gitspace 0.2.0-rc.16 → 0.2.0-rc.17

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/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.17",
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.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"
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
  );
@@ -1,30 +1,71 @@
1
- import { getAllProjectNames, readProjectConfig } from './config.js';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { getAllProjectNames, getSpacesDir, readProjectConfig } from './config.js';
2
4
  import { logger } from '../utils/logger.js';
3
- import { preloadProjectSecrets } from '../utils/secrets.js';
5
+ import { preloadAllSecrets } from '../utils/secrets.js';
4
6
  import { SpacesError } from '../types/errors.js';
5
7
 
6
8
  interface SecretRuntimeState {
7
9
  ignoreKeychainAndSkipSecrets: boolean;
8
- initialized: boolean;
9
10
  projectsWithSecrets: Set<string>;
11
+ legacyEntriesDetected: boolean;
12
+ legacyReminderConsumed: boolean;
10
13
  }
11
14
 
12
- const state: SecretRuntimeState = {
13
- ignoreKeychainAndSkipSecrets: false,
14
- initialized: false,
15
- projectsWithSecrets: new Set<string>(),
16
- };
15
+ export interface SecretMigrationInputs {
16
+ projectNames: string[];
17
+ projectSecretKeys: Record<string, string[]>;
18
+ globalSecretKeys: string[];
19
+ }
20
+
21
+ function readHostSubdomains(): string[] {
22
+ const hostPath = join(getSpacesDir(), 'host.json');
23
+ if (!existsSync(hostPath)) {
24
+ return [];
25
+ }
26
+
27
+ try {
28
+ const parsed = JSON.parse(readFileSync(hostPath, 'utf-8')) as {
29
+ subdomain?: unknown;
30
+ subdomains?: unknown;
31
+ };
32
+
33
+ const found = new Set<string>();
34
+ if (typeof parsed.subdomain === 'string' && parsed.subdomain.length > 0) {
35
+ found.add(parsed.subdomain);
36
+ }
37
+
38
+ if (Array.isArray(parsed.subdomains)) {
39
+ for (const candidate of parsed.subdomains) {
40
+ if (typeof candidate === 'string' && candidate.length > 0) {
41
+ found.add(candidate);
42
+ }
43
+ }
44
+ }
45
+
46
+ return [...found];
47
+ } catch {
48
+ return [];
49
+ }
50
+ }
51
+
52
+ export function getSecretMigrationInputs(): SecretMigrationInputs {
53
+ const projectNames = getAllProjectNames();
54
+ const projectSecretKeys: Record<string, string[]> = {};
55
+ const globalSecretKeys = new Set<string>([
56
+ 'GITSPACE_TOKEN',
57
+ 'linear-api-key',
58
+ 'relay:signingPrivateKey',
59
+ ]);
17
60
 
18
- function collectProjectsWithSecrets(): Array<{ projectName: string; keys: string[] }> {
19
- const projects = getAllProjectNames();
20
- const result: Array<{ projectName: string; keys: string[] }> = [];
61
+ for (const projectName of projectNames) {
62
+ globalSecretKeys.add(`linear-api-key-${projectName}`);
21
63
 
22
- for (const projectName of projects) {
23
64
  try {
24
65
  const config = readProjectConfig(projectName);
25
- const keys = config.bundleSecretKeys ?? [];
66
+ const keys = [...new Set(config.bundleSecretKeys ?? [])];
26
67
  if (keys.length > 0) {
27
- result.push({ projectName, keys });
68
+ projectSecretKeys[projectName] = keys;
28
69
  }
29
70
  } catch (error) {
30
71
  logger.debug(
@@ -33,9 +74,24 @@ function collectProjectsWithSecrets(): Array<{ projectName: string; keys: string
33
74
  }
34
75
  }
35
76
 
36
- return result;
77
+ for (const subdomain of readHostSubdomains()) {
78
+ globalSecretKeys.add(`TUNNEL_TOKEN_${subdomain}`);
79
+ }
80
+
81
+ return {
82
+ projectNames,
83
+ projectSecretKeys,
84
+ globalSecretKeys: [...globalSecretKeys],
85
+ };
37
86
  }
38
87
 
88
+ const state: SecretRuntimeState = {
89
+ ignoreKeychainAndSkipSecrets: false,
90
+ projectsWithSecrets: new Set<string>(),
91
+ legacyEntriesDetected: false,
92
+ legacyReminderConsumed: false,
93
+ };
94
+
39
95
  export interface InitializeSecretRuntimeOptions {
40
96
  ignoreKeychainAndSkipSecrets?: boolean;
41
97
  }
@@ -45,13 +101,14 @@ export async function initializeSecretRuntime(
45
101
  ): Promise<void> {
46
102
  const ignore = options.ignoreKeychainAndSkipSecrets ?? false;
47
103
  state.ignoreKeychainAndSkipSecrets = ignore;
104
+ state.legacyEntriesDetected = false;
105
+ state.legacyReminderConsumed = false;
48
106
 
49
- const projectsWithSecrets = collectProjectsWithSecrets();
50
- state.projectsWithSecrets = new Set(projectsWithSecrets.map((entry) => entry.projectName));
107
+ const migrationInputs = getSecretMigrationInputs();
108
+ state.projectsWithSecrets = new Set(Object.keys(migrationInputs.projectSecretKeys));
51
109
 
52
110
  if (ignore) {
53
- state.initialized = true;
54
- if (projectsWithSecrets.length > 0) {
111
+ if (state.projectsWithSecrets.size > 0) {
55
112
  logger.warning(
56
113
  'Ignoring keychain and skipping secret-dependent scripts (use with caution).'
57
114
  );
@@ -59,16 +116,12 @@ export async function initializeSecretRuntime(
59
116
  return;
60
117
  }
61
118
 
62
- if (projectsWithSecrets.length === 0) {
63
- state.initialized = true;
64
- return;
65
- }
66
-
67
119
  try {
68
- for (const entry of projectsWithSecrets) {
69
- await preloadProjectSecrets(entry.projectName, entry.keys);
70
- }
71
- state.initialized = true;
120
+ const preloadResult = await preloadAllSecrets(migrationInputs.projectNames, {
121
+ projectLegacyKeys: migrationInputs.projectSecretKeys,
122
+ globalLegacyKeys: migrationInputs.globalSecretKeys,
123
+ });
124
+ state.legacyEntriesDetected = preloadResult.legacyEntriesDetected;
72
125
  } catch (error) {
73
126
  const message = error instanceof Error ? error.message : String(error);
74
127
  throw new SpacesError(
@@ -80,6 +133,15 @@ export async function initializeSecretRuntime(
80
133
  }
81
134
  }
82
135
 
136
+ export function consumeLegacyCleanupReminderForTui(): string | null {
137
+ if (!state.legacyEntriesDetected || state.legacyReminderConsumed) {
138
+ return null;
139
+ }
140
+
141
+ state.legacyReminderConsumed = true;
142
+ return 'Legacy keychain entries are still present. Run `gssh migrate cleanup-legacy` once you are confident the unified keychain storage is stable.';
143
+ }
144
+
83
145
  export function shouldSkipSecretDependentScripts(
84
146
  projectName: string,
85
147
  configuredSecretKeys?: string[]
@@ -193,9 +193,29 @@ export async function deleteWorkspaceCore(
193
193
  try {
194
194
  await removeWorktree(baseDir, workspacePath, true);
195
195
  } catch (e) {
196
- result.errorCode = 'WORKTREE_REMOVE_FAILED';
197
- result.error = e instanceof Error ? e.message : 'Failed to remove worktree';
198
- return result;
196
+ const message = e instanceof Error ? e.message : String(e);
197
+ const isNotWorkingTreeError = /not a working tree/i.test(message);
198
+
199
+ if (isNotWorkingTreeError) {
200
+ logger.debug(
201
+ `Worktree metadata missing for ${workspaceName}; removing directory directly.`
202
+ );
203
+
204
+ try {
205
+ rmSync(workspacePath, { recursive: true, force: true });
206
+ } catch (rmError) {
207
+ result.errorCode = 'WORKTREE_REMOVE_FAILED';
208
+ result.error =
209
+ rmError instanceof Error
210
+ ? rmError.message
211
+ : 'Failed to remove orphaned workspace directory';
212
+ return result;
213
+ }
214
+ } else {
215
+ result.errorCode = 'WORKTREE_REMOVE_FAILED';
216
+ result.error = message || 'Failed to remove worktree';
217
+ return result;
218
+ }
199
219
  }
200
220
 
201
221
  // Try to delete the local branch
package/src/index.ts CHANGED
@@ -56,6 +56,7 @@ import { hostReserve, hostRelease, hostList, hostSetPrimary, hostStatus } from '
56
56
  import { startTmux, stopTmux, statusTmux, listTmux, newTmux, attachTmux, killTmux } from './commands/tmux.js'
57
57
  import { showStatus } from './commands/status.js'
58
58
  import { configNotifications, linearSetup, linearShow, linearClear } from './commands/config.js'
59
+ import { migrateCleanupLegacy } from './commands/migrate.js'
59
60
  import { notificationsInstall, notificationsUninstall, notificationsHook, notificationsStatus } from './commands/notifications.js'
60
61
  import { bundleRefresh, bundleStatus } from './commands/bundle.js'
61
62
 
@@ -723,6 +724,27 @@ configLinearCommand
723
724
  }
724
725
  })
725
726
 
727
+ // ============================================================================
728
+ // Migration Commands
729
+ // ============================================================================
730
+
731
+ const migrateCommand = program
732
+ .command('migrate')
733
+ .description('Migration and cleanup utilities')
734
+
735
+ migrateCommand
736
+ .command('cleanup-legacy')
737
+ .description('Delete legacy keychain entries kept for backwards compatibility')
738
+ .option('-y, --yes', 'Skip confirmation prompt')
739
+ .action(async (options) => {
740
+ await checkFirstTimeSetup()
741
+ try {
742
+ await migrateCleanupLegacy(options)
743
+ } catch (error) {
744
+ handleError(error)
745
+ }
746
+ })
747
+
726
748
  // ============================================================================
727
749
  // Notifications Commands
728
750
  // ============================================================================
@@ -394,10 +394,8 @@ export function useBundleRefreshAttachFlow(
394
394
  return false;
395
395
  }
396
396
 
397
- currentOptions.flow.showLoading({
398
- title: 'Bundle Refresh',
399
- message: 'Retrying session attach...',
400
- });
397
+ // Ensure lifecycle script output is visible in ScriptTerminal during retry.
398
+ currentOptions.flow.close();
401
399
  await Promise.resolve(currentOptions.attachSession(pending.params));
402
400
  currentOptions.flow.close();
403
401
  return true;
@@ -436,10 +434,8 @@ export function useBundleRefreshAttachFlow(
436
434
 
437
435
  await currentOptions.applyBundleRefresh(projectName, pending.workspaceId, submission);
438
436
 
439
- currentOptions.flow.showLoading({
440
- title: 'Bundle Refresh',
441
- message: 'Retrying session attach...',
442
- });
437
+ // Ensure lifecycle script output is visible in ScriptTerminal during retry.
438
+ currentOptions.flow.close();
443
439
 
444
440
  await Promise.resolve(currentOptions.attachSession(pending.params));
445
441
  currentOptions.flow.close();
@@ -476,10 +472,8 @@ export function useBundleRefreshAttachFlow(
476
472
  }
477
473
 
478
474
  try {
479
- currentOptions.flow.showLoading({
480
- title: 'Attaching Session',
481
- message: 'Retrying without workspace scripts...',
482
- });
475
+ // Do not block ScriptTerminal with loading overlays while attach retries.
476
+ currentOptions.flow.close();
483
477
  await Promise.resolve(currentOptions.attachSession({
484
478
  ...pending.params,
485
479
  scriptPolicy: 'skip',
@@ -19,6 +19,7 @@ let mockProjectConfig: any;
19
19
  let secretStore: Record<string, string>;
20
20
  let onboardingQueue: Array<any>;
21
21
  let capturedOnboardingBatches: OnboardingStep[][];
22
+ let mockRemoveWorktreeError: string | null;
22
23
  let oldTmuxSocket: string | undefined;
23
24
  let oldTmuxSessionDir: string | undefined;
24
25
  let oldTmuxPidFile: string | undefined;
@@ -48,6 +49,9 @@ function setupModuleMocks(): void {
48
49
  lastCommit: '',
49
50
  }),
50
51
  removeWorktree: async (_baseDir: string, workspacePath: string) => {
52
+ if (mockRemoveWorktreeError) {
53
+ throw new Error(mockRemoveWorktreeError);
54
+ }
51
55
  if (existsSync(workspacePath)) {
52
56
  rmSync(workspacePath, { recursive: true, force: true });
53
57
  }
@@ -205,6 +209,7 @@ describe('workspace setup integration', () => {
205
209
  secretStore = {};
206
210
  onboardingQueue = [];
207
211
  capturedOnboardingBatches = [];
212
+ mockRemoveWorktreeError = null;
208
213
 
209
214
  // Guard rails: isolate tmux-lite env in case a future code path accidentally
210
215
  // reaches tmux-lite helpers.
@@ -604,4 +609,25 @@ describe('workspace setup integration', () => {
604
609
  expect(secondAttempt.success).toBe(true);
605
610
  expect(existsSync(workspacePath)).toBe(false);
606
611
  });
612
+
613
+ it('falls back to removing workspace directory when git reports not a working tree', async () => {
614
+ const { deleteWorkspaceCore } = await loadWorkspaceCoreModule();
615
+
616
+ const workspaceName = 'ws-orphan';
617
+ const workspacePath = join(workspacesDir, workspaceName);
618
+ mkdirSync(workspacePath, { recursive: true });
619
+
620
+ mockRemoveWorktreeError = `fatal: '${workspacePath}' is not a working tree`;
621
+
622
+ const result = await deleteWorkspaceCore('test-project', workspaceName, {
623
+ nonInteractive: true,
624
+ keepBranch: true,
625
+ removeScriptPolicy: 'skip',
626
+ });
627
+
628
+ expect(result.success).toBe(true);
629
+ expect(result.error).toBeUndefined();
630
+ expect(result.errorCode).toBeUndefined();
631
+ expect(existsSync(workspacePath)).toBe(false);
632
+ });
607
633
  });
@@ -1,28 +1,162 @@
1
1
  /**
2
- * Secure secrets management using Bun.secrets API
3
- * Stores secrets in the OS keychain (macOS Keychain, Linux libsecret, Windows Credential Manager)
2
+ * Secure secrets management using Bun.secrets API.
4
3
  *
5
- * All project secrets are stored in a single keychain entry per project (as JSON).
6
- * All global secrets are stored in a single keychain entry (as JSON).
7
- * This ensures only ONE keychain prompt per operation, regardless of how many secrets are needed.
4
+ * Current format: a SINGLE unified keychain entry containing both global
5
+ * and project-scoped secret maps.
6
+ *
7
+ * Legacy formats still supported for reads/migration:
8
+ * - project blobs: project:<name>
9
+ * - global blob: global
10
+ * - very old per-secret keys: <project>:<key> and <key>
8
11
  */
9
12
 
10
13
  const SERVICE_NAME = 'com.gitspace';
11
14
 
12
15
  // Keychain entry names
16
+ const UNIFIED_SECRETS_KEY = 'secrets';
13
17
  const PROJECT_SECRETS_PREFIX = 'project:';
14
18
  const GLOBAL_SECRETS_KEY = 'global';
15
19
 
16
- // In-memory cache for loaded secret blobs
17
- // Maps keychain entry name -> parsed secrets object
18
- const secretsBlobCache = new Map<string, Record<string, string>>();
20
+ interface UnifiedSecretsBlob {
21
+ global: Record<string, string>;
22
+ projects: Record<string, Record<string, string>>;
23
+ metadata: {
24
+ schemaVersion: number;
25
+ legacyMigrationComplete: boolean;
26
+ legacyEntriesRetained: boolean;
27
+ };
28
+ }
29
+
30
+ const UNIFIED_SECRETS_SCHEMA_VERSION = 2;
31
+
32
+ // In-memory cache for unified secrets blob
33
+ let unifiedSecretsCache: UnifiedSecretsBlob | null = null;
34
+
35
+ // Process-level marker used for reminders about legacy cleanup.
36
+ let legacyEntriesDetected = false;
37
+ const legacyProjectBlobChecked = new Set<string>();
38
+ let legacyGlobalBlobChecked = false;
39
+
40
+ function createEmptyBlob(): UnifiedSecretsBlob {
41
+ return {
42
+ global: {},
43
+ projects: {},
44
+ metadata: {
45
+ schemaVersion: UNIFIED_SECRETS_SCHEMA_VERSION,
46
+ legacyMigrationComplete: false,
47
+ legacyEntriesRetained: false,
48
+ },
49
+ };
50
+ }
51
+
52
+ function normalizeRecord(value: unknown): Record<string, string> {
53
+ if (!value || typeof value !== 'object') {
54
+ return {};
55
+ }
56
+
57
+ const output: Record<string, string> = {};
58
+ for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
59
+ if (typeof raw === 'string') {
60
+ output[key] = raw;
61
+ }
62
+ }
63
+ return output;
64
+ }
65
+
66
+ function parseUnifiedSecretsBlob(raw: string | null): UnifiedSecretsBlob {
67
+ if (!raw) {
68
+ return createEmptyBlob();
69
+ }
70
+
71
+ try {
72
+ const parsed = JSON.parse(raw) as {
73
+ global?: unknown;
74
+ projects?: unknown;
75
+ metadata?: unknown;
76
+ };
77
+
78
+ const projects: Record<string, Record<string, string>> = {};
79
+ if (parsed.projects && typeof parsed.projects === 'object') {
80
+ for (const [projectName, projectSecrets] of Object.entries(parsed.projects as Record<string, unknown>)) {
81
+ projects[projectName] = normalizeRecord(projectSecrets);
82
+ }
83
+ }
84
+
85
+ const metadata =
86
+ parsed.metadata && typeof parsed.metadata === 'object'
87
+ ? (parsed.metadata as {
88
+ schemaVersion?: unknown;
89
+ legacyMigrationComplete?: unknown;
90
+ legacyEntriesRetained?: unknown;
91
+ })
92
+ : {};
93
+
94
+ return {
95
+ global: normalizeRecord(parsed.global),
96
+ projects,
97
+ metadata: {
98
+ schemaVersion:
99
+ typeof metadata.schemaVersion === 'number' && Number.isFinite(metadata.schemaVersion)
100
+ ? metadata.schemaVersion
101
+ : UNIFIED_SECRETS_SCHEMA_VERSION,
102
+ legacyMigrationComplete: metadata.legacyMigrationComplete === true,
103
+ legacyEntriesRetained: metadata.legacyEntriesRetained === true,
104
+ },
105
+ };
106
+ } catch {
107
+ return createEmptyBlob();
108
+ }
109
+ }
110
+
111
+ function isLegacyMigrationComplete(blob: UnifiedSecretsBlob): boolean {
112
+ return blob.metadata.legacyMigrationComplete === true;
113
+ }
114
+
115
+ async function loadUnifiedSecretsBlob(): Promise<UnifiedSecretsBlob> {
116
+ if (unifiedSecretsCache) {
117
+ return unifiedSecretsCache;
118
+ }
119
+
120
+ const raw = await Bun.secrets.get({
121
+ service: SERVICE_NAME,
122
+ name: UNIFIED_SECRETS_KEY,
123
+ });
124
+
125
+ unifiedSecretsCache = parseUnifiedSecretsBlob(raw);
126
+ if (unifiedSecretsCache.metadata.legacyMigrationComplete) {
127
+ legacyEntriesDetected = unifiedSecretsCache.metadata.legacyEntriesRetained;
128
+ }
129
+ return unifiedSecretsCache;
130
+ }
131
+
132
+ async function saveUnifiedSecretsBlob(blob: UnifiedSecretsBlob): Promise<void> {
133
+ const normalized: UnifiedSecretsBlob = {
134
+ global: { ...blob.global },
135
+ projects: { ...blob.projects },
136
+ metadata: {
137
+ schemaVersion: UNIFIED_SECRETS_SCHEMA_VERSION,
138
+ legacyMigrationComplete: blob.metadata.legacyMigrationComplete === true,
139
+ legacyEntriesRetained: blob.metadata.legacyEntriesRetained === true,
140
+ },
141
+ };
142
+ unifiedSecretsCache = normalized;
143
+
144
+ await Bun.secrets.set({
145
+ service: SERVICE_NAME,
146
+ name: UNIFIED_SECRETS_KEY,
147
+ value: JSON.stringify(normalized),
148
+ });
149
+ }
19
150
 
20
151
  /**
21
152
  * Clear the in-memory secrets cache
22
153
  * Useful for long-running processes that need fresh values
23
154
  */
24
155
  export function clearSecretsCache(): void {
25
- secretsBlobCache.clear();
156
+ unifiedSecretsCache = null;
157
+ legacyProjectBlobChecked.clear();
158
+ legacyGlobalBlobChecked = false;
159
+ legacyEntriesDetected = false;
26
160
  }
27
161
 
28
162
  // ============================================================================
@@ -36,38 +170,64 @@ function getProjectSecretsKey(projectName: string): string {
36
170
  return `${PROJECT_SECRETS_PREFIX}${projectName}`;
37
171
  }
38
172
 
39
- /**
40
- * Load the secrets blob for a project from keychain (or cache)
41
- * This is the only function that accesses the keychain for project secrets
42
- */
43
- async function loadProjectSecretsBlob(projectName: string): Promise<Record<string, string>> {
44
- const keychainKey = getProjectSecretsKey(projectName);
173
+ async function loadLegacyProjectBlob(projectName: string): Promise<{
174
+ hasLegacyEntry: boolean;
175
+ secrets: Record<string, string>;
176
+ }> {
177
+ legacyProjectBlobChecked.add(projectName);
178
+ const raw = await Bun.secrets.get({
179
+ service: SERVICE_NAME,
180
+ name: getProjectSecretsKey(projectName),
181
+ });
182
+
183
+ if (!raw) {
184
+ return { hasLegacyEntry: false, secrets: {} };
185
+ }
45
186
 
46
- // Check cache first
47
- if (secretsBlobCache.has(keychainKey)) {
48
- return secretsBlobCache.get(keychainKey)!;
187
+ legacyEntriesDetected = true;
188
+ try {
189
+ return { hasLegacyEntry: true, secrets: normalizeRecord(JSON.parse(raw)) };
190
+ } catch {
191
+ return { hasLegacyEntry: true, secrets: {} };
49
192
  }
193
+ }
50
194
 
51
- // Load from keychain (triggers one OS prompt)
195
+ async function loadLegacyGlobalBlob(): Promise<{
196
+ hasLegacyEntry: boolean;
197
+ secrets: Record<string, string>;
198
+ }> {
199
+ legacyGlobalBlobChecked = true;
52
200
  const raw = await Bun.secrets.get({
53
201
  service: SERVICE_NAME,
54
- name: keychainKey,
202
+ name: GLOBAL_SECRETS_KEY,
55
203
  });
56
204
 
57
- // Parse JSON or return empty object
58
- let secrets: Record<string, string> = {};
59
- if (raw) {
60
- try {
61
- secrets = JSON.parse(raw);
62
- } catch {
63
- // Invalid JSON, start fresh
64
- secrets = {};
65
- }
205
+ if (!raw) {
206
+ return { hasLegacyEntry: false, secrets: {} };
66
207
  }
67
208
 
68
- // Cache the loaded blob
69
- secretsBlobCache.set(keychainKey, secrets);
70
- return secrets;
209
+ legacyEntriesDetected = true;
210
+ try {
211
+ return { hasLegacyEntry: true, secrets: normalizeRecord(JSON.parse(raw)) };
212
+ } catch {
213
+ return { hasLegacyEntry: true, secrets: {} };
214
+ }
215
+ }
216
+
217
+ function mergeSecrets(
218
+ existing: Record<string, string>,
219
+ legacy: Record<string, string>
220
+ ): Record<string, string> {
221
+ // New-format values win if both are present.
222
+ return { ...legacy, ...existing };
223
+ }
224
+
225
+ /**
226
+ * Load project secrets from the unified blob.
227
+ */
228
+ async function loadProjectSecretsBlob(projectName: string): Promise<Record<string, string>> {
229
+ const blob = await loadUnifiedSecretsBlob();
230
+ return { ...(blob.projects[projectName] || {}) };
71
231
  }
72
232
 
73
233
  /**
@@ -77,25 +237,13 @@ async function saveProjectSecretsBlob(
77
237
  projectName: string,
78
238
  secrets: Record<string, string>
79
239
  ): Promise<void> {
80
- const keychainKey = getProjectSecretsKey(projectName);
81
-
82
- // Update cache
83
- secretsBlobCache.set(keychainKey, secrets);
84
-
85
- // Save to keychain
240
+ const blob = await loadUnifiedSecretsBlob();
86
241
  if (Object.keys(secrets).length === 0) {
87
- // Delete the entry if no secrets remain
88
- await Bun.secrets.delete({
89
- service: SERVICE_NAME,
90
- name: keychainKey,
91
- });
242
+ delete blob.projects[projectName];
92
243
  } else {
93
- await Bun.secrets.set({
94
- service: SERVICE_NAME,
95
- name: keychainKey,
96
- value: JSON.stringify(secrets),
97
- });
244
+ blob.projects[projectName] = { ...secrets };
98
245
  }
246
+ await saveUnifiedSecretsBlob(blob);
99
247
  }
100
248
 
101
249
  /**
@@ -121,11 +269,28 @@ export async function getProjectSecret(
121
269
  projectName: string,
122
270
  key: string
123
271
  ): Promise<string | null> {
124
- const secrets = await loadProjectSecretsBlob(projectName);
272
+ const blob = await loadUnifiedSecretsBlob();
273
+ const current = blob.projects[projectName] || {};
125
274
 
126
275
  // Found in new format
127
- if (secrets[key] !== undefined) {
128
- return secrets[key];
276
+ if (current[key] !== undefined) {
277
+ return current[key];
278
+ }
279
+
280
+ if (isLegacyMigrationComplete(blob)) {
281
+ return null;
282
+ }
283
+
284
+ // Try legacy project blob format: project:<name>
285
+ if (!legacyProjectBlobChecked.has(projectName)) {
286
+ const legacyBlob = await loadLegacyProjectBlob(projectName);
287
+ if (legacyBlob.hasLegacyEntry) {
288
+ blob.projects[projectName] = mergeSecrets(current, legacyBlob.secrets);
289
+ await saveUnifiedSecretsBlob(blob);
290
+ if (blob.projects[projectName][key] !== undefined) {
291
+ return blob.projects[projectName][key];
292
+ }
293
+ }
129
294
  }
130
295
 
131
296
  // Try old format: ${projectName}:${key}
@@ -136,15 +301,12 @@ export async function getProjectSecret(
136
301
  });
137
302
 
138
303
  if (oldValue) {
139
- // Migrate to new format automatically
140
- secrets[key] = oldValue;
141
- await saveProjectSecretsBlob(projectName, secrets);
142
-
143
- // Delete old entry
144
- await Bun.secrets.delete({
145
- service: SERVICE_NAME,
146
- name: oldKeychainName,
147
- });
304
+ legacyEntriesDetected = true;
305
+ blob.projects[projectName] = {
306
+ ...(blob.projects[projectName] || {}),
307
+ [key]: oldValue,
308
+ };
309
+ await saveUnifiedSecretsBlob(blob);
148
310
 
149
311
  return oldValue;
150
312
  }
@@ -176,15 +338,53 @@ export async function getProjectSecrets(
176
338
  projectName: string,
177
339
  keys: string[]
178
340
  ): Promise<Record<string, string>> {
179
- const secrets = await loadProjectSecretsBlob(projectName);
341
+ const blob = await loadUnifiedSecretsBlob();
342
+ let secrets = blob.projects[projectName] || {};
343
+ let changed = false;
344
+
345
+ if (!isLegacyMigrationComplete(blob) && !legacyProjectBlobChecked.has(projectName)) {
346
+ const legacyBlob = await loadLegacyProjectBlob(projectName);
347
+ if (legacyBlob.hasLegacyEntry) {
348
+ secrets = mergeSecrets(secrets, legacyBlob.secrets);
349
+ blob.projects[projectName] = secrets;
350
+ changed = true;
351
+ }
352
+ }
353
+
180
354
  const result: Record<string, string> = {};
181
355
 
182
356
  for (const key of keys) {
183
357
  if (key in secrets) {
184
358
  result[key] = secrets[key];
359
+ continue;
360
+ }
361
+
362
+ if (isLegacyMigrationComplete(blob)) {
363
+ continue;
364
+ }
365
+
366
+ // Legacy per-secret fallback (very old format)
367
+ const oldKeychainName = `${projectName}:${key}`;
368
+ const oldValue = await Bun.secrets.get({
369
+ service: SERVICE_NAME,
370
+ name: oldKeychainName,
371
+ });
372
+
373
+ if (oldValue) {
374
+ legacyEntriesDetected = true;
375
+ result[key] = oldValue;
376
+ blob.projects[projectName] = {
377
+ ...(blob.projects[projectName] || {}),
378
+ [key]: oldValue,
379
+ };
380
+ changed = true;
185
381
  }
186
382
  }
187
383
 
384
+ if (changed) {
385
+ await saveUnifiedSecretsBlob(blob);
386
+ }
387
+
188
388
  return result;
189
389
  }
190
390
 
@@ -219,12 +419,9 @@ export async function deleteProjectSecrets(
219
419
  * Used when removing a project entirely
220
420
  */
221
421
  export async function deleteAllProjectSecrets(projectName: string): Promise<void> {
222
- const keychainKey = getProjectSecretsKey(projectName);
223
- secretsBlobCache.delete(keychainKey);
224
- await Bun.secrets.delete({
225
- service: SERVICE_NAME,
226
- name: keychainKey,
227
- });
422
+ const blob = await loadUnifiedSecretsBlob();
423
+ delete blob.projects[projectName];
424
+ await saveUnifiedSecretsBlob(blob);
228
425
  }
229
426
 
230
427
  // ============================================================================
@@ -232,58 +429,20 @@ export async function deleteAllProjectSecrets(projectName: string): Promise<void
232
429
  // ============================================================================
233
430
 
234
431
  /**
235
- * Load the global secrets blob from keychain (or cache)
236
- * This is the only function that accesses the keychain for global secrets
432
+ * Load global secrets from the unified blob.
237
433
  */
238
434
  async function loadGlobalSecretsBlob(): Promise<Record<string, string>> {
239
- // Check cache first
240
- if (secretsBlobCache.has(GLOBAL_SECRETS_KEY)) {
241
- return secretsBlobCache.get(GLOBAL_SECRETS_KEY)!;
242
- }
243
-
244
- // Load from keychain (triggers one OS prompt)
245
- const raw = await Bun.secrets.get({
246
- service: SERVICE_NAME,
247
- name: GLOBAL_SECRETS_KEY,
248
- });
249
-
250
- // Parse JSON or return empty object
251
- let secrets: Record<string, string> = {};
252
- if (raw) {
253
- try {
254
- secrets = JSON.parse(raw);
255
- } catch {
256
- // Invalid JSON, start fresh
257
- secrets = {};
258
- }
259
- }
260
-
261
- // Cache the loaded blob
262
- secretsBlobCache.set(GLOBAL_SECRETS_KEY, secrets);
263
- return secrets;
435
+ const blob = await loadUnifiedSecretsBlob();
436
+ return { ...blob.global };
264
437
  }
265
438
 
266
439
  /**
267
440
  * Save the global secrets blob to keychain
268
441
  */
269
442
  async function saveGlobalSecretsBlob(secrets: Record<string, string>): Promise<void> {
270
- // Update cache
271
- secretsBlobCache.set(GLOBAL_SECRETS_KEY, secrets);
272
-
273
- // Save to keychain
274
- if (Object.keys(secrets).length === 0) {
275
- // Delete the entry if no secrets remain
276
- await Bun.secrets.delete({
277
- service: SERVICE_NAME,
278
- name: GLOBAL_SECRETS_KEY,
279
- });
280
- } else {
281
- await Bun.secrets.set({
282
- service: SERVICE_NAME,
283
- name: GLOBAL_SECRETS_KEY,
284
- value: JSON.stringify(secrets),
285
- });
286
- }
443
+ const blob = await loadUnifiedSecretsBlob();
444
+ blob.global = { ...secrets };
445
+ await saveUnifiedSecretsBlob(blob);
287
446
  }
288
447
 
289
448
  /**
@@ -303,13 +462,30 @@ export async function setSecret(key: string, value: string): Promise<void> {
303
462
  * format for seamless migration from older versions.
304
463
  */
305
464
  export async function getSecret(key: string): Promise<string | null> {
306
- const secrets = await loadGlobalSecretsBlob();
465
+ const blob = await loadUnifiedSecretsBlob();
466
+ const secrets = blob.global;
307
467
 
308
468
  // Found in new format
309
469
  if (secrets[key] !== undefined) {
310
470
  return secrets[key];
311
471
  }
312
472
 
473
+ if (isLegacyMigrationComplete(blob)) {
474
+ return null;
475
+ }
476
+
477
+ // Try legacy global blob format: global
478
+ if (!legacyGlobalBlobChecked) {
479
+ const legacyBlob = await loadLegacyGlobalBlob();
480
+ if (legacyBlob.hasLegacyEntry) {
481
+ blob.global = mergeSecrets(blob.global, legacyBlob.secrets);
482
+ await saveUnifiedSecretsBlob(blob);
483
+ if (blob.global[key] !== undefined) {
484
+ return blob.global[key];
485
+ }
486
+ }
487
+ }
488
+
313
489
  // Try old format: direct key name
314
490
  const oldValue = await Bun.secrets.get({
315
491
  service: SERVICE_NAME,
@@ -317,15 +493,9 @@ export async function getSecret(key: string): Promise<string | null> {
317
493
  });
318
494
 
319
495
  if (oldValue) {
320
- // Migrate to new format automatically
321
- secrets[key] = oldValue;
322
- await saveGlobalSecretsBlob(secrets);
323
-
324
- // Delete old entry
325
- await Bun.secrets.delete({
326
- service: SERVICE_NAME,
327
- name: key,
328
- });
496
+ legacyEntriesDetected = true;
497
+ blob.global[key] = oldValue;
498
+ await saveUnifiedSecretsBlob(blob);
329
499
 
330
500
  return oldValue;
331
501
  }
@@ -346,6 +516,204 @@ export async function deleteSecret(key: string): Promise<boolean> {
346
516
  return true;
347
517
  }
348
518
 
519
+ export interface PreloadAllSecretsResult {
520
+ legacyEntriesDetected: boolean;
521
+ importedLegacyProjectEntries: number;
522
+ importedLegacyGlobalEntry: boolean;
523
+ }
524
+
525
+ export interface PreloadAllSecretsOptions {
526
+ globalLegacyKeys?: string[];
527
+ projectLegacyKeys?: Record<string, string[]>;
528
+ }
529
+
530
+ /**
531
+ * Preload all secrets for the process into memory.
532
+ * This is intended to be called once at startup (serve + TUI).
533
+ */
534
+ export async function preloadAllSecrets(
535
+ projectNames: string[],
536
+ options: PreloadAllSecretsOptions = {}
537
+ ): Promise<PreloadAllSecretsResult> {
538
+ const blob = await loadUnifiedSecretsBlob();
539
+
540
+ if (isLegacyMigrationComplete(blob)) {
541
+ legacyEntriesDetected = blob.metadata.legacyEntriesRetained;
542
+ return {
543
+ legacyEntriesDetected,
544
+ importedLegacyProjectEntries: 0,
545
+ importedLegacyGlobalEntry: false,
546
+ };
547
+ }
548
+
549
+ let importedLegacyProjectEntries = 0;
550
+ let importedLegacyGlobalEntry = false;
551
+ let changed = false;
552
+ let detectedLegacyEntries = false;
553
+
554
+ const projectLegacyKeys = options.projectLegacyKeys ?? {};
555
+ const globalLegacyKeys = [...new Set(options.globalLegacyKeys ?? [])];
556
+
557
+ const projectNamesToCheck = [...new Set([...projectNames, ...Object.keys(projectLegacyKeys)])];
558
+
559
+ for (const projectName of projectNamesToCheck) {
560
+ const legacyProject = await loadLegacyProjectBlob(projectName);
561
+ if (!legacyProject.hasLegacyEntry) {
562
+ // continue with per-key migration below
563
+ } else {
564
+ detectedLegacyEntries = true;
565
+ importedLegacyProjectEntries += 1;
566
+ const current = blob.projects[projectName] || {};
567
+ const merged = mergeSecrets(current, legacyProject.secrets);
568
+ if (JSON.stringify(current) !== JSON.stringify(merged)) {
569
+ blob.projects[projectName] = merged;
570
+ changed = true;
571
+ }
572
+ }
573
+
574
+ const oldKeys = [...new Set(projectLegacyKeys[projectName] ?? [])];
575
+ for (const key of oldKeys) {
576
+ const oldValue = await Bun.secrets.get({
577
+ service: SERVICE_NAME,
578
+ name: `${projectName}:${key}`,
579
+ });
580
+
581
+ if (!oldValue) {
582
+ continue;
583
+ }
584
+
585
+ detectedLegacyEntries = true;
586
+ const currentProjectSecrets = blob.projects[projectName] || {};
587
+ if (currentProjectSecrets[key] === undefined) {
588
+ blob.projects[projectName] = {
589
+ ...currentProjectSecrets,
590
+ [key]: oldValue,
591
+ };
592
+ changed = true;
593
+ }
594
+ }
595
+ }
596
+
597
+ const legacyGlobal = await loadLegacyGlobalBlob();
598
+ if (legacyGlobal.hasLegacyEntry) {
599
+ detectedLegacyEntries = true;
600
+ importedLegacyGlobalEntry = true;
601
+ const merged = mergeSecrets(blob.global, legacyGlobal.secrets);
602
+ if (JSON.stringify(blob.global) !== JSON.stringify(merged)) {
603
+ blob.global = merged;
604
+ changed = true;
605
+ }
606
+ }
607
+
608
+ for (const key of globalLegacyKeys) {
609
+ const oldValue = await Bun.secrets.get({
610
+ service: SERVICE_NAME,
611
+ name: key,
612
+ });
613
+
614
+ if (!oldValue) {
615
+ continue;
616
+ }
617
+
618
+ detectedLegacyEntries = true;
619
+ if (blob.global[key] === undefined) {
620
+ blob.global[key] = oldValue;
621
+ changed = true;
622
+ }
623
+ }
624
+
625
+ blob.metadata.legacyMigrationComplete = true;
626
+ blob.metadata.legacyEntriesRetained = detectedLegacyEntries;
627
+ changed = true;
628
+
629
+ legacyEntriesDetected = detectedLegacyEntries;
630
+
631
+ if (changed) {
632
+ await saveUnifiedSecretsBlob(blob);
633
+ }
634
+
635
+ return {
636
+ legacyEntriesDetected,
637
+ importedLegacyProjectEntries,
638
+ importedLegacyGlobalEntry,
639
+ };
640
+ }
641
+
642
+ export function hasLegacyEntriesInProcess(): boolean {
643
+ return legacyEntriesDetected;
644
+ }
645
+
646
+ export interface CleanupLegacySecretsResult {
647
+ deleted: number;
648
+ missing: number;
649
+ errors: string[];
650
+ }
651
+
652
+ export interface CleanupLegacySecretsOptions {
653
+ globalLegacyKeys?: string[];
654
+ projectLegacyKeys?: Record<string, string[]>;
655
+ }
656
+
657
+ /**
658
+ * Remove legacy keychain entries now that unified secrets are in use.
659
+ * This only removes legacy blob entries (`project:*`, `global`).
660
+ *
661
+ * TODO(v0.3+): remove legacy blob reads and this cleanup path once
662
+ * unified secrets storage has been stable for several releases.
663
+ */
664
+ export async function cleanupLegacySecretEntries(
665
+ projectNames: string[],
666
+ options: CleanupLegacySecretsOptions = {}
667
+ ): Promise<CleanupLegacySecretsResult> {
668
+ const result: CleanupLegacySecretsResult = {
669
+ deleted: 0,
670
+ missing: 0,
671
+ errors: [],
672
+ };
673
+
674
+ const projectLegacyKeys = options.projectLegacyKeys ?? {};
675
+ const globalLegacyKeys = options.globalLegacyKeys ?? [];
676
+
677
+ const entries = new Set<string>([
678
+ GLOBAL_SECRETS_KEY,
679
+ ...[...new Set(projectNames)].map((name) => getProjectSecretsKey(name)),
680
+ ...globalLegacyKeys,
681
+ ]);
682
+
683
+ for (const [projectName, keys] of Object.entries(projectLegacyKeys)) {
684
+ for (const key of keys) {
685
+ entries.add(`${projectName}:${key}`);
686
+ }
687
+ }
688
+
689
+ for (const name of entries) {
690
+ try {
691
+ const deleted = await Bun.secrets.delete({
692
+ service: SERVICE_NAME,
693
+ name,
694
+ });
695
+ if (deleted) {
696
+ result.deleted += 1;
697
+ } else {
698
+ result.missing += 1;
699
+ }
700
+ } catch (error) {
701
+ result.errors.push(`${name}: ${error instanceof Error ? error.message : String(error)}`);
702
+ }
703
+ }
704
+
705
+ if (result.errors.length === 0) {
706
+ const blob = await loadUnifiedSecretsBlob();
707
+ if (blob.metadata.legacyEntriesRetained) {
708
+ blob.metadata.legacyEntriesRetained = false;
709
+ await saveUnifiedSecretsBlob(blob);
710
+ }
711
+ legacyEntriesDetected = false;
712
+ }
713
+
714
+ return result;
715
+ }
716
+
349
717
  // ============================================================================
350
718
  // Migration from old format (one keychain entry per secret)
351
719
  // ============================================================================