start-command 0.25.0 → 0.25.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # start-command
2
2
 
3
+ ## 0.25.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 8f58d2b: fix: support --session name lookups in --status and track detached session lifecycle
8
+
9
+ `--status` now accepts session names in addition to UUIDs. Detached mode no longer incorrectly reports immediate completion — status is determined at query time by checking if the actual session is still running.
10
+
11
+ ## 0.25.1
12
+
13
+ ### Patch Changes
14
+
15
+ - da6df0e: fix: correct license field from MIT to Unlicense (public domain)
16
+
17
+ Updated `package.json` to correctly reflect the Unlicense (public domain) license instead of MIT. The project's `LICENSE` file has always contained the Unlicense text; this change aligns the metadata with the actual license.
18
+
19
+ Fixes #99
20
+
3
21
  ## 0.25.0
4
22
 
5
23
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.25.0",
3
+ "version": "0.25.2",
4
4
  "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
5
5
  "main": "src/bin/cli.js",
6
6
  "exports": {
@@ -32,7 +32,7 @@
32
32
  "automation"
33
33
  ],
34
34
  "author": "",
35
- "license": "MIT",
35
+ "license": "Unlicense",
36
36
  "engines": {
37
37
  "bun": ">=1.0.0"
38
38
  },
package/src/bin/cli.js CHANGED
@@ -333,7 +333,7 @@ Options:
333
333
  --auto-remove-docker-container Auto-remove docker container after exit
334
334
  --shell <shell> Shell to use in isolation environments: auto, bash, zsh, sh (default: auto)
335
335
  --use-command-stream Use command-stream library for execution (experimental)
336
- --status <uuid> Show status of execution by UUID (--output-format: links-notation|json|text)
336
+ --status <id> Show status of execution by UUID or session name (--output-format: links-notation|json|text)
337
337
  --cleanup Clean up stale "executing" records (crashed/killed processes)
338
338
  --cleanup-dry-run Show stale records that would be cleaned up (without cleaning)
339
339
  --version, -v Show version information
@@ -468,12 +468,9 @@ async function runWithIsolation(
468
468
  }
469
469
  }
470
470
 
471
- // Add isolation info to extra lines
471
+ // Add isolation info to extra lines (session name for reconnecting, see issue #67)
472
472
  if (environment) {
473
473
  extraLines.push(`[Isolation] Environment: ${environment}, Mode: ${mode}`);
474
- // Always add the session name so users can reconnect to detached sessions
475
- // This is important for screen, tmux, docker where the session/container name
476
- // is different from the session UUID used for tracking (see issue #67)
477
474
  extraLines.push(`[Isolation] Session: ${sessionName}`);
478
475
  }
479
476
  if (effectiveImage) {
@@ -602,9 +599,11 @@ async function runWithIsolation(
602
599
  // Write log file
603
600
  writeLogFile(logFilePath, logContent);
604
601
 
605
- // Update execution record as completed and clear global reference
602
+ // Update execution record: detached keeps "executing" (resolved at query time)
606
603
  if (executionRecord && store) {
607
- executionRecord.complete(exitCode);
604
+ if (mode !== 'detached') {
605
+ executionRecord.complete(exitCode);
606
+ }
608
607
  try {
609
608
  store.save(executionRecord);
610
609
  } catch (err) {
@@ -614,7 +613,6 @@ async function runWithIsolation(
614
613
  );
615
614
  }
616
615
  }
617
- // Clear global reference since we've completed normally
618
616
  currentExecutionRecord = null;
619
617
  }
620
618
 
@@ -418,13 +418,13 @@ function parseOption(args, index, options) {
418
418
  return 1;
419
419
  }
420
420
 
421
- // --status <uuid>
421
+ // --status <uuid-or-session-name>
422
422
  if (arg === '--status') {
423
423
  if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
424
424
  options.status = args[index + 1];
425
425
  return 2;
426
426
  } else {
427
- throw new Error(`Option ${arg} requires a UUID argument`);
427
+ throw new Error(`Option ${arg} requires a UUID or session name argument`);
428
428
  }
429
429
  }
430
430
 
@@ -470,14 +470,23 @@ class ExecutionStore {
470
470
  }
471
471
 
