rvlib-shipkit 0.19.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 CHANGED
@@ -1,5 +1,19 @@
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
+
11
+ ## 0.20.0
12
+
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.
14
+ - **`start` is the from-anywhere bootstrap, `init` says it is cwd-only.** His friction: "no idea what shipkit start does" and init never said where it acts. `shipkit start <name>` now also WIRES the project (runs the init scaffold) when `~/dev/<name>` already has a package.json, so a pre-existing repo goes zero to shipkit'd with one command from any cwd; an empty new dir prints the next step on stderr. stdout stays the dir alone (the cd-alias contract, frozen by test). Both usage texts state their scope and point at each other.
15
+ - **The commit-message gate blocks French.** Git surfaces are English, always. New `language` offense in the shared predicate: an accented letter, or two distinct French function words on one line, trips it. Quoted spans (backticks, double quotes, guillemets) are exempt as product copy under discussion. Single quotes stay in scope because they are French apostrophes. `hooks.languageAllow` in repo.config exempts listed words (proper nouns that carry accents without being a language choice), and `hooks.disable: ['commit-language']` lifts only the French check, never the dash and attribution guards. Write-time only: SK009 history audits still scan attribution and dashes, so no repo retro-fails on old French commits.
16
+
3
17
  ## 0.19.0
4
18
 
