rvlib-shipkit 0.1.1 → 0.3.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 (57) hide show
  1. package/CHANGELOG.md +137 -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 +179 -0
  11. package/src/assets/kits/identity-kit.ts +119 -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 +142 -0
  16. package/src/assets/server/studio-page.ts +620 -0
  17. package/src/assets/server/studio-queue.ts +58 -0
  18. package/src/assets/server/studio-server.ts +412 -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_Serve.ts +34 -0
  27. package/src/cli/cmd_Tui.ts +36 -0
  28. package/src/cli/cmd_Wiki.ts +53 -0
  29. package/src/cli.ts +44 -25
  30. package/src/contracts/run-contracts.ts +33 -0
  31. package/src/core/check-ctx.ts +3 -16
  32. package/src/core/check-suite.ts +16 -2
  33. package/src/core/discover-checks.ts +48 -0
  34. package/src/core/run-check.ts +73 -0
  35. package/src/core/run-ci.ts +54 -0
  36. package/src/core/spawn-timeout.ts +65 -0
  37. package/src/core/task-runner.ts +36 -43
  38. package/src/core/wire.ts +20 -0
  39. package/src/core/write-status.ts +70 -0
  40. package/src/init-repo.ts +60 -0
  41. package/src/repo-config.ts +82 -15
  42. package/src/serve/hub-pages.ts +84 -0
  43. package/src/serve/run-serve.ts +85 -0
  44. package/src/serve/serve-hub.ts +77 -0
  45. package/src/shipkit.ts +39 -3
  46. package/src/tui/checks-panel.ts +39 -0
  47. package/src/tui/tasks-panel.ts +54 -0
  48. package/src/tui/tui-app.tsx +91 -0
  49. package/src/tui/tui-panel.ts +57 -0
  50. package/src/tui/tui-store.ts +98 -0
  51. package/src/wiki/run-wiki.ts +50 -0
  52. package/src/cli/shipkit-asks.ts +0 -26
  53. package/src/cli/shipkit-check.ts +0 -26
  54. package/src/cli/shipkit-ci.ts +0 -30
  55. package/src/cli/shipkit-contracts.ts +0 -26
  56. package/src/cli/shipkit-init.ts +0 -41
  57. package/src/cli/shipkit-wiki.ts +0 -74
