pantheon-opencode 1.6.0-beta.10 → 1.6.0-beta.12

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.
Files changed (71) hide show
  1. package/AGENTS.global.md +42 -18
  2. package/AGENTS.md +1 -1
  3. package/CHANGELOG.md +46 -0
  4. package/README.md +122 -38
  5. package/README.pt-BR.md +5 -4
  6. package/bin/pantheon-init.mjs +322 -101
  7. package/package.json +7 -6
  8. package/plugin.json +1 -1
  9. package/pyproject.toml +1 -1
  10. package/scripts/build-agents-md.mjs +128 -10
  11. package/scripts/check-plugin-v2-coverage.mjs +7 -7
  12. package/scripts/doctor.mjs +242 -19
  13. package/scripts/install/backup.mjs +648 -0
  14. package/scripts/install/cli-ui.mjs +79 -1
  15. package/scripts/install/exit-codes.mjs +26 -0
  16. package/scripts/install/file-inventory.mjs +555 -0
  17. package/scripts/install/interactive.mjs +15 -1
  18. package/scripts/install/migrate.mjs +32 -6
  19. package/scripts/install/model-picker.mjs +63 -22
  20. package/scripts/install/opencode-version.mjs +9 -1
  21. package/scripts/install/opencode.mjs +761 -63
  22. package/scripts/install/plan-artifact.mjs +517 -0
  23. package/scripts/install/plan.mjs +449 -0
  24. package/scripts/install/plugin.mjs +136 -18
  25. package/scripts/install/shared.mjs +249 -94
  26. package/scripts/install/state.mjs +63 -8
  27. package/scripts/install/strings.mjs +20 -0
  28. package/scripts/install/venv.mjs +25 -6
  29. package/scripts/install/verify.mjs +128 -0
  30. package/scripts/install/vision-prompt.mjs +13 -1
  31. package/scripts/install-mcp.mjs +1 -1
  32. package/scripts/manifest-inventory.mjs +49 -3
  33. package/scripts/mcp_persistence.py +50 -15
  34. package/scripts/node-engine-policy.mjs +74 -0
  35. package/scripts/package-evidence.mjs +6 -0
  36. package/scripts/postinstall.mjs +3 -4
  37. package/scripts/prune.mjs +9 -3
  38. package/scripts/reconcile.mjs +325 -0
  39. package/scripts/restore.mjs +222 -0
  40. package/scripts/uninstall.mjs +258 -34
  41. package/scripts/update.mjs +164 -0
  42. package/scripts/validate-node-engine-policy.mjs +13 -0
  43. package/scripts/version-check.mjs +34 -1
  44. package/scripts/versioning.mjs +9 -1
  45. package/skills-lock.json +4 -4
  46. package/src/agents/iris.md +6 -0
  47. package/src/agents/mnemosyne.md +7 -2
  48. package/src/agents/zeus.md +19 -62
  49. package/src/instructions/agent-return-format.instructions.md +17 -15
  50. package/src/instructions/memory-protocol.instructions.md +3 -2
  51. package/src/instructions/test-economy.instructions.md +21 -0
  52. package/src/instructions/zeus-anti-stall.instructions.md +6 -6
  53. package/src/instructions/zeus-council-synthesis.instructions.md +10 -11
  54. package/src/instructions/zeus-memory-operations.instructions.md +11 -6
  55. package/src/instructions/zeus-timeout-retry.instructions.md +28 -9
  56. package/src/mcp/mcp_persistence.py +50 -15
  57. package/src/pantheon/cost-command.ts +5 -2
  58. package/src/pantheon/model-agents.ts +19 -0
  59. package/src/pantheon/model-command.ts +218 -56
  60. package/src/pantheon/v2-model-overrides.ts +275 -0
  61. package/src/pantheon/v2-tools.ts +18 -29
  62. package/src/pantheon/v2-unsupported.mjs +2 -3
  63. package/src/plugin-v2.ts +13 -0
  64. package/src/plugins/tui/dist/tui-v2.js +2 -2
  65. package/src/plugins/tui/dist/tui.js +1 -1
  66. package/src/plugins/tui/package-lock.json +3 -3
  67. package/src/plugins/tui/package.json +2 -2
  68. package/src/skills/artifact-management/SKILL.md +5 -1
  69. package/src/skills/auto-continue/SKILL.md +14 -6
  70. package/src/skills/memory-bank/SKILL.md +5 -1
  71. package/src/skills/worktrees/SKILL.md +30 -11
