rvlib-shipkit 0.14.0 → 0.16.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 (65) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/README.md +6 -22
  3. package/package.json +1 -1
  4. package/src/assets/kits/identity-kit.ts +1 -1
  5. package/src/assets/panels/geometry-rects.ts +3 -17
  6. package/src/assets/server/studio-server.ts +1 -1
  7. package/src/cli/cmd_Bump.ts +52 -0
  8. package/src/cli/cmd_Check.ts +2 -2
  9. package/src/cli/cmd_Ci.ts +10 -2
  10. package/src/cli/cmd_Commands.ts +126 -0
  11. package/src/cli/cmd_Hooks.ts +66 -0
  12. package/src/cli/cmd_Mv.ts +29 -6
  13. package/src/cli/cmd_Release.ts +41 -0
  14. package/src/cli/cmd_Releases.ts +42 -0
  15. package/src/cli/cmd_Rules.ts +20 -6
  16. package/src/cli/cmd_Start.ts +2 -2
  17. package/src/cli.ts +14 -1
  18. package/src/collections/posts.ts +1 -1
  19. package/src/collections/providers.ts +1 -1
  20. package/src/contracts/manifests/discord.contract.ts +1 -1
  21. package/src/contracts/manifests/itch.contract.ts +2 -4
  22. package/src/core/canonical-scripts.ts +9 -14
  23. package/src/core/check-suite.ts +1 -1
  24. package/src/core/discover-checks.ts +21 -6
  25. package/src/core/run-check.ts +69 -21
  26. package/src/core/run-ci.ts +75 -9
  27. package/src/core/run-fixers.ts +144 -0
  28. package/src/core/write-status.ts +14 -5
  29. package/src/hooks/commit-message.ts +42 -0
  30. package/src/hooks/leak-guard.ts +0 -0
  31. package/src/hooks/run-hooks.ts +131 -0
  32. package/src/init-repo.ts +10 -5
  33. package/src/reflect/prompt-corpus.ts +3 -4
  34. package/src/release/changelog.ts +53 -0
  35. package/src/release/git-facts.ts +133 -0
  36. package/src/release/run-bump.ts +419 -0
  37. package/src/release/run-release.ts +267 -0
  38. package/src/release/run-releases.ts +230 -0
  39. package/src/release/unit-gate.ts +67 -0
  40. package/src/release/units.ts +246 -0
  41. package/src/repo-config.ts +56 -2
  42. package/src/rules/SK001-package-json-shape.ts +39 -17
  43. package/src/rules/SK002-contracts-fulfillness.ts +1 -0
  44. package/src/rules/SK003-claude-md-shape.ts +2 -1
  45. package/src/rules/SK004-gitignore-policy.ts +1 -0
  46. package/src/rules/SK005-rvlib-versions.ts +22 -3
  47. package/src/rules/SK006-npm-publish-safety.ts +1 -0
  48. package/src/rules/SK007-github-visibility.ts +5 -1
  49. package/src/rules/SK008-reviewer-agent.ts +1 -0
  50. package/src/rules/SK009-commit-ai-tells.ts +66 -0
  51. package/src/rules/SK010-undeclared-release-unit.ts +114 -0
  52. package/src/rules/rule-registry.ts +26 -6
  53. package/src/rules/rule.ts +8 -1
  54. package/src/rules/run-fix.ts +15 -8
  55. package/src/serve/hub-app/col-card.tsx +2 -3
  56. package/src/serve/hub-app/hub-app.tsx +6 -8
  57. package/src/serve/hub-app/hub-st.ts +4 -7
  58. package/src/serve/hub-app/media-view.tsx +2 -2
  59. package/src/serve/run-serve.ts +4 -5
  60. package/src/serve/serve-hub.ts +2 -3
  61. package/src/shipkit.ts +9 -1
  62. package/src/tui/checks-panel.ts +1 -1
  63. package/src/tui/run-tui.ts +2 -3
  64. package/src/tui/tasks-panel.ts +10 -4
  65. package/src/tui/tui-store.ts +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,134 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.0
