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
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { createHash } from 'node:crypto'
|
|
20
|
-
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync
|
|
20
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs'
|
|
21
21
|
import { homedir } from 'node:os'
|
|
22
|
-
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
22
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
23
23
|
import { listV2UnsupportedFeatures } from '../../src/pantheon/v2-unsupported.mjs'
|
|
24
24
|
import { copyAgentPrompts, writeAgentsMdManaged } from '../build-agents-md.mjs'
|
|
25
|
+
import { backupWarning, createBackupSet, previewBackupSet } from './backup.mjs'
|
|
25
26
|
import {
|
|
26
27
|
bullet,
|
|
27
28
|
colors,
|
|
@@ -29,10 +30,12 @@ import {
|
|
|
29
30
|
error,
|
|
30
31
|
info,
|
|
31
32
|
printSummary,
|
|
33
|
+
printWorktreeGuidance,
|
|
32
34
|
section,
|
|
33
35
|
spinner,
|
|
34
36
|
step,
|
|
35
37
|
success,
|
|
38
|
+
tag,
|
|
36
39
|
warning,
|
|
37
40
|
} from './cli-ui.mjs'
|
|
38
41
|
import {
|
|
@@ -40,9 +43,25 @@ import {
|
|
|
40
43
|
migrateMcpEntrypointPaths,
|
|
41
44
|
migrateV1toV2,
|
|
42
45
|
} from './config-migration.mjs'
|
|
46
|
+
import { componentForInstalledPath, updateFileInventory } from './file-inventory.mjs'
|
|
43
47
|
import { healthCheck } from './health-check.mjs'
|
|
44
48
|
import { detectVersion, runMigrations } from './migrate.mjs'
|
|
45
49
|
import { resolveOpenCodeVersion } from './opencode-version.mjs'
|
|
50
|
+
import {
|
|
51
|
+
ACTIONS,
|
|
52
|
+
createRecorder,
|
|
53
|
+
displayPath,
|
|
54
|
+
fingerprintOutput,
|
|
55
|
+
formatChangePlan,
|
|
56
|
+
isRecording,
|
|
57
|
+
planStatsMismatch,
|
|
58
|
+
recordChange,
|
|
59
|
+
recordRemovalTree,
|
|
60
|
+
removeReviewedTree,
|
|
61
|
+
snapshot,
|
|
62
|
+
summarizeChanges,
|
|
63
|
+
} from './plan.mjs'
|
|
64
|
+
import { findPlanDrift } from './plan-artifact.mjs'
|
|
46
65
|
import { copyPluginFiles, installPlugin, registerPlugin, unregisterPlugin } from './plugin.mjs'
|
|
47
66
|
import {
|
|
48
67
|
checkRuntimePrerequisites,
|
|
@@ -60,6 +79,7 @@ import { createInitialState, hasState, readState, setComponentState, writeState
|
|
|
60
79
|
import { strings } from './strings.mjs'
|
|
61
80
|
|
|
62
81
|
import { setupVenv, venvPythonPath } from './venv.mjs'
|
|
82
|
+
import { assertNoSymlinkAncestors, assertPathWithin, assertSafeRemoval } from './verify.mjs'
|
|
63
83
|
|
|
64
84
|
const COMPONENT_NAMES = [
|
|
65
85
|
'agents',
|
|
@@ -71,6 +91,170 @@ const COMPONENT_NAMES = [
|
|
|
71
91
|
'runtime',
|
|
72
92
|
]
|
|
73
93
|
|
|
94
|
+
/**
|
|
95
|
+
* MCP server scripts the runtime component installs. Hoisted so the backup-set
|
|
96
|
+
* enumeration and the runtime writer share one source of truth.
|
|
97
|
+
*/
|
|
98
|
+
const MCP_SCRIPTS = [
|
|
99
|
+
'mcp_resources.py',
|
|
100
|
+
'code_mode.py',
|
|
101
|
+
'memory_mcp.py',
|
|
102
|
+
'scrub-secrets.py',
|
|
103
|
+
'_pantheon_paths.py',
|
|
104
|
+
'mcp_codemap_module.py',
|
|
105
|
+
'mcp_persistence.py',
|
|
106
|
+
'pantheon_vision.py',
|
|
107
|
+
'eval_store.py',
|
|
108
|
+
'themis_heuristic_scan.py',
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
/** Recursively list regular files under `root`, relative to `root`. */
|
|
112
|
+
function walkFiles(root, base = root, out = []) {
|
|
113
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
114
|
+
const full = join(root, entry.name)
|
|
115
|
+
if (entry.isDirectory()) walkFiles(full, base, out)
|
|
116
|
+
else if (entry.isFile()) out.push(relative(base, full))
|
|
117
|
+
}
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Enumerate what the backup set must preserve, derived from the selected
|
|
123
|
+
* components. It returns:
|
|
124
|
+
* - `entries`: paths (relative to `target`) the run can CREATE/UPDATE/REMOVE.
|
|
125
|
+
* For every managed destination tree we take the UNION of the files that
|
|
126
|
+
* already exist there (so a `--clean`, a stale removal, or an
|
|
127
|
+
* instructions-only refresh is captured) and the source-derived destination
|
|
128
|
+
* paths (so a file that will be created is recorded as absent). The union is
|
|
129
|
+
* a superset of the plan's changes — unchanged files are backed up too.
|
|
130
|
+
* - `notRevertible`: paths the run may delete or write but whose byte-for-byte
|
|
131
|
+
* copy is not feasible or is out of the config dir (bulk `.venv` / plugin
|
|
132
|
+
* `node_modules`, the global `tui.json` copies and the global vision config).
|
|
133
|
+
* These are recorded explicitly in the manifest rather than silently ignored.
|
|
134
|
+
* It never reads file contents.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} target
|
|
137
|
+
* @param {Set<string>} componentSet
|
|
138
|
+
* @param {string} version
|
|
139
|
+
* @param {boolean} isGlobal
|
|
140
|
+
* @returns {{ entries: string[], notRevertible: string[] }}
|
|
141
|
+
*/
|
|
142
|
+
function collectInstallBackupEntries(target, componentSet, version, isGlobal) {
|
|
143
|
+
const entries = []
|
|
144
|
+
const notRevertible = []
|
|
145
|
+
const rel = (abs) => relative(target, abs)
|
|
146
|
+
const addFile = (abs) => entries.push(rel(abs))
|
|
147
|
+
// Existing files under an in-target tree (covers removals / clean / refresh).
|
|
148
|
+
// `skipDirNames` prunes bulk subdirectories (e.g. plugin `node_modules`) so the
|
|
149
|
+
// walk stays cheap while everything else — including legacy files a clean pass
|
|
150
|
+
// deletes — is captured.
|
|
151
|
+
const addExisting = (dir, skipDirNames = null) => {
|
|
152
|
+
if (!existsSync(dir)) return
|
|
153
|
+
const walk = (base) => {
|
|
154
|
+
for (const entry of readdirSync(base, { withFileTypes: true })) {
|
|
155
|
+
if (skipDirNames && entry.isDirectory() && skipDirNames.has(entry.name)) continue
|
|
156
|
+
const full = join(base, entry.name)
|
|
157
|
+
if (entry.isDirectory()) walk(full)
|
|
158
|
+
else if (entry.isFile()) addFile(full)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
walk(dir)
|
|
162
|
+
}
|
|
163
|
+
// Destination paths derived from a source tree (covers files to be created).
|
|
164
|
+
const addSource = (srcDir, dstDir, filter = null) => {
|
|
165
|
+
if (!existsSync(srcDir)) return
|
|
166
|
+
for (const r of walkFiles(srcDir)) {
|
|
167
|
+
if (filter && !filter(r)) continue
|
|
168
|
+
addFile(join(dstDir, r))
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const baseDir = isGlobal ? target : join(target, '.opencode')
|
|
173
|
+
const agentPrefix = isGlobal || version === 'v2' ? 'agents' : '.opencode/agents'
|
|
174
|
+
const agentDir = join(target, agentPrefix)
|
|
175
|
+
|
|
176
|
+
// Always touched.
|
|
177
|
+
addFile(join(target, 'opencode.json'))
|
|
178
|
+
addFile(join(target, '.pantheon', 'install-state.json'))
|
|
179
|
+
addFile(join(baseDir, 'opencode-vision.json'))
|
|
180
|
+
// The active-preset writer (writeActivePreset(target, …)) lives at
|
|
181
|
+
// <target>/.pantheon, NOT under <baseDir>, for both project and global scope.
|
|
182
|
+
addFile(join(target, '.pantheon', 'active-preset.json'))
|
|
183
|
+
addFile(join(target, '.pantheon', 'active-preset.json.bak'))
|
|
184
|
+
|
|
185
|
+
// The agent prompt dir is refreshed (existing-only) by an instructions-only
|
|
186
|
+
// run too, so enumerate it whenever EITHER component can write it.
|
|
187
|
+
if (componentSet.has('agents') || componentSet.has('instructions')) {
|
|
188
|
+
addExisting(agentDir)
|
|
189
|
+
}
|
|
190
|
+
if (componentSet.has('agents')) {
|
|
191
|
+
addFile(join(target, 'routing.yml'))
|
|
192
|
+
addSource(
|
|
193
|
+
join(ROOT, 'src', 'agents'),
|
|
194
|
+
agentDir,
|
|
195
|
+
(r) => r.endsWith('.md') && r.toLowerCase() !== 'readme.md',
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
if (componentSet.has('skills')) {
|
|
199
|
+
addExisting(join(baseDir, 'skills'))
|
|
200
|
+
addSource(join(ROOT, 'src', 'skills'), join(baseDir, 'skills'))
|
|
201
|
+
}
|
|
202
|
+
if (componentSet.has('instructions')) addFile(join(target, 'AGENTS.md'))
|
|
203
|
+
if (componentSet.has('prompts')) {
|
|
204
|
+
addExisting(join(target, 'prompts'))
|
|
205
|
+
addSource(join(ROOT, 'prompts'), join(target, 'prompts'))
|
|
206
|
+
}
|
|
207
|
+
if (componentSet.has('commands')) {
|
|
208
|
+
const commandDir = isGlobal || version === 'v2' ? 'commands' : '.opencode/commands'
|
|
209
|
+
addExisting(join(target, commandDir))
|
|
210
|
+
addSource(join(ROOT, 'commands'), join(target, commandDir), (r) => r.endsWith('.md'))
|
|
211
|
+
}
|
|
212
|
+
if (componentSet.has('plugins')) {
|
|
213
|
+
const dstPluginDir = isGlobal
|
|
214
|
+
? join(target, 'plugins', 'pantheon-tui')
|
|
215
|
+
: join(target, '.opencode', 'plugins', 'pantheon-tui')
|
|
216
|
+
// Existing files under the installed plugin dir (legacy `index.tsx` /
|
|
217
|
+
// `dist/tui.tsx` and any extras a `--clean` run deletes) are captured, like
|
|
218
|
+
// every sibling tree — EXCEPT the bulk `node_modules`, which is regenerated
|
|
219
|
+
// by `npm ci` and listed as not revertible.
|
|
220
|
+
addExisting(dstPluginDir, new Set(['node_modules']))
|
|
221
|
+
addSource(join(ROOT, 'src', 'plugins', 'tui', 'dist'), join(dstPluginDir, 'dist'))
|
|
222
|
+
addFile(join(dstPluginDir, 'package.json'))
|
|
223
|
+
addFile(join(dstPluginDir, 'package-lock.json'))
|
|
224
|
+
addFile(join(dstPluginDir, 'tui.ts'))
|
|
225
|
+
addFile(join(dstPluginDir, 'index.ts'))
|
|
226
|
+
notRevertible.push(rel(join(dstPluginDir, 'node_modules')))
|
|
227
|
+
if (version === 'v1') {
|
|
228
|
+
const projectTui = join(baseDir, 'tui.json')
|
|
229
|
+
addFile(projectTui)
|
|
230
|
+
// Cross-location cleanup touches copies OUTSIDE the config dir.
|
|
231
|
+
for (const loc of tuiConfigLocations(target)) {
|
|
232
|
+
if (resolve(loc) !== resolve(projectTui)) notRevertible.push(loc)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (componentSet.has('runtime')) {
|
|
237
|
+
const runtimeTarget = isGlobal ? target : join(target, '.opencode')
|
|
238
|
+
addExisting(join(runtimeTarget, 'scripts'))
|
|
239
|
+
for (const script of MCP_SCRIPTS) addFile(join(runtimeTarget, 'scripts', script))
|
|
240
|
+
addFile(join(runtimeTarget, 'requirements-vision.txt'))
|
|
241
|
+
addSource(join(ROOT, '.pantheon', 'code-mode'), join(runtimeTarget, '.pantheon', 'code-mode'))
|
|
242
|
+
addExisting(join(runtimeTarget, '.pantheon', 'code-mode'))
|
|
243
|
+
addFile(join(runtimeTarget, '.pantheon', 'tiers.json'))
|
|
244
|
+
// `--force` recreates the venv from scratch: bulk, not copied.
|
|
245
|
+
notRevertible.push(rel(join(target, '.venv')))
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// The vision config is written GLOBALLY (outside the config dir).
|
|
249
|
+
const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), '.config')
|
|
250
|
+
const globalVision = join(xdg, 'opencode', 'opencode-vision.json')
|
|
251
|
+
if (resolve(globalVision) !== resolve(join(baseDir, 'opencode-vision.json'))) {
|
|
252
|
+
notRevertible.push(globalVision)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return { entries, notRevertible }
|
|
256
|
+
}
|
|
257
|
+
|
|
74
258
|
/**
|
|
75
259
|
* The product defaults an install seeds into the user's `opencode.json`.
|
|
76
260
|
*
|
|
@@ -210,30 +394,40 @@ function deepClone(value) {
|
|
|
210
394
|
|
|
211
395
|
/**
|
|
212
396
|
* (Re)generate the code-mode manifest with SHA-256 digests for every bundled
|
|
213
|
-
* script. This is the explicit installation
|
|
214
|
-
* executes scripts listed here whose on-disk hash
|
|
215
|
-
*
|
|
397
|
+
* script, and write it to the destination. This is the explicit installation
|
|
398
|
+
* opt-in: the MCP server only executes scripts listed here whose on-disk hash
|
|
399
|
+
* matches. Missing or empty source approves nothing — execution is never
|
|
400
|
+
* implicit.
|
|
401
|
+
*
|
|
402
|
+
* This function is the SINGLE owner of `manifest.json`: `syncDir` deliberately
|
|
403
|
+
* excludes it (see the runtime block), so exactly one writer and one stats
|
|
404
|
+
* count exist for that path. The payload is derived from the SOURCE scripts
|
|
405
|
+
* (what will be installed), then compared against the CURRENT installed
|
|
406
|
+
* manifest, so the preview action matches what apply writes — including on a
|
|
407
|
+
* script upgrade, where the installed manifest is stale.
|
|
408
|
+
*
|
|
409
|
+
* @returns {{ approved: number, status: 'created'|'skipped'|'conflict'|null }}
|
|
216
410
|
*/
|
|
217
|
-
function writeCodeModeManifest(
|
|
218
|
-
if (!existsSync(
|
|
411
|
+
function writeCodeModeManifest(srcDir, dstDir, dryRun, recorder = null) {
|
|
412
|
+
if (!existsSync(srcDir)) return { approved: 0, status: null }
|
|
219
413
|
let names
|
|
220
414
|
try {
|
|
221
|
-
names = readdirSync(
|
|
415
|
+
names = readdirSync(srcDir)
|
|
222
416
|
.filter((name) => /\.(sh|py)$/i.test(name) && !name.startsWith('.'))
|
|
223
417
|
.sort()
|
|
224
418
|
} catch {
|
|
225
|
-
return 0
|
|
419
|
+
return { approved: 0, status: null }
|
|
226
420
|
}
|
|
227
|
-
if (names.length === 0) return 0
|
|
421
|
+
if (names.length === 0) return { approved: 0, status: null }
|
|
228
422
|
const scripts = {}
|
|
229
423
|
for (const name of names) {
|
|
230
424
|
scripts[name] = createHash('sha256')
|
|
231
|
-
.update(readFileSync(join(
|
|
425
|
+
.update(readFileSync(join(srcDir, name)))
|
|
232
426
|
.digest('hex')
|
|
233
427
|
}
|
|
234
428
|
const payload = `${JSON.stringify({ version: 1, scripts }, null, 2)}\n`
|
|
235
|
-
writeIfChanged(join(
|
|
236
|
-
return names.length
|
|
429
|
+
const status = writeIfChanged(join(dstDir, 'manifest.json'), payload, dryRun, { recorder })
|
|
430
|
+
return { approved: names.length, status }
|
|
237
431
|
}
|
|
238
432
|
|
|
239
433
|
function readJsonConfig(filePath) {
|
|
@@ -594,11 +788,19 @@ export function tuiConfigLocations(target) {
|
|
|
594
788
|
* @param {object} [opts]
|
|
595
789
|
* @param {boolean} [opts.isGlobal=false] - flat global layout vs .opencode/
|
|
596
790
|
* @param {boolean} [opts.dryRun=false]
|
|
791
|
+
* @param {(change: object) => void} [opts.recorder] - optional change recorder for preview
|
|
597
792
|
* @returns {'created'|'skipped'} status for the target registration
|
|
598
793
|
*/
|
|
599
794
|
export function syncTuiRegistration(
|
|
600
795
|
target,
|
|
601
|
-
{
|
|
796
|
+
{
|
|
797
|
+
isGlobal = false,
|
|
798
|
+
dryRun = false,
|
|
799
|
+
version = 'v1',
|
|
800
|
+
recorder = null,
|
|
801
|
+
reviewedRemovals = null,
|
|
802
|
+
reviewedSnapshots = null,
|
|
803
|
+
} = {},
|
|
602
804
|
) {
|
|
603
805
|
const configDir = isGlobal ? target : join(target, '.opencode')
|
|
604
806
|
const targetTuiConfigPath = join(configDir, 'tui.json')
|
|
@@ -608,7 +810,13 @@ export function syncTuiRegistration(
|
|
|
608
810
|
const tuiSrcDir = join(ROOT, 'src', 'plugins', 'tui')
|
|
609
811
|
const tuiCopyDir = resolveTuiCopyTarget(configDir)
|
|
610
812
|
if (existsSync(tuiSrcDir)) {
|
|
611
|
-
copyPluginFiles(tuiSrcDir, tuiCopyDir, {
|
|
813
|
+
copyPluginFiles(tuiSrcDir, tuiCopyDir, {
|
|
814
|
+
dryRun,
|
|
815
|
+
version,
|
|
816
|
+
recorder,
|
|
817
|
+
reviewedRemovals,
|
|
818
|
+
reviewedSnapshots,
|
|
819
|
+
})
|
|
612
820
|
}
|
|
613
821
|
|
|
614
822
|
// 2. Clean stale Pantheon TUI refs from every location OpenCode reads.
|
|
@@ -621,6 +829,7 @@ export function syncTuiRegistration(
|
|
|
621
829
|
unregisterPlugin(tuiConfigPath, 'plugins/pantheon-tui', {
|
|
622
830
|
dryRun,
|
|
623
831
|
knownPantheonPaths: [ownedCopy, tuiCopyDir],
|
|
832
|
+
recorder,
|
|
624
833
|
})
|
|
625
834
|
}
|
|
626
835
|
|
|
@@ -630,7 +839,7 @@ export function syncTuiRegistration(
|
|
|
630
839
|
if (version === 'v2') return 'skipped'
|
|
631
840
|
|
|
632
841
|
// 3. Register the copied directory in the selected target only.
|
|
633
|
-
return registerPlugin(targetTuiConfigPath, tuiCopyDir, { dryRun })
|
|
842
|
+
return registerPlugin(targetTuiConfigPath, tuiCopyDir, { dryRun, recorder })
|
|
634
843
|
}
|
|
635
844
|
|
|
636
845
|
/**
|
|
@@ -639,13 +848,24 @@ export function syncTuiRegistration(
|
|
|
639
848
|
* MCP entries from the config (entries pointing at a missing venv would
|
|
640
849
|
* make every MCP fail to launch). `deps` is injectable for tests.
|
|
641
850
|
*/
|
|
642
|
-
export function setupRuntimePhase(
|
|
851
|
+
export function setupRuntimePhase(
|
|
852
|
+
target,
|
|
853
|
+
{ dryRun, clean, isGlobal, recorder = null, reviewedRemovals = null, reviewedSnapshots = null },
|
|
854
|
+
stats,
|
|
855
|
+
deps = {},
|
|
856
|
+
) {
|
|
643
857
|
const S = strings()
|
|
644
858
|
const setup = deps.setupVenv ?? setupVenv
|
|
645
859
|
const check = deps.healthCheck ?? healthCheck
|
|
646
860
|
try {
|
|
647
861
|
const venvSpinner = spinner(S.settingUpRuntime)
|
|
648
|
-
setup(target, {
|
|
862
|
+
setup(target, {
|
|
863
|
+
dryRun,
|
|
864
|
+
force: clean,
|
|
865
|
+
recorder,
|
|
866
|
+
reviewedRemovals,
|
|
867
|
+
reviewedSnapshots,
|
|
868
|
+
})
|
|
649
869
|
venvSpinner(true)
|
|
650
870
|
const runtimeTarget = isGlobal ? target : join(target, '.opencode')
|
|
651
871
|
const health = check(runtimeTarget, { dryRun, pythonTarget: target })
|
|
@@ -758,12 +978,80 @@ export function applyMcpServerEntries(config, { runtimeTarget, venvPython }) {
|
|
|
758
978
|
return { healed }
|
|
759
979
|
}
|
|
760
980
|
|
|
981
|
+
/**
|
|
982
|
+
* Phase 4 drift detection: the planned targets whose on-disk state changed
|
|
983
|
+
* between the plan pass (snapshots) and the apply pass. A path counts as drifted
|
|
984
|
+
* when its existence, content hash, size, or mode differs — which includes a
|
|
985
|
+
* planned-NEW path that now exists. Returns the absolute paths (empty = no
|
|
986
|
+
* drift); callers print only these paths, never contents or hashes.
|
|
987
|
+
*/
|
|
988
|
+
function detectSnapshotDrift(snapshots) {
|
|
989
|
+
if (!Array.isArray(snapshots)) return []
|
|
990
|
+
const changed = []
|
|
991
|
+
for (const before of snapshots) {
|
|
992
|
+
const now = snapshot(before.path)
|
|
993
|
+
if (
|
|
994
|
+
now.exists !== before.exists ||
|
|
995
|
+
now.sha256 !== before.sha256 ||
|
|
996
|
+
now.size !== before.size ||
|
|
997
|
+
now.mode !== before.mode
|
|
998
|
+
) {
|
|
999
|
+
changed.push(before.path)
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
return changed
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Phase 5 failure semantics. `installPass` is the whole installer; this thin
|
|
1007
|
+
* wrapper reports a mid-apply failure HONESTLY: it aborts the remaining writes
|
|
1008
|
+
* (the throw), relies on each writer to clean up only its own temporary file,
|
|
1009
|
+
* reports the PATHS already applied (never contents), prints the recovery
|
|
1010
|
+
* path, and re-throws so the CLI exits non-zero. There is NO automatic rollback — the
|
|
1011
|
+
* exact backup set + `pantheon-opencode restore` is the recovery.
|
|
1012
|
+
*
|
|
1013
|
+
* Progress is tracked in `_lastRun` (set only by a non-dry pass, so the nested
|
|
1014
|
+
* dry plan pass never clobbers it). The RESOLVED target is read back from
|
|
1015
|
+
* `_lastRun.target` so the report and the sweep work when the caller passed
|
|
1016
|
+
* `undefined` (the default global install resolves to `~/.config/opencode`).
|
|
1017
|
+
*/
|
|
1018
|
+
let _lastRun = null
|
|
1019
|
+
|
|
761
1020
|
export async function installOpenCode(
|
|
762
1021
|
target,
|
|
763
1022
|
dryRun = false,
|
|
764
1023
|
clean = false,
|
|
765
1024
|
components = ['agents', 'skills', 'instructions', 'commands', 'plugins', 'runtime'],
|
|
766
1025
|
opts = {},
|
|
1026
|
+
) {
|
|
1027
|
+
// Reset per-call so a stale run can never leak into a fresh failure report.
|
|
1028
|
+
if (!dryRun) _lastRun = null
|
|
1029
|
+
try {
|
|
1030
|
+
return await installPass(target, dryRun, clean, components, opts)
|
|
1031
|
+
} catch (err) {
|
|
1032
|
+
const run = dryRun ? null : _lastRun
|
|
1033
|
+
if (run) {
|
|
1034
|
+
const S = strings()
|
|
1035
|
+
const paths = summarizeChanges(run.entries ?? [])
|
|
1036
|
+
.files.filter((f) => f.action !== ACTIONS.UNCHANGED && f.action !== ACTIONS.CONFLICT)
|
|
1037
|
+
.map((f) => displayPath(run.target, f.path))
|
|
1038
|
+
error(S.applyFailed(err instanceof Error ? err.message : String(err)))
|
|
1039
|
+
if (paths.length > 0) {
|
|
1040
|
+
warning(S.alreadyApplied)
|
|
1041
|
+
for (const p of paths) warning(` ${p}`)
|
|
1042
|
+
}
|
|
1043
|
+
warning(run.backupDir ? S.restoreRecoveryHint(run.backupDir) : S.restoreNoBackup)
|
|
1044
|
+
}
|
|
1045
|
+
throw err
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
async function installPass(
|
|
1050
|
+
target,
|
|
1051
|
+
dryRun = false,
|
|
1052
|
+
clean = false,
|
|
1053
|
+
components = ['agents', 'skills', 'instructions', 'commands', 'plugins', 'runtime'],
|
|
1054
|
+
opts = {},
|
|
767
1055
|
) {
|
|
768
1056
|
// Default target if not provided (global ~/.config/opencode)
|
|
769
1057
|
if (!target) {
|
|
@@ -772,8 +1060,45 @@ export async function installOpenCode(
|
|
|
772
1060
|
|
|
773
1061
|
const componentSet = new Set(components)
|
|
774
1062
|
const stats = summary.opencode
|
|
1063
|
+
// Per-call baseline. `stats` is the module-level cumulative summary, so the
|
|
1064
|
+
// consistency guard must compare against THIS call's delta, not the total.
|
|
1065
|
+
const statsBefore = { created: stats.created, skipped: stats.skipped }
|
|
1066
|
+
// Number of planned changes, filled by a dry plan pass (used by the gate).
|
|
1067
|
+
let plannedChanges = 0
|
|
1068
|
+
let planFiles = []
|
|
1069
|
+
let removalPaths = []
|
|
1070
|
+
const planTimestamp = opts.planTimestamp ?? new Date().toISOString()
|
|
1071
|
+
let projectedState = null
|
|
1072
|
+
// Phase 4: planned-target snapshots from the plan pass, used for drift checks.
|
|
1073
|
+
let plannedSnapshots = null
|
|
1074
|
+
let reviewedRemovals = null
|
|
1075
|
+
let reviewedSnapshots = null
|
|
775
1076
|
const S = strings()
|
|
776
1077
|
|
|
1078
|
+
// Beta 11 (Setup phase 1): a dry pass records every writer's intended change
|
|
1079
|
+
// so we can print the REAL per-file plan without writing anything. A recorder
|
|
1080
|
+
// ALWAYS runs now: a dry pass builds the preview/plan from it, and an apply
|
|
1081
|
+
// pass uses it to report which PATHS were already written if a writer throws
|
|
1082
|
+
// (Phase 5 failure semantics). The default is the module-level no-op recorder,
|
|
1083
|
+
// so an uninstrumented caller stays byte-identical.
|
|
1084
|
+
const ownRecorder = opts.recorder ? null : createRecorder()
|
|
1085
|
+
const preview = dryRun && ownRecorder ? ownRecorder : null
|
|
1086
|
+
let recorder = opts.recorder ?? ownRecorder.recorder
|
|
1087
|
+
// Test-only failure seam: throw after N recorded writes so the failure path is
|
|
1088
|
+
// deterministic. Never reachable from production callers (opts.deps is not a
|
|
1089
|
+
// CLI flag) and never applied to the internal dry plan pass.
|
|
1090
|
+
if (!dryRun && typeof opts.deps?.failAfter === 'number') {
|
|
1091
|
+
const base = recorder
|
|
1092
|
+
const limit = opts.deps.failAfter
|
|
1093
|
+
let applied = 0
|
|
1094
|
+
recorder = (change) => {
|
|
1095
|
+
base(change)
|
|
1096
|
+
if (++applied >= limit) throw new Error('injected failure (test-only failAfter seam)')
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
// Track this apply so the wrapper can report the paths written on a failure.
|
|
1100
|
+
if (!dryRun) _lastRun = { target, entries: ownRecorder?.entries ?? [], backupDir: null }
|
|
1101
|
+
|
|
777
1102
|
// V1/V2 dual-version awareness (Phase 3): the installer builds a unified
|
|
778
1103
|
// config, then applies V2-native migration (migrateV1toV2) when
|
|
779
1104
|
// --opencode-version=v2. V2 normalizes V1 fields in memory (no rewrite) and
|
|
@@ -784,7 +1109,16 @@ export async function installOpenCode(
|
|
|
784
1109
|
// the V1 branch writes .ts file paths that a 2.x host drops from the config.
|
|
785
1110
|
// `opts` is forwarded as the resolver's options bag so a caller (and CI) can
|
|
786
1111
|
// pin env/probe instead of spawning a real host binary.
|
|
787
|
-
const version = resolveOpenCodeVersion(opts.version ?? 'auto',
|
|
1112
|
+
const version = resolveOpenCodeVersion(opts.version ?? 'auto', {
|
|
1113
|
+
...opts,
|
|
1114
|
+
skipProbe: dryRun,
|
|
1115
|
+
})
|
|
1116
|
+
const versionAssumed =
|
|
1117
|
+
dryRun &&
|
|
1118
|
+
(opts.version ?? 'auto') === 'auto' &&
|
|
1119
|
+
process.env.OPENCODE_VERSION !== 'v1' &&
|
|
1120
|
+
process.env.OPENCODE_VERSION !== 'v2' &&
|
|
1121
|
+
!/(?:^|[\\/])opencode2(?:\.exe)?$/i.test(process.env.OPENCODE_BIN ?? '')
|
|
788
1122
|
if (version === 'v2') {
|
|
789
1123
|
info(
|
|
790
1124
|
'OpenCode V2 target — shared config; state isolated via OPENCODE_DB ' +
|
|
@@ -822,7 +1156,7 @@ export async function installOpenCode(
|
|
|
822
1156
|
|
|
823
1157
|
if (!result.confirmed && !autoYes) {
|
|
824
1158
|
info('Installation canceled.')
|
|
825
|
-
return stats
|
|
1159
|
+
return { ...stats, applied: false, reason: 'canceled' }
|
|
826
1160
|
}
|
|
827
1161
|
|
|
828
1162
|
// Map target selection to actual path
|
|
@@ -869,11 +1203,165 @@ export async function installOpenCode(
|
|
|
869
1203
|
// active config directory is the project root and its agent loader scans
|
|
870
1204
|
// <config-dir>/{agent,agents}/**, so V2 project agents must be at ./agents.
|
|
871
1205
|
// Global installs are flat for both versions.
|
|
872
|
-
const isGlobal = isGlobalConfigDir(target)
|
|
1206
|
+
const isGlobal = opts.global ?? isGlobalConfigDir(target)
|
|
873
1207
|
const _subDir = isGlobal ? '' : '.opencode'
|
|
874
1208
|
const agentPrefix = isGlobal || version === 'v2' ? 'agents' : '.opencode/agents'
|
|
875
1209
|
|
|
876
|
-
configure({ dryRun })
|
|
1210
|
+
configure({ dryRun, quiet: opts.quiet === true })
|
|
1211
|
+
|
|
1212
|
+
// -----------------------------------------------------------------------
|
|
1213
|
+
// Plan pass + apply gate + drift check (Beta 11 Setup phases 3-4).
|
|
1214
|
+
// * the plan pass (a dry run — writes nothing) prints the plan and
|
|
1215
|
+
// snapshots every planned target;
|
|
1216
|
+
// * applying requires explicit authorization (Phase 3): a declined or
|
|
1217
|
+
// unauthorized run writes NOTHING;
|
|
1218
|
+
// * drift (Phase 4) aborts BEFORE any write if a planned target changed
|
|
1219
|
+
// between the plan pass and now.
|
|
1220
|
+
// A result with `applied: false` means the caller must not print success.
|
|
1221
|
+
// -----------------------------------------------------------------------
|
|
1222
|
+
const applyRequested = !dryRun
|
|
1223
|
+
if (applyRequested) {
|
|
1224
|
+
const savedStats = { ...stats }
|
|
1225
|
+
let planned
|
|
1226
|
+
try {
|
|
1227
|
+
// Use the LIVE selection: the wizard above may have changed componentSet.
|
|
1228
|
+
// An AUTHORIZED apply (`--yes`) is not a preview: compute the plan +
|
|
1229
|
+
// snapshots but stay silent. The gate path (no `--yes`) still prints the
|
|
1230
|
+
// plan for the review / decline preview.
|
|
1231
|
+
planned = await installOpenCode(target, true, clean, [...componentSet], {
|
|
1232
|
+
...opts,
|
|
1233
|
+
planTimestamp,
|
|
1234
|
+
planOnly: true,
|
|
1235
|
+
quiet: autoYes,
|
|
1236
|
+
quietPlan: autoYes,
|
|
1237
|
+
})
|
|
1238
|
+
} finally {
|
|
1239
|
+
Object.assign(stats, savedStats)
|
|
1240
|
+
// The plan pass left the UI in dry-run mode; restore this run's mode.
|
|
1241
|
+
configure({ dryRun, quiet: opts.quiet === true })
|
|
1242
|
+
}
|
|
1243
|
+
plannedSnapshots = planned?.snapshots ?? null
|
|
1244
|
+
|
|
1245
|
+
if (opts.expectedPlan) {
|
|
1246
|
+
const packageVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
|
|
1247
|
+
const drift = findPlanDrift(opts.expectedPlan, {
|
|
1248
|
+
target,
|
|
1249
|
+
packageRoot: ROOT,
|
|
1250
|
+
packageVersion,
|
|
1251
|
+
files: planned?.files ?? [],
|
|
1252
|
+
snapshots: planned?.snapshots ?? [],
|
|
1253
|
+
})
|
|
1254
|
+
if (drift.length > 0) {
|
|
1255
|
+
warning('Reviewed install plan drifted; no files were written.')
|
|
1256
|
+
for (const item of drift) warning(` ${item}`)
|
|
1257
|
+
return { ...stats, applied: false, reason: 'drift', drift }
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
if (!autoYes) {
|
|
1262
|
+
const injected = opts.deps?.promptReview
|
|
1263
|
+
const canPrompt = typeof injected === 'function' || interactive
|
|
1264
|
+
if (canPrompt) {
|
|
1265
|
+
const { promptReview } = await import('./interactive.mjs')
|
|
1266
|
+
const ask = typeof injected === 'function' ? injected : promptReview
|
|
1267
|
+
const accepted = await ask(`Apply these ${planned?.planned ?? 0} changes?`, {
|
|
1268
|
+
default: false,
|
|
1269
|
+
})
|
|
1270
|
+
if (!accepted) {
|
|
1271
|
+
info(S.nothingApplied)
|
|
1272
|
+
return { ...stats, applied: false, reason: 'declined' }
|
|
1273
|
+
}
|
|
1274
|
+
} else {
|
|
1275
|
+
info(S.planOnlyNoYes)
|
|
1276
|
+
return { ...stats, applied: false, reason: 'not-authorized' }
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// Test seam: mutate a target between the plan/authorization and the drift
|
|
1281
|
+
// check. Absent in production callers.
|
|
1282
|
+
if (typeof opts.deps?.betweenPlanAndApply === 'function') opts.deps.betweenPlanAndApply()
|
|
1283
|
+
|
|
1284
|
+
const drifted = detectSnapshotDrift(plannedSnapshots)
|
|
1285
|
+
if (drifted.length > 0) {
|
|
1286
|
+
warning(S.driftHeader)
|
|
1287
|
+
for (const p of drifted) warning(` ${displayPath(target, p)}`)
|
|
1288
|
+
warning(S.driftAbort)
|
|
1289
|
+
return {
|
|
1290
|
+
...stats,
|
|
1291
|
+
applied: false,
|
|
1292
|
+
reason: 'drift',
|
|
1293
|
+
drift: drifted.map((p) => displayPath(target, p)),
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
reviewedRemovals = new Set((planned?.removals ?? []).map((filePath) => resolve(filePath)))
|
|
1297
|
+
reviewedSnapshots = new Map(
|
|
1298
|
+
(plannedSnapshots ?? []).map((before) => [resolve(before.path), before]),
|
|
1299
|
+
)
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// -----------------------------------------------------------------------
|
|
1303
|
+
// 0. Exact backup (Beta 11 Setup phase 2): preserve the CURRENT state of
|
|
1304
|
+
// every regular file this run can modify/remove INSIDE the config dir,
|
|
1305
|
+
// byte-for-byte, BEFORE the first modifying write. Bulk directories and
|
|
1306
|
+
// out-of-target global files are recorded in the manifest as
|
|
1307
|
+
// `notRevertible`. A dry run records the planned backup-manifest path only.
|
|
1308
|
+
// -----------------------------------------------------------------------
|
|
1309
|
+
if (componentSet.has('agents')) {
|
|
1310
|
+
const agentTarget = join(target, agentPrefix)
|
|
1311
|
+
assertPathWithin(target, agentTarget)
|
|
1312
|
+
assertNoSymlinkAncestors(agentTarget)
|
|
1313
|
+
}
|
|
1314
|
+
if (componentSet.has('skills')) {
|
|
1315
|
+
const skillsTarget = isGlobal ? join(target, 'skills') : join(target, '.opencode', 'skills')
|
|
1316
|
+
assertPathWithin(target, skillsTarget)
|
|
1317
|
+
assertNoSymlinkAncestors(skillsTarget)
|
|
1318
|
+
}
|
|
1319
|
+
const backupPlan = collectInstallBackupEntries(target, componentSet, version, isGlobal)
|
|
1320
|
+
const pkgVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
|
|
1321
|
+
if (dryRun) {
|
|
1322
|
+
const preview = previewBackupSet({
|
|
1323
|
+
configDir: target,
|
|
1324
|
+
entries: backupPlan.entries,
|
|
1325
|
+
notRevertible: backupPlan.notRevertible,
|
|
1326
|
+
packageVersion: pkgVersion,
|
|
1327
|
+
clock: () => new Date(planTimestamp),
|
|
1328
|
+
})
|
|
1329
|
+
if (isRecording(recorder)) {
|
|
1330
|
+
for (const file of preview.files) {
|
|
1331
|
+
recordChange(
|
|
1332
|
+
{
|
|
1333
|
+
path: file.path,
|
|
1334
|
+
action: ACTIONS.CREATE,
|
|
1335
|
+
bytes: file.bytes,
|
|
1336
|
+
outputFingerprint: file.outputFingerprint,
|
|
1337
|
+
scored: false,
|
|
1338
|
+
},
|
|
1339
|
+
recorder,
|
|
1340
|
+
)
|
|
1341
|
+
}
|
|
1342
|
+
recordChange(
|
|
1343
|
+
{
|
|
1344
|
+
path: preview.manifestPath,
|
|
1345
|
+
action: ACTIONS.CREATE,
|
|
1346
|
+
bytes: preview.manifestBytes,
|
|
1347
|
+
outputFingerprint: preview.manifestFingerprint,
|
|
1348
|
+
scored: false,
|
|
1349
|
+
},
|
|
1350
|
+
recorder,
|
|
1351
|
+
)
|
|
1352
|
+
}
|
|
1353
|
+
} else {
|
|
1354
|
+
const { dir: backupDir } = createBackupSet({
|
|
1355
|
+
configDir: target,
|
|
1356
|
+
entries: backupPlan.entries,
|
|
1357
|
+
notRevertible: backupPlan.notRevertible,
|
|
1358
|
+
packageVersion: pkgVersion,
|
|
1359
|
+
clock: () => new Date(planTimestamp),
|
|
1360
|
+
})
|
|
1361
|
+
if (_lastRun && _lastRun.target === target) _lastRun.backupDir = backupDir
|
|
1362
|
+
// Secret-safe: the path only. The exact wording is fixed (once per apply).
|
|
1363
|
+
console.warn(backupWarning(backupDir))
|
|
1364
|
+
}
|
|
877
1365
|
|
|
878
1366
|
if (isGlobal) {
|
|
879
1367
|
info(S.globalLayout)
|
|
@@ -891,14 +1379,26 @@ export async function installOpenCode(
|
|
|
891
1379
|
} else {
|
|
892
1380
|
const agentStep = step(S.installingAgents)
|
|
893
1381
|
const dstDir = join(target, agentPrefix)
|
|
894
|
-
|
|
895
|
-
|
|
1382
|
+
assertPathWithin(target, dstDir)
|
|
1383
|
+
assertNoSymlinkAncestors(dstDir)
|
|
1384
|
+
if (!dryRun) {
|
|
1385
|
+
mkdirSync(dstDir, { recursive: true })
|
|
1386
|
+
assertNoSymlinkAncestors(dstDir)
|
|
1387
|
+
}
|
|
1388
|
+
if (clean && existsSync(dstDir)) {
|
|
1389
|
+
assertNoSymlinkAncestors(dstDir)
|
|
896
1390
|
const existing = readdirSync(dstDir)
|
|
897
1391
|
for (const f of existing) {
|
|
898
|
-
|
|
1392
|
+
const agentPath = join(dstDir, f)
|
|
1393
|
+
assertSafeRemoval(dstDir, agentPath)
|
|
1394
|
+
if (dryRun) {
|
|
1395
|
+
recordRemovalTree(agentPath, recorder)
|
|
1396
|
+
} else {
|
|
1397
|
+
removeReviewedTree(agentPath, reviewedRemovals, reviewedSnapshots)
|
|
1398
|
+
}
|
|
899
1399
|
}
|
|
900
1400
|
}
|
|
901
|
-
const { created, skipped } = copyAgentPrompts(srcDir, dstDir, dryRun)
|
|
1401
|
+
const { created, skipped } = copyAgentPrompts(srcDir, dstDir, dryRun, false, recorder)
|
|
902
1402
|
stats.created += created
|
|
903
1403
|
stats.skipped += skipped
|
|
904
1404
|
agentStep(true)
|
|
@@ -912,7 +1412,9 @@ export async function installOpenCode(
|
|
|
912
1412
|
const srcRouting = join(ROOT, 'src', 'routing.yml')
|
|
913
1413
|
const dstRouting = join(target, 'routing.yml')
|
|
914
1414
|
if (existsSync(srcRouting)) {
|
|
915
|
-
const routingStatus = writeIfChanged(dstRouting, readFileSync(srcRouting, 'utf8'), dryRun
|
|
1415
|
+
const routingStatus = writeIfChanged(dstRouting, readFileSync(srcRouting, 'utf8'), dryRun, {
|
|
1416
|
+
recorder,
|
|
1417
|
+
})
|
|
916
1418
|
if (routingStatus === 'created') stats.created++
|
|
917
1419
|
else stats.skipped++
|
|
918
1420
|
}
|
|
@@ -927,10 +1429,19 @@ export async function installOpenCode(
|
|
|
927
1429
|
if (skillNames.length > 0) {
|
|
928
1430
|
const skillStep = step(`Installing ${skillNames.length} skills`)
|
|
929
1431
|
const dstSkillsDir = isGlobal ? join(target, 'skills') : join(target, '.opencode', 'skills')
|
|
930
|
-
|
|
1432
|
+
assertPathWithin(target, dstSkillsDir)
|
|
1433
|
+
assertNoSymlinkAncestors(dstSkillsDir)
|
|
1434
|
+
if (clean && existsSync(dstSkillsDir)) {
|
|
1435
|
+
assertNoSymlinkAncestors(dstSkillsDir)
|
|
931
1436
|
const existing = readdirSync(dstSkillsDir)
|
|
932
1437
|
for (const s of existing) {
|
|
933
|
-
|
|
1438
|
+
const skillPath = join(dstSkillsDir, s)
|
|
1439
|
+
assertSafeRemoval(dstSkillsDir, skillPath)
|
|
1440
|
+
if (dryRun) {
|
|
1441
|
+
recordRemovalTree(skillPath, recorder)
|
|
1442
|
+
} else {
|
|
1443
|
+
removeReviewedTree(skillPath, reviewedRemovals, reviewedSnapshots)
|
|
1444
|
+
}
|
|
934
1445
|
}
|
|
935
1446
|
}
|
|
936
1447
|
const installSubDir = isGlobal ? '' : '.opencode'
|
|
@@ -939,6 +1450,10 @@ export async function installOpenCode(
|
|
|
939
1450
|
target,
|
|
940
1451
|
dryRun,
|
|
941
1452
|
installSubDir,
|
|
1453
|
+
recorder,
|
|
1454
|
+
reviewedRemovals,
|
|
1455
|
+
reviewedSnapshots,
|
|
1456
|
+
clean,
|
|
942
1457
|
)
|
|
943
1458
|
stats.created += sCreated
|
|
944
1459
|
stats.skipped += sSkipped
|
|
@@ -960,7 +1475,7 @@ export async function installOpenCode(
|
|
|
960
1475
|
const dstAgentsMd = join(target, 'AGENTS.md')
|
|
961
1476
|
if (existsSync(srcAgentsMd)) {
|
|
962
1477
|
const content = readFileSync(srcAgentsMd, 'utf8')
|
|
963
|
-
const status = writeAgentsMdManaged(dstAgentsMd, content, dryRun)
|
|
1478
|
+
const status = writeAgentsMdManaged(dstAgentsMd, content, dryRun, recorder)
|
|
964
1479
|
if (status === 'skipped') stats.skipped++
|
|
965
1480
|
else {
|
|
966
1481
|
stats.created++
|
|
@@ -982,7 +1497,7 @@ export async function installOpenCode(
|
|
|
982
1497
|
const installedAgents = join(target, agentPrefix)
|
|
983
1498
|
const sourceAgents = join(ROOT, 'src', 'agents')
|
|
984
1499
|
if (existsSync(installedAgents) && existsSync(sourceAgents)) {
|
|
985
|
-
const refreshed = copyAgentPrompts(sourceAgents, installedAgents, dryRun, true)
|
|
1500
|
+
const refreshed = copyAgentPrompts(sourceAgents, installedAgents, dryRun, true, recorder)
|
|
986
1501
|
stats.created += refreshed.created
|
|
987
1502
|
stats.skipped += refreshed.skipped
|
|
988
1503
|
}
|
|
@@ -997,7 +1512,16 @@ export async function installOpenCode(
|
|
|
997
1512
|
const srcPrompts = join(ROOT, 'prompts')
|
|
998
1513
|
const dstPrompts = join(target, 'prompts')
|
|
999
1514
|
if (existsSync(srcPrompts)) {
|
|
1000
|
-
const promptsResult = syncDir(
|
|
1515
|
+
const promptsResult = syncDir(
|
|
1516
|
+
srcPrompts,
|
|
1517
|
+
dstPrompts,
|
|
1518
|
+
dryRun,
|
|
1519
|
+
clean,
|
|
1520
|
+
null,
|
|
1521
|
+
recorder,
|
|
1522
|
+
reviewedRemovals,
|
|
1523
|
+
reviewedSnapshots,
|
|
1524
|
+
)
|
|
1001
1525
|
stats.created += promptsResult.created
|
|
1002
1526
|
stats.skipped += promptsResult.skipped
|
|
1003
1527
|
}
|
|
@@ -1015,7 +1539,16 @@ export async function installOpenCode(
|
|
|
1015
1539
|
const commandDir = isGlobal || version === 'v2' ? 'commands' : '.opencode/commands'
|
|
1016
1540
|
const dstCmds = join(target, commandDir)
|
|
1017
1541
|
if (existsSync(srcCmds)) {
|
|
1018
|
-
const cmdResult = syncDir(
|
|
1542
|
+
const cmdResult = syncDir(
|
|
1543
|
+
srcCmds,
|
|
1544
|
+
dstCmds,
|
|
1545
|
+
dryRun,
|
|
1546
|
+
clean,
|
|
1547
|
+
(f) => f.endsWith('.md'),
|
|
1548
|
+
recorder,
|
|
1549
|
+
reviewedRemovals,
|
|
1550
|
+
reviewedSnapshots,
|
|
1551
|
+
)
|
|
1019
1552
|
stats.created += cmdResult.created
|
|
1020
1553
|
stats.skipped += cmdResult.skipped
|
|
1021
1554
|
}
|
|
@@ -1038,7 +1571,14 @@ export async function installOpenCode(
|
|
|
1038
1571
|
const dstPluginDir = isGlobal
|
|
1039
1572
|
? join(target, 'plugins', 'pantheon-tui')
|
|
1040
1573
|
: join(target, '.opencode', 'plugins', 'pantheon-tui')
|
|
1041
|
-
const pluginStats = installPlugin(srcPluginDir, dstPluginDir, {
|
|
1574
|
+
const pluginStats = installPlugin(srcPluginDir, dstPluginDir, {
|
|
1575
|
+
dryRun,
|
|
1576
|
+
clean,
|
|
1577
|
+
version,
|
|
1578
|
+
recorder,
|
|
1579
|
+
reviewedRemovals,
|
|
1580
|
+
reviewedSnapshots,
|
|
1581
|
+
})
|
|
1042
1582
|
stats.created += pluginStats.created
|
|
1043
1583
|
stats.skipped += pluginStats.skipped
|
|
1044
1584
|
stats.errors += pluginStats.errors
|
|
@@ -1050,7 +1590,14 @@ export async function installOpenCode(
|
|
|
1050
1590
|
if (componentSet.has('plugins')) {
|
|
1051
1591
|
let tuiStatus
|
|
1052
1592
|
try {
|
|
1053
|
-
tuiStatus = syncTuiRegistration(target, {
|
|
1593
|
+
tuiStatus = syncTuiRegistration(target, {
|
|
1594
|
+
isGlobal,
|
|
1595
|
+
dryRun,
|
|
1596
|
+
version,
|
|
1597
|
+
recorder,
|
|
1598
|
+
reviewedRemovals,
|
|
1599
|
+
reviewedSnapshots,
|
|
1600
|
+
})
|
|
1054
1601
|
} catch (cause) {
|
|
1055
1602
|
error(`TUI plugin setup failed: ${cause.message}`)
|
|
1056
1603
|
stats.errors++
|
|
@@ -1069,18 +1616,7 @@ export async function installOpenCode(
|
|
|
1069
1616
|
const runtimeTarget = isGlobal ? target : join(target, '.opencode')
|
|
1070
1617
|
|
|
1071
1618
|
// ── MCP server scripts ──
|
|
1072
|
-
const mcpScripts =
|
|
1073
|
-
'mcp_resources.py',
|
|
1074
|
-
'code_mode.py',
|
|
1075
|
-
'memory_mcp.py',
|
|
1076
|
-
'scrub-secrets.py',
|
|
1077
|
-
'_pantheon_paths.py',
|
|
1078
|
-
'mcp_codemap_module.py',
|
|
1079
|
-
'mcp_persistence.py',
|
|
1080
|
-
'pantheon_vision.py',
|
|
1081
|
-
'eval_store.py',
|
|
1082
|
-
'themis_heuristic_scan.py',
|
|
1083
|
-
]
|
|
1619
|
+
const mcpScripts = MCP_SCRIPTS
|
|
1084
1620
|
const srcScriptsDir = join(ROOT, 'scripts')
|
|
1085
1621
|
// Canonical MCP server sources live in src/mcp/. Map them explicitly so the
|
|
1086
1622
|
// sync always copies from the canonical source, never from the stale root
|
|
@@ -1108,7 +1644,7 @@ export async function installOpenCode(
|
|
|
1108
1644
|
}
|
|
1109
1645
|
const content = readFileSync(src, 'utf8')
|
|
1110
1646
|
const dst = join(dstScriptsDir, script)
|
|
1111
|
-
const status = writeIfChanged(dst, content, dryRun)
|
|
1647
|
+
const status = writeIfChanged(dst, content, dryRun, { recorder })
|
|
1112
1648
|
if (status === 'created') {
|
|
1113
1649
|
stats.created++
|
|
1114
1650
|
// Make executable
|
|
@@ -1126,7 +1662,9 @@ export async function installOpenCode(
|
|
|
1126
1662
|
const visionRequirements = join(ROOT, 'src', 'mcp', 'requirements-vision.txt')
|
|
1127
1663
|
if (existsSync(visionRequirements)) {
|
|
1128
1664
|
const destination = join(runtimeTarget, 'requirements-vision.txt')
|
|
1129
|
-
const status = writeIfChanged(destination, readFileSync(visionRequirements, 'utf8'), dryRun
|
|
1665
|
+
const status = writeIfChanged(destination, readFileSync(visionRequirements, 'utf8'), dryRun, {
|
|
1666
|
+
recorder,
|
|
1667
|
+
})
|
|
1130
1668
|
if (status === 'created') stats.created++
|
|
1131
1669
|
else stats.skipped++
|
|
1132
1670
|
} else {
|
|
@@ -1138,7 +1676,19 @@ export async function installOpenCode(
|
|
|
1138
1676
|
const srcCodeModeDir = join(ROOT, '.pantheon', 'code-mode')
|
|
1139
1677
|
const dstCodeModeDir = join(runtimeTarget, '.pantheon', 'code-mode')
|
|
1140
1678
|
if (existsSync(srcCodeModeDir)) {
|
|
1141
|
-
|
|
1679
|
+
// manifest.json is owned by writeCodeModeManifest (below), not by the dir
|
|
1680
|
+
// sync. Excluding it here keeps exactly ONE writer/count for that path,
|
|
1681
|
+
// so the preview action and the install summary can never diverge.
|
|
1682
|
+
const cmResult = syncDir(
|
|
1683
|
+
srcCodeModeDir,
|
|
1684
|
+
dstCodeModeDir,
|
|
1685
|
+
dryRun,
|
|
1686
|
+
clean,
|
|
1687
|
+
(f) => f !== 'manifest.json',
|
|
1688
|
+
recorder,
|
|
1689
|
+
reviewedRemovals,
|
|
1690
|
+
reviewedSnapshots,
|
|
1691
|
+
)
|
|
1142
1692
|
stats.created += cmResult.created
|
|
1143
1693
|
stats.skipped += cmResult.skipped
|
|
1144
1694
|
} else {
|
|
@@ -1149,13 +1699,18 @@ export async function installOpenCode(
|
|
|
1149
1699
|
warning(`Code-mode source not found: ${srcCodeModeDir} — creating empty ${dstCodeModeDir}`)
|
|
1150
1700
|
if (!dryRun) mkdirSync(dstCodeModeDir, { recursive: true })
|
|
1151
1701
|
stats.created++
|
|
1702
|
+
// The empty runtime dir is a real create counted by `stats.created`, so
|
|
1703
|
+
// record it too (or the preview under-reports on this fallback path).
|
|
1704
|
+
recordChange({ path: dstCodeModeDir, action: ACTIONS.CREATE, bytes: 0 }, recorder)
|
|
1152
1705
|
}
|
|
1153
1706
|
|
|
1154
1707
|
// B3-08: explicit installation mode — seed the manifest with SHA-256
|
|
1155
1708
|
// digests so the server never executes a script implicitly.
|
|
1156
|
-
const
|
|
1157
|
-
if (
|
|
1158
|
-
|
|
1709
|
+
const manifest = writeCodeModeManifest(srcCodeModeDir, dstCodeModeDir, dryRun, recorder)
|
|
1710
|
+
if (manifest.status === 'created') stats.created++
|
|
1711
|
+
else if (manifest.status === 'skipped') stats.skipped++
|
|
1712
|
+
if (manifest.approved > 0) {
|
|
1713
|
+
info(`Code-mode manifest: ${manifest.approved} script(s) approved`)
|
|
1159
1714
|
}
|
|
1160
1715
|
|
|
1161
1716
|
// ── tiers.json ──
|
|
@@ -1163,7 +1718,7 @@ export async function installOpenCode(
|
|
|
1163
1718
|
const dstTiers = join(runtimeTarget, '.pantheon', 'tiers.json')
|
|
1164
1719
|
if (existsSync(srcTiers)) {
|
|
1165
1720
|
const content = readFileSync(srcTiers, 'utf8')
|
|
1166
|
-
const status = writeIfChanged(dstTiers, content, dryRun)
|
|
1721
|
+
const status = writeIfChanged(dstTiers, content, dryRun, { recorder })
|
|
1167
1722
|
if (status === 'created') stats.created++
|
|
1168
1723
|
else stats.skipped++
|
|
1169
1724
|
}
|
|
@@ -1177,7 +1732,11 @@ export async function installOpenCode(
|
|
|
1177
1732
|
// -----------------------------------------------------------------------
|
|
1178
1733
|
let runtimeHealthy = !componentSet.has('runtime')
|
|
1179
1734
|
if (componentSet.has('runtime')) {
|
|
1180
|
-
runtimeHealthy = setupRuntimePhase(
|
|
1735
|
+
runtimeHealthy = setupRuntimePhase(
|
|
1736
|
+
target,
|
|
1737
|
+
{ dryRun, clean, isGlobal, recorder, reviewedRemovals, reviewedSnapshots },
|
|
1738
|
+
stats,
|
|
1739
|
+
)
|
|
1181
1740
|
}
|
|
1182
1741
|
|
|
1183
1742
|
// -----------------------------------------------------------------------
|
|
@@ -1625,12 +2184,13 @@ export async function installOpenCode(
|
|
|
1625
2184
|
// Test seam: lets a test simulate a concurrent writer landing between the
|
|
1626
2185
|
// initial config read and the pre-swap recheck. Absent in production.
|
|
1627
2186
|
beforeRecheck: opts.deps?.beforeRecheck,
|
|
2187
|
+
recorder,
|
|
1628
2188
|
})
|
|
1629
2189
|
if (status === 'conflict') {
|
|
1630
2190
|
// The user's config changed under the installer between the read and the
|
|
1631
2191
|
// swap. Treating this as an ordinary skipped write would report a
|
|
1632
2192
|
// successful install over a file we never actually updated — fail loudly
|
|
1633
|
-
// and leave the target
|
|
2193
|
+
// and leave the target byte-identical instead.
|
|
1634
2194
|
throw new Error(
|
|
1635
2195
|
`Installation aborted: ${targetConfigPath} changed on disk during install ` +
|
|
1636
2196
|
'(it no longer matches the content that was read); refusing to overwrite it. ' +
|
|
@@ -1646,7 +2206,12 @@ export async function installOpenCode(
|
|
|
1646
2206
|
if (componentSet.has('runtime')) {
|
|
1647
2207
|
const currentVersion = detectVersion(target)
|
|
1648
2208
|
if (currentVersion) {
|
|
1649
|
-
const migration = runMigrations(target, currentVersion, {
|
|
2209
|
+
const migration = runMigrations(target, currentVersion, {
|
|
2210
|
+
dryRun,
|
|
2211
|
+
recorder,
|
|
2212
|
+
updatedAt: planTimestamp,
|
|
2213
|
+
})
|
|
2214
|
+
if (migration.plannedState) projectedState = migration.plannedState
|
|
1650
2215
|
if (migration.applied > 0) {
|
|
1651
2216
|
success(`Applied ${migration.applied} migration(s)`)
|
|
1652
2217
|
for (const msg of migration.messages) {
|
|
@@ -1656,6 +2221,100 @@ export async function installOpenCode(
|
|
|
1656
2221
|
}
|
|
1657
2222
|
}
|
|
1658
2223
|
|
|
2224
|
+
if (opts.preset && dryRun) {
|
|
2225
|
+
const { writeActivePreset } = await import('./model-picker.mjs')
|
|
2226
|
+
writeActivePreset(target, opts.preset, {
|
|
2227
|
+
dryRun: true,
|
|
2228
|
+
source: 'cli',
|
|
2229
|
+
updatedAt: planTimestamp,
|
|
2230
|
+
recorder,
|
|
2231
|
+
logger: { log() {} },
|
|
2232
|
+
})
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
// -----------------------------------------------------------------------
|
|
2236
|
+
// 3.75 Preview summary (Beta 11 Setup phase 1): print the REAL per-file
|
|
2237
|
+
// change set this same pass would apply. Paths + action + byte size
|
|
2238
|
+
// ONLY — never file contents or hashes. Nothing was written.
|
|
2239
|
+
// -----------------------------------------------------------------------
|
|
2240
|
+
if (preview) {
|
|
2241
|
+
// Project the terminal state write using the plan's stable timestamp so
|
|
2242
|
+
// saved-plan apply can validate its exact output fingerprint.
|
|
2243
|
+
const packageVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
|
|
2244
|
+
let state = projectedState ?? readState(target)
|
|
2245
|
+
if (!state) state = createInitialState(packageVersion, planTimestamp)
|
|
2246
|
+
state.pantheon_version = packageVersion
|
|
2247
|
+
state.updated_at = planTimestamp
|
|
2248
|
+
state.schema_version = 2
|
|
2249
|
+
for (const component of ['agents', 'skills', 'plugins', 'runtime']) {
|
|
2250
|
+
if (!componentSet.has(component)) continue
|
|
2251
|
+
const componentStatus = component === 'runtime' && !runtimeHealthy ? 'failed' : 'installed'
|
|
2252
|
+
const componentVersion = state.components?.[component]?.version ?? 1
|
|
2253
|
+
setComponentState(state, component, componentVersion, componentStatus)
|
|
2254
|
+
}
|
|
2255
|
+
const stateContent = `${JSON.stringify(state, null, 2)}\n`
|
|
2256
|
+
recordChange(
|
|
2257
|
+
{
|
|
2258
|
+
path: join(target, '.pantheon', 'install-state.json'),
|
|
2259
|
+
action: hasState(target) ? ACTIONS.UPDATE : ACTIONS.CREATE,
|
|
2260
|
+
bytes: Buffer.byteLength(stateContent, 'utf8'),
|
|
2261
|
+
outputFingerprint: fingerprintOutput(stateContent),
|
|
2262
|
+
scored: false,
|
|
2263
|
+
},
|
|
2264
|
+
recorder,
|
|
2265
|
+
)
|
|
2266
|
+
const plan = summarizeChanges(preview.entries)
|
|
2267
|
+
planFiles = plan.files
|
|
2268
|
+
plannedChanges = plan.totals.create + plan.totals.update + plan.totals.remove
|
|
2269
|
+
removalPaths = [
|
|
2270
|
+
...new Set(
|
|
2271
|
+
preview.entries
|
|
2272
|
+
.filter((entry) => entry.action === ACTIONS.REMOVE)
|
|
2273
|
+
.map((entry) => resolve(entry.path)),
|
|
2274
|
+
),
|
|
2275
|
+
].sort()
|
|
2276
|
+
// Snapshot both net changes and every raw removal target. A later write can
|
|
2277
|
+
// make a removal path look unchanged in the net plan, but it still needs
|
|
2278
|
+
// review before the clean operation deletes and recreates it.
|
|
2279
|
+
const snapshotPaths = new Set([...plan.files.map((file) => file.path), ...removalPaths])
|
|
2280
|
+
plannedSnapshots = [...snapshotPaths].map((filePath) => ({
|
|
2281
|
+
path: filePath,
|
|
2282
|
+
...snapshot(filePath),
|
|
2283
|
+
}))
|
|
2284
|
+
const statsDelta = {
|
|
2285
|
+
created: stats.created - statsBefore.created,
|
|
2286
|
+
skipped: stats.skipped - statsBefore.skipped,
|
|
2287
|
+
}
|
|
2288
|
+
const mismatch = planStatsMismatch(plan, statsDelta)
|
|
2289
|
+
if (mismatch) {
|
|
2290
|
+
// Development safety net: a writer that incremented THIS call's install
|
|
2291
|
+
// stats but never recorded a change would otherwise make the preview
|
|
2292
|
+
// silently incomplete. Surface it loudly instead of hiding it.
|
|
2293
|
+
warning(
|
|
2294
|
+
`Change-plan consistency mismatch: plan reports ${mismatch.changed} changed / ` +
|
|
2295
|
+
`${mismatch.unchanged} unchanged vs stats ${mismatch.recordedChanged}/${mismatch.recordedUnchanged}`,
|
|
2296
|
+
)
|
|
2297
|
+
}
|
|
2298
|
+
// Tag the plan like every other dry-run line so the output reads uniformly.
|
|
2299
|
+
// An internal snapshot pass on the apply path (`quietPlan`, e.g. an
|
|
2300
|
+
// authorized `--yes` run) computes the plan and snapshots but must NOT echo
|
|
2301
|
+
// it — a real apply is not a dry run.
|
|
2302
|
+
if (!opts.quietPlan) {
|
|
2303
|
+
console.log('')
|
|
2304
|
+
if (removalPaths.length > 0) {
|
|
2305
|
+
console.log(tag(`Removal review — ${removalPaths.length} path(s):`))
|
|
2306
|
+
for (const filePath of removalPaths) {
|
|
2307
|
+
console.log(tag(` remove ${displayPath(target, filePath)}`))
|
|
2308
|
+
}
|
|
2309
|
+
console.log('')
|
|
2310
|
+
}
|
|
2311
|
+
for (const line of formatChangePlan(target, plan).split('\n')) {
|
|
2312
|
+
console.log(tag(line))
|
|
2313
|
+
}
|
|
2314
|
+
console.log('')
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
|
|
1659
2318
|
// -----------------------------------------------------------------------
|
|
1660
2319
|
// 4. (runtime setup moved to 2.11 — before the config write — so MCP
|
|
1661
2320
|
// entries are only added when the Python runtime is actually healthy)
|
|
@@ -1695,7 +2354,8 @@ export async function installOpenCode(
|
|
|
1695
2354
|
` ${colors.dim('\u2022')} Run 'opencode' to start using Pantheon agents\n`,
|
|
1696
2355
|
)
|
|
1697
2356
|
process.stdout.write('\n')
|
|
1698
|
-
|
|
2357
|
+
printWorktreeGuidance({ projectRoot: process.cwd(), interactive: true })
|
|
2358
|
+
} else if (!opts.planOnly) {
|
|
1699
2359
|
printSummary(target, ['opencode'], stats)
|
|
1700
2360
|
}
|
|
1701
2361
|
|
|
@@ -1712,7 +2372,7 @@ export async function installOpenCode(
|
|
|
1712
2372
|
|
|
1713
2373
|
if (opts.preset && !dryRun) {
|
|
1714
2374
|
const { writeActivePreset } = await import('./model-picker.mjs')
|
|
1715
|
-
writeActivePreset(target, opts.preset, { source: 'cli' })
|
|
2375
|
+
writeActivePreset(target, opts.preset, { source: 'cli', updatedAt: planTimestamp, recorder })
|
|
1716
2376
|
}
|
|
1717
2377
|
|
|
1718
2378
|
// -----------------------------------------------------------------------
|
|
@@ -1743,18 +2403,56 @@ export async function installOpenCode(
|
|
|
1743
2403
|
// to `doctor` forever. Unselected components retain their previous state.
|
|
1744
2404
|
if (!dryRun) {
|
|
1745
2405
|
const packageVersion = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version
|
|
1746
|
-
let state = readState(target)
|
|
2406
|
+
let state = projectedState ?? readState(target)
|
|
1747
2407
|
if (!state && hasState(target)) {
|
|
1748
2408
|
throw new Error('Installation state exists but cannot be read; refusing to replace it')
|
|
1749
2409
|
}
|
|
1750
|
-
state ??= createInitialState(packageVersion)
|
|
2410
|
+
state ??= createInitialState(packageVersion, planTimestamp)
|
|
1751
2411
|
state.pantheon_version = packageVersion
|
|
2412
|
+
state.updated_at = planTimestamp
|
|
1752
2413
|
for (const component of ['agents', 'skills', 'plugins', 'runtime']) {
|
|
1753
2414
|
if (!componentSet.has(component)) continue
|
|
1754
2415
|
const status = component === 'runtime' && !runtimeHealthy ? 'failed' : 'installed'
|
|
1755
2416
|
const componentVersion = state.components?.[component]?.version ?? 1
|
|
1756
2417
|
setComponentState(state, component, componentVersion, status)
|
|
1757
2418
|
}
|
|
1758
|
-
|
|
1759
|
-
|
|
2419
|
+
const verifiedFiles = summarizeChanges(ownRecorder?.entries ?? []).files.flatMap((file) => {
|
|
2420
|
+
if (!['create', 'update', 'unchanged'].includes(file.action) || !file.outputFingerprint)
|
|
2421
|
+
return []
|
|
2422
|
+
const owner = componentForInstalledPath(target, file.path, { isGlobal, version })
|
|
2423
|
+
if (!owner) return []
|
|
2424
|
+
return [
|
|
2425
|
+
{
|
|
2426
|
+
path: file.path,
|
|
2427
|
+
sha256: file.outputFingerprint,
|
|
2428
|
+
component: owner.component,
|
|
2429
|
+
layout: owner.layout,
|
|
2430
|
+
action: file.action,
|
|
2431
|
+
},
|
|
2432
|
+
]
|
|
2433
|
+
})
|
|
2434
|
+
updateFileInventory(state, target, {
|
|
2435
|
+
selectedComponents: [...componentSet],
|
|
2436
|
+
verifiedFiles,
|
|
2437
|
+
})
|
|
2438
|
+
writeState(target, state, { updatedAt: planTimestamp })
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
// Beta 11 Setup phase 3: report whether this call APPLIED, so the caller can
|
|
2442
|
+
// avoid a success banner for a plan-only / unauthorized run. The dry pass also
|
|
2443
|
+
// returns the planned-target snapshots for the apply pass's drift check.
|
|
2444
|
+
return dryRun
|
|
2445
|
+
? {
|
|
2446
|
+
...stats,
|
|
2447
|
+
applied: false,
|
|
2448
|
+
reason: 'plan',
|
|
2449
|
+
planned: plannedChanges,
|
|
2450
|
+
snapshots: plannedSnapshots,
|
|
2451
|
+
files: planFiles,
|
|
2452
|
+
removals: removalPaths,
|
|
2453
|
+
target,
|
|
2454
|
+
openCodeVersion: version,
|
|
2455
|
+
versionAssumed,
|
|
2456
|
+
}
|
|
2457
|
+
: { ...stats, applied: true, reason: 'applied' }
|
|
1760
2458
|
}
|