5
19
  - **The attribution tell list covers session trailers and assistant share urls.** `<assistant>-Session:` trailers (claude, chatgpt, copilot, cursor, aider, devin, gemini, codex) and claude.ai/claude.com/chatgpt.com/chat.openai.com urls now trip the shared predicate (commit-msg hook + SK009 history scan). Field case: 162 session links sat in a history both gates called clean.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.19.0",
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_Ci.ts CHANGED
@@ -35,6 +35,7 @@ export class CiCommand extends Command {
35
35
  json: this.json,
36
36
  fix: this.fix,
37
37
  fixRules: this.fixRules,
38
+ armHooks: true,
38
39
  frozen: this.frozen,
39
40
  skipGroups: this.skip,
40
41
  withIds: this.withIds,
@@ -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', 'bunx rvlib-shipkit', { description: 'how the hook body invokes shipkit (in-repo consumers pass `bun <path to cli.ts>`)' })
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`)
@@ -8,9 +8,9 @@ export class InitCommand extends Command {
8
8
 
9
9
  static override usage = Command.Usage({
10
10
  category: 'Setup',
11
- description: 'scaffold repo.config.ts + ci/check/contracts package.json scripts, then print how the floor resolved',
11
+ description: 'wire THE CURRENT DIRECTORY as a shipkit repo, like git init: cwd only, never creates a project dir. Scaffolds repo.config.ts + ci/check/contracts package.json scripts, then prints how the floor resolved. Idempotent on an already-wired repo. To create or adopt ~/dev/<name> from anywhere: `shipkit start <name>`',
12
12
  examples: [
13
- ['bootstrap the current repo', 'shipkit init'],
13
+ ['wire the repo you are standing in', 'shipkit init'],
14
14
  ['in-repo-source consumer: scripts run the CLI by path', 'shipkit init --bin wiki/build/shipkit/rvlib-shipkit/src/cli.ts'],
15
15
  ],
16
16
  })
@@ -9,10 +9,11 @@ export class StartCommand extends Command {
9
9
 
10
10
  static override usage = Command.Usage({
11
11
  category: 'Setup',
12
- description: 'ensure <root>/<name> exists, optionally with a CLAUDE.md include line. Prints the dir on stdout so a shell alias can cd there',
12
+ description: 'create (or enter) ~/dev/<name> from ANY cwd: ensures the dir, seeds CLAUDE.md include lines, and wires shipkit on the spot (repo.config.ts via init) when the project already has a package.json. An empty new dir prints the next step instead. Prints the dir on stdout so a shell alias can cd there. cwd-only sibling: `shipkit init`',
13
13
  examples: [
14
- ['start (or enter) a repo', 'shipkit start pong'],
14
+ ['create or enter ~/dev/pong, wiring it if it is already a project', 'shipkit start pong'],
15
15
  ['seed CLAUDE.md includes', "shipkit start pong --include '@~/wiki/index.md' --include '@./agent/index.md'"],
16
+ ['adopt a pre-existing repo from anywhere', 'shipkit start my-old-project'],
16
17
  ],
17
18
  })
18
19
 
@@ -20,7 +21,7 @@ export class StartCommand extends Command {
20
21
  root = Option.String('--root', { description: 'parent dir for repos (default ~/dev)' })
21
22
  include = Option.Array('--include', { description: 'line CLAUDE.md must carry, repeatable, order kept. Omitted means only ensure the dir' })
22
23
  alsoOk = Option.Array('--also-ok', { description: 'alternative lines satisfying the first --include — never double-add' })
23
- skip = Option.Array('--skip', { description: 'repo names that never get the include' })
24
+ skip = Option.Array('--skip', { description: 'repo names shipkit never touches: no CLAUDE.md include, no wiring' })
24
25
 
25
26
  async execute(): Promise<number> {
26
27
  return startRepo(this.name, { root: this.root, include: this.include, alsoOk: this.alsoOk, skip: this.skip })
@@ -27,6 +27,8 @@ export type CiOpts = {
27
27
  fix?: boolean
28
28
  /** also apply the structural SK rule fixers during the fix pass */
29
29
  fixRules?: boolean
30
+ /** self-arm the git hooks in the fix pass (interactive ci entry only) */
31
+ armHooks?: boolean
30
32
  /** run only the checks that can block (core/run-check onlyBlocking) */
31
33
  onlyBlockingChecks?: boolean
32
34
  /** task ids the caller does not need. A sub-unit release skips the tasks
@@ -76,7 +78,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
76
78
  // mechanical failures are not information, so they never cost a round trip
77
79
  let fixFailed = false
78
80
  if (o.fix !== false)
79
- fixFailed = (await runFixPass(root, c, { rules: o.fixRules !== false, print: sink })).failed
81
+ fixFailed = (await runFixPass(root, c, { rules: o.fixRules !== false, armHooks: o.armHooks === true, print: sink })).failed
80
82
  else sink('skip: fix pass (--no-fix)')
81
83
  const all = allTasks(c, root)
82
84
  const { disabled, unfilled } = all
@@ -11,6 +11,7 @@
11
11
  import { existsSync, readFileSync } from 'fs'
12
12
  import { join } from 'path'
13
13
  import { fileURLToPath } from 'url'
14
+ import { armHooks } from '../hooks/run-hooks.ts'
14
15
  import type { RepoConfig } from '../repo-config.ts'
15
16
  import { runFix } from '../rules/run-fix.ts'
16
17
  import { spawnWithTimeout } from './spawn-timeout.ts'
@@ -105,6 +106,10 @@ function treeState(root: string): Map<string, string> | null {
105
106
  export type FixPassOpts = {
106
107
  /** also apply the structural SK rule fixers (default false) */
107
108
  rules?: boolean
109
+ /** also self-arm the git hooks (default false — ONLY the interactive
110
+ * `shipkit ci` entry passes true; bump and release gates must never
111
+ * mutate the tree they are about to ship) */
112
+ armHooks?: boolean
108
113
  print: (line: string) => void
109
114
  }
110
115
 
@@ -120,6 +125,16 @@ export async function runFixPass(root: string, config: RepoConfig, o: FixPassOpt
120
125
  // verify mode: same fixers, but rewriting anything means the COMMIT was
121
126
  // not clean, and the gate says so instead of quietly repairing it
122
127
  const verify = process.env.CI != null && process.env.CI !== ''
128
+ // commit gates arm themselves on the interactive gate: a repo.config with
129
+ // dark hooks is the half-wired state that let a whole drill session
130
+ // commit ungated (2026-08-04). Runs BEFORE the tree snapshot: arming is
131
+ // its own reported concern, never counted as a fixer rewrite.
132
+ if (o.armHooks === true && !verify) {
133
+ const armed = armHooks(root, config)
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}`)
137
+ }
123
138
  const { slots, unfilled, disabled } = resolveFixers(root, config)
