rvlib-shipkit 0.25.0 → 0.27.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,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.27.0
4
+
5
+ - **`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.
6
+ - **The scaffolded tsconfig drops the root `*.ts` glob.** That glob picks up `repo.config.ts`, whose `rvlib-shipkit` import is raw TypeScript, so `tsc --noEmit` walked into the dependency and typechecked the entire library (`skipLibCheck` covers `.d.ts` only). A fresh repo's typecheck failed on someone else's code, which no user can fix. List a root config file by name when one earns checking, the way a vite repo lists `vite.config.ts`.
7
+ - **The baseline `.gitignore` is written before anything else can create one.** The scaffold's own ignore lines landed first, so the `node_modules/` baseline was skipped and the first commit refused on 7700 staged dependency files.
8
+ - **`start` says how to land in the dir it made.** A child process cannot cd its parent shell, so the path goes to stdout for a sourced wrapper and, when none is in play, the last line names the `cd`. `SHIPKIT_CD=1` silences it for a wrapper that does the move.
9
+
10
+ ## 0.26.0
11
+
12
+ - **start means START: the full bootstrap runs on any dir with no commit history.** 0.25.0 widened the scaffold to empty dirs and commitless husks, and the very next field run hit the remaining wall: a folder holding one stray readme was still refused. The gate is now commit history alone: no commits means the dir is startable, and loose files simply ride in the first commit as the repo's first backup. What start still refuses is structural: a repo with real history is never scaffolded or pushed over, a subdirectory of another work tree gets nothing, a temp dir never mints a github repo, and the risky-file refusal still blocks a first commit carrying a `.env` or `node_modules` anywhere in the tree.
13
+
3
14
  ## 0.25.0
4
15
 
5
16
  - **A pre-existing EMPTY dir (or a commitless git-only husk) starts like a brand-new one.** The full-bootstrap scaffold keyed on "this run created the dir", so `mkdir foo` followed by `shipkit start foo`, or a re-run over the husk an older shipkit left, still ended in git-only plus manual next steps. The everyday-cd guard exists to protect a folder holding files, and an empty folder holds none: emptiness (junk like `.DS_Store` aside) now counts as starting intent, a `.git` with no commits counts too, and a git dir with real history stays untouched. A folder with files keeps the old behavior exactly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.25.0",
3
+ "version": "0.27.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, and on a BRAND-NEW dir scaffolds a minimal package.json + src/index.ts + the rvlib-shipkit dev dep, wires shipkit (repo.config.ts via init), then runs the whole bootstrap chain: git init, first commit, PRIVATE github repo, push. A pre-existing folder counts as brand new when it is EMPTY or a commitless git husk. One holding files gets git only, never a scaffold (--github forces only the github half for it). 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: 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`',
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'"],
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ Newest first. Each entry narrates WHAT changed and WHY, for the next
4
+ session that arrives with no context.
5
+
6
+ ## {{DATE}}
7
+
8
+ - Repo scaffolded: package.json, tsconfig, vscode tasks, agent/ wiki,
9
+ shipkit wiring, first commit.
@@ -0,0 +1,35 @@
1
+ # Coding standards — {{NAME}}
2
+
3
+ The always-loaded page. Keep it to hard gates plus a screen of standards.
4
+ Once it grows past that, split the gates into `process.md`, make that the
5
+ always-loaded one, and route the rest by surface.
6
+
7
+ ## Gates
8
+
9
+ - **Spec-first**: the `agent/` page changes BEFORE the code it describes.
10
+ - **`shipkit ci` is the verification.** Run it, not the linter, the
11
+ typechecker and the tests one at a time.
12
+ - **Repro before fix**: write the failing test, then fix, then re-run it.
13
+ - One code path per concern. Delete the redundant mechanism rather than
14
+ keeping two.
15
+
16
+ ## TypeScript
17
+
18
+ - `strict`, `noUncheckedIndexedAccess`, `noUnusedLocals`,
19
+ `noUnusedParameters`, `noFallthroughCasesInSwitch`: all on, always.
20
+ - No `as any`, no `@ts-ignore`. A cast that survives review is listed here
21
+ with the reason it is safe.
22
+ - Imports are absolute (`src/...`), never `../..` chains.
23
+
24
+ ## Comments
25
+
26
+ - The default is NO comment. Write one only for what the code cannot say:
27
+ a constraint, an invariant, a performance reason, a workaround.
28
+ - Never narrate the edit, the date, or who asked for it. That is what git
29
+ and the changelog are for.
30
+ - Lowercase, no dash punctuation, matching the density of the file.
31
+
32
+ ## Layout
33
+
34
+ - A file past ~600 lines is a smell; carve it at ~800.
35
+ - Anti-patterns: <fill as the project teaches them>.
@@ -0,0 +1,25 @@
1
+ # {{NAME}}
2
+
3
+ <One paragraph: what this project is, who it is for, and the stack.>
4
+
5
+ ## Quick start
6
+
7
+ - `bun run hub` → the repo toolbox at http://localhost:{{PORT}}
8
+ - `shipkit ci` → THE gate: run it to check your work, never the parts
9
+
10
+ This folder is the project's internal wiki and its SPEC. Update the page
11
+ BEFORE the change it describes.
12
+
13
+ **Always loaded every session:**
14
+
15
+ @coding.md
16
+
17
+ Read the matching page before touching a surface:
18
+
19
+ | read when touching… | page |
20
+ |---|---|
21
+ | any source file | `agent/coding.md` |
22
+ | recent changes and why | `agent/changelog.md` |
23
+
24
+ Grow this table: a subsystem, a service, a runbook each earn their own page,
25
+ cross-linked from here.
@@ -0,0 +1,23 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(shipkit *)",
5
+ "Bash(bun run *)",
6
+ "Bash(bun test*)",
7
+ "Bash(bun install*)",
8
+ "Bash(bun add *)",
9
+ "Bash(bunx tsc*)",
10
+ "Bash(bunx wrangler*)",
11
+ "Bash(git status*)",
12
+ "Bash(git diff*)",
13
+ "Bash(git log*)",
14
+ "Bash(git add *)",
15
+ "Bash(git commit*)",
16
+ "Bash(git push*)",
17
+ "Bash(curl *)",
18
+ "Bash(oxlint*)",
19
+ "Bash(./deploy.sh)"
20
+ ],
21
+ "deny": []
22
+ }
23
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "attribution": {
3
+ "commit": "",
4
+ "pr": ""
5
+ }
6
+ }
@@ -0,0 +1,42 @@
1
+ {
2
+ // ONE tsconfig at the repo root — copy as-is (replaces whatever
3
+ // `bun init` generated), adjust include/paths to the repo's dirs.
4
+ "compilerOptions": {
5
+ // real node resolution, no bundler hand-waving — what tsc resolves is
6
+ // what bun resolves at runtime (bun follows tsconfig paths)
7
+ "module": "nodenext",
8
+ "moduleResolution": "nodenext",
9
+
10
+ // the pair that legalizes `from 'src/foo/Bar.ts'` — extensions are part
11
+ // of the exact-path search key; tsc only typechecks, bun/vite emit
12
+ "allowImportingTsExtensions": true,
13
+ "noEmit": true,
14
+
15
+ // THE mapping that makes 'src/...' specifiers resolve. No baseUrl —
16
+ // explicit paths only, so bare specifiers can't collide with packages.
17
+ // paths doubles as an escape hatch for deps with broken "exports"
18
+ // (map the submodule) — but bun follows these at RUNTIME too, so
19
+ // runtime-loaded packages need "exports" patches instead, or bun
20
+ // resolves a .d.ts where it needs a .js.
21
+ "paths": {
22
+ "src/*": ["./src/*"],
23
+ "scripts/*": ["./scripts/*"]
24
+ },
25
+
26
+ // every file is a module even with no import/export (standalone scripts)
27
+ "moduleDetection": "force",
28
+
29
+ // the house TS standard is all five, always on (meta/rv-conventions):
30
+ // strict alone leaves dead locals and fallthrough bugs in the codebase
31
+ "strict": true,
32
+ "noUncheckedIndexedAccess": true,
33
+ "noUnusedLocals": true,
34
+ "noUnusedParameters": true,
35
+ "noFallthroughCasesInSwitch": true,
36
+ "isolatedModules": true,
37
+ "skipLibCheck": true,
38
+ "incremental": true,
39
+ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
40
+ },
41
+ "include": ["src", "scripts", "*.ts"]
42
+ }
@@ -0,0 +1,31 @@
1
+ ---
2
+ name: verify
3
+ description: Prove a change in THIS project actually works — drive the real flow end to end, never trust green checks or self-reports. Use before committing any nontrivial change.
4
+ ---
5
+
6
+ # verify — how to PROVE a change works here
7
+
8
+ <!-- Template: copy to .claude/skills/verify/SKILL.md at project start, fill the
9
+ placeholders, then GROW it — every gotcha discovered in this repo gets
10
+ encoded here the same day. -->
11
+
12
+ ## The rules (house-wide)
13
+ - Failing repro test BEFORE the fix. Green checks ≠ verification.
14
+ - Logic → fast headless `bun test` (tiny ~10-line test files reusing fixtures).
15
+ - Look & feel → NEVER screenshot-self-verify; the repo owner playtests it.
16
+ - Verify as the end user would: drive the real flow, observe the real signal.
17
+
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)
23
+
24
+ ## Browser verification (only when a browser is truly needed)
25
+ Playwright with explicit `executablePath`, `deviceScaleFactor: 2`, localStorage
26
+ reset between runs, `page.evaluate` probes for state, collect `pageerror`
27
+ ("none" is evidence). Screenshots are for LOOKS, never for "does it fit".
28
+
29
+ ## Gotchas discovered in this repo
30
+ <!-- append as found — this list is the reason this file exists -->
31
+ - (none yet)
@@ -0,0 +1,40 @@
1
+ // merge into .vscode/settings.json — the convention only sticks if the editor
2
+ // produces it by default. Load-bearing keys for the absolute-import style.
3
+ // NOTE: `js/ts.*` is the unified prefix (recent VSCode); on older builds
4
+ // expand each to its `typescript.*` + `javascript.*` twins
5
+ // (e.g. typescript.preferences.importModuleSpecifier, typescript.tsdk).
6
+ {
7
+ // THE key pair: auto-import inserts 'src/...' absolute specifiers, never
8
+ // './relative'. Modern tsserver respects this reliably.
9
+ // the ONE key that makes auto-import write `src/foo/Bar.ts`, not
10
+ // `../foo/Bar`. It is the only real id in that family (`…Ending` aside);
11
+ // vscode ignores unknown keys in silence, so an invented name looks like
12
+ // it works
13
+ "js/ts.preferences.importModuleSpecifier": "non-relative",
14
+
15
+ // use the workspace typescript (native ts7 compiler), not vscode's bundled one
16
+ "js/ts.tsdk.path": "node_modules/typescript/lib",
17
+
18
+ // auto-imports emit `import type` — saves don't fight the lint's
19
+ // consistent-type-imports rule
20
+ "js/ts.preferences.preferTypeOnlyAutoImports": true,
21
+
22
+ // kill junk suggestions: only tsserver speaks, no word-soup completions
23
+ "editor.suggest.showWords": false,
24
+ "editor.wordBasedSuggestions": "off",
25
+ "js/ts.preferences.includePackageJsonAutoImports": "off",
26
+
27
+ // rename the real symbol, not an import alias; rename both jsx tags
28
+ "js/ts.preferences.useAliasesForRenames": false,
29
+ "js/ts.preferences.renameMatchingJsxTags": true,
30
+
31
+ // big-repo hygiene: more tsserver memory, no npm probing, no auto type acquisition
32
+ "js/ts.tsserver.maxMemory": 6144,
33
+ "js/ts.tsserver.automaticTypeAcquisition.enabled": false,
34
+ "js/ts.tsserver.checkNpmIsInstalled": false,
35
+
36
+ // oxc lints in-editor; eslint off; formatter owns formatting, not tsserver
37
+ "oxc.enable": true,
38
+ "eslint.enable": false,
39
+ "js/ts.format.enabled": false
40
+ }
@@ -0,0 +1,45 @@
1
+ {
2
+ // one master task with runOn folderOpen, every dev process as a subtask.
3
+ // Opening the folder in vscode starts them all. Add a subtask per
4
+ // long-running watcher the stack earns (dev server, css, tests), never
5
+ // for a one-off script.
6
+ "version": "2.0.0",
7
+ "tasks": [
8
+ {
9
+ "label": "starts everything",
10
+ "dependsOn": ["👁 TSC", "🚀 shipkit"],
11
+ "group": { "kind": "build", "isDefault": true },
12
+ "runOptions": { "runOn": "folderOpen", "instanceLimit": 1 },
13
+ "problemMatcher": []
14
+ },
15
+ {
16
+ "label": "👁 TSC",
17
+ "detail": "Get all typescript errors in problems pane",
18
+ "type": "process",
19
+ "command": "${workspaceFolder}/node_modules/.bin/tsc",
20
+ "args": ["--noEmit", "--incremental", "--watch", "--project", "tsconfig.json"],
21
+ "options": {
22
+ "cwd": "${workspaceFolder}",
23
+ "env": { "NODE_OPTIONS": "--max-old-space-size=8192" }
24
+ },
25
+ "isBackground": true,
26
+ "presentation": { "reveal": "never", "clear": true },
27
+ "group": "build",
28
+ "runOptions": { "instanceLimit": 1 },
29
+ "problemMatcher": []
30
+ },
31
+ {
32
+ "label": "🚀 shipkit",
33
+ "detail": "The shipkit hub at http://localhost:{{PORT}} — clickable URLs: the page, /graph/wiki, /graph/agent",
34
+ "type": "process",
35
+ "command": "bun",
36
+ "args": ["run", "hub"],
37
+ "options": { "cwd": "${workspaceFolder}" },
38
+ "isBackground": true,
39
+ "presentation": { "reveal": "always", "close": false },
40
+ "group": "build",
41
+ "runOptions": { "instanceLimit": 1 },
42
+ "problemMatcher": []
43
+ }
44
+ ]
45
+ }
@@ -0,0 +1,126 @@
1
+ // the day-one shape of a repo, written by `shipkit start` for a dir with no
2
+ // commit history. The bar: opening the folder in vscode starts the watchers,
3
+ // an agent lands on a wiki that routes it, and `shipkit ci` is green with no
4
+ // warning a machine could have cleared.
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.
8
+ // Templates live beside this file in files/ and ship in the npm tarball, so
9
+ // none of them may name a machine-local tool or a private path.
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
11
+ import { dirname, join } from 'path'
12
+ import { defaultHubPort } from '../serve/run-serve.ts'
13
+
14
+ const FILES = join(import.meta.dir, 'files')
15
+
16
+ export type ScaffoldOpts = {
17
+ /** repo name, substituted into the templates */
18
+ name: string
19
+ /** report sink */
20
+ print?: (line: string) => void
21
+ /** the changelog's first dated entry — injected so callers stay testable */
22
+ today?: string
23
+ }
24
+
25
+ /** every dep the house toolchain needs for the scripts below to run */
26
+ export const TOOLCHAIN = ['typescript', 'oxlint', 'oxfmt', '@types/bun'] as const
27
+
28
+ /** script bodies for a fresh repo whose only source dir is src/. A stack
29
+ * scaffold (vite, hono) adds dev/build on top; these three are the floor
30
+ * rule SK001 asks every repo for */
31
+ const SCRIPTS: Record<string, string> = {
32
+ lint: 'oxlint src/',
33
+ fmt: 'oxfmt src/',
34
+ typecheck: 'tsc --noEmit',
35
+ }
36
+
37
+ /** repo-owned ignore lines, written OUTSIDE rule SK004's managed block so its
38
+ * fixer never rewrites them */
39
+ const IGNORE_EXTRA = ['backups/', '.claude/settings.local.json']
40
+
41
+ const template = (file: string, vars: Record<string, string>): string => {
42
+ let out = readFileSync(join(FILES, file), 'utf8')
43
+ for (const [k, v] of Object.entries(vars)) out = out.replaceAll(`{{${k}}}`, v)
44
+ return out
45
+ }
46
+
47
+ /** write only when the path is free. Returns whether it wrote */
48
+ function put(root: string, rel: string, body: string): boolean {
49
+ const path = join(root, rel)
50
+ if (existsSync(path)) return false
51
+ mkdirSync(dirname(path), { recursive: true })
52
+ writeFileSync(path, body)
53
+ return true
54
+ }
55
+
56
+ export function scaffoldProject(root: string, opts: ScaffoldOpts): string[] {
57
+ const p = opts.print ?? console.log
58
+ const vars = {
59
+ NAME: opts.name,
60
+ PORT: String(defaultHubPort(opts.name)),
61
+ DATE: opts.today ?? new Date().toISOString().slice(0, 10),
62
+ }
63
+ const written: string[] = []
64
+ const write = (rel: string, body: string): void => { if (put(root, rel, body)) written.push(rel) }
65
+
66
+ 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"]'))
74
+ write('.vscode/settings.json', template('vscode-settings.json', vars))
75
+ 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
+ write('.claude/skills/verify/SKILL.md', template('verify-skill.md', vars))
81
+ write('agent/index.md', template('agent-index.md', vars))
82
+ write('agent/coding.md', template('agent-coding.md', vars))
83
+ write('agent/changelog.md', template('agent-changelog.md', vars))
84
+ // gitignored by policy, so it never ships: it exists so an agent hitting
85
+ // friction with the toolchain finds the place to write it down
86
+ write('.rv-reflections/README.md', REFLECTIONS_README)
87
+
88
+ addScripts(root, written)
89
+ addIgnoreLines(root, written)
90
+
91
+ if (written.length > 0) p(`ok: scaffolded ${written.length} file(s): ${written.slice(0, 6).join(', ')}${written.length > 6 ? `, +${written.length - 6} more` : ''}`)
92
+ return written
93
+ }
94
+
95
+ const REFLECTIONS_README = `# .rv-reflections
96
+
97
+ One file per friction with the shared toolchain: \`YYYYMMDD-<slug>.md\`.
98
+ What broke, which page or lib, what you expected. Gitignored on purpose,
99
+ transient by design: it is harvested, then deleted.
100
+ `
101
+
102
+ /** the three baseline scripts, added only where the key is free */
103
+ function addScripts(root: string, written: string[]): void {
104
+ const path = join(root, 'package.json')
105
+ if (!existsSync(path)) return
106
+ const raw = readFileSync(path, 'utf8')
107
+ let pkg: { scripts?: Record<string, string> }
108
+ try { pkg = JSON.parse(raw) as { scripts?: Record<string, string> } } catch { return }
109
+ pkg.scripts ??= {}
110
+ let added = 0
111
+ for (const [k, v] of Object.entries(SCRIPTS)) if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
112
+ if (added === 0) return
113
+ const indent = raw.match(/^(\s+)"/m)?.[1] ?? ' '
114
+ writeFileSync(path, JSON.stringify(pkg, null, indent) + '\n')
115
+ written.push(`package.json (${added} script(s))`)
116
+ }
117
+
118
+ function addIgnoreLines(root: string, written: string[]): void {
119
+ const path = join(root, '.gitignore')
120
+ const cur = existsSync(path) ? readFileSync(path, 'utf8') : ''
121
+ const have = new Set(cur.split('\n').map((l) => l.trim()))
122
+ const missing = IGNORE_EXTRA.filter((l) => !have.has(l))
123
+ if (missing.length === 0) return
124
+ writeFileSync(path, `${cur}${cur === '' || cur.endsWith('\n') ? '' : '\n'}${missing.join('\n')}\n`)
125
+ written.push('.gitignore')
126
+ }
package/src/start-repo.ts CHANGED
@@ -4,11 +4,12 @@
4
4
  // output is the resolved dir, so a sourced shell alias can
5
5
  // `cd "$(shipkit start <name>)"` (the one thing a child process can't do
6
6
  // for its parent shell).
7
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
8
8
  import { homedir } from 'os'
9
9
  import { join } from 'path'
10
10
  import { ensureGitRepo, firstGhLine, IGNORE_BASELINE, isScratch, publishRepo, runner, type Run } from './git-bootstrap.ts'
11
11
  import { initRepo } from './init-repo.ts'
12
+ import { scaffoldProject, TOOLCHAIN } from './scaffold/project-scaffold.ts'
12
13
 
13
14
  export type StartOpts = {
14
15
  /** parent dir for repos — default ~/dev */
@@ -35,18 +36,7 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
35
36
  return 2
36
37
  }
37
38
  const dir = join(opts.root ?? join(homedir(), 'dev'), name)
38
- // this run creating the dir is the strongest signal that a repo is being
39
- // STARTED, which is what the scaffold and the github half key on:
40
- // `ship <name>` is also the everyday cd into a folder that already
41
- // exists. A pre-existing dir with NOTHING in it carries the same intent
42
- // (there are no files to protect), and so does a git-only husk with no
43
- // commits (what an interrupted start leaves behind). Captured here,
44
- // BEFORE the include seeding writes CLAUDE.md into the dir
45
39
  const justCreated = !existsSync(dir)
46
- const JUNK = new Set(['.DS_Store', '.localized'])
47
- const entries = justCreated ? [] : readdirSync(dir).filter((e) => !JUNK.has(e))
48
- const emptyDir = !justCreated && entries.length === 0
49
- const gitOnlyDir = !justCreated && entries.length === 1 && entries[0] === '.git'
50
40
  if (justCreated) {
51
41
  mkdirSync(dir, { recursive: true })
52
42
  console.error(`ok: created ${dir}`)
@@ -78,14 +68,12 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
78
68
  }
79
69
  }
