start-command 0.37.9 → 0.38.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.
@@ -142,7 +142,7 @@ describe('query mode exclusivity', () => {
142
142
  test(`rejects ${args.join(' ')}`, () => {
143
143
  assert.throws(() => {
144
144
  parse(args);
145
- }, /Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, or --cleanup/);
145
+ }, /Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, --cleanup, --alias, or --rename/);
146
146
  });
147
147
  }
148
148
  });
@@ -54,13 +54,13 @@ describe('control options', () => {
54
54
  it('should reject combining query and control modes', () => {
55
55
  assert.throws(() => {
56
56
  parseArgs(['--status', 'uuid-here', '--stop', 'my-session']);
57
- }, /Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, or --cleanup/);
57
+ }, /Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, --cleanup, --alias, or --rename/);
58
58
  });
59
59
 
60
60
  it('should reject combining upload-log with control modes', () => {
61
61
  assert.throws(() => {
62
62
  parseArgs(['--upload-log', 'uuid-here', '--terminate', 'my-session']);
63
- }, /Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, or --cleanup/);
63
+ }, /Cannot combine --status, --list, --upload-log, --stop, --terminate, --attach, --resume, --resume-all, --cleanup, --alias, or --rename/);
64
64
  });
65
65
 
66
66
  it('should reject output-format with control modes', () => {
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Issue #229: a new launch with an existing --session-id used to replace the
3
+ * execution record and truncate its log. It is now refused unless
4
+ * --append-attempt is given, which keeps the record and appends the new
5
+ * attempt to the same log.
6
+ */
7
+ const { describe, it, expect } = require('bun:test');
8
+ const { spawnSync } = require('child_process');
9
+ const fs = require('fs');
10
+ const os = require('os');
11
+ const path = require('path');
12
+ const { parseArgs } = require('../src/lib/args-parser');
13
+ const {
14
+ ExecutionStore,
15
+ ExecutionRecord,
16
+ } = require('../src/lib/execution-store');
17
+ const {
18
+ LaunchRefusedError,
19
+ alreadyExistsMessage,
20
+ claimLaunch,
21
+ mergeAppendedAttempt,
22
+ preflightLaunch,
23
+ } = require('../src/lib/launch-identity');
24
+
25
+ const CLI = path.join(__dirname, '../src/bin/cli.js');
26
+ const UUID = '22900000-0000-4000-8000-000000000229';
27
+
28
+ function sandbox() {
29
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'start-229-'));
30
+ const app = path.join(root, 'app');
31
+ const logs = path.join(root, 'logs');
32
+ return {
33
+ root,
34
+ app,
35
+ logs,
36
+ env: {
37
+ ...process.env,
38
+ START_APP_FOLDER: app,
39
+ START_LOG_DIR: logs,
40
+ START_DISABLE_AUTO_ISSUE: '1',
41
+ START_DISABLE_LOG_UPLOAD: '1',
42
+ START_EXECUTION_ID: '',
43
+ },
44
+ };
45
+ }
46
+
47
+ function run(box, args) {
48
+ return spawnSync(process.execPath, [CLI, ...args], {
49
+ env: box.env,
50
+ encoding: 'utf8',
51
+ timeout: 20000,
52
+ });
53
+ }
54
+
55
+ function storeOf(box) {
56
+ return new ExecutionStore({ appFolder: box.app });
57
+ }
58
+
59
+ describe('issue #229: an existing --session-id is never overwritten', () => {
60
+ if (process.platform === 'win32') {
61
+ return;
62
+ }
63
+
64
+ it('refuses a second launch and keeps the first log intact', () => {
65
+ const box = sandbox();
66
+ const first = run(box, ['--session-id', UUID, '--', 'echo', 'first-run']);
67
+ expect(first.status).toBe(0);
68
+ const logPath = storeOf(box).get(UUID).logPath;
69
+ const before = fs.readFileSync(logPath, 'utf8');
70
+
71
+ const second = run(box, ['--session-id', UUID, '--', 'echo', 'second']);
72
+ expect(second.status).toBe(1);
73
+ expect(second.stderr).toContain(alreadyExistsMessage(UUID));
74
+ expect(second.stdout).not.toContain('second');
75
+ expect(fs.readFileSync(logPath, 'utf8')).toBe(before);
76
+ const record = storeOf(box).get(UUID);
77
+ expect(record.command).toBe('echo first-run');
78
+ expect(record.attemptHistory || []).toEqual([]);
79
+ });
80
+
81
+ it('--append-attempt keeps the record and appends to the same log', () => {
82
+ const box = sandbox();
83
+ run(box, ['--session-id', UUID, '--', 'echo', 'first-run']);
84
+ const logPath = storeOf(box).get(UUID).logPath;
85
+ const firstSize = fs.statSync(logPath).size;
86
+
87
+ const appended = run(box, [
88
+ '--session-id',
89
+ UUID,
90
+ '--append-attempt',
91
+ '--',
92
+ 'sh',
93
+ '-c',
94
+ 'echo second-run $START_ATTEMPT',
95
+ ]);
96
+ expect(appended.status).toBe(0);
97
+ expect(appended.stdout).toContain('second-run 2');
98
+
99
+ const log = fs.readFileSync(logPath, 'utf8');
100
+ expect(log).toContain('first-run');
101
+ expect(log).toContain('second-run 2');
102
+ expect(log.split('=== Start Command Log ===').length - 1).toBe(2);
103
+ expect(log).toContain('"event":"attempt-appended"');
104
+
105
+ const record = storeOf(box).get(UUID);
106
+ expect(record.attempt.number).toBe(2);
107
+ expect(record.attempt.mode).toBe('append-attempt');
108
+ expect(record.attempt.logOffset).toBe(firstSize);
109
+ expect(record.attemptHistory).toHaveLength(1);
110
+ expect(record.attemptHistory[0]).toMatchObject({
111
+ number: 1,
112
+ logOffset: 0,
113
+ command: 'echo first-run',
114
+ exitCode: 0,
115
+ });
116
+ expect(record.logPath).toBe(logPath);
117
+ });
118
+
119
+ it('refuses an id that is the name of another execution', () => {
120
+ const box = sandbox();
121
+ const other = '22900000-0000-4000-8000-000000000001';
122
+ run(box, ['--session-id', other, '--', 'echo', 'x']);
123
+ expect(run(box, ['--alias', other, UUID]).status).toBe(0);
124
+ const launch = run(box, ['--session-id', UUID, '--', 'echo', 'y']);
125
+ expect(launch.status).toBe(1);
126
+ expect(launch.stderr).toContain(
127
+ `execution id ${UUID} is already used as a name of execution ${other}`
128
+ );
129
+ });
130
+
131
+ it('recognises an untracked execution by its log file', () => {
132
+ const box = sandbox();
133
+ box.env.START_DISABLE_TRACKING = '1';
134
+ const logPath = path.join(box.logs, 'direct', `${UUID}.log`);
135
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
136
+ fs.writeFileSync(logPath, '=== Start Command Log ===\nold\n');
137
+ const store = { get: () => null };
138
+ expect(() =>
139
+ preflightLaunch({
140
+ store,
141
+ sessionId: UUID,
142
+ explicitSessionId: true,
143
+ logPath,
144
+ env: {},
145
+ })
146
+ ).toThrow(LaunchRefusedError);
147
+ const launch = preflightLaunch({
148
+ store,
149
+ sessionId: UUID,
150
+ explicitSessionId: true,
151
+ appendAttempt: true,
152
+ logPath,
153
+ env: {},
154
+ });
155
+ expect(launch).toMatchObject({ append: true, attemptNumber: 2 });
156
+ });
157
+
158
+ it('refuses --append-attempt while the execution is still running', () => {
159
+ const record = new ExecutionRecord({ uuid: UUID, command: 'sleep 9' });
160
+ record.pid = process.pid;
161
+ expect(() =>
162
+ preflightLaunch({
163
+ store: { get: () => record },
164
+ sessionId: UUID,
165
+ explicitSessionId: true,
166
+ appendAttempt: true,
167
+ logPath: '/nonexistent',
168
+ env: {},
169
+ })
170
+ ).toThrow(/is still running/);
171
+ });
172
+
173
+ it('lets the next level of a stacked isolation reuse the id', () => {
174
+ const record = new ExecutionRecord({ uuid: UUID, command: 'x' });
175
+ const launch = preflightLaunch({
176
+ store: { get: () => record },
177
+ sessionId: UUID,
178
+ explicitSessionId: true,
179
+ logPath: '/nonexistent',
180
+ env: { START_EXECUTION_ID: UUID, START_ATTEMPT: '3' },
181
+ });
182
+ expect(launch).toMatchObject({ nested: true, attemptNumber: 3 });
183
+ });
184
+
185
+ it('a generated id is never checked', () => {
186
+ const launch = preflightLaunch({
187
+ store: {
188
+ get: () => {
189
+ throw new Error('must not be read');
190
+ },
191
+ },
192
+ sessionId: UUID,
193
+ explicitSessionId: false,
194
+ logPath: '/nonexistent',
195
+ });
196
+ expect(launch).toMatchObject({ append: false, checked: false });
197
+ });
198
+
199
+ it('claimLaunch refuses a launch that lost the race for the id', () => {
200
+ const box = sandbox();
201
+ const store = storeOf(box);
202
+ const launch = preflightLaunch({
203
+ store,
204
+ sessionId: UUID,
205
+ explicitSessionId: true,
206
+ logPath: path.join(box.root, 'none.log'),
207
+ env: {},
208
+ });
209
+ store.save(new ExecutionRecord({ uuid: UUID, command: 'winner' }));
210
+ expect(() =>
211
+ claimLaunch(
212
+ store,
213
+ new ExecutionRecord({ uuid: UUID, command: 'loser' }),
214
+ launch
215
+ )
216
+ ).toThrow(LaunchRefusedError);
217
+ expect(store.get(UUID).command).toBe('winner');
218
+ });
219
+
220
+ it('mergeAppendedAttempt keeps names, continuation and history', () => {
221
+ const previous = new ExecutionRecord({
222
+ uuid: UUID,
223
+ command: 'old',
224
+ logPath: '/nonexistent.log',
225
+ options: { sessionName: 'old-s', aliases: ['a'], name: 'n' },
226
+ });
227
+ previous.continues = '22900000-0000-4000-8000-000000000002';
228
+ previous.complete(3);
229
+ const record = new ExecutionRecord({
230
+ uuid: UUID,
231
+ command: 'new',
232
+ options: { sessionName: 'new-s' },
233
+ });
234
+ mergeAppendedAttempt(record, previous);
235
+ expect(record.startTime).toBe(previous.startTime);
236
+ expect(record.continues).toBe(previous.continues);
237
+ expect(record.options).toMatchObject({
238
+ sessionName: 'new-s',
239
+ sessionNameHistory: ['old-s'],
240
+ aliases: ['a'],
241
+ name: 'n',
242
+ });
243
+ expect(record.attempt.number).toBe(2);
244
+ expect(record.attemptHistory[0]).toMatchObject({
245
+ number: 1,
246
+ exitCode: 3,
247
+ sessionName: 'old-s',
248
+ });
249
+ });
250
+
251
+ it('parses --append-attempt and requires --session-id', () => {
252
+ const parsed = parseArgs([
253
+ '--session-id',
254
+ UUID,
255
+ '--append-attempt',
256
+ '--',
257
+ 'echo',
258
+ ]);
259
+ expect(parsed.wrapperOptions.appendAttempt).toBe(true);
260
+ expect(() => parseArgs(['--append-attempt', '--', 'echo'])).toThrow(
261
+ '--append-attempt option requires --session-id <uuid>'
262
+ );
263
+ expect(() => parseArgs(['--status', UUID, '--append-attempt'])).toThrow(
264
+ '--append-attempt option is only valid when launching a command'
265
+ );
266
+ });
267
+ });
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Issue #230: every backend exports START_EXECUTION_ID, START_SESSION_NAME and
3
+ * START_ATTEMPT to the command it runs, so a supervisor can recognise its
4
+ * own executions without parsing the start block.
5
+ */
6
+ const { describe, it, expect } = require('bun:test');
7
+ const { spawnSync } = require('child_process');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const {
12
+ buildExecutionEnv,
13
+ buildExecutionEnvExport,
14
+ executionEnvFromOptions,
15
+ isNestedLaunchOf,
16
+ prefixWithExecutionEnv,
17
+ } = require('../src/lib/execution-env');
18
+ const { buildDockerRuntimeArgs } = require('../src/lib/docker-runtime-args');
19
+ const {
20
+ buildCommandHandoffArgs,
21
+ writeExecutionEnvHandoff,
22
+ } = require('../src/lib/docker-command-handoff');
23
+ const { nextAttemptNumber } = require('../src/lib/execution-attempt');
24
+ const { isolatedUserCommand } = require('../src/lib/launch-identity');
25
+
26
+ const CLI = path.join(__dirname, '../src/bin/cli.js');
27
+ const UUID = '23000000-0000-4000-8000-000000000230';
28
+ const PRINT =
29
+ 'echo "ID=$START_EXECUTION_ID S=$START_SESSION_NAME A=$START_ATTEMPT"';
30
+
31
+ function run(args, extraEnv = {}) {
32
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'start-230-'));
33
+ return spawnSync(process.execPath, [CLI, ...args], {
34
+ env: {
35
+ ...process.env,
36
+ START_APP_FOLDER: path.join(root, 'app'),
37
+ START_LOG_DIR: path.join(root, 'logs'),
38
+ START_DISABLE_AUTO_ISSUE: '1',
39
+ START_DISABLE_LOG_UPLOAD: '1',
40
+ START_EXECUTION_ID: '',
41
+ ...extraEnv,
42
+ },
43
+ encoding: 'utf8',
44
+ timeout: 30000,
45
+ });
46
+ }
47
+
48
+ function hasCommand(name) {
49
+ return spawnSync('sh', ['-c', `command -v ${name}`]).status === 0;
50
+ }
51
+
52
+ describe('issue #230: START_* execution identity variables', () => {
53
+ it('builds the three variables, attempt 1 by default', () => {
54
+ expect(buildExecutionEnv({ executionId: UUID, sessionName: 's' })).toEqual({
55
+ START_EXECUTION_ID: UUID,
56
+ START_SESSION_NAME: 's',
57
+ START_ATTEMPT: '1',
58
+ });
59
+ expect(buildExecutionEnv({})).toEqual({});
60
+ expect(
61
+ executionEnvFromOptions({
62
+ sessionId: UUID,
63
+ session: 'tmux-1',
64
+ attemptNumber: 4,
65
+ })
66
+ ).toMatchObject({ START_SESSION_NAME: 'tmux-1', START_ATTEMPT: '4' });
67
+ });
68
+
69
+ it('quotes the export prefix used by screen, tmux and ssh', () => {
70
+ const env = buildExecutionEnv({
71
+ executionId: UUID,
72
+ sessionName: "it's",
73
+ attempt: 2,
74
+ });
75
+ expect(buildExecutionEnvExport(env)).toBe(
76
+ `export START_EXECUTION_ID='${UUID}' START_SESSION_NAME='it'\\''s' START_ATTEMPT='2'`
77
+ );
78
+ const out = spawnSync('sh', ['-c', prefixWithExecutionEnv(PRINT, env)], {
79
+ encoding: 'utf8',
80
+ }).stdout;
81
+ expect(out.trim()).toBe(`ID=${UUID} S=it's A=2`);
82
+ });
83
+
84
+ it('passes them to docker run after the user --env, so they win', () => {
85
+ const args = buildDockerRuntimeArgs({
86
+ env: [`START_EXECUTION_ID=spoofed`],
87
+ sessionId: UUID,
88
+ containerName: 'box',
89
+ attemptNumber: 3,
90
+ });
91
+ const envs = args.filter((_, i) => args[i - 1] === '-e');
92
+ expect(envs).toEqual([
93
+ 'START_EXECUTION_ID=spoofed',
94
+ `START_EXECUTION_ID=${UUID}`,
95
+ 'START_SESSION_NAME=box',
96
+ 'START_ATTEMPT=3',
97
+ ]);
98
+ });
99
+
100
+ it('the docker handoff selector sources the next attempt variables', () => {
101
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'start-230-handoff-'));
102
+ const marker = path.join(dir, 'marker');
103
+ const calls = [];
104
+ writeExecutionEnvHandoff(
105
+ 'box',
106
+ buildExecutionEnv({ executionId: UUID, sessionName: 'box', attempt: 2 }),
107
+ (cmd, argv) => {
108
+ calls.push(argv);
109
+ fs.copyFileSync(argv[1], `${marker}.env`);
110
+ return { status: 0 };
111
+ }
112
+ );
113
+ expect(calls[0][2]).toBe('box:/.start-command-resume-box.env');
114
+ const [shell, ...rest] = buildCommandHandoffArgs(
115
+ ['sh', '-c', PRINT],
116
+ marker
117
+ );
118
+ const out = spawnSync(shell, rest, {
119
+ encoding: 'utf8',
120
+ env: { ...process.env, START_ATTEMPT: '1' },
121
+ }).stdout;
122
+ expect(out.trim()).toBe(`ID=${UUID} S=box A=2`);
123
+ });
124
+
125
+ it('numbers the next attempt after resumes and recoveries', () => {
126
+ expect(nextAttemptNumber({})).toBe(2);
127
+ expect(nextAttemptNumber({ attempt: { number: 3 } })).toBe(4);
128
+ expect(nextAttemptNumber({ options: { resumeCount: 1 } })).toBe(3);
129
+ });
130
+
131
+ it('recognises the next level of a stacked isolation', () => {
132
+ expect(isNestedLaunchOf(UUID, { START_EXECUTION_ID: UUID })).toBe(true);
133
+ expect(isNestedLaunchOf(UUID, { START_EXECUTION_ID: 'other' })).toBe(false);
134
+ expect(isNestedLaunchOf(UUID, {})).toBe(false);
135
+ });
136
+
137
+ if (process.platform === 'win32') {
138
+ return;
139
+ }
140
+
141
+ it('an --isolated-user run exports them with an empty session name', () => {
142
+ const command = isolatedUserCommand(PRINT, UUID, { attemptNumber: 2 });
143
+ expect(command).toStartWith('export START_EXECUTION_ID=');
144
+ const result = spawnSync('sh', ['-c', command], { encoding: 'utf8' });
145
+ expect(result.stdout.trim()).toBe(`ID=${UUID} S= A=2`);
146
+ });
147
+
148
+ it("$ --session-id X -- sh -c 'echo $START_EXECUTION_ID' prints X", () => {
149
+ const result = run([
150
+ '--session-id',
151
+ UUID,
152
+ '--',
153
+ 'sh',
154
+ '-c',
155
+ 'echo $START_EXECUTION_ID',
156
+ ]);
157
+ expect(result.status).toBe(0);
158
+ expect(result.stdout).toContain(`\n${UUID}\n`);
159
+ });
160
+
161
+ it('a direct run exports an empty session name and attempt 1', () => {
162
+ const result = run(['--session-id', UUID, '--', 'sh', '-c', PRINT]);
163
+ expect(result.stdout).toContain(`ID=${UUID} S= A=1`);
164
+ });
165
+
166
+ it('a generated execution id is exported too', () => {
167
+ const result = run(['--', 'sh', '-c', PRINT]);
168
+ const match = /ID=([0-9a-f-]{36}) S= A=1/.exec(result.stdout);
169
+ expect(match).not.toBeNull();
170
+ expect(result.stdout).toContain(match[1]);
171
+ });
172
+
173
+ it('the command-stream runner exports them', () => {
174
+ const result = run(
175
+ ['--session-id', UUID, '--use-command-stream', '--', 'sh', '-c', PRINT],
176
+ {}
177
+ );
178
+ expect(result.stdout).toContain(`ID=${UUID} S= A=1`);
179
+ });
180
+
181
+ it.skipIf(!hasCommand('screen'))(
182
+ 'screen exports them with the session name',
183
+ () => {
184
+ const session = `start-230-${process.pid}`;
185
+ const result = run([
186
+ '-i',
187
+ 'screen',
188
+ '--session',
189
+ session,
190
+ '--session-id',
191
+ UUID,
192
+ '--',
193
+ 'sh',
194
+ '-c',
195
+ PRINT,
196
+ ]);
197
+ expect(result.stdout).toContain(`ID=${UUID} S=${session} A=1`);
198
+ },
199
+ 30000
200
+ );
201
+
202
+ // Attached tmux needs a terminal, which CI and test runners do not have,
203
+ // so this runs detached and reads the command output from the log.
204
+ it.skipIf(!hasCommand('tmux'))(
205
+ 'tmux exports them with the session name',
206
+ () => {
207
+ const session = `start-230-tmux-${process.pid}`;
208
+ const result = run([
209
+ '-i',
210
+ 'tmux',
211
+ '--detached',
212
+ '--session',
213
+ session,
214
+ '--session-id',
215
+ UUID,
216
+ '--',
217
+ 'sh',
218
+ '-c',
219
+ PRINT,
220
+ ]);
221
+ expect(result.status).toBe(0);
222
+ const logPath = result.stdout.match(/^│ log\s+(.+)$/m)[1].trim();
223
+ const expected = `ID=${UUID} S=${session} A=1`;
224
+ let log = '';
225
+ for (let i = 0; i < 100 && !log.includes(expected); i++) {
226
+ spawnSync('sleep', ['0.1']);
227
+ log = fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : '';
228
+ }
229
+ spawnSync('tmux', ['kill-session', '-t', session]);
230
+ expect(log).toContain(expected);
231
+ },
232
+ 30000
233
+ );
234
+ });