opencode-session-port 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +84 -2
- package/package.json +56 -4
- package/src/core/contracts/crypto.ts +66 -0
- package/src/core/contracts/index.ts +13 -0
- package/src/core/contracts/json.ts +142 -0
- package/src/core/contracts/session.ts +12 -0
- package/src/core/crypto/container.ts +686 -0
- package/src/core/crypto/envelope.ts +232 -0
- package/src/core/crypto/errors.ts +52 -0
- package/src/core/crypto/gzip-stream.ts +388 -0
- package/src/core/crypto/index.ts +13 -0
- package/src/core/crypto/kdf.ts +101 -0
- package/src/core/crypto/policy.ts +202 -0
- package/src/core/crypto/zero.ts +33 -0
- package/src/core/restore/identity.ts +202 -0
- package/src/core/restore/index.ts +24 -0
- package/src/core/restore/planner.ts +1122 -0
- package/src/core/sessions/collector.ts +323 -0
- package/src/core/sessions/errors.ts +37 -0
- package/src/core/sessions/guards.ts +11 -0
- package/src/core/sessions/index.ts +29 -0
- package/src/core/sessions/policy.ts +80 -0
- package/src/core/sessions/port.ts +88 -0
- package/src/core/sessions/serialize.ts +15 -0
- package/src/core/sessions/unsettled.ts +35 -0
- package/src/integrations/omo/index.ts +28 -0
- package/src/tui/adapters/host-client.ts +254 -0
- package/src/tui/adapters/index.ts +72 -0
- package/src/tui/adapters/location-resolver.ts +281 -0
- package/src/tui/adapters/project-destinations.ts +271 -0
- package/src/tui/adapters/session-id-inventory.ts +378 -0
- package/src/tui/adapters/session-import.ts +189 -0
- package/src/tui/adapters/session-read.ts +115 -0
- package/src/tui/artifacts/files.ts +285 -0
- package/src/tui/orchestration/checkpoint.ts +160 -0
- package/src/tui/orchestration/index.ts +55 -0
- package/src/tui/orchestration/restore-auth.ts +94 -0
- package/src/tui/orchestration/restore-execution.ts +381 -0
- package/src/tui/orchestration/restore-preflight.ts +223 -0
- package/src/tui/orchestration/shared.ts +75 -0
- package/src/tui/ui/password-dialog.tsx +251 -0
- package/src/tui/ui/password-flow.ts +288 -0
- package/src/tui/ui/restore-project-selector.ts +62 -0
- package/src/tui/ui/restore-report-dialog.tsx +251 -0
- package/src/tui/ui/restore-report-flow.ts +94 -0
- package/src/tui/ui/sessionport-commands.tsx +65 -0
- package/src/tui/wiring/composition.ts +354 -0
- package/src/tui/wiring/experimental-profile.ts +86 -0
- package/src/tui/wiring/index.ts +51 -0
- package/src/tui/wiring/install.ts +108 -0
- package/src/tui/wiring/reports.ts +340 -0
- package/src/tui.ts +34 -0
- package/tsconfig.json +18 -0
- package/tui.ts +12 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin/tui";
|
|
2
|
+
import { useKeyboard, usePaste, type JSX } from "@opentui/solid";
|
|
3
|
+
import { createMemo, createSignal, onCleanup } from "solid-js";
|
|
4
|
+
import {
|
|
5
|
+
showPasswordPrompt,
|
|
6
|
+
type PasswordMode,
|
|
7
|
+
type PasswordPromptState,
|
|
8
|
+
} from "./password-flow.ts";
|
|
9
|
+
|
|
10
|
+
type Context = Plugin.Context;
|
|
11
|
+
|
|
12
|
+
const palette = {
|
|
13
|
+
background: "#111922",
|
|
14
|
+
field: "#202d3b",
|
|
15
|
+
edge: "#344557",
|
|
16
|
+
accent: "#53d6c3",
|
|
17
|
+
text: "#edf3f7",
|
|
18
|
+
muted: "#94a4b2",
|
|
19
|
+
gold: "#e5be76",
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
interface PasswordDialogProps {
|
|
23
|
+
readonly mode: PasswordMode;
|
|
24
|
+
readonly maxPasswordBytes: number;
|
|
25
|
+
readonly prompt: PasswordPromptState;
|
|
26
|
+
readonly accept: (password: Uint8Array) => void;
|
|
27
|
+
readonly cancel: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface PasswordFieldProps {
|
|
31
|
+
readonly label: string;
|
|
32
|
+
readonly active: boolean;
|
|
33
|
+
/** A mask from PasswordPromptState; never pass the entered password here. */
|
|
34
|
+
readonly displayMask: string;
|
|
35
|
+
readonly emptyHint: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ConsumableKeyEvent {
|
|
39
|
+
preventDefault(): void;
|
|
40
|
+
stopPropagation(): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function consumeKey(event: ConsumableKeyEvent): void {
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
event.stopPropagation();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function formatByteLimit(maxPasswordBytes: number): string {
|
|
49
|
+
return maxPasswordBytes % 1024 === 0
|
|
50
|
+
? `${maxPasswordBytes / 1024} KiB`
|
|
51
|
+
: `${maxPasswordBytes} bytes`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function PasswordField(props: PasswordFieldProps) {
|
|
55
|
+
return (
|
|
56
|
+
<box
|
|
57
|
+
flexDirection="column"
|
|
58
|
+
border
|
|
59
|
+
borderStyle="rounded"
|
|
60
|
+
borderColor={props.active ? palette.accent : palette.edge}
|
|
61
|
+
backgroundColor={palette.field}
|
|
62
|
+
paddingX={1}
|
|
63
|
+
paddingY={0}
|
|
64
|
+
>
|
|
65
|
+
<box flexDirection="row" gap={1}>
|
|
66
|
+
<text fg={props.active ? palette.accent : palette.muted}>
|
|
67
|
+
{props.active ? ">" : " "}
|
|
68
|
+
</text>
|
|
69
|
+
<text fg={props.active ? palette.text : palette.muted}>{props.label}</text>
|
|
70
|
+
</box>
|
|
71
|
+
<text fg={props.displayMask ? palette.text : palette.muted}>
|
|
72
|
+
{props.displayMask || props.emptyHint}
|
|
73
|
+
{props.active ? "▏" : ""}
|
|
74
|
+
</text>
|
|
75
|
+
</box>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function PasswordDialog(props: PasswordDialogProps) {
|
|
80
|
+
const [revision, setRevision] = createSignal(0);
|
|
81
|
+
const view = createMemo(() => {
|
|
82
|
+
revision();
|
|
83
|
+
return props.prompt.snapshot();
|
|
84
|
+
});
|
|
85
|
+
const refresh = () => setRevision((current) => current + 1);
|
|
86
|
+
|
|
87
|
+
const submit = () => {
|
|
88
|
+
const result = props.prompt.submit();
|
|
89
|
+
refresh();
|
|
90
|
+
if (result.status === "accepted") props.accept(result.password);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
onCleanup(() => props.prompt.dispose());
|
|
94
|
+
|
|
95
|
+
useKeyboard((event) => {
|
|
96
|
+
const key = event.name.toLowerCase();
|
|
97
|
+
|
|
98
|
+
if (key === "escape" || key === "esc") {
|
|
99
|
+
consumeKey(event);
|
|
100
|
+
props.cancel();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (key === "enter" || key === "return") {
|
|
104
|
+
consumeKey(event);
|
|
105
|
+
submit();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (key === "tab") {
|
|
109
|
+
consumeKey(event);
|
|
110
|
+
props.prompt.switchField();
|
|
111
|
+
refresh();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (key === "backspace") {
|
|
115
|
+
consumeKey(event);
|
|
116
|
+
props.prompt.backspace();
|
|
117
|
+
refresh();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (event.ctrl && key === "u") {
|
|
121
|
+
consumeKey(event);
|
|
122
|
+
props.prompt.clearActive();
|
|
123
|
+
refresh();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (event.ctrl || event.meta) return;
|
|
127
|
+
|
|
128
|
+
const text = event.sequence;
|
|
129
|
+
if (text && !/[\u0000-\u001f\u007f-\u009f]/u.test(text)) {
|
|
130
|
+
consumeKey(event);
|
|
131
|
+
props.prompt.insert(text);
|
|
132
|
+
refresh();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
usePaste((event) => {
|
|
137
|
+
// Prevent the paste from reaching other handlers. Decode strictly and
|
|
138
|
+
// keep only the mask in state exposed to the rendered tree.
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
event.stopPropagation();
|
|
141
|
+
props.prompt.insertPasteBytes(event.bytes);
|
|
142
|
+
refresh();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<box
|
|
147
|
+
flexDirection="column"
|
|
148
|
+
width={62}
|
|
149
|
+
gap={1}
|
|
150
|
+
border
|
|
151
|
+
borderStyle="rounded"
|
|
152
|
+
borderColor={palette.edge}
|
|
153
|
+
backgroundColor={palette.background}
|
|
154
|
+
paddingX={2}
|
|
155
|
+
paddingY={1}
|
|
156
|
+
>
|
|
157
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
158
|
+
<text fg={palette.accent}>SESSIONPORT</text>
|
|
159
|
+
<text fg={palette.muted}>
|
|
160
|
+
{props.mode === "checkpoint" ? "CHECKPOINT" : "RESTORE"}
|
|
161
|
+
</text>
|
|
162
|
+
</box>
|
|
163
|
+
|
|
164
|
+
<box flexDirection="column" gap={0}>
|
|
165
|
+
<text fg={palette.text}>Password</text>
|
|
166
|
+
<text fg={palette.muted}>
|
|
167
|
+
{props.mode === "checkpoint"
|
|
168
|
+
? "Choose a password to protect this checkpoint."
|
|
169
|
+
: "Enter the password used to create this checkpoint."}
|
|
170
|
+
</text>
|
|
171
|
+
{props.mode === "checkpoint" && (
|
|
172
|
+
<>
|
|
173
|
+
<text fg={palette.muted}>
|
|
174
|
+
Use a longer password you do not reuse elsewhere.
|
|
175
|
+
</text>
|
|
176
|
+
<text fg={palette.muted}>
|
|
177
|
+
Password strength is not checked.
|
|
178
|
+
</text>
|
|
179
|
+
</>
|
|
180
|
+
)}
|
|
181
|
+
<text fg={palette.muted}>SessionPort cannot recover a lost password.</text>
|
|
182
|
+
<text fg={palette.muted}>You can type or paste; your entry stays masked.</text>
|
|
183
|
+
<text fg={palette.muted}>
|
|
184
|
+
Password entries are limited to {formatByteLimit(props.maxPasswordBytes)} of UTF-8.
|
|
185
|
+
</text>
|
|
186
|
+
</box>
|
|
187
|
+
|
|
188
|
+
<PasswordField
|
|
189
|
+
label="Password"
|
|
190
|
+
active={view().activeField === "password"}
|
|
191
|
+
displayMask={view().passwordMask}
|
|
192
|
+
emptyHint="Type a password"
|
|
193
|
+
/>
|
|
194
|
+
|
|
195
|
+
{props.mode === "checkpoint" && (
|
|
196
|
+
<PasswordField
|
|
197
|
+
label="Confirm password"
|
|
198
|
+
active={view().activeField === "confirmation"}
|
|
199
|
+
displayMask={view().confirmationMask ?? ""}
|
|
200
|
+
emptyHint="Type it again"
|
|
201
|
+
/>
|
|
202
|
+
)}
|
|
203
|
+
|
|
204
|
+
<box flexDirection="row" minHeight={1}>
|
|
205
|
+
<text fg={view().message ? palette.gold : palette.muted}>
|
|
206
|
+
{view().message ?? " "}
|
|
207
|
+
</text>
|
|
208
|
+
</box>
|
|
209
|
+
|
|
210
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
211
|
+
<text fg={palette.muted}>
|
|
212
|
+
{props.mode === "checkpoint" ? "Tab: switch fields · Esc: cancel" : "Esc: cancel"}
|
|
213
|
+
</text>
|
|
214
|
+
<text fg={palette.accent}>
|
|
215
|
+
{props.mode === "restore"
|
|
216
|
+
? "ENTER CONTINUE"
|
|
217
|
+
: view().activeField === "password"
|
|
218
|
+
? "ENTER NEXT"
|
|
219
|
+
: "ENTER CREATE"}
|
|
220
|
+
</text>
|
|
221
|
+
</box>
|
|
222
|
+
<text fg={palette.muted}>
|
|
223
|
+
Backspace: delete · Ctrl+U: clear · Paste: allowed
|
|
224
|
+
</text>
|
|
225
|
+
</box>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Request one password entry (or a matching checkpoint confirmation). */
|
|
230
|
+
export function requestPassword(
|
|
231
|
+
context: Context,
|
|
232
|
+
mode: "checkpoint" | "restore",
|
|
233
|
+
maxPasswordBytes = 4096,
|
|
234
|
+
): Promise<Uint8Array | undefined> {
|
|
235
|
+
context.ui.dialog.set({ size: "medium", centered: true });
|
|
236
|
+
|
|
237
|
+
return showPasswordPrompt<JSX.Element>(
|
|
238
|
+
context.ui.dialog,
|
|
239
|
+
mode,
|
|
240
|
+
(prompt, accept, cancel) => (
|
|
241
|
+
<PasswordDialog
|
|
242
|
+
mode={mode}
|
|
243
|
+
maxPasswordBytes={maxPasswordBytes}
|
|
244
|
+
prompt={prompt}
|
|
245
|
+
accept={accept}
|
|
246
|
+
cancel={cancel}
|
|
247
|
+
/>
|
|
248
|
+
),
|
|
249
|
+
maxPasswordBytes,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret-entry state for the password dialog.
|
|
3
|
+
*
|
|
4
|
+
* This module deliberately exposes masks and actions, never password strings.
|
|
5
|
+
* JavaScript strings cannot be reliably zeroed; dispose() drops the owned
|
|
6
|
+
* references as a best-effort cleanup, not a secure-erasure guarantee.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type PasswordMode = "checkpoint" | "restore";
|
|
10
|
+
|
|
11
|
+
export type PasswordField = "password" | "confirmation";
|
|
12
|
+
|
|
13
|
+
export interface PasswordPromptSnapshot {
|
|
14
|
+
readonly mode: PasswordMode;
|
|
15
|
+
readonly activeField: PasswordField;
|
|
16
|
+
readonly passwordMask: string;
|
|
17
|
+
readonly confirmationMask?: string;
|
|
18
|
+
readonly message?: string;
|
|
19
|
+
readonly closed: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type PasswordSubmission =
|
|
23
|
+
| { readonly status: "confirmation-required" }
|
|
24
|
+
| { readonly status: "mismatch" }
|
|
25
|
+
| { readonly status: "invalid-encoding" }
|
|
26
|
+
| { readonly status: "accepted"; readonly password: Uint8Array }
|
|
27
|
+
| { readonly status: "closed" };
|
|
28
|
+
|
|
29
|
+
const MASK_GLYPH = "●";
|
|
30
|
+
const MISMATCH_MESSAGE = "The entries did not match. Try again.";
|
|
31
|
+
const INVALID_PASTE_MESSAGE = "Paste was not valid UTF-8 and was not added.";
|
|
32
|
+
const INVALID_ENCODING_MESSAGE = "Password contains invalid Unicode and cannot be encoded as UTF-8.";
|
|
33
|
+
const PASSWORD_LIMIT_MESSAGE = "Password limit reached. Remove some text before adding more.";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Count the bytes TextEncoder would produce, stopping as soon as the supplied
|
|
37
|
+
* limit is exceeded. Unpaired surrogates count as their UTF-8 replacement
|
|
38
|
+
* character here; submit() still rejects them as invalid Unicode.
|
|
39
|
+
*/
|
|
40
|
+
function utf8ByteLengthWithin(value: string, limit: number): number | undefined {
|
|
41
|
+
let byteLength = 0;
|
|
42
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
43
|
+
const unit = value.charCodeAt(index);
|
|
44
|
+
let bytes: number;
|
|
45
|
+
|
|
46
|
+
if (unit <= 0x7f) {
|
|
47
|
+
bytes = 1;
|
|
48
|
+
} else if (unit <= 0x7ff) {
|
|
49
|
+
bytes = 2;
|
|
50
|
+
} else if (unit >= 0xd800 && unit <= 0xdbff) {
|
|
51
|
+
const next = value.charCodeAt(index + 1);
|
|
52
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
53
|
+
bytes = 4;
|
|
54
|
+
index += 1;
|
|
55
|
+
} else {
|
|
56
|
+
bytes = 3;
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
// BMP characters and isolated low surrogates (TextEncoder replacement).
|
|
60
|
+
bytes = 3;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Subtraction keeps the addition bounded even for very large input.
|
|
64
|
+
if (bytes > limit - byteLength) return undefined;
|
|
65
|
+
byteLength += bytes;
|
|
66
|
+
}
|
|
67
|
+
return byteLength;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isWellFormedUnicode(value: string): boolean {
|
|
71
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
72
|
+
const unit = value.charCodeAt(index);
|
|
73
|
+
if (unit >= 0xd800 && unit <= 0xdbff) {
|
|
74
|
+
const next = value.charCodeAt(index + 1);
|
|
75
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) return false;
|
|
76
|
+
index += 1;
|
|
77
|
+
} else if (unit >= 0xdc00 && unit <= 0xdfff) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function mask(value: string): string {
|
|
85
|
+
// One mask per Unicode code point keeps surrogate pairs intact and never
|
|
86
|
+
// includes any part of the entered value in a rendered string.
|
|
87
|
+
return MASK_GLYPH.repeat(Array.from(value).length);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Owns the one or two secret strings for a single modal session. */
|
|
91
|
+
export class PasswordPromptState {
|
|
92
|
+
readonly #mode: PasswordMode;
|
|
93
|
+
readonly #maxPasswordBytes: number;
|
|
94
|
+
#password = "";
|
|
95
|
+
#confirmation = "";
|
|
96
|
+
#activeField: PasswordField = "password";
|
|
97
|
+
#message: string | undefined;
|
|
98
|
+
#closed = false;
|
|
99
|
+
|
|
100
|
+
constructor(mode: PasswordMode, maxPasswordBytes = 4096) {
|
|
101
|
+
if (!Number.isSafeInteger(maxPasswordBytes) || maxPasswordBytes < 0) {
|
|
102
|
+
throw new RangeError("maxPasswordBytes must be a non-negative safe integer");
|
|
103
|
+
}
|
|
104
|
+
this.#mode = mode;
|
|
105
|
+
this.#maxPasswordBytes = maxPasswordBytes;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
snapshot(): PasswordPromptSnapshot {
|
|
109
|
+
const shared = {
|
|
110
|
+
mode: this.#mode,
|
|
111
|
+
activeField: this.#activeField,
|
|
112
|
+
passwordMask: mask(this.#password),
|
|
113
|
+
message: this.#message,
|
|
114
|
+
closed: this.#closed,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return Object.freeze(
|
|
118
|
+
this.#mode === "checkpoint"
|
|
119
|
+
? { ...shared, confirmationMask: mask(this.#confirmation) }
|
|
120
|
+
: shared,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Add keyboard or pasted text exactly as supplied; no normalization/filtering. */
|
|
125
|
+
insert(text: string): void {
|
|
126
|
+
if (this.#closed || text.length === 0) return;
|
|
127
|
+
const isConfirmation = this.#mode === "checkpoint" && this.#activeField === "confirmation";
|
|
128
|
+
const currentValue = isConfirmation ? this.#confirmation : this.#password;
|
|
129
|
+
const currentByteLength = utf8ByteLengthWithin(currentValue, this.#maxPasswordBytes);
|
|
130
|
+
const additionalByteLength =
|
|
131
|
+
currentByteLength === undefined
|
|
132
|
+
? undefined
|
|
133
|
+
: utf8ByteLengthWithin(text, this.#maxPasswordBytes - currentByteLength);
|
|
134
|
+
|
|
135
|
+
if (additionalByteLength === undefined) {
|
|
136
|
+
this.#message = PASSWORD_LIMIT_MESSAGE;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
this.#message = undefined;
|
|
141
|
+
if (isConfirmation) {
|
|
142
|
+
this.#confirmation += text;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
this.#password += text;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Decode pasted bytes as strict UTF-8 without stripping a leading BOM.
|
|
150
|
+
* Invalid byte sequences fail closed and leave the current field unchanged.
|
|
151
|
+
*/
|
|
152
|
+
insertPasteBytes(bytes: Uint8Array): void {
|
|
153
|
+
if (this.#closed || bytes.length === 0) return;
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
157
|
+
this.insert(text);
|
|
158
|
+
} catch {
|
|
159
|
+
this.#message = INVALID_PASTE_MESSAGE;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Delete one Unicode code point from the currently active field. */
|
|
164
|
+
backspace(): void {
|
|
165
|
+
if (this.#closed) return;
|
|
166
|
+
this.#message = undefined;
|
|
167
|
+
if (this.#mode === "checkpoint" && this.#activeField === "confirmation") {
|
|
168
|
+
this.#confirmation = Array.from(this.#confirmation).slice(0, -1).join("");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
this.#password = Array.from(this.#password).slice(0, -1).join("");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
clearActive(): void {
|
|
175
|
+
if (this.#closed) return;
|
|
176
|
+
this.#message = undefined;
|
|
177
|
+
if (this.#mode === "checkpoint" && this.#activeField === "confirmation") {
|
|
178
|
+
this.#confirmation = "";
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
this.#password = "";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Move between the checkpoint password and confirmation fields. */
|
|
185
|
+
switchField(): void {
|
|
186
|
+
if (this.#closed || this.#mode !== "checkpoint") return;
|
|
187
|
+
this.#activeField = this.#activeField === "password" ? "confirmation" : "password";
|
|
188
|
+
this.#message = undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
submit(): PasswordSubmission {
|
|
192
|
+
if (this.#closed) return { status: "closed" };
|
|
193
|
+
|
|
194
|
+
if (this.#mode === "checkpoint" && this.#activeField === "password") {
|
|
195
|
+
if (!isWellFormedUnicode(this.#password)) {
|
|
196
|
+
this.#message = INVALID_ENCODING_MESSAGE;
|
|
197
|
+
return { status: "invalid-encoding" };
|
|
198
|
+
}
|
|
199
|
+
this.#activeField = "confirmation";
|
|
200
|
+
this.#message = undefined;
|
|
201
|
+
return { status: "confirmation-required" };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (
|
|
205
|
+
!isWellFormedUnicode(this.#password) ||
|
|
206
|
+
(this.#mode === "checkpoint" && !isWellFormedUnicode(this.#confirmation))
|
|
207
|
+
) {
|
|
208
|
+
this.#message = INVALID_ENCODING_MESSAGE;
|
|
209
|
+
return { status: "invalid-encoding" };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (this.#mode === "checkpoint" && this.#password !== this.#confirmation) {
|
|
213
|
+
this.#password = "";
|
|
214
|
+
this.#confirmation = "";
|
|
215
|
+
this.#activeField = "password";
|
|
216
|
+
this.#message = MISMATCH_MESSAGE;
|
|
217
|
+
return { status: "mismatch" };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// TextEncoder provides the required exact UTF-8 encoding without any
|
|
221
|
+
// Unicode normalization. Clear owned string references before returning.
|
|
222
|
+
const password = new TextEncoder().encode(this.#password);
|
|
223
|
+
this.dispose();
|
|
224
|
+
return { status: "accepted", password };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Drop the owned secret references on cancel, completion, or unmount. */
|
|
228
|
+
dispose(): void {
|
|
229
|
+
this.#password = "";
|
|
230
|
+
this.#confirmation = "";
|
|
231
|
+
this.#message = undefined;
|
|
232
|
+
this.#closed = true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface PasswordDialogPort<Rendered> {
|
|
237
|
+
show(render: () => Rendered, onClose?: () => void): void;
|
|
238
|
+
clear(): void;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Small dialog lifecycle seam so cancellation/cleanup can be tested without
|
|
243
|
+
* loading the OpenTUI runtime. The real UI supplies the public dialog API.
|
|
244
|
+
*/
|
|
245
|
+
export function showPasswordPrompt<Rendered>(
|
|
246
|
+
dialog: PasswordDialogPort<Rendered>,
|
|
247
|
+
mode: PasswordMode,
|
|
248
|
+
render: (
|
|
249
|
+
prompt: PasswordPromptState,
|
|
250
|
+
accept: (password: Uint8Array) => void,
|
|
251
|
+
cancel: () => void,
|
|
252
|
+
) => Rendered,
|
|
253
|
+
maxPasswordBytes = 4096,
|
|
254
|
+
): Promise<Uint8Array | undefined> {
|
|
255
|
+
return new Promise((resolve, reject) => {
|
|
256
|
+
const prompt = new PasswordPromptState(mode, maxPasswordBytes);
|
|
257
|
+
let settled = false;
|
|
258
|
+
|
|
259
|
+
const settle = (password: Uint8Array | undefined): void => {
|
|
260
|
+
if (settled) return;
|
|
261
|
+
settled = true;
|
|
262
|
+
prompt.dispose();
|
|
263
|
+
try {
|
|
264
|
+
dialog.clear();
|
|
265
|
+
} finally {
|
|
266
|
+
resolve(password);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const onClose = (): void => {
|
|
271
|
+
if (settled) return;
|
|
272
|
+
settled = true;
|
|
273
|
+
prompt.dispose();
|
|
274
|
+
resolve(undefined);
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
dialog.show(
|
|
279
|
+
() => render(prompt, (password) => settle(password), () => settle(undefined)),
|
|
280
|
+
onClose,
|
|
281
|
+
);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
settled = true;
|
|
284
|
+
prompt.dispose();
|
|
285
|
+
reject(error);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin/tui";
|
|
2
|
+
|
|
3
|
+
export interface RestoreProjectChoice {
|
|
4
|
+
readonly projectID: string;
|
|
5
|
+
/** Canonical host-project directory; this is also the explicit import target. */
|
|
6
|
+
readonly targetDirectory: string;
|
|
7
|
+
readonly label: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type RestoreProjectDialog = Pick<Plugin.Context["ui"]["dialog"], "select">;
|
|
11
|
+
|
|
12
|
+
function assertResolvedChoices(choices: readonly RestoreProjectChoice[]): void {
|
|
13
|
+
const directoryByProjectID = new Map<string, string>();
|
|
14
|
+
const projectIDByDirectory = new Map<string, string>();
|
|
15
|
+
|
|
16
|
+
for (const choice of choices) {
|
|
17
|
+
if (!choice.projectID.trim() || !choice.targetDirectory.trim()) {
|
|
18
|
+
throw new TypeError(
|
|
19
|
+
"Restore choices require both a project ID and canonical target directory.",
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const knownDirectory = directoryByProjectID.get(choice.projectID);
|
|
24
|
+
if (knownDirectory !== undefined && knownDirectory !== choice.targetDirectory) {
|
|
25
|
+
throw new Error("Restore choices contain an ambiguous project ID and target directory pair.");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const knownProjectID = projectIDByDirectory.get(choice.targetDirectory);
|
|
29
|
+
if (knownProjectID !== undefined && knownProjectID !== choice.projectID) {
|
|
30
|
+
throw new Error("Restore choices contain an ambiguous project ID and target directory pair.");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
directoryByProjectID.set(choice.projectID, choice.targetDirectory);
|
|
34
|
+
projectIDByDirectory.set(choice.targetDirectory, choice.projectID);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Present already-resolved host projects for selection. This helper does not
|
|
40
|
+
* enumerate projects or derive a directory; callers must provide each
|
|
41
|
+
* project's canonical target directory alongside its project ID. Empty input
|
|
42
|
+
* stays in the host selector so the user sees an actionable empty state; the
|
|
43
|
+
* host's undefined result continues to mean that selection was cancelled.
|
|
44
|
+
*/
|
|
45
|
+
export function selectRestoreProject(
|
|
46
|
+
dialog: RestoreProjectDialog,
|
|
47
|
+
choices: readonly RestoreProjectChoice[],
|
|
48
|
+
): Promise<RestoreProjectChoice | undefined> {
|
|
49
|
+
assertResolvedChoices(choices);
|
|
50
|
+
const empty = choices.length === 0;
|
|
51
|
+
return dialog.select<RestoreProjectChoice>({
|
|
52
|
+
title: empty
|
|
53
|
+
? "No projects available to restore into"
|
|
54
|
+
: "Choose an existing project to restore into",
|
|
55
|
+
placeholder: empty ? "Open or add a project, then retry /restore" : "Select a project",
|
|
56
|
+
options: choices.map((choice) => ({
|
|
57
|
+
title: choice.label.trim() || choice.targetDirectory,
|
|
58
|
+
description: choice.targetDirectory,
|
|
59
|
+
value: choice,
|
|
60
|
+
})),
|
|
61
|
+
});
|
|
62
|
+
}
|