nearly-cli 0.1.12 → 0.1.17

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.
@@ -16,17 +16,19 @@ import { join, resolve, basename, dirname } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { execFileSync, spawnSync } from 'node:child_process';
18
18
  import { paths, dataRoot } from '../server/paths.mjs';
19
- import { ADAPTERS } from '../server/adapters.mjs';
19
+ import { ADAPTERS, OURS_RE } from '../server/adapters.mjs';
20
20
 
21
21
  const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
22
22
  const repo = resolve(process.argv.slice(2).find((a) => !a.startsWith('--')) || process.cwd());
23
23
  const PORT = Number(process.env.NEARLY_PORT || 47653);
24
24
 
25
- const dim = (s) => `\x1b[2m${s}\x1b[0m`;
26
- const bold = (s) => `\x1b[1m${s}\x1b[0m`;
27
- const green = (s) => `\x1b[32m${s}\x1b[0m`;
28
- const red = (s) => `\x1b[31m${s}\x1b[0m`;
29
- const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
25
+ // Colour only on a terminal; piped into a file or a CI log it is noise.
26
+ const COLOR = !!process.stdout.isTTY && !process.env.NO_COLOR;
27
+ const dim = (s) => (COLOR ? `\x1b[2m${s}\x1b[0m` : String(s));
28
+ const bold = (s) => (COLOR ? `\x1b[1m${s}\x1b[0m` : String(s));
29
+ const green = (s) => (COLOR ? `\x1b[32m${s}\x1b[0m` : String(s));
30
+ const red = (s) => (COLOR ? `\x1b[31m${s}\x1b[0m` : String(s));
31
+ const yellow = (s) => (COLOR ? `\x1b[33m${s}\x1b[0m` : String(s));
30
32
 
31
33
  const blockers = [];
