rvlib-shipkit 0.20.0 → 0.20.1
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 +8 -0
- package/package.json +1 -1
- package/src/cli/cmd_Hooks.ts +3 -3
- package/src/cli/cmd_Start.ts +1 -1
- package/src/core/run-fixers.ts +3 -2
- package/src/hooks/run-hooks.ts +18 -8
- package/src/init-repo.ts +17 -13
- package/src/start-repo.ts +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.20.1
|
|
4
|
+
|
|
5
|
+
- **Fix: `start --skip` means shipkit never touches the repo.** 0.20.0's adoption block ran after the skip branch, so `ship <job-repo>` would have scaffolded `repo.config.ts` into a repo on the never-wire list (caught by review in a sandbox). The whole wiring block sits behind the skip list now, frozen by test.
|
|
6
|
+
- **Fix: a failed adoption never breaks the cd contract.** A broken `package.json` in the target repo downgrades wiring to a WARN on stderr, and `start` still prints the dir. `initRepo` gained a `print` sink, so `start` reports on stderr without touching the console global.
|
|
7
|
+
- **Fix: arming only ever happens at the work-tree ROOT.** `armHooks` in a subdirectory used to set the PARENT repo's `core.hooksPath` onto a dir that does not exist there, disarming the parent's gates. It compares `git rev-parse --show-toplevel` now and declines with a named state.
|
|
8
|
+
- **Fix: arming never rewrites existing hook bodies.** A repo with `core.hooksPath=hooks` and both hook files present is `current`, whatever its bodies say: arming means making dark gates live, not normalizing another repo's `--bin` choice. `hooks install` with no `--bin` now defaults to the same resolution as arming (the repo's own node_modules copy first), one invocation policy instead of two.
|
|
9
|
+
- A failure to arm prints as a WARN, never as a skip, and a successful arm says to commit the `hooks/` dir.
|
|
10
|
+
|
|
3
11
|
## 0.20.0
|
|
4
12
|
|
|
5
13
|
- **Commit gates arm themselves.** An interactive `shipkit ci` run now installs the git hooks whenever the repo has a `repo.config.ts` and `core.hooksPath` is unset or already `hooks`: hook bodies written, leak-guard keyword starter seeded, `core.hooksPath` set, idempotent. The field case: a whole drill session committed with dark gates because init was treated as "create repo.config.ts". A FOREIGN `core.hooksPath` (husky and friends) is respected and named, never taken. Hook bodies prefer the repo's own `node_modules/rvlib-shipkit` copy over the cli that happens to be running, so a repo keeps its pinned version. Scoped to the interactive gate on purpose: CI verify mode, `bump` and `release` gate runs never mutate the tree they are about to ship, and arming reports on its own line, never as a fixer rewrite.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.1",
|
|
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",
|
package/src/cli/cmd_Hooks.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// shipkit hooks, install the git hooks, run their gates, or list what
|
|
2
2
|
// blocks a commit. Thin clipanion surface; logic in hooks/run-hooks.ts.
|
|
3
3
|
import { Command, Option } from 'clipanion'
|
|
4
|
-
import { installHooks, listGates, runCommitMsg, runPreCommit, type GateOutcome } from '../hooks/run-hooks.ts'
|
|
4
|
+
import { hookBinInvocation, installHooks, listGates, runCommitMsg, runPreCommit, type GateOutcome } from '../hooks/run-hooks.ts'
|
|
5
5
|
import { loadRepoConfig } from '../repo-config.ts'
|
|
6
6
|
|
|
7
7
|
const report = (out: GateOutcome[], write: (s: string) => void): number => {
|
|
@@ -17,11 +17,11 @@ export class HooksInstallCommand extends Command {
|
|
|
17
17
|
examples: [['wire the hooks', 'shipkit hooks install']],
|
|
18
18
|
})
|
|
19
19
|
|
|
20
|
-
bin = Option.String('--bin',
|
|
20
|
+
bin = Option.String('--bin', { description: 'how the hook body invokes shipkit (default: the repo\'s own node_modules copy, else the running cli — in-repo consumers pass `bun <path to cli.ts>`)' })
|
|
21
21
|
|
|
22
22
|
async execute(): Promise<number> {
|
|
23
23
|
const config = await loadRepoConfig(process.cwd()).catch(() => null)
|
|
24
|
-
const r = installHooks(process.cwd(), this.bin, config?.hooks?.keywordFile)
|
|
24
|
+
const r = installHooks(process.cwd(), this.bin ?? hookBinInvocation(process.cwd()), config?.hooks?.keywordFile)
|
|
25
25
|
for (const w of r.written) this.context.stdout.write(`ok: wrote ${w}\n`)
|
|
26
26
|
if (r.written.length === 0) this.context.stdout.write('ok: hook bodies already current\n')
|
|
27
27
|
this.context.stdout.write(`${r.note.startsWith('WARN') ? r.note : `ok: ${r.note}`}\n`)
|
package/src/cli/cmd_Start.ts
CHANGED
|
@@ -21,7 +21,7 @@ export class StartCommand extends Command {
|
|
|
21
21
|
root = Option.String('--root', { description: 'parent dir for repos (default ~/dev)' })
|
|
22
22
|
include = Option.Array('--include', { description: 'line CLAUDE.md must carry, repeatable, order kept. Omitted means only ensure the dir' })
|
|
23
23
|
alsoOk = Option.Array('--also-ok', { description: 'alternative lines satisfying the first --include — never double-add' })
|
|
24
|
-
skip = Option.Array('--skip', { description: 'repo names
|
|
24
|
+
skip = Option.Array('--skip', { description: 'repo names shipkit never touches: no CLAUDE.md include, no wiring' })
|
|
25
25
|
|
|
26
26
|
async execute(): Promise<number> {
|
|
27
27
|
return startRepo(this.name, { root: this.root, include: this.include, alsoOk: this.alsoOk, skip: this.skip })
|
package/src/core/run-fixers.ts
CHANGED
|
@@ -131,8 +131,9 @@ export async function runFixPass(root: string, config: RepoConfig, o: FixPassOpt
|
|
|
131
131
|
// its own reported concern, never counted as a fixer rewrite.
|
|
132
132
|
if (o.armHooks === true && !verify) {
|
|
133
133
|
const armed = armHooks(root, config)
|
|
134
|
-
if (armed.state === 'armed') o.print(`ok: hooks armed (${armed.detail})`)
|
|
135
|
-
else if (armed.state === 'foreign') o.print(`skip: hooks (${armed.detail})`)
|
|
134
|
+
if (armed.state === 'armed') o.print(`ok: hooks armed (${armed.detail}), commit the hooks/ dir`)
|
|
135
|
+
else if (armed.state === 'foreign' || armed.state === 'not-root') o.print(`skip: hooks (${armed.detail})`)
|
|
136
|
+
else if (armed.state === 'failed') o.print(`WARN hooks could not arm: ${armed.detail}`)
|
|
136
137
|
}
|
|
137
138
|
const { slots, unfilled, disabled } = resolveFixers(root, config)
|
|
138
139
|
for (const u of unfilled) o.print(`skip: ${u.id} (${u.reason})`)
|
package/src/hooks/run-hooks.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// hand-rolled hooks are invisible to `shipkit check`: a repo reports green
|
|
4
4
|
// while its next commit is already doomed, and nothing can enumerate what
|
|
5
5
|
// the hooks enforce. `shipkit hooks list` answers that without reading shell.
|
|
6
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'fs'
|
|
7
7
|
import { dirname, join } from 'path'
|
|
8
8
|
import { fileURLToPath } from 'url'
|
|
9
9
|
import { commitMessageOffenses } from './commit-message.ts'
|
|
@@ -47,20 +47,30 @@ export function hookBinInvocation(root: string): string {
|
|
|
47
47
|
return running.startsWith(prefix) ? `bun ${running.slice(prefix.length)}` : `bun ${running}`
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
export type ArmHooksResult = { state: 'armed' | 'current' | 'foreign' | 'no-git'; detail: string }
|
|
50
|
+
export type ArmHooksResult = { state: 'armed' | 'current' | 'foreign' | 'no-git' | 'not-root' | 'failed'; detail: string }
|
|
51
51
|
|
|
52
52
|
/** self-arming: a repo.config with dark commit gates is the half-wired state
|
|
53
|
-
* that let a whole drill session commit ungated (2026-08-04).
|
|
54
|
-
*
|
|
53
|
+
* that let a whole drill session commit ungated (2026-08-04). Arming means
|
|
54
|
+
* making DARK gates live: hooks that already exist under an armed hooksPath
|
|
55
|
+
* are never rewritten (the repo's own --bin choice stands). A FOREIGN
|
|
56
|
+
* core.hooksPath is respected and named, and only the work-tree ROOT may
|
|
57
|
+
* arm (a subdir would set the parent's hooksPath onto a dir that does not
|
|
58
|
+
* exist there, disarming the parent's gates). */
|
|
55
59
|
export function armHooks(root: string, config: RepoConfig): ArmHooksResult {
|
|
56
|
-
const
|
|
57
|
-
if (
|
|
60
|
+
const top = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], { cwd: root })
|
|
61
|
+
if (top.exitCode !== 0) return { state: 'no-git', detail: 'not a git repo' }
|
|
62
|
+
const topDir = top.stdout.toString().trim()
|
|
63
|
+
try {
|
|
64
|
+
if (realpathSync(topDir) !== realpathSync(root)) return { state: 'not-root', detail: `work tree root is ${topDir}, not here` }
|
|
65
|
+
} catch {
|
|
66
|
+
return { state: 'no-git', detail: 'work tree root unresolvable' }
|
|
67
|
+
}
|
|
58
68
|
const p = Bun.spawnSync(['git', 'config', 'core.hooksPath'], { cwd: root })
|
|
59
69
|
const current = p.exitCode === 0 ? p.stdout.toString().trim() : ''
|
|
60
70
|
if (current !== '' && current !== HOOK_DIR) return { state: 'foreign', detail: `core.hooksPath=${current}, foreign, left alone` }
|
|
71
|
+
if (current === HOOK_DIR && HOOKS.every((h) => existsSync(join(root, HOOK_DIR, h)))) return { state: 'current', detail: 'hooks already armed' }
|
|
61
72
|
const r = installHooks(root, hookBinInvocation(root), config.hooks?.keywordFile)
|
|
62
|
-
if (r.note.startsWith('WARN')) return { state: '
|
|
63
|
-
if (r.written.length === 0 && current === HOOK_DIR) return { state: 'current', detail: 'hooks already armed' }
|
|
73
|
+
if (r.note.startsWith('WARN')) return { state: 'failed', detail: r.note }
|
|
64
74
|
return { state: 'armed', detail: `wrote ${r.written.join(', ') || 'nothing'}, ${r.note}` }
|
|
65
75
|
}
|
|
66
76
|
|
package/src/init-repo.ts
CHANGED
|
@@ -35,20 +35,24 @@ export default defineRepo({
|
|
|
35
35
|
export type InitOpts = {
|
|
36
36
|
/** in-repo-source consumers: path to shipkit's cli.ts — scripts become `bun <path> <cmd>` instead of `shipkit <cmd>` */
|
|
37
37
|
bin?: string
|
|
38
|
+
/** report sink, default console.log (startRepo passes console.error: its
|
|
39
|
+
* stdout contract is the resolved dir alone) */
|
|
40
|
+
print?: (line: string) => void
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
export async function initRepo(root: string, opts: InitOpts = {}): Promise<number> {
|
|
44
|
+
const p = opts.print ?? console.log
|
|
41
45
|
const configPath = join(root, 'repo.config.ts')
|
|
42
46
|
if (existsSync(configPath)) {
|
|
43
|
-
|
|
47
|
+
p(`skip: repo.config.ts already exists`)
|
|
44
48
|
// adoption path: re-running init on an existing repo still converges
|
|
45
49
|
// its .gitignore policy block (same fixer as `check --fix SK004`),
|
|
46
50
|
// with the repo's own contributed lines honored
|
|
47
51
|
const cfg = await loadRepoConfig(root)
|
|
48
52
|
const g = await gitignorePolicy.fix!({ root, config: cfg })
|
|
49
|
-
|
|
53
|
+
p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
|
|
50
54
|
const mh = reportToMotherhome(root, cfg)
|
|
51
|
-
if (mh.note != null)
|
|
55
|
+
if (mh.note != null) p(mh.note)
|
|
52
56
|
return 0
|
|
53
57
|
}
|
|
54
58
|
const pkgPath = join(root, 'package.json')
|
|
@@ -57,7 +61,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
57
61
|
const pkg = JSON.parse(pkgRaw) as { name?: string; scripts?: Record<string, string> }
|
|
58
62
|
const name = pkg.name ?? 'my-repo'
|
|
59
63
|
writeFileSync(configPath, CONFIG_TEMPLATE(name))
|
|
60
|
-
|
|
64
|
+
p('ok: repo.config.ts created')
|
|
61
65
|
const indent = pkgRaw.match(/^(\s+)"/m)?.[1] ?? ' '
|
|
62
66
|
pkg.scripts ??= {}
|
|
63
67
|
let added = 0
|
|
@@ -69,32 +73,32 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
69
73
|
for (const [k, v] of Object.entries(canonicalBodies({ bin: opts.bin }))) {
|
|
70
74
|
if (k === 'test' && !hasTestFiles(root)) continue
|
|
71
75
|
if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
|
|
72
|
-
else if (pkg.scripts[k] !== v)
|
|
76
|
+
else if (pkg.scripts[k] !== v) p(`skip: package.json script '${k}' exists — left untouched`)
|
|
73
77
|
}
|
|
74
78
|
if (added > 0) {
|
|
75
79
|
writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n')
|
|
76
|
-
|
|
80
|
+
p(`ok: ${added} package.json script(s) added`)
|
|
77
81
|
}
|
|
78
82
|
// the .gitignore policy block (contract git policy) — same fixer as
|
|
79
83
|
// `shipkit check --fix SK004`, one code path
|
|
80
84
|
const g = await gitignorePolicy.fix!({ root, config: defineRepo({ name }) })
|
|
81
|
-
|
|
85
|
+
p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
|
|
82
86
|
// show what the gate actually is — a thin floor must be seen at init
|
|
83
87
|
// time, not discovered after a bad commit slipped through
|
|
84
88
|
const floor = resolveFloor(defineRepo({ name }), root)
|
|
85
|
-
for (const t of floor.tasks)
|
|
86
|
-
for (const u of floor.unfilled)
|
|
89
|
+
for (const t of floor.tasks) p(`ok: floor ${t.id} ← ${floor.sources[t.id] ?? 'unknown'}`)
|
|
90
|
+
for (const u of floor.unfilled) p(`skip: ${u.id} (${u.reason})`)
|
|
87
91
|
// every verb comes from the `shipkit` bin — dead on arrival unless the
|
|
88
92
|
// package is actually installed (bunx runs without adding the dep)
|
|
89
93
|
if (opts.bin == null) {
|
|
90
94
|
try {
|
|
91
95
|
Bun.resolveSync('rvlib-shipkit', root)
|
|
92
96
|
} catch {
|
|
93
|
-
|
|
94
|
-
|
|
97
|
+
p('WARN rvlib-shipkit not installed here — the scripts above call its bin and will exit 127')
|
|
98
|
+
p(' fix: bun add -d rvlib-shipkit')
|
|
95
99
|
}
|
|
96
100
|
}
|
|
97
|
-
|
|
98
|
-
|
|
101
|
+
p('next: fill venues/social in repo.config.ts, then `shipkit ci`')
|
|
102
|
+
p('next: identity+assets — mint .rv-identity/brand.ts + .rv-assets/kit.ts, `shipkit assets identity` (the identity contract expects public/ favicon+og+logo)')
|
|
99
103
|
return 0
|
|
100
104
|
}
|
package/src/start-repo.ts
CHANGED
|
@@ -60,13 +60,13 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
|
|
|
60
60
|
// (package.json) gets wired on the spot, so a pre-existing repo goes
|
|
61
61
|
// 0 → shipkit'd with the one command. init itself stays cwd-only, like
|
|
62
62
|
// git init. A brand-new empty dir cannot be wired yet, say what is next.
|
|
63
|
-
|
|
63
|
+
// --skip means shipkit NEVER TOUCHES that repo (his job repo lives on
|
|
64
|
+
// this list), not just no CLAUDE.md. And the cd contract survives a
|
|
65
|
+
// failed adoption: the dir always prints, wiring trouble is a WARN.
|
|
66
|
+
if (!(opts.skip ?? []).includes(name) && !existsSync(join(dir, 'repo.config.ts'))) {
|
|
64
67
|
if (existsSync(join(dir, 'package.json'))) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const realLog = console.log
|
|
68
|
-
console.log = ((...a: unknown[]) => console.error(...a)) as typeof console.log
|
|
69
|
-
try { await initRepo(dir, {}) } finally { console.log = realLog }
|
|
68
|
+
try { await initRepo(dir, { print: (l) => console.error(l) }) }
|
|
69
|
+
catch (e) { console.error(`WARN could not wire ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
|
|
70
70
|
} else {
|
|
71
71
|
console.error(`next: scaffold the app in ${dir} (package.json), then \`shipkit init\` inside it — the commit gates arm on the first ci run`)
|
|
72
72
|
}
|