rvlib-shipkit 0.27.0 → 0.28.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,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.28.0
4
+
5
+ - **The `.claude/` policy files are OPT-IN.** 0.27.0 wrote a pre-approved shell allowlist (`curl *`, `git push*`, `./deploy.sh`) and an attribution silencer into every started repo, gitignored and therefore invisible to review. Those are decisions about what an agent can do in a repo and what its git history says, so they now require the caller to declare a house (`claudePolicy`, which `start` enables when it seeds CLAUDE.md include lines), and the grant prints once by name when it lands. The verify skill still lands for everyone.
6
+ - **The scaffolded verify skill points at `shipkit ci`.** It shipped a gate of `bunx tsc && bun test && bun run build`: scripts a started repo does not have, contradicting the coding page written beside it.
7
+ - **The `.gitignore` baseline is appended line by line, by ONE writer.** It was written only when the file was absent, so a dir arriving with any `.gitignore` at all (a downloaded template, one stray line) skipped it: the install then wrote `node_modules/` and the first commit refused over thousands of staged files. A `.env` in a started dir is now ignored rather than blocking the commit outright, so the secret stays out AND the work gets backed up.
8
+ - **A scaffold write error no longer aborts the start.** It is wrapped like every other step, warns, and the chain still commits and pushes.
9
+ - **Smaller repairs**: an unparseable `package.json` warns instead of silently skipping the scripts · files the scaffold SKIPPED are reported, because on a half-built dir the skipped one is the interesting one · delivered templates get their "copy me somewhere" comment blocks stripped, true on the page they twin and nonsense in the repo they land in · the hub port in `tasks.json` derives from the package name, matching what `init` writes into `repo.config.ts`.
10
+
3
11
  ## 0.27.0
4
12
 
5
13
  - **`shipkit start` writes the whole day-one shape, not just a repo.** A started dir was a repo you still had to furnish: no tsconfig, no editor tasks, no agent wiki behind CLAUDE.md's own include, no verify skill, no attribution silencer, and two baseline scripts the rules then warned about. One scaffold pass now writes all of it (`src/scaffold/project-scaffold.ts`), installs the toolchain, and the result passes its OWN `shipkit ci` with nothing left but the identity assets a human has to choose. Every file is CREATE-ONLY, so running it over a half-built dir never destroys work.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.27.0",
