gitspace 0.2.0-rc.17 → 0.2.0-rc.18

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.
@@ -3,8 +3,8 @@
3
3
  #
4
4
  # This runs every time you switch to an existing workspace.
5
5
  #
6
- # Bundle values are available as environment variables using bundle key names.
7
- # Example: DEVELOPERNAME, EXAMPLEAPITOKEN
6
+ # Bundle values are available using exact keys and uppercase snake-case aliases.
7
+ # Example aliases: DEVELOPER_NAME, EXAMPLE_API_TOKEN
8
8
 
9
9
  WORKSPACE_NAME=$1
10
10
  REPOSITORY=$2
@@ -14,14 +14,14 @@ echo "=== Workspace: $WORKSPACE_NAME ==="
14
14
  echo ""
15
15
 
16
16
  # Show bundle values (proof of concept)
17
- if [ -n "$DEVELOPERNAME" ]; then
18
- echo "Welcome back, $DEVELOPERNAME!"
17
+ if [ -n "$DEVELOPER_NAME" ]; then
18
+ echo "Welcome back, $DEVELOPER_NAME!"
19
19
  fi
20
20
 
21
21
  # Show that we have access to the secret (masked)
22
- if [ -n "$EXAMPLEAPITOKEN" ]; then
22
+ if [ -n "$EXAMPLE_API_TOKEN" ]; then
23
23
  # Only show first 4 characters to prove we have access
24
- TOKEN_PREVIEW="${EXAMPLEAPITOKEN:0:4}..."
24
+ TOKEN_PREVIEW="${EXAMPLE_API_TOKEN:0:4}..."
25
25
  echo "API Token available: $TOKEN_PREVIEW (stored in OS keychain)"
26
26
  fi
27
27
 
package/README.md CHANGED
@@ -183,7 +183,7 @@ A bundle is a directory (typically `.gitspace/`) containing:
183
183
  Bundle values are passed to scripts as environment variables using the configured bundle keys:
184
184
 
185
185
  - `<KEY>` - Regular or secret value using the exact `configKey` from `bundle.json`
186
- - Legacy aliases `SPACE_VALUE_<KEY>` / `SPACE_SECRET_<KEY>` are also provided for compatibility
186
+ - `<NORMALIZED_KEY>` - Uppercase snake-case alias (for example, `teamName` -> `TEAM_NAME`)
187
187
 
188
188
  **Example script:**
189
189
 
@@ -195,12 +195,12 @@ WORKSPACE_NAME=$1
195
195
  REPOSITORY=$2
196
196
 
197
197
  # Access bundle values
198
- if [ -n "$TEAMNAME" ]; then
199
- echo "Welcome, $TEAMNAME team!"
198
+ if [ -n "$TEAM_NAME" ]; then
199
+ echo "Welcome, $TEAM_NAME team!"
200
200
  fi
201
201
 
202
202
  # Access secrets (stored securely in OS keychain)
203
- if [ -n "$APIKEY" ]; then
203
+ if [ -n "$API_KEY" ]; then
204
204
  echo "API Key configured"
205
205
  fi
206
206
  ```
@@ -393,9 +393,8 @@ inside each workspace so they can vary by branch:
393
393
  export SPACES_CURRENT_PROJECT="my-app"
394
394
 
395
395
  # Available in scripts (from bundle onboarding):
396
- # <KEY> - Value by bundle config key name
397
- # SPACE_VALUE_<KEY> - Legacy alias for regular values
398
- # SPACE_SECRET_<KEY> - Legacy alias for secret values
396
+ # <KEY> - Value by exact bundle config key name
397
+ # <NORMALIZED_KEY> - Uppercase snake-case alias (e.g. teamName -> TEAM_NAME)
399
398
  ```
400
399
 
401
400
  ## Directory Structure
package/bun.lock CHANGED
@@ -39,10 +39,10 @@
39
39
  "typescript": "^5.9.3",
40
40
  },