4
+
5
+ Upgrading from 0.14.0 lands straight here: 0.15.0 was stamped and never
6
+ published, so everything under the `## 0.15.0` heading below ships in this
7
+ release too, its BREAKING lines included.
8
+
9
+ Releasing becomes part of the toolkit. `shipkit ci` is the one gate, `shipkit
10
+ bump` is the one release command, git hooks are owned by the binary instead of
11
+ by hand-written shell, and a repo can now ship more than its own root.
12
+
13
+ ### One gate
14
+
15
+ - `shipkit ci` repairs before it judges. A mechanical fix pass runs first
16
+ (formatter, canonical imports, the fixable repo-shape rules), then the task
17
+ DAG, then every check. `--no-fix` skips the repair pass. For a run that
18
+ writes nothing, add `--frozen`. Under CI the
19
+ fixers still run and the gate FAILS on any file they rewrote, so a green CI
20
+ cannot hide an unformatted commit. `--fix-rules` covers the structural rule
21
+ autofixes and `--no-fix-rules` leaves repo config alone.
22
+ - **BREAKING: every check declares `blocking`.** `export const blocking = true`
23
+ means the failure is about this repo and stops you. `false` means the check
24
+ reports the WORLD (a credential nobody minted, a machine that is off), so it
25
+ prints as a warning and never blocks. A repo whose checks all probe the world
26
+ declares `checkDefaults: { blocking: false }` once instead of repeating it in
27
+ every file. A check that declares neither, in a repo with no default, now
28
+ fails the run: a check that silently does not block is the failure nobody
29
+ sees. A non-blocking failure is demoted where it is produced, so the printed
30
+ severity can never disagree with the exit code.
31
+ - A repo's own rules (`repo.config.ts rules: []`) run in the same engine as the
32
+ built-in `SK###` rules, so a repo rule can BLOCK rather than only warn in a
33
+ health report. `shipkit rules` marks every rule BLOCKS or warns.
34
+ - New `runCi` options: `frozen`, `failOnAdvisory`, `skipChecks`.
35
+ - `shipkit check` writes STATUS.md as `shipkit check`, and only the full gate
36
+ says "clear to ship".
37
+
38
+ ### Release units
39
+
40
+ - A repo is N releasable units. The ROOT unit is mandatory and never declared.
41
+ Sub-units are optional and declared in `repo.config.ts units: [{ dir }]`.
42
+ Each unit owns its own CHANGELOG, its own version, its own tag and its own
43
+ targets. Tag shapes stay per unit: `v{version}` for the root,
44
+ `{name}@{version}` for a sub-unit by default, overridable with `tag`.
45
+ - **BREAKING: a sub-unit publishes to npm only when its entry declares
46
+ `npm: true`.** A package that merely forgot `private: true` is no longer a
47
+ publish target by inference. `npm: true` on a private package.json is a
48
+ resolution failure, not a silent skip.
49
+ - `notReleasable: [dir]` declares a package that is deliberately not a release
50
+ unit. New rule SK010 fails on any package that is neither declared nor
51
+ excluded, so a forgotten package cannot hide. It asks git for the package
52
+ list, so gitignored trees never trip it.
53
+ - Nothing is scanned into existence. Every surface that used to rediscover
54
+ packages on its own (drift probes, per-unit tests, per-unit typecheck, the
55
+ stranded-tag probe) reads the declared units.
56
+
57
+ ### The release path
58
+
59
+ - `shipkit bump <major|minor|patch> [unit] [--all]` is the whole release from
60
+ one word. Run from a feature branch it merges into the release branch FIRST,
61
+ then computes every fact on the merged tree (next version, tag refusals,
62
+ per-unit test and typecheck), promotes the CHANGELOG `## Unreleased` section,
63
+ writes the version, runs the gate and every prepublish script, commits, tags,
64
+ pushes and publishes. Nothing is pushed until the sequence succeeds, so any
65
+ refusal before the version commit rolls the merge back and returns you to
66
+ your branch. A tag the remote already carries refuses before anything is
67
+ written. `--all` bumps every unit that has Unreleased notes, root last, in
68
+ one version commit. `--dry-run` rehearses the whole thing and writes nothing.
69
+ - `shipkit release [unit]` publishes the version already in package.json,
70
+ reading its notes from the matching `## <version>` section. It resumes a
71
+ partial release by comparing the tag's COMMIT rather than its name, so a tag
72
+ name taken on the remote reads as a burnt version instead of "fully
73
+ released".
74
+ - `shipkit releases` prints one row per unit: local version, npm version, tag
75
+ state, work landed since the tag, GitHub release state, then ONE next action
76
+ per unit. A version stamped without a release is called out: notes that stop
77
+ before the work did are how a package ships under someone else's changelog.
78
+ A unit that is in sync with npm and carries `## Unreleased` notes says so as
79
+ a note, so a release waiting on a decision is never invisible.
80
+ - npm publishing lives here now. A publish runs the unit's own test suite and
81
+ typecheck, refuses a dirty unit directory, and reads the npm token from
82
+ `release.npmTokenCommand` into a throwaway userconfig, so the token never
83
+ reaches argv, a log, or a tracked `.npmrc`. A private unit skips npm and
84
+ still gets its tag, its push and its GitHub release.
85
+ - The release path runs ONE gate. The separate health gate is gone and
86
+ `release.requireHealthy` works through `runCi` (non-blocking check failures
87
+ become fatal). The gate runs frozen, and the tree is re-checked for dirt
88
+ before anything publishes.
89
+
90
+ ### Commit gates, owned by the binary
91
+
92
+ - `shipkit hooks install | list | pre-commit | commit-msg`. Built in: a leak
93
+ guard over a keyword file, and one commit-message predicate that rule SK009
94
+ also uses, replacing a copy kept in sync by hand. A repo adds its own gates
95
+ with `hooks: { preCommit: [{ id, cmd }] }` or `commitMsg`, and disables a
96
+ built-in by name. Hand-rolled hooks are invisible: a repo reports green while
97
+ its next commit is already doomed, and `shipkit hooks list` answers what
98
+ blocks a commit without anyone reading shell.
99
+
100
+ ### Discoverability and scaffolding
101
+
102
+ - `shipkit commands [--json] [--out <file>] [--check]` prints every command the
103
+ binary registers, with every flag and a worked example. `--out` rewrites a
104
+ marked block inside a docs page and `--check` fails when that block is stale,
105
+ so a command added without a doc line cannot land.
106
+ - `shipkit mv` renames code as well as md pages. A non-md path routes to the
107
+ ts-tools rename, which rewrites every path reference. Knowing WHICH rename
108
+ tool applies is exactly the doubt the toolkit exists to remove.
109
+ - **BREAKING: a package.json script that only forwards to a shipkit verb is no
110
+ longer canon.** SK001 stops requiring `ci`, `check`, `contracts`, `tui`,
111
+ `mv`, `asks` and `assets:identity` scripts, and `shipkit init` no longer
112
+ scaffolds them. The verb is the vocabulary; an alias is one more name to keep
113
+ in sync. Only a body shipkit cannot express itself (`hub`, which needs watch
114
+ mode) stays a script.
115
+ - SK001 asks for a `test` script only once the repo has test files. `bun test`
116
+ exits non-zero when it finds none, and a fixer must never manufacture a
117
+ failure.
118
+ - SK007 (github visibility) blocks.
119
+
120
+ ## 0.15.0 — 2026-07-28
121
+
122
+ - Hub: the `?needs=1` URL form is gone — the severity filter address is
123
+ `/?f=needs` · `/?f=red` only.
124
+ - Assets (BREAKING): the exported `migrateCircle` is removed, and
125
+ `cleanRect` no longer reads the legacy rect `scale` key — session files
126
+ must carry `size` (a one-shot `"scale"` → `"size"` rename migrates old
127
+ files).
128
+ - Comment cleanup across src (no behavior change).
129
+ - check discovery: the self-cached heuristic recognizes `PageValidator`
130
+ (replacing the old subclass name) alongside `CheckSuite`.
131
+
3
132
  ## 0.14.0 — 2026-07-27
