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.
- package/.gitspace/{select → scripts/select}/01-status.sh +6 -6
- package/README.md +6 -7
- package/bun.lock +5 -5
- package/docs/SITE_DOCS_FIGMA_MAKE.md +7 -8
- package/landing-page/src/components/docs/DocsContent.tsx +2 -2
- package/package.json +5 -5
- package/src/core/__tests__/bundle.test.ts +209 -0
- package/src/core/bundle.ts +70 -0
- package/src/utils/__tests__/run-scripts.test.ts +106 -5
- package/src/utils/normalize-env-key.ts +13 -0
- package/src/utils/run-scripts.ts +109 -14
- /package/.gitspace/{setup → scripts/setup}/01-install-deps.sh +0 -0
- /package/.gitspace/{setup → scripts/setup}/02-typecheck.sh +0 -0
|
@@ -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
|
|
7
|
-
# Example:
|
|
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 "$
|
|
18
|
-
echo "Welcome back, $
|
|
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 "$
|
|
22
|
+
if [ -n "$EXAMPLE_API_TOKEN" ]; then
|
|
23
23
|
# Only show first 4 characters to prove we have access
|
|
24
|
-
TOKEN_PREVIEW="${
|
|
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
|
-
-
|
|
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 "$
|
|
199
|
-
echo "Welcome, $
|
|
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 "$
|
|
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
|
-
#
|
|
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.
|
|
43
|
-
"@gitspace/darwin-x64": "0.2.0-rc.
|
|
44
|
-
"@gitspace/linux-arm64": "0.2.0-rc.
|
|
45
|
-
"@gitspace/linux-x64": "0.2.0-rc.
|
|
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.
|
|
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
|
-
#
|
|
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 "$
|
|
385
|
-
echo "Welcome, $
|
|
383
|
+
if [ -n "$TEAM_NAME" ]; then
|
|
384
|
+
echo "Welcome, $TEAM_NAME team!"
|
|
386
385
|
fi
|
|
387
386
|
|
|
388
|
-
if [ -n "$
|
|
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: $
|
|
964
|
-
echo "Has API key: $
|
|
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: $
|
|
382
|
-
echo "Has API key: $
|
|
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.
|
|
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.
|
|
21
|
-
"@gitspace/darwin-x64": "0.2.0-rc.
|
|
22
|
-
"@gitspace/linux-x64": "0.2.0-rc.
|
|
23
|
-
"@gitspace/linux-arm64": "0.2.0-rc.
|
|
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
|
+
});
|
package/src/core/bundle.ts
CHANGED
|
@@ -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('
|
|
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 "$
|
|
341
|
-
echo "$
|
|
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
|
-
|
|
348
|
+
teamName: 'platform',
|
|
349
349
|
},
|
|
350
350
|
bundleSecrets: {
|
|
351
|
-
|
|
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
|
+
}
|
package/src/utils/run-scripts.ts
CHANGED
|
@@ -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
|
-
|
|
116
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
215
|
+
if (normalizedKey === key) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
129
218
|
|
|
130
|
-
// Uppercase
|
|
131
|
-
//
|
|
132
|
-
env
|
|
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
|
-
* -
|
|
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
|
|
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
|
|
175
|
-
setCompatibilityAliases(scriptEnv, key, value, 'SPACE_SECRET_');
|
|
270
|
+
setScriptEnvVars(scriptEnv, envBindings, key, value, 'secret');
|
|
176
271
|
}
|
|
177
272
|
}
|
|
178
273
|
|
|
File without changes
|
|
File without changes
|