@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,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
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {tmpdir} from 'node:os';
|
|
3
|
+
import {parse,fail} from '../contracts/parse.js';
|
|
4
|
+
import {validateStructure} from '../contracts/validate.js';
|
|
5
|
+
import {validateLayoutReferences} from '../contracts/semantic.js';
|
|
6
|
+
import {absoluteRoot} from '../workspace/paths.js';
|
|
7
|
+
import {assertUnreserved,overlaps} from '../workspace/reserved.js';
|
|
8
|
+
import {acquire} from '../source/git.js';
|
|
9
|
+
import {compileDesiredManifest} from '../contracts/desired-state.js';
|
|
10
|
+
|
|
11
|
+
export function parseDesiredWorkspace(text) {
|
|
12
|
+
const value=parse(text,'json');
|
|
13
|
+
if(value.schemaVersion!==2 || Object.keys(value).some(k=>!['schemaVersion','pipeline','adapters','layout'].includes(k)) ||
|
|
14
|
+
!Array.isArray(value.adapters)||!value.adapters.length || new Set(value.adapters).size!==value.adapters.length ||
|
|
15
|
+
value.adapters.some(id=>typeof id!=='string'||!/^[a-z][a-z0-9-]{0,62}$/.test(id)))fail('desired.workspace-schema');
|
|
16
|
+
// Reuse the established Git/layout data types, not old provider installation.
|
|
17
|
+
validateStructure('workspace',{schemaVersion:1,pipeline:value.pipeline,providers:['codex'],layout:value.layout});
|
|
18
|
+
validateLayoutReferences(value.layout);
|
|
19
|
+
const repositories=Object.values(value.layout.repositories).map(repo=>repo.path);
|
|
20
|
+
for(const name of repositories){assertUnreserved(name);if(overlaps(name,'workspace.json'))fail('layout.reserved');}
|
|
21
|
+
for(let i=0;i<repositories.length;i++)for(let j=0;j<i;j++)
|
|
22
|
+
if(overlaps(repositories[i],repositories[j]))fail('desired.repository-overlap');
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// No writes to workspace, no harness execution. Acquired Git objects/snapshot
|
|
27
|
+
// are temporary source material, never backups of installed user contents.
|
|
28
|
+
export async function prepareDesiredWorkspace(workspace,text,{manifestBase,tempRoot=tmpdir(),network=false}={}) {
|
|
29
|
+
workspace=absoluteRoot(workspace);tempRoot=absoluteRoot(tempRoot);
|
|
30
|
+
const rel=path.relative(workspace,tempRoot);
|
|
31
|
+
if(!rel || (!rel.startsWith('..'+path.sep)&&rel!=='..'&&!path.isAbsolute(rel)))fail('desired.preparation-location');
|
|
32
|
+
const descriptor=parseDesiredWorkspace(text);
|
|
33
|
+
const acquired=await acquire(descriptor.pipeline,{manifestBase:manifestBase??workspace,tempRoot,network,packageFormat:'desired'});
|
|
34
|
+
const protectedPaths=Object.values(descriptor.layout.repositories).map(repo=>repo.path);
|
|
35
|
+
const manifest=JSON.stringify(acquired.manifest);
|
|
36
|
+
compileDesiredManifest(manifest,{selected:descriptor.adapters,protectedPaths});
|
|
37
|
+
const binding={source:acquired.source,commit:acquired.commit,digest:acquired.digest,layout:descriptor.layout};
|
|
38
|
+
return {input:{workspace,manifest,selected:descriptor.adapters,protectedPaths,source:acquired.files,binding},
|
|
39
|
+
provenance:{source:acquired.source,commit:acquired.commit,digest:acquired.digest},
|
|
40
|
+
preparation:{objects:acquired.preparation,snapshot:acquired.snapshotPath},descriptor};
|
|
41
|
+
}
|
package/src/operations/lock.js
CHANGED
|
@@ -28,8 +28,9 @@ export async function assertLockHeld(lock) {
|
|
|
28
28
|
// Cooperative workspace-local exclusion. Existing/stale locks are never stolen.
|
|
29
29
|
// This does not prevent an editor or hostile process from replacing filesystem
|
|
30
30
|
// entries; S5 apply must independently recheck each subject before every write.
|
|
31
|
-
export async function acquireWorkspaceLock(workspace,{repositoryOperation,migrationOperation}={}) {
|
|
31
|
+
export async function acquireWorkspaceLock(workspace,{repositoryOperation,migrationOperation,purpose}={}) {
|
|
32
32
|
workspace = absoluteRoot(workspace);
|
|
33
|
+
if(purpose!==undefined && (purpose!=='desired-state'||repositoryOperation!==undefined||migrationOperation!==undefined))fail('lock.purpose');
|
|
33
34
|
if(repositoryOperation!==undefined&&migrationOperation!==undefined)fail('lock.capability-conflict');
|
|
34
35
|
const guard=async()=>{
|
|
35
36
|
if(migrationOperation!==undefined){
|
|
@@ -57,7 +58,7 @@ export async function acquireWorkspaceLock(workspace,{repositoryOperation,migrat
|
|
|
57
58
|
// Failure after mkdir deliberately leaves a non-acquirable lock for review.
|
|
58
59
|
await inspectDirectory(directory);
|
|
59
60
|
const owner = { schemaVersion: 1, workspace, token: randomUUID(), pid: process.pid,
|
|
60
|
-
host: hostname(), createdAt: new Date().toISOString() };
|
|
61
|
+
host: hostname(), createdAt: new Date().toISOString(),...(purpose?{purpose}:{}) };
|
|
61
62
|
const bytes = Buffer.from(JSON.stringify(owner) + '\n');
|
|
62
63
|
const handle = await open(ownerFile, 'wx', 0o600);
|
|
63
64
|
try { await handle.writeFile(bytes); await handle.sync(); }
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {checkEntries,LIMITS,cap,utf8,sha256} from './inventory.js';
|
|
2
|
+
import {parse,fail} from '../contracts/parse.js';
|
|
3
|
+
import {contractDigest} from '../contracts/semantic.js';
|
|
4
|
+
import {compileDesiredManifest} from '../contracts/desired-state.js';
|
|
5
|
+
import {materializeDesiredFiles} from '../desired-state/inventory.js';
|
|
6
|
+
|
|
7
|
+
// Git already identifies the immutable tree and blobs. V2 computes its source
|
|
8
|
+
// inventory instead of requiring a second hand-maintained installation plan.
|
|
9
|
+
export async function verifyDesiredPackage(entries,readBlob) {
|
|
10
|
+
checkEntries(entries);
|
|
11
|
+
const manifests=entries.filter(e=>['pipeline.json','pipeline.yaml','pipeline.yml'].includes(e.path));
|
|
12
|
+
if(manifests.length!==1)fail('source.manifest');
|
|
13
|
+
cap(manifests[0].size,LIMITS.manifest,'source.metadata-size');
|
|
14
|
+
const files=new Map();
|
|
15
|
+
for(const entry of entries) {
|
|
16
|
+
const bytes=await readBlob(entry);
|
|
17
|
+
if(!Buffer.isBuffer(bytes)||bytes.length!==entry.size)fail('source.blob-size');
|
|
18
|
+
if(bytes.subarray(0,200).toString('ascii').startsWith('version https://git-lfs.github.com/spec/v1'))fail('source.lfs');
|
|
19
|
+
files.set(entry.path,bytes);
|
|
20
|
+
}
|
|
21
|
+
const manifestPath=manifests[0].path;
|
|
22
|
+
const manifest=parse(utf8(files.get(manifestPath)),manifestPath.endsWith('.json')?'json':'yaml');
|
|
23
|
+
// Compile each delivery separately: different alternatives may intentionally
|
|
24
|
+
// serve the same provider; selecting both later still rejects collisions.
|
|
25
|
+
if(!manifest.adapters || typeof manifest.adapters!=='object')fail('desired.schema');
|
|
26
|
+
const serialized=JSON.stringify(manifest);
|
|
27
|
+
for(const id of Object.keys(manifest.adapters))
|
|
28
|
+
materializeDesiredFiles(compileDesiredManifest(serialized,{selected:[id]}),files);
|
|
29
|
+
if(!Object.keys(manifest.adapters).length)fail('desired.schema');
|
|
30
|
+
const fileHashes=Object.fromEntries([...files].map(([name,bytes])=>[name,sha256(bytes)]));
|
|
31
|
+
const digest=contractDigest(fileHashes);
|
|
32
|
+
return {manifest,manifestPath,files,fileHashes,digest,inventoryDigest:digest};
|
|
33
|
+
}
|
package/src/source/git.js
CHANGED
|
@@ -8,6 +8,7 @@ import { validateStructure } from '../contracts/validate.js';
|
|
|
8
8
|
import { portablePath } from '../contracts/semantic.js';
|
|
9
9
|
import { LIMITS, cap, utf8, verifyPackage } from './inventory.js';
|
|
10
10
|
import { materialize } from './snapshot.js';
|
|
11
|
+
import {verifyDesiredPackage} from './desired-package.js';
|
|
11
12
|
import { repositoryBudget } from './repository-budget.js';
|
|
12
13
|
|
|
13
14
|
const oid = value => /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value);
|
|
@@ -259,7 +260,8 @@ export async function acquireRemoteRepository(source, { tempRoot, network = fals
|
|
|
259
260
|
}
|
|
260
261
|
}
|
|
261
262
|
|
|
262
|
-
export async function acquire(source, { manifestBase, tempRoot = tmpdir(), network = false, packLimit = LIMITS.pack } = {}) {
|
|
263
|
+
export async function acquire(source, { manifestBase, tempRoot = tmpdir(), network = false, packLimit = LIMITS.pack, packageFormat='legacy' } = {}) {
|
|
264
|
+
if(!['legacy','desired'].includes(packageFormat))fail('source.package-format');
|
|
263
265
|
validateSource(source);
|
|
264
266
|
cap(packLimit, LIMITS.pack, 'source.pack-limit');
|
|
265
267
|
if (!path.isAbsolute(manifestBase ?? '')) fail('source.manifest-base');
|
|
@@ -284,7 +286,8 @@ export async function acquire(source, { manifestBase, tempRoot = tmpdir(), netwo
|
|
|
284
286
|
const rootType = await runGit(repo, ['cat-file', '-t', treeish], options);
|
|
285
287
|
if (utf8(rootType.bytes).trim() !== 'tree') fail('source.package-root');
|
|
286
288
|
const listing = await runGit(repo, ['ls-tree', '-r', '-l', '-z', '--full-tree', treeish], options);
|
|
287
|
-
const
|
|
289
|
+
const verify=packageFormat==='desired'?verifyDesiredPackage:verifyPackage;
|
|
290
|
+
const verified = await verify(parseListing(listing.bytes), async entry => {
|
|
288
291
|
const r = await runGit(repo, ['cat-file', 'blob', entry.oid], { ...options, outputLimit: LIMITS.blob });
|
|
289
292
|
return r.bytes;
|
|
290
293
|
});
|
|
@@ -293,7 +296,8 @@ export async function acquire(source, { manifestBase, tempRoot = tmpdir(), netwo
|
|
|
293
296
|
if (performance.now() > deadline) fail('source.timeout');
|
|
294
297
|
return { source: structuredClone(source), resolvedSource, commit, preparation, snapshotPath,
|
|
295
298
|
manifest: verified.manifest, inventoryDigest: verified.inventoryDigest,
|
|
296
|
-
digest: verified.digest, fileHashes: verified.fileHashes, runtime: 'not-run'
|
|
299
|
+
digest: verified.digest, fileHashes: verified.fileHashes, runtime: 'not-run',
|
|
300
|
+
...(packageFormat==='desired'?{files:verified.files}: {}) };
|
|
297
301
|
} catch (cause) {
|
|
298
302
|
const error = cause instanceof ContractError ? cause : new ContractError('source.io');
|
|
299
303
|
if (preparation) error.preparation = preparation;
|