diff --git a/.changeset/remove-ghost-export.md b/.changeset/remove-ghost-export.md new file mode 100644 index 00000000..fa1fccf2 --- /dev/null +++ b/.changeset/remove-ghost-export.md @@ -0,0 +1,5 @@ +--- +"@design-intelligence/ghost": minor +--- + +Remove the `ghost export` command and its package archive output. diff --git a/CLAUDE.md b/CLAUDE.md index 5099cd25..17e2873f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,7 +110,6 @@ Core workflow: | `ghost pull […]` | Emit selected nodes' bodies and materials; append the selection to the local `.ghost/.events` tape. | | `ghost review` | Emit an advisory review packet for a diff using material-backed nodes and checks (requires `.ghost/checks/`). | | `ghost stats` | Summarize local gather/pull events from `.ghost/.events`. | -| `ghost export` | Bundle the guidance as a portable tarball with a materials audit (`--strict` fails on stranded locators). | | `ghost skill install` | Install the unified `ghost` skill bundle. | Advanced/maintenance: diff --git a/README.md b/README.md index 9623a4c5..8c6f80df 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,6 @@ ghost validate # make sure the package is well-formed ghost gather [ask] # before building: show the complete guidance menu ghost pull # read the picked nodes' full bodies ghost review # during review: match a diff to guidance and checks -ghost export # bundle the guidance as a portable artifact ghost stats # while tuning: see what agents reached for ghost skill install # install the unified ghost skill bundle ghost manifest # emit a machine-readable index of commands and flags @@ -164,13 +163,8 @@ agent to weigh. Review output never enters generation context. Different agents can read the same guidance and apply it to a screen, page, email, or sentence. The package moves with the repo when someone clones or -forks it. To move the package on its own: - -```bash -ghost export -``` - -The export audits `materials` entries and reports paths that moved. +forks it. To move the package on its own, copy the `.ghost/` directory and run +`ghost validate --package ` in the receiving workspace. ## Repo Layout diff --git a/docs/purposes.md b/docs/purposes.md index 23fa624e..a3a979d7 100644 --- a/docs/purposes.md +++ b/docs/purposes.md @@ -71,7 +71,6 @@ Two rules keep the reservation honest: | **Generation** | `ghost gather [ask…]`, `ghost pull ` | The flat menu, then selected node bodies and materials. | nodes only | **No** if selection stays with the agent and checks stay invisible. | | **Local signal** | `ghost stats` | The gitignored event tape (`.ghost/.events`) written by `gather` and `pull`, used to tune contexts and menu ergonomics. | event ids and miss suggestions | **No**, observability must not become ranking, memory, or canonical state. | | **Diff review** | `ghost review` | Touched files matched to node `materials`, relevant checks, referenced prose, gaps, and the diff. | nodes, checks, diff | **No** if checks bind by `references` and are not gathered. | -| **Fleet** | (future) | Many ghost packages at once: distances, cohorts, summaries. | many corpora, read-only | **No**, consumes exports read-only. | ## Known leaks diff --git a/packages/ghost/README.md b/packages/ghost/README.md index d7ccc76b..e547c074 100644 --- a/packages/ghost/README.md +++ b/packages/ghost/README.md @@ -46,7 +46,6 @@ ghost validate # make sure the package is well-formed ghost gather [ask] # before building: show the complete guidance menu ghost pull # read the picked nodes' full bodies ghost review # during review: match a diff to guidance and checks -ghost export # bundle the guidance as a portable artifact ghost stats # while tuning: see what agents reached for ghost skill install # install the unified ghost skill bundle ghost manifest # emit a machine-readable index of commands and flags diff --git a/packages/ghost/src/cli.ts b/packages/ghost/src/cli.ts index d08c99d5..92692eb6 100644 --- a/packages/ghost/src/cli.ts +++ b/packages/ghost/src/cli.ts @@ -1,7 +1,6 @@ import { cac } from "cac"; import { registerChecksCommand } from "./commands/checks-command.js"; import { formatGhostHelp } from "./commands/command-discovery.js"; -import { registerExportCommand } from "./commands/export-command.js"; import { registerFingerprintCommands } from "./commands/fingerprint-commands.js"; import { registerGatherCommand } from "./commands/gather-command.js"; import { registerManifestCommand } from "./commands/manifest-command.js"; @@ -24,7 +23,6 @@ export function buildCli(): ReturnType { registerPullCommand(cli); registerStatsCommand(cli); registerReviewCommand(cli); - registerExportCommand(cli); registerChecksCommand(cli); registerManifestCommand(cli); registerSkillCommand(cli); diff --git a/packages/ghost/src/commands/command-discovery.ts b/packages/ghost/src/commands/command-discovery.ts index 73c27d6a..9d85b9e7 100644 --- a/packages/ghost/src/commands/command-discovery.ts +++ b/packages/ghost/src/commands/command-discovery.ts @@ -157,13 +157,6 @@ const COMMAND_DISCOVERY = [ summary: "Emit an advisory review packet for a diff (needs .ghost/checks/).", }, - { - name: "export", - group: "core", - defaultHelp: true, - compactName: "export", - summary: "Package the ghost package as a portable brand artifact.", - }, { name: "checks", group: "core", diff --git a/packages/ghost/src/commands/export-command.ts b/packages/ghost/src/commands/export-command.ts deleted file mode 100644 index 46ea6c61..00000000 --- a/packages/ghost/src/commands/export-command.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { mkdir } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -import type { CAC } from "cac"; -import { - classifyMaterialLocator, - externalLocatorScheme, - type GhostCatalogNode, - materialLocator, - resolveLocalMaterialLocator, - type TransportedMaterialTier, -} from "#ghost-core"; -import { lintGhostPackage, resolveGhostPackage } from "../package.js"; -import { readPackageVersion } from "../package-version.js"; -import { GHOST_CHECKS_DIR } from "../scan/check-files.js"; -import { - GHOST_EVENTS_FILENAME, - GHOST_MATERIALS_DIR, -} from "../scan/constants.js"; -import { - type LoadedGhostPackage, - loadGhostPackage, -} from "../scan/fingerprint-package.js"; -import { resolveGitRoot } from "../scan/package-paths.js"; -import { defaultArchiveName, writeDirectoryTarball } from "../scan/tarball.js"; -import { exitCli, failFromError } from "./errors.js"; - -interface ExportAuditTravelingLocator { - nodeId: string; - locator: string; - tier: Extract; - access?: "https" | "connector"; -} - -interface ExportAuditStrandedLocator { - nodeId: string; - locator: string; -} - -interface ExportAudit { - travels: ExportAuditTravelingLocator[]; - stranded: ExportAuditStrandedLocator[]; -} - -const EXPORT_SCHEMA = "ghost.export/v1"; - -export function registerExportCommand(cli: CAC): void { - cli - .command( - "export", - "Package the ghost package as a portable brand artifact with a locator audit.", - ) - .option("--out ", "Write the archive to this path") - .option("--no-checks", "Exclude the checks/ directory from the archive") - .option( - "--strict", - "Exit 2 if any referenced local material locators will not travel", - ) - .option( - "--package ", - "Use this ghost package directory (default: ./.ghost)", - ) - .option("--format ", "Output format: markdown or json", { - default: "markdown", - }) - .action(async (opts) => { - try { - if (opts.format !== "markdown" && opts.format !== "json") { - console.error("Error: --format must be 'markdown' or 'json'"); - await exitCli(2); - return; - } - - const paths = resolveGhostPackage(opts.package, process.cwd()); - const report = await lintGhostPackage(opts.package, process.cwd()); - if (report.errors > 0) { - console.error( - "Error: ghost package has validation errors. Run `ghost validate` and fix them before exporting.", - ); - await exitCli(2); - return; - } - const loaded = await loadGhostPackage(paths); - - const archive = - typeof opts.out === "string" - ? resolve(process.cwd(), opts.out) - : resolve(process.cwd(), defaultArchiveName(loaded.manifest.id)); - const exported = new Date().toISOString(); - await mkdir(dirname(archive), { recursive: true }); - await writeDirectoryTarball({ - rootDir: paths.packageDir, - outFile: archive, - extraEntries: [ - { - path: "export.yml", - data: formatExportManifest({ - id: loaded.manifest.id, - cli: readPackageVersion(), - exported, - }), - mtime: new Date(exported), - }, - ], - exclude: (relativePath) => - relativePath === GHOST_EVENTS_FILENAME || - (opts.checks === false && - (relativePath === GHOST_CHECKS_DIR || - relativePath.startsWith(`${GHOST_CHECKS_DIR}/`))), - }); - - const repoRoot = await resolveGitRoot(process.cwd()); - const audit = buildExportAudit(loaded, { - repoRoot, - packageDir: paths.packageDir, - }); - - if (opts.format === "json") { - process.stdout.write( - `${JSON.stringify( - { - kind: "export", - archive, - id: loaded.manifest.id, - audit, - }, - null, - 2, - )}\n`, - ); - } else { - process.stdout.write( - formatExportMarkdown({ - archive, - id: loaded.manifest.id, - audit, - }), - ); - } - - await exitCli(opts.strict && audit.stranded.length > 0 ? 2 : 0); - } catch (err) { - await failFromError(err); - } - }); -} - -function buildExportAudit( - loaded: LoadedGhostPackage, - options: { repoRoot: string; packageDir: string }, -): ExportAudit { - const travels: ExportAuditTravelingLocator[] = []; - const stranded: ExportAuditStrandedLocator[] = []; - - for (const node of loaded.catalog.nodes.values()) { - auditNodeMaterials(node, options, travels, stranded); - } - - return { travels, stranded }; -} - -function auditNodeMaterials( - node: GhostCatalogNode, - options: { repoRoot: string; packageDir: string }, - travels: ExportAuditTravelingLocator[], - stranded: ExportAuditStrandedLocator[], -): void { - for (const material of node.materials ?? []) { - const locator = materialLocator(material); - const classified = classifyMaterialLocator(locator); - if (classified.kind === "url") { - travels.push({ - nodeId: node.id, - locator, - tier: "url", - access: classified.access, - }); - continue; - } - - const resolved = resolveLocalMaterialLocator(locator, { - repoRoot: options.repoRoot, - packageDir: options.packageDir, - materialsDir: GHOST_MATERIALS_DIR, - }); - if (resolved.tier === "bundled") { - travels.push({ nodeId: node.id, locator, tier: "bundled" }); - } else { - stranded.push({ nodeId: node.id, locator }); - } - } -} - -function formatExportManifest(fields: { - id: string; - cli: string; - exported: string; -}): string { - return [ - `schema: ${EXPORT_SCHEMA}`, - `id: ${fields.id}`, - `cli: ${fields.cli}`, - `exported: ${fields.exported}`, - "", - ].join("\n"); -} - -function formatExportMarkdown(fields: { - archive: string; - id: string; - audit: ExportAudit; -}): string { - const lines = [ - "# ghost Export", - "", - `Archive: \`${fields.archive}\``, - `Package: \`${fields.id}\``, - "", - "## Locator audit", - "", - ]; - - if (fields.audit.travels.length > 0) { - lines.push("Travels with the archive:", ""); - for (const item of fields.audit.travels) { - if (item.access === "connector") { - const provider = externalLocatorScheme(item.locator) ?? "connector"; - lines.push( - `- \`${item.nodeId}\` — \`${item.locator}\` (${provider} external locator only)`, - ` - The locator travels; the recipient may need a ${provider} connection or permission to access the material.`, - ); - } else if (item.access === "https") { - lines.push( - `- \`${item.nodeId}\` — \`${item.locator}\` (HTTPS external locator only)`, - " - The locator travels; access depends on the URL and any permissions it requires.", - ); - } else { - lines.push( - `- \`${item.nodeId}\` — \`${item.locator}\` (bundled material)`, - ); - } - } - } else { - lines.push("Travels with the archive: none."); - } - - lines.push("", "Will not travel:", ""); - if (fields.audit.stranded.length > 0) { - for (const item of fields.audit.stranded) { - lines.push( - `- \`${item.nodeId}\` — \`${item.locator}\``, - " - Bundle it into `.ghost/materials/` or accept the gap.", - ); - } - } else { - lines.push("- No referenced local material locators are stranded."); - } - - return `${lines.join("\n")}\n`; -} diff --git a/packages/ghost/src/scan/tarball.ts b/packages/ghost/src/scan/tarball.ts deleted file mode 100644 index 98e510c8..00000000 --- a/packages/ghost/src/scan/tarball.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { readdir, readFile, stat, writeFile } from "node:fs/promises"; -import { join, relative } from "node:path"; -import { gzipSync } from "node:zlib"; - -export interface TarballEntryInput { - /** Archive path, using forward slashes and no leading slash. */ - path: string; - data: Buffer | string; - mtime?: Date; - mode?: number; -} - -export interface CreateDirectoryTarballOptions { - rootDir: string; - outFile: string; - extraEntries?: TarballEntryInput[]; - exclude?: (relativePath: string) => boolean; -} - -interface FileEntry { - path: string; - absolutePath: string; - size: number; - mode: number; - mtime: Date; -} - -const BLOCK_SIZE = 512; - -/** - * Write a dependency-free `.tgz` archive for a ghost package directory. - * - * This intentionally implements only the portable subset ghost needs: ustar - * regular-file entries, deterministic path ordering, no symlink traversal. - */ -export async function writeDirectoryTarball( - options: CreateDirectoryTarballOptions, -): Promise { - const files = await listRegularFiles(options.rootDir, options.exclude); - const fileEntries: TarballEntryInput[] = await Promise.all( - files.map(async (file) => ({ - path: file.path, - data: await readFile(file.absolutePath), - mtime: file.mtime, - mode: file.mode, - })), - ); - - const archive = createTarArchive([ - ...(options.extraEntries ?? []), - ...fileEntries, - ]); - await writeFile(options.outFile, gzipSync(archive)); -} - -export function createTarArchive(entries: TarballEntryInput[]): Buffer { - const sorted = entries - .map(normalizeEntry) - .sort((a, b) => a.path.localeCompare(b.path)); - const chunks: Buffer[] = []; - - for (const entry of sorted) { - const data = Buffer.isBuffer(entry.data) - ? entry.data - : Buffer.from(entry.data, "utf-8"); - chunks.push( - createHeader({ - path: entry.path, - size: data.byteLength, - mode: entry.mode ?? 0o644, - mtime: entry.mtime ?? new Date(0), - }), - ); - chunks.push(data); - const remainder = data.byteLength % BLOCK_SIZE; - if (remainder !== 0) chunks.push(Buffer.alloc(BLOCK_SIZE - remainder)); - } - - chunks.push(Buffer.alloc(BLOCK_SIZE * 2)); - return Buffer.concat(chunks); -} - -async function listRegularFiles( - rootDir: string, - exclude: ((relativePath: string) => boolean) | undefined, -): Promise { - const files: FileEntry[] = []; - await walk(rootDir, rootDir, exclude, files); - files.sort((a, b) => a.path.localeCompare(b.path)); - return files; -} - -async function walk( - rootDir: string, - dir: string, - exclude: ((relativePath: string) => boolean) | undefined, - files: FileEntry[], -): Promise { - const entries = await readdir(dir, { withFileTypes: true }); - for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { - const absolutePath = join(dir, entry.name); - const relPath = normalizeTarPath(relative(rootDir, absolutePath)); - if (exclude?.(relPath)) continue; - - if (entry.isDirectory()) { - await walk(rootDir, absolutePath, exclude, files); - continue; - } - if (!entry.isFile()) continue; - - const s = await stat(absolutePath); - if (!s.isFile()) continue; - files.push({ - path: relPath, - absolutePath, - size: s.size, - mode: s.mode & 0o777, - mtime: s.mtime, - }); - } -} - -function normalizeEntry(entry: TarballEntryInput): TarballEntryInput { - const path = normalizeTarPath(entry.path); - if (path === "" || path.startsWith("../") || path.includes("/../")) { - throw new Error(`Invalid tar entry path: ${entry.path}`); - } - return { ...entry, path }; -} - -function createHeader(entry: { - path: string; - size: number; - mode: number; - mtime: Date; -}): Buffer { - const header = Buffer.alloc(BLOCK_SIZE, 0); - const path = splitUstarPath(entry.path); - writeString(header, path.name, 0, 100); - writeOctal(header, entry.mode, 100, 8); - writeOctal(header, 0, 108, 8); - writeOctal(header, 0, 116, 8); - writeOctal(header, entry.size, 124, 12); - writeOctal(header, Math.floor(entry.mtime.getTime() / 1000), 136, 12); - header.fill(0x20, 148, 156); - writeString(header, "0", 156, 1); - writeString(header, "ustar", 257, 6); - writeString(header, "00", 263, 2); - if (path.prefix !== undefined) writeString(header, path.prefix, 345, 155); - - const checksum = header.reduce((sum, byte) => sum + byte, 0); - writeChecksum(header, checksum); - return header; -} - -function splitUstarPath(path: string): { name: string; prefix?: string } { - const byteLength = Buffer.byteLength(path); - if (byteLength <= 100) return { name: path }; - - const parts = path.split("/"); - for (let index = 1; index < parts.length; index++) { - const prefix = parts.slice(0, index).join("/"); - const name = parts.slice(index).join("/"); - if (Buffer.byteLength(prefix) <= 155 && Buffer.byteLength(name) <= 100) { - return { prefix, name }; - } - } - throw new Error(`Tar entry path is too long for ustar: ${path}`); -} - -function writeString( - buffer: Buffer, - value: string, - offset: number, - length: number, -): void { - const bytes = Buffer.from(value, "utf-8"); - if (bytes.byteLength > length) { - throw new Error(`Tar header value is too long: ${value}`); - } - bytes.copy(buffer, offset); -} - -function writeOctal( - buffer: Buffer, - value: number, - offset: number, - length: number, -): void { - const text = value.toString(8).padStart(length - 1, "0"); - if (text.length > length - 1) { - throw new Error(`Tar numeric value is too large: ${value}`); - } - buffer.write(text, offset, length - 1, "ascii"); -} - -function writeChecksum(buffer: Buffer, checksum: number): void { - const text = checksum.toString(8).padStart(6, "0"); - buffer.write(text, 148, 6, "ascii"); - buffer[154] = 0; - buffer[155] = 0x20; -} - -function normalizeTarPath(path: string): string { - return path.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, ""); -} - -export function defaultArchiveName(id: string): string { - return `${id}-ghost-package.tgz`; -} diff --git a/packages/ghost/src/skill-bundle/SKILL.md b/packages/ghost/src/skill-bundle/SKILL.md index 74b24b5b..d5e804d9 100644 --- a/packages/ghost/src/skill-bundle/SKILL.md +++ b/packages/ghost/src/skill-bundle/SKILL.md @@ -68,7 +68,6 @@ ghost validate # artifact shape + node/material/check validation ghost gather # emit Available guidance for this task ghost pull # pull selected node bodies and materials ghost review # assemble diff + matched material-backed nodes + checks -ghost export # package .ghost/ as a portable brand artifact ghost stats # summarize local gather/pull events while tuning ``` @@ -105,9 +104,9 @@ a brief. ## Receiving a ghost package -Unpack the exported archive, run `ghost validate --package `, then run +Copy the `.ghost/` directory, run `ghost validate --package `, then run `ghost skill install` in the receiving workspace. From there, gather and pull -against the unpacked package with `--package `. +against that package with `--package `. ghost package authoring is **elicitation, not scanning**. The raw material is what the human brings and points at: words, images, links, products, brand docs, copy diff --git a/packages/ghost/src/skill-bundle/references/materials.md b/packages/ghost/src/skill-bundle/references/materials.md index 35a92162..cfcf501c 100644 --- a/packages/ghost/src/skill-bundle/references/materials.md +++ b/packages/ghost/src/skill-bundle/references/materials.md @@ -92,10 +92,10 @@ and when. ## Bundle or reference -Put brand-owned artifacts that must travel through export or survive refactors -under `.ghost/materials/`: token output, logos, type files, motion data, and -portable examples. Reference living components, stories, tests, and styles at -their repository paths. Guidance stays in prose in both cases. +Put brand-owned artifacts that should travel with a copied package or survive +refactors under `.ghost/materials/`: token output, logos, type files, motion +data, and portable examples. Reference living components, stories, tests, and +styles at their repository paths. Guidance stays in prose in both cases. Use external locators when the authoritative material remains external. Add a short `note` only when the locator itself does not tell the agent what it will diff --git a/packages/ghost/src/skill-bundle/references/schema.md b/packages/ghost/src/skill-bundle/references/schema.md index 31a6ebaa..92b5b0bc 100644 --- a/packages/ghost/src/skill-bundle/references/schema.md +++ b/packages/ghost/src/skill-bundle/references/schema.md @@ -115,5 +115,4 @@ it does not grade them. into inspect-pointers, and leaves external materials as locators. - `ghost review` matches touched files to exact local material paths, offers relevant checks, and emits a review packet for the host agent. -- `ghost export` bundles the package and audits which locators travel. - `ghost stats` summarizes local gather and pull events. diff --git a/packages/ghost/test/cli.test.ts b/packages/ghost/test/cli.test.ts index d33f3662..f9c8c8a0 100644 --- a/packages/ghost/test/cli.test.ts +++ b/packages/ghost/test/cli.test.ts @@ -1,7 +1,6 @@ -import { mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { cp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { gunzipSync } from "node:zlib"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { parse as parseYaml } from "yaml"; import { @@ -69,7 +68,6 @@ describe("ghost CLI", () => { "pull", "stats", "review", - "export", "checks init", "skill install", ]) { @@ -96,7 +94,6 @@ describe("ghost CLI", () => { "pull <...ids>", "stats", "review", - "export", "checks ", "pulse", "manifest", @@ -124,7 +121,6 @@ describe("ghost CLI", () => { expect(names).toContain("stats"); expect(names).toContain("pulse"); expect(names).toContain("review"); - expect(names).toContain("export"); expect(names).toContain("checks"); expect(names).toContain("manifest"); @@ -1912,150 +1908,7 @@ describe("ghost CLI", () => { ); }); - it("export writes a portable tarball with export metadata and private events excluded", async () => { - await runCli(["init", "--with", "checks"], dir); - await mkdir(join(dir, ".ghost", "materials"), { recursive: true }); - await writeFile(join(dir, ".ghost", ".events"), '{"event":"gather"}\n'); - await writeFile( - join(dir, ".ghost", "materials", "tokens.css"), - ":root{}\n", - ); - await writeFile( - join(dir, ".ghost", "asset.tokens.md"), - "---\nfor: Tokens.\nmaterials:\n - materials/tokens.css\n - https://example.com/tokens\n - mcp://brand-assets/tokens\n---\n\nToken prose.\n", - ); - - const out = join(dir, "brand.tgz"); - const result = await runCli(["export", "--out", out], dir); - - expect(result.code).toBe(0); - expect(result.stdout).toContain("Locator audit"); - expect(result.stdout).toContain("materials/tokens.css"); - expect(result.stdout).toContain("HTTPS external locator only"); - expect(result.stdout).toContain( - "access depends on the URL and any permissions it requires", - ); - expect(result.stdout).toContain("mcp external locator only"); - expect(result.stdout).toContain( - "recipient may need a mcp connection or permission", - ); - const archive = parseTarEntries(gunzipSync(await readFile(out))); - expect(archive.has("asset.tokens.md")).toBe(true); - expect(archive.has("export.yml")).toBe(true); - expect(archive.has("glossary.md")).toBe(true); - expect(archive.has("checks/example.md.example")).toBe(true); - expect(archive.has("manifest.yml")).toBe(true); - expect(archive.has("materials/tokens.css")).toBe(true); - expect(archive.has(".events")).toBe(false); - expect( - parseYaml(archive.get("export.yml")?.toString("utf-8") ?? ""), - ).toMatchObject({ - schema: "ghost.export/v1", - id: "local", - cli: expect.any(String), - exported: expect.any(String), - }); - }); - - it("export --no-checks excludes checks from the archive", async () => { - await runCli(["init", "--with", "checks"], dir); - - const out = join(dir, "brand-no-checks.tgz"); - const result = await runCli(["export", "--out", out, "--no-checks"], dir); - - expect(result.code).toBe(0); - const archive = parseTarEntries(gunzipSync(await readFile(out))); - expect([...archive.keys()].some((path) => path.startsWith("checks/"))).toBe( - false, - ); - }); - - it("export audits bundled, URL, and referenced local material locators", async () => { - await writeBareTestPackage(dir); - await mkdir(join(dir, ".ghost", "materials"), { recursive: true }); - await mkdir(join(dir, "brand"), { recursive: true }); - await writeFile( - join(dir, ".ghost", "materials", "tokens.css"), - ":root{}\n", - ); - await writeFile(join(dir, "brand", "voice.txt"), "Plain.\n"); - await writeFile( - join(dir, ".ghost", "asset.tokens.md"), - "---\nfor: Tokens.\nmaterials:\n - materials/tokens.css\n - brand/voice.txt\n - https://example.com/tokens\n - figma://file/abc\n---\n\nToken prose.\n", - ); - - const result = await runCli(["export", "--format", "json"], dir); - - expect(result.code).toBe(0); - const payload = JSON.parse(result.stdout); - expect(payload).toMatchObject({ - kind: "export", - id: "local", - archive: expect.stringContaining("local-ghost-package.tgz"), - }); - expect(payload.audit.travels).toEqual([ - { - nodeId: "asset.tokens", - locator: "materials/tokens.css", - tier: "bundled", - }, - { - nodeId: "asset.tokens", - locator: "https://example.com/tokens", - tier: "url", - access: "https", - }, - { - nodeId: "asset.tokens", - locator: "figma://file/abc", - tier: "url", - access: "connector", - }, - ]); - expect(payload.audit.stranded).toEqual([ - { nodeId: "asset.tokens", locator: "brand/voice.txt" }, - ]); - }); - - it("export --strict allows connection-dependent external locators", async () => { - await writeBareTestPackage(dir); - await writeFile( - join(dir, ".ghost", "asset.remote.md"), - "---\nfor: Remote material.\nmaterials:\n - mcp://brand-assets/tokens\n - figma://file/abc\n - github:acme/brand-assets\n---\n\nRemote prose.\n", - ); - - const result = await runCli(["export", "--strict"], dir); - - expect(result.code).toBe(0); - expect(result.stdout).toContain("mcp external locator only"); - expect(result.stdout).toContain( - "recipient may need a mcp connection or permission", - ); - expect(result.stdout).toContain("figma external locator only"); - expect(result.stdout).toContain( - "recipient may need a figma connection or permission", - ); - expect(result.stdout).toContain("github external locator only"); - expect(result.stdout).toContain( - "recipient may need a github connection or permission", - ); - }); - - it("export --strict exits 2 when referenced local material locators are stranded", async () => { - await writeBareTestPackage(dir); - await writeFile( - join(dir, ".ghost", "asset.voice.md"), - "---\nfor: Voice.\nmaterials:\n - brand/voice.txt\n---\n\nVoice prose.\n", - ); - - const result = await runCli(["export", "--strict"], dir); - - expect(result.code).toBe(2); - expect(result.stdout).toContain("brand/voice.txt"); - expect(result.stdout).toContain("Bundle it into `.ghost/materials/`"); - }); - - it("commands work against an unpacked export directory outside a git repo", async () => { + it("commands work against a copied package directory outside a git repo", async () => { await writeBareTestPackage(dir); await mkdir(join(dir, ".ghost", "materials"), { recursive: true }); await writeFile( @@ -2066,22 +1919,19 @@ describe("ghost CLI", () => { join(dir, ".ghost", "asset.tokens.md"), "---\nfor: Tokens.\nmaterials:\n - materials/tokens.css\n---\n\nToken prose.\n", ); - const out = join(dir, "portable.tgz"); - await runCli(["export", "--out", out], dir); const receiver = join(dir, "receiver"); - const unpacked = join(receiver, "fingerprint"); - await mkdir(unpacked, { recursive: true }); - const archive = parseTarEntries(gunzipSync(await readFile(out))); - await writeEntries(unpacked, archive); + const packageDir = join(receiver, "ghost-package"); + await mkdir(receiver, { recursive: true }); + await cp(join(dir, ".ghost"), packageDir, { recursive: true }); const validate = await runCli( - ["validate", "--package", unpacked], + ["validate", "--package", packageDir], receiver, ); expect(validate.code).toBe(0); const gather = await runCli( - ["gather", "--package", unpacked, "--format", "json"], + ["gather", "--package", packageDir, "--format", "json"], receiver, ); expect(gather.code).toBe(0); @@ -2089,7 +1939,7 @@ describe("ghost CLI", () => { expect.objectContaining({ id: "asset.tokens" }), ); const pull = await runCli( - ["pull", "asset.tokens", "--package", unpacked], + ["pull", "asset.tokens", "--package", packageDir], receiver, ); expect(pull.code).toBe(0); @@ -2228,40 +2078,6 @@ describe("ghost CLI", () => { }); }); -function parseTarEntries(buffer: Buffer): Map { - const entries = new Map(); - let offset = 0; - while (offset + 512 <= buffer.byteLength) { - const header = buffer.subarray(offset, offset + 512); - if (header.every((byte) => byte === 0)) break; - const name = readTarString(header, 0, 100); - const prefix = readTarString(header, 345, 155); - const sizeText = readTarString(header, 124, 12).replace(/\0/g, "").trim(); - const size = Number.parseInt(sizeText || "0", 8); - const path = prefix ? `${prefix}/${name}` : name; - const dataStart = offset + 512; - entries.set(path, buffer.subarray(dataStart, dataStart + size)); - offset = dataStart + Math.ceil(size / 512) * 512; - } - return entries; -} - -function readTarString(buffer: Buffer, offset: number, length: number): string { - const slice = buffer.subarray(offset, offset + length); - const end = slice.indexOf(0); - return slice.subarray(0, end === -1 ? slice.length : end).toString("utf-8"); -} - -async function writeEntries( - root: string, - entries: Map, -): Promise { - for (const [path, data] of entries) { - await mkdir(join(root, path, ".."), { recursive: true }); - await writeFile(join(root, path), data); - } -} - async function writeGatherPackage(dir: string): Promise { const ghost = join(dir, ".ghost"); await mkdir(join(ghost, "email", "marketing"), { recursive: true }); diff --git a/packages/ghost/test/tarball.test.ts b/packages/ghost/test/tarball.test.ts deleted file mode 100644 index 3df259c5..00000000 --- a/packages/ghost/test/tarball.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { gunzipSync } from "node:zlib"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { writeDirectoryTarball } from "../src/scan/tarball.js"; - -describe("tarball writer", () => { - let dir: string; - - beforeEach(async () => { - dir = join( - tmpdir(), - `ghost-tarball-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - await mkdir(dir, { recursive: true }); - }); - - afterEach(async () => { - await rm(dir, { recursive: true, force: true }); - }); - - it("round-trips sorted regular files with export.yml metadata", async () => { - const root = join(dir, "pkg"); - await mkdir(join(root, "nested"), { recursive: true }); - await writeFile(join(root, "b.txt"), "bee\n"); - await writeFile(join(root, "nested", "a.txt"), "aye\n"); - const archivePath = join(dir, "archive.tgz"); - - await writeDirectoryTarball({ - rootDir: root, - outFile: archivePath, - extraEntries: [ - { - path: "export.yml", - data: "schema: ghost.export/v1\nid: local\ncli: 0.0.0\nexported: 2026-01-02T03:04:05.000Z\n", - mtime: new Date("2026-01-02T03:04:05.000Z"), - }, - ], - }); - - const entries = parseTarEntries(gunzipSync(await readFile(archivePath))); - expect([...entries.keys()]).toEqual([ - "b.txt", - "export.yml", - "nested/a.txt", - ]); - expect(entries.get("b.txt")?.toString("utf-8")).toBe("bee\n"); - expect(entries.get("nested/a.txt")?.toString("utf-8")).toBe("aye\n"); - expect(entries.get("export.yml")?.toString("utf-8")).toContain( - "schema: ghost.export/v1", - ); - expect(entries.get("export.yml")?.toString("utf-8")).toContain( - "exported: 2026-01-02T03:04:05.000Z", - ); - }); - - it("excludes matching paths", async () => { - const root = join(dir, "pkg"); - await mkdir(join(root, "checks"), { recursive: true }); - await writeFile(join(root, ".events"), "private\n"); - await writeFile(join(root, "checks", "example.md"), "check\n"); - await writeFile(join(root, "manifest.yml"), "id: local\n"); - const archivePath = join(dir, "archive.tgz"); - - await writeDirectoryTarball({ - rootDir: root, - outFile: archivePath, - exclude: (path) => path === ".events" || path.startsWith("checks/"), - }); - - const entries = parseTarEntries(gunzipSync(await readFile(archivePath))); - expect([...entries.keys()]).toEqual(["manifest.yml"]); - }); -}); - -function parseTarEntries(buffer: Buffer): Map { - const entries = new Map(); - let offset = 0; - while (offset + 512 <= buffer.byteLength) { - const header = buffer.subarray(offset, offset + 512); - if (header.every((byte) => byte === 0)) break; - const name = readTarString(header, 0, 100); - const prefix = readTarString(header, 345, 155); - const sizeText = readTarString(header, 124, 12).replace(/\0/g, "").trim(); - const size = Number.parseInt(sizeText || "0", 8); - const path = prefix ? `${prefix}/${name}` : name; - const dataStart = offset + 512; - entries.set(path, buffer.subarray(dataStart, dataStart + size)); - offset = dataStart + Math.ceil(size / 512) * 512; - } - return entries; -} - -function readTarString(buffer: Buffer, offset: number, length: number): string { - const slice = buffer.subarray(offset, offset + length); - const end = slice.indexOf(0); - return slice.subarray(0, end === -1 ? slice.length : end).toString("utf-8"); -}