472
472
  /**
473
- * Get an execution record by UUID
474
- * @param {string} uuid
473
+ * Get an execution record by UUID or session name
474
+ * First tries exact UUID match, then falls back to session name lookup
475
+ * @param {string} identifier - UUID or session name
475
476
  * @returns {ExecutionRecord|null}
476
477
  */
477
- get(uuid) {
478
+ get(identifier) {
478
479
  const records = this.readLinoRecords();
479
- const found = records.find((r) => r.uuid === uuid);
480
- return found || null;
480
+ // First try exact UUID match
481
+ const byUuid = records.find((r) => r.uuid === identifier);
482
+ if (byUuid) {
483
+ return byUuid;
484
+ }
485
+ // Fall back to session name lookup (stored in options.sessionName)
486
+ const bySessionName = records.find(
487
+ (r) => r.options && r.options.sessionName === identifier
488
+ );
489
+ return bySessionName || null;
481
490
  }
482
491
 
483
492
  /**
@@ -7,11 +7,108 @@
7
7
  * - Text: Human-readable text format
8
8
  */
9
9
 
10
+ const { execSync } = require('child_process');
10
11
  const {
11
12
  escapeForLinksNotation,
12
13
  formatAsNestedLinksNotation,
13
14
  } = require('./output-blocks');
14
15
 
16
+ /**
17
+ * Check if a detached isolation session is still running
18
+ * @param {Object} record - Execution record
19
+ * @returns {boolean|null} true if running, false if not, null if unable to determine
20
+ */
21
+ function isDetachedSessionAlive(record) {
22
+ const opts = record.options || {};
23
+ const sessionName = opts.sessionName;
24
+ const isolationMode = opts.isolationMode;
25
+ const isolated = opts.isolated;
26
+
27
+ if (!sessionName || isolationMode !== 'detached') {
28
+ return null;
29
+ }
30
+
31
+ try {
32
+ switch (isolated) {
33
+ case 'screen': {
34
+ const output = execSync('screen -ls', {
35
+ encoding: 'utf8',
36
+ stdio: ['pipe', 'pipe', 'pipe'],
37
+ });
38
+ return output.includes(sessionName);
39
+ }
40
+ case 'tmux': {
41
+ execSync(`tmux has-session -t ${sessionName}`, {
42
+ stdio: ['pipe', 'pipe', 'pipe'],
43
+ });
44
+ return true;
45
+ }
46
+ case 'docker': {
47
+ const output = execSync(
48
+ `docker inspect -f "{{.State.Running}}" ${sessionName}`,
49
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
50
+ );
51
+ return output.trim() === 'true';
52
+ }
53
+ case 'ssh': {
54
+ // For SSH, check if the PID is still running on remote would require
55
+ // re-connecting. Fall back to checking the local wrapper PID.
56
+ if (record.pid) {
57
+ try {
58
+ process.kill(record.pid, 0);
59
+ return true;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+ default:
67
+ return null;
68
+ }
69
+ } catch {
70
+ // Command failed - session is likely not running
71
+ return false;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Enrich execution record with live session status for detached executions.
77
+ * If a record shows "executing" but the detached session has actually ended,
78
+ * returns an updated copy with status "executed". If it shows "executed" but
79
+ * the session is still running, returns a copy with status "executing".
80
+ * @param {Object} record - Execution record
81
+ * @returns {Object} Possibly updated execution record
82
+ */
83
+ function enrichDetachedStatus(record) {
84
+ const alive = isDetachedSessionAlive(record);
85
+ if (alive === null) {
86
+ return record;
87
+ }
88
+
89
+ // Create a shallow copy to avoid mutating the original
90
+ const enriched = Object.create(Object.getPrototypeOf(record));
91
+ Object.assign(enriched, record);
92
+
93
+ if (alive && enriched.status === 'executed') {
94
+ // Session still running but record says executed - correct it
95
+ enriched.status = 'executing';
96
+ enriched.exitCode = null;
97
+ enriched.endTime = null;
98
+ } else if (!alive && enriched.status === 'executing') {
99
+ // Session ended but record says executing - correct it
100
+ enriched.status = 'executed';
101
+ if (enriched.exitCode === null || enriched.exitCode === undefined) {
102
+ enriched.exitCode = -1; // Unknown exit code
103
+ }
104
+ if (!enriched.endTime) {
105
+ enriched.endTime = new Date().toISOString();
106
+ }
107
+ }
108
+
109
+ return enriched;
110
+ }
111
+
15
112
  /**
16
113
  * Format execution record as Links Notation (indented style)
17
114
  * Uses nested Links notation for object values (like options) instead of JSON
@@ -122,18 +219,23 @@ function formatRecord(record, format) {
122
219
  * @param {string|null} outputFormat - Output format (links-notation, json, text)
123
220
  * @returns {{success: boolean, output?: string, error?: string}}
124
221
  */
125
- function queryStatus(store, uuid, outputFormat) {
222
+ function queryStatus(store, identifier, outputFormat) {
126
223
  if (!store) {
127
224
  return { success: false, error: 'Execution tracking is disabled.' };
128
225
  }
129
- const record = store.get(uuid);
226
+ const record = store.get(identifier);
130
227
  if (!record) {
131
- return { success: false, error: `No execution found with UUID: ${uuid}` };
228
+ return {
229
+ success: false,
230
+ error: `No execution found with UUID or session name: ${identifier}`,
231
+ };
132
232
  }
133
233
  try {
234
+ // Enrich detached execution status with live session check
235
+ const enrichedRecord = enrichDetachedStatus(record);
134
236
  return {
135
237
  success: true,
136
- output: formatRecord(record, outputFormat || 'links-notation'),
238
+ output: formatRecord(enrichedRecord, outputFormat || 'links-notation'),
137
239
  };
138
240
  } catch (err) {
139
241
  return { success: false, error: err.message };
@@ -145,4 +247,6 @@ module.exports = {
145
247
  formatRecordAsText,
146
248
  formatRecord,
147
249
  queryStatus,
250
+ isDetachedSessionAlive,
251
+ enrichDetachedStatus,
148
252
  };
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Tests for --status lookup by session name and detached status enrichment
3
+ * Issue #101: --session name not usable with --status, and --detached reports immediate completion
4
+ */
5
+
6
+ const { describe, it, expect, beforeEach, afterEach } = require('bun:test');
7
+ const { spawnSync } = require('child_process');
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const os = require('os');
11
+
12
+ const {
13
+ ExecutionStore,
14
+ ExecutionRecord,
15
+ ExecutionStatus,
16
+ } = require('../src/lib/execution-store');
17
+ const {
18
+ queryStatus,
19
+ isDetachedSessionAlive,
20
+ enrichDetachedStatus,
21
+ } = require('../src/lib/status-formatter');
22
+
23
+ // Use temp directory for tests
24
+ const TEST_APP_FOLDER = path.join(
25
+ os.tmpdir(),
26
+ `session-name-status-test-${Date.now()}`
27
+ );
28
+
29
+ // Path to CLI
30
+ const CLI_PATH = path.join(__dirname, '../src/bin/cli.js');
31
+
32
+ // Helper to clean up test directory
33
+ function cleanupTestDir() {
34
+ if (fs.existsSync(TEST_APP_FOLDER)) {
35
+ fs.rmSync(TEST_APP_FOLDER, { recursive: true, force: true });
36
+ }
37
+ }
38
+
39
+ // Helper to run CLI command
40
+ function runCli(args, env = {}) {
41
+ const result = spawnSync('bun', [CLI_PATH, ...args], {
42
+ encoding: 'utf8',
43
+ env: {
44
+ ...process.env,
45
+ START_APP_FOLDER: TEST_APP_FOLDER,
46
+ ...env,
47
+ },
48
+ timeout: 10000,
49
+ });
50
+ return {
51
+ stdout: result.stdout || '',
52
+ stderr: result.stderr || '',
53
+ exitCode: result.status,
54
+ };
55
+ }
56
+
57
+ describe('Issue #101: --status with session name lookup', () => {
58
+ let store;
59
+
60
+ beforeEach(() => {
61
+ cleanupTestDir();
62
+ store = new ExecutionStore({
63
+ appFolder: TEST_APP_FOLDER,
64
+ useLinks: false,
65
+ });
66
+ });
67
+
68
+ afterEach(() => {
69
+ cleanupTestDir();
70
+ });
71
+
72
+ describe('ExecutionStore.get() session name lookup', () => {
73
+ it('should find record by UUID', () => {
74
+ const record = new ExecutionRecord({
75
+ command: 'echo hello',
76
+ pid: 12345,
77
+ logPath: '/tmp/test.log',
78
+ options: { sessionName: 'my-custom-session' },
79
+ });
80
+ record.complete(0);
81
+ store.save(record);
82
+
83
+ const found = store.get(record.uuid);
84
+ expect(found).not.toBeNull();
85
+ expect(found.uuid).toBe(record.uuid);
86
+ });
87
+
88
+ it('should find record by session name', () => {
89
+ const record = new ExecutionRecord({
90
+ command: 'sleep 60',
91
+ pid: 12345,
92
+ logPath: '/tmp/test.log',
93
+ options: {
94
+ sessionName: 'my-custom-session',
95
+ isolated: 'screen',
96
+ isolationMode: 'detached',
97
+ },
98
+ });
99
+ store.save(record);
100
+
101
+ const found = store.get('my-custom-session');
102
+ expect(found).not.toBeNull();
103
+ expect(found.uuid).toBe(record.uuid);
104
+ expect(found.options.sessionName).toBe('my-custom-session');
105
+ });
106
+
107
+ it('should prefer UUID match over session name', () => {
108
+ // Create two records: one whose session name matches the UUID of another
109
+ const record1 = new ExecutionRecord({
110
+ command: 'echo first',
111
+ pid: 111,
112
+ logPath: '/tmp/first.log',
113
+ options: { sessionName: 'some-session' },
114
+ });
115
+ record1.complete(0);
116
+ store.save(record1);
117
+
118
+ const record2 = new ExecutionRecord({
119
+ command: 'echo second',
120
+ pid: 222,
121
+ logPath: '/tmp/second.log',
122
+ options: { sessionName: record1.uuid }, // session name matches record1's UUID
123
+ });
124
+ store.save(record2);
125
+
126
+ // Looking up by record1's UUID should return record1, not record2
127
+ const found = store.get(record1.uuid);
128
+ expect(found.command).toBe('echo first');
129
+ });
130
+
131
+ it('should return null for non-existent session name', () => {
132
+ const found = store.get('nonexistent-session');
133
+ expect(found).toBeNull();
134
+ });
135
+
136
+ it('should return null for record without session name', () => {
137
+ const record = new ExecutionRecord({
138
+ command: 'echo hello',
139
+ pid: 12345,
140
+ logPath: '/tmp/test.log',
141
+ });
142
+ store.save(record);
143
+
144
+ const found = store.get('some-session-name');
145
+ expect(found).toBeNull();
146
+ });
147
+ });
148
+
149
+ describe('queryStatus() with session name', () => {
150
+ it('should query status by session name', () => {
151
+ const record = new ExecutionRecord({
152
+ command: 'sleep 60',
153
+ pid: 12345,
154
+ logPath: '/tmp/test.log',
155
+ options: {
156
+ sessionName: 'my-test-session',
157
+ isolated: 'screen',
158
+ isolationMode: 'attached',
159
+ },
160
+ });
161
+ record.complete(0);
162
+ store.save(record);
163
+
164
+ const result = queryStatus(store, 'my-test-session', 'json');
165
+ expect(result.success).toBe(true);
166
+
167
+ const parsed = JSON.parse(result.output);
168
+ expect(parsed.uuid).toBe(record.uuid);
169
+ expect(parsed.command).toBe('sleep 60');
170
+ });
171
+
172
+ it('should show error for non-existent session name', () => {
173
+ const result = queryStatus(store, 'nonexistent-session', 'json');
174
+ expect(result.success).toBe(false);
175
+ expect(result.error).toContain('No execution found');
176
+ expect(result.error).toContain('nonexistent-session');
177
+ });
178
+ });
179
+
180
+ describe('CLI --status with session name', () => {
181
+ it('should query by session name via CLI', () => {
182
+ const record = new ExecutionRecord({
183
+ command: 'echo hello world',
184
+ pid: 12345,
185
+ logPath: '/tmp/test.log',
186
+ options: {
187
+ sessionName: 'cli-test-session',
188
+ isolated: 'screen',
189
+ isolationMode: 'attached',
190
+ },
191
+ });
192
+ record.complete(0);
193
+ store.save(record);
194
+
195
+ const result = runCli([
196
+ '--status',
197
+ 'cli-test-session',
198
+ '--output-format',
199
+ 'json',
200
+ ]);
201
+
202
+ expect(result.exitCode).toBe(0);
203
+ const parsed = JSON.parse(result.stdout);
204
+ expect(parsed.uuid).toBe(record.uuid);
205
+ expect(parsed.command).toBe('echo hello world');
206
+ expect(parsed.status).toBe('executed');
207
+ });
208
+
209
+ it('should show error for non-existent session name via CLI', () => {
210
+ const result = runCli(['--status', 'nonexistent-session']);
211
+ expect(result.exitCode).toBe(1);
212
+ expect(result.stderr).toContain('No execution found');
213
+ });
214
+ });
215
+ });
216
+
217
+ describe('Issue #101: Detached status enrichment', () => {
218
+ describe('isDetachedSessionAlive()', () => {
219
+ it('should return null for non-detached records', () => {
220
+ const record = new ExecutionRecord({
221
+ command: 'echo hello',
222
+ options: {
223
+ sessionName: 'test',
224
+ isolated: 'screen',
225
+ isolationMode: 'attached',
226
+ },
227
+ });
228
+ expect(isDetachedSessionAlive(record)).toBeNull();
229
+ });
230
+
231
+ it('should return null for records without session name', () => {
232
+ const record = new ExecutionRecord({
233
+ command: 'echo hello',
234
+ options: { isolationMode: 'detached' },
235
+ });
236
+ expect(isDetachedSessionAlive(record)).toBeNull();
237
+ });
238
+
239
+ it('should return false for non-existent screen session', () => {
240
+ const record = new ExecutionRecord({
241
+ command: 'sleep 60',
242
+ options: {
243
+ sessionName: 'nonexistent-screen-session-test-101',
244
+ isolated: 'screen',
245
+ isolationMode: 'detached',
246
+ },
247
+ });
248
+ const alive = isDetachedSessionAlive(record);
249
+ // May be false or null depending on whether screen is installed
250
+ if (alive !== null) {
251
+ expect(alive).toBe(false);
252
+ }
253
+ });
254
+
255
+ it('should return false for non-existent docker container', () => {
256
+ const record = new ExecutionRecord({
257
+ command: 'sleep 60',
258
+ options: {
259
+ sessionName: 'nonexistent-docker-container-test-101',
260
+ isolated: 'docker',
261
+ isolationMode: 'detached',
262
+ },
263
+ });
264
+ const alive = isDetachedSessionAlive(record);
265
+ // May be false or null depending on whether docker is installed
266
+ if (alive !== null) {
267
+ expect(alive).toBe(false);
268
+ }
269
+ });
270
+ });
271
+
272
+ describe('enrichDetachedStatus()', () => {
273
+ it('should not modify non-detached records', () => {
274
+ const record = new ExecutionRecord({
275
+ command: 'echo hello',
276
+ options: {
277
+ sessionName: 'test',
278
+ isolated: 'screen',
279
+ isolationMode: 'attached',
280
+ },
281
+ });
282
+ record.complete(0);
283
+
284
+ const enriched = enrichDetachedStatus(record);
285
+ expect(enriched.status).toBe('executed');
286
+ expect(enriched.exitCode).toBe(0);
287
+ });
288
+
289
+ it('should mark non-running detached session as executed', () => {
290
+ const record = new ExecutionRecord({
291
+ command: 'sleep 60',
292
+ options: {
293
+ sessionName: 'nonexistent-session-enrich-test-101',
294
+ isolated: 'screen',
295
+ isolationMode: 'detached',
296
+ },
297
+ });
298
+ // Record says executing, but session doesn't exist
299
+
300
+ const enriched = enrichDetachedStatus(record);
301
+ // If screen is available, should mark as executed
302
+ // If screen is not available, should return unchanged
303
+ if (enriched.status === 'executed') {
304
+ expect(enriched.exitCode).toBe(-1); // Unknown exit code
305
+ expect(enriched.endTime).not.toBeNull();
306
+ }
307
+ });
308
+ });
309
+ });
310
+
311
+ console.log('=== Session Name Status Tests (Issue #101) ===');