start-command 0.37.9 → 0.39.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.
Files changed (78) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +7 -2
  3. package/bunfig.toml +4 -0
  4. package/eslint.config.mjs +1 -0
  5. package/package.json +4 -4
  6. package/src/bin/cli.js +62 -26
  7. package/src/lib/args-parser-queries.js +45 -1
  8. package/src/lib/args-parser.js +19 -0
  9. package/src/lib/cgroup-memory.js +95 -23
  10. package/src/lib/command-builder.js +4 -0
  11. package/src/lib/container-removal.js +4 -5
  12. package/src/lib/detached-finalize.js +1 -0
  13. package/src/lib/docker-command-handoff.js +34 -6
  14. package/src/lib/docker-init-option.js +65 -0
  15. package/src/lib/docker-inspect-batch.js +176 -0
  16. package/src/lib/docker-resource-options.js +21 -2
  17. package/src/lib/docker-runtime-args.js +19 -3
  18. package/src/lib/execution-attempt.js +19 -9
  19. package/src/lib/execution-chain.js +182 -0
  20. package/src/lib/execution-control.js +8 -6
  21. package/src/lib/execution-env.js +120 -0
  22. package/src/lib/execution-identity.js +180 -0
  23. package/src/lib/execution-recovery.js +21 -1
  24. package/src/lib/execution-resume.js +21 -1
  25. package/src/lib/execution-store.js +30 -23
  26. package/src/lib/exit-evidence.js +139 -7
  27. package/src/lib/isolation.js +21 -3
  28. package/src/lib/launch-identity-options.js +81 -0
  29. package/src/lib/launch-identity.js +411 -0
  30. package/src/lib/log-uploader.js +15 -1
  31. package/src/lib/output-blocks.js +11 -0
  32. package/src/lib/query-commands.js +18 -3
  33. package/src/lib/resume-resources.js +2 -2
  34. package/src/lib/screen-isolation.js +11 -2
  35. package/src/lib/spawn-helpers.js +33 -16
  36. package/src/lib/status-formatter.js +39 -131
  37. package/src/lib/status-probe.js +149 -0
  38. package/src/lib/store-decode-cache.js +141 -0
  39. package/src/lib/usage.js +11 -1
  40. package/test/args-parser-attach-resume.js +1 -1
  41. package/test/args-parser-control.js +2 -2
  42. package/test/cargo-lock-version.mjs +121 -0
  43. package/test/check-rust-coverage.mjs +71 -0
  44. package/test/check-web-archive.mjs +154 -0
  45. package/test/ci-workflow-invariants.js +204 -0
  46. package/test/command-builder-docker-cleanup.js +24 -0
  47. package/test/command-exit-codes.mjs +91 -0
  48. package/test/docker-runtime-options.js +2 -0
  49. package/test/docker-snapshot-safety.js +39 -0
  50. package/test/execution-resume.js +2 -0
  51. package/test/fixtures/fake-npm.sh +64 -0
  52. package/test/fixtures/lychee-report.md +39 -0
  53. package/test/javascript-first-ci.js +69 -0
  54. package/test/links-workflow.mjs +127 -0
  55. package/test/lint-gate.mjs +56 -0
  56. package/test/npm-publish.mjs +181 -0
  57. package/test/recheck-broken-links.mjs +452 -0
  58. package/test/regression-176.js +2 -0
  59. package/test/regression-187.js +2 -0
  60. package/test/regression-191.js +4 -0
  61. package/test/regression-193.js +2 -0
  62. package/test/regression-198.js +2 -0
  63. package/test/regression-207.js +8 -2
  64. package/test/regression-224.js +2 -0
  65. package/test/regression-229.js +267 -0
  66. package/test/regression-230.js +234 -0
  67. package/test/regression-231.js +298 -0
  68. package/test/regression-236.js +250 -0
  69. package/test/regression-238.js +129 -0
  70. package/test/regression-241.js +254 -0
  71. package/test/regression-242.js +241 -0
  72. package/test/regression-243.js +493 -0
  73. package/test/regression-244.js +471 -0
  74. package/test/release-push-retry.mjs +172 -0
  75. package/test/run-js-tests.mjs +15 -3
  76. package/test/setup/private-snapshot-lock.js +17 -0
  77. package/test/shared-golden.js +44 -2
  78. package/test/transient-links.mjs +139 -0