@@ -1,3 +1,4 @@
1
+ import { basename, dirname, join, resolve } from 'node:path'
1
2
  import { strings } from './strings.mjs'
2
3
 
3
4
  export const icons = {
@@ -33,7 +34,12 @@ export function configure(opts = {}) {
33
34
  if (opts.dryRun !== undefined) _dryRun = opts.dryRun
34
35
  }
35
36
 
36
- function tag(msg) {
37
+ /**
38
+ * Prefix a message with the `[DRY-RUN]` marker while a dry run is configured.
39
+ * Exported so callers that print through raw `console.log` (e.g. the per-file
40
+ * change plan) stay consistent with every other dry-run line.
41
+ */
42
+ export function tag(msg) {
37
43
  return _dryRun ? `[DRY-RUN] ${msg}` : msg
38
44
  }
39
45
 
@@ -191,3 +197,75 @@ export function printSummary(target, _platforms, stats) {
191
197
  console.log(tag(S.summaryNextMcp))
192
198
  console.log(tag(S.summaryNextDoctor))
193
199
  }
200
+
201
+ /**
202
+ * Build a persistent worktree location outside the project directory.
203
+ *
204
+ * @param {string} projectRoot - Project directory whose exact basename, including spaces, is retained.
205
+ * @returns {{ pattern: string, example: string }} Recommended paths for future worktrees.
206
+ */
207
+ export function getWorktreeRecommendation(projectRoot = process.cwd()) {
208
+ const resolvedProject = resolve(projectRoot)
209
+ const projectName = basename(resolvedProject)
210
+ const repositoryWorktreeRoot = resolve(
211
+ dirname(resolvedProject),
212
+ '.pantheon-worktrees',
213
+ projectName || 'project',
214
+ )
215
+
216
+ return {
217
+ pattern: join(repositoryWorktreeRoot, '<task>'),
218
+ example: join(repositoryWorktreeRoot, 'feature-name'),
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Show the suggested external worktree location in a real interactive TTY setup.
224
+ * Pantheon provides guidance only; it never creates a worktree or its directory.
225
+ *
226
+ * @param {object} [options]
227
+ * @param {string} [options.projectRoot=process.cwd()] - Project path for the recommendation.
228
+ * @param {boolean} [options.interactive=false] - Whether setup is interactive.
229
+ * @param {boolean} [options.dryRun=false] - Whether setup is preview-only.
230
+ * @returns {void}
231
+ */
232
+ export function printWorktreeGuidance({
233
+ projectRoot = process.cwd(),
234
+ interactive = false,
235
+ dryRun = false,
236
+ } = {}) {
237
+ if (!interactive || dryRun || !tty()) return
238
+
239
+ const recommendation = getWorktreeRecommendation(projectRoot)
240
+ const rows = [
241
+ { label: 'Default: ', value: recommendation.pattern, color: colors.green },
242
+ { label: 'Example: ', value: recommendation.example, color: colors.cyan },
243
+ {
244
+ label: 'Use: ',
245
+ value:
246
+ 'Persistent location; preserve the exact project directory basename, including spaces.',
247
+ color: colors.dim,
248
+ },
249
+ {
250
+ label: 'Note: ',
251
+ value: 'Pantheon does not create Git worktrees or directories automatically.',
252
+ color: colors.yellow,
253
+ },
254
+ ]
255
+ const contentWidth = Math.max(
256
+ 'Worktrees'.length,
257
+ ...rows.map(({ label, value }) => label.length + value.length),
258
+ )
259
+ const border = colors.cyan
260
+
261
+ console.log('')
262
+ console.log(` ${border(`┌${'─'.repeat(contentWidth + 2)}┐`)}`)
263
+ console.log(` ${border('│')} ${colors.bold('Worktrees'.padEnd(contentWidth))} ${border('│')}`)
264
+ console.log(` ${border(`├${'─'.repeat(contentWidth + 2)}┤`)}`)
265
+ for (const { label, value, color } of rows) {
266
+ const padding = ' '.repeat(contentWidth - label.length - value.length)
267
+ console.log(` ${border('│')} ${colors.bold(label)}${color(value)}${padding} ${border('│')}`)
268
+ }
269
+ console.log(` ${border(`└${'─'.repeat(contentWidth + 2)}┘`)}`)
270
+ console.log('')
271
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * exit-codes.mjs — the process exit code the `init` CLI returns for an
3
+ * `installOpenCode` result. Pure, so the mapping is unit-testable without
4
+ * spawning the CLI.
5
+ *
6
+ * `0` success, or plan-only / declined (nothing applied)
7
+ * `1` failure (the CLI handles thrown errors separately)
8
+ * `2` apply requested without authorization (non-interactive, no --yes)
9
+ * `3` drift detected — a planned target changed between plan and apply
10
+ * `130` canceled
11
+ */
12
+
13
+ export function resolveInstallExitCode(result, isDryRun) {
14
+ if (isDryRun) return 0
15
+ if (result?.applied !== false) return 0
16
+ switch (result.reason) {
17
+ case 'not-authorized':
18
+ return 2
19
+ case 'drift':
20
+ return 3
21
+ case 'canceled':
22
+ return 130
23
+ default:
24
+ return 0
25
+ }
26
+ }
@@ -0,0 +1,555 @@
1
+ /**
2
+ * Conservative per-file installer ownership tracking and stale-file cleanup.
3
+ * The inventory records paths and hashes only; legacy state never implies
4
+ * ownership of files which predate the inventory.
5
+ */
6
+ import { createHash } from 'node:crypto'
7
+ import {
8
+ closeSync,
9
+ constants,
10
+ fstatSync,
11
+ lstatSync,
12
+ openSync,
13
+ readFileSync,
14
+ unlinkSync,
15
+ } from 'node:fs'
16
+ import { isAbsolute, join, posix, relative, resolve, sep } from 'node:path'
17
+ import { createBackupSet } from './backup.mjs'
18
+ import { writeState } from './state.mjs'
19
+ import { assertNoSymlinkAncestors, verifyPathRemoved } from './verify.mjs'
20
+
21
+ export const FILE_INVENTORY_VERSION = 1
22
+
23
+ /** Map installer-owned copy layouts to their logical component without names heuristics. */
24
+ export function componentForInstalledPath(target, filePath, { isGlobal, version = 'v1' } = {}) {
25
+ let relPath
26
+ try {
27
+ relPath = relativeTargetPath(target, filePath)
28
+ } catch {
29
+ return null
30
+ }
31
+ const layout = isGlobal ? 'global' : version === 'v2' ? 'project-v2' : 'project-v1'
32
+ const agentRoot = isGlobal || version === 'v2' ? 'agents' : '.opencode/agents'
33
+ const skillRoot = isGlobal ? 'skills' : '.opencode/skills'
34
+ const commandRoot = isGlobal || version === 'v2' ? 'commands' : '.opencode/commands'
35
+ const pluginRoot = isGlobal ? 'plugins/pantheon-tui' : '.opencode/plugins/pantheon-tui'
36
+ const runtimeRoot = isGlobal ? '' : '.opencode'
37
+ const startsIn = (prefix) => relPath.startsWith(`${prefix}/`)
38
+
39
+ if (startsIn(agentRoot) || relPath === 'routing.yml') return { component: 'agents', layout }
40
+ if (startsIn(skillRoot)) return { component: 'skills', layout }
41
+ // AGENTS.md contains user content outside its managed marker block; the whole
42
+ // file is therefore not safe to claim or remove as a single owned path.
43
+ if (startsIn('instructions')) return { component: 'instructions', layout }
44
+ if (startsIn('prompts')) return { component: 'prompts', layout }
45
+ if (startsIn(commandRoot)) return { component: 'commands', layout }
46
+ if (startsIn(pluginRoot)) return { component: 'plugins', layout }
47
+ const runtimePrefix = runtimeRoot ? `${runtimeRoot}/` : ''
48
+ if (
49
+ startsIn(`${runtimePrefix}scripts`) ||
50
+ startsIn(`${runtimePrefix}code-mode`) ||
51
+ relPath === `${runtimePrefix}tiers.json`
52
+ ) {
53
+ return { component: 'runtime', layout }
54
+ }
55
+ return null
56
+ }
57
+
58
+ /** A new install starts with an empty inventory; `target_root` is bound on write. */
59
+ export function createEmptyFileInventory({ legacyUnknown = false } = {}) {
60
+ return {
61
+ schema_version: FILE_INVENTORY_VERSION,
62
+ target_root: null,
63
+ legacy_unknown: legacyUnknown,
64
+ entries: [],
65
+ }
66
+ }
67
+
68
+ function validHash(value) {
69
+ return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value)
70
+ }
71
+
72
+ function safeRelativePath(value) {
73
+ if (
74
+ typeof value !== 'string' ||
75
+ value.length === 0 ||
76
+ value.includes('\\') ||
77
+ /^[a-z]:/i.test(value)
78
+ )
79
+ return false
80
+ if (value.startsWith('/') || isAbsolute(value)) return false
81
+ const parts = value.split('/')
82
+ return (
83
+ parts.every((part) => part && part !== '.' && part !== '..') &&
84
+ posix.normalize(value) === value &&
85
+ !value.includes('\0')
86
+ )
87
+ }
88
+
89
+ function rootInfo(target) {
90
+ const root = resolve(target)
91
+ assertNoSymlinkAncestors(root)
92
+ const info = lstatSync(root)
93
+ if (!info.isDirectory() || info.isSymbolicLink()) {
94
+ throw new Error('Installer inventory target must be a regular directory')
95
+ }
96
+ return root
97
+ }
98
+
99
+ function inventoryRootMatches(value, root) {
100
+ return typeof value === 'string' && isAbsolute(value) && resolve(value) === root
101
+ }
102
+
103
+ function relativeTargetPath(target, value) {
104
+ if (typeof value !== 'string' || value.split(/[\\/]/).includes('..')) {
105
+ throw new Error('Installer inventory path contains traversal')
106
+ }
107
+ const root = resolve(target)
108
+ const absolute = resolve(value)
109
+ const rel = relative(root, absolute).split(sep).join('/')
110
+ if (!rel || rel === '..' || rel.startsWith('../') || isAbsolute(rel) || !safeRelativePath(rel)) {
111
+ throw new Error('Installer inventory path is outside the selected target')
112
+ }
113
+ return rel
114
+ }
115
+
116
+ function inventoryFingerprint(inventory) {
117
+ return createHash('sha256').update(JSON.stringify(inventory)).digest('hex')
118
+ }
119
+
120
+ function inspectRegularFile(target, relPath) {
121
+ if (!safeRelativePath(relPath)) return { kind: 'ambiguous', reason: 'invalid-path' }
122
+ const filePath = join(target, ...relPath.split('/'))
123
+ try {
124
+ assertNoSymlinkAncestors(filePath)
125
+ const before = lstatSync(filePath)
126
+ if (before.isSymbolicLink()) return { kind: 'ambiguous', reason: 'symlink' }
127
+ if (!before.isFile()) return { kind: 'ambiguous', reason: 'special-file' }
128
+
129
+ let descriptor
130
+ try {
131
+ descriptor = openSync(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
132
+ } catch (error) {
133
+ if (error?.code === 'ELOOP') return { kind: 'ambiguous', reason: 'symlink' }
134
+ throw error
135
+ }
136
+ try {
137
+ const opened = fstatSync(descriptor)
138
+ if (!opened.isFile()) return { kind: 'ambiguous', reason: 'special-file' }
139
+ const bytes = readFileSync(descriptor)
140
+ const after = fstatSync(descriptor)
141
+ if (opened.dev !== after.dev || opened.ino !== after.ino || opened.size !== after.size) {
142
+ return { kind: 'ambiguous', reason: 'file-changed-during-read' }
143
+ }
144
+ return {
145
+ kind: 'regular',
146
+ sha256: createHash('sha256').update(bytes).digest('hex'),
147
+ mode: after.mode & 0o777,
148
+ }
149
+ } finally {
150
+ closeSync(descriptor)
151
+ }
152
+ } catch (error) {
153
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
154
+ return { kind: 'missing' }
155
+ }
156
+ return {
157
+ kind: 'ambiguous',
158
+ reason: error?.message?.includes('symlink') ? 'symlink' : 'unreadable',
159
+ }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Add verified installer writes without discarding entries for unselected
165
+ * components. Existing legacy files are not claimed merely because their
166
+ * bytes happen to equal the current package payload.
167
+ */
168
+ export function updateFileInventory(
169
+ state,
170
+ target,
171
+ { selectedComponents = [], verifiedFiles = [] } = {},
172
+ ) {
173
+ const root = resolve(target)
174
+ let inventory = state.file_inventory
175
+ if (inventory == null) inventory = createEmptyFileInventory({ legacyUnknown: true })
176
+ if (
177
+ !inventory ||
178
+ inventory.schema_version !== FILE_INVENTORY_VERSION ||
179
+ !Array.isArray(inventory.entries) ||
180
+ typeof inventory.legacy_unknown !== 'boolean' ||
181
+ (inventory.target_root == null && inventory.entries.length > 0)
182
+ ) {
183
+ throw new Error('Installer file inventory is invalid; refusing to replace ownership data')
184
+ }
185
+ if (inventory.target_root != null && !inventoryRootMatches(inventory.target_root, root)) {
186
+ // A copied/moved state file must not confer ownership in a new root.
187
+ state.file_inventory = inventory
188
+ return false
189
+ }
190
+ inventory.target_root = root
191
+ const selected = new Set(selectedComponents)
192
+ const entriesByPath = new Map()
193
+ const ambiguousEntries = []
194
+ for (const entry of inventory.entries) {
195
+ if (!entry || !safeRelativePath(entry.path) || !validHash(entry.sha256)) {
196
+ ambiguousEntries.push(entry)
197
+ continue
198
+ }
199
+ if (entriesByPath.has(entry.path)) {
200
+ ambiguousEntries.push(entry)
201
+ continue
202
+ }
203
+ entriesByPath.set(entry.path, entry)
204
+ }
205
+
206
+ for (const file of verifiedFiles) {
207
+ if (!file || !['create', 'update', 'unchanged'].includes(file.action)) continue
208
+ if (
209
+ !['agents', 'skills', 'instructions', 'prompts', 'commands', 'plugins', 'runtime'].includes(
210
+ file.component,
211
+ )
212
+ )
213
+ continue
214
+ if (!selected.has(file.component)) continue
215
+ const relPath = relativeTargetPath(root, file.path)
216
+ const existing = entriesByPath.get(relPath)
217
+ if (existing && existing.component !== file.component) continue
218
+ if (file.action === 'unchanged' && !existing) continue
219
+ if (!validHash(file.sha256)) continue
220
+ const live = inspectRegularFile(root, relPath)
221
+ if (live.kind !== 'regular' || live.sha256 !== file.sha256) continue
222
+ const entry = {
223
+ path: relPath,
224
+ component: file.component,
225
+ layout: String(file.layout),
226
+ sha256: live.sha256,
227
+ mode: live.mode,
228
+ }
229
+ entriesByPath.set(relPath, entry)
230
+ }
231
+
232
+ // The selected set is intentionally not used to filter existing entries:
233
+ // a narrow install must never erase ownership records for other components.
234
+ inventory.entries = [...entriesByPath.values(), ...ambiguousEntries].sort((a, b) =>
235
+ String(a?.path ?? '').localeCompare(String(b?.path ?? '')),
236
+ )
237
+ state.file_inventory = inventory
238
+ return true
239
+ }
240
+
241
+ function normalizeDesiredFiles(target, desiredFiles) {
242
+ const result = new Map()
243
+ const source =
244
+ desiredFiles instanceof Map ? desiredFiles.entries() : Object.entries(desiredFiles ?? {})
245
+ for (const [path, value] of source) {
246
+ if (typeof path !== 'string') continue
247
+ let relPath
248
+ try {
249
+ relPath = path.startsWith('/') ? relativeTargetPath(target, path) : path
250
+ } catch {
251
+ continue
252
+ }
253
+ if (!safeRelativePath(relPath)) continue
254
+ const sha256 = value?.sha256 ?? value?.outputFingerprint
255
+ if (validHash(sha256)) result.set(relPath, { ...value, sha256 })
256
+ }
257
+ return result
258
+ }
259
+
260
+ /** Classify owned paths; only stale-owned regular files become removal candidates. */
261
+ export function classifyFileInventory(
262
+ target,
263
+ state,
264
+ {
265
+ desiredFiles = new Map(),
266
+ desiredUnavailable = false,
267
+ selectedComponents = [],
268
+ selectedLayouts = {},
269
+ } = {},
270
+ ) {
271
+ const root = rootInfo(target)
272
+ const inventory = state?.file_inventory
273
+ if (
274
+ !inventory ||
275
+ inventory.schema_version !== FILE_INVENTORY_VERSION ||
276
+ !Array.isArray(inventory.entries) ||
277
+ typeof inventory.legacy_unknown !== 'boolean'
278
+ ) {
279
+ return {
280
+ target: root,
281
+ manualReview: true,
282
+ inventoryFingerprint: null,
283
+ entries: [
284
+ {
285
+ path: '(legacy installation)',
286
+ classification: 'ambiguous',
287
+ reason: 'inventory-unavailable',
288
+ },
289
+ ],
290
+ operations: [],
291
+ }
292
+ }
293
+
294
+ const rootMismatch = !inventoryRootMatches(inventory.target_root, root)
295
+ const selected = new Set(selectedComponents)
296
+ const layoutFor = (component) =>
297
+ selectedLayouts instanceof Map ? selectedLayouts.get(component) : selectedLayouts?.[component]
298
+ const desired = normalizeDesiredFiles(root, desiredFiles)
299
+ const seen = new Set()
300
+ const entries = []
301
+ const operations = []
302
+ let invalid = false
303
+ for (const owned of inventory.entries) {
304
+ const path = typeof owned?.path === 'string' ? owned.path : '(invalid inventory path)'
305
+ if (
306
+ !owned ||
307
+ !safeRelativePath(owned.path) ||
308
+ typeof owned.component !== 'string' ||
309
+ typeof owned.layout !== 'string' ||
310
+ !validHash(owned.sha256) ||
311
+ !Number.isInteger(owned.mode) ||
312
+ owned.mode < 0 ||
313
+ owned.mode > 0o777 ||
314
+ seen.has(owned.path)
315
+ ) {
316
+ invalid = true
317
+ entries.push({ path, classification: 'ambiguous', reason: 'invalid-or-duplicate-entry' })
318
+ continue
319
+ }
320
+ seen.add(owned.path)
321
+ if (rootMismatch) {
322
+ entries.push({
323
+ path,
324
+ component: owned.component,
325
+ classification: 'ambiguous',
326
+ reason: 'target-root-mismatch',
327
+ })
328
+ continue
329
+ }
330
+ if (!selected.has(owned.component)) {
331
+ entries.push({ path, component: owned.component, classification: 'unselected' })
332
+ continue
333
+ }
334
+ const selectedLayout = layoutFor(owned.component)
335
+ if (selectedLayout && selectedLayout !== owned.layout) {
336
+ entries.push({
337
+ path,
338
+ component: owned.component,
339
+ classification: 'ambiguous',
340
+ reason: 'layout-mismatch',
341
+ })
342
+ invalid = true
343
+ continue
344
+ }
345
+ const live = inspectRegularFile(root, owned.path)
346
+ if (live.kind === 'missing') {
347
+ entries.push({ path: owned.path, component: owned.component, classification: 'missing' })
348
+ continue
349
+ }
350
+ if (live.kind !== 'regular') {
351
+ entries.push({
352
+ path: owned.path,
353
+ component: owned.component,
354
+ classification: 'ambiguous',
355
+ reason: live.reason,
356
+ })
357
+ continue
358
+ }
359
+ if (live.sha256 !== owned.sha256) {
360
+ entries.push({ path: owned.path, component: owned.component, classification: 'modified' })
361
+ continue
362
+ }
363
+ const current = desired.get(owned.path)
364
+ if (!current && desiredUnavailable) {
365
+ entries.push({
366
+ path: owned.path,
367
+ component: owned.component,
368
+ classification: 'recorded-current',
369
+ })
370
+ continue
371
+ }
372
+ if (current) {
373
+ entries.push({
374
+ path: owned.path,
375
+ component: owned.component,
376
+ classification: current.sha256 === live.sha256 ? 'current' : 'update-drift',
377
+ })
378
+ continue
379
+ }
380
+ // Desired payload may be unavailable (e.g. optional runtime output); only
381
+ // a path omitted from the current selected install plan is stale-owned.
382
+ entries.push({ path: owned.path, component: owned.component, classification: 'stale-owned' })
383
+ operations.push({
384
+ path: owned.path,
385
+ sha256: owned.sha256,
386
+ mode: owned.mode,
387
+ component: owned.component,
388
+ })
389
+ }
390
+ operations.sort((a, b) => a.path.localeCompare(b.path))
391
+ return {
392
+ target: root,
393
+ manualReview:
394
+ rootMismatch ||
395
+ invalid ||
396
+ (inventory.legacy_unknown === true && inventory.entries.length === 0),
397
+ legacyAmbiguous: inventory.legacy_unknown === true,
398
+ inventoryFingerprint: inventoryFingerprint(inventory),
399
+ entries,
400
+ operations: rootMismatch || invalid ? [] : operations,
401
+ }
402
+ }
403
+
404
+ function operationSet(operations) {
405
+ return [...operations]
406
+ .map(({ path, sha256, mode, component }) => ({ path, sha256, mode, component }))
407
+ .sort((a, b) => a.path.localeCompare(b.path))
408
+ }
409
+
410
+ function readValidatedStateForApply(root) {
411
+ const stateFile = join(root, '.pantheon', 'install-state.json')
412
+ assertNoSymlinkAncestors(stateFile)
413
+
414
+ const pathInfo = lstatSync(stateFile)
415
+ if (!pathInfo.isFile() || pathInfo.isSymbolicLink()) {
416
+ throw new Error('Installer ownership state must be a regular file; aborting before removal')
417
+ }
418
+
419
+ let descriptor
420
+ try {
421
+ descriptor = openSync(stateFile, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
422
+ const opened = fstatSync(descriptor)
423
+ if (!opened.isFile()) {
424
+ throw new Error('Installer ownership state must be a regular file; aborting before removal')
425
+ }
426
+ const contents = readFileSync(descriptor, 'utf8')
427
+ const after = fstatSync(descriptor)
428
+ if (opened.dev !== after.dev || opened.ino !== after.ino || opened.size !== after.size) {
429
+ throw new Error('Installer ownership state changed while being read; aborting before removal')
430
+ }
431
+
432
+ let state
433
+ try {
434
+ state = JSON.parse(contents)
435
+ } catch {
436
+ throw new Error('Installer ownership state is unreadable; aborting before removal')
437
+ }
438
+ const inventory = state?.file_inventory
439
+ if (
440
+ !state ||
441
+ typeof state !== 'object' ||
442
+ Array.isArray(state) ||
443
+ state.schema_version !== 2 ||
444
+ !inventory ||
445
+ typeof inventory !== 'object' ||
446
+ Array.isArray(inventory) ||
447
+ inventory.schema_version !== FILE_INVENTORY_VERSION ||
448
+ !inventoryRootMatches(inventory.target_root, root) ||
449
+ !Array.isArray(inventory.entries) ||
450
+ typeof inventory.legacy_unknown !== 'boolean'
451
+ ) {
452
+ throw new Error('Installer ownership state or inventory is invalid for this target')
453
+ }
454
+ return state
455
+ } finally {
456
+ if (descriptor !== undefined) closeSync(descriptor)
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Apply only the exact reviewed stale-file set. Hashes and operation set are
462
+ * checked before backup and again after backup; deletion is unlink-only.
463
+ */
464
+ export function applyFileDriftPlan(
465
+ target,
466
+ state,
467
+ reviewedPlan,
468
+ {
469
+ desiredFiles = new Map(),
470
+ selectedComponents = [],
471
+ selectedLayouts = {},
472
+ clock = () => new Date(),
473
+ } = {},
474
+ ) {
475
+ const root = rootInfo(target)
476
+ if (!reviewedPlan || reviewedPlan.target !== root || reviewedPlan.manualReview) {
477
+ throw new Error('Refusing apply: inventory is ambiguous or reviewed target differs')
478
+ }
479
+ const fresh = classifyFileInventory(root, state, {
480
+ desiredFiles,
481
+ selectedComponents,
482
+ selectedLayouts,
483
+ })
484
+ if (
485
+ fresh.manualReview ||
486
+ fresh.inventoryFingerprint !== reviewedPlan.inventoryFingerprint ||
487
+ JSON.stringify(operationSet(fresh.operations)) !==
488
+ JSON.stringify(operationSet(reviewedPlan.operations))
489
+ ) {
490
+ throw new Error('Reviewed installer drift plan changed; no files were removed')
491
+ }
492
+ if (fresh.operations.length === 0) return { removed: [], backupDir: null }
493
+
494
+ const validateOperations = () => {
495
+ const persistedState = readValidatedStateForApply(root)
496
+ const now = classifyFileInventory(root, persistedState, {
497
+ desiredFiles,
498
+ selectedComponents,
499
+ selectedLayouts,
500
+ })
501
+ if (
502
+ now.manualReview ||
503
+ now.inventoryFingerprint !== reviewedPlan.inventoryFingerprint ||
504
+ JSON.stringify(operationSet(now.operations)) !==
505
+ JSON.stringify(operationSet(reviewedPlan.operations))
506
+ ) {
507
+ throw new Error('Reviewed installer drift operation set changed; aborting before removal')
508
+ }
509
+ for (const operation of reviewedPlan.operations) {
510
+ const live = inspectRegularFile(root, operation.path)
511
+ if (
512
+ live.kind !== 'regular' ||
513
+ live.sha256 !== operation.sha256 ||
514
+ live.mode !== operation.mode
515
+ ) {
516
+ throw new Error(
517
+ `Reviewed installer drift target changed; aborting before removal: ${operation.path}`,
518
+ )
519
+ }
520
+ }
521
+ return persistedState
522
+ }
523
+
524
+ const stateBeforeBackup = validateOperations()
525
+ const backup = createBackupSet({
526
+ configDir: root,
527
+ entries: reviewedPlan.operations.map((operation) => operation.path),
528
+ packageVersion: stateBeforeBackup.pantheon_version ?? 'unknown',
529
+ clock,
530
+ keep: 0,
531
+ })
532
+ const persistedState = validateOperations()
533
+
534
+ for (const operation of reviewedPlan.operations) {
535
+ const filePath = join(root, ...operation.path.split('/'))
536
+ assertNoSymlinkAncestors(filePath)
537
+ const live = inspectRegularFile(root, operation.path)
538
+ if (
539
+ live.kind !== 'regular' ||
540
+ live.sha256 !== operation.sha256 ||
541
+ live.mode !== operation.mode
542
+ ) {
543
+ throw new Error(`Reviewed installer drift target changed during apply: ${operation.path}`)
544
+ }
545
+ unlinkSync(filePath)
546
+ verifyPathRemoved(filePath)
547
+ }
548
+
549
+ const removedPaths = new Set(reviewedPlan.operations.map((operation) => operation.path))
550
+ persistedState.file_inventory.entries = persistedState.file_inventory.entries.filter(
551
+ (entry) => !removedPaths.has(entry.path),
552
+ )
553
+ writeState(root, persistedState)
554
+ return { removed: [...removedPaths].sort(), backupDir: backup.dir }
555
+ }
@@ -184,7 +184,7 @@ export async function promptConfirm(message, { default: def = true } = {}) {
184
184
  reject(new Error('Canceled'))
185
185
  }
186
186
 
187
- function handler(_str, key) {
187
+ function handler(str, key) {
188
188
  if (!key) return
189
189
  if ((key.ctrl && key.name === 'c') || key.name === 'escape') {
190
190
  cancel()
@@ -208,6 +208,20 @@ export async function promptConfirm(message, { default: def = true } = {}) {
208
208
  })
209
209
  }
210
210
 
211
+ /**
212
+ * Apply-gate prompt. Unlike the pre-install confirm this defaults to NO, so a
213
+ * bare Enter never authorizes a write. In a non-TTY it returns the default
214
+ * (`false`) without blocking — callers that must not prompt at all should branch
215
+ * on TTY before calling.
216
+ *
217
+ * @param {string} planSummary e.g. "Apply these 12 changes?"
218
+ * @param {{ default?: boolean }} [opts]
219
+ * @returns {Promise<boolean>}
220
+ */
221
+ export async function promptReview(planSummary, { default: def = false } = {}) {
222
+ return promptConfirm(planSummary, { default: def })
223
+ }
224
+
211
225
  export async function promptList(options, { title = '' } = {}) {
212
226
  if (options.length === 0) throw new Error('No options provided')
213
227
  if (options.length === 1) return options[0].value