gitspace 0.2.0-rc.15 → 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.
Files changed (36) hide show
  1. package/bun.lock +5 -5
  2. package/package.json +5 -5
  3. package/src/app/session/useWorkspaceDeleteFlow.ts +170 -0
  4. package/src/app.tui.tsx +52 -21
  5. package/src/app.web.tsx +77 -10
  6. package/src/commands/migrate.ts +52 -0
  7. package/src/commands/remove.ts +26 -4
  8. package/src/commands/serve.ts +20 -0
  9. package/src/components/RemoteMachineScreen.tui.tsx +116 -9
  10. package/src/components/SessionTerminal.web.tsx +82 -0
  11. package/src/components/SpacesBrowser.tsx +9 -12
  12. package/src/core/__tests__/workspace-lifecycle.test.ts +23 -0
  13. package/src/core/secret-runtime.ts +167 -0
  14. package/src/core/workspace-lifecycle.ts +24 -2
  15. package/src/core/workspace.ts +86 -23
  16. package/src/hooks/__tests__/useLocalSession.tui.test.ts +11 -2
  17. package/src/hooks/useLocalSession.tui.ts +7 -2
  18. package/src/index.ts +53 -13
  19. package/src/lib/remote-session/protocol.ts +3 -1
  20. package/src/lib/remote-session/session-handler.ts +105 -29
  21. package/src/session/__tests__/backend-manager.test.ts +11 -2
  22. package/src/session/__tests__/local-session-backend.test.ts +119 -0
  23. package/src/session/__tests__/remote-session-backend.test.ts +324 -0
  24. package/src/session/__tests__/session-name.test.ts +35 -0
  25. package/src/session/__tests__/useRemoteSessionClient.test.ts +10 -2
  26. package/src/session/backend.ts +14 -1
  27. package/src/session/backends/local-session-backend.ts +103 -15
  28. package/src/session/backends/remote-session-backend.ts +146 -7
  29. package/src/session/index.ts +1 -0
  30. package/src/session/session-name.ts +50 -0
  31. package/src/session/useBundleRefreshAttachFlow.ts +6 -12
  32. package/src/session/useRemoteSessionClient.ts +19 -7
  33. package/src/session/useSessionEngine.ts +41 -3
  34. package/src/types/errors.ts +45 -0
  35. package/src/utils/__tests__/workspace-setup.integration.test.ts +70 -0
  36. package/src/utils/secrets.ts +489 -121
package/bun.lock CHANGED
@@ -39,10 +39,10 @@
39
39
  "typescript": "^5.9.3",
40
40
  },
41
41
  "optionalDependencies": {
42
- "@gitspace/darwin-arm64": "0.2.0-rc.12",
43
- "@gitspace/darwin-x64": "0.2.0-rc.12",
44
- "@gitspace/linux-arm64": "0.2.0-rc.12",
45
- "@gitspace/linux-x64": "0.2.0-rc.12",
42
+ "@gitspace/darwin-arm64": "0.2.0-rc.15",
43
+ "@gitspace/darwin-x64": "0.2.0-rc.15",
44
+ "@gitspace/linux-arm64": "0.2.0-rc.15",
45
+ "@gitspace/linux-x64": "0.2.0-rc.15",
46
46
  },
47
47
  },
48
48
  },
@@ -63,7 +63,7 @@
63
63
 
64
64
  "@eslint/js": ["@eslint/js@8.57.1", "", {}, "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q=="],
65
65
 
66
- "@gitspace/darwin-arm64": ["@gitspace/darwin-arm64@0.2.0-rc.12", "", { "os": "darwin", "cpu": "arm64", "bin": { "gssh-darwin-arm64": "bin/gssh" } }, "sha512-FUCz7f6EThB1/epRZ+X3SV7wDq/m4mFRDy2AVXGurVK6OWAA+D8tddUYeGmxvYT9+PXEZDATu5ukBh2RKpr5OQ=="],
66
+ "@gitspace/darwin-arm64": ["@gitspace/darwin-arm64@0.2.0-rc.15", "", { "os": "darwin", "cpu": "arm64", "bin": { "gssh-darwin-arm64": "bin/gssh" } }, "sha512-gjqsRdz2f7cOOTbj97wmQ4Z0nh7jWUVN4Dxj1N/Ex4fp6dJ8qu2d5izbsCHWVIPX4uN+gK/qqzxuwLwCdaEqlQ=="],
67
67
 