124
139
  for (const u of unfilled) o.print(`skip: ${u.id} (${u.reason})`)
125
140
  for (const d of disabled) o.print(`skip: ${d.id} (${d.reason})`)
@@ -4,9 +4,37 @@
4
4
  //
5
5
  // mechanical text rules only, so this stays publishable.
6
6
 
7
- export type OffenseKind = 'dash' | 'attribution'
7
+ export type OffenseKind = 'dash' | 'attribution' | 'language'
8
8
  export type Offense = { line: number; text: string; kind: OffenseKind; why: string }
9
9
 
10
+ /** git surfaces are English, always (identity/writing-voice.md). cheap French
11
+ * detector: an accented letter, or two distinct French function words on one
12
+ * line. quoted spans are exempt: they carry product copy under discussion
13
+ * ("Modification refusée"), not the message's own voice. single quotes stay
14
+ * in (they are French apostrophes: stripping l'écran would hide the French).
15
+ * `languageAllow` (repo.config hooks) exempts listed words, for proper nouns
16
+ * that carry accents without being a language choice. */
17
+ const QUOTED_SPANS = /`[^`\n]*`|"[^"\n]*"|“[^”\n]*”|«[^»\n]*»/g
18
+ const FRENCH_ACCENTS = /[àâçéèêëîïôùûœÀÂÇÉÈÊËÎÏÔÙÛŒ]/
19
+ const FRENCH_STOPWORDS =
20
+ /\b(?:le|la|les|un|une|des|du|au|aux|et|est|sont|avec|pour|dans|sur|pas|que|qui|ne|se|ce|cette|ces|chaque|tout|toute|tous|toutes|jamais|toujours|mais|donc|sans|sous|vers|chez|depuis|encore|aussi|devient|deviennent|nouvelle|ancienne)\b/gi
21
+
22
+ const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
23
+
24
+ export function frenchHits(line: string, languageAllow: string[] = []): boolean {
25
+ let prose = line.replace(QUOTED_SPANS, ' ')
26
+ for (const word of languageAllow) prose = prose.replace(new RegExp(`(?:^|(?<=[^\\p{L}]))${escapeRe(word)}(?:'s)?(?=$|[^\\p{L}])`, 'giu'), ' ')
27
+ if (FRENCH_ACCENTS.test(prose)) return true
28
+ const words = new Set<string>()
29
+ // an ALL-CAPS token is an acronym (UN, EST, CE), not a French word
30
+ for (const m of prose.matchAll(FRENCH_STOPWORDS)) {
31
+ const w = m[0]
32
+ if (w.length > 1 && w === w.toUpperCase()) continue
33
+ words.add(w.toLowerCase())
34
+ }
35
+ return words.size >= 2
36
+ }
37
+
10
38
  /** trailers and footers that name an assistant, assistant session trailers,
11
39
  * assistant share-domain urls (a session link is a louder tell than any
12
40
  * trailer), plus the robot emoji. the session/url family lists every
@@ -25,7 +53,7 @@ export function stripNonMessage(message: string): string {
25
53
  * `#` comment lines never land in the message and are ignored; a `Revert "…"`
26
54
  * title quotes the reverted subject verbatim and is exempt; a line-start
27
55
  * `- ` bullet is a list marker, not punctuation. */
