mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
feat(cli): stable public URL for hyperframes publish (re-publish updates the same link) (#2363)
* feat(cli): stable-URL re-publish (send owned id, honest UX, --update, team id file) Resolve a stable project id (committed team id > machine store > mint), send it when authenticated so an owned re-publish updates the same URL in place, and persist the server id+url. Report updated-vs-created honestly, add --update to target a project explicitly, write a committable .hyperframes/project.json so a team shares one link, and show the prior URL on re-publish. * test(cli): env-gated E2E round-trip for stable-URL re-publish Publish -> edit -> re-publish asserts one URL with updated content against a live EF (HYPERFRAMES_E2E_API_URL + an authenticated runner); skipped otherwise. * fix(cli): real team space, auth-gate --update/--space, safe team-file write - --space + committed .hyperframes/project.json (projectId+spaceId) send X-Space-Id so a team converges on one link; personal space stays the default for solo users - --update/--space error when unauthenticated, and warn loudly when a resolved-but-invalid token silently downgrades to a new anonymous URL (no more generic-tip-only) - team-file write in its own try/catch so a read-only dir can't fake 'Publish failed' - parseUpdateTarget handles scheme-less URLs + query/hash; X-Space-Id on metadata only (not S3 PUT) - share readJsonRecord across the local + team descriptors * test(cli): e2e team-space convergence + cross-space hijack guard * test(cli): unit-test parseUpdateTarget url shapes (export for test) * fix(cli): warn on committed-team miss too, not just --update
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseUpdateTarget } from "./publish.js";
|
||||
|
||||
describe("parseUpdateTarget", () => {
|
||||
it("extracts the id from a full published URL", () => {
|
||||
expect(parseUpdateTarget("https://hyperframes.dev/p/hfp_abc123")).toBe("hfp_abc123");
|
||||
});
|
||||
|
||||
it("handles a scheme-less URL (which new URL() rejects)", () => {
|
||||
expect(parseUpdateTarget("hyperframes.dev/p/hfp_abc123")).toBe("hfp_abc123");
|
||||
});
|
||||
|
||||
it("strips a trailing query and hash", () => {
|
||||
expect(parseUpdateTarget("https://hyperframes.dev/p/hfp_abc123?claim_token=x#frag")).toBe(
|
||||
"hfp_abc123",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a bare id unchanged and trims surrounding whitespace", () => {
|
||||
expect(parseUpdateTarget(" hfp_abc123 ")).toBe("hfp_abc123");
|
||||
});
|
||||
|
||||
it("falls back to the last path segment for a non-/p/ URL", () => {
|
||||
expect(parseUpdateTarget("https://example.com/foo/hfp_abc123")).toBe("hfp_abc123");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import { resolve } from "node:path";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { defineCommand } from "citty";
|
||||
import * as clack from "@clack/prompts";
|
||||
|
||||
@@ -9,14 +8,39 @@ import { c } from "../ui/colors.js";
|
||||
import { lintProject } from "../utils/lintProject.js";
|
||||
import { formatLintFindings } from "../utils/lintFormat.js";
|
||||
import { publishProjectArchive } from "../utils/publishProject.js";
|
||||
import { tryResolveCredential } from "../auth/index.js";
|
||||
import {
|
||||
ensureProjectId,
|
||||
readProjectLink,
|
||||
readTeamProject,
|
||||
writeTeamProject,
|
||||
} from "../utils/projectLink.js";
|
||||
|
||||
export const examples: Example[] = [
|
||||
["Publish the current project with a public URL", "hyperframes publish"],
|
||||
["Publish a specific directory", "hyperframes publish ./my-video"],
|
||||
["Make the claimed project public to anyone", "hyperframes publish --public"],
|
||||
["Update an existing published project in place", "hyperframes publish --update <url|id>"],
|
||||
["Publish to a shared team space", "hyperframes publish --space <space-id>"],
|
||||
["Skip the consent prompt (scripts)", "hyperframes publish --yes"],
|
||||
];
|
||||
|
||||
/** Extract a project id from a published URL (with or without scheme, query, or hash) or accept a bare id. */
|
||||
export function parseUpdateTarget(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
// Pull the id straight out of a `/p/<id>` path — works for full URLs, scheme-less URLs
|
||||
// (which `new URL` rejects), and links carrying `?query`/`#hash`.
|
||||
const pathMatch = trimmed.match(/\/p\/([^/?#]+)/);
|
||||
if (pathMatch?.[1]) return pathMatch[1];
|
||||
try {
|
||||
const segment = new URL(trimmed).pathname.split("/").filter(Boolean).pop();
|
||||
if (segment) return segment;
|
||||
} catch {
|
||||
// Not a URL — treat as a bare id.
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
meta: {
|
||||
name: "publish",
|
||||
@@ -35,6 +59,14 @@ export default defineCommand({
|
||||
description: "Make the claimed project public to anyone, not just the claimer",
|
||||
default: false,
|
||||
},
|
||||
update: {
|
||||
type: "string",
|
||||
description: "Update an existing published project in place (its URL or id)",
|
||||
},
|
||||
space: {
|
||||
type: "string",
|
||||
description: "Publish into a shared team space (its id) so teammates update one link",
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const rawArg = args.dir;
|
||||
@@ -67,25 +99,119 @@ export default defineCommand({
|
||||
}
|
||||
}
|
||||
|
||||
const updateTarget =
|
||||
typeof args.update === "string" && args.update.trim()
|
||||
? parseUpdateTarget(args.update)
|
||||
: undefined;
|
||||
const spaceOverride =
|
||||
typeof args.space === "string" && args.space.trim() ? args.space.trim() : undefined;
|
||||
|
||||
// --update / --space only take effect for an authenticated owner. Fail loudly rather
|
||||
// than silently minting a fresh URL — the exact failure mode this feature removes.
|
||||
if (updateTarget || spaceOverride) {
|
||||
const credential = await tryResolveCredential();
|
||||
if (!credential) {
|
||||
console.log();
|
||||
console.log(
|
||||
` ${c.error(`${updateTarget ? "--update" : "--space"} requires authentication. Run 'hyperframes auth login' first.`)}`,
|
||||
);
|
||||
console.log();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const committedTeam = readTeamProject(dir);
|
||||
// Stable id: explicit --update wins, else the committed team id, else this machine's stored/minted id.
|
||||
const requestedProjectId = updateTarget ?? committedTeam?.projectId ?? ensureProjectId(dir);
|
||||
// Team space: explicit --space wins, else the committed space id, else the personal space.
|
||||
const spaceId = spaceOverride ?? committedTeam?.spaceId;
|
||||
|
||||
// Continuity cue: if this directory was published before, show where it lives so the
|
||||
// user knows a re-publish updates that same link (when logged in).
|
||||
const priorLink = readProjectLink(dir);
|
||||
if (priorLink?.url) {
|
||||
console.log();
|
||||
console.log(` ${c.dim(`Previously published at ${priorLink.url}`)}`);
|
||||
}
|
||||
|
||||
clack.intro(c.bold("hyperframes publish"));
|
||||
const publishSpinner = clack.spinner();
|
||||
publishSpinner.start("Uploading project...");
|
||||
|
||||
try {
|
||||
const published = await publishProjectArchive(dir, { public: args.public === true });
|
||||
const claimUrl = new URL(published.url);
|
||||
claimUrl.searchParams.set("claim_token", published.claimToken);
|
||||
const published = await publishProjectArchive(dir, {
|
||||
public: args.public === true,
|
||||
projectId: requestedProjectId,
|
||||
spaceId,
|
||||
});
|
||||
publishSpinner.stop(c.success("Project published"));
|
||||
|
||||
console.log();
|
||||
console.log(` ${c.dim("Project")} ${c.accent(published.title)}`);
|
||||
console.log(` ${c.dim("Files")} ${String(published.fileCount)}`);
|
||||
console.log(` ${c.dim("Public")} ${c.accent(claimUrl.toString())}`);
|
||||
console.log();
|
||||
console.log(
|
||||
` ${c.dim("Open the URL on hyperframes.dev to claim the project and continue editing.")}`,
|
||||
);
|
||||
console.log();
|
||||
|
||||
if (published.claimed) {
|
||||
// The server returns the same id on an in-place update, a fresh id on create.
|
||||
const updatedInPlace = published.projectId === requestedProjectId;
|
||||
console.log(` ${c.dim("URL")} ${c.accent(published.url)}`);
|
||||
console.log(
|
||||
` ${c.dim("Status")} ${c.accent(updatedInPlace ? "Updated existing project" : "Created new project")}`,
|
||||
);
|
||||
// Warn whenever we aimed at a KNOWN existing project (an explicit --update target or
|
||||
// a committed team id) but the server created a fresh one instead — so a teammate
|
||||
// whose space doesn't own the committed project doesn't silently lose the shared link.
|
||||
if ((updateTarget || committedTeam) && !updatedInPlace) {
|
||||
const targetDesc = updateTarget ? "The requested project" : "The committed team project";
|
||||
console.log();
|
||||
console.log(
|
||||
` ${c.dim(`${targetDesc} was not updated (not found, or your space can't access it); a new project was created above instead.`)}`,
|
||||
);
|
||||
}
|
||||
// Persist a committable descriptor so a team converges on this link. This is a
|
||||
// convenience: wrap it so a read-only project dir can't turn a successful publish
|
||||
// into a "Publish failed" (the outer catch owns publish failures only).
|
||||
if (
|
||||
committedTeam === null ||
|
||||
(spaceId !== undefined && committedTeam.spaceId !== spaceId)
|
||||
) {
|
||||
try {
|
||||
const file = writeTeamProject(dir, { projectId: published.projectId, spaceId });
|
||||
console.log();
|
||||
console.log(
|
||||
` ${c.dim(`Wrote ${relative(dir, file) || file} — commit it so your team publishes to this link.`)}`,
|
||||
);
|
||||
} catch {
|
||||
// Convenience file only; never shadow a successful publish with a local write failure.
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
} else {
|
||||
const claimUrl = new URL(published.url);
|
||||
claimUrl.searchParams.set("claim_token", published.claimToken);
|
||||
console.log(` ${c.dim("Public")} ${c.accent(claimUrl.toString())}`);
|
||||
console.log();
|
||||
if (updateTarget || spaceOverride) {
|
||||
// The pre-publish gate saw a credential, but the server didn't accept it (expired
|
||||
// or invalid) and fell back to anonymous — say so loudly instead of pretending the
|
||||
// requested update happened.
|
||||
console.log(
|
||||
` ${c.error(`Your login looks expired or invalid, so ${updateTarget ? "--update" : "--space"} was ignored and a NEW url was created above.`)}`,
|
||||
);
|
||||
console.log(
|
||||
` ${c.dim("Run 'hyperframes auth login' again, then re-publish to update in place.")}`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
` ${c.dim("Open the URL on hyperframes.dev to claim the project and continue editing.")}`,
|
||||
);
|
||||
console.log();
|
||||
console.log(
|
||||
` ${c.dim("Tip: run 'hyperframes auth login' first for a stable link you can re-publish to.")}`,
|
||||
);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
return;
|
||||
} catch (err: unknown) {
|
||||
publishSpinner.stop(c.error("Publish failed"));
|
||||
|
||||
Reference in New Issue
Block a user