41
41
  "optionalDependencies": {
42
- "@gitspace/darwin-arm64": "0.2.0-rc.15",
43
- "@gitspace/darwin-x64": "0.2.0-rc.15",
44
- "@gitspace/linux-arm64": "0.2.0-rc.15",
45
- "@gitspace/linux-x64": "0.2.0-rc.15",
42
+ "@gitspace/darwin-arm64": "0.2.0-rc.17",
43
+ "@gitspace/darwin-x64": "0.2.0-rc.17",
44
+ "@gitspace/linux-arm64": "0.2.0-rc.17",
45
+ "@gitspace/linux-x64": "0.2.0-rc.17",
46
46
  },
47
47
  },
48
48
  },
@@ -63,7 +63,7 @@
63
63
 
64
64
  "@eslint/js": ["@eslint/js@8.57.1", "", {}, "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q=="],
65
65
 
66
- "@gitspace/darwin-arm64": ["@gitspace/darwin-arm64@0.2.0-rc.15", "", { "os": "darwin", "cpu": "arm64", "bin": { "gssh-darwin-arm64": "bin/gssh" } }, "sha512-gjqsRdz2f7cOOTbj97wmQ4Z0nh7jWUVN4Dxj1N/Ex4fp6dJ8qu2d5izbsCHWVIPX4uN+gK/qqzxuwLwCdaEqlQ=="],
66
+ "@gitspace/darwin-arm64": ["@gitspace/darwin-arm64@0.2.0-rc.17", "", { "os": "darwin", "cpu": "arm64", "bin": { "gssh-darwin-arm64": "bin/gssh" } }, "sha512-aPei17D31di296UBdqOt59HrdvcIRL2oVGVRWT4etCIESgjTyhZQcCLsIezsWI4E4/9DQAqaLAfTif7TsuCRHQ=="],
67
67
 
68
68
  "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
69
69
 
@@ -377,15 +377,14 @@ Using bundle values in scripts:
377
377
  ```bash
378
378
  #!/bin/bash
379
379
  # Values available as environment variables:
380
- # <KEY> - Value by bundle config key name
381
- # SPACE_VALUE_<KEY> - Legacy alias for regular values
382
- # SPACE_SECRET_<KEY> - Legacy alias for secret values
380
+ # <KEY> - Value by exact bundle config key name
381
+ # <NORMALIZED_KEY> - Uppercase snake-case alias (e.g. teamName -> TEAM_NAME)
383
382
 
384
- if [ -n "$TEAMNAME" ]; then
385
- echo "Welcome, $TEAMNAME team!"
383
+ if [ -n "$TEAM_NAME" ]; then
384
+ echo "Welcome, $TEAM_NAME team!"
386
385
  fi
387
386
 
388
- if [ -n "$APIKEY" ]; then
387
+ if [ -n "$API_KEY" ]; then
389
388
  echo "API Key configured"
390
389
  fi
391
390
  ```
@@ -960,8 +959,8 @@ Bundles allow teams to share onboarding configurations. Place in `.gitspace/`:
960
959
 
961
960
  **Using values in scripts:**
962
961
  ```bash
963
- echo "Team: $TEAMNAME"
964
- echo "Has API key: $APIKEY"
962
+ echo "Team: $TEAM_NAME"
963
+ echo "Has API key: $API_KEY"
965
964
  ```
966
965
 
967
966
  ---