28
- export function commitMessageOffenses(message: string): Offense[] {
56
+ export function commitMessageOffenses(message: string, opts?: { languageAllow?: string[] }): Offense[] {
29
57
  const offenses: Offense[] = []
30
58
  stripNonMessage(message).split('\n').forEach((raw, i) => {
31
59
  if (raw.startsWith('#')) return
@@ -37,6 +65,8 @@ export function commitMessageOffenses(message: string): Offense[] {
37
65
  offenses.push({ line: i + 1, text: raw, kind: 'dash', why: 'inline " - " separator: same fix' })
38
66
  if (AI_ATTRIBUTION.test(raw))
39
67
  offenses.push({ line: i + 1, text: raw, kind: 'attribution', why: 'AI attribution: no assistant trailer, session trailer, claude.ai/chatgpt.com url, "Generated with" footer, or robot emoji' })
68
+ if (frenchHits(raw, opts?.languageAllow))
69
+ offenses.push({ line: i + 1, text: raw, kind: 'language', why: 'French: git surfaces are English, always (identity/writing-voice.md); quote product copy in "…" or `…` to exempt it' })
40
70
  })
41
71
  return offenses
42
72
  }
@@ -3,8 +3,9 @@
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
+ import { fileURLToPath } from 'url'
8
9
  import { commitMessageOffenses } from './commit-message.ts'
9
10
  import { globalKeywordFile, runLeakGuard } from './leak-guard.ts'
10
11
  import { runRootIdentity } from './root-identity.ts'
@@ -23,13 +24,56 @@ export type HooksConfig = {
23
24
  /** staged-path prefixes that may never enter git (local state like
24
25
  * '.comfy-ts/'). gitignore is advisory and editable; this gate is not. */
25
26
  blockedPaths?: string[]
26
- /** disable a built-in gate, visible in `hooks list`, never silent */
27
- disable?: ('leak-guard' | 'commit-message' | 'root-identity')[]
27
+ /** words the commit-message language gate never counts as French
28
+ * (proper nouns that carry accents without being a language choice) */
29
+ languageAllow?: string[]
30
+ /** disable a built-in gate, visible in `hooks list`, never silent.
31
+ * 'commit-language' lifts ONLY the French check, keeping the dash and
32
+ * AI-attribution guards armed. */
33
+ disable?: ('leak-guard' | 'commit-message' | 'root-identity' | 'commit-language')[]
28
34
  }
29
35
 
30
36
  const HOOK_DIR = 'hooks'
31
37
  const HOOKS = ['pre-commit', 'commit-msg'] as const
32
38
 
39
+ /** how a self-written hook body should invoke shipkit in this repo: the
40
+ * repo's own node_modules copy when it has one (survives shim churn, pins
41
+ * the repo's version), else the cli that is running right now. */
42
+ export function hookBinInvocation(root: string): string {
43
+ const local = join(root, 'node_modules/rvlib-shipkit/src/cli.ts')
44
+ if (existsSync(local)) return 'bun node_modules/rvlib-shipkit/src/cli.ts'
45
+ const running = join(dirname(fileURLToPath(import.meta.url)), '..', 'cli.ts')
46
+ const prefix = root.endsWith('/') ? root : root + '/'
47
+ return running.startsWith(prefix) ? `bun ${running.slice(prefix.length)}` : `bun ${running}`
48
+ }
49
+
50
+ export type ArmHooksResult = { state: 'armed' | 'current' | 'foreign' | 'no-git' | 'not-root' | 'failed'; detail: string }
51
+
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). 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). */
59
+ export function armHooks(root: string, config: RepoConfig): ArmHooksResult {
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
+ }
68
+ const p = Bun.spawnSync(['git', 'config', 'core.hooksPath'], { cwd: root })
69
+ const current = p.exitCode === 0 ? p.stdout.toString().trim() : ''
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' }
72
+ const r = installHooks(root, hookBinInvocation(root), config.hooks?.keywordFile)
73
+ if (r.note.startsWith('WARN')) return { state: 'failed', detail: r.note }
74
+ return { state: 'armed', detail: `wrote ${r.written.join(', ') || 'nothing'}, ${r.note}` }
75
+ }
76
+
33
77
  /** wire `core.hooksPath` and write the two hook bodies. Idempotent.
34
78
  * `keywordFile` = the repo's configured leak-guard file, seeding target. */
