triad-plus 1.10.0 → 1.11.0
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/CHANGELOG.md +11 -0
- package/README.md +32 -0
- package/bin/triad-plus.js +565 -20
- package/docs/configuration.md +31 -0
- package/docs/npx-installation.md +61 -4
- package/docs/troubleshooting.md +41 -0
- package/package.json +2 -2
- package/runtime/lib/installation-manifest.mjs +269 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.11.0 — 2026-09-17
|
|
4
|
+
|
|
5
|
+
- Add an installation manifest that records Triad-owned assets, materialized
|
|
6
|
+
version, adapter scopes, hashes, and deterministic ownership fingerprint.
|
|
7
|
+
- Add CLI/workspace version visibility, legacy manifest migration through
|
|
8
|
+
`upgrade --apply`, and conservative dry-run/apply uninstall for all adapters.
|
|
9
|
+
- Preserve modified or unknown assets and all project-control state during
|
|
10
|
+
uninstall; report version skew and manifest integrity through `doctor`.
|
|
11
|
+
- Preserve shared global assets by default, remove only exact managed
|
|
12
|
+
`AGENTS.md` blocks, and leave generic host directories untouched.
|
|
13
|
+
|
|
3
14
|
## 1.10.0 — 2026-09-16
|
|
4
15
|
|
|
5
16
|
- Improve TUI and doctor output with terminal-native summaries, sober color
|
package/README.md
CHANGED
|
@@ -107,6 +107,38 @@ The Orchestrator first shows the feature cards, then delegates the bounded work.
|
|
|
107
107
|
If a tutorial step is unclear, see the [OpenCode guide](docs/runtimes.md#opencode)
|
|
108
108
|
and [troubleshooting](docs/troubleshooting.md).
|
|
109
109
|
|
|
110
|
+
## Installation version and safe uninstall
|
|
111
|
+
|
|
112
|
+
The CLI version and the version materialized in a control workspace are
|
|
113
|
+
reported independently:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
npx triad-plus --version
|
|
117
|
+
npx triad-plus version --control /absolute/path/to/triad-control
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Successful `init` and `upgrade --apply` operations record exact Triad-owned
|
|
121
|
+
files in `.triad-plus/installation.json`. This manifest does not own
|
|
122
|
+
`.triad-plus/team.json`, `.loop/`, product files, or evidence.
|
|
123
|
+
`init` is the first-install path; `upgrade --apply` is also the managed
|
|
124
|
+
update/repair/restore path for a registered workspace, including one left
|
|
125
|
+
`uninstalled` or `partial` by safe uninstall.
|
|
126
|
+
|
|
127
|
+
Uninstall is conservative and dry-run by default:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
npx triad-plus uninstall --host opencode --control /absolute/path/to/triad-control
|
|
131
|
+
npx triad-plus uninstall --host opencode --control /absolute/path/to/triad-control --apply
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Only unchanged files recorded by the manifest are removed. Modified or
|
|
135
|
+
unknown files are preserved and reported; user state remains available for a
|
|
136
|
+
future installation or review. Generic host directories are never pruned. A
|
|
137
|
+
managed `AGENTS.md` role-run block is removed only when its markers and hash
|
|
138
|
+
are exact; surrounding user content is preserved. Global assets are shown but
|
|
139
|
+
preserved by default with `--global` because another control workspace may
|
|
140
|
+
share them.
|
|
141
|
+
|
|
110
142
|
## Optional immutable quality target
|
|
111
143
|
|
|
112
144
|
An initialized project may bind an immutable JSON Quality Baseline through
|
package/bin/triad-plus.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { access, cp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { access, cp, mkdir, readFile, rm, stat, writeFile, lstat, readdir } from 'node:fs/promises';
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
5
6
|
import { homedir } from 'node:os';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
import { dirname, join, resolve, relative, sep } from 'node:path';
|
|
8
9
|
import process from 'node:process';
|
|
9
10
|
import { createInterface } from 'node:readline/promises';
|
|
10
11
|
import { getAdapter, listAdapters, roleDefinitions, sharedSkillNames } from '../adapters/registry.mjs';
|
|
@@ -20,8 +21,21 @@ import {
|
|
|
20
21
|
formatDoctorSection,
|
|
21
22
|
formatSetupSummary
|
|
22
23
|
} from '../runtime/lib/terminal.mjs';
|
|
24
|
+
import {
|
|
25
|
+
buildInstallationManifest,
|
|
26
|
+
collectManagedAssetRecords,
|
|
27
|
+
installationManifestFingerprint,
|
|
28
|
+
installationManifestPath,
|
|
29
|
+
loadInstallationManifest,
|
|
30
|
+
manifestIsUninstalled,
|
|
31
|
+
manifestScopeStatus,
|
|
32
|
+
sha256Text,
|
|
33
|
+
sha256File,
|
|
34
|
+
writeInstallationManifest
|
|
35
|
+
} from '../runtime/lib/installation-manifest.mjs';
|
|
23
36
|
|
|
24
37
|
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
38
|
+
const packageVersion = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
|
|
25
39
|
|
|
26
40
|
function usage(exitCode = 0) {
|
|
27
41
|
const stream = exitCode === 0 ? process.stdout : process.stderr;
|
|
@@ -29,15 +43,18 @@ function usage(exitCode = 0) {
|
|
|
29
43
|
|
|
30
44
|
Usage:
|
|
31
45
|
npx triad-plus
|
|
46
|
+
npx triad-plus --version
|
|
32
47
|
npx triad-plus init --host <adapter-id> --control <path> [--global] [--team-config <path>] [--allow-product-repo]
|
|
33
48
|
npx triad-plus doctor --host <adapter-id> --control <path> [--global] [--hook-config <path>]
|
|
49
|
+
npx triad-plus version --control <path>
|
|
34
50
|
npx triad-plus upgrade --host <adapter-id> --control <path> [--global] [--apply]
|
|
51
|
+
npx triad-plus uninstall --host <adapter-id> --control <path> [--global] [--apply]
|
|
35
52
|
npx triad-plus import-bmad-story --source <story.md> --output <card.md> [--target-repository <id>] [--provenance <record.json>] [--required-gate <id>] [--depends-on <card-id>]
|
|
36
53
|
|
|
37
54
|
Adapters: ${listAdapters().map((adapter) => adapter.id).join(', ')}
|
|
38
55
|
|
|
39
56
|
The control path is a project-control workspace, not a product repository.
|
|
40
|
-
Installation refuses
|
|
57
|
+
Installation refuses conflicting asset overwrites; identical global assets may be reused. Upgrade is a dry run unless --apply is supplied.
|
|
41
58
|
`);
|
|
42
59
|
process.exit(exitCode);
|
|
43
60
|
}
|
|
@@ -77,6 +94,16 @@ async function requireDirectory(target) {
|
|
|
77
94
|
if (!(await stat(target)).isDirectory()) throw new Error(`Control path is not a directory: ${target}`);
|
|
78
95
|
}
|
|
79
96
|
|
|
97
|
+
async function requireExistingDirectory(target) {
|
|
98
|
+
let info;
|
|
99
|
+
try { info = await stat(target); }
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (error.code === 'ENOENT') throw new Error(`Control workspace does not exist: ${target}`);
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
if (!info.isDirectory()) throw new Error(`Control path is not a directory: ${target}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
80
107
|
function codexHome() {
|
|
81
108
|
return process.env.CODEX_HOME || join(homedir(), '.codex');
|
|
82
109
|
}
|
|
@@ -122,6 +149,101 @@ async function installAssets(assets, root, installContext) {
|
|
|
122
149
|
for (const asset of assets) await copyAsset(asset, root, installContext);
|
|
123
150
|
}
|
|
124
151
|
|
|
152
|
+
function assetDestinations(assets, root, installContext) {
|
|
153
|
+
return assets.map((asset) => resolveDestination(asset.destination, root, installContext));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function sourceFiles(source) {
|
|
157
|
+
const info = await lstat(source);
|
|
158
|
+
if (info.isFile()) return [source];
|
|
159
|
+
if (!info.isDirectory()) throw new Error(`Managed asset source is not a regular file or directory: ${source}`);
|
|
160
|
+
const files = [];
|
|
161
|
+
for (const name of (await readdir(source)).sort()) files.push(...await sourceFiles(join(source, name)));
|
|
162
|
+
return files;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Expand registry assets to exact source/destination file pairs, never to a host directory. */
|
|
166
|
+
async function materializedAssetPairs(assets, root, installContext) {
|
|
167
|
+
const pairs = [];
|
|
168
|
+
for (const asset of assets) {
|
|
169
|
+
const destination = resolveDestination(asset.destination, root, installContext);
|
|
170
|
+
if (asset.source === 'shared-skills') {
|
|
171
|
+
for (const name of sharedSkillNames) {
|
|
172
|
+
const source = join(packageRoot, 'skills', name);
|
|
173
|
+
const sourceRoot = resolve(source);
|
|
174
|
+
for (const file of await sourceFiles(sourceRoot)) {
|
|
175
|
+
pairs.push({ source: file, target: join(destination, name, relative(sourceRoot, file)) });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const source = sourcePath(asset.source);
|
|
181
|
+
const sourceInfo = await lstat(source);
|
|
182
|
+
if (asset.file || sourceInfo.isFile()) {
|
|
183
|
+
pairs.push({ source, target: destination });
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const sourceRoot = resolve(source);
|
|
187
|
+
for (const file of await sourceFiles(sourceRoot)) pairs.push({ source: file, target: join(destination, relative(sourceRoot, file)) });
|
|
188
|
+
}
|
|
189
|
+
return pairs.map((pair) => ({ source: resolve(pair.source), target: resolve(pair.target) }));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Expand registry assets to the exact files copied, never to a host directory. */
|
|
193
|
+
async function materializedAssetPaths(assets, root, installContext) {
|
|
194
|
+
return (await materializedAssetPairs(assets, root, installContext)).map((pair) => pair.target);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function generatedRoleAssetPaths(adapter, installContext, scope, team) {
|
|
198
|
+
if (!team || scope !== 'global' || adapter.modelBinding !== 'global-profiles' || typeof adapter.roleModelPaths !== 'function') return [];
|
|
199
|
+
return adapter.roleModelPaths(installContext);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function collectInstallationAssets(adapter, controlRoot, installContext, scope, team) {
|
|
203
|
+
const assets = scope === 'global' ? adapter.globalAssets : adapter.projectAssets;
|
|
204
|
+
const paths = [
|
|
205
|
+
...await materializedAssetPaths(assets, controlRoot, installContext),
|
|
206
|
+
...generatedRoleAssetPaths(adapter, installContext, scope, team)
|
|
207
|
+
];
|
|
208
|
+
return collectManagedAssetRecords(paths, {
|
|
209
|
+
scope,
|
|
210
|
+
baseRoot: scope === 'project' ? controlRoot : null
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function overallInstallationStatus(scopeStatus) {
|
|
215
|
+
const values = Object.values(scopeStatus);
|
|
216
|
+
if (values.every((value) => ['not_configured', 'uninstalled'].includes(value))) return 'uninstalled';
|
|
217
|
+
if (values.some((value) => ['partial', 'uninstalled'].includes(value))) return 'partial';
|
|
218
|
+
return 'installed';
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function installationManifestFor({ adapter, controlRoot, installContext, team, global = false, previous = null, installedAt = null }) {
|
|
222
|
+
const projectAssets = await collectInstallationAssets(adapter, controlRoot, installContext, 'project', team);
|
|
223
|
+
const previousManifest = previous?.manifest ?? previous;
|
|
224
|
+
const overlayAssets = await managedOverlayRecord(controlRoot, previousManifest, team);
|
|
225
|
+
const globalConfigured = Boolean(global || previousManifest?.scopes?.global);
|
|
226
|
+
const globalAssets = global
|
|
227
|
+
? await collectInstallationAssets(adapter, controlRoot, installContext, 'global', team)
|
|
228
|
+
: (previousManifest?.managed_assets ?? []).filter((asset) => asset.scope === 'global');
|
|
229
|
+
const scopeStatus = {
|
|
230
|
+
project: 'installed',
|
|
231
|
+
global: globalConfigured
|
|
232
|
+
? (global ? 'installed' : manifestScopeStatus(previousManifest, 'global'))
|
|
233
|
+
: 'not_configured'
|
|
234
|
+
};
|
|
235
|
+
return buildInstallationManifest({
|
|
236
|
+
triadVersion: packageVersion,
|
|
237
|
+
adapter: adapter.id,
|
|
238
|
+
installedAt: installedAt ?? previousManifest?.installed_at ?? new Date().toISOString(),
|
|
239
|
+
updatedAt: new Date().toISOString(),
|
|
240
|
+
scopes: { project: true, global: globalConfigured },
|
|
241
|
+
scopeStatus,
|
|
242
|
+
managedAssets: [...projectAssets, ...overlayAssets, ...globalAssets],
|
|
243
|
+
status: overallInstallationStatus(scopeStatus)
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
125
247
|
async function replaceManagedPath(source, destination, backup, apply) {
|
|
126
248
|
const present = await exists(destination);
|
|
127
249
|
process.stdout.write(` ${apply ? 'Update' : 'Would update'} ${destination}${present ? ' (backup)' : ''}\n`);
|
|
@@ -172,16 +294,26 @@ outside Triad+.
|
|
|
172
294
|
${overlayEnd}`;
|
|
173
295
|
}
|
|
174
296
|
|
|
297
|
+
function managedBlockIn(source, startMarker = overlayStart, endMarker = overlayEnd) {
|
|
298
|
+
const startCount = source.split(startMarker).length - 1;
|
|
299
|
+
const endCount = source.split(endMarker).length - 1;
|
|
300
|
+
if (startCount === 0 && endCount === 0) return null;
|
|
301
|
+
if (startCount !== 1 || endCount !== 1) throw new Error('Managed instruction block is ambiguous: duplicate or incomplete markers.');
|
|
302
|
+
const start = source.indexOf(startMarker);
|
|
303
|
+
const end = source.indexOf(endMarker, start + startMarker.length);
|
|
304
|
+
if (end < 0) throw new Error('Managed instruction block has invalid marker order.');
|
|
305
|
+
const blockEnd = end + endMarker.length;
|
|
306
|
+
return { start, end, blockEnd, content: source.slice(start, blockEnd) };
|
|
307
|
+
}
|
|
308
|
+
|
|
175
309
|
async function overlayPlan(controlRoot, team) {
|
|
176
310
|
const target = join(controlRoot, 'AGENTS.md');
|
|
177
311
|
const overlay = instructionOverlay(team);
|
|
178
312
|
if (!(await exists(target))) return { target, action: 'create', content: `# Project instructions\n\n${overlay}\n` };
|
|
179
313
|
const source = await readFile(target, 'utf8');
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if (start < 0 || end < start) throw new Error(`Cannot safely update managed instruction block: ${target}`);
|
|
184
|
-
return { target, action: 'update', content: `${source.slice(0, start)}${overlay}${source.slice(end + overlayEnd.length)}` };
|
|
314
|
+
const managed = managedBlockIn(source);
|
|
315
|
+
if (!managed) return { target, action: 'append', content: `${source}${source.endsWith('\n') ? '\n' : '\n\n'}${overlay}\n` };
|
|
316
|
+
return { target, action: 'update', content: `${source.slice(0, managed.start)}${overlay}${source.slice(managed.blockEnd)}` };
|
|
185
317
|
}
|
|
186
318
|
|
|
187
319
|
async function applyOverlay(controlRoot, apply, team) {
|
|
@@ -190,6 +322,23 @@ async function applyOverlay(controlRoot, apply, team) {
|
|
|
190
322
|
if (apply) await writeFile(plan.target, plan.content, 'utf8');
|
|
191
323
|
}
|
|
192
324
|
|
|
325
|
+
async function managedOverlayRecord(controlRoot, previousManifest, team) {
|
|
326
|
+
const previousBlocks = (previousManifest?.managed_assets ?? []).filter((asset) => asset.kind === 'managed_block' && asset.scope === 'project');
|
|
327
|
+
if (!team) return previousBlocks;
|
|
328
|
+
const target = join(controlRoot, 'AGENTS.md');
|
|
329
|
+
const source = await readFile(target, 'utf8');
|
|
330
|
+
const managed = managedBlockIn(source);
|
|
331
|
+
if (!managed) throw new Error(`Managed instruction block is missing after materialization: ${target}`);
|
|
332
|
+
return [{
|
|
333
|
+
scope: 'project',
|
|
334
|
+
path: 'AGENTS.md',
|
|
335
|
+
kind: 'managed_block',
|
|
336
|
+
start_marker: overlayStart,
|
|
337
|
+
end_marker: overlayEnd,
|
|
338
|
+
sha256: sha256Text(managed.content)
|
|
339
|
+
}];
|
|
340
|
+
}
|
|
341
|
+
|
|
193
342
|
async function loadTeamConfig(options) {
|
|
194
343
|
if (options.team) return validateTeamConfiguration(options.team);
|
|
195
344
|
if (!options.teamConfig) return null;
|
|
@@ -306,6 +455,138 @@ async function anyExist(paths) {
|
|
|
306
455
|
return false;
|
|
307
456
|
}
|
|
308
457
|
|
|
458
|
+
async function globalPathReusable(target, pairs) {
|
|
459
|
+
let info;
|
|
460
|
+
try { info = await lstat(target); } catch (error) {
|
|
461
|
+
if (error.code === 'ENOENT') return true;
|
|
462
|
+
throw error;
|
|
463
|
+
}
|
|
464
|
+
if (info.isDirectory()) {
|
|
465
|
+
const descendants = pairs.filter((pair) => pathWithin(target, pair.target));
|
|
466
|
+
if (descendants.length === 0) return false;
|
|
467
|
+
for (const pair of descendants) {
|
|
468
|
+
let pairInfo;
|
|
469
|
+
try { pairInfo = await lstat(pair.target); } catch (error) {
|
|
470
|
+
if (error.code === 'ENOENT') continue;
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
if (!pairInfo.isFile() || await sha256File(pair.target) !== await sha256File(pair.source)) return false;
|
|
474
|
+
}
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
const pair = pairs.find((candidate) => candidate.target === resolve(target));
|
|
478
|
+
return Boolean(pair && info.isFile() && await sha256File(target) === await sha256File(pair.source));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function versionTuple(value) {
|
|
482
|
+
const match = String(value ?? '').match(/^(?:v)?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
|
|
483
|
+
return match ? match.slice(1).map(Number) : null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function compareVersions(left, right) {
|
|
487
|
+
const a = versionTuple(left);
|
|
488
|
+
const b = versionTuple(right);
|
|
489
|
+
if (!a || !b) return null;
|
|
490
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
491
|
+
if (a[index] > b[index]) return 1;
|
|
492
|
+
if (a[index] < b[index]) return -1;
|
|
493
|
+
}
|
|
494
|
+
return 0;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function pathWithin(root, target) {
|
|
498
|
+
const base = resolve(root);
|
|
499
|
+
const candidate = resolve(target);
|
|
500
|
+
return candidate === base || candidate.startsWith(`${base}${sep}`);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function managedAssetPaths(adapter, controlRoot, installContext, scope) {
|
|
504
|
+
const assets = scope === 'global' ? adapter.globalAssets : adapter.projectAssets;
|
|
505
|
+
const paths = await materializedAssetPaths(assets, controlRoot, installContext);
|
|
506
|
+
if (scope === 'global' && adapter.modelBinding === 'global-profiles' && typeof adapter.roleModelPaths === 'function') {
|
|
507
|
+
paths.push(...adapter.roleModelPaths(installContext));
|
|
508
|
+
}
|
|
509
|
+
return new Set(paths.map((target) => resolve(target)));
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function manifestAssetPath(controlRoot, asset) {
|
|
513
|
+
return asset.scope === 'project' ? resolve(controlRoot, asset.path) : resolve(asset.path);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function displayManagedAssetPath(asset) {
|
|
517
|
+
if (asset.scope !== 'global') return asset.path;
|
|
518
|
+
const home = resolve(homedir());
|
|
519
|
+
const target = resolve(asset.path);
|
|
520
|
+
return pathWithin(home, target) ? `~${relative(home, target) ? `/${relative(home, target)}` : ''}` : target;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function assetAllowedByRegistry(managedPaths, controlRoot, asset) {
|
|
524
|
+
const target = manifestAssetPath(controlRoot, asset);
|
|
525
|
+
if (asset.kind === 'managed_block') return asset.scope === 'project' && target === resolve(controlRoot, 'AGENTS.md');
|
|
526
|
+
return managedPaths.has(target);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function installationAssetIssues(controlRoot, adapter, installContext, manifest) {
|
|
530
|
+
const issues = [];
|
|
531
|
+
for (const asset of manifest.managed_assets) {
|
|
532
|
+
const target = manifestAssetPath(controlRoot, asset);
|
|
533
|
+
const expectedState = manifestScopeStatus(manifest, asset.scope);
|
|
534
|
+
let info;
|
|
535
|
+
try { info = await lstat(target); } catch (error) {
|
|
536
|
+
if (error.code === 'ENOENT') {
|
|
537
|
+
if (expectedState !== 'uninstalled') issues.push({ asset, status: 'missing' });
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
issues.push({ asset, status: `unreadable:${error.code ?? 'error'}` });
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (asset.kind === 'managed_block') {
|
|
544
|
+
if (!info.isFile()) {
|
|
545
|
+
issues.push({ asset, status: 'modified' });
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
let source;
|
|
549
|
+
try { source = await readFile(target, 'utf8'); } catch { issues.push({ asset, status: 'unreadable' }); continue; }
|
|
550
|
+
let managed;
|
|
551
|
+
try { managed = managedBlockIn(source, asset.start_marker, asset.end_marker); } catch { managed = null; }
|
|
552
|
+
if (managed && sha256Text(managed.content) !== asset.sha256) issues.push({ asset, status: 'modified' });
|
|
553
|
+
else if (managed && expectedState === 'uninstalled') issues.push({ asset, status: 'present_after_uninstall' });
|
|
554
|
+
else if (!managed && expectedState !== 'uninstalled') issues.push({ asset, status: 'modified' });
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (!info.isFile()) {
|
|
558
|
+
issues.push({ asset, status: 'modified' });
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
let observed;
|
|
562
|
+
try { observed = await sha256File(target); } catch { issues.push({ asset, status: 'unreadable' }); continue; }
|
|
563
|
+
if (observed !== asset.sha256) issues.push({ asset, status: 'modified' });
|
|
564
|
+
else if (expectedState === 'uninstalled') issues.push({ asset, status: 'present_after_uninstall' });
|
|
565
|
+
}
|
|
566
|
+
return issues;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function inspectInstallation(controlRoot, adapter, installContext) {
|
|
570
|
+
let loaded;
|
|
571
|
+
try { loaded = await loadInstallationManifest(controlRoot); }
|
|
572
|
+
catch (error) {
|
|
573
|
+
return { state: 'invalid', message: error.message, manifest: null, issues: [] };
|
|
574
|
+
}
|
|
575
|
+
if (!loaded) return { state: 'legacy', manifest: null, issues: [] };
|
|
576
|
+
const manifest = loaded.manifest;
|
|
577
|
+
if (manifest.adapter !== adapter.id) {
|
|
578
|
+
return {
|
|
579
|
+
state: 'invalid',
|
|
580
|
+
message: `manifest adapter ${manifest.adapter} does not match selected ${adapter.id}`,
|
|
581
|
+
manifest,
|
|
582
|
+
issues: []
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
const issues = await installationAssetIssues(controlRoot, adapter, installContext, manifest);
|
|
586
|
+
const versionRelation = compareVersions(packageVersion, manifest.triad_version);
|
|
587
|
+
return { state: manifestIsUninstalled(manifest) ? 'uninstalled' : 'valid', manifest, issues, versionRelation };
|
|
588
|
+
}
|
|
589
|
+
|
|
309
590
|
/** Return one status per shared skill so a partial install cannot look healthy. */
|
|
310
591
|
async function sharedSkillStatuses(adapter, root, installContext, scope) {
|
|
311
592
|
const roots = sharedSkillRoots(adapter, root, installContext, scope);
|
|
@@ -438,24 +719,52 @@ async function init(options) {
|
|
|
438
719
|
if (!options.allowProductRepo && productRepositoryAt(controlRoot)) {
|
|
439
720
|
throw new Error('Control path appears to be a product repository. Use a separate project-control workspace, or pass --allow-product-repo after reviewing the risk.');
|
|
440
721
|
}
|
|
722
|
+
let previousManifest = null;
|
|
723
|
+
if (await exists(installationManifestPath(controlRoot))) previousManifest = await loadInstallationManifest(controlRoot);
|
|
441
724
|
const installContext = context(controlRoot);
|
|
442
|
-
const
|
|
725
|
+
const projectPlanned = [
|
|
443
726
|
...adapter.projectPaths(controlRoot, installContext),
|
|
444
|
-
...(team ? [teamConfigPath(controlRoot)] : [])
|
|
445
|
-
...(options.global ? adapter.globalPaths(installContext) : []),
|
|
446
|
-
...(team && options.global && adapter.modelBinding === 'global-profiles' ? adapter.roleModelPaths(installContext) : []),
|
|
447
|
-
...(team && options.global && adapter.modelBinding === 'project-frontmatter' && adapter.globalRoleModelPaths
|
|
448
|
-
? adapter.globalRoleModelPaths(installContext)
|
|
449
|
-
: [])
|
|
727
|
+
...(team ? [teamConfigPath(controlRoot)] : [])
|
|
450
728
|
];
|
|
451
|
-
const
|
|
452
|
-
if (
|
|
729
|
+
const existingProject = await collisions(projectPlanned);
|
|
730
|
+
if (existingProject.length > 0) throw new Error(`Installation aborted; existing paths would be overwritten:\n${existingProject.map((target) => ` ${target}`).join('\n')}`);
|
|
731
|
+
if (options.global) {
|
|
732
|
+
const globalPlanned = [
|
|
733
|
+
...adapter.globalPaths(installContext),
|
|
734
|
+
...(team && adapter.modelBinding === 'global-profiles' ? adapter.roleModelPaths(installContext) : []),
|
|
735
|
+
...(team && adapter.modelBinding === 'project-frontmatter' && adapter.globalRoleModelPaths
|
|
736
|
+
? adapter.globalRoleModelPaths(installContext)
|
|
737
|
+
: [])
|
|
738
|
+
];
|
|
739
|
+
const globalPairs = await materializedAssetPairs(adapter.globalAssets, controlRoot, installContext);
|
|
740
|
+
const teamModelPaths = team
|
|
741
|
+
? new Set([
|
|
742
|
+
...(adapter.modelBinding === 'global-profiles' && typeof adapter.roleModelPaths === 'function' ? adapter.roleModelPaths(installContext) : []),
|
|
743
|
+
...(adapter.modelBinding === 'project-frontmatter' && typeof adapter.globalRoleModelPaths === 'function' ? adapter.globalRoleModelPaths(installContext) : [])
|
|
744
|
+
].map((target) => resolve(target)))
|
|
745
|
+
: new Set();
|
|
746
|
+
const existingGlobal = [];
|
|
747
|
+
for (const target of globalPlanned) {
|
|
748
|
+
if (!(await exists(target))) continue;
|
|
749
|
+
if (teamModelPaths.has(resolve(target)) || !(await globalPathReusable(target, globalPairs))) existingGlobal.push(target);
|
|
750
|
+
}
|
|
751
|
+
if (existingGlobal.length > 0) throw new Error(`Installation aborted; existing global paths would be overwritten:\n${existingGlobal.map((target) => ` ${target}`).join('\n')}`);
|
|
752
|
+
}
|
|
453
753
|
await installAssets(adapter.projectAssets, controlRoot, installContext);
|
|
454
754
|
if (team) await writeTeamConfig(controlRoot, team);
|
|
455
755
|
if (team) await applyOverlay(controlRoot, true, team);
|
|
456
756
|
if (options.global) await installAssets(adapter.globalAssets, controlRoot, installContext);
|
|
457
757
|
if (team) await applyTeamBinding(adapter, controlRoot, team, installContext, 'project');
|
|
458
758
|
if (team && options.global) await applyTeamBinding(adapter, controlRoot, team, installContext, 'global');
|
|
759
|
+
const manifest = await installationManifestFor({
|
|
760
|
+
adapter,
|
|
761
|
+
controlRoot,
|
|
762
|
+
installContext,
|
|
763
|
+
team,
|
|
764
|
+
global: options.global,
|
|
765
|
+
previous: previousManifest
|
|
766
|
+
});
|
|
767
|
+
await writeInstallationManifest(controlRoot, manifest);
|
|
459
768
|
process.stdout.write(`Triad+ installed for ${adapter.label} in ${controlRoot}\n`);
|
|
460
769
|
if (adapter.globalEntry) {
|
|
461
770
|
process.stdout.write(`Install user-level assets with --global to expose ${adapter.entry}.\n`);
|
|
@@ -504,7 +813,12 @@ async function upgrade(options) {
|
|
|
504
813
|
if (!adapter) throw new Error(`Choose --host ${listAdapters().map((item) => item.id).join(', ')}.`);
|
|
505
814
|
if (!options.control) throw new Error('Provide --control <project-control-path>.');
|
|
506
815
|
const controlRoot = resolve(options.control);
|
|
507
|
-
await
|
|
816
|
+
await requireExistingDirectory(controlRoot);
|
|
817
|
+
let previousManifest = null;
|
|
818
|
+
if (await exists(installationManifestPath(controlRoot))) previousManifest = await loadInstallationManifest(controlRoot);
|
|
819
|
+
if (previousManifest && previousManifest.manifest.adapter !== adapter.id) {
|
|
820
|
+
throw new Error(`Installation manifest belongs to ${previousManifest.manifest.adapter}, not ${adapter.id}.`);
|
|
821
|
+
}
|
|
508
822
|
const team = await currentTeam(controlRoot);
|
|
509
823
|
const installContext = context(controlRoot);
|
|
510
824
|
const stamp = new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-');
|
|
@@ -521,6 +835,20 @@ async function upgrade(options) {
|
|
|
521
835
|
await refreshAssets(adapter.globalAssets, controlRoot, installContext, join(backupRoot, 'global'), options.apply);
|
|
522
836
|
if (team && options.apply) await applyTeamBinding(adapter, controlRoot, team, installContext, 'global');
|
|
523
837
|
}
|
|
838
|
+
if (options.apply) {
|
|
839
|
+
const manifest = await installationManifestFor({
|
|
840
|
+
adapter,
|
|
841
|
+
controlRoot,
|
|
842
|
+
installContext,
|
|
843
|
+
team,
|
|
844
|
+
global: options.global,
|
|
845
|
+
previous: previousManifest
|
|
846
|
+
});
|
|
847
|
+
await writeInstallationManifest(controlRoot, manifest);
|
|
848
|
+
process.stdout.write(` Installation manifest updated ${installationManifestPath(controlRoot)}\n`);
|
|
849
|
+
} else {
|
|
850
|
+
process.stdout.write(` Installation manifest would be ${previousManifest ? 'updated' : 'created'} ${installationManifestPath(controlRoot)}\n`);
|
|
851
|
+
}
|
|
524
852
|
if (!options.apply) process.stdout.write('Dry run only. Re-run with --apply to update managed assets.\n');
|
|
525
853
|
}
|
|
526
854
|
|
|
@@ -539,6 +867,181 @@ async function importBmadStory(options) {
|
|
|
539
867
|
process.stdout.write(`Provenance ${result.provenancePath}\n`);
|
|
540
868
|
}
|
|
541
869
|
|
|
870
|
+
function installationScopeText(manifest, scope) {
|
|
871
|
+
const status = manifestScopeStatus(manifest, scope);
|
|
872
|
+
if (status === 'installed') return 'yes';
|
|
873
|
+
if (status === 'not_configured') return 'no';
|
|
874
|
+
return status;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
async function versionCommand(options) {
|
|
878
|
+
if (!options.control) throw new Error('Provide --control <project-control-path>.');
|
|
879
|
+
const controlRoot = resolve(options.control);
|
|
880
|
+
await requireExistingDirectory(controlRoot);
|
|
881
|
+
process.stdout.write(`Triad+ CLI ${packageVersion}\n`);
|
|
882
|
+
process.stdout.write(`Workspace ${controlRoot}\n`);
|
|
883
|
+
let loaded;
|
|
884
|
+
try { loaded = await loadInstallationManifest(controlRoot); }
|
|
885
|
+
catch (error) {
|
|
886
|
+
process.stdout.write('Installed Triad unknown (manifest invalid)\n');
|
|
887
|
+
process.stdout.write(`Manifest invalid — ${error.message}\n`);
|
|
888
|
+
throw error;
|
|
889
|
+
}
|
|
890
|
+
if (!loaded) {
|
|
891
|
+
process.stdout.write('Installed Triad unknown (legacy installation)\n');
|
|
892
|
+
process.stdout.write('Manifest legacy / manifest missing\n');
|
|
893
|
+
process.stdout.write('Adapter unknown\n');
|
|
894
|
+
process.stdout.write('Project install unknown\n');
|
|
895
|
+
process.stdout.write('Global install unknown\n');
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
const manifest = loaded.manifest;
|
|
899
|
+
const adapter = getAdapter(manifest.adapter);
|
|
900
|
+
const uninstalled = manifestIsUninstalled(manifest);
|
|
901
|
+
process.stdout.write(`Installed Triad ${uninstalled ? 'unknown (uninstalled)' : manifest.triad_version}\n`);
|
|
902
|
+
process.stdout.write(`Manifest ${uninstalled ? 'OK (uninstalled)' : 'OK'}\n`);
|
|
903
|
+
process.stdout.write(`Adapter ${adapter?.label ?? manifest.adapter}\n`);
|
|
904
|
+
process.stdout.write(`Project install ${installationScopeText(manifest, 'project')}\n`);
|
|
905
|
+
process.stdout.write(`Global install ${installationScopeText(manifest, 'global')}\n`);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
async function uninstall(options) {
|
|
909
|
+
const adapter = getAdapter(options.host);
|
|
910
|
+
if (!adapter) throw new Error(`Choose --host ${listAdapters().map((item) => item.id).join(', ')}.`);
|
|
911
|
+
if (!options.control) throw new Error('Provide --control <project-control-path>.');
|
|
912
|
+
const controlRoot = resolve(options.control);
|
|
913
|
+
await requireExistingDirectory(controlRoot);
|
|
914
|
+
const loaded = await loadInstallationManifest(controlRoot);
|
|
915
|
+
process.stdout.write(`Triad+ uninstall — ${adapter.label}\n`);
|
|
916
|
+
if (!loaded) {
|
|
917
|
+
process.stdout.write('Manifest legacy / manifest missing\n');
|
|
918
|
+
process.stdout.write('No files were changed. Refusing to delete assets without an installation manifest.\n');
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
const manifest = loaded.manifest;
|
|
922
|
+
if (manifest.adapter !== adapter.id) throw new Error(`Installation manifest belongs to ${manifest.adapter}, not ${adapter.id}.`);
|
|
923
|
+
const installContext = context(controlRoot);
|
|
924
|
+
const selectedScopes = options.global ? ['project', 'global'] : ['project'];
|
|
925
|
+
const scopeStats = Object.fromEntries(selectedScopes.map((scope) => [scope, { modified: 0, removed: 0 }]));
|
|
926
|
+
if (options.global) process.stdout.write('Global assets are preserved because cross-workspace ownership cannot be proven.\n');
|
|
927
|
+
for (const scope of selectedScopes) {
|
|
928
|
+
const registryPaths = await managedAssetPaths(adapter, controlRoot, installContext, scope);
|
|
929
|
+
process.stdout.write(`\n${scope === 'project' ? 'Project' : 'Global'} managed assets\n`);
|
|
930
|
+
const entries = manifest.managed_assets.filter((asset) => asset.scope === scope);
|
|
931
|
+
if (entries.length === 0) process.stdout.write(' (none)\n');
|
|
932
|
+
for (const asset of entries) {
|
|
933
|
+
const target = manifestAssetPath(controlRoot, asset);
|
|
934
|
+
const display = displayManagedAssetPath(asset);
|
|
935
|
+
let info;
|
|
936
|
+
try { info = await lstat(target); } catch (error) {
|
|
937
|
+
if (error.code === 'ENOENT') {
|
|
938
|
+
process.stdout.write(` ABSENT ${display}\n`);
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
process.stdout.write(` PRESERVE ${display}\n`);
|
|
942
|
+
process.stdout.write(` cannot inspect asset: ${error.message}\n`);
|
|
943
|
+
scopeStats[scope].modified += 1;
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
if (scope === 'global') {
|
|
947
|
+
process.stdout.write(` PRESERVE ${display}\n`);
|
|
948
|
+
process.stdout.write(' global ownership may be shared by another control workspace\n');
|
|
949
|
+
scopeStats[scope].modified += 1;
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
if (!assetAllowedByRegistry(registryPaths, controlRoot, asset)) {
|
|
953
|
+
process.stdout.write(` PRESERVE ${display}\n`);
|
|
954
|
+
process.stdout.write(' path is not in the current adapter managed plan\n');
|
|
955
|
+
scopeStats[scope].modified += 1;
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
if (asset.kind === 'managed_block') {
|
|
959
|
+
if (!info.isFile()) {
|
|
960
|
+
process.stdout.write(` PRESERVE ${display}\n`);
|
|
961
|
+
process.stdout.write(' managed block host file is not a regular file\n');
|
|
962
|
+
scopeStats[scope].modified += 1;
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
let source;
|
|
966
|
+
let managed;
|
|
967
|
+
try {
|
|
968
|
+
source = await readFile(target, 'utf8');
|
|
969
|
+
managed = managedBlockIn(source, asset.start_marker, asset.end_marker);
|
|
970
|
+
} catch {
|
|
971
|
+
managed = null;
|
|
972
|
+
}
|
|
973
|
+
if (!managed || sha256Text(managed.content) !== asset.sha256) {
|
|
974
|
+
process.stdout.write(` PRESERVE ${display}\n`);
|
|
975
|
+
process.stdout.write(' managed block is modified, ambiguous, or unverifiable\n');
|
|
976
|
+
scopeStats[scope].modified += 1;
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
process.stdout.write(` ${options.apply ? 'REMOVE' : 'WOULD REMOVE'} block ${display}\n`);
|
|
980
|
+
if (options.apply) {
|
|
981
|
+
await writeFile(target, `${source.slice(0, managed.start)}${source.slice(managed.blockEnd)}`, 'utf8');
|
|
982
|
+
scopeStats[scope].removed += 1;
|
|
983
|
+
}
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
if (!info.isFile()) {
|
|
987
|
+
process.stdout.write(` PRESERVE ${display}\n`);
|
|
988
|
+
process.stdout.write(' asset is no longer a regular file\n');
|
|
989
|
+
scopeStats[scope].modified += 1;
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
const observed = await sha256File(target);
|
|
993
|
+
if (observed !== asset.sha256) {
|
|
994
|
+
process.stdout.write(` PRESERVE ${display}\n`);
|
|
995
|
+
process.stdout.write(' content differs from installation manifest\n');
|
|
996
|
+
scopeStats[scope].modified += 1;
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
999
|
+
process.stdout.write(` ${options.apply ? 'REMOVE' : 'WOULD REMOVE'} ${display}\n`);
|
|
1000
|
+
if (options.apply) {
|
|
1001
|
+
await rm(target, { force: true });
|
|
1002
|
+
scopeStats[scope].removed += 1;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
process.stdout.write('\nPreserved state\n');
|
|
1007
|
+
for (const preserved of ['.triad-plus/team.json', '.loop/', 'project.yaml', 'features/', 'artifacts/', 'evidence/']) {
|
|
1008
|
+
process.stdout.write(` KEEP ${preserved}\n`);
|
|
1009
|
+
}
|
|
1010
|
+
if (!options.apply) {
|
|
1011
|
+
process.stdout.write('\nNo files were changed. Re-run with --apply to uninstall managed assets.\n');
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
const scopeStatus = { project: manifestScopeStatus(manifest, 'project'), global: manifestScopeStatus(manifest, 'global') };
|
|
1015
|
+
for (const scope of selectedScopes) scopeStatus[scope] = scopeStats[scope].modified > 0 ? 'partial' : 'uninstalled';
|
|
1016
|
+
const status = overallInstallationStatus(scopeStatus);
|
|
1017
|
+
const updated = buildInstallationManifest({
|
|
1018
|
+
triadVersion: manifest.triad_version,
|
|
1019
|
+
adapter: manifest.adapter,
|
|
1020
|
+
installedAt: manifest.installed_at,
|
|
1021
|
+
updatedAt: new Date().toISOString(),
|
|
1022
|
+
uninstalledAt: status === 'uninstalled' ? new Date().toISOString() : undefined,
|
|
1023
|
+
scopes: manifest.scopes,
|
|
1024
|
+
scopeStatus,
|
|
1025
|
+
managedAssets: manifest.managed_assets,
|
|
1026
|
+
status
|
|
1027
|
+
});
|
|
1028
|
+
await writeInstallationManifest(controlRoot, updated);
|
|
1029
|
+
process.stdout.write(`\nInstallation manifest updated ${installationManifestPath(controlRoot)}\n`);
|
|
1030
|
+
const projectPartial = scopeStatus.project === 'partial';
|
|
1031
|
+
const globalPreserved = options.global && scopeStatus.global === 'partial';
|
|
1032
|
+
if (status === 'uninstalled') {
|
|
1033
|
+
process.stdout.write('Complete project uninstall; user state was preserved.\n');
|
|
1034
|
+
} else if (projectPartial && globalPreserved) {
|
|
1035
|
+
process.stdout.write('Partial uninstall; modified or unverifiable project assets and shared global assets were preserved.\n');
|
|
1036
|
+
} else if (projectPartial) {
|
|
1037
|
+
process.stdout.write('Partial project uninstall; modified or unverifiable assets were preserved.\n');
|
|
1038
|
+
} else if (globalPreserved) {
|
|
1039
|
+
process.stdout.write('Project uninstall complete; global assets were intentionally preserved because shared ownership could not be proven.\n');
|
|
1040
|
+
} else {
|
|
1041
|
+
process.stdout.write('Uninstall completed conservatively; preserved assets remain in place.\n');
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
542
1045
|
async function doctor(options) {
|
|
543
1046
|
if (!options.control) throw new Error('Provide --control <project-control-path>.');
|
|
544
1047
|
const controlRoot = resolve(options.control);
|
|
@@ -551,6 +1054,7 @@ async function doctor(options) {
|
|
|
551
1054
|
}
|
|
552
1055
|
for (const adapter of requested) {
|
|
553
1056
|
const installContext = context(controlRoot);
|
|
1057
|
+
const installation = await inspectInstallation(controlRoot, adapter, installContext);
|
|
554
1058
|
const absent = [];
|
|
555
1059
|
for (const target of adapter.projectPaths(controlRoot, installContext)) if (!(await exists(target))) absent.push(target);
|
|
556
1060
|
const globalDetected = adapter.globalPaths ? await anyExist(adapter.globalPaths(installContext)) : false;
|
|
@@ -568,7 +1072,41 @@ async function doctor(options) {
|
|
|
568
1072
|
const triadSkillTargets = targets.filter((target) => /[/\\]skills[/\\]triad$/.test(target));
|
|
569
1073
|
const capability = manifest ? capabilitySnapshot(controlRoot, manifestPath) : null;
|
|
570
1074
|
process.stdout.write(`\n${formatDoctorSection(`Triad+ doctor — ${adapter.label}`)}\n`);
|
|
571
|
-
process.stdout.write(`${formatDoctorLine(adapter.label, absent.length || globalAbsent.length ? 'incomplete' : 'OK')}\n`);
|
|
1075
|
+
process.stdout.write(`${formatDoctorLine(adapter.label, absent.length || globalAbsent.length || installation.issues.length ? 'incomplete' : 'OK')}\n`);
|
|
1076
|
+
process.stdout.write(`${formatDoctorSection('Triad+ installation')}\n`);
|
|
1077
|
+
process.stdout.write(` ${formatDoctorLine('CLI version', packageVersion)}\n`);
|
|
1078
|
+
if (installation.state === 'legacy') {
|
|
1079
|
+
process.stdout.write(` ${formatDoctorLine('Installed version', 'unknown (legacy installation)')}\n`);
|
|
1080
|
+
process.stdout.write(` ${formatDoctorLine('Manifest', 'legacy / manifest missing')}\n`);
|
|
1081
|
+
process.stdout.write(` ${formatDoctorLine('Adapter', adapter.label)}\n`);
|
|
1082
|
+
process.stdout.write(` ${formatDoctorLine('Project scope', 'unknown (legacy)')}\n`);
|
|
1083
|
+
process.stdout.write(` ${formatDoctorLine('Global scope', 'unknown (legacy)')}\n`);
|
|
1084
|
+
} else if (installation.state === 'invalid') {
|
|
1085
|
+
process.stdout.write(` ${formatDoctorLine('Installed version', 'unknown (manifest invalid)')}\n`);
|
|
1086
|
+
process.stdout.write(` ${formatDoctorLine('Manifest', 'invalid')} — ${installation.message}\n`);
|
|
1087
|
+
process.stdout.write(` ${formatDoctorLine('Adapter', adapter.label)}\n`);
|
|
1088
|
+
process.stdout.write(` ${formatDoctorLine('Project scope', 'unknown')}\n`);
|
|
1089
|
+
process.stdout.write(` ${formatDoctorLine('Global scope', 'unknown')}\n`);
|
|
1090
|
+
} else {
|
|
1091
|
+
const versionStatus = installation.state === 'uninstalled'
|
|
1092
|
+
? `unknown (uninstalled; last ${installation.manifest.triad_version})`
|
|
1093
|
+
: installation.manifest.triad_version;
|
|
1094
|
+
const versionWarning = installation.versionRelation === 1
|
|
1095
|
+
? ' — CLI newer / upgrade available'
|
|
1096
|
+
: installation.versionRelation === -1
|
|
1097
|
+
? ' — CLI older than installed version'
|
|
1098
|
+
: '';
|
|
1099
|
+
const issueStatus = installation.issues.some((issue) => issue.status === 'modified' || issue.status === 'present_after_uninstall')
|
|
1100
|
+
? 'managed asset modified'
|
|
1101
|
+
: installation.issues.some((issue) => issue.status === 'missing')
|
|
1102
|
+
? 'managed asset missing'
|
|
1103
|
+
: installation.state === 'uninstalled' ? 'OK (uninstalled)' : 'OK';
|
|
1104
|
+
process.stdout.write(` ${formatDoctorLine('Installed version', versionStatus)}${versionWarning}\n`);
|
|
1105
|
+
process.stdout.write(` ${formatDoctorLine('Manifest', issueStatus)}\n`);
|
|
1106
|
+
process.stdout.write(` ${formatDoctorLine('Adapter', adapter.label)}\n`);
|
|
1107
|
+
process.stdout.write(` ${formatDoctorLine('Project scope', installationScopeText(installation.manifest, 'project'))}\n`);
|
|
1108
|
+
process.stdout.write(` ${formatDoctorLine('Global scope', installationScopeText(installation.manifest, 'global'))}\n`);
|
|
1109
|
+
}
|
|
572
1110
|
process.stdout.write(`${formatDoctorSection('Runtime and installation')}\n`);
|
|
573
1111
|
process.stdout.write(` ${formatDoctorLine('Host runtime', binary ? `OK (${binary})` : 'not installed or version unavailable')}\n`);
|
|
574
1112
|
process.stdout.write(` ${formatDoctorLine('Verifier', node && await exists(join(controlRoot, '.triad-runtime', 'triad-verify.mjs')) ? 'OK' : 'incomplete')}\n`);
|
|
@@ -660,10 +1198,17 @@ async function interactiveInit() {
|
|
|
660
1198
|
}
|
|
661
1199
|
|
|
662
1200
|
try {
|
|
663
|
-
const
|
|
1201
|
+
const argv = process.argv.slice(2);
|
|
1202
|
+
if (argv.length === 1 && ['--version', '-v'].includes(argv[0])) {
|
|
1203
|
+
process.stdout.write(`${packageVersion}\n`);
|
|
1204
|
+
process.exit(0);
|
|
1205
|
+
}
|
|
1206
|
+
const options = parseArgs(argv);
|
|
664
1207
|
if (options.command === 'init') await init(options);
|
|
665
1208
|
else if (options.command === 'doctor') await doctor(options);
|
|
1209
|
+
else if (options.command === 'version') await versionCommand(options);
|
|
666
1210
|
else if (options.command === 'upgrade') await upgrade(options);
|
|
1211
|
+
else if (options.command === 'uninstall') await uninstall(options);
|
|
667
1212
|
else if (options.command === 'import-bmad-story') await importBmadStory(options);
|
|
668
1213
|
else if (!options.command) await interactiveInit();
|
|
669
1214
|
else if (options.command === '--help' || options.command === '-h') usage(0);
|
package/docs/configuration.md
CHANGED
|
@@ -31,6 +31,37 @@ adapter writes those into host-native profiles only where the selected host
|
|
|
31
31
|
supports that facility. A blank model means the host default. Never put tokens,
|
|
32
32
|
API keys, or private deployment data in this file.
|
|
33
33
|
|
|
34
|
+
## Installation ownership and version
|
|
35
|
+
|
|
36
|
+
The package version and the installed workspace version are separate facts:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npx triad-plus --version
|
|
40
|
+
npx triad-plus version --control /path/to/project-control
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
After successful materialization, `.triad-plus/installation.json` records the
|
|
44
|
+
adapter, project/global scopes, exact managed files, SHA-256 hashes, timestamps,
|
|
45
|
+
and a deterministic fingerprint. Project paths are control-workspace-relative;
|
|
46
|
+
global paths identify the user-level managed asset. The manifest is generated
|
|
47
|
+
from the same adapter registry and install plan used by `init` and `upgrade`.
|
|
48
|
+
|
|
49
|
+
The manifest deliberately does not own `.triad-plus/team.json`, `.loop/`,
|
|
50
|
+
`project.yaml`, feature cards, artifacts, evidence, or product source. A failed
|
|
51
|
+
install never writes a success manifest. Legacy workspaces are migrated by
|
|
52
|
+
`upgrade --apply` using the currently executing package version; the previous
|
|
53
|
+
version is not guessed.
|
|
54
|
+
|
|
55
|
+
When a team configuration is materialized, the managed role-run block in
|
|
56
|
+
`AGENTS.md` is recorded as a bounded block asset. Uninstall removes that block
|
|
57
|
+
only when its markers and hash still match, never the surrounding user file.
|
|
58
|
+
Global assets are shared-capable and therefore preserved by default by
|
|
59
|
+
`uninstall --global`.
|
|
60
|
+
|
|
61
|
+
`doctor` reports CLI version, installed version, manifest state, adapter, and
|
|
62
|
+
project/global scope. It reports version skew explicitly and never queries npm
|
|
63
|
+
for `latest`.
|
|
64
|
+
|
|
34
65
|
## Configure models through the agent
|
|
35
66
|
|
|
36
67
|
The installed `triad-model-configuration` skill lets an owner ask the selected
|
package/docs/npx-installation.md
CHANGED
|
@@ -22,11 +22,68 @@ npx triad-plus init --host codex --control /path/to/project-control --global
|
|
|
22
22
|
npx triad-plus doctor --host codex --control /path/to/project-control
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
##
|
|
25
|
+
## Version visibility
|
|
26
26
|
|
|
27
|
-
`
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
`--version` reports the package that is actually executing:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npx triad-plus --version
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The workspace command reports the materialized installation recorded by the
|
|
34
|
+
control workspace manifest:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npx triad-plus version --control /path/to/project-control
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
An older workspace without `.triad-plus/installation.json` is reported as a
|
|
41
|
+
legacy installation. Triad+ does not infer a historical version from scattered
|
|
42
|
+
agent files.
|
|
43
|
+
|
|
44
|
+
## Installation manifest and safe uninstall
|
|
45
|
+
|
|
46
|
+
After a successful `init`, Triad+ writes
|
|
47
|
+
`.triad-plus/installation.json`. It records the selected adapter, CLI version,
|
|
48
|
+
project/global scopes, normalized managed-file paths, SHA-256 hashes, and a
|
|
49
|
+
manifest fingerprint. `upgrade --apply` refreshes this record after managed
|
|
50
|
+
assets are materialized; a legacy workspace receives a new manifest without
|
|
51
|
+
inventing its previous version.
|
|
52
|
+
|
|
53
|
+
Uninstall is dry-run by default:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npx triad-plus uninstall --host opencode --control /path/to/project-control
|
|
57
|
+
npx triad-plus uninstall --host opencode --control /path/to/project-control --apply
|
|
58
|
+
npx triad-plus uninstall --host opencode --control /path/to/project-control --global --apply
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Only files listed in the manifest, still unchanged from their recorded hash,
|
|
62
|
+
are removed. Missing files are reported as `ABSENT`; modified assets are
|
|
63
|
+
preserved. Host directories are never pruned because directory ownership is
|
|
64
|
+
not claimed. The team configuration, `.loop/`, project manifest, feature
|
|
65
|
+
cards, artifacts, evidence, and other user state are preserved. A managed
|
|
66
|
+
`AGENTS.md` role-run block is tracked separately and removed only when its
|
|
67
|
+
markers and hash are exact; surrounding user content remains. A manifest
|
|
68
|
+
remains as an `uninstalled` or `partial` tombstone so a second uninstall is
|
|
69
|
+
idempotent and the ownership history is auditable.
|
|
70
|
+
|
|
71
|
+
`--global --apply` is deliberately preserve-by-default: global assets may be
|
|
72
|
+
shared by several control workspaces, so the command reports them as shared
|
|
73
|
+
and does not delete them without a cross-workspace ownership model.
|
|
74
|
+
|
|
75
|
+
Read-only commands require an existing control workspace; a typo path is
|
|
76
|
+
never created by `version` or `uninstall`.
|
|
77
|
+
|
|
78
|
+
## Upgrade, repair, or restore an existing control workspace
|
|
79
|
+
|
|
80
|
+
`init` is the first-install command and refuses to overwrite an existing
|
|
81
|
+
workspace. `upgrade --apply` is the managed update, repair, and restore path
|
|
82
|
+
for a workspace that is already registered by an installation manifest,
|
|
83
|
+
including a workspace whose project scope is `uninstalled` or `partial` after a
|
|
84
|
+
safe uninstall. It re-materializes project assets, reuses the preserved
|
|
85
|
+
`team.json`, and refreshes the manifest without changing user state, `.loop/`,
|
|
86
|
+
PRD files, evidence, or product repositories. The default is a dry run:
|
|
30
87
|
|
|
31
88
|
```bash
|
|
32
89
|
npx triad-plus upgrade --host codex --control /path/to/project-control --global
|
package/docs/troubleshooting.md
CHANGED
|
@@ -6,6 +6,26 @@ Run doctor first:
|
|
|
6
6
|
npx triad-plus doctor --host <runtime> --control /path/to/triad-control
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
+
Compare the executing CLI with the materialized workspace installation:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx triad-plus --version
|
|
13
|
+
npx triad-plus version --control /path/to/triad-control
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`legacy / manifest missing` means the workspace predates the installation
|
|
17
|
+
manifest. Run `upgrade --apply` to materialize a current manifest; Triad+ does
|
|
18
|
+
not infer the old version. The same `upgrade --apply` command is the managed
|
|
19
|
+
restore path after a safe uninstall leaves an `uninstalled` or `partial`
|
|
20
|
+
manifest: it re-materializes managed assets and reuses the preserved team
|
|
21
|
+
configuration and user state. `init` is reserved for first installation and
|
|
22
|
+
refuses existing paths. `manifest invalid` means the ownership record or its
|
|
23
|
+
fingerprint is malformed and should be reviewed before any uninstall.
|
|
24
|
+
|
|
25
|
+
Doctor also reports `CLI newer / upgrade available` and `CLI older than
|
|
26
|
+
installed version` instead of silently claiming compatibility. It never checks
|
|
27
|
+
the npm `latest` tag.
|
|
28
|
+
|
|
9
29
|
`not installed` means the selected adapter assets are absent from that control
|
|
10
30
|
workspace. `not installed or version unavailable` for a host means its binary is
|
|
11
31
|
not on PATH or otherwise cannot answer `--version`. Re-run the appropriate host
|
|
@@ -20,3 +40,24 @@ baselines, worktree, branch, and candidate. Do not treat it as a passing test.
|
|
|
20
40
|
|
|
21
41
|
If Evaluator+ is unavailable, check `roles.evaluator.enabled` in the team file.
|
|
22
42
|
An Evaluator+ failure is post-run information, not an automatic repair request.
|
|
43
|
+
|
|
44
|
+
## Safe uninstall
|
|
45
|
+
|
|
46
|
+
Uninstall is a dry run unless `--apply` is supplied:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npx triad-plus uninstall --host <runtime> --control /path/to/triad-control
|
|
50
|
+
npx triad-plus uninstall --host <runtime> --control /path/to/triad-control --apply
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Only unchanged files listed in `.triad-plus/installation.json` are removed.
|
|
54
|
+
`ABSENT` files are harmless. A `PRESERVE` line means the file was modified, no
|
|
55
|
+
longer matches the trusted managed plan, or is not a regular file; no force
|
|
56
|
+
option exists for this operation. Add `--global` only when user-level Triad
|
|
57
|
+
assets should also be considered. Global assets are nevertheless preserved by
|
|
58
|
+
default because another workspace may share them. Team config, loop state,
|
|
59
|
+
cards, artifacts, evidence, generic host directories, and other user files are
|
|
60
|
+
preserved. A managed `AGENTS.md` block is removed only when its exact markers
|
|
61
|
+
and hash still match; a modified or ambiguous block leaves the uninstall
|
|
62
|
+
partial. `version` and `uninstall` reject nonexistent control paths without
|
|
63
|
+
creating them.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "triad-plus",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"description": "A lightweight, evidence-backed engineering loop for coding agents.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"node": ">=20"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
|
-
"test": "node tests/runtime-forward-test.mjs && node tests/retry-scope-contracts-test.mjs && node tests/cli-install-test.mjs && node tests/codex-liveness-test.mjs && node tests/bmad-story-importer-test.mjs && node tests/bmad-epics-intake-test.mjs && node tests/immutable-quality-contract-test.mjs && node tests/assignment-packet-test.mjs && node tests/repository-context-test.mjs && node tests/tui-model-configuration-test.mjs",
|
|
31
|
+
"test": "node tests/runtime-forward-test.mjs && node tests/retry-scope-contracts-test.mjs && node tests/cli-install-test.mjs && node tests/installation-manifest-test.mjs && node tests/codex-liveness-test.mjs && node tests/bmad-story-importer-test.mjs && node tests/bmad-epics-intake-test.mjs && node tests/immutable-quality-contract-test.mjs && node tests/assignment-packet-test.mjs && node tests/repository-context-test.mjs && node tests/tui-model-configuration-test.mjs",
|
|
32
32
|
"pack:check": "npm pack --dry-run"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { access, lstat, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const INSTALLATION_MANIFEST_SCHEMA_VERSION = 1;
|
|
6
|
+
export const INSTALLATION_MANIFEST_PATH = '.triad-plus/installation.json';
|
|
7
|
+
|
|
8
|
+
const SHA256 = /^[a-f0-9]{64}$/i;
|
|
9
|
+
const SCOPES = new Set(['project', 'global']);
|
|
10
|
+
const SCOPE_STATES = new Set(['installed', 'partial', 'uninstalled', 'not_configured']);
|
|
11
|
+
const TOP_LEVEL_KEYS = new Set([
|
|
12
|
+
'schema_version',
|
|
13
|
+
'triad_version',
|
|
14
|
+
'adapter',
|
|
15
|
+
'installed_at',
|
|
16
|
+
'updated_at',
|
|
17
|
+
'uninstalled_at',
|
|
18
|
+
'scopes',
|
|
19
|
+
'scope_status',
|
|
20
|
+
'managed_assets',
|
|
21
|
+
'status',
|
|
22
|
+
'fingerprint'
|
|
23
|
+
]);
|
|
24
|
+
const ASSET_KEYS = new Set(['scope', 'path', 'kind', 'sha256', 'start_marker', 'end_marker']);
|
|
25
|
+
|
|
26
|
+
function invalid(message) {
|
|
27
|
+
const error = new Error(message);
|
|
28
|
+
error.code = 'installation_manifest_invalid';
|
|
29
|
+
return error;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function objectLike(value) {
|
|
33
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function digest(value) {
|
|
37
|
+
return createHash('sha256').update(value).digest('hex');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Return a stable JSON-compatible value with object keys ordered recursively.
|
|
42
|
+
* Array order is intentionally preserved because the manifest is an ordered
|
|
43
|
+
* record of the managed install plan.
|
|
44
|
+
*/
|
|
45
|
+
export function canonicalize(value) {
|
|
46
|
+
if (Array.isArray(value)) return value.map((item) => canonicalize(item));
|
|
47
|
+
if (!objectLike(value)) return value;
|
|
48
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function canonicalJson(value) {
|
|
52
|
+
return JSON.stringify(canonicalize(value));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function installationManifestPayload(manifest) {
|
|
56
|
+
const { fingerprint: _fingerprint, ...payload } = manifest;
|
|
57
|
+
return payload;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function installationManifestFingerprint(manifest) {
|
|
61
|
+
return digest(canonicalJson(installationManifestPayload(manifest)));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeRelative(value) {
|
|
65
|
+
if (typeof value !== 'string' || !value.trim()) throw invalid('installation manifest asset path must be non-empty');
|
|
66
|
+
if (value.includes('\0') || path.isAbsolute(value)) throw invalid(`installation manifest project path must be relative: ${value}`);
|
|
67
|
+
const normalized = value.split(path.sep).join('/');
|
|
68
|
+
const parts = normalized.split('/');
|
|
69
|
+
if (parts.some((part) => !part || part === '.' || part === '..')) {
|
|
70
|
+
throw invalid(`installation manifest path contains traversal or empty segments: ${value}`);
|
|
71
|
+
}
|
|
72
|
+
if (path.posix.normalize(normalized) !== normalized) throw invalid(`installation manifest path is not normalized: ${value}`);
|
|
73
|
+
return normalized;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeAbsolute(value) {
|
|
77
|
+
if (typeof value !== 'string' || !value.trim() || value.includes('\0') || !path.isAbsolute(value)) {
|
|
78
|
+
throw invalid(`installation manifest global path must be absolute: ${value}`);
|
|
79
|
+
}
|
|
80
|
+
const normalized = path.resolve(value);
|
|
81
|
+
if (normalized !== value) throw invalid(`installation manifest global path is not normalized: ${value}`);
|
|
82
|
+
return normalized;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function validateTimestamp(value, label) {
|
|
86
|
+
if (typeof value !== 'string' || !value.trim() || Number.isNaN(Date.parse(value))) {
|
|
87
|
+
throw invalid(`installation manifest ${label} must be an ISO timestamp`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function rejectUnknown(value, allowed, label) {
|
|
92
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) throw invalid(`${label} has unknown property: ${key}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Validate a manifest, including its self-declared fingerprint. */
|
|
96
|
+
export function validateInstallationManifest(manifest) {
|
|
97
|
+
if (!objectLike(manifest)) throw invalid('installation manifest must be a JSON object');
|
|
98
|
+
rejectUnknown(manifest, TOP_LEVEL_KEYS, 'installation manifest');
|
|
99
|
+
if (manifest.schema_version !== INSTALLATION_MANIFEST_SCHEMA_VERSION) {
|
|
100
|
+
throw invalid(`installation manifest schema_version must be ${INSTALLATION_MANIFEST_SCHEMA_VERSION}`);
|
|
101
|
+
}
|
|
102
|
+
if (typeof manifest.triad_version !== 'string' || !manifest.triad_version.trim()) throw invalid('installation manifest triad_version must be non-empty');
|
|
103
|
+
if (typeof manifest.adapter !== 'string' || !manifest.adapter.trim()) throw invalid('installation manifest adapter must be non-empty');
|
|
104
|
+
validateTimestamp(manifest.installed_at, 'installed_at');
|
|
105
|
+
validateTimestamp(manifest.updated_at, 'updated_at');
|
|
106
|
+
if (manifest.uninstalled_at !== undefined) validateTimestamp(manifest.uninstalled_at, 'uninstalled_at');
|
|
107
|
+
|
|
108
|
+
if (!objectLike(manifest.scopes)) throw invalid('installation manifest scopes must be an object');
|
|
109
|
+
rejectUnknown(manifest.scopes, SCOPES, 'installation manifest scopes');
|
|
110
|
+
if (typeof manifest.scopes.project !== 'boolean' || typeof manifest.scopes.global !== 'boolean') {
|
|
111
|
+
throw invalid('installation manifest scopes.project and scopes.global must be booleans');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (manifest.scope_status !== undefined) {
|
|
115
|
+
if (!objectLike(manifest.scope_status)) throw invalid('installation manifest scope_status must be an object');
|
|
116
|
+
rejectUnknown(manifest.scope_status, SCOPES, 'installation manifest scope_status');
|
|
117
|
+
for (const scope of SCOPES) {
|
|
118
|
+
if (!SCOPE_STATES.has(manifest.scope_status[scope])) throw invalid(`installation manifest scope_status.${scope} is invalid`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!Array.isArray(manifest.managed_assets)) throw invalid('installation manifest managed_assets must be an array');
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
for (const asset of manifest.managed_assets) {
|
|
125
|
+
if (!objectLike(asset)) throw invalid('installation manifest managed asset must be an object');
|
|
126
|
+
rejectUnknown(asset, ASSET_KEYS, 'installation manifest managed asset');
|
|
127
|
+
if (!SCOPES.has(asset.scope)) throw invalid(`installation manifest managed asset scope is invalid: ${asset.scope}`);
|
|
128
|
+
const normalized = asset.scope === 'project' ? normalizeRelative(asset.path) : normalizeAbsolute(asset.path);
|
|
129
|
+
if (normalized !== asset.path) throw invalid(`installation manifest managed asset path is not normalized: ${asset.path}`);
|
|
130
|
+
if (asset.kind === 'file') {
|
|
131
|
+
if (asset.start_marker !== undefined || asset.end_marker !== undefined) {
|
|
132
|
+
throw invalid(`installation manifest file asset cannot contain managed block markers: ${asset.path}`);
|
|
133
|
+
}
|
|
134
|
+
} else if (asset.kind === 'managed_block') {
|
|
135
|
+
if (asset.scope !== 'project') throw invalid(`installation manifest managed block must be project-scoped: ${asset.path}`);
|
|
136
|
+
if (typeof asset.start_marker !== 'string' || !asset.start_marker || typeof asset.end_marker !== 'string' || !asset.end_marker || asset.start_marker === asset.end_marker) {
|
|
137
|
+
throw invalid(`installation manifest managed block markers are invalid: ${asset.path}`);
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
throw invalid(`installation manifest managed asset kind is unsupported: ${asset.kind}`);
|
|
141
|
+
}
|
|
142
|
+
if (typeof asset.sha256 !== 'string' || !SHA256.test(asset.sha256)) throw invalid(`installation manifest managed asset sha256 is invalid: ${asset.path}`);
|
|
143
|
+
const key = `${asset.scope}:${asset.path}`;
|
|
144
|
+
if (seen.has(key)) throw invalid(`installation manifest managed asset is duplicated: ${key}`);
|
|
145
|
+
seen.add(key);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (manifest.status !== undefined && !SCOPE_STATES.has(manifest.status)) throw invalid(`installation manifest status is invalid: ${manifest.status}`);
|
|
149
|
+
if (typeof manifest.fingerprint !== 'string' || !SHA256.test(manifest.fingerprint)) throw invalid('installation manifest fingerprint is invalid');
|
|
150
|
+
const calculated = installationManifestFingerprint(manifest);
|
|
151
|
+
if (manifest.fingerprint.toLowerCase() !== calculated) throw invalid('installation manifest fingerprint does not match its contents');
|
|
152
|
+
return manifest;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function installationManifestPath(controlRoot) {
|
|
156
|
+
return path.join(controlRoot, INSTALLATION_MANIFEST_PATH);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function loadInstallationManifest(controlRoot) {
|
|
160
|
+
const manifestPath = installationManifestPath(controlRoot);
|
|
161
|
+
try {
|
|
162
|
+
await access(manifestPath);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (error.code === 'ENOENT') return null;
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
let manifest;
|
|
168
|
+
try {
|
|
169
|
+
manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
170
|
+
} catch (error) {
|
|
171
|
+
throw invalid(`installation manifest is not valid JSON: ${error.message}`);
|
|
172
|
+
}
|
|
173
|
+
validateInstallationManifest(manifest);
|
|
174
|
+
return { path: manifestPath, manifest };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function writeInstallationManifest(controlRoot, manifest) {
|
|
178
|
+
validateInstallationManifest(manifest);
|
|
179
|
+
const target = installationManifestPath(controlRoot);
|
|
180
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
181
|
+
const temporary = `${target}.tmp`;
|
|
182
|
+
await writeFile(temporary, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
183
|
+
await rename(temporary, target);
|
|
184
|
+
return target;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function within(root, target) {
|
|
188
|
+
const base = path.resolve(root);
|
|
189
|
+
const resolved = path.resolve(target);
|
|
190
|
+
return resolved === base || resolved.startsWith(`${base}${path.sep}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function walkFiles(target, files) {
|
|
194
|
+
let info;
|
|
195
|
+
try { info = await lstat(target); } catch (error) {
|
|
196
|
+
if (error.code === 'ENOENT') return;
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
if (info.isDirectory()) {
|
|
200
|
+
for (const name of (await readdir(target)).sort()) await walkFiles(path.join(target, name), files);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (!info.isFile()) throw invalid(`managed installation asset is not a regular file: ${target}`);
|
|
204
|
+
files.push(target);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function sha256File(filePath) {
|
|
208
|
+
return digest(await readFile(filePath));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function sha256Text(value) {
|
|
212
|
+
return digest(value);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Collect the exact regular files materialized below one or more asset roots. */
|
|
216
|
+
export async function collectManagedAssetRecords(roots, { scope, baseRoot = null } = {}) {
|
|
217
|
+
if (!SCOPES.has(scope)) throw new Error(`unknown installation asset scope: ${scope}`);
|
|
218
|
+
const files = [];
|
|
219
|
+
for (const root of [...new Set(roots.map((item) => path.resolve(item)))]) await walkFiles(root, files);
|
|
220
|
+
const unique = [...new Set(files.map((item) => path.resolve(item)))].sort();
|
|
221
|
+
return Promise.all(unique.map(async (filePath) => {
|
|
222
|
+
const assetPath = scope === 'project'
|
|
223
|
+
? path.relative(path.resolve(baseRoot), filePath).split(path.sep).join('/')
|
|
224
|
+
: filePath;
|
|
225
|
+
if (scope === 'project' && (!assetPath || assetPath.startsWith('..') || !within(baseRoot, filePath))) {
|
|
226
|
+
throw invalid(`managed project asset escaped control root: ${filePath}`);
|
|
227
|
+
}
|
|
228
|
+
return { scope, path: assetPath, kind: 'file', sha256: await sha256File(filePath) };
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function buildInstallationManifest({
|
|
233
|
+
triadVersion,
|
|
234
|
+
adapter,
|
|
235
|
+
installedAt = new Date().toISOString(),
|
|
236
|
+
updatedAt = installedAt,
|
|
237
|
+
uninstalledAt,
|
|
238
|
+
scopes = { project: true, global: false },
|
|
239
|
+
scopeStatus = { project: 'installed', global: scopes.global ? 'installed' : 'not_configured' },
|
|
240
|
+
managedAssets = [],
|
|
241
|
+
status = 'installed'
|
|
242
|
+
}) {
|
|
243
|
+
const manifest = {
|
|
244
|
+
schema_version: INSTALLATION_MANIFEST_SCHEMA_VERSION,
|
|
245
|
+
triad_version: triadVersion,
|
|
246
|
+
adapter,
|
|
247
|
+
installed_at: installedAt,
|
|
248
|
+
updated_at: updatedAt,
|
|
249
|
+
scopes: { project: Boolean(scopes.project), global: Boolean(scopes.global) },
|
|
250
|
+
scope_status: { project: scopeStatus.project, global: scopeStatus.global },
|
|
251
|
+
managed_assets: [...managedAssets].sort((left, right) => `${left.scope}:${left.path}`.localeCompare(`${right.scope}:${right.path}`)),
|
|
252
|
+
status
|
|
253
|
+
};
|
|
254
|
+
if (uninstalledAt) manifest.uninstalled_at = uninstalledAt;
|
|
255
|
+
manifest.fingerprint = installationManifestFingerprint(manifest);
|
|
256
|
+
validateInstallationManifest(manifest);
|
|
257
|
+
return manifest;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function manifestScopeStatus(manifest, scope) {
|
|
261
|
+
if (manifest.scope_status?.[scope]) return manifest.scope_status[scope];
|
|
262
|
+
return manifest.scopes?.[scope] ? 'installed' : 'not_configured';
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function manifestIsUninstalled(manifest) {
|
|
266
|
+
return manifest.status === 'uninstalled' || (
|
|
267
|
+
['project', 'global'].every((scope) => ['not_configured', 'uninstalled'].includes(manifestScopeStatus(manifest, scope)))
|
|
268
|
+
);
|
|
269
|
+
}
|