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.
- package/AGENTS.global.md +42 -18
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +46 -0
- package/README.md +122 -38
- package/README.pt-BR.md +5 -4
- package/bin/pantheon-init.mjs +322 -101
- package/package.json +7 -6
- package/plugin.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/build-agents-md.mjs +128 -10
- package/scripts/check-plugin-v2-coverage.mjs +7 -7
- package/scripts/doctor.mjs +242 -19
- package/scripts/install/backup.mjs +648 -0
- package/scripts/install/cli-ui.mjs +79 -1
- package/scripts/install/exit-codes.mjs +26 -0
- package/scripts/install/file-inventory.mjs +555 -0
- package/scripts/install/interactive.mjs +15 -1
- package/scripts/install/migrate.mjs +32 -6
- package/scripts/install/model-picker.mjs +63 -22
- package/scripts/install/opencode-version.mjs +9 -1
- package/scripts/install/opencode.mjs +761 -63
- package/scripts/install/plan-artifact.mjs +517 -0
- package/scripts/install/plan.mjs +449 -0
- package/scripts/install/plugin.mjs +136 -18
- package/scripts/install/shared.mjs +249 -94
- package/scripts/install/state.mjs +63 -8
- package/scripts/install/strings.mjs +20 -0
- package/scripts/install/venv.mjs +25 -6
- package/scripts/install/verify.mjs +128 -0
- package/scripts/install/vision-prompt.mjs +13 -1
- package/scripts/install-mcp.mjs +1 -1
- package/scripts/manifest-inventory.mjs +49 -3
- package/scripts/mcp_persistence.py +50 -15
- package/scripts/node-engine-policy.mjs +74 -0
- package/scripts/package-evidence.mjs +6 -0
- package/scripts/postinstall.mjs +3 -4
- package/scripts/prune.mjs +9 -3
- package/scripts/reconcile.mjs +325 -0
- package/scripts/restore.mjs +222 -0
- package/scripts/uninstall.mjs +258 -34
- package/scripts/update.mjs +164 -0
- package/scripts/validate-node-engine-policy.mjs +13 -0
- package/scripts/version-check.mjs +34 -1
- package/scripts/versioning.mjs +9 -1
- package/skills-lock.json +4 -4
- package/src/agents/iris.md +6 -0
- package/src/agents/mnemosyne.md +7 -2
- package/src/agents/zeus.md +19 -62
- package/src/instructions/agent-return-format.instructions.md +17 -15
- package/src/instructions/memory-protocol.instructions.md +3 -2
- package/src/instructions/test-economy.instructions.md +21 -0
- package/src/instructions/zeus-anti-stall.instructions.md +6 -6
- package/src/instructions/zeus-council-synthesis.instructions.md +10 -11
- package/src/instructions/zeus-memory-operations.instructions.md +11 -6
- package/src/instructions/zeus-timeout-retry.instructions.md +28 -9
- package/src/mcp/mcp_persistence.py +50 -15
- package/src/pantheon/cost-command.ts +5 -2
- package/src/pantheon/model-agents.ts +19 -0
- package/src/pantheon/model-command.ts +218 -56
- package/src/pantheon/v2-model-overrides.ts +275 -0
- package/src/pantheon/v2-tools.ts +18 -29
- package/src/pantheon/v2-unsupported.mjs +2 -3
- package/src/plugin-v2.ts +13 -0
- package/src/plugins/tui/dist/tui-v2.js +2 -2
- package/src/plugins/tui/dist/tui.js +1 -1
- package/src/plugins/tui/package-lock.json +3 -3
- package/src/plugins/tui/package.json +2 -2
- package/src/skills/artifact-management/SKILL.md +5 -1
- package/src/skills/auto-continue/SKILL.md +14 -6
- package/src/skills/memory-bank/SKILL.md +5 -1
- 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
|
-
|
|
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(
|
|
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
|