rvlib-shipkit 0.27.0 → 0.29.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 +12 -0
- package/package.json +1 -1
- package/src/cli/cmd_Start.ts +1 -1
- package/src/git-bootstrap.ts +23 -8
- package/src/init-repo.ts +14 -6
- package/src/scaffold/files/tsconfig.json +7 -1
- package/src/scaffold/files/verify-skill.md +5 -4
- package/src/scaffold/project-scaffold.ts +96 -20
- package/src/start-repo.ts +34 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.29.0
|
|
4
|
+
|
|
5
|
+
- **Creation output states facts instead of listing chores.** A fresh `start` ended on five lines that meant nothing to the person reading them: `skip: lint/typecheck/test (no … script)` about scripts a repo born one second ago has no use for, plus `next:` nags to fill venues and mint identity assets, neither of which anybody has decided on yet. `init` now reports the gate floor as ONE line naming what resolved, and `start` closes with one measured summary (`<name> is ready — N file(s) committed, pushed to <remote>`). Venues raise themselves through the contract checker the day one is declared, and identity through rule SK002 the day the repo grows a public surface. An output that lists undecided work trains the reader to skip all of it.
|
|
6
|
+
|
|
7
|
+
## 0.28.0
|
|
8
|
+
|
|
9
|
+
- **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.
|
|
10
|
+
- **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.
|
|
11
|
+
- **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.
|
|
12
|
+
- **A scaffold write error no longer aborts the start.** It is wrapped like every other step, warns, and the chain still commits and pushes.
|
|
13
|
+
- **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`.
|
|
14
|
+
|
|
3
15
|
## 0.27.0
|
|
4
16
|
|
|
5
17
|
- **`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.
|
|
3
|
+
"version": "0.29.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_Start.ts
CHANGED
|
@@ -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
|
|
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'"],
|
package/src/git-bootstrap.ts
CHANGED
|
@@ -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
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
// repo
|
|
270
|
-
|
|
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 {
|
package/src/init-repo.ts
CHANGED
|
@@ -99,11 +99,15 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
99
99
|
// `shipkit check --fix SK004`, one code path
|
|
100
100
|
const g = await gitignorePolicy.fix!({ root, config: defineRepo({ name }) })
|
|
101
101
|
p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
|
|
102
|
-
//
|
|
103
|
-
// time
|
|
102
|
+
// what the gate actually runs, in ONE line: a thin floor must be seen at
|
|
103
|
+
// init time rather than discovered after a bad commit slipped through.
|
|
104
|
+
// The per-task resolution is diagnostics, and a `skip:` naming a script
|
|
105
|
+
// this repo has no use for yet reads as a chore list on a repo that was
|
|
106
|
+
// born a second ago. `shipkit ci` reports the same facts when they
|
|
107
|
+
// matter, and reports them about a repo that has code in it
|
|
104
108
|
const floor = resolveFloor(defineRepo({ name }), root)
|
|
105
|
-
|
|
106
|
-
|
|
109
|
+
if (floor.tasks.length > 0) p(`ok: gate floor: ${floor.tasks.map((t) => t.id).join(', ')}`)
|
|
110
|
+
else p(`WARN the gate would check NOTHING here (${floor.unfilled.map((u) => u.id).join(', ')} all unresolved) — see \`shipkit rules\``)
|
|
107
111
|
// every verb comes from the `shipkit` bin — dead on arrival unless the
|
|
108
112
|
// package is actually installed (bunx runs without adding the dep)
|
|
109
113
|
if (opts.bin == null) {
|
|
@@ -116,7 +120,11 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
116
120
|
}
|
|
117
121
|
// last, so the first commit carries repo.config.ts and the scripts above
|
|
118
122
|
if (wired && opts.noPublish !== true) await publishRepo(root, { github: opts.github, gh: opts.gh, print: p })
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
// no chore list here. Venues belong in repo.config the day a venue is
|
|
124
|
+
// CHOSEN, and the contract checker fails the gate from that day on.
|
|
125
|
+
// Identity is needed once the repo has a public surface, and rule SK002
|
|
126
|
+
// warns exactly then. Both told at creation time are noise about a
|
|
127
|
+
// decision nobody has made yet. `start` owns the one closing line
|
|
128
|
+
if (opts.noPublish !== true) p('next: `shipkit ci` is the gate — run it to check your work')
|
|
121
129
|
return 0
|
|
122
130
|
}
|
|
@@ -38,5 +38,11 @@
|
|
|
38
38
|
"incremental": true,
|
|
39
39
|
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
|
40
40
|
},
|
|
41
|
-
|
|
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
|
-
-
|
|
20
|
-
|
|
21
|
-
-
|
|
22
|
-
|
|
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
|
-
//
|
|
7
|
-
//
|
|
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(
|
|
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
|
|
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
|
-
|
|
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> } }
|
|
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,
|
|
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
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
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
|
|
112
|
-
//
|
|
113
|
-
|
|
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
|
|
@@ -142,6 +148,7 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
|
|
|
142
148
|
}
|
|
143
149
|
// last line, so what to do next is the last thing read
|
|
144
150
|
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`)
|
|
151
|
+
else console.error(`ok: ${name} is ready — ${await summarize(dir)}`)
|
|
145
152
|
}
|
|
146
153
|
// a child process cannot cd its parent shell, so the dir goes to stdout
|
|
147
154
|
// and a sourced wrapper does the move. Without one, say the move out
|
|
@@ -151,3 +158,20 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
|
|
|
151
158
|
console.log(dir)
|
|
152
159
|
return 0
|
|
153
160
|
}
|
|
161
|
+
|
|
162
|
+
/** the one closing fact: what this repo IS now, measured rather than
|
|
163
|
+
* narrated. Everything a repo still needs is reported by the gate, about a
|
|
164
|
+
* repo with code in it, on the day it matters */
|
|
165
|
+
async function summarize(dir: string): Promise<string> {
|
|
166
|
+
const git = runner('git', dir, 30_000)
|
|
167
|
+
const files = (await git(['ls-files'])).out.trim().split('\n').filter((l) => l !== '').length
|
|
168
|
+
const head = await git(['log', '-1', '--format=%h'])
|
|
169
|
+
// git merges stderr into stdout, so a missing remote answers with its
|
|
170
|
+
// error text: only a zero exit means that output is a url
|
|
171
|
+
const origin = await git(['remote', 'get-url', 'origin'])
|
|
172
|
+
const pushed = (await git(['rev-parse', '--verify', '@{upstream}'])).code === 0
|
|
173
|
+
const bits = [head.code === 0 ? `${files} file(s) committed` : 'not committed yet']
|
|
174
|
+
if (origin.code === 0) bits.push(pushed ? `pushed to ${origin.out.trim()}` : `remote wired, nothing pushed yet`)
|
|
175
|
+
else bits.push('local only, no remote')
|
|
176
|
+
return bits.join(', ')
|
|
177
|
+
}
|