@6reduk/workspace-pipeline 0.5.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.
Files changed (36) hide show
  1. package/README.md +19 -1
  2. package/docs/desired-state.md +243 -0
  3. package/docs/lifecycle-cli.md +57 -6
  4. package/docs/migrations/unity.md +1 -1
  5. package/docs/rebind.md +1 -1
  6. package/docs/repositories.md +2 -2
  7. package/package.json +1 -1
  8. package/schemas/pipeline-v2.schema.json +43 -0
  9. package/src/cli.js +18 -2
  10. package/src/commands/desired-lifecycle.js +93 -0
  11. package/src/commands/desired-recover.js +28 -0
  12. package/src/commands/desired-remove.js +48 -0
  13. package/src/commands/desired-setup.js +47 -0
  14. package/src/commands/dispatch.js +28 -16
  15. package/src/commands/init.js +1 -1
  16. package/src/commands/interactive-update.js +59 -0
  17. package/src/commands/migration.js +2 -3
  18. package/src/commands/output.js +12 -1
  19. package/src/contracts/desired-state.js +56 -0
  20. package/src/desired-state/apply-files.js +172 -0
  21. package/src/desired-state/binding.js +23 -0
  22. package/src/desired-state/doctor.js +106 -0
  23. package/src/desired-state/global-settings.js +74 -0
  24. package/src/desired-state/inventory.js +102 -0
  25. package/src/desired-state/legacy-state.js +37 -0
  26. package/src/desired-state/local-settings.js +40 -0
  27. package/src/desired-state/lock-recovery.js +78 -0
  28. package/src/desired-state/records.js +75 -0
  29. package/src/desired-state/removal.js +32 -0
  30. package/src/desired-state/reset.js +27 -0
  31. package/src/desired-state/retirement.js +37 -0
  32. package/src/desired-state/settings.js +125 -0
  33. package/src/desired-state/source.js +41 -0
  34. package/src/operations/lock.js +3 -2
  35. package/src/source/desired-package.js +33 -0
  36. package/src/source/git.js +7 -3
