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
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
import { spawnSync } from 'node:child_process'
|
|
6
6
|
import {
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
chmodSync,
|
|
8
|
+
closeSync,
|
|
9
9
|
existsSync,
|
|
10
10
|
lstatSync,
|
|
11
11
|
mkdirSync,
|
|
12
|
+
openSync,
|
|
12
13
|
readdirSync,
|
|
13
14
|
readFileSync,
|
|
14
15
|
renameSync,
|
|
@@ -16,17 +17,35 @@ import {
|
|
|
16
17
|
statSync,
|
|
17
18
|
writeFileSync,
|
|
18
19
|
} from 'node:fs'
|
|
19
|
-
import {
|
|
20
|
+
import { dirname, join } from 'node:path'
|
|
20
21
|
import { fileURLToPath } from 'node:url'
|
|
22
|
+
import { isSupportedNodeVersion, MIN_SUPPORTED_NODE_VERSION } from '../node-engine-policy.mjs'
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
if (!isSupportedNodeVersion(process.versions.node)) {
|
|
25
|
+
console.error(
|
|
26
|
+
`❌ Pantheon requires Node.js >= ${MIN_SUPPORTED_NODE_VERSION} (current: ${process.versions.node})`,
|
|
27
|
+
)
|
|
25
28
|
process.exit(1)
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
import { dump, load } from 'js-yaml'
|
|
29
32
|
|
|
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'
|
|
48
|
+
|
|
30
49
|
export const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
31
50
|
export const ROOT = join(__dirname, '..', '..')
|
|
32
51
|
export const AGENTS_DIR = join(ROOT, 'src', 'agents')
|
|
@@ -141,33 +160,6 @@ export function resolveTarget(target) {
|
|
|
141
160
|
return join(process.cwd(), target)
|
|
142
161
|
}
|
|
143
162
|
|
|
144
|
-
export function createBackup(target) {
|
|
145
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
|
146
|
-
const backupDir = join(target, '..', `.pantheon-bak-${basename(target)}-${timestamp}`)
|
|
147
|
-
|
|
148
|
-
console.log(`\n 💾 Creating backup: ${backupDir}`)
|
|
149
|
-
mkdirSync(backupDir, { recursive: true })
|
|
150
|
-
|
|
151
|
-
// Copy only relevant Pantheon directories/files
|
|
152
|
-
const items = ['agents', 'skills', 'commands', 'instructions', 'opencode.json', 'tui.json']
|
|
153
|
-
let count = 0
|
|
154
|
-
for (const item of items) {
|
|
155
|
-
const src = join(target, item)
|
|
156
|
-
try {
|
|
157
|
-
cpSync(src, join(backupDir, item), {
|
|
158
|
-
recursive: true,
|
|
159
|
-
errorOnExist: false,
|
|
160
|
-
})
|
|
161
|
-
count++
|
|
162
|
-
} catch {
|
|
163
|
-
// File or directory doesn't exist in target — skip silently
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
console.log(` ✅ Backed up ${count} items to: ${backupDir}`)
|
|
168
|
-
return backupDir
|
|
169
|
-
}
|
|
170
|
-
|
|
171
163
|
export function detectAndReport(target) {
|
|
172
164
|
const found = detectPlatforms(target).length > 0
|
|
173
165
|
|
|
@@ -197,6 +189,7 @@ export function sourceDirValid(dir) {
|
|
|
197
189
|
}
|
|
198
190
|
|
|
199
191
|
export function copyFiles(srcDir, dstDir, dryRun, renameMap = null, clean = false) {
|
|
192
|
+
assertNoSymlinkAncestors(dstDir)
|
|
200
193
|
const entries = readdirSync(srcDir)
|
|
201
194
|
let created = 0
|
|
202
195
|
let skipped = 0
|
|
@@ -212,6 +205,8 @@ export function copyFiles(srcDir, dstDir, dryRun, renameMap = null, clean = fals
|
|
|
212
205
|
dstNames.add(dstName)
|
|
213
206
|
|
|
214
207
|
const dstFile = join(dstDir, dstName)
|
|
208
|
+
assertPathWithin(dstDir, dstFile)
|
|
209
|
+
assertNoSymlinkAncestors(dstFile)
|
|
215
210
|
|
|
216
211
|
if (statSync(srcFile).isDirectory()) continue
|
|
217
212
|
const content = readFileSync(srcFile, 'utf8')
|
|
@@ -222,14 +217,10 @@ export function copyFiles(srcDir, dstDir, dryRun, renameMap = null, clean = fals
|
|
|
222
217
|
skipped++
|
|
223
218
|
continue
|
|
224
219
|
}
|
|
225
|
-
|
|
226
|
-
writeFileSync(dstFile, content, 'utf8')
|
|
227
|
-
}
|
|
220
|
+
writeIfChanged(dstFile, content, dryRun)
|
|
228
221
|
created++
|
|
229
222
|
} else {
|
|
230
|
-
|
|
231
|
-
writeFileSync(dstFile, content, 'utf8')
|
|
232
|
-
}
|
|
223
|
+
writeIfChanged(dstFile, content, dryRun)
|
|
233
224
|
created++
|
|
234
225
|
}
|
|
235
226
|
}
|
|
@@ -239,17 +230,21 @@ export function copyFiles(srcDir, dstDir, dryRun, renameMap = null, clean = fals
|
|
|
239
230
|
// (lowercase name + .md/.mdc extension), regardless of AGENT_NAMES membership.
|
|
240
231
|
// This handles deleted canonical agents (e.g., agora) whose source file is gone.
|
|
241
232
|
if (clean && existsSync(dstDir)) {
|
|
233
|
+
assertNoSymlinkAncestors(dstDir)
|
|
242
234
|
const dstEntries = readdirSync(dstDir)
|
|
243
235
|
const agentPattern = /^[a-z-]+\.(md|mdc)$/
|
|
244
236
|
for (const entry of dstEntries) {
|
|
245
237
|
if (dstNames.has(entry)) continue // still in source
|
|
246
238
|
const dstFile = join(dstDir, entry)
|
|
239
|
+
assertSafeRemoval(dstDir, dstFile)
|
|
247
240
|
if (!statSync(dstFile).isFile()) continue
|
|
248
241
|
// Safety: only remove files that match agent file naming pattern
|
|
249
242
|
// (user custom files with different naming patterns are untouched)
|
|
250
243
|
if (!agentPattern.test(entry)) continue
|
|
251
244
|
if (!dryRun) {
|
|
245
|
+
assertSafeRemoval(dstDir, dstFile)
|
|
252
246
|
rmSync(dstFile, { force: true })
|
|
247
|
+
verifyPathRemoved(dstFile)
|
|
253
248
|
}
|
|
254
249
|
created++
|
|
255
250
|
}
|
|
@@ -302,35 +297,71 @@ function readFileOrNull(filePath) {
|
|
|
302
297
|
* filesystem CAS: the final re-read and the rename are still two syscalls, so
|
|
303
298
|
* a writer that lands between them is not prevented. What it does close is
|
|
304
299
|
* the window the installer actually races with — a config the caller parsed
|
|
305
|
-
* going stale during the same run — and it keeps the target
|
|
306
|
-
*
|
|
300
|
+
* going stale during the same run — and it keeps the target consistent with
|
|
301
|
+
* the bytes that were checked.
|
|
307
302
|
* @param {(existing: string|null) => void} [options.beforeRecheck] - runs after
|
|
308
303
|
* the initial content check and before the last-moment re-read. Injection
|
|
309
304
|
* point so tests can simulate a concurrent writer landing in the window;
|
|
310
305
|
* production callers omit it (undefined is a no-op).
|
|
311
306
|
* @param {(existing: string|null) => void} [options.beforeSwap] - runs after the
|
|
312
|
-
* expected-content checks pass and immediately before the rename,
|
|
313
|
-
* effects that must not fire on a conflict
|
|
307
|
+
* expected-content checks pass and immediately before the rename, for side
|
|
308
|
+
* effects that must not fire on a conflict.
|
|
309
|
+
* @param {(change: object) => void} [options.recorder] - optional change
|
|
310
|
+
* recorder. When supplied, the outcome is recorded (path + action + byte
|
|
311
|
+
* size only) so `--dry-run` can print the real per-file plan. Defaults to
|
|
312
|
+
* the module-level no-op singleton, so an uninstrumented caller is
|
|
313
|
+
* byte-identical to before.
|
|
314
|
+
* @param {number} [options.mode] - mode for a newly created file; existing
|
|
315
|
+
* destination modes are preserved.
|
|
316
|
+
* @param {boolean} [options.scored] - set `false` when this write is not part
|
|
317
|
+
* of the install summary (e.g. stale-ref cleanup), so it is shown in the plan
|
|
318
|
+
* but excluded from the consistency guard.
|
|
314
319
|
* @returns {'created'|'skipped'|'conflict'}
|
|
315
320
|
*/
|
|
316
321
|
export function writeIfChanged(filePath, content, dryRun, options = {}) {
|
|
317
|
-
const { expectedContent, beforeSwap, beforeRecheck } = options
|
|
322
|
+
const { expectedContent, beforeSwap, beforeRecheck, recorder, scored, mode } = options
|
|
318
323
|
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
|
|
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)
|
|
322
327
|
const stat = lstatOrNull(filePath)
|
|
323
328
|
if (stat?.isSymbolicLink()) {
|
|
324
329
|
throw new Error(`Refusing to write through symlink: ${filePath}`)
|
|
325
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
|
|
326
334
|
|
|
327
335
|
const existing = stat === null ? null : readFileOrNull(filePath)
|
|
336
|
+
const record = (outcome) => {
|
|
337
|
+
if (!isRecording(recorder)) return
|
|
338
|
+
const action =
|
|
339
|
+
outcome === 'conflict'
|
|
340
|
+
? ACTIONS.CONFLICT
|
|
341
|
+
: outcome === 'skipped'
|
|
342
|
+
? ACTIONS.UNCHANGED
|
|
343
|
+
: existing === null
|
|
344
|
+
? ACTIONS.CREATE
|
|
345
|
+
: ACTIONS.UPDATE
|
|
346
|
+
recordChange(
|
|
347
|
+
{
|
|
348
|
+
path: filePath,
|
|
349
|
+
action,
|
|
350
|
+
bytes: Buffer.byteLength(content, 'utf8'),
|
|
351
|
+
outputFingerprint: fingerprintOutput(content),
|
|
352
|
+
scored,
|
|
353
|
+
},
|
|
354
|
+
recorder,
|
|
355
|
+
)
|
|
356
|
+
}
|
|
328
357
|
if (existing === content) {
|
|
358
|
+
record('skipped')
|
|
329
359
|
return 'skipped'
|
|
330
360
|
}
|
|
331
361
|
if (expectedContent !== undefined && existing !== expectedContent) {
|
|
332
362
|
// The file changed since the caller read it (or appeared/disappeared).
|
|
333
363
|
// Do not clobber the newer content; the caller decides how to react.
|
|
364
|
+
record('conflict')
|
|
334
365
|
return 'conflict'
|
|
335
366
|
}
|
|
336
367
|
if (!dryRun) {
|
|
@@ -342,36 +373,71 @@ export function writeIfChanged(filePath, content, dryRun, options = {}) {
|
|
|
342
373
|
// crash mid-write can never leave a truncated file behind (rename is
|
|
343
374
|
// atomic within the same directory).
|
|
344
375
|
const tmpPath = `${filePath}.tmp-${process.pid}`
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
+
}
|
|
356
399
|
}
|
|
357
|
-
}
|
|
358
400
|
|
|
359
|
-
|
|
360
|
-
|
|
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
|
+
}
|
|
361
424
|
}
|
|
425
|
+
record('created')
|
|
362
426
|
return 'created'
|
|
363
427
|
}
|
|
364
428
|
|
|
365
429
|
/**
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
430
|
+
* Write a config file atomically, honoring the compare-before-write guard.
|
|
431
|
+
* This used to always keep a `<config>.bak` sidecar; the main `opencode.json`
|
|
432
|
+
* no longer does — the target-scoped exact backup set
|
|
433
|
+
* (scripts/install/backup.mjs) preserves it byte-for-byte and
|
|
434
|
+
* `pantheon-opencode restore` reverts it.
|
|
369
435
|
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
436
|
+
* A caller may still supply `beforeSwap` for a file the exact set does NOT
|
|
437
|
+
* cover: the GLOBAL `opencode-vision.json` lives outside the config dir, so
|
|
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
|
|
@@ -382,14 +448,10 @@ export function writeConfigWithBackup(filePath, content, dryRun, options = {}) {
|
|
|
382
448
|
return writeIfChanged(filePath, content, dryRun, {
|
|
383
449
|
expectedContent: options.expectedContent,
|
|
384
450
|
beforeRecheck: options.beforeRecheck,
|
|
385
|
-
beforeSwap:
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
} catch {
|
|
390
|
-
// Best-effort: a backup failure must not block the install.
|
|
391
|
-
}
|
|
392
|
-
},
|
|
451
|
+
beforeSwap: options.beforeSwap,
|
|
452
|
+
recorder: options.recorder,
|
|
453
|
+
scored: options.scored,
|
|
454
|
+
mode: options.mode ?? 0o600,
|
|
393
455
|
})
|
|
394
456
|
}
|
|
395
457
|
|
|
@@ -403,7 +465,10 @@ export function checkRuntimePrerequisites(env = process.env) {
|
|
|
403
465
|
const problems = []
|
|
404
466
|
const checks = [
|
|
405
467
|
{ cmd: 'python3', hint: 'install Python 3.10+ (https://www.python.org/downloads/)' },
|
|
406
|
-
{
|
|
468
|
+
{
|
|
469
|
+
cmd: 'npm',
|
|
470
|
+
hint: `install Node.js >= ${MIN_SUPPORTED_NODE_VERSION} (https://nodejs.org) — npm ships with it`,
|
|
471
|
+
},
|
|
407
472
|
]
|
|
408
473
|
for (const { cmd, hint } of checks) {
|
|
409
474
|
const probe = spawnSync(cmd, ['--version'], { encoding: 'utf8', timeout: 10_000, env })
|
|
@@ -455,9 +520,27 @@ export function serializeFm(fm) {
|
|
|
455
520
|
})
|
|
456
521
|
}
|
|
457
522
|
|
|
458
|
-
|
|
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
|
+
) {
|
|
459
541
|
const srcSkillsDir = join(ROOT, 'src', 'skills')
|
|
460
542
|
const dstSkillsDir = join(target, subDir, 'skills')
|
|
543
|
+
assertNoSymlinkAncestors(dstSkillsDir)
|
|
461
544
|
|
|
462
545
|
let created = 0
|
|
463
546
|
let skipped = 0
|
|
@@ -466,7 +549,12 @@ export function installSkills(skills, target, dryRun, subDir = '.opencode') {
|
|
|
466
549
|
const src = join(srcSkillsDir, skill)
|
|
467
550
|
const dst = join(dstSkillsDir, skill)
|
|
468
551
|
|
|
469
|
-
if (!dryRun)
|
|
552
|
+
if (!dryRun) {
|
|
553
|
+
assertPathWithin(dstSkillsDir, dst)
|
|
554
|
+
assertNoSymlinkAncestors(dst)
|
|
555
|
+
mkdirSync(dst, { recursive: true })
|
|
556
|
+
assertNoSymlinkAncestors(dst)
|
|
557
|
+
}
|
|
470
558
|
|
|
471
559
|
function copyDirRecursive(from, to) {
|
|
472
560
|
const entries = readdirSync(from)
|
|
@@ -474,15 +562,47 @@ export function installSkills(skills, target, dryRun, subDir = '.opencode') {
|
|
|
474
562
|
const srcPath = join(from, entry)
|
|
475
563
|
const dstPath = join(to, entry)
|
|
476
564
|
if (statSync(srcPath).isDirectory()) {
|
|
477
|
-
if (!dryRun)
|
|
565
|
+
if (!dryRun) {
|
|
566
|
+
assertPathWithin(dstSkillsDir, dstPath)
|
|
567
|
+
assertNoSymlinkAncestors(dstPath)
|
|
568
|
+
mkdirSync(dstPath, { recursive: true })
|
|
569
|
+
assertNoSymlinkAncestors(dstPath)
|
|
570
|
+
}
|
|
478
571
|
copyDirRecursive(srcPath, dstPath)
|
|
479
572
|
} else {
|
|
480
573
|
const content = readFileSync(srcPath, 'utf8')
|
|
481
|
-
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')
|
|
482
579
|
if (existing === content) {
|
|
483
580
|
skipped++
|
|
581
|
+
if (isRecording(recorder)) {
|
|
582
|
+
recordChange(
|
|
583
|
+
{
|
|
584
|
+
path: dstPath,
|
|
585
|
+
action: ACTIONS.UNCHANGED,
|
|
586
|
+
bytes: Buffer.byteLength(content, 'utf8'),
|
|
587
|
+
outputFingerprint: fingerprintOutput(content),
|
|
588
|
+
},
|
|
589
|
+
recorder,
|
|
590
|
+
)
|
|
591
|
+
}
|
|
484
592
|
} else {
|
|
485
|
-
if (!dryRun)
|
|
593
|
+
if (!dryRun) {
|
|
594
|
+
writeIfChanged(dstPath, content, false, { recorder })
|
|
595
|
+
} else if (isRecording(recorder)) {
|
|
596
|
+
recordChange(
|
|
597
|
+
{
|
|
598
|
+
path: dstPath,
|
|
599
|
+
action: existing === null ? ACTIONS.CREATE : ACTIONS.UPDATE,
|
|
600
|
+
bytes: Buffer.byteLength(content, 'utf8'),
|
|
601
|
+
outputFingerprint: fingerprintOutput(content),
|
|
602
|
+
},
|
|
603
|
+
recorder,
|
|
604
|
+
)
|
|
605
|
+
}
|
|
486
606
|
created++
|
|
487
607
|
}
|
|
488
608
|
}
|
|
@@ -490,20 +610,29 @@ export function installSkills(skills, target, dryRun, subDir = '.opencode') {
|
|
|
490
610
|
}
|
|
491
611
|
|
|
492
612
|
copyDirRecursive(src, dst)
|
|
613
|
+
if (clean && existsSync(dst)) recordDirectoryOutcomes(src, dst, recorder)
|
|
493
614
|
}
|
|
494
615
|
|
|
495
616
|
// Remove stale skills that exist in dst but not in skills list
|
|
496
617
|
if (existsSync(dstSkillsDir)) {
|
|
618
|
+
assertNoSymlinkAncestors(dstSkillsDir)
|
|
497
619
|
const dstEntries = readdirSync(dstSkillsDir)
|
|
498
620
|
for (const entry of dstEntries) {
|
|
499
621
|
const dstPath = join(dstSkillsDir, entry)
|
|
622
|
+
assertSafeRemoval(dstSkillsDir, dstPath)
|
|
500
623
|
if (!statSync(dstPath).isDirectory()) continue
|
|
501
624
|
if (!skills.includes(entry)) {
|
|
502
|
-
if (
|
|
503
|
-
|
|
625
|
+
if (dryRun) {
|
|
626
|
+
recordRemovalTree(dstPath, recorder)
|
|
627
|
+
} else {
|
|
628
|
+
assertSafeRemoval(dstSkillsDir, dstPath)
|
|
629
|
+
removeReviewedTree(dstPath, reviewedRemovals, reviewedSnapshots)
|
|
504
630
|
console.log(` 🗑️ Removed stale skill: ${entry}`)
|
|
505
631
|
}
|
|
506
632
|
created++
|
|
633
|
+
// A stale removal increments `created` (a change), so it must also be
|
|
634
|
+
// recorded or the preview would under-report on a non-fresh target.
|
|
635
|
+
recordChange({ path: dstPath, action: ACTIONS.REMOVE, bytes: 0 }, recorder)
|
|
507
636
|
}
|
|
508
637
|
}
|
|
509
638
|
}
|
|
@@ -511,18 +640,35 @@ export function installSkills(skills, target, dryRun, subDir = '.opencode') {
|
|
|
511
640
|
return { created, skipped }
|
|
512
641
|
}
|
|
513
642
|
|
|
514
|
-
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
|
+
) {
|
|
515
653
|
if (!existsSync(src)) return { created: 0, skipped: 0 }
|
|
516
654
|
let created = 0
|
|
517
655
|
let skipped = 0
|
|
656
|
+
const cleanTargetExisted = clean && existsSync(dst)
|
|
518
657
|
|
|
519
|
-
if (
|
|
520
|
-
if (
|
|
521
|
-
|
|
658
|
+
if (cleanTargetExisted) {
|
|
659
|
+
if (dryRun) {
|
|
660
|
+
recordRemovalTree(dst, recorder)
|
|
661
|
+
} else {
|
|
662
|
+
assertSafeRemoval(dirname(dst), dst)
|
|
663
|
+
removeReviewedTree(dst, reviewedRemovals, reviewedSnapshots)
|
|
522
664
|
}
|
|
523
665
|
}
|
|
524
666
|
|
|
525
|
-
if (!dryRun)
|
|
667
|
+
if (!dryRun) {
|
|
668
|
+
assertNoSymlinkAncestors(dst)
|
|
669
|
+
mkdirSync(dst, { recursive: true })
|
|
670
|
+
assertNoSymlinkAncestors(dst)
|
|
671
|
+
}
|
|
526
672
|
|
|
527
673
|
const entries = readdirSync(src)
|
|
528
674
|
for (const entry of entries) {
|
|
@@ -530,20 +676,29 @@ export function syncDir(src, dst, dryRun, clean = false, filter = null) {
|
|
|
530
676
|
const srcPath = join(src, entry)
|
|
531
677
|
const dstPath = join(dst, entry)
|
|
532
678
|
if (statSync(srcPath).isDirectory()) {
|
|
533
|
-
const sub = syncDir(
|
|
679
|
+
const sub = syncDir(
|
|
680
|
+
srcPath,
|
|
681
|
+
dstPath,
|
|
682
|
+
dryRun,
|
|
683
|
+
false,
|
|
684
|
+
filter,
|
|
685
|
+
recorder,
|
|
686
|
+
reviewedRemovals,
|
|
687
|
+
reviewedSnapshots,
|
|
688
|
+
)
|
|
534
689
|
created += sub.created
|
|
535
690
|
skipped += sub.skipped
|
|
536
691
|
} else {
|
|
537
692
|
const content = readFileSync(srcPath, 'utf8')
|
|
538
|
-
const
|
|
539
|
-
if (
|
|
693
|
+
const status = writeIfChanged(dstPath, content, dryRun, { recorder })
|
|
694
|
+
if (status === 'skipped') {
|
|
540
695
|
skipped++
|
|
541
696
|
} else {
|
|
542
|
-
if (!dryRun) writeFileSync(dstPath, content, 'utf8')
|
|
543
697
|
created++
|
|
544
698
|
}
|
|
545
699
|
}
|
|
546
700
|
}
|
|
701
|
+
if (cleanTargetExisted) recordDirectoryOutcomes(src, dst, recorder)
|
|
547
702
|
return { created, skipped }
|
|
548
703
|
}
|
|
549
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
|
/**
|