3
+ "version": "0.28.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",
@@ -9,7 +9,7 @@ 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, READY to work in: ensures the dir, seeds CLAUDE.md include lines, scaffolds a minimal package.json + src/index.ts + the rvlib-shipkit dev dep on any dir with no commit history (loose files ride in the first commit), wires shipkit (repo.config.ts via init), then runs the whole bootstrap chain: git init, first commit, PRIVATE github repo, push. Only a repo with real history is left as it is. 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, READY to work in. On any dir with no commit history it writes the day-one shape (package.json + src/index.ts + lint/fmt/typecheck, tsconfig, .vscode tasks + settings, the verify skill, an agent/ wiki, .rv-reflections), installs the toolchain, wires shipkit (repo.config.ts via init), and runs the whole bootstrap chain: git init, first commit, PRIVATE github repo, push. Loose files ride in that first commit, and only a repo with real history is left as it is. Writes are create-only for files, additive for package.json scripts and .gitignore. Seeding CLAUDE.md include lines also opts the repo into the .claude/ policy files (a pre-approved shell allowlist + the attribution silencer). Prints the dir on stdout so a shell alias can cd there. cwd-only sibling: `shipkit init`',
13
13
  examples: [
14
14
  ['create or enter ~/dev/pong: dir, package.json, shipkit 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'"],
@@ -14,7 +14,7 @@
14
14
  // identity), it never touches a history that already has commits, and every
15
15
  // unverifiable path (no gh, not authenticated, offline) is a WARN that
16
16
  // leaves the local repo wired and names the next move.
17
- import { existsSync, realpathSync } from 'fs'
17
+ import { existsSync, readFileSync, realpathSync, writeFileSync } from 'fs'
18
18
  import { tmpdir } from 'os'
19
19
  import { basename, join } from 'path'
20
20
  import { spawnWithTimeout } from './core/spawn-timeout.ts'
@@ -261,13 +261,15 @@ async function firstCommit(root: string, git: Run, p: (line: string) => void): P
261
261
  p('skip: first commit — nothing in this dir yet')
262
262
  return false
263
263
  }
264
- // a repo with no .gitignore at all gets the baseline: the very first
265
- // commit is the one nobody reviews, and it is the one that ships a .env
266
- // to github. A repo that HAS one keeps its own answer, whatever it says.
267
- // rule SK004 stays the only writer of its managed block, with the repo's
268
- // real config: rewriting it from a synthetic one here DELETED the lines
269
- // repo.config.ts contributed, seconds before staging what they covered
270
- if (!existsSync(join(root, '.gitignore'))) await Bun.write(join(root, '.gitignore'), `${IGNORE_BASELINE.join('\n')}\n`)
264
+ // the baseline lands before the very first commit, the one nobody
265
+ // reviews and the one that ships a .env to github. Every MISSING line is
266
+ // appended, because a repo that arrived with a one-line .gitignore is
267
+ // exactly as exposed as one with none. Repo-owned lines are never
268
+ // touched, and rule SK004 stays the only writer of its managed block,
269
+ // with the repo's real config: rewriting it from a synthetic one here
270
+ // DELETED the lines repo.config.ts contributed, seconds before staging
271
+ // what they covered
272
+ ensureIgnoreBaseline(root)
271
273
  const status = await git(['status', '--porcelain', '-uall'])
272
274
  if (status.code !== 0) {
273
275
  p(`WARN no first commit: ${firstGhLine(status.out)}`)
@@ -300,6 +302,19 @@ async function firstCommit(root: string, git: Run, p: (line: string) => void): P
300
302
  return true
301
303
  }
302
304
 
305
+ /** append whichever baseline lines this repo's .gitignore does not already
306
+ * carry. ONE writer of this fact, called by the first commit and by the
307
+ * start scaffold (which runs before the dep install, so node_modules can
308
+ * never reach the staging area) */
309
+ export function ensureIgnoreBaseline(root: string): void {
310
+ const path = join(root, '.gitignore')
311
+ const cur = existsSync(path) ? readFileSync(path, 'utf8') : ''
312
+ const have = new Set(cur.split('\n').map((l) => l.trim()))
313
+ const missing = IGNORE_BASELINE.filter((l) => !have.has(l))
314
+ if (missing.length === 0) return
315
+ writeFileSync(path, `${cur}${cur === '' || cur.endsWith('\n') ? '' : '\n'}${missing.join('\n')}\n`)
316
+ }
317
+
303
318
  /** never in a first commit, wherever it sits in the tree. `.env.example` and
304
319
  * friends are documentation and belong in the repo */
305
320
  function isRisky(f: string): boolean {
@@ -38,5 +38,11 @@
38
38
  "incremental": true,
39
39
  "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
40
40
  },
41
- "include": ["src", "scripts", "*.ts"]
41
+ // NO root `*.ts` glob: it picks up a config file like repo.config.ts,
42
+ // and a dependency imported from there whose package points at raw .ts
43
+ // (rvlib-shipkit does) gets walked and typechecked in full, since
44
+ // skipLibCheck covers .d.ts only. The repo then fails on code its owner
45
+ // cannot edit. List a root file BY NAME when it earns checking, the way
46
+ // a vite repo lists vite.config.ts
47
+ "include": ["src", "scripts"]
42
48
  }
@@ -16,10 +16,11 @@ description: Prove a change in THIS project actually works — drive the real fl
16
16
  - Verify as the end user would: drive the real flow, observe the real signal.
17
17
 
18
18
  ## This project
19
- - Dev server: `bun run dev` → http://localhost:<PORT> (check it's not already
20
- running first: `curl -s localhost:<PORT> >/dev/null && echo up`)
21
- - Full gate: `bunx tsc --noEmit && bun test && bun run build`
22
- - Live URL after deploy: https://<project>.pages.dev (curl 200 + drive the flow)
19
+ - Full gate: `shipkit ci` — THE command, never the linter, the typechecker
20
+ and the tests one at a time.
21
+ - TODO, fill these in once the stack exists: the dev server command and its
22
+ port (check it is not already running: `curl -s localhost:PORT >/dev/null
23
+ && echo up`), and the live URL after deploy (curl 200 + drive the flow).
23
24
 
24
25
  ## Browser verification (only when a browser is truly needed)
25
26
  Playwright with explicit `executablePath`, `deviceScaleFactor: 2`, localStorage
@@ -3,8 +3,11 @@
3
3
  // an agent lands on a wiki that routes it, and `shipkit ci` is green with no
4
4
  // warning a machine could have cleared.
5
5
  //
