@promptctl/cc-candybar 1.149.5 → 1.151.0
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/dist/cc-extra/PROVENANCE +2 -0
- package/dist/cc-extra/package.json +4 -0
- package/dist/cc-extra/src/attach-registrar.js +65 -0
- package/dist/cc-extra/src/boot-channel.js +63 -0
- package/dist/cc-extra/src/bun-attach.js +290 -0
- package/dist/cc-extra/src/bun-graph.js +191 -0
- package/dist/cc-extra/src/inspector-session.js +62 -0
- package/dist/cc-extra/src/launch.js +417 -0
- package/dist/cc-extra/src/seam-plan.js +160 -0
- package/dist/cc-extra/src/seams.js +163 -0
- package/dist/cc-extra/src/statusline-push.js +179 -0
- package/dist/cc-extra/src/ws-unix.js +125 -0
- package/dist/index.mjs +134 -125
- package/package.json +5 -5
- package/schema/cc-candybar.schema.json +51 -51
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// attach-registrar.js — the registrar a stock Bun session gets over its inspector, and the source text
|
|
2
|
+
// that installs it there.
|
|
3
|
+
//
|
|
4
|
+
// The hosted session builds its registrar in bun-host.mjs, in the same process as the graph it
|
|
5
|
+
// splices. An attached session is a different process — a stock binary this launcher can only reach by
|
|
6
|
+
// evaluating source in it — so the registrar crosses as TEXT: `installRegistrar` below, serialized
|
|
7
|
+
// with Function.prototype.toString, and statusline-push.js's own file, wrapped as a module. Nothing is
|
|
8
|
+
// re-spelled for the trip: the subscription logic that runs in the attached process is the same file
|
|
9
|
+
// the hosted one requires. [LAW:one-source-of-truth]
|
|
10
|
+
//
|
|
11
|
+
// Because it crosses as text, `installRegistrar` may use NOTHING from this module's scope: every
|
|
12
|
+
// value it needs is a parameter, and the suite evaluates the serialized form in an empty context to
|
|
13
|
+
// hold it to that.
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
// Runs INSIDE the attached process. `statusLinePush` is statusline-push.js's exports; `io` is the
|
|
21
|
+
// process's own fs and net, and `env` its environment.
|
|
22
|
+
function installRegistrar({ registrar, logPath }, statusLinePush, io, env, pid, uid) {
|
|
23
|
+
// [LAW:no-silent-failure] exception: this log is the only channel out of the user's session, and it
|
|
24
|
+
// is written from the push's socket handlers inside the app — a write it cannot make (a full disk)
|
|
25
|
+
// must cost the line, never throw into the app and take the session down with it.
|
|
26
|
+
const report = (line) => { try { io.fs.appendFileSync(logPath, `${new Date().toISOString()} ${pid} ${line}\n`); } catch {} };
|
|
27
|
+
const push = statusLinePush.createStatusLinePush({
|
|
28
|
+
connect: (socketPath) => io.net.createConnection(socketPath),
|
|
29
|
+
socketPath: statusLinePush.daemonSocketPath(env, uid),
|
|
30
|
+
report,
|
|
31
|
+
});
|
|
32
|
+
// The deps objects already wrapped. A breakpoint action runs on EVERY command run, and the wrap must
|
|
33
|
+
// happen once per controller: wrapping twice would run the command through two subscriptions.
|
|
34
|
+
const wrapped = new WeakSet();
|
|
35
|
+
globalThis[registrar] = {
|
|
36
|
+
// Called from the breakpoint on every command run, in the controller's frame. Answers what it did,
|
|
37
|
+
// so the launcher can log the first hit and any that fail.
|
|
38
|
+
attachStatusLine(controller, deps, emit) {
|
|
39
|
+
if (wrapped.has(deps)) return 'held';
|
|
40
|
+
wrapped.add(deps);
|
|
41
|
+
const run = deps.execute;
|
|
42
|
+
deps.execute = (payload, ...rest) => push.statusLine(controller, payload, emit, () => run.call(deps, payload, ...rest));
|
|
43
|
+
report('statusline-push attached to a status line controller');
|
|
44
|
+
return 'attached';
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
report('registrar installed');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// statusline-push.js as an expression that evaluates to its exports: a CommonJS module body run
|
|
51
|
+
// inside a function that hands it `module` and `exports`. It requires nothing, which is what lets it
|
|
52
|
+
// cross as text.
|
|
53
|
+
const moduleExpression = (source) => `(() => { const module = { exports: {} }; ((module, exports) => {\n${source}\n})(module, module.exports); return module.exports; })()`;
|
|
54
|
+
|
|
55
|
+
// The one expression the launcher evaluates in the attached process, at entry, before release. Strict
|
|
56
|
+
// from its first line, because this file's own 'use strict' does not cross as text: an assignment that
|
|
57
|
+
// does not take — `deps.execute` frozen in some release — throws and is reported as a failed hit, never
|
|
58
|
+
// logged as 'attached'.
|
|
59
|
+
function registrarSource({ registrar, logPath }) {
|
|
60
|
+
const statusLinePush = fs.readFileSync(path.join(__dirname, 'statusline-push.js'), 'utf8');
|
|
61
|
+
return `'use strict'; (${installRegistrar.toString()})(${JSON.stringify({ registrar, logPath })}, ${moduleExpression(statusLinePush)}, ` +
|
|
62
|
+
`{ fs: process.getBuiltinModule('node:fs'), net: process.getBuiltinModule('node:net') }, process.env, process.pid, process.getuid())`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { installRegistrar, registrarSource };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// boot-channel.js — the line protocol bun-host.mjs reports on and launch.js reads.
|
|
2
|
+
//
|
|
3
|
+
// The host says only what it can see and never decides; the launcher forms the verdict. The four
|
|
4
|
+
// things a host can observe are here, in one place, because a protocol with a writer and a reader
|
|
5
|
+
// that each carry their own idea of it has two ideas of it. [LAW:one-source-of-truth]
|
|
6
|
+
//
|
|
7
|
+
// started — control has passed to the hosted app. Before this nothing the user asked
|
|
8
|
+
// for has run, so a failure is always safe to replace; after it, the app owns
|
|
9
|
+
// whatever it did and re-running would repeat it. Reported rather than
|
|
10
|
+
// inferred from whether stdout happened to see a byte.
|
|
11
|
+
// painted — the hosted graph first wrote to the terminal. Not proof of a working
|
|
12
|
+
// session — a pre-flight notice counts — but a session that never paints at
|
|
13
|
+
// all is the hang the whole check exists to catch.
|
|
14
|
+
// absent-api <name> — a Bun member the graph asked for that the surface does not have. Sent as
|
|
15
|
+
// each is seen, not collected at the end, because the failure worth
|
|
16
|
+
// diagnosing is the one where this process never reaches an end.
|
|
17
|
+
// anything else — a named refusal, carried out as the reason.
|
|
18
|
+
//
|
|
19
|
+
// [LAW:no-silent-failure] every degrade leaves here, named.
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
// The descriptor the host writes on is chosen by the launcher and passed in this variable. It is
|
|
24
|
+
// part of the protocol, not of either end: a host reading one name and a launcher writing another
|
|
25
|
+
// leaves the host reporting onto an inherited stderr, where the verdicts corrupt the TUI's own
|
|
26
|
+
// rendering and the launcher sees a silent channel. [LAW:one-source-of-truth]
|
|
27
|
+
const BOOT_FD_ENV = 'CC_EXTRA_BOOT_FD';
|
|
28
|
+
|
|
29
|
+
const RECORD_SEPARATOR = '\n';
|
|
30
|
+
const STARTED = 'started';
|
|
31
|
+
const PAINTED = 'painted';
|
|
32
|
+
const ABSENT_API = 'absent-api ';
|
|
33
|
+
|
|
34
|
+
// A newline inside a message would arrive as several records, so a reason carrying one — an error
|
|
35
|
+
// with a multi-line message, say — would be read as a refusal followed by garbage.
|
|
36
|
+
const oneRecord = (line) => String(line).replace(/[\r\n]+/g, ' ');
|
|
37
|
+
|
|
38
|
+
// `write` is a parameter so this is testable with no descriptor, and so the host can decide where
|
|
39
|
+
// the channel points. [LAW:effects-at-boundaries]
|
|
40
|
+
function createBootChannel({ write }) {
|
|
41
|
+
let reported = false;
|
|
42
|
+
const absent = new Set();
|
|
43
|
+
const send = (line) => write(oneRecord(line) + RECORD_SEPARATOR);
|
|
44
|
+
return {
|
|
45
|
+
// The one-per-process VERDICT. Later observations still go out — a graph that painted and then
|
|
46
|
+
// threw has spent its verdict, and the message it died with is the useful part.
|
|
47
|
+
report(verdict) { if (reported) return; reported = true; send(verdict); },
|
|
48
|
+
send,
|
|
49
|
+
started() { send(STARTED); },
|
|
50
|
+
absentApi(name) { if (absent.has(name)) return; absent.add(name); send(ABSENT_API + name); },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The reader's half. Returns what this line means, so the launcher never re-derives the protocol.
|
|
55
|
+
function readReport(line) {
|
|
56
|
+
if (line === STARTED) return { kind: 'started' };
|
|
57
|
+
if (line === PAINTED) return { kind: 'painted' };
|
|
58
|
+
if (line.startsWith(ABSENT_API)) return { kind: 'absent-api', name: line.slice(ABSENT_API.length) };
|
|
59
|
+
if (line) return { kind: 'refusal', reason: line };
|
|
60
|
+
return { kind: 'blank' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { BOOT_FD_ENV, RECORD_SEPARATOR, STARTED, PAINTED, ABSENT_API, oneRecord, createBootChannel, readReport };
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// bun-attach.js — run the shipped claude binary under Bun, exactly as shipped, and apply every seam
|
|
3
|
+
// that has a breakpoint form through the inspector Bun opens for it.
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS EXISTS BESIDE launch.js. The hosted session (launch.js -> bun-host.mjs) splices the app's
|
|
6
|
+
// source as node links it, which reaches every seam but replaces Bun with a surface assembled here.
|
|
7
|
+
// This launcher keeps Bun and the binary untouched and reaches the app the one way a stock Bun process
|
|
8
|
+
// can be reached: JavaScriptCore's inspector, over a unix socket, with breakpoints that evaluate a
|
|
9
|
+
// seam's action in the frame they stop in and carry on without pausing. It reaches only the seams
|
|
10
|
+
// whose effect a breakpoint can produce (`attach` in seams.js); a gate that must RETURN something else,
|
|
11
|
+
// like propose-goal, needs the splice.
|
|
12
|
+
//
|
|
13
|
+
// WHY THE DEBUGGER ATTACHES AT ENTRY. JSC gives debug hooks only to code compiled while a debugger is
|
|
14
|
+
// attached, and Bun does not recompile on attach: a breakpoint set in a session that booted without
|
|
15
|
+
// one resolves and never fires (MEASURED on 2.1.299, three app functions called through breakpoints,
|
|
16
|
+
// zero pauses). So the binary is started held at entry (`?wait=1`), the debugger is enabled, the
|
|
17
|
+
// registrar is installed and the breakpoints are set — all before a line of the app has run — and
|
|
18
|
+
// only then is it released. The breakpoints are set BY URL before their chunk exists; JSC binds each
|
|
19
|
+
// one as its chunk parses (MEASURED: `breakpointResolved` at the predicted line and column).
|
|
20
|
+
// [LAW:no-ambient-temporal-coupling] the order is this sequence's, and two waits in it are on a clock,
|
|
21
|
+
// each with one owner: the socket's appearance, which has a deadline, and the question asked of the
|
|
22
|
+
// released process (WHY IT STAYS CONNECTED), which is asked once, at ASK_AFTER_MS.
|
|
23
|
+
//
|
|
24
|
+
// WHY A UNIX SOCKET. The inspector runs arbitrary code in the session. On TCP it is reachable by every
|
|
25
|
+
// process on the machine; a socket in a directory only this user can enter is reachable by this user.
|
|
26
|
+
//
|
|
27
|
+
// WHY IT STAYS CONNECTED. A breakpoint fires only while a client is connected: disconnect, and the
|
|
28
|
+
// status line's breakpoint stopped firing (MEASURED on 2.1.299: 2 hits before, still 2 after six status
|
|
29
|
+
// line reruns; connected, 2 became 8). What outlives a disconnect is only the wrapper an earlier hit
|
|
30
|
+
// installed, on the one controller that hit reached — a controller the app builds later would never be
|
|
31
|
+
// wrapped (MEASURED: a live session wrapped a second controller 18s in). So for a TUI the launcher stays
|
|
32
|
+
// connected for the life of the session, and a connected debugger does not keep it from exiting
|
|
33
|
+
// (MEASURED: `/exit` ended it, exit 0). The run it lets go is a one-shot: the inspector holds a process
|
|
34
|
+
// that ends by draining its loop (`--version`) for as long as it is connected, and that process gives
|
|
35
|
+
// no sign it is done (MEASURED: `beforeExit` never fires while it is held). Which of the two a run is,
|
|
36
|
+
// the process is ASKED, ASK_AFTER_MS after release: a TUI has stdin in raw mode by then.
|
|
37
|
+
//
|
|
38
|
+
// FAILURE. Every step before release happens while the binary is held at entry, where nothing the user
|
|
39
|
+
// asked for has run, so a failure there is answered by killing the held process and exiting non-zero,
|
|
40
|
+
// with one line on stderr saying why — before any TUI exists to draw over it. Never with a stock
|
|
41
|
+
// session: one would look like the attached session that failed, so whoever asked for the attach could
|
|
42
|
+
// not tell it never happened. [LAW:no-silent-failure] After release
|
|
43
|
+
// the session is the user's: nothing this launcher sees is a reason to end it, and what goes wrong is
|
|
44
|
+
// written to the log and named on stderr once the session is over. [LAW:no-silent-failure]
|
|
45
|
+
|
|
46
|
+
'use strict';
|
|
47
|
+
|
|
48
|
+
const childProcess = require('child_process');
|
|
49
|
+
const fs = require('fs');
|
|
50
|
+
const os = require('os');
|
|
51
|
+
const path = require('path');
|
|
52
|
+
const { readGraphFromFile } = require('./bun-graph.js');
|
|
53
|
+
const { SEAMS, REGISTRAR } = require('./seams.js');
|
|
54
|
+
const { resolveBreakpoints } = require('./seam-plan.js');
|
|
55
|
+
const { registrarSource } = require('./attach-registrar.js');
|
|
56
|
+
const { connect } = require('./ws-unix.js');
|
|
57
|
+
const { createInspectorSession } = require('./inspector-session.js');
|
|
58
|
+
const { superviseSignals, hasControllingTerminal, attachedToTerminal, rendersTui, exitStatus } = require('./launch.js');
|
|
59
|
+
|
|
60
|
+
// Measured ~20ms from spawn to the socket on 2.1.299. Generous, because missing it costs the
|
|
61
|
+
// session and waiting costs only a slower start.
|
|
62
|
+
const SOCKET_DEADLINE_MS = 5000;
|
|
63
|
+
|
|
64
|
+
// Whether a TUI is up. A TUI puts stdin in raw mode as it mounts — a trust dialog before the prompt
|
|
65
|
+
// included — and a one-shot never does (MEASURED on 2.1.299: raw 127-131ms after release, every run;
|
|
66
|
+
// `--version` not raw, exiting as soon as it was let go — the asking does not hold it).
|
|
67
|
+
const TUI_IS_UP = 'process.stdin.isTTY === true && process.stdin.isRaw === true';
|
|
68
|
+
|
|
69
|
+
// When after release the process is asked TUI_IS_UP: about fifteen times the measured time to raw
|
|
70
|
+
// mode, and the wait an interactive one-shot like `--version` pays before it is let go. A TUI that
|
|
71
|
+
// mounts slower than this is taken for a one-shot and runs without the push; the log says which.
|
|
72
|
+
const ASK_AFTER_MS = 2000;
|
|
73
|
+
const SOCKET_NAME = 'inspector.sock';
|
|
74
|
+
|
|
75
|
+
// Per user, never the shared temp dir: on Linux that is /tmp, where another user could plant the log
|
|
76
|
+
// name first, as a file to read the session's lines from or a link to append them through.
|
|
77
|
+
const LOG_DIR = path.join(process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state'), 'cc-extra');
|
|
78
|
+
const LOG_PATH = path.join(LOG_DIR, 'attach.log');
|
|
79
|
+
|
|
80
|
+
// What the binary is started with: its inspector on the socket, holding at entry until released.
|
|
81
|
+
const inspectedEnv = (env, socketPath) => ({ ...env, BUN_INSPECT: `ws+unix://${socketPath}?wait=1` });
|
|
82
|
+
|
|
83
|
+
// The inspector calls that make a held process an attached one, in the order they must run: the
|
|
84
|
+
// debugger is enabled before any app code compiles, the registrar exists before any breakpoint can
|
|
85
|
+
// call it, and release comes last. BUN_INSPECT is taken out of the process's environment before a
|
|
86
|
+
// line of the app runs: every Bun process the session starts — the Bash tool's `bun`, a hook, a nested
|
|
87
|
+
// claude — would inherit it and fail to start its own inspector on the socket this one holds
|
|
88
|
+
// (MEASURED: `Failed to start inspector`, exit 1; deleted at entry, the same child ran clean). Each breakpoint's action is a PROBE, which evaluates it as an
|
|
89
|
+
// evaluate action would and also sends the result back, so the launcher sees each hit's answer —
|
|
90
|
+
// including a throw, which an evaluate action would swallow. [LAW:no-silent-failure]
|
|
91
|
+
// [LAW:effects-at-boundaries] a description, so the suite checks the order with no process.
|
|
92
|
+
function attachCalls(points, registrarExpression) {
|
|
93
|
+
return [
|
|
94
|
+
['Debugger.enable', {}],
|
|
95
|
+
// JSC's debugger starts with breakpoints deactivated; its own frontend activates them on connect.
|
|
96
|
+
['Debugger.setBreakpointsActive', { active: true }],
|
|
97
|
+
['Runtime.evaluate', { expression: 'delete process.env.BUN_INSPECT', returnByValue: true }],
|
|
98
|
+
['Runtime.evaluate', { expression: registrarExpression, returnByValue: true }],
|
|
99
|
+
...points.map((p, i) => ['Debugger.setBreakpointByUrl', {
|
|
100
|
+
url: p.url, lineNumber: p.lineNumber, columnNumber: p.columnNumber,
|
|
101
|
+
options: { autoContinue: true, actions: [{ id: i + 1, type: 'probe', data: p.action }] },
|
|
102
|
+
}]),
|
|
103
|
+
['Inspector.initialized', {}],
|
|
104
|
+
];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// What one probe sample means: the registrar's answer ('attached' the first time a controller is
|
|
108
|
+
// reached, 'held' after), or a failure naming what came back instead.
|
|
109
|
+
function readSample(payload) {
|
|
110
|
+
if (payload.type === 'string' && (payload.value === 'attached' || payload.value === 'held')) return { ok: true, value: payload.value };
|
|
111
|
+
return { ok: false, detail: payload.description || JSON.stringify(payload.value) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// How often the wait looks for the socket. A look is one stat; the socket appears ~20ms after spawn.
|
|
115
|
+
const SOCKET_LOOK_MS = 10;
|
|
116
|
+
|
|
117
|
+
// Resolves once `name` exists in `dir`, rejects at the deadline or when `exited` settles first. It
|
|
118
|
+
// LOOKS on a clock rather than watching the directory: on macOS fs.watch does not report every bind
|
|
119
|
+
// of a unix socket (MEASURED on 2.1.289: 18 of 20 binds unseen, the socket there when the deadline
|
|
120
|
+
// fired), and a missed one cost the attach. [LAW:no-ambient-temporal-coupling] the clock has one
|
|
121
|
+
// owner, this wait, and one end, the deadline.
|
|
122
|
+
function socketAppears(dir, name, deadlineMs, exited) {
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
const done = (settle) => { clearTimeout(timer); clearInterval(looking); settle(); };
|
|
125
|
+
const check = () => { if (fs.existsSync(path.join(dir, name))) done(resolve); };
|
|
126
|
+
const timer = setTimeout(() => done(() => reject(new Error(`no inspector socket within ${deadlineMs}ms`))), deadlineMs);
|
|
127
|
+
const looking = setInterval(check, SOCKET_LOOK_MS);
|
|
128
|
+
exited.then((ending) => done(() => reject(new Error(`claude ${describeEnding(ending)} before opening its inspector`))));
|
|
129
|
+
check();
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const describeEnding = ({ code, signal, error }) => (error ? `could not start: ${error}` : `exited ${signal || code}`);
|
|
134
|
+
|
|
135
|
+
// What the released run is, asked of it `askAfterMs` after release: `{ runs: 'tui' }`, `{ runs:
|
|
136
|
+
// 'one-shot' }`, `{ runs: 'closed' }` when the inspector went away first (the run ended, or the
|
|
137
|
+
// connection did), or `{ runs: 'unanswered', why }` when the question itself failed. A close resolves
|
|
138
|
+
// before the rejection it causes reaches here, so a rejection that does is the question failing.
|
|
139
|
+
// `session` is anything with the session's `send` and `on`; `delay(ms, fn)` is a cancellable timer. A
|
|
140
|
+
// pure seam, so the suite drives it with neither an inspector nor a clock. [LAW:effects-at-boundaries]
|
|
141
|
+
function askWhatRuns(session, askAfterMs, delay) {
|
|
142
|
+
return new Promise((resolve) => {
|
|
143
|
+
const answered = (r) => (r.wasThrown
|
|
144
|
+
? { runs: 'unanswered', why: r.result.description ?? JSON.stringify(r.result.value) }
|
|
145
|
+
: { runs: r.result.value === true ? 'tui' : 'one-shot' });
|
|
146
|
+
const cancel = delay(askAfterMs, () => session.send('Runtime.evaluate', { expression: TUI_IS_UP, returnByValue: true })
|
|
147
|
+
.then((r) => resolve(answered(r)), (e) => resolve({ runs: 'unanswered', why: e.message })));
|
|
148
|
+
session.on('close', () => { cancel(); resolve({ runs: 'closed' }); });
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Run `binary` with the user's terminal and signals, resolving to how it ended: `{ code, signal }`, or
|
|
153
|
+
// `{ code: 70, signal: null, error }` when it could not be started at all. `interrupted()` says whether
|
|
154
|
+
// the user signalled an end to the session, as launch.js's verdict reads it.
|
|
155
|
+
function runChild(binary, args, env) {
|
|
156
|
+
const child = childProcess.spawn(binary, args, { stdio: 'inherit', env });
|
|
157
|
+
let interrupted = false;
|
|
158
|
+
const stopSupervising = superviseSignals(child, {
|
|
159
|
+
sharesTerminalGroup: hasControllingTerminal(),
|
|
160
|
+
onSignal: () => { interrupted = true; },
|
|
161
|
+
on: (signal, handler) => process.on(signal, handler),
|
|
162
|
+
off: (signal, handler) => process.off(signal, handler),
|
|
163
|
+
});
|
|
164
|
+
const exited = new Promise((resolve) => {
|
|
165
|
+
child.on('error', (e) => resolve({ code: 70, signal: null, error: e.message }));
|
|
166
|
+
child.on('exit', (code, signal) => resolve({ code, signal }));
|
|
167
|
+
}).then((ending) => { stopSupervising(); return ending; });
|
|
168
|
+
return { child, exited, interrupted: () => interrupted };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// An attach that cannot happen: said, and nothing started in its place.
|
|
172
|
+
function refuse(why, { stderr }) {
|
|
173
|
+
stderr(`cc-extra: not attached — ${why}; claude was not started (log: ${LOG_PATH})`);
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function runStock(binary, args, why, { stderr }) {
|
|
178
|
+
stderr(`cc-extra: not attaching — ${why}; running stock claude`);
|
|
179
|
+
const { exited } = runChild(binary, args, process.env);
|
|
180
|
+
const ending = await exited;
|
|
181
|
+
if (ending.error) stderr(`cc-extra: claude ${describeEnding(ending)}`);
|
|
182
|
+
return exitStatus(ending.code, ending.signal);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function attachAndRun(binary, args, io = {}) {
|
|
186
|
+
const stderr = io.stderr || ((line) => process.stderr.write(line + '\n'));
|
|
187
|
+
// [LAW:no-silent-failure] a line the log cannot take is counted and named when the session ends, and
|
|
188
|
+
// never changes what the launcher does: thrown, it could leave a held binary held.
|
|
189
|
+
let unlogged = 0;
|
|
190
|
+
const log = (line) => {
|
|
191
|
+
try {
|
|
192
|
+
fs.mkdirSync(LOG_DIR, { recursive: true, mode: 0o700 });
|
|
193
|
+
fs.appendFileSync(LOG_PATH, `${new Date().toISOString()} ${process.pid} launcher: ${line}\n`);
|
|
194
|
+
} catch { unlogged++; }
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// Only a TUI has a status line to drive. A headless, piped or print-mode run (`-p`, at a terminal or
|
|
198
|
+
// not) is a one-shot that would pay the wait below for nothing and wants to stay exactly as fast as
|
|
199
|
+
// it shipped. [LAW:one-source-of-truth] whether a run renders a TUI is launch.js's one parse of it; an
|
|
200
|
+
// interactive `--version` still reads as a TUI there, and is let go at ASK_AFTER_MS.
|
|
201
|
+
if (!rendersTui(args, io.terminal ?? attachedToTerminal())) return runStock(binary, args, 'not a TUI: no status line to drive', { stderr });
|
|
202
|
+
|
|
203
|
+
const graph = readGraphFromFile(binary);
|
|
204
|
+
const plan = graph.ok ? resolveBreakpoints(new Map(graph.modules.map((m) => [m.name, m])), SEAMS) : graph;
|
|
205
|
+
if (!plan.ok) return refuse(`${plan.reason}${plan.seam ? ` (seam ${plan.seam})` : ''}${plan.detail ? `: ${plan.detail}` : ''}`, { stderr });
|
|
206
|
+
|
|
207
|
+
// Before the spawn, so the log's directory exists for the registrar's first line.
|
|
208
|
+
log(`attaching ${binary}`);
|
|
209
|
+
// mkdtemp makes the directory 0700: only this user can reach the socket inside it.
|
|
210
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-extra-attach-'));
|
|
211
|
+
const socketPath = path.join(dir, SOCKET_NAME);
|
|
212
|
+
const calls = attachCalls(plan.points, registrarSource({ registrar: REGISTRAR, logPath: LOG_PATH }));
|
|
213
|
+
const release = calls.at(-1);
|
|
214
|
+
const { child, exited, interrupted } = runChild(binary, args, inspectedEnv(process.env, socketPath));
|
|
215
|
+
|
|
216
|
+
// What went wrong after release, said once the session is over rather than over the TUI. A set: a
|
|
217
|
+
// failure that repeats on every status line run is one trouble, not one per second of the session.
|
|
218
|
+
const troubles = new Set();
|
|
219
|
+
const bound = new Set();
|
|
220
|
+
let session;
|
|
221
|
+
try {
|
|
222
|
+
await socketAppears(dir, SOCKET_NAME, SOCKET_DEADLINE_MS, exited);
|
|
223
|
+
session = createInspectorSession(await connect(socketPath));
|
|
224
|
+
const seamOf = new Map(plan.points.map((p, i) => [i + 1, p.seam]));
|
|
225
|
+
session.on('Debugger.breakpointResolved', ({ breakpointId, location }) => {
|
|
226
|
+
bound.add(breakpointId);
|
|
227
|
+
log(`breakpoint ${breakpointId} bound at ${location.lineNumber}:${location.columnNumber}`);
|
|
228
|
+
});
|
|
229
|
+
session.on('Debugger.didSampleProbe', ({ sample }) => {
|
|
230
|
+
const read = readSample(sample.payload);
|
|
231
|
+
if (read.ok && read.value === 'held') return;
|
|
232
|
+
const line = read.ok ? `${seamOf.get(sample.probeId)}: ${read.value}` : `${seamOf.get(sample.probeId)} failed in the session: ${read.detail}`;
|
|
233
|
+
if (read.ok) { log(line); return; }
|
|
234
|
+
if (!troubles.has(line)) log(line);
|
|
235
|
+
troubles.add(line);
|
|
236
|
+
});
|
|
237
|
+
session.on('fault', (why) => { troubles.add(`the inspector session broke, so the seams stopped: ${why}`); });
|
|
238
|
+
session.on('close', (why) => { log(`inspector closed: ${why}`); });
|
|
239
|
+
for (const [method, params] of calls.slice(0, -1)) {
|
|
240
|
+
const result = await session.send(method, params);
|
|
241
|
+
if (result && result.wasThrown) throw new Error(`${method} threw: ${result.result.description ?? JSON.stringify(result.result.value)}`);
|
|
242
|
+
}
|
|
243
|
+
} catch (e) {
|
|
244
|
+
// Still held at entry: nothing the user asked for has run, so replacing it repeats nothing — unless
|
|
245
|
+
// the user is what ended it. A Ctrl+C while held kills the child and fails the attach with it, and
|
|
246
|
+
// answering that with a stock session starts the one the user just stopped.
|
|
247
|
+
if (session) session.close();
|
|
248
|
+
child.kill('SIGKILL');
|
|
249
|
+
log(`attach failed: ${e.message}`);
|
|
250
|
+
const ending = await exited;
|
|
251
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
252
|
+
if (interrupted()) return exitStatus(ending.code, ending.signal);
|
|
253
|
+
return refuse(`attach failed: ${e.message}`, { stderr });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Release. A failure here leaves the process's state unknown — it may already be running the user's
|
|
257
|
+
// command — so it is never answered with a second session; it is reported when this one ends.
|
|
258
|
+
await session.send(...release).catch((e) => troubles.add(`release failed: ${e.message}`));
|
|
259
|
+
log(`released ${binary} with ${plan.points.map((p) => p.seam).join(', ')}`);
|
|
260
|
+
|
|
261
|
+
// Only a run that answered 'tui' keeps the launcher connected for the session (see WHY IT STAYS
|
|
262
|
+
// CONNECTED). Anything else is let go, because staying is what holds a one-shot, and a question that
|
|
263
|
+
// could not be answered is said why.
|
|
264
|
+
const { runs, why } = await askWhatRuns(session, ASK_AFTER_MS, (ms, fn) => { const t = setTimeout(fn, ms); return () => clearTimeout(t); });
|
|
265
|
+
log(`after release: ${runs}${why ? ` (${why})` : ''}`);
|
|
266
|
+
if (runs !== 'tui') session.close();
|
|
267
|
+
if (runs === 'unanswered') troubles.add(`could not ask the session whether it is a TUI, so it ran without the push: ${why}`);
|
|
268
|
+
|
|
269
|
+
const { code, signal } = await exited;
|
|
270
|
+
// Our end of the connection is ours to close, rather than waiting on the peer's to.
|
|
271
|
+
session.close();
|
|
272
|
+
// A breakpoint set by a URL the release no longer parses its chunk under binds nowhere, and nothing
|
|
273
|
+
// else would say so: the push is simply absent.
|
|
274
|
+
if (runs === 'tui' && bound.size < plan.points.length) troubles.add(`${plan.points.length - bound.size} of ${plan.points.length} breakpoint(s) never bound in this session, so their seams never ran`);
|
|
275
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
276
|
+
if (unlogged) troubles.add(`${unlogged} line(s) could not be written to the log`);
|
|
277
|
+
for (const line of troubles) stderr(`cc-extra: ${line} (log: ${LOG_PATH})`);
|
|
278
|
+
return exitStatus(code, signal);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
module.exports = { SOCKET_DEADLINE_MS, ASK_AFTER_MS, TUI_IS_UP, LOG_PATH, inspectedEnv, attachCalls, readSample, socketAppears, askWhatRuns, attachAndRun };
|
|
282
|
+
|
|
283
|
+
if (require.main === module) (async function main() {
|
|
284
|
+
const [binary, ...args] = process.argv.slice(2);
|
|
285
|
+
if (!binary) {
|
|
286
|
+
process.stderr.write('usage: bun-attach.js <installed-claude-binary> [claude args...]\n');
|
|
287
|
+
process.exit(2);
|
|
288
|
+
}
|
|
289
|
+
process.exitCode = await attachAndRun(binary, args);
|
|
290
|
+
})();
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// bun-graph.js — read the module graph Bun embeds in a compiled standalone executable, in memory,
|
|
3
|
+
// from the INSTALLED binary. The host (bun-host.mjs) runs those modules under node; this file's only
|
|
4
|
+
// job is producing the graph, or proving it cannot.
|
|
5
|
+
//
|
|
6
|
+
// WHY THIS EXISTS AT ALL. Claude Code ships as a Bun-compiled standalone Mach-O with its JavaScript
|
|
7
|
+
// embedded. Keeping a copy of that JavaScript in the repo would be [LAW:one-source-of-truth] violated
|
|
8
|
+
// on a weekly release cadence: the copy is a second map of the installed binary, and it starts lying
|
|
9
|
+
// the next time the user updates. The installed binary is the single source; this file is a visibly
|
|
10
|
+
// derived read of it, and nothing is written to disk.
|
|
11
|
+
//
|
|
12
|
+
// WHY THE CONTAINER'S OWN TABLE AND NOT A SCAN. Bun writes a module table at the end of the file and
|
|
13
|
+
// points at it from a fixed struct just before the `\n---- Bun! ----\n` trailer. That table names
|
|
14
|
+
// every embedded module, gives its exact bounds, and names the ENTRY POINT BY INDEX — so nothing here
|
|
15
|
+
// has to know what the entry point is called, scan for delimiters, or disambiguate look-alike records.
|
|
16
|
+
// It also means the only thing this file is coupled to is BUN'S container format, which changes when
|
|
17
|
+
// Bun changes rather than when Claude ships. There is deliberately no version literal in this file and
|
|
18
|
+
// no {version: offset} table anywhere in the tree.
|
|
19
|
+
//
|
|
20
|
+
// MEASURED, on two shipped binaries a month apart, with one code path and no special cases:
|
|
21
|
+
// 2.1.226 (279,661,952 bytes) — 14 modules, entry id 0 = /$bunfs/root/src/entrypoints/cli.js,
|
|
22
|
+
// contents at 245,797,944, length 23,985,682. Those are exactly the bounds the previous
|
|
23
|
+
// delimiter-scanning extractor measured, and the slice `node --check` accepts; they are also
|
|
24
|
+
// byte-identical to what `Debugger.getScriptSource` returns from a LIVE session.
|
|
25
|
+
// 2.1.258 (199,027,600 bytes) — 1,818 modules, entry id 5 = /$bunfs/root/cli, an ESM graph of
|
|
26
|
+
// 1,640 js modules (the entry plus 1,639 code-split chunks) plus native, compressed and
|
|
27
|
+
// text assets.
|
|
28
|
+
// Those numbers are dated observations, not inputs: nothing below reads them.
|
|
29
|
+
//
|
|
30
|
+
// [LAW:effects-at-boundaries] the whole parser is pure (Buffer in, result out); reading the binary is
|
|
31
|
+
// a separate one-line edge. That is why the tests need no 199MB fixture and no mocks.
|
|
32
|
+
// [LAW:parse-dont-validate] the output is a PROVEN graph or a typed absence — never a half-parsed
|
|
33
|
+
// table that callers must re-check, and never a record wearing the shape of a good one.
|
|
34
|
+
|
|
35
|
+
'use strict';
|
|
36
|
+
|
|
37
|
+
const fs = require('fs');
|
|
38
|
+
|
|
39
|
+
// Bun writes this immediately after the offsets struct, at the end of the graph it appended to the
|
|
40
|
+
// executable. On macOS the code signature follows it, so the LAST occurrence is the real one.
|
|
41
|
+
const TRAILER = Buffer.from('\n---- Bun! ----\n', 'latin1');
|
|
42
|
+
|
|
43
|
+
// The struct between the module blob and the trailer. Only the four fields below are read; the
|
|
44
|
+
// remaining bytes are not named here, because naming a field whose meaning has not been established
|
|
45
|
+
// would be a map claiming to show territory nobody has walked.
|
|
46
|
+
const OFFSETS_BYTES = 32;
|
|
47
|
+
const OFFSETS = { byteCount: 0, modulesOffset: 8, modulesLength: 12, entryPointId: 16 };
|
|
48
|
+
|
|
49
|
+
// One module's row in that table. Every offset in a row is relative to the START of the blob.
|
|
50
|
+
const ROW_BYTES = 52;
|
|
51
|
+
const ROW = { nameOffset: 0, nameLength: 4, contentsOffset: 8, contentsLength: 12, encoding: 48, loader: 49 };
|
|
52
|
+
|
|
53
|
+
// How the contents are stored, and what they are. Both come from the container itself, so nothing
|
|
54
|
+
// downstream has to sniff bytes to decide how to decode a module or what to do with it.
|
|
55
|
+
const ENCODINGS = { 0: 'binary', 1: 'utf8', 2: 'utf16le' };
|
|
56
|
+
const LOADERS = { 1: 'js', 5: 'file', 10: 'napi', 13: 'text' };
|
|
57
|
+
|
|
58
|
+
// Embedded module paths are rooted at Bun's virtual filesystem. Used only to check that a decoded
|
|
59
|
+
// name IS a name — a row whose name is arbitrary bytes means the table was not where we thought.
|
|
60
|
+
const VIRTUAL_ROOT = '/$bunfs/';
|
|
61
|
+
|
|
62
|
+
// Every way this can fail to produce a graph, named. Callers switch on the reason to log WHY they
|
|
63
|
+
// fell back to stock claude, so these are part of the contract, not debug strings.
|
|
64
|
+
// [LAW:no-silent-failure] an unreadable container is reported, never guessed past.
|
|
65
|
+
const ABSENT = {
|
|
66
|
+
unreadable: 'binary-unreadable',
|
|
67
|
+
noTrailer: 'no-bun-module-graph-trailer',
|
|
68
|
+
trailerTooEarly: 'bun-trailer-has-no-offsets-struct-before-it',
|
|
69
|
+
blobTooLarge: 'module-blob-larger-than-the-file',
|
|
70
|
+
duplicateName: 'two-modules-share-one-name',
|
|
71
|
+
tableOutOfRange: 'module-table-outside-blob',
|
|
72
|
+
tableNotWholeRows: 'module-table-is-not-whole-rows',
|
|
73
|
+
empty: 'module-table-empty',
|
|
74
|
+
rowOutOfRange: 'module-contents-outside-blob',
|
|
75
|
+
badName: 'module-name-is-not-a-virtual-path',
|
|
76
|
+
unknownEncoding: 'module-has-unknown-encoding',
|
|
77
|
+
unknownLoader: 'module-has-unknown-loader',
|
|
78
|
+
entryOutOfRange: 'entry-point-id-outside-module-table',
|
|
79
|
+
entryNotJs: 'entry-point-is-not-a-javascript-module',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const absent = (reason, detail) => (detail === undefined ? { ok: false, reason } : { ok: false, reason, detail });
|
|
83
|
+
|
|
84
|
+
// Parse the graph out of a whole standalone executable.
|
|
85
|
+
// Returns { ok: true, modules, entryIndex, entryName } or { ok: false, reason }. Callers cannot
|
|
86
|
+
// receive a module list that has not passed every check below. [LAW:parse-dont-validate]
|
|
87
|
+
function readGraph(buf) {
|
|
88
|
+
const trailerAt = buf.lastIndexOf(TRAILER);
|
|
89
|
+
if (trailerAt === -1) return absent(ABSENT.noTrailer);
|
|
90
|
+
|
|
91
|
+
const offsets = trailerAt - OFFSETS_BYTES;
|
|
92
|
+
// A trailer with no room for its own struct in front of it is a trailer-shaped run of bytes, not
|
|
93
|
+
// a module graph. Saying so beats reporting that no trailer was found, which would be false.
|
|
94
|
+
if (offsets < 0) return absent(ABSENT.trailerTooEarly, `trailer at ${trailerAt}`);
|
|
95
|
+
const at = (field) => buf.readUInt32LE(offsets + field);
|
|
96
|
+
|
|
97
|
+
// byteCount is the one 64-bit field here. Reading only its low half would work on every binary
|
|
98
|
+
// measured so far and would silently produce a wrong blob start the first time it did not — a
|
|
99
|
+
// valid-looking graph built from the wrong bytes, which is the single outcome this file refuses.
|
|
100
|
+
const byteCount = buf.readBigUInt64LE(offsets + OFFSETS.byteCount);
|
|
101
|
+
// One check covers both ways this field can be wrong — a value too large for the file, and a value
|
|
102
|
+
// whose high half was never meant to be discarded — because they are the same statement: the blob
|
|
103
|
+
// must fit in front of the struct that describes it.
|
|
104
|
+
if (byteCount > BigInt(offsets)) return absent(ABSENT.blobTooLarge, `${byteCount} bytes claimed before offset ${offsets}`);
|
|
105
|
+
|
|
106
|
+
// The blob is byteCount bytes ending where the offsets struct begins; every pointer below is
|
|
107
|
+
// relative to its start.
|
|
108
|
+
const blob = offsets - Number(byteCount);
|
|
109
|
+
|
|
110
|
+
const tableAt = blob + at(OFFSETS.modulesOffset);
|
|
111
|
+
const tableLength = at(OFFSETS.modulesLength);
|
|
112
|
+
if (tableAt < blob || tableAt + tableLength > offsets) return absent(ABSENT.tableOutOfRange);
|
|
113
|
+
if (tableLength % ROW_BYTES !== 0) return absent(ABSENT.tableNotWholeRows, `${tableLength} is not a multiple of ${ROW_BYTES}`);
|
|
114
|
+
|
|
115
|
+
const count = tableLength / ROW_BYTES;
|
|
116
|
+
if (count === 0) return absent(ABSENT.empty);
|
|
117
|
+
|
|
118
|
+
const modules = [];
|
|
119
|
+
const seen = new Set();
|
|
120
|
+
for (let i = 0; i < count; i++) {
|
|
121
|
+
const row = tableAt + i * ROW_BYTES;
|
|
122
|
+
const field = (f) => buf.readUInt32LE(row + f);
|
|
123
|
+
const nameAt = blob + field(ROW.nameOffset), nameLength = field(ROW.nameLength);
|
|
124
|
+
const contentsAt = blob + field(ROW.contentsOffset), contentsLength = field(ROW.contentsLength);
|
|
125
|
+
if (nameAt < blob || nameAt + nameLength > offsets) return absent(ABSENT.rowOutOfRange, `row ${i} name`);
|
|
126
|
+
if (contentsAt < blob || contentsAt + contentsLength > offsets) return absent(ABSENT.rowOutOfRange, `row ${i} contents`);
|
|
127
|
+
|
|
128
|
+
const name = buf.toString('latin1', nameAt, nameAt + nameLength);
|
|
129
|
+
if (!name.startsWith(VIRTUAL_ROOT)) return absent(ABSENT.badName, `row ${i}: ${JSON.stringify(name.slice(0, 40))}`);
|
|
130
|
+
// Callers key these by name, so a duplicate does not collide here — it makes one of the two
|
|
131
|
+
// modules unreachable somewhere downstream, with nothing to show for it.
|
|
132
|
+
if (seen.has(name)) return absent(ABSENT.duplicateName, name);
|
|
133
|
+
seen.add(name);
|
|
134
|
+
|
|
135
|
+
// An unrecognised code means Bun's format moved under us. Decoding it as a neighbouring value
|
|
136
|
+
// would produce a module that looks fine and is wrong, which is the one outcome worth refusing.
|
|
137
|
+
const encoding = ENCODINGS[buf[row + ROW.encoding]];
|
|
138
|
+
const loader = LOADERS[buf[row + ROW.loader]];
|
|
139
|
+
if (encoding === undefined) return absent(ABSENT.unknownEncoding, `row ${i}: ${buf[row + ROW.encoding]}`);
|
|
140
|
+
if (loader === undefined) return absent(ABSENT.unknownLoader, `row ${i}: ${buf[row + ROW.loader]}`);
|
|
141
|
+
|
|
142
|
+
modules.push({
|
|
143
|
+
name, loader, encoding, offset: contentsAt, length: contentsLength,
|
|
144
|
+
bytes: () => buf.subarray(contentsAt, contentsAt + contentsLength),
|
|
145
|
+
// 'binary' contents have no text form; latin1 is the byte-preserving decode, so a caller that
|
|
146
|
+
// asks anyway gets its bytes back rather than replacement characters.
|
|
147
|
+
text: () => buf.toString(encoding === 'binary' ? 'latin1' : encoding, contentsAt, contentsAt + contentsLength),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const entryIndex = at(OFFSETS.entryPointId);
|
|
152
|
+
if (entryIndex >= modules.length) return absent(ABSENT.entryOutOfRange, `${entryIndex} of ${modules.length}`);
|
|
153
|
+
// An entry the host cannot evaluate is not an entry. Proving it here means no caller has to ask.
|
|
154
|
+
// [LAW:parse-dont-validate]
|
|
155
|
+
if (modules[entryIndex].loader !== 'js') return absent(ABSENT.entryNotJs, `${modules[entryIndex].name} is a ${modules[entryIndex].loader} module`);
|
|
156
|
+
|
|
157
|
+
return { ok: true, modules, entryIndex, entryName: modules[entryIndex].name };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The edge: the only line in this module that touches the world. [LAW:effects-at-boundaries]
|
|
161
|
+
function readGraphFromFile(binaryPath) {
|
|
162
|
+
let buf;
|
|
163
|
+
try {
|
|
164
|
+
buf = fs.readFileSync(binaryPath);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
return absent(ABSENT.unreadable, e && e.message);
|
|
167
|
+
}
|
|
168
|
+
return readGraph(buf);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = { ABSENT, ENCODINGS, LOADERS, ROW_BYTES, VIRTUAL_ROOT, readGraph, readGraphFromFile };
|
|
172
|
+
|
|
173
|
+
if (require.main === module) (function main() {
|
|
174
|
+
const [binaryPath] = process.argv.slice(2);
|
|
175
|
+
if (!binaryPath) {
|
|
176
|
+
process.stderr.write('usage: bun-graph.js <installed-claude-binary>\n');
|
|
177
|
+
process.exit(2);
|
|
178
|
+
}
|
|
179
|
+
const g = readGraphFromFile(binaryPath);
|
|
180
|
+
// A summary, never the sources: this CLI is the launcher's self-check and a human's diagnostic,
|
|
181
|
+
// and 32MB on stdout serves neither. The digest is what makes two runs comparable.
|
|
182
|
+
const out = g.ok
|
|
183
|
+
? {
|
|
184
|
+
ok: true, modules: g.modules.length, entryIndex: g.entryIndex, entryName: g.entryName,
|
|
185
|
+
byLoader: g.modules.reduce((acc, m) => ({ ...acc, [m.loader]: (acc[m.loader] || 0) + 1 }), {}),
|
|
186
|
+
entrySha256: require('crypto').createHash('sha256').update(g.modules[g.entryIndex].bytes()).digest('hex'),
|
|
187
|
+
}
|
|
188
|
+
: g;
|
|
189
|
+
process.stdout.write(JSON.stringify(out) + '\n');
|
|
190
|
+
process.exit(g.ok ? 0 : 1);
|
|
191
|
+
})();
|