32
34
  function say(ok, label, detail, fix) {
@@ -45,7 +47,8 @@ console.log(` ${bold('Nearly')} ${dim(repo)}`);
45
47
  console.log('');
46
48
 
47
49
  // 1 — a repo at all
48
- const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']);
50
+ // A repo with no commits has no HEAD to name, but it does have a branch.
51
+ const branch = git(['symbolic-ref', '--short', 'HEAD']) || git(['rev-parse', '--abbrev-ref', 'HEAD']) || '(no branch)';
49
52
  if (!existsSync(join(repo, '.git'))) {
50
53
  say(false, 'a git repository', 'this is not one', `cd into the repo you work in, then run nearly`);
51
54
  } else {
@@ -56,7 +59,7 @@ if (!existsSync(join(repo, '.git'))) {
56
59
  // and that is the single most common reason for an empty pull request.
57
60
  const gated = ADAPTERS.filter((a) => {
58
61
  const f = join(repo, a.config);
59
- try { return existsSync(f) && /nearly/i.test(readFileSync(f, 'utf8')); } catch { return false; }
62
+ try { return existsSync(f) && OURS_RE.test(readFileSync(f, 'utf8')); } catch { return false; }
60
63
  });
61
64
  if (gated.length) say(true, 'agents gated here', gated.map((a) => a.name).join(', '));
62
65
  else say(false, 'agents gated here', 'none', 'run `nearly` in this repo to turn it on');
@@ -75,7 +78,7 @@ if (gated.length) {
75
78
  const cfg = JSON.parse(readFileSync(join(repo, cc.config), 'utf8'));
76
79
  const walk = (o) => {
77
80
  if (!o || typeof o !== 'object') return;
78
- if (typeof o.command === 'string' && /nearly/i.test(o.command) && /pre-tool/.test(o.command)) cmd = o.command;
81
+ if (typeof o.command === 'string' && OURS_RE.test(o.command) && /pre-tool/.test(o.command)) cmd = o.command;
79
82
  for (const v of Object.values(o)) walk(v);
80
83
  };
81
84
  walk(cfg);
@@ -91,13 +94,27 @@ if (gated.length) {
91
94
  });
92
95
  // shell: true because the agent runs these through a shell, and on Windows
93
96
  // the installed command is a .cmd that will not spawn any other way.
94
- const r = spawnSync(cmd, { input: probe, shell: true, encoding: 'utf8', timeout: 30_000 });
97
+ // Run it with the PATH the agent will have, not this process's. Launched
98
+ // through npx, this process has npx's temporary bin first on PATH, holding a
99
+ // `nearly` that vanishes when npx exits — so a hook calling `nearly` passed
100
+ // here and could never run for the agent. npm scripts add node_modules/.bin
101
+ // the same way.
102
+ const sep = process.platform === 'win32' ? ';' : ':';
103
+ const agentPath = String(process.env.PATH || process.env.Path || '').split(sep)
104
+ .filter((d) => d && !/[\\/]_npx[\\/]/.test(d) && !/[\\/]node_modules[\\/]\.bin$/.test(d)).join(sep);
105
+ const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^npm_/i.test(k)));
106
+ env.PATH = agentPath;
107
+ if (process.platform === 'win32') env.Path = agentPath;
108
+ const r = spawnSync(cmd, { input: probe, shell: true, encoding: 'utf8', timeout: 30_000, env });
95
109
  const decided = /permissionDecision|"decision"|"permission"/.test(r.stdout || '');
96
110
  if (decided) {
97
111
  say(true, 'hooks actually fire', 'the gate answered a test call');
98
112
  } else {
99
- const why = (r.error && r.error.message)
100
- || (r.stderr || '').trim().split('\n')[0]
113
+ const lines = String(r.stderr || '').split('\n').map((l) => l.trim()).filter(Boolean)
114
+ .filter((l) => !/^(at |node:internal|npm (warn|notice)|\^+$|Node\.js v)/.test(l));
115
+ const pick = lines.find((l) => /not found|no such file|cannot find|ENOENT|EACCES|permission denied|is not recognized|Error:/i.test(l))
116
+ || lines[lines.length - 1];
117
+ const why = (r.error && r.error.message) || pick
101
118
  || (r.status !== 0 ? `the hook command exited ${r.status}` : 'the hook ran but answered nothing');
102
119
  say(false, 'hooks actually fire', why,
103
120
  `the hook command in ${cc.config} does not work here, so nothing is gated and nothing is recorded — check that \`nearly\` runs in a plain shell, then re-run \`nearly\` to rewrite the hooks`);
@@ -139,7 +156,7 @@ const recDir = paths.recordings();
139
156
  let runs = 0, otherBranches = new Set();
140
157
  const real = (p) => {
141
158
  let r;
142
- try { r = realpathSync(resolve(p)); } catch { r = resolve(p); }
159
+ try { r = realpathSync.native(resolve(p)); } catch { r = resolve(p); }
143
160
  return process.platform === 'win32' ? r.toLowerCase() : r; // same place, spelled differently
144
161
  };
145
162
  try {
@@ -170,10 +187,14 @@ if (runs) {
170
187
 
171
188
  // 5 — the hook that offers the record at push time
172
189
  const pushHook = join(repo, '.git', 'hooks', 'pre-push');
173
- const hasPush = existsSync(pushHook) && /x-session-record-hook|nearly/i.test((() => {
174
- try { return readFileSync(pushHook, 'utf8'); } catch { return ''; }
175
- })());
176
- say(hasPush, 'pre-push hook', hasPush ? 'installed' : 'missing', 'run `nearly` here to install it');
190
+ const pushText = (() => { try { return readFileSync(pushHook, 'utf8'); } catch { return null; } })();
191
+ // A pre-push hook of the person's own (husky, lefthook, a script) makes install
192
+ // refuse to overwrite it, so "run nearly" would send them round in a circle.
193
+ const hasPush = !!pushText && /x-session-record-hook|push-record/.test(pushText);
194
+ if (hasPush) say(true, 'pre-push hook', 'installed');
195
+ else if (pushText != null) say(false, 'pre-push hook', 'yours is there, without Nearly in it',
196
+ 'your own pre-push hook was left alone — add `nearly push-record "$(git rev-parse --show-toplevel)" || true` to it');
197
+ else say(false, 'pre-push hook', 'missing', 'run `nearly` here to install it');
177
198
 
178
199
  // 6 — gh, which is how a pull request is found and commented on. Its absence
179
200
  // used to be reported as "no pull request yet", which sent people off to raise
package/scripts/hook.mjs CHANGED
@@ -25,8 +25,9 @@
25
25
  import { spawn } from 'node:child_process';
26
26
  import { join, dirname } from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
- import { realpathSync } from 'node:fs';
28
+ import { realpathSync, readFileSync } from 'node:fs';
29
29
  import { byId } from '../server/adapters.mjs';
30
+ import { keep } from '../server/reclaim.mjs';
30
31
 
31
32
  const HOST = '127.0.0.1';
32
33
  const PORT = Number(process.env.NEARLY_PORT || 47653);
@@ -41,6 +42,10 @@ if (!event) process.exit(0);
41
42
 
42
43
  // An unknown id is a typo in a config file, not a reason to wedge the agent.
43
44
  const adapter = flag ? byId(flag.slice('--adapter='.length)) : null;
45
+ // Nobody is at the keyboard. Set by attach --auto, carried per repo rather than
46
+ // as machine-wide state, because supervising one project and not another is the
47
+ // normal case.
48
+ const unattended = args.includes('--auto');
44
49
 
45
50
  const body = await new Promise((r) => {
46
51
  let s = '';
@@ -51,6 +56,7 @@ const body = await new Promise((r) => {
51
56
  });
52
57
 
53
58
  const realRoot = (() => { try { return realpathSync(root); } catch { return root; } })();
59
+ const VERSION = (() => { try { return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version; } catch { return null; } })();
54
60
 
55
61
  // Is the thing on this port *us*?
56
62
  //
@@ -63,8 +69,8 @@ async function up(ms = 400) {
63
69
  const r = await fetch(`${BASE}/health`, { signal: AbortSignal.timeout(ms) });
64
70
  if (!r.ok) return false;
65
71
  const h = await r.json().catch(() => ({}));
66
- if (h.root !== realRoot) return 'stale'; // no root at all means older than this check
67
- return true;
72
+ if (h.root === realRoot || keep(h.version, VERSION)) return true;
73
+ return 'stale'; // older, or from before servers said what they were
68
74
  } catch { return false; }
69
75
  }
70
76
 
@@ -88,7 +94,7 @@ if (health === 'stale') {
88
94
  // only people who ever get the fix are the ones who happen to re-run `nearly`
89
95
  // and read the message.
90
96
  const { reclaim } = await import('../server/reclaim.mjs');
91
- const { outcome } = await reclaim({ port: PORT, base: BASE, root: realRoot });
97
+ const { outcome } = await reclaim({ port: PORT, base: BASE, root: realRoot, version: VERSION });
92
98
  // 'busy' and 'stuck' both mean it is still there. Talking to an old server
93
99
  // still gates the call, which is better than not gating it.
94
100
  health = (outcome === 'stood-down' || outcome === 'ended' || outcome === 'free') ? false : true;
@@ -111,7 +117,8 @@ if (adapter && adapter.normalize) {
111
117
 
112
118
  try {
113
119
  const hold = adapter?.holdMs ? `&hold=${adapter.holdMs}` : '';
114
- const res = await fetch(`${BASE}/hooks/${event}?attach=${encodeURIComponent(name)}${hold}`, {
120
+ const auto = unattended ? '&auto=1' : '';
121
+ const res = await fetch(`${BASE}/hooks/${event}?attach=${encodeURIComponent(name)}${hold}${auto}`, {
115
122
  method: 'POST',
116
123
  headers: { 'content-type': 'application/json' },
117
124
  body: payload,
@@ -1,7 +1,7 @@
1
1
  // Install a git pre-push hook that builds the branch's session record and
2
2
  // offers to post it to the pull request.
3
3
  //
4
- // node scripts/install-push-hook.mjs <repo-path> [--remove]
4
+ // node scripts/install-push-hook.mjs <repo-path> [--cmd "<how to run nearly>"] [--remove]
5
5
  //
6
6
  // Pushing is the moment your work stops being yours and becomes someone else's
7
7
  // to review, so it is the right moment to hand over the record. The hook:
@@ -22,7 +22,14 @@ import { fileURLToPath } from 'node:url';
22
22
 
23
23
  const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
24
24
  const argv = process.argv.slice(2);
25
- const repo = resolve(argv.find((a) => !a.startsWith('--')) || '.');
25
+ const cmdAt = argv.indexOf('--cmd');
26
+ // How the hook runs Nearly. attach passes the same command it wrote into the
27
+ // agent hooks, so both survive the same things. The default — this file's own
28
+ // folder — is only right for a checkout: run through npx it was the npx cache,
29
+ // and once npm cleared that every push printed a stack trace while doctor went
30
+ // on reporting the hook as installed.
31
+ const RUN = cmdAt !== -1 && argv[cmdAt + 1] ? argv[cmdAt + 1] : `node ${JSON.stringify(join(root, 'bin', 'nearly.mjs'))}`;
32
+ const repo = resolve(argv.find((a, i) => !a.startsWith('--') && i !== cmdAt + 1) || '.');
26
33
  const remove = argv.includes('--remove');
27
34
 
28
35
  if (!existsSync(join(repo, '.git'))) {
@@ -35,25 +42,37 @@ const MARKER = 'x-session-record-hook';
35
42
  const hooksDir = join(repo, '.git', 'hooks');
36
43
  const hookPath = join(hooksDir, 'pre-push');
37
44
 
45
+ // Ours by the marker, or by the exact comment older versions wrote. Matching the
46
+ // word "nearly" anywhere claimed any hook that happened to mention it.
47
+ const ours = (text) => text.includes(MARKER) || /^# (control-room|nearly): hand the session record/m.test(text);
48
+
38
49
  if (remove) {
39
- if (existsSync(hookPath)) { rmSync(hookPath); console.log(`Removed ${hookPath}`); }
40
- else console.log('No pre-push hook to remove.');
50
+ if (!existsSync(hookPath)) { console.log('No pre-push hook to remove.'); process.exit(0); }
51
+ // Installing refused to overwrite someone else's hook; removing used to delete
52
+ // it anyway, and said "removed" while doing so.
53
+ if (!ours(readFileSync(hookPath, 'utf8'))) {
54
+ console.error(`${hookPath} was not written by Nearly, so it was left alone.`);
55
+ process.exit(1);
56
+ }
57
+ rmSync(hookPath);
58
+ console.log(`Removed ${hookPath}`);
41
59
  process.exit(0);
42
60
  }
43
61
 
44
62
  if (existsSync(hookPath)) {
45
63
  const existing = readFileSync(hookPath, 'utf8');
46
- // Recognise it by a marker that does not change when the product is renamed,
47
- // and still recognise hooks written before this file existed.
48
- const mine = existing.includes(MARKER) || /control-room|nearly/.test(existing);
64
+ const mine = ours(existing);
49
65
  if (!mine) {
50
- console.error(`${hookPath} already exists and was not written by the Nearly.`);
66
+ console.error(`${hookPath} already exists and was not written by Nearly.`);
51
67
  console.error('Refusing to overwrite it. Move it aside, or add this line to it yourself:');
52
- console.error(` node ${join(root, 'scripts', 'push-record.mjs')} "${repo}" || true`);
68
+ console.error(` ${RUN} push-record "${repo}" || true`);
53
69
  process.exit(1);
54
70
  }
55
71
  }
56
72
 
73
+ // Safe inside the double quotes of a sh script.
74
+ const shq = (s) => String(s).replace(/[\\"$`]/g, '\\$&');
75
+
57
76
  mkdirSync(hooksDir, { recursive: true });
58
77
  writeFileSync(hookPath, `#!/bin/sh
59
78
  # ${MARKER}
@@ -69,11 +88,10 @@ writeFileSync(hookPath, `#!/bin/sh
69
88
  # ENXIO, when no terminal is attached. The open has to happen inside a subshell
70
89
  # too: a failed redirection is reported by the shell itself, so redirecting the
71
90
  # command's stderr does not silence it, but redirecting the subshell's does.
72
- CR="${join(root, 'scripts', 'push-record.mjs')}"
73
91
  if (: >/dev/tty) 2>/dev/null; then
74
- node "$CR" "${repo}" </dev/tty >/dev/tty 2>&1 || true
92
+ ${RUN} push-record "${shq(repo)}" </dev/tty >/dev/tty 2>&1 || true
75
93
  else
76
- NEARLY_NO_TTY=1 node "$CR" "${repo}" || true
94
+ NEARLY_NO_TTY=1 ${RUN} push-record "${shq(repo)}" || true
77
95
  fi
78
96
  exit 0
79
97
  `);
@@ -59,7 +59,7 @@ lines.push('');
59
59
  if (outcome?.notDone?.length) {
60
60
  lines.push(`> **${outcome.notDone.length} thing${outcome.notDone.length > 1 ? 's' : ''} the agent wanted to do did not happen.** The diff cannot show you this.`);
61
61
  lines.push('>');
62
- for (const n of outcome.notDone) lines.push(`> - \`${n.tool}\` · \`${n.what}\` — ${n.by === 'policy' ? 'blocked by policy' : 'refused by the supervisor'}`);
62
+ for (const n of outcome.notDone) lines.push(`> - \`${n.tool}\` · \`${n.what}\` — ${n.by === 'policy' ? 'blocked by policy' : n.by === 'timeout' ? 'nobody answered, so it was refused' : 'refused by the supervisor'}`);
63
63
  lines.push('');
64
64
  }
65
65
  lines.push('| ' + cover.stats.map(([k]) => k).join(' | ') + ' |');
@@ -30,9 +30,11 @@ const URL_BASE = (process.env.NEARLY_URL_BASE || configured() || '').replace(/\/
30
30
  // wait at a push. Opt in with NEARLY_AUDIO=1 when you are making the good one.
31
31
  const WANT_AUDIO = process.env.NEARLY_AUDIO === '1';
32
32
 
33
- const dim = (s) => `\x1b[2m${s}\x1b[0m`;
34
- const bold = (s) => `\x1b[1m${s}\x1b[0m`;
35
- const red = (s) => `\x1b[31m${s}\x1b[0m`;
33
+ // Colour only on a terminal; piped into a file or a CI log it is noise.
34
+ const COLOR = !!process.stdout.isTTY && !process.env.NO_COLOR;
35
+ const dim = (s) => (COLOR ? `\x1b[2m${s}\x1b[0m` : String(s));
36
+ const bold = (s) => (COLOR ? `\x1b[1m${s}\x1b[0m` : String(s));
37
+ const red = (s) => (COLOR ? `\x1b[31m${s}\x1b[0m` : String(s));
36
38
 
37
39
  function bail(msg) { if (msg) console.error(dim(`nearly: ${msg}`)); process.exit(0); }
38
40
 
@@ -76,7 +78,7 @@ if (notDone.length) {
76
78
  console.log('');
77
79
  console.log(` ${red('These never happened, and the diff will not show them:')}`);
78
80
  for (const n of notDone) {
79
- const by = n.by === 'policy' ? 'blocked by policy' : 'you refused it';
81
+ const by = n.by === 'policy' ? 'blocked by policy' : n.by === 'timeout' ? 'nobody answered' : 'you refused it';
80
82
  console.log(` · ${n.tool} ${n.what} ${dim(`(${by})`)}`);
81
83
  }
82
84
  }
@@ -91,7 +93,7 @@ console.log('');
91
93
  // hear about a fix.
92
94
  try {
93
95
  const { checkForUpdate, applyUpdate } = await import('./update-check.mjs');
94
- applyUpdate(await checkForUpdate());
96
+ applyUpdate(await checkForUpdate(), { background: true });
95
97
  } catch { /* never worth failing a push over */ }
96
98
 
97
99
  // Finding the pull request needs gh. Not having it and not having a pull
@@ -0,0 +1,84 @@
1
+ // Where Nearly installs itself so hooks have something stable to call.
2
+ //
3
+ // Hooks run on every tool call, so they need a command that is fast, will still
4
+ // exist tomorrow, and picks up upgrades. `npm install -g` was supposed to give
5
+ // that and failed in two common ways, both silently:
6
+ //
7
+ // · Windows: spawning npm.cmd without a shell is refused by every current
8
+ // Node release (the 2024 fix for CVE-2024-27980). So the install failed on
9
+ // every up-to-date Windows machine, every time.
10
+ // · macOS with Node from the official installer: the global prefix is
11
+ // /usr/local, owned by root, so the install fails without sudo.
12
+ //
13
+ // Either way attach fell back to `npx -y nearly-cli@<version>` in every hook —
14
+ // slower on each call, and pinned, so no fix ever reached that repo again. A
15
+ // Cursor user was stuck on the exact release that was breaking his editor.
16
+ //
17
+ // So install into a directory the user always owns. No root, no PATH, no
18
+ // global prefix to be wrong about.
19
+
20
+ import { existsSync, mkdirSync } from 'node:fs';
21
+ import { join, resolve } from 'node:path';
22
+ import { spawnSync } from 'node:child_process';
23
+ import { homedir } from 'node:os';
24
+
25
+ // Not paths.mjs: from a checkout that resolves to the checkout, and a runtime
26
+ // install must live in the user's own space whatever ran it.
27
+ export const RUNTIME = join(process.env.NEARLY_HOME || join(homedir(), '.nearly'), 'runtime');
28
+ export const ENTRY = join(RUNTIME, 'node_modules', 'nearly-cli', 'bin', 'nearly.mjs');
29
+
30
+ export const hasRuntime = () => existsSync(ENTRY);
31
+
32
+ export function isRuntime(root) {
33
+ const rel = resolve(root);
34
+ return rel === resolve(RUNTIME) || rel.startsWith(resolve(RUNTIME) + (process.platform === 'win32' ? '\\' : '/'));
35
+ }
36
+
37
+ // The command a hook runs. Quoted, because a home directory with a space in it
38
+ // is ordinary on Windows and would otherwise split into two arguments.
39
+ export const runtimeCommand = () => `node ${JSON.stringify(ENTRY)}`;
40
+
41
+ const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm';
42
+
43
+ // Everything that installs Nearly runs inside `npx nearly-cli`, and npx hands its
44
+ // own settings to its children as npm_config_* variables. A nested npm install
45
+ // inherits them and obeys them — on one machine `allow_scripts` turned a plain
46
+ // install into EALLOWSCRIPTS, and on another it will be whatever that person's
47
+ // npm config happens to hold. The identical command succeeds with them removed.
48
+ export function cleanEnv() {
49
+ return Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^npm_/i.test(k)));
50
+ }
51
+
52
+ // npm reports a failure as key/value lines (`code`, `syscall`, `path`, `errno`)
53
+ // followed by a sentence, and ends almost every one with "A complete log of this
54
+ // run can be found in". Showing the last line pointed people at a log file;
55
+ // showing the first two showed `code ENOENT — syscall open` and dropped the part
56
+ // that says which file. The error code plus npm's own sentence is the answer.
57
+ const KEYS = /^(code|syscall|path|errno|dest|spawnargs|signal|cmd|cwd|file|type|stack|A complete log)\b/i;
58
+ export function npmError(r) {
59
+ const lines = String(r.stderr || r.stdout || '').split('\n').map((l) => l.trim()).filter(Boolean);
60
+ const body = (l) => l.replace(/^npm (error|ERR!)\s*/i, '');
61
+ const errs = lines.filter((l) => /^npm (error|ERR!)/i.test(l)).map(body);
62
+ const code = errs.find((l) => /^code\b/i.test(l))?.replace(/^code\s+/i, '');
63
+ const sentence = errs.filter((l) => !KEYS.test(l)).map((l) => l.replace(/^[a-z]+ (?=[A-Z])/, ''))[0];
64
+ const said = [code, sentence].filter(Boolean);
65
+ // Drop the code when the sentence already starts with it.
66
+ if (said.length === 2 && said[1].startsWith(said[0])) said.shift();
67
+ return said.join(' — ') || lines.filter((l) => !/complete log of this run/i.test(l)).pop() || `npm exited ${r.status}`;
68
+ }
69
+
70
+ export function installRuntime(version) {
71
+ try { mkdirSync(RUNTIME, { recursive: true }); } catch (e) { return { ok: false, error: e.message }; }
72
+ // A tarball or path can stand in for the registry — how the tests install a
73
+ // build that has not been published yet.
74
+ const spec = process.env.NEARLY_INSTALL_SPEC || `nearly-cli@${version}`;
75
+ const r = spawnSync(NPM, ['install', '--prefix', RUNTIME, spec, '--no-save', '--no-fund', '--no-audit', '--loglevel=error'], {
76
+ encoding: 'utf8', timeout: 180_000,
77
+ // Windows will not spawn a .cmd any other way.
78
+ shell: process.platform === 'win32',
79
+ env: cleanEnv(),
80
+ });
81
+ if (r.error) return { ok: false, error: r.error.message };
82
+ if (r.status !== 0 || !hasRuntime()) return { ok: false, error: npmError(r) };
83
+ return { ok: true };
84
+ }
@@ -19,7 +19,8 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from
19
19
  import { join, dirname } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { homedir, tmpdir } from 'node:os';
22
- import { spawnSync, execFileSync } from 'node:child_process';
22
+ import { spawn, spawnSync, execFileSync } from 'node:child_process';
23
+ import { installRuntime, isRuntime, cleanEnv } from './runtime.mjs';
23
24
 
24
25
  const root = join(dirname(fileURLToPath(import.meta.url)), '..');
25
26
  const DAY = 24 * 60 * 60 * 1000;
@@ -27,8 +28,10 @@ const DAY = 24 * 60 * 60 * 1000;
27
28
  // npm is a .cmd shim on Windows and Node will not run one through spawn unless
28
29
  // it is named exactly. Without this, installing and upgrading both fail there.
29
30
  const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm';
30
- const dim = (s) => `\x1b[2m${s}\x1b[0m`;
31
- const bold = (s) => `\x1b[1m${s}\x1b[0m`;
31
+ // Colour only on a terminal; piped into a file or a CI log it is noise.
32
+ const COLOR = !!process.stdout.isTTY && !process.env.NO_COLOR;
33
+ const dim = (s) => (COLOR ? `\x1b[2m${s}\x1b[0m` : String(s));
34
+ const bold = (s) => (COLOR ? `\x1b[1m${s}\x1b[0m` : String(s));
32
35
 
33
36
  function stampPath() {
34
37
  const dir = process.env.XDG_CACHE_HOME || join(homedir() || tmpdir(), '.cache');
@@ -46,6 +49,9 @@ function compare(a, b) {
46
49
  // Updating in place only makes sense for a global install. An npx run is
47
50
  // ephemeral and a checkout belongs to whoever cloned it.
48
51
  function installKind() {
52
+ // Installed by attach into ~/.nearly/runtime. It upgrades itself in place, so
53
+ // every repo whose hooks point there moves with it.
54
+ if (isRuntime(root)) return 'runtime';
49
55
  if (/[\\/]_npx[\\/]/.test(root)) return 'npx';
50
56
  try {
51
57
  const bin = execFileSync(process.platform === 'win32' ? 'where' : 'which', ['nearly'],
@@ -84,7 +90,12 @@ export async function checkForUpdate() {
84
90
  } catch { return null; }
85
91
  }
86
92
 
87
- export function applyUpdate(u) {
93
+ // `background` is for the push path. An update installing in the foreground
94
+ // held `git push` for up to two minutes, and a push that hangs reads as a push
95
+ // that is broken — which is a strange thing for a tool that must never block a
96
+ // push to do. Commands a person typed and is watching still install in front
97
+ // of them, because there they want to know it happened.
98
+ export function applyUpdate(u, { background = false } = {}) {
88
99
  if (!u) return;
89
100
 
90
101
  if (u.major) {
@@ -95,6 +106,27 @@ export function applyUpdate(u) {
95
106
  return;
96
107
  }
97
108
 
109
+ if (u.kind === 'runtime') {
110
+ if (background) {
111
+ try {
112
+ const child = spawn(process.execPath, [join(root, 'scripts', 'update-check.mjs'), '--install-runtime', u.to],
113
+ { detached: true, stdio: 'ignore' });
114
+ child.on('error', () => { /* next time */ });
115
+ child.unref();
116
+ console.log('');
117
+ console.log(dim(` Nearly ${u.to} is out (you have ${u.from}) — updating in the background.`));
118
+ } catch { /* never worth failing a push over */ }
119
+ return;
120
+ }
121
+ process.stdout.write(dim(` Updating Nearly ${u.from} → ${u.to}… `));
122
+ const r = installRuntime(u.to);
123
+ console.log(r.ok ? 'done' : 'could not');
124
+ if (!r.ok) console.log(` ${r.error}`);
125
+ else console.log(dim(' Every repo you turned it on for is now on the new version.'));
126
+ console.log('');
127
+ return;
128
+ }
129
+
98
130
  if (u.kind !== 'global') {
99
131
  console.log('');
100
132
  console.log(` ${bold(`Nearly ${u.to} is out`)} ${dim(`(you have ${u.from})`)}`);
@@ -102,9 +134,24 @@ export function applyUpdate(u) {
102
134
  return;
103
135
  }
104
136
 
137
+ if (background) {
138
+ try {
139
+ // Detached and forgotten: the push goes ahead now and the new version is
140
+ // in place by the next command. A .cmd on Windows only spawns through a
141
+ // shell, which is the same trap npm itself fell into here once already.
142
+ const child = spawn(NPM, ['install', '-g', `${u.name}@${u.to}`, '--silent', '--no-fund', '--no-audit'],
143
+ { detached: true, stdio: 'ignore', shell: process.platform === 'win32', env: cleanEnv() });
144
+ child.on('error', () => { /* offline, or no permission: next time */ });
145
+ child.unref();
146
+ console.log('');
147
+ console.log(dim(` Nearly ${u.to} is out (you have ${u.from}) — updating in the background.`));
148
+ } catch { /* never worth failing a push over */ }
149
+ return;
150
+ }
151
+
105
152
  process.stdout.write(dim(` Updating Nearly ${u.from} → ${u.to}… `));
106
153
  const r = spawnSync(NPM, ['install', '-g', `${u.name}@${u.to}`, '--silent', '--no-fund', '--no-audit'],
107
- { encoding: 'utf8', timeout: 120_000 });
154
+ { encoding: 'utf8', timeout: 120_000, shell: process.platform === 'win32', env: cleanEnv() });
108
155
 
109
156
  if (r.status === 0) {
110
157
  console.log('done');
@@ -117,3 +164,9 @@ export function applyUpdate(u) {
117
164
  }
118
165
  console.log('');
119
166
  }
167
+
168
+ // `node update-check.mjs --install-runtime <version>` — what the background
169
+ // updater runs, detached, so a push never waits on npm.
170
+ if (process.argv[2] === '--install-runtime' && process.argv[3]) {
171
+ installRuntime(process.argv[3]);
172
+ }
@@ -126,10 +126,20 @@ function writeJson(file, obj) {
126
126
  writeFileSync(file, JSON.stringify(obj, null, 2) + '\n');
127
127
  }
128
128
 
129
- // Ours is anything that runs nearly. Matching on that rather than on a version
130
- // or a path is what makes attach safe to re-run, and what stopped a rename from
131
- // orphaning hooks the last time.
132
- const isOurs = (h) => /nearly/i.test(JSON.stringify(h ?? ''));
129
+ // Ours: a hook command that runs Nearly's hook, in any form attach has ever
130
+ // written `nearly hook <event>`, `node "…/nearly.mjs" hook <event>`,
131
+ // `npx -y nearly-cli@x hook <event>`, `node "…/scripts/hook.mjs" <event>`, or
132
+ // the old HTTP hooks. Matching the word "nearly" anywhere claimed any hook that
133
+ // mentioned it, and attach deleted a user's `nearly-finished-notifier/notify.sh`.
134
+ // The quote-and-backslash run tolerates the same command read out of raw JSON.
135
+ export const OURS_RE = /(?:^|[\s"'/\\])(?:nearly(?:\.mjs)?|nearly-cli@\S+?|hook\.mjs)[\\"']*\s+(?:hook\s+)?(?:session-start|prompt|pre-tool|post-tool|stop|session-end|subagent-stop)\b|:\d+\/hooks\/(?:session-start|prompt|pre-tool|post-tool|stop|session-end|subagent-stop)\b/;
136
+
137
+ export function isOurs(value) {
138
+ if (value == null) return false;
139
+ if (typeof value === 'string') return OURS_RE.test(value);
140
+ if (typeof value === 'object') return Object.values(value).some(isOurs);
141
+ return false;
142
+ }
133
143
 
134
144
  // Strip our entries out of an event map shaped { event: [entry, ...] }, and drop
135
145
  // events we emptied so the file does not fill with husks.
@@ -226,7 +236,13 @@ export const ADAPTERS = [
226
236
  cfg.hooks = stripEvents(cfg.hooks || {});
227
237
  for (const [their, ours] of Object.entries(this.events)) {
228
238
  cfg.hooks[their] = [...(cfg.hooks[their] || []),
229
- { command: cmdFor(ours), timeout: holdFor(ours), failClosed: ours === 'pre-tool' }];
239
+ // Fail open, as every other harness does. failClosed only ever
240
+ // applied when the gate did not answer — a slow npx, a server that
241
+ // had not started — and then it denied every write in the editor.
242
+ // A reported Cursor session could not edit a single file. The
243
+ // never-rules do not depend on this: the server enforces them whenever
244
+ // it is reachable.
245
+ { command: cmdFor(ours), timeout: holdFor(ours), failClosed: false }];
230
246
  }
231
247
  writeJson(file, cfg);
232
248
  return { file };
package/server/index.mjs CHANGED
@@ -427,7 +427,14 @@ const server = http.createServer(async (req, res) => {
427
427
  }
428
428
 
429
429
  if (ev === 'pre-tool') {
430
- const { tier, reason } = classifyWith(hook, rules);
430
+ // Unattended: nobody is going to answer, so holding a call for two
431
+ // minutes and then failing closed does not protect anything — it just
432
+ // stalls the run and teaches people to turn the gate off. The never-rules
433
+ // still bite, because those never needed a person. Everything that would
434
+ // have been asked is done and written down instead.
435
+ const unattended = url.searchParams.get('auto') === '1';
436
+ let { tier, reason } = classifyWith(hook, rules);
437
+ if (unattended && tier === 'ask') { tier = 'log'; reason = 'allowed unattended — nobody was asked'; }
431
438
  const id = hook.tool_use_id || randomUUID();
432
439
  // Policy keys on the canonical name so a rule means the same thing in every
433
440
  // harness; the record shows the harness's own name so it stays truthful
@@ -437,8 +444,11 @@ const server = http.createServer(async (req, res) => {
437
444
  hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: decision, permissionDecisionReason: `nearly: ${why}` },
438
445
  });
439
446
  if (!s) return respond('deny', 'unknown session');
440
- if (tier === 'never') { record(sid, { type: 'decision', id, decision: 'deny', why: reason, scope: 'policy', tool: shown, input: hook.tool_input, tier }); return respond('deny', `never (${reason})`); }
441
- if (tier === 'log') { record(sid, { type: 'decision', id, decision: 'allow', why: reason, scope: 'policy', tool: shown, input: hook.tool_input, tier }); return respond('allow', `do and log (${reason})`); }
447
+ if (tier === 'never') { record(sid, { type: 'decision', id, decision: 'deny', why: reason, scope: 'policy', tool: shown, input: hook.tool_input, tier, unattended }); return respond('deny', `never (${reason})`); }
448
+ if (tier === 'log') {
449
+ record(sid, { type: 'decision', id, decision: 'allow', why: reason, scope: unattended ? 'auto' : 'policy', tool: shown, input: hook.tool_input, tier });
450
+ return respond('allow', unattended ? reason : `do and log (${reason})`);
451
+ }
442
452
  // ask: hold the response until the UI decides, or fail closed
443
453
  // A harness may say it will not wait as long as we would. It can shorten
444
454
  // the deadline, never lengthen it: the point of the cap is that nobody
@@ -451,7 +461,7 @@ const server = http.createServer(async (req, res) => {
451
461
  if (already) { already.responders.push(respond); return; }
452
462
  const item = { id, sid, tool: shown, input: hook.tool_input, tier, reason, key: ruleKey(hook), at: Date.now(), holdMs, responders: [respond] };
453
463
  item.timer = setTimeout(() => decide(sid, id, 'deny',
454
- `no human answer in ${Math.round(holdMs / 1000)}s; nearly fails closed`), holdMs);
464
+ `no human answer in ${Math.round(holdMs / 1000)}s; nearly fails closed`, 'timeout'), holdMs);
455
465
  s.pending.set(id, item);
456
466
  s.state = 'waiting';
457
467
  record(sid, { type: 'ask', ...pendingView(item) });