@skitterbyte/skitterspec 21.0.0 → 22.1.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/MIGRATION.md +218 -0
- package/README.md +127 -6
- package/assets/claude-md-section.md +42 -18
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-remote-review.md +22 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/env.config.json.example +6 -3
- package/assets/core/env.config.md +183 -25
- package/assets/hooks/main-guard.cjs +145 -0
- package/assets/hooks/review-gate.cjs +8 -3
- package/assets/review/page.html +1174 -101
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +195 -14
- package/assets/rules/spec-reports.md +181 -29
- package/assets/skills/no-spec/SKILL.md +219 -0
- package/assets/skills/spec/SKILL.md +248 -21
- package/assets/skills/spec-bug/SKILL.md +34 -24
- package/assets/skills/spec-cancel/SKILL.md +29 -0
- package/assets/skills/spec-complete/SKILL.md +25 -0
- package/assets/skills/spec-diff/SKILL.md +164 -37
- package/assets/skills/spec-hotfix/SKILL.md +22 -19
- package/assets/skills/spec-init/SKILL.md +4 -2
- package/assets/skills/spec-next/SKILL.md +166 -58
- package/assets/skills/spec-review/SKILL.md +87 -0
- package/assets/skills/spec-reviewed/SKILL.md +31 -5
- package/assets/skills/spec-start/SKILL.md +33 -0
- package/package.json +1 -1
- package/src/cli.js +1612 -172
- package/src/env/classify.js +87 -2
- package/src/env/config.js +333 -19
- package/src/env/hooks.js +102 -33
- package/src/env/live.js +124 -1
- package/src/env/mainguard.js +240 -0
- package/src/env/provision.js +106 -10
- package/src/env/registry.js +70 -6
- package/src/env/resolve.js +128 -5
- package/src/env/review.js +854 -29
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +474 -50
- package/src/env/supervise.js +8 -1
- package/src/env/teardown.js +12 -1
- package/src/init.js +185 -24
package/src/env/teardown.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
const { expandTokens } = require('./resolve.js')
|
|
25
|
+
const { composeFileArg } = require('./provision.js')
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* @param {object} spec resolved spec: { slug, branch, worktreePath, projectName, ... }
|
|
@@ -64,6 +65,14 @@ function planDown(spec, config, flags, ctx) {
|
|
|
64
65
|
// (Stack: worktree) has no stack/volumes even when the project master switch is
|
|
65
66
|
// on — tearing it down is just removing the worktree. A spec resolved without an
|
|
66
67
|
// explicit stack (legacy/tests) follows the master switch (pre-`Stack` behaviour).
|
|
68
|
+
//
|
|
69
|
+
// DELIBERATELY NOT GATED ON THE COMPOSE FILE, where `planUp` is. The two
|
|
70
|
+
// directions are not symmetric: bringing a stack up without evidence there is
|
|
71
|
+
// one to bring costs a failed provision, while skipping a `down` costs an
|
|
72
|
+
// orphaned stack and its volumes, still running, that nothing will come back
|
|
73
|
+
// for. So `up` needs a positive signal and `down` errs toward cleaning up —
|
|
74
|
+
// a `down` for a stack that was never started is a no-op (rule 4, pointing
|
|
75
|
+
// the other way because the harmless branch is the other branch).
|
|
67
76
|
const stack = spec.stack || (config.docker.enabled ? 'docker' : 'worktree')
|
|
68
77
|
const wantsDocker = stack === 'docker' && config.docker.enabled
|
|
69
78
|
|
|
@@ -86,7 +95,9 @@ function planDown(spec, config, flags, ctx) {
|
|
|
86
95
|
|
|
87
96
|
// --- docker compose down (drop volumes unless kept) ---
|
|
88
97
|
if (wantsDocker) {
|
|
89
|
-
const base =
|
|
98
|
+
const base =
|
|
99
|
+
`docker compose${composeFileArg(config, ctx ? ctx.composeFilePresent : undefined)}` +
|
|
100
|
+
` --project-name ${spec.projectName} down`
|
|
90
101
|
commands.push(volumesDropped ? `${base} --volumes` : base)
|
|
91
102
|
}
|
|
92
103
|
|
package/src/init.js
CHANGED
|
@@ -5,7 +5,7 @@ const path = require('path')
|
|
|
5
5
|
const crypto = require('crypto')
|
|
6
6
|
|
|
7
7
|
const { ensureWorktreeDirTrusted } = require('./env/trust.js')
|
|
8
|
-
const {
|
|
8
|
+
const { ensureHooks } = require('./env/hooks.js')
|
|
9
9
|
const { repoInfo, expandTokens } = require('./env/resolve.js')
|
|
10
10
|
|
|
11
11
|
const ASSETS = path.join(__dirname, '..', 'assets')
|
|
@@ -93,14 +93,20 @@ const SPEC_FOLDERS = ['.core', 'backlog', 'in-progress', 'complete', 'cancelled'
|
|
|
93
93
|
// env.config isolation templates; a provider superset also ships its own).
|
|
94
94
|
const CORE_FILES = listCoreTemplates()
|
|
95
95
|
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
96
|
+
// A command file pre-executes the CLI, so it must carry a literal invocation
|
|
97
|
+
// that actually resolves in THIS project. Three installs are possible — local
|
|
98
|
+
// dependency, global, or none — and the runner differs for each.
|
|
99
99
|
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
100
|
+
// A LOCKFILE DOES NOT ANSWER THIS QUESTION. It says which runner would be used
|
|
101
|
+
// *if* the CLI were installed here, which is a different thing, and reading it
|
|
102
|
+
// as evidence of an install is inferring from an absence
|
|
103
|
+
// (`.claude/rules/negative-checks.md` rule 1). It cost the non-Node case
|
|
104
|
+
// outright: a project with no lockfile got `npx skitterspec` baked into every
|
|
105
|
+
// command file, and the unscoped name `skitterspec` does not exist on npm, so
|
|
106
|
+
// all four 404'd.
|
|
107
|
+
//
|
|
108
|
+
// So the lockfile keeps the job it is good at — picking the runner once a local
|
|
109
|
+
// install is established — and a ladder decides whether there is one.
|
|
104
110
|
const PACKAGE_MANAGERS = [
|
|
105
111
|
['pnpm-lock.yaml', 'pnpm exec'],
|
|
106
112
|
['yarn.lock', 'yarn'],
|
|
@@ -108,6 +114,8 @@ const PACKAGE_MANAGERS = [
|
|
|
108
114
|
['bun.lockb', 'bunx'],
|
|
109
115
|
]
|
|
110
116
|
|
|
117
|
+
// Which runner would reach a LOCAL install here? Rung 1's answer only — it says
|
|
118
|
+
// nothing about whether the CLI is present, which is `detectRunner`'s job.
|
|
111
119
|
function detectPackageManager(dir) {
|
|
112
120
|
for (const [lockfile, exec] of PACKAGE_MANAGERS) {
|
|
113
121
|
if (fs.existsSync(path.join(dir, lockfile))) return exec
|
|
@@ -115,12 +123,97 @@ function detectPackageManager(dir) {
|
|
|
115
123
|
return 'npx'
|
|
116
124
|
}
|
|
117
125
|
|
|
126
|
+
// The bin names a distribution may expose. A provider superset ships its own
|
|
127
|
+
// name alongside `skitterspec`, and either proves the engine is reachable.
|
|
128
|
+
const ENGINE_BINS = ['skitterspec', 'skitterspec-linear']
|
|
129
|
+
|
|
130
|
+
function binNames() {
|
|
131
|
+
return process.platform === 'win32'
|
|
132
|
+
? ENGINE_BINS.flatMap((n) => [n, `${n}.cmd`, `${n}.exe`])
|
|
133
|
+
: ENGINE_BINS
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Rung 1 — a local install, found by walking up. Bounded at 6 levels, matching
|
|
137
|
+
// the hooks' own `findEngine`: a git worktree's checkout has its own
|
|
138
|
+
// `node_modules`, and a monorepo package hoists to the root.
|
|
139
|
+
function hasLocalInstall(dir) {
|
|
140
|
+
let at = path.resolve(dir)
|
|
141
|
+
for (let i = 0; i < 6; i++) {
|
|
142
|
+
for (const name of binNames()) {
|
|
143
|
+
if (fs.existsSync(path.join(at, 'node_modules', '.bin', name))) return true
|
|
144
|
+
}
|
|
145
|
+
const up = path.dirname(at)
|
|
146
|
+
if (up === at) break
|
|
147
|
+
at = up
|
|
148
|
+
}
|
|
149
|
+
return false
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// An npx cache bin directory. Matched on the path SEGMENT, so a project that
|
|
153
|
+
// happens to live under a directory called `_npxtools` is not mistaken for one.
|
|
154
|
+
const NPX_CACHE_RE = /[\\/]_npx[\\/]/
|
|
155
|
+
|
|
156
|
+
// Rung 2 — a global install, found on PATH.
|
|
157
|
+
//
|
|
158
|
+
// WHAT WOULD FOOL THIS, and why the filter is not optional: `npx` PREPENDS its
|
|
159
|
+
// own cache bin directory to PATH for the processes it spawns. Verified, not
|
|
160
|
+
// assumed. So running `npx @skitterbyte/skitterspec init` puts a `skitterspec`
|
|
161
|
+
// on PATH that exists only for the duration of that one command — and probing
|
|
162
|
+
// for it here would bake a bare `skitterspec …` into four COMMITTED files that
|
|
163
|
+
// stop working the moment init exits. Excluding `_npx` entries is what makes
|
|
164
|
+
// this rung evidence of an install rather than evidence of the install running.
|
|
165
|
+
function hasPathInstall(env = process.env) {
|
|
166
|
+
const raw = env.PATH || env.Path || ''
|
|
167
|
+
if (!raw) return false
|
|
168
|
+
for (const entry of raw.split(path.delimiter)) {
|
|
169
|
+
if (!entry || NPX_CACHE_RE.test(entry)) continue
|
|
170
|
+
for (const name of binNames()) {
|
|
171
|
+
if (fs.existsSync(path.join(entry, name))) return true
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return false
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* How should a command file invoke the CLI here?
|
|
179
|
+
*
|
|
180
|
+
* A positive-signal ladder: each rung asserts something that must be PRESENT,
|
|
181
|
+
* and only the last is a fallback.
|
|
182
|
+
*
|
|
183
|
+
* local — a local install; the lockfile picks the runner
|
|
184
|
+
* path — a global install; no runner at all, the bin is invoked bare
|
|
185
|
+
* none — neither could be found. `npx` is written as before, and the caller
|
|
186
|
+
* warns, because this is the cannot-tell case rather than a third
|
|
187
|
+
* kind of install (rule 4: the unknown routes to the harmless branch,
|
|
188
|
+
* which here is "write what we always wrote and say so").
|
|
189
|
+
*
|
|
190
|
+
* PROJECT-VISIBLE SIGNALS OUTRANK MACHINE-VISIBLE ONES, which is why `local` is
|
|
191
|
+
* checked first. `.claude/commands/*.md` is committed and `renderCommand` feeds
|
|
192
|
+
* `managedTargets`, so a render that varies by machine makes the file flip
|
|
193
|
+
* between pristine and updatable as different people run `update`. A lockfile
|
|
194
|
+
* is committed and answers the same everywhere; PATH is one machine's business.
|
|
195
|
+
*/
|
|
196
|
+
function detectRunner(dir, env = process.env) {
|
|
197
|
+
if (hasLocalInstall(dir)) return { runner: detectPackageManager(dir), rung: 'local' }
|
|
198
|
+
if (hasPathInstall(env)) return { runner: '', rung: 'path' }
|
|
199
|
+
return { runner: 'npx', rung: 'none' }
|
|
200
|
+
}
|
|
201
|
+
|
|
118
202
|
// Fill a command file's `{{exec}}` placeholders. Kept a pure function of
|
|
119
203
|
// (content, dir) so `managedTargets` can compare against exactly what
|
|
120
204
|
// `installCommands` would write — otherwise every install would hash as
|
|
121
205
|
// customized on the next run.
|
|
206
|
+
//
|
|
207
|
+
// The token is consumed WITH its trailing space, so an empty runner leaves
|
|
208
|
+
// `Bash(skitterspec …)` rather than `Bash( skitterspec …)`. The second pass
|
|
209
|
+
// catches a bare `{{exec}}` in case a template ever lands without one.
|
|
122
210
|
function renderCommand(content, dir) {
|
|
123
|
-
|
|
211
|
+
const { runner } = detectRunner(dir)
|
|
212
|
+
return content
|
|
213
|
+
.split('{{exec}} ')
|
|
214
|
+
.join(runner ? `${runner} ` : '')
|
|
215
|
+
.split('{{exec}}')
|
|
216
|
+
.join(runner)
|
|
124
217
|
}
|
|
125
218
|
|
|
126
219
|
// --- composed-assets guard -------------------------------------------------
|
|
@@ -179,7 +272,7 @@ function assertComposedAssets() {
|
|
|
179
272
|
const SPEC_MARKER_START = '<!-- skitterspec:start -->'
|
|
180
273
|
const SPEC_MARKER_END = '<!-- skitterspec:end -->'
|
|
181
274
|
|
|
182
|
-
const report = { created: [], updated: [], skipped: [], refused: [], removed: [], customized: [], healed: [], warnings: [] }
|
|
275
|
+
const report = { created: [], updated: [], skipped: [], refused: [], removed: [], customized: [], adopted: [], healed: [], warnings: [] }
|
|
183
276
|
|
|
184
277
|
function resetReport() {
|
|
185
278
|
for (const k of Object.keys(report)) report[k].length = 0
|
|
@@ -241,16 +334,31 @@ function managedTargets(dir) {
|
|
|
241
334
|
}
|
|
242
335
|
|
|
243
336
|
// Read the manifest (tolerant: missing/malformed → an empty baseline).
|
|
337
|
+
// `baselined` answers a question `files` cannot: could this manifest have SEEN
|
|
338
|
+
// a path? It is true only when the file parsed into the expected shape AND
|
|
339
|
+
// records at least one entry — a POSITIVE signal, not an absence, which is what
|
|
340
|
+
// lets `managedState` read a path's absence from it as evidence of anything
|
|
341
|
+
// (`.claude/rules/negative-checks.md` rule 1).
|
|
342
|
+
//
|
|
343
|
+
// WHAT WOULD FOOL IT: nothing silently. A missing manifest and a malformed one
|
|
344
|
+
// both fall into the same empty baseline below — correct for reading hashes,
|
|
345
|
+
// and useless for reasoning about absence, since on a repo with no manifest yet
|
|
346
|
+
// EVERY path is absent. Both answer `baselined: false`, so the one conclusion
|
|
347
|
+
// that depends on it is simply not drawn.
|
|
244
348
|
function readManifest(dir) {
|
|
245
349
|
try {
|
|
246
350
|
const parsed = JSON.parse(fs.readFileSync(path.join(dir, MANIFEST_FILE), 'utf8'))
|
|
247
351
|
if (parsed && typeof parsed === 'object' && parsed.files && typeof parsed.files === 'object') {
|
|
248
|
-
return {
|
|
352
|
+
return {
|
|
353
|
+
version: parsed.version || MANIFEST_VERSION,
|
|
354
|
+
files: parsed.files,
|
|
355
|
+
baselined: Object.keys(parsed.files).length > 0,
|
|
356
|
+
}
|
|
249
357
|
}
|
|
250
358
|
} catch {
|
|
251
359
|
/* missing or malformed → empty baseline */
|
|
252
360
|
}
|
|
253
|
-
return { version: MANIFEST_VERSION, files: {} }
|
|
361
|
+
return { version: MANIFEST_VERSION, files: {}, baselined: false }
|
|
254
362
|
}
|
|
255
363
|
|
|
256
364
|
function writeManifest(dir, files) {
|
|
@@ -265,6 +373,29 @@ function writeManifest(dir, files) {
|
|
|
265
373
|
// missing — not on disk
|
|
266
374
|
// pristine — ours to update: it matches the package asset, or the hash we recorded
|
|
267
375
|
// customized — on disk but differs from both — a user edit; keep it
|
|
376
|
+
// adopted — a path this version has NEWLY CLAIMED, where the project
|
|
377
|
+
// already had a file: it differs from what we ship, and the
|
|
378
|
+
// manifest that could have named it does not. Keep it too — the
|
|
379
|
+
// two states differ only in what the report CLAIMS about it.
|
|
380
|
+
//
|
|
381
|
+
// The split exists because `customized` made a false statement about the second
|
|
382
|
+
// case. "Your edit — kept" describes our file at a path upstream had not
|
|
383
|
+
// claimed, and the reader acts on it: v21 renamed the commit hook to
|
|
384
|
+
// `review-gate.cjs`, which is the same name anyone would have picked to work
|
|
385
|
+
// around v20's ESM crash — so their shim was kept, the real hook was never
|
|
386
|
+
// installed, and `review-gate.js` was pruned out from under it. The shim fails
|
|
387
|
+
// open, so the gate went silently absent under a report that said success.
|
|
388
|
+
//
|
|
389
|
+
// WHAT WOULD FOOL THIS, and why neither does:
|
|
390
|
+
// - A repo with no manifest (or an unreadable one) has EVERY path absent.
|
|
391
|
+
// `baselined` is the positive signal that gates the conclusion, so those
|
|
392
|
+
// fall back to `customized` — which keeps the file either way
|
|
393
|
+
// (`.claude/rules/negative-checks.md` rules 1 and 4).
|
|
394
|
+
// - The signal is ONE-SHOT. `flushManifest`'s migration seed gives any
|
|
395
|
+
// present managed file an entry on the very next run, so an adopted path
|
|
396
|
+
// reads `customized` from then on. Anyone who already upgraded has spent
|
|
397
|
+
// it — which is why the `.cjs` trap is also written into MIGRATION.md,
|
|
398
|
+
// where that reader is actually looking.
|
|
268
399
|
//
|
|
269
400
|
// `bundled` (the current package asset) is optional but decisive: a file whose
|
|
270
401
|
// CONTENT equals what we ship is not customized, whatever the manifest says.
|
|
@@ -280,7 +411,8 @@ function managedState(dir, relPath, manifest, bundled) {
|
|
|
280
411
|
const onDisk = fs.readFileSync(abs, 'utf8')
|
|
281
412
|
if (bundled !== undefined && onDisk === bundled) return 'pristine'
|
|
282
413
|
const known = manifest.files[relPath]
|
|
283
|
-
|
|
414
|
+
if (known) return sha1(onDisk) === known ? 'pristine' : 'customized'
|
|
415
|
+
return manifest.baselined ? 'adopted' : 'customized'
|
|
284
416
|
}
|
|
285
417
|
|
|
286
418
|
// Reconcile and persist the manifest after an install/resync run: keep prior
|
|
@@ -386,6 +518,19 @@ function installCommands(dir, opts) {
|
|
|
386
518
|
)
|
|
387
519
|
writeFile(dir, path.join(dir, '.claude', 'commands', name), content, opts)
|
|
388
520
|
}
|
|
521
|
+
|
|
522
|
+
// ONLY on the cannot-tell rung, and it warns rather than refusing: `init` has
|
|
523
|
+
// real work to do either way — skills, rules, folders — and none of it needs
|
|
524
|
+
// the CLI reachable later. Silence here is what let a project install cleanly
|
|
525
|
+
// and hand back four commands that 404 on first use.
|
|
526
|
+
if (COMMANDS.length && detectRunner(dir).rung === 'none') {
|
|
527
|
+
report.warnings.push(
|
|
528
|
+
`the skitterspec CLI could not be found here, so the ${COMMANDS.length} slash ` +
|
|
529
|
+
'command(s) were written to call `npx skitterspec`, which will fail — ' +
|
|
530
|
+
'install it (npm i -g @skitterbyte/skitterspec, or add it as a dev ' +
|
|
531
|
+
'dependency) and re-run `skitterspec update` to rewrite them',
|
|
532
|
+
)
|
|
533
|
+
}
|
|
389
534
|
}
|
|
390
535
|
|
|
391
536
|
function installRule(dir, opts) {
|
|
@@ -405,17 +550,20 @@ function installRule(dir, opts) {
|
|
|
405
550
|
// reported and left alone, never rewritten, and never fatal — the hook is an
|
|
406
551
|
// extra layer, and the engine and `/spec-next` hold the gate without it.
|
|
407
552
|
function registerReviewGateHook(dir) {
|
|
408
|
-
|
|
553
|
+
// "hooks", plural, because two ship now — the review gate and the main guard.
|
|
554
|
+
// The label names the file and the subject rather than one script, so adding a
|
|
555
|
+
// third does not leave a report that undercounts what it wrote.
|
|
556
|
+
const label = '.claude/settings.json (skitterspec hooks)'
|
|
409
557
|
let res
|
|
410
558
|
try {
|
|
411
|
-
res =
|
|
559
|
+
res = ensureHooks(dir)
|
|
412
560
|
} catch {
|
|
413
|
-
report.warnings.push('could not write .claude/settings.json —
|
|
561
|
+
report.warnings.push('could not write .claude/settings.json — skitterspec hooks not registered')
|
|
414
562
|
return
|
|
415
563
|
}
|
|
416
564
|
if (res.reason === 'malformed') {
|
|
417
565
|
report.warnings.push(
|
|
418
|
-
'.claude/settings.json is not valid JSON — did not register the
|
|
566
|
+
'.claude/settings.json is not valid JSON — did not register the skitterspec hooks',
|
|
419
567
|
)
|
|
420
568
|
} else if (res.reason === 'created') {
|
|
421
569
|
report.created.push(label)
|
|
@@ -426,9 +574,9 @@ function registerReviewGateHook(dir) {
|
|
|
426
574
|
// that: rewriting the registered path and then reporting "already
|
|
427
575
|
// registered" — a run that acts and says it did not, which is the shape of
|
|
428
576
|
// the bug this whole change exists to fix.
|
|
429
|
-
report.updated.push('.claude/settings.json (
|
|
577
|
+
report.updated.push('.claude/settings.json (skitterspec hooks repointed at the renamed script)')
|
|
430
578
|
} else {
|
|
431
|
-
report.skipped.push('.claude/settings.json (
|
|
579
|
+
report.skipped.push('.claude/settings.json (skitterspec hooks already registered)')
|
|
432
580
|
}
|
|
433
581
|
}
|
|
434
582
|
|
|
@@ -627,6 +775,8 @@ function checkSync(dir, { claudeMd = true, log = console.log } = {}) {
|
|
|
627
775
|
const state = managedState(dir, relPath, manifest, bundled)
|
|
628
776
|
if (state === 'missing') rows.push([relPath, 'missing — would be created'])
|
|
629
777
|
else if (state === 'customized') rows.push([relPath, 'your edit — kept (--force overwrites)'])
|
|
778
|
+
else if (state === 'adopted')
|
|
779
|
+
rows.push([relPath, 'adopted upstream — yours kept, theirs not installed (--force takes theirs)'])
|
|
630
780
|
else if (fs.readFileSync(abs, 'utf8') !== bundled) rows.push([relPath, 'out of date — would be updated'])
|
|
631
781
|
}
|
|
632
782
|
const section = claudeMd ? claudeMdSectionState(dir) : 'fresh'
|
|
@@ -714,14 +864,16 @@ function resyncManagedFile(dir, target, manifest, force) {
|
|
|
714
864
|
report[bucket].push(relPath)
|
|
715
865
|
}
|
|
716
866
|
if (state === 'missing') return write('created')
|
|
717
|
-
|
|
867
|
+
// `adopted` writes exactly as `customized` does — keep unless forced. Only the
|
|
868
|
+
// bucket differs, because only the REPORT was ever wrong about it.
|
|
869
|
+
if (state === 'customized' || state === 'adopted') {
|
|
718
870
|
if (force) return write('updated')
|
|
719
871
|
writtenHashes[relPath] = manifest.files[relPath] || writtenHashes[relPath] // keep baseline
|
|
720
872
|
// Carry the change the user just DECLINED. A bare filename tells them a
|
|
721
873
|
// decision was made on their behalf but not what it was, which leaves
|
|
722
874
|
// "clobber and re-apply my edits by hand" as the only safe way to upgrade.
|
|
723
875
|
const { added, removed, hunks } = linesDiff(fs.readFileSync(abs, 'utf8'), bundled)
|
|
724
|
-
return report.
|
|
876
|
+
return report[state].push({ relPath, added, removed, hunks })
|
|
725
877
|
}
|
|
726
878
|
// pristine — update only if the bundled content actually changed
|
|
727
879
|
if (fs.readFileSync(abs, 'utf8') === bundled) {
|
|
@@ -836,6 +988,10 @@ function printReport(dir, mode, { diff = false } = {}) {
|
|
|
836
988
|
'customized (kept)',
|
|
837
989
|
report.customized.map((c) => `${c.relPath} +${c.added} \u2212${c.removed}`),
|
|
838
990
|
)
|
|
991
|
+
line(
|
|
992
|
+
'adopted upstream (kept — ours was never installed)',
|
|
993
|
+
report.adopted.map((c) => `${c.relPath} +${c.added} \u2212${c.removed}`),
|
|
994
|
+
)
|
|
839
995
|
line('manifest repaired', report.healed)
|
|
840
996
|
line('unchanged', report.skipped)
|
|
841
997
|
if (report.refused.length) {
|
|
@@ -851,13 +1007,17 @@ function printReport(dir, mode, { diff = false } = {}) {
|
|
|
851
1007
|
process.stdout.write('\nwarnings:\n')
|
|
852
1008
|
for (const w of report.warnings) process.stdout.write(` ! ${w}\n`)
|
|
853
1009
|
}
|
|
1010
|
+
const declined = [
|
|
1011
|
+
...report.customized.map((c) => [c, 'kept — this is what you declined']),
|
|
1012
|
+
...report.adopted.map((c) => [c, 'kept — this is what was not installed']),
|
|
1013
|
+
]
|
|
854
1014
|
if (diff) {
|
|
855
|
-
for (const c of
|
|
1015
|
+
for (const [c, why] of declined) {
|
|
856
1016
|
if (!c.hunks.length) continue
|
|
857
|
-
process.stdout.write(`\n--- ${c.relPath} (
|
|
1017
|
+
process.stdout.write(`\n--- ${c.relPath} (${why})\n`)
|
|
858
1018
|
for (const h of c.hunks) process.stdout.write(`${h}\n`)
|
|
859
1019
|
}
|
|
860
|
-
} else if (
|
|
1020
|
+
} else if (declined.length) {
|
|
861
1021
|
process.stdout.write('\nRe-run with --diff to see the changes those files declined.\n')
|
|
862
1022
|
}
|
|
863
1023
|
const isolationOn = fs.existsSync(path.join(dir, 'specs', '.core', 'env.config.json'))
|
|
@@ -953,5 +1113,6 @@ module.exports = {
|
|
|
953
1113
|
assertSafeToDelete,
|
|
954
1114
|
assertComposedAssets,
|
|
955
1115
|
detectPackageManager,
|
|
1116
|
+
detectRunner,
|
|
956
1117
|
renderCommand,
|
|
957
1118
|
}
|