@@ -0,0 +1,411 @@
1
+ /**
2
+ * What a new launch may do with its execution id (issue #229).
3
+ *
4
+ * A launch with an explicit `--session-id` that is already tracked used to
5
+ * replace the record and truncate the log. It is now refused unless
6
+ * `--append-attempt` is given, which keeps the record, archives the previous
7
+ * attempt into `attemptHistory`, and appends the new attempt to the same log
8
+ * after a lifecycle header (the layout `--resume` already writes).
9
+ *
10
+ * Two cases keep the earlier behaviour:
11
+ * - the next level of a stacked isolation, which receives the same
12
+ * `--session-id` from its parent and runs with START_EXECUTION_ID already
13
+ * set to it (`isNestedLaunchOf`);
14
+ * - a generated UUID, which cannot collide.
15
+ */
16
+ const fs = require('fs');
17
+ const { isNestedLaunchOf, ATTEMPT_ENV } = require('./execution-env');
18
+ const { findRecord, withStoreLock } = require('./execution-identity');
19
+
20
+ const LOG_HEADER = '=== Start Command Log ===';
21
+
22
+ class LaunchRefusedError extends Error {}
23
+
24
+ function alreadyExistsMessage(uuid) {
25
+ return `execution ${uuid} already exists; use $ --resume ${uuid} -- <command> to continue it, or pass --append-attempt`;
26
+ }
27
+
28
+ function isPidAlive(pid) {
29
+ if (!pid) {
30
+ return false;
31
+ }
32
+ try {
33
+ process.kill(pid, 0);
34
+ return true;
35
+ } catch (error) {
36
+ return error.code === 'EPERM';
37
+ }
38
+ }
39
+
40
+ /** True while the stored execution may still be running. */
41
+ function isExecutionLive(record) {
42
+ if (require('./launch-owner').hasActiveLaunch(record)) {
43
+ return true;
44
+ }
45
+ if (record.status !== 'executing') {
46
+ return false;
47
+ }
48
+ if (record.options && record.options.isolated) {
49
+ try {
50
+ return require('./session-probe').probeSession(record).alive === true;
51
+ } catch {
52
+ return true;
53
+ }
54
+ }
55
+ return isPidAlive(record.pid);
56
+ }
57
+
58
+ /** Attempts already in a log written without tracking, from its headers. */
59
+ function countLoggedAttempts(logPath) {
60
+ try {
61
+ return fs.readFileSync(logPath, 'utf8').split(LOG_HEADER).length - 1;
62
+ } catch {
63
+ return 0;
64
+ }
65
+ }
66
+
67
+ function logSize(logPath) {
68
+ try {
69
+ return fs.statSync(logPath).size;
70
+ } catch {
71
+ return 0;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Decide how a launch uses its execution id, before anything is printed or
77
+ * created.
78
+ * @param {object} params - {store, sessionId, explicitSessionId,
79
+ * appendAttempt, logPath, env}
80
+ * @returns {{append: boolean, previous: object|null, attemptNumber: number,
81
+ * logPath: string, nested: boolean, checked: boolean}}
82
+ * @throws {LaunchRefusedError} When the launch must not run
83
+ */
84
+ function preflightLaunch(params) {
85
+ const { store, sessionId, logPath } = params;
86
+ const env = params.env || process.env;
87
+ const fresh = {
88
+ append: false,
89
+ previous: null,
90
+ attemptNumber: 1,
91
+ logPath,
92
+ nested: false,
93
+ checked: false,
94
+ };
95
+ if (!params.explicitSessionId) {
96
+ return fresh;
97
+ }
98
+ if (isNestedLaunchOf(sessionId, env)) {
99
+ const inherited = Number(env[ATTEMPT_ENV]);
100
+ return {
101
+ ...fresh,
102
+ nested: true,
103
+ attemptNumber:
104
+ Number.isSafeInteger(inherited) && inherited > 0 ? inherited : 1,
105
+ };
106
+ }
107
+ const existing = store ? store.get(sessionId) : null;
108
+ if (existing && existing.uuid !== sessionId) {
109
+ throw new LaunchRefusedError(
110
+ `execution id ${sessionId} is already used as a name of execution ${existing.uuid}; pick another --session-id`
111
+ );
112
+ }
113
+ if (existing) {
114
+ if (!params.appendAttempt) {
115
+ throw new LaunchRefusedError(alreadyExistsMessage(sessionId));
116
+ }
117
+ if (isExecutionLive(existing)) {
118
+ throw new LaunchRefusedError(
119
+ `execution ${sessionId} is still running; wait for it to finish or use $ --stop ${sessionId} before --append-attempt`
120
+ );
121
+ }
122
+ const { nextAttemptNumber } = require('./execution-attempt');
123
+ return {
124
+ ...fresh,
125
+ append: true,
126
+ previous: existing,
127
+ attemptNumber: nextAttemptNumber(existing),
128
+ logPath: existing.logPath || logPath,
129
+ checked: true,
130
+ };
131
+ }
132
+ // Untracked (or deleted) executions are recognised by their log file.
133
+ if (logPath && fs.existsSync(logPath)) {
134
+ if (!params.appendAttempt) {
135
+ throw new LaunchRefusedError(alreadyExistsMessage(sessionId));
136
+ }
137
+ return {
138
+ ...fresh,
139
+ append: true,
140
+ attemptNumber: countLoggedAttempts(logPath) + 1,
141
+ checked: true,
142
+ };
143
+ }
144
+ return { ...fresh, checked: true };
145
+ }
146
+
147
+ /**
148
+ * Turn the record a launch built into the next attempt of `previous`,
149
+ * keeping the identity, history and log of the execution.
150
+ * @param {object} record - Freshly built ExecutionRecord (mutated)
151
+ * @param {object} previous - Stored record of the same UUID
152
+ * @returns {object} The record
153
+ */
154
+ function mergeAppendedAttempt(record, previous) {
155
+ const {
156
+ archiveAttempt,
157
+ createAttempt,
158
+ currentAttemptNumber,
159
+ } = require('./execution-attempt');
160
+ const prior = require('./execution-store').ExecutionRecord.fromObject(
161
+ JSON.parse(JSON.stringify(previous.toObject()))
162
+ );
163
+ if (!prior.attempt && currentAttemptNumber(prior) === 1) {
164
+ // A launch writes a fresh log, so its first attempt starts at offset 0.
165
+ prior.attempt = { number: 1, startedAt: prior.startTime, logOffset: 0 };
166
+ }
167
+ const attempt = createAttempt(prior, {
168
+ mode: 'append-attempt',
169
+ sessionName: record.options.sessionName || null,
170
+ });
171
+ archiveAttempt(prior);
172
+ const keep = prior.options || {};
173
+ const history = [...(keep.sessionNameHistory || [])];
174
+ if (keep.sessionName && keep.sessionName !== record.options.sessionName) {
175
+ history.push(keep.sessionName);
176
+ }
177
+ record.attempt = attempt;
178
+ record.attemptHistory = prior.attemptHistory;
179
+ record.startTime = prior.startTime;
180
+ record.logPath = prior.logPath || record.logPath;
181
+ record.continues = record.continues || prior.continues;
182
+ record.options = {
183
+ ...record.options,
184
+ ...(history.length ? { sessionNameHistory: [...new Set(history)] } : {}),
185
+ ...(keep.aliases ? { aliases: keep.aliases } : {}),
186
+ ...(keep.name ? { name: keep.name } : {}),
187
+ ...(keep.resumeCount ? { resumeCount: keep.resumeCount } : {}),
188
+ };
189
+ return record;
190
+ }
191
+
192
+ /**
193
+ * Persist the launch's record, refusing if another launch claimed the id in
194
+ * the meantime: a new id must still be free, an appended attempt must still
195
+ * follow the attempt the preflight saw.
196
+ * @param {object} store - ExecutionStore
197
+ * @param {object} record - Record to save
198
+ * @param {object} launch - preflightLaunch() result
199
+ */
200
+ function claimLaunch(store, record, launch) {
201
+ if (!launch.checked) {
202
+ store.save(record);
203
+ return;
204
+ }
205
+ withStoreLock(store, (records) => {
206
+ const index = records.findIndex((r) => r.uuid === record.uuid);
207
+ if (launch.previous) {
208
+ const current = records[index];
209
+ if (
210
+ !current ||
211
+ require('./launch-owner').hasActiveLaunch(current) ||
212
+ (current.attempt?.number || 0) !==
213
+ (launch.previous.attempt?.number || 0)
214
+ ) {
215
+ throw new LaunchRefusedError(
216
+ `execution ${record.uuid} was claimed by another launch`
217
+ );
218
+ }
219
+ records[index] = record;
220
+ } else {
221
+ if (findRecord(records, record.uuid)) {
222
+ throw new LaunchRefusedError(alreadyExistsMessage(record.uuid));
223
+ }
224
+ records.push(record);
225
+ }
226
+ return record;
227
+ });
228
+ }
229
+
230
+ /**
231
+ * Header a launch writes into its log: unchanged for a fresh log; for an
232
+ * appended attempt, preceded by a lifecycle line in the format `--resume`
233
+ * writes, so the attempts stay apart.
234
+ * @param {string} content - Header content
235
+ * @param {object} launch - preflightLaunch() result
236
+ * @param {object|null} record - Record of the launch, when tracked
237
+ * @returns {string} Content to write
238
+ */
239
+ function launchLogContent(content, launch, record = null) {
240
+ if (!launch.append) {
241
+ return content;
242
+ }
243
+ const lifecycle = {
244
+ event: 'attempt-appended',
245
+ at: new Date().toISOString(),
246
+ uuid: record ? record.uuid : null,
247
+ attemptNumber: launch.attemptNumber,
248
+ ...(record && record.attempt ? record.attempt : {}),
249
+ };
250
+ return `\n[Start Command Lifecycle] ${JSON.stringify(lifecycle)}\n${content}`;
251
+ }
252
+
253
+ /**
254
+ * Save the initial record of a direct run, before it starts. A refused
255
+ * claim ends the launch with exit code 1; other failures only lose tracking.
256
+ * @param {object|null} store - ExecutionStore
257
+ * @param {object|null} record - Execution record
258
+ * @param {object} launch - prepareLaunch() result
259
+ * @param {object} [config] - CLI configuration ({verbose})
260
+ */
261
+ function claimDirectLaunch(store, record, launch, config = {}) {
262
+ if (!record || !store) {
263
+ return;
264
+ }
265
+ try {
266
+ claimLaunch(store, record, launch);
267
+ } catch (err) {
268
+ if (err instanceof LaunchRefusedError) {
269
+ console.error(`Error: ${err.message}`);
270
+ process.exit(1);
271
+ }
272
+ if (config.verbose) {
273
+ console.error(
274
+ `[Tracking] Warning: Could not save execution record: ${err.message}`
275
+ );
276
+ }
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Write the launch header: a fresh log, or, for an appended attempt, a
282
+ * lifecycle line and the header after the earlier attempts.
283
+ * @param {string} logPath - Log path
284
+ * @param {string} content - Header content
285
+ * @param {object} launch - preflightLaunch() result
286
+ * @param {object|null} record - Record of the launch, when tracked
287
+ */
288
+ function writeLaunchLog(logPath, content, launch, record = null) {
289
+ const { appendLogFile, writeLogFile } = require('./isolation-log-utils');
290
+ const text = launchLogContent(content, launch, record);
291
+ if (launch.append) {
292
+ appendLogFile(logPath, text);
293
+ } else {
294
+ writeLogFile(logPath, text);
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Preflight of the CLI: how this launch uses its id (#229) and which
300
+ * execution it continues (#231).
301
+ * @param {object} store - ExecutionStore, or null when tracking is off
302
+ * @param {object} options - Parsed wrapper options
303
+ * @param {string} sessionId - Execution UUID of the launch
304
+ * @param {string} logPath - Default log path of the launch
305
+ * @returns {object} preflightLaunch() result plus `continues`
306
+ * @throws {Error} When the launch must not run
307
+ */
308
+ function prepareLaunch(store, options, sessionId, logPath) {
309
+ const launch = preflightLaunch({
310
+ store,
311
+ sessionId,
312
+ explicitSessionId: Boolean(options.sessionId),
313
+ appendAttempt: Boolean(options.appendAttempt),
314
+ logPath,
315
+ });
316
+ launch.continues = options.continues
317
+ ? require('./execution-chain').resolveContinues(
318
+ store,
319
+ options.continues,
320
+ sessionId
321
+ )
322
+ : null;
323
+ return launch;
324
+ }
325
+
326
+ /**
327
+ * Apply the preflight to the record a launch built: the continued UUID and,
328
+ * for `--append-attempt`, the identity and history of the stored execution.
329
+ * @param {object|null} record - Freshly built ExecutionRecord (mutated)
330
+ * @param {object} launch - prepareLaunch() result
331
+ * @returns {object|null} The record
332
+ */
333
+ function applyLaunch(record, launch) {
334
+ if (!record) {
335
+ return record;
336
+ }
337
+ if (launch.continues) {
338
+ record.continues = launch.continues;
339
+ }
340
+ if (launch.previous) {
341
+ mergeAppendedAttempt(record, launch.previous);
342
+ } else if (launch.attemptNumber > 1) {
343
+ // A nested level or an untracked log: number the attempt all the same.
344
+ record.attempt = {
345
+ number: launch.attemptNumber,
346
+ startedAt: new Date().toISOString(),
347
+ logOffset: launch.append ? logSize(launch.logPath) : 0,
348
+ mode: launch.nested ? 'nested' : 'append-attempt',
349
+ };
350
+ }
351
+ return record;
352
+ }
353
+
354
+ /**
355
+ * START_* variables of a run without a backend session (direct, or
356
+ * `--isolated-user` alone): the session name is empty.
357
+ * @param {string} sessionId - Execution UUID
358
+ * @param {object} launch - prepareLaunch() result
359
+ * @returns {object} Variables
360
+ */
361
+ function directExecutionEnv(sessionId, launch) {
362
+ return require('./execution-env').buildExecutionEnv({
363
+ executionId: sessionId,
364
+ sessionName: '',
365
+ attempt: launch.attemptNumber,
366
+ });
367
+ }
368
+
369
+ /**
370
+ * Child environment of a direct run.
371
+ * @param {string} sessionId - Execution UUID
372
+ * @param {object} launch - prepareLaunch() result
373
+ * @returns {object} Child environment
374
+ */
375
+ function directLaunchEnv(sessionId, launch) {
376
+ return require('./execution-env').withExecutionEnv(
377
+ directExecutionEnv(sessionId, launch)
378
+ );
379
+ }
380
+
381
+ /**
382
+ * Command of an `--isolated-user` run without a backend: `sudo` resets the
383
+ * environment, so the variables are exported by the command itself.
384
+ * @param {string} cmd - Shell command
385
+ * @param {string} sessionId - Execution UUID
386
+ * @param {object} launch - prepareLaunch() result
387
+ * @returns {string} Prefixed command
388
+ */
389
+ function isolatedUserCommand(cmd, sessionId, launch) {
390
+ return require('./execution-env').prefixWithExecutionEnv(
391
+ cmd,
392
+ directExecutionEnv(sessionId, launch)
393
+ );
394
+ }
395
+
396
+ module.exports = {
397
+ LaunchRefusedError,
398
+ alreadyExistsMessage,
399
+ applyLaunch,
400
+ claimDirectLaunch,
401
+ claimLaunch,
402
+ directExecutionEnv,
403
+ directLaunchEnv,
404
+ isolatedUserCommand,
405
+ isExecutionLive,
406
+ launchLogContent,
407
+ mergeAppendedAttempt,
408
+ prepareLaunch,
409
+ preflightLaunch,
410
+ writeLaunchLog,
411
+ };
@@ -200,7 +200,21 @@ function uploadExecutionLog(store, identifier, options = {}) {
200
200
  };
201
201
  }
202
202
 
203
- return uploadLogPath(record.logPath, options);
203
+ // A continuation chain uploads every execution, oldest first (#231).
204
+ let prepared;
205
+ try {
206
+ prepared = require('./execution-chain').prepareUploadLog(store, record);
207
+ } catch (error) {
208
+ return {
209
+ success: false,
210
+ error: `Could not join chain logs: ${error.message}`,
211
+ };
212
+ }
213
+ try {
214
+ return uploadLogPath(prepared.path, options);
215
+ } finally {
216
+ prepared.cleanup();
217
+ }
204
218
  }
205
219
 
206
220
  module.exports = {
@@ -119,6 +119,7 @@ function parseIsolationMetadata(extraLines) {
119
119
  session: null,
120
120
  endpoint: null,
121
121
  user: null,
122
+ init: false,
122
123
  };
123
124
 
124
125
  for (const line of extraLines) {
@@ -146,6 +147,12 @@ function parseIsolationMetadata(extraLines) {
146
147
  continue;
147
148
  }
148
149
 
150
+ // Parse [Isolation] Init: true (docker run --init, issue #244)
151
+ if (line === '[Isolation] Init: true') {
152
+ metadata.init = true;
153
+ continue;
154
+ }
155
+
149
156
  // Parse [Isolation] Endpoint: user@host
150
157
  const endpointMatch = line.match(/\[Isolation\] Endpoint: (.+)/);
151
158
  if (endpointMatch) {
@@ -207,6 +214,10 @@ function generateIsolationLines(metadata, containerOrScreenName = null) {
207
214
  }
208
215
  }
209
216
 
217
+ if (metadata.init) {
218
+ lines.push(createTimelineLine('init', 'true'));
219
+ }
220
+
210
221
  if (metadata.user) {
211
222
  lines.push(createTimelineLine('user', metadata.user));
212
223
  }
@@ -4,8 +4,9 @@
4
4
  * Every option in this module addresses an execution that already exists in
5
5
  * the tracking store instead of launching a new one: --status, --list,
6
6
  * --upload-log, --stop, --terminate, --attach, --resume, --resume-all and
7
- * --cleanup. `dispatchQueryCommand` is the single entry point the CLI calls
8
- * before it starts interpreting the command line as a command to run.
7
+ * --cleanup, plus --alias and --rename. `dispatchQueryCommand` is the single
8
+ * entry point the CLI calls before it starts interpreting the command line as
9
+ * a command to run.
9
10
  */
10
11
 
11
12
  const { queryStatus, listExecutions } = require('./status-formatter');
@@ -157,7 +158,9 @@ function hasQueryMode(options) {
157
158
  hasValue(options.attach) ||
158
159
  hasValue(options.resume) ||
159
160
  options.resumeAll === true ||
160
- options.cleanup === true
161
+ options.cleanup === true ||
162
+ hasValue(options.alias) ||
163
+ hasValue(options.rename)
161
164
  );
162
165
  }
163
166
 
@@ -208,6 +211,18 @@ async function dispatchQueryCommand(options, context = {}) {
208
211
  if (options.cleanup) {
209
212
  return handleCleanup(store, options.cleanupDryRun, options.cleanupTarget);
210
213
  }
214
+ if (hasValue(options.alias) || hasValue(options.rename)) {
215
+ const { nameExecution } = require('./execution-identity');
216
+ const rename = hasValue(options.rename);
217
+ return reportResult(
218
+ nameExecution(
219
+ store,
220
+ rename ? options.rename : options.alias,
221
+ options.newName,
222
+ { rename }
223
+ )
224
+ );
225
+ }
211
226
 
212
227
  return null;
213
228
  }
@@ -34,7 +34,7 @@ function prepareResumeResources(record, probe, overrides, runner, random) {
34
34
  const options = record.options || {};
35
35
  if (options.isolated !== 'docker') {
36
36
  if (
37
- ['memory', 'memorySwap', 'cpus'].some(
37
+ ['memory', 'memorySwap', 'memoryReservation', 'cpus'].some(
38
38
  (k) => overrides[k] !== null && overrides[k] !== undefined
39
39
  )
40
40
  ) {
@@ -81,7 +81,7 @@ function prepareResumeResources(record, probe, overrides, runner, random) {
81
81
  };
82
82
  result.update =
83
83
  probe.state === 'stopped' &&
84
- (['memory', 'memorySwap', 'cpus'].some(
84
+ (['memory', 'memorySwap', 'memoryReservation', 'cpus'].some(
85
85
  (k) => overrides[k] !== null && overrides[k] !== undefined
86
86
  ) ||
87
87
  Boolean(options.cpuPenaltyConfig));
@@ -128,6 +128,8 @@ function supportsLogfileOption() {
128
128
  * @param {string|null} user - Username to run command as (optional)
129
129
  * @param {Function} wrapCommandWithUser - Function to wrap command with user
130
130
  * @param {Function} isInteractiveShellCommand - Function to check if command is interactive shell
131
+ * @param {string|null} logPath - Log file to append to (optional)
132
+ * @param {object|null} env - Extra environment, e.g. START_* (optional)
131
133
  * @returns {Promise<{success: boolean, sessionName: string, message: string, output: string, exitCode: number}>}
132
134
  */
133
135
  function runScreenWithLogCapture(
@@ -137,7 +139,8 @@ function runScreenWithLogCapture(
137
139
  user = null,
138
140
  wrapCommandWithUser,
139
141
  isInteractiveShellCommand,
140
- logPath = null
142
+ logPath = null,
143
+ env = null
141
144
  ) {
142
145
  const { shell, shellArg } = shellInfo;
143
146
  const screenTempDir = getTempDir('isolation', 'screen');
@@ -237,8 +240,10 @@ function runScreenWithLogCapture(
237
240
  }
238
241
 
239
242
  // Use spawnSync with array args (not execSync string) to avoid quoting issues (issue #25)
243
+ // Each `screen -dmS` server inherits this env (START_*, issue #230).
240
244
  const result = spawnSync('screen', screenArgs, {
241
245
  stdio: 'inherit',
246
+ env: { ...process.env, ...env },
242
247
  });
243
248
 
244
249
  if (result.error) {
@@ -424,6 +429,7 @@ function startDetachedScreenWithLogCapture(
424
429
  keepAlive = false,
425
430
  logPath = null,
426
431
  wrapCommandWithUser,
432
+ env = null,
427
433
  } = options;
428
434
 
429
435
  const screenTempDir = getTempDir('isolation', 'screen');
@@ -471,7 +477,10 @@ function startDetachedScreenWithLogCapture(
471
477
  }
472
478
 
473
479
  try {
474
- const result = spawnSync('screen', screenArgs, { stdio: 'inherit' });
480
+ const result = spawnSync('screen', screenArgs, {
481
+ stdio: 'inherit',
482
+ env: { ...process.env, ...env },
483
+ });
475
484
  if (result.error) {
476
485
  throw result.error;
477
486
  }
@@ -18,9 +18,14 @@ function ensureParentDirectory(filePath) {
18
18
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
19
19
  }
20
20
 
21
- function writeInitialLog(logFilePath, logContent) {
21
+ function writeInitialLog(logFilePath, logContent, append = false) {
22
22
  ensureParentDirectory(logFilePath);
23
- fs.writeFileSync(logFilePath, logContent || '', 'utf8');
23
+ if (append) {
24
+ // `--append-attempt` (issue #229): never truncate earlier attempts.
25
+ fs.appendFileSync(logFilePath, logContent || '', 'utf8');
26
+ } else {
27
+ fs.writeFileSync(logFilePath, logContent || '', 'utf8');
28
+ }
24
29
  }
25
30
 
26
31
  function appendLog(logFilePath, content) {
@@ -40,6 +45,8 @@ function appendLog(logFilePath, content) {
40
45
  * @param {Object} options.executionRecord - Execution tracking record
41
46
  * @param {Object} options.store - Execution store
42
47
  * @param {Object} options.config - CLI configuration
48
+ * @param {Object} [options.env] - Child environment (defaults to process.env)
49
+ * @param {boolean} [options.appendLog] - Append to an existing log (issue #229)
43
50
  * @param {Function} options.onComplete - Callback for completion (exitCode, endTime, logContent, durationMs)
44
51
  * @param {Function} options.onError - Callback for errors (errorMessage, endTime, durationMs)
45
52
  */
@@ -54,18 +61,21 @@ async function runWithBunSpawn(options) {
54
61
  config,
55
62
  onComplete,
56
63
  onError,
64
+ env,
65
+ appendLog: appendToLog = false,
57
66
  } = options;
58
67
 
59
68
  let logContent = options.logContent || '';
60
69
 
61
70
  try {
62
- writeInitialLog(logFilePath, logContent);
71
+ writeInitialLog(logFilePath, logContent, appendToLog);
63
72
 
64
73
  // Spawn the process using Bun's native API
65
74
  const proc = Bun.spawn([shell, ...shellArgs], {
66
75
  stdout: 'pipe',
67
76
  stderr: 'pipe',
68
77
  stdin: 'inherit',
78
+ ...(env ? { env } : {}),
69
79
  });
70
80
 
71
81
  // Update execution record with PID and save initial state
@@ -186,14 +196,16 @@ async function runWithBunSpawn(options) {
186
196
  const endTime = new Date().toISOString().replace('T', ' ').substring(0, 23);
187
197
  const errorMessage = `Error executing command: ${err.message}`;
188
198
 
189
- logContent += `\n${errorMessage}\n`;
190
- logContent += `\n${'='.repeat(50)}\n`;
191
- logContent += `Finished: ${endTime}\n`;
192
- logContent += `Exit Code: 1\n`;
199
+ const errorTail = `\n${errorMessage}\n\n${'='.repeat(50)}\nFinished: ${endTime}\nExit Code: 1\n`;
200
+ logContent += errorTail;
193
201
 
194
- // Write log file
202
+ // Write log file; an appended attempt keeps the earlier attempts (#229).
195
203
  try {
196
- writeInitialLog(logFilePath, logContent);
204
+ if (appendToLog) {
205
+ writeInitialLog(logFilePath, errorTail, true);
206
+ } else {
207
+ writeInitialLog(logFilePath, logContent);
208
+ }
197
209
  } catch (writeErr) {
198
210
  console.error(`\nWarning: Could not save log file: ${writeErr.message}`);
199
211
  }
@@ -238,15 +250,18 @@ function runWithNodeSpawn(options) {
238
250
  config,
239
251
  onComplete,
240
252
  onError,
253
+ env,
254
+ appendLog: appendToLog = false,
241
255
  } = options;
242
256
 
243
257
  let logContent = options.logContent || '';
244
- writeInitialLog(logFilePath, logContent);
258
+ writeInitialLog(logFilePath, logContent, appendToLog);
245
259
 
246
260
  // Execute the command with captured output
247
261
  const child = spawn(shell, shellArgs, {
248
262
  stdio: ['inherit', 'pipe', 'pipe'],
249
263
  shell: false,
264
+ ...(env ? { env } : {}),
250
265
  });
251
266
 
252
267
  // Update execution record with PID and save initial state
@@ -330,14 +345,16 @@ function runWithNodeSpawn(options) {
330
345
  const endTime = new Date().toISOString().replace('T', ' ').substring(0, 23);
331
346
  const errorMessage = `Error executing command: ${err.message}`;
332
347
 
333
- logContent += `\n${errorMessage}\n`;
334
- logContent += `\n${'='.repeat(50)}\n`;
335
- logContent += `Finished: ${endTime}\n`;
336
- logContent += `Exit Code: 1\n`;
348
+ const errorTail = `\n${errorMessage}\n\n${'='.repeat(50)}\nFinished: ${endTime}\nExit Code: 1\n`;
349
+ logContent += errorTail;
337
350
 
338
- // Write log file
351
+ // Write log file; an appended attempt keeps the earlier attempts (#229).
339
352
  try {
340
- writeInitialLog(logFilePath, logContent);
353
+ if (appendToLog) {
354
+ writeInitialLog(logFilePath, errorTail, true);
355
+ } else {
356
+ writeInitialLog(logFilePath, logContent);
357
+ }
341
358
  } catch (writeErr) {
342
359
  console.error(`\nWarning: Could not save log file: ${writeErr.message}`);
343
360
  }