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