diff --git a/README.md b/README.md index f1716ff..94a2b49 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,15 @@ Create a project. The slug is generated from the name. Show the authenticated org, plan, API key (name, prefix, permissions), and AI usage this month. +### `deploylog manual export` + +Export a project's whole manual as JSON: every version with its commit map, and every chapter with its claims. The payload is validated against the server's published schema before anything is written, and the export is available on every plan. + +``` +-p, --project Project slug (or set in .deploylog.yml) +-o, --out Output file (default: ./-manual.json; - for stdout) +``` + ### `deploylog list` (alias: `ls`) List recent entries for a project. Prints each entry's slug and id. diff --git a/package-lock.json b/package-lock.json index 2c7d5ae..121e1c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,23 @@ { "name": "deploylog", - "version": "0.2.2", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "deploylog", - "version": "0.2.2", + "version": "0.4.0", "license": "MIT", "dependencies": { "chalk": "^5.4.1", "commander": "^13.1.0", "conf": "^13.1.0", - "yaml": "^2.7.1" + "yaml": "^2.7.1", + "zod": "^4.4.3" }, "bin": { - "deploylog": "dist/index.js" + "deploylog": "dist/index.js", + "dpl": "dist/index.js" }, "devDependencies": { "@types/node": "^22.15.3", @@ -1573,6 +1575,15 @@ "funding": { "url": "https://github.com/sponsors/eemeli" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index eebdc2f..dc60137 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "chalk": "^5.4.1", "commander": "^13.1.0", "conf": "^13.1.0", - "yaml": "^2.7.1" + "yaml": "^2.7.1", + "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^22.15.3", diff --git a/src/api.test.ts b/src/api.test.ts index 4ab76d1..02458f1 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -8,7 +8,7 @@ vi.mock('./config.js', () => ({ getApiUrl: () => getApiUrl(), })) -import { listProjects, createEntry, ApiError } from './api.js' +import { listProjects, createEntry, exportManual, ApiError } from './api.js' function fetchReturning(status: number, body: string, ok?: boolean) { return vi.fn().mockResolvedValue({ @@ -99,3 +99,16 @@ describe('createEntry()', () => { expect(JSON.parse(opts.body as string)).toEqual({ title: 'x', body_markdown: 'b' }) }) }) + +describe('exportManual()', () => { + it('GETs /api/cli/manual/export?project= and returns the raw data', async () => { + const fetchMock = fetchReturning(200, JSON.stringify({ data: { project: 'my app' } })) + vi.stubGlobal('fetch', fetchMock) + + await expect(exportManual('my app')).resolves.toEqual({ project: 'my app' }) + + const [url, opts] = fetchMock.mock.calls[0] + expect(url).toBe('https://deploylog.dev/api/cli/manual/export?project=my+app') + expect(opts.method ?? 'GET').toBe('GET') + }) +}) diff --git a/src/api.ts b/src/api.ts index 592ad78..9ce94fb 100644 --- a/src/api.ts +++ b/src/api.ts @@ -247,3 +247,15 @@ export async function summarize(input: SummarizeInput): Promise`. Returns the body untyped on + * purpose: the caller validates it against the mirrored server schema + * (`manual-schema.ts`) before anything is written. + */ +export async function exportManual(projectSlug: string): Promise { + const params = new URLSearchParams({ project: projectSlug }) + return request(`/manual/export?${params.toString()}`) +} diff --git a/src/index.ts b/src/index.ts index 052b4af..02f6659 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { } from './entry-commands.js' import { runInit, defaultInitDeps } from './init.js' import { runOpen } from './open.js' +import { runManualExport } from './manual.js' const program = new Command() @@ -191,6 +192,41 @@ program } }) +// ─── manual ───────────────────────────────────────────────────────────────── + +const manual = program.command('manual').description('Work with a project\'s manual') + +manual + .command('export') + .description('Export the whole manual (every version, commit map, chapter and claim) as JSON') + .option('-p, --project ', 'Project slug (or set in .deploylog.yml)') + .option('-o, --out ', 'Output file (default: ./-manual.json; - for stdout)') + .option('--json', 'Output JSON (machine-readable, never prompts)') + .action(async (opts: { project?: string; out?: string; json?: boolean }) => { + try { + const result = await runManualExport({ project: opts.project, out: opts.out }) + switch (result.kind) { + case 'written': + if (opts.json) printJson({ path: result.path, versions: result.versions }) + else + console.log( + `${chalk.green('✓')} Wrote ${chalk.bold(result.path)} ${chalk.dim(`(${result.versions} version${result.versions === 1 ? '' : 's'})`)}`, + ) + break + case 'streamed': + // The payload is already on stdout and is the whole of stdout. + break + default: + if (opts.json) + printJsonError(result.kind.toUpperCase().replace(/-/g, '_'), result.message) + else console.error(chalk.red(result.message)) + process.exit(1) + } + } catch (err) { + handleError(err, opts.json) + } + }) + // ─── list ─────────────────────────────────────────────────────────────────── program diff --git a/src/manual-schema.ts b/src/manual-schema.ts new file mode 100644 index 0000000..0511bb7 --- /dev/null +++ b/src/manual-schema.ts @@ -0,0 +1,148 @@ +// mirrored from deploylog src/lib/schemas.ts @ 946dead +// +// `GET /api/cli/manual/export` validates its body against the server's +// ManualExportResponseSchema on the way out; this file is that schema and every +// schema it embeds, copied verbatim (comments included) so `deploylog manual +// export` refuses a payload the server would also have refused. If the server's +// copy changes, re-copy and bump the sha above — never loosen this one to make +// a drifted payload fit (issue 03, Boundaries). + +import { z } from 'zod' + +const REPOSITORY_SLUG = /^[\w.-]+\/[\w.-]+$/ + +export const CommitShaSchema = z + .string() + .regex(/^[0-9a-f]{40}$/, 'Must be a full 40-character commit sha') + +// --- Manual claims (Manual feature) --- +// A claim is the unit the verification service checks: one manual sentence, the +// repository and file it refers to, and the value it asserts. Four kinds cover +// the measured error classes. The absence kind exists because immutability +// claims ("this cannot be changed later") were the most severe class found. + +export const CLAIM_KINDS = ['const', 'zod-field', 'zod-field-absent', 'literal'] as const + +export type ClaimKind = (typeof CLAIM_KINDS)[number] + +/** + * A path inside a repository. + * + * Every segment must be an ordinary name. Percent-encoding a path is not + * protection: `encodeURIComponent('..')` is `'..'`, and `fetch` resolves the + * finished URL through the WHATWG parser, which collapses dot segments — so a + * path containing `..` walked out of the repository the caller had been + * authorized for and read a different one through the same installation token. + * Rejecting the input is the guard; encoding it is not. + */ +export const RepoFilePathSchema = z + .string() + .min(1, 'A path is required') + .refine( + (path) => + path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..'), + 'Every path segment must be an ordinary name (no empty, "." or ".." segments)', + ) + +export const ClaimSchema = z + .object({ + id: z.string().min(1), + // The manual sentence, mandatory. A finding that names a moved symbol but no + // sentence is not actionable, and the literal kind carries no symbol at all, + // so this is the only universal handle on a finding. + text: z.string().min(1), + repository: z.string().regex(REPOSITORY_SLUG, 'Must be owner/repo'), + // The same rule the reader enforces, applied where a claim enters the system, + // so a traversal path cannot be stored and replayed later. + source: RepoFilePathSchema, + symbol: z.string().min(1).optional(), + kind: z.enum(CLAIM_KINDS), + // No empty expect: a literal claim expecting '' matches every file and could + // never fire. + expect: z.string().min(1), + }) + // strict, so a hand-authored `anchors` key is REJECTED rather than silently + // stripped. Anchors are derived from the files claims already cite; a + // hand-authored anchor encodes where an author believes behaviour lives and + // rots exactly as the manual does. + .strict() + +export type Claim = z.infer + +export const ChapterSchema = z + .object({ + // A string: chapters are numbered "01", "02", and a number type would reject + // the leading zero. + number: z.string().min(1), + title: z.string().min(1), + // The chapter's prose, in markdown. Required, not optional: claim coverage is + // measured over these sentences, and a chapter whose body went missing would + // measure as "no sentences to cover" — a clean coverage figure produced by + // the absence of the thing being measured. + body: z.string(), + claims: z.array(ClaimSchema), + }) + .strict() + +export type Chapter = z.infer + +/** + * Repository slug to the commit it is pinned at. A product may span + * repositories, so each claim is verified against the commit map entry for its + * own repository. A single commit cannot describe a multi-repo product. + */ +export const CommitMapSchema = z.record(z.string().regex(REPOSITORY_SLUG), CommitShaSchema) + +export type CommitMap = z.infer + +export const CHAPTER_STATUSES = ['draft', 'flagged', 'approved', 'published'] as const + +export type ChapterStatus = (typeof CHAPTER_STATUSES)[number] + +// --- Manual export (issue 57) --- +// The portability answer to `wiki/decisions/claims-are-mirror-owned.md`: the +// whole manual, every version with its commit map and its chapters with their +// claims, retrievable on any tier. The schema IS the contract the CLI's +// `deploylog manual export` mirrors, exactly as ManualVerifyResponseSchema is +// for the Action. + +/** + * A chapter as exported: ChapterSchema itself, so the claims are ClaimSchema + * and cannot drift from the vocabulary `manual_claims` stores, plus the + * review state the mirror holds for it. + */ +const ExportChapterSchema = ChapterSchema.extend({ status: z.enum(CHAPTER_STATUSES) }).strict() + +/** + * One version. `commitMap` is required and nullable, never optional: `expect` + * is the value read at generation, so claims without the map of the version + * they were cut against verify nothing. A version whose stored map is empty + * (the working version's column default) is exported with `null` — marked as + * having none — rather than with `{}`, which would read as a pinned version + * that happens to cite few repositories. A non-null map must pin at least one + * repository, so the empty object cannot reach the wire under either spelling. + */ +const ExportVersionSchema = z + .object({ + id: z.string().min(1), + label: z.string().min(1), + /** When the version was cut, or null for the working version. */ + publishedAt: z.string().nullable(), + createdAt: z.string(), + commitMap: CommitMapSchema.refine( + (map) => Object.keys(map).length > 0, + 'A pinned version maps at least one repository; an unpinned one is null', + ).nullable(), + chapters: z.array(ExportChapterSchema), + }) + .strict() + +export const ManualExportResponseSchema = z + .object({ + project: z.string().min(1), + manual: z.object({ id: z.string().min(1), title: z.string() }).strict(), + versions: z.array(ExportVersionSchema), + }) + .strict() + +export type ManualExportResponse = z.infer diff --git a/src/manual.test.ts b/src/manual.test.ts new file mode 100644 index 0000000..b36a8db --- /dev/null +++ b/src/manual.test.ts @@ -0,0 +1,241 @@ +import { readFileSync } from 'node:fs' +import { describe, it, expect, vi } from 'vitest' +import { ApiError } from './api.js' +import { runManualExport, type ManualExportDeps } from './manual.js' +import { ManualExportResponseSchema } from './manual-schema.js' + +const REPO = 'marko-builds/deploylog' +const SIBLING = 'marko-builds/deploylog-action' +const PINNED = 'a'.repeat(40) + +function claim(overrides: Record = {}) { + return { + id: 'claim-1', + text: 'A free organisation may create three projects.', + repository: REPO, + source: 'src/lib/plan.ts', + symbol: 'FREE_PROJECT_LIMIT', + kind: 'const' as const, + expect: '3', + ...overrides, + } +} + +/** + * The same two-version manual the server route test exports: an archived + * version cut with a commit map, and the working one exported with + * `commitMap: null`. If this fixture ever fails the mirrored schema, the + * mirror (not the fixture) has drifted from the route. + */ +function payload() { + return { + project: 'my-app', + manual: { id: 'manual-1', title: 'The DeployLog Manual' }, + versions: [ + { + id: 'version-1', + label: 'v1.0', + publishedAt: '2026-08-19T10:00:00.000Z', + createdAt: '2026-08-19T10:00:00.000Z', + commitMap: { [REPO]: PINNED, [SIBLING]: PINNED }, + chapters: [ + { + number: '01', + title: 'Plans and limits', + status: 'published', + body: 'A free organisation may create three projects. That is the free limit.', + claims: [claim()], + }, + { + number: '02', + title: 'Billing', + status: 'published', + body: 'Pro costs 19 dollars a month. Billing is monthly.', + claims: [ + claim({ + id: 'claim-2', + text: 'Pro costs 19 dollars a month.', + source: 'src/lib/billing.ts', + symbol: 'PRO_PRICE', + expect: '19', + }), + claim({ + id: 'claim-3', + text: 'Billing is monthly.', + repository: SIBLING, + source: 'src/verdict.ts', + symbol: 'FAIL_ON_DEFAULT', + expect: 'none', + }), + ], + }, + ], + }, + { + id: 'version-2', + label: 'draft', + publishedAt: null, + createdAt: '2026-08-20T10:00:00.000Z', + commitMap: null, + chapters: [ + { + number: '01', + title: 'Plans and limits', + status: 'draft', + body: 'A free organisation may create three projects.', + claims: [claim()], + }, + ], + }, + ], + } +} + +function makeDeps(overrides: Partial = {}): ManualExportDeps { + return { + api: { exportManual: vi.fn().mockResolvedValue(payload()) }, + readProjectConfig: () => ({ project: 'from-config' }), + writeFile: vi.fn(), + stdout: vi.fn(), + ...overrides, + } +} + +function writes(deps: ManualExportDeps) { + return (deps.writeFile as ReturnType).mock.calls +} + +function stdoutChunks(deps: ManualExportDeps) { + return (deps.stdout as ReturnType).mock.calls.map((c) => c[0]) +} + +describe('manual export — writes the validated payload', () => { + it('writes the payload to --out as pretty JSON', async () => { + const deps = makeDeps() + const res = await runManualExport({ project: 'x', out: 'f.json' }, deps) + + expect(res).toEqual({ kind: 'written', path: 'f.json', versions: 2 }) + expect(deps.api.exportManual).toHaveBeenCalledWith('x') + expect(writes(deps)).toHaveLength(1) + const [path, text] = writes(deps)[0] + expect(path).toBe('f.json') + expect(JSON.parse(text)).toEqual(payload()) + expect(text.endsWith('\n')).toBe(true) + expect(stdoutChunks(deps)).toEqual([]) + }) + + it('control: a claim with its `expect` removed exits non-zero and writes nothing', async () => { + const bad = payload() + delete (bad.versions[0]!.chapters[0]!.claims[0] as { expect?: string }).expect + const deps = makeDeps({ api: { exportManual: vi.fn().mockResolvedValue(bad) } }) + + const res = await runManualExport({ project: 'x', out: 'f.json' }, deps) + + expect(res.kind).toBe('invalid-payload') + // Names the first failing path, so the user can see what drifted. + expect((res as { message: string }).message).toContain('versions.0.chapters.0.claims.0.expect') + expect(writes(deps)).toHaveLength(0) + expect(stdoutChunks(deps)).toEqual([]) + }) + + it('control: a payload with an extra top-level key is refused (the mirror is strict)', async () => { + const bad = { ...payload(), plan: 'free' } + const deps = makeDeps({ api: { exportManual: vi.fn().mockResolvedValue(bad) } }) + + const res = await runManualExport({ project: 'x', out: '-' }, deps) + + expect(res.kind).toBe('invalid-payload') + expect(stdoutChunks(deps)).toEqual([]) + }) + + it('defaults --out to ./-manual.json and resolves the slug from .deploylog.yml', async () => { + const deps = makeDeps() + const res = await runManualExport({}, deps) + + expect(res).toEqual({ kind: 'written', path: 'from-config-manual.json', versions: 2 }) + expect(deps.api.exportManual).toHaveBeenCalledWith('from-config') + }) + + it('an explicit --project wins over .deploylog.yml', async () => { + const deps = makeDeps() + await runManualExport({ project: 'flag' }, deps) + expect(deps.api.exportManual).toHaveBeenCalledWith('flag') + expect(writes(deps)[0][0]).toBe('flag-manual.json') + }) + + it('refuses with no project from either source, before any request', async () => { + const deps = makeDeps({ readProjectConfig: () => null }) + const res = await runManualExport({}, deps) + + expect(res.kind).toBe('missing-fields') + expect(deps.api.exportManual).not.toHaveBeenCalled() + expect(writes(deps)).toHaveLength(0) + }) +}) + +describe('manual export — --out - streams to stdout', () => { + it('puts the JSON and nothing else on stdout, and writes no file', async () => { + const deps = makeDeps() + const res = await runManualExport({ project: 'x', out: '-' }, deps) + + expect(res).toEqual({ kind: 'streamed', versions: 2 }) + expect(writes(deps)).toHaveLength(0) + const chunks = stdoutChunks(deps) + expect(chunks).toHaveLength(1) + expect(JSON.parse(chunks[0])).toEqual(payload()) + }) +}) + +describe('manual export — honest errors', () => { + it('404 from the route exits non-zero with the project slug in the message', async () => { + const deps = makeDeps({ + api: { + exportManual: vi + .fn() + .mockRejectedValue(new ApiError(404, 'NOT_FOUND', "Project 'ghost' not found")), + }, + }) + + const res = await runManualExport({ project: 'ghost', out: 'f.json' }, deps) + + expect(res.kind).toBe('not-found') + expect((res as { message: string }).message).toContain('ghost') + expect(writes(deps)).toHaveLength(0) + }) + + it('lets a 401 through untouched, so the adapter prints the existing login nudge', async () => { + const err = new ApiError(401, 'UNAUTHORIZED', 'Invalid API key') + const deps = makeDeps({ api: { exportManual: vi.fn().mockRejectedValue(err) } }) + + await expect(runManualExport({ project: 'x' }, deps)).rejects.toBe(err) + expect(writes(deps)).toHaveLength(0) + }) +}) + +describe('manual export — no plan gate', () => { + it('neither the command nor its schema mirror references plan or can()', () => { + for (const file of ['./manual.ts', './manual-schema.ts']) { + const source = readFileSync(new URL(file, import.meta.url), 'utf8') + expect(source, file).not.toMatch(/plan/i) + expect(source, file).not.toMatch(/\bcan\s*\(/) + } + }) +}) + +describe('mirrored ManualExportResponseSchema', () => { + it('accepts the shape the server route test ships', () => { + expect(ManualExportResponseSchema.safeParse(payload()).success).toBe(true) + }) + + it('rejects an empty commit map (the route sends null, never {})', () => { + const bad = payload() + ;(bad.versions[0] as { commitMap: unknown }).commitMap = {} + expect(ManualExportResponseSchema.safeParse(bad).success).toBe(false) + }) + + it('rejects a hand-authored anchors key on a claim (ClaimSchema is strict)', () => { + const bad = payload() + ;(bad.versions[0]!.chapters[0]!.claims[0] as Record).anchors = [] + expect(ManualExportResponseSchema.safeParse(bad).success).toBe(false) + }) +}) diff --git a/src/manual.ts b/src/manual.ts new file mode 100644 index 0000000..bb8aa49 --- /dev/null +++ b/src/manual.ts @@ -0,0 +1,99 @@ +import { writeFileSync } from 'node:fs' +import { ApiError, exportManual } from './api.js' +import { readProjectConfig, type ProjectConfig } from './project-config.js' +import { ManualExportResponseSchema, type ManualExportResponse } from './manual-schema.js' + +export interface ManualExportOptions { + project?: string + /** Output path; `-` streams to stdout. Default `./-manual.json`. */ + out?: string +} + +/** + * Outcome of an export. `written` / `streamed` carry the version count; every + * other kind is a typed refusal the adapter prints to stderr with exit 1. + * A 401 is not caught here: it propagates as an ApiError so the adapter's + * existing login nudge handles it the same way as every other command. + */ +export type ManualExportResult = + | { kind: 'written'; path: string; versions: number } + | { kind: 'streamed'; versions: number } + | { kind: 'missing-fields'; message: string } + | { kind: 'not-found'; message: string } + | { kind: 'invalid-payload'; message: string } + +export interface ManualExportDeps { + api: { exportManual(slug: string): Promise } + readProjectConfig(): ProjectConfig | null + writeFile(path: string, text: string): void + /** The only thing that ever reaches stdout: the payload, when `--out -`. */ + stdout(text: string): void +} + +/** + * `deploylog manual export`: fetch the whole manual for one project and write + * it where a human can open it. The payload is validated against the mirrored + * server schema BEFORE anything is written, so a file on disk is always a + * payload the server's own contract accepted. No reader, no diff, no import + * (issue 03, Boundaries) — and no tier check of any kind: the export is the + * exit, and the exit is never charged for. + */ +export async function runManualExport( + opts: ManualExportOptions, + deps: ManualExportDeps = defaultManualExportDeps, +): Promise { + const slug = opts.project ?? deps.readProjectConfig()?.project + if (!slug) { + return { + kind: 'missing-fields', + message: + 'No project specified. Use --project or create a .deploylog.yml with:\n project: my-app', + } + } + + let raw: unknown + try { + raw = await deps.api.exportManual(slug) + } catch (err) { + if (err instanceof ApiError && err.status === 404) { + return { + kind: 'not-found', + message: `No manual for project '${slug}' under this key (${err.message}).`, + } + } + throw err + } + + const parsed = ManualExportResponseSchema.safeParse(raw) + if (!parsed.success) { + const first = parsed.error.issues[0] + const path = first && first.path.length > 0 ? first.path.join('.') : '(root)' + return { + kind: 'invalid-payload', + message: + `The export for '${slug}' does not match the schema this CLI mirrors ` + + `(first failing path: ${path}: ${first?.message ?? 'invalid'}). Nothing was written.\n` + + 'Update the CLI, or report this if you are already on the latest version.', + } + } + + const payload: ManualExportResponse = parsed.data + const text = `${JSON.stringify(payload, null, 2)}\n` + const versions = payload.versions.length + + if (opts.out === '-') { + deps.stdout(text) + return { kind: 'streamed', versions } + } + + const path = opts.out ?? `${slug}-manual.json` + deps.writeFile(path, text) + return { kind: 'written', path, versions } +} + +export const defaultManualExportDeps: ManualExportDeps = { + api: { exportManual }, + readProjectConfig: () => readProjectConfig(), + writeFile: (path, text) => writeFileSync(path, text, 'utf8'), + stdout: (text) => process.stdout.write(text), +}