@@ -0,0 +1,48 @@
1
+ import {ContractError,fail} from '../contracts/parse.js';
2
+ import {absoluteRoot} from '../workspace/paths.js';
3
+ import {observeTargets} from '../operations/state.js';
4
+ import {readDesiredRecords,installedPath,pendingPath} from '../desired-state/records.js';
5
+ import {prepareDesiredRemoval} from '../desired-state/removal.js';
6
+ import {inspectDesiredFiles} from '../desired-state/inventory.js';
7
+ import {prepareLocalSettings} from '../desired-state/local-settings.js';
8
+ import {applyDesiredFiles} from '../desired-state/apply-files.js';
9
+ import {desiredArguments} from './desired-setup.js';
10
+ import {safeTerminalText as safe} from './output.js';
11
+
12
+ export async function tryDesiredRemove(args,options,{isTTY=false,confirm,display,hostOptions={}}={}) {
13
+ if(args[0]!=='remove')return null;
14
+ const at=args.indexOf('--workspace');if(at<0||!args[at+1])return null;
15
+ try {
16
+ const workspace=absoluteRoot(args[at+1]);
17
+ const markers=await observeTargets(workspace,[installedPath,pendingPath]);
18
+ if(markers.every(m=>m.bytes===null))return null;
19
+ const {seen}=desiredArguments(args);
20
+ if([...seen].some(f=>!['--workspace','--preview','--yes','--json','--backup'].includes(f)))fail('cli.arguments');
21
+ const dry=seen.has('--preview'),yes=seen.has('--yes');
22
+ if(dry&&yes)fail('cli.arguments');
23
+ if(!dry&&!yes&&(!isTTY||!confirm))fail('cli.confirmation-required');
24
+ const records=await readDesiredRecords(workspace),prepared=await prepareDesiredRemoval(workspace,records);
25
+ if(prepared.alreadyRemoved){await options.stdout(JSON.stringify({workspace,status:'removed',applied:false,globalSettings:'preserved'})+'\n');return 0;}
26
+ const files=await inspectDesiredFiles(workspace,prepared.desired);
27
+ const local=await prepareLocalSettings(workspace,prepared.settings);
28
+ const summary={command:'remove',workspace,status:'preview',applied:false,files,
29
+ localSettings:local.map(s=>({path:s.path,changed:s.changed})),backup:seen.has('--backup'),globalSettings:'preserved',
30
+ preserved:['workspace.json','project repositories','.pipeline journals and backups']};
31
+ if(dry){await options.stdout(JSON.stringify(summary)+'\n');return files.ready?0:1;}
32
+ const lines=['Workspace Pipeline — remove',`Workspace: ${safe(workspace)}`,`Backup: ${summary.backup?'enabled':'OFF'}`,
33
+ 'Remove all recorded adapter files, including custom files inside owned directories.',
34
+ 'Preserve repositories, workspace.json, journals/backups and shared global settings.'];
35
+ for(const item of files.extra)lines.push(` remove: ${safe(item.path)}`);
36
+ for(const item of files.blocked)lines.push(` blocked: ${safe(item.path)}`);
37
+ for(const item of summary.localSettings)lines.push(` settings: ${safe(item.path)} (${item.changed?'remove owned fields':'unchanged'})`);
38
+ if(display)await display(lines.join('\n')+'\n');else await options.stderr(lines.join('\n')+'\n');
39
+ if(!files.ready)fail('desired.unsafe-target');
40
+ if(!yes&&await confirm()!==true){await options.stdout(JSON.stringify({status:'cancelled',applied:false})+'\n');return 0;}
41
+ const result=await applyDesiredFiles({workspace,protectedPaths:prepared.protectedPaths,backup:summary.backup},
42
+ {...hostOptions,removeExisting:true,removalExpected:{installed:records.installed?.hash??null,pending:records.pending?.hash??null}});
43
+ await options.stdout(JSON.stringify({...result,workspace,preserved:summary.preserved})+'\n');return 0;
44
+ }catch(error){
45
+ await options.stderr(JSON.stringify({error:error instanceof ContractError?error.code:'cli.desired-remove-io',
46
+ ...(error.desiredState?{application:error.desiredState}:{})})+'\n');return 2;
47
+ }
48
+ }
@@ -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,21 +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]
45
+ workspace-pipeline update --workspace <absolute-directory> [--yes | --preview] [--json]
34
46
  workspace-pipeline compat claude [recover-lock] [--apply --preview <absolute-json-file>] [--json]
35
47
  workspace-pipeline launch grok --workspace <absolute-directory> --executable <absolute-native-executable> [--inspect] [--execute]
36
- 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>]
37
49
  workspace-pipeline <init|adopt|wrap> --workspace <absolute-directory> --apply --preview <absolute-json-file>
38
- workspace-pipeline <setup|update> --workspace <absolute-directory> [--manifest <absolute-file>] [--network]
50
+ workspace-pipeline <setup|update> --workspace <absolute-directory> [--manifest <absolute-file>]
39
51
  workspace-pipeline <setup|update> --workspace <absolute-directory> --apply --preview <absolute-json-file>
40
52
  workspace-pipeline rebind --workspace <absolute-directory> --manifest <absolute-file>
41
- workspace-pipeline update --workspace <absolute-directory> --accept-rebind <absolute-json-file> [--network]
53
+ workspace-pipeline update --workspace <absolute-directory> --accept-rebind <absolute-json-file>
42
54
  workspace-pipeline reset --workspace <absolute-directory> --all [--to installed|empty]
43
55
  workspace-pipeline reset --workspace <absolute-directory> [--providers <ids>] [--bundles <ids>] [--to installed|empty]
44
56
  workspace-pipeline reset --workspace <absolute-directory> --apply --preview <absolute-json-file>
