start-command 0.36.0 → 0.37.1

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +6 -0
  3. package/package.json +1 -1
  4. package/src/bin/cli.js +5 -0
  5. package/src/lib/args-parser-queries.js +14 -0
  6. package/src/lib/args-parser.js +20 -1
  7. package/src/lib/command-builder.js +3 -0
  8. package/src/lib/cpu-penalty-monitor.js +209 -87
  9. package/src/lib/cpu-penalty.js +30 -1
  10. package/src/lib/docker-cleanup.js +12 -5
  11. package/src/lib/docker-command-handoff.js +75 -0
  12. package/src/lib/docker-labels.js +33 -0
  13. package/src/lib/docker-runtime-args.js +8 -0
  14. package/src/lib/docker-snapshot-safety.js +164 -0
  15. package/src/lib/execution-recovery.js +8 -1
  16. package/src/lib/execution-resume.js +184 -37
  17. package/src/lib/failure-handler.js +15 -5
  18. package/src/lib/isolation.js +20 -4
  19. package/src/lib/log-sanitizer.js +267 -0
  20. package/src/lib/log-uploader.js +38 -19
  21. package/src/lib/query-commands.js +4 -3
  22. package/src/lib/resume-resources.js +15 -3
  23. package/src/lib/shell-utils.js +67 -17
  24. package/src/lib/store-lock.js +54 -3
  25. package/src/lib/usage.js +5 -1
  26. package/test/ci-workflow-invariants.js +15 -14
  27. package/test/docker-command-handoff.js +107 -0
  28. package/test/docker-runtime-options.js +4 -0
  29. package/test/docker-snapshot-safety.js +106 -0
  30. package/test/execution-resume.js +10 -8
  31. package/test/failure-handler.js +11 -0
  32. package/test/javascript-first-ci.js +216 -0
  33. package/test/log-sanitizer.js +306 -0
  34. package/test/regression-176.js +7 -0
  35. package/test/regression-187.js +9 -1
  36. package/test/regression-191.js +9 -1
  37. package/test/regression-193.js +11 -1
  38. package/test/regression-198.js +281 -0
  39. package/test/regression-199.js +79 -0
  40. package/test/regression-201.js +25 -0
  41. package/test/regression-202.js +92 -0
  42. package/test/regression-207.js +249 -0
  43. package/test/regression-91.js +23 -20
  44. package/test/shared-golden.js +77 -0
  45. package/test/status-query.js +97 -15
