@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.
@@ -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
+ }
@@ -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>] [--network]
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>] [--network]
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> [--network]
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> [--network]
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> [--network]
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; --network explicitly permits
89
- remote acquisition. Save the complete prepared JSON privately and inspect it
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','--network','--json'].includes(flag) || seen.has(flag))fail('cli.arguments');
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 || result.network):(!result.choicesFile || result.previewFile))fail('cli.arguments');
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','--network'])];
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.network || result.providers || result.bundles):result.previewFile)fail('cli.arguments');
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.network)input.network=true;
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 result=await doctorCompatibility(await inspectInstallation(command.workspace,options),compatibility);
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) {
@@ -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:command.network??false});
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','--network','--json']:['--workspace','--recovery','--phase','--json'];
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:command.network===true,
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);
@@ -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
+ }