rvlib-shipkit 0.1.0 → 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.
- package/CHANGELOG.md +82 -0
- package/README.md +23 -8
- package/package.json +29 -13
- package/src/asks/asks-queue.ts +20 -7
- package/src/assets/gen/comfy-gen.ts +106 -0
- package/src/assets/gen/generate-roll.ts +57 -0
- package/src/assets/gen/workflow-loader.ts +67 -0
- package/src/assets/image-ops.ts +30 -0
- package/src/assets/json-store.ts +27 -0
- package/src/assets/kit-config.ts +161 -0
- package/src/assets/kits/identity-kit.ts +80 -0
- package/src/assets/native-deps.ts +18 -0
- package/src/assets/panels/geometry-rects.ts +84 -0
- package/src/assets/roll-workspace.ts +41 -0
- package/src/assets/run-assets.ts +122 -0
- package/src/assets/server/studio-page.ts +587 -0
- package/src/assets/server/studio-queue.ts +58 -0
- package/src/assets/server/studio-server.ts +360 -0
- package/src/assets/vite.ts +62 -0
- package/src/cli/cmd_Asks.ts +83 -0
- package/src/cli/cmd_Assets.ts +120 -0
- package/src/cli/cmd_Check.ts +30 -0
- package/src/cli/cmd_Ci.ts +41 -0
- package/src/cli/cmd_Contracts.ts +35 -0
- package/src/cli/cmd_Init.ts +28 -0
- package/src/cli/cmd_Tui.ts +36 -0
- package/src/cli/cmd_Wiki.ts +53 -0
- package/src/cli.ts +42 -25
- package/src/contracts/run-contracts.ts +33 -0
- package/src/core/check-ctx.ts +3 -16
- package/src/core/check-suite.ts +16 -2
- package/src/core/discover-checks.ts +48 -0
- package/src/core/run-check.ts +73 -0
- package/src/core/run-ci.ts +54 -0
- package/src/core/spawn-timeout.ts +65 -0
- package/src/core/task-runner.ts +36 -43
- package/src/core/wire.ts +20 -0
- package/src/core/write-status.ts +70 -0
- package/src/init-repo.ts +60 -0
- package/src/repo-config.ts +68 -16
- package/src/shipkit.ts +34 -3
- package/src/tui/checks-panel.ts +39 -0
- package/src/tui/tasks-panel.ts +54 -0
- package/src/tui/tui-app.tsx +91 -0
- package/src/tui/tui-panel.ts +57 -0
- package/src/tui/tui-store.ts +98 -0
- package/src/wiki/run-wiki.ts +50 -0
- package/src/cli/shipkit-asks.ts +0 -26
- package/src/cli/shipkit-check.ts +0 -26
- package/src/cli/shipkit-ci.ts +0 -30
- package/src/cli/shipkit-contracts.ts +0 -26
- package/src/cli/shipkit-init.ts +0 -40
- package/src/cli/shipkit-wiki.ts +0 -74
|
@@ -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,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,45 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
// shipkit —
|
|
3
|
-
|
|
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 { TuiCommand } from './cli/cmd_Tui.ts'
|
|
14
|
+
import { WikiLintCommand, WikiServeCommand } from './cli/cmd_Wiki.ts'
|
|
4
15
|
|
|
5
|
-
const
|
|
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
|
-
}
|
|
16
|
+
const version = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version
|
|
27
17
|
|
|
28
|
-
|
|
18
|
+
const cli = new Cli({
|
|
19
|
+
binaryLabel: 'shipkit — repo toolkit',
|
|
20
|
+
binaryName: 'shipkit',
|
|
21
|
+
binaryVersion: version,
|
|
22
|
+
enableCapture: true, // console output pipes into the command streams (testable)
|
|
23
|
+
enableColors: true,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
cli.register(Builtins.HelpCommand)
|
|
27
|
+
cli.register(Builtins.VersionCommand)
|
|
28
|
+
cli.register(CiCommand)
|
|
29
|
+
cli.register(CheckCommand)
|
|
30
|
+
cli.register(TuiCommand)
|
|
31
|
+
cli.register(ContractsCommand)
|
|
32
|
+
cli.register(AsksCommand)
|
|
33
|
+
cli.register(AsksCheckCommand)
|
|
34
|
+
cli.register(AskNewCommand)
|
|
35
|
+
cli.register(WikiServeCommand)
|
|
36
|
+
cli.register(WikiLintCommand)
|
|
37
|
+
cli.register(AssetsServeCommand)
|
|
38
|
+
cli.register(AssetsFillCommand)
|
|
39
|
+
cli.register(AssetsAddCommand)
|
|
40
|
+
cli.register(AssetsGenCommand)
|
|
41
|
+
cli.register(AssetsIdentityCommand)
|
|
42
|
+
cli.register(AssetsCheckCommand)
|
|
43
|
+
cli.register(InitCommand)
|
|
44
|
+
|
|
45
|
+
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
|
+
}
|
package/src/core/check-ctx.ts
CHANGED
|
@@ -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 {
|
|
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 (
|
|
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
|
-
|
|
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
|
}
|
package/src/core/check-suite.ts
CHANGED
|
@@ -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) {
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// periodic health — contracts + every *.check.ts in the repo. Discovery IS
|
|
2
|
+
// the registration (core/discover-checks.ts); each check runs as a
|
|
3
|
+
// subprocess: wire lines parsed back, exit code is truth. A check that runs
|
|
4
|
+
// its own CheckSuite keeps its own TTL cache and is never double-cached
|
|
5
|
+
// here; plain checks get a short orchestrator TTL. `--fresh` busts both
|
|
6
|
+
// layers (SHIPKIT_FRESH=1 + RV_CHECK_FRESH=1 travel to children).
|
|
7
|
+
import { join, relative } from 'path'
|
|
8
|
+
import { CheckSuite, type CheckOutcome } from './check-suite.ts'
|
|
9
|
+
import { discoverChecks } from './discover-checks.ts'
|
|
10
|
+
import { spawnWithTimeout } from './spawn-timeout.ts'
|
|
11
|
+
import { activeContracts, applyContractsResult, contractsConfigFrom, loadRepoConfig } from '../repo-config.ts'
|
|
12
|
+
import { evaluateContracts } from '../contracts/contracts-engine.ts'
|
|
13
|
+
import { formatLine, parseWireOutput } from './wire.ts'
|
|
14
|
+
import { writeStatus } from './write-status.ts'
|
|
15
|
+
|
|
16
|
+
// orchestrator cache for checks WITHOUT their own suite — short on purpose:
|
|
17
|
+
// this layer only absorbs back-to-back runs, real TTLs live in the checks
|
|
18
|
+
const DISCOVERED_TTL = '30m'
|
|
19
|
+
const CHECK_TIMEOUT_MS = 120_000
|
|
20
|
+
|
|
21
|
+
export type CheckOpts = {
|
|
22
|
+
json?: boolean
|
|
23
|
+
fresh?: boolean
|
|
24
|
+
/** refresh STATUS.md after the run (default true — O4: every repo gets it) */
|
|
25
|
+
status?: boolean
|
|
26
|
+
/** line sink — commands pass their context stream; defaults to console */
|
|
27
|
+
print?: (line: string) => void
|
|
28
|
+
/** per-check completion tap — the TUI checks panel streams results in */
|
|
29
|
+
onResult?: (name: string, r: CheckOutcome) => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function runCheck(root: string, o: CheckOpts = {}): Promise<number> {
|
|
33
|
+
const print = o.print ?? ((l: string) => console.log(l))
|
|
34
|
+
const c = await loadRepoConfig(root)
|
|
35
|
+
if (o.fresh === true) process.env.SHIPKIT_FRESH = '1'
|
|
36
|
+
const suite = new CheckSuite({ name: c.name, cacheDir: join(root, 'tmp/.shipkit-cache'), onResult: o.onResult })
|
|
37
|
+
suite.addCheck({
|
|
38
|
+
name: 'contracts',
|
|
39
|
+
logic: async (t) => {
|
|
40
|
+
applyContractsResult(t, await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root)))
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
const childEnv: Record<string, string | undefined> = {
|
|
44
|
+
...process.env,
|
|
45
|
+
...(o.fresh === true ? { SHIPKIT_FRESH: '1', RV_CHECK_FRESH: '1' } : {}),
|
|
46
|
+
}
|
|
47
|
+
for (const d of discoverChecks(root)) {
|
|
48
|
+
const rel = relative(root, d.path)
|
|
49
|
+
suite.addCheck({
|
|
50
|
+
name: d.name,
|
|
51
|
+
remainValidFor: d.selfCached ? undefined : DISCOVERED_TTL,
|
|
52
|
+
logic: async (t) => {
|
|
53
|
+
const r = await spawnWithTimeout(['bun', d.path], { cwd: root, timeoutMs: CHECK_TIMEOUT_MS, env: childEnv })
|
|
54
|
+
for (const l of parseWireOutput(r.out)) t.lines.push(l)
|
|
55
|
+
if (t.lines.some((l) => l.kind === 'warn')) t.warned = true
|
|
56
|
+
if (t.lines.some((l) => l.kind === 'fail')) t.failed = true
|
|
57
|
+
// exit code is truth — a non-zero exit with no FAIL line
|
|
58
|
+
// (crash, non-wire output, timeout) must still fail loud
|
|
59
|
+
if (r.code !== 0 && !t.failed)
|
|
60
|
+
t.fail(`${d.name} exited ${r.code}${r.timedOut ? ` (timeout after ${CHECK_TIMEOUT_MS}ms)` : ''} — ${r.out.trim().split('\n').at(-1)?.slice(0, 160) ?? ''}`, `bun ${rel}`)
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
const r = await suite.execute()
|
|
65
|
+
const statusOutcome = o.status !== false
|
|
66
|
+
? writeStatus(root, { name: c.name, command: 'shipkit check', lines: r.lines.map((l) => formatLine(l, false)), exitCode: r.exitCode })
|
|
67
|
+
: null
|
|
68
|
+
if (o.json === true) { print(JSON.stringify(r)); return r.exitCode }
|
|
69
|
+
const color = process.stdout.isTTY && process.env.NO_COLOR == null
|
|
70
|
+
for (const l of r.lines) print(formatLine(l, color))
|
|
71
|
+
if (statusOutcome != null && !statusOutcome.written) print(`WARN ${statusOutcome.warn}`)
|
|
72
|
+
return r.exitCode
|
|
73
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// the ci gate — floor + contracts + repo tasks through the DAG runner.
|
|
2
|
+
// Plain module behind cmd_Ci (logic stays out of command classes) so tests
|
|
3
|
+
// can run the gate in-process. Returns the exit code; json prints the
|
|
4
|
+
// structured summary. Every run refreshes STATUS.md (write-status.ts —
|
|
5
|
+
// atomic + lock) unless status:false.
|
|
6
|
+
import { join } from 'path'
|
|
7
|
+
import { allTasks, loadRepoConfig } from '../repo-config.ts'
|
|
8
|
+
import { runTasks, type TaskResult } from './task-runner.ts'
|
|
9
|
+
import { writeStatus } from './write-status.ts'
|
|
10
|
+
|
|
11
|
+
export type CiOpts = {
|
|
12
|
+
bail?: boolean
|
|
13
|
+
json?: boolean
|
|
14
|
+
skipGroups?: string[]
|
|
15
|
+
withIds?: string[]
|
|
16
|
+
onlyIds?: string[]
|
|
17
|
+
/** refresh STATUS.md after the run (default true — O4: every repo gets it) */
|
|
18
|
+
status?: boolean
|
|
19
|
+
/** line sink — commands pass their context stream; defaults to console */
|
|
20
|
+
print?: (line: string) => void
|
|
21
|
+
/** per-task completion tap — the TUI tasks panel streams results in */
|
|
22
|
+
onTaskFinish?: (r: TaskResult) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
26
|
+
const print = o.print ?? ((l: string) => console.log(l))
|
|
27
|
+
const collected: string[] = []
|
|
28
|
+
const sink = (l: string): void => { collected.push(l); print(l) }
|
|
29
|
+
const c = await loadRepoConfig(root)
|
|
30
|
+
const { tasks, disabled, unfilled } = allTasks(c, root)
|
|
31
|
+
for (const d of disabled) sink(`skip: ${d} (skipped-by-config)`)
|
|
32
|
+
// a floor slot nothing could fill is VISIBLE — a thin gate must never
|
|
33
|
+
// look like a full one (the fresh-repo floor gap)
|
|
34
|
+
for (const u of unfilled) sink(`skip: ${u.id} (${u.reason})`)
|
|
35
|
+
const summary = await runTasks({
|
|
36
|
+
tasks,
|
|
37
|
+
logDir: c.logDir ?? join(root, 'tmp/shipkit-ci'),
|
|
38
|
+
cwd: root,
|
|
39
|
+
bail: o.bail ?? false,
|
|
40
|
+
skipGroups: o.skipGroups ?? [],
|
|
41
|
+
withIds: o.withIds ?? [],
|
|
42
|
+
onlyIds: o.onlyIds ?? [],
|
|
43
|
+
print: sink,
|
|
44
|
+
onTaskFinish: o.onTaskFinish,
|
|
45
|
+
})
|
|
46
|
+
const code = summary.ok ? 0 : 1
|
|
47
|
+
if (o.status !== false) {
|
|
48
|
+
const st = writeStatus(root, { name: c.name, command: 'shipkit ci', lines: collected, exitCode: code })
|
|
49
|
+
if (!st.written) print(`WARN ${st.warn}`)
|
|
50
|
+
}
|
|
51
|
+
if (o.json === true)
|
|
52
|
+
print(JSON.stringify({ ok: summary.ok, interrupted: summary.interrupted, disabled, tasks: summary.results.map((r) => ({ ...r, lines: undefined })) }))
|
|
53
|
+
return code
|
|
54
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// THE spawn-with-timeout — one code path for every shipkit surface that
|
|
2
|
+
// runs a child process (task runner, CheckCtx.exec, *.check.ts discovery).
|
|
3
|
+
// Resolves the binary first (Bun.spawn throws on a missing one), streams
|
|
4
|
+
// stdout+stderr, SIGTERM at timeoutMs, SIGKILL after a grace period so a
|
|
5
|
+
// SIGTERM-ignoring child can never hang the caller.
|
|
6
|
+
|
|
7
|
+
import { existsSync } from 'fs'
|
|
8
|
+
|
|
9
|
+
export type SpawnOutcome = {
|
|
10
|
+
code: number
|
|
11
|
+
out: string
|
|
12
|
+
timedOut: boolean
|
|
13
|
+
/** true when the child never ran (empty cmd, binary missing, spawn threw) — `out` carries the reason */
|
|
14
|
+
spawnFailed: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type SpawnTimeoutOpts = {
|
|
18
|
+
timeoutMs: number
|
|
19
|
+
/** ms between SIGTERM and SIGKILL (default 2000) */
|
|
20
|
+
killGraceMs?: number
|
|
21
|
+
cwd?: string
|
|
22
|
+
env?: Record<string, string | undefined>
|
|
23
|
+
/** raw chunk tap (log streaming, last-line tracking) — `out` accumulates regardless */
|
|
24
|
+
onChunk?: (text: string) => void
|
|
25
|
+
/** handle tap so callers can kill externally (bail / SIGINT) */
|
|
26
|
+
onSpawn?: (proc: ReturnType<typeof Bun.spawn>) => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function spawnWithTimeout(cmd: string[], opts: SpawnTimeoutOpts): Promise<SpawnOutcome> {
|
|
30
|
+
const head = cmd[0]
|
|
31
|
+
if (head == null) return { code: -1, out: 'empty command', timedOut: false, spawnFailed: true }
|
|
32
|
+
// path-style heads verified too — Bun.spawn on a missing path throws,
|
|
33
|
+
// which must surface as a result, never a crash
|
|
34
|
+
const bin = head.includes('/') ? head : Bun.which(head)
|
|
35
|
+
if (bin == null || !existsSync(bin)) return { code: -1, out: `${head}: not found`, timedOut: false, spawnFailed: true }
|
|
36
|
+
let proc: ReturnType<typeof Bun.spawn>
|
|
37
|
+
try {
|
|
38
|
+
proc = Bun.spawn([bin, ...cmd.slice(1)], {
|
|
39
|
+
stdout: 'pipe',
|
|
40
|
+
stderr: 'pipe',
|
|
41
|
+
cwd: opts.cwd,
|
|
42
|
+
env: opts.env as Record<string, string> | undefined,
|
|
43
|
+
})
|
|
44
|
+
} catch (e) {
|
|
45
|
+
return { code: -1, out: `spawn error: ${String(e).slice(0, 200)}`, timedOut: false, spawnFailed: true }
|
|
46
|
+
}
|
|
47
|
+
opts.onSpawn?.(proc)
|
|
48
|
+
let timedOut = false
|
|
49
|
+
const timer = setTimeout(() => { timedOut = true; try { proc.kill() } catch {} }, opts.timeoutMs)
|
|
50
|
+
const killer = setTimeout(() => { try { proc.kill('SIGKILL') } catch {} }, opts.timeoutMs + (opts.killGraceMs ?? 2000))
|
|
51
|
+
let out = ''
|
|
52
|
+
const eat = async (stream: ReadableStream<Uint8Array>): Promise<void> => {
|
|
53
|
+
const dec = new TextDecoder()
|
|
54
|
+
for await (const chunk of stream) {
|
|
55
|
+
const text = dec.decode(chunk)
|
|
56
|
+
out += text
|
|
57
|
+
opts.onChunk?.(text)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
await Promise.all([eat(proc.stdout as ReadableStream<Uint8Array>), eat(proc.stderr as ReadableStream<Uint8Array>)])
|
|
61
|
+
const code = await proc.exited
|
|
62
|
+
clearTimeout(timer)
|
|
63
|
+
clearTimeout(killer)
|
|
64
|
+
return { code, out, timedOut, spawnFailed: false }
|
|
65
|
+
}
|