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
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Preview or remove only files proven stale by the install ownership inventory. */
|
|
3
|
+
import {
|
|
4
|
+
closeSync,
|
|
5
|
+
constants,
|
|
6
|
+
existsSync,
|
|
7
|
+
fsyncSync,
|
|
8
|
+
openSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs'
|
|
12
|
+
import { homedir } from 'node:os'
|
|
13
|
+
import { join, resolve } from 'node:path'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
import { applyFileDriftPlan, classifyFileInventory } from './install/file-inventory.mjs'
|
|
16
|
+
import { readState } from './install/state.mjs'
|
|
17
|
+
import { assertNoSymlinkAncestors, verifyRegularFile } from './install/verify.mjs'
|
|
18
|
+
|
|
19
|
+
const VALID_COMPONENTS = [
|
|
20
|
+
'agents',
|
|
21
|
+
'skills',
|
|
22
|
+
'instructions',
|
|
23
|
+
'prompts',
|
|
24
|
+
'commands',
|
|
25
|
+
'plugins',
|
|
26
|
+
'runtime',
|
|
27
|
+
]
|
|
28
|
+
const DEFAULT_COMPONENTS = [...VALID_COMPONENTS]
|
|
29
|
+
|
|
30
|
+
export function parseArgs(argv) {
|
|
31
|
+
const args = {
|
|
32
|
+
project: false,
|
|
33
|
+
global: false,
|
|
34
|
+
target: null,
|
|
35
|
+
components: null,
|
|
36
|
+
apply: false,
|
|
37
|
+
dryRun: false,
|
|
38
|
+
yes: false,
|
|
39
|
+
savePlan: null,
|
|
40
|
+
applyPlan: null,
|
|
41
|
+
help: false,
|
|
42
|
+
}
|
|
43
|
+
for (let i = 0; i < argv.length; i++) {
|
|
44
|
+
const arg = argv[i]
|
|
45
|
+
if (arg === '--project') args.project = true
|
|
46
|
+
else if (arg === '--global') args.global = true
|
|
47
|
+
else if (arg === '--target') {
|
|
48
|
+
const target = argv[++i]
|
|
49
|
+
if (!target || target.startsWith('--')) throw new Error('--target requires a path')
|
|
50
|
+
args.target = target
|
|
51
|
+
} else if (arg === '--components') args.components = argv[++i] ?? null
|
|
52
|
+
else if (arg === '--apply') args.apply = true
|
|
53
|
+
else if (arg === '--dry-run') args.dryRun = true
|
|
54
|
+
else if (arg === '--yes' || arg === '-y') args.yes = true
|
|
55
|
+
else if (arg === '--save-plan') args.savePlan = argv[++i] ?? null
|
|
56
|
+
else if (arg === '--apply-plan') args.applyPlan = argv[++i] ?? null
|
|
57
|
+
else if (arg === '--help' || arg === '-h') args.help = true
|
|
58
|
+
else throw new Error(`Unknown reconcile option: ${arg}`)
|
|
59
|
+
}
|
|
60
|
+
if (!args.applyPlan && !args.help) {
|
|
61
|
+
if (args.project && (args.global || args.target !== null)) {
|
|
62
|
+
throw new Error('--project cannot be combined with --global or --target')
|
|
63
|
+
}
|
|
64
|
+
if (!args.project && !args.global && args.target === null) {
|
|
65
|
+
throw new Error('Choose a target scope: --project, --global, or --target PATH')
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (args.components !== null) {
|
|
69
|
+
args.components = args.components
|
|
70
|
+
.split(',')
|
|
71
|
+
.map((item) => item.trim())
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
if (
|
|
74
|
+
args.components.length === 0 ||
|
|
75
|
+
args.components.some((item) => !VALID_COMPONENTS.includes(item))
|
|
76
|
+
) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`--components must be a comma-separated list of: ${VALID_COMPONENTS.join(', ')}`,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
args.components = [...new Set(args.components)]
|
|
82
|
+
}
|
|
83
|
+
if (args.apply && args.applyPlan) throw new Error('Use either --apply or --apply-plan, not both')
|
|
84
|
+
if (args.dryRun && (args.apply || args.applyPlan)) {
|
|
85
|
+
throw new Error('--dry-run cannot be combined with an apply option')
|
|
86
|
+
}
|
|
87
|
+
if (args.savePlan && (args.apply || args.applyPlan))
|
|
88
|
+
throw new Error('--save-plan is available only during preview')
|
|
89
|
+
if (args.yes && !args.apply && !args.applyPlan)
|
|
90
|
+
throw new Error('--yes requires --apply or --apply-plan')
|
|
91
|
+
if (args.applyPlan && (args.project || args.global || args.target || args.components)) {
|
|
92
|
+
throw new Error('--apply-plan uses the target and components embedded in the reviewed plan')
|
|
93
|
+
}
|
|
94
|
+
return args
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function printHelp() {
|
|
98
|
+
console.log(`
|
|
99
|
+
pantheon-opencode reconcile — review and remove exact stale installer-owned files
|
|
100
|
+
|
|
101
|
+
Usage:
|
|
102
|
+
pantheon-opencode reconcile --project [--dry-run] [--components agents,skills] [--save-plan <file>]
|
|
103
|
+
pantheon-opencode reconcile --global [--target PATH] [--dry-run] [--components agents,skills]
|
|
104
|
+
pantheon-opencode reconcile --target PATH [--dry-run] [--components agents,skills]
|
|
105
|
+
pantheon-opencode reconcile --project --apply [--yes]
|
|
106
|
+
pantheon-opencode reconcile --apply-plan <file> --yes
|
|
107
|
+
|
|
108
|
+
Use --global for the global OpenCode config target, or --target PATH for an explicit
|
|
109
|
+
target. An explicit target is project-scoped unless combined with --global, which
|
|
110
|
+
selects the flat global layout for that path. Preview is read-only. Apply removes only unchanged, inventoried stale files;
|
|
111
|
+
legacy, modified, missing, symlinked, and ambiguous files are preserved.
|
|
112
|
+
`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function resolveTarget(args) {
|
|
116
|
+
if (args.target) return resolve(args.target)
|
|
117
|
+
if (args.project) return resolve(process.cwd())
|
|
118
|
+
return resolve(join(homedir(), '.config', 'opencode'))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function layoutFor({ global, version }) {
|
|
122
|
+
return Object.fromEntries(
|
|
123
|
+
VALID_COMPONENTS.map((component) => [
|
|
124
|
+
component,
|
|
125
|
+
global ? 'global' : version === 'v2' ? 'project-v2' : 'project-v1',
|
|
126
|
+
]),
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function buildReview(target, components, global) {
|
|
131
|
+
if (!existsSync(target)) throw new Error(`Target directory does not exist: ${target}`)
|
|
132
|
+
const state = readState(target)
|
|
133
|
+
if (!state)
|
|
134
|
+
throw new Error('No readable installer state found; unowned legacy files will not be removed')
|
|
135
|
+
const { installOpenCode } = await import('./install/opencode.mjs')
|
|
136
|
+
const desired = await installOpenCode(target, true, false, components, {
|
|
137
|
+
version: 'auto',
|
|
138
|
+
global,
|
|
139
|
+
quiet: true,
|
|
140
|
+
quietPlan: true,
|
|
141
|
+
planOnly: true,
|
|
142
|
+
headless: true,
|
|
143
|
+
})
|
|
144
|
+
const desiredFiles = new Map(
|
|
145
|
+
(desired?.files ?? [])
|
|
146
|
+
.filter((file) => typeof file.outputFingerprint === 'string')
|
|
147
|
+
.map((file) => [file.path, { sha256: file.outputFingerprint }]),
|
|
148
|
+
)
|
|
149
|
+
const selectedLayouts = layoutFor({ global, version: desired?.openCodeVersion })
|
|
150
|
+
const report = classifyFileInventory(target, state, {
|
|
151
|
+
desiredFiles,
|
|
152
|
+
selectedComponents: components,
|
|
153
|
+
selectedLayouts,
|
|
154
|
+
})
|
|
155
|
+
return {
|
|
156
|
+
state,
|
|
157
|
+
report,
|
|
158
|
+
desiredFiles,
|
|
159
|
+
selectedLayouts,
|
|
160
|
+
components,
|
|
161
|
+
plan: {
|
|
162
|
+
schema_version: 1,
|
|
163
|
+
command: 'reconcile',
|
|
164
|
+
target: resolve(target),
|
|
165
|
+
selected_components: components,
|
|
166
|
+
selected_layouts: selectedLayouts,
|
|
167
|
+
inventory_fingerprint: report.inventoryFingerprint,
|
|
168
|
+
manual_review: report.manualReview,
|
|
169
|
+
operations: report.operations,
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function safePlanShape(plan) {
|
|
175
|
+
return (
|
|
176
|
+
plan &&
|
|
177
|
+
plan.schema_version === 1 &&
|
|
178
|
+
plan.command === 'reconcile' &&
|
|
179
|
+
typeof plan.target === 'string' &&
|
|
180
|
+
Array.isArray(plan.selected_components) &&
|
|
181
|
+
plan.selected_components.every((component) => VALID_COMPONENTS.includes(component)) &&
|
|
182
|
+
plan.selected_layouts &&
|
|
183
|
+
typeof plan.selected_layouts === 'object' &&
|
|
184
|
+
typeof plan.inventory_fingerprint === 'string' &&
|
|
185
|
+
Array.isArray(plan.operations)
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function savePlan(filePath, plan) {
|
|
190
|
+
const destination = resolve(filePath)
|
|
191
|
+
assertNoSymlinkAncestors(destination)
|
|
192
|
+
const descriptor = openSync(
|
|
193
|
+
destination,
|
|
194
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
|
|
195
|
+
0o600,
|
|
196
|
+
)
|
|
197
|
+
const contents = `${JSON.stringify(plan, null, 2)}\n`
|
|
198
|
+
try {
|
|
199
|
+
writeFileSync(descriptor, contents, 'utf8')
|
|
200
|
+
fsyncSync(descriptor)
|
|
201
|
+
} finally {
|
|
202
|
+
closeSync(descriptor)
|
|
203
|
+
}
|
|
204
|
+
verifyRegularFile(destination, contents)
|
|
205
|
+
return destination
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function authorize(plan, args) {
|
|
209
|
+
const count = plan.operations.length
|
|
210
|
+
console.log(`Reviewed ${count} exact stale-owned file(s) under ${plan.target}:`)
|
|
211
|
+
for (const operation of plan.operations) console.log(` remove ${operation.path}`)
|
|
212
|
+
if (plan.operations.length === 0) return true
|
|
213
|
+
if (args.yes) return true
|
|
214
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
215
|
+
console.error(
|
|
216
|
+
'Apply requested without authorization. Re-run with --apply --yes or --apply-plan FILE --yes.',
|
|
217
|
+
)
|
|
218
|
+
process.exitCode = 2
|
|
219
|
+
return false
|
|
220
|
+
}
|
|
221
|
+
const readline = await import('node:readline/promises')
|
|
222
|
+
const prompt = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
223
|
+
try {
|
|
224
|
+
const answer = await prompt.question('Remove exactly these files? [y/N] ')
|
|
225
|
+
return ['y', 'yes'].includes(answer.trim().toLowerCase())
|
|
226
|
+
} finally {
|
|
227
|
+
prompt.close()
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function printReport(report) {
|
|
232
|
+
console.log('Installer file ownership report:')
|
|
233
|
+
if (report.legacyAmbiguous) {
|
|
234
|
+
console.log(
|
|
235
|
+
' manual-review Legacy files without inventory are unknown and will never be auto-deleted.',
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
for (const entry of report.entries) {
|
|
239
|
+
const suffix = entry.reason ? ` (${entry.reason})` : ''
|
|
240
|
+
console.log(` ${entry.classification.padEnd(14)} ${entry.path}${suffix}`)
|
|
241
|
+
}
|
|
242
|
+
console.log(
|
|
243
|
+
` ${report.operations.length} stale-owned regular file(s) eligible for reviewed removal.`,
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function runApplyPlan(plan, args) {
|
|
248
|
+
if (!safePlanShape(plan) || plan.manual_review === true) {
|
|
249
|
+
throw new Error('Reviewed reconcile plan is invalid or marked for manual review')
|
|
250
|
+
}
|
|
251
|
+
const target = resolve(plan.target)
|
|
252
|
+
const components = plan.selected_components
|
|
253
|
+
const review = await buildReview(target, components, plan.selected_layouts?.agents === 'global')
|
|
254
|
+
if (JSON.stringify(review.plan.operations) !== JSON.stringify(plan.operations)) {
|
|
255
|
+
throw new Error('Reviewed reconcile operation set drifted; no files were removed')
|
|
256
|
+
}
|
|
257
|
+
const reviewed = {
|
|
258
|
+
target,
|
|
259
|
+
operations: plan.operations,
|
|
260
|
+
inventoryFingerprint: plan.inventory_fingerprint,
|
|
261
|
+
manualReview: plan.manual_review,
|
|
262
|
+
}
|
|
263
|
+
if (!(await authorize(reviewed, args))) return
|
|
264
|
+
const result = applyFileDriftPlan(target, review.state, reviewed, {
|
|
265
|
+
desiredFiles: review.desiredFiles,
|
|
266
|
+
selectedComponents: components,
|
|
267
|
+
selectedLayouts: review.selectedLayouts,
|
|
268
|
+
})
|
|
269
|
+
console.log(`Removed ${result.removed.length} file(s).`)
|
|
270
|
+
if (result.backupDir) console.log(`Restorable backup: ${result.backupDir}`)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
274
|
+
let args
|
|
275
|
+
try {
|
|
276
|
+
args = parseArgs(argv)
|
|
277
|
+
} catch (error) {
|
|
278
|
+
console.error(`❌ ${error.message}`)
|
|
279
|
+
process.exitCode = 1
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
if (args.help) return printHelp()
|
|
283
|
+
|
|
284
|
+
if (args.applyPlan) {
|
|
285
|
+
try {
|
|
286
|
+
const plan = JSON.parse(readFileSync(resolve(args.applyPlan), 'utf8'))
|
|
287
|
+
await runApplyPlan(plan, args)
|
|
288
|
+
} catch (error) {
|
|
289
|
+
console.error(`❌ ${error.message}`)
|
|
290
|
+
process.exitCode = 3
|
|
291
|
+
}
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const target = resolveTarget(args)
|
|
296
|
+
const components = args.components ?? DEFAULT_COMPONENTS
|
|
297
|
+
try {
|
|
298
|
+
const review = await buildReview(target, components, args.global)
|
|
299
|
+
printReport(review.report)
|
|
300
|
+
if (args.savePlan) {
|
|
301
|
+
const savedPath = savePlan(args.savePlan, review.plan)
|
|
302
|
+
console.log(`Reviewed plan saved: ${savedPath}`)
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
if (!args.apply) {
|
|
306
|
+
console.log('Preview only; no files were written or removed.')
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
if (!(await authorize(review.plan, args))) return
|
|
310
|
+
const result = applyFileDriftPlan(target, review.state, review.report, {
|
|
311
|
+
desiredFiles: review.desiredFiles,
|
|
312
|
+
selectedComponents: components,
|
|
313
|
+
selectedLayouts: review.selectedLayouts,
|
|
314
|
+
})
|
|
315
|
+
console.log(`Removed ${result.removed.length} file(s).`)
|
|
316
|
+
if (result.backupDir) console.log(`Restorable backup: ${result.backupDir}`)
|
|
317
|
+
} catch (error) {
|
|
318
|
+
console.error(`❌ ${error.message}`)
|
|
319
|
+
process.exitCode = 1
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
324
|
+
await main()
|
|
325
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* restore.mjs — Pantheon exact-restore CLI (Beta 11 Setup, phase 2).
|
|
4
|
+
*
|
|
5
|
+
* Restores a backup set created by the installer back into the config dir,
|
|
6
|
+
* reproducing each file byte-for-byte and re-applying its recorded mode. The
|
|
7
|
+
* restore is itself reversible: the CURRENT state is backed up first.
|
|
8
|
+
*
|
|
9
|
+
* Safety contract:
|
|
10
|
+
* - the backup manifest is verified (hash of every backed-up file) before any
|
|
11
|
+
* restore; a tampered or incomplete set is refused outright;
|
|
12
|
+
* - the backup-vs-current diff is shown BY PATH ONLY (never file contents);
|
|
13
|
+
* - writing requires explicit authorization (`--yes`); a non-TTY run without
|
|
14
|
+
* `--yes` previews and exits without writing.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* pantheon-opencode restore [--project] [--from <dir>] [--dry-run] [--yes]
|
|
18
|
+
*
|
|
19
|
+
* Exit codes:
|
|
20
|
+
* 0 = restored (or preview shown with --dry-run)
|
|
21
|
+
* 1 = refused (missing/tampered backup, or no authorization)
|
|
22
|
+
* 2 = usage error
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { existsSync } from 'node:fs'
|
|
26
|
+
import { homedir } from 'node:os'
|
|
27
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
backupRoot,
|
|
31
|
+
createBackupSet,
|
|
32
|
+
isBackupDirName,
|
|
33
|
+
listBackupSets,
|
|
34
|
+
planRestore,
|
|
35
|
+
restoreBackupSet,
|
|
36
|
+
verifyBackupSet,
|
|
37
|
+
} from './install/backup.mjs'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the config dir whose `opencode.json` the installer manages.
|
|
41
|
+
* `--project` targets the CWD; otherwise XDG / PANTHEON_HOME.
|
|
42
|
+
* @param {{ project?: boolean }} opts
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
export function resolveConfigDir({ project = false } = {}) {
|
|
46
|
+
if (project) return resolve(process.cwd())
|
|
47
|
+
if (process.env.PANTHEON_HOME) return resolve(process.env.PANTHEON_HOME)
|
|
48
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config')
|
|
49
|
+
return join(base, 'opencode')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseArgs(argv) {
|
|
53
|
+
const args = { project: false, from: null, dryRun: false, yes: false, help: false }
|
|
54
|
+
for (let i = 0; i < argv.length; i++) {
|
|
55
|
+
const arg = argv[i]
|
|
56
|
+
switch (arg) {
|
|
57
|
+
case '--project':
|
|
58
|
+
args.project = true
|
|
59
|
+
break
|
|
60
|
+
case '--dry-run':
|
|
61
|
+
args.dryRun = true
|
|
62
|
+
break
|
|
63
|
+
case '--yes':
|
|
64
|
+
case '-y':
|
|
65
|
+
args.yes = true
|
|
66
|
+
break
|
|
67
|
+
case '--from':
|
|
68
|
+
args.from = argv[++i] ?? null
|
|
69
|
+
break
|
|
70
|
+
case '--help':
|
|
71
|
+
case '-h':
|
|
72
|
+
args.help = true
|
|
73
|
+
break
|
|
74
|
+
default:
|
|
75
|
+
break
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return args
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function showHelp() {
|
|
82
|
+
console.log(`
|
|
83
|
+
pantheon-opencode restore — restore an exact installer backup
|
|
84
|
+
|
|
85
|
+
Usage:
|
|
86
|
+
pantheon-opencode restore restore newest backup (global config)
|
|
87
|
+
pantheon-opencode restore --project restore newest backup (project)
|
|
88
|
+
pantheon-opencode restore --from <dir> restore a specific backup set
|
|
89
|
+
pantheon-opencode restore --dry-run preview only (writes nothing)
|
|
90
|
+
pantheon-opencode restore --yes authorize the write (non-interactive)
|
|
91
|
+
|
|
92
|
+
A backup set lives under <config-dir>/.pantheon/backups/<timestamp>/.
|
|
93
|
+
Restore reproduces each file byte-for-byte and re-applies its recorded mode.
|
|
94
|
+
File contents are never printed.
|
|
95
|
+
`)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Choose the backup set to restore.
|
|
100
|
+
* @param {string} configDir
|
|
101
|
+
* @param {string|null} from
|
|
102
|
+
* @returns {{ dir: string, name: string } | null}
|
|
103
|
+
*/
|
|
104
|
+
export function pickBackupSet(configDir, from) {
|
|
105
|
+
if (from) {
|
|
106
|
+
// Accept either a bare timestamp set name (resolved under the backups root)
|
|
107
|
+
// or an explicit directory path.
|
|
108
|
+
if (isBackupDirName(from)) {
|
|
109
|
+
const dir = join(backupRoot(configDir), from)
|
|
110
|
+
return existsSync(dir) ? { dir, name: from } : null
|
|
111
|
+
}
|
|
112
|
+
const dir = isAbsolute(from) ? from : resolve(process.cwd(), from)
|
|
113
|
+
if (!existsSync(dir)) return null
|
|
114
|
+
return { dir, name: dir.split(/[\\/]/).pop() }
|
|
115
|
+
}
|
|
116
|
+
return listBackupSets(configDir)[0] ?? null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param {object} opts
|
|
121
|
+
* @param {string} opts.configDir
|
|
122
|
+
* @param {string} opts.backupDir
|
|
123
|
+
* @param {boolean} [opts.dryRun]
|
|
124
|
+
* @param {boolean} [opts.yes]
|
|
125
|
+
* @returns {Promise<{ ok: boolean, code: number, entries?: object[] }>}
|
|
126
|
+
*/
|
|
127
|
+
export async function runRestore({ configDir, backupDir, dryRun = false, yes = false }) {
|
|
128
|
+
const verify = verifyBackupSet(backupDir)
|
|
129
|
+
if (!verify.ok) {
|
|
130
|
+
console.error(
|
|
131
|
+
`❌ Refusing to restore: backup failed integrity verification (${verify.reason ?? 'hash mismatch'}).`,
|
|
132
|
+
)
|
|
133
|
+
return { ok: false, code: 1 }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const plan = planRestore(backupDir, configDir)
|
|
137
|
+
if (plan.error) {
|
|
138
|
+
console.error(`❌ Refusing to restore: ${plan.error}.`)
|
|
139
|
+
return { ok: false, code: 1 }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const counts = { create: 0, update: 0, remove: 0, unchanged: 0 }
|
|
143
|
+
for (const entry of plan.entries) counts[entry.action] += 1
|
|
144
|
+
|
|
145
|
+
console.log('')
|
|
146
|
+
console.log(`Restore plan — ${configDir}`)
|
|
147
|
+
console.log(` from ${backupDir}`)
|
|
148
|
+
for (const entry of plan.entries) {
|
|
149
|
+
if (entry.action === 'unchanged') continue
|
|
150
|
+
console.log(` ${entry.action.padEnd(9)} ${entry.relativePath}`)
|
|
151
|
+
}
|
|
152
|
+
console.log(
|
|
153
|
+
` ${counts.create} create · ${counts.update} update · ${counts.remove} remove · ${counts.unchanged} unchanged`,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if (dryRun) {
|
|
157
|
+
console.log(' (dry-run) No files were written.')
|
|
158
|
+
return { ok: true, code: 0, entries: plan.entries }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!yes) {
|
|
162
|
+
// Reuse the shared confirm semantics (default NO). An interactive TTY may
|
|
163
|
+
// authorize without --yes; a non-TTY run must never proceed silently.
|
|
164
|
+
const tty = Boolean(process.stdin.isTTY && process.stdout.isTTY)
|
|
165
|
+
if (!tty) {
|
|
166
|
+
console.error('❌ Refusing to restore without authorization. Re-run with --yes.')
|
|
167
|
+
return { ok: false, code: 1 }
|
|
168
|
+
}
|
|
169
|
+
const { promptReview } = await import('./install/interactive.mjs')
|
|
170
|
+
const accepted = await promptReview('Restore these changes?', { default: false })
|
|
171
|
+
if (!accepted) {
|
|
172
|
+
console.log(' Nothing restored.')
|
|
173
|
+
return { ok: true, code: 0, entries: plan.entries }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Reversible: snapshot the CURRENT state of exactly the affected paths first.
|
|
178
|
+
// Retention is DISABLED here (`keep: 0`): a retention pass at this point could
|
|
179
|
+
// delete the very set being restored once 5 older sets already exist (the new
|
|
180
|
+
// snapshot would be the 6th, and pruning would drop the oldest two — including
|
|
181
|
+
// the source). Apply still prunes; restore never deletes backups.
|
|
182
|
+
const { dir: preDir } = createBackupSet({
|
|
183
|
+
configDir,
|
|
184
|
+
entries: plan.manifest.entries.map((e) => e.relativePath),
|
|
185
|
+
packageVersion: plan.manifest.packageVersion,
|
|
186
|
+
keep: 0,
|
|
187
|
+
})
|
|
188
|
+
console.log(` pre-restore backup: ${preDir}`)
|
|
189
|
+
|
|
190
|
+
restoreBackupSet(backupDir, configDir, { dryRun: false })
|
|
191
|
+
console.log(` ✅ Restored ${plan.entries.length} path(s).`)
|
|
192
|
+
return { ok: true, code: 0, entries: plan.entries }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function main() {
|
|
196
|
+
const args = parseArgs(process.argv.slice(2))
|
|
197
|
+
if (args.help) {
|
|
198
|
+
showHelp()
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
const configDir = resolveConfigDir({ project: args.project })
|
|
202
|
+
const backup = pickBackupSet(configDir, args.from)
|
|
203
|
+
if (!backup) {
|
|
204
|
+
console.error(`❌ No backup set found under ${backupRoot(configDir)}`)
|
|
205
|
+
process.exitCode = 1
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
const result = await runRestore({
|
|
209
|
+
configDir,
|
|
210
|
+
backupDir: backup.dir,
|
|
211
|
+
dryRun: args.dryRun,
|
|
212
|
+
yes: args.yes,
|
|
213
|
+
})
|
|
214
|
+
if (!result.ok) process.exitCode = result.code
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
218
|
+
main().catch((err) => {
|
|
219
|
+
console.error(`❌ Restore failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
220
|
+
process.exitCode = 1
|
|
221
|
+
})
|
|
222
|
+
}
|