@@ -378,8 +378,8 @@ git status`} />
378
378
  </div>
379
379
 
380
380
  <h3 className="text-xl font-semibold text-white mb-4">Using Values in Scripts</h3>
381
- <JsonBlock code={`echo "Team: $TEAMNAME"
382
- echo "Has API key: $APIKEY"`} />
381
+ <JsonBlock code={`echo "Team: $TEAM_NAME"
382
+ echo "Has API key: $API_KEY"`} />
383
383
  </div>
384
384
  );
385
385
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitspace",
3
- "version": "0.2.0-rc.17",
3
+ "version": "0.2.0-rc.18",
4
4
  "description": "CLI for managing GitHub workspaces with git worktrees and secure remote terminal access",
5
5
  "bin": {
6
6
  "gssh": "./bin/gssh"
@@ -17,10 +17,10 @@
17
17
  "relay": "bun src/relay/index.ts"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@gitspace/darwin-arm64": "0.2.0-rc.17",
21
- "@gitspace/darwin-x64": "0.2.0-rc.17",
22
- "@gitspace/linux-x64": "0.2.0-rc.17",
23
- "@gitspace/linux-arm64": "0.2.0-rc.17"
20
+ "@gitspace/darwin-arm64": "0.2.0-rc.18",
21
+ "@gitspace/darwin-x64": "0.2.0-rc.18",
22
+ "@gitspace/linux-x64": "0.2.0-rc.18",
23
+ "@gitspace/linux-arm64": "0.2.0-rc.18"
24
24
  },
25
25
  "keywords": [
26
26
  "cli",
@@ -0,0 +1,209 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { validateBundle } from '../bundle';
3
+
4
+ describe('validateBundle', () => {
5
+ it('allows unique config keys with distinct normalized aliases', () => {
6
+ expect(() =>
7
+ validateBundle({
8
+ version: '1.0',
9
+ name: 'Valid Bundle',
10
+ onboarding: [
11
+ {
12
+ id: 'env-name',
13
+ type: 'input',
14
+ title: 'Environment Name',
15
+ description: 'Name of environment',
16
+ configKey: 'vercelEnv',
17
+ },
18
+ {
19
+ id: 'api-token',
20
+ type: 'secret',
21
+ title: 'API Token',
22
+ description: 'Service token',
23
+ configKey: 'apiToken',
24
+ },
25
+ ],
26
+ })
27
+ ).not.toThrow();
28
+ });
29
+
30
+ it('throws helpful error when configKey is duplicated across steps', () => {
31
+ expect(() =>
32
+ validateBundle({
33
+ version: '1.0',
34
+ name: 'Duplicate Key Bundle',
35
+ onboarding: [
36
+ {
37
+ id: 'api-token-input',
38
+ type: 'input',
39
+ title: 'API token input',
40
+ description: 'input',
41
+ configKey: 'apiToken',
42
+ },
43
+ {
44
+ id: 'api-token-secret',
45
+ type: 'secret',
46
+ title: 'API token secret',
47
+ description: 'secret',
48
+ configKey: 'apiToken',
49
+ },
50
+ ],
51
+ })
52
+ ).toThrow(/Bundle configKey collision/);
53
+ });
54
+
55
+ it('throws helpful error when normalized aliases collide', () => {
56
+ expect(() =>
57
+ validateBundle({
58
+ version: '1.0',
59
+ name: 'Alias Collision Bundle',
60
+ onboarding: [
61
+ {
62
+ id: 'api-token-dash',
63
+ type: 'input',
64
+ title: 'API token dash',
65
+ description: 'dash',
66
+ configKey: 'api-token',
67
+ },
68
+ {
69
+ id: 'api-token-camel',
70
+ type: 'secret',
71
+ title: 'API token camel',
72
+ description: 'camel',
73
+ configKey: 'apiToken',
74
+ },
75
+ ],
76
+ })
77
+ ).toThrow(/Bundle configKey alias collision/);
78
+
79
+ expect(() =>
80
+ validateBundle({
81
+ version: '1.0',
82
+ name: 'Alias Collision Bundle',
83
+ onboarding: [
84
+ {
85
+ id: 'api-token-dash',
86
+ type: 'input',
87
+ title: 'API token dash',
88
+ description: 'dash',
89
+ configKey: 'api-token',
90
+ },
91
+ {
92
+ id: 'api-token-camel',
93
+ type: 'secret',
94
+ title: 'API token camel',
95
+ description: 'camel',
96
+ configKey: 'apiToken',
97
+ },
98
+ ],
99
+ })
100
+ ).toThrow(/API_TOKEN/);
101
+ });
102
+
103
+ it('treats acronym boundaries as part of normalized alias collisions', () => {
104
+ expect(() =>
105
+ validateBundle({
106
+ version: '1.0',
107
+ name: 'Acronym Collision Bundle',
108
+ onboarding: [
109
+ {
110
+ id: 'http-client-acronym',
111
+ type: 'input',
112
+ title: 'HTTP client acronym',
113
+ description: 'acronym',
114
+ configKey: 'myHTTPClient',
115
+ },
116
+ {
117
+ id: 'http-client-camel',
118
+ type: 'secret',
119
+ title: 'HTTP client camel',
120
+ description: 'camel',
121
+ configKey: 'myHttpClient',
122
+ },
123
+ ],
124
+ })
125
+ ).toThrow(/Bundle configKey alias collision/);
126
+
127
+ expect(() =>
128
+ validateBundle({
129
+ version: '1.0',
130
+ name: 'Acronym Collision Bundle',
131
+ onboarding: [
132
+ {
133
+ id: 'http-client-acronym',
134
+ type: 'input',
135
+ title: 'HTTP client acronym',
136
+ description: 'acronym',
137
+ configKey: 'myHTTPClient',
138
+ },
139
+ {
140
+ id: 'http-client-camel',
141
+ type: 'secret',
142
+ title: 'HTTP client camel',
143
+ description: 'camel',
144
+ configKey: 'myHttpClient',
145
+ },
146
+ ],
147
+ })
148
+ ).toThrow(/MY_HTTP_CLIENT/);
149
+ });
150
+
151
+ it('throws helpful error when normalized alias is not shell-safe', () => {
152
+ expect(() =>
153
+ validateBundle({
154
+ version: '1.0',
155
+ name: 'Digit Prefix Bundle',
156
+ onboarding: [
157
+ {
158
+ id: 'two-fa-token',
159
+ type: 'secret',
160
+ title: '2FA token',
161
+ description: '2FA token',
162
+ configKey: '2faToken',
163
+ },
164
+ ],
165
+ })
166
+ ).toThrow(/non-shell env alias/);
167
+
168
+ expect(() =>
169
+ validateBundle({
170
+ version: '1.0',
171
+ name: 'Digit Prefix Bundle',
172
+ onboarding: [
173
+ {
174
+ id: 'two-fa-token',
175
+ type: 'secret',
176
+ title: '2FA token',
177
+ description: '2FA token',
178
+ configKey: '2faToken',
179
+ },
180
+ ],
181
+ })
182
+ ).toThrow(/2FA_TOKEN/);
183
+ });
184
+
185
+ it('detects exact key and normalized alias collisions', () => {
186
+ expect(() =>
187
+ validateBundle({
188
+ version: '1.0',
189
+ name: 'Exact Alias Collision Bundle',
190
+ onboarding: [
191
+ {
192
+ id: 'exact-upper',
193
+ type: 'input',
194
+ title: 'Exact upper',
195
+ description: 'exact',
196
+ configKey: 'API_TOKEN',
197
+ },
198
+ {
199
+ id: 'camel-key',
200
+ type: 'secret',
201
+ title: 'Camel key',
202
+ description: 'camel',
203
+ configKey: 'apiToken',
204
+ },
205
+ ],
206
+ })
207
+ ).toThrow(/Bundle configKey alias collision/);
208
+ });
209
+ });
@@ -17,6 +17,7 @@ import { exec } from 'child_process';
17
17
  import { promisify } from 'util';
18
18
  import { SpacesError } from '../types/errors.js';
19
19
  import { logger } from '../utils/logger.js';
20
+ import { isShellEnvKey, normalizeEnvKey } from '../utils/normalize-env-key.js';
20
21
  import type { SpacesBundle, LoadedBundle } from '../types/bundle.js';
21
22
 
22
23
  const BUNDLE_FILENAME = 'bundle.json';
@@ -211,6 +212,9 @@ export function validateBundle(bundle: SpacesBundle): void {
211
212
  // Validate onboarding steps if present
212
213
  if (bundle.onboarding) {
213
214
  const ids = new Set<string>();
215
+ const configKeys = new Map<string, string>();
216
+ const normalizedAliases = new Map<string, { stepId: string; configKey: string }>();
217
+
214
218
  for (const step of bundle.onboarding) {
215
219
  if (!step.id) {
216
220
  throw new SpacesError('Each onboarding step must have an id', 'USER_ERROR', 1);
@@ -235,6 +239,72 @@ export function validateBundle(bundle: SpacesBundle): void {
235
239
  1
236
240
  );
237
241
  }
242
+
243
+ const existingConfigKeyStepId = configKeys.get(stepWithKey.configKey);
244
+ if (existingConfigKeyStepId) {
245
+ throw new SpacesError(
246
+ [
247
+ 'Bundle configKey collision',
248
+ '',
249
+ `The configKey "${stepWithKey.configKey}" is used by multiple onboarding steps.`,
250
+ `- step "${existingConfigKeyStepId}"`,
251
+ `- step "${step.id}"`,
252
+ '',
253
+ 'Fix: each input/secret step must use a unique configKey.',
254
+ ].join('\n'),
255
+ 'USER_ERROR',
256
+ 1
257
+ );
258
+ }
259
+ configKeys.set(stepWithKey.configKey, step.id);
260
+
261
+ const normalizedAlias = normalizeEnvKey(stepWithKey.configKey);
262
+ if (!normalizedAlias) {
263
+ throw new SpacesError(
264
+ `Step "${step.id}" has invalid configKey "${stepWithKey.configKey}" (no usable env alias)`,
265
+ 'USER_ERROR',
266
+ 1
267
+ );
268
+ }
269
+
270
+ if (!isShellEnvKey(normalizedAlias)) {
271
+ throw new SpacesError(
272
+ [
273
+ 'Bundle configKey produces non-shell env alias',
274
+ '',
275
+ `Step "${step.id}" configKey "${stepWithKey.configKey}" normalizes to "${normalizedAlias}".`,
276
+ `Scripts cannot access this via $${normalizedAlias}.`,
277
+ '',
278
+ 'Shell variable names must match: [A-Za-z_][A-Za-z0-9_]*',
279
+ 'Fix: rename the configKey so its normalized alias starts with a letter or underscore.',
280
+ ].join('\n'),
281
+ 'USER_ERROR',
282
+ 1
283
+ );
284
+ }
285
+
286
+ const existingAlias = normalizedAliases.get(normalizedAlias);
287
+ if (existingAlias && existingAlias.configKey !== stepWithKey.configKey) {
288
+ throw new SpacesError(
289
+ [
290
+ 'Bundle configKey alias collision',
291
+ '',
292
+ `Multiple config keys normalize to the same script env alias: ${normalizedAlias}`,
293
+ `- step "${existingAlias.stepId}" configKey "${existingAlias.configKey}"`,
294
+ `- step "${step.id}" configKey "${stepWithKey.configKey}"`,
295
+ '',
296
+ `Scripts would read an ambiguous value from $${normalizedAlias}.`,
297
+ 'Fix: rename one of the conflicting configKey values so each exported env var is unique.',
298
+ ].join('\n'),
299
+ 'USER_ERROR',
300
+ 1
301
+ );
302
+ }
303
+
304
+ normalizedAliases.set(normalizedAlias, {
305
+ stepId: step.id,
306
+ configKey: stepWithKey.configKey,
307
+ });
238
308
  }
239
309
  }
240
310
  }
@@ -333,22 +333,22 @@ echo "$TOKEN" >> "${outputFile}"
333
333
  expect(output.trim()).toBe('super-secret-token');
334
334
  });
335
335
 
336
- it('keeps legacy SPACE_* aliases for backwards compatibility', async () => {
336
+ it('provides uppercase snake-case aliases for camelCase keys', async () => {
337
337
  const outputFile = join(testDir, 'aliases.txt');
338
338
  const scriptPath = join(scriptsDir, '01-aliases.sh');
339
339
  writeFileSync(scriptPath, `#!/bin/bash
