rvlib-shipkit 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +23 -8
  3. package/package.json +15 -3
  4. package/src/asks/asks-queue.ts +20 -7
  5. package/src/assets/gen/comfy-gen.ts +106 -0
  6. package/src/assets/gen/generate-roll.ts +57 -0
  7. package/src/assets/gen/workflow-loader.ts +67 -0
  8. package/src/assets/image-ops.ts +30 -0
  9. package/src/assets/json-store.ts +27 -0
  10. package/src/assets/kit-config.ts +161 -0
  11. package/src/assets/kits/identity-kit.ts +80 -0
  12. package/src/assets/native-deps.ts +18 -0
  13. package/src/assets/panels/geometry-rects.ts +84 -0
  14. package/src/assets/roll-workspace.ts +41 -0
  15. package/src/assets/run-assets.ts +122 -0
  16. package/src/assets/server/studio-page.ts +587 -0
  17. package/src/assets/server/studio-queue.ts +58 -0
  18. package/src/assets/server/studio-server.ts +360 -0
  19. package/src/assets/vite.ts +62 -0
  20. package/src/cli/cmd_Asks.ts +83 -0
  21. package/src/cli/cmd_Assets.ts +120 -0
  22. package/src/cli/cmd_Check.ts +30 -0
  23. package/src/cli/cmd_Ci.ts +41 -0
  24. package/src/cli/cmd_Contracts.ts +35 -0
  25. package/src/cli/cmd_Init.ts +28 -0
  26. package/src/cli/cmd_Tui.ts +36 -0
  27. package/src/cli/cmd_Wiki.ts +53 -0
  28. package/src/cli.ts +42 -25
  29. package/src/contracts/run-contracts.ts +33 -0
  30. package/src/core/check-ctx.ts +3 -16
  31. package/src/core/check-suite.ts +16 -2
  32. package/src/core/discover-checks.ts +48 -0
  33. package/src/core/run-check.ts +73 -0
  34. package/src/core/run-ci.ts +54 -0
  35. package/src/core/spawn-timeout.ts +65 -0
  36. package/src/core/task-runner.ts +36 -43
  37. package/src/core/wire.ts +20 -0
  38. package/src/core/write-status.ts +70 -0
  39. package/src/init-repo.ts +60 -0
  40. package/src/repo-config.ts +68 -16
  41. package/src/shipkit.ts +34 -3
  42. package/src/tui/checks-panel.ts +39 -0
  43. package/src/tui/tasks-panel.ts +54 -0
  44. package/src/tui/tui-app.tsx +91 -0
  45. package/src/tui/tui-panel.ts +57 -0
  46. package/src/tui/tui-store.ts +98 -0
  47. package/src/wiki/run-wiki.ts +50 -0
  48. package/src/cli/shipkit-asks.ts +0 -26
  49. package/src/cli/shipkit-check.ts +0 -26
  50. package/src/cli/shipkit-ci.ts +0 -30
  51. package/src/cli/shipkit-contracts.ts +0 -26
  52. package/src/cli/shipkit-init.ts +0 -41
  53. package/src/cli/shipkit-wiki.ts +0 -74
