@yunhozz/arc 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/MANIFEST.sha256 +48 -35
  2. package/README.md +11 -0
  3. package/bin/arc-upgrade.mjs +3 -0
  4. package/claude/agents/arc-coder.md +1 -1
  5. package/claude/agents/arc-qa.md +2 -2
  6. package/claude/skill/SKILL.md +8 -8
  7. package/claude/skill/references/commit.md +4 -8
  8. package/claude/skill/references/coordination.md +6 -6
  9. package/claude/skill/references/git-recovery.md +3 -2
  10. package/claude/skill/references/handoff-prep.md +4 -2
  11. package/claude/skill/references/handoff.md +6 -7
  12. package/claude/skill/references/limits.md +1 -1
  13. package/claude/skill/references/planning.md +19 -40
  14. package/claude/skill/references/verification.md +8 -28
  15. package/claude/skill/references/visual-companion.md +1 -1
  16. package/codex/agents/arc-coder.toml +1 -0
  17. package/codex/agents/arc-planner.toml +2 -0
  18. package/codex/agents/arc-qa.toml +2 -0
  19. package/codex/agents/arc-reviewer.toml +2 -0
  20. package/codex/agents/arc-scouter.toml +2 -0
  21. package/codex/skill/SKILL.md +10 -11
  22. package/codex/skill/references/commit.md +9 -21
  23. package/codex/skill/references/coordination.md +5 -7
  24. package/codex/skill/references/git-recovery.md +21 -18
  25. package/codex/skill/references/handoff-prep.md +14 -16
  26. package/codex/skill/references/handoff-worktree.md +10 -12
  27. package/codex/skill/references/handoff.md +27 -44
  28. package/codex/skill/references/limits.md +1 -1
  29. package/codex/skill/references/planning.md +34 -61
  30. package/codex/skill/references/verification.md +22 -50
  31. package/codex/skill/references/visual-companion.md +6 -8
  32. package/lib/cli/choices.mjs +4 -4
  33. package/lib/cli/completion.mjs +10 -8
  34. package/lib/cli/index.mjs +5 -5
  35. package/lib/cli/postinstall.mjs +17 -0
  36. package/lib/cli/root.mjs +2 -1
  37. package/lib/cli/terminal.mjs +19 -0
  38. package/lib/cli/upgrade.mjs +57 -0
  39. package/lib/installer/plan.mjs +3 -2
  40. package/lib/installer/upgrade-state.mjs +29 -0
  41. package/lib/installer/upgrade.mjs +34 -0
  42. package/lib/npm/context.mjs +36 -0
  43. package/lib/npm/process.mjs +17 -0
  44. package/lib/npm/upgrade.mjs +29 -0
  45. package/lib/release/artifact.mjs +163 -0
  46. package/package.json +3 -2
  47. package/scripts/pack-release.mjs +9 -0
  48. package/scripts/postinstall.mjs +4 -0
  49. package/scripts/verify-release.mjs +6 -0
@@ -2,17 +2,18 @@ export function renderCompletion(shell){
2
2
  if(shell==='bash')return `_arc() {
3
3
  local cur="\${COMP_WORDS[COMP_CWORD]}" prev="\${COMP_WORDS[COMP_CWORD-1]}" command="\${COMP_WORDS[1]}" choices=""
4
4
  case "$prev" in
5
- --target) choices="claude codex all" ;;
6
- --preset) choices="economy balanced smart" ;;
7
- --language) choices="en ko" ;;
5
+ --target) [[ $command == -* || $command == preset || $command == uninstall ]] && choices="claude codex all" ;;
6
+ --preset) [[ $command == -* ]] && choices="economy balanced smart" ;;
7
+ --language) [[ $command == -* ]] && choices="en ko" ;;
8
8
  *)
9
- if (( COMP_CWORD == 1 )) && [[ $cur != -* ]]; then choices="preset uninstall"
9
+ if (( COMP_CWORD == 1 )) && [[ $cur != -* ]]; then choices="preset uninstall completion upgrade"
10
10
  else
11
11
  case "$command" in
12
12
  preset)
13
13
  if (( COMP_CWORD == 2 )) && [[ $cur != -* ]]; then choices="economy balanced smart"
14
14
  else choices="--target --check --dry-run --help"; fi ;;
15
15
  uninstall) choices="--target --dry-run --skip-global --help --version" ;;
16
+ upgrade) choices="--help" ;;
16
17
  completion) (( COMP_CWORD == 2 )) && choices="zsh bash" ;;
17
18
  -*) choices="--target --preset --language --max-total-subagents --max-concurrent-subagents --check --dry-run --skip-global --help --version" ;;
18
19
  esac
@@ -25,17 +26,18 @@ complete -F _arc arc
25
26
  if(shell==='zsh')return `_arc() {
26
27
  local -a candidates
27
28
  if (( CURRENT == 2 )) && [[ \${words[CURRENT]} != -* ]]; then
28
- candidates=(preset uninstall)
29
+ candidates=(preset uninstall completion upgrade)
29
30
  else
30
31
  case "\${words[CURRENT-1]}" in
31
- --target) candidates=(claude codex all) ;;
32
- --preset) candidates=(economy balanced smart) ;;
33
- --language) candidates=(en ko) ;;
32
+ --target) [[ \${words[2]} == -* || \${words[2]} == preset || \${words[2]} == uninstall ]] && candidates=(claude codex all) ;;
33
+ --preset) [[ \${words[2]} == -* ]] && candidates=(economy balanced smart) ;;
34
+ --language) [[ \${words[2]} == -* ]] && candidates=(en ko) ;;
34
35
  *) case "\${words[2]}" in
35
36
  preset)
36
37
  if (( CURRENT == 3 )) && [[ \${words[CURRENT]} != -* ]]; then candidates=(economy balanced smart)
37
38
  else candidates=(--target --check --dry-run --help); fi ;;
38
39
  uninstall) candidates=(--target --dry-run --skip-global --help --version) ;;
40
+ upgrade) candidates=(--help) ;;
39
41
  completion) (( CURRENT == 3 )) && candidates=(zsh bash) ;;
40
42
  -*) candidates=(--target --preset --language --max-total-subagents --max-concurrent-subagents --check --dry-run --skip-global --help --version) ;;
41
43
  esac ;;
package/lib/cli/index.mjs CHANGED
@@ -13,7 +13,7 @@ export function parseArgs(args){
13
13
  if(options.check&&options.dryRun)throw inputError('--check and --dry-run cannot be combined');return options;
14
14
  }
15
15
  export async function selectChoices(options,io){return(await import('./choices.mjs')).selectChoices(options,io);}