35
79
  export function installHooks(root: string, binInvocation: string, keywordFile?: string): { written: string[]; note: string } {
@@ -149,9 +193,11 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
149
193
  const out: GateOutcome[] = []
150
194
  const message = readFileSync(messageFile, 'utf8')
151
195
  if (!(h.disable ?? []).includes('commit-message')) {
152
- const offenses = commitMessageOffenses(message)
196
+ const languageOff = (h.disable ?? []).includes('commit-language')
197
+ const offenses = commitMessageOffenses(message, { languageAllow: h.languageAllow })
198
+ .filter((o) => o.kind !== 'language' || !languageOff)
153
199
  out.push(offenses.length === 0
154
- ? { id: 'commit-message', ok: true, lines: ['ok: commit message clean (no dash punctuation, no AI attribution)'] }
200
+ ? { id: 'commit-message', ok: true, lines: [`ok: commit message clean (no dash punctuation, no AI attribution${languageOff ? '' : ', English'})`] }
155
201
  : {
156
202
  id: 'commit-message',
157
203
  ok: false,
@@ -192,7 +238,7 @@ export function listGates(config: RepoConfig, root = process.cwd()): string[] {
192
238
  ...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths — nothing under ${h.blockedPaths!.join(', ')} enters git`] : []),
193
239
  `pre-commit root-identity${off.has('root-identity') ? ' (DISABLED by config)' : ''} — the repo's own name in package.json + repo.config.ts, and scratch files`,
194
240
  ...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
195
- `commit-msg commit-message${off.has('commit-message') ? ' (DISABLED by config)' : ''} — dash punctuation + AI attribution`,
241
+ `commit-msg commit-message${off.has('commit-message') ? ' (DISABLED by config)' : ''} — dash punctuation + AI attribution + French${off.has('commit-language') ? ' (commit-language DISABLED by config)' : ''}`,
196
242
  `commit-msg leak-guard${leakState} — the message text`,
197
243
  ...(h.commitMsg ?? []).map((c) => `commit-msg ${c.id} — ${c.cmd.join(' ')}`),
198
244
  ]
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
- console.log(`skip: repo.config.ts already exists`)
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
- console.log(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
53
+ p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
50
54
  const mh = reportToMotherhome(root, cfg)
51
- if (mh.note != null) console.log(mh.note)
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
- console.log('ok: repo.config.ts created')
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) console.log(`skip: package.json script '${k}' exists — left untouched`)
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
- console.log(`ok: ${added} package.json script(s) added`)
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
- console.log(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
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) console.log(`ok: floor ${t.id} ← ${floor.sources[t.id] ?? 'unknown'}`)
86
- for (const u of floor.unfilled) console.log(`skip: ${u.id} (${u.reason})`)
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
- console.log('WARN rvlib-shipkit not installed here — the scripts above call its bin and will exit 127')
94
- console.log(' fix: bun add -d rvlib-shipkit')
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
- console.log('next: fill venues/social in repo.config.ts, then `shipkit ci`')
98
- console.log('next: identity+assets — mint .rv-identity/brand.ts + .rv-assets/kit.ts, `shipkit assets identity` (the identity contract expects public/ favicon+og+logo)')
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
@@ -7,6 +7,7 @@
7
7
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
8
8
  import { homedir } from 'os'
9
9
  import { join } from 'path'
10
+ import { initRepo } from './init-repo.ts'
10
11
 
11
12
  export type StartOpts = {
12
13
  /** parent dir for repos — default ~/dev */
@@ -19,7 +20,7 @@ export type StartOpts = {
19
20
  skip?: string[]
20
21
  }
21
22
 
22
- export function startRepo(name: string, opts: StartOpts = {}): number {
23
+ export async function startRepo(name: string, opts: StartOpts = {}): Promise<number> {
23
24
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
24
25
  console.error(`FAIL: bad repo name '${name}' — letters/digits/._- only, no leading dot, no slashes`)
25
26
  return 2
@@ -55,6 +56,21 @@ export function startRepo(name: string, opts: StartOpts = {}): number {
55
56
  }
56
57
  }
57
58
  }
59
+ // start is the from-anywhere verb: a dir that is already a real project
60
+ // (package.json) gets wired on the spot, so a pre-existing repo goes
61
+ // 0 → shipkit'd with the one command. init itself stays cwd-only, like
62
+ // git init. A brand-new empty dir cannot be wired yet, say what is next.
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'))) {
67
+ if (existsSync(join(dir, 'package.json'))) {
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
+ } else {
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
+ }
73
+ }
58
74
  console.log(dir)
59
75
  return 0
60
76
  }