@@ -0,0 +1,57 @@
1
+ // TUI panel contract — render-free and mobx-free on purpose: a panel is
2
+ // DATA (rows + wire-format detail lines), the generic view (tui-app.tsx)
3
+ // owns all ink. Built-ins: tasks-panel.ts (the ci gate) + checks-panel.ts
4
+ // (contracts + discovered *.check.ts); a repo injects extras via
5
+ // repo.config `tui: { panels: [...] }`.
6
+
7
+ export type TuiRowStatus = 'pass' | 'fail' | 'warn' | 'skip' | 'pending' | 'info'
8
+
9
+ export type TuiRow = {
10
+ key: string
11
+ /** left-list label */
12
+ label: string
13
+ /** drives the list mark + detail border color; 'pending' shows the spinner */
14
+ status: TuiRowStatus
15
+ /** dim right-side hint (duration, age, cached, owner) */
16
+ hint?: string
17
+ /** list-mark override (default derives from status — asks rows use '?') */
18
+ mark?: string
19
+ /** detail-panel lines — wire format (ok:/WARN/FAIL:/skip:/fix:) or plain text */
20
+ detail: string[]
21
+ }
22
+
23
+ export type TuiPanelDef = {
24
+ id: string
25
+ /** tab label */
26
+ title: string
27
+ /** shown green when rows() is empty after refresh (e.g. "no open asks …") */
28
+ emptyText?: string
29
+ /** current rows — the store re-reads after every update() */
30
+ rows: () => TuiRow[]
31
+ /** (re)load data; call update() after each mutation so the view repaints.
32
+ * The spinner runs until every panel's returned promise settled. */
33
+ refresh: (update: () => void) => void | Promise<void>
34
+ }
35
+
36
+ export const ROW_MARK: Record<TuiRowStatus, { mark: string; color?: string }> = {
37
+ pass: { mark: '✓', color: 'green' },
38
+ fail: { mark: '✗', color: 'red' },
39
+ warn: { mark: '⚠', color: 'yellow' },
40
+ skip: { mark: '~', color: 'gray' },
41
+ pending: { mark: '', color: 'cyan' }, // the view substitutes the spinner frame
42
+ info: { mark: '·' },
43
+ }
44
+
45
+ // normalize a wire/detail line to the one visual language (✓/✗/⚠/~/»),
46
+ // no raw 'ok'/'FAIL' prefixes in the detail panel
47
+ export type MarkedLine = { mark: string; color?: string; text: string; bold?: boolean }
48
+
49
+ export const markLine = (l: string): MarkedLine => {
50
+ if (/^ok[:\s]/.test(l)) return { mark: '✓', color: 'green', text: l.replace(/^ok[:\s]+/, '') }
51
+ if (l.startsWith('FAIL')) return { mark: '✗', color: 'red', text: l.replace(/^FAIL[:\s]+/, '') }
52
+ if (l.startsWith('WARN')) return { mark: '⚠', color: 'yellow', text: l.replace(/^WARN[:\s]+/, '') }
53
+ if (l.startsWith('skip:')) return { mark: '~', color: 'gray', text: l.replace(/^skip:\s*/, '') }
54
+ if (/^\s+fix: /.test(l)) return { mark: '»', color: 'yellow', text: l.trim() }
55
+ if (l.startsWith('VERDICT')) return { mark: '»', text: l, bold: true }
56
+ return { mark: ' ', text: l }
57
+ }
@@ -0,0 +1,98 @@
1
+ // TUI state — MobX per the house playbook (build/state + build/mobx):
2
+ // `makeAutoObservable` WITH exceptions (the 2026-07-21 doctrine — no
3
+ // decorators), runInAction around every post-await mutation. Panels are
4
+ // plain data providers (no mobx): they signal mutations through update(),
5
+ // which bumps the observable `version` counter every rows() read tracks.
6
+ import { makeAutoObservable, runInAction } from 'mobx'
7
+ import type { TuiPanelDef, TuiRow, TuiRowStatus } from './tui-panel.ts'
8
+
9
+ export class TuiStore {
10
+ tab = 0
11
+ sel = 0
12
+ spin = 0 // spinner frame, ticked while any panel refresh is in flight
13
+ version = 0 // bumped by panel update() — activeRows re-derives from it
14
+ error: string | null = null
15
+ inflight = 0 // panel refresh promises not yet settled
16
+
17
+ readonly name: string
18
+ readonly panels: TuiPanelDef[]
19
+ private spinTimer: ReturnType<typeof setInterval> | null = null
20
+
21
+ constructor(name: string, panels: TuiPanelDef[]) {
22
+ if (panels.length === 0) throw new Error('TuiStore: at least one panel required')
23
+ this.name = name
24
+ this.panels = panels
25
+ // panels/name/spinTimer are infra, never observed
26
+ makeAutoObservable<TuiStore, 'spinTimer'>(this, { name: false, panels: false, spinTimer: false })
27
+ }
28
+
29
+ get activePanel(): TuiPanelDef {
30
+ return this.panels[this.tab]!
31
+ }
32
+
33
+ get activeRows(): TuiRow[] {
34
+ void this.version // panels mutate outside mobx — update() bumps this
35
+ return this.activePanel.rows()
36
+ }
37
+
38
+ get selectedRow(): TuiRow | null {
39
+ return this.activeRows[this.sel] ?? null
40
+ }
41
+
42
+ /** per-status counts over the ACTIVE panel — the header summary */
43
+ get counts(): Record<TuiRowStatus, number> {
44
+ const c: Record<TuiRowStatus, number> = { pass: 0, fail: 0, warn: 0, skip: 0, pending: 0, info: 0 }
45
+ for (const r of this.activeRows) c[r.status]++
46
+ return c
47
+ }
48
+
49
+ rowsOf(panel: TuiPanelDef): TuiRow[] {
50
+ void this.version
51
+ return panel.rows()
52
+ }
53
+
54
+ setTab(i: number): void {
55
+ if (i < 0 || i >= this.panels.length || i === this.tab) return
56
+ this.tab = i
57
+ this.sel = 0
58
+ }
59
+
60
+ cycleTab(delta = 1): void {
61
+ this.setTab((this.tab + delta + this.panels.length) % this.panels.length)
62
+ }
63
+
64
+ move(delta: number): void {
65
+ const max = Math.max(0, this.activeRows.length - 1)
66
+ this.sel = Math.min(max, Math.max(0, this.sel + delta))
67
+ }
68
+
69
+ // kick every panel's refresh; results stream in via update(). A refresh
70
+ // while one is in flight is ignored — panels own no superseding logic.
71
+ refresh(): void {
72
+ if (this.inflight > 0) return
73
+ this.error = null
74
+ this.startSpinner()
75
+ const update = (): void => runInAction(() => { this.version++ })
76
+ for (const p of this.panels) {
77
+ this.inflight++
78
+ void Promise.resolve()
79
+ .then(() => p.refresh(update))
80
+ .catch((e) => runInAction(() => { this.error = `${p.id}: ${String(e).slice(0, 200)}` })) // never fail silently — surfaced in the header
81
+ .finally(() => runInAction(() => {
82
+ this.version++
83
+ if (--this.inflight === 0) this.stopSpinner()
84
+ }))
85
+ }
86
+ }
87
+
88
+ private startSpinner(): void {
89
+ if (this.spinTimer != null) return
90
+ this.spinTimer = setInterval(() => runInAction(() => { this.spin++ }), 120)
91
+ }
92
+
93
+ private stopSpinner(): void {
94
+ if (this.spinTimer == null) return
95
+ clearInterval(this.spinTimer)
96
+ this.spinTimer = null
97
+ }
98
+ }
@@ -0,0 +1,50 @@
1
+ // md-wiki graph server + link lint (folded in from the standalone mdwiki
2
+ // lib; that npm package is frozen). Plain modules behind cmd_Wiki —
3
+ // clipanion owns the option parsing.
4
+ import { basename, relative, resolve } from 'path'
5
+ import { lintMd } from './lint-md.ts'
6
+ import { scanMdGraph } from './scan-md.ts'
7
+ import { serveMdGraph } from './serve-graph.ts'
8
+
9
+ export type WikiScanOpts = { root: string; index?: string; skip?: string }
10
+
11
+ const scanConfig = (o: WikiScanOpts): { root: string; index?: string; skipDirs?: string[] } => ({
12
+ root: resolve(process.cwd(), o.root),
13
+ index: o.index,
14
+ skipDirs: o.skip?.split(',').filter(Boolean),
15
+ })
16
+
17
+ export async function runWikiServe(o: WikiScanOpts & { port: number; title?: string }): Promise<number> {
18
+ const cfg = scanConfig(o)
19
+ const rel = relative(process.cwd(), cfg.root)
20
+ const title = o.title ?? (rel === '' ? basename(cfg.root) : `${basename(process.cwd())} ${rel}`)
21
+ try {
22
+ serveMdGraph({ port: o.port, title, load: () => scanMdGraph(cfg) })
23
+ console.log(`wiki graph → http://localhost:${o.port} (open in browser, or VSCode "Simple Browser: Show")`)
24
+ // keep the server alive — the command must not resolve
25
+ await new Promise(() => {})
26
+ return 0
27
+ } catch (e) {
28
+ if (e instanceof Error && e.message.includes('in use')) {
29
+ console.log(`wiki graph already served → http://localhost:${o.port} (another window owns it)`)
30
+ return 0
31
+ }
32
+ throw e
33
+ }
34
+ }
35
+
36
+ export function runWikiLint(o: WikiScanOpts): number {
37
+ const cfg = scanConfig(o)
38
+ const r = lintMd(cfg)
39
+ if (r.index == null) {
40
+ console.log(o.index != null
41
+ ? `FAIL entrypoint '${o.index}' not found in ${cfg.root}`
42
+ : `FAIL no entrypoint: neither index.md nor README.md in ${cfg.root} — pass --index <entry.md>`)
43
+ return 1
44
+ }
45
+ for (const f of r.orphans) console.log(`FAIL orphan: ${f} — unreachable from ${r.index}; link it or delete it`)
46
+ for (const d of r.dangling) console.log(`FAIL dangling: ${d.from} → ${d.token} — target not found`)
47
+ if (!r.ok) { console.log(`FAIL mdwiki lint: ${r.orphans.length} orphan(s), ${r.dangling.length} dangling ref(s) in ${cfg.root}`); return 1 }
48
+ console.log(`ok mdwiki lint: ${r.files} files, all reachable from ${r.index}, no dangling refs`)
49
+ return 0
50
+ }
@@ -1,26 +0,0 @@
1
- // shipkit asks — the per-repo human queue. `asks` lists, `asks check`
2
- // probes + archives, `ask "<title>"` (dispatch rewrites to `asks new`).
3
- import { join } from 'path'
4
- import { checkAsks, createAsk, renderAsksTable } from '../asks/asks-queue.ts'
5
- import { loadRepoConfig } from '../repo-config.ts'
6
-
7
- const flagVal = (args: string[], name: string): string | undefined => {
8
- const i = args.indexOf(`--${name}`)
9
- return i !== -1 ? args[i + 1] : undefined
10
- }
11
-
12
- export async function main(args: string[]): Promise<number> {
13
- const root = process.cwd()
14
- const dir = join(root, (await loadRepoConfig(root)).asks?.dir ?? 'asks')
15
- const sub = args[0]
16
- if (sub === 'check') { checkAsks(dir); return 0 }
17
- if (sub === 'new') {
18
- const title = args[1]
19
- if (title == null || title === '') { console.error('usage: shipkit ask "<title>" [--kind action|info] [--blocks <what>] [--check "<cmd>"]'); return 2 }
20
- const file = createAsk(dir, title, { kind: flagVal(args, 'kind'), blocks: flagVal(args, 'blocks'), check: flagVal(args, 'check') })
21
- console.log(`created ${dir}/${file}`)
22
- return 0
23
- }
24
- renderAsksTable(dir)
25
- return 0
26
- }
@@ -1,26 +0,0 @@
1
- // shipkit check — periodic health: contracts + the repo's TTL'd checks.
2
- // Clean passes replay from cache; SHIPKIT_FRESH=1 (or --fresh) busts.
3
- import { join } from 'path'
4
- import { CheckSuite } from '../core/check-suite.ts'
5
- import { activeContracts, applyContractsResult, contractsConfigFrom, loadRepoConfig } from '../repo-config.ts'
6
- import { evaluateContracts } from '../contracts/contracts-engine.ts'
7
-
8
- export async function main(args: string[]): Promise<number> {
9
- const root = process.cwd()
10
- const c = await loadRepoConfig(root)
11
- if (args.includes('--fresh')) process.env.SHIPKIT_FRESH = '1'
12
- const suite = new CheckSuite({ name: c.name, cacheDir: join(root, 'tmp/.shipkit-cache') })
13
- suite.addCheck({
14
- name: 'contracts',
15
- logic: async (t) => {
16
- applyContractsResult(t, await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root)))
17
- },
18
- })
19
- for (const chk of c.checks ?? []) suite.addCheck({ name: chk.id, remainValidFor: chk.remainValidFor, version: chk.version, logic: chk.logic })
20
- const r = await suite.execute()
21
- if (args.includes('--json')) { console.log(JSON.stringify(r)); return r.exitCode }
22
- const { formatLine } = await import('../core/wire.ts')
23
- const color = process.stdout.isTTY && process.env.NO_COLOR == null
24
- for (const l of r.lines) console.log(formatLine(l, color))
25
- return r.exitCode
26
- }
@@ -1,30 +0,0 @@
1
- // shipkit ci — the commit/deploy gate: floor + contracts + repo tasks
2
- // through the DAG runner. Exit 0/1; --json prints the structured summary.
3
- import { join } from 'path'
4
- import { allTasks, loadRepoConfig } from '../repo-config.ts'
5
- import { runTasks } from '../core/task-runner.ts'
6
-
7
- const flagVals = (args: string[], name: string): string[] => {
8
- const out: string[] = []
9
- for (let i = 0; i < args.length; i++) if (args[i] === name && args[i + 1] != null) out.push(args[i + 1]!)
10
- return out
11
- }
12
-
13
- export async function main(args: string[]): Promise<number> {
14
- const root = process.cwd()
15
- const c = await loadRepoConfig(root)
16
- const { tasks, disabled } = allTasks(c, root)
17
- for (const d of disabled) console.log(`skip: ${d} (skipped-by-config)`)
18
- const summary = await runTasks({
19
- tasks,
20
- logDir: c.logDir ?? join(root, 'tmp/shipkit-ci'),
21
- cwd: root,
22
- bail: args.includes('--bail'),
23
- skipGroups: flagVals(args, '--skip'),
24
- withIds: flagVals(args, '--with'),
25
- onlyIds: flagVals(args, '--only'),
26
- })
27
- if (args.includes('--json'))
28
- console.log(JSON.stringify({ ok: summary.ok, interrupted: summary.interrupted, disabled, tasks: summary.results.map((r) => ({ ...r, lines: undefined })) }))
29
- return summary.ok ? 0 : 1
30
- }
@@ -1,26 +0,0 @@
1
- // shipkit contracts — fulfillness alone, fast and filesystem-only.
2
- // Venue deploy scripts call this before any upload.
3
- import { activeContracts, contractsConfigFrom, loadRepoConfig } from '../repo-config.ts'
4
- import { evaluateContracts } from '../contracts/contracts-engine.ts'
5
- import { formatLine } from '../core/wire.ts'
6
-
7
- export async function main(args: string[]): Promise<number> {
8
- const root = process.cwd()
9
- const c = await loadRepoConfig(root)
10
- const venueIdx = args.indexOf('--venue')
11
- const only = venueIdx !== -1 ? args[venueIdx + 1] : null
12
- let manifests = activeContracts(c)
13
- if (only != null) {
14
- manifests = manifests.filter((m) => m.venue === only)
15
- if (manifests.length === 0) { console.error(`FAIL: unknown venue '${only}'`); return 1 }
16
- }
17
- const r = await evaluateContracts(manifests, contractsConfigFrom(c, root))
18
- if (args.includes('--json')) {
19
- console.log(JSON.stringify({ ok: r.exitCode === 0, outcomes: r.outcomes }))
20
- return r.exitCode
21
- }
22
- const color = process.stdout.isTTY && process.env.NO_COLOR == null
23
- for (const l of r.lines) console.log(formatLine(l, color))
24
- if (r.lines.length === 0) console.log('ok: no contracts apply (nothing declared, nothing detected)')
25
- return r.exitCode
26
- }
@@ -1,41 +0,0 @@
1
- // shipkit init — scaffold repo.config.ts + package.json scripts. Refuses to
2
- // overwrite an existing config; script merge never clobbers existing keys.
3
- import { existsSync, readFileSync, writeFileSync } from 'fs'
4
- import { join } from 'path'
5
-
6
- const CONFIG_TEMPLATE = (name: string): string => `import { defineRepo } from 'rvlib-shipkit'
7
-
8
- export default defineRepo({
9
- name: '${name}',
10
- venues: [], // e.g. ['itch', 'steam'] — selects which .rv-<venue>/ contracts are REQUIRED
11
- social: [], // e.g. ['x', 'discord'] — .rv-social/<channel>/ post folders (+ .rv-discord/)
12
- tasks: [], // repo commit-gate tasks; deps double as race guards
13
- checks: [], // periodic health, TTL-cached
14
- })
15
- `
16
-
17
- export async function main(_args: string[]): Promise<number> {
18
- const root = process.cwd()
19
- const configPath = join(root, 'repo.config.ts')
20
- if (existsSync(configPath)) { console.log(`skip: repo.config.ts already exists`); return 0 }
21
- const pkgPath = join(root, 'package.json')
22
- if (!existsSync(pkgPath)) { console.error('FAIL: no package.json here — run from the repo root'); return 1 }
23
- const pkgRaw = readFileSync(pkgPath, 'utf8')
24
- const pkg = JSON.parse(pkgRaw) as { name?: string; scripts?: Record<string, string> }
25
- writeFileSync(configPath, CONFIG_TEMPLATE(pkg.name ?? 'my-repo'))
26
- console.log('ok: repo.config.ts created')
27
- const indent = pkgRaw.match(/^(\s+)"/m)?.[1] ?? ' '
28
- pkg.scripts ??= {}
29
- let added = 0
30
- for (const [k, v] of [['ci', 'shipkit ci'], ['check', 'shipkit check'], ['contracts', 'shipkit contracts']] as const) {
31
- if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
32
- else if (pkg.scripts[k] !== v) console.log(`skip: package.json script '${k}' exists — left untouched`)
33
- }
34
- if (added > 0) {
35
- writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n')
36
- console.log(`ok: ${added} package.json script(s) added`)
37
- }
38
- console.log('next: fill venues/social in repo.config.ts, then `bun run ci`')
39
- console.log('next: identity+assets — `bun add -d rvlib-atelier`, mint .rv-identity/brand.ts + .rv-assets/kit.ts, `rvlib-atelier identity` (the identity contract expects public/ favicon+og+logo)')
40
- return 0
41
- }
@@ -1,74 +0,0 @@
1
- // shipkit wiki — md-wiki graph server + link lint (folded in from the
2
- // standalone mdwiki lib; that npm package is frozen).
3
- // shipkit wiki serve --root agent --port 4800 [--title t] [--index e.md] [--skip a,b]
4
- // shipkit wiki lint --root agent [--index e.md]
5
- import { basename, relative, resolve } from 'path'
6
- import { parseArgs } from 'util'
7
- import { lintMd } from '../wiki/lint-md.ts'
8
- import { scanMdGraph } from '../wiki/scan-md.ts'
9
- import { serveMdGraph } from '../wiki/serve-graph.ts'
10
-
11
- const USAGE = `usage: shipkit wiki <serve|lint> [--root <dir>] [--index <entry.md>] [--skip <dir,dir>]
12
- serve --port <n> (default 6161) --title <t> live link-graph server, reload = rescan
13
- lint fail on orphans + dangling refs (exit 1)
14
- --root defaults to cwd; default entrypoints: index.md + README.md (--index <e> = only that one)`
15
-
16
- export async function main(args: string[]): Promise<number> {
17
- let parsed: ReturnType<typeof parseArgs<{ options: { root: { type: 'string'; default: string }; port: { type: 'string'; default: string }; title: { type: 'string' }; index: { type: 'string' }; skip: { type: 'string' } }; allowPositionals: true }>>
18
- try {
19
- parsed = parseArgs({
20
- args,
21
- allowPositionals: true,
22
- options: {
23
- root: { type: 'string', default: '.' },
24
- port: { type: 'string', default: '6161' },
25
- title: { type: 'string' },
26
- index: { type: 'string' },
27
- skip: { type: 'string' },
28
- },
29
- })
30
- } catch (e) {
31
- console.log(`FAIL ${e instanceof Error ? e.message : e}\n${USAGE}`)
32
- return 2
33
- }
34
- const { values, positionals } = parsed
35
- const cmd = positionals[0]
36
- const root = resolve(process.cwd(), values.root)
37
- const scanCfg = { root, index: values.index, skipDirs: values.skip?.split(',').filter(Boolean) }
38
-
39
- if (cmd === 'serve') {
40
- const port = Number(values.port)
41
- if (!Number.isInteger(port) || port <= 0) { console.log(`FAIL --port '${values.port}' is not a port number`); return 2 }
42
- const rel = relative(process.cwd(), root)
43
- const title = values.title ?? (rel === '' ? basename(root) : `${basename(process.cwd())} ${rel}`)
44
- try {
45
- serveMdGraph({ port, title, load: () => scanMdGraph(scanCfg) })
46
- console.log(`wiki graph → http://localhost:${port} (open in browser, or VSCode "Simple Browser: Show")`)
47
- // keep the server alive — dispatch must not exit
48
- await new Promise(() => {})
49
- return 0
50
- } catch (e) {
51
- if (e instanceof Error && e.message.includes('in use')) {
52
- console.log(`wiki graph already served → http://localhost:${port} (another window owns it)`)
53
- return 0
54
- }
55
- throw e
56
- }
57
- }
58
- if (cmd === 'lint') {
59
- const r = lintMd(scanCfg)
60
- if (r.index == null) {
61
- console.log(values.index != null
62
- ? `FAIL entrypoint '${values.index}' not found in ${root}`
63
- : `FAIL no entrypoint: neither index.md nor README.md in ${root} — pass --index <entry.md>`)
64
- return 1
65
- }
66
- for (const f of r.orphans) console.log(`FAIL orphan: ${f} — unreachable from ${r.index}; link it or delete it`)
67
- for (const d of r.dangling) console.log(`FAIL dangling: ${d.from} → ${d.token} — target not found`)
68
- if (!r.ok) { console.log(`FAIL mdwiki lint: ${r.orphans.length} orphan(s), ${r.dangling.length} dangling ref(s) in ${root}`); return 1 }
69
- console.log(`ok mdwiki lint: ${r.files} files, all reachable from ${r.index}, no dangling refs`)
70
- return 0
71
- }
72
- console.log(USAGE)
73
- return 2
74
- }