68
68
  "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
69
69
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitspace",
3
- "version": "0.2.0-rc.15",
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.15",
21
- "@gitspace/darwin-x64": "0.2.0-rc.15",
22
- "@gitspace/linux-x64": "0.2.0-rc.15",
23
- "@gitspace/linux-arm64": "0.2.0-rc.15"
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",
@@ -0,0 +1,170 @@
1
+ import { useCallback } from 'react';
2
+ import type { UseFlowReturn } from '../../components/Flow.js';
3
+ import type { DeleteWorkspaceParams } from '../../session/backend.js';
4
+
5
+ export interface WorkspaceDeleteTarget {
6
+ projectName: string;
7
+ workspaceId: string;
8
+ workspaceName: string;
9
+ }
10
+
11
+ export interface WorkspaceDeleteContext {
12
+ target: WorkspaceDeleteTarget;
13
+ params: DeleteWorkspaceParams;
14
+ isRetry: boolean;
15
+ }
16
+
17
+ export interface WorkspaceDeleteErrorContext extends WorkspaceDeleteContext {
18
+ error: unknown;
19
+ message: string;
20
+ }
21
+
22
+ export interface UseWorkspaceDeleteFlowOptions {
23
+ flow: Pick<UseFlowReturn, 'showLoading' | 'showConfirm' | 'showMessage' | 'close'>;
24
+ deleteWorkspace: (
25
+ projectName: string,
26
+ workspaceId: string,
27
+ params?: DeleteWorkspaceParams
28
+ ) => Promise<void>;
29
+ onBeforeDelete?: (context: WorkspaceDeleteContext) => void | Promise<void>;
30
+ onDeleteSuccess?: (context: WorkspaceDeleteContext) => void | Promise<void>;
31
+ onDeleteCancelled?: (context: WorkspaceDeleteContext) => void | Promise<void>;
32
+ onDeleteError?: (context: WorkspaceDeleteErrorContext) => void | Promise<void>;
33
+ showLoadingDuringDelete?: boolean;
34
+ }
35
+
36
+ export interface UseWorkspaceDeleteFlowResult {
37
+ deleteWorkspaceWithPrompt: (target: WorkspaceDeleteTarget) => Promise<boolean>;
38
+ }
39
+
40
+ function getErrorCode(error: unknown): string | undefined {
41
+ if (!error || typeof error !== 'object') {
42
+ return undefined;
43
+ }
44
+
45
+ const candidate = error as { code?: unknown };
46
+ return typeof candidate.code === 'string' ? candidate.code : undefined;
47
+ }
48
+
49
+ function toErrorMessage(error: unknown): string {
50
+ if (error instanceof Error && error.message) {
51
+ return error.message;
52
+ }
53
+
54
+ if (typeof error === 'string' && error.length > 0) {
55
+ return error;
56
+ }
57
+
58
+ return 'Failed to delete workspace';
59
+ }
60
+
61
+ function promptRemoveScriptFailure(
62
+ flow: UseWorkspaceDeleteFlowOptions['flow'],
63
+ workspaceName: string,
64
+ message: string
65
+ ): Promise<boolean> {
66
+ return new Promise<boolean>((resolve) => {
67
+ flow.showConfirm({
68
+ title: 'Remove Scripts Failed',
69
+ message:
70
+ `Cleanup scripts failed for workspace "${workspaceName}".\n\n${message}\n\nRemove anyway and skip cleanup scripts?`,
71
+ variant: 'warning',
72
+ confirmLabel: 'Remove anyway',
73
+ cancelLabel: 'Keep workspace',
74
+ onConfirm: () => resolve(true),
75
+ onCancel: () => resolve(false),
76
+ });
77
+ });
78
+ }
79
+
80
+ export function useWorkspaceDeleteFlow(
81
+ options: UseWorkspaceDeleteFlowOptions
82
+ ): UseWorkspaceDeleteFlowResult {
83
+ const {
84
+ flow,
85
+ deleteWorkspace,
86
+ onBeforeDelete,
87
+ onDeleteSuccess,
88
+ onDeleteCancelled,
89
+ onDeleteError,
90
+ showLoadingDuringDelete = false,
91
+ } = options;
92
+
93
+ const executeDelete = useCallback(async (
94
+ target: WorkspaceDeleteTarget,
95
+ params: DeleteWorkspaceParams,
96
+ isRetry: boolean
97
+ ): Promise<boolean> => {
98
+ const context: WorkspaceDeleteContext = {
99
+ target,
100
+ params,
101
+ isRetry,
102
+ };
103
+
104
+ await onBeforeDelete?.(context);
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
+ }
116
+
117
+ try {
118
+ await deleteWorkspace(target.projectName, target.workspaceId, params);
119
+ flow.close();
120
+ await onDeleteSuccess?.(context);
121
+ return true;
122
+ } catch (error) {
123
+ const message = toErrorMessage(error);
124
+ const code = getErrorCode(error);
125
+
126
+ if (code === 'REMOVE_SCRIPT_FAILED' && params.scriptPolicy !== 'skip') {
127
+ const removeAnyway = await promptRemoveScriptFailure(flow, target.workspaceName, message);
128
+ if (!removeAnyway) {
129
+ await onDeleteCancelled?.(context);
130
+ return false;
131
+ }
132
+
133
+ return executeDelete(target, { scriptPolicy: 'skip' }, true);
134
+ }
135
+
136
+ flow.close();
137
+ if (onDeleteError) {
138
+ await onDeleteError({
139
+ ...context,
140
+ error,
141
+ message,
142
+ });
143
+ } else {
144
+ flow.showMessage({
145
+ title: 'Delete Failed',
146
+ message,
147
+ variant: 'error',
148
+ });
149
+ }
150
+
151
+ return false;
152
+ }
153
+ }, [
154
+ deleteWorkspace,
155
+ flow,
156
+ onBeforeDelete,
157
+ onDeleteCancelled,
158
+ onDeleteError,
159
+ onDeleteSuccess,
160
+ showLoadingDuringDelete,
161
+ ]);
162
+
163
+ const deleteWorkspaceWithPrompt = useCallback(async (target: WorkspaceDeleteTarget): Promise<boolean> => {
164
+ return executeDelete(target, { scriptPolicy: 'auto' }, false);
165
+ }, [executeDelete]);
166
+
167
+ return {
168
+ deleteWorkspaceWithPrompt,
169
+ };
170
+ }
package/src/app.tui.tsx CHANGED
@@ -86,6 +86,11 @@ import { useLocalSession } from './hooks/useLocalSession.tui.js';
86
86
  import { useUserActivity } from './hooks/index.js';
