fix(cli): clarify publish visibility and claim links (#3730)

* fix(cli): clarify publish visibility and claim links

* style(cli): apply oxfmt to publish visibility test

* chore(skills): regenerate skills manifest for updated references

* fix(cli): stop an in-place re-publish claiming it made the project private

* test(cli): pin in-place visibility copy to the plain re-publish route
This commit is contained in:
Miguel Ángel
2026-09-05 23:10:42 -04:00
committed by GitHub
parent 1fa31d5dfb
commit 672ea844a5
8 changed files with 149 additions and 33 deletions
+7 -5
View File
@@ -73,7 +73,8 @@ npx hyperframes render -c ./my-composition.html -o output.mp4
### `publish`
Upload a project directory and get a hosted URL that keeps working after the CLI exits:
Upload a project directory and get a hosted URL that keeps working after the CLI exits.
Published projects are private by default:
```bash
npx hyperframes publish
@@ -82,10 +83,11 @@ npx hyperframes publish --public
npx hyperframes publish --yes
```
Signed-out publishing returns a URL with a claim token; opening it lets someone
sign in and claim the project. Sign in first with `npx hyperframes auth login`
to publish an owned project you can update. Use `--public` to make the claimed
project visible to anyone, and `--yes` to skip the confirmation prompt.
Signed-out publishing returns an authentication-required claim URL; opening it
lets someone sign in and claim the project. Sign in first with
`npx hyperframes auth login` to publish an owned project you can update. Use
`--public` to make the claimed project visible to anyone. `--yes` only skips the
confirmation prompt and does not change visibility.
Signed-in publishers can use `--update <url-or-id>` to target an existing project
or `--space <space-id>` to publish into a shared team space. If the requested
+86 -1
View File
@@ -10,7 +10,8 @@ vi.mock("../utils/publishProject.js", async (importOriginal) => ({
publishProjectArchive: publishState.publish,
}));
import publishCommand, { parseUpdateTarget } from "./publish.js";
import publishCommand, { examples, parseUpdateTarget } from "./publish.js";
import { ensureProjectId } from "../utils/projectLink.js";
describe("parseUpdateTarget", () => {
it("extracts the id from a full published URL", () => {
@@ -90,3 +91,87 @@ describe("publish default-entry preflight", () => {
expect(output).toContain("publish accepts project directories, not individual HTML files");
});
});
describe("publish visibility messaging", () => {
async function runPublish(options: {
public: boolean;
claimed?: boolean;
inPlace?: boolean;
}): Promise<string> {
const project = mkdtempSync(join(tmpdir(), "hf-publish-visibility-"));
writeFileSync(
join(project, "index.html"),
`<html><body><div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="5"><div class="clip" data-start="0" data-duration="5">Visible</div></div></body></html>`,
);
// "Updated in place" is decided by the response echoing the id the directory already
// resolves to — no --update flag required, which is how a plain re-publish reaches it.
const projectId = options.inPlace === true ? ensureProjectId(project) : "project-id";
publishState.publish.mockReset();
publishState.publish.mockResolvedValue({
title: "test",
fileCount: 1,
claimed: options.claimed ?? true,
projectId,
url: `https://hyperframes.dev/p/${projectId}`,
claimToken: "claim-secret",
});
const lines: string[] = [];
const log = vi.spyOn(console, "log").mockImplementation((...parts: unknown[]) => {
lines.push(parts.map(String).join(" "));
});
try {
await publishCommand.run?.({
args: { dir: project, yes: true, public: options.public, proxy: false },
} as never);
return lines.join("\n");
} finally {
log.mockRestore();
rmSync(project, { recursive: true, force: true });
}
}
it.each([
{ public: false, label: "Private", hint: "--public" },
{ public: true, label: "Public", hint: undefined },
])(
"keeps --yes orthogonal to requested $label visibility",
async ({ public: isPublic, label, hint }) => {
const output = await runPublish({ public: isPublic });
expect(publishState.publish).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ public: isPublic }),
);
expect(output).toContain("Requested visibility");
expect(output).toContain(label);
if (hint) expect(output).toContain(hint);
},
);
// A re-publish without --public sends no visibility, so the server keeps whatever the
// project already had. Claiming "Private" here would tell someone a public link is locked
// down. This is the plain `hyperframes publish` path in an already-published directory,
// not just --update — the same branch serves all three routes to an in-place update.
it("does not claim private when re-publishing in place without --public", async () => {
const output = await runPublish({ public: false, inPlace: true });
expect(output).toContain("Requested visibility");
expect(output).toContain("Unchanged — keeps this project's current setting");
expect(output).toContain("Updated existing project");
expect(output).not.toContain("Private — authentication and access required");
});
it("labels an authentication-required anonymous URL as a claim URL", async () => {
const output = await runPublish({ public: false, claimed: false });
expect(output).toContain("Claim URL");
expect(output).toContain("claim_token=claim-secret");
expect(output).toContain("sign in");
expect(output).not.toMatch(/^\s*Public\s/m);
});
it("does not describe default publishing as public", () => {
expect(examples[0]?.[0]).not.toContain("public URL");
});
});
+47 -19
View File
@@ -28,7 +28,7 @@ import {
} from "../utils/projectLink.js";
export const examples: Example[] = [
["Publish the current project with a public URL", "hyperframes publish"],
["Publish the current project privately to a stable 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>"],
@@ -56,7 +56,7 @@ export function parseUpdateTarget(value: string): string {
export default defineCommand({
meta: {
name: "publish",
description: "Upload the project and return a stable public URL",
description: "Upload the project to a stable URL (private by default)",
},
args: {
dir: { type: "positional", description: "Project directory", required: false },
@@ -121,12 +121,19 @@ export default defineCommand({
if (args.yes !== true) {
console.log();
console.log(
` ${c.bold("hyperframes publish uploads this project and creates a stable public URL.")}`,
);
console.log(
` ${c.dim("Anyone with the URL can open the published project and claim it after authenticating.")}`,
);
if (args.public === true) {
console.log(
` ${c.bold("hyperframes publish uploads this project and requests public visibility at a stable URL.")}`,
);
console.log(` ${c.dim("Anyone with the URL can open a claimed public project.")}`);
} else {
console.log(
` ${c.bold("hyperframes publish uploads this project privately to a stable URL.")}`,
);
console.log(
` ${c.dim("Viewing requires authentication and access. Pass --public to allow anyone with the URL.")}`,
);
}
console.log();
const approved = await clack.confirm({ message: "Publish this project?" });
if (clack.isCancel(approved) || approved !== true) {
@@ -200,13 +207,15 @@ export default defineCommand({
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("Project")} ${c.accent(published.title)}`);
console.log(` ${c.dim("Files")} ${String(published.fileCount)}`);
if (proxyBakeManifest) {
console.log(` ${c.dim("Proxies")} ${String(proxyBakeManifest.proxied.length)} baked`);
console.log(
` ${c.dim("Proxies")} ${String(proxyBakeManifest.proxied.length)} baked`,
);
if (proxyBakeManifest.skippedAlpha.length > 0) {
console.log(
` ${c.dim("Proxy note")} ${String(proxyBakeManifest.skippedAlpha.length)} alpha source(s) kept original`,
` ${c.dim("Proxy note")} ${String(proxyBakeManifest.skippedAlpha.length)} alpha source(s) kept original`,
);
}
}
@@ -214,10 +223,26 @@ export default defineCommand({
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("URL")} ${c.accent(published.url)}`);
// The CLI only ever states what it ASKED for: no response field carries the project's
// actual visibility. A re-publish without --public sends no visibility at all, so the
// server keeps whatever the project already had — saying "Private" there would claim
// a state we neither requested nor observed.
const requestedVisibility =
args.public === true
? "Public — anyone with the URL"
: updatedInPlace
? "Unchanged — keeps this project's current setting"
: "Private — authentication and access required";
console.log(` ${c.dim("Requested visibility")} ${c.accent(requestedVisibility)}`);
console.log(
` ${c.dim("Status")} ${c.accent(updatedInPlace ? "Updated existing project" : "Created new project")}`,
` ${c.dim("Status")} ${c.accent(updatedInPlace ? "Updated existing project" : "Created new project")}`,
);
if (args.public !== true) {
console.log(
` ${c.dim("Tip")} ${c.dim("Re-publish with --public to allow anyone with the URL.")}`,
);
}
// 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.
@@ -249,7 +274,8 @@ export default defineCommand({
} 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(` ${c.dim("Claim URL")} ${c.accent(claimUrl.toString())}`);
console.log(` ${c.dim("Access")} ${c.accent("Sign in required to claim")}`);
console.log();
if (updateTarget || spaceOverride) {
// The pre-publish gate saw a credential, but the server didn't accept it (expired
@@ -263,12 +289,14 @@ export default defineCommand({
);
} else {
console.log(
` ${c.dim("Open the URL on hyperframes.dev to claim the project and continue editing.")}`,
` ${c.dim("Open the claim URL on hyperframes.dev, sign in, and claim the project to continue editing.")}`,
);
console.log();
console.log(
` ${c.dim("Tip: run 'hyperframes auth login' first for a stable link you can re-publish to.")}`,
);
const visibilityTip =
args.public === true
? "--public applies to the claimed project; this claim URL still requires sign-in."
: "Run 'hyperframes auth login' first for a stable link you can re-publish to; add --public to allow signed-out viewing.";
console.log(` ${c.dim(`Tip: ${visibilityTip}`)}`);
}
console.log();
}
+1 -1
View File
@@ -25,7 +25,7 @@ const GROUPS: Group[] = [
["catalog", "Browse and install blocks and components"],
["preview", "Start the studio for previewing compositions"],
["present", "Open a slideshow deck in presenter mode (with audience sync)"],
["publish", "Upload a project and get a stable public URL"],
["publish", "Upload a project to a stable URL (private by default)"],
["render", "Render a composition to MP4 or WebM"],
],
},
+3 -3
View File
@@ -18,7 +18,7 @@
"files": 4
},
"hyperframes": {
"hash": "5be130da8d7ff59e",
"hash": "f09636f3307817ab",
"files": 17
},
"hyperframes-animation": {
@@ -30,11 +30,11 @@
"files": 7
},
"hyperframes-cli": {
"hash": "70bf0363795a160b",
"hash": "1a691d7069bc158e",
"files": 11
},
"hyperframes-core": {
"hash": "587492c7413387f6",
"hash": "033a6c675e66a5e7",
"files": 20
},
"hyperframes-creative": {
@@ -182,9 +182,10 @@ Hit a reproducible bug? Add `--file-issue` (optionally `--dir <project>` and `--
## publish
```bash
npx hyperframes publish # upload current project, return public URL
npx hyperframes publish # upload current project privately, return stable URL
npx hyperframes publish ./my-video # specific project
npx hyperframes publish --public # allow anyone with the URL to view the claimed project
npx hyperframes publish --yes # skip the confirmation prompt (scripts/CI)
```
Uploads the project's source (HTML + assets) and returns a stable public URL that renders in the browser. Use this for sharing a draft for review before rendering MP4, or for embedding the composition elsewhere. Lint findings are surfaced before upload but do not block.
Uploads the project's source (HTML + assets) and returns a stable hosted URL that renders in the browser. A fresh publish is private by default and requires authentication plus access to view. Use `--public` to allow anyone with the URL to view the claimed project. Updating a project in place keeps its existing visibility: re-publishing without `--public` never turns a public project private. `--yes` only skips the confirmation prompt; it does not change visibility. A signed-out publish returns an authentication-required claim URL rather than a public playback URL. Lint findings are surfaced before upload but do not block.
@@ -14,7 +14,7 @@ The shipped narrative workflows implement these stages with their own scripts; a
| **Transitions** | the assembled index | scene handoffs injected | `hyperframes-animation/transitions/overview.md` → `catalog.md`; borrowable `transitions.mjs` (menu) |
| **Captions** | word timings + the index | the caption track | borrowable `captions.mjs` (menu); no script to time against → `media-use` `scripts/transcribe.mjs` first |
| **Verify** | the index (+ captions / transitions when present) | `npx hyperframes lint` and `npx hyperframes check` **passing**; a contact-sheet glance (`snapshot --at <frame-midpoints>`) | `hyperframes-cli` |
| **Deliver** | verify passing | the final-look pause → on approval `render` → optionally `publish` (a stable public link) → the recipe offer | final approval and recipe offer: `review-loop.md` § 4; render / publish: `hyperframes-cli` |
| **Deliver** | verify passing | the final-look pause → on approval `render` → optionally `publish` (a stable hosted link, private by default) → the recipe offer | final approval and recipe offer: `review-loop.md` § 4; render / publish: `hyperframes-cli` |
The Frames stage follows the plan's citations: a scene planned on a blueprint or on named rules is built by reading that recipe's body (`hyperframes-animation/blueprints/<id>.md`, `rules/<id>.md`) before its motion is written — names come from the indexes, never invented, and a scene the plan left uncited gets its citation at build time, not improvised motion.
@@ -22,7 +22,7 @@ Each row's last column reads **home → entry → what you get**: the owning ski
| **Registry blocks** — 50+ installable scene compositions (data charts, device mockups, quote cards…) | "ready-made scenes we can drop in and restyle" | `/hyperframes-registry` → `npx hyperframes add <block>` → an installed sub-composition (wiring: `references/wiring-blocks.md`) |
| **Scene transitions** — cuts, crossfades, wipes, WebGL shader transitions between scenes | "how one scene hands off to the next — up to full shader wipes" | `/hyperframes-animation` → `transitions/overview.md` then `transitions/catalog.md`; assembly `transitions.mjs` (home: `/faceless-explainer`) → injected handoffs in the index |
| **User media on the timeline** — the user's own images / clips staged and woven into frames | "your own footage, screenshots, or photos placed into the video" | staging `stage-assets.mjs` (home: `/music-to-video`, `/product-launch-video`); adoption: `/media-use` `--adopt` → files in `assets/` + manifest records |
| **Publish to a stable link** — the finished piece on a public URL; re-publishing updates the same link | "when it's done I can publish it to a link you can share — updates keep the same URL" | `/hyperframes-cli` → `references/preview-render.md` (`npx hyperframes publish`) → a stable public URL |
| **Publish to a stable link** — the finished piece on a private-by-default hosted URL; re-publishing updates the same link | "when it's done I can publish it to a stable link — private by default, or public when you ask" | `/hyperframes-cli` → `references/preview-render.md` (`npx hyperframes publish`) → a stable hosted URL with explicit visibility |
Offer, don't unload: the intent layer recommends the one or two rows the confirmed concept itself calls for, states each as one plain-language line traced to the brief, and asks once — the route-filtered slice on request, the full table for the companion.