@6reduk/workspace-pipeline 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/docs/desired-state.md +243 -0
- package/docs/lifecycle-cli.md +11 -6
- package/docs/migrations/unity.md +1 -1
- package/docs/rebind.md +1 -1
- package/docs/repositories.md +2 -2
- package/package.json +1 -1
- package/schemas/pipeline-v2.schema.json +43 -0
- package/src/cli.js +5 -2
- package/src/commands/desired-lifecycle.js +93 -0
- package/src/commands/desired-recover.js +28 -0
- package/src/commands/desired-remove.js +48 -0
- package/src/commands/desired-setup.js +47 -0
- package/src/commands/dispatch.js +27 -16
- package/src/commands/init.js +1 -1
- package/src/commands/migration.js +2 -3
- package/src/commands/output.js +10 -0
- package/src/contracts/desired-state.js +56 -0
- package/src/desired-state/apply-files.js +172 -0
- package/src/desired-state/binding.js +23 -0
- package/src/desired-state/doctor.js +106 -0
- package/src/desired-state/global-settings.js +74 -0
- package/src/desired-state/inventory.js +102 -0
- package/src/desired-state/legacy-state.js +37 -0
- package/src/desired-state/local-settings.js +40 -0
- package/src/desired-state/lock-recovery.js +78 -0
- package/src/desired-state/records.js +75 -0
- package/src/desired-state/removal.js +32 -0
- package/src/desired-state/reset.js +27 -0
- package/src/desired-state/retirement.js +37 -0
- package/src/desired-state/settings.js +125 -0
- package/src/desired-state/source.js +41 -0
- package/src/operations/lock.js +3 -2
- package/src/source/desired-package.js +33 -0
- package/src/source/git.js +7 -3
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {homedir,hostname} from 'node:os';
|
|
3
|
+
import {randomUUID} from 'node:crypto';
|
|
4
|
+
import {mkdir,mkdtemp,writeFile,readFile,readdir,unlink,rmdir,open,rename} from 'node:fs/promises';
|
|
5
|
+
import {fail} from '../contracts/parse.js';
|
|
6
|
+
import {absoluteRoot,inspectDirectory} from '../workspace/paths.js';
|
|
7
|
+
import {observeTargets} from '../operations/state.js';
|
|
8
|
+
import {sha256} from '../source/inventory.js';
|
|
9
|
+
import {compileDesiredSettings} from './settings.js';
|
|
10
|
+
|
|
11
|
+
// userHome is a trusted host/test dependency, never a source manifest field.
|
|
12
|
+
// Cooperative profile lock serializes installations from different workspaces.
|
|
13
|
+
export async function acquireGlobalSettings(operations,{userHome=homedir(),workspace}={}) {
|
|
14
|
+
if(!operations.length)return null;
|
|
15
|
+
if(operations.some(op=>op.target!=='grok.user'))fail('desired.global-target');
|
|
16
|
+
// Validate scope/value declarations before creating profile metadata.
|
|
17
|
+
compileDesiredSettings('grok.user',null,operations);
|
|
18
|
+
const root=absoluteRoot(userHome);
|
|
19
|
+
if(!(await inspectDirectory(root)).exists)fail('desired.profile-missing');
|
|
20
|
+
const directory=path.join(root,'.grok'),lockDirectory=path.join(directory,'.wpc-config-lock');
|
|
21
|
+
await inspectDirectory(directory);await mkdir(directory,{recursive:true});await inspectDirectory(directory);
|
|
22
|
+
try{await mkdir(lockDirectory);}catch(e){if(e.code==='EEXIST')fail('desired.profile-busy');throw e;}
|
|
23
|
+
const token=randomUUID(),owner=Buffer.from(JSON.stringify({schemaVersion:1,token,pid:process.pid,host:hostname(),
|
|
24
|
+
createdAt:new Date().toISOString(),workspace:workspace===undefined?null:absoluteRoot(workspace),purpose:'desired-global-settings'})+'\n'),ownerPath=path.join(lockDirectory,'owner');
|
|
25
|
+
await writeFile(ownerPath,owner,{flag:'wx',mode:0o600});
|
|
26
|
+
let released=false;
|
|
27
|
+
async function held() {
|
|
28
|
+
if(released)fail('desired.profile-lock-released');
|
|
29
|
+
await inspectDirectory(lockDirectory);
|
|
30
|
+
const [observed]=await observeTargets(root,['.grok/.wpc-config-lock/owner']);
|
|
31
|
+
if(observed.bytes===null || !observed.bytes.equals(owner))fail('desired.profile-lock-changed');
|
|
32
|
+
}
|
|
33
|
+
async function release() {
|
|
34
|
+
await held();
|
|
35
|
+
const names=await readdir(lockDirectory);
|
|
36
|
+
if(names.length!==1 || names[0]!=='owner')fail('desired.profile-lock-changed');
|
|
37
|
+
await unlink(ownerPath);await rmdir(lockDirectory);released=true;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const relative='.grok/config.toml',filename=path.join(directory,'config.toml');
|
|
41
|
+
const [initial]=await observeTargets(root,[relative]);
|
|
42
|
+
const edited=compileDesiredSettings('grok.user',initial.bytes,operations);
|
|
43
|
+
const beforeHash=initial.bytes===null?null:sha256(initial.bytes);
|
|
44
|
+
let backupDirectory=null;
|
|
45
|
+
async function check() {
|
|
46
|
+
await held();const [current]=await observeTargets(root,[relative]);
|
|
47
|
+
if((current.bytes===null?null:sha256(current.bytes))!==beforeHash)fail('desired.global-config-changed');
|
|
48
|
+
return current.bytes;
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
path:filename,changed:edited.changed,release,check,
|
|
52
|
+
async backup() {
|
|
53
|
+
const bytes=await check();
|
|
54
|
+
if(!edited.changed || bytes===null)return null;
|
|
55
|
+
const parent=path.join(directory,'workspace-pipeline-backups');
|
|
56
|
+
await inspectDirectory(parent);await mkdir(parent,{recursive:true});await inspectDirectory(parent);
|
|
57
|
+
backupDirectory=await mkdtemp(path.join(parent,'desired-'));
|
|
58
|
+
const destination=path.join(backupDirectory,'config.toml');
|
|
59
|
+
await writeFile(destination,bytes,{flag:'wx',mode:0o600});
|
|
60
|
+
if(sha256(await readFile(destination))!==beforeHash)fail('desired.backup-verification');
|
|
61
|
+
return backupDirectory;
|
|
62
|
+
},
|
|
63
|
+
async apply() {
|
|
64
|
+
await check();if(!edited.changed)return;
|
|
65
|
+
const staging=beforeHash===null?filename:path.join(directory,'config.toml.wpc-'+token+'.tmp');
|
|
66
|
+
const handle=await open(staging,'wx',0o600);
|
|
67
|
+
try{await handle.writeFile(edited.bytes);await handle.sync();}finally{await handle.close();}
|
|
68
|
+
if(beforeHash!==null){await check();await rename(staging,filename);}
|
|
69
|
+
const [after]=await observeTargets(root,[relative]);
|
|
70
|
+
if(after.bytes===null || !after.bytes.equals(edited.bytes))fail('desired.global-readback');
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
} catch(error){await release();throw error;}
|
|
74
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {createHash} from 'node:crypto';
|
|
3
|
+
import {lstat,readdir,readFile} from 'node:fs/promises';
|
|
4
|
+
import {fail} from '../contracts/parse.js';
|
|
5
|
+
import {portablePath} from '../contracts/semantic.js';
|
|
6
|
+
import {absoluteRoot,inspectDirectory,resolveChild} from '../workspace/paths.js';
|
|
7
|
+
|
|
8
|
+
const hash=bytes=>'sha256:'+createHash('sha256').update(bytes).digest('hex');
|
|
9
|
+
const order=(a,b)=>a.path<b.path?-1:a.path>b.path?1:0;
|
|
10
|
+
const maxFiles=10000,maxBytes=64*1024*1024;
|
|
11
|
+
|
|
12
|
+
// Compiled manifest and authenticated source map are supplied by the caller.
|
|
13
|
+
// This function performs no writes and copies source buffers before returning.
|
|
14
|
+
export function materializeDesiredFiles(compiled,source) {
|
|
15
|
+
if(!compiled || compiled.schemaVersion!==2 || !Array.isArray(compiled.files) || !(source instanceof Map))fail('desired.input');
|
|
16
|
+
const entries=new Map(),aliases=new Map();let bytes=0;
|
|
17
|
+
function add(name,kind,content) {
|
|
18
|
+
portablePath(name);
|
|
19
|
+
const key=name.toLowerCase(),old=entries.get(name);
|
|
20
|
+
if(aliases.has(key) && aliases.get(key)!==name)fail('desired.case-alias');
|
|
21
|
+
if(old){if(kind==='directory' && old.kind===kind)return;fail('desired.materialized-overlap');}
|
|
22
|
+
aliases.set(key,name);const entry={path:name,kind};
|
|
23
|
+
if(kind==='file') {
|
|
24
|
+
if(!Buffer.isBuffer(content))fail('desired.source-bytes');
|
|
25
|
+
bytes+=content.length;if(bytes>maxBytes)fail('desired.byte-limit');
|
|
26
|
+
entry.bytes=Buffer.from(content);entry.hash=hash(entry.bytes);
|
|
27
|
+
}
|
|
28
|
+
entries.set(name,entry);if(entries.size>maxFiles)fail('desired.file-limit');
|
|
29
|
+
}
|
|
30
|
+
for(const mapping of compiled.files) {
|
|
31
|
+
portablePath(mapping.source);portablePath(mapping.target);
|
|
32
|
+
if(mapping.kind==='file') {
|
|
33
|
+
if(!source.has(mapping.source))fail('desired.source-missing');
|
|
34
|
+
add(mapping.target,'file',source.get(mapping.source));
|
|
35
|
+
} else if(mapping.kind==='directory') {
|
|
36
|
+
add(mapping.target,'directory');let found=false;
|
|
37
|
+
for(const [name,content] of source) {
|
|
38
|
+
portablePath(name);if(!name.startsWith(mapping.source+'/'))continue;
|
|
39
|
+
found=true;const suffix=name.slice(mapping.source.length+1),parts=suffix.split('/');
|
|
40
|
+
for(let i=1;i<parts.length;i++)add(mapping.target+'/'+parts.slice(0,i).join('/'),'directory');
|
|
41
|
+
add(mapping.target+'/'+suffix,'file',content);
|
|
42
|
+
}
|
|
43
|
+
// Git has no empty directory entries: a missing source must not become
|
|
44
|
+
// an empty desired tree that deletes all installed contents.
|
|
45
|
+
if(!found)fail('desired.source-missing');
|
|
46
|
+
} else fail('desired.kind');
|
|
47
|
+
}
|
|
48
|
+
return {scopes:compiled.files.map(({target,kind})=>({path:target,kind})),entries:[...entries.values()].sort(order)};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Read-only observation, not a lease or write authorization. Apply must hold
|
|
52
|
+
// its lock and recheck safety. Only declared file scopes are traversed.
|
|
53
|
+
export async function inspectDesiredFiles(workspace,desired) {
|
|
54
|
+
workspace=absoluteRoot(workspace);
|
|
55
|
+
if(!(await inspectDirectory(workspace)).exists)fail('desired.workspace-missing');
|
|
56
|
+
const actual=new Map(),blocked=[];let totalBytes=0,count=0;
|
|
57
|
+
async function visit(relative,absolute,depth=0) {
|
|
58
|
+
if(++count>maxFiles || depth>64)fail('desired.file-limit');
|
|
59
|
+
let stat;
|
|
60
|
+
try{stat=await lstat(absolute);}catch(error){if(error.code==='ENOENT')return;throw error;}
|
|
61
|
+
if(stat.isSymbolicLink()){blocked.push({path:relative,reason:'link'});return;}
|
|
62
|
+
if(stat.isDirectory()) {
|
|
63
|
+
await inspectDirectory(absolute);actual.set(relative,{path:relative,kind:'directory'});
|
|
64
|
+
const names=await readdir(absolute),seen=new Set();
|
|
65
|
+
for(const name of names.sort()) {
|
|
66
|
+
const child=relative+'/'+name,key=name.toLowerCase();
|
|
67
|
+
if(seen.has(key)){blocked.push({path:child,reason:'case-alias'});continue;}
|
|
68
|
+
seen.add(key);
|
|
69
|
+
if(key==='.git'){blocked.push({path:child,reason:'nested-repository'});continue;}
|
|
70
|
+
await visit(child,path.join(absolute,name),depth+1);
|
|
71
|
+
}
|
|
72
|
+
} else if(stat.isFile()) {
|
|
73
|
+
if(stat.nlink>1){blocked.push({path:relative,reason:'hardlink'});return;}
|
|
74
|
+
totalBytes+=stat.size;if(totalBytes>maxBytes)fail('desired.byte-limit');
|
|
75
|
+
const bytes=await readFile(absolute);
|
|
76
|
+
if(bytes.length!==stat.size)fail('desired.observation-changed');
|
|
77
|
+
actual.set(relative,{path:relative,kind:'file',hash:hash(bytes)});
|
|
78
|
+
} else blocked.push({path:relative,reason:'special-file'});
|
|
79
|
+
}
|
|
80
|
+
for(const scope of desired.scopes) {
|
|
81
|
+
const absolute=resolveChild(workspace,scope.path),parent=await inspectDirectory(path.dirname(absolute));
|
|
82
|
+
if(!parent.exists)continue;
|
|
83
|
+
const matches=(await readdir(parent.path)).filter(n=>n.toLowerCase()===path.basename(absolute).toLowerCase());
|
|
84
|
+
if(matches.length>1 || (matches.length===1 && matches[0]!==path.basename(absolute))) {
|
|
85
|
+
blocked.push({path:scope.path,reason:'case-alias'});continue;
|
|
86
|
+
}
|
|
87
|
+
await visit(scope.path,absolute);
|
|
88
|
+
}
|
|
89
|
+
const expected=new Map(desired.entries.map(e=>[e.path,e]));
|
|
90
|
+
const extra=[],modified=[],missing=[],unchanged=[];
|
|
91
|
+
for(const [name,want] of expected) {
|
|
92
|
+
const have=actual.get(name);
|
|
93
|
+
if(!have){missing.push({path:name,kind:want.kind});continue;}
|
|
94
|
+
if(have.kind!==want.kind || (want.kind==='file' && have.hash!==want.hash))
|
|
95
|
+
modified.push({path:name,kind:want.kind,currentKind:have.kind,beforeHash:have.hash??null,desiredHash:want.hash??null});
|
|
96
|
+
else unchanged.push({path:name,kind:want.kind});
|
|
97
|
+
}
|
|
98
|
+
for(const [name,have] of actual)if(!expected.has(name))extra.push(have);
|
|
99
|
+
return {extra:extra.sort(order),modified:modified.sort(order),missing:missing.sort(order),
|
|
100
|
+
unchanged:unchanged.sort(order),blocked:blocked.sort(order),ready:blocked.length===0,
|
|
101
|
+
backupDefault:false,scope:'declared-file-targets-only'};
|
|
102
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {parse,fail} from '../contracts/parse.js';
|
|
3
|
+
import {validateState} from '../contracts/semantic.js';
|
|
4
|
+
import {sha256,utf8} from '../source/inventory.js';
|
|
5
|
+
import {observeTargets} from '../operations/state.js';
|
|
6
|
+
import {writeCheckedFile,deleteCheckedFile} from '../operations/apply.js';
|
|
7
|
+
|
|
8
|
+
const configTargets={'.codex/config.toml':'codex.workspace','.claude/settings.local.json':'claude.workspace',
|
|
9
|
+
'.mcp.json':'claude.mcp','.grok/config.toml':'grok.workspace'};
|
|
10
|
+
|
|
11
|
+
export function decodeLegacyState(workspace,bytes) {
|
|
12
|
+
const value=parse(utf8(bytes),'json');validateState(value);
|
|
13
|
+
if(path.resolve(value.workspace)!==path.resolve(workspace))fail('desired.legacy-workspace');
|
|
14
|
+
if(value.pending!==null || !value.active || !['ready','drift','conflict'].includes(value.status))fail('desired.legacy-unfinished');
|
|
15
|
+
const scopes=[],settings=[];
|
|
16
|
+
for(const owned of value.active.owned) {
|
|
17
|
+
if(owned.kind==='file')scopes.push({path:owned.path,kind:'file'});
|
|
18
|
+
else {
|
|
19
|
+
const target=configTargets[owned.path];
|
|
20
|
+
if(!target)fail('desired.legacy-field-target');
|
|
21
|
+
settings.push({target,pointer:owned.pointer,operation:'set',valueHash:owned.managedHash});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return {value,bytes,hash:sha256(bytes),protectedPaths:Object.values(value.active.layout.repositories).map(repo=>repo.path),
|
|
25
|
+
ownership:{pipeline:{id:value.active.pipelineId,version:value.active.version},scopes,settings}};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function retireLegacyState(lock,legacy) {
|
|
29
|
+
if(!legacy)return;
|
|
30
|
+
const history='.pipeline/history/state-v1-'+legacy.hash.slice(7)+'.json';
|
|
31
|
+
const [existing]=await observeTargets(lock.workspace,[history]);
|
|
32
|
+
if(existing.bytes===null)await writeCheckedFile(lock,history,null,legacy.bytes);
|
|
33
|
+
else if(sha256(existing.bytes)!==legacy.hash)fail('desired.legacy-history-conflict');
|
|
34
|
+
// Preserve the pre-existing operational record as history, not a backup of
|
|
35
|
+
// overwritten adapter files. Original journals/snapshots are not rewritten.
|
|
36
|
+
await deleteCheckedFile(lock,'.pipeline/state.json',legacy.hash,async()=>{});
|
|
37
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {fail} from '../contracts/parse.js';
|
|
2
|
+
import {observeTargets} from '../operations/state.js';
|
|
3
|
+
import {sha256} from '../source/inventory.js';
|
|
4
|
+
import {compileDesiredSettings} from './settings.js';
|
|
5
|
+
|
|
6
|
+
const destinations=Object.freeze({
|
|
7
|
+
'codex.workspace':'.codex/config.toml',
|
|
8
|
+
'claude.workspace':'.claude/settings.local.json',
|
|
9
|
+
'claude.mcp':'.mcp.json',
|
|
10
|
+
'grok.workspace':'.grok/config.toml'
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// Read and compile every configuration before any adapter file is deleted.
|
|
14
|
+
// Global configuration is intentionally not resolved through workspace paths.
|
|
15
|
+
export async function prepareLocalSettings(workspace,settings) {
|
|
16
|
+
const groups=new Map();
|
|
17
|
+
for(const operation of settings) {
|
|
18
|
+
if(!Object.hasOwn(destinations,operation.target))fail('desired.global-settings-not-integrated');
|
|
19
|
+
if(!groups.has(operation.target))groups.set(operation.target,[]);
|
|
20
|
+
groups.get(operation.target).push(operation);
|
|
21
|
+
}
|
|
22
|
+
const observations=await observeTargets(workspace,[...groups.keys()].map(key=>destinations[key]));
|
|
23
|
+
const result=[];
|
|
24
|
+
for(const [target,operations] of groups) {
|
|
25
|
+
const relative=destinations[target],before=observations.find(o=>o.path===relative).bytes;
|
|
26
|
+
const edited=compileDesiredSettings(target,before,operations);
|
|
27
|
+
result.push({path:relative,beforeHash:before===null?null:sha256(before),
|
|
28
|
+
bytes:edited.bytes,afterHash:sha256(edited.bytes),changed:edited.changed});
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function checkLocalSettings(workspace,prepared) {
|
|
34
|
+
const observed=await observeTargets(workspace,prepared.map(item=>item.path));
|
|
35
|
+
for(const item of prepared) {
|
|
36
|
+
const current=observed.find(o=>o.path===item.path);
|
|
37
|
+
if((current.bytes===null?null:sha256(current.bytes))!==item.beforeHash)fail('desired.config-changed');
|
|
38
|
+
}
|
|
39
|
+
return observed;
|
|
40
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {homedir,hostname} from 'node:os';
|
|
3
|
+
import {randomUUID} from 'node:crypto';
|
|
4
|
+
import {readdir,rename,unlink,rmdir} from 'node:fs/promises';
|
|
5
|
+
import {fail,parse} from '../contracts/parse.js';
|
|
6
|
+
import {contractDigest} from '../contracts/semantic.js';
|
|
7
|
+
import {absoluteRoot,inspectDirectory} from '../workspace/paths.js';
|
|
8
|
+
import {observeTargets} from '../operations/state.js';
|
|
9
|
+
import {assertNoRepositoryPending} from '../operations/repository-pending.js';
|
|
10
|
+
import {withRecoveryLease,assertRecoveryLeaseHeld} from '../operations/recovery-lease.js';
|
|
11
|
+
import {sha256,utf8} from '../source/inventory.js';
|
|
12
|
+
import {readDesiredRecords} from './records.js';
|
|
13
|
+
|
|
14
|
+
const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
15
|
+
function liveness(owner) {
|
|
16
|
+
if(owner.host!==hostname())return 'unknown-host';
|
|
17
|
+
try{process.kill(owner.pid,0);return 'live-or-reused-pid';}
|
|
18
|
+
catch(e){return e.code==='ESRCH'?'local-pid-absent':'unknown';}
|
|
19
|
+
}
|
|
20
|
+
function location(workspace,{global=false,userHome=homedir()}={}) {
|
|
21
|
+
if(typeof global!=='boolean')fail('desired.recovery-options');
|
|
22
|
+
const root=global?absoluteRoot(userHome):workspace;
|
|
23
|
+
return {root,relative:global?'.grok/.wpc-config-lock':'.pipeline/lock',
|
|
24
|
+
filename:global?'owner':'owner.json',purpose:global?'desired-global-settings':'desired-state'};
|
|
25
|
+
}
|
|
26
|
+
async function ownerAt(loc,relative,workspace) {
|
|
27
|
+
const directory=path.join(loc.root,...relative.split('/'));
|
|
28
|
+
if(!(await inspectDirectory(directory)).exists)return null;
|
|
29
|
+
const names=await readdir(directory);
|
|
30
|
+
if(names.length!==1||names[0]!==loc.filename)fail('desired.recovery-foreign-entry');
|
|
31
|
+
const [observed]=await observeTargets(loc.root,[relative+'/'+loc.filename]);
|
|
32
|
+
if(observed.bytes===null)fail('desired.recovery-owner-missing');
|
|
33
|
+
const owner=parse(utf8(observed.bytes),'json');
|
|
34
|
+
if(!owner || Object.keys(owner).sort().join(',')!=='createdAt,host,pid,purpose,schemaVersion,token,workspace' ||
|
|
35
|
+
owner.schemaVersion!==1||owner.purpose!==loc.purpose||owner.workspace!==workspace||!uuid.test(owner.token??'')||
|
|
36
|
+
!Number.isSafeInteger(owner.pid)||owner.pid<=0||typeof owner.host!=='string'||
|
|
37
|
+
typeof owner.createdAt!=='string'||!Number.isFinite(Date.parse(owner.createdAt)))fail('desired.recovery-owner-invalid');
|
|
38
|
+
return {owner,hash:sha256(observed.bytes),liveness:liveness(owner)};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Read-only: age alone never authorizes release, and signal 0 does not kill.
|
|
42
|
+
export async function inspectDesiredLock(workspace,options={}) {
|
|
43
|
+
workspace=absoluteRoot(workspace);const loc=location(workspace,options);
|
|
44
|
+
await assertNoRepositoryPending(workspace);
|
|
45
|
+
const records=await readDesiredRecords(workspace);
|
|
46
|
+
const state={installed:records.installed?.hash??null,pending:records.pending?.hash??null};
|
|
47
|
+
const lock=await ownerAt(loc,loc.relative,workspace);
|
|
48
|
+
const result={workspace,global:options.global===true,path:path.join(loc.root,...loc.relative.split('/')),state,
|
|
49
|
+
status:lock===null?'absent':lock.liveness==='local-pid-absent'?'stopped-owner-observed':'owner-unconfirmed',lock};
|
|
50
|
+
return {...result,digest:contractDigest(result)};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The existing OS lease serializes recoverers, without another stale lockfile.
|
|
54
|
+
// Rename the exact stale directory atomically; never delete/reuse its old path
|
|
55
|
+
// after rename, since a fresh cooperative writer may immediately acquire it.
|
|
56
|
+
export async function recoverDesiredLock(workspace,expected,options={}) {
|
|
57
|
+
workspace=absoluteRoot(workspace);const loc=location(workspace,options);
|
|
58
|
+
return withRecoveryLease(loc.root,async lease=>{
|
|
59
|
+
let current=await inspectDesiredLock(workspace,options);
|
|
60
|
+
if(current.digest!==expected.digest)fail('desired.recovery-observation-changed');
|
|
61
|
+
if(current.status==='absent')return {status:'no-lock',workspace,global:options.global===true};
|
|
62
|
+
if(current.status!=='stopped-owner-observed')fail('desired.recovery-owner-unconfirmed');
|
|
63
|
+
current=await inspectDesiredLock(workspace,options);
|
|
64
|
+
if(current.digest!==expected.digest)fail('desired.recovery-observation-changed');
|
|
65
|
+
assertRecoveryLeaseHeld(lease);
|
|
66
|
+
const relative=loc.relative+'-retiring-'+randomUUID(),temporary=path.join(loc.root,...relative.split('/'));
|
|
67
|
+
await rename(current.path,temporary);
|
|
68
|
+
try {
|
|
69
|
+
const saved=await ownerAt(loc,relative,workspace);
|
|
70
|
+
if(!saved || saved.hash!==current.lock.hash)fail('desired.recovery-retired-mismatch');
|
|
71
|
+
assertRecoveryLeaseHeld(lease);
|
|
72
|
+
await unlink(path.join(temporary,loc.filename));
|
|
73
|
+
await rmdir(temporary); // Never recursively remove unexpected children.
|
|
74
|
+
}catch(error){error.recovery={lockReleased:true,residualPath:temporary};throw error;}
|
|
75
|
+
return {status:'lock-retired',workspace,global:options.global===true,
|
|
76
|
+
next:'Inspect doctor, then explicitly retry reset or remove; operation records and configuration were not changed.'};
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
2
|
+
import {fail,parse} from '../contracts/parse.js';
|
|
3
|
+
import {contractDigest,portablePath} from '../contracts/semantic.js';
|
|
4
|
+
import {observeTargets} from '../operations/state.js';
|
|
5
|
+
import {writeCheckedFile,deleteCheckedFile} from '../operations/apply.js';
|
|
6
|
+
import {sha256,utf8} from '../source/inventory.js';
|
|
7
|
+
import {decodeLegacyState,retireLegacyState} from './legacy-state.js';
|
|
8
|
+
import {validateDesiredBinding} from './binding.js';
|
|
9
|
+
|
|
10
|
+
export const installedPath='.pipeline/desired-install.json';
|
|
11
|
+
export const pendingPath='.pipeline/desired-pending.json';
|
|
12
|
+
const hash={type:'string',pattern:'^sha256:[a-f0-9]{64}$'};
|
|
13
|
+
const validate=new Ajv2020({strict:true}).compile({type:'object',additionalProperties:false,
|
|
14
|
+
required:['schemaVersion','operationHash','pipeline','providers','scopes','files','settings'],properties:{
|
|
15
|
+
schemaVersion:{const:1},operationHash:hash,binding:{type:'object'},
|
|
16
|
+
intent:{enum:['remove','removed']},
|
|
17
|
+
adapters:{type:'array',minItems:1,uniqueItems:true,items:{type:'string',pattern:'^[a-z][a-z0-9-]{0,62}$'}},
|
|
18
|
+
pipeline:{type:'object',additionalProperties:false,required:['id','version'],properties:{id:{type:'string'},version:{type:'string'}}},
|
|
19
|
+
providers:{type:'array',uniqueItems:true,items:{enum:['codex','claude','grok','kimi']}},
|
|
20
|
+
scopes:{type:'array',maxItems:10000,items:{type:'object',additionalProperties:false,required:['path','kind'],properties:{path:{type:'string'},kind:{enum:['file','directory']}}}},
|
|
21
|
+
files:{type:'array',maxItems:10000,items:{type:'object',additionalProperties:false,required:['path','kind','hash'],properties:{path:{type:'string'},kind:{enum:['file','directory']},hash:{anyOf:[hash,{type:'null'}]}}}},
|
|
22
|
+
settings:{type:'array',maxItems:1000,items:{type:'object',additionalProperties:false,required:['target','pointer','operation','valueHash'],properties:{target:{type:'string'},pointer:{type:'string'},operation:{enum:['set','remove']},valueHash:{anyOf:[hash,{type:'null'}]}}}}
|
|
23
|
+
}});
|
|
24
|
+
|
|
25
|
+
// Only desired identities/digests, never old contents or secret setting values.
|
|
26
|
+
export function installationRecord(compiled,desired,{protectedPaths,globalConfigPath,binding}) {
|
|
27
|
+
const files=desired.entries.map(e=>({path:e.path,kind:e.kind,hash:e.hash??null}));
|
|
28
|
+
const provenance=binding===undefined?{}:{binding:validateDesiredBinding(binding)};
|
|
29
|
+
return {schemaVersion:1,operationHash:contractDigest({compiled,files,protectedPaths,globalConfigPath,...provenance}),...provenance,
|
|
30
|
+
pipeline:compiled.pipeline,adapters:compiled.adapters,providers:compiled.providers,scopes:desired.scopes,files,
|
|
31
|
+
settings:compiled.settings.map(s=>({target:s.target,pointer:s.pointer,operation:s.operation,
|
|
32
|
+
valueHash:s.operation==='set'?contractDigest(s.value):null}))};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function readDesiredRecords(workspace,{allowLegacyMigration=false}={}) {
|
|
36
|
+
const observations=await observeTargets(workspace,[installedPath,pendingPath,'.pipeline/state.json']);
|
|
37
|
+
// Migration is explicit; never silently abandon the old lifecycle state.
|
|
38
|
+
const legacyBytes=observations.find(o=>o.path==='.pipeline/state.json').bytes;
|
|
39
|
+
if(legacyBytes!==null && !allowLegacyMigration)fail('desired.legacy-migration-required');
|
|
40
|
+
const result={legacy:legacyBytes===null?null:decodeLegacyState(workspace,legacyBytes)};
|
|
41
|
+
for(const [key,name] of [['installed',installedPath],['pending',pendingPath]]) {
|
|
42
|
+
const bytes=observations.find(o=>o.path===name).bytes;
|
|
43
|
+
if(bytes===null){result[key]=null;continue;}
|
|
44
|
+
const value=parse(utf8(bytes),'json');
|
|
45
|
+
if(!validate(value))fail('desired.record-invalid');
|
|
46
|
+
if(value.intent==='removed' && (key==='pending'||value.scopes.length||value.files.length||value.settings.length||value.providers.length))fail('desired.record-invalid');
|
|
47
|
+
if(value.intent==='remove' && (key==='installed'||value.files.length||value.settings.some(s=>s.operation!=='remove'||s.target==='grok.user')))fail('desired.record-invalid');
|
|
48
|
+
if(value.binding!==undefined)validateDesiredBinding(value.binding);
|
|
49
|
+
for(const e of [...value.scopes,...value.files])portablePath(e.path);
|
|
50
|
+
result[key]={value,hash:sha256(bytes)};
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function assertDesiredTransition(records,next) {
|
|
56
|
+
if(records.pending?.value.intent==='remove' && next.intent!=='remove')fail('desired.finish-remove-before-update');
|
|
57
|
+
if(records.pending && records.pending.value.operationHash!==next.operationHash)fail('desired.retry-same-source-required');
|
|
58
|
+
const previous=records.installed?.value??records.legacy?.ownership;
|
|
59
|
+
if(!previous || previous.intent==='removed')return;
|
|
60
|
+
if(previous.pipeline.id!==next.pipeline.id)fail('desired.pipeline-switch-not-integrated');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const encode=record=>Buffer.from(JSON.stringify(record,null,2)+'\n');
|
|
64
|
+
export async function beginDesiredInstall(lock,records,next) {
|
|
65
|
+
if(records.pending)return records.pending.hash;
|
|
66
|
+
return writeCheckedFile(lock,pendingPath,null,encode(next));
|
|
67
|
+
}
|
|
68
|
+
export async function completeDesiredInstall(lock,records,next,pendingHash) {
|
|
69
|
+
const completed=next.intent==='remove'?{...next,intent:'removed',scopes:[],files:[],settings:[],providers:[]}:next;
|
|
70
|
+
const bytes=encode(completed);
|
|
71
|
+
if(records.installed?.hash!==sha256(bytes))
|
|
72
|
+
await writeCheckedFile(lock,installedPath,records.installed?.hash??null,bytes);
|
|
73
|
+
await retireLegacyState(lock,records.legacy);
|
|
74
|
+
if(pendingHash)await deleteCheckedFile(lock,pendingPath,pendingHash,async()=>{});
|
|
75
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {fail} from '../contracts/parse.js';
|
|
2
|
+
import {contractDigest} from '../contracts/semantic.js';
|
|
3
|
+
import {observeTargets} from '../operations/state.js';
|
|
4
|
+
import {utf8} from '../source/inventory.js';
|
|
5
|
+
import {parseDesiredWorkspace} from './source.js';
|
|
6
|
+
import {includeRetiredTargets} from './retirement.js';
|
|
7
|
+
|
|
8
|
+
// Offline full-delivery removal. Historical ownership never grants deletion of
|
|
9
|
+
// repositories. User-wide compatibility remains shared, not workspace-owned.
|
|
10
|
+
export async function prepareDesiredRemoval(workspace,records,protectedPaths=[]) {
|
|
11
|
+
if(records.pending && records.pending.value.intent!=='remove')fail('desired.finish-install-before-remove');
|
|
12
|
+
const previous=(records.pending??records.installed)?.value;
|
|
13
|
+
if(!previous)fail('desired.not-installed');
|
|
14
|
+
if(previous.intent==='removed')return {alreadyRemoved:true};
|
|
15
|
+
if(!previous.binding)fail('desired.removal-binding-required');
|
|
16
|
+
const protectedRoots=new Set(protectedPaths);
|
|
17
|
+
for(const repo of Object.values(previous.binding.layout.repositories))protectedRoots.add(repo.path);
|
|
18
|
+
const [entry]=await observeTargets(workspace,['workspace.json']);
|
|
19
|
+
if(entry.bytes!==null) {
|
|
20
|
+
const descriptor=parseDesiredWorkspace(utf8(entry.bytes));
|
|
21
|
+
for(const repo of Object.values(descriptor.layout.repositories))protectedRoots.add(repo.path);
|
|
22
|
+
}
|
|
23
|
+
const desired={scopes:[],entries:[]};
|
|
24
|
+
const retired=includeRetiredTargets(previous,{settings:[]},desired,[...protectedRoots]);
|
|
25
|
+
const settings=records.pending?previous.settings.map(s=>({target:s.target,pointer:s.pointer,operation:'remove'})):
|
|
26
|
+
retired.settings.filter(s=>s.target!=='grok.user');
|
|
27
|
+
const next=records.pending?.value??{...previous,intent:'remove',files:[],
|
|
28
|
+
settings:settings.map(s=>({...s,valueHash:null})),
|
|
29
|
+
operationHash:contractDigest({intent:'remove',previous,preserveGlobalSettings:true})};
|
|
30
|
+
return {alreadyRemoved:false,next,desired:retired.desired,settings,
|
|
31
|
+
protectedPaths:[...protectedRoots]};
|
|
32
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {fail} from '../contracts/parse.js';
|
|
2
|
+
import {contractDigest} from '../contracts/semantic.js';
|
|
3
|
+
import {readDesiredRecords} from './records.js';
|
|
4
|
+
import {parseDesiredWorkspace,prepareDesiredWorkspace} from './source.js';
|
|
5
|
+
|
|
6
|
+
// Reset is restoration of the recorded Git package, not a fetch of today's ref.
|
|
7
|
+
// The source remains declarative: updates keep using its original branch/ref.
|
|
8
|
+
export async function prepareDesiredReset(workspace,text,options={}) {
|
|
9
|
+
const descriptor=parseDesiredWorkspace(text),records=await readDesiredRecords(workspace);
|
|
10
|
+
if(records.pending?.value.intent==='remove')fail('desired.finish-remove-before-reset');
|
|
11
|
+
const record=(records.pending??records.installed)?.value;
|
|
12
|
+
if(!record?.binding || !record.adapters)fail('desired.reset-binding-required');
|
|
13
|
+
if(contractDigest(descriptor.pipeline)!==contractDigest(record.binding.source) ||
|
|
14
|
+
contractDigest(descriptor.layout)!==contractDigest(record.binding.layout) ||
|
|
15
|
+
contractDigest([...descriptor.adapters].sort())!==contractDigest([...record.adapters].sort()))
|
|
16
|
+
fail('desired.reset-workspace-different');
|
|
17
|
+
const pinned={...descriptor,pipeline:{...record.binding.source,ref:record.binding.commit}};
|
|
18
|
+
const prepared=await prepareDesiredWorkspace(workspace,JSON.stringify(pinned),options);
|
|
19
|
+
if(prepared.provenance.commit!==record.binding.commit || prepared.provenance.digest!==record.binding.digest)
|
|
20
|
+
fail('desired.reset-source-mismatch');
|
|
21
|
+
prepared.input.binding=record.binding;
|
|
22
|
+
prepared.provenance={source:record.binding.source,commit:record.binding.commit,digest:record.binding.digest};
|
|
23
|
+
prepared.descriptor=descriptor;
|
|
24
|
+
prepared.reset={mode:records.pending?'pending-installation':'installed',commit:record.binding.commit};
|
|
25
|
+
prepared.expectedRecords={installed:records.installed?.hash??null,pending:records.pending?.hash??null};
|
|
26
|
+
return prepared;
|
|
27
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {compileDesiredManifest} from '../contracts/desired-state.js';
|
|
2
|
+
import {compileDesiredSettings} from './settings.js';
|
|
3
|
+
import {fail} from '../contracts/parse.js';
|
|
4
|
+
|
|
5
|
+
const contains=(parent,child)=>parent===child || child.startsWith(parent+'/');
|
|
6
|
+
|
|
7
|
+
// Revalidate stored ownership through today's scope policy. A historical record
|
|
8
|
+
// is not a bypass for repository exclusions or config-field permissions.
|
|
9
|
+
export function includeRetiredTargets(previous,compiled,desired,protectedPaths) {
|
|
10
|
+
if(!previous)return {desired,settings:compiled.settings};
|
|
11
|
+
const priorSettings=previous.settings.map(s=>({target:s.target,pointer:s.pointer,operation:'remove'}));
|
|
12
|
+
compileDesiredManifest(JSON.stringify({schemaVersion:2,id:'retirement-check',version:'0.0.0',adapters:{prior:{
|
|
13
|
+
providers:['codex'],files:previous.scopes.map(s=>({source:'unused',target:s.path,kind:s.kind})),settings:priorSettings
|
|
14
|
+
}}}),{selected:['prior'],protectedPaths});
|
|
15
|
+
for(const op of priorSettings)compileDesiredSettings(op.target,null,[op]);
|
|
16
|
+
const settings=[...compiled.settings,...priorSettings.filter(old=>
|
|
17
|
+
previous.settings.find(s=>s.target===old.target && s.pointer===old.pointer).operation==='set' &&
|
|
18
|
+
!compiled.settings.some(s=>s.target===old.target && s.pointer===old.pointer))];
|
|
19
|
+
const all=[...previous.scopes,...desired.scopes];
|
|
20
|
+
// Different spellings of intersecting scopes cannot be resolved portably.
|
|
21
|
+
for(const a of all)for(const b of all) {
|
|
22
|
+
if(contains(a.path.toLowerCase(),b.path.toLowerCase()) && !contains(a.path,b.path))fail('desired.retirement-case-alias');
|
|
23
|
+
}
|
|
24
|
+
const unique=[...new Map(all.map(s=>[s.path,s])).values()];
|
|
25
|
+
const scopes=unique.filter(s=>!unique.some(parent=>parent.path!==s.path && contains(parent.path,s.path)));
|
|
26
|
+
const entries=new Map(desired.entries.map(e=>[e.path,e]));
|
|
27
|
+
// A narrowed former directory still needs structural parents while deleting
|
|
28
|
+
// its obsolete siblings. Those parents are not new ownership grants.
|
|
29
|
+
for(const entry of desired.entries) {
|
|
30
|
+
const parts=entry.path.split('/');
|
|
31
|
+
for(let i=1;i<parts.length;i++) {
|
|
32
|
+
const name=parts.slice(0,i).join('/');
|
|
33
|
+
if(scopes.some(s=>contains(s.path,name)) && !entries.has(name))entries.set(name,{path:name,kind:'directory'});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return {desired:{scopes,entries:[...entries.values()]},settings};
|
|
37
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import {isDeepStrictEqual} from 'node:util';
|
|
2
|
+
import {parseTOML,getStaticTOMLValue} from 'toml-eslint-parser';
|
|
3
|
+
import {fail,parse,MAX_INPUT_BYTES} from '../contracts/parse.js';
|
|
4
|
+
import {contractDigest} from '../contracts/semantic.js';
|
|
5
|
+
import {utf8} from '../source/inventory.js';
|
|
6
|
+
import {readTOMLField,reconcileTOMLFields,AGENT_CONTROL_KEYS} from '../operations/toml-fields.js';
|
|
7
|
+
|
|
8
|
+
const compat=['skills','rules','agents','mcps','hooks'];
|
|
9
|
+
const named=/^\/(agents|mcp_servers|mcpServers)\/[a-z][a-z0-9_-]{0,99}$/;
|
|
10
|
+
const object=v=>v!==null && typeof v==='object' && !Array.isArray(v);
|
|
11
|
+
|
|
12
|
+
// Named configuration destinations, never source-provided absolute filenames.
|
|
13
|
+
// No I/O. This module cannot change auth, models, trust, permissions or hooks.
|
|
14
|
+
export function compileDesiredSettings(target,bytes,operations) {
|
|
15
|
+
if(bytes!==null && (!Buffer.isBuffer(bytes)||bytes.length>MAX_INPUT_BYTES))fail('desired.config-input');
|
|
16
|
+
if(!Array.isArray(operations)||!operations.length||operations.length>1000)fail('desired.config-operations');
|
|
17
|
+
const checked=parse(JSON.stringify(operations),'json');
|
|
18
|
+
const pointers=[];
|
|
19
|
+
for(const op of checked) {
|
|
20
|
+
if(op.target!==target || !['set','remove'].includes(op.operation) ||
|
|
21
|
+
Object.keys(op).some(k=>!['adapter','target','pointer','operation','value'].includes(k)) ||
|
|
22
|
+
(op.operation==='set')!==Object.hasOwn(op,'value'))fail('desired.config-operation');
|
|
23
|
+
const p=op.pointer;
|
|
24
|
+
if(typeof p!=='string')fail('desired.config-scope');
|
|
25
|
+
let allowed=false;
|
|
26
|
+
if(target==='codex.workspace')allowed=named.test(p) && /^\/(agents|mcp_servers)\//.test(p) && !AGENT_CONTROL_KEYS.includes(p.split('/')[2]);
|
|
27
|
+
if(target==='grok.workspace')allowed=named.test(p) && p.startsWith('/mcp_servers/');
|
|
28
|
+
if(target==='claude.mcp')allowed=named.test(p) && p.startsWith('/mcpServers/');
|
|
29
|
+
if(target==='claude.workspace')allowed=p==='/enabledMcpjsonServers';
|
|
30
|
+
if(target==='grok.user')allowed=compat.some(k=>p==='/compat/claude/'+k);
|
|
31
|
+
if(!allowed || p.split('/').some(k=>['__proto__','prototype','constructor'].includes(k)))fail('desired.config-scope');
|
|
32
|
+
if(pointers.some(q=>p===q || p.startsWith(q+'/') || q.startsWith(p+'/')))fail('desired.field-overlap');
|
|
33
|
+
pointers.push(p);
|
|
34
|
+
if(op.operation==='set') {
|
|
35
|
+
if(target==='grok.user' && typeof op.value!=='boolean')fail('desired.config-value');
|
|
36
|
+
if(target==='claude.workspace' && (!Array.isArray(op.value)||op.value.some(v=>typeof v!=='string')||new Set(op.value).size!==op.value.length))fail('desired.config-value');
|
|
37
|
+
if(named.test(p) && !object(op.value))fail('desired.config-value');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if(target==='grok.user')return compatSettings(bytes,checked);
|
|
41
|
+
if(['codex.workspace','grok.workspace'].includes(target)) {
|
|
42
|
+
const requests=checked.map(op=>{
|
|
43
|
+
const current=readTOMLField(bytes,op.pointer);
|
|
44
|
+
return {pointer:op.pointer,present:op.operation==='set',...(op.operation==='set'?{value:op.value}:{}),
|
|
45
|
+
managedHash:current.present?contractDigest(current.value):null};
|
|
46
|
+
});
|
|
47
|
+
// The old codec is reused only as a syntax-preserving editor. The observed
|
|
48
|
+
// value is deliberately replaceable; its backup/conflict decisions are not
|
|
49
|
+
// part of the new installation policy and are not returned.
|
|
50
|
+
const result=reconcileTOMLFields(bytes,requests);
|
|
51
|
+
return {bytes:result.bytes,changed:!result.bytes.equals(bytes??Buffer.alloc(0))};
|
|
52
|
+
}
|
|
53
|
+
const current=bytes===null?{}:parse(utf8(bytes),'json');
|
|
54
|
+
if(!object(current))fail('desired.config-shape');
|
|
55
|
+
const result=structuredClone(current);
|
|
56
|
+
for(const op of checked) {
|
|
57
|
+
const parts=op.pointer.slice(1).split('/');let parent=result;
|
|
58
|
+
for(const key of parts.slice(0,-1)) {
|
|
59
|
+
if(!Object.hasOwn(parent,key)) {
|
|
60
|
+
if(op.operation==='remove'){parent=null;break;}
|
|
61
|
+
parent[key]={};
|
|
62
|
+
}
|
|
63
|
+
if(!object(parent[key]))fail('desired.config-ancestor');
|
|
64
|
+
parent=parent[key];
|
|
65
|
+
}
|
|
66
|
+
if(parent!==null) {
|
|
67
|
+
if(op.operation==='remove')delete parent[parts.at(-1)];
|
|
68
|
+
else parent[parts.at(-1)]=op.value;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if(JSON.stringify(result)===JSON.stringify(current))return {bytes:bytes??Buffer.from('{}\n'),changed:false};
|
|
72
|
+
return {bytes:Buffer.from(JSON.stringify(result,null,2)+'\n'),changed:true};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function compatSettings(bytes,operations) {
|
|
76
|
+
const text=bytes===null?'':utf8(bytes);
|
|
77
|
+
let ast,value;try{ast=parseTOML(text,{tomlVersion:'1.0'});value=getStaticTOMLValue(ast);}catch{fail('desired.config-syntax');}
|
|
78
|
+
if(value.compat!==undefined && !object(value.compat))fail('desired.config-ancestor');
|
|
79
|
+
if(value.compat?.claude!==undefined && !object(value.compat.claude))fail('desired.config-ancestor');
|
|
80
|
+
const expected=structuredClone(value),edits=[],seen=new Set(),nl=text.includes('\r\n')?'\r\n':'\n';
|
|
81
|
+
const changes=operations.filter(op=>{
|
|
82
|
+
const k=op.pointer.split('/').at(-1),exists=Object.hasOwn(value.compat?.claude??{},k);
|
|
83
|
+
if(op.operation==='remove'){if(exists)delete expected.compat.claude[k];return exists;}
|
|
84
|
+
expected.compat??={};expected.compat.claude??={};expected.compat.claude[k]=op.value;
|
|
85
|
+
return !exists || value.compat.claude[k]!==op.value;
|
|
86
|
+
});
|
|
87
|
+
if(!changes.length)return {bytes:bytes??Buffer.alloc(0),changed:false};
|
|
88
|
+
function pair(node,base) {
|
|
89
|
+
const keys=[...base,...node.key.keys.map(k=>k.name??k.value)];
|
|
90
|
+
for(const op of changes) {
|
|
91
|
+
const wanted=op.pointer.slice(1).split('/');
|
|
92
|
+
if(keys.length<wanted.length && keys.every((k,i)=>k===wanted[i]))fail('desired.config-inline-ancestor');
|
|
93
|
+
if(keys.length===wanted.length && keys.every((k,i)=>k===wanted[i])) {
|
|
94
|
+
seen.add(op.pointer);
|
|
95
|
+
edits.push(op.operation==='remove'?[...node.range,'']:[...node.value.range,String(op.value)]);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
let table=null,parentTable=null;
|
|
100
|
+
for(const node of ast.body[0].body) {
|
|
101
|
+
if(node.type==='TOMLKeyValue')pair(node,[]);
|
|
102
|
+
else if(node.type==='TOMLTable') {
|
|
103
|
+
if(node.resolvedKey.join('.')==='compat.claude')table=node;
|
|
104
|
+
if(node.resolvedKey.length===1 && node.resolvedKey[0]==='compat')parentTable=node;
|
|
105
|
+
for(const child of node.body)pair(child,node.resolvedKey);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const additions=changes.filter(op=>op.operation==='set'&&!seen.has(op.pointer));
|
|
109
|
+
if(additions.length) {
|
|
110
|
+
const selected=table??parentTable;
|
|
111
|
+
if(selected) {
|
|
112
|
+
if(selected.kind!=='standard')fail('desired.config-ancestor');
|
|
113
|
+
const end=text.indexOf('\n',selected.key.range[1]),pos=end<0?text.length:end+1;
|
|
114
|
+
edits.push([pos,pos,(end<0?nl:'')+additions.map(op=>(table?'':'claude.')+
|
|
115
|
+
op.pointer.split('/').at(-1)+' = '+String(op.value)+nl).join('')]);
|
|
116
|
+
} else edits.push([0,0,additions.map(op=>op.pointer.slice(1).split('/').join('.')+' = '+String(op.value)+nl).join('')]);
|
|
117
|
+
}
|
|
118
|
+
let output=text;
|
|
119
|
+
for(const [start,end,replacement] of edits.sort((a,b)=>b[0]-a[0]))output=output.slice(0,start)+replacement+output.slice(end);
|
|
120
|
+
let after;try{after=getStaticTOMLValue(parseTOML(output,{tomlVersion:'1.0'}));}catch{fail('desired.config-postcondition');}
|
|
121
|
+
function normalize(v){if(v.compat?.claude && !Object.keys(v.compat.claude).length)delete v.compat.claude;if(v.compat && !Object.keys(v.compat).length)delete v.compat;return v;}
|
|
122
|
+
if(!isDeepStrictEqual(normalize(after),normalize(expected)))fail('desired.config-postcondition');
|
|
123
|
+
const bom=bytes?.subarray(0,3).equals(Buffer.from([0xef,0xbb,0xbf]));
|
|
124
|
+
return {bytes:Buffer.from((bom?'\uFEFF':'')+output),changed:true};
|
|
125
|
+
}
|