87
87
  import { useBundleRefreshAttachFlow } from './session/index.js';
88
88
  import { useAttachController } from './app/session/useAttachController.js';
89
+ import { useWorkspaceDeleteFlow } from './app/session/useWorkspaceDeleteFlow.js';
90
+ import {
91
+ consumeLegacyCleanupReminderForTui,
92
+ initializeSecretRuntime,
93
+ } from './core/secret-runtime.js';
89
94
  import {
90
95
  resolveInboxCommand,
91
96
  resolveMachineListCommand,
@@ -438,6 +443,27 @@ function App({ relayConfig, onQuit }: AppProps) {
438
443
  },
439
444
  });
440
445
 
446
+ const { deleteWorkspaceWithPrompt } = useWorkspaceDeleteFlow({
447
+ flow,
448
+ deleteWorkspace: deleteLocalWorkspace,
449
+ onBeforeDelete: ({ target }) => {
450
+ setScriptWorkspaceName(target.workspaceName);
451
+ dispatch({ type: 'SET_VIEW', view: 'scripts' });
452
+ },
453
+ onDeleteSuccess: async () => {
454
+ dispatch({ type: 'SET_VIEW', view: 'projects' });
455
+ await refreshWorkspaces();
456
+ },
457
+ onDeleteError: async ({ message }) => {
458
+ dispatch({ type: 'SET_VIEW', view: 'projects' });
459
+ flow.showMessage({
460
+ title: 'Delete Failed',
461
+ message,
462
+ variant: 'error',
463
+ });
464
+ },
465
+ });
466
+
441
467
  // Daemon status hook (tmux-lite and serve)
442
468
  const { status: daemonStatus } = useDaemonStatus({ pollInterval: 5000 });
443
469
 
