rvlib-shipkit 0.20.1 → 0.22.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 CHANGED
@@ -1,11 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.22.0
4
+
5
+ - **Fix: the machine-row auto-commit could not see STATUS.md.** The git helper trims its whole output, so the FIRST porcelain line loses its leading space and the old column-3 slice read "TATUS.md", making the preflight silently decline the exact dirt it was built to eat. `dirtyPathList` parses status prefixes robustly now (renames and quoted paths included, regression-tested on the trimmed-first-line case), and a decline is never silent: the preflight names the non-machine paths it refused.
6
+ - **Cached check lines carry an absolute stamp** (`cached 2026-08-05 09:12:34`), never a relative age: "cached 34m ago" re-rendered on every run and turned committed STATUS.md checkpoints into noise diffs that startled agents (his call 2026-08-05). The line now changes only when the cache actually refreshes.
7
+
8
+ - **The pre-commit gate times itself.** Past ~1.5s it WARNs naming the fix (move slow preCommit commands to ci tasks), because "a commit takes ~1s" was a stated rule with no tripwire, and a repo drifting back to 40-second commits would have told nobody.
9
+ - **The bootstrap is a CHAIN, and it ends at a push.** `shipkit start` and `shipkit init` now run git init → **first commit** → PRIVATE github repo → **push**, in that order. A repo wired but never pushed had backed up nothing, which was the whole point. The chain runs in two phases around whatever the verb scaffolds, so `CLAUDE.md`, `repo.config.ts` and the package.json scripts ride INSIDE the first commit instead of trailing it as dirt.
10
+ - The first commit (`chore: initial commit`) is made only when the history has NONE: an existing history belongs to whoever wrote it, and their uncommitted work stays theirs to stage. A repo with no `.gitignore` at all gets a baseline (`node_modules/`, `.env*` while keeping `.env.example`, `.DS_Store`). A repo that HAS one keeps its own answer, and if a `node_modules` or a `.env` is still staged ANYWHERE in the tree the commit REFUSES and names them. The scan is `-uall`, because plain `git status --porcelain` collapses an untracked dir to its top entry and a nested `.env` reads as `?? packages/`. Rule SK004 stays the only writer of its managed block: `firstCommit` no longer rewrites it from a synthetic config, which deleted the lines `repo.config.ts` contributed right before staging what they covered.
11
+ - The push goes only into a repo this run CREATED, carrying a history this run WROTE. Never into an adopted one (pushing into an empty unrelated repo of the same name succeeds and mixes two projects), and never a pre-existing local history (a one way door over content nobody reviewed for that purpose). Both get a `next:` line naming the push instead.
12
+ - `--no-github` says what it does: everything stays local, the first commit included.
13
+
14
+ ## 0.21.0
15
+
16
+ - **shipkit updates ITSELF and restarts.** An interactive `ci`/`check` on an npm consumer probes the registry (24h per-machine cache), and a behind-latest `rvlib-shipkit` runs `bun update --latest` on itself, then RE-EXECS the same invocation on the new version (loop-guarded by env). A dirty package.json/lockfile defers with a WARN (a tool never mixes its writes into staged work), CI/frozen/source-path consumption never fire it, offline is a silent skip. Version drift stops being anyone's job.
17
+ - **The bump preflight commits machine rows itself.** STATUS.md and the motherhome local-projects row re-dirty on every gate run, and three releases in a row bounced on exactly that dirt. When they are the ONLY dirty paths, bump commits them ("machine rows refresh before release") and proceeds. Any human dirt still refuses.
18
+ - **`init` and `start` wire git and github, not just the shipkit scaffold.** A dir with no `.git` gets `git init -b main`; a repo with no remote gets a PRIVATE github repo (created when missing, adopted when the name is already taken) wired as `origin` through gh's own `git_protocol`. Nothing is committed, nothing is pushed. ON by default, including for an EMPTY new dir, because starting a repo means wanting it private on github.
19
+ - The github half fires for a repo being STARTED, not for a folder being entered: a dir the run just created, or one holding a package.json. An existing folder with no project gets git and a line naming `--github` to force the repo, so the everyday `ship <name>` cd (and a typo) cannot mint an account artifact. `--no-github` wires local git only, `SHIPKIT_NO_GITHUB=1` does it for every run on a machine, and `--skip` names stay untouched, git included.
20
+ - The github half never guesses: only a repo github reports as MISSING is created, so a network or permission error can never answer "does it exist?" with a new repository. No gh, no auth, a login gh did not actually answer, an unreadable answer or a failed create are each a WARN that leaves local git wired and names the next command. A create that succeeds but cannot be read back says exactly that, never "creating failed". An ADOPTED repo is a WARN with no push instruction, because a name match is not proof of identity. A dir INSIDE another work tree is never initialized, and `init` refuses a cwd that is no project before wiring anything. An adopted repo that is already PUBLIC prints the SK007 mismatch on the spot.
21
+
3
22
  ## 0.20.1
4
23
 
5
24
  - **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
25
  - **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
26
  - **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.
27
+ - **Fix: arming never rewrites hook bodies already present.** 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
28
  - A failure to arm prints as a WARN, never as a skip, and a successful arm says to commit the `hooks/` dir.
10
29
 
11
30
  ## 0.20.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.20.1",
3
+ "version": "0.22.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",
@@ -3,6 +3,7 @@
3
3
  // core/run-check.ts and rules/run-fix.ts. ci NEVER fixes.
4
4
  import { Command, Option } from 'clipanion'
5
5
  import { runCheck } from '../core/run-check.ts'
6
+ import { maybeSelfUpdateAndRestart } from '../core/self-update.ts'
6
7
  import { runFix } from '../rules/run-fix.ts'
7
8
 
