gitspace 0.2.0-rc.4 → 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.
@@ -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
+ });
@@ -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: 'inherit',
121
+ stdio,
109
122
  shell: false,
110
123
  cwd: workspacePath,
111
124
  env: scriptEnv,
112
125
  });
113
126
 
114
- child.on('close', (code) => {
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}`,