4
133
 
5
134
  New rule SK008 — the mandated reviewer agent type. A repo declares
package/README.md CHANGED
@@ -1,9 +1,6 @@
1
1
  # rvlib-shipkit
2
2
 
3
- Repo toolkit for shipping: one config file gives a repo a commit gate
4
- (task DAG), release-contract fulfillness checks, a human ask queue,
5
- markdown-wiki tooling, and the asset atelier (generate, curate, and ship
6
- assets — favicon/logo/OG included).
3
+ Repo toolkit for shipping: one config file gives a repo a commit gate (task DAG), release-contract fulfillness checks, a human ask queue, markdown-wiki tooling, and the asset atelier (generate, curate, and ship assets — favicon/logo/OG included).
7
4
 
8
5
  ```sh
9
6
  bun add -d rvlib-shipkit
@@ -22,8 +19,7 @@ export default defineRepo({
22
19
  })
23
20
  ```
24
21
 
25
- Health checks need no config at all: drop a `<name>.check.ts` file anywhere
26
- and `shipkit check` discovers and runs it (wire output + exit code).
22
+ Health checks need no config at all: drop a `<name>.check.ts` file anywhere and `shipkit check` discovers and runs it (wire output + exit code).
27
23
 
28
24
  ## CLI
29
25
 
@@ -60,28 +56,16 @@ shipkit init scaffold config + scripts, print the resolved floor
60
56
  shipkit start ensure <root>/<name> + its CLAUDE.md include; prints the dir
