pantheon-opencode 1.6.0-beta.1 → 1.6.0-beta.11
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 +206 -0
- package/AGENTS.md +10 -891
- package/CHANGELOG.md +350 -0
- package/README.md +561 -52
- package/README.pt-BR.md +285 -20
- package/bin/pantheon-init.mjs +70 -4
- package/commands/pantheon-audit.md +7 -1
- package/commands/pantheon-consolidate.md +1 -1
- package/commands/pantheon-deepwork.md +14 -3
- package/commands/pantheon-focus.md +17 -0
- package/commands/pantheon-vision.md +61 -12
- package/package.json +24 -17
- package/plugin.json +2 -2
- package/pyproject.toml +8 -6
- package/scripts/build-agents-md.mjs +428 -56
- package/scripts/changelog-merge.mjs +161 -0
- package/scripts/{code_mode_server.py → code_mode.py} +3 -3
- package/scripts/cost.mjs +244 -41
- package/scripts/doctor.mjs +151 -1
- package/scripts/install/agents-md.mjs +4 -4
- package/scripts/install/backup.mjs +475 -0
- package/scripts/install/cli-ui.mjs +6 -1
- package/scripts/install/config-migration.mjs +317 -4
- package/scripts/install/exit-codes.mjs +26 -0
- package/scripts/install/health-check.mjs +21 -19
- package/scripts/install/interactive.mjs +16 -2
- package/scripts/install/migrate.mjs +41 -22
- package/scripts/install/opencode-version.mjs +246 -4
- package/scripts/install/opencode.mjs +1175 -215
- package/scripts/install/plan.mjs +287 -0
- package/scripts/install/plugin.mjs +62 -14
- package/scripts/install/shared.mjs +190 -48
- package/scripts/install/strings.mjs +20 -0
- package/scripts/install/venv.mjs +13 -3
- package/scripts/install/vision-prompt.mjs +171 -0
- package/scripts/install-mcp.mjs +28 -9
- package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
- package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
- package/scripts/{memory_mcp_server.py → memory_mcp.py} +2 -2
- package/scripts/probe-context-rehydrate.mjs +8 -2
- package/scripts/probe-pantheon-cost.mjs +71 -15
- package/scripts/prune.mjs +9 -3
- package/scripts/restore.mjs +222 -0
- package/scripts/run-node-tests.mjs +189 -0
- package/scripts/sync-artifacts.mjs +26 -13
- package/scripts/sync-tui.mjs +24 -3
- package/scripts/test-opencode-v2-sandbox.sh +180 -26
- package/scripts/validate-routing.mjs +3 -3
- package/scripts/validate-sandbox-install.mjs +138 -0
- package/scripts/versioning.mjs +13 -1
- package/scripts/vision-status.mjs +682 -0
- package/skills-lock.json +8 -8
- package/src/agents/aphrodite.md +20 -37
- package/src/agents/apollo.md +7 -3
- package/src/agents/athena.md +63 -35
- package/src/agents/demeter.md +8 -28
- package/src/agents/gaia.md +1 -1
- package/src/agents/hephaestus.md +10 -21
- package/src/agents/hermes.md +15 -38
- package/src/agents/iris.md +7 -1
- package/src/agents/mnemosyne.md +18 -54
- package/src/agents/nyx.md +1 -1
- package/src/agents/prometheus.md +16 -20
- package/src/agents/talos.md +10 -2
- package/src/agents/themis.md +5 -5
- package/src/agents/zeus.md +99 -172
- package/src/instructions/agent-return-format.instructions.md +34 -17
- package/src/instructions/backend-standards.instructions.md +2 -0
- package/src/instructions/frontend-standards.instructions.md +3 -1
- package/src/instructions/memory-protocol.instructions.md +12 -110
- package/src/instructions/test-economy.instructions.md +21 -0
- package/src/instructions/zeus-anti-stall.instructions.md +20 -67
- package/src/instructions/zeus-communication-rules.instructions.md +0 -1
- package/src/instructions/zeus-council-synthesis.instructions.md +31 -260
- package/src/instructions/zeus-memory-operations.instructions.md +30 -0
- package/src/instructions/zeus-timeout-retry.instructions.md +61 -89
- package/src/mcp/{code_mode_server.py → code_mode.py} +3 -3
- package/src/mcp/eval_store.py +2 -2
- package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +52 -17
- package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
- package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +2 -2
- package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +286 -42
- package/src/mcp/requirements-mcp.txt +6 -4
- package/src/mcp/requirements-vision.txt +2 -2
- package/src/pantheon/cost-command.ts +403 -102
- package/src/pantheon/delegation-enforce.ts +10 -1
- package/src/pantheon/model-agents.ts +19 -0
- package/src/pantheon/model-command.ts +218 -56
- package/src/pantheon/presets.d.mts +0 -8
- package/src/pantheon/presets.mjs +5 -32
- package/src/pantheon/v2-bounded-read.ts +640 -0
- package/src/pantheon/v2-delegation-enforce.ts +214 -0
- package/src/pantheon/v2-execute-after.ts +186 -0
- package/src/pantheon/v2-model-overrides.ts +275 -0
- package/src/pantheon/v2-tools.ts +418 -0
- package/src/pantheon/v2-unsupported.d.mts +11 -0
- package/src/pantheon/v2-unsupported.mjs +90 -0
- package/src/pantheon/v2-vision.ts +622 -0
- package/src/pantheon/vision-local.ts +531 -0
- package/src/pantheon/vision.ts +1160 -115
- package/src/plugin-v2.ts +1158 -219
- package/src/plugin.ts +12 -9
- package/src/plugins/tui/dist/server.d.ts +1 -2
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
- package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
- package/src/plugins/tui/dist/tui-v2.js +659 -0
- package/src/plugins/tui/dist/tui-v2.js.map +1 -0
- package/src/plugins/tui/dist/tui.d.ts +85 -82
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +72 -59
- package/src/plugins/tui/index-v2.ts +6 -0
- package/src/plugins/tui/package-lock.json +5516 -1353
- package/src/plugins/tui/package.json +22 -12
- package/src/plugins/tui/src/index-v2.tsx +734 -0
- package/src/plugins/tui/src/index.tsx +106 -85
- package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
- package/src/plugins/tui/tsconfig.build.json +5 -0
- package/src/plugins/tui/tsconfig.typecheck.json +5 -0
- package/src/plugins/tui/tsdown.config.ts +22 -2
- package/src/plugins/tui/tui-v2.ts +2 -0
- package/src/plugins/tui/tui.ts +3 -0
- package/src/routing.yml +11 -21
- package/src/skills/README.md +3 -3
- package/src/skills/agent-coordination/SKILL.md +7 -2
- package/src/skills/artifact-management/SKILL.md +12 -5
- package/src/skills/auto-continue/SKILL.md +52 -35
- package/src/skills/code-review-checklist/SKILL.md +5 -5
- package/src/skills/context-compression/SKILL.md +26 -879
- package/src/skills/session-goal/SKILL.md +14 -5
- package/src/skills/spec-driven-development/SKILL.md +3 -1
- package/src/skills/tdd-with-agents/SKILL.md +18 -10
- package/opencode.json +0 -58
package/scripts/doctor.mjs
CHANGED
|
@@ -29,6 +29,8 @@ import { homedir } from 'node:os'
|
|
|
29
29
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
|
30
30
|
import { fileURLToPath } from 'node:url'
|
|
31
31
|
|
|
32
|
+
import { listV2UnsupportedFeatures } from '../src/pantheon/v2-unsupported.mjs'
|
|
33
|
+
|
|
32
34
|
// ---------------------------------------------------------------------------
|
|
33
35
|
// Paths
|
|
34
36
|
// ---------------------------------------------------------------------------
|
|
@@ -1000,7 +1002,7 @@ function checkVenvLayer(args) {
|
|
|
1000
1002
|
/**
|
|
1001
1003
|
* Return whether *dir* contains an executable code-mode target.
|
|
1002
1004
|
*
|
|
1003
|
-
* Mirrors `_has_usable_scripts` in `scripts/
|
|
1005
|
+
* Mirrors `_has_usable_scripts` in `scripts/code_mode.py`: a directory
|
|
1004
1006
|
* is only a valid resolver candidate when it is a real (non-symlink) directory
|
|
1005
1007
|
* containing a regular `.py`/`.sh` direct child. This prevents an empty project
|
|
1006
1008
|
* overlay from masking a lower-priority installation that actually ships
|
|
@@ -1430,6 +1432,153 @@ export function checkPluginVersionDrift(args) {
|
|
|
1430
1432
|
return 'sync'
|
|
1431
1433
|
}
|
|
1432
1434
|
|
|
1435
|
+
// ---------------------------------------------------------------------------
|
|
1436
|
+
// Check H4: V2 feature reduction (the surface a migrating user loses)
|
|
1437
|
+
// ---------------------------------------------------------------------------
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Whether an opencode.json registers the V2 generation of the Pantheon plugin.
|
|
1441
|
+
*
|
|
1442
|
+
* The V2 contract is the PLURAL `plugins` key; V1 is the singular `plugin` key.
|
|
1443
|
+
* So the key alone is not the test — a config can legitimately carry a
|
|
1444
|
+
* third-party entry in `plugins`. What identifies the generation is a Pantheon
|
|
1445
|
+
* V2 entry inside it. Matched on the `plugin-v2` marker so all three real
|
|
1446
|
+
* spellings are recognised: the canonical DIRECTORY (`src/plugin-v2`), an
|
|
1447
|
+
* absolute path to it, and the package export (`pantheon-opencode/plugin-v2`).
|
|
1448
|
+
*
|
|
1449
|
+
* @param {unknown} ref
|
|
1450
|
+
* @returns {boolean}
|
|
1451
|
+
*/
|
|
1452
|
+
function isV2PluginRef(ref) {
|
|
1453
|
+
const candidate =
|
|
1454
|
+
typeof ref === 'string'
|
|
1455
|
+
? ref
|
|
1456
|
+
: ref && typeof ref === 'object'
|
|
1457
|
+
? (ref.path ?? ref.source ?? ref.id ?? ref.name)
|
|
1458
|
+
: undefined
|
|
1459
|
+
return typeof candidate === 'string' && candidate.includes('plugin-v2')
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/**
|
|
1463
|
+
* Whether any reachable config registers the V2 generation of the Pantheon
|
|
1464
|
+
* plugin. Split out of {@link collectV2UnsupportedFeatures} because "is the V2
|
|
1465
|
+
* generation registered" and "does the seed list anything" are TWO facts: the
|
|
1466
|
+
* first is about the user's config, the second about this repository's seed. A
|
|
1467
|
+
* single `features.length === 0` cannot tell them apart, and conflating them
|
|
1468
|
+
* let an emptied seed print "no V2 generation registered — nothing reduced".
|
|
1469
|
+
*
|
|
1470
|
+
* @param {{path:string, data:object}[]} configs
|
|
1471
|
+
* @returns {boolean}
|
|
1472
|
+
*/
|
|
1473
|
+
export function isV2GenerationRegistered(configs) {
|
|
1474
|
+
return configs.some((cfg) => {
|
|
1475
|
+
const list = cfg.data?.plugins
|
|
1476
|
+
return Array.isArray(list) && list.some(isV2PluginRef)
|
|
1477
|
+
})
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/**
|
|
1481
|
+
* The V2 feature-reduction markers to report for a set of collected configs.
|
|
1482
|
+
*
|
|
1483
|
+
* Returns the shared seed verbatim when ANY reachable config registers the V2
|
|
1484
|
+
* generation, and an empty list otherwise. The strings come from
|
|
1485
|
+
* `src/pantheon/v2-unsupported.mjs` — the same seed `getUnsupportedFeatures()`
|
|
1486
|
+
* is built from — so doctor cannot report a reduction the plugin does not, or
|
|
1487
|
+
* miss one it does. Nothing is reworded here.
|
|
1488
|
+
*
|
|
1489
|
+
* An empty result is ambiguous on its own and deliberately not interpreted
|
|
1490
|
+
* here: ask {@link isV2GenerationRegistered} for the registration fact, or
|
|
1491
|
+
* {@link describeV2Reduction} to get both at once.
|
|
1492
|
+
*
|
|
1493
|
+
* @param {{path:string, data:object}[]} configs
|
|
1494
|
+
* @returns {string[]}
|
|
1495
|
+
*/
|
|
1496
|
+
export function collectV2UnsupportedFeatures(configs) {
|
|
1497
|
+
return isV2GenerationRegistered(configs) ? listV2UnsupportedFeatures() : []
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/**
|
|
1501
|
+
* Decide what H4 prints, from the two distinct facts rather than from one list
|
|
1502
|
+
* length.
|
|
1503
|
+
*
|
|
1504
|
+
* Three cases, and the middle one used to be silently reported as the first:
|
|
1505
|
+
*
|
|
1506
|
+
* - not registered → info. A V1 install is a supported configuration with
|
|
1507
|
+
* nothing to reduce, and saying so plainly is correct.
|
|
1508
|
+
* - registered, seed EMPTY → warn. This is a packaging fault in THIS repo,
|
|
1509
|
+
* not an absence of reduction: the V2 surface really is reduced and the
|
|
1510
|
+
* report is missing. It must never claim "nothing reduced".
|
|
1511
|
+
* - registered, seed populated → warn, listing every marker.
|
|
1512
|
+
*
|
|
1513
|
+
* Pure and exported so the empty-seed branch is testable without stubbing the
|
|
1514
|
+
* frozen seed module. `status` is returned alongside the text so a caller never
|
|
1515
|
+
* has to recover the case by matching on the message.
|
|
1516
|
+
*
|
|
1517
|
+
* @param {{registered:boolean, features:string[]}} input
|
|
1518
|
+
* @returns {{status:'reported'|'empty-seed'|'no-v2-generation', level:'info'|'warn', message:string}}
|
|
1519
|
+
*/
|
|
1520
|
+
export function describeV2Reduction({ registered, features }) {
|
|
1521
|
+
if (!registered) {
|
|
1522
|
+
return {
|
|
1523
|
+
status: 'no-v2-generation',
|
|
1524
|
+
level: 'info',
|
|
1525
|
+
message: 'No V2 plugin generation registered — nothing reduced',
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
if (features.length === 0) {
|
|
1529
|
+
return {
|
|
1530
|
+
status: 'empty-seed',
|
|
1531
|
+
level: 'warn',
|
|
1532
|
+
message:
|
|
1533
|
+
'Pantheon IS registered on the V2 plugin generation, but the shared ' +
|
|
1534
|
+
'unsupported-feature seed is empty, so the reduction cannot be reported. ' +
|
|
1535
|
+
"That is a fault in this installation's reporting, not an absence of " +
|
|
1536
|
+
'reduction: the V2 surface still drops the 3 goal tools and the ' +
|
|
1537
|
+
'caller/target delegation matrix.',
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
return {
|
|
1541
|
+
status: 'reported',
|
|
1542
|
+
level: 'warn',
|
|
1543
|
+
message:
|
|
1544
|
+
'Pantheon is registered on the V2 plugin generation, which registers 3 tools ' +
|
|
1545
|
+
"(hashline_edit, pantheon_cost, pantheon_model) instead of the V1 surface's 6 plus " +
|
|
1546
|
+
`the BackgroundJobBoard and the caller/target delegation matrix. Unsupported: ${features.join(', ')}`,
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* Report the V2 feature reduction through doctor's own output shape
|
|
1552
|
+
* (section + warn/info), so it lands in the health summary like every other
|
|
1553
|
+
* finding and never changes the exit code: a V2 install is a supported
|
|
1554
|
+
* configuration, not a broken one.
|
|
1555
|
+
*
|
|
1556
|
+
* @param {{target:string, env?:object}} args
|
|
1557
|
+
* @returns {'reported'|'empty-seed'|'no-v2-generation'|'no-config'}
|
|
1558
|
+
*/
|
|
1559
|
+
export function checkV2UnsupportedFeatures(args) {
|
|
1560
|
+
section('H4. V2 Feature Reduction')
|
|
1561
|
+
|
|
1562
|
+
const configs = collectMcpConfigs(args)
|
|
1563
|
+
if (configs.length === 0) {
|
|
1564
|
+
info('No opencode.json was found — V2 feature reduction check skipped')
|
|
1565
|
+
return 'no-config'
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// Branch on REGISTRATION, not on the marker count: an empty seed on a
|
|
1569
|
+
// registered V2 generation is a reporting fault and must not be reported as
|
|
1570
|
+
// the reassuring "nothing reduced".
|
|
1571
|
+
const registered = isV2GenerationRegistered(configs)
|
|
1572
|
+
const verdict = describeV2Reduction({
|
|
1573
|
+
registered,
|
|
1574
|
+
features: collectV2UnsupportedFeatures(configs),
|
|
1575
|
+
})
|
|
1576
|
+
if (verdict.level === 'info') info(verdict.message)
|
|
1577
|
+
else warn(verdict.message)
|
|
1578
|
+
|
|
1579
|
+
return verdict.status
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1433
1582
|
// ---------------------------------------------------------------------------
|
|
1434
1583
|
// Check G: AGENTS.md freshness
|
|
1435
1584
|
// ---------------------------------------------------------------------------
|
|
@@ -1802,6 +1951,7 @@ async function main() {
|
|
|
1802
1951
|
checkAgentsMdFreshness(args)
|
|
1803
1952
|
checkSubagentDepthPlacement(args)
|
|
1804
1953
|
checkPermissionTaskPresence(args)
|
|
1954
|
+
checkV2UnsupportedFeatures(args)
|
|
1805
1955
|
|
|
1806
1956
|
printSummary(args.target)
|
|
1807
1957
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* agents-md.mjs — Dynamic AGENTS.md generator from canonical agent frontmatter
|
|
4
4
|
*
|
|
5
|
-
* Reads all agents/*.
|
|
5
|
+
* Reads all canonical src/agents/*.md files, extracts name + description from frontmatter,
|
|
6
6
|
* and generates AGENTS.md content dynamically. Eliminates hardcoded agent tables
|
|
7
7
|
* in platform installers (claude.mjs, cursor.mjs, windsurf.mjs, cline.mjs, etc.).
|
|
8
8
|
*
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
22
22
|
import { dirname, join } from 'node:path'
|
|
23
23
|
import { fileURLToPath } from 'node:url'
|
|
24
|
-
import
|
|
24
|
+
import { load } from 'js-yaml'
|
|
25
25
|
|
|
26
26
|
// ---------------------------------------------------------------------------
|
|
27
27
|
// Paths
|
|
@@ -47,7 +47,7 @@ function parseAgentFrontmatter(content) {
|
|
|
47
47
|
if (!match) return null
|
|
48
48
|
|
|
49
49
|
try {
|
|
50
|
-
const fm =
|
|
50
|
+
const fm = load(match[1])
|
|
51
51
|
if (!fm || typeof fm !== 'object' || !fm.name) return null
|
|
52
52
|
return {
|
|
53
53
|
name: String(fm.name),
|
|
@@ -64,7 +64,7 @@ function parseAgentFrontmatter(content) {
|
|
|
64
64
|
// ---------------------------------------------------------------------------
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
|
-
* Read all canonical agents/*.
|
|
67
|
+
* Read all canonical src/agents/*.md files, parse frontmatter,
|
|
68
68
|
* and return an array of agent metadata objects sorted alphabetically by name.
|
|
69
69
|
*
|
|
70
70
|
* Skips files that don't match the `*.agent.md` pattern, files without
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* backup.mjs — Exact backup sets for the Pantheon installer (Beta 11 Setup,
|
|
3
|
+
* phase 2).
|
|
4
|
+
*
|
|
5
|
+
* Before the installer modifies or creates a file, the CURRENT state of every
|
|
6
|
+
* affected path is preserved byte-for-byte under
|
|
7
|
+
* `<configDir>/.pantheon/backups/<UTC-timestamp>/`, mirroring the path relative
|
|
8
|
+
* to `configDir`, and described by a `manifest.json`. Files that do not exist
|
|
9
|
+
* yet are recorded as absent so a restore can reproduce the original state
|
|
10
|
+
* exactly.
|
|
11
|
+
*
|
|
12
|
+
* SECURITY
|
|
13
|
+
* --------
|
|
14
|
+
* A backup is an exact copy, so it can contain API keys or tokens that were
|
|
15
|
+
* already in the user's files. This module therefore:
|
|
16
|
+
* - never prints file contents, values or hashes (only paths / counts / modes);
|
|
17
|
+
* - writes the backup directory `0700` and every file `0600`.
|
|
18
|
+
* Windows is best-effort: `chmodSync` is attempted and a failure never aborts.
|
|
19
|
+
*
|
|
20
|
+
* SCOPE
|
|
21
|
+
* -----
|
|
22
|
+
* IN scope (byte-for-byte, restorable): every regular file the install can
|
|
23
|
+
* modify, rewrite, or remove INSIDE the config dir — including stale files that
|
|
24
|
+
* a `--clean` run deletes.
|
|
25
|
+
* OUT of scope (recorded in `manifest.notRevertible`, never restored): bulk
|
|
26
|
+
* directories whose byte-for-byte copy is not feasible (`.venv`, the plugin
|
|
27
|
+
* dir with its `node_modules`) and files OUTSIDE the config dir (the global
|
|
28
|
+
* `tui.json` copies and the global `opencode-vision.json`). The manifest states
|
|
29
|
+
* these explicitly rather than silently omitting them.
|
|
30
|
+
*
|
|
31
|
+
* The functions are `pure-ish`: they take explicit paths and an injectable
|
|
32
|
+
* `clock`, return data (never logging), and are safe to unit-test without a
|
|
33
|
+
* running installer.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { createHash } from 'node:crypto'
|
|
37
|
+
import {
|
|
38
|
+
chmodSync,
|
|
39
|
+
existsSync,
|
|
40
|
+
lstatSync,
|
|
41
|
+
mkdirSync,
|
|
42
|
+
readdirSync,
|
|
43
|
+
readFileSync,
|
|
44
|
+
rmSync,
|
|
45
|
+
statSync,
|
|
46
|
+
writeFileSync,
|
|
47
|
+
} from 'node:fs'
|
|
48
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
49
|
+
|
|
50
|
+
/** Directory (relative to the config dir) that holds the backup sets. */
|
|
51
|
+
export const BACKUP_SUBDIR = join('.pantheon', 'backups')
|
|
52
|
+
|
|
53
|
+
/** Number of most-recent backup sets retained. */
|
|
54
|
+
export const DEFAULT_KEEP = 5
|
|
55
|
+
|
|
56
|
+
/** UTC timestamp directory name: `YYYY-MM-DDTHH-MM-SSZ` (filesystem-safe). */
|
|
57
|
+
const TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z$/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Format a Date as the backup-directory timestamp.
|
|
61
|
+
* @param {Date} date
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
export function formatTimestamp(date = new Date()) {
|
|
65
|
+
return date
|
|
66
|
+
.toISOString()
|
|
67
|
+
.replace(/\.\d+Z$/, 'Z')
|
|
68
|
+
.replace(/:/g, '-')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether a directory name is one of our timestamped backup sets. Used as the
|
|
73
|
+
* guard so retention can never delete a directory we did not create.
|
|
74
|
+
* @param {string} name
|
|
75
|
+
* @returns {boolean}
|
|
76
|
+
*/
|
|
77
|
+
export function isBackupDirName(name) {
|
|
78
|
+
return typeof name === 'string' && TIMESTAMP_RE.test(name)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Absolute path of the backups root for a config dir. */
|
|
82
|
+
export function backupRoot(configDir) {
|
|
83
|
+
return join(configDir, BACKUP_SUBDIR)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Absolute path of a named backup set. */
|
|
87
|
+
export function backupSetPath(configDir, name) {
|
|
88
|
+
return join(backupRoot(configDir), name)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whether `candidate` is a direct child of `root` (no traversal). Pure string
|
|
93
|
+
* check — the retention path guard.
|
|
94
|
+
* @param {string} root
|
|
95
|
+
* @param {string} candidate
|
|
96
|
+
* @returns {boolean}
|
|
97
|
+
*/
|
|
98
|
+
export function isWithin(root, candidate) {
|
|
99
|
+
const rel = relative(resolve(root), resolve(candidate))
|
|
100
|
+
if (rel === '') return false
|
|
101
|
+
return (
|
|
102
|
+
!rel.startsWith('..') && !rel.split(sep).includes('..') && !resolve(candidate).startsWith('..')
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Guard for a deletable backup set: it must live directly under
|
|
108
|
+
* `<configDir>/.pantheon/backups/` AND its name must match the timestamp
|
|
109
|
+
* pattern. Nothing else may ever be removed by retention.
|
|
110
|
+
* @param {string} configDir
|
|
111
|
+
* @param {string} dir
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
export function isDeletableBackupDir(configDir, dir) {
|
|
115
|
+
const root = backupRoot(configDir)
|
|
116
|
+
if (!isWithin(root, dir)) return false
|
|
117
|
+
const rel = relative(resolve(root), resolve(dir))
|
|
118
|
+
return isBackupDirName(rel)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** List backup sets, newest first. */
|
|
122
|
+
export function listBackupSets(configDir) {
|
|
123
|
+
const root = backupRoot(configDir)
|
|
124
|
+
if (!existsSync(root)) return []
|
|
125
|
+
const sets = []
|
|
126
|
+
for (const name of readdirSync(root)) {
|
|
127
|
+
if (!isBackupDirName(name)) continue
|
|
128
|
+
const dir = join(root, name)
|
|
129
|
+
try {
|
|
130
|
+
if (!statSync(dir).isDirectory()) continue
|
|
131
|
+
} catch {
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
sets.push({ name, dir })
|
|
135
|
+
}
|
|
136
|
+
sets.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0))
|
|
137
|
+
return sets
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Choose the next free backup-set name for `configDir`. Second-granularity names
|
|
142
|
+
* collide when two sets are created in the same second (apply immediately
|
|
143
|
+
* followed by a pre-restore snapshot); the seconds are bumped until the name is
|
|
144
|
+
* free so a new set never overwrites an existing one. Shared by the apply path
|
|
145
|
+
* and the dry-run preview so both agree.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} configDir
|
|
148
|
+
* @param {Date} [base]
|
|
149
|
+
* @returns {string}
|
|
150
|
+
*/
|
|
151
|
+
export function nextBackupName(configDir, base = new Date()) {
|
|
152
|
+
let name = formatTimestamp(base)
|
|
153
|
+
let bump = 0
|
|
154
|
+
while (existsSync(backupSetPath(configDir, name))) {
|
|
155
|
+
bump += 1
|
|
156
|
+
name = formatTimestamp(new Date(base.getTime() + bump * 1000))
|
|
157
|
+
}
|
|
158
|
+
return name
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** sha256 hex of a buffer. */
|
|
162
|
+
function sha256(buffer) {
|
|
163
|
+
return createHash('sha256').update(buffer).digest('hex')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function chmodBestEffort(path, mode) {
|
|
167
|
+
try {
|
|
168
|
+
chmodSync(path, mode)
|
|
169
|
+
} catch {
|
|
170
|
+
// Windows / exotic filesystems: best-effort only.
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Create a directory ONLY when it is absent, and never chmod a directory that
|
|
176
|
+
* already exists. Chmodding a pre-existing directory is an irreversible side
|
|
177
|
+
* effect on the user's tree (F1): restoring into a project would otherwise
|
|
178
|
+
* rewrite the project root to 0700. Bulk-directory modes are out of scope for
|
|
179
|
+
* the exact backup (see `notRevertible`).
|
|
180
|
+
*/
|
|
181
|
+
function ensureDir(path, mode) {
|
|
182
|
+
if (existsSync(path)) return
|
|
183
|
+
try {
|
|
184
|
+
mkdirSync(path, { recursive: true, mode })
|
|
185
|
+
chmodBestEffort(path, mode)
|
|
186
|
+
} catch {
|
|
187
|
+
// A concurrent creator won the race; leave it untouched.
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Create an exact backup set for `entries` (paths relative to `configDir`).
|
|
193
|
+
*
|
|
194
|
+
* A path that exists as a regular file is copied byte-for-byte with mode 0600
|
|
195
|
+
* and hashed; anything else (absent, directory, symlink) is recorded as absent.
|
|
196
|
+
* The whole set is written before this function returns, so a caller can run it
|
|
197
|
+
* BEFORE the first modifying write.
|
|
198
|
+
*
|
|
199
|
+
* @param {object} opts
|
|
200
|
+
* @param {string} opts.configDir
|
|
201
|
+
* @param {string[]} opts.entries - relative paths (deduped, order preserved)
|
|
202
|
+
* @param {string[]} [opts.notRevertible] - paths the run may delete but cannot
|
|
203
|
+
* capture byte-for-byte (bulk dirs like `.venv`/plugin `node_modules`, or
|
|
204
|
+
* out-of-target global files). Recorded in the manifest so the guarantee is
|
|
205
|
+
* honest, never restored.
|
|
206
|
+
* @param {string} [opts.packageVersion]
|
|
207
|
+
* @param {() => Date} [opts.clock]
|
|
208
|
+
* @param {number} [opts.keep=DEFAULT_KEEP] - retained sets (0 = no pruning)
|
|
209
|
+
* @returns {{ dir: string, name: string, manifest: object }}
|
|
210
|
+
*/
|
|
211
|
+
export function createBackupSet({
|
|
212
|
+
configDir,
|
|
213
|
+
entries,
|
|
214
|
+
notRevertible = [],
|
|
215
|
+
packageVersion = 'unknown',
|
|
216
|
+
clock = () => new Date(),
|
|
217
|
+
keep = DEFAULT_KEEP,
|
|
218
|
+
} = {}) {
|
|
219
|
+
const base = clock()
|
|
220
|
+
const name = nextBackupName(configDir, base)
|
|
221
|
+
const dir = backupSetPath(configDir, name)
|
|
222
|
+
ensureDir(backupRoot(configDir), 0o700)
|
|
223
|
+
ensureDir(dir, 0o700)
|
|
224
|
+
|
|
225
|
+
const seen = new Set()
|
|
226
|
+
const manifestEntries = []
|
|
227
|
+
for (const relativePath of entries ?? []) {
|
|
228
|
+
const rel = String(relativePath)
|
|
229
|
+
if (rel === '' || seen.has(rel)) continue
|
|
230
|
+
seen.add(rel)
|
|
231
|
+
const source = join(configDir, rel)
|
|
232
|
+
let stat = null
|
|
233
|
+
try {
|
|
234
|
+
stat = lstatSync(source)
|
|
235
|
+
} catch {
|
|
236
|
+
stat = null
|
|
237
|
+
}
|
|
238
|
+
if (stat?.isFile()) {
|
|
239
|
+
const buffer = readFileSync(source)
|
|
240
|
+
const dest = join(dir, rel)
|
|
241
|
+
ensureDir(dirname(dest), 0o700)
|
|
242
|
+
writeFileSync(dest, buffer, { mode: 0o600 })
|
|
243
|
+
chmodBestEffort(dest, 0o600)
|
|
244
|
+
manifestEntries.push({
|
|
245
|
+
relativePath: rel,
|
|
246
|
+
existed: true,
|
|
247
|
+
sha256: sha256(buffer),
|
|
248
|
+
bytes: buffer.length,
|
|
249
|
+
mode: stat.mode & 0o777,
|
|
250
|
+
})
|
|
251
|
+
} else {
|
|
252
|
+
manifestEntries.push({
|
|
253
|
+
relativePath: rel,
|
|
254
|
+
existed: false,
|
|
255
|
+
sha256: null,
|
|
256
|
+
bytes: 0,
|
|
257
|
+
mode: null,
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const manifest = {
|
|
263
|
+
createdAt: base.toISOString(),
|
|
264
|
+
packageVersion,
|
|
265
|
+
configDir,
|
|
266
|
+
notRevertible: [...new Set((notRevertible ?? []).map(String))],
|
|
267
|
+
entries: manifestEntries,
|
|
268
|
+
}
|
|
269
|
+
const manifestPath = join(dir, 'manifest.json')
|
|
270
|
+
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 })
|
|
271
|
+
chmodBestEffort(manifestPath, 0o600)
|
|
272
|
+
|
|
273
|
+
const pruned = keep > 0 ? pruneBackups(configDir, keep) : { removed: [], failures: [] }
|
|
274
|
+
return { dir, name, manifest, pruned }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* A manifest entry path must be a plain relative path inside the config dir —
|
|
279
|
+
* no absolute path and no `..` segment (the backup mirrors the config dir).
|
|
280
|
+
* Rejected at read time so a crafted manifest can never steer verify or restore
|
|
281
|
+
* outside the tree.
|
|
282
|
+
* @param {unknown} rel
|
|
283
|
+
* @returns {boolean}
|
|
284
|
+
*/
|
|
285
|
+
function isSafeRelativePath(rel) {
|
|
286
|
+
if (typeof rel !== 'string' || rel === '') return false
|
|
287
|
+
if (isAbsolute(rel)) return false
|
|
288
|
+
return !rel.split(/[\\/]/).includes('..')
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Read and validate the manifest of a backup set.
|
|
293
|
+
*
|
|
294
|
+
* VALIDATION SCOPE (documented, not implied): this rejects a malformed manifest
|
|
295
|
+
* and any entry whose `relativePath` is absolute or contains `..` (fail closed).
|
|
296
|
+
* It does NOT make the manifest self-verifying — a DROPPED entry or a flipped
|
|
297
|
+
* `existed` flag cannot be detected from the manifest alone. Tampering with the
|
|
298
|
+
* CONTENT of a listed file (newline/truncation/hash change) IS detected by
|
|
299
|
+
* {@link verifyBackupSet}, because each listed file is re-hashed.
|
|
300
|
+
*
|
|
301
|
+
* @param {string} dir
|
|
302
|
+
* @returns {{ ok: true, manifest: object } | { ok: false, reason: string }}
|
|
303
|
+
*/
|
|
304
|
+
export function readManifest(dir) {
|
|
305
|
+
const manifestPath = join(dir, 'manifest.json')
|
|
306
|
+
if (!existsSync(manifestPath)) return { ok: false, reason: 'missing-manifest' }
|
|
307
|
+
try {
|
|
308
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
309
|
+
if (!manifest || !Array.isArray(manifest.entries)) {
|
|
310
|
+
return { ok: false, reason: 'invalid-manifest' }
|
|
311
|
+
}
|
|
312
|
+
if (manifest.entries.some((e) => !e || !isSafeRelativePath(e.relativePath))) {
|
|
313
|
+
return { ok: false, reason: 'unsafe-path' }
|
|
314
|
+
}
|
|
315
|
+
return { ok: true, manifest }
|
|
316
|
+
} catch {
|
|
317
|
+
return { ok: false, reason: 'invalid-manifest' }
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Verify every backed-up file still matches its manifest hash. Any missing file
|
|
323
|
+
* or hash mismatch is reported (no silent partial restore).
|
|
324
|
+
* @param {string} dir
|
|
325
|
+
* @returns {{ ok: boolean, reason?: string, mismatches: string[] }}
|
|
326
|
+
*/
|
|
327
|
+
export function verifyBackupSet(dir) {
|
|
328
|
+
const read = readManifest(dir)
|
|
329
|
+
if (!read.ok) return { ok: false, reason: read.reason, mismatches: [] }
|
|
330
|
+
const mismatches = []
|
|
331
|
+
for (const entry of read.manifest.entries) {
|
|
332
|
+
if (!entry.existed) continue
|
|
333
|
+
const file = join(dir, entry.relativePath)
|
|
334
|
+
if (!existsSync(file)) {
|
|
335
|
+
mismatches.push(entry.relativePath)
|
|
336
|
+
continue
|
|
337
|
+
}
|
|
338
|
+
if (sha256(readFileSync(file)) !== entry.sha256) mismatches.push(entry.relativePath)
|
|
339
|
+
}
|
|
340
|
+
return { ok: mismatches.length === 0, mismatches }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Order for writes: `opencode.json` strictly last. */
|
|
344
|
+
function restoreOrder(a, b) {
|
|
345
|
+
const last = (rel) => (rel === 'opencode.json' ? 1 : 0)
|
|
346
|
+
if (last(a.relativePath) !== last(b.relativePath))
|
|
347
|
+
return last(a.relativePath) - last(b.relativePath)
|
|
348
|
+
return a.relativePath.localeCompare(b.relativePath)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Compute the restore plan by PATH ONLY (no contents exposed). Absent-in-backup
|
|
353
|
+
* entries that now exist become `remove`; a present target is hashed internally
|
|
354
|
+
* to classify it as `unchanged` or `update`. Hashes are never returned or
|
|
355
|
+
* printed at this layer — only `{ relativePath, action }`.
|
|
356
|
+
*
|
|
357
|
+
* @param {string} dir - backup set directory
|
|
358
|
+
* @param {string} configDir
|
|
359
|
+
* @returns {{ entries: Array<{relativePath: string, action: string}>, manifest: object } | { error: string }}
|
|
360
|
+
*/
|
|
361
|
+
export function planRestore(dir, configDir) {
|
|
362
|
+
const read = readManifest(dir)
|
|
363
|
+
if (!read.ok) return { error: read.reason }
|
|
364
|
+
const verify = verifyBackupSet(dir)
|
|
365
|
+
if (!verify.ok) return { error: verify.reason ?? 'integrity', mismatches: verify.mismatches }
|
|
366
|
+
|
|
367
|
+
const entries = []
|
|
368
|
+
for (const entry of [...read.manifest.entries].sort(restoreOrder)) {
|
|
369
|
+
const target = join(configDir, entry.relativePath)
|
|
370
|
+
if (entry.existed) {
|
|
371
|
+
let action = 'create'
|
|
372
|
+
try {
|
|
373
|
+
const st = lstatSync(target)
|
|
374
|
+
if (st.isFile()) {
|
|
375
|
+
const current = readFileSync(target)
|
|
376
|
+
action = sha256(current) === entry.sha256 ? 'unchanged' : 'update'
|
|
377
|
+
} else {
|
|
378
|
+
action = 'update'
|
|
379
|
+
}
|
|
380
|
+
} catch {
|
|
381
|
+
action = 'create'
|
|
382
|
+
}
|
|
383
|
+
entries.push({ relativePath: entry.relativePath, action })
|
|
384
|
+
} else {
|
|
385
|
+
const exists = existsSync(target)
|
|
386
|
+
entries.push({ relativePath: entry.relativePath, action: exists ? 'remove' : 'unchanged' })
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return { entries, manifest: read.manifest }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Restore a verified backup set into `configDir`. Refuses (throws) if the
|
|
394
|
+
* manifest is missing/invalid or any backed-up file is tampered.
|
|
395
|
+
*
|
|
396
|
+
* @param {string} dir
|
|
397
|
+
* @param {string} configDir
|
|
398
|
+
* @param {{ dryRun?: boolean }} [opts]
|
|
399
|
+
* @returns {{ entries: Array<{relativePath: string, action: string}> }}
|
|
400
|
+
*/
|
|
401
|
+
export function restoreBackupSet(dir, configDir, { dryRun = false } = {}) {
|
|
402
|
+
const plan = planRestore(dir, configDir)
|
|
403
|
+
if (plan.error) {
|
|
404
|
+
const detail = plan.mismatches?.length ? ` (${plan.mismatches.length} mismatch(es))` : ''
|
|
405
|
+
throw new Error(`Backup is not restorable: ${plan.error}${detail}`)
|
|
406
|
+
}
|
|
407
|
+
if (dryRun) return { entries: plan.entries }
|
|
408
|
+
|
|
409
|
+
for (const item of plan.entries) {
|
|
410
|
+
const backUpFile = join(dir, item.relativePath)
|
|
411
|
+
const target = join(configDir, item.relativePath)
|
|
412
|
+
if (item.action === 'unchanged') continue
|
|
413
|
+
|
|
414
|
+
// Refuse to write through/over a symlink (mirrors writeIfChanged).
|
|
415
|
+
let linkStat = null
|
|
416
|
+
try {
|
|
417
|
+
linkStat = lstatSync(target)
|
|
418
|
+
} catch {
|
|
419
|
+
linkStat = null
|
|
420
|
+
}
|
|
421
|
+
if (linkStat?.isSymbolicLink()) {
|
|
422
|
+
throw new Error(`Refusing to restore through symlink: ${item.relativePath}`)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (item.action === 'remove') {
|
|
426
|
+
if (linkStat?.isFile()) rmSync(target, { force: true })
|
|
427
|
+
continue
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const buffer = readFileSync(backUpFile)
|
|
431
|
+
ensureDir(dirname(target), 0o700)
|
|
432
|
+
writeFileSync(target, buffer, { mode: 0o600 })
|
|
433
|
+
const recorded = plan.manifest.entries.find((e) => e.relativePath === item.relativePath)
|
|
434
|
+
if (recorded && recorded.mode !== null) chmodBestEffort(target, recorded.mode)
|
|
435
|
+
}
|
|
436
|
+
return { entries: plan.entries }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Delete all but the `keep` most-recent backup sets under the config dir.
|
|
441
|
+
* Only directories that pass {@link isDeletableBackupDir} are ever removed;
|
|
442
|
+
* deletion failures are reported, never thrown.
|
|
443
|
+
*
|
|
444
|
+
* @param {string} configDir
|
|
445
|
+
* @param {number} [keep=DEFAULT_KEEP]
|
|
446
|
+
* @returns {{ removed: string[], failures: string[] }}
|
|
447
|
+
*/
|
|
448
|
+
export function pruneBackups(configDir, keep = DEFAULT_KEEP) {
|
|
449
|
+
const removed = []
|
|
450
|
+
const failures = []
|
|
451
|
+
const sets = listBackupSets(configDir)
|
|
452
|
+
for (const set of sets.slice(keep)) {
|
|
453
|
+
if (!isDeletableBackupDir(configDir, set.dir)) continue
|
|
454
|
+
try {
|
|
455
|
+
rmSync(set.dir, { recursive: true, force: true })
|
|
456
|
+
removed.push(set.name)
|
|
457
|
+
} catch {
|
|
458
|
+
failures.push(set.name)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return { removed, failures }
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* The one-time, exact, secret-safe warning printed before the installer applies.
|
|
466
|
+
* @param {string} backupPath
|
|
467
|
+
* @returns {string}
|
|
468
|
+
*/
|
|
469
|
+
export function backupWarning(backupPath) {
|
|
470
|
+
return (
|
|
471
|
+
`Backup created at ${backupPath} (mode 0600). It is an exact copy of your ` +
|
|
472
|
+
`current files and therefore also contains any API keys or tokens already ` +
|
|
473
|
+
`present in them. It is never printed. Restore with: pantheon-opencode restore`
|
|
474
|
+
)
|
|
475
|
+
}
|
|
@@ -33,7 +33,12 @@ export function configure(opts = {}) {
|
|
|
33
33
|
if (opts.dryRun !== undefined) _dryRun = opts.dryRun
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Prefix a message with the `[DRY-RUN]` marker while a dry run is configured.
|
|
38
|
+
* Exported so callers that print through raw `console.log` (e.g. the per-file
|
|
39
|
+
* change plan) stay consistent with every other dry-run line.
|
|
40
|
+
*/
|
|
41
|
+
export function tag(msg) {
|
|
37
42
|
return _dryRun ? `[DRY-RUN] ${msg}` : msg
|
|
38
43
|
}
|
|
39
44
|
|