@6reduk/workspace-pipeline 0.6.0 → 0.7.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/README.md +14 -1
- package/docs/desired-state.md +243 -0
- package/docs/lifecycle-cli.md +11 -6
- package/docs/migrations/unity.md +1 -1
- package/docs/rebind.md +1 -1
- package/docs/repositories.md +2 -2
- package/package.json +1 -1
- package/schemas/pipeline-v2.schema.json +43 -0
- package/src/cli.js +5 -2
- package/src/commands/desired-lifecycle.js +93 -0
- package/src/commands/desired-recover.js +28 -0
- package/src/commands/desired-remove.js +48 -0
- package/src/commands/desired-setup.js +47 -0
- package/src/commands/dispatch.js +27 -16
- package/src/commands/init.js +1 -1
- package/src/commands/migration.js +2 -3
- package/src/commands/output.js +10 -0
- package/src/contracts/desired-state.js +56 -0
- package/src/desired-state/apply-files.js +172 -0
- package/src/desired-state/binding.js +23 -0
- package/src/desired-state/doctor.js +106 -0
- package/src/desired-state/global-settings.js +74 -0
- package/src/desired-state/inventory.js +102 -0
- package/src/desired-state/legacy-state.js +37 -0
- package/src/desired-state/local-settings.js +40 -0
- package/src/desired-state/lock-recovery.js +78 -0
- package/src/desired-state/records.js +75 -0
- package/src/desired-state/removal.js +32 -0
- package/src/desired-state/reset.js +27 -0
- package/src/desired-state/retirement.js +37 -0
- package/src/desired-state/settings.js +125 -0
- package/src/desired-state/source.js +41 -0
- package/src/operations/lock.js +3 -2
- package/src/source/desired-package.js +33 -0
- package/src/source/git.js +7 -3
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {fail} from '../contracts/parse.js';
|
|
3
|
+
import {parseDesiredWorkspace} from '../desired-state/source.js';
|
|
4
|
+
|
|
5
|
+
const valued=new Set(['--workspace','--source','--ref','--subdirectory','--adapters','--repository','--documentation']);
|
|
6
|
+
const switches=new Set(['--yes','--backup','--json','--preview']);
|
|
7
|
+
export function desiredArguments(args) {
|
|
8
|
+
const values=new Map(),seen=new Set(),repositories=[];
|
|
9
|
+
for(let i=1;i<args.length;i++) {
|
|
10
|
+
const flag=args[i];
|
|
11
|
+
if(!valued.has(flag)&&!switches.has(flag))fail('cli.arguments');
|
|
12
|
+
if(seen.has(flag)&&flag!=='--repository')fail('cli.arguments');
|
|
13
|
+
seen.add(flag);
|
|
14
|
+
if(valued.has(flag)) {
|
|
15
|
+
const value=args[++i];if(!value||value.startsWith('--'))fail('cli.arguments');
|
|
16
|
+
if(flag==='--repository')repositories.push(value);else values.set(flag,value);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return {values,seen,repositories};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// User argv only; source manifests cannot execute code or choose arbitrary homes.
|
|
23
|
+
export function initialDescriptor(workspace,parsed,{cwd=process.cwd()}={}) {
|
|
24
|
+
const {values,repositories}=parsed,source=values.get('--source');
|
|
25
|
+
if(!source||!values.has('--adapters'))fail('desired.setup-source-and-adapters-required');
|
|
26
|
+
const pipeline={type:'git',ref:values.get('--ref')??'HEAD',subdirectory:values.get('--subdirectory')??'.'};
|
|
27
|
+
if(/^(https|ssh):\/\//.test(source))Object.assign(pipeline,{transport:'remote',url:source});
|
|
28
|
+
else {
|
|
29
|
+
if(source.includes('://'))fail('source.url');
|
|
30
|
+
const relative=path.relative(workspace,path.resolve(cwd,source));
|
|
31
|
+
if(path.isAbsolute(relative))fail('desired.local-source-different-volume');
|
|
32
|
+
Object.assign(pipeline,{transport:'local',path:relative.split(path.sep).join('/')||'.'});
|
|
33
|
+
}
|
|
34
|
+
function pair(value) {
|
|
35
|
+
const at=value.indexOf('=');
|
|
36
|
+
if(at<1||at===value.length-1)fail('desired.layout-argument');
|
|
37
|
+
return [value.slice(0,at),value.slice(at+1)];
|
|
38
|
+
}
|
|
39
|
+
const entries=(repositories.length?repositories:['game=project']).map(pair);
|
|
40
|
+
if(new Set(entries.map(([id])=>id)).size!==entries.length)fail('desired.layout-argument');
|
|
41
|
+
const repos=Object.fromEntries(entries.map(([id,p])=>[id,{path:p,role:'code'}]));
|
|
42
|
+
if(entries.length>1&&!values.has('--documentation'))fail('desired.documentation-required');
|
|
43
|
+
const [repository,docPath]=pair(values.get('--documentation')??entries[0][0]+'=docs');
|
|
44
|
+
const descriptor={schemaVersion:2,pipeline,adapters:values.get('--adapters').split(','),
|
|
45
|
+
layout:{kind:entries.length===1?'single-repo':'multi-repo',repositories:repos,documentation:{repository,path:docPath}}};
|
|
46
|
+
return parseDesiredWorkspace(JSON.stringify(descriptor));
|
|
47
|
+
}
|
package/src/commands/dispatch.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {inspectInstallation} from '../operations/doctor.js';
|
|
2
|
+
import {inspectDesiredInstallation} from '../desired-state/doctor.js';
|
|
2
3
|
import {absoluteRoot} from '../workspace/paths.js';
|
|
3
4
|
import {ContractError,fail} from '../contracts/parse.js';
|
|
4
5
|
import {runRepositoryRecovery} from './repositories.js';
|
|
@@ -30,22 +31,32 @@ import {bindCompatibility,unwrapCompatibility,finishCompatibility,doctorCompatib
|
|
|
30
31
|
import {parseCompat,runCompat} from './compat.js';
|
|
31
32
|
|
|
32
33
|
export const help=`Workspace Pipeline CLI — development preview
|
|
34
|
+
Desired-state (schema 2, executable):
|
|
35
|
+
workspace-pipeline recover-lock --workspace <absolute-directory> [--global] [--yes | --preview] [--json]
|
|
36
|
+
workspace-pipeline reset --workspace <absolute-directory> [--backup] [--yes | --preview] [--json]
|
|
37
|
+
workspace-pipeline remove --workspace <absolute-directory> [--backup] [--yes | --preview] [--json]
|
|
38
|
+
workspace-pipeline setup --workspace <existing-absolute-directory> --source <local-Git-path|https-or-ssh-URL> --adapters <comma-separated-ids> [--ref <Git-ref>] [--subdirectory <source-package-path>]
|
|
39
|
+
[--repository <id=relative-path> ...] [--documentation <id=relative-path>] [--yes | --preview] [--backup] [--json]
|
|
40
|
+
workspace-pipeline update --workspace <absolute-directory> [--yes | --preview] [--backup] [--json]
|
|
41
|
+
setup defaults to game=project, documentation game=docs; multi-repo requires explicit documentation.
|
|
42
|
+
No backup by default. Whole-owned adapter customizations are replaced. No project move/clone.
|
|
43
|
+
Existing schema-1 and advanced lifecycle commands:
|
|
33
44
|
Usage: workspace-pipeline doctor --workspace <absolute-directory> [--recovery <relative-record>] [--json]
|
|
34
45
|
workspace-pipeline update --workspace <absolute-directory> [--yes | --preview] [--json]
|
|
35
46
|
workspace-pipeline compat claude [recover-lock] [--apply --preview <absolute-json-file>] [--json]
|
|
36
47
|
workspace-pipeline launch grok --workspace <absolute-directory> --executable <absolute-native-executable> [--inspect] [--execute]
|
|
37
|
-
workspace-pipeline <init|adopt|wrap> --workspace <absolute-directory> --choices <absolute-json-file> [--manifest <absolute-file>]
|
|
48
|
+
workspace-pipeline <init|adopt|wrap> --workspace <absolute-directory> --choices <absolute-json-file> [--manifest <absolute-file>]
|
|
38
49
|
workspace-pipeline <init|adopt|wrap> --workspace <absolute-directory> --apply --preview <absolute-json-file>
|
|
39
|
-
workspace-pipeline <setup|update> --workspace <absolute-directory> [--manifest <absolute-file>]
|
|
50
|
+
workspace-pipeline <setup|update> --workspace <absolute-directory> [--manifest <absolute-file>]
|
|
40
51
|
workspace-pipeline <setup|update> --workspace <absolute-directory> --apply --preview <absolute-json-file>
|
|
41
52
|
workspace-pipeline rebind --workspace <absolute-directory> --manifest <absolute-file>
|
|
42
|
-
workspace-pipeline update --workspace <absolute-directory> --accept-rebind <absolute-json-file>
|
|
53
|
+
workspace-pipeline update --workspace <absolute-directory> --accept-rebind <absolute-json-file>
|
|
43
54
|
workspace-pipeline reset --workspace <absolute-directory> --all [--to installed|empty]
|
|
44
55
|
workspace-pipeline reset --workspace <absolute-directory> [--providers <ids>] [--bundles <ids>] [--to installed|empty]
|
|
45
56
|
workspace-pipeline reset --workspace <absolute-directory> --apply --preview <absolute-json-file>
|
|
46
57
|
workspace-pipeline <repair|remove> --workspace <absolute-directory> [--providers <comma-separated-ids>] [--bundles <comma-separated-ids>]
|
|
47
58
|
workspace-pipeline <repair|remove> --workspace <absolute-directory> --apply --preview <absolute-json-file>
|
|
48
|
-
workspace-pipeline switch --workspace <absolute-directory> --manifest <absolute-file>
|
|
59
|
+
workspace-pipeline switch --workspace <absolute-directory> --manifest <absolute-file>
|
|
49
60
|
workspace-pipeline switch --workspace <absolute-directory> --apply --preview <absolute-json-file>
|
|
50
61
|
workspace-pipeline continue --workspace <absolute-directory> --recovery <relative-record>
|
|
51
62
|
workspace-pipeline continue --workspace <absolute-directory> --apply --preview <absolute-json-file>
|
|
@@ -59,7 +70,7 @@ Usage: workspace-pipeline doctor --workspace <absolute-directory> [--recovery <r
|
|
|
59
70
|
workspace-pipeline --help
|
|
60
71
|
|
|
61
72
|
Migration commands (development preview):
|
|
62
|
-
workspace-pipeline migration unity preview --workspace <absolute-directory> --manifest <absolute-file>
|
|
73
|
+
workspace-pipeline migration unity preview --workspace <absolute-directory> --manifest <absolute-file>
|
|
63
74
|
workspace-pipeline migration unity inspect --workspace <absolute-directory> --recovery <relative-record> --phase <deactivation|installation|recovery|closeout|compensation>
|
|
64
75
|
workspace-pipeline migration unity apply --workspace <absolute-directory> --preview <absolute-file>
|
|
65
76
|
Preview stages Git externally; inspect is read-only/offline. JSON can contain
|
|
@@ -85,8 +96,8 @@ Built-in workspace adapters: Codex, Claude, Kimi and Grok (configuration deliver
|
|
|
85
96
|
Grok Claude-import suppression requires the scoped launch command, not direct grok.
|
|
86
97
|
Kimi/Grok native session discovery remains separately verified; setup is not runtime certification.
|
|
87
98
|
No native plugin installation, global activation, trust grant or MCP invocation.
|
|
88
|
-
Preview may stage Git source outside the workspace;
|
|
89
|
-
|
|
99
|
+
Preview may stage Git source outside the workspace; selecting a remote source
|
|
100
|
+
permits acquisition during preparation. Save the complete prepared JSON privately and inspect it
|
|
90
101
|
before --apply --preview. It may contain configuration secrets. Apply cannot
|
|
91
102
|
select a new source/manifest or download a replacement for missing staged data.
|
|
92
103
|
No native plugins are installed. Source packages cannot supply executable adapters.
|
|
@@ -218,16 +229,15 @@ function parseRepositories(args) {
|
|
|
218
229
|
const result={command:args[0]==='wrap'?'adopt':args[0]},seen=new Set();
|
|
219
230
|
for(let i=1;i<args.length;i++) {
|
|
220
231
|
const flag=args[i];
|
|
221
|
-
if(!['--workspace','--manifest','--choices','--apply','--preview','--
|
|
232
|
+
if(!['--workspace','--manifest','--choices','--apply','--preview','--json'].includes(flag) || seen.has(flag))fail('cli.arguments');
|
|
222
233
|
seen.add(flag);
|
|
223
234
|
if(flag==='--json')continue;
|
|
224
235
|
if(flag==='--apply'){result.apply=true;continue;}
|
|
225
|
-
if(flag==='--network'){result.network=true;continue;}
|
|
226
236
|
const value=args[++i];if(value===undefined || value.startsWith('--'))fail('cli.arguments');
|
|
227
237
|
result[{'--workspace':'workspace','--manifest':'manifestPath','--choices':'choicesFile','--preview':'previewFile'}[flag]]=absoluteRoot(value);
|
|
228
238
|
}
|
|
229
239
|
if(!result.workspace)fail('cli.workspace-required');
|
|
230
|
-
if(result.apply?(!result.previewFile || result.choicesFile || result.manifestPath
|
|
240
|
+
if(result.apply?(!result.previewFile || result.choicesFile || result.manifestPath):(!result.choicesFile || result.previewFile))fail('cli.arguments');
|
|
231
241
|
return result;
|
|
232
242
|
}
|
|
233
243
|
|
|
@@ -235,12 +245,11 @@ function parseLifecycle(args) {
|
|
|
235
245
|
const result={command:args[0]},seen=new Set(),maintenance=['repair','remove'].includes(args[0]);
|
|
236
246
|
for(let i=1;i<args.length;i++) {
|
|
237
247
|
const flag=args[i];
|
|
238
|
-
const allowed=['--workspace','--apply','--preview','--json',...(result.command==='update'?['--accept-rebind']:[]),...(result.command==='continue'?['--recovery']:maintenance?result.command==='remove'?['--providers','--bundles']:[]:['--manifest'
|
|
248
|
+
const allowed=['--workspace','--apply','--preview','--json',...(result.command==='update'?['--accept-rebind']:[]),...(result.command==='continue'?['--recovery']:maintenance?result.command==='remove'?['--providers','--bundles']:[]:['--manifest'])];
|
|
239
249
|
if(!allowed.includes(flag) || seen.has(flag))fail('cli.arguments');
|
|
240
250
|
seen.add(flag);
|
|
241
251
|
if(flag==='--json')continue;
|
|
242
252
|
if(flag==='--apply'){result.apply=true;continue;}
|
|
243
|
-
if(flag==='--network'){result.network=true;continue;}
|
|
244
253
|
const value=args[++i];if(value===undefined || value.startsWith('--'))fail('cli.arguments');
|
|
245
254
|
if(flag==='--recovery') {
|
|
246
255
|
if(!/^\.pipeline\/transactions\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\/recovery\.json$(?![\s\S])/.test(value))fail('recovery.path');
|
|
@@ -260,7 +269,7 @@ function parseLifecycle(args) {
|
|
|
260
269
|
}
|
|
261
270
|
if(!result.workspace)fail('cli.workspace-required');
|
|
262
271
|
if(result.rebindFile && (result.apply || result.manifestPath))fail('cli.arguments');
|
|
263
|
-
if(result.apply?(!result.previewFile || result.manifestPath || result.
|
|
272
|
+
if(result.apply?(!result.previewFile || result.manifestPath || result.providers || result.bundles):result.previewFile)fail('cli.arguments');
|
|
264
273
|
if(result.command==='switch' && !result.apply && !result.manifestPath)fail('switch.manifest-required');
|
|
265
274
|
if(result.command==='continue' && (result.apply?result.recoveryPath:!result.recoveryPath))fail('continuation.recovery-required');
|
|
266
275
|
return result;
|
|
@@ -277,7 +286,7 @@ async function runLifecycle(command,registry,stdout,stderr,compatibility) {
|
|
|
277
286
|
if(!command.apply) {
|
|
278
287
|
const input={command:command.command,wrapper:command.workspace};
|
|
279
288
|
if(command.manifestPath)input.manifestPath=command.manifestPath;
|
|
280
|
-
if(command.
|
|
289
|
+
if(['setup','update','switch'].includes(command.command))input.network=true;
|
|
281
290
|
if(command.providers)input.providers=command.providers;
|
|
282
291
|
if(command.bundles)input.bundles=command.bundles;
|
|
283
292
|
if(command.recoveryPath)input.recoveryPath=command.recoveryPath;
|
|
@@ -401,7 +410,7 @@ async function runLogs(command,stdout,stderr) {
|
|
|
401
410
|
}finally{await lock.release();}
|
|
402
411
|
}
|
|
403
412
|
|
|
404
|
-
export async function runCli(args,{stdout,stderr,registry=null,compatibility=null}) {
|
|
413
|
+
export async function runCli(args,{stdout,stderr,registry=null,compatibility=null,desiredHostOptions={}}) {
|
|
405
414
|
if(typeof stdout!=='function' || typeof stderr!=='function')fail('cli.transport');
|
|
406
415
|
try {
|
|
407
416
|
const command=parseCommand(args);
|
|
@@ -420,7 +429,9 @@ export async function runCli(args,{stdout,stderr,registry=null,compatibility=nul
|
|
|
420
429
|
['recover-bootstrap','continue-bootstrap','retire-bootstrap'].includes(command.action)?runBootstrapContinuation:runRepositoryRecovery)(command,stdout);
|
|
421
430
|
if(['setup','update','repair','remove','switch','continue'].includes(command.command))return await runLifecycle(command,registry,stdout,stderr,compatibility);
|
|
422
431
|
const options=command.recoveryPath===undefined?{}:{recoveryPath:command.recoveryPath};
|
|
423
|
-
const
|
|
432
|
+
const desired=await inspectDesiredInstallation(command.workspace,desiredHostOptions);
|
|
433
|
+
if(desired && command.recoveryPath!==undefined)fail('desired.legacy-recovery-option');
|
|
434
|
+
const result=desired??await doctorCompatibility(await inspectInstallation(command.workspace,options),compatibility);
|
|
424
435
|
await stdout(JSON.stringify(result)+'\n');
|
|
425
436
|
return result.ready?0:1;
|
|
426
437
|
} catch(error) {
|
package/src/commands/init.js
CHANGED
|
@@ -74,7 +74,7 @@ export async function runRepositoryCommand(command,stdout,stderr) {
|
|
|
74
74
|
if(!command.apply) {
|
|
75
75
|
const choices=(await readRecord(command.choicesFile)).value;
|
|
76
76
|
const prepared=await prepareRepositoryCommand({command:command.command,wrapper:command.workspace,
|
|
77
|
-
manifestPath:command.manifestPath,choices,network:
|
|
77
|
+
manifestPath:command.manifestPath,choices,network:true});
|
|
78
78
|
await stdout(JSON.stringify(prepared)+'\n');return prepared.preview.status==='blocked'?1:0;
|
|
79
79
|
}
|
|
80
80
|
const prepared=(await readRecord(command.previewFile)).value;
|
|
@@ -18,11 +18,10 @@ const inspectors={deactivation:prepareLegacyUnityDeactivationResume,installation
|
|
|
18
18
|
export function parseMigrationCommand(args){
|
|
19
19
|
if(args[1]!=='unity'||!['preview','inspect','apply'].includes(args[2]))fail('cli.arguments');
|
|
20
20
|
const result={command:'migration',action:args[2]},seen=new Set();
|
|
21
|
-
const allowed=result.action==='apply'?['--workspace','--preview','--json']:result.action==='preview'?['--workspace','--manifest','--
|
|
21
|
+
const allowed=result.action==='apply'?['--workspace','--preview','--json']:result.action==='preview'?['--workspace','--manifest','--json']:['--workspace','--recovery','--phase','--json'];
|
|
22
22
|
for(let i=3;i<args.length;i++){
|
|
23
23
|
const flag=args[i];if(!allowed.includes(flag)||seen.has(flag))fail('cli.arguments');seen.add(flag);
|
|
24
24
|
if(flag==='--json')continue;
|
|
25
|
-
if(flag==='--network'){result.network=true;continue;}
|
|
26
25
|
const value=args[++i];if(value===undefined||value.startsWith('--'))fail('cli.arguments');
|
|
27
26
|
if(flag==='--workspace')result.workspace=absoluteRoot(value);
|
|
28
27
|
if(flag==='--manifest')result.manifestPath=absoluteRoot(value);
|
|
@@ -41,7 +40,7 @@ export async function runMigrationCommand(command,stdout,stderr){
|
|
|
41
40
|
if(command.action==='apply')return runMigrationApply(command,stdout,stderr);
|
|
42
41
|
await stderr('Migration preview may contain private configuration bytes. Save it locally; do not publish it. Inspect before explicit apply.\n');
|
|
43
42
|
const before=await readInstallerIdentity();
|
|
44
|
-
const result=command.action==='preview'?await prepareLegacyUnityPreview({wrapper:command.workspace,manifestPath:command.manifestPath,network:
|
|
43
|
+
const result=command.action==='preview'?await prepareLegacyUnityPreview({wrapper:command.workspace,manifestPath:command.manifestPath,network:true,
|
|
45
44
|
tempRoot:await mkdtemp(path.join(tmpdir(),'wpc-migration-prepare-'))}):
|
|
46
45
|
await inspectors[command.phase](command.workspace,command.recoveryPath);
|
|
47
46
|
const bound=await bindMigrationInstaller(result);
|
package/src/commands/output.js
CHANGED
|
@@ -12,6 +12,16 @@ export function formatResult(value) {
|
|
|
12
12
|
if (value.pipeline) lines.push(`Pipeline: ${safe(value.pipeline.id)} @ ${safe(value.pipeline.version)}`,
|
|
13
13
|
`Providers: ${value.pipeline.providers.map(safe).join(', ')}`);
|
|
14
14
|
for (const key of ['configuration', 'transactionEvidence']) if (value[key] !== undefined) lines.push(`${key}: ${safe(value[key])}`);
|
|
15
|
+
if(value.binding) lines.push(`Git source: ${safe(value.binding.source.url??value.binding.source.path)}`,
|
|
16
|
+
`Git revision: ${safe(value.binding.commit)}`, `Source digest: ${safe(value.binding.digest)}`);
|
|
17
|
+
if(value.files) {
|
|
18
|
+
lines.push('', 'Adapter files (changes that setup/update may replace):');
|
|
19
|
+
for(const [key,label] of [['extra','Extra — removed'],['modified','Modified — overwritten'],['missing','Missing — installed'],['blocked','Unsafe — requires attention']]) {
|
|
20
|
+
const items=value.files[key]??[];lines.push(` ${label}: ${items.length}`);
|
|
21
|
+
for(const item of items)lines.push(` ${safe(item.path)}${item.reason?' ('+safe(item.reason)+')':''}`);
|
|
22
|
+
}
|
|
23
|
+
lines.push('Backups are off by default; use --backup when applying.');
|
|
24
|
+
}
|
|
15
25
|
if(value.compatibility)lines.push(`Grok / Claude compatibility: ${safe(value.compatibility.status)}`,
|
|
16
26
|
...(value.compatibility.path?[`User config: ${safe(value.compatibility.path)}`]:[]),
|
|
17
27
|
...(value.compatibility.blockers??[]).map(b=>` - ${safe(b)}`),
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {readFileSync} from 'node:fs';
|
|
2
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
3
|
+
import {fail, parse} from './parse.js';
|
|
4
|
+
import {portablePath} from './semantic.js';
|
|
5
|
+
|
|
6
|
+
const schema = JSON.parse(readFileSync(new URL('../../schemas/pipeline-v2.schema.json', import.meta.url), 'utf8'));
|
|
7
|
+
const validate = new Ajv2020({strict:true, strictRequired:false, ownProperties:true}).compile(schema);
|
|
8
|
+
const configPaths = ['workspace.json', '.codex/config.toml', '.claude/settings.local.json', '.mcp.json', '.grok/config.toml'];
|
|
9
|
+
const overlap = (a,b) => a===b || a.startsWith(b+'/') || b.startsWith(a+'/');
|
|
10
|
+
const folded = value => value.toLowerCase();
|
|
11
|
+
|
|
12
|
+
// Pure compilation only: no filesystem access to source/workspace, no authority
|
|
13
|
+
// to apply. Filesystem/reparse checks and config-field permissions belong to the
|
|
14
|
+
// later materialization boundary. Accept serialized data, never executable objects.
|
|
15
|
+
export function compileDesiredManifest(text, {selected, protectedPaths=[]}={}) {
|
|
16
|
+
const manifest = parse(text, 'json');
|
|
17
|
+
if (!validate(manifest)) fail('desired.schema');
|
|
18
|
+
if (!Array.isArray(selected) || !selected.length || new Set(selected).size!==selected.length ||
|
|
19
|
+
selected.some(id=>typeof id!=='string' || !Object.hasOwn(manifest.adapters,id))) fail('desired.selection');
|
|
20
|
+
if (!Array.isArray(protectedPaths)) fail('desired.protected-paths');
|
|
21
|
+
for (const p of protectedPaths) portablePath(p);
|
|
22
|
+
const files=[], settings=[], providers=new Set();
|
|
23
|
+
// Validate paths even in unselected adapters; reject a malformed source early.
|
|
24
|
+
// Common payload is installed once regardless of selected provider count.
|
|
25
|
+
// $shared cannot be a user adapter ID under the schema's identifier rule.
|
|
26
|
+
const deliveries=[['$shared',{providers:[],files:manifest.files??[],settings:[]}],...Object.entries(manifest.adapters)];
|
|
27
|
+
for (const [id,adapter] of deliveries) {
|
|
28
|
+
for (const file of adapter.files) {
|
|
29
|
+
portablePath(file.source); portablePath(file.target);
|
|
30
|
+
const target=folded(file.target);
|
|
31
|
+
if (['.git','.pipeline',...configPaths,...protectedPaths].some(p=>overlap(target,folded(p)))) fail('desired.scope');
|
|
32
|
+
}
|
|
33
|
+
for (const item of adapter.settings) {
|
|
34
|
+
if (!item.pointer.startsWith('/') || /~(?![01])/.test(item.pointer)) fail('desired.pointer');
|
|
35
|
+
const tokens=item.pointer.slice(1).split('/').map(p=>p.replace(/~1/g,'/').replace(/~0/g,'~'));
|
|
36
|
+
if (tokens.some(p=>!p || ['__proto__','constructor','prototype'].includes(p))) fail('desired.pointer');
|
|
37
|
+
}
|
|
38
|
+
if (id!=='$shared' && !selected.includes(id)) continue;
|
|
39
|
+
for (const provider of adapter.providers) {
|
|
40
|
+
if (providers.has(provider)) fail('desired.provider-overlap');
|
|
41
|
+
providers.add(provider);
|
|
42
|
+
}
|
|
43
|
+
files.push(...adapter.files.map(f=>({...f,adapter:id})));
|
|
44
|
+
settings.push(...adapter.settings.map(s=>({...s,adapter:id})));
|
|
45
|
+
}
|
|
46
|
+
for (let i=0;i<files.length;i++) for (let j=0;j<i;j++) {
|
|
47
|
+
if (overlap(folded(files[i].target),folded(files[j].target))) fail('desired.target-overlap');
|
|
48
|
+
}
|
|
49
|
+
for (let i=0;i<settings.length;i++) for (let j=0;j<i;j++) {
|
|
50
|
+
if (settings[i].target===settings[j].target && overlap(settings[i].pointer,settings[j].pointer)) fail('desired.field-overlap');
|
|
51
|
+
}
|
|
52
|
+
return {schemaVersion:2, pipeline:{id:manifest.id,version:manifest.version},
|
|
53
|
+
adapters:[...selected].sort(),providers:[...providers].sort(),
|
|
54
|
+
files:files.sort((a,b)=>a.target.localeCompare(b.target,'en')),
|
|
55
|
+
settings:settings.sort((a,b)=>(a.target+a.pointer).localeCompare(b.target+b.pointer,'en'))};
|
|
56
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {createHash} from 'node:crypto';
|
|
3
|
+
import {lstat,readFile,mkdir,mkdtemp,writeFile,unlink,rmdir} from 'node:fs/promises';
|
|
4
|
+
import {compileDesiredManifest} from '../contracts/desired-state.js';
|
|
5
|
+
import {fail} from '../contracts/parse.js';
|
|
6
|
+
import {inspectDirectory,pathBudget} from '../workspace/paths.js';
|
|
7
|
+
import {acquireWorkspaceLock,assertLockHeld} from '../operations/lock.js';
|
|
8
|
+
import {materializeDesiredFiles,inspectDesiredFiles} from './inventory.js';
|
|
9
|
+
import {prepareLocalSettings,checkLocalSettings} from './local-settings.js';
|
|
10
|
+
import {writeCheckedFile} from '../operations/apply.js';
|
|
11
|
+
import {acquireGlobalSettings} from './global-settings.js';
|
|
12
|
+
import {installationRecord,readDesiredRecords,assertDesiredTransition,beginDesiredInstall,completeDesiredInstall} from './records.js';
|
|
13
|
+
import {includeRetiredTargets} from './retirement.js';
|
|
14
|
+
import {bindDesiredSource} from './binding.js';
|
|
15
|
+
import {observeTargets} from '../operations/state.js';
|
|
16
|
+
import {prepareDesiredRemoval} from './removal.js';
|
|
17
|
+
|
|
18
|
+
const digest=bytes=>'sha256:'+createHash('sha256').update(bytes).digest('hex');
|
|
19
|
+
const depth=name=>name.split('/').length;
|
|
20
|
+
|
|
21
|
+
// Internal engine. Legacy migration and public command authorization remain
|
|
22
|
+
// separate. hostOptions (including fault-injection boundary) are trusted code.
|
|
23
|
+
export async function applyDesiredFiles({workspace,manifest,selected,protectedPaths,source,binding,backup=false},hostOptions={}) {
|
|
24
|
+
if(typeof backup!=='boolean' || !Array.isArray(protectedPaths))fail('desired.apply-options');
|
|
25
|
+
const removing=hostOptions.removeExisting===true;
|
|
26
|
+
const compiled=removing?null:compileDesiredManifest(manifest,{selected,protectedPaths});
|
|
27
|
+
const delivered=removing?null:materializeDesiredFiles(compiled,source);
|
|
28
|
+
if(!removing)binding=bindDesiredSource(binding,source);
|
|
29
|
+
const lock=await acquireWorkspaceLock(workspace,{purpose:'desired-state'});
|
|
30
|
+
workspace=lock.workspace;
|
|
31
|
+
let backupDirectory=null,globalBackupDirectory=null,global=null,mutations=0;
|
|
32
|
+
try {
|
|
33
|
+
const records=await readDesiredRecords(workspace,{allowLegacyMigration:hostOptions.migrateLegacy===true});
|
|
34
|
+
if(hostOptions.expectedRecords &&
|
|
35
|
+
(hostOptions.expectedRecords.installed!==(records.installed?.hash??null) ||
|
|
36
|
+
hostOptions.expectedRecords.pending!==(records.pending?.hash??null)))fail('desired.installation-state-changed');
|
|
37
|
+
if(removing && hostOptions.removalExpected &&
|
|
38
|
+
(hostOptions.removalExpected.installed!==(records.installed?.hash??null) ||
|
|
39
|
+
hostOptions.removalExpected.pending!==(records.pending?.hash??null)))fail('desired.removal-state-changed');
|
|
40
|
+
const previousRoots=Object.values(records.installed?.value.binding?.layout.repositories??{}).map(r=>r.path);
|
|
41
|
+
const protectedDuringMigration=[...new Set([...protectedPaths,...previousRoots,...(records.legacy?.protectedPaths??[])])];
|
|
42
|
+
if(!removing)compileDesiredManifest(manifest,{selected,protectedPaths:protectedDuringMigration});
|
|
43
|
+
const removal=removing?await prepareDesiredRemoval(workspace,records,protectedDuringMigration):null;
|
|
44
|
+
if(removal?.alreadyRemoved)return {status:'removed',mutations:0,backupDirectory:null,globalSettings:'preserved'};
|
|
45
|
+
const retirement=removal??includeRetiredTargets(records.installed?.value??records.legacy?.ownership,compiled,delivered,protectedDuringMigration);
|
|
46
|
+
const desired=retirement.desired;
|
|
47
|
+
const report=await inspectDesiredFiles(workspace,desired);
|
|
48
|
+
if(!report.ready)fail('desired.unsafe-target');
|
|
49
|
+
const localOperations=retirement.settings.filter(op=>op.target!=='grok.user');
|
|
50
|
+
const settings=await prepareLocalSettings(workspace,localOperations);
|
|
51
|
+
global=await acquireGlobalSettings(retirement.settings.filter(op=>op.target==='grok.user'),{...hostOptions,workspace});
|
|
52
|
+
const next=removal?.next??installationRecord(compiled,delivered,{protectedPaths,binding,
|
|
53
|
+
globalConfigPath:compiled.settings.some(op=>op.target==='grok.user')?global?.path??null:null});
|
|
54
|
+
assertDesiredTransition(records,next);
|
|
55
|
+
const createDescriptor=!removing && hostOptions.createDescriptor===true;
|
|
56
|
+
if(createDescriptor) {
|
|
57
|
+
if(!binding)fail('desired.setup-binding-required');
|
|
58
|
+
const [entry]=await observeTargets(workspace,['workspace.json']);
|
|
59
|
+
if(entry.bytes!==null)fail('desired.setup-already-configured');
|
|
60
|
+
}
|
|
61
|
+
async function saveDescriptor() {
|
|
62
|
+
if(!createDescriptor)return;
|
|
63
|
+
const descriptor={schemaVersion:2,pipeline:binding.source,adapters:compiled.adapters,layout:binding.layout};
|
|
64
|
+
mutations++;
|
|
65
|
+
await writeCheckedFile(lock,'workspace.json',null,Buffer.from(JSON.stringify(descriptor,null,2)+'\n'));
|
|
66
|
+
}
|
|
67
|
+
const changedSettings=settings.filter(item=>item.changed);
|
|
68
|
+
const filesChanged=Boolean(report.extra.length || report.modified.length || report.missing.length);
|
|
69
|
+
if(!filesChanged && !changedSettings.length && !global?.changed) {
|
|
70
|
+
const pendingHash=createDescriptor||removing?await beginDesiredInstall(lock,records,next):records.pending?.hash??null;
|
|
71
|
+
await saveDescriptor();
|
|
72
|
+
await completeDesiredInstall(lock,records,next,pendingHash);
|
|
73
|
+
return {status:removing?'removed':mutations?'applied':'unchanged',backupDirectory:null,mutations,...(removing?{globalSettings:'preserved'}:{})};
|
|
74
|
+
}
|
|
75
|
+
const expected=new Map(desired.entries.map(e=>[e.path,e]));
|
|
76
|
+
const current=[...report.extra,
|
|
77
|
+
...report.modified.map(e=>({path:e.path,kind:e.currentKind,hash:e.beforeHash})),
|
|
78
|
+
...report.unchanged.map(e=>({path:e.path,kind:e.kind,hash:expected.get(e.path).hash}))];
|
|
79
|
+
function target(name) {
|
|
80
|
+
// Observed custom filenames may be Unicode, unlike source manifest paths.
|
|
81
|
+
if(typeof name!=='string' || name.split('/').some(p=>!p || p==='.' || p==='..' || p.toLowerCase()==='.git') ||
|
|
82
|
+
/[\\:\u0000-\u001f\u007f]/.test(name))fail('desired.observed-path');
|
|
83
|
+
if(!desired.scopes.some(s=>name===s.path || name.startsWith(s.path+'/')))fail('desired.scope');
|
|
84
|
+
const result=path.resolve(workspace,...name.split('/')),rel=path.relative(workspace,result);
|
|
85
|
+
if(!rel || rel==='..' || rel.startsWith('..'+path.sep) || path.isAbsolute(rel))fail('desired.scope');
|
|
86
|
+
pathBudget(result);return result;
|
|
87
|
+
}
|
|
88
|
+
async function check(entry) {
|
|
89
|
+
await assertLockHeld(lock);
|
|
90
|
+
const file=target(entry.path);
|
|
91
|
+
await inspectDirectory(path.dirname(file));
|
|
92
|
+
const stat=await lstat(file);
|
|
93
|
+
if(stat.isSymbolicLink() || (entry.kind==='file' && (!stat.isFile() || stat.nlink>1)) ||
|
|
94
|
+
(entry.kind==='directory' && !stat.isDirectory()))fail('desired.target-changed');
|
|
95
|
+
if(entry.kind==='file') {
|
|
96
|
+
const bytes=await readFile(file);
|
|
97
|
+
if(digest(bytes)!==entry.hash)fail('desired.target-changed');
|
|
98
|
+
return bytes;
|
|
99
|
+
}
|
|
100
|
+
await inspectDirectory(file);return null;
|
|
101
|
+
}
|
|
102
|
+
// Validate the complete observed set before optional backup or deletion.
|
|
103
|
+
for(const entry of current)await check(entry);
|
|
104
|
+
await checkLocalSettings(workspace,settings);
|
|
105
|
+
await global?.check();
|
|
106
|
+
const pendingHash=await beginDesiredInstall(lock,records,next);
|
|
107
|
+
await saveDescriptor();
|
|
108
|
+
await hostOptions.boundary?.('before-content');
|
|
109
|
+
if(backup) {
|
|
110
|
+
const parent=path.join(workspace,'.pipeline','backups');
|
|
111
|
+
await inspectDirectory(parent);await mkdir(parent,{recursive:true});await inspectDirectory(parent);
|
|
112
|
+
backupDirectory=await mkdtemp(path.join(parent,'desired-'));
|
|
113
|
+
for(const entry of current.sort((a,b)=>depth(a.path)-depth(b.path))) {
|
|
114
|
+
const bytes=await check(entry),destination=path.join(backupDirectory,...entry.path.split('/'));
|
|
115
|
+
await mkdir(path.dirname(destination),{recursive:true});
|
|
116
|
+
if(entry.kind==='directory')await mkdir(destination,{recursive:true});
|
|
117
|
+
else {
|
|
118
|
+
await writeFile(destination,bytes,{flag:'wx',mode:0o600});
|
|
119
|
+
if(digest(await readFile(destination))!==entry.hash)fail('desired.backup-verification');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Shared configs may contain secrets: backups stay private/local and are
|
|
123
|
+
// made only on explicit request. Their contents are never put in reports.
|
|
124
|
+
const observed=await checkLocalSettings(workspace,settings);
|
|
125
|
+
for(const item of changedSettings.filter(item=>item.beforeHash!==null)) {
|
|
126
|
+
const destination=path.join(backupDirectory,...item.path.split('/'));
|
|
127
|
+
await mkdir(path.dirname(destination),{recursive:true});
|
|
128
|
+
await writeFile(destination,observed.find(o=>o.path===item.path).bytes,{flag:'wx',mode:0o600});
|
|
129
|
+
if(digest(await readFile(destination))!==item.beforeHash)fail('desired.backup-verification');
|
|
130
|
+
}
|
|
131
|
+
globalBackupDirectory=await global?.backup()??null;
|
|
132
|
+
}
|
|
133
|
+
await checkLocalSettings(workspace,settings);
|
|
134
|
+
await global?.check();
|
|
135
|
+
// Enumerated, non-recursive deletion: unexpected new directory contents make
|
|
136
|
+
// rmdir fail instead of being swept away. No old contents retained by default.
|
|
137
|
+
for(const entry of (filesChanged?current:[]).sort((a,b)=>depth(b.path)-depth(a.path))) {
|
|
138
|
+
await check(entry);
|
|
139
|
+
if(entry.kind==='directory')await rmdir(target(entry.path));
|
|
140
|
+
else await unlink(target(entry.path));
|
|
141
|
+
mutations++;
|
|
142
|
+
await hostOptions.boundary?.('after-delete');
|
|
143
|
+
}
|
|
144
|
+
for(const entry of (filesChanged?[...desired.entries]:[]).sort((a,b)=>depth(a.path)-depth(b.path))) {
|
|
145
|
+
await assertLockHeld(lock);
|
|
146
|
+
const file=target(entry.path);
|
|
147
|
+
await inspectDirectory(path.dirname(file));
|
|
148
|
+
await mkdir(path.dirname(file),{recursive:true});await inspectDirectory(path.dirname(file));
|
|
149
|
+
if(entry.kind==='directory')await mkdir(file);
|
|
150
|
+
else await writeFile(file,entry.bytes,{flag:'wx',mode:0o600});
|
|
151
|
+
mutations++;
|
|
152
|
+
}
|
|
153
|
+
for(const item of changedSettings) {
|
|
154
|
+
// Count attempted writes conservatively: an I/O failure can follow a
|
|
155
|
+
// successful rename or a partially written new file.
|
|
156
|
+
mutations++;
|
|
157
|
+
await writeCheckedFile(lock,item.path,item.beforeHash,item.bytes);
|
|
158
|
+
}
|
|
159
|
+
if(global?.changed){mutations++;await global.apply();}
|
|
160
|
+
const after=await inspectDesiredFiles(workspace,desired);
|
|
161
|
+
if(!after.ready || after.extra.length || after.modified.length || after.missing.length)fail('desired.readback');
|
|
162
|
+
const settingsAfter=await prepareLocalSettings(workspace,localOperations);
|
|
163
|
+
if(settingsAfter.some(item=>item.changed))fail('desired.config-readback');
|
|
164
|
+
await hostOptions.boundary?.('before-record');
|
|
165
|
+
await completeDesiredInstall(lock,records,next,pendingHash);
|
|
166
|
+
return {status:removing?'removed':'applied',backupDirectory,globalBackupDirectory,globalConfigPath:global?.path??null,mutations,...(removing?{globalSettings:'preserved'}:{})};
|
|
167
|
+
} catch(error) {
|
|
168
|
+
// Partial application is explicit; there is no implicit rollback promise.
|
|
169
|
+
error.desiredState={status:mutations?'partial':'not-applied',mutations,backupDirectory,globalBackupDirectory};
|
|
170
|
+
throw error;
|
|
171
|
+
} finally {try{await global?.release();}finally{await lock.release();}}
|
|
172
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {fail,parse} from '../contracts/parse.js';
|
|
2
|
+
import {validateStructure} from '../contracts/validate.js';
|
|
3
|
+
import {contractDigest,validateLayoutReferences} from '../contracts/semantic.js';
|
|
4
|
+
import {sha256} from '../source/inventory.js';
|
|
5
|
+
|
|
6
|
+
// Provenance of an acquired Git package, not a signature or a pin on project rules.
|
|
7
|
+
export function validateDesiredBinding(value) {
|
|
8
|
+
const binding=parse(JSON.stringify(value),'json');
|
|
9
|
+
if(!binding || Object.keys(binding).sort().join(',')!=='commit,digest,layout,source' ||
|
|
10
|
+
!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(binding.commit) ||
|
|
11
|
+
!/^sha256:[a-f0-9]{64}$/.test(binding.digest))fail('desired.binding-invalid');
|
|
12
|
+
validateStructure('workspace',{schemaVersion:1,pipeline:binding.source,providers:['codex'],layout:binding.layout});
|
|
13
|
+
validateLayoutReferences(binding.layout);
|
|
14
|
+
return binding;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function bindDesiredSource(value,source) {
|
|
18
|
+
if(value===undefined)return undefined; // Internal fixtures/older records may lack Git provenance.
|
|
19
|
+
const binding=validateDesiredBinding(value);
|
|
20
|
+
const actual=contractDigest(Object.fromEntries([...source].map(([name,bytes])=>[name,sha256(bytes)])));
|
|
21
|
+
if(actual!==binding.digest)fail('desired.binding-source-mismatch');
|
|
22
|
+
return binding;
|
|
23
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import {homedir} from 'node:os';
|
|
2
|
+
import {parseTOML,getStaticTOMLValue} from 'toml-eslint-parser';
|
|
3
|
+
import {parse,fail,ContractError,MAX_INPUT_BYTES} from '../contracts/parse.js';
|
|
4
|
+
import {contractDigest} from '../contracts/semantic.js';
|
|
5
|
+
import {absoluteRoot} from '../workspace/paths.js';
|
|
6
|
+
import {observeTargets} from '../operations/state.js';
|
|
7
|
+
import {readTOMLField} from '../operations/toml-fields.js';
|
|
8
|
+
import {utf8} from '../source/inventory.js';
|
|
9
|
+
import {readDesiredRecords,installedPath,pendingPath} from './records.js';
|
|
10
|
+
import {includeRetiredTargets} from './retirement.js';
|
|
11
|
+
import {inspectDesiredFiles} from './inventory.js';
|
|
12
|
+
import {parseDesiredWorkspace} from './source.js';
|
|
13
|
+
import {inspectDesiredLock} from './lock-recovery.js';
|
|
14
|
+
|
|
15
|
+
const paths={
|
|
16
|
+
'codex.workspace':'.codex/config.toml','claude.workspace':'.claude/settings.local.json',
|
|
17
|
+
'claude.mcp':'.mcp.json','grok.workspace':'.grok/config.toml','grok.user':'.grok/config.toml'
|
|
18
|
+
};
|
|
19
|
+
function field(bytes,setting) {
|
|
20
|
+
if(bytes===null)return {present:false};
|
|
21
|
+
if(bytes.length>MAX_INPUT_BYTES)fail('desired.config-size');
|
|
22
|
+
if(['codex.workspace','grok.workspace'].includes(setting.target))return readTOMLField(bytes,setting.pointer);
|
|
23
|
+
let value;
|
|
24
|
+
if(setting.target==='grok.user') {
|
|
25
|
+
try{value=getStaticTOMLValue(parseTOML(utf8(bytes),{tomlVersion:'1.0'}));}catch{fail('desired.config-syntax');}
|
|
26
|
+
} else value=parse(utf8(bytes),'json');
|
|
27
|
+
for(const part of setting.pointer.slice(1).split('/')) {
|
|
28
|
+
if(value===null || typeof value!=='object' || Array.isArray(value))fail('desired.config-ancestor');
|
|
29
|
+
if(!Object.hasOwn(value,part))return {present:false};
|
|
30
|
+
value=value[part];
|
|
31
|
+
}
|
|
32
|
+
return {present:true,value};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Read-only and local: no source fetch, lock, settings change, harness or MCP.
|
|
36
|
+
// null routes old-format installations to the existing doctor implementation.
|
|
37
|
+
export async function inspectDesiredInstallation(workspace,{userHome=homedir(),protectedPaths=[]}={}) {
|
|
38
|
+
workspace=absoluteRoot(workspace);
|
|
39
|
+
const markers=await observeTargets(workspace,[installedPath,pendingPath]);
|
|
40
|
+
if(markers.every(item=>item.bytes===null))return null;
|
|
41
|
+
const records=await readDesiredRecords(workspace),record=(records.pending??records.installed).value;
|
|
42
|
+
if(record.intent==='removed')return {workspace,ready:false,status:'not-installed',
|
|
43
|
+
pipeline:{...record.pipeline,providers:[]},binding:record.binding??null,configuration:'not-installed',
|
|
44
|
+
diagnostics:[{code:'desired.not-installed',subject:installedPath}],globalSettings:'preserved',
|
|
45
|
+
limits:'Delivery removed; saved workspace declaration and shared user settings were preserved. No runtime checks.'};
|
|
46
|
+
const diagnostics=[];
|
|
47
|
+
const locks=[];
|
|
48
|
+
for(const global of [false,...(record.settings.some(s=>s.target==='grok.user')?[true]:[])]) {
|
|
49
|
+
const target=global?'global':'workspace';
|
|
50
|
+
try {
|
|
51
|
+
const observed=await inspectDesiredLock(workspace,{userHome,global});
|
|
52
|
+
locks.push({target,path:observed.path,status:observed.status});
|
|
53
|
+
if(observed.status!=='absent')diagnostics.push({code:'desired.'+target+'-lock-present',subject:observed.path});
|
|
54
|
+
}catch(error){
|
|
55
|
+
locks.push({target,status:'unverified'});
|
|
56
|
+
diagnostics.push({code:error instanceof ContractError?error.code:'desired.lock-read',subject:target+' lock'});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const protectedRoots=new Set(protectedPaths);
|
|
60
|
+
if(record.binding) {
|
|
61
|
+
for(const repo of Object.values(record.binding.layout.repositories))protectedRoots.add(repo.path);
|
|
62
|
+
try {
|
|
63
|
+
const [entry]=await observeTargets(workspace,['workspace.json']);
|
|
64
|
+
if(entry.bytes===null)diagnostics.push({code:'desired.workspace-missing',subject:'workspace.json'});
|
|
65
|
+
else {
|
|
66
|
+
const descriptor=parseDesiredWorkspace(utf8(entry.bytes));
|
|
67
|
+
for(const repo of Object.values(descriptor.layout.repositories))protectedRoots.add(repo.path);
|
|
68
|
+
if(contractDigest(descriptor.pipeline)!==contractDigest(record.binding.source) ||
|
|
69
|
+
contractDigest(descriptor.layout)!==contractDigest(record.binding.layout) ||
|
|
70
|
+
(record.adapters && contractDigest([...descriptor.adapters].sort())!==contractDigest([...record.adapters].sort())))
|
|
71
|
+
diagnostics.push({code:'desired.workspace-different',subject:'workspace.json'});
|
|
72
|
+
}
|
|
73
|
+
}catch(error){
|
|
74
|
+
diagnostics.push({code:'desired.workspace-invalid',subject:'workspace.json',
|
|
75
|
+
reason:error instanceof ContractError?error.code:'desired.workspace-read'});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const desired={scopes:record.scopes,entries:record.files};
|
|
79
|
+
includeRetiredTargets(record,{settings:[]},desired,[...protectedRoots]);
|
|
80
|
+
const files=await inspectDesiredFiles(workspace,desired);
|
|
81
|
+
if(records.pending)diagnostics.push({code:'desired.installation-incomplete',subject:pendingPath});
|
|
82
|
+
for(const [category,items] of Object.entries(files)) {
|
|
83
|
+
if(!['extra','modified','missing','blocked'].includes(category))continue;
|
|
84
|
+
for(const item of items)diagnostics.push({code:'desired.file-'+category,subject:item.path,...(item.reason?{reason:item.reason}:{})});
|
|
85
|
+
}
|
|
86
|
+
const cache=new Map(),settings=[];
|
|
87
|
+
for(const setting of record.settings) {
|
|
88
|
+
try {
|
|
89
|
+
if(!cache.has(setting.target)) {
|
|
90
|
+
const [observed]=await observeTargets(setting.target==='grok.user'?absoluteRoot(userHome):workspace,[paths[setting.target]]);
|
|
91
|
+
cache.set(setting.target,observed.bytes);
|
|
92
|
+
}
|
|
93
|
+
const found=field(cache.get(setting.target),setting);
|
|
94
|
+
const matches=setting.operation==='remove'?!found.present:found.present && contractDigest(found.value)===setting.valueHash;
|
|
95
|
+
settings.push({target:setting.target,pointer:setting.pointer,status:matches?'pass':'different'});
|
|
96
|
+
if(!matches)diagnostics.push({code:'desired.setting-different',subject:setting.target,pointer:setting.pointer});
|
|
97
|
+
}catch(error){
|
|
98
|
+
settings.push({target:setting.target,pointer:setting.pointer,status:'unverified'});
|
|
99
|
+
diagnostics.push({code:error instanceof ContractError?error.code:'desired.config-read',subject:setting.target,pointer:setting.pointer});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return {workspace,ready:diagnostics.length===0,status:records.pending?'incomplete':diagnostics.length?'drift':'ready',
|
|
103
|
+
pipeline:{...record.pipeline,providers:record.providers},binding:record.binding??null,configuration:diagnostics.length?'fail':'pass',
|
|
104
|
+
diagnostics,files,settings,locks,backupDefault:false,
|
|
105
|
+
limits:'Installed-record comparison only; no source authenticity, runtime, model-visible skills or MCP certification.'};
|
|
106
|
+
}
|