@@ -0,0 +1,249 @@
1
+ const { it, expect } = require('bun:test');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+ const { initialState, transitionLog } = require('../src/lib/cpu-penalty');
7
+ const cpu = require('../src/lib/cpu-penalty-monitor');
8
+
9
+ it('restarted monitors retain an observed base absent from launch flags', () => {
10
+ const saved = { ...initialState(4, 0), phase: 'penalized', limitCpus: 2 };
11
+ expect(initialState(null, 100, saved).baseCpus).toBe(4);
12
+ });
13
+
14
+ it('saved observing limits preserve the base after a daemon-clamped lift', () => {
15
+ const saved = { ...initialState(4, 0), limitCpus: 1 };
16
+ const { observeBase } = require('../src/lib/cpu-penalty');
17
+ expect(observeBase(initialState(null, 100, saved), 1, 100).baseCpus).toBe(4);
18
+ });
19
+
20
+ for (const mode of [
21
+ 'nano',
22
+ 'quota',
23
+ 'restart',
24
+ 'zombie',
25
+ 'reused',
26
+ 'supervise',
27
+ 'publish-fault',
28
+ 'graceful',
29
+ 'override',
30
+ ]) {
31
+ it(`CPU monitor recovers external base and idle release: ${mode}`, () => {
32
+ if (
33
+ process.platform === 'win32' ||
34
+ (['zombie', 'reused'].includes(mode) && process.platform !== 'linux')
35
+ ) {
36
+ return;
37
+ }
38
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cpu-monitor-207-'));
39
+ try {
40
+ const logPath = path.join(dir, 'task.log');
41
+ fs.writeFileSync(logPath, '');
42
+ const record = {
43
+ logPath,
44
+ options: {
45
+ sessionName: 'cpu-task',
46
+ isolated: 'docker',
47
+ cpuPenaltyConfig: {
48
+ cpus: 2,
49
+ trigger: 95,
50
+ release: 65,
51
+ triggerWindowMs: 400,
52
+ releaseWindowMs: 400,
53
+ },
54
+ baseResourceLimits: [],
55
+ },
56
+ };
57
+ const command = [
58
+ process.execPath,
59
+ require.resolve('../src/lib/cpu-penalty-monitor'),
60
+ '',
61
+ 'cpu-task',
62
+ '1',
63
+ JSON.stringify(record),
64
+ ];
65
+ if (mode === 'supervise') {
66
+ const script = `${cpu.startSnippet(null, 'cpu-task', 1, { ...record.options, logPath })}; read done; ${cpu.stopSnippet()}`;
67
+ command.splice(0, command.length, 'sh', '-c', script);
68
+ }
69
+ const result = spawnSync(
70
+ 'python3',
71
+ [
72
+ path.resolve(__dirname, '../../experiments/issue-207-monitor.py'),
73
+ mode,
74
+ logPath,
75
+ ...command,
76
+ ],
77
+ { encoding: 'utf8' }
78
+ );
79
+ expect(result.status, result.stderr).toBe(0);
80
+ const state = cpu.readState(record);
81
+ const expected = mode === 'override' ? 3 : 4;
82
+ expect(state.baseCpus).toBe(expected);
83
+ expect(state.phase).toBe('observing');
84
+ expect(state.limitCpus).toBe(expected);
85
+ if (mode !== 'override') {
86
+ expect(fs.readFileSync(logPath, 'utf8')).toContain(
87
+ 'CPU penalty lifted: 4'
88
+ );
89
+ }
90
+ const { ExecutionRecord } = require('../src/lib/execution-store');
91
+ const {
92
+ enrichDetachedStatus,
93
+ formatRecord,
94
+ } = require('../src/lib/status-formatter');
95
+ const statusRecord = new ExecutionRecord({
96
+ ...record,
97
+ options: { ...record.options, isolated: undefined },
98
+ });
99
+ const status = JSON.parse(
100
+ formatRecord(enrichDetachedStatus(statusRecord), 'json')
101
+ );
102
+ expect(status.cpuPenalty.baseCpus).toBe(expected);
103
+ expect(status.cpuPenalty.limitCpus).toBe(expected);
104
+ } finally {
105
+ fs.rmSync(dir, { recursive: true, force: true });
106
+ }
107
+ }, 15000);
108
+ }
109
+
110
+ for (const failure of ['inspect', 'stats', 'update', 'publish']) {
111
+ it(`an exception in ${failure} resets coverage and monitoring continues`, async () => {
112
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cpu-tick-207-'));
113
+ const logPath = path.join(dir, 'task.log');
114
+ const record = {
115
+ logPath,
116
+ options: {
117
+ sessionName: 'cpu-task',
118
+ cpuPenaltyConfig: {
119
+ cpus: 2,
120
+ trigger: 95,
121
+ release: 65,
122
+ triggerWindowMs: 100,
123
+ releaseWindowMs: 100,
124
+ },
125
+ },
126
+ };
127
+ let ticks = 0;
128
+ let injected = false,
129
+ limit = 4,
130
+ lifted = false;
131
+ const filesystem = Object.create(fs);
132
+ filesystem.renameSync = (from, to) => {
133
+ if (
134
+ failure === 'publish' &&
135
+ !injected &&
136
+ JSON.parse(fs.readFileSync(from)).phase === 'penalized'
137
+ ) {
138
+ injected = true;
139
+ throw new Error('injected ENOSPC');
140
+ }
141
+ fs.renameSync(from, to);
142
+ };
143
+ try {
144
+ await cpu.monitor('', 'cpu-task', 1, JSON.stringify(record), {
145
+ fs: filesystem,
146
+ run: (_command, args) => {
147
+ if (args[0] === failure && !injected) {
148
+ injected = true;
149
+ throw new Error(`injected ${failure}`);
150
+ }
151
+ const result = (stdout) => ({ success: true, stdout });
152
+ if (args[0] === 'inspect') {
153
+ if (!args[2].includes('HostConfig') && ++ticks > 60) {
154
+ lifted = false;
155
+ return result('false');
156
+ }
157
+ return result(
158
+ args[2].includes('HostConfig')
159
+ ? JSON.stringify({ NanoCpus: limit * 1e9 })
160
+ : String(!lifted)
161
+ );
162
+ }
163
+ if (args[0] === 'info') {
164
+ return result('{"NCPU":6}');
165
+ }
166
+ if (args[0] === 'stats') {
167
+ return result(
168
+ JSON.stringify({
169
+ Name: 'cpu-task',
170
+ CPUPerc: limit === 2 ? '20%' : '400%',
171
+ })
172
+ );
173
+ }
174
+ if (args[0] === 'update') {
175
+ limit = Number(args[2]);
176
+ lifted = limit === 4;
177
+ return result('');
178
+ }
179
+ throw new Error('unexpected command');
180
+ },
181
+ });
182
+ expect(injected).toBe(true);
183
+ expect(lifted).toBe(true);
184
+ expect(cpu.readState(record).baseCpus).toBe(4);
185
+ } finally {
186
+ fs.rmSync(dir, { recursive: true, force: true });
187
+ }
188
+ }, 5000);
189
+ }
190
+
191
+ it('stopped resume uses the observed base rather than the temporary quota', () => {
192
+ const { restoreBaseCpu } = require('../src/lib/resume-resources');
193
+ expect(
194
+ restoreBaseCpu(
195
+ ['--memory=512m', '--cpus=2'],
196
+ { cpuPenaltyConfig: {}, baseResourceLimits: [] },
197
+ { baseCpus: 4 }
198
+ )
199
+ ).toEqual(['--memory=512m', '--cpus=4']);
200
+ });
201
+
202
+ it('stopped resume preserves a later supervisor override instead of the stale cap', () => {
203
+ const { restoreBaseCpu } = require('../src/lib/resume-resources');
204
+ const options = { cpuPenaltyConfig: {}, baseResourceLimits: [] };
205
+ const saved = { phase: 'penalized', baseCpus: 4, limitCpus: 2 };
206
+ expect(restoreBaseCpu(['--cpus=2'], options, saved, true)).toEqual([
207
+ '--cpus=4',
208
+ ]);
209
+ expect(restoreBaseCpu(['--cpus=3'], options, saved, true)).toEqual([
210
+ '--cpus=3',
211
+ ]);
212
+ });
213
+
214
+ it('transition logs explain the window and phase denominator', () => {
215
+ const config = { cpus: 2, triggerWindowMs: 900000, releaseWindowMs: 400 };
216
+ expect(
217
+ transitionLog({ kind: 'apply', cpus: 2, capacity: 4, average: 100 }, config)
218
+ ).toContain('of 4 CPUs for 15m');
219
+ expect(
220
+ transitionLog({ kind: 'lift', cpus: 4, capacity: 4, average: 10 }, config)
221
+ ).toContain('of 2 CPUs for 400ms');
222
+ });
223
+
224
+ it('process birth identity reclaims reused PIDs without evicting a live lock', () => {
225
+ if (process.platform !== 'linux') {
226
+ return;
227
+ }
228
+ const { LockManager } = require('../src/lib/store-lock');
229
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cpu-lock-207-'));
230
+ const file = path.join(dir, 'lock');
231
+ const first = new LockManager(file);
232
+ const replacement = new LockManager(file);
233
+ try {
234
+ expect(first.acquire(100)).toBe(true);
235
+ const data = JSON.parse(fs.readFileSync(file));
236
+ expect(data.processStart).toBeTruthy();
237
+ expect(replacement.acquire(100)).toBe(false);
238
+ fs.writeFileSync(
239
+ file,
240
+ JSON.stringify({ ...data, processStart: 'different-birth' })
241
+ );
242
+ expect(replacement.acquire(100)).toBe(true);
243
+ first.release();
244
+ expect(fs.existsSync(file)).toBe(true);
245
+ } finally {
246
+ replacement.release();
247
+ fs.rmSync(dir, { recursive: true, force: true });
248
+ }
249
+ });
@@ -69,7 +69,7 @@ function buildAttachedCmdArgs(command, shellToUse = '/bin/bash') {
69
69
  describe('isShellInvocationWithArgs (issue #91)', () => {
70
70
  it('should return true for "bash -i -c nvm --version"', () => {
71
71
  assert.strictEqual(
72
- isShellInvocationWithArgs('bash -i -c nvm --version'),
72
+ isShellInvocationWithArgs('bash -i -c "nvm --version"'),
73
73
  true
74
74
  );
75
75
  });
@@ -79,11 +79,14 @@ describe('isShellInvocationWithArgs (issue #91)', () => {
79
79
  });
80
80
 
81
81
  it('should return true for "bash -c echo hello"', () => {
82
- assert.strictEqual(isShellInvocationWithArgs('bash -c echo hello'), true);
82
+ assert.strictEqual(isShellInvocationWithArgs('bash -c "echo hello"'), true);
83
83
  });
84
84
 
85
85
  it('should return true for "zsh -c nvm --version"', () => {
86
- assert.strictEqual(isShellInvocationWithArgs('zsh -c nvm --version'), true);
86
+ assert.strictEqual(
87
+ isShellInvocationWithArgs('zsh -c "nvm --version"'),
88
+ true
89
+ );
87
90
  });
88
91
 
89
92
  it('should return true for "sh -c ls"', () => {
@@ -115,12 +118,12 @@ describe('isShellInvocationWithArgs (issue #91)', () => {
115
118
 
116
119
  describe('buildShellWithArgsCmdArgs (issue #91)', () => {
117
120
  it('should reconstruct "bash -i -c nvm --version" correctly', () => {
118
- const result = buildShellWithArgsCmdArgs('bash -i -c nvm --version');
121
+ const result = buildShellWithArgsCmdArgs('bash -i -c "nvm --version"');
119
122
  assert.deepStrictEqual(result, ['bash', '-i', '-c', 'nvm --version']);
120
123
  });
121
124
 
122
125
  it('should reconstruct "bash -c echo hello" correctly', () => {
123
- const result = buildShellWithArgsCmdArgs('bash -c echo hello');
126
+ const result = buildShellWithArgsCmdArgs('bash -c "echo hello"');
124
127
  assert.deepStrictEqual(result, ['bash', '-c', 'echo hello']);
125
128
  });
126
129
 
@@ -130,13 +133,13 @@ describe('buildShellWithArgsCmdArgs (issue #91)', () => {
130
133
  });
131
134
 
132
135
  it('should handle zsh with -c', () => {
133
- const result = buildShellWithArgsCmdArgs('zsh -c nvm --version');
136
+ const result = buildShellWithArgsCmdArgs('zsh -c "nvm --version"');
134
137
  assert.deepStrictEqual(result, ['zsh', '-c', 'nvm --version']);
135
138
  });
136
139
 
137
140
  it('should handle /bin/bash -i -c with multi-word script', () => {
138
141
  const result = buildShellWithArgsCmdArgs(
139
- '/bin/bash -i -c node -e process.version'
142
+ '/bin/bash -i -c "node -e process.version"'
140
143
  );
141
144
  assert.deepStrictEqual(result, [
142
145
  '/bin/bash',
@@ -147,7 +150,7 @@ describe('buildShellWithArgsCmdArgs (issue #91)', () => {
147
150
  });
148
151
 
149
152
  it('should not include -c argument inside script argument (no double -c)', () => {
150
- const result = buildShellWithArgsCmdArgs('bash -i -c nvm --version');
153
+ const result = buildShellWithArgsCmdArgs('bash -i -c "nvm --version"');
151
154
  // The script arg must be 'nvm --version', not '-c nvm --version'
152
155
  assert.strictEqual(result[result.length - 1], 'nvm --version');
153
156
  assert.strictEqual(result.indexOf('-c'), 2);
@@ -159,13 +162,13 @@ describe('Regression: No Double-Wrapping for Shell With -c (issue #91)', () => {
159
162
  // Each test verifies that `bash -i -c "cmd"` style commands are NOT re-wrapped
160
163
  // in another outer shell -c invocation.
161
164
  //
162
- // Before fix: buildAttachedCmdArgs('bash -i -c nvm --version')
163
- // → ['/bin/bash', '-i', '-c', 'bash -i -c nvm --version'] (WRONG: double-wrap)
164
- // After fix: buildAttachedCmdArgs('bash -i -c nvm --version')
165
+ // Before fix: buildAttachedCmdArgs('bash -i -c "nvm --version"')
166
+ // → ['/bin/bash', '-i', '-c', 'bash -i -c "nvm --version"'] (WRONG: double-wrap)
167
+ // After fix: buildAttachedCmdArgs('bash -i -c "nvm --version"')
165
168
  // → ['bash', '-i', '-c', 'nvm --version'] (CORRECT: direct pass)
166
169
 
167
170
  it('should pass "bash -i -c nvm --version" directly without outer shell wrapper', () => {
168
- const args = buildAttachedCmdArgs('bash -i -c nvm --version');
171
+ const args = buildAttachedCmdArgs('bash -i -c "nvm --version"');
169
172
  // Must start with 'bash', not with '/bin/bash' (the outer shellToUse)
170
173
  assert.strictEqual(args[0], 'bash');
171
174
  // Must not wrap in outer bash -i -c
@@ -182,7 +185,7 @@ describe('Regression: No Double-Wrapping for Shell With -c (issue #91)', () => {
182
185
  });
183
186
 
184
187
  it('should pass "zsh -c nvm --version" directly without outer shell wrapper', () => {
185
- const args = buildAttachedCmdArgs('zsh -c nvm --version', '/bin/zsh');
188
+ const args = buildAttachedCmdArgs('zsh -c "nvm --version"', '/bin/zsh');
186
189
  assert.deepStrictEqual(args, ['zsh', '-c', 'nvm --version']);
187
190
  assert.ok(
188
191
  args.filter((a) => a === '-c').length === 1,
@@ -191,12 +194,12 @@ describe('Regression: No Double-Wrapping for Shell With -c (issue #91)', () => {
191
194
  });
192
195
 
193
196
  it('should pass "bash -c echo hello" directly', () => {
194
- const args = buildAttachedCmdArgs('bash -c echo hello');
197
+ const args = buildAttachedCmdArgs('bash -c "echo hello"');
195
198
  assert.deepStrictEqual(args, ['bash', '-c', 'echo hello']);
196
199
  });
197
200
 
198
201
  it('should not introduce a second bash layer (no shell-inside-shell)', () => {
199
- const args = buildAttachedCmdArgs('bash -i -c nvm --version');
202
+ const args = buildAttachedCmdArgs('bash -i -c "nvm --version"');
200
203
  // The first element of the docker image command args must NOT be the outer shell
201
204
  // i.e., must not be ['/bin/bash', '-i', '-c', 'bash ...']
202
205
  assert.notStrictEqual(
@@ -206,7 +209,7 @@ describe('Regression: No Double-Wrapping for Shell With -c (issue #91)', () => {
206
209
  );
207
210
  // Must not contain the original full command string as an argument
208
211
  assert.ok(
209
- !args.includes('bash -i -c nvm --version'),
212
+ !args.includes('bash -i -c "nvm --version"'),
210
213
  'Must not contain the full command string as a single argument (double-wrap)'
211
214
  );
212
215
  });
@@ -241,7 +244,7 @@ describe('isShellInvocationWithArgs is mutually exclusive with isInteractiveShel
241
244
  'bash',
242
245
  'bash -i',
243
246
  'bash --norc',
244
- 'bash -i -c nvm --version',
247
+ 'bash -i -c "nvm --version"',
245
248
  'bash -c echo hi',
246
249
  'zsh -c ls',
247
250
  'nvm --version',
@@ -263,14 +266,14 @@ describe('isShellInvocationWithArgs is mutually exclusive with isInteractiveShel
263
266
  describe('buildDisplayCommand (issue #91 display fix)', () => {
264
267
  it('should quote the -c script argument when it contains spaces', () => {
265
268
  assert.strictEqual(
266
- buildDisplayCommand('bash -i -c nvm --version'),
269
+ buildDisplayCommand('bash -i -c "nvm --version"'),
267
270
  'bash -i -c "nvm --version"'
268
271
  );
269
272
  });
270
273
 
271
274
  it('should quote the -c script argument for plain "bash -c echo hello"', () => {
272
275
  assert.strictEqual(
273
- buildDisplayCommand('bash -c echo hello'),
276
+ buildDisplayCommand('bash -c "echo hello"'),
274
277
  'bash -c "echo hello"'
275
278
  );
276
279
  });
@@ -289,7 +292,7 @@ describe('buildDisplayCommand (issue #91 display fix)', () => {
289
292
 
290
293
  it('should handle zsh -c with spaces', () => {
291
294
  assert.strictEqual(
292
- buildDisplayCommand('zsh -c echo hello world'),
295
+ buildDisplayCommand('zsh -c "echo hello world"'),
293
296
  'zsh -c "echo hello world"'
294
297
  );
295
298
  });
@@ -0,0 +1,77 @@
1
+ const { describe, it } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { readFileSync } = require('node:fs');
4
+ const { resolve } = require('node:path');
5
+ const { parseArgs } = require('../src/lib/args-parser');
6
+ const { buildDockerRuntimeArgs } = require('../src/lib/docker-runtime-args');
7
+ const { ExecutionRecord } = require('../src/lib/execution-store');
8
+ const { formatRecord } = require('../src/lib/status-formatter');
9
+ const fixtures = JSON.parse(
10
+ readFileSync(resolve(__dirname, '../../parity/fixtures/contracts.json'))
11
+ );
12
+
13
+ describe('shared JavaScript and Rust golden contracts', () => {
14
+ for (const fixture of fixtures['cli-parsing']) {
15
+ it(`CLI parsing: ${fixture.name}`, () => {
16
+ if (fixture.error) {
17
+ assert.throws(() => parseArgs(fixture.argv), new RegExp(fixture.error));
18
+ return;
19
+ }
20
+ const parsed = parseArgs(fixture.argv);
21
+ assert.equal(parsed.command, fixture.command);
22
+ assert.deepEqual(parsed.rawCommand, fixture.rawCommand);
23
+ for (const [key, value] of Object.entries(fixture.options)) {
24
+ assert.deepEqual(parsed.wrapperOptions[key], value, key);
25
+ }
26
+ });
27
+ }
28
+ for (const fixture of fixtures['isolation-arguments']) {
29
+ it(`isolation argv: ${fixture.name}`, () => {
30
+ assert.deepEqual(buildDockerRuntimeArgs(fixture.options), fixture.argv);
31
+ });
32
+ }
33
+ it('uses the same JSON and text status output', () => {
34
+ const fixture = fixtures['status-output'];
35
+ const record = new ExecutionRecord(fixture.record);
36
+ assert.deepEqual(JSON.parse(formatRecord(record, 'json')), fixture.json);
37
+ assert.equal(formatRecord(record, 'text'), fixture.text);
38
+ });
39
+ for (const fixture of fixtures['execution-record-format']) {
40
+ it(`preserves the complete execution record format (exit ${fixture.exitCode})`, () => {
41
+ assert.deepEqual(ExecutionRecord.fromObject(fixture).toObject(), fixture);
42
+ });
43
+ }
44
+ it('executes the JavaScript source of the generated Rust parity policy', async () => {
45
+ const { minimumRustTestCount } =
46
+ await import('../../scripts/parity-threshold.mjs');
47
+ for (const fixture of fixtures['parity-threshold']) {
48
+ assert.equal(
49
+ minimumRustTestCount(fixture.javascript),
50
+ fixture.minimumRust
51
+ );
52
+ }
53
+ });
54
+ for (const fixture of fixtures['cpu-penalty-state']) {
55
+ it(`CPU state: ${fixture.name}`, () => {
56
+ const {
57
+ initialState,
58
+ observeBase,
59
+ evaluate,
60
+ } = require('../src/lib/cpu-penalty');
61
+ let state = initialState(fixture.baseCpus, 0, fixture.saved);
62
+ for (const step of fixture.steps) {
63
+ state = observeBase(state, step.baseCpus, step.now);
64
+ const decision = evaluate(
65
+ state,
66
+ { now: step.now, cores: step.cores, daemonCpus: 6, maxGapMs: 50 },
67
+ fixture.config
68
+ );
69
+ assert.equal(decision.action?.kind || null, step.action);
70
+ state = decision.state;
71
+ }
72
+ for (const [key, value] of Object.entries(fixture.expected)) {
73
+ assert.deepEqual(state[key], value, key);
74
+ }
75
+ });
76
+ }
77
+ });
@@ -53,19 +53,35 @@ function createExecutable(filePath, content) {
53
53
  fs.chmodSync(filePath, 0o755);
54
54
  }
55
55
 
56
+ function fakeUploaderScript(fakeBin, outputPrefix) {
57
+ const fixturePath = path.join(fakeBin, 'capture-upload.js');
58
+ fs.writeFileSync(
59
+ fixturePath,
60
+ [
61
+ "const fs = require('fs');",
62
+ 'const args = process.argv.slice(2);',
63
+ 'fs.writeFileSync(process.env.UPLOAD_CAPTURE, JSON.stringify({',
64
+ ' args, content: fs.readFileSync(args[0], "utf8"),',
65
+ ' mode: fs.statSync(args[0]).mode & 0o777,',
66
+ '}));',
67
+ `console.log(${JSON.stringify(outputPrefix)} + ': ' + args[0]);`,
68
+ '',
69
+ ].join('\n')
70
+ );
71
+ if (process.platform === 'win32') {
72
+ return `@echo off\r\n"${process.execPath}" "${fixturePath}" %*\r\n`;
73
+ }
74
+ return `#!/bin/sh\n"${process.execPath}" "${fixturePath}" "$@"\n`;
75
+ }
76
+
56
77
  function createFakeUploader(fakeBin, outputPrefix) {
78
+ const content = fakeUploaderScript(fakeBin, outputPrefix);
57
79
  if (process.platform === 'win32') {
58
- createExecutable(
59
- path.join(fakeBin, 'gh-upload-log.cmd'),
60
- `@echo off\r\necho ${outputPrefix}: %1\r\n`
61
- );
80
+ createExecutable(path.join(fakeBin, 'gh-upload-log.cmd'), content);
62
81
  return;
63
82
  }
64
83
 
65
- createExecutable(
66
- path.join(fakeBin, 'gh-upload-log'),
67
- `#!/bin/sh\necho "${outputPrefix}: $1"\n`
68
- );
84
+ createExecutable(path.join(fakeBin, 'gh-upload-log'), content);
69
85
  }
70
86
 
71
87
  describe('--status query functionality', () => {
@@ -262,10 +278,14 @@ describe('--status query functionality', () => {
262
278
  });
263
279
 
264
280
  describe('--upload-log functionality', () => {
265
- it('should run gh-upload-log with the stored execution log path', () => {
281
+ it('should upload a private sanitized copy and preserve the stored log', () => {
266
282
  const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'upload-log-bin-'));
267
283
  const logPath = path.join(TEST_APP_FOLDER, 'command.log');
268
- fs.writeFileSync(logPath, 'captured command output\n', 'utf8');
284
+ const capturePath = path.join(fakeBin, 'capture.json');
285
+ const token = ['gh', 'p_', 'a'.repeat(36)].join('');
286
+ const customSecret = 'fake-custom-credential';
287
+ const original = `captured command output\n${token}\n${customSecret}\n`;
288
+ fs.writeFileSync(logPath, original, 'utf8');
269
289
  createFakeUploader(fakeBin, 'fake uploader received');
270
290
 
271
291
  testRecord.logPath = logPath;
@@ -274,11 +294,28 @@ describe('--status query functionality', () => {
274
294
  const result = runCli(['--upload-log', testRecord.uuid], {
275
295
  PATH: fakeBin,
276
296
  HOME: fakeBin,
297
+ UPLOAD_CAPTURE: capturePath,
298
+ EXAMPLE_API_KEY: customSecret,
299
+ GH_UPLOAD_PUBLIC: 'true',
277
300
  });
278
301
 
279
302
  expect(result.exitCode).toBe(0);
280
- expect(result.stdout).toContain(`fake uploader received: ${logPath}`);
303
+ const captured = JSON.parse(fs.readFileSync(capturePath, 'utf8'));
304
+ expect(captured.args[0]).not.toBe(logPath);
305
+ expect(captured.args[1]).toBe('--private');
306
+ expect(captured.content).toContain('captured command output');
307
+ expect(captured.content).toContain('[REDACTED]');
308
+ expect(captured.content).not.toContain(token);
309
+ expect(captured.content).not.toContain(customSecret);
310
+ if (process.platform !== 'win32') {
311
+ expect(captured.mode).toBe(0o600);
312
+ }
313
+ expect(result.stdout).toContain(
314
+ `fake uploader received: ${captured.args[0]}`
315
+ );
281
316
  expect(result.stderr).toBe('');
317
+ expect(fs.readFileSync(logPath, 'utf8')).toBe(original);
318
+ expect(fs.existsSync(captured.args[0])).toBe(false);
282
319
 
283
320
  fs.rmSync(fakeBin, { recursive: true, force: true });
284
321
  });
@@ -293,8 +330,15 @@ describe('--status query functionality', () => {
293
330
  path.join(os.tmpdir(), 'upload-log-install-bin-')
294
331
  );
295
332
  const installMarker = path.join(fakeBin, 'install.log');
333
+ const capturePath = path.join(fakeBin, 'capture.json');
296
334
  const logPath = path.join(TEST_APP_FOLDER, 'install-command.log');
297
- fs.writeFileSync(logPath, 'captured command output\n', 'utf8');
335
+ const token = ['gh', 'p_', 'b'.repeat(36)].join('');
336
+ const original = `captured command output\n${token}\n`;
337
+ fs.writeFileSync(logPath, original, 'utf8');
338
+ const uploaderScript = fakeUploaderScript(
339
+ fakeBin,
340
+ 'installed uploader received'
341
+ );
298
342
 
299
343
  createExecutable(
300
344
  path.join(fakeBin, 'bun'),
@@ -302,8 +346,7 @@ describe('--status query functionality', () => {
302
346
  '#!/bin/sh',
303
347
  `echo "$@" > "${installMarker}"`,
304
348
  `cat > "${path.join(fakeBin, 'gh-upload-log')}" <<'SCRIPT'`,
305
- '#!/bin/sh',
306
- 'echo "installed uploader received: $1"',
349
+ uploaderScript,
307
350
  'SCRIPT',
308
351
  `chmod +x "${path.join(fakeBin, 'gh-upload-log')}"`,
309
352
  'exit 0',
@@ -317,6 +360,7 @@ describe('--status query functionality', () => {
317
360
  const result = runCli(['--upload-log', testRecord.uuid], {
318
361
  PATH: `${fakeBin}${path.delimiter}/usr/bin${path.delimiter}/bin`,
319
362
  HOME: fakeBin,
363
+ UPLOAD_CAPTURE: capturePath,
320
364
  });
321
365
 
322
366
  expect(result.exitCode).toBe(0);
@@ -324,10 +368,48 @@ describe('--status query functionality', () => {
324
368
  'install -g gh-upload-log'
325
369
  );
326
370
  expect(result.stdout).toContain('gh-upload-log not found');
371
+ const captured = JSON.parse(fs.readFileSync(capturePath, 'utf8'));
372
+ expect(captured.args[0]).not.toBe(logPath);
373
+ expect(captured.args[1]).toBe('--private');
374
+ expect(captured.content).toContain('captured command output');
375
+ expect(captured.content).toContain('[REDACTED]');
376
+ expect(captured.content).not.toContain(token);
377
+ expect(captured.mode).toBe(0o600);
327
378
  expect(result.stdout).toContain(
328
- `installed uploader received: ${logPath}`
379
+ `installed uploader received: ${captured.args[0]}`
380
+ );
381
+ expect(fs.readFileSync(logPath, 'utf8')).toBe(original);
382
+ expect(fs.existsSync(captured.args[0])).toBe(false);
383
+
384
+ fs.rmSync(fakeBin, { recursive: true, force: true });
385
+ });
386
+
387
+ it('should upload the original privately only with explicit --no-sanitize', () => {
388
+ const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'upload-raw-bin-'));
389
+ const capturePath = path.join(fakeBin, 'capture.json');
390
+ const logPath = path.join(TEST_APP_FOLDER, 'raw-command.log');
391
+ const token = ['gh', 'p_', 'c'.repeat(36)].join('');
392
+ const original = `captured command output\n${token}\n`;
393
+ fs.writeFileSync(logPath, original, 'utf8');
394
+ createFakeUploader(fakeBin, 'raw uploader received');
395
+ testRecord.logPath = logPath;
396
+ store.save(testRecord);
397
+
398
+ const result = runCli(
399
+ ['--upload-log', testRecord.uuid, '--no-sanitize'],
400
+ {
401
+ PATH: fakeBin,
402
+ HOME: fakeBin,
403
+ UPLOAD_CAPTURE: capturePath,
404
+ GH_UPLOAD_PUBLIC: 'true',
405
+ }
329
406
  );
330
407
 
408
+ expect(result.exitCode).toBe(0);
409
+ const captured = JSON.parse(fs.readFileSync(capturePath, 'utf8'));
410
+ expect(captured.args).toEqual([logPath, '--private']);
411
+ expect(captured.content).toBe(original);
412
+ expect(fs.readFileSync(logPath, 'utf8')).toBe(original);
331
413
  fs.rmSync(fakeBin, { recursive: true, force: true });
332
414
  });
333
415