gitspace 0.2.0-rc.5 → 0.2.0-rc.6
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/README.md +1 -1
- package/bun.lock +8 -0
- package/docs/SITE_DOCS_FIGMA_MAKE.md +8 -8
- package/package.json +5 -5
- package/src/commands/remove.ts +66 -50
- package/src/core/__tests__/workspace.test.ts +149 -0
- package/src/core/bundle.ts +1 -1
- package/src/core/workspace.ts +249 -0
- package/src/index.ts +7 -1
- package/src/lib/remote-session/session-handler.ts +31 -21
- package/src/tui/app.tsx +178 -25
- package/src/utils/__tests__/run-scripts.test.ts +306 -0
- package/src/utils/__tests__/run-workspace-scripts.test.ts +252 -0
- package/src/utils/run-scripts.ts +27 -3
- package/src/utils/run-workspace-scripts.ts +89 -0
- package/src/utils/sanitize.ts +64 -0
- package/src/utils/workspace-state.ts +17 -1
- package/src/version.generated.d.ts +2 -0
- package/.claude/settings.local.json +0 -25
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for run-scripts.ts
|
|
3
|
+
* Specifically testing the nonInteractive mode that prevents script blocking
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
|
7
|
+
import { runScriptsInTerminal, discoverScripts } from '../run-scripts';
|
|
8
|
+
import { mkdirSync, writeFileSync, chmodSync, rmSync, existsSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { tmpdir } from 'os';
|
|
11
|
+
|
|
12
|
+
describe('runScriptsInTerminal', () => {
|
|
13
|
+
let testDir: string;
|
|
14
|
+
let scriptsDir: string;
|
|
15
|
+
let workspacePath: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
testDir = join(tmpdir(), `run-scripts-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
19
|
+
scriptsDir = join(testDir, 'scripts');
|
|
20
|
+
workspacePath = join(testDir, 'workspace');
|
|
21
|
+
mkdirSync(scriptsDir, { recursive: true });
|
|
22
|
+
mkdirSync(workspacePath, { recursive: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
if (existsSync(testDir)) {
|
|
27
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('discoverScripts', () => {
|
|
32
|
+
it('should discover executable scripts in directory', () => {
|
|
33
|
+
const script1 = join(scriptsDir, '01-first.sh');
|
|
34
|
+
const script2 = join(scriptsDir, '02-second.sh');
|
|
35
|
+
const nonExec = join(scriptsDir, 'readme.txt');
|
|
36
|
+
|
|
37
|
+
writeFileSync(script1, '#!/bin/bash\necho "first"');
|
|
38
|
+
writeFileSync(script2, '#!/bin/bash\necho "second"');
|
|
39
|
+
writeFileSync(nonExec, 'not executable');
|
|
40
|
+
|
|
41
|
+
chmodSync(script1, 0o755);
|
|
42
|
+
chmodSync(script2, 0o755);
|
|
43
|
+
// nonExec stays non-executable
|
|
44
|
+
|
|
45
|
+
const scripts = discoverScripts(scriptsDir);
|
|
46
|
+
expect(scripts).toHaveLength(2);
|
|
47
|
+
expect(scripts[0]).toContain('01-first.sh');
|
|
48
|
+
expect(scripts[1]).toContain('02-second.sh');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should return empty array for non-existent directory', () => {
|
|
52
|
+
const scripts = discoverScripts('/non/existent/path');
|
|
53
|
+
expect(scripts).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should return scripts in alphabetical order', () => {
|
|
57
|
+
const scriptZ = join(scriptsDir, 'z-last.sh');
|
|
58
|
+
const scriptA = join(scriptsDir, 'a-first.sh');
|
|
59
|
+
const scriptM = join(scriptsDir, 'm-middle.sh');
|
|
60
|
+
|
|
61
|
+
writeFileSync(scriptZ, '#!/bin/bash\necho "z"');
|
|
62
|
+
writeFileSync(scriptA, '#!/bin/bash\necho "a"');
|
|
63
|
+
writeFileSync(scriptM, '#!/bin/bash\necho "m"');
|
|
64
|
+
|
|
65
|
+
chmodSync(scriptZ, 0o755);
|
|
66
|
+
chmodSync(scriptA, 0o755);
|
|
67
|
+
chmodSync(scriptM, 0o755);
|
|
68
|
+
|
|
69
|
+
const scripts = discoverScripts(scriptsDir);
|
|
70
|
+
expect(scripts[0]).toContain('a-first.sh');
|
|
71
|
+
expect(scripts[1]).toContain('m-middle.sh');
|
|
72
|
+
expect(scripts[2]).toContain('z-last.sh');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('nonInteractive mode', () => {
|
|
77
|
+
it('should not block when script tries to read stdin', async () => {
|
|
78
|
+
// Create a script that tries to read from stdin with a timeout
|
|
79
|
+
// In interactive mode with no input, this would wait for the timeout
|
|
80
|
+
// In non-interactive mode, stdin is closed so read gets EOF immediately
|
|
81
|
+
const scriptPath = join(scriptsDir, '01-read-stdin.sh');
|
|
82
|
+
writeFileSync(scriptPath, `#!/bin/bash
|
|
83
|
+
# Try to read from stdin - should get EOF immediately in non-interactive mode
|
|
84
|
+
if read -t 5 input 2>/dev/null; then
|
|
85
|
+
echo "Got input: $input"
|
|
86
|
+
else
|
|
87
|
+
echo "No input (stdin closed or timeout)"
|
|
88
|
+
fi
|
|
89
|
+
exit 0
|
|
90
|
+
`);
|
|
91
|
+
chmodSync(scriptPath, 0o755);
|
|
92
|
+
|
|
93
|
+
// This should complete quickly without blocking for 5 seconds
|
|
94
|
+
const startTime = Date.now();
|
|
95
|
+
await runScriptsInTerminal(scriptsDir, workspacePath, 'test-workspace', 'test/repo', {
|
|
96
|
+
nonInteractive: true,
|
|
97
|
+
});
|
|
98
|
+
const elapsed = Date.now() - startTime;
|
|
99
|
+
|
|
100
|
+
// Should complete in well under 5 seconds (the read timeout)
|
|
101
|
+
// Give it 2 seconds max for script startup overhead
|
|
102
|
+
expect(elapsed).toBeLessThan(2000);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should complete successfully for simple scripts', async () => {
|
|
106
|
+
const scriptPath = join(scriptsDir, '01-simple.sh');
|
|
107
|
+
writeFileSync(scriptPath, `#!/bin/bash
|
|
108
|
+
echo "Running cleanup for $1 in $2"
|
|
109
|
+
exit 0
|
|
110
|
+
`);
|
|
111
|
+
chmodSync(scriptPath, 0o755);
|
|
112
|
+
|
|
113
|
+
// Should not throw
|
|
114
|
+
await runScriptsInTerminal(scriptsDir, workspacePath, 'test-workspace', 'test/repo', {
|
|
115
|
+
nonInteractive: true,
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should throw on script failure', async () => {
|
|
120
|
+
const scriptPath = join(scriptsDir, '01-fail.sh');
|
|
121
|
+
writeFileSync(scriptPath, `#!/bin/bash
|
|
122
|
+
echo "About to fail"
|
|
123
|
+
exit 1
|
|
124
|
+
`);
|
|
125
|
+
chmodSync(scriptPath, 0o755);
|
|
126
|
+
|
|
127
|
+
await expect(
|
|
128
|
+
runScriptsInTerminal(scriptsDir, workspacePath, 'test-workspace', 'test/repo', {
|
|
129
|
+
nonInteractive: true,
|
|
130
|
+
})
|
|
131
|
+
).rejects.toThrow(/Script failed with exit code 1/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should pass workspace name and repository as arguments', async () => {
|
|
135
|
+
// Create a script that writes its arguments to a file
|
|
136
|
+
const outputFile = join(testDir, 'args.txt');
|
|
137
|
+
const scriptPath = join(scriptsDir, '01-check-args.sh');
|
|
138
|
+
writeFileSync(scriptPath, `#!/bin/bash
|
|
139
|
+
echo "$1" > "${outputFile}"
|
|
140
|
+
echo "$2" >> "${outputFile}"
|
|
141
|
+
`);
|
|
142
|
+
chmodSync(scriptPath, 0o755);
|
|
143
|
+
|
|
144
|
+
await runScriptsInTerminal(scriptsDir, workspacePath, 'my-workspace', 'owner/repo', {
|
|
145
|
+
nonInteractive: true,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const output = await Bun.file(outputFile).text();
|
|
149
|
+
const lines = output.trim().split('\n');
|
|
150
|
+
expect(lines[0]).toBe('my-workspace');
|
|
151
|
+
expect(lines[1]).toBe('owner/repo');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should set working directory to workspace path', async () => {
|
|
155
|
+
const outputFile = join(testDir, 'cwd.txt');
|
|
156
|
+
const scriptPath = join(scriptsDir, '01-check-cwd.sh');
|
|
157
|
+
writeFileSync(scriptPath, `#!/bin/bash
|
|
158
|
+
pwd > "${outputFile}"
|
|
159
|
+
`);
|
|
160
|
+
chmodSync(scriptPath, 0o755);
|
|
161
|
+
|
|
162
|
+
await runScriptsInTerminal(scriptsDir, workspacePath, 'test-workspace', 'test/repo', {
|
|
163
|
+
nonInteractive: true,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const output = await Bun.file(outputFile).text();
|
|
167
|
+
// On macOS, /var is a symlink to /private/var, so we need to handle both
|
|
168
|
+
const actualCwd = output.trim();
|
|
169
|
+
const expectedCwd = workspacePath;
|
|
170
|
+
// Either direct match or with /private prefix (macOS symlink)
|
|
171
|
+
expect(
|
|
172
|
+
actualCwd === expectedCwd ||
|
|
173
|
+
actualCwd === `/private${expectedCwd}` ||
|
|
174
|
+
expectedCwd === `/private${actualCwd}`
|
|
175
|
+
).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should run scripts in alphabetical order', async () => {
|
|
179
|
+
const outputFile = join(testDir, 'order.txt');
|
|
180
|
+
|
|
181
|
+
// Create scripts out of order
|
|
182
|
+
const script3 = join(scriptsDir, '03-third.sh');
|
|
183
|
+
const script1 = join(scriptsDir, '01-first.sh');
|
|
184
|
+
const script2 = join(scriptsDir, '02-second.sh');
|
|
185
|
+
|
|
186
|
+
writeFileSync(script3, `#!/bin/bash\necho "third" >> "${outputFile}"`);
|
|
187
|
+
writeFileSync(script1, `#!/bin/bash\necho "first" >> "${outputFile}"`);
|
|
188
|
+
writeFileSync(script2, `#!/bin/bash\necho "second" >> "${outputFile}"`);
|
|
189
|
+
|
|
190
|
+
chmodSync(script3, 0o755);
|
|
191
|
+
chmodSync(script1, 0o755);
|
|
192
|
+
chmodSync(script2, 0o755);
|
|
193
|
+
|
|
194
|
+
await runScriptsInTerminal(scriptsDir, workspacePath, 'test-workspace', 'test/repo', {
|
|
195
|
+
nonInteractive: true,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const output = await Bun.file(outputFile).text();
|
|
199
|
+
const lines = output.trim().split('\n');
|
|
200
|
+
expect(lines).toEqual(['first', 'second', 'third']);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('should stop on first script failure', async () => {
|
|
204
|
+
const outputFile = join(testDir, 'stopped.txt');
|
|
205
|
+
|
|
206
|
+
const script1 = join(scriptsDir, '01-success.sh');
|
|
207
|
+
const script2 = join(scriptsDir, '02-fail.sh');
|
|
208
|
+
const script3 = join(scriptsDir, '03-never-runs.sh');
|
|
209
|
+
|
|
210
|
+
writeFileSync(script1, `#!/bin/bash\necho "1" >> "${outputFile}"`);
|
|
211
|
+
writeFileSync(script2, `#!/bin/bash\necho "2" >> "${outputFile}"\nexit 1`);
|
|
212
|
+
writeFileSync(script3, `#!/bin/bash\necho "3" >> "${outputFile}"`);
|
|
213
|
+
|
|
214
|
+
chmodSync(script1, 0o755);
|
|
215
|
+
chmodSync(script2, 0o755);
|
|
216
|
+
chmodSync(script3, 0o755);
|
|
217
|
+
|
|
218
|
+
await expect(
|
|
219
|
+
runScriptsInTerminal(scriptsDir, workspacePath, 'test-workspace', 'test/repo', {
|
|
220
|
+
nonInteractive: true,
|
|
221
|
+
})
|
|
222
|
+
).rejects.toThrow();
|
|
223
|
+
|
|
224
|
+
const output = await Bun.file(outputFile).text();
|
|
225
|
+
const lines = output.trim().split('\n');
|
|
226
|
+
// Script 3 should never have run
|
|
227
|
+
expect(lines).toEqual(['1', '2']);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('interactive mode (default)', () => {
|
|
232
|
+
it('should run scripts successfully', async () => {
|
|
233
|
+
const scriptPath = join(scriptsDir, '01-simple.sh');
|
|
234
|
+
writeFileSync(scriptPath, `#!/bin/bash
|
|
235
|
+
echo "Running in interactive mode"
|
|
236
|
+
exit 0
|
|
237
|
+
`);
|
|
238
|
+
chmodSync(scriptPath, 0o755);
|
|
239
|
+
|
|
240
|
+
// Should not throw - default is interactive mode
|
|
241
|
+
await runScriptsInTerminal(scriptsDir, workspacePath, 'test-workspace', 'test/repo');
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('environment variables', () => {
|
|
246
|
+
it('should pass bundle values as SPACE_VALUE_* env vars', async () => {
|
|
247
|
+
const outputFile = join(testDir, 'env.txt');
|
|
248
|
+
const scriptPath = join(scriptsDir, '01-env.sh');
|
|
249
|
+
writeFileSync(scriptPath, `#!/bin/bash
|
|
250
|
+
echo "$SPACE_VALUE_API_KEY" >> "${outputFile}"
|
|
251
|
+
echo "$SPACE_VALUE_DATABASE_URL" >> "${outputFile}"
|
|
252
|
+
`);
|
|
253
|
+
chmodSync(scriptPath, 0o755);
|
|
254
|
+
|
|
255
|
+
await runScriptsInTerminal(scriptsDir, workspacePath, 'test-workspace', 'test/repo', {
|
|
256
|
+
nonInteractive: true,
|
|
257
|
+
bundleValues: {
|
|
258
|
+
'api-key': 'my-api-key',
|
|
259
|
+
'database_url': 'postgres://localhost/db',
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const output = await Bun.file(outputFile).text();
|
|
264
|
+
const lines = output.trim().split('\n');
|
|
265
|
+
expect(lines[0]).toBe('my-api-key');
|
|
266
|
+
expect(lines[1]).toBe('postgres://localhost/db');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('should pass bundle secrets as SPACE_SECRET_* env vars', async () => {
|
|
270
|
+
const outputFile = join(testDir, 'secrets.txt');
|
|
271
|
+
const scriptPath = join(scriptsDir, '01-secrets.sh');
|
|
272
|
+
writeFileSync(scriptPath, `#!/bin/bash
|
|
273
|
+
echo "$SPACE_SECRET_TOKEN" >> "${outputFile}"
|
|
274
|
+
`);
|
|
275
|
+
chmodSync(scriptPath, 0o755);
|
|
276
|
+
|
|
277
|
+
await runScriptsInTerminal(scriptsDir, workspacePath, 'test-workspace', 'test/repo', {
|
|
278
|
+
nonInteractive: true,
|
|
279
|
+
bundleSecrets: {
|
|
280
|
+
'token': 'super-secret-token',
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const output = await Bun.file(outputFile).text();
|
|
285
|
+
expect(output.trim()).toBe('super-secret-token');
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe('no scripts', () => {
|
|
290
|
+
it('should complete successfully when no scripts exist', async () => {
|
|
291
|
+
// scriptsDir exists but is empty
|
|
292
|
+
await runScriptsInTerminal(scriptsDir, workspacePath, 'test-workspace', 'test/repo', {
|
|
293
|
+
nonInteractive: true,
|
|
294
|
+
});
|
|
295
|
+
// Should not throw
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should complete successfully when scripts dir does not exist', async () => {
|
|
299
|
+
const nonExistentDir = join(testDir, 'non-existent-scripts');
|
|
300
|
+
await runScriptsInTerminal(nonExistentDir, workspacePath, 'test-workspace', 'test/repo', {
|
|
301
|
+
nonInteractive: true,
|
|
302
|
+
});
|
|
303
|
+
// Should not throw
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for run-workspace-scripts.ts
|
|
3
|
+
*
|
|
4
|
+
* Tests the consolidated workspace script execution with phase tracking.
|
|
5
|
+
* Uses real temporary directories and scripts to verify behavior.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
|
|
9
|
+
import { mkdirSync, writeFileSync, chmodSync, rmSync, existsSync } from 'fs';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
import { tmpdir } from 'os';
|
|
12
|
+
|
|
13
|
+
// We need to mock the config and secrets modules before importing the function
|
|
14
|
+
const mockProjectConfig = {
|
|
15
|
+
repository: 'owner/repo',
|
|
16
|
+
baseBranch: 'main',
|
|
17
|
+
bundleValues: { testKey: 'testValue' },
|
|
18
|
+
bundleSecretKeys: [],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
mock.module('../../core/config', () => ({
|
|
22
|
+
readProjectConfig: () => mockProjectConfig,
|
|
23
|
+
getScriptsPhaseDir: (projectName: string, phase: string) => {
|
|
24
|
+
// Return the test scripts directory based on phase
|
|
25
|
+
return join(globalTestDir, 'scripts', phase);
|
|
26
|
+
},
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
mock.module('./secrets', () => ({
|
|
30
|
+
getProjectSecrets: async () => ({}),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
// Track the test directory globally so the mock can access it
|
|
34
|
+
let globalTestDir: string;
|
|
35
|
+
|
|
36
|
+
// Import after mocking
|
|
37
|
+
import { runWorkspaceScripts } from '../run-workspace-scripts';
|
|
38
|
+
import { hasSetupBeenRun, markSetupComplete, clearSetupMarker } from '../workspace-state';
|
|
39
|
+
|
|
40
|
+
describe('runWorkspaceScripts', () => {
|
|
41
|
+
let testDir: string;
|
|
42
|
+
let workspacePath: string;
|
|
43
|
+
let preScriptsDir: string;
|
|
44
|
+
let setupScriptsDir: string;
|
|
45
|
+
let selectScriptsDir: string;
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
testDir = join(tmpdir(), `workspace-scripts-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
49
|
+
globalTestDir = testDir;
|
|
50
|
+
workspacePath = join(testDir, 'workspace');
|
|
51
|
+
preScriptsDir = join(testDir, 'scripts', 'pre');
|
|
52
|
+
setupScriptsDir = join(testDir, 'scripts', 'setup');
|
|
53
|
+
selectScriptsDir = join(testDir, 'scripts', 'select');
|
|
54
|
+
|
|
55
|
+
mkdirSync(preScriptsDir, { recursive: true });
|
|
56
|
+
mkdirSync(setupScriptsDir, { recursive: true });
|
|
57
|
+
mkdirSync(selectScriptsDir, { recursive: true });
|
|
58
|
+
mkdirSync(workspacePath, { recursive: true });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
if (existsSync(testDir)) {
|
|
63
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('first-time workspace (pre + setup)', () => {
|
|
68
|
+
it('should run pre and setup scripts successfully', async () => {
|
|
69
|
+
const outputFile = join(testDir, 'output.txt');
|
|
70
|
+
|
|
71
|
+
// Create pre script
|
|
72
|
+
const preScript = join(preScriptsDir, '01-pre.sh');
|
|
73
|
+
writeFileSync(preScript, `#!/bin/bash\necho "pre" >> "${outputFile}"`);
|
|
74
|
+
chmodSync(preScript, 0o755);
|
|
75
|
+
|
|
76
|
+
// Create setup script
|
|
77
|
+
const setupScript = join(setupScriptsDir, '01-setup.sh');
|
|
78
|
+
writeFileSync(setupScript, `#!/bin/bash\necho "setup" >> "${outputFile}"`);
|
|
79
|
+
chmodSync(setupScript, 0o755);
|
|
80
|
+
|
|
81
|
+
const result = await runWorkspaceScripts({
|
|
82
|
+
projectName: 'test-project',
|
|
83
|
+
workspacePath,
|
|
84
|
+
workspaceName: 'test-workspace',
|
|
85
|
+
repository: 'owner/repo',
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(result.success).toBe(true);
|
|
89
|
+
|
|
90
|
+
// Verify both scripts ran in order
|
|
91
|
+
const output = await Bun.file(outputFile).text();
|
|
92
|
+
expect(output.trim().split('\n')).toEqual(['pre', 'setup']);
|
|
93
|
+
|
|
94
|
+
// Verify setup was marked complete
|
|
95
|
+
expect(hasSetupBeenRun(workspacePath)).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should return pre phase error when pre script fails', async () => {
|
|
99
|
+
// Create failing pre script
|
|
100
|
+
const preScript = join(preScriptsDir, '01-fail.sh');
|
|
101
|
+
writeFileSync(preScript, `#!/bin/bash\necho "failing"; exit 1`);
|
|
102
|
+
chmodSync(preScript, 0o755);
|
|
103
|
+
|
|
104
|
+
// Create setup script (should never run)
|
|
105
|
+
const setupScript = join(setupScriptsDir, '01-setup.sh');
|
|
106
|
+
writeFileSync(setupScript, `#!/bin/bash\necho "setup"`);
|
|
107
|
+
chmodSync(setupScript, 0o755);
|
|
108
|
+
|
|
109
|
+
const result = await runWorkspaceScripts({
|
|
110
|
+
projectName: 'test-project',
|
|
111
|
+
workspacePath,
|
|
112
|
+
workspaceName: 'test-workspace',
|
|
113
|
+
repository: 'owner/repo',
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(result.success).toBe(false);
|
|
117
|
+
if (!result.success) {
|
|
118
|
+
expect(result.phase).toBe('pre');
|
|
119
|
+
expect(result.error).toContain('exit code 1');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Verify setup was NOT marked complete
|
|
123
|
+
expect(hasSetupBeenRun(workspacePath)).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should return setup phase error when setup script fails', async () => {
|
|
127
|
+
const outputFile = join(testDir, 'output.txt');
|
|
128
|
+
|
|
129
|
+
// Create passing pre script
|
|
130
|
+
const preScript = join(preScriptsDir, '01-pre.sh');
|
|
131
|
+
writeFileSync(preScript, `#!/bin/bash\necho "pre" >> "${outputFile}"`);
|
|
132
|
+
chmodSync(preScript, 0o755);
|
|
133
|
+
|
|
134
|
+
// Create failing setup script
|
|
135
|
+
const setupScript = join(setupScriptsDir, '01-setup.sh');
|
|
136
|
+
writeFileSync(setupScript, `#!/bin/bash\necho "setup" >> "${outputFile}"; exit 1`);
|
|
137
|
+
chmodSync(setupScript, 0o755);
|
|
138
|
+
|
|
139
|
+
const result = await runWorkspaceScripts({
|
|
140
|
+
projectName: 'test-project',
|
|
141
|
+
workspacePath,
|
|
142
|
+
workspaceName: 'test-workspace',
|
|
143
|
+
repository: 'owner/repo',
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(result.success).toBe(false);
|
|
147
|
+
if (!result.success) {
|
|
148
|
+
expect(result.phase).toBe('setup');
|
|
149
|
+
expect(result.error).toContain('exit code 1');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Verify pre script ran but setup wasn't marked complete
|
|
153
|
+
const output = await Bun.file(outputFile).text();
|
|
154
|
+
expect(output.trim().split('\n')).toContain('pre');
|
|
155
|
+
expect(hasSetupBeenRun(workspacePath)).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('existing workspace (select only)', () => {
|
|
160
|
+
beforeEach(() => {
|
|
161
|
+
// Mark setup as already complete
|
|
162
|
+
markSetupComplete(workspacePath);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
afterEach(() => {
|
|
166
|
+
clearSetupMarker(workspacePath);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should run only select scripts for existing workspace', async () => {
|
|
170
|
+
const outputFile = join(testDir, 'output.txt');
|
|
171
|
+
|
|
172
|
+
// Create pre script (should NOT run)
|
|
173
|
+
const preScript = join(preScriptsDir, '01-pre.sh');
|
|
174
|
+
writeFileSync(preScript, `#!/bin/bash\necho "pre" >> "${outputFile}"`);
|
|
175
|
+
chmodSync(preScript, 0o755);
|
|
176
|
+
|
|
177
|
+
// Create setup script (should NOT run)
|
|
178
|
+
const setupScript = join(setupScriptsDir, '01-setup.sh');
|
|
179
|
+
writeFileSync(setupScript, `#!/bin/bash\necho "setup" >> "${outputFile}"`);
|
|
180
|
+
chmodSync(setupScript, 0o755);
|
|
181
|
+
|
|
182
|
+
// Create select script (should run)
|
|
183
|
+
const selectScript = join(selectScriptsDir, '01-select.sh');
|
|
184
|
+
writeFileSync(selectScript, `#!/bin/bash\necho "select" >> "${outputFile}"`);
|
|
185
|
+
chmodSync(selectScript, 0o755);
|
|
186
|
+
|
|
187
|
+
const result = await runWorkspaceScripts({
|
|
188
|
+
projectName: 'test-project',
|
|
189
|
+
workspacePath,
|
|
190
|
+
workspaceName: 'test-workspace',
|
|
191
|
+
repository: 'owner/repo',
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(result.success).toBe(true);
|
|
195
|
+
|
|
196
|
+
// Verify only select script ran
|
|
197
|
+
const output = await Bun.file(outputFile).text();
|
|
198
|
+
expect(output.trim()).toBe('select');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should return select phase error when select script fails', async () => {
|
|
202
|
+
// Create failing select script
|
|
203
|
+
const selectScript = join(selectScriptsDir, '01-fail.sh');
|
|
204
|
+
writeFileSync(selectScript, `#!/bin/bash\necho "failing select"; exit 1`);
|
|
205
|
+
chmodSync(selectScript, 0o755);
|
|
206
|
+
|
|
207
|
+
const result = await runWorkspaceScripts({
|
|
208
|
+
projectName: 'test-project',
|
|
209
|
+
workspacePath,
|
|
210
|
+
workspaceName: 'test-workspace',
|
|
211
|
+
repository: 'owner/repo',
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
expect(result.success).toBe(false);
|
|
215
|
+
if (!result.success) {
|
|
216
|
+
expect(result.phase).toBe('select');
|
|
217
|
+
expect(result.error).toContain('exit code 1');
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('no scripts', () => {
|
|
223
|
+
it('should succeed when no scripts exist for first-time workspace', async () => {
|
|
224
|
+
// Empty script directories (created in beforeEach but with no scripts)
|
|
225
|
+
|
|
226
|
+
const result = await runWorkspaceScripts({
|
|
227
|
+
projectName: 'test-project',
|
|
228
|
+
workspacePath,
|
|
229
|
+
workspaceName: 'test-workspace',
|
|
230
|
+
repository: 'owner/repo',
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(result.success).toBe(true);
|
|
234
|
+
expect(hasSetupBeenRun(workspacePath)).toBe(true);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('should succeed when no select scripts exist for existing workspace', async () => {
|
|
238
|
+
markSetupComplete(workspacePath);
|
|
239
|
+
|
|
240
|
+
const result = await runWorkspaceScripts({
|
|
241
|
+
projectName: 'test-project',
|
|
242
|
+
workspacePath,
|
|
243
|
+
workspaceName: 'test-workspace',
|
|
244
|
+
repository: 'owner/repo',
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
expect(result.success).toBe(true);
|
|
248
|
+
|
|
249
|
+
clearSetupMarker(workspacePath);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
});
|
package/src/utils/run-scripts.ts
CHANGED
|
@@ -53,6 +53,13 @@ export interface RunScriptsOptions {
|
|
|
53
53
|
bundleValues?: Record<string, string>;
|
|
54
54
|
/** Secret values to pass as SPACE_SECRET_* environment variables */
|
|
55
55
|
bundleSecrets?: Record<string, string>;
|
|
56
|
+
/**
|
|
57
|
+
* Run scripts in non-interactive mode (for daemon/remote contexts).
|
|
58
|
+
* - stdin is closed immediately (scripts can't prompt for input)
|
|
59
|
+
* - stdout/stderr are captured and logged on failure
|
|
60
|
+
* - Prevents scripts from blocking indefinitely
|
|
61
|
+
*/
|
|
62
|
+
nonInteractive?: boolean;
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
/**
|
|
@@ -104,15 +111,32 @@ export async function runScriptsInTerminal(
|
|
|
104
111
|
const scriptName = scriptPath.split('/').pop() || scriptPath;
|
|
105
112
|
logger.dim(` $ ${scriptName} ${workspaceName} ${repository}`);
|
|
106
113
|
|
|
114
|
+
// Non-interactive mode: stdin=ignore, capture stdout/stderr
|
|
115
|
+
// Interactive mode: inherit all stdio
|
|
116
|
+
const stdio: 'inherit' | ['ignore', 'pipe', 'pipe'] = options?.nonInteractive
|
|
117
|
+
? ['ignore', 'pipe', 'pipe']
|
|
118
|
+
: 'inherit';
|
|
119
|
+
|
|
107
120
|
const child = spawn(scriptPath, [workspaceName, repository], {
|
|
108
|
-
stdio
|
|
121
|
+
stdio,
|
|
109
122
|
shell: false,
|
|
110
123
|
cwd: workspacePath,
|
|
111
124
|
env: scriptEnv,
|
|
112
125
|
});
|
|
113
126
|
|
|
114
|
-
|
|
127
|
+
// Capture output in non-interactive mode for logging on failure
|
|
128
|
+
let output = '';
|
|
129
|
+
if (options?.nonInteractive && child.stdout && child.stderr) {
|
|
130
|
+
child.stdout.on('data', (data: Buffer) => { output += data.toString(); });
|
|
131
|
+
child.stderr.on('data', (data: Buffer) => { output += data.toString(); });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
child.on('close', (code: number | null) => {
|
|
115
135
|
if (code !== 0) {
|
|
136
|
+
// Log captured output on failure in non-interactive mode
|
|
137
|
+
if (options?.nonInteractive && output) {
|
|
138
|
+
logger.debug(`Script output:\n${output}`);
|
|
139
|
+
}
|
|
116
140
|
reject(
|
|
117
141
|
new SpacesError(
|
|
118
142
|
`Script failed with exit code ${code}: ${scriptName}`,
|
|
@@ -125,7 +149,7 @@ export async function runScriptsInTerminal(
|
|
|
125
149
|
}
|
|
126
150
|
});
|
|
127
151
|
|
|
128
|
-
child.on('error', (error) => {
|
|
152
|
+
child.on('error', (error: Error) => {
|
|
129
153
|
reject(
|
|
130
154
|
new SpacesError(
|
|
131
155
|
`Failed to run script: ${error.message}`,
|