nearly-cli 0.1.18 → 0.1.20
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 +13 -6
- package/package.json +2 -2
- package/scripts/attach.mjs +12 -5
- package/scripts/build-recap.mjs +28 -6
- package/scripts/posting.mjs +21 -0
- package/scripts/push-record.mjs +11 -17
- package/server/index.mjs +23 -0
- package/server/policy.mjs +18 -4
- package/ui/recap.template.html +1 -1
package/README.md
CHANGED
|
@@ -226,7 +226,7 @@ Nothing about how you work changes. Open the repo in VS Code or a terminal, star
|
|
|
226
226
|
1. **Reads run silently.** Anything that only looks at your code is allowed and logged.
|
|
227
227
|
2. **Anything that changes or reaches out is held.** It appears at http://127.0.0.1:47653 with the command, what it can affect, and a countdown. Answer with `A` or `D`, or shift for always and never. Nobody answering means denied after two minutes.
|
|
228
228
|
3. **The record builds itself** when the session ends.
|
|
229
|
-
4. **At `git push`** the hook merges every session on that branch, prints what was refused, and
|
|
229
|
+
4. **At `git push`** the hook merges every session on that branch, prints what was refused, and posts it to the branch's open pull request. When the agent opens the pull request itself, the record is posted right then.
|
|
230
230
|
5. **Your reviewer opens the pull request** and the record is there, as one comment that updates on every push rather than a new one each time.
|
|
231
231
|
|
|
232
232
|
### For a team
|
|
@@ -377,16 +377,23 @@ node scripts/install-push-hook.mjs ~/code/my-app
|
|
|
377
377
|
```
|
|
378
378
|
|
|
379
379
|
That installs a `pre-push` hook. On your next push it builds the branch record,
|
|
380
|
-
prints what it found including anything refused, and
|
|
381
|
-
pull request —
|
|
382
|
-
|
|
380
|
+
prints what it found including anything refused, and posts it to the branch's
|
|
381
|
+
open pull request — one comment, updated on every push after that.
|
|
382
|
+
|
|
383
|
+
It used to ask first, in the terminal. That never happened in practice: the push
|
|
384
|
+
that matters is the agent's own ("raise a PR"), an agent's push has no terminal,
|
|
385
|
+
and so nothing was ever posted and every reviewer saw an empty pull request. The
|
|
386
|
+
pull request is also usually opened after that push, so Nearly now posts as soon
|
|
387
|
+
as it sees the agent run `gh pr create` too.
|
|
383
388
|
|
|
384
389
|
Three rules it follows:
|
|
385
390
|
|
|
386
391
|
- **It never blocks a push.** No sessions on the branch, no server, a crash, a
|
|
387
392
|
timeout: it prints one dim line at most and exits 0.
|
|
388
|
-
- **
|
|
389
|
-
|
|
393
|
+
- **Posting is on unless you turn it off.** The record includes every prompt word
|
|
394
|
+
for word. `nearly --no-post` stops posting for a repo and `nearly --post` turns
|
|
395
|
+
it back on; `NEARLY_NO_POST=1` stops it everywhere. A merged or closed pull
|
|
396
|
+
request is never posted to.
|
|
390
397
|
- **It stays fast.** Narration is skipped by default, because a minute of `say`
|
|
391
398
|
at every push is not acceptable. Set `NEARLY_AUDIO=1` when you want the good one.
|
|
392
399
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nearly-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
4
4
|
"description": "A pull request tells you what changed. Nearly tells you what nearly happened: the commands a human refused, the pushes policy blocked, the turns rolled back.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,6 +34,6 @@
|
|
|
34
34
|
},
|
|
35
35
|
"homepage": "https://anujpatel06.github.io/nearly/",
|
|
36
36
|
"scripts": {
|
|
37
|
-
"test": "node --test --test-concurrency=1 test/policy.test.mjs test/server.test.mjs test/record.test.mjs test/resilience.test.mjs test/detect.test.mjs test/adapters.test.mjs test/spawn.test.mjs test/stale-server.test.mjs test/outside.test.mjs test/runtime.test.mjs"
|
|
37
|
+
"test": "node --test --test-concurrency=1 test/policy.test.mjs test/server.test.mjs test/record.test.mjs test/resilience.test.mjs test/detect.test.mjs test/adapters.test.mjs test/spawn.test.mjs test/stale-server.test.mjs test/outside.test.mjs test/posting.test.mjs test/runtime.test.mjs"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/scripts/attach.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// gated and recorded whether you start them in a terminal, in VS Code or in
|
|
11
11
|
// JetBrains. Claude Code always; Cursor, Antigravity, Copilot, Codex, Gemini
|
|
12
12
|
// and Windsurf when the repo shows signs of them, or on --agent=
|
|
13
|
-
// · a git pre-push hook, so the record is
|
|
13
|
+
// · a git pre-push hook, so the record is posted when the work leaves your
|
|
14
14
|
// machine
|
|
15
15
|
// · where the records are published, read from the Nearly's own remote
|
|
16
16
|
// · one hook in Claude Code's user settings, so a session opened in another
|
|
@@ -30,6 +30,7 @@ import { installRuntime, hasRuntime, isRuntime, runtimeCommand } from './runtime
|
|
|
30
30
|
import { ADAPTERS } from '../server/adapters.mjs';
|
|
31
31
|
import { installOutside, removeOutside, attachedRepos, userSettingsFile } from './outside.mjs';
|
|
32
32
|
import { prForBranch } from './pr-state.mjs';
|
|
33
|
+
import { postingOff, setPosting } from './posting.mjs';
|
|
33
34
|
|
|
34
35
|
const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
|
|
35
36
|
const HOOK = join(root, 'scripts', 'hook.mjs');
|
|
@@ -271,6 +272,10 @@ try {
|
|
|
271
272
|
// in its user settings covers the rest, and stays only while some repo still
|
|
272
273
|
// has Nearly on. Never from a pinned npx call: that would put a registry lookup
|
|
273
274
|
// in front of every tool call in every session on the machine.
|
|
275
|
+
if (!off && (argv.includes('--no-post') || argv.includes('--post'))) {
|
|
276
|
+
try { setPosting(repo, argv.includes('--post')); } catch (e) { notes.push(`could not save the posting choice: ${e.message}`); }
|
|
277
|
+
}
|
|
278
|
+
|
|
274
279
|
let reach = null;
|
|
275
280
|
const claudeWired = wired.some((a) => a.id === 'claude-code');
|
|
276
281
|
if (!off && claudeWired && !localOnly && (installed || runtime || !fromPackage)) {
|
|
@@ -405,12 +410,14 @@ if (push.status === 0) {
|
|
|
405
410
|
// request on a push. Said here, because the natural thing is to look at a pull
|
|
406
411
|
// request that already exists and wonder where the record is.
|
|
407
412
|
const pr = prForBranch(repo);
|
|
408
|
-
if (
|
|
409
|
-
console.log(` ${ok('·')} the record is
|
|
413
|
+
if (postingOff(repo)) {
|
|
414
|
+
console.log(` ${ok('·')} the record is built on push but not posted ${dim('— nearly --post puts it on the pull request')}`);
|
|
415
|
+
} else if (pr.state === 'open') {
|
|
416
|
+
console.log(` ${ok('·')} the record is posted to #${pr.number} on your next push ${dim('— sessions from now on; nearly --no-post stops it')}`);
|
|
410
417
|
} else if (pr.state === 'merged' || pr.state === 'closed') {
|
|
411
|
-
console.log(` ${ok('·')} the record is
|
|
418
|
+
console.log(` ${ok('·')} the record is posted on your next push ${dim(`— #${pr.number} for this branch is ${pr.state}, so open a new pull request first`)}`);
|
|
412
419
|
} else {
|
|
413
|
-
console.log(` ${ok('·')} the record is
|
|
420
|
+
console.log(` ${ok('·')} the record is posted to the pull request when it is opened, and updated on every push ${dim('— nearly --no-post stops it')}`);
|
|
414
421
|
}
|
|
415
422
|
} else {
|
|
416
423
|
// All of it: when a pre-push hook of yours is already there, the lines after
|
package/scripts/build-recap.mjs
CHANGED
|
@@ -160,6 +160,22 @@ function patch(cwd, range, maxLines = 48) {
|
|
|
160
160
|
const short = (s, n = 90) => { s = String(s ?? '').replace(/\s+/g, ' ').trim(); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
|
|
161
161
|
const secs = (ms) => (ms / 1000).toFixed(1);
|
|
162
162
|
const plural = (n, w, ws = w + 's') => `${n} ${n === 1 ? w : ws}`;
|
|
163
|
+
// "5221s" is a number nobody reads as an hour and a half.
|
|
164
|
+
const clock = (s) => {
|
|
165
|
+
s = Math.round(s);
|
|
166
|
+
if (s < 90) return `${s}s`;
|
|
167
|
+
const h = Math.floor(s / 3600), m = Math.round((s % 3600) / 60);
|
|
168
|
+
return h ? `${h}h ${String(m).padStart(2, '0')}m` : `${Math.round(s / 60)}m`;
|
|
169
|
+
};
|
|
170
|
+
const spoken = (s) => {
|
|
171
|
+
s = Math.round(s);
|
|
172
|
+
if (s < 90) return plural(s, 'second');
|
|
173
|
+
const h = Math.floor(s / 3600), m = Math.round((s % 3600) / 60);
|
|
174
|
+
return h ? `${plural(h, 'hour')}${m ? ` ${plural(m, 'minute')}` : ''}` : plural(Math.round(s / 60), 'minute');
|
|
175
|
+
};
|
|
176
|
+
// Tools that only look. Anything else allowed without asking changed something,
|
|
177
|
+
// and calling it read-only told a reviewer that edits were only reads.
|
|
178
|
+
const READS = new Set(['Read', 'Grep', 'Glob', 'LS', 'WebFetch', 'WebSearch', 'TodoWrite']);
|
|
163
179
|
|
|
164
180
|
function describeInput(tool, input = {}) {
|
|
165
181
|
if (tool === 'Bash') return input.command || '';
|
|
@@ -253,6 +269,9 @@ function buildStoryboard({ id, events, runs: sbRuns = 1 }) {
|
|
|
253
269
|
// itself the fact. Leaving it implied reads as "nothing needed approving"
|
|
254
270
|
// when what happened is that nobody was asked.
|
|
255
271
|
const unattended = decisions.some((d) => d.scope === 'auto');
|
|
272
|
+
// With nobody watching, "waiting on a human: 0.0s" and "asked Anuj: 0" are not
|
|
273
|
+
// facts about the work, just columns about a supervisor who was not there.
|
|
274
|
+
const nobodyThere = unattended && !humanDecisions.length && !humanWaitMs && !asks.length;
|
|
256
275
|
scenes.push({
|
|
257
276
|
kind: 'cover',
|
|
258
277
|
title: headlineBits.length ? headlineBits.join(', ') : 'A session with nothing to flag',
|
|
@@ -268,14 +287,14 @@ function buildStoryboard({ id, events, runs: sbRuns = 1 }) {
|
|
|
268
287
|
repo: worktree ? basename(worktree) : null,
|
|
269
288
|
branch: created?.branch || null,
|
|
270
289
|
stats: [
|
|
271
|
-
['Ran for',
|
|
272
|
-
[V('Waiting on a human', 'Waiting on you'), `${secs(humanWaitMs)}s`, 'ask'],
|
|
290
|
+
['Ran for', clock(durS), ''],
|
|
291
|
+
...(nobodyThere ? [] : [[V('Waiting on a human', 'Waiting on you'), `${secs(humanWaitMs)}s`, 'ask']]),
|
|
273
292
|
['Tool calls', String(toolUses.length), ''],
|
|
274
|
-
[V('Asked ' + AUTHOR, 'Asked you'), String(humanDecisions.length), ''],
|
|
293
|
+
...(nobodyThere ? [] : [[V('Asked ' + AUTHOR, 'Asked you'), String(humanDecisions.length), '']]),
|
|
275
294
|
['Refused', String(denied.length), denied.length ? 'deny' : ''],
|
|
276
295
|
['Rolled back', String(undos.length), undos.length ? 'undo' : ''],
|
|
277
296
|
],
|
|
278
|
-
narration: `${sbRuns > 1 ? `${plural(sbRuns, 'agent session')} on this branch, ${durS
|
|
297
|
+
narration: `${sbRuns > 1 ? `${plural(sbRuns, 'agent session')} on this branch, ${spoken(durS)} in total` : `Agent ${name} ran for ${spoken(durS)}`}${nobodyThere ? ', with nobody watching' : ` under ${supPoss} supervision`}. ${plural(toolUses.length, 'tool call')}, ${nobodyThere ? '' : `${humanDecisions.length} held for a decision, `}${denied.length} refused${undos.length ? `, ${plural(undos.length, 'turn')} rolled back` : ''}.`,
|
|
279
298
|
});
|
|
280
299
|
|
|
281
300
|
// 2. intent. One scene per thing that was asked for, in order, so a branch
|
|
@@ -290,10 +309,13 @@ function buildStoryboard({ id, events, runs: sbRuns = 1 }) {
|
|
|
290
309
|
const flushQuiet = () => {
|
|
291
310
|
if (!quiet.length) return;
|
|
292
311
|
const tools = [...new Set(quiet.map((q) => q.tool))];
|
|
312
|
+
const onlyReads = quiet.every((q) => READS.has(q.tool));
|
|
313
|
+
const byRule = quiet.every((q) => !q.auto);
|
|
293
314
|
scenes.push({
|
|
294
315
|
kind: 'quiet',
|
|
316
|
+
onlyReads,
|
|
295
317
|
items: quiet.map((q) => ({ tool: q.tool, sub: short(describeInput(q.tool, q.input), 80) })),
|
|
296
|
-
narration: `${plural(quiet.length, 'read-only step')} ran without asking: ${tools.join(', ')}. Logged, not gated
|
|
318
|
+
narration: `${plural(quiet.length, onlyReads ? 'read-only step' : 'step')} ran without asking: ${tools.join(', ')}. ${onlyReads || byRule ? 'Logged, not gated.' : 'Nobody was watching, so nobody was asked; every one is logged.'}`,
|
|
297
319
|
});
|
|
298
320
|
quiet = [];
|
|
299
321
|
};
|
|
@@ -318,7 +340,7 @@ function buildStoryboard({ id, events, runs: sbRuns = 1 }) {
|
|
|
318
340
|
const tool = e.tool;
|
|
319
341
|
const input = ask?.input ?? e.input ?? {};
|
|
320
342
|
const res = results.find((r) => r.id === e.id);
|
|
321
|
-
if (e.tier === 'log') { quiet.push({ tool, input }); continue; }
|
|
343
|
+
if (e.tier === 'log') { quiet.push({ tool, input, auto: e.scope === 'auto' }); continue; }
|
|
322
344
|
flushQuiet();
|
|
323
345
|
const human = e.waitedMs != null;
|
|
324
346
|
const w = human ? secs(e.waitedMs) : null;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Whether a repo's record is posted to its pull request on push. On unless it
|
|
2
|
+
// was turned off for that repo, or for everything with NEARLY_NO_POST=1.
|
|
3
|
+
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, realpathSync } from 'node:fs';
|
|
5
|
+
import { paths } from '../server/paths.mjs';
|
|
6
|
+
|
|
7
|
+
const key = (repo) => { try { return realpathSync.native(repo); } catch { return repo; } };
|
|
8
|
+
const read = () => { try { return existsSync(paths.config()) ? JSON.parse(readFileSync(paths.config(), 'utf8')) : {}; } catch { return {}; } };
|
|
9
|
+
|
|
10
|
+
export function postingOff(repo) {
|
|
11
|
+
if (process.env.NEARLY_NO_POST === '1') return true;
|
|
12
|
+
return (read().noPost || []).includes(key(repo));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function setPosting(repo, on) {
|
|
16
|
+
const cfg = read();
|
|
17
|
+
const list = new Set(cfg.noPost || []);
|
|
18
|
+
if (on) list.delete(key(repo)); else list.add(key(repo));
|
|
19
|
+
cfg.noPost = [...list];
|
|
20
|
+
writeFileSync(paths.config(), JSON.stringify(cfg, null, 2) + '\n');
|
|
21
|
+
}
|
package/scripts/push-record.mjs
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
// What the pre-push hook runs. Builds the branch's session record, shows what
|
|
2
|
-
// it found, and
|
|
2
|
+
// it found, and puts it on the branch's open pull request.
|
|
3
|
+
//
|
|
4
|
+
// It used to ask first, in the terminal. But the push that matters is usually
|
|
5
|
+
// made by the agent — "raise a PR" — and an agent's push has no terminal, so the
|
|
6
|
+
// question was never asked and nothing was ever posted. The reviewer saw an
|
|
7
|
+
// empty pull request on every branch Nearly had recorded. Now it posts, as one
|
|
8
|
+
// comment that each push updates; `nearly --no-post` stops it for a repo.
|
|
3
9
|
//
|
|
4
10
|
// node scripts/push-record.mjs <repo-path>
|
|
5
11
|
//
|
|
@@ -9,7 +15,6 @@ import { readFileSync, existsSync } from 'node:fs';
|
|
|
9
15
|
import { join, dirname, resolve, basename } from 'node:path';
|
|
10
16
|
import { fileURLToPath } from 'node:url';
|
|
11
17
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
12
|
-
import { createInterface } from 'node:readline';
|
|
13
18
|
import { paths } from '../server/paths.mjs';
|
|
14
19
|
|
|
15
20
|
const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
|
|
@@ -121,21 +126,10 @@ if (pr.state !== 'open') {
|
|
|
121
126
|
console.log('');
|
|
122
127
|
process.exit(0);
|
|
123
128
|
}
|
|
124
|
-
const prUrl = pr.url || null;
|
|
125
|
-
|
|
126
|
-
if (process.env.NEARLY_NO_TTY === '1' || !process.stdin.isTTY) {
|
|
127
|
-
console.log(dim(' No terminal to ask on, so nothing was posted.'));
|
|
128
|
-
console.log(dim(` Post it yourself: node scripts/post-recap.mjs ${slug}${URL_BASE ? ` --url-base ${URL_BASE}` : ''}`));
|
|
129
|
-
console.log('');
|
|
130
|
-
process.exit(0);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
134
|
-
const answer = await new Promise((r) => rl.question(` Post this record to ${prUrl || 'the pull request'}? [y/N] `, r))
|
|
135
|
-
.finally(() => rl.close());
|
|
136
129
|
|
|
137
|
-
|
|
138
|
-
|
|
130
|
+
const { postingOff } = await import('./posting.mjs');
|
|
131
|
+
if (postingOff(repo)) {
|
|
132
|
+
console.log(dim(` Not posted: posting is off for this repo. \`nearly --post\` turns it back on.`));
|
|
139
133
|
console.log('');
|
|
140
134
|
process.exit(0);
|
|
141
135
|
}
|
|
@@ -144,7 +138,7 @@ const postArgs = [join(root, 'scripts', 'post-recap.mjs'), slug];
|
|
|
144
138
|
if (URL_BASE) postArgs.push('--url-base', URL_BASE);
|
|
145
139
|
const post = spawnSync(process.execPath, postArgs, { cwd: root, encoding: 'utf8', timeout: 60_000 });
|
|
146
140
|
console.log(post.status === 0
|
|
147
|
-
? `
|
|
141
|
+
? ` Record ${(post.stdout || '').trim()} ${dim('(nearly --no-post stops this)')}`
|
|
148
142
|
: red(` Could not post: ${(post.stderr || post.stdout || '').trim().split('\n').pop()}`));
|
|
149
143
|
console.log('');
|
|
150
144
|
process.exit(0);
|
package/server/index.mjs
CHANGED
|
@@ -243,6 +243,28 @@ function modelFromTranscript(p) {
|
|
|
243
243
|
return null;
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
// "Raise a PR" pushes the branch first and opens the pull request second. The
|
|
247
|
+
// push found no pull request to post to, and nothing happens after the pull
|
|
248
|
+
// request exists — so on the agent's own branch, the one thing a reviewer opens
|
|
249
|
+
// never had a record on it. When an agent opens one, post straight away, from a
|
|
250
|
+
// process of its own so the agent is not kept waiting.
|
|
251
|
+
const OPENED = /\bgh\s+pr\s+create\b/;
|
|
252
|
+
function postWhenOpened(s, hook) {
|
|
253
|
+
const cmd = hook.tool_input?.command;
|
|
254
|
+
if (typeof cmd !== 'string' || !OPENED.test(cmd)) return;
|
|
255
|
+
const said = typeof hook.tool_response === 'string' ? hook.tool_response : JSON.stringify(hook.tool_response ?? '');
|
|
256
|
+
if (!/\/pull\/\d+/.test(said)) return; // it did not open one
|
|
257
|
+
try {
|
|
258
|
+
const child = spawn(process.execPath, [path.join(ROOT, 'scripts', 'push-record.mjs'), s.worktree], {
|
|
259
|
+
cwd: s.worktree, detached: true, stdio: 'ignore',
|
|
260
|
+
env: { ...process.env, NEARLY_NO_UPDATE: '1', NEARLY_NO_TTY: '1' },
|
|
261
|
+
});
|
|
262
|
+
child.on('error', () => { /* the next push posts it */ });
|
|
263
|
+
child.unref();
|
|
264
|
+
record(s.id, { type: 'hook', event: 'record-posting', detail: 'pull request opened' });
|
|
265
|
+
} catch { /* the next push posts it */ }
|
|
266
|
+
}
|
|
267
|
+
|
|
246
268
|
// The most recent thing the person typed, from a Claude Code transcript.
|
|
247
269
|
function promptFromTranscript(p) {
|
|
248
270
|
if (!p || !fs.existsSync(p)) return null;
|
|
@@ -529,6 +551,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
529
551
|
const seen = s && hook.tool_use_id && s.posted?.has(hook.tool_use_id);
|
|
530
552
|
if (s && hook.tool_use_id) { s.posted ||= new Set(); s.posted.add(hook.tool_use_id); if (s.posted.size > 500) s.posted.delete(s.posted.values().next().value); }
|
|
531
553
|
if (s && !seen) record(sid, { type: 'post_tool', id: hook.tool_use_id, tool: hook.tool_label || hook.tool_name, duration_ms: hook.duration_ms, response: trim(hook.tool_response ?? '') });
|
|
554
|
+
if (s && !seen && s.attached && s.worktree) postWhenOpened(s, hook);
|
|
532
555
|
return hookOk(res);
|
|
533
556
|
}
|
|
534
557
|
if (ev === 'stop') {
|
package/server/policy.mjs
CHANGED
|
@@ -270,7 +270,10 @@ function expand(raw, st) {
|
|
|
270
270
|
let s = String(raw);
|
|
271
271
|
if (s === '~' || s.startsWith('~/') || s.startsWith('~\\')) s = path.join(os.homedir(), s.slice(1));
|
|
272
272
|
else if (/^~[^/\\]/.test(s)) return { unknown: `another user's home directory (${raw})` };
|
|
273
|
-
|
|
273
|
+
// Variables set earlier on the same line count: `S=/tmp/scratch; rm -f $S/x`
|
|
274
|
+
// is a scratch file, and refusing it as "decided at run time" stopped real work.
|
|
275
|
+
// One set to something unknowable stays unknowable (null).
|
|
276
|
+
const vars = { HOME: os.homedir(), USERPROFILE: os.homedir(), TMPDIR: process.env.TMPDIR || os.tmpdir(), TMP: os.tmpdir(), TEMP: os.tmpdir(), PWD: st.cwd, ...(st.vars || {}) };
|
|
274
277
|
s = s.replace(/\$\(\s*pwd\s*\)/g, () => st.cwd ?? '\0');
|
|
275
278
|
s = s.replace(/\$\{?env:([A-Za-z_]+)\}?/gi, (_, k) => vars[k.toUpperCase()] ?? '\0');
|
|
276
279
|
s = s.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (_, k) => vars[k] ?? '\0');
|
|
@@ -742,7 +745,13 @@ function stageReason(stage, pipe, idx, st, depth) {
|
|
|
742
745
|
|
|
743
746
|
// What a command changes for the commands after it on the same line.
|
|
744
747
|
function applyState(stage, st) {
|
|
745
|
-
const { argv, prog } = unwrap(stage.words);
|
|
748
|
+
const { argv, prog, assigns } = unwrap(stage.words);
|
|
749
|
+
// `NAME=value` on its own sets a shell variable for the rest of the line. With
|
|
750
|
+
// a command after it, it only sets that command's environment, and the shell
|
|
751
|
+
// has already expanded the line by then — so it changes nothing here.
|
|
752
|
+
const remember = (k, v) => { const x = expand(v, st); (st.vars ||= {})[k] = x.path ?? null; };
|
|
753
|
+
if (!prog) { for (const [k, v] of Object.entries(assigns)) remember(k, v); return; }
|
|
754
|
+
if (prog === 'unset') { for (const a of argv.slice(1)) if (st.vars) delete st.vars[a]; return; }
|
|
746
755
|
if (['cd', 'pushd', 'set-location', 'sl', 'chdir'].includes(prog)) {
|
|
747
756
|
const target = argv.slice(1).find((a) => !a.startsWith('-'));
|
|
748
757
|
if (!target) { st.cwd = os.homedir(); return; }
|
|
@@ -753,7 +762,12 @@ function applyState(stage, st) {
|
|
|
753
762
|
}
|
|
754
763
|
if (prog === 'popd') { st.cwd = null; return; }
|
|
755
764
|
if (prog === 'export') {
|
|
756
|
-
for (const a of argv.slice(1)) {
|
|
765
|
+
for (const a of argv.slice(1)) {
|
|
766
|
+
const m = a.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
767
|
+
if (!m) continue;
|
|
768
|
+
if (m[1] === 'GIT_DIR') st.gitDir = m[2];
|
|
769
|
+
remember(m[1], m[2]);
|
|
770
|
+
}
|
|
757
771
|
return;
|
|
758
772
|
}
|
|
759
773
|
if (prog !== 'git') return;
|
|
@@ -795,7 +809,7 @@ function analyze(src, st, depth = 0) {
|
|
|
795
809
|
function stateFor(cwd) {
|
|
796
810
|
const dir = cwd ? (real(cwd) || cwd) : null;
|
|
797
811
|
const top = dir ? git(dir, ['rev-parse', '--show-toplevel']) : null;
|
|
798
|
-
return { cwd: dir, root: top ? (real(top) || top) : null, branch: null, gitDir: null, aliases: {} };
|
|
812
|
+
return { cwd: dir, root: top ? (real(top) || top) : null, branch: null, gitDir: null, aliases: {}, vars: {} };
|
|
799
813
|
}
|
|
800
814
|
|
|
801
815
|
// The reason a command must never run, or null. Exported for the tests, which
|
package/ui/recap.template.html
CHANGED
|
@@ -300,7 +300,7 @@ const render = {
|
|
|
300
300
|
quiet: (s) => `
|
|
301
301
|
<div class="eyebrow"><span class="lbl">Ran without asking</span>${T('auto', 'log tier')}</div>
|
|
302
302
|
<div class="rows">${s.items.map((i) => `<div class="row"><span class="t">${esc(i.tool)}</span><span class="s" title="${esc(i.sub)}">${esc(i.sub)}</span>${T('auto', 'receipt')}</div>`).join('')}</div>
|
|
303
|
-
<div class="sub" style="margin-top:auto"
|
|
303
|
+
<div class="sub" style="margin-top:auto">${s.onlyReads === false ? 'Allowed without anyone being asked. Every call is on the record.' : 'Read-only tools run on the log tier. Nobody was asked, but every call is on the record.'}</div>`,
|
|
304
304
|
|
|
305
305
|
decision: (s) => {
|
|
306
306
|
const d = s.tier === 'never' ? 'never' : s.decision;
|