340
- echo "$SPACE_VALUE_TEAM_NAME" >> "${outputFile}"
341
- echo "$SPACE_SECRET_API_TOKEN" >> "${outputFile}"
340
+ echo "$TEAM_NAME" >> "${outputFile}"
341
+ echo "$API_TOKEN" >> "${outputFile}"
342
342
  `);
343
343
  chmodSync(scriptPath, 0o755);
344
344
 
345
345
  await runScriptsInTerminal(scriptsDir, workspacePath, 'test-workspace', 'test/repo', {
346
346
  nonInteractive: true,
347
347
  bundleValues: {
348
- TEAM_NAME: 'platform',
348
+ teamName: 'platform',
349
349
  },
350
350
  bundleSecrets: {
351
- API_TOKEN: 'shh',
351
+ apiToken: 'shh',
352
352
  },
353
353
  });
354
354
 
@@ -357,6 +357,107 @@ echo "$SPACE_SECRET_API_TOKEN" >> "${outputFile}"
357
357
  expect(lines[0]).toBe('platform');
358
358
  expect(lines[1]).toBe('shh');
359
359
  });
360
+
361
+ it('splits acronym boundaries when normalizing aliases', async () => {
362
+ const outputFile = join(testDir, 'acronyms.txt');
363
+ const scriptPath = join(scriptsDir, '01-acronyms.sh');
364
+ writeFileSync(scriptPath, `#!/bin/bash
365
+ echo "$MY_HTTP_CLIENT" >> "${outputFile}"
366
+ echo "$XML_PARSER" >> "${outputFile}"
367
+ `);
368
+ chmodSync(scriptPath, 0o755);
369
+
370
+ await runScriptsInTerminal(scriptsDir, workspacePath, 'test-workspace', 'test/repo', {
371
+ nonInteractive: true,
372
+ bundleValues: {
373
+ myHTTPClient: 'client-a',
374
+ XMLParser: 'parser-b',
375
+ },
376
+ });
377
+
378
+ const output = await Bun.file(outputFile).text();
379
+ const lines = output.trim().split('\n');
380
+ expect(lines[0]).toBe('client-a');
381
+ expect(lines[1]).toBe('parser-b');
382
+ });
383
+
384
+ it('throws helpful error when normalized alias is not shell-safe', async () => {
385
+ const scriptPath = join(scriptsDir, '01-noop.sh');
386
+ writeFileSync(scriptPath, '#!/bin/bash\necho "ok"');
387
+ chmodSync(scriptPath, 0o755);
388
+
389
+ await expect(
390
+ runScriptsInTerminal(scriptsDir, workspacePath, 'test-workspace', 'test/repo', {
391
+ nonInteractive: true,
392
+ bundleValues: {
393
+ '2faToken': 'secret',
394
+ },
395
+ })
396
+ ).rejects.toThrow(/not shell-safe/);
397
+
398
+ await expect(
399
+ runScriptsInTerminal(scriptsDir, workspacePath, 'test-workspace', 'test/repo', {
400
+ nonInteractive: true,
401
+ bundleValues: {
402
+ '2faToken': 'secret',
403
+ },
404
+ })
405
+ ).rejects.toThrow(/2FA_TOKEN/);
406
+ });
407
+
408
+ it('throws a helpful error when normalized aliases collide', async () => {
409
+ const scriptPath = join(scriptsDir, '01-noop.sh');
410
+ writeFileSync(scriptPath, '#!/bin/bash\necho "ok"');
411
+ chmodSync(scriptPath, 0o755);
412
+
413
+ await expect(
414
+ runScriptsInTerminal(scriptsDir, workspacePath, 'test-workspace', 'test/repo', {
415
+ nonInteractive: true,
416
+ bundleValues: {
417
+ apiToken: 'a',
418
+ 'api-token': 'b',
419
+ },
420
+ })
421
+ ).rejects.toThrow(/Bundle script env collision/);
422
+
423
+ await expect(
424
+ runScriptsInTerminal(scriptsDir, workspacePath, 'test-workspace', 'test/repo', {
425
+ nonInteractive: true,
426
+ bundleValues: {
427
+ apiToken: 'a',
428
+ 'api-token': 'b',
429
+ },
430
+ })
431
+ ).rejects.toThrow(/apiToken/);
432
+
433
+ await expect(
434
+ runScriptsInTerminal(scriptsDir, workspacePath, 'test-workspace', 'test/repo', {
435
+ nonInteractive: true,
436
+ bundleValues: {
437
+ apiToken: 'a',
438
+ 'api-token': 'b',
439
+ },
440
+ })
441
+ ).rejects.toThrow(/API_TOKEN/);
442
+ });
443
+
444
+ it('throws when same key is exported with conflicting values', async () => {
445
+ const scriptPath = join(scriptsDir, '01-noop.sh');
446
+ writeFileSync(scriptPath, '#!/bin/bash\necho "ok"');
447
+ chmodSync(scriptPath, 0o755);
448
+
449
+ await expect(
450
+ runScriptsInTerminal(scriptsDir, workspacePath, 'test-workspace', 'test/repo', {
451
+ nonInteractive: true,
452
+ bundleValues: {
453
+ sharedKey: 'from-value',
454
+ },
455
+ bundleSecrets: {
456
+ sharedKey: 'from-secret',
457
+ },
458
+ })
459
+ ).rejects.toThrow(/Bundle script env conflict/);
460
+ });
360
461
  });
361
462
 
362
463
  describe('no scripts', () => {
@@ -0,0 +1,13 @@
1
+ export function normalizeEnvKey(key: string): string {
2
+ return key
3
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
4
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
5
+ .replace(/[^A-Za-z0-9]+/g, '_')
6
+ .replace(/_+/g, '_')
7
+ .replace(/^_+|_+$/g, '')
8
+ .toUpperCase();
9
+ }
10
+
11
+ export function isShellEnvKey(key: string): boolean {
12
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key);
13
+ }
@@ -8,6 +8,7 @@ import { spawn } from 'child_process';
8
8
  import { join } from 'path';
9
9
  import { SpacesError } from '../types/errors.js';
10
10
  import { logger } from './logger.js';
11
+ import { isShellEnvKey, normalizeEnvKey } from './normalize-env-key.js';
11
12
 
12
13
  const FAILURE_OUTPUT_TAIL_MAX_LINES = 25;
13
14
  const FAILURE_OUTPUT_TAIL_MAX_CHARS = 4000;
@@ -112,24 +113,118 @@ export interface RunScriptsOptions {
112
113
  onOutput?: (data: Buffer) => void;
113
114
  }
114
115
 
115
- function normalizeEnvKey(key: string): string {
116
- return key.toUpperCase().replace(/[^A-Z0-9]/g, '_');
116
+ type BundleValueKind = 'value' | 'secret';
117
+
118
+ interface EnvBinding {
119
+ envName: string;
120
+ configKey: string;
121
+ sourceKind: BundleValueKind;
122
+ exportKind: 'exact' | 'normalized';
123
+ value: string;
124
+ }
125
+
126
+ function describeBinding(binding: EnvBinding): string {
127
+ return `${binding.configKey} (${binding.exportKind} ${binding.sourceKind})`;
128
+ }
129
+
130
+ function describeValue(binding: EnvBinding): string {
131
+ return binding.sourceKind === 'secret' ? '[redacted]' : JSON.stringify(binding.value);
132
+ }
133
+
134
+ function formatEnvCollisionMessage(existing: EnvBinding, incoming: EnvBinding): string {
135
+ return [
136
+ 'Bundle script env collision',
137
+ '',
138
+ `Multiple config keys resolve to the same environment variable: ${incoming.envName}`,
139
+ `- ${incoming.envName} <- ${describeBinding(existing)}`,
140
+ `- ${incoming.envName} <- ${describeBinding(incoming)}`,
141
+ '',
142
+ `Scripts would read an ambiguous value from $${incoming.envName}.`,
143
+ 'Fix: rename one of the conflicting configKey values in .gitspace/bundle.json so each exported env var is unique.',
144
+ ].join('\n');
117
145
  }
118
146
 
119
- function setCompatibilityAliases(
147
+ function formatDuplicateValueMessage(existing: EnvBinding, incoming: EnvBinding): string {
148
+ return [
149
+ 'Bundle script env conflict',
150
+ '',
151
+ `The same configKey is exported with different values: ${incoming.configKey}`,
152
+ `- ${describeBinding(existing)} => ${describeValue(existing)}`,
153
+ `- ${describeBinding(incoming)} => ${describeValue(incoming)}`,
154
+ '',
155
+ `This would silently overwrite $${incoming.envName}.`,
156
+ `Fix: ensure configKey "${incoming.configKey}" is defined once with a single value.`,
157
+ ].join('\n');
158
+ }
159
+
160
+ function formatInvalidNormalizedAliasMessage(configKey: string, normalizedAlias: string): string {
161
+ return [
162
+ 'Bundle script env alias is not shell-safe',
163
+ '',
164
+ `configKey "${configKey}" normalizes to "${normalizedAlias}".`,
165
+ `Scripts cannot access this via $${normalizedAlias}.`,
166
+ '',
167
+ 'Shell variable names must match: [A-Za-z_][A-Za-z0-9_]*',
168
+ 'Fix: rename the configKey so its normalized alias starts with a letter or underscore.',
169
+ ].join('\n');
170
+ }
171
+
172
+ function registerEnvBinding(
120
173
  env: Record<string, string>,
174
+ bindings: Map<string, EnvBinding>,
175
+ binding: EnvBinding
176
+ ): void {
177
+ const existing = bindings.get(binding.envName);
178
+
179
+ if (existing && existing.configKey !== binding.configKey) {
180
+ throw new SpacesError(formatEnvCollisionMessage(existing, binding), 'USER_ERROR', 1);
181
+ }
182
+
183
+ if (existing && existing.configKey === binding.configKey && existing.value !== binding.value) {
184
+ throw new SpacesError(formatDuplicateValueMessage(existing, binding), 'USER_ERROR', 1);
185
+ }
186
+
187
+ bindings.set(binding.envName, binding);
188
+ env[binding.envName] = binding.value;
189
+ }
190
+
191
+ function setScriptEnvVars(
192
+ env: Record<string, string>,
193
+ bindings: Map<string, EnvBinding>,
121
194
  key: string,
122
195
  value: string,
123
- prefix: 'SPACE_VALUE_' | 'SPACE_SECRET_'
196
+ sourceKind: BundleValueKind
124
197
  ): void {
198
+ registerEnvBinding(env, bindings, {
199
+ envName: key,
200
+ configKey: key,
201
+ sourceKind,
202
+ exportKind: 'exact',
203
+ value,
204
+ });
205
+
125
206
  const normalizedKey = normalizeEnvKey(key);
207
+ if (!normalizedKey) {
208
+ return;
209
+ }
210
+
211
+ if (!isShellEnvKey(normalizedKey)) {
212
+ throw new SpacesError(formatInvalidNormalizedAliasMessage(key, normalizedKey), 'USER_ERROR', 1);
213
+ }
126
214
 
127
- // Legacy namespaced key for backwards compatibility.
128
- env[`${prefix}${normalizedKey}`] = value;
215
+ if (normalizedKey === key) {
216
+ return;
217
+ }
129
218
 
130
- // Uppercase normalized alias for shell-friendly access when key contains
131
- // non-shell characters (for example, api-key -> API_KEY).
132
- env[normalizedKey] = value;
219
+ // Uppercase snake-case alias for shell-friendly access when key uses
220
+ // camelCase or punctuation (for example, apiToken -> API_TOKEN).
221
+ registerEnvBinding(env, bindings, {
222
+ envName: normalizedKey,
223
+ configKey: key,
224
+ sourceKind,
225
+ exportKind: 'normalized',
226
+ value,
227
+ });
133
228
  }
134
229
 
135
230
  /**
@@ -138,7 +233,8 @@ function setCompatibilityAliases(
138
233
  *
139
234
  * Bundle values are passed as environment variables:
140
235
  * - <KEY> using the exact bundle config key name
141
- * - Backward-compatible aliases: SPACE_VALUE_<KEY>, SPACE_SECRET_<KEY>, and normalized <KEY>
236
+ * - <NORMALIZED_KEY> uppercase snake-case alias (for example, apiToken -> API_TOKEN)
237
+ * - Throws a user-facing error when multiple keys collide on the same env name
142
238
  */
143
239
  export async function runScriptsInTerminal(
144
240
  scriptsDir: string,
@@ -159,20 +255,19 @@ export async function runScriptsInTerminal(
159
255
 
160
256
  // Build environment variables from bundle values
161
257
  const scriptEnv: Record<string, string> = { ...process.env } as Record<string, string>;
258
+ const envBindings = new Map<string, EnvBinding>();
162
259
 
163
260
  // Add bundle values using configured key names.
164
261
  if (options?.bundleValues) {
165
262
  for (const [key, value] of Object.entries(options.bundleValues)) {
166
- scriptEnv[key] = value;
167
- setCompatibilityAliases(scriptEnv, key, value, 'SPACE_VALUE_');
263
+ setScriptEnvVars(scriptEnv, envBindings, key, value, 'value');
168
264
  }
169
265
  }
170
266
 
171
267
  // Add bundle secrets using configured key names.
172
268
  if (options?.bundleSecrets) {
173
269
  for (const [key, value] of Object.entries(options.bundleSecrets)) {
174
- scriptEnv[key] = value;
175
- setCompatibilityAliases(scriptEnv, key, value, 'SPACE_SECRET_');
270
+ setScriptEnvVars(scriptEnv, envBindings, key, value, 'secret');
176
271
  }
177
272
  }
178
273