pantheon-opencode 1.6.0-beta.11 → 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 +3 -3
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +22 -0
- package/README.md +69 -30
- package/README.pt-BR.md +5 -4
- package/bin/pantheon-init.mjs +279 -101
- package/package.json +4 -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/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 +265 -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/shared.mjs +186 -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 +74 -0
- package/scripts/package-evidence.mjs +6 -0
- package/scripts/postinstall.mjs +3 -4
- package/scripts/reconcile.mjs +325 -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 +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
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
import { spawnSync } from 'node:child_process'
|
|
6
6
|
import {
|
|
7
|
+
chmodSync,
|
|
8
|
+
closeSync,
|
|
7
9
|
existsSync,
|
|
8
10
|
lstatSync,
|
|
9
11
|
mkdirSync,
|
|
12
|
+
openSync,
|
|
10
13
|
readdirSync,
|
|
11
14
|
readFileSync,
|
|
12
15
|
renameSync,
|
|
@@ -16,16 +19,32 @@ import {
|
|
|
16
19
|
} from 'node:fs'
|
|
17
20
|
import { dirname, join } from 'node:path'
|
|
18
21
|
import { fileURLToPath } from 'node:url'
|
|
22
|
+
import { isSupportedNodeVersion, MIN_SUPPORTED_NODE_VERSION } from '../node-engine-policy.mjs'
|
|
19
23
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
24
|
+
if (!isSupportedNodeVersion(process.versions.node)) {
|
|
25
|
+
console.error(
|
|
26
|
+
`❌ Pantheon requires Node.js >= ${MIN_SUPPORTED_NODE_VERSION} (current: ${process.versions.node})`,
|
|
27
|
+
)
|
|
23
28
|
process.exit(1)
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
import { dump, load } from 'js-yaml'
|
|
27
32
|
|
|
28
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
ACTIONS,
|
|
35
|
+
fingerprintOutput,
|
|
36
|
+
isRecording,
|
|
37
|
+
recordChange,
|
|
38
|
+
recordRemovalTree,
|
|
39
|
+
removeReviewedTree,
|
|
40
|
+
} from './plan.mjs'
|
|
41
|
+
import {
|
|
42
|
+
assertNoSymlinkAncestors,
|
|
43
|
+
assertPathWithin,
|
|
44
|
+
assertSafeRemoval,
|
|
45
|
+
verifyPathRemoved,
|
|
46
|
+
verifyRegularFile,
|
|
47
|
+
} from './verify.mjs'
|
|
29
48
|
|
|
30
49
|
export const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
31
50
|
export const ROOT = join(__dirname, '..', '..')
|
|
@@ -170,6 +189,7 @@ export function sourceDirValid(dir) {
|
|
|
170
189
|
}
|
|
171
190
|
|
|
172
191
|
export function copyFiles(srcDir, dstDir, dryRun, renameMap = null, clean = false) {
|
|
192
|
+
assertNoSymlinkAncestors(dstDir)
|
|
173
193
|
const entries = readdirSync(srcDir)
|
|
174
194
|
let created = 0
|
|
175
195
|
let skipped = 0
|
|
@@ -185,6 +205,8 @@ export function copyFiles(srcDir, dstDir, dryRun, renameMap = null, clean = fals
|
|
|
185
205
|
dstNames.add(dstName)
|
|
186
206
|
|
|
187
207
|
const dstFile = join(dstDir, dstName)
|
|
208
|
+
assertPathWithin(dstDir, dstFile)
|
|
209
|
+
assertNoSymlinkAncestors(dstFile)
|
|
188
210
|
|
|
189
211
|
if (statSync(srcFile).isDirectory()) continue
|
|
190
212
|
const content = readFileSync(srcFile, 'utf8')
|
|
@@ -195,14 +217,10 @@ export function copyFiles(srcDir, dstDir, dryRun, renameMap = null, clean = fals
|
|
|
195
217
|
skipped++
|
|
196
218
|
continue
|
|
197
219
|
}
|
|
198
|
-
|
|
199
|
-
writeFileSync(dstFile, content, 'utf8')
|
|
200
|
-
}
|
|
220
|
+
writeIfChanged(dstFile, content, dryRun)
|
|
201
221
|
created++
|
|
202
222
|
} else {
|
|
203
|
-
|
|
204
|
-
writeFileSync(dstFile, content, 'utf8')
|
|
205
|
-
}
|
|
223
|
+
writeIfChanged(dstFile, content, dryRun)
|
|
206
224
|
created++
|
|
207
225
|
}
|
|
208
226
|
}
|
|
@@ -212,17 +230,21 @@ export function copyFiles(srcDir, dstDir, dryRun, renameMap = null, clean = fals
|
|
|
212
230
|
// (lowercase name + .md/.mdc extension), regardless of AGENT_NAMES membership.
|
|
213
231
|
// This handles deleted canonical agents (e.g., agora) whose source file is gone.
|
|
214
232
|
if (clean && existsSync(dstDir)) {
|
|
233
|
+
assertNoSymlinkAncestors(dstDir)
|
|
215
234
|
const dstEntries = readdirSync(dstDir)
|
|
216
235
|
const agentPattern = /^[a-z-]+\.(md|mdc)$/
|
|
217
236
|
for (const entry of dstEntries) {
|
|
218
237
|
if (dstNames.has(entry)) continue // still in source
|
|
219
238
|
const dstFile = join(dstDir, entry)
|
|
239
|
+
assertSafeRemoval(dstDir, dstFile)
|
|
220
240
|
if (!statSync(dstFile).isFile()) continue
|
|
221
241
|
// Safety: only remove files that match agent file naming pattern
|
|
222
242
|
// (user custom files with different naming patterns are untouched)
|
|
223
243
|
if (!agentPattern.test(entry)) continue
|
|
224
244
|
if (!dryRun) {
|
|
245
|
+
assertSafeRemoval(dstDir, dstFile)
|
|
225
246
|
rmSync(dstFile, { force: true })
|
|
247
|
+
verifyPathRemoved(dstFile)
|
|
226
248
|
}
|
|
227
249
|
created++
|
|
228
250
|
}
|
|
@@ -289,21 +311,26 @@ function readFileOrNull(filePath) {
|
|
|
289
311
|
* size only) so `--dry-run` can print the real per-file plan. Defaults to
|
|
290
312
|
* the module-level no-op singleton, so an uninstrumented caller is
|
|
291
313
|
* byte-identical to before.
|
|
314
|
+
* @param {number} [options.mode] - mode for a newly created file; existing
|
|
315
|
+
* destination modes are preserved.
|
|
292
316
|
* @param {boolean} [options.scored] - set `false` when this write is not part
|
|
293
317
|
* of the install summary (e.g. stale-ref cleanup), so it is shown in the plan
|
|
294
318
|
* but excluded from the consistency guard.
|
|
295
319
|
* @returns {'created'|'skipped'|'conflict'}
|
|
296
320
|
*/
|
|
297
321
|
export function writeIfChanged(filePath, content, dryRun, options = {}) {
|
|
298
|
-
const { expectedContent, beforeSwap, beforeRecheck, recorder, scored } = options
|
|
322
|
+
const { expectedContent, beforeSwap, beforeRecheck, recorder, scored, mode } = options
|
|
299
323
|
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
|
|
324
|
+
// A linked parent redirects both the atomic temp write and the eventual
|
|
325
|
+
// rename, so validate the full path rather than only its final component.
|
|
326
|
+
assertNoSymlinkAncestors(filePath)
|
|
303
327
|
const stat = lstatOrNull(filePath)
|
|
304
328
|
if (stat?.isSymbolicLink()) {
|
|
305
329
|
throw new Error(`Refusing to write through symlink: ${filePath}`)
|
|
306
330
|
}
|
|
331
|
+
// Preserve the destination's access mode across the atomic rename. A mode
|
|
332
|
+
// supplied by the caller is used only for a newly created destination.
|
|
333
|
+
const targetMode = stat === null ? mode : stat.mode & 0o777
|
|
307
334
|
|
|
308
335
|
const existing = stat === null ? null : readFileOrNull(filePath)
|
|
309
336
|
const record = (outcome) => {
|
|
@@ -317,7 +344,13 @@ export function writeIfChanged(filePath, content, dryRun, options = {}) {
|
|
|
317
344
|
? ACTIONS.CREATE
|
|
318
345
|
: ACTIONS.UPDATE
|
|
319
346
|
recordChange(
|
|
320
|
-
{
|
|
347
|
+
{
|
|
348
|
+
path: filePath,
|
|
349
|
+
action,
|
|
350
|
+
bytes: Buffer.byteLength(content, 'utf8'),
|
|
351
|
+
outputFingerprint: fingerprintOutput(content),
|
|
352
|
+
scored,
|
|
353
|
+
},
|
|
321
354
|
recorder,
|
|
322
355
|
)
|
|
323
356
|
}
|
|
@@ -340,23 +373,54 @@ export function writeIfChanged(filePath, content, dryRun, options = {}) {
|
|
|
340
373
|
// crash mid-write can never leave a truncated file behind (rename is
|
|
341
374
|
// atomic within the same directory).
|
|
342
375
|
const tmpPath = `${filePath}.tmp-${process.pid}`
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
376
|
+
assertNoSymlinkAncestors(tmpPath)
|
|
377
|
+
let fd
|
|
378
|
+
let createdTemporary = false
|
|
379
|
+
try {
|
|
380
|
+
// Exclusive creation establishes ownership. Cleanup below is limited to
|
|
381
|
+
// this exact path and only runs after this invocation opened it.
|
|
382
|
+
fd = openSync(tmpPath, 'wx', targetMode)
|
|
383
|
+
createdTemporary = true
|
|
384
|
+
writeFileSync(fd, content, { encoding: 'utf8' })
|
|
385
|
+
closeSync(fd)
|
|
386
|
+
fd = undefined
|
|
387
|
+
if (targetMode !== undefined) chmodSync(tmpPath, targetMode)
|
|
388
|
+
|
|
389
|
+
// Last-moment re-read. The initial compare and this rename are separate
|
|
390
|
+
// syscalls, so a concurrent writer can land in between; re-check the
|
|
391
|
+
// expected bytes immediately before the swap. On a mismatch, leave the
|
|
392
|
+
// target byte-identical; finally removes only the temp created above.
|
|
393
|
+
if (expectedContent !== undefined) {
|
|
394
|
+
const current = readFileOrNull(filePath)
|
|
395
|
+
if (current !== expectedContent) {
|
|
396
|
+
record('conflict')
|
|
397
|
+
return 'conflict'
|
|
398
|
+
}
|
|
355
399
|
}
|
|
356
|
-
}
|
|
357
400
|
|
|
358
|
-
|
|
359
|
-
|
|
401
|
+
if (typeof beforeSwap === 'function') beforeSwap(existing)
|
|
402
|
+
assertNoSymlinkAncestors(filePath)
|
|
403
|
+
renameSync(tmpPath, filePath)
|
|
404
|
+
createdTemporary = false
|
|
405
|
+
verifyRegularFile(filePath, content)
|
|
406
|
+
} finally {
|
|
407
|
+
if (fd !== undefined) {
|
|
408
|
+
try {
|
|
409
|
+
closeSync(fd)
|
|
410
|
+
} catch {
|
|
411
|
+
// Preserve the write error; closing a failed descriptor is best-effort.
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (createdTemporary) {
|
|
415
|
+
try {
|
|
416
|
+
assertSafeRemoval(dirname(tmpPath), tmpPath)
|
|
417
|
+
rmSync(tmpPath, { force: true })
|
|
418
|
+
verifyPathRemoved(tmpPath)
|
|
419
|
+
} catch {
|
|
420
|
+
// Preserve the write error and never follow an unsafe temp path.
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
360
424
|
}
|
|
361
425
|
record('created')
|
|
362
426
|
return 'created'
|
|
@@ -372,6 +436,8 @@ export function writeIfChanged(filePath, content, dryRun, options = {}) {
|
|
|
372
436
|
* A caller may still supply `beforeSwap` for a file the exact set does NOT
|
|
373
437
|
* cover: the GLOBAL `opencode-vision.json` lives outside the config dir, so
|
|
374
438
|
* vision-prompt passes a `beforeSwap` that keeps its `<file>.bak` recovery.
|
|
439
|
+
* New config files default to mode `0600`; existing destination modes survive
|
|
440
|
+
* replacement.
|
|
375
441
|
*
|
|
376
442
|
* @param {string} filePath
|
|
377
443
|
* @param {string} content
|
|
@@ -385,6 +451,7 @@ export function writeConfigWithBackup(filePath, content, dryRun, options = {}) {
|
|
|
385
451
|
beforeSwap: options.beforeSwap,
|
|
386
452
|
recorder: options.recorder,
|
|
387
453
|
scored: options.scored,
|
|
454
|
+
mode: options.mode ?? 0o600,
|
|
388
455
|
})
|
|
389
456
|
}
|
|
390
457
|
|
|
@@ -398,7 +465,10 @@ export function checkRuntimePrerequisites(env = process.env) {
|
|
|
398
465
|
const problems = []
|
|
399
466
|
const checks = [
|
|
400
467
|
{ cmd: 'python3', hint: 'install Python 3.10+ (https://www.python.org/downloads/)' },
|
|
401
|
-
{
|
|
468
|
+
{
|
|
469
|
+
cmd: 'npm',
|
|
470
|
+
hint: `install Node.js >= ${MIN_SUPPORTED_NODE_VERSION} (https://nodejs.org) — npm ships with it`,
|
|
471
|
+
},
|
|
402
472
|
]
|
|
403
473
|
for (const { cmd, hint } of checks) {
|
|
404
474
|
const probe = spawnSync(cmd, ['--version'], { encoding: 'utf8', timeout: 10_000, env })
|
|
@@ -450,9 +520,27 @@ export function serializeFm(fm) {
|
|
|
450
520
|
})
|
|
451
521
|
}
|
|
452
522
|
|
|
453
|
-
|
|
523
|
+
function recordDirectoryOutcomes(srcDir, dstDir, recorder) {
|
|
524
|
+
recordChange({ path: dstDir, action: ACTIONS.UNCHANGED, bytes: 0, scored: false }, recorder)
|
|
525
|
+
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
|
|
526
|
+
if (!entry.isDirectory()) continue
|
|
527
|
+
recordDirectoryOutcomes(join(srcDir, entry.name), join(dstDir, entry.name), recorder)
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export function installSkills(
|
|
532
|
+
skills,
|
|
533
|
+
target,
|
|
534
|
+
dryRun,
|
|
535
|
+
subDir = '.opencode',
|
|
536
|
+
recorder = null,
|
|
537
|
+
reviewedRemovals = null,
|
|
538
|
+
reviewedSnapshots = null,
|
|
539
|
+
clean = false,
|
|
540
|
+
) {
|
|
454
541
|
const srcSkillsDir = join(ROOT, 'src', 'skills')
|
|
455
542
|
const dstSkillsDir = join(target, subDir, 'skills')
|
|
543
|
+
assertNoSymlinkAncestors(dstSkillsDir)
|
|
456
544
|
|
|
457
545
|
let created = 0
|
|
458
546
|
let skipped = 0
|
|
@@ -461,7 +549,12 @@ export function installSkills(skills, target, dryRun, subDir = '.opencode', reco
|
|
|
461
549
|
const src = join(srcSkillsDir, skill)
|
|
462
550
|
const dst = join(dstSkillsDir, skill)
|
|
463
551
|
|
|
464
|
-
if (!dryRun)
|
|
552
|
+
if (!dryRun) {
|
|
553
|
+
assertPathWithin(dstSkillsDir, dst)
|
|
554
|
+
assertNoSymlinkAncestors(dst)
|
|
555
|
+
mkdirSync(dst, { recursive: true })
|
|
556
|
+
assertNoSymlinkAncestors(dst)
|
|
557
|
+
}
|
|
465
558
|
|
|
466
559
|
function copyDirRecursive(from, to) {
|
|
467
560
|
const entries = readdirSync(from)
|
|
@@ -469,11 +562,20 @@ export function installSkills(skills, target, dryRun, subDir = '.opencode', reco
|
|
|
469
562
|
const srcPath = join(from, entry)
|
|
470
563
|
const dstPath = join(to, entry)
|
|
471
564
|
if (statSync(srcPath).isDirectory()) {
|
|
472
|
-
if (!dryRun)
|
|
565
|
+
if (!dryRun) {
|
|
566
|
+
assertPathWithin(dstSkillsDir, dstPath)
|
|
567
|
+
assertNoSymlinkAncestors(dstPath)
|
|
568
|
+
mkdirSync(dstPath, { recursive: true })
|
|
569
|
+
assertNoSymlinkAncestors(dstPath)
|
|
570
|
+
}
|
|
473
571
|
copyDirRecursive(srcPath, dstPath)
|
|
474
572
|
} else {
|
|
475
573
|
const content = readFileSync(srcPath, 'utf8')
|
|
476
|
-
const
|
|
574
|
+
const dstStat = lstatOrNull(dstPath)
|
|
575
|
+
if (dstStat?.isSymbolicLink()) {
|
|
576
|
+
throw new Error(`Refusing to read through symlink: ${dstPath}`)
|
|
577
|
+
}
|
|
578
|
+
const existing = dstStat === null ? null : readFileSync(dstPath, 'utf8')
|
|
477
579
|
if (existing === content) {
|
|
478
580
|
skipped++
|
|
479
581
|
if (isRecording(recorder)) {
|
|
@@ -482,40 +584,49 @@ export function installSkills(skills, target, dryRun, subDir = '.opencode', reco
|
|
|
482
584
|
path: dstPath,
|
|
483
585
|
action: ACTIONS.UNCHANGED,
|
|
484
586
|
bytes: Buffer.byteLength(content, 'utf8'),
|
|
587
|
+
outputFingerprint: fingerprintOutput(content),
|
|
485
588
|
},
|
|
486
589
|
recorder,
|
|
487
590
|
)
|
|
488
591
|
}
|
|
489
592
|
} else {
|
|
490
|
-
if (!dryRun)
|
|
491
|
-
|
|
492
|
-
if (isRecording(recorder)) {
|
|
593
|
+
if (!dryRun) {
|
|
594
|
+
writeIfChanged(dstPath, content, false, { recorder })
|
|
595
|
+
} else if (isRecording(recorder)) {
|
|
493
596
|
recordChange(
|
|
494
597
|
{
|
|
495
598
|
path: dstPath,
|
|
496
599
|
action: existing === null ? ACTIONS.CREATE : ACTIONS.UPDATE,
|
|
497
600
|
bytes: Buffer.byteLength(content, 'utf8'),
|
|
601
|
+
outputFingerprint: fingerprintOutput(content),
|
|
498
602
|
},
|
|
499
603
|
recorder,
|
|
500
604
|
)
|
|
501
605
|
}
|
|
606
|
+
created++
|
|
502
607
|
}
|
|
503
608
|
}
|
|
504
609
|
}
|
|
505
610
|
}
|
|
506
611
|
|
|
507
612
|
copyDirRecursive(src, dst)
|
|
613
|
+
if (clean && existsSync(dst)) recordDirectoryOutcomes(src, dst, recorder)
|
|
508
614
|
}
|
|
509
615
|
|
|
510
616
|
// Remove stale skills that exist in dst but not in skills list
|
|
511
617
|
if (existsSync(dstSkillsDir)) {
|
|
618
|
+
assertNoSymlinkAncestors(dstSkillsDir)
|
|
512
619
|
const dstEntries = readdirSync(dstSkillsDir)
|
|
513
620
|
for (const entry of dstEntries) {
|
|
514
621
|
const dstPath = join(dstSkillsDir, entry)
|
|
622
|
+
assertSafeRemoval(dstSkillsDir, dstPath)
|
|
515
623
|
if (!statSync(dstPath).isDirectory()) continue
|
|
516
624
|
if (!skills.includes(entry)) {
|
|
517
|
-
if (
|
|
518
|
-
|
|
625
|
+
if (dryRun) {
|
|
626
|
+
recordRemovalTree(dstPath, recorder)
|
|
627
|
+
} else {
|
|
628
|
+
assertSafeRemoval(dstSkillsDir, dstPath)
|
|
629
|
+
removeReviewedTree(dstPath, reviewedRemovals, reviewedSnapshots)
|
|
519
630
|
console.log(` 🗑️ Removed stale skill: ${entry}`)
|
|
520
631
|
}
|
|
521
632
|
created++
|
|
@@ -529,18 +640,35 @@ export function installSkills(skills, target, dryRun, subDir = '.opencode', reco
|
|
|
529
640
|
return { created, skipped }
|
|
530
641
|
}
|
|
531
642
|
|
|
532
|
-
export function syncDir(
|
|
643
|
+
export function syncDir(
|
|
644
|
+
src,
|
|
645
|
+
dst,
|
|
646
|
+
dryRun,
|
|
647
|
+
clean = false,
|
|
648
|
+
filter = null,
|
|
649
|
+
recorder = null,
|
|
650
|
+
reviewedRemovals = null,
|
|
651
|
+
reviewedSnapshots = null,
|
|
652
|
+
) {
|
|
533
653
|
if (!existsSync(src)) return { created: 0, skipped: 0 }
|
|
534
654
|
let created = 0
|
|
535
655
|
let skipped = 0
|
|
656
|
+
const cleanTargetExisted = clean && existsSync(dst)
|
|
536
657
|
|
|
537
|
-
if (
|
|
538
|
-
if (
|
|
539
|
-
|
|
658
|
+
if (cleanTargetExisted) {
|
|
659
|
+
if (dryRun) {
|
|
660
|
+
recordRemovalTree(dst, recorder)
|
|
661
|
+
} else {
|
|
662
|
+
assertSafeRemoval(dirname(dst), dst)
|
|
663
|
+
removeReviewedTree(dst, reviewedRemovals, reviewedSnapshots)
|
|
540
664
|
}
|
|
541
665
|
}
|
|
542
666
|
|
|
543
|
-
if (!dryRun)
|
|
667
|
+
if (!dryRun) {
|
|
668
|
+
assertNoSymlinkAncestors(dst)
|
|
669
|
+
mkdirSync(dst, { recursive: true })
|
|
670
|
+
assertNoSymlinkAncestors(dst)
|
|
671
|
+
}
|
|
544
672
|
|
|
545
673
|
const entries = readdirSync(src)
|
|
546
674
|
for (const entry of entries) {
|
|
@@ -548,36 +676,29 @@ export function syncDir(src, dst, dryRun, clean = false, filter = null, recorder
|
|
|
548
676
|
const srcPath = join(src, entry)
|
|
549
677
|
const dstPath = join(dst, entry)
|
|
550
678
|
if (statSync(srcPath).isDirectory()) {
|
|
551
|
-
const sub = syncDir(
|
|
679
|
+
const sub = syncDir(
|
|
680
|
+
srcPath,
|
|
681
|
+
dstPath,
|
|
682
|
+
dryRun,
|
|
683
|
+
false,
|
|
684
|
+
filter,
|
|
685
|
+
recorder,
|
|
686
|
+
reviewedRemovals,
|
|
687
|
+
reviewedSnapshots,
|
|
688
|
+
)
|
|
552
689
|
created += sub.created
|
|
553
690
|
skipped += sub.skipped
|
|
554
691
|
} else {
|
|
555
692
|
const content = readFileSync(srcPath, 'utf8')
|
|
556
|
-
const
|
|
557
|
-
if (
|
|
693
|
+
const status = writeIfChanged(dstPath, content, dryRun, { recorder })
|
|
694
|
+
if (status === 'skipped') {
|
|
558
695
|
skipped++
|
|
559
|
-
if (isRecording(recorder)) {
|
|
560
|
-
recordChange(
|
|
561
|
-
{ path: dstPath, action: ACTIONS.UNCHANGED, bytes: Buffer.byteLength(content, 'utf8') },
|
|
562
|
-
recorder,
|
|
563
|
-
)
|
|
564
|
-
}
|
|
565
696
|
} else {
|
|
566
|
-
if (!dryRun) writeFileSync(dstPath, content, 'utf8')
|
|
567
697
|
created++
|
|
568
|
-
if (isRecording(recorder)) {
|
|
569
|
-
recordChange(
|
|
570
|
-
{
|
|
571
|
-
path: dstPath,
|
|
572
|
-
action: existing === null ? ACTIONS.CREATE : ACTIONS.UPDATE,
|
|
573
|
-
bytes: Buffer.byteLength(content, 'utf8'),
|
|
574
|
-
},
|
|
575
|
-
recorder,
|
|
576
|
-
)
|
|
577
|
-
}
|
|
578
698
|
}
|
|
579
699
|
}
|
|
580
700
|
}
|
|
701
|
+
if (cleanTargetExisted) recordDirectoryOutcomes(src, dst, recorder)
|
|
581
702
|
return { created, skipped }
|
|
582
703
|
}
|
|
583
704
|
|
|
@@ -20,27 +20,57 @@
|
|
|
20
20
|
* "skills": { "version": 1, "status": "installed" },
|
|
21
21
|
* "plugins": { "version": 1, "status": "installed" },
|
|
22
22
|
* "runtime": { "version": 1, "status": "installed" }
|
|
23
|
+
* },
|
|
24
|
+
* "file_inventory": {
|
|
25
|
+
* "schema_version": 1, "target_root": "/absolute/target",
|
|
26
|
+
* "legacy_unknown": false, "entries": []
|
|
23
27
|
* }
|
|
24
28
|
* }
|
|
25
29
|
*/
|
|
26
30
|
|
|
27
|
-
import { existsSync, mkdirSync, readFileSync
|
|
28
|
-
import { join } from 'node:path'
|
|
31
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
32
|
+
import { join, resolve } from 'node:path'
|
|
33
|
+
import { assertNoSymlinkAncestors, writeFileAtomically } from './verify.mjs'
|
|
29
34
|
|
|
30
35
|
const STATE_FILE = 'install-state.json'
|
|
31
36
|
const STATE_DIR = '.pantheon'
|
|
32
37
|
|
|
38
|
+
function emptyFileInventory(legacyUnknown = true) {
|
|
39
|
+
return {
|
|
40
|
+
schema_version: 1,
|
|
41
|
+
target_root: null,
|
|
42
|
+
legacy_unknown: legacyUnknown,
|
|
43
|
+
entries: [],
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function invalidFileInventory() {
|
|
48
|
+
return { schema_version: 0, target_root: null, legacy_unknown: true, entries: [] }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeFileInventory(state) {
|
|
52
|
+
if (!Object.hasOwn(state, 'file_inventory')) return emptyFileInventory(true)
|
|
53
|
+
if (
|
|
54
|
+
!state.file_inventory ||
|
|
55
|
+
typeof state.file_inventory !== 'object' ||
|
|
56
|
+
Array.isArray(state.file_inventory)
|
|
57
|
+
) {
|
|
58
|
+
return invalidFileInventory()
|
|
59
|
+
}
|
|
60
|
+
return state.file_inventory
|
|
61
|
+
}
|
|
62
|
+
|
|
33
63
|
/**
|
|
34
64
|
* Create a fresh v2 state object for a new install.
|
|
35
65
|
* @param {string} version - Pantheon version string
|
|
36
66
|
* @returns {object} v2 state
|
|
37
67
|
*/
|
|
38
|
-
export function createInitialState(version) {
|
|
68
|
+
export function createInitialState(version, updatedAt = new Date().toISOString()) {
|
|
39
69
|
return {
|
|
40
70
|
schema_version: 2,
|
|
41
71
|
pantheon_version: version || '0.0.0',
|
|
42
72
|
previous_version: null,
|
|
43
|
-
updated_at:
|
|
73
|
+
updated_at: updatedAt,
|
|
44
74
|
migration_checkpoint: null,
|
|
45
75
|
applied_migrations: [],
|
|
46
76
|
components: {
|
|
@@ -49,6 +79,7 @@ export function createInitialState(version) {
|
|
|
49
79
|
plugins: { version: 1, status: 'pending' },
|
|
50
80
|
runtime: { version: 1, status: 'pending' },
|
|
51
81
|
},
|
|
82
|
+
file_inventory: emptyFileInventory(false),
|
|
52
83
|
}
|
|
53
84
|
}
|
|
54
85
|
|
|
@@ -104,6 +135,7 @@ export function upgradeStateV1toV2(state) {
|
|
|
104
135
|
plugins: { version: 1, status: 'unknown' },
|
|
105
136
|
runtime: { version: 1, status: 'unknown' },
|
|
106
137
|
},
|
|
138
|
+
file_inventory: normalizeFileInventory(state),
|
|
107
139
|
}
|
|
108
140
|
}
|
|
109
141
|
|
|
@@ -129,6 +161,14 @@ function ensureV2Shape(state) {
|
|
|
129
161
|
) {
|
|
130
162
|
state.components = upgradeStateV1toV2(state).components
|
|
131
163
|
}
|
|
164
|
+
if (!Object.hasOwn(state, 'file_inventory')) state.file_inventory = emptyFileInventory(true)
|
|
165
|
+
else if (
|
|
166
|
+
!state.file_inventory ||
|
|
167
|
+
typeof state.file_inventory !== 'object' ||
|
|
168
|
+
Array.isArray(state.file_inventory)
|
|
169
|
+
) {
|
|
170
|
+
state.file_inventory = invalidFileInventory()
|
|
171
|
+
}
|
|
132
172
|
return state
|
|
133
173
|
}
|
|
134
174
|
|
|
@@ -149,15 +189,30 @@ function normalizeState(state) {
|
|
|
149
189
|
* @param {string} target - Installation directory
|
|
150
190
|
* @param {object} state - v2 state object
|
|
151
191
|
*/
|
|
152
|
-
export function writeState(target, state) {
|
|
192
|
+
export function writeState(target, state, options = {}) {
|
|
153
193
|
const stateDir = join(target, STATE_DIR)
|
|
194
|
+
const stateFile = join(stateDir, STATE_FILE)
|
|
195
|
+
assertNoSymlinkAncestors(stateFile)
|
|
154
196
|
if (!existsSync(stateDir)) {
|
|
155
197
|
mkdirSync(stateDir, { recursive: true })
|
|
156
198
|
}
|
|
157
|
-
|
|
158
|
-
state.updated_at = new Date().toISOString()
|
|
199
|
+
assertNoSymlinkAncestors(stateFile)
|
|
200
|
+
state.updated_at = options.updatedAt ?? new Date().toISOString()
|
|
159
201
|
state.schema_version = 2
|
|
160
|
-
|
|
202
|
+
if (!Object.hasOwn(state, 'file_inventory')) {
|
|
203
|
+
state.file_inventory = emptyFileInventory(true)
|
|
204
|
+
} else if (
|
|
205
|
+
!state.file_inventory ||
|
|
206
|
+
typeof state.file_inventory !== 'object' ||
|
|
207
|
+
Array.isArray(state.file_inventory)
|
|
208
|
+
) {
|
|
209
|
+
state.file_inventory = invalidFileInventory()
|
|
210
|
+
}
|
|
211
|
+
if (state.file_inventory.target_root == null) {
|
|
212
|
+
state.file_inventory.target_root = resolve(target)
|
|
213
|
+
}
|
|
214
|
+
const content = `${JSON.stringify(state, null, 2)}\n`
|
|
215
|
+
writeFileAtomically(stateFile, content)
|
|
161
216
|
}
|
|
162
217
|
|
|
163
218
|
/**
|
package/scripts/install/venv.mjs
CHANGED
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { spawnSync } from 'node:child_process'
|
|
13
|
-
import { existsSync
|
|
13
|
+
import { existsSync } from 'node:fs'
|
|
14
14
|
import { dirname, join } from 'node:path'
|
|
15
15
|
import { fileURLToPath } from 'node:url'
|
|
16
16
|
|
|
17
|
-
import { ACTIONS, recordChange } from './plan.mjs'
|
|
17
|
+
import { ACTIONS, recordChange, recordRemovalTree, removeReviewedTree } from './plan.mjs'
|
|
18
18
|
|
|
19
19
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
20
20
|
const ROOT = join(__dirname, '..', '..')
|
|
@@ -42,21 +42,30 @@ export function venvPythonPath(target) {
|
|
|
42
42
|
* Set up a Python virtual environment and install MCP dependencies.
|
|
43
43
|
*
|
|
44
44
|
* @param {string} target - Target installation directory (e.g. ~/.config/opencode)
|
|
45
|
-
* @param {{ dryRun?: boolean, skipInstall?: boolean, force?: boolean, recorder?: (change: object) => void }} [options]
|
|
45
|
+
* @param {{ dryRun?: boolean, skipInstall?: boolean, force?: boolean, recorder?: (change: object) => void, reviewedRemovals?: Set<string>|null, reviewedSnapshots?: Map<string, object>|null }} [options]
|
|
46
46
|
* @returns {{ venvPath: string, python: string }}
|
|
47
47
|
*/
|
|
48
48
|
export function setupVenv(
|
|
49
49
|
target,
|
|
50
|
-
{
|
|
50
|
+
{
|
|
51
|
+
dryRun = false,
|
|
52
|
+
skipInstall = false,
|
|
53
|
+
force = false,
|
|
54
|
+
recorder = null,
|
|
55
|
+
reviewedRemovals = null,
|
|
56
|
+
reviewedSnapshots = null,
|
|
57
|
+
} = {},
|
|
51
58
|
) {
|
|
52
59
|
const venvPath = join(target, '.venv')
|
|
53
60
|
const pythonBin = venvPythonPath(target)
|
|
54
61
|
|
|
55
62
|
// Step 1: Force recreate venv if --force
|
|
56
63
|
if (force && existsSync(venvPath)) {
|
|
57
|
-
if (
|
|
64
|
+
if (dryRun) {
|
|
65
|
+
recordRemovalTree(venvPath, recorder)
|
|
66
|
+
} else {
|
|
58
67
|
console.log(' Removing existing .venv (--force)...')
|
|
59
|
-
|
|
68
|
+
removeReviewedTree(venvPath, reviewedRemovals, reviewedSnapshots)
|
|
60
69
|
}
|
|
61
70
|
console.log(' ✅ .venv removed')
|
|
62
71
|
}
|