pantheon-opencode 1.6.0-beta.11 → 1.6.0-beta.13
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 +3 -3
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +39 -0
- package/README.md +85 -31
- package/README.pt-BR.md +7 -4
- package/bin/pantheon-init.mjs +278 -102
- package/package.json +5 -3
- package/plugin.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/build-agents-md.mjs +77 -13
- package/scripts/check-plugin-v2-coverage.mjs +7 -7
- package/scripts/doctor.mjs +242 -19
- package/scripts/install/backup.mjs +191 -18
- package/scripts/install/cli-ui.mjs +73 -0
- package/scripts/install/file-inventory.mjs +555 -0
- package/scripts/install/interactive.mjs +69 -391
- package/scripts/install/migrate.mjs +45 -38
- package/scripts/install/model-picker.mjs +63 -22
- package/scripts/install/opencode-version.mjs +9 -1
- package/scripts/install/opencode.mjs +279 -79
- package/scripts/install/plan-artifact.mjs +517 -0
- package/scripts/install/plan.mjs +165 -3
- package/scripts/install/plugin.mjs +118 -14
- package/scripts/install/prompt-result.mjs +2 -0
- package/scripts/install/shared.mjs +188 -65
- package/scripts/install/state.mjs +63 -8
- package/scripts/install/venv.mjs +15 -6
- package/scripts/install/verify.mjs +128 -0
- package/scripts/install/vision-prompt.mjs +2 -0
- package/scripts/install-mcp.mjs +1 -1
- package/scripts/manifest-inventory.mjs +49 -3
- package/scripts/node-engine-policy.mjs +85 -0
- package/scripts/package-evidence.mjs +6 -0
- package/scripts/postinstall.mjs +26 -26
- package/scripts/reconcile.mjs +325 -0
- package/scripts/sync-tui.mjs +5 -1
- 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 +3 -3
- package/src/agents/mnemosyne.md +7 -2
- package/src/instructions/memory-protocol.instructions.md +2 -2
- package/src/instructions/zeus-memory-operations.instructions.md +4 -3
- package/src/pantheon/v2-unsupported.mjs +2 -3
- 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/memory-bank/SKILL.md +5 -1
- package/src/skills/worktrees/SKILL.md +30 -11
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
/** Versioned, content-free install-plan artifacts and path/snapshot validation. */
|
|
2
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
3
|
+
import {
|
|
4
|
+
closeSync,
|
|
5
|
+
constants,
|
|
6
|
+
fchmodSync,
|
|
7
|
+
fstatSync,
|
|
8
|
+
linkSync,
|
|
9
|
+
lstatSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
openSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
readlinkSync,
|
|
15
|
+
realpathSync,
|
|
16
|
+
unlinkSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from 'node:fs'
|
|
19
|
+
import { homedir } from 'node:os'
|
|
20
|
+
import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path'
|
|
21
|
+
|
|
22
|
+
export const INSTALL_PLAN_SCHEMA_VERSION = 1
|
|
23
|
+
const ACTIONS = new Set(['create', 'update', 'remove', 'unchanged', 'conflict'])
|
|
24
|
+
const IGNORED_SOURCE_DIRS = new Set([
|
|
25
|
+
'.git',
|
|
26
|
+
'.pantheon',
|
|
27
|
+
'node_modules',
|
|
28
|
+
'tests',
|
|
29
|
+
'logs',
|
|
30
|
+
'bench',
|
|
31
|
+
'benchmarks',
|
|
32
|
+
'bench-run-A',
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
function hash(value) {
|
|
36
|
+
return createHash('sha256').update(value).digest('hex')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function lstatOrNull(path) {
|
|
40
|
+
try {
|
|
41
|
+
return lstatSync(path)
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null
|
|
44
|
+
throw error
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isWithin(root, path) {
|
|
49
|
+
const rel = relative(resolve(root), resolve(path))
|
|
50
|
+
return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Reject symlinks in the target and every existing destination ancestor. */
|
|
54
|
+
export function assertSafeDestination(target, relativePath) {
|
|
55
|
+
const root = resolve(target)
|
|
56
|
+
const rootStat = lstatOrNull(root)
|
|
57
|
+
if (rootStat?.isSymbolicLink()) throw new Error(`Target is a symlink: ${root}`)
|
|
58
|
+
if (rootStat && !rootStat.isDirectory()) throw new Error(`Target is not a directory: ${root}`)
|
|
59
|
+
if (
|
|
60
|
+
isAbsolute(relativePath) ||
|
|
61
|
+
relativePath.split(/[\\/]/).some((part) => part === '..' || part === '')
|
|
62
|
+
) {
|
|
63
|
+
throw new Error(`Unsafe plan destination path: ${relativePath}`)
|
|
64
|
+
}
|
|
65
|
+
const destination = resolve(root, relativePath)
|
|
66
|
+
if (!isWithin(root, destination))
|
|
67
|
+
throw new Error(`Plan destination escapes target: ${relativePath}`)
|
|
68
|
+
let current = root
|
|
69
|
+
for (const segment of relativePath.split(/[\\/]/)) {
|
|
70
|
+
current = join(current, segment)
|
|
71
|
+
const info = lstatOrNull(current)
|
|
72
|
+
if (info?.isSymbolicLink()) throw new Error(`Plan destination contains a symlink: ${current}`)
|
|
73
|
+
if (info && current !== destination && !info.isDirectory()) {
|
|
74
|
+
throw new Error(`Plan destination parent is not a directory: ${current}`)
|
|
75
|
+
}
|
|
76
|
+
if (current === destination && info && !info.isFile() && !info.isDirectory()) {
|
|
77
|
+
throw new Error(`Unsupported plan destination type: ${current}`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return destination
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function relativePlanPath(target, absolutePath) {
|
|
84
|
+
const rel = relative(resolve(target), resolve(absolutePath))
|
|
85
|
+
if (!isWithin(target, absolutePath) || !rel || rel === '..' || rel.startsWith(`..${sep}`)) {
|
|
86
|
+
throw new Error(`Install plan operation is outside its target: ${absolutePath}`)
|
|
87
|
+
}
|
|
88
|
+
return rel.split(sep).join('/')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function allowedExternalTuiPaths() {
|
|
92
|
+
const home = homedir()
|
|
93
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME?.trim() || join(home, '.config')
|
|
94
|
+
return new Set([
|
|
95
|
+
resolve(home, '.opencode', 'tui.json'),
|
|
96
|
+
resolve(xdgConfig, 'opencode', 'tui.json'),
|
|
97
|
+
])
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function assertSafeExternalDestination(path) {
|
|
101
|
+
const absolute = resolve(path)
|
|
102
|
+
if (!allowedExternalTuiPaths().has(absolute)) {
|
|
103
|
+
throw new Error(`Install plan external operation is not an allowed TUI config: ${absolute}`)
|
|
104
|
+
}
|
|
105
|
+
assertNoSymlinkAncestors(absolute)
|
|
106
|
+
const info = lstatOrNull(absolute)
|
|
107
|
+
if (info && !info.isFile()) {
|
|
108
|
+
throw new Error(`Install plan external destination is not a regular file: ${absolute}`)
|
|
109
|
+
}
|
|
110
|
+
return absolute
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function operationLocation(target, absolutePath) {
|
|
114
|
+
const absolute = resolve(absolutePath)
|
|
115
|
+
if (isWithin(target, absolute)) return { path: relativePlanPath(target, absolute) }
|
|
116
|
+
assertSafeExternalDestination(absolute)
|
|
117
|
+
return { path: absolute, scope: 'user-config' }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function assertNoPlanPathCollision(path, { packageRoot, target, operations }) {
|
|
121
|
+
const destination = resolve(path)
|
|
122
|
+
if (destination === resolve(packageRoot)) {
|
|
123
|
+
throw new Error(`Plan artifact destination collides with package source: ${destination}`)
|
|
124
|
+
}
|
|
125
|
+
if (destination === resolve(target)) {
|
|
126
|
+
throw new Error(`Plan artifact destination collides with install target: ${destination}`)
|
|
127
|
+
}
|
|
128
|
+
for (const operation of operations) {
|
|
129
|
+
const operationPath =
|
|
130
|
+
operation.scope === 'user-config' ? resolve(operation.path) : resolve(target, operation.path)
|
|
131
|
+
if (isWithin(operationPath, destination) || isWithin(destination, operationPath)) {
|
|
132
|
+
throw new Error(`Plan artifact destination collides with planned operation: ${destination}`)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Hash package source identities without exposing paths' contents or hashes. */
|
|
138
|
+
export function sourceFingerprint(packageRoot, excludedPaths = []) {
|
|
139
|
+
const root = resolve(packageRoot)
|
|
140
|
+
const excluded = new Set(excludedPaths.filter(Boolean).map((path) => resolve(path)))
|
|
141
|
+
const records = []
|
|
142
|
+
const walk = (directory) => {
|
|
143
|
+
for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) =>
|
|
144
|
+
a.name.localeCompare(b.name),
|
|
145
|
+
)) {
|
|
146
|
+
if (directory === root && IGNORED_SOURCE_DIRS.has(entry.name)) continue
|
|
147
|
+
const path = join(directory, entry.name)
|
|
148
|
+
if (excluded.has(resolve(path))) continue
|
|
149
|
+
const rel = relative(root, path).split(sep).join('/')
|
|
150
|
+
const info = lstatSync(path)
|
|
151
|
+
if (info.isSymbolicLink()) records.push(`L\0${rel}\0${readlinkSync(path)}`)
|
|
152
|
+
else if (info.isDirectory()) walk(path)
|
|
153
|
+
else if (info.isFile()) records.push(`F\0${rel}\0${hash(readFileSync(path))}`)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
walk(root)
|
|
157
|
+
return hash(records.join('\n'))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Create a versioned plan containing paths and fingerprints, never contents. */
|
|
161
|
+
export function createPlanArtifact({
|
|
162
|
+
target,
|
|
163
|
+
packageRoot,
|
|
164
|
+
packageVersion,
|
|
165
|
+
plannedAt,
|
|
166
|
+
artifactPath = null,
|
|
167
|
+
options,
|
|
168
|
+
files,
|
|
169
|
+
snapshots,
|
|
170
|
+
}) {
|
|
171
|
+
const resolvedTarget = resolve(target)
|
|
172
|
+
const snapshotByPath = new Map(snapshots.map((item) => [resolve(item.path), item]))
|
|
173
|
+
const operations = files.map((file) => {
|
|
174
|
+
const location = operationLocation(resolvedTarget, file.path)
|
|
175
|
+
const path = location.path
|
|
176
|
+
if (location.scope) assertSafeExternalDestination(path)
|
|
177
|
+
else assertSafeDestination(resolvedTarget, path)
|
|
178
|
+
const before = snapshotByPath.get(resolve(file.path))
|
|
179
|
+
if (!before) throw new Error(`Missing destination snapshot for planned path: ${path}`)
|
|
180
|
+
return {
|
|
181
|
+
...location,
|
|
182
|
+
action: file.action,
|
|
183
|
+
bytes: file.bytes,
|
|
184
|
+
outputFingerprint: file.outputFingerprint ?? null,
|
|
185
|
+
before: {
|
|
186
|
+
exists: before.exists,
|
|
187
|
+
sha256: before.sha256,
|
|
188
|
+
size: before.size,
|
|
189
|
+
mode: before.mode,
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
if (artifactPath) {
|
|
194
|
+
assertNoPlanPathCollision(artifactPath, {
|
|
195
|
+
packageRoot,
|
|
196
|
+
target: resolvedTarget,
|
|
197
|
+
operations,
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
const targetInfo = lstatOrNull(resolvedTarget)
|
|
201
|
+
const targetRealpath = targetInfo?.isDirectory() ? realpathSync(resolvedTarget) : null
|
|
202
|
+
return {
|
|
203
|
+
schema_version: INSTALL_PLAN_SCHEMA_VERSION,
|
|
204
|
+
planned_at: plannedAt,
|
|
205
|
+
artifact_path: artifactPath ? resolve(artifactPath) : null,
|
|
206
|
+
package: {
|
|
207
|
+
root: resolve(packageRoot),
|
|
208
|
+
realpath: realpathSync(packageRoot),
|
|
209
|
+
version: packageVersion,
|
|
210
|
+
source_fingerprint: sourceFingerprint(packageRoot, [artifactPath]),
|
|
211
|
+
},
|
|
212
|
+
target: { path: resolvedTarget, realpath: targetRealpath },
|
|
213
|
+
options: structuredClone(options),
|
|
214
|
+
operations,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Validate the closed artifact shape and every destination path. */
|
|
219
|
+
export function validatePlanArtifact(artifact) {
|
|
220
|
+
if (
|
|
221
|
+
!artifact ||
|
|
222
|
+
typeof artifact !== 'object' ||
|
|
223
|
+
artifact.schema_version !== INSTALL_PLAN_SCHEMA_VERSION
|
|
224
|
+
) {
|
|
225
|
+
throw new Error('Unsupported or invalid install-plan artifact version')
|
|
226
|
+
}
|
|
227
|
+
if (
|
|
228
|
+
!artifact.package ||
|
|
229
|
+
typeof artifact.package.root !== 'string' ||
|
|
230
|
+
typeof artifact.package.realpath !== 'string' ||
|
|
231
|
+
typeof artifact.package.version !== 'string' ||
|
|
232
|
+
!/^[a-f0-9]{64}$/.test(artifact.package.source_fingerprint)
|
|
233
|
+
)
|
|
234
|
+
throw new Error('Invalid install-plan package identity')
|
|
235
|
+
if (
|
|
236
|
+
typeof artifact.planned_at !== 'string' ||
|
|
237
|
+
!Number.isFinite(Date.parse(artifact.planned_at))
|
|
238
|
+
) {
|
|
239
|
+
throw new Error('Invalid install-plan timestamp')
|
|
240
|
+
}
|
|
241
|
+
if (
|
|
242
|
+
artifact.artifact_path !== null &&
|
|
243
|
+
(typeof artifact.artifact_path !== 'string' || !isAbsolute(artifact.artifact_path))
|
|
244
|
+
) {
|
|
245
|
+
throw new Error('Invalid install-plan artifact identity')
|
|
246
|
+
}
|
|
247
|
+
if (
|
|
248
|
+
!artifact.target ||
|
|
249
|
+
typeof artifact.target.path !== 'string' ||
|
|
250
|
+
!isAbsolute(artifact.target.path)
|
|
251
|
+
) {
|
|
252
|
+
throw new Error('Invalid install-plan target identity')
|
|
253
|
+
}
|
|
254
|
+
if (artifact.target.realpath !== null && typeof artifact.target.realpath !== 'string') {
|
|
255
|
+
throw new Error('Invalid install-plan target realpath')
|
|
256
|
+
}
|
|
257
|
+
if (
|
|
258
|
+
!artifact.options ||
|
|
259
|
+
typeof artifact.options !== 'object' ||
|
|
260
|
+
Array.isArray(artifact.options)
|
|
261
|
+
) {
|
|
262
|
+
throw new Error('Invalid install-plan options')
|
|
263
|
+
}
|
|
264
|
+
if (!Array.isArray(artifact.operations)) throw new Error('Invalid install-plan operations')
|
|
265
|
+
const seen = new Set()
|
|
266
|
+
for (const operation of artifact.operations) {
|
|
267
|
+
if (
|
|
268
|
+
!operation ||
|
|
269
|
+
typeof operation.path !== 'string' ||
|
|
270
|
+
!operation.path ||
|
|
271
|
+
(operation.scope === undefined &&
|
|
272
|
+
(operation.path.split('/').some((part) => part === '..' || part === '') ||
|
|
273
|
+
isAbsolute(operation.path))) ||
|
|
274
|
+
(operation.scope !== undefined &&
|
|
275
|
+
(operation.scope !== 'user-config' ||
|
|
276
|
+
!isAbsolute(operation.path) ||
|
|
277
|
+
!allowedExternalTuiPaths().has(resolve(operation.path)))) ||
|
|
278
|
+
!ACTIONS.has(operation.action) ||
|
|
279
|
+
!Number.isSafeInteger(operation.bytes) ||
|
|
280
|
+
operation.bytes < 0 ||
|
|
281
|
+
(operation.outputFingerprint !== null && !/^[a-f0-9]{64}$/.test(operation.outputFingerprint))
|
|
282
|
+
)
|
|
283
|
+
throw new Error('Invalid install-plan operation path or metadata')
|
|
284
|
+
if (operation.scope === 'user-config') assertSafeExternalDestination(operation.path)
|
|
285
|
+
if (seen.has(operation.path)) throw new Error(`Duplicate install-plan path: ${operation.path}`)
|
|
286
|
+
seen.add(operation.path)
|
|
287
|
+
const before = operation.before
|
|
288
|
+
if (
|
|
289
|
+
!before ||
|
|
290
|
+
typeof before.exists !== 'boolean' ||
|
|
291
|
+
(before.sha256 !== null && !/^[a-f0-9]{64}$/.test(before.sha256)) ||
|
|
292
|
+
!Number.isSafeInteger(before.size) ||
|
|
293
|
+
before.size < 0 ||
|
|
294
|
+
(before.mode !== null && !Number.isSafeInteger(before.mode))
|
|
295
|
+
)
|
|
296
|
+
throw new Error(`Invalid destination snapshot for ${operation.path}`)
|
|
297
|
+
}
|
|
298
|
+
return artifact
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function assertNoSymlinkAncestors(path) {
|
|
302
|
+
const absolute = resolve(path)
|
|
303
|
+
const parts = absolute.split(sep).filter(Boolean)
|
|
304
|
+
let current = absolute.startsWith(sep) ? sep : ''
|
|
305
|
+
for (const part of parts) {
|
|
306
|
+
current = current ? join(current, part) : part
|
|
307
|
+
const info = lstatOrNull(current)
|
|
308
|
+
if (info?.isSymbolicLink()) throw new Error(`Plan artifact path contains a symlink: ${current}`)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function openPinnedDirectory(path, { create = false } = {}) {
|
|
313
|
+
let descriptorRoot
|
|
314
|
+
if (process.platform === 'linux') descriptorRoot = '/proc/self/fd'
|
|
315
|
+
else if (['darwin', 'freebsd', 'openbsd', 'netbsd'].includes(process.platform))
|
|
316
|
+
descriptorRoot = '/dev/fd'
|
|
317
|
+
else {
|
|
318
|
+
throw new Error(`Cannot safely save a plan artifact on ${process.platform}`)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const absolute = resolve(path)
|
|
322
|
+
const root = parse(absolute).root
|
|
323
|
+
const directoryFlags = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW
|
|
324
|
+
let directoryFd = openSync(root, directoryFlags)
|
|
325
|
+
try {
|
|
326
|
+
const descriptorDirectory = join(descriptorRoot, String(directoryFd))
|
|
327
|
+
const probeFd = openSync(descriptorDirectory, constants.O_RDONLY | constants.O_DIRECTORY)
|
|
328
|
+
closeSync(probeFd)
|
|
329
|
+
|
|
330
|
+
for (const segment of relative(root, absolute).split(sep).filter(Boolean)) {
|
|
331
|
+
const childPath = join(join(descriptorRoot, String(directoryFd)), segment)
|
|
332
|
+
let info = lstatOrNull(childPath)
|
|
333
|
+
if (info?.isSymbolicLink()) {
|
|
334
|
+
throw new Error(`Plan artifact path contains a symlink: ${join(path, segment)}`)
|
|
335
|
+
}
|
|
336
|
+
if (!info && create) {
|
|
337
|
+
try {
|
|
338
|
+
mkdirSync(childPath, { mode: 0o700 })
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (error?.code !== 'EEXIST') throw error
|
|
341
|
+
}
|
|
342
|
+
info = lstatOrNull(childPath)
|
|
343
|
+
}
|
|
344
|
+
if (info && !info.isDirectory()) {
|
|
345
|
+
throw new Error(`Plan artifact parent is not a directory: ${path}`)
|
|
346
|
+
}
|
|
347
|
+
if (!info) throw new Error(`Plan artifact parent does not exist: ${path}`)
|
|
348
|
+
|
|
349
|
+
const childFd = openSync(childPath, directoryFlags)
|
|
350
|
+
if (!fstatSync(childFd).isDirectory()) {
|
|
351
|
+
closeSync(childFd)
|
|
352
|
+
throw new Error(`Plan artifact parent is not a directory: ${path}`)
|
|
353
|
+
}
|
|
354
|
+
closeSync(directoryFd)
|
|
355
|
+
directoryFd = childFd
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
fd: directoryFd,
|
|
360
|
+
path: join(descriptorRoot, String(directoryFd)),
|
|
361
|
+
}
|
|
362
|
+
} catch (error) {
|
|
363
|
+
try {
|
|
364
|
+
closeSync(directoryFd)
|
|
365
|
+
} catch {}
|
|
366
|
+
throw error
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function assertPinnedDirectoryMatches(fd, path) {
|
|
371
|
+
assertNoSymlinkAncestors(path)
|
|
372
|
+
const pathInfo = lstatOrNull(path)
|
|
373
|
+
const fdInfo = fstatSync(fd)
|
|
374
|
+
if (!pathInfo?.isDirectory() || pathInfo.dev !== fdInfo.dev || pathInfo.ino !== fdInfo.ino) {
|
|
375
|
+
throw new Error(`Plan artifact parent changed during write: ${path}`)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Atomically write a plan file with restrictive permissions. */
|
|
380
|
+
export function savePlanArtifact(path, artifact) {
|
|
381
|
+
validatePlanArtifact(artifact)
|
|
382
|
+
const destination = resolve(path)
|
|
383
|
+
assertNoPlanPathCollision(destination, {
|
|
384
|
+
packageRoot: artifact.package.root,
|
|
385
|
+
target: artifact.target.path,
|
|
386
|
+
operations: artifact.operations,
|
|
387
|
+
})
|
|
388
|
+
assertNoSymlinkAncestors(destination)
|
|
389
|
+
const parent = dirname(destination)
|
|
390
|
+
const pinnedParent = openPinnedDirectory(parent, { create: true })
|
|
391
|
+
const destinationName = basename(destination)
|
|
392
|
+
const pinnedDestination = join(pinnedParent.path, destinationName)
|
|
393
|
+
const temporary = join(
|
|
394
|
+
pinnedParent.path,
|
|
395
|
+
`${destinationName}.tmp-${process.pid}-${randomBytes(12).toString('hex')}`,
|
|
396
|
+
)
|
|
397
|
+
const payload = `${JSON.stringify(artifact, null, 2)}\n`
|
|
398
|
+
let descriptor = null
|
|
399
|
+
let temporaryCreated = false
|
|
400
|
+
let destinationCreated = false
|
|
401
|
+
let temporaryInfo = null
|
|
402
|
+
try {
|
|
403
|
+
assertPinnedDirectoryMatches(pinnedParent.fd, parent)
|
|
404
|
+
const existing = lstatOrNull(pinnedDestination)
|
|
405
|
+
if (existing?.isSymbolicLink()) {
|
|
406
|
+
throw new Error(`Plan artifact destination is a symlink: ${destination}`)
|
|
407
|
+
}
|
|
408
|
+
if (existing && !existing.isFile()) {
|
|
409
|
+
throw new Error(`Plan artifact destination is not a regular file: ${destination}`)
|
|
410
|
+
}
|
|
411
|
+
if (existing) throw new Error(`Plan artifact destination already exists: ${destination}`)
|
|
412
|
+
|
|
413
|
+
descriptor = openSync(temporary, 'wx', 0o600)
|
|
414
|
+
temporaryCreated = true
|
|
415
|
+
writeFileSync(descriptor, payload)
|
|
416
|
+
fchmodSync(descriptor, 0o600)
|
|
417
|
+
temporaryInfo = fstatSync(descriptor)
|
|
418
|
+
closeSync(descriptor)
|
|
419
|
+
descriptor = null
|
|
420
|
+
assertPinnedDirectoryMatches(pinnedParent.fd, parent)
|
|
421
|
+
// Hard-link creation is atomic and fails with EEXIST instead of replacing
|
|
422
|
+
// a destination that appeared after the anchored validation.
|
|
423
|
+
linkSync(temporary, pinnedDestination)
|
|
424
|
+
destinationCreated = true
|
|
425
|
+
assertPinnedDirectoryMatches(pinnedParent.fd, parent)
|
|
426
|
+
unlinkSync(temporary)
|
|
427
|
+
temporaryCreated = false
|
|
428
|
+
} catch (error) {
|
|
429
|
+
if (descriptor !== null) {
|
|
430
|
+
try {
|
|
431
|
+
closeSync(descriptor)
|
|
432
|
+
} catch {}
|
|
433
|
+
}
|
|
434
|
+
if (destinationCreated) {
|
|
435
|
+
try {
|
|
436
|
+
const destinationInfo = lstatOrNull(pinnedDestination)
|
|
437
|
+
if (
|
|
438
|
+
destinationInfo?.isFile() &&
|
|
439
|
+
destinationInfo.dev === temporaryInfo?.dev &&
|
|
440
|
+
destinationInfo.ino === temporaryInfo?.ino
|
|
441
|
+
) {
|
|
442
|
+
unlinkSync(pinnedDestination)
|
|
443
|
+
}
|
|
444
|
+
} catch {}
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
if (temporaryCreated) unlinkSync(temporary)
|
|
448
|
+
} catch {}
|
|
449
|
+
throw error
|
|
450
|
+
} finally {
|
|
451
|
+
closeSync(pinnedParent.fd)
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Read and validate a plan artifact without following a symlink. */
|
|
456
|
+
export function loadPlanArtifact(path) {
|
|
457
|
+
const absolute = resolve(path)
|
|
458
|
+
assertNoSymlinkAncestors(absolute)
|
|
459
|
+
const info = lstatOrNull(absolute)
|
|
460
|
+
if (info?.isSymbolicLink()) throw new Error(`Plan artifact is a symlink: ${absolute}`)
|
|
461
|
+
if (!info?.isFile()) throw new Error(`Plan artifact is not a regular file: ${absolute}`)
|
|
462
|
+
if (info.size > 10 * 1024 * 1024)
|
|
463
|
+
throw new Error('Install-plan artifact exceeds the 10 MiB limit')
|
|
464
|
+
let artifact
|
|
465
|
+
try {
|
|
466
|
+
artifact = JSON.parse(readFileSync(absolute, 'utf8'))
|
|
467
|
+
} catch {
|
|
468
|
+
throw new Error('Install-plan artifact is not valid JSON')
|
|
469
|
+
}
|
|
470
|
+
return validatePlanArtifact(artifact)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Compare current inputs and destinations against a saved reviewed plan. */
|
|
474
|
+
export function findPlanDrift(artifact, { target, packageRoot, packageVersion, files, snapshots }) {
|
|
475
|
+
validatePlanArtifact(artifact)
|
|
476
|
+
const root = resolve(target)
|
|
477
|
+
const reasons = []
|
|
478
|
+
const targetRealpath = lstatOrNull(root)?.isDirectory() ? realpathSync(root) : null
|
|
479
|
+
if (root !== artifact.target.path || targetRealpath !== artifact.target.realpath)
|
|
480
|
+
reasons.push('target')
|
|
481
|
+
if (
|
|
482
|
+
resolve(packageRoot) !== artifact.package.root ||
|
|
483
|
+
realpathSync(packageRoot) !== artifact.package.realpath ||
|
|
484
|
+
packageVersion !== artifact.package.version ||
|
|
485
|
+
sourceFingerprint(packageRoot, [artifact.artifact_path]) !== artifact.package.source_fingerprint
|
|
486
|
+
)
|
|
487
|
+
reasons.push('source')
|
|
488
|
+
|
|
489
|
+
const currentSnapshots = new Map(snapshots.map((item) => [resolve(item.path), item]))
|
|
490
|
+
for (const operation of artifact.operations) {
|
|
491
|
+
const absolute =
|
|
492
|
+
operation.scope === 'user-config'
|
|
493
|
+
? assertSafeExternalDestination(operation.path)
|
|
494
|
+
: assertSafeDestination(root, operation.path)
|
|
495
|
+
const current = currentSnapshots.get(absolute)
|
|
496
|
+
if (
|
|
497
|
+
!current ||
|
|
498
|
+
JSON.stringify(current) !== JSON.stringify({ path: absolute, ...operation.before })
|
|
499
|
+
) {
|
|
500
|
+
reasons.push(operation.path)
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
const generated = files.map((file) => ({
|
|
504
|
+
...operationLocation(root, file.path),
|
|
505
|
+
action: file.action,
|
|
506
|
+
bytes: file.bytes,
|
|
507
|
+
outputFingerprint: file.outputFingerprint ?? null,
|
|
508
|
+
}))
|
|
509
|
+
const reviewed = artifact.operations.map(({ path, scope, action, bytes, outputFingerprint }) => ({
|
|
510
|
+
...(scope === undefined ? { path } : { path, scope }),
|
|
511
|
+
action,
|
|
512
|
+
bytes,
|
|
513
|
+
outputFingerprint,
|
|
514
|
+
}))
|
|
515
|
+
if (JSON.stringify(generated) !== JSON.stringify(reviewed)) reasons.push('operations')
|
|
516
|
+
return [...new Set(reasons)]
|
|
517
|
+
}
|