@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.
- package/README.md +19 -1
- package/docs/desired-state.md +243 -0
- package/docs/lifecycle-cli.md +57 -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 +18 -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 +28 -16
- package/src/commands/init.js +1 -1
- package/src/commands/interactive-update.js +59 -0
- package/src/commands/migration.js +2 -3
- package/src/commands/output.js +12 -1
- 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,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
|
+
}
|
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,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>]
|
|
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>]
|
|
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>
|
|
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>
|
|
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>
|
|
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;
|
|
88
|
-
|
|
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','--
|
|
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
|
|
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'
|
|
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.
|
|
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.
|
|
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
|
|
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) {
|
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;
|
|
@@ -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','--
|
|
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
|
@@ -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
|
|
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
|
+
}
|