80
70
  // start is the from-anywhere verb, and a STARTED repo comes out READY:
81
- // a brand-new dir gets a minimal package.json, a first src/index.ts, the
82
- // rvlib-shipkit dev dep, repo.config.ts via init, git, first commit,
83
- // private github repo, push. A dir that already holds a project
84
- // (package.json) gets the same minus the scaffold. A PRE-EXISTING dir
85
- // with NO project is the everyday cd into some old folder: git only,
86
- // never a scaffold (--github forces only the github half, so it must
87
- // never write a package.json into a folder that holds someone's files).
88
- // init itself stays cwd-only, like git init.
71
+ // any dir with no commit history gets a minimal package.json, a first
72
+ // src/index.ts, the rvlib-shipkit dev dep, repo.config.ts via init, git,
73
+ // first commit, private github repo, push. A dir that already holds a
74
+ // project (package.json) gets the same minus the scaffold. Only a repo
75
+ // with real history is left as it is (git wiring aside): that history
76
+ // owns the dir. init itself stays cwd-only, like git init.
89
77
  // --skip means shipkit NEVER TOUCHES a listed repo (a day-job checkout
90
78
  // belongs on that list), not just no CLAUDE.md. And the cd contract survives a
91
79
  // failed adoption: the dir always prints, wiring trouble is a WARN.