6
- // Every file is CREATE-ONLY: a path that exists is left exactly as it is, so
7
- // the scaffold can run over a half-built dir without ever destroying work.
6
+ // Writes are CREATE-ONLY for files (a path that exists is left exactly as it
7
+ // is) and ADDITIVE for package.json scripts and .gitignore (a free key or a
8
+ // missing line is filled, an existing one is never touched), so the scaffold
9
+ // can run over a half-built dir without ever destroying work. Note that the
10
+ // package.json rewrite reformats the file it edits.
8
11
  // Templates live beside this file in files/ and ship in the npm tarball, so
9
12
  // none of them may name a machine-local tool or a private path.
10
13
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
@@ -20,6 +23,12 @@ export type ScaffoldOpts = {
20
23
  print?: (line: string) => void
21
24
  /** the changelog's first dated entry — injected so callers stay testable */
22
25
  today?: string
26
+ /** write the .claude/ POLICY files: a pre-approved permission list and an
27
+ * attribution silencer. Both are decisions about what agents may do in
28
+ * someone's repo and what its git history says, so they are opt-in: only
29
+ * a caller that declares a house (start seeds include lines) turns them
30
+ * on, and the grant is printed the one time it lands */
31
+ claudePolicy?: boolean
23
32
  }
24
33
 
25
34
  /** every dep the house toolchain needs for the scripts below to run */
