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 +5 -5
- package/src/app/session/useWorkspaceDeleteFlow.ts +22 -8
- package/src/app.tui.tsx +16 -3
- package/src/app.web.tsx +3 -2
- package/src/commands/migrate.ts +52 -0
- package/src/components/RemoteMachineScreen.tui.tsx +32 -2
- package/src/core/secret-runtime.ts +90 -28
- package/src/core/workspace.ts +23 -3
- package/src/index.ts +22 -0
- package/src/session/useBundleRefreshAttachFlow.ts +6 -12
- package/src/utils/__tests__/workspace-setup.integration.test.ts +26 -0
- package/src/utils/secrets.ts +489 -121
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.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.
|
|
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.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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
}, [
|
|
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 {
|
|
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={
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
50
|
-
state.projectsWithSecrets = new Set(
|
|
107
|
+
const migrationInputs = getSecretMigrationInputs();
|
|
108
|
+
state.projectsWithSecrets = new Set(Object.keys(migrationInputs.projectSecretKeys));
|
|
51
109
|
|
|
52
110
|
if (ignore) {
|
|
53
|
-
state.
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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[]
|
package/src/core/workspace.ts
CHANGED
|
@@ -193,9 +193,29 @@ export async function deleteWorkspaceCore(
|
|
|
193
193
|
try {
|
|
194
194
|
await removeWorktree(baseDir, workspacePath, true);
|
|
195
195
|
} catch (e) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
398
|
-
|
|
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
|
-
|
|
440
|
-
|
|
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
|
-
|
|
480
|
-
|
|
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
|
});
|
package/src/utils/secrets.ts
CHANGED
|
@@ -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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
return
|
|
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
|
-
|
|
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:
|
|
202
|
+
name: GLOBAL_SECRETS_KEY,
|
|
55
203
|
});
|
|
56
204
|
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
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
|
-
|
|
88
|
-
await Bun.secrets.delete({
|
|
89
|
-
service: SERVICE_NAME,
|
|
90
|
-
name: keychainKey,
|
|
91
|
-
});
|
|
242
|
+
delete blob.projects[projectName];
|
|
92
243
|
} else {
|
|
93
|
-
|
|
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
|
|
272
|
+
const blob = await loadUnifiedSecretsBlob();
|
|
273
|
+
const current = blob.projects[projectName] || {};
|
|
125
274
|
|
|
126
275
|
// Found in new format
|
|
127
|
-
if (
|
|
128
|
-
return
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
await
|
|
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
|
|
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
|
|
223
|
-
|
|
224
|
-
await
|
|
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
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
271
|
-
|
|
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
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
await
|
|
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
|
// ============================================================================
|