@@ -600,26 +626,14 @@ function App({ relayConfig, onQuit }: AppProps) {
600
626
  warning: workspace.sessionCount > 0 ? `This will kill ${workspace.sessionCount} active session(s)!` : undefined,
601
627
  onConfirm: async () => {
602
628
  if (!currentProject) return;
603
- flow.showLoading({ title: 'Deleting', message: 'Preparing...' });
604
-
605
- try {
606
- await deleteLocalWorkspace(currentProject, workspace.id);
607
- } catch (error) {
608
- console.error('[tui] Failed to delete workspace:', error);
609
- flow.close();
610
- flow.showMessage({
611
- title: 'Delete Failed',
612
- message: error instanceof Error ? error.message : `Failed to delete workspace "${workspace.name}".`,
613
- variant: 'error',
614
- });
615
- return;
616
- }
617
-
618
- flow.close();
619
- await refreshWorkspaces();
629
+ await deleteWorkspaceWithPrompt({
630
+ projectName: currentProject,
631
+ workspaceId: workspace.id,
632
+ workspaceName: workspace.name,
633
+ });
620
634
  },
621
635
  });
622
- }, [currentProject, flow, refreshWorkspaces]);
636
+ }, [currentProject, flow, deleteWorkspaceWithPrompt]);
623
637
 
624
638
  // Delete session