61
57
  ```
62
58
 
63
- Output is a stable line protocol — `ok:` / `WARN` / `FAIL:` / `skip:` with
64
- indented `fix:` pointers — exit 0/1. Greppable by humans, parseable by
65
- agents, `--json` where structure helps.
59
+ Output is a stable line protocol — `ok:` / `WARN` / `FAIL:` / `skip:` with indented `fix:` pointers — exit 0/1. Greppable by humans, parseable by agents, `--json` where structure helps.
66
60
 
67
61
  ## Release contracts
68
62
 
69
- A venue contract is data: required files, exact PNG dimensions (header-parsed,
70
- no image dependency), glob minimums, required markdown sections. Enforcement
71
- is both ways — a declared venue with an incomplete folder fails, and an
72
- undeclared venue whose signals fire (deploy scripts, folders) fails too.
73
- `exempt: { steam: "reason" }` skips visibly. Wire venue deploys as
74
- `deps: ['contracts']` and an incomplete folder physically blocks the upload.
63
+ A venue contract is data: required files, exact PNG dimensions (header-parsed, no image dependency), glob minimums, required markdown sections. Enforcement is both ways — a declared venue with an incomplete folder fails, and an undeclared venue whose signals fire (deploy scripts, folders) fails too. `exempt: { steam: "reason" }` skips visibly. Wire venue deploys as `deps: ['contracts']` and an incomplete folder physically blocks the upload.
75
64
 
76
- Ship your own contracts via `contracts: { extra: [defineContract({...})] }`
77
- — see `rvlib-shipkit/contracts` for the rule vocabulary.
65
+ Ship your own contracts via `contracts: { extra: [defineContract({...})] }` — see `rvlib-shipkit/contracts` for the rule vocabulary.
78
66
 
79
67
  ## Library use
80
68
 
81
- Everything the CLI does is importable: `runTasks`, `CheckSuite`,
82
- `evaluateContracts`, the asks queue, `scanMdGraph`/`lintMd`/`serveMdGraph`,
83
- and the assets surface (`defineKit`/`defineKind`, `identityKinds`,
84
- `studioRoutes`, `saveEndpoint` via `rvlib-shipkit/vite`).
85
- Ships TypeScript source; Bun ≥ 1.0.
69
+ Everything the CLI does is importable: `runTasks`, `CheckSuite`, `evaluateContracts`, the asks queue, `scanMdGraph`/`lintMd`/`serveMdGraph`, and the assets surface (`defineKit`/`defineKind`, `identityKinds`, `studioRoutes`, `saveEndpoint` via `rvlib-shipkit/vite`). Ships TypeScript source. Bun ≥ 1.0.
86
70
 
87
71
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,7 +37,7 @@ export const OG_H = 630
37
37
  export const LOGO_SIZE = 1024
38
38
 
39
39
  /** the one next step when a comfy mark has no picked roll yet */
40
- export const NO_PICK_FIX = 'roll + pick in the studio: bun run assets → #identity-logo'
40
+ export const NO_PICK_FIX = 'roll + pick in the studio: bun run hub → /assets/#identity-logo'
41
41
 
42
42
  export function identityKinds(brand: Brand): Kind[] {
43
43
  return [
@@ -20,11 +20,11 @@ export type GroundRect = {
20
20
  const r2 = (n: number): number => Math.round(n * 100) / 100
21
21
  const r3 = (n: number): number => Math.round(n * 1000) / 1000
22
22
 
23
- /** Normalize a possibly-legacy rect: fold `scale` into `size`, round
24
- * (x/y/w/h 2dp, size 3dp, legs 2dp), ratio-lock h preserving center-y. */
23
+ /** Normalize a rect: round (x/y/w/h 2dp, size 3dp, legs 2dp), ratio-lock h
24
+ * preserving center-y. */
25
25
  export function cleanRect(raw: Record<string, unknown>, ratio: number): GroundRect {
26
26
  const num = (k: string, dflt: number): number => (typeof raw[k] === 'number' && Number.isFinite(raw[k]) ? (raw[k] as number) : dflt)
27
- const size = typeof raw.size === 'number' ? (raw.size as number) : num('scale', 1) // legacy key folds in
27
+ const size = num('size', 1)
28
28
  const x = num('x', 0)
29
29
  const y = num('y', 0)
30
30
  const w = Math.max(1, num('w', 1))
@@ -68,17 +68,3 @@ export function defaultLegs(oval: GroundRect): LegRect[] {
68
68
  return { x: r2(cx + dx * u - lw / 2), y: r2(footY - lh), w: r2(lw), h: r2(lh) }
69
69
  })
70
70
  }
71
-
72
- /** Legacy circle → oval migration (run on session read). A circle is
73
- * |h − w| ≤ 0.5. anchor: where the new oval's center-y comes from —
74
- * 'bottom' (characters/scenery: feet at circle bottom) or 'center'
75
- * (buildings). artNormalized folds art-height units into oval-width units:
76
- * size *= w / art. Non-circles pass through untouched. */
77
- export function migrateCircle(rect: GroundRect, opts: { ratio: number; anchor: 'bottom' | 'center'; artNormalized?: boolean }): GroundRect {
78
- if (Math.abs(rect.h - rect.w) > 0.5) return rect
79
- const cy = opts.anchor === 'bottom' ? rect.y + rect.h : rect.y + rect.h / 2
80
- let size = rect.size
81
- if (opts.artNormalized === true && rect.art != null && rect.art > 0) size = size * (rect.w / rect.art)
82
- const h = rect.w * opts.ratio
83
- return cleanRect({ ...rect, size, h, y: cy - h / 2 } as unknown as Record<string, unknown>, opts.ratio)
84
- }
@@ -1,6 +1,6 @@
1
1
  // The studio HTTP server — roll variants, compare, pick, geometry metadata,
2
2
  // category grid data. 127.0.0.1 ONLY, forever. Watch-mode restarts drop the
3
- // in-memory queue — accepted tradeoff (his order 2026-07-23); `shipkit
3
+ // in-memory queue — accepted tradeoff; `shipkit
4
4
  // assets fill` is the recovery path.
5
5
  //
6
6
  // ids/names are validated against the kit + the roll-file regex — no path
@@ -0,0 +1,52 @@
1
+ // shipkit bump, version, changelog, gate, prepublish, commit, release, from
2
+ // one word. Thin clipanion surface; logic in release/run-bump.ts.
3
+ import { Command, Option } from 'clipanion'
4
+ import { loadRepoConfig } from '../repo-config.ts'
5
+ import { runBump, type BumpKind } from '../release/run-bump.ts'
6
+
7
+ export class BumpCommand extends Command {
8
+ static override paths = [['bump']]
9
+
10
+ static override usage = Command.Usage({
11
+ category: 'Gate',
12
+ description: 'ship a version in one word: bumps a release unit (default the repo root), run from a feature branch it merges into the release branch first, then promotes the CHANGELOG Unreleased section, bumps package.json, runs the gate and every prepublish script, commits, tags, pushes, and cuts the release. Any failure before the version commit rolls the merge back and returns you to your branch.',
13
+ examples: [
14
+ ['a bugfix release of the repo itself', 'shipkit bump patch'],
15
+ ['a feature release', 'shipkit bump minor'],
16
+ ['a breaking release', 'shipkit bump major'],
17
+ ['one sub-unit (ids: shipkit releases)', 'shipkit bump minor rvlib-shipkit'],
18
+ ['every unit that has Unreleased notes, root last', 'shipkit bump patch --all'],
19
+ ['rehearse the whole thing', 'shipkit bump minor --dry-run'],
20
+ ['bump and commit, release later', 'shipkit bump patch --no-release'],
21
+ ['from a feature branch, refuse rather than merge', 'shipkit bump patch --no-merge'],
22
+ ],
23
+ })
24
+
25
+ kind = Option.String({ required: true })
26
+ unit = Option.String({ required: false })
27
+ dryRun = Option.Boolean('--dry-run', false, { description: 'rehearse: run every preflight, the gate and the prepublish scripts, then stop before any version is written. the gate can rewrite files, and the merge is probed rather than performed, so the release branch never moves' })
28
+ noRelease = Option.Boolean('--no-release', false, { description: 'stop after the version commit, do not tag or publish' })
29
+ noMerge = Option.Boolean('--no-merge', false, { description: 'refuse on a feature branch instead of merging it into the release branch' })
30
+ all = Option.Boolean('--all', false, { description: 'bump every unit whose changelog carries Unreleased notes, in declared order, root last. The kind applies to ALL of them, so a batch mixing a typo fix and a break wants one bump per unit instead' })
31
+
32
+ async execute(): Promise<number> {
33
+ if (this.kind !== 'major' && this.kind !== 'minor' && this.kind !== 'patch') {
34
+ this.context.stderr.write(`FAIL: bump takes major, minor or patch (got '${this.kind}')\n`)
35
+ return 1
36
+ }
37
+ try {
38
+ const config = await loadRepoConfig(process.cwd())
39
+ return await runBump(process.cwd(), config, this.kind as BumpKind, {
40
+ dryRun: this.dryRun,
41
+ noRelease: this.noRelease,
42
+ noMerge: this.noMerge,
43
+ unit: this.unit,
44
+ all: this.all,
45
+ print: (l) => this.context.stdout.write(`${l}\n`),
46
+ })
47
+ } catch (e) {
48
+ this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
49
+ return 1
50
+ }
51
+ }
52
+ }
@@ -6,11 +6,11 @@ import { runCheck } from '../core/run-check.ts'
6
6
  import { runFix } from '../rules/run-fix.ts'
