@redue/cli 0.1.0-alpha.27 → 0.1.0-alpha.29
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/CHANGELOG.md +91 -0
- package/README.md +50 -22
- package/bin/redue.mjs +24 -12
- package/docs/user/agents.md +14 -7
- package/docs/user/getting-started.md +4 -3
- package/docs/user/json.md +2 -2
- package/docs/user/node-script.md +2 -2
- package/docs/user/sandbox.md +119 -42
- package/docs/user/shell.md +3 -3
- package/docs/user/supported-projects.md +10 -5
- package/package.json +1 -1
- package/src/claude-hook.mjs +49 -29
- package/src/cli.mjs +48 -17
- package/src/daemon.mjs +10 -31
- package/src/landlock.py +319 -20
- package/src/node-onboarding.mjs +3 -1
- package/src/presentation.mjs +4 -4
- package/src/python-onboarding.mjs +9 -7
- package/src/sandbox.mjs +168 -22
- package/src/setup.mjs +26 -4
package/src/sandbox.mjs
CHANGED
|
@@ -29,9 +29,37 @@ export const sandboxInterpretation='sandbox@1';
|
|
|
29
29
|
export const hiddenQualification='sandbox-v2';
|
|
30
30
|
export const hiddenInterpretation='sandbox@2';
|
|
31
31
|
export const hiddenListIssue='git cannot list the ignored files';
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
export const
|
|
32
|
+
// And the project's documentation hidden too (docPatterns): an agent edits it
|
|
33
|
+
// often, and a typecheck, lint or test suite seldom reads it.
|
|
34
|
+
export const docsQualification='sandbox-v3';
|
|
35
|
+
export const docsInterpretation='sandbox@3';
|
|
36
|
+
// Under docs/, only documentation files: code there (examples a typecheck
|
|
37
|
+
// includes) stays visible.
|
|
38
|
+
const docsFolderTypes=['txt','adoc','png','jpg','jpeg','gif','svg','webp','pdf'];
|
|
39
|
+
export const docPatterns=['**/*.md','**/*.mdx','**/*.rst','**/LICENSE*','**/CHANGELOG*',...docsFolderTypes.map(type=>`docs/**/*.${type}`)];
|
|
40
|
+
export const hiddenDocsIssue='git cannot list the documentation';
|
|
41
|
+
// What macOS refuses by path pattern: the extensions, in any case (Seatbelt
|
|
42
|
+
// matches regexes without regard to case). Every other listed file
|
|
43
|
+
// (LICENSE*, CHANGELOG*) it refuses by its path; a pattern would refuse
|
|
44
|
+
// license-check.ts too.
|
|
45
|
+
const docsByPattern=rel=>/^(.+\/)?[^/]*\.(md|mdx|rst)$/i.test(rel)||new RegExp(`^docs/(.+/)?[^/]*\\.(${docsFolderTypes.join('|')})$`,'i').test(rel);
|
|
46
|
+
// One path each makes the profile larger and slower to compile (1,000 add
|
|
47
|
+
// about 190 ms to a run; a few thousand do not compile). Past this many,
|
|
48
|
+
// those files stay visible and count as inputs, on every platform.
|
|
49
|
+
export const namedDocsLimit=100;
|
|
50
|
+
export const isSandboxed=qualification=>[sandboxQualification,hiddenQualification,docsQualification].includes(qualification);
|
|
51
|
+
// Whether the recipe hides the checkout's git-ignored entries (v2 and v3).
|
|
52
|
+
export const hidesIgnored=qualification=>qualification===hiddenQualification||qualification===docsQualification;
|
|
53
|
+
// A macOS test run has had no TCP since alpha.29: its own interpretation, so a
|
|
54
|
+
// result recorded with loopback is not the same plan.
|
|
55
|
+
export const sandboxInterpretationOf=(qualification,kind=null,platform=process.platform)=>
|
|
56
|
+
(qualification===docsQualification?docsInterpretation:qualification===hiddenQualification?hiddenInterpretation:sandboxInterpretation)+
|
|
57
|
+
(kind==='test'&&platform==='darwin'?'+no-tcp':'');
|
|
58
|
+
export const sandboxKinds=['typecheck','lint','test'];
|
|
59
|
+
// A test run gets a private /tmp (sandboxArgv's `writes`) and, on Linux,
|
|
60
|
+
// supervised loopback networking (`network`); a typecheck or lint, and a
|
|
61
|
+
// test run on macOS, get no network but Unix sockets.
|
|
62
|
+
export const testRun=check=>check?.kind==='test';
|
|
35
63
|
const fail=code=>{throw Object.assign(Error(code),{code});};
|
|
36
64
|
const digest=value=>createHash('sha256').update(value).digest('hex');
|
|
37
65
|
const real=file=>{try{return fs.realpathSync(file);}catch{return null;}};
|
|
@@ -62,10 +90,12 @@ export const pythonTool=check=>Array.isArray(check?.command)&&/^\.venv\/bin\/[A-
|
|
|
62
90
|
// script check names its package manager first among the executables, as the
|
|
63
91
|
// general recipe does.
|
|
64
92
|
const managers=['npm','pnpm','yarn','corepack'];
|
|
65
|
-
export function sandboxEnvironment(python=false,manager=null){
|
|
93
|
+
export function sandboxEnvironment(python=false,manager=null,kind=null){
|
|
66
94
|
return {variables:['TZ','LANG','LANGUAGE','CI','USER','LOGNAME'],
|
|
67
95
|
prefixes:['LC_','DYLD_','LD_',...python?['PYTHON','MYPY','RUFF_','PYRIGHT','TY_']:
|
|
68
|
-
['NODE_','npm_config_','PNPM_','YARN_','COREPACK_','TSGO_']
|
|
96
|
+
['NODE_','npm_config_','PNPM_','YARN_','COREPACK_','TSGO_'],
|
|
97
|
+
// A test runner's own variables.
|
|
98
|
+
...kind==='test'?python?['PYTEST_']:['VITEST_','JEST_']:[]],
|
|
69
99
|
...manager?{pathExecutables:['node',manager,...managers.filter(name=>name!==manager)]}:{}};
|
|
70
100
|
}
|
|
71
101
|
|
|
@@ -247,6 +277,24 @@ export function hiddenEntries(root){
|
|
|
247
277
|
!runnerCache(rel)&&!runnerCache(rel.replace(/\/$/,'')+'/x'));
|
|
248
278
|
}
|
|
249
279
|
|
|
280
|
+
// The documentation files docPatterns names among the files git lists and
|
|
281
|
+
// does not ignore (git-ignored ones are hidden as such), as regular files:
|
|
282
|
+
// a link is not hidden. Files, never folders, so a folder stays listable.
|
|
283
|
+
// Relative; null when git cannot tell. git filters by pathspec, so a large
|
|
284
|
+
// checkout is not listed whole.
|
|
285
|
+
const docMatch=picomatch(docPatterns,{dot:true});
|
|
286
|
+
export function hiddenDocs(root){
|
|
287
|
+
const r=spawnSync('git',[...projectGit,'ls-files','-z','--cached','--others','--exclude-standard','--',...docPatterns.map(pattern=>`:(glob)${pattern}`)],
|
|
288
|
+
{cwd:root,encoding:'utf8',maxBuffer:256*1024*1024,timeout:60000,stdio:['ignore','pipe','ignore'],env:gitEnv()});
|
|
289
|
+
if(r.status!==0)return null;
|
|
290
|
+
const out=new Set();
|
|
291
|
+
for(const rel of r.stdout.split('\0'))if(rel&&!out.has(rel)&&docMatch(rel))
|
|
292
|
+
try{if(fs.lstatSync(path.join(root,rel)).isFile())out.add(rel);}catch{}
|
|
293
|
+
const named=[...out].filter(rel=>!docsByPattern(rel));
|
|
294
|
+
if(named.length>namedDocsLimit)for(const rel of named)out.delete(rel);
|
|
295
|
+
return [...out].sort();
|
|
296
|
+
}
|
|
297
|
+
|
|
250
298
|
// What git lists now as ignored at or beneath `paths` (relative files, or
|
|
251
299
|
// directories ending in /), as hiddenEntries would: nested repositories are
|
|
252
300
|
// not entered, and an entry that is an installed tree is not ignored (one
|
|
@@ -273,6 +321,39 @@ export function trackedBeneath(root,entries){
|
|
|
273
321
|
{cwd:root,encoding:'utf8',maxBuffer:256*1024*1024,timeout:10000,stdio:['ignore','pipe','ignore'],env:gitEnv()});
|
|
274
322
|
return r.status===0?new Set(r.stdout.split('\0').filter(Boolean)):null;
|
|
275
323
|
}
|
|
324
|
+
// For a run that hid `hidden` (relative entries), the first path of `paths`
|
|
325
|
+
// that changes its inputs, between `before` and `current` ({rel: identity}),
|
|
326
|
+
// or null. A file the run saw always counts. A file beneath a hidden entry
|
|
327
|
+
// counts only once git tracks it, and a new file only when git does not list
|
|
328
|
+
// it as ignored now, as the next run's hidden entries would not. When git
|
|
329
|
+
// cannot tell, every change counts. `external:` paths always count. With
|
|
330
|
+
// `during` (changes while the run ran), a new file outside the hidden
|
|
331
|
+
// entries counts too: the run could read it.
|
|
332
|
+
// With `docs` (sandbox@3: the documentation files the run hid, relative), a
|
|
333
|
+
// later change to one of those files does not count. A new documentation
|
|
334
|
+
// file does, and so does any change while the run ran: the run is not shown
|
|
335
|
+
// to have been kept from it.
|
|
336
|
+
export function visibleChange(root,hidden,before,current,paths,{during=false,docs=null}={}){
|
|
337
|
+
const entries=hidden.map(entry=>entry.replace(/\/$/,''));
|
|
338
|
+
const beneath=file=>entries.some(entry=>file===entry||file.startsWith(entry+'/'));
|
|
339
|
+
const hiddenDoc=new Set(during?[]:docs||[]);
|
|
340
|
+
const changed=[...new Set(paths)].filter(file=>before[file]!==current[file]&&!hiddenDoc.has(file)).sort();
|
|
341
|
+
const seen=changed.filter(file=>file in before&&!beneath(file));
|
|
342
|
+
if(seen.length)return seen[0];
|
|
343
|
+
const covered=changed.filter(beneath),fresh=changed.filter(file=>!(file in before)&&!beneath(file));
|
|
344
|
+
if(covered.length){
|
|
345
|
+
const tracked=trackedBeneath(root,entries.filter(entry=>covered.some(file=>file===entry||file.startsWith(entry+'/'))));
|
|
346
|
+
const found=covered.find(file=>!tracked||tracked.has(file));
|
|
347
|
+
if(found)return found;
|
|
348
|
+
}
|
|
349
|
+
if(!fresh.length)return null;
|
|
350
|
+
if(during)return fresh[0];
|
|
351
|
+
// Asked once per directory the run had no file in, then the files outside one.
|
|
352
|
+
const dirs=new Set(Object.keys(before).flatMap(file=>file.split('/').slice(0,-1).map((_,i,parts)=>parts.slice(0,i+1).join('/'))));
|
|
353
|
+
const newDir=file=>{const parts=file.split('/');for(let i=1;i<parts.length;i++){const dir=parts.slice(0,i).join('/');if(!dirs.has(dir))return dir+'/';}return null;};
|
|
354
|
+
const ignored=ignoredNow(root,[...new Set(fresh.filter(file=>!file.startsWith('external:')).map(file=>newDir(file)??file))]);
|
|
355
|
+
return fresh.find(file=>file.startsWith('external:')||!ignored||!ignored.has(file))??null;
|
|
356
|
+
}
|
|
276
357
|
// A refusal line that names a hidden entry (relative): a refusal by the
|
|
277
358
|
// sandbox, or a missing file, which Linux tools report for an unreadable one.
|
|
278
359
|
export function hiddenRefusal(text,hidden){
|
|
@@ -288,29 +369,78 @@ const ancestors=list=>[...new Set(list.flatMap(dir=>{const out=[];for(let at=pat
|
|
|
288
369
|
// metadata of their parent directories, and no network but Unix sockets in
|
|
289
370
|
// the run's own temporary directory (`own`), which is new for each run: a
|
|
290
371
|
// daemon an earlier run left behind cannot answer a later one. `hidden`:
|
|
291
|
-
// absolute paths it may not read
|
|
292
|
-
|
|
372
|
+
// absolute paths whose contents it may not read or execute.
|
|
373
|
+
// A test run gets no TCP either: Seatbelt cannot refuse one loopback port
|
|
374
|
+
// while allowing the others (a per-port deny under a loopback allow is not
|
|
375
|
+
// applied; an allow list of 65,000 ports does not compile), so a suite could
|
|
376
|
+
// reach a database started outside the run. A suite that needs loopback
|
|
377
|
+
// fails init's trial and runs plainly. With `writes`, a test run writes
|
|
378
|
+
// nothing beneath /tmp and /var/tmp but beneath those places (its checkout,
|
|
379
|
+
// home and own temporary directory).
|
|
380
|
+
// `docs` ({root, entries}): the checkout's documentation it may not read,
|
|
381
|
+
// regular files only, so folders stay listable. Extensions go by path
|
|
382
|
+
// pattern, so the profile stays small however many files match: every file
|
|
383
|
+
// hiddenDocs lists, and more (one in node_modules, README.MD). Seatbelt
|
|
384
|
+
// matches a regex without regard to case, so LICENSE* and CHANGELOG* go by
|
|
385
|
+
// the files hiddenDocs listed: a pattern would hide license-check.ts too.
|
|
386
|
+
export function seatbeltProfile(reads,own=null,hidden=[],{writes=null,docs=null}={}){
|
|
293
387
|
const quote=value=>JSON.stringify(value);
|
|
388
|
+
const both=list=>[...new Set(list.flatMap(dir=>[dir,real(dir)]).filter(Boolean))];
|
|
294
389
|
const all=[...new Set(reads.flatMap(dir=>[dir,real(dir)]).filter(Boolean))];
|
|
295
390
|
return ['(version 1)','(allow default)','(deny file-read* process-exec*)',
|
|
296
391
|
`(allow file-read* process-exec* ${all.map(dir=>`(subpath ${quote(dir)})`).join(' ')} (literal "/") (literal "/etc") (literal "/var") (literal "/tmp"))`,
|
|
297
392
|
`(allow file-read-metadata ${ancestors(all).map(dir=>`(literal ${quote(dir)})`).join(' ')})`,
|
|
298
393
|
// The entry's own path, by its directory's real path: a hidden link's target is not hidden.
|
|
299
|
-
|
|
394
|
+
// Its contents only, as on Linux: a run may still see that it exists, and write beneath it.
|
|
395
|
+
...hidden.length?[`(deny file-read-data process-exec* ${[...new Set(hidden.flatMap(file=>[file,path.join(real(path.dirname(file))??path.dirname(file),path.basename(file))]))]
|
|
300
396
|
.map(file=>`(subpath ${quote(file)})`).join(' ')})`]:[],
|
|
301
|
-
|
|
397
|
+
...docs?[docsRule(docs,both([docs.root]))]:[],
|
|
398
|
+
'(deny network*)',...own?[`(allow network* ${[own,real(own)].filter(Boolean).map(dir=>`(subpath ${quote(dir)})`).join(' ')})`]:[],
|
|
399
|
+
...writes?['(deny file-write* (subpath "/tmp") (subpath "/private/tmp") (subpath "/var/tmp") (subpath "/private/var/tmp"))',
|
|
400
|
+
`(allow file-write* ${both(writes).map(dir=>`(subpath ${quote(dir)})`).join(' ')})`]:[]].join('\n');
|
|
401
|
+
}
|
|
402
|
+
// docsByPattern's patterns for each root, and each listed file they do not cover by its path.
|
|
403
|
+
function docsRule(docs,roots){
|
|
404
|
+
const named=docs.entries.filter(rel=>!docsByPattern(rel));
|
|
405
|
+
const patterns=roots.map(dir=>escape(dir)).flatMap(dir=>[`^${dir}/(.+/)?[^/]*\\.(md|mdx|rst)$`,`^${dir}/docs/(.+/)?[^/]*\\.(${docsFolderTypes.join('|')})$`])
|
|
406
|
+
.map(pattern=>`(regex #"${pattern.replace(/"/g,'\\"')}")`);
|
|
407
|
+
const files=roots.flatMap(dir=>named.map(rel=>`(literal ${JSON.stringify(path.join(dir,rel))})`));
|
|
408
|
+
return `(deny file-read-data process-exec* (require-all (vnode-type REGULAR-FILE) (require-any ${[...patterns,...files].join(' ')})))`;
|
|
302
409
|
}
|
|
303
410
|
// The argv that runs `argv` sandboxed: reads beneath `reads`, no network but
|
|
304
411
|
// Unix sockets beneath `tmp` on macOS, nothing beneath `hidden` (absolute).
|
|
305
412
|
// On Linux a hidden directory can still be listed and its entries stat'ed:
|
|
306
413
|
// Landlock restricts neither beneath a listable parent.
|
|
307
|
-
|
|
308
|
-
|
|
414
|
+
// `network` "loopback" and `writes`: a test run's. Only Linux gives it a
|
|
415
|
+
// network (seatbeltProfile says why macOS does not), supervised: `stats`
|
|
416
|
+
// then says whether the supervisor served the whole run (supervisorIssue).
|
|
417
|
+
// `docs` ({root, entries}): the checkout's documentation, hidden too.
|
|
418
|
+
export function sandboxArgv(mode,argv,{reads,tmp=null,python,hidden=[],stats=null,network='none',writes=null,fault=null,docs=null}){
|
|
419
|
+
if(mode==='seatbelt')return ['/usr/bin/sandbox-exec','-p',seatbeltProfile(reads,tmp,hidden,{writes,docs}),...argv];
|
|
420
|
+
if(docs)hidden=[...hidden,...docs.entries.map(rel=>path.join(docs.root,rel.replace(/\/$/,'')))];
|
|
309
421
|
// The launcher's own warnings would become the check's output.
|
|
310
422
|
if(mode==='landlock')return [python,'-I','-W','ignore',landlockScript,...reads.flatMap(dir=>['--read',dir]),
|
|
311
|
-
...hidden.flatMap(file=>['--hide',file]),...stats?['--stats',stats]:[],
|
|
423
|
+
...hidden.flatMap(file=>['--hide',file]),...stats?['--stats',stats]:[],
|
|
424
|
+
...network==='loopback'?['--loopback']:['--no-tcp'],
|
|
425
|
+
...writes?['--deny-write','/tmp','--deny-write','/var/tmp',...writes.flatMap(dir=>['--write',dir])]:[],
|
|
426
|
+
...fault?['--test-fault',fault]:[],'--',...argv];
|
|
312
427
|
throw Error(`unknown sandbox ${mode}`);
|
|
313
428
|
}
|
|
429
|
+
// Why a supervised run's network was not supervised throughout, from the
|
|
430
|
+
// launcher's stats file, or null when it was.
|
|
431
|
+
export function supervisorIssue(stats){
|
|
432
|
+
let value=null;try{value=JSON.parse(fs.readFileSync(stats,'utf8'));}catch{}
|
|
433
|
+
return value?.supervisor==='ok'?null:`the network supervisor ${value?.supervisor?`reported: ${value.supervisor}`:'left no report'}`;
|
|
434
|
+
}
|
|
435
|
+
// The counts a test runner's summary gives, the last of each: pytest, Vitest,
|
|
436
|
+
// Jest, Mocha, node --test.
|
|
437
|
+
const ansi=/\u001b\[[0-9;]*m/g;
|
|
438
|
+
export function testCounts(text){
|
|
439
|
+
const plain=String(text).replace(ansi,'');
|
|
440
|
+
const last=(...patterns)=>{for(const re of patterns){let m,v=null;const g=new RegExp(re,'gim');while((m=g.exec(plain)))v=Number(m[1]);if(v!==null)return v;}return null;};
|
|
441
|
+
return {passed:last('(\\d+) (?:tests? )?(?:passed|passing)\\b','^(?:#|ℹ) pass (\\d+)'),failed:last('(\\d+) (?:tests? )?(?:failed|failing)\\b','^(?:#|ℹ) fail (\\d+)'),
|
|
442
|
+
skipped:last('(\\d+) (?:tests? )?(?:skipped|pending|todo)\\b','^(?:#|ℹ) (?:skip|skipped) (\\d+)')};
|
|
443
|
+
}
|
|
314
444
|
// A line of a failed run's output that looks like the sandbox refused
|
|
315
445
|
// something: a refused read or write, or the network unreachable. Such a
|
|
316
446
|
// failure is run again outside the sandbox to tell a denial from a real one.
|
|
@@ -454,6 +584,7 @@ export function runToEnd(argv,{cwd,env,timeout=0,echo=false,started=null}){
|
|
|
454
584
|
});
|
|
455
585
|
}
|
|
456
586
|
// {same, plain, sandboxed, line} or {issue} when the sandbox cannot run here.
|
|
587
|
+
// With `hide` 'docs', the documentation (hiddenDocs) is hidden too.
|
|
457
588
|
// With `hide`, the git-ignored entries hiddenEntries lists are kept from the
|
|
458
589
|
// sandboxed run, and it also returns them, their count, the run times and, on
|
|
459
590
|
// Linux, the launcher's rule count and setup time.
|
|
@@ -463,18 +594,27 @@ export async function sandboxTrial(root,check,{timeout=300000,hide=false,plain:e
|
|
|
463
594
|
if(supported.issue)return {issue:supported.issue};
|
|
464
595
|
const python=Boolean(pythonTool(check));
|
|
465
596
|
const command=python?check.command:[lookup(scriptProject(root,check.script).manager,process.env.PATH,root)||'npm','run',check.script];
|
|
466
|
-
const selected={...check,command,environment:sandboxEnvironment(python,python?null:scriptProject(root,check.script).manager)};
|
|
467
|
-
const
|
|
468
|
-
|
|
597
|
+
const selected={...check,command,environment:sandboxEnvironment(python,python?null:scriptProject(root,check.script).manager,check.kind)};
|
|
598
|
+
const tests=testRun(check);
|
|
599
|
+
// A test run's home is beneath its temporary directory, as in cli.mjs: on
|
|
600
|
+
// macOS that is where its Unix sockets may be.
|
|
601
|
+
const scratch=fs.mkdtempSync(path.join(os.tmpdir(),'redue-trial-')),tmp=path.join(scratch,'tmp'),home=tests?path.join(tmp,'home'):path.join(scratch,'home');
|
|
602
|
+
fs.mkdirSync(tmp);fs.mkdirSync(home);
|
|
469
603
|
try{
|
|
470
|
-
const
|
|
471
|
-
if(!
|
|
472
|
-
|
|
604
|
+
const ignored=hide?hiddenEntries(root):[],docEntries=hide==='docs'?hiddenDocs(root):[];
|
|
605
|
+
if(!ignored)return {issue:hiddenListIssue};
|
|
606
|
+
if(!docEntries)return {issue:hiddenDocsIssue};
|
|
607
|
+
const entries=[...ignored,...docEntries];
|
|
608
|
+
const hidden=ignored.map(rel=>path.join(root,rel.replace(/\/$/,""))),stats=(hide||tests)&&supported.mode==='landlock'?path.join(scratch,'stats.json'):null;
|
|
473
609
|
const run=async(argv,env)=>{const started=Date.now(),r=await runToEnd(argv,{cwd:root,env,timeout});
|
|
474
610
|
return {status:r.timedOut?'timeout':r.status??r.signal??r.error?.code,output:`${r.stdout}\n${r.stderr}`,ms:Date.now()-started};};
|
|
475
611
|
const plain=earlier??await run(command,{...process.env,...python?{}:{npm_config_update_notifier:'false'}});
|
|
476
612
|
const reads=[root,home,tmp,...sandboxPlaces(selected,process.env,root),...systemPlaces];
|
|
477
|
-
const sandboxed=await run(sandboxArgv(supported.mode,command,{reads,tmp,python:supported.python,hidden,stats
|
|
613
|
+
const sandboxed=await run(sandboxArgv(supported.mode,command,{reads,tmp,python:supported.python,hidden,stats,
|
|
614
|
+
...hide==='docs'?{docs:{root,entries:docEntries}}:{},
|
|
615
|
+
...tests?{...supported.mode==='landlock'?{network:'loopback'}:{},writes:[root,home,tmp]}:{}}),sandboxEnv(selected,process.env,{root,home,tmp}));
|
|
616
|
+
// A test run's network was supervised throughout, on Linux.
|
|
617
|
+
const supervisor=tests&&stats?supervisorIssue(stats):null;
|
|
478
618
|
// A failure both ways counts as the same only without a sign of a refusal.
|
|
479
619
|
const denied=sandboxed.status===0?null:denialLine(sandboxed.output,entries);
|
|
480
620
|
const realOf=dir=>{try{return fs.realpathSync(dir);}catch{return null;}},plainTmp=process.env.TMPDIR||os.tmpdir();
|
|
@@ -486,8 +626,14 @@ export async function sandboxTrial(root,check,{timeout=300000,hide=false,plain:e
|
|
|
486
626
|
if(at<0&&b.length>a.length)at=a.length;
|
|
487
627
|
const output=at<0?null:{plain:(a[at]??'(no more output)').slice(0,160),sandboxed:(b[at]??'(no more output)').slice(0,160)};
|
|
488
628
|
let launcher=null;if(stats)try{launcher=JSON.parse(fs.readFileSync(stats,'utf8'));}catch{}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
629
|
+
// Tests print in an order their workers decide: the counts their summary
|
|
630
|
+
// gives are compared, not every line.
|
|
631
|
+
// When neither summary can be read, every line is compared, as for a typecheck.
|
|
632
|
+
const counts=tests?[plain,sandboxed].map(r=>testCounts(r.output)):null;
|
|
633
|
+
const read=counts?.some(c=>Object.values(c).some(value=>value!==null));
|
|
634
|
+
const differs=tests&&read?(JSON.stringify(counts[0])===JSON.stringify(counts[1])?null:{plain:JSON.stringify(counts[0]),sandboxed:JSON.stringify(counts[1])}):output;
|
|
635
|
+
return {same:plain.status===sandboxed.status&&!denied&&!differs&&!supervisor,plain:plain.status,sandboxed:sandboxed.status,output:differs,plainRun:plain,
|
|
636
|
+
line:supervisor||denied||(plain.status===sandboxed.status?null:sandboxed.output.trim().split('\n').at(-1)?.slice(0,200)||null),
|
|
637
|
+
...hide?{hidden:entries.length,entries,docs:docEntries.length,plainMs:plain.ms,sandboxedMs:sandboxed.ms,...launcher?{rules:launcher.rules,setupMs:launcher.ms}:{}}:{}};
|
|
492
638
|
}finally{fs.rmSync(scratch,{recursive:true,force:true});}
|
|
493
639
|
}
|
package/src/setup.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import fs from 'node:fs';
|
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import {createHash} from 'node:crypto';
|
|
14
14
|
import {spawnSync} from 'node:child_process';
|
|
15
|
+
import {testCounts} from './sandbox.mjs';
|
|
15
16
|
import {createInterface} from 'node:readline/promises';
|
|
16
17
|
import {projectState} from './project-location.mjs';
|
|
17
18
|
import {claudeHooksInstalled,removeClaudeHooks,removeIntegration} from './agent-integration.mjs';
|
|
@@ -80,7 +81,19 @@ async function setup({root,redue,yes,record:previous,recordFile,exclude,save}){
|
|
|
80
81
|
const names=configExisted?JSON.parse(readText(configPath)).checks.map(check=>check.name):found.checks.map(check=>check.name);
|
|
81
82
|
const ready=names.filter(name=>levels.get(name)==='ready');
|
|
82
83
|
console.log(`Setting up REDUE for Claude Code in ${root}`);
|
|
83
|
-
console.log(configExisted?` keep ${configName} (exists)`:` create ${configName}: ${names.map(name=>`${name} (${levels.get(name)==='ready'?'can qualify':'recording
|
|
84
|
+
console.log(configExisted?` keep ${configName} (exists)`:` create ${configName}: ${names.map(name=>`${name} (${levels.get(name)==='ready'?'can qualify':'recording only'})`).join(', ')}`);
|
|
85
|
+
// What the checks setup does not make reusable are, and why.
|
|
86
|
+
if(!configExisted){
|
|
87
|
+
const recording=found.checks.filter(check=>check.level!=='ready');
|
|
88
|
+
if(recording.length)console.log(' recording only: REDUE records these runs but never reports one CURRENT, because it cannot tell what the result depends on:');
|
|
89
|
+
for(const check of recording)console.log(` ${check.name}: ${check.reason}`);
|
|
90
|
+
const out=[...found.skipped||[]].filter(row=>row.reason);
|
|
91
|
+
const kinds=new Set([...found.checks.map(check=>check.kind??check.name.split(':')[0]),...out.map(row=>String(row.kind).split(':')[0])]);
|
|
92
|
+
for(const kind of ['typecheck','lint','test'])if(!kinds.has(kind)&&!found.checks.some(check=>check.name===kind))
|
|
93
|
+
out.push({kind,reason:`${kind}: none found (no script or tool REDUE recognizes)`});
|
|
94
|
+
if(out.length)console.log(' left out:');
|
|
95
|
+
for(const row of out)console.log(` ${row.reason.startsWith(String(row.kind))?row.reason:`${row.kind}: ${row.reason}`}`);
|
|
96
|
+
}
|
|
84
97
|
console.log(` write the Claude Code integration: CLAUDE.md section, .claude/skills/redue-verification/SKILL.md, ${localSettings} hooks, ${agentRecord}`);
|
|
85
98
|
console.log(` start the observer and record ${ready.length?ready.join(', '):'no check'} once, unless already CURRENT`);
|
|
86
99
|
if(!await confirm('Proceed?',yes,{defaultYes:true})){console.log('Nothing written.');return {applied:false};}
|
|
@@ -121,7 +134,7 @@ async function setup({root,redue,yes,record:previous,recordFile,exclude,save}){
|
|
|
121
134
|
// recorded as reusable. Wait while it plans and indexes; once its plan is
|
|
122
135
|
// ready, an observer that is still unhealthy says why in the status below.
|
|
123
136
|
// Nothing to wait for when no check can qualify.
|
|
124
|
-
const observation=()=>{const out=redue(['status','--json'],{capture:true});try{return JSON.parse(out.stdout).observation??null;}catch{return null;}};
|
|
137
|
+
const observation=()=>{const out=redue(['status','--cached','--json'],{capture:true});try{return JSON.parse(out.stdout).observation??null;}catch{return null;}};
|
|
125
138
|
// An answer without a phase (a stopped observer) for 10 s in a row ends the
|
|
126
139
|
// wait too.
|
|
127
140
|
const begun=Date.now();let told=-1,unread=0,phaseless=0;
|
|
@@ -136,9 +149,18 @@ async function setup({root,redue,yes,record:previous,recordFile,exclude,save}){
|
|
|
136
149
|
// One recorded run of each check that can qualify and is not CURRENT.
|
|
137
150
|
const statusOf=()=>{const out=redue(['status','--sync','--json'],{capture:true,timeout:600_000});try{return JSON.parse(out.stdout).checks;}catch{return [];}};
|
|
138
151
|
const rows=statusOf(),due=ready.filter(name=>rows.find(row=>row.name===name)?.freshness!=='CURRENT');
|
|
139
|
-
|
|
152
|
+
// One line per check; the run's own output only when it fails.
|
|
153
|
+
for(const name of due){
|
|
154
|
+
console.log(`Recording ${name} once (running it)...`);
|
|
155
|
+
const started=Date.now(),run=redue(['run',name],{capture:true});
|
|
156
|
+
const output=`${run.stdout??''}\n${run.stderr??''}`,counts=testCounts(output),seconds=((Date.now()-started)/1000).toFixed(1);
|
|
157
|
+
const tally=Object.entries(counts).filter(([,value])=>value!==null).map(([key,value])=>`${value} ${key}`).join(', ');
|
|
158
|
+
console.log(` ${name}: ${run.status===0?'PASS':`FAIL (exit ${run.status??run.signal})`} in ${seconds} s${tally?`, ${tally}`:''}`);
|
|
159
|
+
if(run.status!==0)console.log(output.trim().split('\n').slice(-15).map(line=>` ${line}`).join('\n'));
|
|
160
|
+
}
|
|
140
161
|
if(ready.length&&!due.length)console.log(`Already CURRENT: ${ready.join(', ')}.`);
|
|
141
|
-
for(const row of statusOf())console.log(` ${row.name}: ${row.freshness}${row.result?' / '+row.result:''}${row.freshness==='CURRENT'?'':
|
|
162
|
+
for(const row of statusOf())console.log(` ${row.name}: ${row.freshness}${row.result?' / '+row.result:''}${row.freshness==='CURRENT'?'':
|
|
163
|
+
levels.get(row.name)!=='ready'?' — recording only':' — '+row.reason}`);
|
|
142
164
|
console.log('Done. Start a new Claude Code session in this project. Undo with redue remove.');
|
|
143
165
|
return {applied:true};
|
|
144
166
|
}
|