Files
hyperframes/packages/cli/src/utils/projectLink.test.ts
Miguel Ángel 4495cb7355 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
2026-07-13 22:47:28 -04:00

117 lines
4.3 KiB
TypeScript

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" });
});
});