8
9
  export class CheckCommand extends Command {
@@ -28,6 +29,9 @@ export class CheckCommand extends Command {
28
29
  fixIds = Option.Rest()
29
30
 
30
31
  async execute(): Promise<number> {
32
+ // a stale toolkit updates itself and restarts this exact invocation
33
+ // (loop-guarded, skipped under CI/frozen/source consumption)
34
+ await maybeSelfUpdateAndRestart(process.cwd())
31
35
  try {
32
36
  if (this.fixIds.length > 0 && !this.fix) {
33
37
  this.context.stderr.write(`FAIL: rule ids need --fix (shipkit check --fix ${this.fixIds.join(' ')})\n`)
package/src/cli/cmd_Ci.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // core/run-ci.ts.
3
3
  import { Command, Option } from 'clipanion'
4
4
  import { runCi } from '../core/run-ci.ts'
5
+ import { maybeSelfUpdateAndRestart } from '../core/self-update.ts'
5
6
 
6
7
  export class CiCommand extends Command {
7
8
  static override paths = [['ci']]
@@ -29,6 +30,9 @@ export class CiCommand extends Command {
29
30
  only = Option.Array('--only', [], { description: 'run only this task + its dep closure (repeatable)' })
30
31
 
31
32
  async execute(): Promise<number> {
33
+ // a stale toolkit updates itself and restarts this exact invocation
34
+ // (loop-guarded, skipped under CI/frozen/source consumption)
35
+ await maybeSelfUpdateAndRestart(process.cwd())
32
36
  try {
33
37
  return await runCi(process.cwd(), {
34
38
  bail: this.bail,
@@ -8,18 +8,20 @@ export class InitCommand extends Command {
8
8
 
9
9
  static override usage = Command.Usage({
10
10
  category: 'Setup',
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>`',
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, prints how the floor resolved, and runs the bootstrap chain around it: git init, then first commit + PRIVATE github repo + push once the scaffold is in. Idempotent on an already-wired repo. To create or adopt ~/dev/<name> from anywhere: `shipkit start <name>`',
12
12
  examples: [
13
13
  ['wire the repo you are standing in', 'shipkit init'],
14
+ ['local git and the first commit, never touch github', 'shipkit init --no-github'],
14
15
  ['in-repo-source consumer: scripts run the CLI by path', 'shipkit init --bin wiki/build/shipkit/rvlib-shipkit/src/cli.ts'],
15
16
  ],
16
17
  })
17
18
 
18
19
  bin = Option.String('--bin', { description: 'path to shipkit cli.ts — scripts become `bun <path> <cmd>` instead of `shipkit <cmd>`' })
20
+ github = Option.Boolean('--github', { description: 'ON by default: a repo with no remote gets a PRIVATE github repo wired as origin, and the first commit is pushed to it. --no-github keeps everything local, the first commit included (SHIPKIT_NO_GITHUB=1 does it for every run)' })
19
21
 
20
22
  async execute(): Promise<number> {
21
23
  try {
22
- return await initRepo(process.cwd(), { bin: this.bin })
24
+ return await initRepo(process.cwd(), { bin: this.bin, github: this.github })
23
25
  } catch (e) {
24
26
  this.context.stderr.write(`FAIL: ${e instanceof Error ? e.message : String(e)}\n`)
25
27
  return 1
@@ -9,11 +9,13 @@ export class StartCommand extends Command {
9
9
 
10
10
  static override usage = Command.Usage({
11
11
  category: 'Setup',
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`',
12
+ description: 'create (or enter) ~/dev/<name> from ANY cwd: ensures the dir, seeds CLAUDE.md include lines, wires shipkit on the spot (repo.config.ts via init) when the project already has a package.json, then runs the whole bootstrap chain: git init, first commit, PRIVATE github repo, push. Prints the dir on stdout so a shell alias can cd there. cwd-only sibling: `shipkit init`',
13
13
  examples: [
14
- ['create or enter ~/dev/pong, wiring it if it is already a project', 'shipkit start pong'],
14
+ ['create or enter ~/dev/pong: dir, wiring, git, first commit, private github repo, push', 'shipkit start pong'],
15
15
  ['seed CLAUDE.md includes', "shipkit start pong --include '@~/wiki/index.md' --include '@./agent/index.md'"],
16
16
  ['adopt a pre-existing repo from anywhere', 'shipkit start my-old-project'],
17
+ ['force the private github repo for a folder that was already there', 'shipkit start old-notes --github'],
18
+ ['local git and the first commit, never touch github', 'shipkit start pong --no-github'],
17
19
  ],
18
20
  })
19
21
 
@@ -22,8 +24,9 @@ export class StartCommand extends Command {
22
24
  include = Option.Array('--include', { description: 'line CLAUDE.md must carry, repeatable, order kept. Omitted means only ensure the dir' })
23
25
  alsoOk = Option.Array('--also-ok', { description: 'alternative lines satisfying the first --include — never double-add' })
24
26
  skip = Option.Array('--skip', { description: 'repo names shipkit never touches: no CLAUDE.md include, no wiring' })
27
+ github = Option.Boolean('--github', { description: 'ON by default when a repo is being STARTED (a dir this run created, or one with a package.json): it gets a PRIVATE github repo wired as origin, and the first commit is pushed to it. Pass --github to force it for a pre-existing folder, --no-github to keep everything local, the first commit included (SHIPKIT_NO_GITHUB=1 does it for every run)' })
25
28
 
26
29
  async execute(): Promise<number> {
27
- return startRepo(this.name, { root: this.root, include: this.include, alsoOk: this.alsoOk, skip: this.skip })
30
+ return startRepo(this.name, { root: this.root, include: this.include, alsoOk: this.alsoOk, skip: this.skip, github: this.github })
28
31
  }
29
32
  }
@@ -11,7 +11,7 @@
11
11
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
12
12
  import { join } from 'path'
13
13
  import { CheckCtx, RateLimited, ToolUnresolved } from './check-ctx.ts'
14
- import { fmtAge, formatLine, parseDuration, type Line } from './wire.ts'
14
+ import { fmtStamp, formatLine, parseDuration, type Line } from './wire.ts'
15
15
 
16
16
  type Logic = (t: CheckCtx) => unknown
17
17
  type Prep = { skip?: string; cacheExtra?: string }
@@ -143,8 +143,11 @@ export class CheckSuite {
143
143
  && entry != null && entry.version === r.version && entry.key === key
144
144
  && this.now() - entry.at < r.ttlMs && this.now() >= entry.at
145
145
  if (fresh) {
146
- const age = fmtAge(this.now() - entry.at)
147
- const lines = entry.lines.map((l, i): Line => (i === 0 ? { ...l, text: `${l.text} (cached ${age} ago)` } : l))
146
+ // absolute stamp, never "Xm ago": a relative age re-renders on
147
+ // every run and turns committed STATUS.md checkpoints into
148
+ // noise diffs (his call 2026-08-05). The line now changes only
149
+ // when the cache actually refreshes.
150
+ const lines = entry.lines.map((l, i): Line => (i === 0 ? { ...l, text: `${l.text} (cached ${fmtStamp(entry.at)})` } : l))
148
151
  out.push(...lines)
149
152
  this.onResult?.(r.name, { lines, status: 'pass', cached: true }) // only clean passes are cached
150
153
  continue
@@ -0,0 +1,118 @@
1
+ // zero-friction self-update: an interactive ci/check run on an npm consumer
2
+ // checks the registry (24h cache on disk), updates rvlib-shipkit when it is
3
+ // behind, and RE-EXECS the same invocation so the run continues on the new
4
+ // version. a stale toolkit must never wait for a human to police versions.
5
+ //
6
+ // never fires: running from source (not under node_modules), CI env, frozen,
7
+ // re-exec guard set, dirty package.json/lockfile (a tool must not mix its
8
+ // writes into human-staged work), registry unreachable (offline is not a
9
+ // defect). every skip is silent except the actionable ones, which WARN.
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
11
+ import { homedir } from 'os'
12
+ import { dirname, join } from 'path'
13
+ import { fileURLToPath } from 'url'
14
+
15
+ const PKG = 'rvlib-shipkit'
16
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000
17
+
18
+ export type SelfUpdatePlan =
19
+ | { action: 'skip'; reason: string }
20
+ | { action: 'warn'; reason: string }
21
+ | { action: 'update'; installed: string; latest: string }
22
+
23
+ /** pure decision, testable without network or filesystem */
24
+ export function planSelfUpdate(o: {
25
+ installed: string | null
26
+ latest: string | null
27
+ runningFromNodeModules: boolean
28
+ reexecGuard: boolean
29
+ ci: boolean
30
+ frozen: boolean
31
+ manifestDirty: boolean
32
+ }): SelfUpdatePlan {
33
+ if (o.reexecGuard) return { action: 'skip', reason: 'already re-executed once' }
34
+ if (o.ci) return { action: 'skip', reason: 'CI env' }
35
+ if (o.frozen) return { action: 'skip', reason: 'frozen' }
36
+ if (!o.runningFromNodeModules) return { action: 'skip', reason: 'running from source' }
37
+ if (o.installed == null) return { action: 'skip', reason: 'not an npm consumer here' }
38
+ if (o.latest == null) return { action: 'skip', reason: 'registry unreachable' }
39
+ if (o.installed === o.latest) return { action: 'skip', reason: 'current' }
40
+ if (newerThan(o.installed, o.latest)) return { action: 'skip', reason: 'ahead of the registry' }
41
+ if (o.manifestDirty) return { action: 'warn', reason: `rvlib-shipkit ${o.installed} is behind ${o.latest}, package.json or lockfile dirty, not touching — commit, then rerun` }
42
+ return { action: 'update', installed: o.installed, latest: o.latest }
43
+ }
44
+
45
+ /** true when a is strictly newer than b (plain x.y.z compare, prerelease ignored) */
46
+ const newerThan = (a: string, b: string): boolean => {
47
+ const pa = a.split('.').map(Number)
48
+ const pb = b.split('.').map(Number)
49
+ for (let i = 0; i < 3; i++) {
50
+ if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true
51
+ if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false
52
+ }
53
+ return false
54
+ }
55
+
56
+ const cachePath = (): string => join(homedir(), '.cache', 'shipkit', 'self-update.json')
57
+
58
+ /** npm latest with a per-machine 24h cache, null when unreachable */
59
+ async function latestCached(): Promise<string | null> {
60
+ const p = cachePath()
61
+ try {
62
+ const c = JSON.parse(readFileSync(p, 'utf8')) as { at: number; version: string }
63
+ if (Date.now() - c.at < CACHE_TTL_MS) return c.version
64
+ } catch { /* no cache yet */ }
65
+ try {
66
+ const res = await fetch(`https://registry.npmjs.org/${PKG}/latest`, { signal: AbortSignal.timeout(5000) })
67
+ if (!res.ok) return null
68
+ const v = ((await res.json()) as { version?: string }).version ?? null
69
+ if (v != null) {
70
+ mkdirSync(dirname(p), { recursive: true })
71
+ writeFileSync(p, JSON.stringify({ at: Date.now(), version: v }))
72
+ }
73
+ return v
74
+ } catch {
75
+ return null
76
+ }
77
+ }
78
+
79
+ const manifestDirty = (root: string): boolean => {
80
+ const p = Bun.spawnSync(['git', 'status', '--porcelain', '--', 'package.json', 'bun.lock', 'bun.lockb', 'package-lock.json'], { cwd: root })
81
+ if (p.exitCode !== 0) return true // no git = no safe write
82
+ return p.stdout.toString().trim() !== ''
83
+ }
84
+
85
+ /** run before dispatching an interactive ci/check. On update: re-exec the
86
+ * SAME argv with a loop guard and exit with the child's code (never
87
+ * returns). Otherwise returns after at most one cached registry probe. */
88
+ export async function maybeSelfUpdateAndRestart(root: string): Promise<void> {
89
+ const here = fileURLToPath(import.meta.url)
90
+ const base = {
91
+ installed: installedVersion(root),
92
+ runningFromNodeModules: here.includes(`${join('node_modules', PKG)}/`),
93
+ reexecGuard: process.env.SHIPKIT_SELF_UPDATED === '1',
94
+ ci: process.env.CI != null && process.env.CI !== '',
95
+ frozen: process.env.SHIPKIT_FROZEN === '1',
96
+ }
97
+ // cheap gates first: no network unless an update is even possible
98
+ if (base.reexecGuard || base.ci || base.frozen || !base.runningFromNodeModules || base.installed == null) return
99
+ const plan = planSelfUpdate({ ...base, latest: await latestCached(), manifestDirty: manifestDirty(root) })
100
+ if (plan.action === 'warn') { console.error(`WARN self-update: ${plan.reason}`); return }
101
+ if (plan.action !== 'update') return
102
+ console.error(`ok: rvlib-shipkit ${plan.installed} → ${plan.latest}, self-updating (bun update --latest ${PKG})`)
103
+ const up = Bun.spawnSync(['bun', 'update', '--latest', PKG], { cwd: root, stdout: 'inherit', stderr: 'inherit' })
104
+ if (up.exitCode !== 0) { console.error(`WARN self-update failed (exit ${up.exitCode}), continuing on ${plan.installed}`) ; return }
105
+ console.error(`ok: restarting on ${plan.latest} — commit package.json + lockfile with your work`)
106
+ const child = Bun.spawnSync([process.execPath, ...process.argv.slice(1)], {
107
+ cwd: process.cwd(),
108
+ stdout: 'inherit', stderr: 'inherit', stdin: 'inherit',
109
+ env: { ...process.env, SHIPKIT_SELF_UPDATED: '1' },
110
+ })
111
+ process.exit(child.exitCode ?? 1)
112
+ }
113
+
114
+ const installedVersion = (root: string): string | null => {
115
+ const p = join(root, 'node_modules', PKG, 'package.json')
116
+ if (!existsSync(p)) return null
117
+ try { return (JSON.parse(readFileSync(p, 'utf8')) as { version?: string }).version ?? null } catch { return null }
118
+ }
package/src/core/wire.ts CHANGED
@@ -49,8 +49,11 @@ export function parseDuration(d: string | number): number {
49
49
  return Number(m[1]) * { ms: 1, s: 1000, m: 60_000, h: 3600_000, d: 86400_000 }[m[2] as 'ms' | 's' | 'm' | 'h' | 'd']
50
50
  }
51
51
 
52
- export const fmtAge = (ms: number): string =>
53
- ms < 60_000 ? `${Math.round(ms / 1000)}s`
54
- : ms < 3600_000 ? `${Math.round(ms / 60_000)}m`
55
- : ms < 86400_000 ? `${Math.round(ms / 3600_000)}h`
56
- : `${Math.round(ms / 86400_000)}d`
52
+ /** absolute local stamp (2026-08-05 09:12:34), never a relative age: a
53
+ * relative render changes every run and turns committed STATUS.md
54
+ * checkpoints into noise diffs */
55
+ export const fmtStamp = (epochMs: number): string => {
56
+ const d = new Date(epochMs)
57
+ const p = (n: number): string => String(n).padStart(2, '0')
58
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
59
+ }
@@ -0,0 +1,284 @@
1
+ // the bootstrap chain a started repo gets, in this order: git init, first
2
+ // commit, PRIVATE github repo, push. A project that stops before the push is
3
+ // still one disk away from gone, so the chain only counts when it completes.
4
+ // `shipkit init` and `shipkit start` both run it, in two phases: the repo
5
+ // first (cheap, local), then the publish LAST, so whatever those verbs
6
+ // scaffold rides inside the first commit instead of trailing it. Every step
7
+ // is idempotent and SILENT when it has nothing to do.
8
+ //
9
+ // The github half is ON by default, because starting a repo means wanting it
10
+ // private on github. It is timid about everything else: it only ever creates
11
+ // PRIVATE repositories (flipping one public is a human act behind the
12
+ // pre-public audit), it creates only what github reports as MISSING, it
13
+ // pushes only into a repo it created THIS RUN (a name match is not proof of
14
+ // identity), it never touches a history that already has commits, and every
15
+ // unverifiable path (no gh, not authenticated, offline) is a WARN that
16
+ // leaves the local repo wired and names the next move.
17
+ import { existsSync, realpathSync } from 'fs'
18
+ import { basename, join } from 'path'
19
+ import { spawnWithTimeout } from './core/spawn-timeout.ts'
20
+
21
+ export type Run = (cmd: string[]) => Promise<{ code: number; out: string }>
22
+
23
+ export type PublishOpts = {
24
+ /** github repository name — default the directory's basename */
25
+ name?: string
26
+ /** true forces the publish chain, false refuses the github half.
27
+ * undefined (the default) runs it when this really is a repo being
28
+ * STARTED: see `justCreated` */
29
+ github?: boolean
30
+ /** this run created the dir, so the intent was to start a project. Left
31
+ * false, a dir that already existed needs a package.json to earn a
32
+ * commit and a remote, which is what keeps `ship <old-folder>` from
33
+ * committing someone's photos and minting a repo nobody asked for */
34
+ justCreated?: boolean
35
+ /** report sink, default console.log */
36
+ print?: (line: string) => void
37
+ /** how `gh` runs. Injected by tests, which never touch a real account:
38
+ * a runner (fake account) or an explicit null (no gh on this machine).
39
+ * Undefined resolves the real binary, which is why no test may leave it
40
+ * undefined and no test may lift SHIPKIT_NO_GITHUB */
41
+ gh?: Run | null
42
+ }
43
+
44
+ /** github repo names, and the same shape `shipkit start` accepts for a dir */
45
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
46
+
47
+ /** what a repo with NO .gitignore at all gets before its first commit. Not a
48
+ * policy (rule SK004 owns that one), just the three things nobody ever meant
49
+ * to publish. A repo that already has a .gitignore keeps its own answer, and
50
+ * the commit refuses instead when that answer misses these */
51
+ const IGNORE_BASELINE = ['node_modules/', '.env', '.env.*', '!.env.example', '.DS_Store']
52
+
53
+ /** the one line of gh or git output worth reporting — both merge stderr into
54
+ * stdout, so the first non-empty line is the message and the rest is chatter */
55
+ export const firstGhLine = (s: string): string => s.trim().split('\n').find((l) => l.trim() !== '')?.slice(0, 160) ?? ''
56
+
57
+ /** gh mixes stderr chatter (upgrade notices, SSO warnings) into its output, so
58
+ * the JSON object is carved out rather than parsed whole */
59
+ export function carveGhJson<T>(out: string): T | null {
60
+ const start = out.indexOf('{')
61
+ const end = out.lastIndexOf('}')
62
+ if (start === -1 || end < start) return null
63
+ try {
64
+ return JSON.parse(out.slice(start, end + 1)) as T
65
+ } catch {
66
+ return null
67
+ }
68
+ }
69
+
70
+ const runner = (bin: string, root: string, timeoutMs: number): Run => async (cmd) => {
71
+ // a credential prompt would hang a bootstrap forever: git fails instead
72
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: '0' }
73
+ const r = await spawnWithTimeout([bin, ...cmd], { cwd: root, timeoutMs, env })
74
+ return { code: r.code, out: r.timedOut ? `${r.out}\n${bin} timed out after ${timeoutMs}ms` : r.out }
75
+ }
76
+
77
+ const gitIn = (root: string): Run => runner('git', root, 120_000)
78
+
79
+ const samePath = (a: string, b: string): boolean => {
80
+ try {
81
+ return realpathSync(a) === realpathSync(b)
82
+ } catch {
83
+ return a === b
84
+ }
85
+ }
86
+
87
+ type RepoView = { nameWithOwner?: string; isPrivate?: boolean; url?: string; sshUrl?: string }
88
+
89
+ /** phase one: this dir IS a git work tree root. false means the caller must
90
+ * not publish anything (a subdirectory of another repo, or git refused), and
91
+ * both callers honor it. */
92
+ export async function ensureGitRepo(root: string, opts: { print?: (line: string) => void } = {}): Promise<boolean> {
93
+ const p = opts.print ?? console.log
94
+ const git = gitIn(root)
95
+ const top = await git(['rev-parse', '--show-toplevel'])
96
+ if (top.code === 0) {
97
+ // a dir INSIDE another work tree is a subdirectory, not a repo waiting
98
+ // to be born: initializing there would shadow the parent's history
99
+ if (samePath(top.out.trim(), root)) return true
100
+ p(`skip: git — ${root} sits inside the repo at ${top.out.trim()}`)
101
+ return false
102
+ }
103
+ let init = await git(['init', '-b', 'main'])
104
+ // -b landed in git 2.28; older gits get the same result in two steps
105
+ if (init.code !== 0) {
106
+ init = await git(['init'])
107
+ if (init.code === 0) await git(['symbolic-ref', 'HEAD', 'refs/heads/main'])
108
+ }
109
+ if (init.code !== 0) {
110
+ p(`WARN git init failed: ${firstGhLine(init.out)}`)
111
+ return false
112
+ }
113
+ p('ok: git repo initialized (branch main)')
114
+ return true
115
+ }
116
+
117
+ /** phase two, run LAST: first commit → private github repo → push. */
118
+ export async function publishRepo(root: string, opts: PublishOpts = {}): Promise<void> {
119
+ const p = opts.print ?? console.log
120
+ const git = gitIn(root)
121
+
122
+ const top = await git(['rev-parse', '--show-toplevel'])
123
+ if (top.code !== 0 || !samePath(top.out.trim(), root)) return
124
+
125
+ // starting a repo means a commit and a remote. ENTERING a dir that was
126
+ // already there and is not a project means neither: `ship <name>` is also
127
+ // the everyday cd, and a typo must never commit a stranger's folder or
128
+ // mint an account artifact nobody asked for
129
+ const starting = opts.github === true || opts.justCreated === true || existsSync(join(root, 'package.json'))
130
+ const name = opts.name ?? basename(root)
131
+
132
+ const had = (await git(['rev-parse', '--verify', 'HEAD'])).code === 0
133
+ const commits = had || (starting && (await firstCommit(root, git, p)))
134
+
135
+ // a repo that already has a remote is published somewhere: nothing here
136
+ // gets to decide where its code goes. A first commit made just now still
137
+ // has to reach it, and that stays the caller's move
138
+ const remotes = await git(['remote'])
139
+ if (remotes.out.trim() !== '') {
140
+ if (!had && commits) p(`next: git push -u ${remotes.out.trim().split('\n')[0]} ${(await git(['branch', '--show-current'])).out.trim() || 'main'}`)
141
+ return
142
+ }
143
+
144
+ if (opts.github === false) return void p('skip: github — --no-github')
145
+ if (!NAME_RE.test(name)) return void p(`skip: github — '${name}' is not a usable repository name`)
146
+ if (!starting) {
147
+ p(`skip: github — ${root} already existed without a package.json`)
148
+ return void p(' fix: pass --github to commit and create the private repo anyway')
149
+ }
150
+ // an injected runner wins (a test with its own account), then the env
151
+ // switch a machine sets to turn the whole half off, then the real binary
152
+ let gh: Run | null
153
+ if (opts.gh !== undefined) gh = opts.gh
154
+ else if ((process.env.SHIPKIT_NO_GITHUB ?? '') !== '') return void p('skip: github — SHIPKIT_NO_GITHUB is set')
155
+ else gh = Bun.which('gh') == null ? null : runner('gh', root, 60_000)
156
+ if (gh == null) {
157
+ p('WARN no remote wired: gh cli not found')
158
+ return void p(' fix: install gh, `gh auth login`, then re-run this command')
159
+ }
160
+
161
+ const user = await gh(['api', 'user', '--jq', '.login'])
162
+ if (user.code !== 0) {
163
+ p(`WARN no remote wired: gh is not usable here (${firstGhLine(user.out)})`)
164
+ return void p(' fix: `gh auth login` (or reconnect), then re-run this command')
165
+ }
166
+ // stdout and stderr arrive merged, so an SSO or upgrade notice would
167
+ // otherwise become the owner and every call after it would address a
168
+ // repository that cannot exist
169
+ const login = firstGhLine(user.out)
170
+ if (!NAME_RE.test(login)) return void p(`WARN no remote wired: gh did not answer with a login (${login})`)
171
+ const target = `${login}/${name}`
172
+ const viewArgs = ['repo', 'view', target, '--json', 'nameWithOwner,isPrivate,url,sshUrl']
173
+
174
+ let view = await gh(viewArgs)
175
+ let created = false
176
+ if (view.code === 0) {
177
+ // a name match is not proof of identity: an unrelated `api` or `docs`
178
+ // in the same account matches too, and pushing into it mixes projects
179
+ p(`WARN github ${target} already exists — wiring it as origin, confirm it is THIS project before you push`)
180
+ } else {
181
+ // only a MISSING repo means "create it". a network or permission error
182
+ // must never be answered by creating something
183
+ if (!/not found|could not resolve|404/i.test(view.out)) return void p(`WARN no remote wired: gh could not read ${target} (${firstGhLine(view.out)})`)
184
+ const create = await gh(['repo', 'create', target, '--private'])
185
+ if (create.code !== 0) return void p(`WARN no remote wired: creating ${target} failed (${firstGhLine(create.out)})`)
186
+ view = await gh(viewArgs)
187
+ // the repo EXISTS at this point, so the line must never read as a
188
+ // failed creation: only the wiring is missing
189
+ if (view.code !== 0) return void p(`WARN created PRIVATE github repo ${target}, but could not read it back to wire origin (${firstGhLine(view.out)}) — re-run to finish`)
190
+ created = true
191
+ p(`ok: created PRIVATE github repo ${target}`)
192
+ }
193
+
194
+ const info = carveGhJson<RepoView>(view.out)
195
+ const proto = await gh(['config', 'get', 'git_protocol'])
196
+ const url = (firstGhLine(proto.out) === 'ssh' ? info?.sshUrl : info?.url) ?? info?.url ?? info?.sshUrl
197
+ if (url == null) return void p(`WARN no remote wired: gh answer unreadable (${firstGhLine(view.out)})`)
198
+
199
+ const add = await git(['remote', 'add', 'origin', url])
200
+ if (add.code !== 0) return void p(`WARN no remote wired: git remote add failed (${firstGhLine(add.out)})`)
201
+ p(`ok: remote origin → ${url}`)
202
+ // the declared-visibility rule (SK007) reads this same fact later, but it
203
+ // only runs once the repo has a config: say it here, at the wiring
204
+ if (info?.isPrivate === false) p(`WARN ${target} is PUBLIC — declare visibility: 'public' in repo.config.ts, or make it private (rule SK007)`)
205
+
206
+ // never into an ADOPTED repo: the warning above says to check what that
207
+ // repo is first, and a push into an empty unrelated one succeeds
208
+ if (!created) return
209
+ const branch = (await git(['branch', '--show-current'])).out.trim() || 'main'
210
+ if (!commits) return void p(`next: first commit, then \`git push -u origin ${branch}\``)
211
+ // and never a history this run did not write: publishing an existing
212
+ // local-only history is a one-way door over content nobody reviewed for
213
+ // this purpose (deleted secrets stay reachable in old objects)
214
+ if (had) return void p(`next: review what this history contains, then \`git push -u origin ${branch}\``)
215
+ const push = await git(['push', '-u', 'origin', branch])
216
+ if (push.code !== 0) {
217
+ p(`WARN nothing pushed yet: ${firstGhLine(push.out)}`)
218
+ return void p(` fix: git push -u origin ${branch}`)
219
+ }
220
+ p(`ok: pushed ${branch} → origin, the repo exists somewhere other than this disk`)
221
+ }
222
+
223
+ /** the first commit, and only ever the FIRST: a history that already has one
224
+ * belongs to whoever wrote it, and their uncommitted work is theirs to
225
+ * stage. Returns whether the repo now has a commit. */
226
+ async function firstCommit(root: string, git: Run, p: (line: string) => void): Promise<boolean> {
227
+ // -uall, never the default: `git status --porcelain` collapses an
228
+ // untracked dir to its topmost entry, so a nested .env reads as `?? src/`
229
+ // and every check below it looks at a path that is not the file
230
+ const before = await git(['status', '--porcelain', '-uall'])
231
+ if (before.code !== 0) {
232
+ p(`WARN no first commit: ${firstGhLine(before.out)}`)
233
+ return false
234
+ }
235
+ if (before.out.trim() === '') {
236
+ p('skip: first commit — nothing in this dir yet')
237
+ return false
238
+ }
239
+ // a repo with no .gitignore at all gets the baseline: the very first
240
+ // commit is the one nobody reviews, and it is the one that ships a .env
241
+ // to github. A repo that HAS one keeps its own answer, whatever it says.
242
+ // rule SK004 stays the only writer of its managed block, with the repo's
243
+ // real config: rewriting it from a synthetic one here DELETED the lines
244
+ // repo.config.ts contributed, seconds before staging what they covered
245
+ if (!existsSync(join(root, '.gitignore'))) await Bun.write(join(root, '.gitignore'), `${IGNORE_BASELINE.join('\n')}\n`)
246
+ const status = await git(['status', '--porcelain', '-uall'])
247
+ if (status.code !== 0) {
248
+ p(`WARN no first commit: ${firstGhLine(status.out)}`)
249
+ return false
250
+ }
251
+ const paths = status.out.split('\n').filter((l) => l.trim() !== '').map((l) => l.slice(3).trim().replace(/^"|"$/g, ''))
252
+ // the .gitignore in this repo does not cover these, at any depth: the
253
+ // commit is not ours to make, and a PRIVATE github repo is still a leak
254
+ const risky = paths.filter(isRisky)
255
+ if (risky.length > 0) {
256
+ p(`WARN no first commit: ${risky.slice(0, 3).join(', ')}${risky.length > 3 ? ` and ${risky.length - 3} more` : ''} would be committed`)
257
+ p(' fix: gitignore those, then commit by hand')
258
+ return false
259
+ }
260
+ const add = await git(['add', '-A'])
261
+ if (add.code !== 0) {
262
+ p(`WARN no first commit: ${firstGhLine(add.out)}`)
263
+ return false
264
+ }
265
+ const commit = await git(['commit', '-m', 'chore: initial commit'])
266
+ if (commit.code !== 0) {
267
+ p(`WARN no first commit: ${firstGhLine(commit.out)}`)
268
+ // a hook, a locked index and an unset identity all land here: only
269
+ // the last one is fixed by setting an identity
270
+ if (/user\.email|user\.name|tell me who you are/i.test(commit.out)) p(' fix: git config user.email + user.name, then re-run')
271
+ else p(' fix: resolve that, then commit by hand')
272
+ return false
273
+ }
274
+ p(`ok: initial commit (${paths.length} file${paths.length === 1 ? '' : 's'})`)
275
+ return true
276
+ }
277
+
278
+ /** never in a first commit, wherever it sits in the tree. `.env.example` and
279
+ * friends are documentation and belong in the repo */
280
+ function isRisky(f: string): boolean {
281
+ if (f.split('/').includes('node_modules')) return true
282
+ const base = basename(f)
283
+ return base.startsWith('.env') && !/^\.env\.(example|sample|template|dist)$/.test(base)
284
+ }
@@ -51,8 +51,9 @@ export type ArmHooksResult = { state: 'armed' | 'current' | 'foreign' | 'no-git'
51
51
 
52
52
  /** self-arming: a repo.config with dark commit gates is the half-wired state
53
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
54
+ * making DARK gates live: with BOTH hook files present under an armed
55
+ * hooksPath nothing is rewritten (the repo's own --bin choice stands; a
56
+ * missing file still triggers install, which overwrites a stale sibling). A FOREIGN
56
57
  * core.hooksPath is respected and named, and only the work-tree ROOT may
57
58
  * arm (a subdir would set the parent's hooksPath onto a dir that does not
58
59
  * exist there, disarming the parent's gates). */
@@ -126,8 +127,11 @@ function seedKeywordFile(root: string, keywordFile?: string): string | null {
126
127
 
127
128
  export type GateOutcome = { id: string; ok: boolean; lines: string[] }
128
129
 
129
- /** the pre-commit gate: built-in leak guard, then the repo's own commands. */
130
+ /** the pre-commit gate: built-in leak guard, then the repo's own commands.
131
+ * A commit takes ~1s (build/shipkit.md): the gate times itself, and going
132
+ * over budget WARNs naming the fix, so drift back to slow commits is loud. */
130
133
  export async function runPreCommit(root: string, config: RepoConfig): Promise<GateOutcome[]> {
134
+ const started = performance.now()
131
135
  const h = config.hooks ?? {}
132
136
  const out: GateOutcome[] = []
133
137
  if (!(h.disable ?? []).includes('leak-guard')) {
@@ -184,6 +188,13 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
184
188
  lines: r.code === 0 ? [`ok: ${c.id}`] : [`FAIL: ${c.id} (exit ${r.code})`, ...r.out.trim().split('\n').slice(-30).map((l) => ` ${l}`)],
185
189
  })
186
190
  }
191
+ const elapsed = performance.now() - started
192
+ if (elapsed > 1500)
193
+ out.push({
194
+ id: 'hook-budget',
195
+ ok: true,
196
+ lines: [`WARN pre-commit took ${(elapsed / 1000).toFixed(1)}s (budget ~1s) — move slow preCommit commands to ci tasks (build/shipkit.md)`],
197
+ })
187
198
  return out
188
199
  }
189
200
 
package/src/init-repo.ts CHANGED
@@ -11,6 +11,7 @@ import { join } from 'path'
11
11
  import { canonicalBodies } from './core/canonical-scripts.ts'
12
12
  import { hasTestFiles } from './rules/SK001-package-json-shape.ts'
13
13
  import { reportToMotherhome } from './core/motherhome.ts'
14
+ import { ensureGitRepo, publishRepo, type Run } from './git-bootstrap.ts'
14
15
  import { defineRepo, loadRepoConfig, resolveFloor } from './repo-config.ts'
15
16
  import { gitignorePolicy } from './rules/SK004-gitignore-policy.ts'
16
17
 
@@ -38,11 +39,26 @@ export type InitOpts = {
38
39
  /** report sink, default console.log (startRepo passes console.error: its
39
40
  * stdout contract is the resolved dir alone) */
40
41
  print?: (line: string) => void
42
+ /** false wires git only, never github. undefined keeps the default (on) */
43
+ github?: boolean
44
+ /** the caller already ran the publish chain (startRepo does, after this
45
+ * scaffold, so the first commit carries repo.config.ts) */
46
+ noPublish?: boolean
47
+ /** how `gh` runs — injected by tests, which never touch a real account */
48
+ gh?: Run | null
41
49
  }
42
50
 
43
51
  export async function initRepo(root: string, opts: InitOpts = {}): Promise<number> {
44
52
  const p = opts.print ?? console.log
45
53
  const configPath = join(root, 'repo.config.ts')
54
+ const pkgPath = join(root, 'package.json')
55
+ // the wrong-cwd refusal comes FIRST: a dir that is no project of any kind
56
+ // must not be left holding a git repo, let alone a github one, by a
57
+ // command whose next line says the cwd was wrong
58
+ if (!existsSync(pkgPath) && !existsSync(configPath)) { console.error('FAIL: no package.json here — run from the repo root'); return 1 }
59
+ // the repo exists before anything is scaffolded, the PUBLISH comes last
60
+ // (bottom of this function) so the first commit carries the scaffold
61
+ const wired = await ensureGitRepo(root, { print: p })
46
62
  if (existsSync(configPath)) {
47
63
  p(`skip: repo.config.ts already exists`)
48
64
  // adoption path: re-running init on an existing repo still converges
@@ -53,10 +69,10 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
53
69
  p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
54
70
  const mh = reportToMotherhome(root, cfg)
55
71
  if (mh.note != null) p(mh.note)
72
+ if (wired && opts.noPublish !== true) await publishRepo(root, { github: opts.github, gh: opts.gh, print: p })
56
73
  return 0
57
74
  }
58
- const pkgPath = join(root, 'package.json')
59
- if (!existsSync(pkgPath)) { console.error('FAIL: no package.json here — run from the repo root'); return 1 }
75
+ // guaranteed by the refusal above: no config means a package.json is here
60
76
  const pkgRaw = readFileSync(pkgPath, 'utf8')
61
77
  const pkg = JSON.parse(pkgRaw) as { name?: string; scripts?: Record<string, string> }
62
78
  const name = pkg.name ?? 'my-repo'
@@ -98,6 +114,8 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
98
114
  p(' fix: bun add -d rvlib-shipkit')
99
115
  }
100
116
  }
117
+ // last, so the first commit carries repo.config.ts and the scripts above
118
+ if (wired && opts.noPublish !== true) await publishRepo(root, { github: opts.github, gh: opts.gh, print: p })
101
119
  p('next: fill venues/social in repo.config.ts, then `shipkit ci`')
102
120
  p('next: identity+assets — mint .rv-identity/brand.ts + .rv-assets/kit.ts, `shipkit assets identity` (the identity contract expects public/ favicon+og+logo)')
103
121
  return 0
@@ -39,6 +39,18 @@ export function dirtyPaths(root: string, config: RepoConfig, pathspec?: string):
39
39
  .join('\n')
40
40
  }
41
41
 
42
+ /** porcelain lines → bare paths. Column math is a trap here: the git()
43
+ * helper trims the WHOLE output, so the FIRST line loses its leading
44
+ * status space and a fixed slice eats the path's first letter (field bug
45
+ * 2026-08-05: "STATUS.md" parsed as "TATUS.md" and the machine-row
46
+ * auto-commit silently declined). Renames keep only the NEW path. */
47
+ export const dirtyPathList = (dirty: string): string[] =>
48
+ dirty
49
+ .split('\n')
50
+ .map((l) => l.trim())
51
+ .filter((l) => l !== '')
52
+ .map((l) => (l.split(' -> ').at(-1) ?? l).replace(/^[MTADRCU?!]{1,2}\s+/, '').replace(/^"|"$/g, ''))
53
+
42
54
  export const tagExistsLocally = (root: string, tag: string): boolean =>
43
55
  git(root, ['rev-parse', '--quiet', '--verify', `refs/tags/${tag}`]).code === 0
44
56
 
@@ -27,9 +27,10 @@
27
27
  // RESUMABLE instead, because an npm publish cannot be undone and a rollback
28
28
  // that cannot roll back is a lie.
29
29
  import { existsSync, readFileSync, writeFileSync } from 'fs'
30
- import { relative } from 'path'
30
+ import { homedir } from 'os'
31
+ import { join, relative } from 'path'
31
32
  import { declaredKind, hasUnreleasedNotes, promoteUnreleased } from './changelog.ts'
32
- import { deriveRemote, dirtyPaths, git as gitAt, mergeProbe, remoteTags, tagExistsLocally } from './git-facts.ts'
33
+ import { deriveRemote, dirtyPathList, dirtyPaths, git as gitAt, mergeProbe, remoteTags, tagExistsLocally } from './git-facts.ts'
33
34
  import { unitBlockers } from './unit-gate.ts'
34
35
  import { formatUnitError, releaseOrder, renderTag, resolveUnits, unitById, unitPrepublishScripts, type ReleaseUnit } from './units.ts'
35
36
  import { environmentBlockers, runReleaseGate, releaseUnit } from './run-release.ts'
@@ -206,8 +207,37 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind |
206
207
  return 1
207
208
  }
208
209
  }
210
+ // surfaces the toolkit itself rewrites on gate runs, safe to auto-commit
211
+ const machineWrittenPaths = (r: string, c: RepoConfig): Set<string> => {
212
+ const s = new Set(['STATUS.md'])
213
+ if (c.docsBase != null) {
214
+ const base = c.docsBase.startsWith('~/') ? join(homedir(), c.docsBase.slice(2)) : c.docsBase
215
+ const page = join(base, 'build/shipkit/local-projects.md')
216
+ const prefix = r.endsWith('/') ? r : `${r}/`
217
+ if (page.startsWith(prefix)) s.add(relative(r, page))
218
+ }
219
+ return s
220
+ }
209
221
  // the tree must be clean BEFORE any branch move, or a checkout carries
210
- // uncommitted work across branches
222
+ // uncommitted work across branches. Machine-written surfaces (STATUS.md,
223
+ // the motherhome row) re-dirty on every gate run, so when they are the
224
+ // ONLY dirt the preflight commits them itself instead of bouncing the
225
+ // release back to a human (field wart 2026-08-04: three refusals in a row)
226
+ const preDirty = dirtyPaths(root, config)
227
+ if (preDirty !== '') {
228
+ const machine = machineWrittenPaths(root, config)
229
+ const paths = dirtyPathList(preDirty)
230
+ if (paths.every((p) => machine.has(p))) {
231
+ gitAt(root, ['add', '--', ...paths])
232
+ const c = gitAt(root, ['commit', '-m', 'machine rows refresh before release'])
233
+ if (c.code === 0) print('ok: machine-written rows committed (they re-dirty on every gate run)')
234
+ else print(`WARN machine-row auto-commit failed (exit ${c.code}), the refusal below says what to do:\n${c.out.split('\n').slice(-6).join('\n')}`)
235
+ } else {
236
+ // never decline in silence: name the human dirt so the refusal
237
+ // below reads as a decision, not a mystery
238
+ print(`note: dirt beyond the machine rows (${paths.filter((p) => !machine.has(p)).join(', ')}), not auto-committing`)
239
+ }
240
+ }
211
241
  const dirty = dirtyPaths(root, config)
212
242
  if (dirty !== '') { print(`FAIL: working tree not clean, commit your work first:\n${dirty.split('\n').slice(0, 10).join('\n')}`); return 1 }
213
243
 
@@ -13,6 +13,7 @@
13
13
  // unverifiable path (no git, no remote, non-github remote, gh missing or
14
14
  // erroring, unreadable answer) is a loud skip — an UNKNOWN, never red,
15
15
  // never cached.
16
+ import { carveGhJson, firstGhLine } from '../git-bootstrap.ts'
16
17
  import type { Rule } from './rule.ts'
17
18
 
18
19
  export const githubVisibility: Rule = {
@@ -28,7 +29,7 @@ export const githubVisibility: Rule = {
28
29
  lint: async (t, env) => {
29
30
  const declared = env.config.visibility ?? 'private'
30
31
  const skip = (why: string): void => void t.lines.push({ kind: 'skip', text: `github visibility unverified — ${why} (declared: ${declared})` })
31
- const firstLine = (s: string): string => s.trim().split('\n').find((l) => l.trim() !== '')?.slice(0, 120) ?? ''
32
+ const firstLine = firstGhLine
32
33
 
33
34
  const remotes = await t.exec(['git', 'remote'], { cwd: env.root })
34
35
  if (remotes.code !== 0) return skip(`git says: ${firstLine(remotes.out) || `exit ${remotes.code}`}`)
@@ -44,18 +45,9 @@ export const githubVisibility: Rule = {
44
45
 
45
46
  const r = await t.exec(['gh', 'repo', 'view', target, '--json', 'isPrivate,nameWithOwner'], { cwd: env.root, timeoutMs: 30_000 })
46
47
  if (r.code !== 0) return skip(`gh failed (${firstLine(r.out)})`)
47
- // gh mixes stderr chatter (release notices, SSO warnings) into the
48
- // captured output — carve the JSON object out before parsing, and an
49
- // unreadable answer stays an UNKNOWN, never a FAIL
50
- const start = r.out.indexOf('{')
51
- const end = r.out.lastIndexOf('}')
52
- let parsed: { isPrivate?: boolean; nameWithOwner?: string }
53
- try {
54
- if (start === -1 || end < start) throw new Error('no JSON object in output')
55
- parsed = JSON.parse(r.out.slice(start, end + 1)) as typeof parsed
56
- } catch {
57
- return skip(`gh answer unreadable (${firstLine(r.out)})`)
58
- }
48
+ // an unreadable answer stays an UNKNOWN, never a FAIL
49
+ const parsed = carveGhJson<{ isPrivate?: boolean; nameWithOwner?: string }>(r.out)
50
+ if (parsed == null) return skip(`gh answer unreadable (${firstLine(r.out)})`)
59
51
  const actual = parsed.isPrivate === true ? 'private' : 'public'
60
52
  const who = parsed.nameWithOwner ?? target
61
53
  if (actual === declared) t.ok(`github ${who} is ${actual} — matches repo.config visibility`)
package/src/shipkit.ts CHANGED
@@ -33,7 +33,7 @@ export { spawnWithTimeout } from './core/spawn-timeout.ts'
33
33
  export type { SpawnOutcome, SpawnTimeoutOpts } from './core/spawn-timeout.ts'
34
34
  export { writeStatus } from './core/write-status.ts'
35
35
  export type { StatusInput, StatusOutcome } from './core/write-status.ts'
36
- export { formatLine, parseDuration, fmtAge, parseWireOutput } from './core/wire.ts'
36
+ export { formatLine, parseDuration, fmtStamp, parseWireOutput } from './core/wire.ts'
37
37
  export type { Line } from './core/wire.ts'
38
38
  // the SK rule registry — stable-id invariants `shipkit check` enforces;
39
39
  // consumers (e.g. a wiki lint verifying mentioned ids exist) read RULES
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 { ensureGitRepo, publishRepo, type Run } from './git-bootstrap.ts'
10
11
  import { initRepo } from './init-repo.ts'
11
12
 
12
13
  export type StartOpts = {
@@ -18,6 +19,11 @@ export type StartOpts = {
18
19
  alsoOk?: string[]
19
20
  /** repo names that never get includes */
20
21
  skip?: string[]
22
+ /** true forces the github half, false refuses it. undefined = the default:
23
+ * a dir this run created, or an existing project, gets its private repo */
24
+ github?: boolean
25
+ /** how `gh` runs — injected by tests, which never touch a real account */
26
+ gh?: Run | null
21
27
  }
22
28
 
23
29
  export async function startRepo(name: string, opts: StartOpts = {}): Promise<number> {
@@ -26,7 +32,11 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
26
32
  return 2
27
33
  }
28
34
  const dir = join(opts.root ?? join(homedir(), 'dev'), name)
29
- if (!existsSync(dir)) {
35
+ // this run creating the dir IS the signal that a repo is being started,
36
+ // which is what the github half keys on: `ship <name>` is also the
37
+ // everyday cd into a folder that already exists
38
+ const justCreated = !existsSync(dir)
39
+ if (justCreated) {
30
40
  mkdirSync(dir, { recursive: true })
31
41
  console.error(`ok: created ${dir}`)
32
42
  }
@@ -59,17 +69,33 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
59
69
  // start is the from-anywhere verb: a dir that is already a real project
60
70
  // (package.json) gets wired on the spot, so a pre-existing repo goes
61
71
  // 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.
72
+ // git init. A brand-new empty dir gets git + its remote all the same, and
73
+ // is told what is next, because the shipkit scaffold needs a package.json.
63
74
  // --skip means shipkit NEVER TOUCHES that repo (his job repo lives on
64
75
  // this list), not just no CLAUDE.md. And the cd contract survives a
65
76
  // 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`)
77
+ if (!(opts.skip ?? []).includes(name)) {
78
+ // git BEFORE the shipkit scaffold, and whether or not the dir is a
79
+ // project yet: an empty new dir is still work that must not live on
80
+ // one disk. The PUBLISH (first commit → private repo → push) is the
81
+ // last thing that happens, so it carries CLAUDE.md and the scaffold
82
+ const print = (l: string): void => console.error(l)
83
+ let wired = false
84
+ try { wired = await ensureGitRepo(dir, { print }) }
85
+ catch (e) { console.error(`WARN could not wire git in ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
86
+ let unscaffolded = false
87
+ if (!existsSync(join(dir, 'repo.config.ts'))) {
88
+ if (existsSync(join(dir, 'package.json'))) {
89
+ try { await initRepo(dir, { github: opts.github, gh: opts.gh, print, noPublish: true }) }
90
+ catch (e) { console.error(`WARN could not wire ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
91
+ } else unscaffolded = true
92
+ }
93
+ if (wired) {
94
+ try { await publishRepo(dir, { name, github: opts.github, justCreated, gh: opts.gh, print }) }
95
+ catch (e) { console.error(`WARN could not publish ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
72
96
  }
97
+ // last line, so what to do next is the last thing read
98
+ if (unscaffolded) console.error(`next: scaffold the app in ${dir} (package.json), then \`shipkit init\` inside it — the commit gates arm on the first ci run`)
73
99
  }
74
100
  console.log(dir)
75
101
  return 0