Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <slug> Project slug (or set in .deploylog.yml)
-o, --out <path> Output file (default: ./<slug>-manual.json; - for stdout)
```

### `deploylog list` (alias: `ls`)

List recent entries for a project. Prints each entry's slug and id.
Expand Down
19 changes: 15 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 14 additions & 1 deletion src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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=<slug> 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')
})
})
12 changes: 12 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,15 @@ export async function summarize(input: SummarizeInput): Promise<SummarizeRespons
body: JSON.stringify(input),
})
}

// ─── Manual export ──────────────────────────────────────────────────────────

/**
* `GET /api/cli/manual/export?project=<slug>`. 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<unknown> {
const params = new URLSearchParams({ project: projectSlug })
return request(`/manual/export?${params.toString()}`)
}
36 changes: 36 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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 <slug>', 'Project slug (or set in .deploylog.yml)')
.option('-o, --out <path>', 'Output file (default: ./<slug>-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
Expand Down
148 changes: 148 additions & 0 deletions src/manual-schema.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ClaimSchema>

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<typeof ChapterSchema>

/**
* 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<typeof CommitMapSchema>

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<typeof ManualExportResponseSchema>
Loading