@@ -38,10 +47,47 @@ const SCRIPTS: Record<string, string> = {
38
47
  * fixer never rewrites them */
39
48
  const IGNORE_EXTRA = ['backups/', '.claude/settings.local.json']
40
49
 
50
+ /** a line telling a READER to copy this file somewhere. True in the wiki page
51
+ * the template is a twin of, nonsense in the repo it just landed in */
52
+ const COPY_INSTRUCTION = /copy (?:as-is|to|this)|merge into|^\s*<!-- Template:/i
53
+
41
54
  const template = (file: string, vars: Record<string, string>): string => {
42
55
  let out = readFileSync(join(FILES, file), 'utf8')
43
56
  for (const [k, v] of Object.entries(vars)) out = out.replaceAll(`{{${k}}}`, v)
44
- return out
57
+ return stripCopyInstructions(out)
58
+ }
59
+
60
+ /** drop whole COMMENT BLOCKS that address a copier, never a structural line:
61
+ * a template's own "copy me somewhere" line is true on the wiki page it
62
+ * twins and nonsense in the repo it just landed in. Comments explaining the
63
+ * CONTENT are the point of these files and stay */
64
+ export function stripCopyInstructions(body: string): string {
65
+ const lines = body.split('\n')
66
+ const drop = new Set<number>()
67
+ const blockSpeaksToACopier = (from: number, to: number): boolean => COPY_INSTRUCTION.test(lines.slice(from, to + 1).join('\n'))
68
+ for (let i = 0; i < lines.length; i++) {
69
+ if (drop.has(i)) continue
70
+ const line = lines[i]!
71
+ // an html comment block, possibly spanning lines
72
+ if (/^\s*<!--/.test(line)) {
73
+ let end = i
74
+ while (end < lines.length && !/-->/.test(lines[end]!)) end++
75
+ if (end < lines.length && blockSpeaksToACopier(i, end)) for (let k = i; k <= end; k++) drop.add(k)
76
+ i = end
77
+ continue
78
+ }
79
+ // a run of consecutive // lines is one block
80
+ if (/^\s*\/\//.test(line)) {
81
+ let end = i
82
+ while (end + 1 < lines.length && /^\s*\/\//.test(lines[end + 1]!)) end++
83
+ if (blockSpeaksToACopier(i, end)) for (let k = i; k <= end; k++) drop.add(k)
84
+ i = end
85
+ }
86
+ }
87
+ if (drop.size === 0) return body
88
+ const kept = lines.filter((_, i) => !drop.has(i))
89
+ while (kept.length > 0 && kept[0]!.trim() === '') kept.shift()
90
+ return kept.join('\n')
45
91
  }
46
92
 
47
93
  /** write only when the path is free. Returns whether it wrote */
@@ -55,29 +101,35 @@ function put(root: string, rel: string, body: string): boolean {
55
101
 
56
102
  export function scaffoldProject(root: string, opts: ScaffoldOpts): string[] {
57
103
  const p = opts.print ?? console.log
104
+ // the hub resolves its port from the CONFIG name, which init takes from
105
+ // package.json — a dir whose folder name differs would otherwise get a
106
+ // tasks.json advertising a port nothing listens on
107
+ const configName = packageName(root) ?? opts.name
58
108
  const vars = {
59
109
  NAME: opts.name,
60
- PORT: String(defaultHubPort(opts.name)),
110
+ PORT: String(defaultHubPort(configName)),
61
111
  DATE: opts.today ?? new Date().toISOString().slice(0, 10),
62
112
  }
63
113
  const written: string[] = []
64
- const write = (rel: string, body: string): void => { if (put(root, rel, body)) written.push(rel) }
114
+ const skipped: string[] = []
115
+ const write = (rel: string, body: string): void => { (put(root, rel, body) ? written : skipped).push(rel) }
65
116
 
66
117
  write('src/index.ts', 'export {}\n')
67
- // the reference says to adjust include to the repo's own dirs, and a
68
- // fresh repo has exactly one. Its root `*.ts` glob must NOT ship: that
69
- // glob picks up repo.config.ts, whose `rvlib-shipkit` import is raw .ts,
70
- // so tsc walks into the dependency and typechecks the whole library
71
- // (skipLibCheck covers .d.ts only). Add a root file back by name, the
72
- // way a vite repo lists vite.config.ts
73
- write('tsconfig.json', template('tsconfig.json', vars).replace(/"include":.*$/m, '"include": ["src"]'))
118
+ write('tsconfig.json', template('tsconfig.json', vars))
74
119
  write('.vscode/settings.json', template('vscode-settings.json', vars))
75
120
  write('.vscode/tasks.json', template('vscode-tasks.json', vars))
76
- // the harness must never write an assistant trailer into this repo's
77
- // history, and the setting only counts from the FIRST commit on
78
- write('.claude/settings.json', template('claude-settings.json', vars))
79
- write('.claude/settings.local.json', template('claude-settings-local.json', vars))
80
121
  write('.claude/skills/verify/SKILL.md', template('verify-skill.md', vars))
122
+ // a permission grant and a git-history policy are decisions about someone
123
+ // else's repo, so they land only for a caller that declared a house, and
124
+ // the grant is named out loud the one time it is written
125
+ if (opts.claudePolicy === true) {
126
+ write('.claude/settings.json', template('claude-settings.json', vars))
127
+ write('.claude/settings.local.json', template('claude-settings-local.json', vars))
128
+ if (written.includes('.claude/settings.local.json')) p(`ok: .claude/ policy written — agents are pre-approved for ${grantSummary()} in this repo, and the harness writes no attribution trailer`)
129
+ }
130
+ // the project's own docs wiki. A repo whose CLAUDE.md routes at
131
+ // agent/index.md (rule SK003) needs the file to exist, and a repo with no
132
+ // CLAUDE.md at all still gets a place for its docs to live
81
133
  write('agent/index.md', template('agent-index.md', vars))
82
134
  write('agent/coding.md', template('agent-coding.md', vars))
83
135
  write('agent/changelog.md', template('agent-changelog.md', vars))
@@ -85,13 +137,28 @@ export function scaffoldProject(root: string, opts: ScaffoldOpts): string[] {
85
137
  // friction with the toolchain finds the place to write it down
86
138
  write('.rv-reflections/README.md', REFLECTIONS_README)
87
139
 
88
- addScripts(root, written)
140
+ addScripts(root, written, p)
89
141
  addIgnoreLines(root, written)
90
142
 
91
143
  if (written.length > 0) p(`ok: scaffolded ${written.length} file(s): ${written.slice(0, 6).join(', ')}${written.length > 6 ? `, +${written.length - 6} more` : ''}`)
144
+ // the skips are the interesting half on a half-built dir: a pre-existing
145
+ // .claude/settings.json means no attribution silencer, and that one only
146
+ // counts from the first commit on
147
+ if (skipped.length > 0) p(`skip: ${skipped.length} file(s) already present, left untouched: ${skipped.join(', ')}`)
92
148
  return written
93
149
  }
94
150
 
151
+ /** what the pre-approved permission list actually allows, in one phrase */
152
+ function grantSummary(): string {
153
+ try {
154
+ const s = JSON.parse(readFileSync(join(FILES, 'claude-settings-local.json'), 'utf8')) as { permissions?: { allow?: string[] } }
155
+ const allow = s.permissions?.allow ?? []
156
+ return `${allow.length} shell patterns incl. ${allow.filter((a) => /curl|push|deploy/.test(a)).join(', ') || 'none network-facing'}`
157
+ } catch {
158
+ return 'a shell allowlist'
159
+ }
160
+ }
161
+
95
162
  const REFLECTIONS_README = `# .rv-reflections
96
163
 
97
164
  One file per friction with the shared toolchain: \`YYYYMMDD-<slug>.md\`.
@@ -100,12 +167,13 @@ transient by design: it is harvested, then deleted.
100
167
  `
101
168
 
102
169
  /** the three baseline scripts, added only where the key is free */
103
- function addScripts(root: string, written: string[]): void {
170
+ function addScripts(root: string, written: string[], p: (line: string) => void): void {
104
171
  const path = join(root, 'package.json')
105
- if (!existsSync(path)) return
172
+ if (!existsSync(path)) return void p('WARN no package.json here — the lint/fmt/typecheck scripts were not written')
106
173
  const raw = readFileSync(path, 'utf8')
107
174
  let pkg: { scripts?: Record<string, string> }
108
- try { pkg = JSON.parse(raw) as { scripts?: Record<string, string> } } catch { return }
175
+ try { pkg = JSON.parse(raw) as { scripts?: Record<string, string> } }
176
+ catch (e) { return void p(`WARN package.json is unparseable (${String(e).slice(0, 80)}) — the lint/fmt/typecheck scripts were not written, repair it then re-run`) }
109
177
  pkg.scripts ??= {}
110
178
  let added = 0
111
179
  for (const [k, v] of Object.entries(SCRIPTS)) if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
@@ -115,6 +183,14 @@ function addScripts(root: string, written: string[]): void {
115
183
  written.push(`package.json (${added} script(s))`)
116
184
  }
117
185
 
186
+ function packageName(root: string): string | null {
187
+ try {
188
+ return (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { name?: string }).name ?? null
189
+ } catch {
190
+ return null
191
+ }
192
+ }
193
+
118
194
  function addIgnoreLines(root: string, written: string[]): void {
119
195
  const path = join(root, '.gitignore')
120
196
  const cur = existsSync(path) ? readFileSync(path, 'utf8') : ''
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 { ensureGitRepo, firstGhLine, IGNORE_BASELINE, isScratch, publishRepo, runner, type Run } from './git-bootstrap.ts'
10
+ import { ensureGitRepo, ensureIgnoreBaseline, firstGhLine, isScratch, publishRepo, runner, type Run } from './git-bootstrap.ts'
11
11
  import { initRepo } from './init-repo.ts'
12
12
  import { scaffoldProject, TOOLCHAIN } from './scaffold/project-scaffold.ts'
13
13
 
@@ -101,16 +101,22 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
101
101
  writeFileSync(join(dir, 'package.json'), JSON.stringify(pkg, null, 4) + '\n')
102
102
  console.error('ok: package.json created (private, type module)')
103
103
  }
104
- // the baseline ignore goes FIRST, before anything else can create
105
- // a .gitignore: the install below writes node_modules/ and
106
- // neither SK004's block nor the scaffold's own lines carry it, so
107
- // a file that already exists silently skips the baseline and the
108
- // first commit refuses on 7000 staged dep files
109
- if (!existsSync(join(dir, '.gitignore'))) writeFileSync(join(dir, '.gitignore'), `${IGNORE_BASELINE.join('\n')}\n`)
104
+ // the baseline ignore goes FIRST, before anything else creates a
105
+ // .gitignore, and every MISSING line is appended rather than the
106
+ // file being written only when absent: the install below writes
107
+ // node_modules/, neither SK004's block nor the scaffold's own
108
+ // lines carry it, and a dir that arrived with any .gitignore at
109
+ // all (a downloaded template, one stray line) otherwise skipped
110
+ // the baseline and refused the first commit over 7000 dep files
111
+ ensureIgnoreBaseline(dir)
110
112
  // the day-one shape: tsconfig, vscode tasks, the agent wiki, the
111
- // verify skill, the attribution silencer. Create-only, so a dir
112
- // that already holds any of them keeps its own
113
- scaffoldProject(dir, { name, print })
113
+ // verify skill. Create-only, so a dir that already holds any of
114
+ // them keeps its own. The .claude/ policy files ride on the
115
+ // include lines: a caller that declares a house gets the house
116
+ // defaults, a bare `shipkit start` never has its agent
117
+ // permissions widened by a scaffold
118
+ try { scaffoldProject(dir, { name, print, claudePolicy: (opts.include ?? []).length > 0 }) }
119
+ catch (e) { console.error(`WARN scaffold incomplete in ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
114
120
  // the wired scripts call node_modules binaries, so a scaffold
115
121
  // whose scripts exit 127 is not a started repo. Skips mirror the
116
122
  // github half's guards: an injected runner wins, the env switch