rvlib-shipkit 0.20.0 → 0.21.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 +16 -0
- package/package.json +1 -1
- package/src/cli/cmd_Check.ts +4 -0
- package/src/cli/cmd_Ci.ts +4 -0
- package/src/cli/cmd_Hooks.ts +3 -3
- package/src/cli/cmd_Init.ts +4 -2
- package/src/cli/cmd_Start.ts +7 -4
- package/src/core/run-fixers.ts +3 -2
- package/src/core/self-update.ts +118 -0
- package/src/git-bootstrap.ts +165 -0
- package/src/hooks/run-hooks.ts +19 -8
- package/src/init-repo.ts +30 -15
- package/src/release/run-bump.ts +28 -2
- package/src/rules/SK007-github-visibility.ts +5 -13
- package/src/start-repo.ts +30 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.21.0
|
|
4
|
+
|
|
5
|
+
- **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.
|
|
6
|
+
- **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.
|
|
7
|
+
- **`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.
|
|
8
|
+
- 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.
|
|
9
|
+
- 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.
|
|
10
|
+
|
|
11
|
+
## 0.20.1
|
|
12
|
+
|
|
13
|
+
- **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.
|
|
14
|
+
- **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.
|
|
15
|
+
- **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.
|
|
16
|
+
- **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.
|
|
17
|
+
- A failure to arm prints as a WARN, never as a skip, and a successful arm says to commit the `hooks/` dir.
|
|
18
|
+
|
|
3
19
|
## 0.20.0
|
|
4
20
|
|
|
5
21
|
- **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.
|
|
3
|
+
"version": "0.21.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",
|
package/src/cli/cmd_Check.ts
CHANGED
|
@@ -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,
|
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_Init.ts
CHANGED
|
@@ -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.
|
|
11
|
+
description: 'wire THE CURRENT DIRECTORY as a shipkit repo, like git init: cwd only, never creates a project dir. `git init` + a PRIVATE github repo and its origin remote when either is missing, then scaffolds repo.config.ts + ci/check/contracts package.json scripts and 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
13
|
['wire the repo you are standing in', 'shipkit init'],
|
|
14
|
+
['local git only, 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. --no-github wires local git only (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
|
package/src/cli/cmd_Start.ts
CHANGED
|
@@ -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.
|
|
12
|
+
description: 'create (or enter) ~/dev/<name> from ANY cwd: ensures the dir, seeds CLAUDE.md include lines, git-inits it with a PRIVATE github repo on origin, and wires shipkit on the spot (repo.config.ts via init) when the project already has a package.json. 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,
|
|
14
|
+
['create or enter ~/dev/pong: dir, git, private github repo, wiring', '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 only, never touch github', 'shipkit start pong --no-github'],
|
|
17
19
|
],
|
|
18
20
|
})
|
|
19
21
|
|
|
@@ -21,9 +23,10 @@ export class StartCommand extends Command {
|
|
|
21
23
|
root = Option.String('--root', { description: 'parent dir for repos (default ~/dev)' })
|
|
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
|
-
skip = Option.Array('--skip', { description: 'repo names
|
|
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. Pass --github to force it for a pre-existing folder, --no-github to wire local git only (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
|
}
|
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})`)
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// git wiring for a fresh or adopted repo: a project with no .git, or with no
|
|
2
|
+
// remote, exists on ONE disk. `shipkit init` and `shipkit start` both run
|
|
3
|
+
// this, so a repo is versioned and backed up from its first minute. Every
|
|
4
|
+
// step is idempotent and SILENT when it has nothing to do, which is what
|
|
5
|
+
// lets both verbs call it without printing the same lines twice.
|
|
6
|
+
//
|
|
7
|
+
// The github half is ON by default, because starting a repo means wanting it
|
|
8
|
+
// private on github. It is timid about everything else: it only ever creates
|
|
9
|
+
// PRIVATE repositories (flipping one public is a human act behind the
|
|
10
|
+
// pre-public audit), it never pushes and never commits, it creates only what
|
|
11
|
+
// github reports as MISSING, and every unverifiable path (no gh, not
|
|
12
|
+
// authenticated, offline) is a WARN that leaves the local repo wired and
|
|
13
|
+
// names the next move.
|
|
14
|
+
import { existsSync, realpathSync } from 'fs'
|
|
15
|
+
import { basename, join } from 'path'
|
|
16
|
+
import { spawnWithTimeout } from './core/spawn-timeout.ts'
|
|
17
|
+
|
|
18
|
+
export type Run = (cmd: string[]) => Promise<{ code: number; out: string }>
|
|
19
|
+
|
|
20
|
+
export type GitWiringOpts = {
|
|
21
|
+
/** github repository name — default the directory's basename */
|
|
22
|
+
name?: string
|
|
23
|
+
/** true forces the github half, false refuses it. undefined (the default)
|
|
24
|
+
* runs it when this really is a repo being STARTED: see `justCreated` */
|
|
25
|
+
github?: boolean
|
|
26
|
+
/** this run created the dir, so the intent was to start a project. Left
|
|
27
|
+
* false, a dir that already existed needs a package.json to earn a
|
|
28
|
+
* remote, which is what keeps `ship <old-folder>` from minting repos */
|
|
29
|
+
justCreated?: boolean
|
|
30
|
+
/** report sink, default console.log */
|
|
31
|
+
print?: (line: string) => void
|
|
32
|
+
/** how `gh` runs. Injected by tests, which never touch a real account:
|
|
33
|
+
* a runner (fake account) or an explicit null (no gh on this machine).
|
|
34
|
+
* Undefined resolves the real binary, which is why no test may leave it
|
|
35
|
+
* undefined and no test may lift SHIPKIT_NO_GITHUB */
|
|
36
|
+
gh?: Run | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** github repo names, and the same shape `shipkit start` accepts for a dir */
|
|
40
|
+
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
41
|
+
|
|
42
|
+
/** the one line of gh or git output worth reporting — both merge stderr into
|
|
43
|
+
* stdout, so the first non-empty line is the message and the rest is chatter */
|
|
44
|
+
export const firstGhLine = (s: string): string => s.trim().split('\n').find((l) => l.trim() !== '')?.slice(0, 160) ?? ''
|
|
45
|
+
|
|
46
|
+
/** gh mixes stderr chatter (upgrade notices, SSO warnings) into its output, so
|
|
47
|
+
* the JSON object is carved out rather than parsed whole */
|
|
48
|
+
export function carveGhJson<T>(out: string): T | null {
|
|
49
|
+
const start = out.indexOf('{')
|
|
50
|
+
const end = out.lastIndexOf('}')
|
|
51
|
+
if (start === -1 || end < start) return null
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(out.slice(start, end + 1)) as T
|
|
54
|
+
} catch {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const runner = (bin: string, root: string, timeoutMs: number): Run => async (cmd) => {
|
|
60
|
+
const r = await spawnWithTimeout([bin, ...cmd], { cwd: root, timeoutMs })
|
|
61
|
+
return { code: r.code, out: r.timedOut ? `${r.out}\n${bin} timed out after ${timeoutMs}ms` : r.out }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const samePath = (a: string, b: string): boolean => {
|
|
65
|
+
try {
|
|
66
|
+
return realpathSync(a) === realpathSync(b)
|
|
67
|
+
} catch {
|
|
68
|
+
return a === b
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type RepoView = { nameWithOwner?: string; isPrivate?: boolean; url?: string; sshUrl?: string }
|
|
73
|
+
|
|
74
|
+
export async function ensureGitWiring(root: string, opts: GitWiringOpts = {}): Promise<void> {
|
|
75
|
+
const p = opts.print ?? console.log
|
|
76
|
+
const git = runner('git', root, 60_000)
|
|
77
|
+
|
|
78
|
+
const top = await git(['rev-parse', '--show-toplevel'])
|
|
79
|
+
if (top.code === 0) {
|
|
80
|
+
// a dir INSIDE another work tree is a subdirectory, not a repo waiting
|
|
81
|
+
// to be born: initializing there would shadow the parent's history
|
|
82
|
+
if (!samePath(top.out.trim(), root)) return p(`skip: git — ${root} sits inside the repo at ${top.out.trim()}`)
|
|
83
|
+
} else {
|
|
84
|
+
let init = await git(['init', '-b', 'main'])
|
|
85
|
+
// -b landed in git 2.28; older gits get the same result in two steps
|
|
86
|
+
if (init.code !== 0) {
|
|
87
|
+
init = await git(['init'])
|
|
88
|
+
if (init.code === 0) await git(['symbolic-ref', 'HEAD', 'refs/heads/main'])
|
|
89
|
+
}
|
|
90
|
+
if (init.code !== 0) return p(`WARN git init failed: ${firstGhLine(init.out)}`)
|
|
91
|
+
p('ok: git repo initialized (branch main)')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const remotes = await git(['remote'])
|
|
95
|
+
if (remotes.out.trim() !== '') return
|
|
96
|
+
|
|
97
|
+
if (opts.github === false) return p('skip: github — --no-github')
|
|
98
|
+
const name = opts.name ?? basename(root)
|
|
99
|
+
if (!NAME_RE.test(name)) return p(`skip: github — '${name}' is not a usable repository name`)
|
|
100
|
+
// starting a repo means a remote. ENTERING a dir that was already there
|
|
101
|
+
// and is not a project does not: `ship <name>` is also the everyday cd,
|
|
102
|
+
// and a typo must never mint an account artifact nobody asked for
|
|
103
|
+
if (opts.github !== true && opts.justCreated !== true && !existsSync(join(root, 'package.json'))) {
|
|
104
|
+
p(`skip: github — ${root} already existed without a package.json`)
|
|
105
|
+
return p(' fix: pass --github to create the private repo anyway (`shipkit init` wires it once the project exists)')
|
|
106
|
+
}
|
|
107
|
+
// an injected runner wins (a test with its own account), then the env
|
|
108
|
+
// switch a machine sets to turn the whole half off, then the real binary
|
|
109
|
+
let gh: Run | null
|
|
110
|
+
if (opts.gh !== undefined) gh = opts.gh
|
|
111
|
+
else if ((process.env.SHIPKIT_NO_GITHUB ?? '') !== '') return p('skip: github — SHIPKIT_NO_GITHUB is set')
|
|
112
|
+
else gh = Bun.which('gh') == null ? null : runner('gh', root, 60_000)
|
|
113
|
+
if (gh == null) {
|
|
114
|
+
p('WARN no remote wired: gh cli not found')
|
|
115
|
+
return p(' fix: install gh, `gh auth login`, then re-run this command')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const user = await gh(['api', 'user', '--jq', '.login'])
|
|
119
|
+
if (user.code !== 0) {
|
|
120
|
+
p(`WARN no remote wired: gh is not usable here (${firstGhLine(user.out)})`)
|
|
121
|
+
return p(' fix: `gh auth login` (or reconnect), then re-run this command')
|
|
122
|
+
}
|
|
123
|
+
// stdout and stderr arrive merged, so an SSO or upgrade notice would
|
|
124
|
+
// otherwise become the owner and every call after it would address a
|
|
125
|
+
// repository that cannot exist
|
|
126
|
+
const login = firstGhLine(user.out)
|
|
127
|
+
if (!NAME_RE.test(login)) return p(`WARN no remote wired: gh did not answer with a login (${login})`)
|
|
128
|
+
const target = `${login}/${name}`
|
|
129
|
+
const viewArgs = ['repo', 'view', target, '--json', 'nameWithOwner,isPrivate,url,sshUrl']
|
|
130
|
+
|
|
131
|
+
let view = await gh(viewArgs)
|
|
132
|
+
let adopted = false
|
|
133
|
+
if (view.code === 0) {
|
|
134
|
+
// a name match is not proof of identity: an unrelated `api` or `docs`
|
|
135
|
+
// in the same account matches too, and pushing into it mixes projects
|
|
136
|
+
adopted = true
|
|
137
|
+
p(`WARN github ${target} already exists — wiring it as origin, confirm it is THIS project before you push`)
|
|
138
|
+
} else {
|
|
139
|
+
// only a MISSING repo means "create it". a network or permission error
|
|
140
|
+
// must never be answered by creating something
|
|
141
|
+
if (!/not found|could not resolve|404/i.test(view.out)) return p(`WARN no remote wired: gh could not read ${target} (${firstGhLine(view.out)})`)
|
|
142
|
+
const created = await gh(['repo', 'create', target, '--private'])
|
|
143
|
+
if (created.code !== 0) return p(`WARN no remote wired: creating ${target} failed (${firstGhLine(created.out)})`)
|
|
144
|
+
view = await gh(viewArgs)
|
|
145
|
+
// the repo EXISTS at this point, so the line must never read as a
|
|
146
|
+
// failed creation: only the wiring is missing
|
|
147
|
+
if (view.code !== 0) return p(`WARN created PRIVATE github repo ${target}, but could not read it back to wire origin (${firstGhLine(view.out)}) — re-run to finish`)
|
|
148
|
+
p(`ok: created PRIVATE github repo ${target}`)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const info = carveGhJson<RepoView>(view.out)
|
|
152
|
+
const proto = await gh(['config', 'get', 'git_protocol'])
|
|
153
|
+
const url = (firstGhLine(proto.out) === 'ssh' ? info?.sshUrl : info?.url) ?? info?.url ?? info?.sshUrl
|
|
154
|
+
if (url == null) return p(`WARN no remote wired: gh answer unreadable (${firstGhLine(view.out)})`)
|
|
155
|
+
|
|
156
|
+
const add = await git(['remote', 'add', 'origin', url])
|
|
157
|
+
if (add.code !== 0) return p(`WARN no remote wired: git remote add failed (${firstGhLine(add.out)})`)
|
|
158
|
+
p(`ok: remote origin → ${url}`)
|
|
159
|
+
// the declared-visibility rule (SK007) reads this same fact later, but it
|
|
160
|
+
// only runs once the repo has a config: say it here, at the wiring
|
|
161
|
+
if (info?.isPrivate === false) p(`WARN ${target} is PUBLIC — declare visibility: 'public' in repo.config.ts, or make it private (rule SK007)`)
|
|
162
|
+
// never on the adopt path: the line above says to check WHAT that repo is
|
|
163
|
+
// first, and a push into an empty unrelated repo succeeds
|
|
164
|
+
if (!adopted) p('next: first commit, then `git push -u origin main`')
|
|
165
|
+
}
|
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,31 @@ 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: 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
|
|
57
|
+
* core.hooksPath is respected and named, and only the work-tree ROOT may
|
|
58
|
+
* arm (a subdir would set the parent's hooksPath onto a dir that does not
|
|
59
|
+
* exist there, disarming the parent's gates). */
|
|
55
60
|
export function armHooks(root: string, config: RepoConfig): ArmHooksResult {
|
|
56
|
-
const
|
|
57
|
-
if (
|
|
61
|
+
const top = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], { cwd: root })
|
|
62
|
+
if (top.exitCode !== 0) return { state: 'no-git', detail: 'not a git repo' }
|
|
63
|
+
const topDir = top.stdout.toString().trim()
|
|
64
|
+
try {
|
|
65
|
+
if (realpathSync(topDir) !== realpathSync(root)) return { state: 'not-root', detail: `work tree root is ${topDir}, not here` }
|
|
66
|
+
} catch {
|
|
67
|
+
return { state: 'no-git', detail: 'work tree root unresolvable' }
|
|
68
|
+
}
|
|
58
69
|
const p = Bun.spawnSync(['git', 'config', 'core.hooksPath'], { cwd: root })
|
|
59
70
|
const current = p.exitCode === 0 ? p.stdout.toString().trim() : ''
|
|
60
71
|
if (current !== '' && current !== HOOK_DIR) return { state: 'foreign', detail: `core.hooksPath=${current}, foreign, left alone` }
|
|
72
|
+
if (current === HOOK_DIR && HOOKS.every((h) => existsSync(join(root, HOOK_DIR, h)))) return { state: 'current', detail: 'hooks already armed' }
|
|
61
73
|
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' }
|
|
74
|
+
if (r.note.startsWith('WARN')) return { state: 'failed', detail: r.note }
|
|
64
75
|
return { state: 'armed', detail: `wrote ${r.written.join(', ') || 'nothing'}, ${r.note}` }
|
|
65
76
|
}
|
|
66
77
|
|
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 { ensureGitWiring, 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
|
|
|
@@ -35,29 +36,43 @@ export default defineRepo({
|
|
|
35
36
|
export type InitOpts = {
|
|
36
37
|
/** in-repo-source consumers: path to shipkit's cli.ts — scripts become `bun <path> <cmd>` instead of `shipkit <cmd>` */
|
|
37
38
|
bin?: string
|
|
39
|
+
/** report sink, default console.log (startRepo passes console.error: its
|
|
40
|
+
* stdout contract is the resolved dir alone) */
|
|
41
|
+
print?: (line: string) => void
|
|
42
|
+
/** false wires git only, never github. undefined keeps the default (on) */
|
|
43
|
+
github?: boolean
|
|
44
|
+
/** how `gh` runs — injected by tests, which never touch a real account */
|
|
45
|
+
gh?: Run | null
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
export async function initRepo(root: string, opts: InitOpts = {}): Promise<number> {
|
|
49
|
+
const p = opts.print ?? console.log
|
|
41
50
|
const configPath = join(root, 'repo.config.ts')
|
|
51
|
+
const pkgPath = join(root, 'package.json')
|
|
52
|
+
// the wrong-cwd refusal comes FIRST: a dir that is no project of any kind
|
|
53
|
+
// must not be left holding a git repo, let alone a github one, by a
|
|
54
|
+
// command whose next line says the cwd was wrong
|
|
55
|
+
if (!existsSync(pkgPath) && !existsSync(configPath)) { console.error('FAIL: no package.json here — run from the repo root'); return 1 }
|
|
56
|
+
// versioned + backed up before it is gated, on the adoption re-run too
|
|
57
|
+
await ensureGitWiring(root, { github: opts.github, gh: opts.gh, print: p })
|
|
42
58
|
if (existsSync(configPath)) {
|
|
43
|
-
|
|
59
|
+
p(`skip: repo.config.ts already exists`)
|
|
44
60
|
// adoption path: re-running init on an existing repo still converges
|
|
45
61
|
// its .gitignore policy block (same fixer as `check --fix SK004`),
|
|
46
62
|
// with the repo's own contributed lines honored
|
|
47
63
|
const cfg = await loadRepoConfig(root)
|
|
48
64
|
const g = await gitignorePolicy.fix!({ root, config: cfg })
|
|
49
|
-
|
|
65
|
+
p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
|
|
50
66
|
const mh = reportToMotherhome(root, cfg)
|
|
51
|
-
if (mh.note != null)
|
|
67
|
+
if (mh.note != null) p(mh.note)
|
|
52
68
|
return 0
|
|
53
69
|
}
|
|
54
|
-
|
|
55
|
-
if (!existsSync(pkgPath)) { console.error('FAIL: no package.json here — run from the repo root'); return 1 }
|
|
70
|
+
// guaranteed by the refusal above: no config means a package.json is here
|
|
56
71
|
const pkgRaw = readFileSync(pkgPath, 'utf8')
|
|
57
72
|
const pkg = JSON.parse(pkgRaw) as { name?: string; scripts?: Record<string, string> }
|
|
58
73
|
const name = pkg.name ?? 'my-repo'
|
|
59
74
|
writeFileSync(configPath, CONFIG_TEMPLATE(name))
|
|
60
|
-
|
|
75
|
+
p('ok: repo.config.ts created')
|
|
61
76
|
const indent = pkgRaw.match(/^(\s+)"/m)?.[1] ?? ' '
|
|
62
77
|
pkg.scripts ??= {}
|
|
63
78
|
let added = 0
|
|
@@ -69,32 +84,32 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
69
84
|
for (const [k, v] of Object.entries(canonicalBodies({ bin: opts.bin }))) {
|
|
70
85
|
if (k === 'test' && !hasTestFiles(root)) continue
|
|
71
86
|
if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
|
|
72
|
-
else if (pkg.scripts[k] !== v)
|
|
87
|
+
else if (pkg.scripts[k] !== v) p(`skip: package.json script '${k}' exists — left untouched`)
|
|
73
88
|
}
|
|
74
89
|
if (added > 0) {
|
|
75
90
|
writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n')
|
|
76
|
-
|
|
91
|
+
p(`ok: ${added} package.json script(s) added`)
|
|
77
92
|
}
|
|
78
93
|
// the .gitignore policy block (contract git policy) — same fixer as
|
|
79
94
|
// `shipkit check --fix SK004`, one code path
|
|
80
95
|
const g = await gitignorePolicy.fix!({ root, config: defineRepo({ name }) })
|
|
81
|
-
|
|
96
|
+
p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
|
|
82
97
|
// show what the gate actually is — a thin floor must be seen at init
|
|
83
98
|
// time, not discovered after a bad commit slipped through
|
|
84
99
|
const floor = resolveFloor(defineRepo({ name }), root)
|
|
85
|
-
for (const t of floor.tasks)
|
|
86
|
-
for (const u of floor.unfilled)
|
|
100
|
+
for (const t of floor.tasks) p(`ok: floor ${t.id} ← ${floor.sources[t.id] ?? 'unknown'}`)
|
|
101
|
+
for (const u of floor.unfilled) p(`skip: ${u.id} (${u.reason})`)
|
|
87
102
|
// every verb comes from the `shipkit` bin — dead on arrival unless the
|
|
88
103
|
// package is actually installed (bunx runs without adding the dep)
|
|
89
104
|
if (opts.bin == null) {
|
|
90
105
|
try {
|
|
91
106
|
Bun.resolveSync('rvlib-shipkit', root)
|
|
92
107
|
} catch {
|
|
93
|
-
|
|
94
|
-
|
|
108
|
+
p('WARN rvlib-shipkit not installed here — the scripts above call its bin and will exit 127')
|
|
109
|
+
p(' fix: bun add -d rvlib-shipkit')
|
|
95
110
|
}
|
|
96
111
|
}
|
|
97
|
-
|
|
98
|
-
|
|
112
|
+
p('next: fill venues/social in repo.config.ts, then `shipkit ci`')
|
|
113
|
+
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
114
|
return 0
|
|
100
115
|
}
|
package/src/release/run-bump.ts
CHANGED
|
@@ -27,7 +27,8 @@
|
|
|
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 {
|
|
30
|
+
import { homedir } from 'os'
|
|
31
|
+
import { join, relative } from 'path'
|
|
31
32
|
import { declaredKind, hasUnreleasedNotes, promoteUnreleased } from './changelog.ts'
|
|
32
33
|
import { deriveRemote, dirtyPaths, git as gitAt, mergeProbe, remoteTags, tagExistsLocally } from './git-facts.ts'
|
|
33
34
|
import { unitBlockers } from './unit-gate.ts'
|
|
@@ -206,8 +207,33 @@ 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 = preDirty.split('\n').map((l) => l.slice(3).trim().replace(/^"|"$/g, ''))
|
|
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
|
+
}
|
|
236
|
+
}
|
|
211
237
|
const dirty = dirtyPaths(root, config)
|
|
212
238
|
if (dirty !== '') { print(`FAIL: working tree not clean, commit your work first:\n${dirty.split('\n').slice(0, 10).join('\n')}`); return 1 }
|
|
213
239
|
|
|
@@ -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 =
|
|
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
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
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/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 { ensureGitWiring, 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
|
-
|
|
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,16 +69,25 @@ 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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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.
|
|
74
|
+
// --skip means shipkit NEVER TOUCHES that repo (his job repo lives on
|
|
75
|
+
// this list), not just no CLAUDE.md. And the cd contract survives a
|
|
76
|
+
// failed adoption: the dir always prints, wiring trouble is a WARN.
|
|
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. initRepo runs the same wiring, which is silent once done
|
|
81
|
+
const print = (l: string): void => console.error(l)
|
|
82
|
+
try { await ensureGitWiring(dir, { name, github: opts.github, justCreated, gh: opts.gh, print }) }
|
|
83
|
+
catch (e) { console.error(`WARN could not wire git in ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
|
|
84
|
+
if (!existsSync(join(dir, 'repo.config.ts'))) {
|
|
85
|
+
if (existsSync(join(dir, 'package.json'))) {
|
|
86
|
+
try { await initRepo(dir, { github: opts.github, gh: opts.gh, print }) }
|
|
87
|
+
catch (e) { console.error(`WARN could not wire ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
|
|
88
|
+
} else {
|
|
89
|
+
console.error(`next: scaffold the app in ${dir} (package.json), then \`shipkit init\` inside it — the commit gates arm on the first ci run`)
|
|
90
|
+
}
|
|
72
91
|
}
|
|
73
92
|
}
|
|
74
93
|
console.log(dir)
|