16
- export async function runCli(args=process.argv.slice(2),{input=process.stdin,output=process.stdout,error=process.stderr}={}){
16
+ export async function runCli(args=process.argv.slice(2),{input=process.stdin,output=process.stdout,error=process.stderr,cwd=process.cwd(),env=process.env}={}){
17
17
  let controller,onInterrupt,progress;
18
18
  try{
19
19
  const options=parseArgs(args);if(options.help){output.write(usage+'\n');return 0;}if(options.version){output.write(JSON.parse(fs.readFileSync(path.join(packageRoot,'package.json'))).version+'\n');return 0;}
@@ -22,12 +22,12 @@ export async function runCli(args=process.argv.slice(2),{input=process.stdin,out
22
22
  const {startProgress}=await import('./progress.mjs');
23
23
  const interactive=input.isTTY&&output.isTTY,label=options.check?'Checking ARC…':options.dryRun?'Preparing ARC dry run…':'Installing ARC…';
24
24
  if(!interactive)progress=startProgress({output,label});
25
- controller=new AbortController();onInterrupt=()=>controller.abort();process.once('SIGINT',onInterrupt);
25
+ controller=new AbortController();onInterrupt=()=>controller.abort();process.once('SIGINT',onInterrupt);process.once('SIGTERM',onInterrupt);
26
26
  const {loadNative}=await import('../safe-fs/native.mjs');const native=loadNative();
27
27
  const [{openRoot},{verifyPayload},{resolveTarget},{buildInstallPlan,validatePlanSet}]=await Promise.all([import('../safe-fs/index.mjs'),import('../installer/manifest.mjs'),import('../installer/targets.mjs'),import('../installer/plan.mjs')]);
28
28
  const safeFs={openRoot:p=>openRoot(p,{native})},payload=verifyPayload({packageRoot,safeFs});
29
29
  const platforms=options.target==='all'?['codex','claude']:[options.target];
30
- const {previewLimits,previewLanguage}=await import('../installer/plan.mjs');const{defaultLanguage}=await import('../installer/language.mjs');const selections=platforms.map(platform=>{const target=resolveTarget(platform,{userHome:os.homedir(),env:process.env});return{target,preview:previewLimits({target,safeFs}),languagePreview:previewLanguage({target,safeFs})};});
30
+ const {previewLimits,previewLanguage}=await import('../installer/plan.mjs');const{defaultLanguage}=await import('../installer/language.mjs');const selections=platforms.map(platform=>{const target=resolveTarget(platform,{userHome:os.homedir(),env,cwd});return{target,preview:previewLimits({target,safeFs}),languagePreview:previewLanguage({target,safeFs})};});
31
31
  if(interactive){const{selectLimits,selectLanguage}=await import('./choices.mjs');await selectLimits(options,selections,{input,output});await selectLanguage(options,defaultLanguage(selections.map(item=>item.languagePreview.existing)),{input,output});}
32
32
  progress??=startProgress({output,label});
33
33
  const plans=selections.map(({target,preview,languagePreview})=>buildInstallPlan({payload,target,language:options.language,languagePreview,preset:options.preset,skipGlobal:options.skipGlobal,safeFs,limitsPreview:preview,limits:{...(options.maxTotalSubagents===undefined?{}:{max_total_subagents:options.maxTotalSubagents}),...(options.maxConcurrentSubagents===undefined?{}:{max_concurrent_subagents:options.maxConcurrentSubagents})},concurrentExplicit:options.maxConcurrentSubagents!==undefined||Boolean(options.concurrentSelected)}));const {attachShellCompletion}=await import('../installer/shell-completion.mjs');attachShellCompletion(plans,{safeFs});validatePlanSet(plans);
@@ -48,8 +48,8 @@ export async function runCli(args=process.argv.slice(2),{input=process.stdin,out
48
48
  progress.stop();
49
49
  const reportResults=()=>{for(const item of result.results){output.write(`${item.platform}: ${item.status}; rollback: ${item.rollback}\n`);for(const recovery of item.residualPaths)output.write(`Residual path: ${recovery}\n`);if(item.error)error.write(`Error: ${item.error.message}\n`);}};
50
50
  if(result.interrupted){reportResults();return 130;}
51
- if(result.results.every(item=>item.status==='installed'&&!item.error)){output.write(`ARC installed! [${result.results.map(item=>item.platform).join('/')}]\n`);return 0;}
51
+ if(result.results.every(item=>item.status==='installed'&&!item.error)){output.write(`ARC version ${JSON.parse(fs.readFileSync(path.join(packageRoot,'package.json'))).version} installed! [${result.results.map(item=>item.platform).join('/')}]\n`);return 0;}
52
52
  reportResults();return 1;
53
53
  }catch(cause){progress?.stop();error.write(`Error: ${cause.message}\n`);if(cause.code==='ARC_CLI_INPUT'){error.write(usage+'\n');return 2;}if(cause.code==='ARC_CANCELLED')return 130;return ['ARC_UNSUPPORTED_RUNTIME','ARC_NATIVE_UNAVAILABLE','ARC_TARGET_CONFIGURATION'].includes(cause.code)?2:1;
54
- }finally{progress?.stop();if(onInterrupt)process.removeListener('SIGINT',onInterrupt);}
54
+ }finally{progress?.stop();if(onInterrupt){process.removeListener('SIGINT',onInterrupt);process.removeListener('SIGTERM',onInterrupt);}}
55
55
  }
@@ -0,0 +1,17 @@
1
+ import path from 'node:path';
2
+ import {inspectNpmContext,resolveInvocationCwd} from '../npm/context.mjs';
3
+ import {openInstallerTerminal} from './terminal.mjs';
4
+ import {runCli} from './index.mjs';
5
+ export async function runPostinstall({packageRoot,env=process.env,cwd=process.cwd(),input=process.stdin,output=process.stdout,error=process.stderr,deps={}}){
6
+ let terminal;
7
+ try{
8
+ if(env.npm_lifecycle_event!=='postinstall'){output.write('Run arc to install the ARC harness.\n');return 0;}
9
+ const context=await(deps.inspectNpmContext??inspectNpmContext)({packageRoot,entryPath:path.join(packageRoot,'scripts/postinstall.mjs'),env,cwd});
10
+ if(context.kind!=='global'){output.write('Run arc to install the ARC harness.\n');return 0;}
11
+ terminal=(deps.openInstallerTerminal??openInstallerTerminal)({env,input,output,error});
12
+ if(terminal.kind==='skip'){output.write('Run arc to install the ARC harness.\n');return 0;}
13
+ const invocationCwd=resolveInvocationCwd({env,cwd,lifecycle:true});
14
+ return await(deps.runCli??runCli)([],{...terminal.io,cwd:invocationCwd,env});
15
+ }catch(cause){(terminal?.io.error??error).write(`Error: ${cause.message}\n`);return 1;}
16
+ finally{terminal?.dispose?.();}
17
+ }
package/lib/cli/root.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import {renderCompletion} from './completion.mjs';
3
- const usage='Usage: arc [installation options]\n arc preset [economy|balanced|smart] --target <codex|claude|all>\n arc uninstall --target <codex|claude|all>\n arc completion <zsh|bash> (shell setup)\n arc --help | --version';
3
+ const usage='Usage: arc [installation options]\n arc preset [economy|balanced|smart] --target <codex|claude|all>\n arc uninstall --target <codex|claude|all>\n arc upgrade [--help]\n arc completion <zsh|bash> (shell setup)\n arc --help | --version';
4
4
  const completionUsage='Usage: arc completion <zsh|bash>';
5
5
  export async function runRoot(args=process.argv.slice(2),io={}){
6
6
  const output=io.output??process.stdout,error=io.error??process.stderr;
@@ -8,6 +8,7 @@ export async function runRoot(args=process.argv.slice(2),io={}){
8
8
  if(command==='--help'&&!rest.length){output.write(usage+'\n\n');return(await import('./index.mjs')).runCli(args,io);}
9
9
  if(command==='--version'&&!rest.length){output.write(JSON.parse(fs.readFileSync(new URL('../../package.json',import.meta.url))).version+'\n');return 0;}
10
10
  if(command==='preset')return(await import('./preset.mjs')).runCli(rest,io);
11
+ if(command==='upgrade')return(await import('./upgrade.mjs')).runCli(rest,io);
11
12
  if(command==='install'){error.write('Error: The install subcommand was removed. Run npx --yes @yunhozz/arc [installation options].\n');return 2;}
12
13
  if(command===undefined||command.startsWith('--'))return(await import('./index.mjs')).runCli(args,io);
13
14
  if(command==='uninstall')return(await import('./uninstall.mjs')).runCli(rest,io);
@@ -0,0 +1,19 @@
1
+ import fs from 'node:fs';
2
+ import tty from 'node:tty';
3
+ function openControllingTerminal(){
4
+ let inputFd,outputFd,input,output;
5
+ try{
6
+ inputFd=fs.openSync('/dev/tty','r+');outputFd=fs.openSync('/dev/tty','r+');
7
+ input=new tty.ReadStream(inputFd);inputFd=undefined;
8
+ output=new tty.WriteStream(outputFd);outputFd=undefined;
9
+ return{io:{input,output,error:output},dispose(){input.destroy();output.destroy();}};
10
+ }catch(cause){input?.destroy();output?.destroy();if(inputFd!==undefined)fs.closeSync(inputFd);if(outputFd!==undefined)fs.closeSync(outputFd);throw cause;}
11
+ }
12
+ export function openInstallerTerminal({platform=process.platform,env=process.env,input=process.stdin,output=process.stdout,error=process.stderr,openTty=openControllingTerminal}={}){
13
+ if(!['darwin','linux'].includes(platform))return{kind:'skip',reason:'unsupported platform'};
14
+ if(['CI','CONTINUOUS_INTEGRATION','BUILD_NUMBER','RUN_ID'].some(name=>env[name]&&env[name]!=='false'&&env[name]!=='0'))return{kind:'skip',reason:'non-interactive environment'};
15
+ let owned,io;
16
+ try{if(input.isTTY&&output.isTTY)io={input,output,error};else{owned=openTty();io=owned.io;}if(!io.input.isTTY||!io.output.isTTY)throw Error('Terminal is not interactive');}catch{owned?.dispose();return{kind:'skip',reason:'no interactive terminal'};}
17
+ const wasRaw=Boolean(io.input.isRaw),wasFlowing=io.input.readableFlowing;let disposed=false;
18
+ return{kind:'interactive',io,dispose(){if(disposed)return;disposed=true;try{if(io.input.setRawMode&&!io.input.destroyed)io.input.setRawMode(wasRaw);if(wasFlowing!==true)io.input.pause();}finally{owned?.dispose();}}};
19
+ }
@@ -0,0 +1,57 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import {fileURLToPath} from 'node:url';
5
+ const packageRoot=path.resolve(fileURLToPath(new URL('../../',import.meta.url)));
6
+ const usage='Usage: arc upgrade [--help]';
7
+ const inputError=message=>Object.assign(Error(message),{code:'ARC_CLI_INPUT'});
8
+ const configurationError=message=>Object.assign(Error(message),{code:'ARC_TARGET_CONFIGURATION'});
9
+ function identity(root){const pkg=JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8'));if(pkg.name!=='@yunhozz/arc'||!/^\d+\.\d+\.\d+$/.test(pkg.version))throw configurationError('Invalid ARC package identity');return pkg;}
10
+ async function runtime(deps){
11
+ const [context,state,upgrade,npm]=await Promise.all([import('../npm/context.mjs'),import('../installer/upgrade-state.mjs'),import('../installer/upgrade.mjs'),import('../npm/upgrade.mjs')]);
12
+ let safeFs=deps.safeFs;if(!safeFs){const[{loadNative},{openRoot}]=await Promise.all([import('../safe-fs/native.mjs'),import('../safe-fs/index.mjs')]);const native=loadNative();safeFs={openRoot:root=>openRoot(root,{native})};}
13
+ return{...context,...state,...upgrade,...npm,...deps,safeFs};
14
+ }
15
+ function report(result,output,error){
16
+ for(const item of result.results){output.write(item.platform+': '+item.status+'; rollback: '+item.rollback+'\n');for(const filename of item.residualPaths??[])output.write('Residual path: '+filename+'\n');if(item.error)error.write('Error: '+item.error.message+'\n');}
17
+ if(result.interrupted)return 130;
18
+ if(result.results.every(item=>item.status==='installed'&&!item.error)){output.write('ARC version '+result.version+' harness upgraded! Previous harness version: not recorded.\nOpen a new shell session to use updated completion.\nzsh completion: eval "$(arc completion zsh)" for this session\n');return 0;}
19
+ error.write('Harness update did not complete. Retry arc upgrade or npx --yes @yunhozz/arc@latest upgrade.\n');return 1;
20
+ }
21
+ function failure(cause,error){error.write('Error: '+cause.message+'\n');return ['ARC_CANCELLED','ARC_NPM_CANCELLED'].includes(cause.code)?130:['ARC_CLI_INPUT','ARC_TARGET_CONFIGURATION','ARC_UNSUPPORTED_RUNTIME','ARC_NATIVE_UNAVAILABLE'].includes(cause.code)?2:1;}
22
+ export async function runCli(args=process.argv.slice(2),io={},deps={}){
23
+ const output=io.output??process.stdout,error=io.error??process.stderr;let controller,onInterrupt,packageStarted=false;
24
+ try{
25
+ if(args.length===1&&args[0]==='--help'){output.write(usage+'\nUpdates the global CLI and installed harness; npm exec updates the harness only.\n');return 0;}
26
+ if(args.length)throw inputError('Expected no arguments or --help');
27
+ const root=deps.packageRoot??packageRoot,pkg=identity(root),env=io.env??process.env,cwd=fs.realpathSync(io.cwd??process.cwd()),api=await runtime(deps);
28
+ const context=await api.inspectNpmContext({packageRoot:root,entryPath:path.join(root,'bin/arc.mjs'),env,cwd});
29
+ if(!['global','npx'].includes(context.kind))throw configurationError('Run upgrade from a verified global ARC installation or npx --yes @yunhozz/arc@latest upgrade');
30
+ const state=api.collectInstalledState({safeFs:api.safeFs,userHome:deps.userHome??os.homedir(),env,cwd});
31
+ if(!state.targets.length)throw configurationError('No installed ARC harness found. Run npx --yes @yunhozz/arc to install it first');
32
+ controller=new AbortController();onInterrupt=()=>controller.abort();process.once('SIGINT',onInterrupt);process.once('SIGTERM',onInterrupt);
33
+ if(context.kind==='npx')return report(await api.upgradeInstalledTargets({packageRoot:root,state,safeFs:api.safeFs,signal:controller.signal}),output,error);
34
+ const latest=await api.resolveLatest({context,env,cwd,signal:controller.signal});packageStarted=true;
35
+ const replaced=await api.upgradeGlobal({context,expectedVersion:latest.version,env,cwd,signal:controller.signal});
36
+ if(replaced.packageStatus!=='updated')throw replaced.error??Error('CLI replacement failed');
37
+ output.write('CLI: updated to '+latest.version+' (previous '+pkg.version+').\n');
38
+ const result=await api.runUpgradeChild({entryPath:path.join(context.packageRoot,'bin/arc-upgrade.mjs'),request:{protocol:1,expectedVersion:latest.version,invocationCwd:cwd,expectedStateToken:state.stateToken},env,cwd,signal:controller.signal});
39
+ if(result.stdout)output.write(result.stdout);if(result.stderr)error.write(result.stderr);
40
+ if(result.code===0&&!result.signal)return 0;
41
+ error.write('CLI was updated but the harness update did not complete. Retry arc upgrade or npx --yes @yunhozz/arc@latest upgrade.\n');return result.code===130?130:1;
42
+ }catch(cause){const code=failure(cause,error);if(cause.code==='ARC_CLI_INPUT')error.write(usage+'\n');if(packageStarted)error.write('CLI state may have changed. Retry arc upgrade; if it is unavailable, run npx --yes @yunhozz/arc@latest upgrade.\n');return code;}
43
+ finally{if(onInterrupt){process.removeListener('SIGINT',onInterrupt);process.removeListener('SIGTERM',onInterrupt);}}
44
+ }
45
+ export async function runChild(request,io={},deps={}){
46
+ const output=io.output??process.stdout,error=io.error??process.stderr;let onInterrupt;
47
+ try{
48
+ if(!request||Object.keys(request).sort().join(',')!=='expectedStateToken,expectedVersion,invocationCwd,protocol'||request.protocol!==1||typeof request.expectedVersion!=='string'||!/^\d+\.\d+\.\d+$/.test(request.expectedVersion)||typeof request.expectedStateToken!=='string'||!/^[a-f0-9]{64}$/.test(request.expectedStateToken)||typeof request.invocationCwd!=='string'||!path.isAbsolute(request.invocationCwd)||fs.realpathSync(request.invocationCwd)!==request.invocationCwd)throw inputError('Invalid private upgrade request');
49
+ const root=deps.packageRoot??packageRoot,pkg=identity(root);if(pkg.version!==request.expectedVersion)throw configurationError('The new ARC package version does not match the requested upgrade');
50
+ const api=await runtime(deps),env=io.env??process.env,cwd=request.invocationCwd,context=await api.inspectNpmContext({packageRoot:root,entryPath:path.join(root,'bin/arc-upgrade.mjs'),env,cwd});
51
+ if(context.kind!=='global'||context.packageRoot!==root)throw configurationError('Cannot verify the replaced global ARC package');
52
+ const state=api.collectInstalledState({safeFs:api.safeFs,userHome:deps.userHome??os.homedir(),env,cwd});if(state.stateToken!==request.expectedStateToken)throw configurationError('Installed ARC settings changed during CLI replacement; retry upgrade');
53
+ if(!state.targets.length)throw configurationError('No installed ARC harness found');
54
+ const controller=new AbortController();onInterrupt=()=>controller.abort();process.once('SIGINT',onInterrupt);process.once('SIGTERM',onInterrupt);
55
+ return report(await api.upgradeInstalledTargets({packageRoot:root,state,safeFs:api.safeFs,signal:controller.signal}),output,error);
56
+ }catch(cause){return failure(cause,error);}finally{if(onInterrupt){process.removeListener('SIGINT',onInterrupt);process.removeListener('SIGTERM',onInterrupt);}}
57
+ }
@@ -2,6 +2,7 @@ import {installClaudeHook} from '../policy/claude-hook.mjs';
2
2
  import {validateGitPolicy} from '../../claude/skill/hooks/role-git-guard.mjs';
3
3
  import{parseLanguage,serializeLanguage,renderLanguagePolicy,validateLanguage}from'./language.mjs';
4
4
  import{resolveLimits}from'../limits.mjs';import{parseLimits,serializeLimits}from'./limits.mjs';import{readRuntimeConcurrency,renderCodexConcurrency,renderClaudeConcurrency}from'./runtime-config.mjs';import{assertPlanSnapshot}from'./transaction.mjs';
5
+ import{validateModelConfig}from'../models/config.mjs';
5
6
  import path from'node:path';import{loadModelConfig,applyPreset,serializeModelConfig,renderProfiles,resolveModelSettings}from'../models/index.mjs';import{inspectPath,TargetConfigurationError}from'./targets.mjs';import{renderManagedBlock,renderWaitSettings,agentsBlockBody}from'./global-config.mjs';
6
7
  function fail(message){throw new TargetConfigurationError(message);}
7
8
  export function previewLimits({target,safeFs}){
@@ -16,7 +17,7 @@ export function previewLanguage({target,safeFs}){
16
17
  let existing=null;try{if(current.exists)existing=parseLanguage(current.bytes).language;}catch(cause){fail(`Invalid ARC language settings: ${cause.message}`);}
17
18
  return{existing,snapshot};
18
19
  }
19
- export function buildInstallPlan({payload,target,preset,skipGlobal=false,safeFs,limits,concurrentExplicit=false,limitsPreview,language='en',languagePreview}){
20
+ export function buildInstallPlan({payload,target,preset,skipGlobal=false,safeFs,limits,concurrentExplicit=false,limitsPreview,language='en',languagePreview,modelConfig}){
20
21
  validateLanguage(language);
21
22
  if(languagePreview)assertPlanSnapshot({snapshot:languagePreview.snapshot},{safeFs});
22
23
  const languageState=languagePreview??previewLanguage({target,safeFs});
@@ -24,7 +25,7 @@ export function buildInstallPlan({payload,target,preset,skipGlobal=false,safeFs,
24
25
  const preview=limitsPreview??previewLimits({target,safeFs});
25
26
  limits=resolveLimits({existing:preview.existing,overrides:limits??{}});
26
27
  const input=payload.modelInputs[target.platform];if(!input)fail('Selected payload platform is unavailable');
27
- const config=applyPreset(loadModelConfig(input.models,target.platform),preset),profiles=renderProfiles(input.profiles,resolveModelSettings(config));for(const[name,bytes]of profiles)profiles.set(name,renderLanguagePolicy(bytes,language));const modelBytes=Buffer.from(serializeModelConfig(config));
28
+ const config=modelConfig===undefined?applyPreset(loadModelConfig(input.models,target.platform),preset):validateModelConfig(modelConfig);if(config.platform!==target.platform)fail('Model config platform does not match target');const profiles=renderProfiles(input.profiles,resolveModelSettings(config));for(const[name,bytes]of profiles)profiles.set(name,renderLanguagePolicy(bytes,language));const modelBytes=Buffer.from(serializeModelConfig(config));
28
29
  if(target.platform==='claude'){try{const script=payload.files.get('claude/skill/hooks/role-git-guard.mjs'),policy=payload.files.get('claude/skill/hooks/git-policy.json');if(!Buffer.isBuffer(script)||!script.length||!Buffer.isBuffer(policy))throw Error('Missing hook assets');validateGitPolicy(JSON.parse(policy.toString('utf8')));for(const[name,bytes]of profiles)profiles.set(name,installClaudeHook(bytes,path.join(target.home,'skills/arc')));}catch(cause){fail(`Invalid Claude Git guard: ${cause.message}`);}}
29
30
  const skill=new Map();for(const[name,bytes]of payload.files)if(name.startsWith(target.payloadPrefix+'/skill/'))skill.set(name.slice((target.payloadPrefix+'/skill/').length),Buffer.from(bytes));skill.set('SKILL.md',renderLanguagePolicy(skill.get('SKILL.md'),language));skill.set('settings.toml',serializeLanguage(language));skill.set('models.toml',modelBytes);skill.set('limits.toml',serializeLimits(limits));
30
31
  const snapshot=new Map(),home=target.home;const skills=path.join(home,'skills'),agents=path.join(home,'agents'),arc=path.join(skills,'arc');
@@ -0,0 +1,29 @@
1
+ import path from 'node:path';
2
+ import {createHash} from 'node:crypto';
3
+ import {resolveTarget,inspectPath,TargetConfigurationError} from './targets.mjs';
4
+ import {loadModelConfig} from '../models/index.mjs';
5
+ import {parseLanguage} from './language.mjs';
6
+ import {parseLimits} from './limits.mjs';
7
+ import {removeManagedBlock} from './global-config.mjs';
8
+ import {renderShellCompletion} from './shell-completion.mjs';
9
+ import {readRuntimeConcurrency} from './runtime-config.mjs';
10
+ const fail=message=>{throw new TargetConfigurationError(message);};
11
+ export function collectInstalledState({safeFs,userHome,env={},cwd}){
12
+ if(env.CODEX_HOME!==undefined&&!path.isAbsolute(env.CODEX_HOME)&&env.CODEX_HOME!=='~'&&!env.CODEX_HOME.startsWith('~/')&&(!cwd||!path.isAbsolute(cwd)))fail('A verified invocation cwd is required for relative CODEX_HOME');
13
+ const targets=[];
14
+ for(const platform of ['codex','claude']){
15
+ const target=resolveTarget(platform,{userHome,env,cwd}),snapshot=new Map(),arc=path.join(target.home,'skills/arc');
16
+ const skill=inspectPath(safeFs,arc,{kind:'directory',tree:true,allowLinks:true,snapshot});
17
+ const profiles=target.profileNames.map(name=>inspectPath(safeFs,path.join(target.home,'agents',name),{kind:'file',read:true,snapshot}));
18
+ const instruction=inspectPath(safeFs,target.instructionPath,{kind:'file',read:true,snapshot}),managed=instruction.exists?removeManagedBlock(instruction.bytes):{changed:false};
19
+ if(!skill.exists){if(profiles.some(item=>item.exists)||managed.changed)fail(`Incomplete ARC installation: ${target.home}`);continue;}
20
+ function mandatory(name){const found=inspectPath(safeFs,path.join(arc,name),{kind:'file',read:true,snapshot});if(!found.exists||!found.bytes.length)fail(`Incomplete ARC installation: missing ${name} at ${target.home}`);return found.bytes;}
21
+ mandatory('SKILL.md');if(profiles.some(item=>!item.exists||!item.bytes.length))fail(`Incomplete ARC installation: missing profile at ${target.home}`);
22
+ let language,modelConfig,limits;try{language=parseLanguage(mandatory('settings.toml')).language;modelConfig=loadModelConfig(mandatory('models.toml'),platform);limits=parseLimits(mandatory('limits.toml'));}catch(cause){throw new TargetConfigurationError(`Invalid installed ARC settings at ${target.home}: ${cause.message}`,{cause});}
23
+ const native=inspectPath(safeFs,target.runtimeConfigPath,{kind:'file',read:true,snapshot});if(native.exists)try{readRuntimeConcurrency(platform,native.bytes);}catch(cause){throw new TargetConfigurationError(`Invalid native configuration at ${target.runtimeConfigPath}: ${cause.message}`,{cause});}
24
+ const shell=inspectPath(safeFs,path.join(userHome,'.zshrc'),{kind:'file',read:true,snapshot});if(shell.exists)renderShellCompletion(shell.bytes);
25
+ targets.push({target,language,modelConfig,limits,snapshot});
26
+ }
27
+ const hash=createHash('sha256');for(const entry of targets){hash.update(entry.target.platform+'\0'+entry.target.home+'\0');for(const [name,value]of [...entry.snapshot].sort(([a],[b])=>a.localeCompare(b)))hash.update(JSON.stringify([name,value],(key,value)=>key==='atimeNs'?undefined:typeof value==='bigint'?value.toString():value));}
28
+ return{targets,stateToken:hash.digest('hex')};
29
+ }
@@ -0,0 +1,34 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import {buildInstallPlan,validatePlanSet} from './plan.mjs';
5
+ import {assertPlanSnapshot} from './transaction.mjs';
6
+ import {inspectPath} from './targets.mjs';
7
+ import {renderManagedBlock,removeManagedBlock,agentsBlockBody} from './global-config.mjs';
8
+ import {renderShellCompletion} from './shell-completion.mjs';
9
+ import {verifyPayload} from './manifest.mjs';
10
+ import {installTargets} from './install.mjs';
11
+ import {acquireExternalSkills} from './acquisition.mjs';
12
+ export function buildUpgradePlans({payload,state,safeFs}){
13
+ for(const entry of state.targets)assertPlanSnapshot(entry,{safeFs});
14
+ const plans=state.targets.map(entry=>{
15
+ const {target,language,modelConfig,limits}=entry;
16
+ const plan=buildInstallPlan({payload,target,language,modelConfig,preset:modelConfig.preset,limits,skipGlobal:true,safeFs});
17
+ for(const [name,value]of entry.snapshot)plan.snapshot.set(name,value);
18
+ const current=inspectPath(safeFs,target.instructionPath,{kind:'file',read:true,snapshot:plan.snapshot});
19
+ if(current.exists&&removeManagedBlock(current.bytes).changed){const rendered=renderManagedBlock(current.bytes,agentsBlockBody(target.home,target.userHome,language,target.platform));plan.managedPaths.push(target.instructionPath);if(!rendered.content.equals(current.bytes))plan.globalChanges.push({path:target.instructionPath,original:current.bytes,...rendered});}
20
+ return plan;
21
+ });
22
+ const first=plans[0];if(first){const filename=path.join(first.target.userHome,'.zshrc'),current=inspectPath(safeFs,filename,{kind:'file',read:true,snapshot:first.snapshot});
23
+ if(current.exists){const content=renderShellCompletion(current.bytes);if(current.bytes.includes(Buffer.from('# >>> arc completion >>>'))){first.managedPaths.push(filename);if(!content.equals(current.bytes))first.globalChanges.push({path:filename,original:current.bytes,content,action:'update completion'});}}
24
+ for(const plan of plans.slice(1))plan.snapshot.delete(filename);
25
+ }
26
+ validatePlanSet(plans);return plans;
27
+ }
28
+ export async function upgradeInstalledTargets({packageRoot,state,safeFs,signal,acquisition}){
29
+ const payload=verifyPayload({packageRoot,safeFs}),version=JSON.parse(payload.files.get('package.json').toString('utf8')).version,plans=buildUpgradePlans({payload,state,safeFs});
30
+ const acquire=acquisition??(async()=>{const work=fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()),'arc-acquire-'));let acquired;
31
+ try{acquired=await acquireExternalSkills({work,safeFs,signal});}catch(error){if(!error.residualPaths?.length)try{fs.rmdirSync(work);}catch(cleanup){throw Object.assign(new AggregateError([error,cleanup],'Cannot clean private acquisition work'),{code:'ARC_ACQUISITION_CLEANUP',residualPaths:[work]});}throw error;}
32
+ return{...acquired,dispose:async()=>{await acquired.dispose();fs.rmdirSync(work);}};});
33
+ return{version,...await installTargets(plans,{safeFs,signal,acquisition:acquire})};
34
+ }
@@ -0,0 +1,36 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {runNpm as defaultRunNpm} from './process.mjs';
4
+ const unsupported=reason=>({kind:'unsupported',reason});
5
+ function realDirectory(value){if(typeof value!=='string'||!path.isAbsolute(value))throw Error('Expected an absolute directory');const real=fs.realpathSync(value);if(!fs.statSync(real).isDirectory())throw Error('Expected a directory');return real;}
6
+ function npmExecutable(env){
7
+ const candidates=env.npm_execpath?[env.npm_execpath]:(env.PATH??'').split(path.delimiter).filter(p=>path.isAbsolute(p)).map(p=>path.join(p,'npm'));
8
+ for(const candidate of candidates)try{if(!path.isAbsolute(candidate))continue;const cli=fs.realpathSync(candidate),root=path.dirname(path.dirname(cli));if(path.basename(cli)==='npm-cli.js'&&path.basename(path.dirname(cli))==='bin'&&JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8')).name==='npm'&&fs.statSync(cli).isFile())return cli;}catch{}
9
+ throw Error('Cannot verify npm CLI');
10
+ }
11
+ export function resolveInvocationCwd({env={},cwd,lifecycle=false}){
12
+ if(lifecycle){
13
+ if(env.INIT_CWD!==undefined)return realDirectory(env.INIT_CWD);
14
+ if(env.CODEX_HOME!==undefined&&!path.isAbsolute(env.CODEX_HOME)&&env.CODEX_HOME!=='~'&&!env.CODEX_HOME.startsWith('~/'))throw Error('Original invocation directory (INIT_CWD) is required for relative CODEX_HOME');
15
+ }
16
+ return realDirectory(cwd);
17
+ }
18
+ export async function inspectNpmContext({packageRoot,entryPath,env=process.env,cwd=process.cwd(),execPath=process.execPath,runNpm=defaultRunNpm}){
19
+ try{
20
+ if(fs.realpathSync(execPath)!==fs.realpathSync(process.execPath))return unsupported('Node executable does not match this runtime');
21
+ const root=realDirectory(packageRoot),entry=fs.realpathSync(entryPath);
22
+ if(!entry.startsWith(root+path.sep)||!fs.statSync(entry).isFile()||JSON.parse(fs.readFileSync(path.join(root,'package.json'),'utf8')).name!=='@yunhozz/arc')return unsupported('ARC entry is outside the package');
23
+ const npmCli=npmExecutable(env);
24
+ async function query(args){const result=await runNpm(npmCli,args,{cwd,env});if(result.code!==0||result.signal)throw Error('npm context query failed');const value=result.stdout.trim();if(!path.isAbsolute(value)||value.includes('\n'))throw Error('npm returned an invalid context path');try{return fs.realpathSync(value);}catch(error){if(error.code!=='ENOENT')throw error;return path.resolve(value);}}
25
+ const prefix=await query(['prefix','--global']),npmRoot=await query(['root','--global']);
26
+ if([path.join(prefix,'lib/node_modules'),path.join(prefix,'node_modules')].includes(npmRoot)&&root===path.join(npmRoot,'@yunhozz/arc'))return{kind:'global',packageRoot:root,entryPath:entry,prefix,npmCli};
27
+ if(env.npm_command==='exec'){
28
+ const cache=realDirectory(await query(['config','get','cache'])),relative=path.relative(path.join(cache,'_npx'),root),parts=relative.split(path.sep);
29
+ if(parts.length===4&&parts[0]&&parts[0]!=='.'&&parts[0]!=='..'&&parts.slice(1).join('/')==='node_modules/@yunhozz/arc'){
30
+ const bin=path.join(cache,'_npx',parts[0],'node_modules/.bin'),paths=(env.PATH??'').split(path.delimiter);
31
+ if(paths.some(value=>{try{return realDirectory(value)===realDirectory(bin);}catch{return false;}})&&realDirectory(env.INIT_CWD))return{kind:'npx',packageRoot:root,entryPath:entry,npmCli};
32
+ }
33
+ }
34
+ return unsupported('ARC is not in a verified global or npm exec installation');
35
+ }catch{return unsupported('Cannot verify npm installation context');}
36
+ }
@@ -0,0 +1,17 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {spawn} from 'node:child_process';
4
+ function failure(message,code,cause){return Object.assign(new Error(message,{cause}),{code});}
5
+ export async function runNpm(npmCli,args,{cwd,env,signal}={}){
6
+ if(signal?.aborted)throw failure('npm cancelled','ARC_NPM_CANCELLED');
7
+ if(!path.isAbsolute(npmCli)||!Array.isArray(args)||args.some(arg=>typeof arg!=='string'))throw failure('Invalid npm invocation','ARC_NPM_START');
8
+ let cli;try{cli=fs.realpathSync(npmCli);if(!fs.statSync(cli).isFile())throw Error('Not a regular file');}catch(cause){throw failure('Cannot resolve npm CLI','ARC_NPM_START',cause);}
9
+ return new Promise((resolve,reject)=>{
10
+ const child=spawn(process.execPath,[cli,...args],{cwd,env,stdio:['ignore','pipe','pipe'],shell:false});
11
+ const stdout=[],stderr=[];let startError,cancelled=false;
12
+ child.stdout.on('data',bytes=>stdout.push(bytes));child.stderr.on('data',bytes=>stderr.push(bytes));
13
+ const abort=()=>{cancelled=true;child.kill('SIGTERM');};signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)abort();
14
+ child.once('error',error=>{startError=error;});
15
+ child.once('close',(code,killedBy)=>{signal?.removeEventListener('abort',abort);if(cancelled)reject(failure('npm cancelled','ARC_NPM_CANCELLED'));else if(startError)reject(failure('Cannot start npm','ARC_NPM_START',startError));else resolve({code,signal:killedBy,stdout:Buffer.concat(stdout).toString('utf8'),stderr:Buffer.concat(stderr).toString('utf8')});});
16
+ });
17
+ }
@@ -0,0 +1,29 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {spawn} from 'node:child_process';
4
+ import {runNpm as defaultRunNpm} from './process.mjs';
5
+ const validVersion=value=>typeof value==='string'&&/^\d+\.\d+\.\d+$/.test(value);
6
+ function cancelled(){return Object.assign(Error('Upgrade cancelled'),{code:'ARC_CANCELLED'});}
7
+ export async function resolveLatest({context,env,cwd,signal,runNpm=defaultRunNpm}){
8
+ const result=await runNpm(context.npmCli,['view','@yunhozz/arc@latest','--json'],{env,cwd,signal});
9
+ let value;try{value=JSON.parse(result.stdout);}catch{}
10
+ if(result.code!==0||result.signal||value?.name!=='@yunhozz/arc'||!validVersion(value.version))throw Error('Cannot resolve a valid latest ARC package');
11
+ return{name:'@yunhozz/arc',version:value.version};
12
+ }
13
+ export async function upgradeGlobal({context,expectedVersion,env,cwd,signal,runNpm=defaultRunNpm}){
14
+ if(context.kind!=='global'||!path.isAbsolute(context.prefix)||!validVersion(expectedVersion))throw Error('Invalid global upgrade version or prefix');
15
+ const result=await runNpm(context.npmCli,['install','--global','@yunhozz/arc@'+expectedVersion,'--prefix',context.prefix,'--ignore-scripts'],{env,cwd,signal});
16
+ return result.code===0&&!result.signal?{packageStatus:'updated',version:expectedVersion}:{packageStatus:'failed',error:Error('CLI replacement failed; the package may have changed')};
17
+ }
18
+ export async function runUpgradeChild({entryPath,request,env,cwd,signal}){
19
+ if(signal?.aborted)throw cancelled();
20
+ if(!path.isAbsolute(entryPath))throw Error('Invalid upgrade child path');
21
+ const entry=fs.realpathSync(entryPath);if(!fs.statSync(entry).isFile())throw Error('Invalid upgrade child');
22
+ return new Promise((resolve,reject)=>{
23
+ const child=spawn(process.execPath,[entry],{cwd,env,stdio:['pipe','pipe','pipe'],shell:false});let stdout='',stderr='',startError,aborted=false;
24
+ child.stdout.on('data',bytes=>{stdout+=bytes;});child.stderr.on('data',bytes=>{stderr+=bytes;});child.stdin.on('error',()=>{});
25
+ const abort=()=>{aborted=true;child.kill('SIGTERM');};signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)abort();
26
+ child.once('error',error=>{startError=error;});child.once('close',(code,killedBy)=>{signal?.removeEventListener('abort',abort);if(aborted)resolve({code:130,signal:killedBy,stdout,stderr});else if(startError)reject(Error('Cannot start the new ARC CLI',{cause:startError}));else resolve({code,signal:killedBy,stdout,stderr});});
27
+ child.stdin.end(JSON.stringify(request));
28
+ });
29
+ }
@@ -0,0 +1,163 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import {gunzipSync} from 'node:zlib';
6
+ import {isDeepStrictEqual} from 'node:util';
7
+ import {spawnSync} from 'node:child_process';
8
+ import {fileURLToPath} from 'node:url';
9
+ import {openRoot} from '../safe-fs/index.mjs';
10
+ import {verifyPayload} from '../installer/manifest.mjs';
11
+ import {readCheckedFile,typeOf,sameIdentity} from '../installer/targets.mjs';
12
+ import {runNpm} from '../npm/process.mjs';
13
+
14
+ const HOOKS=['preinstall','install','postinstall','prepublish','prepare','prepublishOnly','prepack','postpack','publish','postpublish','preuninstall','uninstall','postuninstall'];
15
+ const NAME='@yunhozz/arc',HOOK='node scripts/postinstall.mjs';
16
+ const same=(a,b)=>a.dev===b.dev&&a.ino===b.ino;
17
+ const digest=(bytes,algorithm='sha256')=>crypto.createHash(algorithm).update(bytes).digest('hex');
18
+ const json=file=>JSON.parse(fs.readFileSync(file,'utf8'));
19
+ function abort(signal){if(signal?.aborted)throw new Error('Release artifact generation cancelled');}
20
+ function inside(parent,child){const relative=path.relative(parent,child);return relative===''||(!relative.startsWith('..'+path.sep)&&relative!=='..'&&!path.isAbsolute(relative));}
21
+ function regular(file){const stat=fs.lstatSync(file);if(!stat.isFile()||stat.isSymbolicLink())throw new Error('Expected regular release input: '+file);return stat;}
22
+ function metadata(pkg,lock,{artifact=false}={}){
23
+ if(pkg.name!==NAME||!/^\d+\.\d+\.\d+$/.test(pkg.version)||pkg.private===true)throw new Error('Invalid release package identity');
24
+ for(const hook of HOOKS)if(pkg.scripts?.[hook]!==undefined&&!(artifact&&hook==='postinstall'&&pkg.scripts[hook]===HOOK))throw new Error('Unapproved package lifecycle: '+hook);
25
+ if(artifact&&pkg.scripts?.postinstall!==HOOK)throw new Error('Artifact postinstall is missing');
26
+ if(Object.hasOwn(pkg.scripts??{},'build')||Object.hasOwn(pkg,'workspaces'))throw new Error('Source Git preparation trigger is not allowed');
27
+ if(lock){
28
+ const root=lock.packages?.[''];if(lock.name!==pkg.name||lock.version!==pkg.version||root?.name!==pkg.name||root?.version!==pkg.version)throw new Error('Package lock identity mismatch');
29
+ for(const key of ['bin','dependencies','optionalDependencies','devDependencies','peerDependencies','engines'])if(!isDeepStrictEqual(root[key],pkg[key]))throw new Error('Package lock metadata mismatch: '+key);
30
+ if(artifact?root.hasInstallScript!==true:Object.hasOwn(root,'hasInstallScript'))throw new Error('Package lock install script metadata mismatch');
31
+ }
32
+ }
33
+ function inputNames(pkg){const names=new Set(['README.md','package.json','package-lock.json','MANIFEST.sha256']);for(const entry of pkg.files??[]){const name=entry.replace(/\/$/,'');if(!name||name.startsWith('/')||name.includes('\\')||name.split('/').some(part=>!part||part==='.'||part==='..')||/[*?\[\]]/.test(name))throw new Error('Unsupported package files entry');names.add(name);}return [...names].sort();}
34
+ function sourceMetadata(root){
35
+ const directory=openRoot(root);
36
+ try{const result={};for(const name of ['package.json','package-lock.json']){const stat=directory.lstat(name);if(typeOf(stat)!==0o100000)throw new Error('Expected regular source metadata: '+name);result[name]=readCheckedFile(directory,name,stat).bytes;}return result;}
37
+ finally{directory.close();}
38
+ }
39
+ function inventory(root,pkg){
40
+ const files=new Map(),directory=openRoot(root);
41
+ function visit(parent,name,relative){
42
+ const stat=parent.lstat(name),type=typeOf(stat);
43
+ if(type===0o040000){
44
+ const child=parent.openDir(name);try{if(!sameIdentity(stat,child.stat()))throw new Error('Release source directory changed');
45
+ const before=child.stat();for(const leaf of child.readDir())visit(child,leaf,relative+'/'+leaf);if(!sameIdentity(before,child.stat()))throw new Error('Release source directory changed while reading');
46
+ }finally{child.close();}
47
+ }else if(type===0o100000){const item=readCheckedFile(parent,name,stat);files.set(relative,{bytes:item.bytes,mode:Number(stat.mode&0o777n)});}
48
+ else throw new Error('Unsafe release source file: '+relative);
49
+ }
50
+ try{
51
+ for(const name of inputNames(pkg)){
52
+ const parts=name.split('/'),opened=[];let parent=directory;
53
+ try{for(const part of parts.slice(0,-1)){const stat=parent.lstat(part);if(typeOf(stat)!==0o040000)throw new Error('Unsafe release source parent');const child=parent.openDir(part);opened.push(child);if(!sameIdentity(stat,child.stat()))throw new Error('Release source parent changed');parent=child;}visit(parent,parts.at(-1),name);}
54
+ finally{for(const child of opened.reverse())child.close();}
55
+ }
56
+ }finally{directory.close();}
57
+ const snapshot=digest([...files].sort(([a],[b])=>a<b?-1:a>b?1:0).map(([name,{bytes,mode}])=>name+'\0'+mode+'\0'+bytes.length+'\0'+digest(bytes)+'\n').join(''));
58
+ return{files,snapshot};
59
+ }
60
+ function refresh(root){
61
+ const result=spawnSync(process.execPath,[path.join(root,'scripts/update-manifest.mjs'),'--root',root],{encoding:'utf8',env:{PATH:process.env.PATH}});
62
+ if(result.status!==0)throw new Error('Staging manifest generation failed: '+result.stderr);
63
+ }
64
+ function safeOutput(sourceRoot,outDir){
65
+ if(!path.isAbsolute(outDir)||path.normalize(outDir)!==outDir||inside(sourceRoot,outDir))throw new Error('Release output must be an absolute directory outside source');
66
+ let current=path.parse(outDir).root;for(const component of outDir.slice(current.length).split(path.sep).filter(Boolean)){
67
+ current=path.join(current,component);try{const stat=fs.lstatSync(current);if(stat.isSymbolicLink()||!stat.isDirectory())throw new Error('Unsafe release output path');}catch(error){if(error.code!=='ENOENT')throw error;if(current!==outDir)throw new Error('Release output parent is missing');}
68
+ }
69
+ const parent=fs.realpathSync(path.dirname(outDir));if(inside(sourceRoot,parent))throw new Error('Release output resolves inside source');
70
+ try{fs.mkdirSync(outDir,{mode:0o700});}catch(error){if(error.code!=='EEXIST')throw error;}
71
+ if(fs.readdirSync(outDir).length)throw new Error('Release output is occupied or incomplete; use a new output directory');
72
+ return openRoot(outDir);
73
+ }
74
+ function readText(bytes,start,length){return bytes.subarray(start,start+length).toString('utf8').replace(/\0.*$/s,'');}
75
+ function number(bytes,start,length){const text=readText(bytes,start,length).trim();if(!/^[0-7]*$/.test(text))throw new Error('Unsupported tar number');const value=parseInt(text||'0',8);if(!Number.isSafeInteger(value))throw new Error('Invalid tar size');return value;}
76
+ function pax(bytes){
77
+ const result={};let offset=0;while(offset<bytes.length){const space=bytes.indexOf(32,offset);if(space<0)throw new Error('Malformed tar metadata');const size=Number(bytes.subarray(offset,space).toString());if(!Number.isSafeInteger(size)||size<=space-offset+1||offset+size>bytes.length)throw new Error('Malformed tar metadata');const record=bytes.subarray(space+1,offset+size).toString('utf8');if(!record.endsWith('\n'))throw new Error('Malformed tar metadata');const equal=record.indexOf('=');if(equal<1)throw new Error('Malformed tar metadata');result[record.slice(0,equal)]=record.slice(equal+1,-1);offset+=size;}return result;
78
+ }
79
+ function archive(bytes){
80
+ const raw=gunzipSync(bytes,{maxOutputLength:256*1024*1024}),files=new Map();let extended={},global={};
81
+ for(let offset=0;offset+512<=raw.length;){
82
+ const header=raw.subarray(offset,offset+512);if(header.every(byte=>byte===0))break;
83
+ let sum=0;for(let i=0;i<512;i++)sum+=i>=148&&i<156?32:header[i];if(sum!==number(header,148,8))throw new Error('Invalid tar header checksum');
84
+ const size=number(header,124,12),type=String.fromCharCode(header[156]||48),end=offset+512+size;if(end>raw.length)throw new Error('Truncated tar archive');
85
+ const data=raw.subarray(offset+512,end);offset+=512+Math.ceil(size/512)*512;
86
+ if(type==='x'||type==='g'){const values=pax(data);if(type==='g')global={...global,...values};else extended=values;continue;}
87
+ const attrs={...global,...extended};extended={};if(attrs.linkpath!==undefined)throw new Error('Archive links are forbidden');
88
+ let name=attrs.path??[readText(header,345,155),readText(header,0,100)].filter(Boolean).join('/');
89
+ if(name.endsWith('/'))name=name.slice(0,-1);
90
+ if(name!=='package'&&(!name.startsWith('package/')||name.split('/').some(part=>!part||part==='.'||part==='..')||name.includes('\\')||name.includes('\0')))throw new Error('Unsafe archive path');
91
+ if(type==='5')continue;if(type!=='0')throw new Error('Archive links and special files are forbidden');
92
+ if(name==='package'||files.has(name))throw new Error('Duplicate archive path');
93
+ if(attrs.size!==undefined&&Number(attrs.size)!==size)throw new Error('Unsupported tar size override');
94
+ files.set(name.slice(8),{bytes:Buffer.from(data),mode:number(header,100,8)&0o777});
95
+ }
96
+ if(!files.size)throw new Error('Empty release archive');return files;
97
+ }
98
+ function validateTarball(bytes,{expectedName,expectedVersion}){
99
+ const files=archive(bytes),pkgFile=files.get('package.json');if(!pkgFile)throw new Error('Artifact package metadata is missing');
100
+ const pkg=JSON.parse(pkgFile.bytes);metadata(pkg,undefined,{artifact:true});
101
+ if(pkg.name!==expectedName||pkg.version!==expectedVersion)throw new Error('Artifact package identity mismatch');
102
+ for(const name of ['scripts/postinstall.mjs','lib/cli/postinstall.mjs','lib/cli/terminal.mjs'])if(!files.has(name))throw new Error('Artifact runtime asset missing: '+name);
103
+ if(files.has('package-lock.json')||[...files.keys()].some(name=>name.split('/').some(part=>part==='.git'||part.startsWith('.arc-release'))))throw new Error('Private staging data in artifact');
104
+ const root=fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()),'arc-release-verify-'));
105
+ try{
106
+ for(const[name,item]of files){const filename=path.join(root,name);fs.mkdirSync(path.dirname(filename),{recursive:true,mode:0o700});fs.writeFileSync(filename,item.bytes,{mode:item.mode});}
107
+ verifyPayload({packageRoot:root,safeFs:{openRoot}});
108
+ }finally{fs.rmSync(root,{recursive:true,force:true});}
109
+ return pkg;
110
+ }
111
+ async function pack({stagingRoot,workRoot,npmCli,signal}){
112
+ const packDir=path.join(workRoot,'packed'),home=path.join(workRoot,'home');fs.mkdirSync(packDir);fs.mkdirSync(home);
113
+ const env={PATH:process.env.PATH,HOME:home,npm_config_userconfig:path.join(home,'user.npmrc'),npm_config_globalconfig:path.join(home,'global.npmrc'),npm_config_cache:path.join(workRoot,'cache'),npm_config_offline:'true',npm_config_ignore_scripts:'true',npm_config_audit:'false',npm_config_fund:'false',npm_config_update_notifier:'false'};
114
+ const result=await runNpm(npmCli,['pack','--ignore-scripts','--offline','--json','--pack-destination',packDir],{cwd:stagingRoot,env,signal});
115
+ if(result.code!==0)throw new Error('Release npm pack failed: '+result.stderr);
116
+ const records=JSON.parse(result.stdout),record=Array.isArray(records)?records[0]:records[NAME];
117
+ if(!record||typeof record.filename!=='string'||path.basename(record.filename)!==record.filename)throw new Error('Invalid npm pack result');
118
+ const filename=path.join(packDir,record.filename);regular(filename);return fs.readFileSync(filename);
119
+ }
120
+ export async function buildReleaseArtifact({sourceRoot,outDir,npmCli,signal,deps={}}){
121
+ if(!path.isAbsolute(sourceRoot)||fs.realpathSync(sourceRoot)!==sourceRoot)throw new Error('Source root must be an absolute real path');
122
+ abort(signal);const inputs=sourceMetadata(sourceRoot),pkg=JSON.parse(inputs['package.json']),lock=JSON.parse(inputs['package-lock.json']);metadata(pkg,lock);
123
+ const source=inventory(sourceRoot,pkg);for(const name of ['package.json','package-lock.json'])if(!inputs[name].equals(source.files.get(name).bytes))throw new Error('Release source metadata changed during validation');
124
+ verifyPayload({packageRoot:sourceRoot,safeFs:{openRoot}});if(inventory(sourceRoot,pkg).snapshot!==source.snapshot)throw new Error('Release source snapshot changed during validation');
125
+ let output,lockIdentity,workRoot,workIdentity;const id=crypto.randomUUID(),partial='.arc-release-'+id+'.partial';let partialExists=false;
126
+ try{
127
+ output=safeOutput(sourceRoot,outDir);const lockFile=output.openFile('.arc-release.lock',{createExclusive:true,read:false});try{lockFile.writeAll(JSON.stringify({schema:1,owner:id,pid:process.pid,state:'incomplete'}));lockIdentity=lockFile.stat();lockFile.sync();}finally{lockFile.close();}
128
+ workRoot=fs.mkdtempSync(path.join(fs.realpathSync(path.dirname(outDir)),'.arc-release-stage-'));workIdentity=fs.lstatSync(workRoot);
129
+ const stagingRoot=path.join(workRoot,'package');fs.mkdirSync(stagingRoot,{mode:0o700});
130
+ for(const[name,item]of source.files){const filename=path.join(stagingRoot,name);fs.mkdirSync(path.dirname(filename),{recursive:true,mode:0o700});fs.writeFileSync(filename,item.bytes,{flag:'wx',mode:item.mode});}
131
+ const stagedPkg={...pkg,scripts:{...pkg.scripts,postinstall:HOOK}},stagedLock=structuredClone(lock);stagedLock.packages[''].hasInstallScript=true;
132
+ fs.writeFileSync(path.join(stagingRoot,'package.json'),JSON.stringify(stagedPkg,null,2)+'\n');fs.writeFileSync(path.join(stagingRoot,'package-lock.json'),JSON.stringify(stagedLock,null,2)+'\n');metadata(stagedPkg,stagedLock,{artifact:true});
133
+ fs.writeFileSync(path.join(stagingRoot,'.arc-release-staging.json'),JSON.stringify({schema:1,owner:id,state:'incomplete',sourceSnapshot:source.snapshot}));
134
+ refresh(stagingRoot);verifyPayload({packageRoot:stagingRoot,safeFs:{openRoot}});await deps.onStaged?.({stagingRoot,signal});abort(signal);
135
+ const bytes=await(deps.pack??pack)({stagingRoot,workRoot,npmCli,signal});abort(signal);validateTarball(bytes,{expectedName:pkg.name,expectedVersion:pkg.version});
136
+ if(inventory(sourceRoot,pkg).snapshot!==source.snapshot)throw new Error('Release source snapshot changed during generation');abort(signal);
137
+ const tarballPath=path.join(outDir,'arc-release.tgz'),receiptPath=path.join(outDir,'arc-release.json');
138
+ const receipt={schema:1,name:pkg.name,version:pkg.version,tarballPath,sha512:digest(bytes,'sha512'),sourceSnapshot:source.snapshot,verified:true};
139
+ const file=output.openFile(partial,{createExclusive:true,read:false});partialExists=true;try{file.writeAll(bytes);file.sync();}finally{file.close();}
140
+ output.renameNoReplaceTo(partial,output,'arc-release.tgz');partialExists=false;
141
+ const receiptFile=output.openFile(partial,{createExclusive:true,read:false});partialExists=true;try{receiptFile.writeAll(JSON.stringify(receipt,null,2)+'\n');receiptFile.sync();}finally{receiptFile.close();}
142
+ output.renameNoReplaceTo(partial,output,'arc-release.json');partialExists=false;output.sync();
143
+ if(!same(output.stat(),fs.lstatSync(outDir,{bigint:true}))||fs.lstatSync(outDir).isSymbolicLink())throw new Error('Release output directory changed');
144
+ return{...receipt,receiptPath};
145
+ }finally{
146
+ if(workRoot){try{if(same(workIdentity,fs.lstatSync(workRoot)))fs.rmSync(workRoot,{recursive:true,force:true});}catch(error){if(error.code!=='ENOENT')throw error;}}
147
+ if(output){try{if(partialExists)output.unlink(partial);if(lockIdentity&&same(lockIdentity,output.lstat('.arc-release.lock')))output.unlink('.arc-release.lock');}finally{output.close();}}
148
+ }
149
+ }
150
+ export async function verifyReleaseArtifact({outDir,expectedName,expectedVersion}){
151
+ if(!path.isAbsolute(outDir)||fs.realpathSync(outDir)!==outDir)throw new Error('Unsafe release output path');
152
+ if(fs.readdirSync(outDir).includes('.arc-release.lock'))throw new Error('Release output is locked or incomplete');
153
+ regular(path.join(outDir,'arc-release.json'));const receipt=json(path.join(outDir,'arc-release.json'));
154
+ if(receipt.schema!==1||receipt.verified!==true||receipt.name!==expectedName||receipt.version!==expectedVersion||receipt.tarballPath!==path.join(outDir,'arc-release.tgz')||!/^[a-f0-9]{128}$/.test(receipt.sha512)||!/^[a-f0-9]{64}$/.test(receipt.sourceSnapshot))throw new Error('Invalid release receipt');
155
+ regular(receipt.tarballPath);const bytes=fs.readFileSync(receipt.tarballPath);if(digest(bytes,'sha512')!==receipt.sha512)throw new Error('Release tarball digest mismatch');
156
+ validateTarball(bytes,{expectedName,expectedVersion});return receipt;
157
+ }
158
+ export function resolveNpmCli(){
159
+ const executable=process.env.npm_execpath;if(executable&&path.isAbsolute(executable)&&fs.existsSync(executable))return fs.realpathSync(executable);
160
+ const candidate=path.resolve(path.dirname(process.execPath),'../lib/node_modules/npm/bin/npm-cli.js');
161
+ if(fs.existsSync(candidate))return fs.realpathSync(candidate);throw new Error('Cannot resolve npm CLI; use npm run pack:release');
162
+ }
163
+ export const releaseSourceRoot=path.resolve(fileURLToPath(new URL('../../',import.meta.url)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yunhozz/arc",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -14,7 +14,8 @@
14
14
  "build:native": "node scripts/build-native.mjs",
15
15
  "check:manifest": "node scripts/update-manifest.mjs --check",
16
16
  "update:role-policy": "node scripts/update-role-policy.mjs",
17
- "check:role-policy": "node scripts/update-role-policy.mjs --check"
17
+ "check:role-policy": "node scripts/update-role-policy.mjs --check",
18
+ "pack:release": "node scripts/pack-release.mjs"
18
19
  },
19
20
  "devDependencies": {
20
21
  "node-gyp": "11.5.0",
@@ -0,0 +1,9 @@
1
+ import {buildReleaseArtifact,resolveNpmCli,releaseSourceRoot} from '../lib/release/artifact.mjs';
2
+ const args=process.argv.slice(2),controller=new AbortController();
3
+ const cancel=()=>controller.abort();process.once('SIGINT',cancel);process.once('SIGTERM',cancel);
4
+ try{
5
+ if(args.length!==2||args[0]!=='--out-dir')throw Error('Usage: npm run pack:release -- --out-dir <absolute-output-directory>');
6
+ const result=await buildReleaseArtifact({sourceRoot:releaseSourceRoot,outDir:args[1],npmCli:resolveNpmCli(),signal:controller.signal});
7
+ console.log(JSON.stringify(result,null,2));
8
+ }catch(error){console.error('Release artifact error: '+error.message);process.exitCode=controller.signal.aborted?130:1;}
9
+ finally{process.removeListener('SIGINT',cancel);process.removeListener('SIGTERM',cancel);}
@@ -0,0 +1,4 @@
1
+ import {fileURLToPath} from 'node:url';
2
+ import {runPostinstall} from '../lib/cli/postinstall.mjs';
3
+ const packageRoot=fileURLToPath(new URL('../',import.meta.url));
4
+ process.exitCode=await runPostinstall({packageRoot,cwd:packageRoot});