7
7
 
8
8
  export class CheckCommand extends Command {
9
- static override paths = [['check']]
9
+ static override paths = [['check'], ['doctor']]
10
10
 
11
11
  static override usage = Command.Usage({
12
12
  category: 'Health',
13
- description: 'periodic health: SK rules + every *.check.ts in the repo (auto-discovered, run as subprocesses), clean passes replay from a TTL cache; --fix applies rule autofixes',
13
+ description: 'the checks alone, without the build tasks. `shipkit ci` runs these too and is what you normally want. --fix applies rule autofixes',
14
14
  examples: [
15
15
  ['run (cached where valid)', 'shipkit check'],
16
16
  ['bust every cache layer', 'shipkit check --fresh'],
package/src/cli/cmd_Ci.ts CHANGED
@@ -8,9 +8,11 @@ export class CiCommand extends Command {
8
8
 
9
9
  static override usage = Command.Usage({
10
10
  category: 'Gate',
11
- description: 'commit gate: floor + contracts + repo tasks through the DAG runner',
11
+ description: 'THE command. Repairs what a machine can (formatter, imports, repo shape), runs every task, then every check. A check that declares blocking:false warns instead of failing. Run this instead of anything else.',
12
12
  examples: [
13
- ['full gate', 'shipkit ci'],
13
+ ['full gate, mechanical fixes applied', 'shipkit ci'],
14
+ ['gate without the repair pass (checks can still write)', 'shipkit ci --no-fix'],
15
+ ['leave repo config files alone', 'shipkit ci --no-fix-rules'],
14
16
  ['stop at the first failure', 'shipkit ci --bail'],
15
17
  ['one task + its dep closure', 'shipkit ci --only typecheck'],
16
18
  ['include an opt-in task', 'shipkit ci --with up:itch'],
@@ -19,6 +21,9 @@ export class CiCommand extends Command {
19
21
 
20
22
  bail = Option.Boolean('--bail', false, { description: 'stop at the first failure' })
21
23
  json = Option.Boolean('--json', false, { description: 'machine-readable result instead of wire lines' })
24
+ fix = Option.Boolean('--fix', true, { description: 'repair the mechanical slots before gating. Default on. --no-fix skips the repair pass, checks can still write, add --frozen for a run that writes nothing. Under CI the fixers still run but the gate FAILS on any file they rewrite' })
25
+ fixRules = Option.Boolean('--fix-rules', true, { description: 'apply the structural rule autofixes too. Default on. --no-fix-rules leaves repo config alone' })
26
+ frozen = Option.Boolean('--frozen', false, { description: 'report-only mode for self-maintaining rules (SK005 never touches package.json). With --no-fix, the run writes nothing' })
22
27
  skip = Option.Array('--skip', [], { description: 'skip a task group (repeatable)' })
23
28
  withIds = Option.Array('--with', [], { description: 'include an opt-in task (repeatable)' })
24
29
  only = Option.Array('--only', [], { description: 'run only this task + its dep closure (repeatable)' })
@@ -28,6 +33,9 @@ export class CiCommand extends Command {
28
33
  return await runCi(process.cwd(), {
29
34
  bail: this.bail,
30
35
  json: this.json,
36
+ fix: this.fix,
37
+ fixRules: this.fixRules,
38
+ frozen: this.frozen,
31
39
  skipGroups: this.skip,
32
40
  withIds: this.withIds,
33
41
  onlyIds: this.only,
@@ -0,0 +1,126 @@
1
+ // the binary describing itself, in markdown. a hand-copied command list
2
+ // drifts the moment a flag changes, and then teaches commands that do not
3
+ // exist. rendering the live registry makes the doc a build artifact.
4
+ import { Command, Option } from 'clipanion'
5
+
6
+ export type CommandOption = { definition: string; description: string }
7
+ export type CommandRow = {
8
+ path: string
9
+ category: string
10
+ description: string
11
+ usage: string
12
+ options: CommandOption[]
13
+ examples: [string, string][]
14
+ }
15
+
16
+ export type Definition = {
17
+ path: string
18
+ category?: string | null
19
+ description?: string | null
20
+ usage?: string | null
21
+ examples?: [string, string][] | null
22
+ options?: { definition?: string; description?: string | null }[] | null
23
+ }
24
+
25
+ // clipanion renders a value placeholder as `#0`, which nobody can type
26
+ const clean = (s: string | null | undefined): string => (s ?? '').trim().replace(/\s+/g, ' ').replace(/ #\d+\b/g, ' <value>')
27
+
28
+ /** rows from the live registry, builtins dropped, sorted by category then path.
29
+ * clipanion splits what we need across two calls: the plain form carries the
30
+ * examples, the detailed form carries the flags. Merge by path, or the page
31
+ * ends up showing a command with no arguments, which is the state that sends
32
+ * an agent to `--help` before it can act. */
33
+ export function commandRows(plain: Definition[], detailed: Definition[] = []): CommandRow[] {
34
+ const flagsByPath = new Map(detailed.map((d) => [d.path, d.options ?? []]))
35
+ return plain
36
+ .filter((d) => !/^shipkit (--help|-h|--version|-v)$/.test(d.path))
37
+ .map((d) => ({
38
+ path: d.path.replace(/^shipkit /, ''),
39
+ category: clean(d.category) || 'Other',
40
+ // verbatim from the CLI so the doc cannot say something it does not
41
+ description: clean(d.description),
42
+ usage: clean(d.usage),
43
+ options: (flagsByPath.get(d.path) ?? [])
44
+ .map((o) => ({ definition: clean(o.definition), description: clean(o.description) }))
45
+ .filter((o) => o.definition !== ''),
46
+ examples: (d.examples ?? []).map(([label, cmd]) => [clean(label), clean(cmd)] as [string, string]),
47
+ }))
48
+ .sort((a, b) => a.category.localeCompare(b.category) || a.path.localeCompare(b.path))
49
+ }
50
+
51
+ /** the markdown block: per command, the real invocation with its arguments and
52
+ * flags, then a worked example. A name and a sentence are not enough to act
53
+ * on: an agent that cannot see the arguments either guesses them or runs
54
+ * `--help` first, and both are the waste this page exists to remove. */
55
+ export function renderMarkdown(rows: CommandRow[]): string {
56
+ const out: string[] = []
57
+ let cat = ''
58
+ for (const r of rows) {
59
+ if (r.category !== cat) { cat = r.category; out.push(`\n### ${cat}\n`) }
60
+ const sig = r.usage.replace(/^\$0/, 'shipkit').trim() || `shipkit ${r.path}`
61
+ const flags = r.options.map((o) => `[${o.definition}]`).join(' ')
62
+ // one line per bullet: a description on its own indented line reads as
63
+ // hard-wrapped prose to the house lint
64
+ out.push(`- \`${sig}${flags === '' ? '' : ` ${flags}`}\` — ${r.description}`)
65
+ for (const o of r.options) if (o.description !== '') out.push(` - \`${o.definition}\` ${o.description}`)
66
+ for (const [label, cmd] of r.examples) out.push(` - ${label}: \`${cmd}\``)
67
+ }
68
+ return out.join('\n').trim()
69
+ }
70
+
71
+ export const BEGIN = '<!-- shipkit:commands -->'
72
+ export const END = '<!-- /shipkit:commands -->'
73
+
74
+ /** replace the marked block in a host file. Returns null when the markers are
75
+ * missing, so a renamed or hand-mangled host fails loud instead of silently
76
+ * writing nothing. */
77
+ export function spliceBlock(host: string, block: string): string | null {
78
+ const a = host.indexOf(BEGIN)
79
+ const b = host.indexOf(END)
80
+ if (a === -1 || b === -1 || b < a) return null
81
+ return `${host.slice(0, a + BEGIN.length)}\n\n${block}\n\n${host.slice(b)}`
82
+ }
83
+
84
+ export class CommandsCommand extends Command {
85
+ static override paths = [['commands']]
86
+
87
+ static override usage = Command.Usage({
88
+ category: 'Health',
89
+ description: 'every command this binary registers, as markdown or json — the source a docs page is generated FROM, never hand-copied',
90
+ examples: [
91
+ ['markdown index', 'shipkit commands'],
92
+ ['machine-readable', 'shipkit commands --json'],
93
+ ['write into a docs page between the markers', 'shipkit commands --out wiki/build/shipkit-cli.md'],
94
+ ['fail if that page is stale (lint mode)', 'shipkit commands --out wiki/build/shipkit-cli.md --check'],
95
+ ],
96
+ })
97
+
98
+ json = Option.Boolean('--json', false, { description: 'machine-readable [{path, category, description}]' })
99
+ out = Option.String('--out', { description: 'host file carrying the shipkit:commands markers. The block between them is rewritten' })
100
+ check = Option.Boolean('--check', false, { description: 'with --out: exit 1 if the block is stale, write nothing' })
101
+
102
+ async execute(): Promise<number> {
103
+ const rows = commandRows(
104
+ this.cli.definitions() as Definition[],
105
+ (this.cli.definitions as (o?: { detailed?: boolean }) => Definition[])({ detailed: true }),
106
+ )
107
+ if (this.out == null) {
108
+ this.context.stdout.write(this.json ? `${JSON.stringify(rows)}\n` : `${renderMarkdown(rows)}\n`)
109
+ return 0
110
+ }
111
+ const { existsSync, readFileSync, writeFileSync } = await import('fs')
112
+ if (!existsSync(this.out)) { this.context.stdout.write(`FAIL: ${this.out} does not exist\n`); return 1 }
113
+ const host = readFileSync(this.out, 'utf8')
114
+ const next = spliceBlock(host, renderMarkdown(rows))
115
+ if (next == null) { this.context.stdout.write(`FAIL: ${this.out} has no ${BEGIN} / ${END} markers\n`); return 1 }
116
+ if (next === host) { this.context.stdout.write(`ok: ${this.out} command index current (${rows.length} commands)\n`); return 0 }
117
+ if (this.check) {
118
+ this.context.stdout.write(`FAIL: ${this.out} command index is STALE — the binary registers commands the page does not list\n`)
119
+ this.context.stdout.write(` fix: shipkit commands --out ${this.out}\n`)
120
+ return 1
121
+ }
122
+ writeFileSync(this.out, next)
123
+ this.context.stdout.write(`ok: ${this.out} command index rewritten (${rows.length} commands)\n`)
124
+ return 0
125
+ }
126
+ }
@@ -0,0 +1,66 @@
1
+ // shipkit hooks, install the git hooks, run their gates, or list what
2
+ // blocks a commit. Thin clipanion surface; logic in hooks/run-hooks.ts.
3
+ import { Command, Option } from 'clipanion'
4
+ import { installHooks, listGates, runCommitMsg, runPreCommit, type GateOutcome } from '../hooks/run-hooks.ts'
5
+ import { loadRepoConfig } from '../repo-config.ts'
6
+
7
+ const report = (out: GateOutcome[], write: (s: string) => void): number => {
8
+ for (const g of out) for (const l of g.lines) write(`${l}\n`)
9
+ return out.every((g) => g.ok) ? 0 : 1
10
+ }
11
+
12
+ export class HooksInstallCommand extends Command {
13
+ static override paths = [['hooks', 'install']]
14
+ static override usage = Command.Usage({
15
+ category: 'Gate',
16
+ description: 'write hooks/pre-commit + hooks/commit-msg and point core.hooksPath at them (idempotent)',
17
+ examples: [['wire the hooks', 'shipkit hooks install']],
18
+ })
19
+
20
+ bin = Option.String('--bin', 'bunx rvlib-shipkit', { description: 'how the hook body invokes shipkit (in-repo consumers pass `bun <path to cli.ts>`)' })
21
+
22
+ async execute(): Promise<number> {
23
+ const r = installHooks(process.cwd(), this.bin)
24
+ for (const w of r.written) this.context.stdout.write(`ok: wrote ${w}\n`)
25
+ if (r.written.length === 0) this.context.stdout.write('ok: hook bodies already current\n')
26
+ this.context.stdout.write(`${r.note.startsWith('WARN') ? r.note : `ok: ${r.note}`}\n`)
27
+ return r.note.startsWith('WARN') ? 1 : 0
28
+ }
29
+ }
30
+
31
+ export class HooksPreCommitCommand extends Command {
32
+ static override paths = [['hooks', 'pre-commit']]
33
+ static override usage = Command.Usage({ category: 'Gate', description: 'run the pre-commit gate (called by the hook)' })
34
+
35
+ async execute(): Promise<number> {
36
+ const config = await loadRepoConfig(process.cwd())
37
+ return report(await runPreCommit(process.cwd(), config), (s) => this.context.stdout.write(s))
38
+ }
39
+ }
40
+
41
+ export class HooksCommitMsgCommand extends Command {
42
+ static override paths = [['hooks', 'commit-msg']]
43
+ static override usage = Command.Usage({ category: 'Gate', description: 'run the commit-message gate (called by the hook)' })
44
+
45
+ file = Option.String({ required: true })
46
+
47
+ async execute(): Promise<number> {
48
+ const config = await loadRepoConfig(process.cwd())
49
+ return report(await runCommitMsg(process.cwd(), config, this.file), (s) => this.context.stdout.write(s))
50
+ }
51
+ }
52
+
53
+ export class HooksListCommand extends Command {
54
+ static override paths = [['hooks', 'list']]
55
+ static override usage = Command.Usage({
56
+ category: 'Gate',
57
+ description: 'what blocks a commit in this repo, built-ins and repo-declared',
58
+ examples: [['list the gates', 'shipkit hooks list']],
59
+ })
60
+
61
+ async execute(): Promise<number> {
62
+ const config = await loadRepoConfig(process.cwd())
63
+ for (const l of listGates(config, process.cwd())) this.context.stdout.write(`${l}\n`)
64
+ return 0
65
+ }
66
+ }
package/src/cli/cmd_Mv.ts CHANGED
@@ -1,27 +1,50 @@
1
- // shipkit mv — md-page rename with repo-wide ref sweep. Thin clipanion
2
- // surface; logic in mv/mv-md.ts.
1
+ // shipkit mv, ONE rename command for the whole repo. An md page routes to
2
+ // mv/mv-md.ts (page + sibling asset dir + check.ts + every tracked ref);
3
+ // anything else routes to rvlib-ts-tools' rename, which rewrites every path
4
+ // reference in the code. Renaming used to mean knowing WHICH tool applied,
5
+ // and knowing that is exactly the kind of doubt the toolkit exists to remove.
3
6
  import { Command, Option } from 'clipanion'
7
+ import { fileURLToPath } from 'url'
4
8
  import { movePage } from '../mv/mv-md.ts'
9
+ import { spawnWithTimeout } from '../core/spawn-timeout.ts'
5
10
 
6
11
  export class MvCommand extends Command {
7
12
  static override paths = [['mv']]
8
13
 
9
14
  static override usage = Command.Usage({
10
- category: 'Wiki',
11
- description: 'rename an md page + sibling dir + check.ts, sweeping every tracked ref; renames must be CHEAP',
15
+ category: 'Refactor',
16
+ description: 'rename anything and sweep every reference: an md page (plus its sibling dir and check.ts) or a code file or folder. Dry run by default. Renames must be CHEAP',
12
17
  examples: [
13
- ['dry run (default): show what would change', 'shipkit mv docs/old-name.md docs/new-name.md'],
18
+ ['dry run, the default, shows what changes', 'shipkit mv docs/old-name.md docs/new-name.md'],
19
+ ['apply it', 'shipkit mv docs/old-name.md docs/new-name.md --write'],
14
20
  ['apply + one conventional commit', 'shipkit mv docs/old-name.md docs/new-name.md --commit'],
21
+ ['a code file, every import rewritten', 'shipkit mv src/old-mod.ts src/new-mod.ts --write'],
22
+ ['a whole folder', 'shipkit mv src/old-dir src/new-dir --write'],
15
23
  ],
16
24
  })
17
25
 
18
26
  oldPath = Option.String()
19
27
  newPath = Option.String()
20
28
  write = Option.Boolean('--write', false, { description: 'apply (default is a dry run)' })
21
- commit = Option.Boolean('--commit', false, { description: 'apply + git commit (pre-commit hook is the gate)' })
29
+ commit = Option.Boolean('--commit', false, { description: 'apply + git commit, md pages only (the pre-commit gate still runs)' })
22
30
 
23
31
  async execute(): Promise<number> {
32
+ const write = this.write || this.commit
24
33
  try {
34
+ const isMd = this.oldPath.endsWith('.md') || this.newPath.endsWith('.md')
35
+ if (!isMd) {
36
+ if (this.commit) {
37
+ this.context.stderr.write('FAIL: --commit is md-only for now — run with --write, then commit\n')
38
+ return 1
39
+ }
40
+ let cli: string
41
+ try { cli = fileURLToPath(import.meta.resolve('rvlib-ts-tools/src/cli.ts')) }
42
+ catch { this.context.stderr.write('FAIL: rvlib-ts-tools is unresolvable — reinstall shipkit deps\n'); return 1 }
43
+ const r = await spawnWithTimeout(['bun', cli, 'rename', this.oldPath, this.newPath, ...(write ? ['--apply'] : [])], { cwd: process.cwd(), timeoutMs: 120_000 })
44
+ this.context.stdout.write(r.out)
45
+ if (!write && r.code === 0) this.context.stdout.write('note: dry run — pass --write to apply\n')
46
+ return r.code
47
+ }
25
48
  return movePage(process.cwd(), this.oldPath, this.newPath, {
26
49
  print: (l) => this.context.stdout.write(`${l}\n`),
27
50
  printErr: (l) => this.context.stderr.write(`${l}\n`),
@@ -0,0 +1,41 @@
1
+ // shipkit release, the one release path, public or private. Thin clipanion
2
+ // surface; logic in release/run-release.ts.
3
+ import { Command, Option } from 'clipanion'
4
+ import { loadRepoConfig } from '../repo-config.ts'
5
+ import { runRelease } from '../release/run-release.ts'
6
+
7
+ export class ReleaseCommand extends Command {
8
+ static override paths = [['release']]
9
+
10
+ static override usage = Command.Usage({
11
+ category: 'Gate',
12
+ description: 'cut a release for ONE unit (default the repo root): ci gate → npm publish (non-private only) → git tag → push → GitHub release, notes from that unit CHANGELOG section. Resumable after a partial failure, which is why it takes no --all.',
13
+ examples: [
14
+ ['rehearse — touches nothing', 'shipkit release --dry-run'],
15
+ ['release the version in package.json', 'shipkit release'],
16
+ ['finish a sub-unit bump stopped part way (ids: shipkit releases)', 'shipkit release rvlib-shipkit'],
17
+ ['release with dirt elsewhere in the worktree', 'shipkit release --allow-dirty'],
18
+ ],
19
+ })
20
+
21
+ unit = Option.String({ required: false })
22
+ dryRun = Option.Boolean('--dry-run', false, { description: 'run every preflight and print the plan, change nothing' })
23
+ allowDirty = Option.Boolean('--allow-dirty', false, { description: 'release from an unclean tree (the tag will not contain it). A SUB-unit still refuses on dirt inside its own path' })
24
+ skipGate = Option.Boolean('--skip-gate', false, { description: 'skip the ci gate preflight — publishes unverified, use only to recover a stuck release' })
25
+
26
+ async execute(): Promise<number> {
27
+ try {
28
+ const config = await loadRepoConfig(process.cwd())
29
+ return await runRelease(process.cwd(), config, {
30
+ dryRun: this.dryRun,
31
+ allowDirty: this.allowDirty,
32
+ skipGate: this.skipGate,
33
+ unit: this.unit,
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
+ }