@redue/cli 0.1.0-alpha.22 → 0.1.0-alpha.24

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/src/setup.mjs ADDED
@@ -0,0 +1,178 @@
1
+ // `redue setup claude` and `redue remove`: one command from a project with
2
+ // installed dependencies to a working Claude Code integration, and one to
3
+ // undo it. Setup runs init when the project has no config, agent setup
4
+ // claude, start, and one recorded run of each check init can qualify that is
5
+ // not CURRENT. It prints every file it writes. What it created is kept in a
6
+ // record in the clone's Git directory (never committed); a later setup adds
7
+ // to that account and never forgets it. Remove undoes exactly what the record
8
+ // says setup created: the agent files or just this machine's hooks, the
9
+ // observer state, the local ignore line, and the config if it is unchanged
10
+ // since setup wrote it.
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import {createHash} from 'node:crypto';
14
+ import {spawnSync} from 'node:child_process';
15
+ import {createInterface} from 'node:readline/promises';
16
+ import {projectState} from './project-location.mjs';
17
+ import {claudeHooksInstalled,removeClaudeHooks,removeIntegration} from './agent-integration.mjs';
18
+
19
+ const configName='redue.config.json',localSettings='.claude/settings.local.json',agentRecord='.redue/agents/claude.json';
20
+ const hash=text=>createHash('sha256').update(text).digest('hex');
21
+ const readText=file=>{try{return fs.readFileSync(file,'utf8');}catch{return null;}};
22
+ const git=(root,...args)=>spawnSync('git',args,{cwd:root,encoding:'utf8',stdio:['ignore','pipe','ignore']});
23
+ const ignoreLine=`/${localSettings}`;
24
+
25
+ async function confirm(question,yes,{defaultYes}){
26
+ if(yes)return true;
27
+ if(!process.stdin.isTTY||!process.stdout.isTTY)throw Error('not a terminal; run again with --yes to proceed without prompts');
28
+ const prompt=createInterface({input:process.stdin,output:process.stdout});
29
+ try{const answer=(await prompt.question(`${question} ${defaultYes?'[Y/n]':'[y/N]'} `)).trim();
30
+ return defaultYes?!/^n/i.test(answer):/^y/i.test(answer);}finally{prompt.close();}
31
+ }
32
+
33
+ export async function setupCommand(args,{entry,cwd=process.cwd()}){
34
+ const action=args.shift();
35
+ const yes=args.includes('--yes'),rest=args.filter(arg=>arg!=='--yes');
36
+ if(action==='setup'&&(rest.length!==1||rest[0]!=='claude'))throw Error('use redue setup claude [--yes]');
37
+ if(action==='remove'&&rest.length)throw Error('use redue remove [--yes]');
38
+ const top=git(cwd,'rev-parse','--show-toplevel');
39
+ if(top.status!==0)throw Error('run this inside a Git checkout');
40
+ const root=fs.realpathSync(top.stdout.trim());
41
+ if(!fs.existsSync(path.join(root,'package.json')))throw Error(`no package.json at the repository root (${root})`);
42
+ // The entry as invoked, so a hook written by a child names the install's
43
+ // stable bin link rather than the versioned file behind it.
44
+ const invoked=typeof process.argv[1]==='string'&&path.isAbsolute(process.argv[1])&&
45
+ (()=>{try{return fs.realpathSync(process.argv[1])===fs.realpathSync(entry);}catch{return false;}})()?process.argv[1]:entry;
46
+ // Every step is the ordinary command, so its own output says what it did.
47
+ const redue=(argv,options={})=>spawnSync(process.execPath,[invoked,...argv],{cwd:root,encoding:'utf8',
48
+ stdio:options.capture?['ignore','pipe','pipe']:'inherit',timeout:options.timeout});
49
+ // One record per checkout, beside the others in the shared Git directory:
50
+ // linked worktrees share .git/info/exclude.
51
+ const common=path.resolve(root,git(root,'rev-parse','--git-common-dir').stdout.trim());
52
+ const recordFile=path.join(common,`redue-setup-${hash(root).slice(0,16)}.json`);
53
+ // Other checkouts' records, for checkouts that still exist.
54
+ const others=()=>{try{return fs.readdirSync(common).filter(name=>/^redue-setup-[0-9a-f]{16}\.json$/.test(name)&&path.join(common,name)!==recordFile)
55
+ .map(name=>JSON.parse(readText(path.join(common,name))??'null')).filter(other=>other&&fs.existsSync(other.root));}catch{return [];}};
56
+ const context={root,redue,yes,record:JSON.parse(readText(recordFile)??'null'),recordFile,
57
+ exclude:path.join(common,'info','exclude'),others,save:next=>fs.writeFileSync(recordFile,JSON.stringify(next,null,2)+'\n')};
58
+ return action==='setup'?setup(context):remove(context);
59
+ }
60
+
61
+ async function setup({root,redue,yes,record:previous,recordFile,exclude,save}){
62
+ if(!fs.existsSync(path.join(root,'node_modules')))
63
+ throw Error('dependencies are not installed: run your package manager\'s install (npm install, pnpm install, yarn) first');
64
+ if(fs.existsSync(path.join(root,'vstate.config.json'))&&!fs.existsSync(path.join(root,configName)))
65
+ throw Error('a legacy vstate.config.json exists; rename it to redue.config.json or remove it first');
66
+ if(git(root,'ls-files','--error-unmatch',localSettings).status===0)
67
+ throw Error(`${localSettings} is committed; the hooks REDUE adds there name this machine's paths. Untrack it (git rm --cached) first`);
68
+ const configPath=path.join(root,configName),configExisted=fs.existsSync(configPath);
69
+ // init's account of the checks: which can qualify, also with a config.
70
+ const preview=redue(['init','--dry-run','--json'],{capture:true});
71
+ if(preview.status!==0)throw Error(preview.stderr.trim().replace(/^redue: /,'')||'init failed');
72
+ const found=JSON.parse(preview.stdout);
73
+ if(!configExisted&&!found.writable)throw Error(`nothing to set up: ${found.issue||(found.checks.length?found.checks.map(check=>`${check.name}: ${check.reason}`).join('; '):'no typecheck, lint or test script REDUE can use')}`);
74
+ const levels=new Map(found.checks.map(check=>[check.name,check.level]));
75
+ const names=configExisted?JSON.parse(readText(configPath)).checks.map(check=>check.name):found.checks.map(check=>check.name);
76
+ const ready=names.filter(name=>levels.get(name)==='ready');
77
+ console.log(`Setting up REDUE for Claude Code in ${root}`);
78
+ console.log(configExisted?` keep ${configName} (exists)`:` create ${configName}: ${names.map(name=>`${name} (${levels.get(name)==='ready'?'can qualify':'recording-only'})`).join(', ')}`);
79
+ console.log(` write the Claude Code integration: CLAUDE.md section, .claude/skills/redue-verification/SKILL.md, ${localSettings} hooks, ${agentRecord}`);
80
+ console.log(` start the observer and record ${ready.length?ready.join(', '):'no check'} once, unless already CURRENT`);
81
+ if(!await confirm('Proceed?',yes,{defaultYes:true})){console.log('Nothing written.');return {applied:false};}
82
+ // What this run creates is added to what earlier runs created.
83
+ const state=projectState(root,configPath);
84
+ const own={schema:1,host:'claude',root,config:configName,state,
85
+ configCreated:Boolean(previous?.configCreated),configHash:previous?.configHash??null,
86
+ agentCreated:Boolean(previous?.agentCreated)||!fs.existsSync(path.join(root,agentRecord)),
87
+ hooksAdded:Boolean(previous?.hooksAdded)||!claudeHooksInstalled(root),
88
+ stateCreated:Boolean(previous?.stateCreated)||!fs.existsSync(state),
89
+ excludeAdded:Boolean(previous?.excludeAdded)};
90
+ if(!configExisted){
91
+ const init=redue(['init']);
92
+ if(init.status!==0||!fs.existsSync(configPath))throw Error(`init did not write ${configName}; nothing else was written`);
93
+ Object.assign(own,{configCreated:true,configHash:hash(readText(configPath))});
94
+ }
95
+ save(own);
96
+ console.log(`${previous?'update':'create'} ${recordFile}: what setup created, for redue remove`);
97
+ const agent=redue(['agent','setup','claude','--apply']);
98
+ if(agent.status!==0)throw Error('agent setup failed; see above. redue remove undoes what setup wrote');
99
+ // The hooks' settings hold machine paths: keep them out of commits in this
100
+ // clone when Git does not ignore them.
101
+ if(git(root,'check-ignore','-q',localSettings).status===1){
102
+ fs.mkdirSync(path.dirname(exclude),{recursive:true});
103
+ const text=readText(exclude)??'';
104
+ fs.writeFileSync(exclude,`${text}${text&&!text.endsWith('\n')?'\n':''}${ignoreLine}\n`);
105
+ console.log(`update ${exclude}: ignore ${ignoreLine} in this clone`);
106
+ own.excludeAdded=true;save(own);
107
+ }
108
+ // An observer that is already running is what start would give.
109
+ const started=redue(['start'],{capture:true});
110
+ process.stdout.write(started.stdout);
111
+ if(started.status!==0){
112
+ if(!/already running/.test(started.stderr))throw Error(`the observer did not start: ${started.stderr.trim().replace(/^redue: /,'')}`);
113
+ console.log('Observer already running.');
114
+ }
115
+ // The observer indexes the project first; a run before that cannot be
116
+ // recorded as reusable. Wait while it plans and indexes; once its plan is
117
+ // ready, an observer that is still unhealthy says why in the status below.
118
+ // Nothing to wait for when no check can qualify.
119
+ const observation=()=>{const out=redue(['status','--json'],{capture:true});try{return JSON.parse(out.stdout).observation??null;}catch{return null;}};
120
+ // An answer without a phase (a stopped observer) for 10 s in a row ends the
121
+ // wait too.
122
+ const begun=Date.now();let told=-1,unread=0,phaseless=0;
123
+ for(let state=observation();ready.length&&!(state?.phase==='ready'||state?.phase==='unavailable')&&phaseless<10;state=observation()){
124
+ const seconds=Math.round((Date.now()-begun)/1000);
125
+ unread=state===null?unread+1:0;phaseless=state&&!state.phase?phaseless+1:0;
126
+ if(unread>=30)throw Error('the observer\'s status cannot be read; run redue status to see why');
127
+ if(seconds>15*60)throw Error(`the observer is not ready after 15 minutes (${state?.reason||state?.phase||'unknown'}); run redue status, then redue setup claude again`);
128
+ if(Math.floor(seconds/15)>told){told=Math.floor(seconds/15);console.log(seconds?`Still indexing (${seconds} s)...`:'Waiting for the observer to index the project...');}
129
+ await new Promise(resolve=>setTimeout(resolve,1000));
130
+ }
131
+ // One recorded run of each check that can qualify and is not CURRENT.
132
+ const statusOf=()=>{const out=redue(['status','--sync','--json'],{capture:true,timeout:600_000});try{return JSON.parse(out.stdout).checks;}catch{return [];}};
133
+ const rows=statusOf(),due=ready.filter(name=>rows.find(row=>row.name===name)?.freshness!=='CURRENT');
134
+ for(const name of due){console.log(`Recording ${name} once:`);redue(['run',name]);}
135
+ if(ready.length&&!due.length)console.log(`Already CURRENT: ${ready.join(', ')}.`);
136
+ for(const row of statusOf())console.log(` ${row.name}: ${row.freshness}${row.result?' / '+row.result:''}${row.freshness==='CURRENT'?'':' — '+row.reason}`);
137
+ console.log('Done. Start a new Claude Code session in this project. Undo with redue remove.');
138
+ return {applied:true};
139
+ }
140
+
141
+ async function remove({root,redue,yes,record:own,recordFile,exclude,others}){
142
+ if(!own){console.log('No setup record for this checkout: nothing redue setup created to remove.');
143
+ console.log('To undo a setup made step by step: redue agent remove claude --apply, then redue remove-state.');return {removed:false};}
144
+ const configPath=path.join(root,configName),configText=readText(configPath);
145
+ const removeAgent=own.agentCreated&&fs.existsSync(path.join(root,agentRecord));
146
+ const removeHooks=!removeAgent&&own.hooksAdded&&claudeHooksInstalled(root);
147
+ const removeState=own.stateCreated&&fs.existsSync(own.state);
148
+ const deleteConfig=own.configCreated&&configText!==null&&hash(configText)===own.configHash;
149
+ // The ignore line goes only when no other checkout of this repository is
150
+ // set up: those rely on it too, whichever added it.
151
+ const removeIgnore=own.excludeAdded&&!others().length;
152
+ console.log(`Removing what redue setup created in ${root}`);
153
+ if(removeAgent)console.log(' remove the Claude Code integration');
154
+ if(removeHooks)console.log(` remove REDUE's hooks from ${localSettings} (the integration predates setup)`);
155
+ if(removeState)console.log(' stop the observer and remove its local state (receipts included)');
156
+ else if(!own.stateCreated)console.log(' keep the observer state (it predates setup)');
157
+ if(own.configCreated&&configText!==null)console.log(deleteConfig?` delete ${configName}`:` keep ${configName} (edited since setup)`);
158
+ if(removeIgnore)console.log(` remove ${ignoreLine} from ${exclude}`);
159
+ console.log(` delete ${recordFile}`);
160
+ if(!await confirm('Proceed?',yes,{defaultYes:false})){console.log('Nothing removed.');return {removed:false};}
161
+ if(removeAgent)for(const line of removeIntegration(root))console.log(line);
162
+ if(removeHooks){const changed=removeClaudeHooks(root);if(changed)console.log(`update ${changed}`);}
163
+ if(removeState&&redue(['--state-dir',own.state,'remove-state']).status!==0)
164
+ throw Error('the observer state was not removed; see above. The config and setup record are kept so you can retry');
165
+ if(deleteConfig){fs.unlinkSync(configPath);console.log(`remove ${configName}`);}
166
+ if(removeIgnore){
167
+ const text=readText(exclude);
168
+ if(text!==null&&text.split('\n').includes(ignoreLine)){
169
+ fs.writeFileSync(exclude,text.split('\n').filter(line=>line!==ignoreLine).join('\n'));
170
+ console.log(`update ${exclude}`);}
171
+ }
172
+ fs.unlinkSync(recordFile);console.log(`remove ${recordFile}`);
173
+ // Folders the integration lived in, when nothing else is left in them.
174
+ for(const dir of ['.redue/agents','.redue','.claude/skills/redue-verification','.claude/skills','.claude'])
175
+ try{fs.rmdirSync(path.join(root,dir));console.log(`remove ${dir}/`);}catch{}
176
+ console.log('Done.');
177
+ return {removed:true};
178
+ }
@@ -430,13 +430,18 @@ function storePath(files,file,st){
430
430
  // files, which is a change REDUE sees. `cache` (in owned state) keeps each
431
431
  // file found in the store with its identity and store path, so an unchanged
432
432
  // file is not hashed again; its store link is still checked.
433
- export function sharedInstalledFile(root,project={},cache=null){
433
+ // Without a pnpm store, a hard-linked installed file is accepted only when
434
+ // every one of its links is an installed file the check observes (`observed`,
435
+ // installed-observation.mjs): esbuild links its binary into its platform
436
+ // package. Each accepted file's link count becomes a run fact in `facts`, so
437
+ // a link added later is noticed before the next answer.
438
+ export function sharedInstalledFile(root,project={},cache=null,{observed=()=>false,facts=[]}={}){
434
439
  const stack=modulesDirs(project).map(dir=>path.join(root,dir));
435
440
  const files=pnpmStoreFiles(root),now=Date.now();
436
441
  let known={},changed=false;
437
442
  if(files&&cache)try{const value=JSON.parse(fs.readFileSync(cache,'utf8'));
438
443
  if(value?.files===files&&value.entries&&typeof value.entries==='object')known=value.entries;}catch{}
439
- const found={};
444
+ const found={},links=new Map();
440
445
  const shared=(file,reason)=>({file:path.relative(root,file).split(path.sep).join('/'),reason});
441
446
  while(stack.length){
442
447
  const dir=stack.pop();let entries;
@@ -447,7 +452,14 @@ export function sharedInstalledFile(root,project={},cache=null){
447
452
  if(!entry.isFile())continue;
448
453
  let st;try{st=fs.lstatSync(file,{bigint:true});}catch{continue;}
449
454
  if(st.nlink<=1n)continue;
450
- if(!files)return shared(file,'has another hard link, and the install records no pnpm store');
455
+ if(!files){
456
+ // Distinct observed links by real path: a directory walked twice
457
+ // through a link counts once.
458
+ const key=`${st.dev}:${st.ino}`,group=links.get(key)??{file,nlink:st.nlink,seen:new Set()};
459
+ if(group.nlink!==st.nlink)return shared(file,'had its hard links change while REDUE checked them');
460
+ if(observed(file))try{group.seen.add(fs.realpathSync(file));}catch{}
461
+ links.set(key,group);continue;
462
+ }
451
463
  const key=`${st.dev}:${st.ino}`,cached=known[key];
452
464
  let at=Array.isArray(cached)&&cached[0]===identity(st)&&typeof cached[1]==='string'&&
453
465
  sameFile(path.join(files,cached[1]),st)?cached[1]:null;
@@ -463,6 +475,10 @@ export function sharedInstalledFile(root,project={},cache=null){
463
475
  else changed=true;
464
476
  }
465
477
  }
478
+ for(const group of links.values()){
479
+ if(BigInt(group.seen.size)<group.nlink)return shared(group.file,'has another hard link that is not an installed file this check observes, and the install records no pnpm store');
480
+ facts.push(['linkCount',group.file,Number(group.nlink)]);
481
+ }
466
482
  if(files&&cache&&(changed||Object.keys(found).length!==Object.keys(known).length))
467
483
  try{atomicJson(cache,{files,entries:found});}catch{}
468
484
  return null;
@@ -15,6 +15,8 @@ export function queryAnswer(kind,file,options) {
15
15
  if(kind==='readFile')return hash(fs.readFileSync(file,'utf8'));
16
16
  if(kind==='fileExists')return fs.statSync(file).isFile();
17
17
  if(kind==='directoryExists')return fs.statSync(file).isDirectory();
18
+ // How many hard links a file has: a link added elsewhere changes it.
19
+ if(kind==='linkCount')return fs.lstatSync(file).nlink;
18
20
  // The compiler resolves through realpathSync.native outside Windows.
19
21
  if(kind==='realpath')return process.platform==='win32'?fs.realpathSync(file):fs.realpathSync.native(file);
20
22
  if(kind==='directories')return fs.readdirSync(file,{withFileTypes:true})
@@ -44,7 +46,7 @@ export function queryAnswer(kind,file,options) {
44
46
  export function runFactChange(facts) {
45
47
  if(!Array.isArray(facts))return 'an unreadable run fact';
46
48
  for(const fact of facts){
47
- if(!Array.isArray(fact)||!['fileExists','directoryExists','realpath','readFile','entries'].includes(fact[0])||
49
+ if(!Array.isArray(fact)||!['fileExists','directoryExists','realpath','readFile','entries','linkCount'].includes(fact[0])||
48
50
  typeof fact[1]!=='string')return 'an unreadable run fact';
49
51
  const [kind,file,answer]=fact;
50
52
  let now;try{now=queryAnswer(kind,file);}catch{return file;}
@@ -63,6 +65,14 @@ export function queriesMatch(queries) {
63
65
  return queries.every(([kind,file,answer,options])=>
64
66
  JSON.stringify(queryAnswer(kind,file,options))===JSON.stringify(answer));
65
67
  }
68
+ // Why a recipe built on TypeScript's compiler API cannot use the TypeScript
69
+ // installed at `tsRoot`, or null: TypeScript 7 is a native compiler and
70
+ // ships no such API.
71
+ export function compilerApiIssue(tsRoot) {
72
+ let version=null;try{version=JSON.parse(fs.readFileSync(path.join(tsRoot,'package.json'),'utf8')).version;}catch{}
73
+ return Number(String(version).split('.')[0])>=7?
74
+ `TypeScript ${version} is a native compiler without the compiler API this recipe reads; REDUE does not support TypeScript 7 yet`:null;
75
+ }
66
76
  export function compilerFiles(tsRoot,cwd,config='tsconfig.json') {
67
77
  const require=createRequire(import.meta.url),ts=require(tsRoot);
68
78
  if(typeof ts.createProgram!=='function'||typeof ts.createCompilerHost!=='function')return null;
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import {execFileSync} from 'node:child_process';
4
4
  import {createRequire} from 'node:module';
5
+ import {compilerApiIssue} from './typescript-list.mjs';
5
6
  import micromatch from './glob.mjs';
6
7
  import {withinPath as within,realObservedPath} from './path-identity.mjs';
7
8
 
@@ -58,6 +59,8 @@ export function yarnWorkspacePackages(root){
58
59
  }
59
60
 
60
61
  function parsedTypeScript(root,dir){
62
+ const apiIssue=compilerApiIssue(path.join(root,'node_modules','typescript'));
63
+ if(apiIssue)throw Error(apiIssue);
61
64
  const require=createRequire(path.join(root,'package.json'));
62
65
  const ts=require(path.join(root,'node_modules','typescript'));
63
66
  const config=path.join(root,dir,'tsconfig.json'),reads=new Set();