start-command 0.27.2 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # start-command
2
2
 
3
+ ## 0.28.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 38d1fe4: Add `--upload-log <id>` to upload a stored execution log with `gh-upload-log`, installing the uploader on demand when it is missing.
8
+
3
9
  ## 0.27.2
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.27.2",
3
+ "version": "0.28.0",
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": {
package/src/bin/cli.js CHANGED
@@ -33,6 +33,7 @@ const { handleFailure } = require('../lib/failure-handler');
33
33
  const { ExecutionStore, ExecutionRecord } = require('../lib/execution-store');
34
34
  const { queryStatus, listExecutions } = require('../lib/status-formatter');
35
35
  const { ControlAction, controlExecution } = require('../lib/execution-control');
36
+ const { uploadExecutionLog } = require('../lib/log-uploader');
36
37
  const { printVersion } = require('../lib/version');
37
38
  const { createStartBlock, createFinishBlock } = require('../lib/output-blocks');
38
39
  const { runWithBunSpawn, runWithNodeSpawn } = require('../lib/spawn-helpers');
@@ -201,6 +202,12 @@ if (wrapperOptions.list) {
201
202
  process.exit(0);
202
203
  }
203
204
 
205
+ // Handle --upload-log flag
206
+ if (wrapperOptions.uploadLog) {
207
+ const exitCode = handleUploadLogQuery(wrapperOptions.uploadLog);
208
+ process.exit(exitCode);
209
+ }
210
+
204
211
  // Handle --stop flag
205
212
  if (wrapperOptions.stop !== null && wrapperOptions.stop !== undefined) {
206
213
  handleControlQuery(wrapperOptions.stop, ControlAction.STOP);
@@ -300,6 +307,16 @@ function handleListQuery(outputFormat) {
300
307
  }
301
308
  }
302
309
 
