@petercjl/cloud-hug-style-transfer 0.5.0 → 0.5.2
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/INSTALL.md +16 -0
- package/README.md +14 -26
- package/UPDATE.md +29 -0
- package/capabilities.json +390 -120
- package/lifecycle/engine.mjs +428 -0
- package/lifecycle/notice.mjs +1 -0
- package/lifecycle/validate-release.mjs +18 -0
- package/lifecycle/version.mjs +19 -0
- package/npm/automatic-update.mjs +1 -82
- package/npm/business.mjs +33 -0
- package/npm/cli.mjs +40 -54
- package/npm/integrity.json +8 -6
- package/npm/skills.mjs +8 -101
- package/npm/update.mjs +6 -99
- package/package.json +21 -5
- package/release-integrity.json +100 -0
- package/skills/cloud-hug-style-transfer/SKILL.md +14 -0
- package/skills/cloud-hug-style-transfer/adapters/codex.json +2 -1
- package/skills/cloud-hug-style-transfer/adapters/generic.json +74 -0
- package/skills/cloud-hug-style-transfer/adapters/sealseek.json +2 -1
- package/skills/cloud-hug-style-transfer/adapters/workbuddy.json +2 -1
- package/skills/cloud-hug-style-transfer/capabilities.json +583 -4
- package/skills/cloud-hug-style-transfer/scripts/run.mjs +10 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import {parseDocument} from 'yaml';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import {spawnSync} from 'node:child_process';
|
|
7
|
+
import {fileURLToPath} from 'node:url';
|
|
8
|
+
|
|
9
|
+
// Package-owned lifecycle implementation. No host directory inference or service access.
|
|
10
|
+
export const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
|
|
11
|
+
export const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'));
|
|
12
|
+
export const config=pkg.skillLifecycle;
|
|
13
|
+
export const source=path.join(root,'skills',config.skill);
|
|
14
|
+
const marker='.agent-package.json',ui='agents/openai.yaml';
|
|
15
|
+
export const fail=(code,message,details={})=>Object.assign(new Error(message),{code,details});
|
|
16
|
+
const exists=p=>{try{return fs.lstatSync(p);}catch(e){if(e.code==='ENOENT')return null;throw e;}};
|
|
17
|
+
const read=p=>JSON.parse(fs.readFileSync(p,'utf8'));
|
|
18
|
+
const hash=b=>crypto.createHash('sha256').update(b).digest('hex');
|
|
19
|
+
const write=(p,v)=>{if(exists(p))throw fail('OUTPUT_EXISTS','Preserving existing file.',{path:p});fs.mkdirSync(path.dirname(p),{recursive:true});fs.writeFileSync(p,JSON.stringify(v,null,2)+'\n',{flag:'wx',mode:0o600});};
|
|
20
|
+
function copyTree(from,to,options={}) {
|
|
21
|
+
const initial=fs.realpathSync(from),base=fs.statSync(from).isDirectory()?initial:path.dirname(initial);
|
|
22
|
+
function copy(src,dest,parents=new Set()) {
|
|
23
|
+
const real=fs.realpathSync(src),stat=fs.statSync(real);
|
|
24
|
+
if(stat.isDirectory()) {
|
|
25
|
+
if(parents.has(real))throw fail('UNSAFE_LINK','Cyclic source directory dependency.');
|
|
26
|
+
fs.mkdirSync(dest,{recursive:true});const next=new Set(parents);next.add(real);
|
|
27
|
+
for(const name of fs.readdirSync(real)){if(options.skipDependencies&&real===initial&&name==='node_modules')continue;copy(path.join(real,name),path.join(dest,name),next);}
|
|
28
|
+
} else if(stat.isFile()) {
|
|
29
|
+
if(exists(dest))throw fail('OUTPUT_EXISTS','Copy destination already exists.',{path:dest});
|
|
30
|
+
fs.mkdirSync(path.dirname(dest),{recursive:true});
|
|
31
|
+
// npm bin aliases become real launchers pointing only within this copied runtime.
|
|
32
|
+
if(fs.lstatSync(src).isSymbolicLink()&&path.basename(path.dirname(src))==='.bin'&&real.startsWith(base+path.sep)) {
|
|
33
|
+
const relative=path.relative(path.dirname(dest),path.join(to,path.relative(base,real)));
|
|
34
|
+
const text='#!/usr/bin/env node\nconst {spawnSync}=require("node:child_process");const path=require("node:path");const r=spawnSync(process.execPath,[path.resolve(__dirname,'+JSON.stringify(relative)+'),...process.argv.slice(2)],{stdio:"inherit"});process.exit(r.status??1);\n';
|
|
35
|
+
fs.writeFileSync(dest,text,{flag:'wx',mode:stat.mode&0o777});
|
|
36
|
+
}else{fs.copyFileSync(real,dest,fs.constants.COPYFILE_EXCL|fs.constants.COPYFILE_FICLONE);fs.chmodSync(dest,stat.mode&0o777);}
|
|
37
|
+
}else throw fail('UNSAFE_FILE','Unsupported runtime dependency file.');
|
|
38
|
+
}copy(from,to);
|
|
39
|
+
}
|
|
40
|
+
export function inventory(dir,{skip=[]}={}) {
|
|
41
|
+
const result={};
|
|
42
|
+
function walk(base='') {for(const ent of fs.readdirSync(path.join(dir,base),{withFileTypes:true})) {
|
|
43
|
+
const rel=base?base+'/'+ent.name:ent.name;
|
|
44
|
+
if(skip.includes(rel))continue;
|
|
45
|
+
if(ent.isSymbolicLink())throw fail('UNSAFE_LINK','Managed content contains a symbolic link.',{file:rel});
|
|
46
|
+
if(ent.isDirectory())walk(rel);else if(ent.isFile())result[rel]=hash(fs.readFileSync(path.join(dir,rel)));
|
|
47
|
+
else throw fail('UNSAFE_FILE','Unsupported managed file type.',{file:rel});
|
|
48
|
+
}}walk();return result;
|
|
49
|
+
}
|
|
50
|
+
function validHashes(value){return value&&typeof value==='object'&&!Array.isArray(value)&&Object.entries(value).every(([k,v])=>k&&!path.isAbsolute(k)&&!k.includes('\\')&&!k.split('/').some(x=>['.','..',''].includes(x))&&/^[a-f0-9]{64}$/.test(v));}
|
|
51
|
+
const equal=(a,b)=>JSON.stringify(Object.entries(a).sort())===JSON.stringify(Object.entries(b).sort());
|
|
52
|
+
const canonical=()=>inventory(source);
|
|
53
|
+
export function resolveTarget(options={},env=process.env) {
|
|
54
|
+
const explicit=['path','root','home','target-directory','skill-path','agent'].some(k=>options[k]!==undefined);
|
|
55
|
+
const agent=(explicit?options.agent:env[config.env+'_AGENT'])??'generic';
|
|
56
|
+
if(!/^[a-z][a-z0-9-]{0,63}$/.test(agent))throw fail('INVALID_TARGET','Use a lowercase Agent identifier.');
|
|
57
|
+
const locations=['path','root','home','target-directory','skill-path'].filter(k=>options[k]!==undefined);
|
|
58
|
+
if(locations.length>1)throw fail('CONFLICTING_TARGET','Supply exactly one explicit destination.');
|
|
59
|
+
const flag=locations[0],value=flag?options[flag]:(!explicit?env[config.env+'_SKILL_PATH']:undefined);
|
|
60
|
+
if(typeof value!=='string'||!value.trim())throw fail('TARGET_PATH_REQUIRED','The current Agent must resolve its own Skill directory and pass --path.',{agent,recovery:'Resolve the current Agent directory, then retry with an explicit --path.'});
|
|
61
|
+
let target=path.resolve(value,...(flag==='root'?[config.skill]:flag==='home'?['skills',config.skill]:[]));
|
|
62
|
+
const tail=[];let parent=path.dirname(target);while(!exists(parent)){tail.unshift(path.basename(parent));parent=path.dirname(parent);}
|
|
63
|
+
target=path.join(fs.realpathSync(parent),...tail,path.basename(target));
|
|
64
|
+
const relative=path.relative(root,target);
|
|
65
|
+
if(target===root||(!path.isAbsolute(relative)&&!relative.startsWith('..'+path.sep)&&relative!=='..')||(root.startsWith(target+path.sep)&&!(root===path.join(target,'.runtime')&&exists(path.join(target,marker))&&read(path.join(target,marker)).package===pkg.name)))throw fail('UNSAFE_TARGET','Skill destination must be separate from the package source.');
|
|
66
|
+
if(target===os.homedir()||target===path.parse(target).root)throw fail('UNSAFE_TARGET','Resolve a dedicated Skill directory.');
|
|
67
|
+
let legacySource;if(options['legacy-source']){legacySource=fs.realpathSync(options['legacy-source']);if(read(path.join(legacySource,'package.json')).name!==pkg.name)throw fail('UNMANAGED_TARGET','Explicit legacy source belongs to a different package.');}
|
|
68
|
+
return {legacySource,agent,target,adapter:exists(path.join(source,'adapters',agent+'.json'))?agent:'generic',selection_source:flag?'--'+flag:'environment'};
|
|
69
|
+
}
|
|
70
|
+
function localFiles(target){return inventory(target,{skip:[marker,config.legacyMarker,'.runtime']});}
|
|
71
|
+
function metadata(target){
|
|
72
|
+
const p=path.join(target,ui);if(!exists(p))return null;const text=fs.readFileSync(p,'utf8'),doc=parseDocument(text,{uniqueKeys:true});if(doc.errors.length)throw fail('SKILL_METADATA_INVALID','Host UI metadata must be valid YAML.');const value=doc.toJSON();
|
|
73
|
+
if(doc.errors.length||!value||typeof value!=='object'||Array.isArray(value))throw fail('SKILL_METADATA_INVALID','Host UI metadata must be a YAML mapping.');
|
|
74
|
+
if(value.interface!==undefined&&(!value.interface||typeof value.interface!=='object'||Array.isArray(value.interface)||Object.values(value.interface).some(x=>typeof x!=='string')))throw fail('SKILL_METADATA_INVALID','Interface values must be strings.');
|
|
75
|
+
if(value.policy!==undefined&&(!value.policy||typeof value.policy!=='object'||Array.isArray(value.policy)))throw fail('SKILL_METADATA_INVALID','Policy must be a mapping.');
|
|
76
|
+
if(value.policy?.allow_implicit_invocation!==undefined&&typeof value.policy.allow_implicit_invocation!=='boolean')throw fail('SKILL_METADATA_INVALID','Implicit invocation policy must be boolean.');
|
|
77
|
+
const products=value.policy?.products;if(products!==undefined&&(!Array.isArray(products)||products.some(x=>!['chatgpt','codex','atlas','CHATGPT','CODEX','ATLAS','api'].includes(x))))throw fail('SKILL_METADATA_INVALID','Unsupported host product policy.');
|
|
78
|
+
if(products?.includes('api')){const valid=products.filter(x=>x!=='api');if(!valid.length)throw fail('SKILL_METADATA_INVALID','Legacy product policy needs explicit resolution.');doc.setIn(['policy','products'],valid);return doc.toString();}
|
|
79
|
+
return text;
|
|
80
|
+
}
|
|
81
|
+
function inspectStatus(resolution,{from=resolution.legacySource||root}={}) {
|
|
82
|
+
const {target,agent,adapter}=resolution,st=exists(target),identity={ok:true,package:pkg.name,version:pkg.version,target,destination:target,agent,adapter,source,hostDiscovery:'pending'};
|
|
83
|
+
if(!st)return {...identity,installed:false,managed:false,current:false,up_to_date:false};
|
|
84
|
+
if(st.isSymbolicLink()) {
|
|
85
|
+
const actual=fs.realpathSync(target),sources=[source,path.join(from,'skills',config.skill)];
|
|
86
|
+
const managed=sources.some(s=>exists(s)&&fs.realpathSync(s)===actual);
|
|
87
|
+
let edited=false,editedFiles=[],recordVersion;
|
|
88
|
+
if(managed){
|
|
89
|
+
const ownerRoot=path.resolve(actual,'../..');if(read(path.join(ownerRoot,'package.json')).name!==pkg.name)throw fail('UNMANAGED_TARGET','Legacy link owner mismatch.');recordVersion=read(path.join(ownerRoot,'package.json')).version;
|
|
90
|
+
let baseline;const release=path.join(ownerRoot,'release-integrity.json'),prefix='skills/'+config.skill+'/';
|
|
91
|
+
if(exists(release)){const files=read(release).files;baseline=Object.fromEntries(Object.entries(files||{}).filter(([k])=>k.startsWith(prefix)).map(([k,v])=>[k.slice(prefix.length),v]));}
|
|
92
|
+
else for(const file of ['skill-integrity.json','npm/integrity.json'])if(exists(path.join(ownerRoot,file))){baseline=read(path.join(ownerRoot,file));break;}
|
|
93
|
+
if(!validHashes(baseline)||!Object.keys(baseline).length)throw fail('LEGACY_INTEGRITY_REQUIRED','Verify this legacy link against its exact published archive before migration.');
|
|
94
|
+
const files=inventory(actual);editedFiles=Object.keys({...baseline,...files}).filter(k=>k!==ui&&files[k]!==baseline[k]);edited=editedFiles.length>0;metadata(target);
|
|
95
|
+
}
|
|
96
|
+
return {...identity,installed:true,managed,current:false,up_to_date:false,mode:'link',migrationRequired:managed,edited,editedFiles,recordVersion};
|
|
97
|
+
}
|
|
98
|
+
if(!st.isDirectory())return {...identity,installed:true,managed:false,current:false};
|
|
99
|
+
let record;try{record=read(path.join(target,marker));}catch{try{record=read(path.join(target,config.legacyMarker));}catch{}}
|
|
100
|
+
if(!record&&resolution.legacySource){const baseline=inventory(path.join(resolution.legacySource,'skills',config.skill)),actual=localFiles(target);if(Object.keys(actual).every(k=>k===ui||baseline[k]===actual[k])&&Object.keys(baseline).every(k=>actual[k]!==undefined))record={package:pkg.name,version:read(path.join(resolution.legacySource,'package.json')).version,hashes:baseline};}
|
|
101
|
+
const owned=record?.package===pkg.name||record?.owner===pkg.name;
|
|
102
|
+
const hashes=record?.hashes||record?.files;
|
|
103
|
+
if(owned&&!validHashes(hashes))throw fail('UNMANAGED_STATE','Invalid managed hash record; preserve it for recovery.');
|
|
104
|
+
if(!owned)return {...identity,installed:true,managed:false,current:false};
|
|
105
|
+
const actual=localFiles(target),tracked=Object.keys(hashes).filter(k=>!k.startsWith('.runtime/')&&![marker,config.legacyMarker].includes(k));
|
|
106
|
+
const editedFiles=tracked.filter(k=>k!==ui&&actual[k]!==undefined&&actual[k]!==hashes[k]);const edited=editedFiles.length>0;
|
|
107
|
+
const missing=tracked.filter(k=>actual[k]===undefined);
|
|
108
|
+
const unknown=Object.keys(actual).filter(k=>k!==ui&&!Object.hasOwn(hashes,k));
|
|
109
|
+
const expected=canonical();
|
|
110
|
+
if(exists(path.join(target,'.runtime'))?.isSymbolicLink())throw fail('UNSAFE_LINK','Installed runtime must be a physical directory.');
|
|
111
|
+
let runtime=null;try{runtime=read(path.join(target,'.runtime',marker));}catch{}
|
|
112
|
+
let runtimeCurrent=false,runtimeEdited=false;
|
|
113
|
+
if(runtime?.package===pkg.name&&validHashes(runtime.hashes)) {
|
|
114
|
+
const files=inventory(path.join(target,'.runtime'),{skip:[marker]});
|
|
115
|
+
const changed=Object.keys({...runtime.hashes,...files}).filter(k=>files[k]!==undefined&&files[k]!==runtime.hashes[k]);editedFiles.push(...changed.map(k=>'.runtime/'+k));runtimeEdited=changed.length>0;
|
|
116
|
+
runtimeCurrent=equal(files,runtime.hashes)&&runtime.version===pkg.version&&runtime.sourceIdentity===releaseIdentity();
|
|
117
|
+
}
|
|
118
|
+
const metadataText=metadata(target),metadataCurrent=metadataText!==null&&metadataText===fs.readFileSync(path.join(target,ui),'utf8');
|
|
119
|
+
const current=metadataCurrent&&!edited&&!unknown.length&&!missing.length&&!runtimeEdited&&runtimeCurrent&&record.version===pkg.version&&Object.entries(expected).every(([k,v])=>k===ui||actual[k]===v);
|
|
120
|
+
return {...identity,installed:true,managed:true,mode:'copy',current,up_to_date:current,edited:edited||runtimeEdited,editedFiles,unknown,missing,runtimeCurrent,recordVersion:record.version};
|
|
121
|
+
}
|
|
122
|
+
export function status(resolution,options={}) {
|
|
123
|
+
const s=inspectStatus(resolution,options);
|
|
124
|
+
const recovery=s.current?{action:'host-discovery',mutates:false}:!s.managed&&s.installed?{action:'preserve-unmanaged-target',mutates:false}:s.edited?{action:'preserve-local-edits',mutates:false}:{action:'reconcile-selected-target',command:['skill','update','--agent',resolution.agent,'--path',resolution.target,'--json'],mutates:true};
|
|
125
|
+
return {...s,schemaVersion:1,name:config.skill,installedVersion:s.recordVersion||null,selectionSource:resolution.selection_source,up_to_date:Boolean(s.current),edited:Boolean(s.edited),editedFiles:s.editedFiles||[],missing:s.missing||[],unknown:s.unknown||[],verification:{scope:'managed-skill-and-runtime',algorithm:'sha256',verified:Boolean(s.managed&&s.current)},recovery,complete:false};
|
|
126
|
+
}
|
|
127
|
+
export const maintenanceContract={schemaVersion:1,agentEligibility:'valid-name-with-generic-fallback',hostDiscoveryAuthority:'host',targetOptions:{'--path':{kind:'skill-directory'},'--target-directory':{kind:'skill-directory'},'--root':{kind:'skill-parent'},'--home':{kind:'host-root'}},source:{name:{field:'name',type:'string'},package:{field:'package',type:'string'},version:{field:'version',type:'string'},path:{field:'source',type:'string'}},status:{name:{field:'name',type:'string'},package:{field:'package',type:'string'},version:{field:'version',type:'string'},target:{field:'target',type:'string'},installed:{field:'installed',type:'boolean'},managed:{field:'managed',type:'boolean'},current:{field:'up_to_date',type:'boolean'},edited:{field:'editedFiles',type:'string[]'},verification:{field:'verification',type:'object'},recovery:{field:'recovery',type:'object'}},legacyFields:{current:'boolean alias of up_to_date',edited:'boolean retained; use editedFiles for paths'},evidenceReuse:'Reuse matching package/version/target/integrity receipts within the operation; repeat only after mutation, conflict or stale channel evidence.'};
|
|
128
|
+
function releaseIdentity(){return hash(fs.readFileSync(path.join(root,'release-integrity.json')));}
|
|
129
|
+
function guard(resolution){const s=status(resolution);if(s.installed&&!s.managed)throw fail('UNMANAGED_TARGET','Existing target is user-owned; choose a new path.',{target:s.target});if(s.edited)throw fail('LOCAL_EDITS','Managed content has local edits; preserve them before updating.',{target:s.target});if(s.unknown?.length)throw fail('UNMANAGED_FILE','Target contains untracked files; preserve them before updating.',{files:s.unknown});return s;}
|
|
130
|
+
function verifyCandidate(from){
|
|
131
|
+
const identity=read(path.join(from,'package.json')),manifest=read(path.join(from,'release-integrity.json'));
|
|
132
|
+
if(identity.name!==pkg.name||manifest.package!==identity.name||manifest.version!==identity.version||!validHashes(manifest.files))throw fail('CANDIDATE_IDENTITY_MISMATCH','Candidate package and release identity must agree.');
|
|
133
|
+
for(const [name,digest]of Object.entries(manifest.files))if(!exists(path.join(from,name))||hash(fs.readFileSync(path.join(from,name)))!==digest)throw fail('CANDIDATE_CONTENT_DRIFT','Candidate content differs from its release manifest.',{file:name});
|
|
134
|
+
}
|
|
135
|
+
function dependencySource(name,owner){
|
|
136
|
+
if(!/^(?:@[a-zA-Z0-9_.-]+\/)?[a-zA-Z0-9_.-]+$/.test(name)||['.','..'].includes(name))throw fail('INVALID_DEPENDENCY','Invalid dependency package identifier.');
|
|
137
|
+
let current=owner;while(true){const candidate=path.join(current,'node_modules',...name.split('/'));if(exists(path.join(candidate,'package.json')))return fs.realpathSync(candidate);const parent=path.dirname(current);if(parent===current)return null;current=parent;}
|
|
138
|
+
}
|
|
139
|
+
function copyDependencies(owner,destination,ancestors=[]) {
|
|
140
|
+
const metadata=read(path.join(owner,'package.json'));
|
|
141
|
+
const requested={...(metadata.peerDependencies||{}),...(metadata.dependencies||{}),...(metadata.optionalDependencies||{})};
|
|
142
|
+
for(const name of Object.keys(requested)) {
|
|
143
|
+
const dep=dependencySource(name,owner),optional=Object.hasOwn(metadata.optionalDependencies||{},name)||metadata.peerDependenciesMeta?.[name]?.optional===true;
|
|
144
|
+
if(!dep){if(optional)continue;throw fail('RUNTIME_DEPENDENCY_MISSING','An installed runtime dependency is missing.',{dependency:name});}
|
|
145
|
+
if(ancestors.some(x=>x.name===name&&x.source===dep))continue;
|
|
146
|
+
const dest=path.join(destination,'node_modules',...name.split('/'));
|
|
147
|
+
if(exists(dest))throw fail('RUNTIME_DEPENDENCY_CONFLICT','Dependency destinations overlap.',{dependency:name});
|
|
148
|
+
copyTree(dep,dest,{skipDependencies:true});
|
|
149
|
+
copyDependencies(dep,dest,[...ancestors,{name,source:dep}]);
|
|
150
|
+
const info=read(path.join(dep,'package.json')),bins=typeof info.bin==='string'?{[name.split('/').at(-1)]:info.bin}:info.bin||{};
|
|
151
|
+
for(const [alias,entry]of Object.entries(bins)){
|
|
152
|
+
if(!/^[a-zA-Z0-9_.-]+$/.test(alias)||typeof entry!=='string')throw fail('INVALID_PACKAGE_BIN','Invalid dependency command alias.');
|
|
153
|
+
const file=path.join(destination,'node_modules','.bin',alias),entryPath=path.resolve(dest,entry);if(!entryPath.startsWith(dest+path.sep))throw fail('INVALID_PACKAGE_BIN','Dependency command escapes its package.');
|
|
154
|
+
if(!exists(file)){fs.mkdirSync(path.dirname(file),{recursive:true});const rel=path.relative(path.dirname(file),entryPath);fs.writeFileSync(file,'#!/usr/bin/env node\nconst {spawnSync}=require("node:child_process"),path=require("node:path");const r=spawnSync(process.execPath,[path.resolve(__dirname,'+JSON.stringify(rel)+'),...process.argv.slice(2)],{stdio:"inherit"});process.exit(r.status??1);\n',{flag:'wx',mode:0o755});}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function copyPackage(from,to) {
|
|
159
|
+
verifyCandidate(from);
|
|
160
|
+
const metadata=read(path.join(from,'package.json'));
|
|
161
|
+
fs.mkdirSync(to,{recursive:true});
|
|
162
|
+
for(const name of ['package.json',...(metadata.files||[])]) {
|
|
163
|
+
if(name.includes('*')||path.isAbsolute(name)||name.split('/').includes('..'))throw fail('INVALID_PACKAGE_FILES','Only explicit package files are supported.');
|
|
164
|
+
const p=path.join(from,name);
|
|
165
|
+
if(exists(p))copyTree(p,path.join(to,name),{recursive:true,dereference:true,force:false,errorOnExist:true});
|
|
166
|
+
}
|
|
167
|
+
copyDependencies(from,to);
|
|
168
|
+
const hashes=inventory(to);write(path.join(to,marker),{package:metadata.name,version:metadata.version,sourceIdentity:hash(fs.readFileSync(path.join(from,'release-integrity.json'))),hashes});
|
|
169
|
+
}
|
|
170
|
+
export function activeWork(){
|
|
171
|
+
if(config.skill!=='sealseek-media')return false;
|
|
172
|
+
const dir=process.env.SEALSEEK_MEDIA_STATE_DIR||path.join(os.homedir(),'.local','state','sealseek-media');
|
|
173
|
+
for(const folder of ['jobs','auth-sessions']){const base=path.join(dir,folder);if(!exists(base))continue;for(const name of fs.readdirSync(base)){
|
|
174
|
+
let record;try{record=read(path.join(base,name));}catch{continue;}
|
|
175
|
+
if(record.owner!==pkg.name||!['queued','running','generated','pending','verifying','starting'].includes(record.status))continue;
|
|
176
|
+
if(Number.isSafeInteger(record.pid)&&record.pid>0){try{process.kill(record.pid,0);return true;}catch(e){if(e.code!=='ESRCH')return true;}}
|
|
177
|
+
else if(Date.now()-Date.parse(record.created_at)<120000)return true;
|
|
178
|
+
}}return false;
|
|
179
|
+
}
|
|
180
|
+
function requireIdle(resolution){if(exists(resolution.target)&&activeWork())throw fail('ACTIVE_WORK','Existing media work is active; defer maintenance and follow its saved task ID.');}
|
|
181
|
+
function operations(resolution){return path.join(path.dirname(resolution.target),'.'+path.basename(resolution.target)+'-operations');}
|
|
182
|
+
function createOperation(resolution,extra={}){const id=crypto.randomUUID(),folder=path.join(operations(resolution),id);fs.mkdirSync(folder,{recursive:true,mode:0o700});const record={schema:1,id,package:pkg.name,target:resolution.target,agent:resolution.agent,from:status(resolution).recordVersion||null,bootstrapVersion:pkg.version,stage:'preflight',packageComplete:false,skillComplete:false,hostDiscovery:'pending',createdAt:new Date().toISOString(),...extra};write(path.join(folder,'000-preflight.json'),record);return {id,folder,record,step:1};}
|
|
183
|
+
function phase(op,stage,extra={}){Object.assign(op.record,{stage,...extra});if(['complete','rolled-back'].includes(stage))delete op.record.error;write(path.join(op.folder,String(op.step++).padStart(3,'0')+'-'+stage+'.json'),op.record);}
|
|
184
|
+
export function operationStatus(resolution,id) {
|
|
185
|
+
if(id&&!/^[a-f0-9-]{36}$/.test(id))throw fail('INVALID_OPERATION','Expected an operation UUID.');
|
|
186
|
+
let folder=id&&path.join(operations(resolution),id);
|
|
187
|
+
if(!folder){const entries=exists(operations(resolution))?fs.readdirSync(operations(resolution)):[];folder=entries.map(x=>path.join(operations(resolution),x)).filter(x=>exists(x)?.isDirectory()).sort((a,b)=>fs.statSync(b).mtimeMs-fs.statSync(a).mtimeMs)[0];}
|
|
188
|
+
if(!folder||!exists(folder))throw fail('OPERATION_NOT_FOUND','No recorded operation for this target.');
|
|
189
|
+
const records=fs.readdirSync(folder).filter(x=>/^\d+-.*\.json$/.test(x)).sort();const record=read(path.join(folder,records.at(-1)));
|
|
190
|
+
if(record.package!==pkg.name||record.target!==resolution.target)throw fail('UNMANAGED_STATE','Operation ownership mismatch.');
|
|
191
|
+
let installation;try{installation=status(resolution);}catch(e){installation={ok:false,code:e.code||'TARGET_STATE_INVALID',message:'Inspect the current target without discarding its recorded backup.'};}
|
|
192
|
+
return {ok:true,...record,operationDirectory:folder,installation};
|
|
193
|
+
}
|
|
194
|
+
function lock(resolution){
|
|
195
|
+
fs.mkdirSync(path.dirname(resolution.target),{recursive:true});const file=resolution.target+'.package-lock';
|
|
196
|
+
function acquire(){
|
|
197
|
+
try{const fd=fs.openSync(file,'wx',0o600);fs.writeFileSync(fd,JSON.stringify({package:pkg.name,pid:process.pid,target:resolution.target}));return ()=>{fs.closeSync(fd);fs.unlinkSync(file);};}
|
|
198
|
+
catch(e){if(e.code!=='EEXIST')throw e;}
|
|
199
|
+
const st=exists(file);if(!st?.isFile()||st.isSymbolicLink())throw fail('UNMANAGED_STATE','Preserving an unsafe target lock.',{lock:file});
|
|
200
|
+
let bytes,held;try{bytes=fs.readFileSync(file);held=JSON.parse(bytes);}catch{throw fail('UNMANAGED_STATE','Preserving an invalid target lock.',{lock:file});}
|
|
201
|
+
if(held.package!==pkg.name||!Number.isSafeInteger(held.pid)||held.pid<=0)throw fail('UNMANAGED_STATE','Target lock ownership is invalid.',{lock:file});
|
|
202
|
+
let dead=false;try{process.kill(held.pid,0);}catch(e){dead=e.code==='ESRCH';}
|
|
203
|
+
if(!dead)throw fail('UPDATE_BUSY','A live process holds the target lock.',{lock:file,pid:held.pid});
|
|
204
|
+
const current=exists(file);if(!current||current.ino!==st.ino||!fs.readFileSync(file).equals(bytes))throw fail('UPDATE_BUSY','Target lock changed during inspection.',{lock:file});
|
|
205
|
+
fs.renameSync(file,file+'.stale-'+crypto.randomUUID());return acquire();
|
|
206
|
+
}return acquire();
|
|
207
|
+
}
|
|
208
|
+
function snapshot(resolution,op){if(!exists(resolution.target)){op.record.targetFingerprint={};return null;}const dest=path.join(op.folder,'before');copyTree(resolution.target,dest,{recursive:true,dereference:true,force:false,errorOnExist:true});const a=inventory(resolution.target,{skip:[]}),b=inventory(dest);if(!equal(a,b))throw fail('BACKUP_VERIFICATION_FAILED','Pre-replacement backup hash mismatch.');op.record.targetFingerprint=a;if(exists(resolution.target)?.isSymbolicLink()){const actual=fs.realpathSync(resolution.target),owner=path.resolve(actual,'../..');if(read(path.join(owner,'package.json')).name!==pkg.name)throw fail('UNMANAGED_TARGET','Legacy package identity mismatch.');const packageBackup=path.join(op.folder,'before-package');fs.mkdirSync(packageBackup);for(const name of ['package.json',...(read(path.join(owner,'package.json')).files||[])])if(exists(path.join(owner,name)))copyTree(path.join(owner,name),path.join(packageBackup,name),{recursive:true,dereference:true,force:false,errorOnExist:true});copyDependencies(owner,packageBackup);op.record.packageBackup=packageBackup;}return dest;}
|
|
209
|
+
function activate(resolution,op,{from=root,rename=fs.renameSync}={}) {
|
|
210
|
+
const old=guard(resolution),before=localFilesIfPresent(resolution.target),stage=path.join(op.folder,'staged');
|
|
211
|
+
if(op.record.targetFingerprint&&!equal(op.record.targetFingerprint,before))throw fail('CONCURRENT_EDIT','Target differs from the original pre-replacement snapshot; preserve edits before recovery.');
|
|
212
|
+
if(exists(stage))throw fail('STAGING_EXISTS','Inspect the existing staged candidate before recovery.');
|
|
213
|
+
copyTree(path.join(from,'skills',config.skill),stage,{recursive:true,dereference:true,errorOnExist:true,force:false});
|
|
214
|
+
const custom=old.installed?metadata(resolution.target):null;
|
|
215
|
+
if(custom!==null)fs.writeFileSync(path.join(stage,ui),custom);
|
|
216
|
+
copyPackage(from,path.join(stage,'.runtime'));
|
|
217
|
+
const candidatePkg=read(path.join(from,'package.json'));
|
|
218
|
+
write(path.join(stage,marker),{package:pkg.name,version:candidatePkg.version,source:path.join(from,'skills',config.skill),hashes:inventory(stage,{skip:['.runtime']}),runtime:'.runtime',hostDiscovery:'pending'});
|
|
219
|
+
phase(op,'candidate-verified',{candidate:from,candidateVersion:candidatePkg.version,candidateIdentity:hash(fs.readFileSync(path.join(from,'release-integrity.json')))});
|
|
220
|
+
// Check again immediately before replacement, including edits during candidate acquisition.
|
|
221
|
+
guard(resolution);if(!equal(before,localFilesIfPresent(resolution.target)))throw fail('CONCURRENT_EDIT','Target changed after its snapshot; preserving new changes.');
|
|
222
|
+
const retired=path.join(op.folder,'retired-'+crypto.randomUUID());let moved=false;
|
|
223
|
+
try {if(exists(resolution.target)){rename(resolution.target,retired);moved=true;}rename(stage,resolution.target);}
|
|
224
|
+
catch(e){
|
|
225
|
+
if(moved&&!exists(resolution.target))fs.renameSync(retired,resolution.target);
|
|
226
|
+
if(!['EPERM','EBUSY','EACCES'].includes(e.code)||!old.installed||old.mode==='link')throw e;
|
|
227
|
+
// Complete backup precedes recoverable in-place sync for live directory handles.
|
|
228
|
+
try{for(const name of fs.readdirSync(resolution.target))fs.rmSync(path.join(resolution.target,name),{recursive:true,force:true});copyTree(stage,resolution.target,{recursive:true,dereference:true});}
|
|
229
|
+
catch(cause){throw fail('IN_PLACE_SYNC_FAILED','Restore the verified before snapshot or resume the candidate.',{backup:op.record.backup,causeCode:cause.code});}
|
|
230
|
+
}
|
|
231
|
+
const manifest=read(path.join(resolution.target,marker));if(!equal(localFiles(resolution.target),manifest.hashes))throw fail('SKILL_VERIFY_FAILED','Activated Skill hashes differ from the candidate.');
|
|
232
|
+
const runtimeManifest=read(path.join(resolution.target,'.runtime',marker));if(!equal(inventory(path.join(resolution.target,'.runtime'),{skip:[marker]}),runtimeManifest.hashes))throw fail('RUNTIME_VERIFY_FAILED','Activated runtime hashes differ from the candidate.');
|
|
233
|
+
const localEntry=path.join(resolution.target,'.runtime',config.entry);
|
|
234
|
+
const probe=spawnSync(process.execPath,[localEntry,'version','--json'],{encoding:'utf8',env:{...process.env,[config.env+'_AUTO_UPDATE']:'0'},timeout:60000,windowsHide:true});
|
|
235
|
+
let version;try{version=JSON.parse(probe.stdout).version;}catch{}
|
|
236
|
+
if(probe.status!==0||version!==candidatePkg.version)throw fail('RUNTIME_VERIFY_FAILED','Installed local runtime failed its version probe.',{exitCode:probe.status,signal:probe.signal});
|
|
237
|
+
phase(op,'complete',{packageComplete:true,skillComplete:true,version,entry:localEntry,migration:old.mode==='link'});
|
|
238
|
+
return {ok:true,package:pkg.name,version,target:resolution.target,destination:resolution.target,agent:resolution.agent,adapter:resolution.adapter,installed:true,managed:true,current:true,up_to_date:true,verified:true,changed:true,state:'installed',mode:'copy',entry:localEntry,backup:op.record.backup,operationId:op.id,operationDirectory:op.folder,hostDiscovery:'pending',complete:false,requiredAction:'The current Agent must reload this Skill and verify native discovery.',migration:old.mode==='link'};
|
|
239
|
+
}
|
|
240
|
+
function localFilesIfPresent(p){return exists(p)?inventory(p):{};}
|
|
241
|
+
export function install(resolution,options={}) {
|
|
242
|
+
const unlock=lock(resolution);let op;
|
|
243
|
+
try {
|
|
244
|
+
const s=guard(resolution);if(s.current)return {...s,verified:true,changed:false,state:'unchanged',complete:false,entry:path.join(s.target,'.runtime',config.entry)};
|
|
245
|
+
requireIdle(resolution);
|
|
246
|
+
op=createOperation(resolution,{candidate:root,candidateVersion:pkg.version,candidateIdentity:releaseIdentity()});const backup=snapshot(resolution,op);phase(op,'snapshotted',{backup});
|
|
247
|
+
return activate(resolution,op,options);
|
|
248
|
+
}catch(e){if(op){phase(op,'failed',{error:{code:e.code||'INSTALL_FAILED',stage:op.record.stage,message:e.message,details:e.details},recovery:'Inspect update operation, then update resume with this operation ID.'});e.details={...e.details,operationId:op.id,operationDirectory:op.folder,backup:op.record.backup};}throw e;}finally{unlock();}
|
|
249
|
+
}
|
|
250
|
+
function npmCommand(){const options=[process.env.npm_execpath,...(process.env.PATH||'').split(path.delimiter).map(p=>path.join(p,process.platform==='win32'?'npm.cmd':'npm'))];for(const p of options.filter(Boolean)){if(!exists(p))continue;const real=fs.realpathSync(p);const script=real.endsWith('npm-cli.js')?real:path.join(path.dirname(real),'node_modules/npm/bin/npm-cli.js');if(exists(script))return script;}throw fail('NPM_UNAVAILABLE','A working npm installation is required.');}
|
|
251
|
+
function npm(args,cwd){const env={...process.env,npm_config_dry_run:'false',npm_config_json:'false',[config.env+'_AUTO_UPDATE']:'0'};delete env.npm_config_global;delete env.npm_config_prefix;const r=spawnSync(process.execPath,[npmCommand(),'--dry-run=false','--json=false',...args],{cwd,env,encoding:'utf8',timeout:args[0]==='view'?2500:180000,windowsHide:true,maxBuffer:1024*1024*4});if(r.status!==0)throw fail('PACKAGE_ACQUISITION_FAILED','npm did not complete; inspect recorded candidate state before retrying.',{exitCode:r.status,signal:r.signal,systemCode:r.error?.code});return r.stdout;}
|
|
252
|
+
export function checkUpdate(){const text=npm(['view',pkg.name,'dist-tags.latest','--json','--fetch-retries=0','--fetch-timeout=1500']);let latest;try{latest=JSON.parse(text);}catch{}if(!/^\d+\.\d+\.\d+$/.test(latest))throw fail('REGISTRY_UNAVAILABLE','Expected a stable registry version.');return {ok:true,current:pkg.version,latest,update_available:newer(latest,pkg.version)};}
|
|
253
|
+
export function newer(a,b){const x=a.split('.').map(Number),y=b.split('.').map(Number);for(let i=0;i<3;i++)if(x[i]!==y[i])return x[i]>y[i];return false;}
|
|
254
|
+
export function compatible(a,b){return /^\d+\.\d+\.\d+$/.test(a)&&newer(a,b)&&a.split('.')[0]===b.split('.')[0]&&(b.split('.')[0]!=='0'||a.split('.')[1]===b.split('.')[1]);}
|
|
255
|
+
export function update(resolution,{candidate,version,yes}={}) {
|
|
256
|
+
if(!yes)throw fail('PERMISSION_REQUIRED','Package updates require --yes.');
|
|
257
|
+
if(version&&!/^\d+\.\d+\.\d+$/.test(version))throw fail('INVALID_VERSION','Use a stable semantic version.');
|
|
258
|
+
if(candidate&&(!path.isAbsolute(candidate)||!exists(candidate)?.isFile()||!candidate.endsWith('.tgz')))throw fail('INVALID_CANDIDATE','Use an existing absolute tarball path.');
|
|
259
|
+
const unlock=lock(resolution);let op;
|
|
260
|
+
try {
|
|
261
|
+
guard(resolution);
|
|
262
|
+
// Preserve an unresolved previous transaction; retries reuse its candidate through resume.
|
|
263
|
+
try{const last=operationStatus(resolution);if(last.stage!=='complete'&&last.stage!=='rolled-back')throw fail('RECOVERY_REQUIRED','An earlier update needs inspection and resume.',{operationId:last.id});}catch(e){if(e.code!=='OPERATION_NOT_FOUND')throw e;}
|
|
264
|
+
const requested=version||(!candidate?checkUpdate().latest:undefined);
|
|
265
|
+
if(!candidate&&requested===pkg.version)return installUnlocked(resolution);
|
|
266
|
+
if(!candidate&&exists(path.join(root,'.git')))throw fail('DEVELOPMENT_CHECKOUT','Update development source through Git; use a packed candidate for isolated QA.');
|
|
267
|
+
requireIdle(resolution);
|
|
268
|
+
op=createOperation(resolution,{requestedVersion:requested||null,acquisitionSpec:candidate||pkg.name+'@'+requested,acquisitionHash:candidate?hash(fs.readFileSync(candidate)):null});phase(op,'snapshotted',{backup:snapshot(resolution,op)});
|
|
269
|
+
const prefix=path.join(op.folder,'acquired');phase(op,'acquiring',{candidatePrefix:prefix});
|
|
270
|
+
npm(['install','--prefix',prefix,'--global=false','--ignore-scripts','--no-audit','--no-fund','--no-package-lock',candidate||pkg.name+'@'+requested],op.folder);
|
|
271
|
+
const from=path.join(prefix,'node_modules',...pkg.name.split('/')),identity=read(path.join(from,'package.json'));
|
|
272
|
+
if(identity.name!==pkg.name||(requested&&identity.version!==requested))throw fail('CANDIDATE_IDENTITY_MISMATCH','Acquired package identity mismatch.');
|
|
273
|
+
verifyCandidate(from);phase(op,'acquired',{candidate:from,candidateVersion:identity.version,candidateIdentity:hash(fs.readFileSync(path.join(from,'release-integrity.json'))),packageComplete:true});
|
|
274
|
+
return activate(resolution,op,{from});
|
|
275
|
+
}catch(e){if(op){phase(op,'failed',{error:{code:e.code||'UPDATE_FAILED',stage:op.record.stage,message:e.message,details:e.details},recovery:'Inspect operation status and resume the same candidate.'});e.details={...e.details,operationId:op.id,operationDirectory:op.folder,backup:op.record.backup};}throw e;}finally{unlock();}
|
|
276
|
+
}
|
|
277
|
+
function installUnlocked(resolution){
|
|
278
|
+
const s=guard(resolution);if(s.current)return {...s,verified:true,changed:false,state:'unchanged',complete:false,entry:path.join(s.target,'.runtime',config.entry)};
|
|
279
|
+
requireIdle(resolution);
|
|
280
|
+
const op=createOperation(resolution,{candidate:root,candidateVersion:pkg.version,candidateIdentity:releaseIdentity()});
|
|
281
|
+
try{phase(op,'snapshotted',{backup:snapshot(resolution,op)});return activate(resolution,op);}
|
|
282
|
+
catch(e){phase(op,'failed',{error:{code:e.code||'SYNC_FAILED',stage:op.record.stage,message:e.message,details:e.details},recovery:'Inspect operation status and resume the recorded local candidate.'});e.details={...e.details,operationId:op.id,backup:op.record.backup,operationDirectory:op.folder};throw e;}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function recordedInstallation(resolution,state){
|
|
286
|
+
if(!state.candidateIdentity||!state.candidateVersion||!exists(resolution.target)?.isDirectory())return null;
|
|
287
|
+
try{
|
|
288
|
+
const runtime=path.join(resolution.target,'.runtime'),identity=read(path.join(runtime,'package.json'));
|
|
289
|
+
if(identity.name!==pkg.name||identity.version!==state.candidateVersion||hash(fs.readFileSync(path.join(runtime,'release-integrity.json')))!==state.candidateIdentity)return null;
|
|
290
|
+
verifyCandidate(runtime);const record=read(path.join(runtime,marker));
|
|
291
|
+
if(record.package!==pkg.name||!validHashes(record.hashes)||!equal(inventory(runtime,{skip:[marker]}),record.hashes))return null;
|
|
292
|
+
const installed=read(path.join(resolution.target,marker)),actual=localFiles(resolution.target),canonical=inventory(path.join(runtime,'skills',config.skill));
|
|
293
|
+
if(installed.package!==pkg.name||installed.version!==identity.version||!validHashes(installed.hashes))return null;
|
|
294
|
+
if(Object.keys({...actual,...canonical}).some(k=>k!==ui&&actual[k]!==canonical[k]))return null;
|
|
295
|
+
if(Object.keys({...actual,...installed.hashes}).some(k=>k!==ui&&actual[k]!==installed.hashes[k]))return null;
|
|
296
|
+
const text=metadata(resolution.target);if(text===null||text!==fs.readFileSync(path.join(resolution.target,ui),'utf8'))return null;
|
|
297
|
+
return {ok:true,version:identity.version,current:true,managed:true,installed:true,target:resolution.target,entry:path.join(runtime,config.entry),hostDiscovery:'pending',complete:false};
|
|
298
|
+
}catch{return null;}
|
|
299
|
+
}
|
|
300
|
+
export function resume(resolution,id,{yes}={}) {
|
|
301
|
+
if(!yes)throw fail('PERMISSION_REQUIRED','Resume requires --yes.');const unlock=lock(resolution);let op;
|
|
302
|
+
try{
|
|
303
|
+
const state=operationStatus(resolution,id);guard(resolution);requireIdle(resolution);
|
|
304
|
+
op={id:state.id,folder:state.operationDirectory,record:{...state},step:fs.readdirSync(state.operationDirectory).filter(x=>/^\d+-/.test(x)).length};
|
|
305
|
+
const verified=recordedInstallation(resolution,state);
|
|
306
|
+
if(verified){if(state.stage!=='complete')phase(op,'complete',{packageComplete:true,skillComplete:true,version:verified.version,recoveredUnknownOutcome:true});return {...op.record,...verified,changed:false,operationId:op.id};}
|
|
307
|
+
if(state.stage==='complete')throw fail('TARGET_CHANGED','The completed installation changed; inspect it before starting new maintenance.');
|
|
308
|
+
let from=state.candidate||(state.candidatePrefix&&path.join(state.candidatePrefix,'node_modules',...pkg.name.split('/')));
|
|
309
|
+
if(!from||!exists(path.join(from,'package.json'))){
|
|
310
|
+
if(state.packageComplete||!state.acquisitionSpec||!state.candidatePrefix)throw fail('CANDIDATE_UNAVAILABLE','The recorded candidate is unavailable; preserve its transaction and backup.');
|
|
311
|
+
if(state.acquisitionHash&&(!exists(state.acquisitionSpec)||hash(fs.readFileSync(state.acquisitionSpec))!==state.acquisitionHash))throw fail('CANDIDATE_IDENTITY_MISMATCH','The original archive changed before acquisition recovery.');
|
|
312
|
+
if(state.targetFingerprint&&!equal(state.targetFingerprint,localFilesIfPresent(resolution.target)))throw fail('CONCURRENT_EDIT','The target changed after the original snapshot.');
|
|
313
|
+
phase(op,'reacquiring',{requiredAction:'Retry only the recorded specification after confirming no acquired package is present.'});
|
|
314
|
+
npm(['install','--prefix',state.candidatePrefix,'--global=false','--ignore-scripts','--no-audit','--no-fund','--no-package-lock',state.acquisitionSpec],op.folder);
|
|
315
|
+
from=path.join(state.candidatePrefix,'node_modules',...pkg.name.split('/'));
|
|
316
|
+
}
|
|
317
|
+
const identity=read(path.join(from,'package.json'));
|
|
318
|
+
if(identity.name!==pkg.name||((state.candidateVersion||state.requestedVersion)&&(state.candidateVersion||state.requestedVersion)!==identity.version))throw fail('CANDIDATE_IDENTITY_MISMATCH','Recorded candidate identity changed.');
|
|
319
|
+
verifyCandidate(from);const digest=hash(fs.readFileSync(path.join(from,'release-integrity.json')));
|
|
320
|
+
if(state.candidateIdentity&&digest!==state.candidateIdentity)throw fail('CANDIDATE_IDENTITY_MISMATCH','Recorded candidate content changed; preserve the original transaction and backup.');
|
|
321
|
+
phase(op,'acquired',{candidate:from,candidateVersion:identity.version,candidateIdentity:digest,packageComplete:true});
|
|
322
|
+
const complete=recordedInstallation(resolution,op.record);
|
|
323
|
+
if(complete){phase(op,'complete',{packageComplete:true,skillComplete:true,version:complete.version,recoveredUnknownOutcome:true});return {...op.record,...complete,changed:false,operationId:op.id};}
|
|
324
|
+
const staged=path.join(op.folder,'staged');if(exists(staged))fs.renameSync(staged,path.join(op.folder,'previous-staging-'+crypto.randomUUID()));
|
|
325
|
+
return activate(resolution,op,{from});
|
|
326
|
+
}catch(e){if(op){phase(op,'failed',{error:{code:e.code||'RESUME_FAILED',message:e.message,details:e.details}});e.details={...e.details,operationId:op.id,backup:op.record.backup};}throw e;}
|
|
327
|
+
finally{unlock();}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function rollback(resolution,id,{yes}={}) {
|
|
331
|
+
if(!yes)throw fail('PERMISSION_REQUIRED','Rollback requires --yes.');const unlock=lock(resolution);let op,retired;
|
|
332
|
+
try {
|
|
333
|
+
const state=operationStatus(resolution,id);guard(resolution);requireIdle(resolution);
|
|
334
|
+
if(!state.backup||!exists(path.join(state.backup,'SKILL.md')))throw fail('BACKUP_UNAVAILABLE','No verified pre-replacement snapshot exists.');
|
|
335
|
+
op={id:state.id,folder:state.operationDirectory,record:{...state},step:fs.readdirSync(state.operationDirectory).filter(x=>/^\d+-/.test(x)).length};
|
|
336
|
+
const restored=path.join(op.folder,'rollback-staged-'+crypto.randomUUID());copyTree(state.backup,restored);
|
|
337
|
+
if(!equal(inventory(restored),inventory(state.backup)))throw fail('BACKUP_VERIFICATION_FAILED','Rollback snapshot hashes differ.');
|
|
338
|
+
phase(op,'rollback-ready',{rollbackSnapshot:state.backup});
|
|
339
|
+
if(exists(resolution.target)){retired=path.join(op.folder,'before-rollback-'+crypto.randomUUID());fs.renameSync(resolution.target,retired);}
|
|
340
|
+
fs.renameSync(restored,resolution.target);phase(op,'rolled-back',{packageComplete:false,skillComplete:false,rollbackSnapshot:state.backup,requiredAction:'Verify the restored version and host discovery before further maintenance.'});
|
|
341
|
+
return {ok:true,...op.record,operationId:op.id};
|
|
342
|
+
}catch(e){
|
|
343
|
+
if(op){const recoveryEntry=retired&&exists(path.join(retired,'.runtime',config.entry))?path.join(retired,'.runtime',config.entry):path.join(root,config.entry);phase(op,'rollback-failed',{error:{code:e.code||'ROLLBACK_FAILED',message:e.message},recoveryEntry});e.details={...e.details,operationId:op.id,backup:op.record.backup,recoveryEntry};}throw e;
|
|
344
|
+
}finally{unlock();}
|
|
345
|
+
}
|
|
346
|
+
function policyPath(target){const scope=target||root;return path.join(process.env[config.env+'_STATE_DIR']||path.join(os.homedir(),'.local','state',config.skill),'lifecycle',hash(Buffer.from(scope)));}
|
|
347
|
+
export function policy(options={},resolution){const dir=policyPath(resolution?.target),file=path.join(dir,'policy.json');let value={mode:'notify',authorized:false};if(exists(file)){value=read(file);if(value.package!==pkg.name||!['off','notify','auto'].includes(value.mode))throw fail('UNMANAGED_STATE','Preserving invalid policy file.');}
|
|
348
|
+
if(options.mode){if(!options.yes)throw fail('PERMISSION_REQUIRED','Policy changes require --yes.');if(!['off','notify','auto'].includes(options.mode))throw fail('INVALID_POLICY','Choose off, notify or auto.');if(options.mode==='auto'&&!resolution)throw fail('TARGET_PATH_REQUIRED','Automatic installation requires a resolved target.');if(exists(file))fs.renameSync(file,file+'.backup-'+crypto.randomUUID());write(file,{package:pkg.name,mode:options.mode,authorized:options.mode==='auto',target:resolution?.target||null});value=read(file);}
|
|
349
|
+
return {ok:true,...value,intervalHours:24,timeoutMs:2000,channel:'latest',scope:resolution?.target||root,policyFile:file};}
|
|
350
|
+
export async function automatic(resolution,{probe,now=Date.now()}={}) {
|
|
351
|
+
if(!probe&&exists(path.join(root,'.git')))return {ok:true,state:'owner-managed'};
|
|
352
|
+
if(process.env[config.env+'_AUTO_UPDATE']==='0'||process.env[config.env+'_UPDATE_CHECK']==='0')return {ok:true,state:'disabled'};
|
|
353
|
+
let p;try{p=policy({},resolution);}catch(e){return {ok:false,state:'deferred',code:e.code||'INVALID_POLICY',message:'Preserving invalid update policy; inspect it before maintenance.'};}if(p.mode==='off')return {ok:true,state:'disabled'};
|
|
354
|
+
const dir=policyPath(resolution?.target),file=path.join(dir,'cache.json'),locked=path.join(dir,'check.lock');let fd;
|
|
355
|
+
try {
|
|
356
|
+
fs.mkdirSync(dir,{recursive:true});try{fd=fs.openSync(locked,'wx',0o600);fs.writeFileSync(fd,JSON.stringify({package:pkg.name,pid:process.pid}));}catch(e){if(e.code!=='EEXIST')throw e;const held=read(locked);if(held.package!==pkg.name||!Number.isSafeInteger(held.pid)||held.pid<=0)throw fail('UNMANAGED_STATE','Preserving invalid automatic-check lock.');let dead=false;try{process.kill(held.pid,0);}catch(cause){dead=cause.code==='ESRCH';}if(!dead)return {ok:true,state:'busy'};fs.renameSync(locked,locked+'.stale-'+crypto.randomUUID());fd=fs.openSync(locked,'wx',0o600);fs.writeFileSync(fd,JSON.stringify({package:pkg.name,pid:process.pid}));}
|
|
357
|
+
let cache;try{cache=read(file);if(cache.package!==pkg.name||!Number.isFinite(cache.time)||cache.time>now||(cache.latest!==null&&!/^\d+\.\d+\.\d+$/.test(cache.latest)))throw fail('UNMANAGED_STATE','Preserving invalid cache.');}catch(e){if(exists(file))throw e;}
|
|
358
|
+
if(cache&&cache.current===pkg.version&&now-cache.time<(cache.latest?86400000:300000))return {ok:true,state:'cached',...cache};
|
|
359
|
+
let latest=null;try{latest=probe?await probe():await fetch('https://registry.npmjs.org/'+encodeURIComponent(pkg.name)+'/latest',{signal:AbortSignal.timeout(2000)}).then(r=>{if(!r.ok)throw Error('registry');return r.json();}).then(v=>{if(v.name!==pkg.name||!/^\d+\.\d+\.\d+$/.test(v.version))throw Error('metadata');return v.version;});}catch{}
|
|
360
|
+
if(exists(file))fs.renameSync(file,file+'.previous-'+crypto.randomUUID());cache={package:pkg.name,time:now,current:pkg.version,latest};write(file,cache);
|
|
361
|
+
if(!latest)return {ok:true,state:'offline',...cache};
|
|
362
|
+
if(!newer(latest,pkg.version))return {ok:true,state:'up-to-date',...cache};
|
|
363
|
+
if(activeWork())return {ok:true,state:'active-work',...cache};
|
|
364
|
+
if(p.mode!=='auto')return {ok:true,state:'update-available',...cache};
|
|
365
|
+
if(!p.authorized||!resolution||!compatible(latest,pkg.version))return {ok:true,state:'manual-update-required',...cache};
|
|
366
|
+
if(exists(path.join(root,'.git')))return {ok:true,state:'owner-managed',...cache};
|
|
367
|
+
return {ok:true,state:'updated',receipt:update(resolution,{version:latest,yes:true}),reloadSkill:true};
|
|
368
|
+
}catch(e){return {ok:false,state:'deferred',code:e.code||'AUTO_CHECK_FAILED',message:e.message,details:e.details};}
|
|
369
|
+
finally{if(fd!==undefined){fs.closeSync(fd);fs.unlinkSync(locked);}}
|
|
370
|
+
}
|
|
371
|
+
export const installationLifecycle={agentNeutral:true,skillRequired:true,updateRequiresSkillSync:true,required_components:['cli','skill','runtime'],installGuide:'INSTALL.md',updateGuide:'UPDATE.md',explicitTargetRequired:true,targetRequired:true,hostDiscoveryVerification:true,hostRegistrationRequired:true,installationMode:'copy',sharedLinksAllowed:false,isolatedRuntimeRequired:true,physicalCopies:true,isolatedRuntime:true,sharedLinks:false,postBumpIdentityValidation:true,install:'Resolve the current Agent directory; skill install --agent AGENT --path EXACT_SKILL_DIR --json',update:'Resolve selected target before replacement; update install --yes --agent AGENT --path EXACT_SKILL_DIR --json',verification:'Verify local runtime version, managed hashes, then reload the host and confirm Skill discovery.',target_scope:'selected current Agent only'};
|
|
372
|
+
export function commandContracts(){
|
|
373
|
+
const result={},target={
|
|
374
|
+
'--agent':{type:'string',description:'Valid current Agent name; generic fallback for unnamed hosts.'},
|
|
375
|
+
'--path':{type:'string',description:'Exact Skill destination resolved by the current Agent.'},
|
|
376
|
+
'--root':{type:'string',description:'Legacy explicit parent directory; appends the canonical Skill name.'},
|
|
377
|
+
'--home':{type:'string',description:'Legacy explicitly resolved host root; appends skills and Skill name.'},
|
|
378
|
+
'--target-directory':{type:'string',description:'Explicit exact Skill destination alias.'}
|
|
379
|
+
},common={'--json':{type:'boolean'},'--help':{type:'boolean'}},yes={'--yes':{type:'boolean',required:true}};
|
|
380
|
+
for(const action of ['source','status','install','update'])result['skill '+action]={options:{...common,...(action==='source'?{}:target),...(['install','update'].includes(action)?{'--mode':{type:'string',enum:['copy','link'],description:'Legacy link requests migrate to a physical copy.'},'--legacy-source':{type:'string',description:'Explicit owning package root for verified legacy migration.'},'--force':{type:'boolean',description:'Accepted legacy flag; local edit protection remains enforced.'},'--backup-edits':{type:'boolean',description:'Accepted legacy flag; local edits require separate reconciliation.'}}:{})},sideEffects:['install','update'].includes(action)?'local-write':'none'};
|
|
381
|
+
result['update check']={options:common,sideEffects:'none'};
|
|
382
|
+
result['update install']={options:{...common,...target,...yes,'--candidate':{type:'string',description:'Absolute packed candidate archive for authorized installation.'},'--version':{type:'string',description:'Exact stable registry version.'}},sideEffects:'package-and-skill-update'};
|
|
383
|
+
result['update operation']={options:{...common,...target,'--operation':{type:'string'}},sideEffects:'none'};
|
|
384
|
+
for(const action of ['resume','rollback'])result['update '+action]={options:{...common,...target,...yes,'--operation':{type:'string'}},sideEffects:'package-and-skill-update'};
|
|
385
|
+
result['update policy']={options:{...common,...target,'--yes':{type:'boolean'},'--mode':{type:'string',enum:['off','notify','auto']}},sideEffects:'local-write',writeCondition:'Only an authorized explicit --mode change writes policy.'};
|
|
386
|
+
result['update status']={options:{...common,...target},sideEffects:'none'};
|
|
387
|
+
return result;
|
|
388
|
+
}
|
|
389
|
+
export function parse(args){const words=[],options={},booleans=new Set(['json','yes','help','force','backup-edits']);for(let i=0;i<args.length;i++){const token=args[i];if(!token.startsWith('--')){words.push(token);continue;}const key=token.slice(2);if(key in options)throw fail('INVALID_ARGUMENT','Duplicate option '+token);if(booleans.has(key))options[key]=true;else{if(!args[i+1]||args[i+1].startsWith('--'))throw fail('INVALID_ARGUMENT','Missing value for '+token);options[key]=args[++i];}}return {words,options};}
|
|
390
|
+
export async function dispatch(args) {
|
|
391
|
+
const managed=['skill','update'].includes(args[0]);if(!managed)return false;
|
|
392
|
+
try {
|
|
393
|
+
const {words,options:o}=parse(args),[command,action,mode]=words;
|
|
394
|
+
const common=['json','help'];
|
|
395
|
+
const targetFlags=['agent','path','root','home','target-directory'];
|
|
396
|
+
const flags=command==='skill'?(action==='source'?[]:[...targetFlags,...(['install','update'].includes(action)?['mode','force','backup-edits','legacy-source']:[])])
|
|
397
|
+
:action==='check'?[]:action==='install'?[...targetFlags,'yes','candidate','version']:action==='operation'?[...targetFlags,'operation']:['resume','rollback'].includes(action)?[...targetFlags,'operation','yes']:action==='status'?targetFlags:action==='policy'?[...targetFlags,'yes','mode']:action==='auto'?[...targetFlags,'yes']:[];
|
|
398
|
+
const allowed=new Set([...common,...flags]);
|
|
399
|
+
for(const k of Object.keys(o))if(!allowed.has(k))throw fail('INVALID_ARGUMENT','Unknown option --'+k);
|
|
400
|
+
const known=command==='skill'?['source','status','install','update']:['check','install','operation','resume','rollback','policy','status','auto'];
|
|
401
|
+
if(!known.includes(action)||words.length>(action==='auto'?3:2))throw fail('INVALID_COMMAND','Unknown lifecycle command.');
|
|
402
|
+
const commands=['skill source','skill status|install|update --agent AGENT --path EXACT_SKILL_DIR --json','update check','update install --yes --path EXACT_SKILL_DIR [--candidate TARBALL]','update operation|resume --path EXACT_SKILL_DIR [--operation UUID]','update policy --mode off|notify|auto --yes [--path EXACT_SKILL_DIR]'];
|
|
403
|
+
if(o.help){console.log(JSON.stringify({ok:true,command:words.join(' '),...commandContracts()[words.slice(0,2).join(' ')],commands,installationLifecycle},null,2));return true;}
|
|
404
|
+
let result;
|
|
405
|
+
if(command==='skill'&&action==='source'){
|
|
406
|
+
if(Object.keys(o).some(k=>k!=='json'))throw fail('INVALID_ARGUMENT','skill source accepts only --json.');
|
|
407
|
+
result={ok:true,package:pkg.name,name:config.skill,source,version:pkg.version,installation:{fallback_agent:'generic',custom_path_required:true},installationLifecycle,maintenanceContract,commandContracts:commandContracts()};
|
|
408
|
+
}else if(command==='update'&&action==='check')result=checkUpdate();
|
|
409
|
+
else if(command==='update'&&['policy','status','auto'].includes(action)){
|
|
410
|
+
const hasTarget=['path','root','home','target-directory'].some(k=>o[k]!==undefined)||process.env[config.env+'_SKILL_PATH'];
|
|
411
|
+
const own=path.basename(root)==='.runtime'&&exists(path.join(path.dirname(root),marker))&&read(path.join(path.dirname(root),marker)).package===pkg.name;
|
|
412
|
+
const target=hasTarget?resolveTarget(o):own?resolveTarget({path:path.dirname(root)}):undefined;
|
|
413
|
+
if(action==='auto'&&mode&&!['on','off','status'].includes(mode))throw fail('INVALID_COMMAND','Use auto on, off or status.');
|
|
414
|
+
if(action==='auto'&&['on','off'].includes(mode))result=policy({mode:mode==='on'?'auto':'off',yes:o.yes},target);
|
|
415
|
+
else result=policy(o,target);
|
|
416
|
+
}else{
|
|
417
|
+
const resolution=resolveTarget(o);
|
|
418
|
+
if(command==='skill'&&o.mode&&!['copy','link'].includes(o.mode))throw fail('INVALID_ARGUMENT','Legacy mode must be copy or link; both install physical copies.');
|
|
419
|
+
if(command==='skill')result=action==='status'?status(resolution):install(resolution);
|
|
420
|
+
else if(action==='operation')result=operationStatus(resolution,o.operation);
|
|
421
|
+
else if(action==='resume')result=resume(resolution,o.operation,{yes:o.yes});
|
|
422
|
+
else if(action==='rollback')result=rollback(resolution,o.operation,{yes:o.yes});
|
|
423
|
+
else result=update(resolution,{candidate:o.candidate,version:o.version,yes:o.yes});
|
|
424
|
+
}
|
|
425
|
+
console.log(JSON.stringify(result,null,2));if(result.ok===false)process.exitCode=1;
|
|
426
|
+
}catch(e){console.log(JSON.stringify({ok:false,code:e.code||'LIFECYCLE_FAILED',message:e.message,error:{code:e.code||'LIFECYCLE_FAILED',message:e.message,details:e.details||{}},details:e.details||{},complete:false},null,2));process.exitCode=1;}
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
console.log(JSON.stringify({complete:false,code:'SKILL_SETUP_REQUIRED',installGuide:'INSTALL.md',updateGuide:'UPDATE.md',message:'The current Agent must resolve its own Skill directory, install a complete isolated copy, verify local runtime and native Skill discovery.'}));
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {parseDocument} from 'yaml';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import {fileURLToPath} from 'node:url';
|
|
6
|
+
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
|
|
7
|
+
const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json'))),manifest=JSON.parse(fs.readFileSync(path.join(root,'release-integrity.json')));
|
|
8
|
+
const cap=JSON.parse(fs.readFileSync(path.join(root,'skills',pkg.skillLifecycle.skill,'capabilities.json')));
|
|
9
|
+
const skill=fs.readFileSync(path.join(root,'skills',pkg.skillLifecycle.skill,'SKILL.md'),'utf8');
|
|
10
|
+
const header=skill.match(/^---\r?\n([\s\S]*?)\r?\n---/);if(!header)throw Error('RELEASE_SKILL_FRONTMATTER_INVALID');const parsed=parseDocument(header[1]);if(parsed.errors.length)throw Error('RELEASE_SKILL_FRONTMATTER_INVALID');const frontmatter=parsed.toJSON();if(frontmatter.metadata?.package!==pkg.name||frontmatter.metadata?.version!==pkg.version)throw Error('RELEASE_SKILL_IDENTITY_DRIFT');
|
|
11
|
+
if(manifest.version!==pkg.version||manifest.package!==pkg.name||cap.package_version!==pkg.version||!skill.includes(' version: '+pkg.version+'\n'))throw Error('RELEASE_IDENTITY_DRIFT: package and Skill versions must agree after bump');
|
|
12
|
+
const actualFiles=[];
|
|
13
|
+
function list(at,prefix=''){for(const entry of fs.readdirSync(at,{withFileTypes:true})){const rel=prefix?prefix+'/'+entry.name:entry.name;if(entry.isSymbolicLink())throw Error('RELEASE_UNSAFE_LINK: '+rel);if(entry.isDirectory())list(path.join(at,entry.name),rel);else actualFiles.push(rel);}}
|
|
14
|
+
for(const name of ['package.json',...pkg.files]){if(name==='release-integrity.json')continue;const p=path.join(root,name);if(fs.statSync(p).isDirectory())list(p,name.replace(/\/$/,''));else actualFiles.push(name);}
|
|
15
|
+
if(JSON.stringify(actualFiles.sort())!==JSON.stringify(Object.keys(manifest.files).sort()))throw Error('RELEASE_RESOURCE_SET_DRIFT');
|
|
16
|
+
for(const [file,expected]of Object.entries(manifest.files)){const bytes=fs.readFileSync(path.join(root,file));if(crypto.createHash('sha256').update(bytes).digest('hex')!==expected)throw Error('RELEASE_CONTENT_DRIFT: '+file);}
|
|
17
|
+
for(const file of ['INSTALL.md','UPDATE.md','lifecycle/engine.mjs'])if(!manifest.files[file])throw Error('RELEASE_RESOURCE_MISSING: '+file);
|
|
18
|
+
console.log(JSON.stringify({ok:true,version:pkg.version,files:Object.keys(manifest.files).length}));
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import {fileURLToPath} from 'node:url';
|
|
5
|
+
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
|
|
6
|
+
const p=JSON.parse(fs.readFileSync(path.join(root,'package.json'))),skill=path.join(root,'skills',p.skillLifecycle.skill);
|
|
7
|
+
const {commandContracts,maintenanceContract}=await import('./engine.mjs');
|
|
8
|
+
const declarationFile=path.join(root,'capabilities.json'),declaration=JSON.parse(fs.readFileSync(declarationFile));
|
|
9
|
+
const existing=declaration.commandContracts||{};for(const key of Object.keys(existing))if(key.startsWith('skill ')||key.startsWith('update '))delete existing[key];
|
|
10
|
+
declaration.maintenanceContract=maintenanceContract;declaration.commandContracts={...existing,...commandContracts()};fs.writeFileSync(declarationFile,JSON.stringify(declaration,null,2)+'\n');
|
|
11
|
+
const file=path.join(skill,'SKILL.md');const text=fs.readFileSync(file,'utf8');
|
|
12
|
+
fs.writeFileSync(file,text.replace(/( version: )[^\n]+/,'$1'+p.version));
|
|
13
|
+
const cap=path.join(skill,'capabilities.json'),v=JSON.parse(fs.readFileSync(cap));v.package_version=p.version;v.maintenanceContract=maintenanceContract;v.commandContracts=declaration.commandContracts;fs.writeFileSync(cap,JSON.stringify(v,null,2)+'\n');
|
|
14
|
+
function inventory(dir){const out={};function walk(at=''){for(const e of fs.readdirSync(path.join(dir,at),{withFileTypes:true})){const rel=at?at+'/'+e.name:e.name;if(e.isDirectory())walk(rel);else if(e.isFile())out[rel]=crypto.createHash('sha256').update(fs.readFileSync(path.join(dir,rel))).digest('hex');else throw Error('Links are not distributable');}}walk();return out;}
|
|
15
|
+
if(p.name.endsWith('/cloud-hug-style-transfer'))fs.writeFileSync(path.join(root,'npm/integrity.json'),JSON.stringify(inventory(skill),null,2)+'\n');
|
|
16
|
+
if(p.name.endsWith('/nano-product-replacement'))fs.writeFileSync(path.join(root,'skill-integrity.json'),JSON.stringify(inventory(skill),null,2)+'\n');
|
|
17
|
+
const files={};for(const entry of ['package.json',...p.files]){if(entry==='release-integrity.json')continue;const target=path.join(root,entry);if(!fs.existsSync(target))throw Error('Missing packed resource '+entry);if(fs.statSync(target).isDirectory()){for(const [name,hash] of Object.entries(inventory(target)))files[entry.replace(/\/$/,'')+'/'+name]=hash;}else files[entry]=crypto.createHash('sha256').update(fs.readFileSync(target)).digest('hex');}
|
|
18
|
+
fs.writeFileSync(path.join(root,'release-integrity.json'),JSON.stringify({package:p.name,version:p.version,files},null,2)+'\n');
|
|
19
|
+
console.log(JSON.stringify({ok:true,version:p.version,files:Object.keys(files).length}));
|
package/npm/automatic-update.mjs
CHANGED
|
@@ -1,82 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import crypto from 'node:crypto';
|
|
4
|
-
import { root, pkg, fail } from './runtime.mjs';
|
|
5
|
-
import { stateRoot, checkUpdate, installUpdate, globalPrefix, newer } from './update.mjs';
|
|
6
|
-
|
|
7
|
-
const defaultPolicy={mode:'notify',intervalHours:24,timeoutMs:2000,offlineRetryMs:300000};
|
|
8
|
-
export const automaticPaths=()=>{
|
|
9
|
-
const key=crypto.createHash('sha256').update(root).digest('hex');
|
|
10
|
-
const dir=path.join(stateRoot(),'automatic-update',key);
|
|
11
|
-
return {dir,policy:path.join(dir,'policy.json'),cache:path.join(dir,'cache.json'),lock:path.join(dir,'check.lock')};
|
|
12
|
-
};
|
|
13
|
-
function readOwned(file) {
|
|
14
|
-
let st;try{st=fs.lstatSync(file);}catch(e){if(e.code==='ENOENT')return null;throw e;}
|
|
15
|
-
if(!st.isFile()||st.isSymbolicLink())throw fail('AUTO_STATE_INVALID','Automatic update state must be a regular managed file.');
|
|
16
|
-
let value;try{value=JSON.parse(fs.readFileSync(file,'utf8'));}catch{throw fail('AUTO_STATE_INVALID','Invalid automatic update state; preserve and reconcile it.');}
|
|
17
|
-
if(value?.package!==pkg.name||value.schemaVersion!==1)throw fail('AUTO_STATE_INVALID','Automatic update state ownership mismatch.');
|
|
18
|
-
return value;
|
|
19
|
-
}
|
|
20
|
-
function saveOwned(file,value) {
|
|
21
|
-
const old=readOwned(file);fs.mkdirSync(path.dirname(file),{recursive:true});let backup=null;
|
|
22
|
-
if(old){backup=file+'.backup-'+crypto.randomUUID();fs.copyFileSync(file,backup,fs.constants.COPYFILE_EXCL);if(!fs.statSync(backup).size)throw fail('AUTO_STATE_INVALID','State backup is empty.');}
|
|
23
|
-
const temporary=file+'.tmp-'+crypto.randomUUID();
|
|
24
|
-
fs.writeFileSync(temporary,JSON.stringify({schemaVersion:1,package:pkg.name,...value},null,2)+'\n',{flag:'wx',mode:0o600});
|
|
25
|
-
try{fs.renameSync(temporary,file);}finally{if(fs.existsSync(temporary))fs.unlinkSync(temporary);}
|
|
26
|
-
return backup;
|
|
27
|
-
}
|
|
28
|
-
export function updatePolicy(changes) {
|
|
29
|
-
const file=automaticPaths().policy,stored=readOwned(file);
|
|
30
|
-
const policy={...defaultPolicy,...(stored?.policy||{}),...changes};
|
|
31
|
-
if(!['off','notify','auto'].includes(policy.mode)||!Number.isFinite(policy.intervalHours)||policy.intervalHours<1||policy.intervalHours>168||policy.timeoutMs!==2000||policy.offlineRetryMs!==300000)throw fail('AUTO_POLICY_INVALID','Choose off/notify/auto and an interval between 1 and 168 hours.');
|
|
32
|
-
const authorized=policy.mode==='auto'&&(changes?.mode==='auto'||stored?.authorizedAutomaticInstall===true);
|
|
33
|
-
if(policy.mode==='auto'&&!authorized)throw fail('AUTO_POLICY_INVALID','Automatic installation needs an explicitly authorized policy.');
|
|
34
|
-
let backup=null;if(changes)backup=saveOwned(file,{policy,authorizedAutomaticInstall:authorized,updatedAt:new Date().toISOString()});
|
|
35
|
-
return {ok:true,policy,policyFile:file,backup,changed:Boolean(changes),installationScope:root};
|
|
36
|
-
}
|
|
37
|
-
export function compatibleStable(available,current) {
|
|
38
|
-
if(!newer(available,current))return false;
|
|
39
|
-
const a=available.split('.').map(Number),b=current.split('.').map(Number);
|
|
40
|
-
return a[0]===b[0]&&(b[0]!==0||a[1]===b[1]);
|
|
41
|
-
}
|
|
42
|
-
function acquire(file) {
|
|
43
|
-
try{const fd=fs.openSync(file,'wx',0o600);fs.writeFileSync(fd,JSON.stringify({schemaVersion:1,package:pkg.name,pid:process.pid}));fs.closeSync(fd);return true;}
|
|
44
|
-
catch(e){if(e.code!=='EEXIST')throw e;}
|
|
45
|
-
const old=readOwned(file);
|
|
46
|
-
if(!Number.isSafeInteger(old?.pid)||old.pid<=0)throw fail('AUTO_STATE_INVALID','Invalid automatic-check lock; inspect it before retrying.');
|
|
47
|
-
try{process.kill(old.pid,0);return false;}catch(e){if(e.code!=='ESRCH')return false;}
|
|
48
|
-
fs.unlinkSync(file);return acquire(file);
|
|
49
|
-
}
|
|
50
|
-
export async function automaticUpdate(options={},services={}) {
|
|
51
|
-
const now=(services.now||Date.now)(),paths=automaticPaths();
|
|
52
|
-
const owner=services.owner|| (globalPrefix()?'global-npm':'source-or-parent');
|
|
53
|
-
if(process.env.CLOUD_HUG_AUTO_UPDATE==='0')return {ok:true,state:'disabled',reason:'environment',changed:false};
|
|
54
|
-
let policy;
|
|
55
|
-
try{policy=updatePolicy().policy;}catch(e){return {ok:true,state:'deferred',code:e.code,message:e.message,changed:false};}
|
|
56
|
-
if(policy.mode==='off')return {ok:true,state:'disabled',policy,changed:false};
|
|
57
|
-
if(owner!=='global-npm')return {ok:true,state:'owner-managed',owner,policy,changed:false};
|
|
58
|
-
let locked=false;
|
|
59
|
-
try {
|
|
60
|
-
fs.mkdirSync(paths.dir,{recursive:true});locked=acquire(paths.lock);
|
|
61
|
-
if(!locked)return {ok:true,state:'busy',policy,changed:false};
|
|
62
|
-
const cache=readOwned(paths.cache);
|
|
63
|
-
if(cache&&(!Number.isFinite(cache.checkedAt)||cache.checkedAt>now||!cache.response||typeof cache.response.ok!=='boolean'||(cache.response.ok&&!/^\d+\.\d+\.\d+$/.test(cache.response.available))))throw fail('AUTO_STATE_INVALID','Invalid cached registry evidence; preserve and reconcile it.');
|
|
64
|
-
const ttl=cache?.response.ok?policy.intervalHours*3600000:policy.offlineRetryMs;
|
|
65
|
-
const cached=Boolean(!options.force&&cache&&now-cache.checkedAt<ttl);
|
|
66
|
-
const check=cached?{...cache.response,current:pkg.version,updateAvailable:cache.response.ok&&newer(cache.response.available,pkg.version)}:await (services.check||checkUpdate)(services.registry||fetch,{timeoutMs:policy.timeoutMs});
|
|
67
|
-
if(!cached)saveOwned(paths.cache,{checkedAt:now,response:check});
|
|
68
|
-
const result={ok:true,policy,cached,check,changed:false};
|
|
69
|
-
if(!check.ok)return {...result,state:'offline',retryAfterMs:policy.offlineRetryMs};
|
|
70
|
-
if(policy.mode==='notify')return {...result,state:check.updateAvailable?'update-available':'up-to-date',action:check.updateAvailable?'update install --yes':null};
|
|
71
|
-
if(check.updateAvailable&&!compatibleStable(check.available,pkg.version))return {...result,state:'manual-update-required',reason:'compatibility-boundary',action:'update install --yes'};
|
|
72
|
-
try{
|
|
73
|
-
const receipt=(services.install||installUpdate)(check,{resolution:options.resolution});
|
|
74
|
-
if(!receipt?.ok||!receipt.verified)throw fail('UPDATE_VERIFY_FAILED','Automatic installer did not return verified evidence.');
|
|
75
|
-
return {...result,state:receipt.changed?'updated':'up-to-date',changed:receipt.changed,packageChanged:receipt.packageChanged,receipt,reloadSkill:receipt.changed};
|
|
76
|
-
}catch(e){
|
|
77
|
-
const safe=['UPDATE_BUSY','SKILL_BUSY','SKILL_EDITED','SKILL_UNMANAGED','SKILL_METADATA_INVALID','UPDATE_OWNER_REQUIRED','SKILL_UNMANAGED_FILE'].includes(e.code);
|
|
78
|
-
return {...result,ok:safe,state:safe?'deferred':'failed',code:e.code||'UPDATE_FAILED',message:e.message,backup:e.backup||null,details:e.details||null};
|
|
79
|
-
}
|
|
80
|
-
}catch(e){return {ok:true,state:'deferred',code:e.code||'AUTO_CHECK_FAILED',message:e.message,changed:false};}
|
|
81
|
-
finally{if(locked)fs.unlinkSync(paths.lock);}
|
|
82
|
-
}
|
|
1
|
+
export {automatic as automaticUpdate,policy as updatePolicy,compatible as compatibleStable} from '../lifecycle/engine.mjs';
|