gitspace 0.2.0-rc.16 → 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.16",
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.16",
21
- "@gitspace/darwin-x64": "0.2.0-rc.16",
22
- "@gitspace/linux-x64": "0.2.0-rc.16",
23
- "@gitspace/linux-arm64": "0.2.0-rc.16"
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",
@@ -30,6 +30,7 @@ export interface UseWorkspaceDeleteFlowOptions {
30
30
  onDeleteSuccess?: (context: WorkspaceDeleteContext) => void | Promise<void>;
31
31
  onDeleteCancelled?: (context: WorkspaceDeleteContext) => void | Promise<void>;
32
32
  onDeleteError?: (context: WorkspaceDeleteErrorContext) => void | Promise<void>;
33
+ showLoadingDuringDelete?: boolean;
33
34
  }
34
35
 
35
36
  export interface UseWorkspaceDeleteFlowResult {
@@ -86,6 +87,7 @@ export function useWorkspaceDeleteFlow(
86
87
  onDeleteSuccess,
87
88
  onDeleteCancelled,
88
89
  onDeleteError,
90
+ showLoadingDuringDelete = false,
89
91
  } = options;
90
92
 
91
93
  const executeDelete = useCallback(async (
@@ -100,13 +102,17 @@ export function useWorkspaceDeleteFlow(
100
102
  };
101
103
 
102
104
  await onBeforeDelete?.(context);
103
- flow.showLoading({
104
- title: 'Deleting Workspace',
105
- message:
106
- params.scriptPolicy === 'skip'
107
- ? 'Removing workspace without cleanup scripts...'
108
- : `Running cleanup scripts for "${target.workspaceName}"...`,
109
- });
105
+ if (showLoadingDuringDelete) {
106
+ flow.showLoading({
107
+ title: 'Deleting Workspace',
108
+ message:
109
+ params.scriptPolicy === 'skip'
110
+ ? 'Removing workspace without cleanup scripts...'
111
+ : `Running cleanup scripts for "${target.workspaceName}"...`,
112
+ });
113
+ } else {
114
+ flow.close();
115
+ }
110
116
 
111
117
  try {
112
118
  await deleteWorkspace(target.projectName, target.workspaceId, params);
@@ -144,7 +150,15 @@ export function useWorkspaceDeleteFlow(
144
150
 
145
151
  return false;
146
152
  }
147
- }, [deleteWorkspace, flow, onBeforeDelete, onDeleteCancelled, onDeleteError, onDeleteSuccess]);
153
+ }, [
154
+ deleteWorkspace,
155
+ flow,
156
+ onBeforeDelete,
157
+ onDeleteCancelled,
158
+ onDeleteError,
159
+ onDeleteSuccess,
160
+ showLoadingDuringDelete,
161
+ ]);
148
162
 