310
+ function handleUploadLogQuery(identifier) {
311
+ const result = uploadExecutionLog(getExecutionStore(), identifier);
312
+ if (result.success) {
313
+ return result.exitCode || 0;
314
+ }
315
+
316
+ console.error(`Error: ${result.error}`);
317
+ return result.exitCode || 1;
318
+ }
319
+
303
320
  function handleControlQuery(identifier, action) {
304
321
  const result = controlExecution(getExecutionStore(), identifier, action);
305
322
  if (result.success) {
@@ -21,6 +21,7 @@
21
21
  * --verbose Enable verbose/debug output (sets START_VERBOSE=1)
22
22
  * --status <uuid> Show status of a previous command execution by UUID
23
23
  * --list List all tracked command executions
24
+ * --upload-log <uuid-or-session> Upload the stored log for a tracked execution
24
25
  * --output-format <format> Output format for status/list (links-notation, json, text)
25
26
  * --stop <uuid-or-session-name> Send CTRL+C/SIGINT to a detached execution
26
27
  * --terminate <uuid-or-session-name> Terminate a detached execution immediately
@@ -177,6 +178,7 @@ function parseArgs(args) {
177
178
  useCommandStream: false, // Use command-stream library for command execution
178
179
  status: null, // UUID to show status for
179
180
  list: false, // List all tracked execution records
181
+ uploadLog: null, // UUID/session name whose stored log should be uploaded
180
182
  outputFormat: null, // Output format for status/list (links-notation, json, text)
181
183
  stop: null, // UUID/session name to stop gracefully
182
184
  terminate: null, // UUID/session name to terminate immediately
@@ -450,6 +452,28 @@ function parseOption(args, index, options) {
450
452
  return 1;
451
453
  }
452
454
 
455
+ // --upload-log <uuid-or-session-name>
456
+ if (arg === '--upload-log') {
457
+ if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
458
+ options.uploadLog = args[index + 1];
459
+ return 2;
460
+ } else {
461
+ throw new Error(`Option ${arg} requires a UUID or session name argument`);
462
+ }
463
+ }
464
+
465
+ // --upload-log=<value>
466
+ if (arg.startsWith('--upload-log=')) {
467
+ const value = arg.slice('--upload-log='.length);
468
+ if (!value) {
469
+ throw new Error(
470
+ `Option --upload-log requires a UUID or session name argument`
471
+ );
472
+ }
473
+ options.uploadLog = value;
474
+ return 1;
475
+ }
476
+
453
477
  // --stop <uuid-or-session-name>
454
478
  if (arg === '--stop') {
455
479
  if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
@@ -724,6 +748,7 @@ function validateOptions(options) {
724
748
  const queryModes = [
725
749
  hasValue(options.status),
726
750
  options.list,
751
+ hasValue(options.uploadLog),
727
752
  hasValue(options.stop),
728
753
  hasValue(options.terminate),
729
754
  options.cleanup,
@@ -731,7 +756,7 @@ function validateOptions(options) {
731
756
 
732
757
  if (queryModes > 1) {
733
758
  throw new Error(
734
- 'Cannot combine --status, --list, --stop, --terminate, or --cleanup in the same invocation'
759
+ 'Cannot combine --status, --list, --upload-log, --stop, --terminate, or --cleanup in the same invocation'
735
760
  );
736
761
  }
737
762
 
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Helpers for uploading stored execution logs with gh-upload-log.
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { spawnSync } = require('child_process');
9
+
10
+ function isExecutable(filePath) {
11
+ try {
12
+ fs.accessSync(filePath, fs.constants.X_OK);
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ function isCommandFile(filePath) {
20
+ if (process.platform === 'win32') {
21
+ return fs.existsSync(filePath);
22
+ }
23
+
24
+ return isExecutable(filePath);
25
+ }
26
+
27
+ function getPathCommandNames(commandName) {
28
+ if (process.platform !== 'win32' || path.extname(commandName)) {
29
+ return [commandName];
30
+ }
31
+
32
+ const extensions = (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD')
33
+ .split(';')
34
+ .filter(Boolean);
35
+
36
+ return [commandName, ...extensions.map((ext) => `${commandName}${ext}`)];
37
+ }
38
+
39
+ function resolveCommandFromPath(commandName) {
40
+ const pathValue = process.env.PATH || '';
41
+ for (const pathEntry of pathValue.split(path.delimiter)) {
42
+ if (!pathEntry) {
43
+ continue;
44
+ }
45
+
46
+ const directory = pathEntry.replace(/^"|"$/g, '');
47
+ for (const candidateName of getPathCommandNames(commandName)) {
48
+ const candidate = path.join(directory, candidateName);
49
+ if (isCommandFile(candidate)) {
50
+ return candidate;
51
+ }
52
+ }
53
+ }
54
+
55
+ return null;
56
+ }
57
+
58
+ function resolveCommand(commandName) {
59
+ const isWindows = process.platform === 'win32';
60
+ const lookupCommand = isWindows ? 'where' : 'which';
61
+ const pathMatch = resolveCommandFromPath(commandName);
62
+ if (pathMatch) {
63
+ return pathMatch;
64
+ }
65
+
66
+ try {
67
+ const result = spawnSync(lookupCommand, [commandName], {
68
+ encoding: 'utf8',
69
+ stdio: ['ignore', 'pipe', 'ignore'],
70
+ });
71
+ if (result.status === 0 && result.stdout.trim()) {
72
+ return result.stdout.trim().split(/\r?\n/)[0];
73
+ }
74
+ } catch {
75
+ // Fall through to common locations.
76
+ }
77
+
78
+ if (!isWindows && commandName === 'gh-upload-log') {
79
+ const bunGlobalPath = path.join(os.homedir(), '.bun', 'bin', commandName);
80
+ if (isExecutable(bunGlobalPath)) {
81
+ return bunGlobalPath;
82
+ }
83
+ }
84
+
85
+ return null;
86
+ }
87
+
88
+ function shouldRunThroughShell(command) {
89
+ return process.platform === 'win32' && /\.(cmd|bat)$/i.test(command);
90
+ }
91
+
92
+ function runCommand(command, args, options = {}) {
93
+ return spawnSync(command, args, {
94
+ ...options,
95
+ shell: shouldRunThroughShell(command),
96
+ });
97
+ }
98
+
99
+ function runInstall(command, displayName, args) {
100
+ console.log(
101
+ `gh-upload-log not found; installing with: ${displayName} ${args.join(' ')}`
102
+ );
103
+ const result = runCommand(command, args, { stdio: 'inherit' });
104
+ return result.status === 0;
105
+ }
106
+
107
+ function ensureGhUploadLogAvailable() {
108
+ const existing = resolveCommand('gh-upload-log');
109
+ if (existing) {
110
+ return { success: true, command: existing };
111
+ }
112
+
113
+ const installers = [
114
+ ['bun', ['install', '-g', 'gh-upload-log']],
115
+ ['npm', ['install', '-g', 'gh-upload-log']],
116
+ ];
117
+
118
+ for (const [command, args] of installers) {
119
+ const installer = resolveCommand(command);
120
+ if (!installer) {
121
+ continue;
122
+ }
123
+ if (runInstall(installer, command, args)) {
124
+ const installed = resolveCommand('gh-upload-log');
125
+ if (installed) {
126
+ return { success: true, command: installed };
127
+ }
128
+ }
129
+ }
130
+
131
+ return {
132
+ success: false,
133
+ error:
134
+ 'gh-upload-log is not installed and automatic installation did not make it available on PATH.',
135
+ };
136
+ }
137
+
138
+ function uploadLogPath(logPath) {
139
+ if (!logPath) {
140
+ return {
141
+ success: false,
142
+ error: 'Execution record does not have a log path.',
143
+ };
144
+ }
145
+ if (!fs.existsSync(logPath)) {
146
+ return { success: false, error: `Log file not found: ${logPath}` };
147
+ }
148
+
149
+ const availability = ensureGhUploadLogAvailable();
150
+ if (!availability.success) {
151
+ return availability;
152
+ }
153
+
154
+ const result = runCommand(availability.command, [logPath], {
155
+ stdio: 'inherit',
156
+ });
157
+
158
+ const exitCode =
159
+ result.status !== null && result.status !== undefined ? result.status : 1;
160
+ if (exitCode !== 0) {
161
+ return {
162
+ success: false,
163
+ exitCode,
164
+ error: `gh-upload-log exited with code ${exitCode}`,
165
+ };
166
+ }
167
+
168
+ return { success: true, exitCode: 0 };
169
+ }
170
+
171
+ function uploadExecutionLog(store, identifier) {
172
+ if (!store) {
173
+ return { success: false, error: 'Execution tracking is disabled.' };
174
+ }
175
+
176
+ const record = store.get(identifier);
177
+ if (!record) {
178
+ return {
179
+ success: false,
180
+ error: `No execution found with UUID or session name: ${identifier}`,
181
+ };
182
+ }
183
+
184
+ return uploadLogPath(record.logPath);
185
+ }
186
+
187
+ module.exports = {
188
+ ensureGhUploadLogAvailable,
189
+ resolveCommand,
190
+ uploadExecutionLog,
191
+ uploadLogPath,
192
+ };
package/src/lib/usage.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Print usage information */
2
2
  function printUsage() {
3
- console.log(`Usage: $ [options] [--] <command> | $ --status <uuid> [--output-format <fmt>] | $ --list [--output-format <fmt>] | $ --stop <id> | $ --terminate <id>
3
+ console.log(`Usage: $ [options] [--] <command> | $ --status <uuid> [--output-format <fmt>] | $ --list [--output-format <fmt>] | $ --upload-log <id> | $ --stop <id> | $ --terminate <id>
4
4
 
5
5
  Options:
6
6
  --isolated, -i <env> Run in isolated environment (screen, tmux, docker, ssh)
@@ -19,6 +19,7 @@ Options:
19
19
  --use-command-stream Use command-stream library for execution (experimental)
20
20
  --status <id> Show status of execution by UUID or session name (--output-format: links-notation|json|text)
21
21
  --list List all tracked executions (--output-format: links-notation|json|text)
22
+ --upload-log <id> Upload the stored log for an execution UUID or session name
22
23
  --stop <id> Send CTRL+C/SIGINT to a detached isolated execution
23
24
  --terminate <id> Terminate a detached isolated execution immediately
24
25
  --cleanup Clean up stale "executing" records (crashed/killed processes)
@@ -39,6 +40,7 @@ Examples:
39
40
  $ --isolated-user --keep-user -- npm start
40
41
  $ --list # List stored execution records
41
42
  $ --list --output-format json # List stored records as JSON
43
+ $ --upload-log my-screen-session # Upload stored execution log
42
44
  $ --stop my-screen-session # Ask detached execution to stop gracefully
43
45
  $ --terminate my-screen-session # Terminate detached execution immediately
44
46
  $ --use-command-stream echo "Hello" # Use command-stream library`);
@@ -54,7 +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, --stop, --terminate, or --cleanup/);
57
+ }, /Cannot combine --status, --list, --upload-log, --stop, --terminate, or --cleanup/);
58
+ });
59
+
60
+ it('should reject combining upload-log with control modes', () => {
61
+ assert.throws(() => {
62
+ parseArgs(['--upload-log', 'uuid-here', '--terminate', 'my-session']);
63
+ }, /Cannot combine --status, --list, --upload-log, --stop, --terminate, or --cleanup/);
58
64
  });
59
65
 
60
66
  it('should reject output-format with control modes', () => {
@@ -832,6 +832,36 @@ describe('status option', () => {
832
832
  });
833
833
  });
834
834
 
835
+ describe('upload-log option', () => {
836
+ it('should parse --upload-log with UUID or session name', () => {
837
+ const result = parseArgs(['--upload-log', 'my-session']);
838
+ assert.strictEqual(result.wrapperOptions.uploadLog, 'my-session');
839
+ assert.strictEqual(result.command, '');
840
+ });
841
+
842
+ it('should parse --upload-log=value format', () => {
843
+ const result = parseArgs(['--upload-log=my-session']);
844
+ assert.strictEqual(result.wrapperOptions.uploadLog, 'my-session');
845
+ });
846
+
847
+ it('should throw error for missing --upload-log argument', () => {
848
+ assert.throws(() => {
849
+ parseArgs(['--upload-log']);
850
+ }, /requires a UUID or session name argument/);
851
+ });
852
+
853
+ it('should throw error for empty --upload-log=value argument', () => {
854
+ assert.throws(() => {
855
+ parseArgs(['--upload-log=']);
856
+ }, /requires a UUID or session name argument/);
857
+ });
858
+
859
+ it('should default uploadLog to null', () => {
860
+ const result = parseArgs(['echo', 'hello']);
861
+ assert.strictEqual(result.wrapperOptions.uploadLog, null);
862
+ });
863
+ });
864
+
835
865
  describe('list option', () => {
836
866
  it('should parse --list flag', () => {
837
867
  const result = parseArgs(['--list']);
@@ -32,7 +32,7 @@ function cleanupTestDir() {
32
32
 
33
33
  // Helper to run CLI command
34
34
  function runCli(args, env = {}) {
35
- const result = spawnSync('bun', [CLI_PATH, ...args], {
35
+ const result = spawnSync(process.execPath, [CLI_PATH, ...args], {
36
36
  encoding: 'utf8',
37
37
  env: {
38
38
  ...process.env,
@@ -48,6 +48,26 @@ function runCli(args, env = {}) {
48
48
  };
49
49
  }
50
50
 
51
+ function createExecutable(filePath, content) {
52
+ fs.writeFileSync(filePath, content, 'utf8');
53
+ fs.chmodSync(filePath, 0o755);
54
+ }
55
+
56
+ function createFakeUploader(fakeBin, outputPrefix) {
57
+ if (process.platform === 'win32') {
58
+ createExecutable(
59
+ path.join(fakeBin, 'gh-upload-log.cmd'),
60
+ `@echo off\r\necho ${outputPrefix}: %1\r\n`
61
+ );
62
+ return;
63
+ }
64
+
65
+ createExecutable(
66
+ path.join(fakeBin, 'gh-upload-log'),
67
+ `#!/bin/sh\necho "${outputPrefix}: $1"\n`
68
+ );
69
+ }
70
+
51
71
  describe('--status query functionality', () => {
52
72
  let store;
53
73
  let testRecord;
@@ -241,6 +261,88 @@ describe('--status query functionality', () => {
241
261
  });
242
262
  });
243
263
 
264
+ describe('--upload-log functionality', () => {
265
+ it('should run gh-upload-log with the stored execution log path', () => {
266
+ const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'upload-log-bin-'));
267
+ const logPath = path.join(TEST_APP_FOLDER, 'command.log');
268
+ fs.writeFileSync(logPath, 'captured command output\n', 'utf8');
269
+ createFakeUploader(fakeBin, 'fake uploader received');
270
+
271
+ testRecord.logPath = logPath;
272
+ store.save(testRecord);
273
+
274
+ const result = runCli(['--upload-log', testRecord.uuid], {
275
+ PATH: fakeBin,
276
+ HOME: fakeBin,
277
+ });
278
+
279
+ expect(result.exitCode).toBe(0);
280
+ expect(result.stdout).toContain(`fake uploader received: ${logPath}`);
281
+ expect(result.stderr).toBe('');
282
+
283
+ fs.rmSync(fakeBin, { recursive: true, force: true });
284
+ });
285
+
286
+ it('should install gh-upload-log when it is missing before uploading', () => {
287
+ if (process.platform === 'win32') {
288
+ console.log(' Skipping: shell fixture uses POSIX scripts');
289
+ return;
290
+ }
291
+
292
+ const fakeBin = fs.mkdtempSync(
293
+ path.join(os.tmpdir(), 'upload-log-install-bin-')
294
+ );
295
+ const installMarker = path.join(fakeBin, 'install.log');
296
+ const logPath = path.join(TEST_APP_FOLDER, 'install-command.log');
297
+ fs.writeFileSync(logPath, 'captured command output\n', 'utf8');
298
+
299
+ createExecutable(
300
+ path.join(fakeBin, 'bun'),
301
+ [
302
+ '#!/bin/sh',
303
+ `echo "$@" > "${installMarker}"`,
304
+ `cat > "${path.join(fakeBin, 'gh-upload-log')}" <<'SCRIPT'`,
305
+ '#!/bin/sh',
306
+ 'echo "installed uploader received: $1"',
307
+ 'SCRIPT',
308
+ `chmod +x "${path.join(fakeBin, 'gh-upload-log')}"`,
309
+ 'exit 0',
310
+ '',
311
+ ].join('\n')
312
+ );
313
+
314
+ testRecord.logPath = logPath;
315
+ store.save(testRecord);
316
+
317
+ const result = runCli(['--upload-log', testRecord.uuid], {
318
+ PATH: `${fakeBin}${path.delimiter}/usr/bin${path.delimiter}/bin`,
319
+ HOME: fakeBin,
320
+ });
321
+
322
+ expect(result.exitCode).toBe(0);
323
+ expect(fs.readFileSync(installMarker, 'utf8').trim()).toBe(
324
+ 'install -g gh-upload-log'
325
+ );
326
+ expect(result.stdout).toContain('gh-upload-log not found');
327
+ expect(result.stdout).toContain(
328
+ `installed uploader received: ${logPath}`
329
+ );
330
+
331
+ fs.rmSync(fakeBin, { recursive: true, force: true });
332
+ });
333
+
334
+ it('should show an error when the stored log file is missing', () => {
335
+ testRecord.logPath = path.join(TEST_APP_FOLDER, 'missing.log');
336
+ store.save(testRecord);
337
+
338
+ const result = runCli(['--upload-log', testRecord.uuid]);
339
+
340
+ expect(result.exitCode).toBe(1);
341
+ expect(result.stderr).toContain('Log file not found');
342
+ expect(result.stderr).toContain(testRecord.logPath);
343
+ });
344
+ });
345
+
244
346
  describe('executing status', () => {
245
347
  it('should show executing status for ongoing commands', () => {
246
348
  // Create an executing (not completed) record