rvlib-shipkit 0.21.0 → 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 +11 -0
- package/package.json +1 -1
- package/src/cli/cmd_Init.ts +3 -3
- package/src/cli/cmd_Start.ts +4 -4
- package/src/core/check-suite.ts +6 -3
- package/src/core/wire.ts +8 -5
- package/src/git-bootstrap.ts +170 -51
- package/src/hooks/run-hooks.ts +11 -1
- package/src/init-repo.ts +10 -3
- package/src/release/git-facts.ts +12 -0
- package/src/release/run-bump.ts +6 -2
- package/src/shipkit.ts +1 -1
- package/src/start-repo.ts +14 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
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
|
+
|
|
3
14
|
## 0.21.0
|
|
4
15
|
|
|
5
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "0.
|
|
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",
|
package/src/cli/cmd_Init.ts
CHANGED
|
@@ -8,16 +8,16 @@ 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. 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
|
|
14
|
+
['local git and the first commit, never touch github', 'shipkit init --no-github'],
|
|
15
15
|
['in-repo-source consumer: scripts run the CLI by path', 'shipkit init --bin wiki/build/shipkit/rvlib-shipkit/src/cli.ts'],
|
|
16
16
|
],
|
|
17
17
|
})
|
|
18
18
|
|
|
19
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
|
|
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)' })
|
|
21
21
|
|
|
22
22
|
async execute(): Promise<number> {
|
|
23
23
|
try {
|
package/src/cli/cmd_Start.ts
CHANGED
|
@@ -9,13 +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,
|
|
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: dir, git, private github repo,
|
|
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
17
|
['force the private github repo for a folder that was already there', 'shipkit start old-notes --github'],
|
|
18
|
-
['local git
|
|
18
|
+
['local git and the first commit, never touch github', 'shipkit start pong --no-github'],
|
|
19
19
|
],
|
|
20
20
|
})
|
|
21
21
|
|
|
@@ -24,7 +24,7 @@ export class StartCommand extends Command {
|
|
|
24
24
|
include = Option.Array('--include', { description: 'line CLAUDE.md must carry, repeatable, order kept. Omitted means only ensure the dir' })
|
|
25
25
|
alsoOk = Option.Array('--also-ok', { description: 'alternative lines satisfying the first --include — never double-add' })
|
|
26
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
|
|
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)' })
|
|
28
28
|
|
|
29
29
|
async execute(): Promise<number> {
|
|
30
30
|
return startRepo(this.name, { root: this.root, include: this.include, alsoOk: this.alsoOk, skip: this.skip, github: this.github })
|
package/src/core/check-suite.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
147
|
-
|
|
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
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
}
|
package/src/git-bootstrap.ts
CHANGED
|
@@ -1,31 +1,36 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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.
|
|
6
8
|
//
|
|
7
9
|
// The github half is ON by default, because starting a repo means wanting it
|
|
8
10
|
// private on github. It is timid about everything else: it only ever creates
|
|
9
11
|
// PRIVATE repositories (flipping one public is a human act behind the
|
|
10
|
-
// pre-public audit), it
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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.
|
|
14
17
|
import { existsSync, realpathSync } from 'fs'
|
|
15
18
|
import { basename, join } from 'path'
|
|
16
19
|
import { spawnWithTimeout } from './core/spawn-timeout.ts'
|
|
17
20
|
|
|
18
21
|
export type Run = (cmd: string[]) => Promise<{ code: number; out: string }>
|
|
19
22
|
|
|
20
|
-
export type
|
|
23
|
+
export type PublishOpts = {
|
|
21
24
|
/** github repository name — default the directory's basename */
|
|
22
25
|
name?: string
|
|
23
|
-
/** true forces the
|
|
24
|
-
* runs it when this really is a repo being
|
|
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` */
|
|
25
29
|
github?: boolean
|
|
26
30
|
/** this run created the dir, so the intent was to start a project. Left
|
|
27
31
|
* false, a dir that already existed needs a package.json to earn a
|
|
28
|
-
* remote, which is what keeps `ship <old-folder>` from
|
|
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 */
|
|
29
34
|
justCreated?: boolean
|
|
30
35
|
/** report sink, default console.log */
|
|
31
36
|
print?: (line: string) => void
|
|
@@ -39,6 +44,12 @@ export type GitWiringOpts = {
|
|
|
39
44
|
/** github repo names, and the same shape `shipkit start` accepts for a dir */
|
|
40
45
|
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
41
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
|
+
|
|
42
53
|
/** the one line of gh or git output worth reporting — both merge stderr into
|
|
43
54
|
* stdout, so the first non-empty line is the message and the rest is chatter */
|
|
44
55
|
export const firstGhLine = (s: string): string => s.trim().split('\n').find((l) => l.trim() !== '')?.slice(0, 160) ?? ''
|
|
@@ -57,10 +68,14 @@ export function carveGhJson<T>(out: string): T | null {
|
|
|
57
68
|
}
|
|
58
69
|
|
|
59
70
|
const runner = (bin: string, root: string, timeoutMs: number): Run => async (cmd) => {
|
|
60
|
-
|
|
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 })
|
|
61
74
|
return { code: r.code, out: r.timedOut ? `${r.out}\n${bin} timed out after ${timeoutMs}ms` : r.out }
|
|
62
75
|
}
|
|
63
76
|
|
|
77
|
+
const gitIn = (root: string): Run => runner('git', root, 120_000)
|
|
78
|
+
|
|
64
79
|
const samePath = (a: string, b: string): boolean => {
|
|
65
80
|
try {
|
|
66
81
|
return realpathSync(a) === realpathSync(b)
|
|
@@ -71,95 +86,199 @@ const samePath = (a: string, b: string): boolean => {
|
|
|
71
86
|
|
|
72
87
|
type RepoView = { nameWithOwner?: string; isPrivate?: boolean; url?: string; sshUrl?: string }
|
|
73
88
|
|
|
74
|
-
|
|
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> {
|
|
75
93
|
const p = opts.print ?? console.log
|
|
76
|
-
const git =
|
|
77
|
-
|
|
94
|
+
const git = gitIn(root)
|
|
78
95
|
const top = await git(['rev-parse', '--show-toplevel'])
|
|
79
96
|
if (top.code === 0) {
|
|
80
97
|
// a dir INSIDE another work tree is a subdirectory, not a repo waiting
|
|
81
98
|
// to be born: initializing there would shadow the parent's history
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
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)')
|
|
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
|
|
92
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
|
+
}
|
|
93
116
|
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
96
124
|
|
|
97
|
-
|
|
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'))
|
|
98
130
|
const name = opts.name ?? basename(root)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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) {
|
|
104
147
|
p(`skip: github — ${root} already existed without a package.json`)
|
|
105
|
-
return p(' fix: pass --github to create the private repo anyway
|
|
148
|
+
return void p(' fix: pass --github to commit and create the private repo anyway')
|
|
106
149
|
}
|
|
107
150
|
// an injected runner wins (a test with its own account), then the env
|
|
108
151
|
// switch a machine sets to turn the whole half off, then the real binary
|
|
109
152
|
let gh: Run | null
|
|
110
153
|
if (opts.gh !== undefined) gh = opts.gh
|
|
111
|
-
else if ((process.env.SHIPKIT_NO_GITHUB ?? '') !== '') return p('skip: github — SHIPKIT_NO_GITHUB is set')
|
|
154
|
+
else if ((process.env.SHIPKIT_NO_GITHUB ?? '') !== '') return void p('skip: github — SHIPKIT_NO_GITHUB is set')
|
|
112
155
|
else gh = Bun.which('gh') == null ? null : runner('gh', root, 60_000)
|
|
113
156
|
if (gh == null) {
|
|
114
157
|
p('WARN no remote wired: gh cli not found')
|
|
115
|
-
return p(' fix: install gh, `gh auth login`, then re-run this command')
|
|
158
|
+
return void p(' fix: install gh, `gh auth login`, then re-run this command')
|
|
116
159
|
}
|
|
117
160
|
|
|
118
161
|
const user = await gh(['api', 'user', '--jq', '.login'])
|
|
119
162
|
if (user.code !== 0) {
|
|
120
163
|
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')
|
|
164
|
+
return void p(' fix: `gh auth login` (or reconnect), then re-run this command')
|
|
122
165
|
}
|
|
123
166
|
// stdout and stderr arrive merged, so an SSO or upgrade notice would
|
|
124
167
|
// otherwise become the owner and every call after it would address a
|
|
125
168
|
// repository that cannot exist
|
|
126
169
|
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})`)
|
|
170
|
+
if (!NAME_RE.test(login)) return void p(`WARN no remote wired: gh did not answer with a login (${login})`)
|
|
128
171
|
const target = `${login}/${name}`
|
|
129
172
|
const viewArgs = ['repo', 'view', target, '--json', 'nameWithOwner,isPrivate,url,sshUrl']
|
|
130
173
|
|
|
131
174
|
let view = await gh(viewArgs)
|
|
132
|
-
let
|
|
175
|
+
let created = false
|
|
133
176
|
if (view.code === 0) {
|
|
134
177
|
// a name match is not proof of identity: an unrelated `api` or `docs`
|
|
135
178
|
// in the same account matches too, and pushing into it mixes projects
|
|
136
|
-
adopted = true
|
|
137
179
|
p(`WARN github ${target} already exists — wiring it as origin, confirm it is THIS project before you push`)
|
|
138
180
|
} else {
|
|
139
181
|
// only a MISSING repo means "create it". a network or permission error
|
|
140
182
|
// 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
|
|
143
|
-
if (
|
|
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)})`)
|
|
144
186
|
view = await gh(viewArgs)
|
|
145
187
|
// the repo EXISTS at this point, so the line must never read as a
|
|
146
188
|
// 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`)
|
|
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
|
|
148
191
|
p(`ok: created PRIVATE github repo ${target}`)
|
|
149
192
|
}
|
|
150
193
|
|
|
151
194
|
const info = carveGhJson<RepoView>(view.out)
|
|
152
195
|
const proto = await gh(['config', 'get', 'git_protocol'])
|
|
153
196
|
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)})`)
|
|
197
|
+
if (url == null) return void p(`WARN no remote wired: gh answer unreadable (${firstGhLine(view.out)})`)
|
|
155
198
|
|
|
156
199
|
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)})`)
|
|
200
|
+
if (add.code !== 0) return void p(`WARN no remote wired: git remote add failed (${firstGhLine(add.out)})`)
|
|
158
201
|
p(`ok: remote origin → ${url}`)
|
|
159
202
|
// the declared-visibility rule (SK007) reads this same fact later, but it
|
|
160
203
|
// only runs once the repo has a config: say it here, at the wiring
|
|
161
204
|
if (info?.isPrivate === false) p(`WARN ${target} is PUBLIC — declare visibility: 'public' in repo.config.ts, or make it private (rule SK007)`)
|
|
162
|
-
|
|
163
|
-
//
|
|
164
|
-
|
|
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)
|
|
165
284
|
}
|
package/src/hooks/run-hooks.ts
CHANGED
|
@@ -127,8 +127,11 @@ function seedKeywordFile(root: string, keywordFile?: string): string | null {
|
|
|
127
127
|
|
|
128
128
|
export type GateOutcome = { id: string; ok: boolean; lines: string[] }
|
|
129
129
|
|
|
130
|
-
/** 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. */
|
|
131
133
|
export async function runPreCommit(root: string, config: RepoConfig): Promise<GateOutcome[]> {
|
|
134
|
+
const started = performance.now()
|
|
132
135
|
const h = config.hooks ?? {}
|
|
133
136
|
const out: GateOutcome[] = []
|
|
134
137
|
if (!(h.disable ?? []).includes('leak-guard')) {
|
|
@@ -185,6 +188,13 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
|
|
|
185
188
|
lines: r.code === 0 ? [`ok: ${c.id}`] : [`FAIL: ${c.id} (exit ${r.code})`, ...r.out.trim().split('\n').slice(-30).map((l) => ` ${l}`)],
|
|
186
189
|
})
|
|
187
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
|
+
})
|
|
188
198
|
return out
|
|
189
199
|
}
|
|
190
200
|
|
package/src/init-repo.ts
CHANGED
|
@@ -11,7 +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 {
|
|
14
|
+
import { ensureGitRepo, publishRepo, type Run } from './git-bootstrap.ts'
|
|
15
15
|
import { defineRepo, loadRepoConfig, resolveFloor } from './repo-config.ts'
|
|
16
16
|
import { gitignorePolicy } from './rules/SK004-gitignore-policy.ts'
|
|
17
17
|
|
|
@@ -41,6 +41,9 @@ export type InitOpts = {
|
|
|
41
41
|
print?: (line: string) => void
|
|
42
42
|
/** false wires git only, never github. undefined keeps the default (on) */
|
|
43
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
|
|
44
47
|
/** how `gh` runs — injected by tests, which never touch a real account */
|
|
45
48
|
gh?: Run | null
|
|
46
49
|
}
|
|
@@ -53,8 +56,9 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
53
56
|
// must not be left holding a git repo, let alone a github one, by a
|
|
54
57
|
// command whose next line says the cwd was wrong
|
|
55
58
|
if (!existsSync(pkgPath) && !existsSync(configPath)) { console.error('FAIL: no package.json here — run from the repo root'); return 1 }
|
|
56
|
-
//
|
|
57
|
-
|
|
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 })
|
|
58
62
|
if (existsSync(configPath)) {
|
|
59
63
|
p(`skip: repo.config.ts already exists`)
|
|
60
64
|
// adoption path: re-running init on an existing repo still converges
|
|
@@ -65,6 +69,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
65
69
|
p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
|
|
66
70
|
const mh = reportToMotherhome(root, cfg)
|
|
67
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 })
|
|
68
73
|
return 0
|
|
69
74
|
}
|
|
70
75
|
// guaranteed by the refusal above: no config means a package.json is here
|
|
@@ -109,6 +114,8 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
109
114
|
p(' fix: bun add -d rvlib-shipkit')
|
|
110
115
|
}
|
|
111
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 })
|
|
112
119
|
p('next: fill venues/social in repo.config.ts, then `shipkit ci`')
|
|
113
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)')
|
|
114
121
|
return 0
|
package/src/release/git-facts.ts
CHANGED
|
@@ -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
|
|
package/src/release/run-bump.ts
CHANGED
|
@@ -30,7 +30,7 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
|
30
30
|
import { homedir } from 'os'
|
|
31
31
|
import { join, relative } from 'path'
|
|
32
32
|
import { declaredKind, hasUnreleasedNotes, promoteUnreleased } from './changelog.ts'
|
|
33
|
-
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'
|
|
34
34
|
import { unitBlockers } from './unit-gate.ts'
|
|
35
35
|
import { formatUnitError, releaseOrder, renderTag, resolveUnits, unitById, unitPrepublishScripts, type ReleaseUnit } from './units.ts'
|
|
36
36
|
import { environmentBlockers, runReleaseGate, releaseUnit } from './run-release.ts'
|
|
@@ -226,12 +226,16 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind |
|
|
|
226
226
|
const preDirty = dirtyPaths(root, config)
|
|
227
227
|
if (preDirty !== '') {
|
|
228
228
|
const machine = machineWrittenPaths(root, config)
|
|
229
|
-
const paths = preDirty
|
|
229
|
+
const paths = dirtyPathList(preDirty)
|
|
230
230
|
if (paths.every((p) => machine.has(p))) {
|
|
231
231
|
gitAt(root, ['add', '--', ...paths])
|
|
232
232
|
const c = gitAt(root, ['commit', '-m', 'machine rows refresh before release'])
|
|
233
233
|
if (c.code === 0) print('ok: machine-written rows committed (they re-dirty on every gate run)')
|
|
234
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`)
|
|
235
239
|
}
|
|
236
240
|
}
|
|
237
241
|
const dirty = dirtyPaths(root, config)
|
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,
|
|
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,7 +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 {
|
|
10
|
+
import { ensureGitRepo, publishRepo, type Run } from './git-bootstrap.ts'
|
|
11
11
|
import { initRepo } from './init-repo.ts'
|
|
12
12
|
|
|
13
13
|
export type StartOpts = {
|
|
@@ -77,18 +77,25 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
|
|
|
77
77
|
if (!(opts.skip ?? []).includes(name)) {
|
|
78
78
|
// git BEFORE the shipkit scaffold, and whether or not the dir is a
|
|
79
79
|
// project yet: an empty new dir is still work that must not live on
|
|
80
|
-
// one disk.
|
|
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
|
|
81
82
|
const print = (l: string): void => console.error(l)
|
|
82
|
-
|
|
83
|
+
let wired = false
|
|
84
|
+
try { wired = await ensureGitRepo(dir, { print }) }
|
|
83
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
|
|
84
87
|
if (!existsSync(join(dir, 'repo.config.ts'))) {
|
|
85
88
|
if (existsSync(join(dir, 'package.json'))) {
|
|
86
|
-
try { await initRepo(dir, { github: opts.github, gh: opts.gh, print }) }
|
|
89
|
+
try { await initRepo(dir, { github: opts.github, gh: opts.gh, print, noPublish: true }) }
|
|
87
90
|
catch (e) { console.error(`WARN could not wire ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
|
|
88
|
-
} else
|
|
89
|
-
|
|
90
|
-
|
|
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`) }
|
|
91
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`)
|
|
92
99
|
}
|
|
93
100
|
console.log(dir)
|
|
94
101
|
return 0
|