start-command 0.27.0 → 0.27.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # start-command
2
2
 
3
+ ## 0.27.1
4
+
5
+ ### Patch Changes
6
+
7
+ - fe362a6: Fix CI release automation by making Docker cleanup tests tolerate Windows Docker startup latency and by hardening GitHub release creation.
8
+
3
9
  ## 0.27.0
4
10
 
5
11
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.27.0",
3
+ "version": "0.27.1",
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": {
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const testDir = dirname(fileURLToPath(import.meta.url));
9
+ const repoRoot = resolve(testDir, '../..');
10
+ const scriptPath = resolve(repoRoot, 'scripts/create-github-release.mjs');
11
+
12
+ function createFakeGhBin(tempDir) {
13
+ const fakeGhJs = join(tempDir, 'fake-gh.cjs');
14
+ writeFileSync(
15
+ fakeGhJs,
16
+ `
17
+ const fs = require('node:fs');
18
+
19
+ let input = '';
20
+ process.stdin.setEncoding('utf8');
21
+ process.stdin.on('data', (chunk) => {
22
+ input += chunk;
23
+ });
24
+ process.stdin.on('end', () => {
25
+ fs.writeFileSync(process.env.FAKE_GH_PAYLOAD_PATH, input);
26
+
27
+ if (process.env.FAKE_GH_MODE === 'already_exists') {
28
+ console.error('gh: Validation Failed (HTTP 422)');
29
+ console.error(JSON.stringify({
30
+ message: 'Validation Failed',
31
+ errors: [{ resource: 'Release', code: 'already_exists', field: 'tag_name' }],
32
+ }));
33
+ process.exit(1);
34
+ }
35
+
36
+ if (process.env.FAKE_GH_MODE === 'failure') {
37
+ console.error('gh: server exploded');
38
+ process.exit(1);
39
+ }
40
+
41
+ console.log(JSON.stringify({ html_url: 'https://github.test/release' }));
42
+ process.exit(0);
43
+ });
44
+ `
45
+ );
46
+
47
+ return fakeGhJs;
48
+ }
49
+
50
+ function runScript(mode) {
51
+ const tempDir = mkdtempSync(join(tmpdir(), 'create-release-test-'));
52
+ const payloadPath = join(tempDir, 'payload.json');
53
+ const changelogPath = join(tempDir, 'CHANGELOG.md');
54
+ writeFileSync(
55
+ changelogPath,
56
+ '# Changelog\n\n## [1.2.3] - 2026-05-03\n\n- Release automation fix.\n'
57
+ );
58
+ const fakeGhJs = createFakeGhBin(tempDir);
59
+
60
+ const result = spawnSync(
61
+ 'node',
62
+ [
63
+ scriptPath,
64
+ '--release-version',
65
+ '1.2.3',
66
+ '--repository',
67
+ 'owner/repo',
68
+ '--prefix',
69
+ 'rust-',
70
+ '--changelog-file',
71
+ changelogPath,
72
+ '--badge-type',
73
+ 'crates',
74
+ '--package-name',
75
+ 'start-command',
76
+ ],
77
+ {
78
+ encoding: 'utf8',
79
+ env: {
80
+ ...process.env,
81
+ FAKE_GH_MODE: mode,
82
+ FAKE_GH_PAYLOAD_PATH: payloadPath,
83
+ START_GH_COMMAND: process.execPath,
84
+ START_GH_COMMAND_ARGS: JSON.stringify([fakeGhJs]),
85
+ },
86
+ }
87
+ );
88
+
89
+ const payload = JSON.parse(readFileSync(payloadPath, 'utf8'));
90
+ rmSync(tempDir, { force: true, recursive: true });
91
+ return { result, payload };
92
+ }
93
+
94
+ describe('create-github-release script', () => {
95
+ it('treats an existing GitHub release as an idempotent skip', () => {
96
+ const { result, payload } = runScript('already_exists');
97
+
98
+ expect(result.status).toBe(0);
99
+ expect(result.stdout).toContain(
100
+ 'GitHub release already exists: rust-v1.2.3'
101
+ );
102
+ expect(result.stdout).not.toContain('Created GitHub release');
103
+ expect(payload).toMatchObject({
104
+ tag_name: 'rust-v1.2.3',
105
+ name: '[Rust] 1.2.3',
106
+ });
107
+ expect(payload.body).toContain('Release automation fix.');
108
+ expect(payload.body).toContain('crates.io');
109
+ });
110
+
111
+ it('fails when gh api returns an unexpected error', () => {
112
+ const { result } = runScript('failure');
113
+
114
+ expect(result.status).not.toBe(0);
115
+ expect(result.stderr).toContain('server exploded');
116
+ expect(result.stderr).toContain('GitHub release creation failed');
117
+ });
118
+ });
@@ -241,29 +241,116 @@ describe('Isolation Resource Cleanup Verification', () => {
241
241
  // Use the canRunLinuxDockerImages function from isolation module
242
242
  // to properly detect if Linux containers can run (handles Windows containers mode)
243
243
  const { canRunLinuxDockerImages } = require('../src/lib/isolation');
244
+ const DOCKER_TEST_TIMEOUT = process.platform === 'win32' ? 30000 : 20000;
245
+ const DOCKER_STATE_WAIT_TIMEOUT =
246
+ process.platform === 'win32' ? 20000 : 10000;
247
+
248
+ it(
249
+ 'should show docker container as exited after command completes (auto-exit by default)',
250
+ { timeout: DOCKER_TEST_TIMEOUT },
251
+ async () => {
252
+ if (!canRunLinuxDockerImages()) {
253
+ console.log(
254
+ ' Skipping: docker not available, daemon not running, or Linux containers not supported'
255
+ );
256
+ return;
257
+ }
244
258
 
245
- it('should show docker container as exited after command completes (auto-exit by default)', async () => {
246
- if (!canRunLinuxDockerImages()) {
247
- console.log(
248
- ' Skipping: docker not available, daemon not running, or Linux containers not supported'
259
+ const containerName = `test-cleanup-docker-${Date.now()}`;
260
+
261
+ // Run a quick command in detached mode
262
+ const result = await runInDocker('echo "test" && sleep 0.1', {
263
+ image: 'alpine:latest',
264
+ session: containerName,
265
+ detached: true,
266
+ keepAlive: false,
267
+ });
268
+
269
+ assert.strictEqual(result.success, true);
270
+
271
+ // Wait for the container to exit
272
+ const containerExited = await waitFor(() => {
273
+ try {
274
+ const status = execSync(
275
+ `docker inspect -f '{{.State.Status}}' ${containerName}`,
276
+ {
277
+ encoding: 'utf8',
278
+ stdio: ['pipe', 'pipe', 'pipe'],
279
+ }
280
+ ).trim();
281
+ return status === 'exited';
282
+ } catch {
283
+ return false;
284
+ }
285
+ }, DOCKER_STATE_WAIT_TIMEOUT);
286
+
287
+ assert.ok(
288
+ containerExited,
289
+ 'Docker container should be in exited state after command completes (auto-exit by default)'
249
290
  );
250
- return;
291
+
292
+ // Verify with docker ps -a that container is exited (not running)
293
+ try {
294
+ const allContainers = execSync('docker ps -a', {
295
+ encoding: 'utf8',
296
+ stdio: ['pipe', 'pipe', 'pipe'],
297
+ });
298
+ assert.ok(
299
+ allContainers.includes(containerName),
300
+ 'Container should appear in docker ps -a'
301
+ );
302
+
303
+ const runningContainers = execSync('docker ps', {
304
+ encoding: 'utf8',
305
+ stdio: ['pipe', 'pipe', 'pipe'],
306
+ });
307
+ assert.ok(
308
+ !runningContainers.includes(containerName),
309
+ 'Container should NOT appear in docker ps (not running)'
310
+ );
311
+ console.log(
312
+ ' ✓ Docker container auto-exited and stopped (resources released, filesystem preserved)'
313
+ );
314
+ } catch (err) {
315
+ assert.fail(`Failed to verify container status: ${err.message}`);
316
+ }
317
+
318
+ // Clean up
319
+ try {
320
+ execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
321
+ } catch {
322
+ // Ignore cleanup errors
323
+ }
251
324
  }
325
+ );
326
+
327
+ it(
328
+ 'should keep docker container running when keepAlive is true',
329
+ { timeout: DOCKER_TEST_TIMEOUT },
330
+ async () => {
331
+ if (!canRunLinuxDockerImages()) {
332
+ console.log(
333
+ ' Skipping: docker not available, daemon not running, or Linux containers not supported'
334
+ );
335
+ return;
336
+ }
252
337
 
253
- const containerName = `test-cleanup-docker-${Date.now()}`;
338
+ const containerName = `test-keepalive-docker-${Date.now()}`;
254
339
 
255
- // Run a quick command in detached mode
256
- const result = await runInDocker('echo "test" && sleep 0.1', {
257
- image: 'alpine:latest',
258
- session: containerName,
259
- detached: true,
260
- keepAlive: false,
261
- });
340
+ // Run command with keepAlive enabled
341
+ const result = await runInDocker('echo "test"', {
342
+ image: 'alpine:latest',
343
+ session: containerName,
344
+ detached: true,
345
+ keepAlive: true,
346
+ });
262
347
 
263
- assert.strictEqual(result.success, true);
348
+ assert.strictEqual(result.success, true);
349
+
350
+ // Wait a bit for the command to complete
351
+ await new Promise((resolve) => setTimeout(resolve, 1000));
264
352
 
265
- // Wait for the container to exit
266
- const containerExited = await waitFor(() => {
353
+ // Container should still be running
267
354
  try {
268
355
  const status = execSync(
269
356
  `docker inspect -f '{{.State.Status}}' ${containerName}`,
@@ -272,101 +359,25 @@ describe('Isolation Resource Cleanup Verification', () => {
272
359
  stdio: ['pipe', 'pipe', 'pipe'],
273
360
  }
274
361
  ).trim();
275
- return status === 'exited';
276
- } catch {
277
- return false;
362
+ assert.strictEqual(
363
+ status,
364
+ 'running',
365
+ 'Container should still be running with keepAlive=true'
366
+ );
367
+ console.log(
368
+ ' ✓ Docker container kept running as expected with --keep-alive'
369
+ );
370
+ } catch (err) {
371
+ assert.fail(`Failed to verify container is running: ${err.message}`);
278
372
  }
279
- }, 10000);
280
-
281
- assert.ok(
282
- containerExited,
283
- 'Docker container should be in exited state after command completes (auto-exit by default)'
284
- );
285
-
286
- // Verify with docker ps -a that container is exited (not running)
287
- try {
288
- const allContainers = execSync('docker ps -a', {
289
- encoding: 'utf8',
290
- stdio: ['pipe', 'pipe', 'pipe'],
291
- });
292
- assert.ok(
293
- allContainers.includes(containerName),
294
- 'Container should appear in docker ps -a'
295
- );
296
-
297
- const runningContainers = execSync('docker ps', {
298
- encoding: 'utf8',
299
- stdio: ['pipe', 'pipe', 'pipe'],
300
- });
301
- assert.ok(
302
- !runningContainers.includes(containerName),
303
- 'Container should NOT appear in docker ps (not running)'
304
- );
305
- console.log(
306
- ' ✓ Docker container auto-exited and stopped (resources released, filesystem preserved)'
307
- );
308
- } catch (err) {
309
- assert.fail(`Failed to verify container status: ${err.message}`);
310
- }
311
-
312
- // Clean up
313
- try {
314
- execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
315
- } catch {
316
- // Ignore cleanup errors
317
- }
318
- });
319
-
320
- it('should keep docker container running when keepAlive is true', async () => {
321
- if (!canRunLinuxDockerImages()) {
322
- console.log(
323
- ' Skipping: docker not available, daemon not running, or Linux containers not supported'
324
- );
325
- return;
326
- }
327
-
328
- const containerName = `test-keepalive-docker-${Date.now()}`;
329
-
330
- // Run command with keepAlive enabled
331
- const result = await runInDocker('echo "test"', {
332
- image: 'alpine:latest',
333
- session: containerName,
334
- detached: true,
335
- keepAlive: true,
336
- });
337
-
338
- assert.strictEqual(result.success, true);
339
-
340
- // Wait a bit for the command to complete
341
- await new Promise((resolve) => setTimeout(resolve, 1000));
342
373
 
343
- // Container should still be running
344
- try {
345
- const status = execSync(
346
- `docker inspect -f '{{.State.Status}}' ${containerName}`,
347
- {
348
- encoding: 'utf8',
349
- stdio: ['pipe', 'pipe', 'pipe'],
350
- }
351
- ).trim();
352
- assert.strictEqual(
353
- status,
354
- 'running',
355
- 'Container should still be running with keepAlive=true'
356
- );
357
- console.log(
358
- ' ✓ Docker container kept running as expected with --keep-alive'
359
- );
360
- } catch (err) {
361
- assert.fail(`Failed to verify container is running: ${err.message}`);
362
- }
363
-
364
- // Clean up
365
- try {
366
- execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
367
- } catch {
368
- // Ignore cleanup errors
374
+ // Clean up
375
+ try {
376
+ execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
377
+ } catch {
378
+ // Ignore cleanup errors
379
+ }
369
380
  }
370
- });
381
+ );
371
382
  });
372
383
  });
@@ -0,0 +1,194 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { spawn } from 'node:child_process';
3
+ import {
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from 'node:fs';
10
+ import { createServer } from 'node:http';
11
+ import { tmpdir } from 'node:os';
12
+ import { dirname, join, resolve } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const testDir = dirname(fileURLToPath(import.meta.url));
16
+ const repoRoot = resolve(testDir, '../..');
17
+ const scriptPath = resolve(repoRoot, 'scripts/publish-to-crates.mjs');
18
+
19
+ function createCargoPackage(tempDir) {
20
+ const packageDir = join(tempDir, 'crate');
21
+ const cargoTomlPath = join(packageDir, 'Cargo.toml');
22
+ mkdirSync(packageDir);
23
+ writeFileSync(
24
+ cargoTomlPath,
25
+ '[package]\nname = "example-crate"\nversion = "1.2.3"\nedition = "2021"\n'
26
+ );
27
+ return packageDir;
28
+ }
29
+
30
+ function createFakeCargoBin(tempDir) {
31
+ const fakeCargoJs = join(tempDir, 'fake-cargo.cjs');
32
+ writeFileSync(
33
+ fakeCargoJs,
34
+ `
35
+ const fs = require('node:fs');
36
+ fs.writeFileSync(process.env.FAKE_CARGO_ARGS_PATH, JSON.stringify(process.argv.slice(2)));
37
+ process.exit(Number(process.env.FAKE_CARGO_EXIT || 0));
38
+ `
39
+ );
40
+
41
+ return fakeCargoJs;
42
+ }
43
+
44
+ async function withCratesServer(status, callback) {
45
+ const requests = [];
46
+ const server = createServer((request, response) => {
47
+ requests.push(request.url);
48
+ response.writeHead(status, { 'content-type': 'application/json' });
49
+ response.end(JSON.stringify({ ok: status === 200 }));
50
+ });
51
+
52
+ await new Promise((resolveListen) =>
53
+ server.listen(0, '127.0.0.1', resolveListen)
54
+ );
55
+ const { port } = server.address();
56
+
57
+ try {
58
+ return await callback(`http://127.0.0.1:${port}/api/v1`, requests);
59
+ } finally {
60
+ await new Promise((resolveClose) => server.close(resolveClose));
61
+ }
62
+ }
63
+
64
+ function runScript({
65
+ cargoToken = '',
66
+ cratesIoBaseUrl,
67
+ fakeCargo = false,
68
+ packageDir,
69
+ tempDir,
70
+ }) {
71
+ const outputPath = join(tempDir, 'github-output.txt');
72
+ const cargoArgsPath = join(tempDir, 'cargo-args.json');
73
+ const fakeCargoJs = fakeCargo ? createFakeCargoBin(tempDir) : '';
74
+
75
+ const env = {
76
+ ...process.env,
77
+ CARGO_REGISTRY_TOKEN: cargoToken,
78
+ CRATES_IO_BASE_URL: cratesIoBaseUrl,
79
+ CRATES_PUBLISH_RETRY_DELAY_MS: '1',
80
+ FAKE_CARGO_ARGS_PATH: cargoArgsPath,
81
+ GITHUB_OUTPUT: outputPath,
82
+ };
83
+
84
+ if (fakeCargoJs) {
85
+ env.START_CARGO_COMMAND = process.execPath;
86
+ env.START_CARGO_COMMAND_ARGS = JSON.stringify([fakeCargoJs]);
87
+ }
88
+
89
+ return new Promise((resolveRun, rejectRun) => {
90
+ const child = spawn('node', [scriptPath, '--working-dir', packageDir], {
91
+ cwd: repoRoot,
92
+ env,
93
+ });
94
+
95
+ let stdout = '';
96
+ let stderr = '';
97
+ child.stdout.setEncoding('utf8');
98
+ child.stderr.setEncoding('utf8');
99
+ child.stdout.on('data', (chunk) => {
100
+ stdout += chunk;
101
+ });
102
+ child.stderr.on('data', (chunk) => {
103
+ stderr += chunk;
104
+ });
105
+ child.on('error', rejectRun);
106
+ child.on('close', (status, signal) => {
107
+ resolveRun({
108
+ cargoArgsPath,
109
+ outputPath,
110
+ result: {
111
+ signal,
112
+ status,
113
+ stderr,
114
+ stdout,
115
+ },
116
+ });
117
+ });
118
+ });
119
+ }
120
+
121
+ describe('publish-to-crates script', () => {
122
+ it('reports success without a token when the version is already published', async () => {
123
+ await withCratesServer(200, async (cratesIoBaseUrl, requests) => {
124
+ const tempDir = mkdtempSync(join(tmpdir(), 'publish-crates-test-'));
125
+ const packageDir = createCargoPackage(tempDir);
126
+
127
+ try {
128
+ const { outputPath, result } = await runScript({
129
+ cratesIoBaseUrl,
130
+ packageDir,
131
+ tempDir,
132
+ });
133
+
134
+ expect(result.status).toBe(0);
135
+ expect(result.stdout).toContain('already_published=true');
136
+ expect(readFileSync(outputPath, 'utf8')).toContain('published=true');
137
+ expect(requests).toEqual(['/api/v1/crates/example-crate/1.2.3']);
138
+ } finally {
139
+ rmSync(tempDir, { force: true, recursive: true });
140
+ }
141
+ });
142
+ });
143
+
144
+ it('fails clearly when a missing version needs a crates token', async () => {
145
+ await withCratesServer(404, async (cratesIoBaseUrl) => {
146
+ const tempDir = mkdtempSync(join(tmpdir(), 'publish-crates-test-'));
147
+ const packageDir = createCargoPackage(tempDir);
148
+
149
+ try {
150
+ const { result } = await runScript({
151
+ cratesIoBaseUrl,
152
+ packageDir,
153
+ tempDir,
154
+ });
155
+
156
+ expect(result.status).not.toBe(0);
157
+ expect(result.stderr).toContain('Missing crates.io token');
158
+ } finally {
159
+ rmSync(tempDir, { force: true, recursive: true });
160
+ }
161
+ });
162
+ });
163
+
164
+ it('publishes a missing version with cargo publish', async () => {
165
+ await withCratesServer(404, async (cratesIoBaseUrl) => {
166
+ const tempDir = mkdtempSync(join(tmpdir(), 'publish-crates-test-'));
167
+ const packageDir = createCargoPackage(tempDir);
168
+
169
+ try {
170
+ const { cargoArgsPath, outputPath, result } = await runScript({
171
+ cargoToken: 'test-token',
172
+ cratesIoBaseUrl,
173
+ fakeCargo: true,
174
+ packageDir,
175
+ tempDir,
176
+ });
177
+
178
+ expect(result.status).toBe(0);
179
+ expect(result.stdout).toContain('publish_result=published');
180
+ expect(readFileSync(outputPath, 'utf8')).toContain('published=true');
181
+ expect(JSON.parse(readFileSync(cargoArgsPath, 'utf8'))).toEqual([
182
+ 'publish',
183
+ '--allow-dirty',
184
+ '--manifest-path',
185
+ join(packageDir, 'Cargo.toml'),
186
+ '--token',
187
+ 'test-token',
188
+ ]);
189
+ } finally {
190
+ rmSync(tempDir, { force: true, recursive: true });
191
+ }
192
+ });
193
+ });
194
+ });