@@ -0,0 +1,120 @@
1
+ // shipkit assets — asset atelier: declare kinds in .rv-assets/kit.ts, roll
2
+ // (ComfyUI or procedural canvas), curate in the studio, ship with metadata
3
+ // write-through. Thin clipanion surfaces; logic in assets/run-assets.ts.
4
+ import { Command, Option } from 'clipanion'
5
+ import { runAssetsCheck, runAssetsGen, runAssetsIdentity, runAssetsServe, runAssetsTopUp, type AssetsIo } from '../assets/run-assets.ts'
6
+
7
+ const ioOf = (cmd: Command): AssetsIo => ({
8
+ print: (l) => cmd.context.stdout.write(`${l}\n`),
9
+ printErr: (l) => cmd.context.stderr.write(`${l}\n`),
10
+ })
11
+
12
+ export class AssetsServeCommand extends Command {
13
+ static override paths = [['assets', 'serve']]
14
+
15
+ static override usage = Command.Usage({
16
+ category: 'Assets',
17
+ description: 'the studio — roll, curate, pick, ship (127.0.0.1, kit port)',
18
+ examples: [['launch the studio for the cwd kit', 'shipkit assets serve']],
19
+ })
20
+
21
+ async execute(): Promise<number> {
22
+ return runAssetsServe(process.cwd(), ioOf(this))
23
+ }
24
+ }
25
+
26
+ export class AssetsFillCommand extends Command {
27
+ static override paths = [['assets', 'fill']]
28
+
29
+ static override usage = Command.Usage({
30
+ category: 'Assets',
31
+ description: 'top rolls up to N per asset (canvas or comfy), sparsest-first',
32
+ examples: [
33
+ ['every asset up to 8 rolls', 'shipkit assets fill 8'],
34
+ ['only two categories', 'shipkit assets fill 8 icons sprites'],
35
+ ],
36
+ })
37
+
38
+ n = Option.String({ required: true })
39
+ categories = Option.Rest()
40
+
41
+ async execute(): Promise<number> {
42
+ const n = Number(this.n)
43
+ if (!Number.isInteger(n) || n <= 0) {
44
+ this.context.stderr.write(`FAIL: '${this.n}' is not a positive roll count — usage: shipkit assets fill <N> [category…]\n`)
45
+ return 1
46
+ }
47
+ return runAssetsTopUp(process.cwd(), ioOf(this), { mode: 'fill', n, categories: this.categories })
48
+ }
49
+ }
50
+
51
+ export class AssetsAddCommand extends Command {
52
+ static override paths = [['assets', 'add']]
53
+
54
+ static override usage = Command.Usage({
55
+ category: 'Assets',
56
+ description: 'N more rolls per asset (canvas or comfy), regardless of what it has',
57
+ examples: [['3 fresh rolls everywhere', 'shipkit assets add 3']],
58
+ })
59
+
60
+ n = Option.String({ required: true })
61
+ categories = Option.Rest()
62
+
63
+ async execute(): Promise<number> {
64
+ const n = Number(this.n)
65
+ if (!Number.isInteger(n) || n <= 0) {
66
+ this.context.stderr.write(`FAIL: '${this.n}' is not a positive roll count — usage: shipkit assets add <N> [category…]\n`)
67
+ return 1
68
+ }
69
+ return runAssetsTopUp(process.cwd(), ioOf(this), { mode: 'add', n, categories: this.categories })
70
+ }
71
+ }
72
+
73
+ export class AssetsGenCommand extends Command {
74
+ static override paths = [['assets', 'gen']]
75
+
76
+ static override usage = Command.Usage({
77
+ category: 'Assets',
78
+ description: 'one-shot rolls for a single asset (canvas or comfy)',
79
+ examples: [
80
+ ['one roll', 'shipkit assets gen hero'],
81
+ ['five rolls', 'shipkit assets gen hero --n 5'],
82
+ ],
83
+ })
84
+
85
+ assetId = Option.String({ required: true })
86
+ n = Option.String('--n', '1', { description: 'how many rolls (default 1)' })
87
+
88
+ async execute(): Promise<number> {
89
+ const n = Math.max(1, Number(this.n) || 1)
90
+ return runAssetsGen(process.cwd(), ioOf(this), { assetId: this.assetId, n })
91
+ }
92
+ }
93
+
94
+ export class AssetsIdentityCommand extends Command {
95
+ static override paths = [['assets', 'identity']]
96
+
97
+ static override usage = Command.Usage({
98
+ category: 'Assets',
99
+ description: 'render favicon/logo/OG from .rv-identity/brand.ts into public/',
100
+ examples: [['regenerate the identity outputs', 'shipkit assets identity']],
101
+ })
102
+
103
+ async execute(): Promise<number> {
104
+ return runAssetsIdentity(process.cwd(), ioOf(this))
105
+ }
106
+ }
107
+
108
+ export class AssetsCheckCommand extends Command {
109
+ static override paths = [['assets', 'check']]
110
+
111
+ static override usage = Command.Usage({
112
+ category: 'Assets',
113
+ description: 'kit loads, kinds valid, workflows bound, backend reachable, identity outputs present',
114
+ examples: [['health-check the asset pipeline', 'shipkit assets check']],
115
+ })
116
+
117
+ async execute(): Promise<number> {
118
+ return runAssetsCheck(process.cwd(), ioOf(this))
119
+ }
120
+ }
@@ -0,0 +1,30 @@
1
+ // shipkit check — periodic health, TTL-cached. Thin clipanion surface;
2
+ // logic in core/run-check.ts.
3
+ import { Command, Option } from 'clipanion'
4
+ import { runCheck } from '../core/run-check.ts'
5
+
6
+ export class CheckCommand extends Command {
7
+ static override paths = [['check']]
8
+
9
+ static override usage = Command.Usage({
10
+ category: 'Health',
11
+ description: 'periodic health: contracts + every *.check.ts in the repo (auto-discovered, run as subprocesses), clean passes replay from a TTL cache',
12
+ examples: [
13
+ ['run (cached where valid)', 'shipkit check'],
14
+ ['bust every cache layer', 'shipkit check --fresh'],
15
+ ['machine-readable', 'shipkit check --json'],
16
+ ],
17
+ })
18
+
19
+ json = Option.Boolean('--json', false, { description: 'machine-readable result instead of wire lines' })
20
+ fresh = Option.Boolean('--fresh', false, { description: 'bust every TTL cache layer (travels to child checks)' })
21
+
22
+ async execute(): Promise<number> {
23
+ try {
24
+ return await runCheck(process.cwd(), { json: this.json, fresh: this.fresh, print: (l) => this.context.stdout.write(`${l}\n`) })
25
+ } catch (e) {
26
+ this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
27
+ return 1
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,41 @@
1
+ // shipkit ci — the commit/deploy gate. Thin clipanion surface; logic in
2
+ // core/run-ci.ts.
3
+ import { Command, Option } from 'clipanion'
4
+ import { runCi } from '../core/run-ci.ts'
5
+
6
+ export class CiCommand extends Command {
7
+ static override paths = [['ci']]
8
+
9
+ static override usage = Command.Usage({
10
+ category: 'Gate',
11
+ description: 'commit gate: floor + contracts + repo tasks through the DAG runner',
12
+ examples: [
13
+ ['full gate', 'shipkit ci'],
14
+ ['stop at the first failure', 'shipkit ci --bail'],
15
+ ['one task + its dep closure', 'shipkit ci --only typecheck'],
16
+ ['include an opt-in task', 'shipkit ci --with up:itch'],
17
+ ],
18
+ })
19
+
20
+ bail = Option.Boolean('--bail', false, { description: 'stop at the first failure' })
21
+ json = Option.Boolean('--json', false, { description: 'machine-readable result instead of wire lines' })
22
+ skip = Option.Array('--skip', [], { description: 'skip a task group (repeatable)' })
23
+ withIds = Option.Array('--with', [], { description: 'include an opt-in task (repeatable)' })
24
+ only = Option.Array('--only', [], { description: 'run only this task + its dep closure (repeatable)' })
25
+
26
+ async execute(): Promise<number> {
27
+ try {
28
+ return await runCi(process.cwd(), {
29
+ bail: this.bail,
30
+ json: this.json,
31
+ skipGroups: this.skip,
32
+ withIds: this.withIds,
33
+ onlyIds: this.only,
34
+ print: (l) => this.context.stdout.write(`${l}\n`),
35
+ })
36
+ } catch (e) {
37
+ this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
38
+ return 1
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,35 @@
1
+ // shipkit contracts — venue fulfillness alone. Thin clipanion surface;
2
+ // logic in contracts/run-contracts.ts.
3
+ import { Command, Option } from 'clipanion'
4
+ import { runContracts } from '../contracts/run-contracts.ts'
5
+
6
+ export class ContractsCommand extends Command {
7
+ static override paths = [['contracts']]
8
+
9
+ static override usage = Command.Usage({
10
+ category: 'Gate',
11
+ description: 'release-contract fulfillness, fast and filesystem-only — deploy tasks dep on it',
12
+ examples: [
13
+ ['all applicable contracts', 'shipkit contracts'],
14
+ ['one venue', 'shipkit contracts --venue itch'],
15
+ ['machine-readable', 'shipkit contracts --json'],
16
+ ],
17
+ })
18
+
19
+ venue = Option.String('--venue', { required: false, description: 'check one venue only (itch, steam, …)' })
20
+ json = Option.Boolean('--json', false, { description: 'machine-readable result instead of wire lines' })
21
+
22
+ async execute(): Promise<number> {
23
+ try {
24
+ return await runContracts(process.cwd(), {
25
+ venue: this.venue,
26
+ json: this.json,
27
+ print: (l) => this.context.stdout.write(`${l}\n`),
28
+ printErr: (l) => this.context.stderr.write(`${l}\n`),
29
+ })
30
+ } catch (e) {
31
+ this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
32
+ return 1
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,28 @@
1
+ // shipkit init — scaffold repo.config.ts + package.json scripts. Thin
2
+ // clipanion surface; logic in init-repo.ts.
3
+ import { Command, Option } from 'clipanion'
4
+ import { initRepo } from '../init-repo.ts'
5
+
6
+ export class InitCommand extends Command {
7
+ static override paths = [['init']]
8
+
9
+ static override usage = Command.Usage({
10
+ category: 'Setup',
11
+ description: 'scaffold repo.config.ts + ci/check/contracts package.json scripts, then print how the floor resolved',
12
+ examples: [
13
+ ['bootstrap the current repo', 'shipkit init'],
14
+ ['in-repo-source consumer: scripts run the CLI by path', 'shipkit init --bin wiki/build/shipkit/rvlib-shipkit/src/cli.ts'],
15
+ ],
16
+ })
17
+
18
+ bin = Option.String('--bin', { description: 'path to shipkit cli.ts — scripts become `bun <path> <cmd>` instead of `shipkit <cmd>`' })
19
+
20
+ async execute(): Promise<number> {
21
+ try {
22
+ return await initRepo(process.cwd(), { bin: this.bin })
23
+ } catch (e) {
24
+ this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
25
+ return 1
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,34 @@
1
+ // shipkit serve — the ONE hub server per repo (one vscode folder-open task,
2
+ // clickable URLs). Thin clipanion surface; logic in serve/run-serve.ts.
3
+ import { Command, Option } from 'clipanion'
4
+ import { runServe } from '../serve/run-serve.ts'
5
+
6
+ export class ServeCommand extends Command {
7
+ static override paths = [['serve']]
8
+
9
+ static override usage = Command.Usage({
10
+ category: 'Serve',
11
+ description: 'ONE hub server per repo: landing page linking the wiki/agent graphs, the assets studio, and live status',
12
+ examples: [
13
+ ['serve the repo toolbox', 'shipkit serve'],
14
+ ['pin the port for this launch', 'shipkit serve --port 4804'],
15
+ ],
16
+ })
17
+
18
+ port = Option.String('--port', { required: false, description: 'hub port (default: repo.config serve.port, else hashed from the repo name into 4800-4979)' })
19
+
20
+ async execute(): Promise<number> {
21
+ let port: number | undefined
22
+ if (this.port != null) {
23
+ port = Number(this.port)
24
+ if (!Number.isInteger(port) || port <= 0) {
25
+ this.context.stderr.write(`FAIL --port '${this.port}' is not a port number\n`)
26
+ return 1
27
+ }
28
+ }
29
+ return runServe(process.cwd(), {
30
+ print: (l) => this.context.stdout.write(`${l}\n`),
31
+ printErr: (l) => this.context.stderr.write(`${l}\n`),
32
+ }, { port })
33
+ }
34
+ }
@@ -0,0 +1,36 @@
1
+ // shipkit tui — the live dashboard: Tasks (the ci gate, streamed through
2
+ // the DAG runner) + Checks (contracts + discovered *.check.ts) built in,
3
+ // repo extras injected via repo.config `tui: { panels: [...] }`.
4
+ import React from 'react'
5
+ import { render } from 'ink'
6
+ import { configure } from 'mobx'
7
+ import { Command } from 'clipanion'
8
+ import { loadRepoConfig } from '../repo-config.ts'
9
+ import { checksPanel } from '../tui/checks-panel.ts'
10
+ import { tasksPanel } from '../tui/tasks-panel.ts'
11
+ import { TuiApp } from '../tui/tui-app.tsx'
12
+ import { TuiStore } from '../tui/tui-store.ts'
13
+
14
+ export class TuiCommand extends Command {
15
+ static override paths = [['tui']]
16
+
17
+ static override usage = Command.Usage({
18
+ category: 'Health',
19
+ description: 'live dashboard: ci tasks + discovered checks, plus panels the repo injects via repo.config tui.panels',
20
+ examples: [['open the dashboard', 'shipkit tui']],
21
+ })
22
+
23
+ async execute(): Promise<number> {
24
+ const root = process.cwd()
25
+ const c = await loadRepoConfig(root)
26
+ // playbook (build/mobx): enforceActions warns on stray mutations — the
27
+ // only signal. Read-warning flags stay off: useInput handler reads are
28
+ // legitimately untracked and would false-positive.
29
+ configure({ enforceActions: 'always' })
30
+ const store = new TuiStore(c.name, [tasksPanel(root, c), checksPanel(root), ...(c.tui?.panels ?? [])])
31
+ store.refresh() // instant static paint; results stream in
32
+ const app = render(React.createElement(TuiApp, { store }))
33
+ await app.waitUntilExit()
34
+ return 0
35
+ }
36
+ }
@@ -0,0 +1,53 @@
1
+ // shipkit wiki — md link-graph server + orphan/dangling lint. Two thin
2
+ // commands over wiki/run-wiki.ts.
3
+ import { Command, Option } from 'clipanion'
4
+ import { runWikiLint, runWikiServe } from '../wiki/run-wiki.ts'
5
+
6
+ export class WikiServeCommand extends Command {
7
+ static override paths = [['wiki', 'serve']]
8
+
9
+ static override usage = Command.Usage({
10
+ category: 'Wiki',
11
+ description: 'live md link-graph server — reload = rescan',
12
+ examples: [
13
+ ['serve the cwd wiki', 'shipkit wiki serve'],
14
+ ['serve a subtree on a port', 'shipkit wiki serve --root agent --index process.md --port 6162'],
15
+ ],
16
+ })
17
+
18
+ root = Option.String('--root', '.', { description: 'wiki root directory' })
19
+ port = Option.String('--port', '6161', { description: 'server port' })
20
+ title = Option.String('--title', { required: false, description: 'page title shown in the graph header' })
21
+ index = Option.String('--index', { required: false, description: 'entrypoint page (default: index.md AND README.md)' })
22
+ skip = Option.String('--skip', { required: false, description: 'comma-separated dir names to exclude from the scan' })
23
+
24
+ async execute(): Promise<number> {
25
+ const port = Number(this.port)
26
+ if (!Number.isInteger(port) || port <= 0) {
27
+ this.context.stderr.write(`FAIL --port '${this.port}' is not a port number\n`)
28
+ return 1
29
+ }
30
+ return runWikiServe({ root: this.root, index: this.index, skip: this.skip, port, title: this.title })
31
+ }
32
+ }
33
+
34
+ export class WikiLintCommand extends Command {
35
+ static override paths = [['wiki', 'lint']]
36
+
37
+ static override usage = Command.Usage({
38
+ category: 'Wiki',
39
+ description: 'fail on orphan pages and dangling refs (exit 1)',
40
+ examples: [
41
+ ['lint the cwd wiki', 'shipkit wiki lint'],
42
+ ['lint a subtree', 'shipkit wiki lint --root agent --index process.md'],
43
+ ],
44
+ })
45
+
46
+ root = Option.String('--root', '.', { description: 'wiki root directory' })
47
+ index = Option.String('--index', { required: false, description: 'entrypoint page (default: index.md AND README.md)' })
48
+ skip = Option.String('--skip', { required: false, description: 'comma-separated dir names to exclude from the scan' })
49
+
50
+ async execute(): Promise<number> {
51
+ return runWikiLint({ root: this.root, index: this.index, skip: this.skip })
52
+ }
53
+ }
package/src/cli.ts CHANGED
@@ -1,28 +1,47 @@
1
1
  #!/usr/bin/env bun
2
- // shipkit — dispatch only; one command per file in cli/.
3
- const cmd = process.argv[2]
2
+ // shipkit — clipanion entry: one command per file in cli/ (cmd_<Name>.ts),
3
+ // logic in plain modules the commands call. runExit sets the exit code
4
+ // without force-exiting, so serve commands keep the process alive.
5
+ import { readFileSync } from 'fs'
6
+ import { Builtins, Cli } from 'clipanion'
7
+ import { AskNewCommand, AsksCheckCommand, AsksCommand } from './cli/cmd_Asks.ts'
8
+ import { AssetsAddCommand, AssetsCheckCommand, AssetsFillCommand, AssetsGenCommand, AssetsIdentityCommand, AssetsServeCommand } from './cli/cmd_Assets.ts'
9
+ import { CheckCommand } from './cli/cmd_Check.ts'
10
+ import { CiCommand } from './cli/cmd_Ci.ts'
11
+ import { ContractsCommand } from './cli/cmd_Contracts.ts'
12
+ import { InitCommand } from './cli/cmd_Init.ts'
13
+ import { ServeCommand } from './cli/cmd_Serve.ts'
14
+ import { TuiCommand } from './cli/cmd_Tui.ts'
15
+ import { WikiLintCommand, WikiServeCommand } from './cli/cmd_Wiki.ts'
4
16
 
5
- const run = async (): Promise<number> => {
6
- const args = process.argv.slice(3)
7
- switch (cmd) {
8
- case 'ci': return (await import('./cli/shipkit-ci.ts')).main(args)
9
- case 'check': return (await import('./cli/shipkit-check.ts')).main(args)
10
- case 'contracts': return (await import('./cli/shipkit-contracts.ts')).main(args)
11
- case 'asks': return (await import('./cli/shipkit-asks.ts')).main(args)
12
- case 'ask': return (await import('./cli/shipkit-asks.ts')).main(['new', ...args])
13
- case 'wiki': return (await import('./cli/shipkit-wiki.ts')).main(args)
14
- case 'init': return (await import('./cli/shipkit-init.ts')).main(args)
15
- default:
16
- console.log(`shipkit — repo toolkit
17
- usage: shipkit <command>
18
- ci commit gate: floor + contracts + repo tasks [--bail --skip <group> --with <id> --only <id...> --json]
19
- check periodic health: contracts + repo checks, TTL'd [--json --fresh]
20
- contracts contract fulfillness alone [--venue <v> --json]
21
- asks list open asks · asks check · ask "<title>" [--kind --blocks --check "<cmd>"]
22
- wiki serve|lint — md-wiki graph + link hygiene
23
- init scaffold repo.config.ts + package.json scripts`)
24
- return 2
25
- }
26
- }
17
+ const version = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version
27
18
 
28
- process.exit(await run())
19
+ const cli = new Cli({
20
+ binaryLabel: 'shipkit — repo toolkit',
21
+ binaryName: 'shipkit',
22
+ binaryVersion: version,
23
+ enableCapture: true, // console output pipes into the command streams (testable)
24
+ enableColors: true,
25
+ })
26
+
27
+ cli.register(Builtins.HelpCommand)
28
+ cli.register(Builtins.VersionCommand)
29
+ cli.register(CiCommand)
30
+ cli.register(CheckCommand)
31
+ cli.register(TuiCommand)
32
+ cli.register(ServeCommand)
33
+ cli.register(ContractsCommand)
34
+ cli.register(AsksCommand)
35
+ cli.register(AsksCheckCommand)
36
+ cli.register(AskNewCommand)
37
+ cli.register(WikiServeCommand)
38
+ cli.register(WikiLintCommand)
39
+ cli.register(AssetsServeCommand)
40
+ cli.register(AssetsFillCommand)
41
+ cli.register(AssetsAddCommand)
42
+ cli.register(AssetsGenCommand)
43
+ cli.register(AssetsIdentityCommand)
44
+ cli.register(AssetsCheckCommand)
45
+ cli.register(InitCommand)
46
+
47
+ cli.runExit(process.argv.slice(2))
@@ -0,0 +1,33 @@
1
+ // contract fulfillness alone, fast and filesystem-only. Plain module
2
+ // behind cmd_Contracts; venue deploy scripts run it before any upload.
3
+ import { activeContracts, contractsConfigFrom, loadRepoConfig } from '../repo-config.ts'
4
+ import { evaluateContracts } from './contracts-engine.ts'
5
+ import { formatLine } from '../core/wire.ts'
6
+
7
+ export type ContractsOpts = {
8
+ venue?: string
9
+ json?: boolean
10
+ /** line sinks — commands pass their context streams; default to console */
11
+ print?: (line: string) => void
12
+ printErr?: (line: string) => void
13
+ }
14
+
15
+ export async function runContracts(root: string, o: ContractsOpts = {}): Promise<number> {
16
+ const print = o.print ?? ((l: string) => console.log(l))
17
+ const printErr = o.printErr ?? ((l: string) => console.error(l))
18
+ const c = await loadRepoConfig(root)
19
+ let manifests = activeContracts(c)
20
+ if (o.venue != null) {
21
+ manifests = manifests.filter((m) => m.venue === o.venue)
22
+ if (manifests.length === 0) { printErr(`FAIL: unknown venue '${o.venue}'`); return 1 }
23
+ }
24
+ const r = await evaluateContracts(manifests, contractsConfigFrom(c, root))
25
+ if (o.json === true) {
26
+ print(JSON.stringify({ ok: r.exitCode === 0, outcomes: r.outcomes }))
27
+ return r.exitCode
28
+ }
29
+ const color = process.stdout.isTTY && process.env.NO_COLOR == null
30
+ for (const l of r.lines) print(formatLine(l, color))
31
+ if (r.lines.length === 0) print('ok: no contracts apply (nothing declared, nothing detected)')
32
+ return r.exitCode
33
+ }
@@ -1,6 +1,6 @@
1
1
  // CheckCtx — the per-check handle a probe's logic receives: outcome lines
2
2
  // (wire.ts kinds), guarded fetch/exec helpers, resolved-tool lookup.
3
- import { existsSync } from 'fs'
3
+ import { spawnWithTimeout } from './spawn-timeout.ts'
4
4
  import type { Line } from './wire.ts'
5
5
 
6
6
  // thrown by t.fetch on 429 — the suite converts it to WARN, never FAIL
@@ -43,21 +43,8 @@ export class CheckCtx {
43
43
  }
44
44
 
45
45
  // spawn without the Bun.spawn throw-on-missing-binary gotcha; SIGTERM at
46
- // timeoutMs, SIGKILL 2s later (a child ignoring SIGTERM must not hang us)
46
+ // timeoutMs, SIGKILL 2s later (one code path: core/spawn-timeout.ts)
47
47
  async exec(cmd: string[], opts?: { env?: Record<string, string | undefined>; cwd?: string; timeoutMs?: number }): Promise<{ code: number; out: string; timedOut: boolean }> {
48
- const head = cmd[0]
49
- if (head == null) return { code: -1, out: 'empty command', timedOut: false }
50
- const bin = head.includes('/') ? head : Bun.which(head)
51
- if (bin == null || !existsSync(bin)) return { code: -1, out: `${head}: not found`, timedOut: false }
52
- const p = Bun.spawn([bin, ...cmd.slice(1)], { stdout: 'pipe', stderr: 'pipe', env: opts?.env as Record<string, string>, cwd: opts?.cwd })
53
- let timedOut = false
54
- const ms = opts?.timeoutMs ?? 15_000
55
- const timer = setTimeout(() => { timedOut = true; p.kill() }, ms)
56
- const killer = setTimeout(() => p.kill('SIGKILL'), ms + 2000)
57
- const out = (await new Response(p.stdout).text()) + (await new Response(p.stderr).text())
58
- const code = await p.exited
59
- clearTimeout(timer)
60
- clearTimeout(killer)
61
- return { code, out, timedOut }
48
+ return spawnWithTimeout(cmd, { timeoutMs: opts?.timeoutMs ?? 15_000, env: opts?.env, cwd: opts?.cwd })
62
49
  }
63
50
  }
@@ -27,6 +27,8 @@ export type SecretSpec = {
27
27
  skipHint: string
28
28
  }
29
29
 
30
+ export type CheckOutcome = { lines: Line[]; status: 'pass' | 'fail' | 'warn' | 'skip'; cached: boolean }
31
+
30
32
  export type CheckSuiteOpts = {
31
33
  name: string
32
34
  cacheDir: string
@@ -35,6 +37,9 @@ export type CheckSuiteOpts = {
35
37
  getSecret?: GetSecret
36
38
  /** env var that busts the cache when set (consumer names it) */
37
39
  freshEnv?: string
40
+ /** per-check completion tap (the TUI streams results in) — cached replays
41
+ * report cached:true, prep-skips (unstored secret) report status 'skip' */
42
+ onResult?: (name: string, r: CheckOutcome) => void
38
43
  }
39
44
 
40
45
  export class CheckSuite {
@@ -45,6 +50,7 @@ export class CheckSuite {
45
50
  private now: () => number
46
51
  private getSecret: GetSecret
47
52
  private freshEnv: string
53
+ private onResult?: (name: string, r: CheckOutcome) => void
48
54
 
49
55
  constructor(opts: CheckSuiteOpts) {
50
56
  this.name = opts.name
@@ -52,6 +58,7 @@ export class CheckSuite {
52
58
  this.now = opts.now ?? Date.now
53
59
  this.getSecret = opts.getSecret ?? (() => null)
54
60
  this.freshEnv = opts.freshEnv ?? 'SHIPKIT_FRESH'
61
+ this.onResult = opts.onResult
55
62
  }
56
63
 
57
64
  addCheck(spec: { name: string; remainValidFor?: string | number; version?: number; logic: Logic }): void {
@@ -124,7 +131,11 @@ export class CheckSuite {
124
131
 
125
132
  for (const r of this.runners) {
126
133
  const prep = r.prep?.() ?? {}
127
- if (prep.skip != null) { skipped.push(prep.skip); continue }
134
+ if (prep.skip != null) {
135
+ skipped.push(prep.skip)
136
+ this.onResult?.(r.name, { lines: [{ kind: 'skip', text: prep.skip }], status: 'skip', cached: false })
137
+ continue
138
+ }
128
139
  ran++
129
140
  const key = prep.cacheExtra ?? ''
130
141
  const entry = cache[r.name]
@@ -133,7 +144,9 @@ export class CheckSuite {
133
144
  && this.now() - entry.at < r.ttlMs && this.now() >= entry.at
134
145
  if (fresh) {
135
146
  const age = fmtAge(this.now() - entry.at)
136
- out.push(...entry.lines.map((l, i): Line => (i === 0 ? { ...l, text: `${l.text} (cached ${age} ago)` } : l)))
147
+ const lines = entry.lines.map((l, i): Line => (i === 0 ? { ...l, text: `${l.text} (cached ${age} ago)` } : l))
148
+ out.push(...lines)
149
+ this.onResult?.(r.name, { lines, status: 'pass', cached: true }) // only clean passes are cached
137
150
  continue
138
151
  }
139
152
  const t = new CheckCtx(this.toolPaths)
@@ -145,6 +158,7 @@ export class CheckSuite {
145
158
  else t.fail(`${r.name} errored (${String(e).slice(0, 200)}) — network down, or a bug in this check`)
146
159
  }
147
160
  out.push(...t.lines)
161
+ this.onResult?.(r.name, { lines: t.lines, status: t.failed ? 'fail' : t.warned ? 'warn' : 'pass', cached: false })
148
162
  if (t.failed) anyFail = true
149
163
  if (r.ttlMs > 0) {
150
164
  if (!t.failed && !t.warned) { cache[r.name] = { version: r.version, key, at: this.now(), lines: t.lines }; cacheDirty = true }
@@ -0,0 +1,48 @@
1
+ // *.check.ts auto-discovery — THE way health checks register (repo.config
2
+ // `checks:[]` is dead, one-runner GO 2026-07-22): drop a `<name>.check.ts`
3
+ // anywhere in the repo and `shipkit check` runs it as a subprocess — wire
4
+ // output parsed back, exit code is truth. Skipped dirs: node_modules, dist,
5
+ // tmp, references (templates for other repos), and every dot-dir (.git,
6
+ // caches, .rv-* contract folders).
7
+
8
+ import { readdirSync, readFileSync } from 'fs'
9
+ import { basename, join, relative } from 'path'
10
+
11
+ const IGNORED_DIRS = new Set(['node_modules', 'dist', 'tmp', 'references'])
12
+ const SUFFIX = '.check.ts'
13
+
14
+ export type DiscoveredCheck = {
15
+ /** absolute path to the check file */
16
+ path: string
17
+ /** display + cache name — file base, disambiguated by relative path on collision */
18
+ name: string
19
+ /** file runs its own CheckSuite (own TTL cache) — the orchestrator must not double-cache it */
20
+ selfCached: boolean
21
+ }
22
+
23
+ export function discoverChecks(root: string): DiscoveredCheck[] {
24
+ const files: string[] = []
25
+ const walk = (dir: string): void => {
26
+ let entries
27
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
28
+ for (const e of entries) {
29
+ if (e.isDirectory()) {
30
+ if (!IGNORED_DIRS.has(e.name) && !e.name.startsWith('.')) walk(join(dir, e.name))
31
+ } else if (e.name.endsWith(SUFFIX)) files.push(join(dir, e.name))
32
+ }
33
+ }
34
+ walk(root)
35
+ files.sort()
36
+ const baseOf = (f: string): string => basename(f).slice(0, -SUFFIX.length)
37
+ const seen = new Map<string, number>()
38
+ for (const f of files) seen.set(baseOf(f), (seen.get(baseOf(f)) ?? 0) + 1)
39
+ return files.map((f) => {
40
+ const base = baseOf(f)
41
+ const name = (seen.get(base) ?? 0) > 1 ? relative(root, f).slice(0, -SUFFIX.length) : base
42
+ // a check built on CheckSuite/SkillValidator replays from its OWN
43
+ // TTL cache — caching it again up here would double the staleness
44
+ let selfCached = false
45
+ try { selfCached = /SkillValidator|CheckSuite/.test(readFileSync(f, 'utf8')) } catch {}
46
+ return { path: f, name, selfCached }
47
+ })
48
+ }