45
57
  workspace-pipeline <repair|remove> --workspace <absolute-directory> [--providers <comma-separated-ids>] [--bundles <comma-separated-ids>]
46
58
  workspace-pipeline <repair|remove> --workspace <absolute-directory> --apply --preview <absolute-json-file>
47
- workspace-pipeline switch --workspace <absolute-directory> --manifest <absolute-file> [--network]
59
+ workspace-pipeline switch --workspace <absolute-directory> --manifest <absolute-file>
48
60
  workspace-pipeline switch --workspace <absolute-directory> --apply --preview <absolute-json-file>
49
61
  workspace-pipeline continue --workspace <absolute-directory> --recovery <relative-record>
50
62
  workspace-pipeline continue --workspace <absolute-directory> --apply --preview <absolute-json-file>
@@ -58,7 +70,7 @@ Usage: workspace-pipeline doctor --workspace <absolute-directory> [--recovery <r
58
70
  workspace-pipeline --help
59
71
 
60
72
  Migration commands (development preview):
61
- workspace-pipeline migration unity preview --workspace <absolute-directory> --manifest <absolute-file> [--network]
73
+ workspace-pipeline migration unity preview --workspace <absolute-directory> --manifest <absolute-file>
62
74
  workspace-pipeline migration unity inspect --workspace <absolute-directory> --recovery <relative-record> --phase <deactivation|installation|recovery|closeout|compensation>
63
75
  workspace-pipeline migration unity apply --workspace <absolute-directory> --preview <absolute-file>
64
76
  Preview stages Git externally; inspect is read-only/offline. JSON can contain
@@ -84,8 +96,8 @@ Built-in workspace adapters: Codex, Claude, Kimi and Grok (configuration deliver
84
96
  Grok Claude-import suppression requires the scoped launch command, not direct grok.
85
97
  Kimi/Grok native session discovery remains separately verified; setup is not runtime certification.
86
98
  No native plugin installation, global activation, trust grant or MCP invocation.
87
- Preview may stage Git source outside the workspace; --network explicitly permits
88
- 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
89
101
  before --apply --preview. It may contain configuration secrets. Apply cannot
90
102
  select a new source/manifest or download a replacement for missing staged data.
91
103
  No native plugins are installed. Source packages cannot supply executable adapters.
@@ -217,16 +229,15 @@ function parseRepositories(args) {
217
229
  const result={command:args[0]==='wrap'?'adopt':args[0]},seen=new Set();
218
230
  for(let i=1;i<args.length;i++) {
219
231
  const flag=args[i];
220
- 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');
221
233
  seen.add(flag);
222
234
  if(flag==='--json')continue;
223
235
  if(flag==='--apply'){result.apply=true;continue;}
224
- if(flag==='--network'){result.network=true;continue;}
225
236
  const value=args[++i];if(value===undefined || value.startsWith('--'))fail('cli.arguments');
226
237
  result[{'--workspace':'workspace','--manifest':'manifestPath','--choices':'choicesFile','--preview':'previewFile'}[flag]]=absoluteRoot(value);
227
238
  }
228
239
  if(!result.workspace)fail('cli.workspace-required');
229
- 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');
230
241
  return result;
231
242
  }
232
243
 
@@ -234,12 +245,11 @@ function parseLifecycle(args) {
234
245
  const result={command:args[0]},seen=new Set(),maintenance=['repair','remove'].includes(args[0]);
235
246
  for(let i=1;i<args.length;i++) {
236
247
  const flag=args[i];
237
- 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'])];
238
249
  if(!allowed.includes(flag) || seen.has(flag))fail('cli.arguments');
239
250
  seen.add(flag);
240
251
  if(flag==='--json')continue;
241
252
  if(flag==='--apply'){result.apply=true;continue;}
242
- if(flag==='--network'){result.network=true;continue;}
243
253
  const value=args[++i];if(value===undefined || value.startsWith('--'))fail('cli.arguments');
244
254
  if(flag==='--recovery') {
245
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');
@@ -259,7 +269,7 @@ function parseLifecycle(args) {
259
269
  }
260
270
  if(!result.workspace)fail('cli.workspace-required');
261
271
  if(result.rebindFile && (result.apply || result.manifestPath))fail('cli.arguments');
262
- 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');
263
273
  if(result.command==='switch' && !result.apply && !result.manifestPath)fail('switch.manifest-required');
264
274
  if(result.command==='continue' && (result.apply?result.recoveryPath:!result.recoveryPath))fail('continuation.recovery-required');
265
275
  return result;
@@ -276,7 +286,7 @@ async function runLifecycle(command,registry,stdout,stderr,compatibility) {
276
286
  if(!command.apply) {
277
287
  const input={command:command.command,wrapper:command.workspace};
278
288
  if(command.manifestPath)input.manifestPath=command.manifestPath;
279
- if(command.network)input.network=true;
289
+ if(['setup','update','switch'].includes(command.command))input.network=true;
280
290
  if(command.providers)input.providers=command.providers;
281
291
  if(command.bundles)input.bundles=command.bundles;
282
292
  if(command.recoveryPath)input.recoveryPath=command.recoveryPath;
@@ -400,7 +410,7 @@ async function runLogs(command,stdout,stderr) {
400
410
  }finally{await lock.release();}
401
411
  }
402
412
 
403
- export async function runCli(args,{stdout,stderr,registry=null,compatibility=null}) {
413
+ export async function runCli(args,{stdout,stderr,registry=null,compatibility=null,desiredHostOptions={}}) {
404
414
  if(typeof stdout!=='function' || typeof stderr!=='function')fail('cli.transport');
405
415
  try {
406
416
  const command=parseCommand(args);
@@ -419,7 +429,9 @@ export async function runCli(args,{stdout,stderr,registry=null,compatibility=nul
419
429
  ['recover-bootstrap','continue-bootstrap','retire-bootstrap'].includes(command.action)?runBootstrapContinuation:runRepositoryRecovery)(command,stdout);
420
430
  if(['setup','update','repair','remove','switch','continue'].includes(command.command))return await runLifecycle(command,registry,stdout,stderr,compatibility);
421
431
  const options=command.recoveryPath===undefined?{}:{recoveryPath:command.recoveryPath};
422
- 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);
423
435
  await stdout(JSON.stringify(result)+'\n');
424
436
  return result.ready?0:1;
425
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;
@@ -0,0 +1,59 @@
1
+ import {mkdtemp,writeFile,unlink,rmdir} from 'node:fs/promises';
2
+ import {tmpdir} from 'node:os';
3
+ import path from 'node:path';
4
+ import {runCli,parseCommand} from './dispatch.js';
5
+ import {safeTerminalText as safe} from './output.js';
6
+
7
+ export function updateSummary(saved){
8
+ const p=saved.kind==='prepared-with-claude-compatibility'?saved.workspace:saved;
9
+ const plan=p.preview.plan;
10
+ const lines=['Workspace Pipeline — update',`Workspace: ${safe(plan.workspace)}`,
11
+ `Pipeline: ${safe(plan.desired.pipelineId)} @ ${safe(plan.desired.version)}`,
12
+ `Providers: ${plan.desired.providers.map(safe).join(', ')}`,`Changes: ${plan.targets.length}`];
13
+ for(const t of plan.targets)lines.push(` ${safe(t.action)} ${safe(t.path)}`);
14
+ const c=saved.compatibility;
15
+ if(c){lines.push(`Grok global compatibility: ${safe(c.status)}`);
16
+ if(c.path)lines.push(` Config: ${safe(c.path)}`);
17
+ for(const k of c.changes??[])lines.push(` compat.claude.${safe(k)} = true`);
18
+ for(const b of c.blockers??[])lines.push(` BLOCKED: ${safe(b)}`);
19
+ if(c.warning)lines.push(safe(c.warning));
20
+ }
21
+ lines.push('No automatic reset. Apply rechecks files and approval before writing.');
22
+ return lines.join('\n')+'\n';
23
+ }
24
+
25
+ // Human-facing entry only. runCli remains the deterministic preview/apply API.
26
+ export async function runInteractiveUpdate(args,options,{isTTY=false,confirm,display,execute=runCli}={}){
27
+ if(args[0]!=='update'||args.includes('--apply'))return execute(args,options);
28
+ const yes=args.includes('--yes'),dry=args.includes('--preview');
29
+ const clean=args.filter(x=>x!=='--yes'&&x!=='--preview');
30
+ if(args.filter(x=>x==='--yes').length>1||args.filter(x=>x==='--preview').length>1||(yes&&dry)){
31
+ await options.stdout(JSON.stringify({error:'cli.arguments'})+'\n');return 2;
32
+ }
33
+ // --json alone retains the existing machine preview contract; it never authorizes writes.
34
+ if(dry||(args.includes('--json')&&!yes))return execute(clean,options);
35
+ try{parseCommand(clean);}catch{return execute(clean,options);}
36
+ if(!yes&&(!isTTY||!confirm)){
37
+ await options.stdout(JSON.stringify({error:'cli.confirmation-required',next:'Use an interactive terminal, --yes to apply, or --preview --json for a saved plan.'})+'\n');return 2;
38
+ }
39
+ let raw='';
40
+ const code=await execute(clean,{...options,stdout:async text=>{raw+=text;}});
41
+ if(code!==0){if(raw)await options.stdout(raw);return code;}
42
+ const saved=JSON.parse(raw);
43
+ await display(updateSummary(saved));
44
+ if(saved.compatibility?.status==='blocked'){
45
+ await options.stdout(JSON.stringify({status:'blocked',error:'grok-compat.blocked'})+'\n');return 1;
46
+ }
47
+ if(!yes&&await confirm()!==true){await options.stdout(JSON.stringify({status:'cancelled',applied:false})+'\n');return 0;}
48
+ // Feed the exact approved bytes through the same public saved-preview verifier.
49
+ const dir=await mkdtemp(path.join(tmpdir(),'wpc-approved-update-'));
50
+ const file=path.join(dir,'preview.json');
51
+ try{
52
+ await writeFile(file,raw,{flag:'wx',mode:0o600});
53
+ const command=parseCommand(clean);
54
+ return await execute(['update','--workspace',command.workspace,'--apply','--preview',file],options);
55
+ }finally{
56
+ // Only our one named temporary file, never a recursive workspace cleanup.
57
+ await unlink(file).catch(()=>{});await rmdir(dir).catch(()=>{});
58
+ }
59
+ }
@@ -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);
@@ -1,6 +1,7 @@
1
1
  // Presentation boundary only: internal command results and saved JSON contracts
2
2
  // remain unchanged. Never infer success from the shape of a rendered result.
3
- const safe = value => String(value).replace(/[\u0000-\u001f\u007f-\u009f]/g, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
3
+ export const safeTerminalText = value => String(value).replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
4
+ const safe = safeTerminalText;
4
5
  const privateKey = /bytes|base64|content|secret|token|password/i;
5
6
 
6
7
  export function formatResult(value) {
@@ -11,6 +12,16 @@ export function formatResult(value) {
11
12
  if (value.pipeline) lines.push(`Pipeline: ${safe(value.pipeline.id)} @ ${safe(value.pipeline.version)}`,
12
13
  `Providers: ${value.pipeline.providers.map(safe).join(', ')}`);
13
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
+ }
14
25
  if(value.compatibility)lines.push(`Grok / Claude compatibility: ${safe(value.compatibility.status)}`,
15
26
  ...(value.compatibility.path?[`User config: ${safe(value.compatibility.path)}`]:[]),
16
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
+ }