@@ -98,43 +86,47 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
98
86
  let wired = false
99
87
  try { wired = await ensureGitRepo(dir, { print }) }
100
88
  catch (e) { console.error(`WARN could not wire git in ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
101
- // the scaffold fires for a dir this run created, a pre-existing dir
102
- // that was EMPTY, or a commitless git-only husk (never --github
103
- // alone: forcing the github half on a folder holding files must not
104
- // write a project into it), and only where git actually wired (a
105
- // subdirectory of another work tree gets nothing): the minimal
106
- // package.json lands so init below has its floor and the first
107
- // commit carries a working repo, not an empty husk
108
- let startable = justCreated || emptyDir
109
- if (!startable && gitOnlyDir && wired)
110
- startable = (await runner('git', dir, 120_000)(['rev-parse', '--verify', 'HEAD'])).code !== 0
111
- if (wired && startable && !existsSync(join(dir, 'package.json')) && !existsSync(join(dir, 'repo.config.ts'))) {
112
- const pkg = { name, version: '0.0.0', private: true, type: 'module', scripts: {} }
113
- writeFileSync(join(dir, 'package.json'), JSON.stringify(pkg, null, 4) + '\n')
114
- // the first source file, so the ci gate is green from commit one:
115
- // an imports gate over zero files fails loud on purpose
116
- mkdirSync(join(dir, 'src'), { recursive: true })
117
- writeFileSync(join(dir, 'src', 'index.ts'), 'export {}\n')
118
- console.error('ok: package.json + src/index.ts scaffolded (private, type module)')
119
- // the install below creates node_modules/ BEFORE the first
120
- // commit, so the baseline ignore must exist first (SK004's block,
121
- // written by init, does not carry node_modules)
89
+ // start means START: the scaffold fires for ANY dir with no commit
90
+ // history (created this run, found empty, found holding loose files,
91
+ // or the husk an interrupted start left). Pre-existing files simply
92
+ // ride in the first commit, which backs them up. What it never
93
+ // touches: a repo with real history (that history owns the dir), a
94
+ // subdirectory of another work tree (git refused, wired is false),
95
+ // and the risky-file commit refusal downstream still guards .env and
96
+ // node_modules
97
+ const startable = wired && (await runner('git', dir, 120_000)(['rev-parse', '--verify', 'HEAD'])).code !== 0
98
+ if (startable && !existsSync(join(dir, 'repo.config.ts'))) {
99
+ if (!existsSync(join(dir, 'package.json'))) {
100
+ const pkg = { name, version: '0.0.0', private: true, type: 'module', scripts: {} }
101
+ writeFileSync(join(dir, 'package.json'), JSON.stringify(pkg, null, 4) + '\n')
102
+ console.error('ok: package.json created (private, type module)')
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
122
109
  if (!existsSync(join(dir, '.gitignore'))) writeFileSync(join(dir, '.gitignore'), `${IGNORE_BASELINE.join('\n')}\n`)
123
- // the scripts init wires call node_modules/rvlib-shipkit — a
124
- // scaffold whose scripts exit 127 is not a started repo. Skips
125
- // mirror the github half's guards: an injected runner wins, the
126
- // env switch turns it off machine-wide, a temp dir is a rehearsal
127
- // that must not pull the network. init owns the fix line for
128
- // every not-installed outcome
110
+ // 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 })
114
+ // the wired scripts call node_modules binaries, so a scaffold
115
+ // whose scripts exit 127 is not a started repo. Skips mirror the
116
+ // github half's guards: an injected runner wins, the env switch
117
+ // turns it off machine-wide, a temp dir is a rehearsal that must
118
+ // not pull the network. init owns the fix line for every
119
+ // not-installed outcome
129
120
  let bun: Run | null
130
121
  if (opts.bun !== undefined) bun = opts.bun
131
- else if ((process.env.SHIPKIT_NO_INSTALL ?? '') !== '') { console.error('skip: rvlib-shipkit install — SHIPKIT_NO_INSTALL is set'); bun = null }
132
- else if (isScratch(dir)) { console.error(`skip: rvlib-shipkit install — ${dir} is a temp dir`); bun = null }
133
- else bun = Bun.which('bun') == null ? null : runner('bun', dir, 180_000)
122
+ else if ((process.env.SHIPKIT_NO_INSTALL ?? '') !== '') { console.error('skip: dependency install — SHIPKIT_NO_INSTALL is set'); bun = null }
123
+ else if (isScratch(dir)) { console.error(`skip: dependency install — ${dir} is a temp dir`); bun = null }
124
+ else bun = Bun.which('bun') == null ? null : runner('bun', dir, 300_000)
134
125
  if (bun != null) {
135
- const r = await bun(['add', '-d', 'rvlib-shipkit'])
136
- if (r.code === 0) console.error('ok: rvlib-shipkit installed (dev dep)')
137
- else console.error(`WARN rvlib-shipkit install failed: ${firstGhLine(r.out)}`)
126
+ const deps = ['rvlib-shipkit', ...TOOLCHAIN]
127
+ const r = await bun(['add', '-d', ...deps])
128
+ if (r.code === 0) console.error(`ok: installed ${deps.join(', ')}`)
129
+ else console.error(`WARN dependency install failed: ${firstGhLine(r.out)}`)
138
130
  }
139
131
  }
140
132
  let unscaffolded = false
@@ -151,6 +143,11 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
151
143
  // last line, so what to do next is the last thing read
152
144
  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`)
153
145
  }
146
+ // a child process cannot cd its parent shell, so the dir goes to stdout
147
+ // and a sourced wrapper does the move. Without one, say the move out
148
+ // loud: a verb that lands you nowhere reads as a verb that did nothing.
149
+ // A wrapper sets SHIPKIT_CD to silence this
150
+ if ((process.env.SHIPKIT_CD ?? '') === '') console.error(`next: cd ${dir} (a sourced shell function running \`cd "$(shipkit start <name>)"\` does this for you)`)
154
151
  console.log(dir)
155
152
  return 0
156
153
  }