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.
- package/README.md +64 -6
- package/bin/nearly.mjs +12 -2
- package/package.json +2 -2
- package/scripts/agents.mjs +10 -6
- package/scripts/attach.mjs +141 -35
- package/scripts/build-recap.mjs +50 -11
- package/scripts/detect.mjs +2 -1
- package/scripts/doctor.mjs +38 -17
- package/scripts/hook.mjs +12 -5
- package/scripts/install-push-hook.mjs +30 -12
- package/scripts/post-recap.mjs +1 -1
- package/scripts/push-record.mjs +7 -5
- package/scripts/runtime.mjs +84 -0
- package/scripts/update-check.mjs +58 -5
- package/server/adapters.mjs +21 -5
- package/server/index.mjs +14 -4
- package/server/policy.mjs +821 -17
- package/server/reclaim.mjs +22 -2
- package/ui/recap.template.html +1 -1
package/scripts/doctor.mjs
CHANGED
|
@@ -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
|
-
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
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
|
-
|
|
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) &&
|
|
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' &&
|
|
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
|
-
|
|
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
|
|
100
|
-
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
67
|
-
return
|
|
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
|
|
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
|
|
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)) {
|
|
40
|
-
else
|
|
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
|
-
|
|
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
|
|
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(`
|
|
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
|
-
|
|
92
|
+
${RUN} push-record "${shq(repo)}" </dev/tty >/dev/tty 2>&1 || true
|
|
75
93
|
else
|
|
76
|
-
NEARLY_NO_TTY=1
|
|
94
|
+
NEARLY_NO_TTY=1 ${RUN} push-record "${shq(repo)}" || true
|
|
77
95
|
fi
|
|
78
96
|
exit 0
|
|
79
97
|
`);
|
package/scripts/post-recap.mjs
CHANGED
|
@@ -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(' | ') + ' |');
|
package/scripts/push-record.mjs
CHANGED
|
@@ -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
|
-
|
|
34
|
-
const
|
|
35
|
-
const
|
|
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
|
+
}
|
package/scripts/update-check.mjs
CHANGED
|
@@ -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
|
-
|
|
31
|
-
const
|
|
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
|
-
|
|
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
|
+
}
|
package/server/adapters.mjs
CHANGED
|
@@ -126,10 +126,20 @@ function writeJson(file, obj) {
|
|
|
126
126
|
writeFileSync(file, JSON.stringify(obj, null, 2) + '\n');
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
// Ours
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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') {
|
|
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
|
|
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) });
|