Skip to content
Merged
27 changes: 27 additions & 0 deletions packages/cli/src/commands/publish.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
148 changes: 137 additions & 11 deletions packages/cli/src/commands/publish.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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",
Expand All @@ -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;
Expand Down Expand Up @@ -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"));
Expand Down
116 changes: 116 additions & 0 deletions packages/cli/src/utils/projectLink.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

// Point the module's ~/.hyperframes config dir at a throwaway home so tests use real fs
// without touching the developer's actual home directory.
const osState = vi.hoisted(() => ({ home: "" }));
vi.mock("node:os", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:os")>();
return { ...actual, homedir: () => osState.home };
});

describe("projectLink", () => {
let projectsPath: string;
let projectDirs: string[];
let ensureProjectId: typeof import("./projectLink.js").ensureProjectId;
let readProjectLink: typeof import("./projectLink.js").readProjectLink;
let writeProjectLink: typeof import("./projectLink.js").writeProjectLink;
let readTeamProject: typeof import("./projectLink.js").readTeamProject;
let writeTeamProject: typeof import("./projectLink.js").writeTeamProject;

beforeEach(async () => {
osState.home = mkdtempSync(join(tmpdir(), "hf-home-"));
projectsPath = join(osState.home, ".hyperframes", "projects.json");
projectDirs = [];
vi.resetModules();
({ ensureProjectId, readProjectLink, writeProjectLink, readTeamProject, writeTeamProject } =
await import("./projectLink.js"));
});

afterEach(() => {
rmSync(osState.home, { recursive: true, force: true });
for (const dir of projectDirs) rmSync(dir, { recursive: true, force: true });
});

function makeProjectDir(): string {
const dir = mkdtempSync(join(tmpdir(), "hf-proj-"));
projectDirs.push(dir);
return dir;
}

it("mints one project id per directory and reuses it", () => {
const first = makeProjectDir();
const second = makeProjectDir();
const firstId = ensureProjectId(first);

expect(ensureProjectId(first)).toBe(firstId);
expect(ensureProjectId(second)).not.toBe(firstId);
});

it("round-trips a project link", () => {
const dir = makeProjectDir();
const link = { projectId: "hfp_123", url: "https://hyperframes.dev/p/hfp_123" };

writeProjectLink(dir, link);

expect(readProjectLink(dir)).toEqual(link);
});

it("persists only the project id and URL", () => {
const dir = makeProjectDir();
const link = { projectId: "hfp_123", url: "https://hyperframes.dev/p/hfp_123", secret: "nope" };

writeProjectLink(dir, link);

expect(readFileSync(projectsPath, "utf-8")).not.toContain("nope");
});

it("treats a missing projects file as empty and creates it on ensure", () => {
const dir = makeProjectDir();
expect(readProjectLink(dir)).toBeNull();

const projectId = ensureProjectId(dir);

expect(projectId).toBeTruthy();
expect(readProjectLink(dir)).toEqual({ projectId, url: "" });
});

it("treats corrupt JSON as empty and rewrites it on ensure", () => {
const dir = makeProjectDir();
writeProjectLink(dir, { projectId: "x", url: "y" });
writeFileSync(projectsPath, "{not valid json", "utf-8");
expect(readProjectLink(dir)).toBeNull();

const projectId = ensureProjectId(dir);

expect(readProjectLink(dir)).toEqual({ projectId, url: "" });
expect(() => JSON.parse(readFileSync(projectsPath, "utf-8"))).not.toThrow();
});

it("uses the resolved directory as the storage key", () => {
const dir = makeProjectDir();
const projectId = ensureProjectId(dir);

expect(ensureProjectId(resolve(dir))).toBe(projectId);
expect(Object.keys(JSON.parse(readFileSync(projectsPath, "utf-8")))).toEqual([resolve(dir)]);
});

it("reads and writes a committed team project (id + optional space)", () => {
const dir = makeProjectDir();
expect(readTeamProject(dir)).toBeNull();

// Personal-space project: id only, no spaceId key.
const soloFile = writeTeamProject(dir, { projectId: "hfp_solo" });
expect(readTeamProject(dir)).toEqual({ projectId: "hfp_solo" });
const soloBody = readFileSync(soloFile, "utf-8");
expect(soloBody).not.toContain("spaceId");
// Never a secret.
expect(soloBody).not.toContain("token");

// Team-space project: id + shared space id round-trip.
writeTeamProject(dir, { projectId: "hfp_team", spaceId: "space-42" });
expect(readTeamProject(dir)).toEqual({ projectId: "hfp_team", spaceId: "space-42" });
});
});
Loading
Loading