625
639
  const handleDeleteSession = useCallback((sessionId: string, sessionName: string) => {
@@ -1311,9 +1325,13 @@ function App({ relayConfig, onQuit }: AppProps) {
1311
1325
  // ========== Keyboard Handlers ==========
1312
1326
 
1313
1327
  useKeyboard(async (key) => {
1328
+ const localScriptTerminalRunning =
1329
+ state.view === 'scripts' &&
1330
+ (localScriptState?.isRunning ?? true);
1331
+
1314
1332
  // Handle flow modals FIRST - even in terminal view
1315
1333
  // This ensures y/n work in confirmation modals when terminal is underneath
1316
- if (flow.isOpen) {
1334
+ if (flow.isOpen && !localScriptTerminalRunning) {
1317
1335
  // Handle confirm modal with y/n shortcuts
1318
1336
  if (flow.flow.type === 'confirm') {
1319
1337
  if (key.raw === 'y' || key.name === 'return') {
@@ -2048,7 +2066,7 @@ function App({ relayConfig, onQuit }: AppProps) {
2048
2066
  error={localScriptState?.error}
2049
2067
  exitCode={localScriptState?.exitCode}
2050
2068
  />
2051
- <FlowTUI flow={flow} />
2069
+ {!isRunning && <FlowTUI flow={flow} />}
2052
2070
  <StatusBar hint={isRunning ? '[Running scripts...]' : '[Esc/n] Back to workspaces'} />
2053
2071
  </Fragment>
2054
2072
  );
@@ -2561,7 +2579,14 @@ function StatusBar({ hint }: { hint: string }) {
2561
2579
  /** @deprecated Use RelayConfig instead */
2562
2580
  export type TUIRelayConfig = RelayConfig;
2563
2581
 
2564
- export async function launchTUI(relayConfig?: RelayConfig): Promise<void> {
2582
+ export async function launchTUI(
2583
+ relayConfig?: RelayConfig,
2584
+ options: { ignoreKeychainAndSkipSecrets?: boolean } = {}
2585
+ ): Promise<void> {
2586
+ await initializeSecretRuntime({
2587
+ ignoreKeychainAndSkipSecrets: options.ignoreKeychainAndSkipSecrets,
2588
+ });
2589
+
2565
2590
  const renderer = await createCliRenderer({
2566
2591
  exitOnCtrlC: false,
2567
2592
  targetFps: 30,
@@ -2572,6 +2597,12 @@ export async function launchTUI(relayConfig?: RelayConfig): Promise<void> {
2572
2597
  // Clean exit handler
2573
2598
  const handleQuit = () => {
2574
2599
  renderer.destroy();
2600
+
2601
+ const legacyReminder = consumeLegacyCleanupReminderForTui();
2602
+ if (legacyReminder) {
2603
+ logger.warning(legacyReminder);
2604
+ }
2605
+
2575
2606
  process.exit(0);
2576
2607
  };
2577
2608
 
package/src/app.web.tsx CHANGED
@@ -18,6 +18,7 @@ import { applyDeviceClasses, isMobileLayout, isTouchDevice } from "./utils/devic
18
18
  import { useUserActivity } from "./hooks/index.js";
19
19
  import { useBundleRefreshAttachFlow } from './session/useBundleRefreshAttachFlow.js';
20
20
  import { useAttachController } from './app/session/useAttachController.js';
21
+ import { useWorkspaceDeleteFlow } from './app/session/useWorkspaceDeleteFlow.js';
21
22
 
22
23
  // Import shared components and hooks
23
24
  import {
@@ -49,6 +50,13 @@ type View = "machines" | "terminal";
49
50
 
50
51
  const PAGE_UP = '\x1b[5~';
51
52
  const PAGE_DOWN = '\x1b[6~';
53
+ const DELETE_ERROR_CODES = new Set([
54
+ 'REMOVE_SCRIPT_FAILED',
55
+ 'DELETE_FAILED',
56
+ 'WORKSPACE_NOT_FOUND',
57
+ 'RESOURCE_NOT_FOUND',
58
+ 'NOT_FOUND',
59
+ ]);
52
60
 
53
61
  export default function App() {
54
62
  const [view, setView] = useState<View>("machines");
@@ -71,6 +79,7 @@ export default function App() {
71
79
  const terminalRef = useRef<SessionTerminalHandle>(null);
72
80
  const lastScriptErrorRef = useRef<string | null>(null);
73
81
  const lastCommandErrorRef = useRef<string | null>(null);
82
+ const suppressDeleteScriptFailureModalRef = useRef(false);
74
83
 
75
84
  // Invite params from URL
76
85
  const [inviteParams, setInviteParams] = useState<{
@@ -174,6 +183,36 @@ export default function App() {
174
183
  },
175
184
  });
176
185
 
186
+ const { deleteWorkspaceWithPrompt } = useWorkspaceDeleteFlow({
187
+ flow,
188
+ deleteWorkspace: terminal.deleteWorkspace,
189
+ onBeforeDelete: ({ target }) => {
190
+ suppressDeleteScriptFailureModalRef.current = true;
191
+ setShowInbox(false);
192
+ setScriptWorkspaceName(target.workspaceName);
193
+ setShowScriptTerminal(true);
194
+ },
195
+ onDeleteSuccess: async () => {
196
+ suppressDeleteScriptFailureModalRef.current = false;
197
+ setShowScriptTerminal(false);
198
+ terminal.requestWorkspaces();
199
+ terminal.requestSessions();
200
+ },
201
+ onDeleteCancelled: async () => {
202
+ suppressDeleteScriptFailureModalRef.current = false;
203
+ setShowScriptTerminal(false);
204
+ },
205
+ onDeleteError: async ({ message }) => {
206
+ suppressDeleteScriptFailureModalRef.current = false;
207
+ setShowScriptTerminal(false);
208
+ flow.showMessage({
209
+ title: 'Delete Failed',
210
+ message,
211
+ variant: 'error',
212
+ });
213
+ },
214
+ });
215
+
177
216
  useEffect(() => {
178
217
  let mounted = true;
179
218
  void browserPreferencesService.getNotificationConfig().then((config) => {
@@ -235,6 +274,11 @@ export default function App() {
235
274
  return;
236
275
  }
237
276
 
277
+ if (suppressDeleteScriptFailureModalRef.current) {
278
+ lastScriptErrorRef.current = scriptError;
279
+ return;
280
+ }
281
+
238
282
  if (lastScriptErrorRef.current === scriptError) {
239
283
  return;
240
284
  }
@@ -259,11 +303,20 @@ export default function App() {
259
303
  }
260
304
  lastCommandErrorRef.current = key;
261
305
 
306
+ if (
307
+ suppressDeleteScriptFailureModalRef.current &&
308
+ terminal.commandError.code &&
309
+ DELETE_ERROR_CODES.has(terminal.commandError.code)
310
+ ) {
311
+ return;
312
+ }
313
+
262
314
  const isScriptFailure =
263
315
  terminal.commandError.code === 'SCRIPT_FAILED' ||
264
316
  terminal.commandError.code === 'PRE_SCRIPT_FAILED' ||
265
317
  terminal.commandError.code === 'SETUP_SCRIPT_FAILED' ||
266
- terminal.commandError.code === 'SELECT_SCRIPT_FAILED';
318
+ terminal.commandError.code === 'SELECT_SCRIPT_FAILED' ||
319
+ terminal.commandError.code === 'REMOVE_SCRIPT_FAILED';
267
320
 
268
321
  if (isScriptFailure) {
269
322
  if (!terminal.scriptState) {
@@ -370,12 +423,10 @@ export default function App() {
370
423
  const spacesBrowserProps = useSpacesBrowser({
371
424
  workspaces: terminal.workspaces,
372
425
  sessions: terminal.sessions,
373
- onRequestSessions: terminal.requestSessions,
426
+ onRequestSessions: () => terminal.requestSessions(),
374
427
  onAttachSession: handleAttachSession,
375
428
  onRefresh: terminal.requestWorkspaces,
376
- onRefreshSessions: (workspaceIds) => {
377
- workspaceIds.forEach(id => terminal.requestSessions(id));
378
- },
429
+ onRefreshSessions: () => terminal.requestSessions(),
379
430
  onBack: handleBackToMachines,
380
431
  machineName: selectedMachine?.label || selectedMachine?.machineId,
381
432
  });
@@ -446,6 +497,7 @@ export default function App() {
446
497
  if (view === "terminal" && terminal.status === "established" && terminal.mode === "browsing") {
447
498
  terminal.requestProjects();
448
499
  terminal.requestWorkspaces();
500
+ terminal.requestSessions();
449
501
  terminal.requestNotificationConfig();
450
502
  }
451
503
  }, [
@@ -454,6 +506,7 @@ export default function App() {
454
506
  terminal.mode,
455
507
  terminal.requestProjects,
456
508
  terminal.requestWorkspaces,
509
+ terminal.requestSessions,
457
510
  terminal.requestNotificationConfig,
458
511
  ]);
459
512
 
@@ -594,8 +647,12 @@ export default function App() {
594
647
  message: `Are you sure you want to delete workspace "${selected.workspace.name}"?`,
595
648
  confirmText: selected.workspace.name,
596
649
  warning: sessionCount > 0 ? `This will kill ${sessionCount} active session(s)!` : undefined,
597
- onConfirm: () => {
598
- terminal.deleteWorkspace(selected.workspace.projectName, selected.workspace.id);
650
+ onConfirm: async () => {
651
+ await deleteWorkspaceWithPrompt({
652
+ projectName: selected.workspace.projectName,
653
+ workspaceId: selected.workspace.id,
654
+ workspaceName: selected.workspace.name,
655
+ });
599
656
  },
600
657
  });
601
658
  }
@@ -607,7 +664,16 @@ export default function App() {
607
664
 
608
665
  window.addEventListener("keydown", handleKeyDown);
609
666
  return () => window.removeEventListener("keydown", handleKeyDown);
610
- }, [view, terminal.status, terminal.mode, showInbox, showScriptTerminal, spacesBrowserProps, flow]);
667
+ }, [
668
+ view,
669
+ terminal.status,
670
+ terminal.mode,
671
+ showInbox,
672
+ showScriptTerminal,
673
+ spacesBrowserProps,
674
+ flow,
675
+ deleteWorkspaceWithPrompt,
676
+ ]);
611
677
 
612
678
  // Attached terminal mode keyboard handler (Ctrl+Esc to detach)
613
679
  useEffect(() => {
@@ -681,18 +747,19 @@ export default function App() {
681
747
  terminal.mode === 'browsing' &&
682
748
  showScriptTerminal
683
749
  ) {
750
+ const isRunning = terminal.scriptState?.isRunning ?? true;
684
751
  return (
685
752
  <>
686
753
  <ScriptTerminal
687
754
  phase={terminal.scriptState?.phase ?? 'pre'}
688
755
  workspaceName={scriptWorkspaceName}
689
- isRunning={terminal.scriptState?.isRunning ?? true}
756
+ isRunning={isRunning}
690
757
  error={terminal.scriptState?.error}
691
758
  exitCode={terminal.scriptState?.exitCode}
692
759
  setWriteCallback={terminal.setWriteCallback}
693
760
  onBack={() => setShowScriptTerminal(false)}
694
761
  />
695
- <FlowWeb flow={flow} />
762
+ {!isRunning && <FlowWeb flow={flow} />}
696
763
  <Toaster theme="dark" position="top-right" richColors />
697
764
  </>
698
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
+ }
@@ -114,10 +114,32 @@ export async function removeWorkspace(
114
114
 
115
115
  // Delegate to core deletion logic (interactive mode for CLI)
116
116
  logger.info('Removing workspace...')
117
- const result = await deleteWorkspaceCore(currentProject, workspaceName, {
118
- nonInteractive: false, // CLI is interactive
119
- keepBranch: options.keepBranch,
120
- })
117
+ const runDelete = async (scriptPolicy: 'enforce' | 'skip') => {
118
+ return deleteWorkspaceCore(currentProject, workspaceName, {
119
+ nonInteractive: false, // CLI is interactive
120
+ keepBranch: options.keepBranch,
121
+ removeScriptPolicy: scriptPolicy,
122
+ })
123
+ }
124
+
125
+ let result = await runDelete('enforce')
126
+
127
+ if (!result.success && result.errorCode === 'REMOVE_SCRIPT_FAILED') {
128
+ logger.warning(result.error || 'Remove scripts failed')
129
+ const removeAnyway = options.force
130
+ ? true
131
+ : await promptConfirm(
132
+ `Remove workspace "${workspaceName}" anyway and skip cleanup scripts?`,
133
+ false
134
+ )
135
+
136
+ if (!removeAnyway) {
137
+ logger.info('Cancelled')
138
+ return
139
+ }
140
+
141
+ result = await runDelete('skip')
142
+ }
121
143
 
122
144
  if (!result.success) {
123
145
  throw new SpacesError(
@@ -61,6 +61,7 @@ import {
61
61
  ensureServeDaemonDir,
62
62
  type StatusResponse,
63
63
  } from '../serve/daemon.js';
64
+ import { initializeSecretRuntime } from '../core/secret-runtime.js';
64
65
 
65
66
  /** Package version for daemon status */
66
67
  const PACKAGE_VERSION = '1.0.0';
@@ -463,6 +464,7 @@ function stopCloudflared(): void {
463
464
  export async function serve(options: {
464
465
  relay?: string;
465
466
  relayPubkey?: string;
467
+ ignoreKeychainAndSkipSecrets?: boolean;
466
468
  } = {}): Promise<void> {
467
469
  // Step 1: Load machine identity
468
470
  if (!keypairExists()) {
@@ -503,6 +505,10 @@ export async function serve(options: {
503
505
  const entries = readAccessList();
504
506
  accessList.import(entries);
505
507
 
508
+ await initializeSecretRuntime({
509
+ ignoreKeychainAndSkipSecrets: options.ignoreKeychainAndSkipSecrets,
510
+ });
511
+
506
512
  // Step 3: Check for gitspace.sh hosting or explicit relay
507
513
  const hostConfig = readHostConfig();
508
514
  const relayUrl = options.relay; // No default - must use hosting or explicit --relay
@@ -1156,6 +1162,7 @@ export async function serveStart(options: {
1156
1162
  relayPubkey?: string;
1157
1163
  passwordStdin?: boolean;
1158
1164
  foreground?: boolean;
1165
+ ignoreKeychainAndSkipSecrets?: boolean;
1159
1166
  } = {}): Promise<void> {
1160
1167
  // Check if already running
1161
1168
  if (isServeRunning()) {
@@ -1234,6 +1241,9 @@ export async function serveStart(options: {
1234
1241
  const serveArgs = ['serve', 'start', '--foreground'];
1235
1242
  if (options.relay) serveArgs.push('--relay', options.relay);
1236
1243
  if (options.relayPubkey) serveArgs.push('--relay-pubkey', options.relayPubkey);
1244
+ if (options.ignoreKeychainAndSkipSecrets) {
1245
+ serveArgs.push('--ignore-keychain-and-skip-secrets');
1246
+ }
1237
1247
  serveArgs.push('--password-stdin');
1238
1248
 
1239
1249
  // Build command: compiled binary runs directly, dev mode uses bun
@@ -1294,6 +1304,16 @@ export async function serveStart(options: {
1294
1304
  const entries = readAccessList();
1295
1305
  accessList.import(entries);
1296
1306
 
1307
+ try {
1308
+ await initializeSecretRuntime({
1309
+ ignoreKeychainAndSkipSecrets: options.ignoreKeychainAndSkipSecrets,
1310
+ });
1311
+ } catch (error) {
1312
+ stopStatusServer();
1313
+ cleanupServeFiles();
1314
+ throw error;
1315
+ }
1316
+
1297
1317
  // Get config
1298
1318
  const machineIdentity = readMachineIdentity();
1299
1319
  const machineId = machineIdentity?.machineId ?? identity.id;