149
163
  const deleteWorkspaceWithPrompt = useCallback(async (target: WorkspaceDeleteTarget): Promise<boolean> => {
150
164
  return executeDelete(target, { scriptPolicy: 'auto' }, false);
package/src/app.tui.tsx CHANGED
@@ -87,7 +87,10 @@ import { useUserActivity } from './hooks/index.js';
87
87
  import { useBundleRefreshAttachFlow } from './session/index.js';
88
88
  import { useAttachController } from './app/session/useAttachController.js';
89
89
  import { useWorkspaceDeleteFlow } from './app/session/useWorkspaceDeleteFlow.js';
90
- import { initializeSecretRuntime } from './core/secret-runtime.js';
90
+ import {
91
+ consumeLegacyCleanupReminderForTui,
92
+ initializeSecretRuntime,
93
+ } from './core/secret-runtime.js';
91
94
  import {
92
95
  resolveInboxCommand,
93
96
  resolveMachineListCommand,
@@ -1322,9 +1325,13 @@ function App({ relayConfig, onQuit }: AppProps) {
1322
1325
  // ========== Keyboard Handlers ==========
1323
1326
 
1324
1327
  useKeyboard(async (key) => {
1328
+ const localScriptTerminalRunning =
1329
+ state.view === 'scripts' &&
1330
+ (localScriptState?.isRunning ?? true);
1331
+
1325
1332
  // Handle flow modals FIRST - even in terminal view
1326
1333
  // This ensures y/n work in confirmation modals when terminal is underneath
1327
- if (flow.isOpen) {
1334
+ if (flow.isOpen && !localScriptTerminalRunning) {
1328
1335
  // Handle confirm modal with y/n shortcuts
1329
1336
  if (flow.flow.type === 'confirm') {
1330
1337
  if (key.raw === 'y' || key.name === 'return') {
@@ -2059,7 +2066,7 @@ function App({ relayConfig, onQuit }: AppProps) {
2059
2066
  error={localScriptState?.error}
2060
2067
  exitCode={localScriptState?.exitCode}
2061
2068
  />
2062
- <FlowTUI flow={flow} />
2069
+ {!isRunning && <FlowTUI flow={flow} />}
2063
2070
  <StatusBar hint={isRunning ? '[Running scripts...]' : '[Esc/n] Back to workspaces'} />
2064
2071
  </Fragment>
2065
2072
  );
@@ -2590,6 +2597,12 @@ export async function launchTUI(
2590
2597
  // Clean exit handler
2591
2598
  const handleQuit = () => {
2592
2599
  renderer.destroy();
2600
+
2601
+ const legacyReminder = consumeLegacyCleanupReminderForTui();
2602
+ if (legacyReminder) {
2603
+ logger.warning(legacyReminder);
2604
+ }
2605
+
2593
2606
  process.exit(0);
2594
2607
  };
2595
2608
 
package/src/app.web.tsx CHANGED
@@ -747,18 +747,19 @@ export default function App() {
747
747
  terminal.mode === 'browsing' &&
748
748
  showScriptTerminal
749
749
  ) {
750
+ const isRunning = terminal.scriptState?.isRunning ?? true;
750
751
  return (
751
752
  <>
752
753
  <ScriptTerminal
753
754
  phase={terminal.scriptState?.phase ?? 'pre'}
754
755
  workspaceName={scriptWorkspaceName}
755
- isRunning={terminal.scriptState?.isRunning ?? true}
756
+ isRunning={isRunning}
756
757
  error={terminal.scriptState?.error}
757
758
  exitCode={terminal.scriptState?.exitCode}
758
759
  setWriteCallback={terminal.setWriteCallback}
759
760
  onBack={() => setShowScriptTerminal(false)}
760
761
  />
761
- <FlowWeb flow={flow} />
762
+ {!isRunning && <FlowWeb flow={flow} />}
762
763
  <Toaster theme="dark" position="top-right" richColors />
763
764
  </>
764
765
  );
@@ -0,0 +1,52 @@
1
+ import { getSecretMigrationInputs } from '../core/secret-runtime.js';
2
+ import { logger } from '../utils/logger.js';
3
+ import { promptConfirm } from '../utils/prompts.js';
4
+ import { cleanupLegacySecretEntries, preloadAllSecrets } from '../utils/secrets.js';
5
+
6
+ export interface CleanupLegacyOptions {
7
+ yes?: boolean;
8
+ }
9
+
10
+ export async function migrateCleanupLegacy(
11
+ options: CleanupLegacyOptions = {}
12
+ ): Promise<void> {
13
+ const migrationInputs = getSecretMigrationInputs();
14
+
15
+ if (!options.yes) {
16
+ const confirmed = await promptConfirm(
17
+ 'Delete legacy keychain entries (project:*, global, and old per-key entries)? Unified secrets are kept.',
18
+ false
19
+ );
20
+
21
+ if (!confirmed) {
22
+ logger.info('Cancelled');
23
+ return;
24
+ }
25
+ }
26
+
27
+ // Ensure legacy entries are copied into unified storage before cleanup.
28
+ await preloadAllSecrets(migrationInputs.projectNames, {
29
+ projectLegacyKeys: migrationInputs.projectSecretKeys,
30
+ globalLegacyKeys: migrationInputs.globalSecretKeys,
31
+ });
32
+
33
+ const result = await cleanupLegacySecretEntries(migrationInputs.projectNames, {
34
+ projectLegacyKeys: migrationInputs.projectSecretKeys,
35
+ globalLegacyKeys: migrationInputs.globalSecretKeys,
36
+ });
37
+
38
+ if (result.errors.length > 0) {
39
+ logger.warning(`Legacy cleanup completed with ${result.errors.length} error(s).`);
40
+ for (const error of result.errors) {
41
+ logger.error(` ${error}`);
42
+ }
43
+ logger.info(
44
+ `Legacy cleanup finished with issues. Deleted ${result.deleted} entries (${result.missing} already absent).`
45
+ );
46
+ return;
47
+ }
48
+
49
+ logger.success(
50
+ `Legacy cleanup complete. Deleted ${result.deleted} entries (${result.missing} already absent).`
51
+ );
52
+ }
@@ -89,6 +89,32 @@ export function RemoteMachineScreen({ machine, relayUrl, identity, onBack }: Rem
89
89
  }
90
90
  return remote.selectedProjectName;
91
91
  },
92
+ onBeforeAttach: ({ target, params }) => {
93
+ if (target === 'workspace' && params.workspaceId) {
94
+ setShowInbox(false);
95
+ setScriptWorkspaceName(params.workspaceId.split(':').slice(-1)[0] ?? params.workspaceId);
96
+ setShowScriptTerminal(true);
97
+ }
98
+ },
99
+ onAttachCancelled: ({ target }) => {
100
+ if (target === 'workspace') {
101
+ setShowScriptTerminal(false);
102
+ }
103
+ },
104
+ onAttachError: ({ target, message }) => {
105
+ const isWorkspaceScriptFailure = message.startsWith('Workspace scripts failed during');
106
+ const hasScriptRuntimeState = Boolean(remote.scriptState);
107
+
108
+ if (target === 'workspace' && (!isWorkspaceScriptFailure || !hasScriptRuntimeState)) {
109
+ setShowScriptTerminal(false);
110
+ }
111
+
112
+ flow.showMessage({
113
+ title: isWorkspaceScriptFailure ? 'Workspace Script Failed' : 'Session Failed',
114
+ message,
115
+ variant: 'error',
116
+ });
117
+ },
92
118
  });
93
119
 
94
120
  const { deleteWorkspaceWithPrompt } = useWorkspaceDeleteFlow({
@@ -221,7 +247,11 @@ export function RemoteMachineScreen({ machine, relayUrl, identity, onBack }: Rem
221
247
  }, [flow, renderer]);
222
248
 
223
249
  useKeyboard(async (key) => {
224
- if (flow.isOpen) {
250
+ const scriptTerminalRunning =
251
+ showScriptTerminal &&
252
+ (remote.scriptState?.isRunning ?? true);
253
+
254
+ if (flow.isOpen && !scriptTerminalRunning) {
225
255
  if (flow.flow.type === 'confirm') {
226
256
  if (key.raw === 'y' || key.name === 'return') {
227
257
  await flow.handleConfirm();
@@ -429,7 +459,7 @@ export function RemoteMachineScreen({ machine, relayUrl, identity, onBack }: Rem
429
459
  error={remote.scriptState?.error}
430
460
  exitCode={remote.scriptState?.exitCode}
431
461
  />
432
- <FlowTUI flow={flow} />
462
+ {!isRunning && <FlowTUI flow={flow} />}
433
463
  <StatusBar hint={isRunning ? '[Running scripts...]' : '[Esc/n] Back to workspaces'} />
434
464
  </Fragment>
435
465
  );
@@ -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
+ });