@goodea/echolet 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/dist/cli.js +39712 -0
- package/dist/cli.red-24474.js +37801 -0
- package/dist/tui.js +1478 -0
- package/package.json +46 -0
- package/web-dist/client/assets/concepts/amber_terminal_ui_1789858657697.jpg +0 -0
- package/web-dist/client/assets/concepts/avionics_cockpit_ui_1789858538190.jpg +0 -0
- package/web-dist/client/assets/concepts/bathyscaphe_sonar_ui_1789858725632.jpg +0 -0
- package/web-dist/client/assets/concepts/polar_expedition_ui_1789858484680.jpg +0 -0
- package/web-dist/client/assets/concepts/spy_briefcase_ui_1789858596025.jpg +0 -0
- package/web-dist/client/assets/skins/audiophile-hifi/chassis.jpg +0 -0
- package/web-dist/client/assets/skins/military-r250/chassis.jpg +0 -0
- package/web-dist/client/assets/skins/nixie-tube/chassis.jpg +0 -0
- package/web-dist/client/assets/skins/nordic-op1/chassis.png +0 -0
- package/web-dist/client/assets/skins/oscilloscope-crt/chassis.jpg +0 -0
- package/web-dist/client/assets/skins/vintage-radiola/chassis.jpg +0 -0
- package/web-dist/client/audiophile_tube_ui_1789836391373.jpg +0 -0
- package/web-dist/client/bundle.js +66 -0
- package/web-dist/client/cyberdeck_spectrum_ui_1789835241900.jpg +0 -0
- package/web-dist/client/echolet_obsidian_clean_1789834739350.jpg +0 -0
- package/web-dist/client/echolet_split_horizon_1789834765974.jpg +0 -0
- package/web-dist/client/echolet_tactical_terminal_1789834714340.jpg +0 -0
- package/web-dist/client/gallery.html +541 -0
- package/web-dist/client/glassmorphic_radio_ui_1789835283668.jpg +0 -0
- package/web-dist/client/index.html +17 -0
- package/web-dist/client/nixie_tube_messenger_1789836184230.jpg +0 -0
- package/web-dist/client/op1_minimal_skin_1789836795427.jpg +0 -0
- package/web-dist/client/soviet_tube_radio_1789836251332.jpg +0 -0
- package/web-dist/client/stealth_zerotrace_ui_1789835261306.jpg +0 -0
- package/web-dist/client/steampunk_brass_ui_1789836339337.jpg +0 -0
- package/web-dist/client/styles.css +3725 -0
- package/web-dist/client/tube_oscilloscope_ui_1789836294852.jpg +0 -0
- package/web-dist/client/vintage_radiola_ui_1789836216660.jpg +0 -0
- package/web-dist/server.js +460 -0
package/dist/tui.js
ADDED
|
@@ -0,0 +1,1478 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
|
|
5
|
+
// src/tui/main.ts
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { dirname, resolve } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
// src/tui/cli-bridge.ts
|
|
11
|
+
var UNREADABLE_CLI_OUTPUT = "UNREADABLE_CLI_OUTPUT";
|
|
12
|
+
function buildArgv(request) {
|
|
13
|
+
switch (request.command) {
|
|
14
|
+
case "init":
|
|
15
|
+
return ["init", "--relay-url", request.relayUrl, "--store-key-env", request.storeKeyEnv, "--profile", request.profileDir, "--json"];
|
|
16
|
+
case "contact export":
|
|
17
|
+
return ["contact", "export", "--out", request.out, "--profile", request.profileDir, "--json"];
|
|
18
|
+
case "contact import":
|
|
19
|
+
return ["contact", "import", "--from", request.from, "--profile", request.profileDir, "--json"];
|
|
20
|
+
case "relay publish":
|
|
21
|
+
return ["relay", "publish", "--profile", request.profileDir, "--json"];
|
|
22
|
+
case "send":
|
|
23
|
+
return [
|
|
24
|
+
"send",
|
|
25
|
+
"--to",
|
|
26
|
+
request.to,
|
|
27
|
+
...request.messageId === void 0 ? [] : ["--message-id", request.messageId],
|
|
28
|
+
"--profile",
|
|
29
|
+
request.profileDir,
|
|
30
|
+
"--json"
|
|
31
|
+
];
|
|
32
|
+
case "poll":
|
|
33
|
+
return ["poll", "--profile", request.profileDir, "--json"];
|
|
34
|
+
case "history":
|
|
35
|
+
return ["history", "--with", request.contactIdentityId, "--profile", request.profileDir, "--json"];
|
|
36
|
+
case "doctor":
|
|
37
|
+
return ["doctor", "--profile", request.profileDir, "--json"];
|
|
38
|
+
default: {
|
|
39
|
+
const refused = request.command;
|
|
40
|
+
throw new Error(`refused: ${String(refused)} is outside the frozen eight-command CLI surface`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function parseCliOutcome(stdout, exitCode) {
|
|
45
|
+
const unreadable = { ok: false, code: UNREADABLE_CLI_OUTPUT, exitCode, data: null };
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(stdout);
|
|
49
|
+
} catch {
|
|
50
|
+
return unreadable;
|
|
51
|
+
}
|
|
52
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return unreadable;
|
|
53
|
+
const envelope = parsed;
|
|
54
|
+
if (envelope.ok === true) return { ok: true, code: "ok", exitCode, data: envelope.data ?? null };
|
|
55
|
+
const error = typeof envelope.error === "object" && envelope.error !== null ? envelope.error : {};
|
|
56
|
+
const code = typeof error.code === "string" && error.code.length > 0 ? error.code : UNREADABLE_CLI_OUTPUT;
|
|
57
|
+
return { ok: false, code, exitCode, data: envelope.data ?? null };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/limits.ts
|
|
61
|
+
var MAX_PLAINTEXT_BYTES = 65536;
|
|
62
|
+
|
|
63
|
+
// src/tui/state.ts
|
|
64
|
+
var PANE_IDS = ["profiles", "mailbox", "history", "rejections", "health"];
|
|
65
|
+
var SETUP_STEPS = 6;
|
|
66
|
+
var PENDING_SETUP = ["pending", "pending", "pending", "pending", "pending", "pending"];
|
|
67
|
+
var PATH_MAX_BYTES = 1024;
|
|
68
|
+
var NAME_MAX_BYTES = 128;
|
|
69
|
+
function inputMaxBytes(field) {
|
|
70
|
+
switch (field) {
|
|
71
|
+
case "message":
|
|
72
|
+
return MAX_PLAINTEXT_BYTES;
|
|
73
|
+
case "contact-name":
|
|
74
|
+
case "store-key-env":
|
|
75
|
+
return NAME_MAX_BYTES;
|
|
76
|
+
case "relay-url":
|
|
77
|
+
case "profile-dir":
|
|
78
|
+
case "export-path":
|
|
79
|
+
case "card-path":
|
|
80
|
+
default:
|
|
81
|
+
return PATH_MAX_BYTES;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function setupIsComplete(profile) {
|
|
85
|
+
const setup = profile.setup;
|
|
86
|
+
if (setup === void 0 || profile.storeKeyPresent !== true) return false;
|
|
87
|
+
for (let step = 1; step < SETUP_STEPS; step += 1) if (setup[step] !== "ok") return false;
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
function profileIsInSetup(profile) {
|
|
91
|
+
return profile.setup !== void 0 && !setupIsComplete(profile);
|
|
92
|
+
}
|
|
93
|
+
function nextSetupStep(profile) {
|
|
94
|
+
const setup = profile.setup;
|
|
95
|
+
if (setup === void 0 || profile.storeKeyPresent !== true) return void 0;
|
|
96
|
+
for (let step = 1; step < SETUP_STEPS; step += 1) if (setup[step] !== "ok") return step;
|
|
97
|
+
return void 0;
|
|
98
|
+
}
|
|
99
|
+
function createInitialState(input) {
|
|
100
|
+
const profiles = [...input.profiles];
|
|
101
|
+
const first = profiles[0];
|
|
102
|
+
return {
|
|
103
|
+
profiles,
|
|
104
|
+
activeProfile: 0,
|
|
105
|
+
contacts: [],
|
|
106
|
+
selectedContactId: null,
|
|
107
|
+
mailbox: { outboxPending: null, inboxReceived: null, more: false, lastPolledAtMs: null },
|
|
108
|
+
rejections: [],
|
|
109
|
+
history: [],
|
|
110
|
+
health: { relayUrl: first?.relayUrl ?? "", status: "unknown", uptimeMs: null, checkedAtMs: null },
|
|
111
|
+
pane: "profiles",
|
|
112
|
+
modal: void 0,
|
|
113
|
+
activity: [],
|
|
114
|
+
busy: false
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// src/tui/text.ts
|
|
119
|
+
function codePoints(text) {
|
|
120
|
+
return [...text];
|
|
121
|
+
}
|
|
122
|
+
function utf8Bytes(text) {
|
|
123
|
+
return Buffer.byteLength(text, "utf8");
|
|
124
|
+
}
|
|
125
|
+
function clipLine(text, width) {
|
|
126
|
+
const limit = Math.max(0, Math.floor(width));
|
|
127
|
+
const points = codePoints(text);
|
|
128
|
+
return points.length <= limit ? text : points.slice(0, limit).join("");
|
|
129
|
+
}
|
|
130
|
+
function padOrClip(text, width) {
|
|
131
|
+
const limit = Math.max(0, Math.floor(width));
|
|
132
|
+
const points = codePoints(text);
|
|
133
|
+
if (points.length >= limit) return points.slice(0, limit).join("");
|
|
134
|
+
return text + " ".repeat(limit - points.length);
|
|
135
|
+
}
|
|
136
|
+
function labelled(label, value, gutter = 16) {
|
|
137
|
+
return `${padOrClip(label, gutter)}${value}`;
|
|
138
|
+
}
|
|
139
|
+
function steersTheDisplay(point) {
|
|
140
|
+
const code = point.codePointAt(0) ?? 0;
|
|
141
|
+
if (code <= 31 || code === 127 || code >= 128 && code <= 159) return true;
|
|
142
|
+
if (code === 8206 || code === 8207) return true;
|
|
143
|
+
if (code >= 8234 && code <= 8238) return true;
|
|
144
|
+
if (code >= 8294 && code <= 8297) return true;
|
|
145
|
+
return code === 8232 || code === 8233 || code === 65279;
|
|
146
|
+
}
|
|
147
|
+
function paintable(text) {
|
|
148
|
+
return [...text].filter((point) => !steersTheDisplay(point)).join("");
|
|
149
|
+
}
|
|
150
|
+
function formatInstant(atMs) {
|
|
151
|
+
if (atMs === null || !Number.isFinite(atMs)) return "never";
|
|
152
|
+
return `${new Date(atMs).toISOString().slice(0, 19).replace("T", " ")}Z`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// src/tui/failure-text.ts
|
|
156
|
+
var CLASSES = [2, 3, 4, 5];
|
|
157
|
+
var isClass = (exitCode) => CLASSES.some((candidate) => candidate === exitCode);
|
|
158
|
+
var fill = (template, code) => template.split("{code}").join(code);
|
|
159
|
+
var ENTRIES = {
|
|
160
|
+
// ── 2 — input and configuration: the command was wrong, not the world ─────────────────────
|
|
161
|
+
"INVALID_ARGUMENTS@2": {
|
|
162
|
+
sentence: "{code} \u2014 the console built this command wrongly. Nothing was read or written.",
|
|
163
|
+
action: "Report it \u2014 no keystroke here should be able to produce it."
|
|
164
|
+
},
|
|
165
|
+
"INVALID_CONFIGURATION@2": {
|
|
166
|
+
sentence: "{code} \u2014 one code for six causes; the console cannot say which.",
|
|
167
|
+
action: "Check steps 0 and 1 on the profiles pane; the rest are on disk."
|
|
168
|
+
},
|
|
169
|
+
"INVALID_CONTACT_CARD@2": {
|
|
170
|
+
sentence: "{code} \u2014 that file is not a contact card the CLI can read, or it is too big.",
|
|
171
|
+
action: "Check the path, and that the file is the card they sent."
|
|
172
|
+
},
|
|
173
|
+
"INVALID_MESSAGE_ID@2": {
|
|
174
|
+
sentence: "{code} \u2014 the message id used here is not a UUID.",
|
|
175
|
+
action: "Report it: the console names no id of its own, so the CLI minted this one."
|
|
176
|
+
},
|
|
177
|
+
"INVALID_MESSAGE@2": {
|
|
178
|
+
sentence: "{code} \u2014 the body is longer than the CLI accepts; nothing was encrypted.",
|
|
179
|
+
action: "Shorten it; the compose row counts bytes once you are past half."
|
|
180
|
+
},
|
|
181
|
+
// ── 3 — trust and protocol: never flattened, because the CLI took trouble not to ──────────
|
|
182
|
+
"TRUST_REJECTED@3": {
|
|
183
|
+
sentence: "{code} \u2014 refused on trust grounds; no pin and no session changed.",
|
|
184
|
+
action: "Check the contact is pinned and the card you imported is the one they sent."
|
|
185
|
+
},
|
|
186
|
+
"CONTACT_NOT_CONFIRMED@3": {
|
|
187
|
+
sentence: "{code} \u2014 the import was answered with no, so nothing was pinned.",
|
|
188
|
+
action: "Press i again and answer y once all four identifiers match."
|
|
189
|
+
},
|
|
190
|
+
"INVALID_CONTACT_CARD@3": {
|
|
191
|
+
sentence: "{code} \u2014 the card read cleanly but failed validation; it may have expired.",
|
|
192
|
+
action: "Ask them to run contact export again and send you the new card."
|
|
193
|
+
},
|
|
194
|
+
"PROTOCOL_REJECTED@3": {
|
|
195
|
+
sentence: "{code} \u2014 the relay refused this, and will refuse the same request again.",
|
|
196
|
+
action: "Check both sides run the same build against the same relay."
|
|
197
|
+
},
|
|
198
|
+
"PREKEY_BUNDLE_UNAVAILABLE@3": {
|
|
199
|
+
sentence: "{code} \u2014 their side has no claimable prekey bundle. Not a trust failure.",
|
|
200
|
+
action: "Ask them to run relay publish, then try again."
|
|
201
|
+
},
|
|
202
|
+
"UNAUTHORIZED_MAILBOX_ACCESS@3": {
|
|
203
|
+
sentence: "{code} \u2014 the relay would not accept this profile as the sender.",
|
|
204
|
+
action: "Run step 2 (r) to publish this profile, then try again."
|
|
205
|
+
},
|
|
206
|
+
"SENDER_QUOTA_EXCEEDED@3": {
|
|
207
|
+
sentence: "{code} \u2014 your unacked allowance in their mailbox is full. Temporary.",
|
|
208
|
+
action: "It clears when they poll, or when the envelopes expire."
|
|
209
|
+
},
|
|
210
|
+
"CONTACT_NOT_TRUSTED@3": {
|
|
211
|
+
sentence: "{code} \u2014 that correspondent is not pinned in this profile.",
|
|
212
|
+
action: "Import their card first: profiles pane, i."
|
|
213
|
+
},
|
|
214
|
+
"CONTACT_PIN_MISMATCH@3": {
|
|
215
|
+
sentence: "{code} \u2014 the bundle the relay served is not the one you pinned. Stop.",
|
|
216
|
+
action: "Do not re-import a card sent the same way; check out of band."
|
|
217
|
+
},
|
|
218
|
+
"PREKEY_BUNDLE_EXPIRED@3": {
|
|
219
|
+
sentence: "{code} \u2014 the bundle pinned for them has expired.",
|
|
220
|
+
action: "Ask them to run relay publish, then try again."
|
|
221
|
+
},
|
|
222
|
+
"MESSAGE_ID_CONFLICT@3": {
|
|
223
|
+
sentence: "{code} \u2014 that message id was already used for different text.",
|
|
224
|
+
action: "Send again; each send takes a fresh id from the CLI."
|
|
225
|
+
},
|
|
226
|
+
"OUTBOUND_REJECTED@3": {
|
|
227
|
+
sentence: "{code} \u2014 the relay refused the envelope; nothing was delivered.",
|
|
228
|
+
action: "Check they are still published, then send again."
|
|
229
|
+
},
|
|
230
|
+
"SENDER_NOT_PUBLISHED@3": {
|
|
231
|
+
sentence: "{code} \u2014 this profile has never published, so this was refused locally.",
|
|
232
|
+
action: "Run step 2 (r) to publish, then send again."
|
|
233
|
+
},
|
|
234
|
+
"INBOUND_REJECTED@3": {
|
|
235
|
+
sentence: "{code} \u2014 the relay refused this profile's authentication; the poll stopped.",
|
|
236
|
+
action: "Check this profile is published to that relay, then poll again."
|
|
237
|
+
},
|
|
238
|
+
"CHALLENGE_EXPIRED@3": {
|
|
239
|
+
sentence: "{code} \u2014 the relay's challenge expired before the poll could use it.",
|
|
240
|
+
action: "Poll again; a slow or loaded relay is the usual cause."
|
|
241
|
+
},
|
|
242
|
+
"PROFILE_REJECTED@3": {
|
|
243
|
+
sentence: "{code} \u2014 the relay refused this profile's own record.",
|
|
244
|
+
action: "Re-run steps 1 and 2, and check the relay URL on the profiles pane."
|
|
245
|
+
},
|
|
246
|
+
// ── 4 — relay and network: the one class where retrying is the answer ─────────────────────
|
|
247
|
+
"RELAY_UNAVAILABLE@4": {
|
|
248
|
+
sentence: "{code} \u2014 the relay did not answer, so nothing was committed anywhere.",
|
|
249
|
+
action: "Check it is up at the URL on the profiles pane; retrying is safe."
|
|
250
|
+
},
|
|
251
|
+
// ── 5 — persistence: the one class where retrying is not ──────────────────────────────────
|
|
252
|
+
"PERSISTENCE_FAILURE@5": {
|
|
253
|
+
sentence: "{code} \u2014 the encrypted store could not be read or written.",
|
|
254
|
+
action: "Do not retry blindly: check the profile directory and its store-key variable."
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
var BY_COMMAND = {
|
|
258
|
+
"PERSISTENCE_FAILURE@5": {
|
|
259
|
+
"contact export": {
|
|
260
|
+
sentence: "{code} \u2014 one cause is that the path already exists; export never overwrites.",
|
|
261
|
+
action: "Export to a new path; a real storage failure looks the same."
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
var GENERIC = {
|
|
266
|
+
2: {
|
|
267
|
+
sentence: "{code} \u2014 the CLI refused the command as it was built, not the state of the world.",
|
|
268
|
+
action: "Correct the operand this command used and run it again."
|
|
269
|
+
},
|
|
270
|
+
3: {
|
|
271
|
+
sentence: "{code} \u2014 a trust or protocol refusal; no pin, session or trust state changed.",
|
|
272
|
+
action: "Read the code before retrying: repeated unchanged, it will be refused again."
|
|
273
|
+
},
|
|
274
|
+
4: {
|
|
275
|
+
sentence: "{code} \u2014 a relay or network failure; nothing was committed anywhere.",
|
|
276
|
+
action: "Check the relay at the URL on the profiles pane; retrying is safe."
|
|
277
|
+
},
|
|
278
|
+
5: {
|
|
279
|
+
sentence: "{code} \u2014 a persistence failure; the encrypted store could not be read or written.",
|
|
280
|
+
action: "Do not retry blindly \u2014 the store may be locked, or opened with a different key."
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
var UNCLASSED = {
|
|
284
|
+
sentence: "{code} \u2014 the CLI exited with a status this console has no class for.",
|
|
285
|
+
action: "Quote the code and the status exactly if you report this."
|
|
286
|
+
};
|
|
287
|
+
var UNREADABLE = {
|
|
288
|
+
sentence: "{code} \u2014 the CLI printed nothing the console could read, so it knows nothing more.",
|
|
289
|
+
action: "Run the same command in a terminal and read its output there."
|
|
290
|
+
};
|
|
291
|
+
function wordsFor(command, code, exitCode) {
|
|
292
|
+
if (code === UNREADABLE_CLI_OUTPUT) return UNREADABLE;
|
|
293
|
+
const key = `${code}@${String(exitCode)}`;
|
|
294
|
+
const override = BY_COMMAND[key]?.[command];
|
|
295
|
+
if (override !== void 0) return override;
|
|
296
|
+
const entry = ENTRIES[key];
|
|
297
|
+
if (entry !== void 0) return entry;
|
|
298
|
+
return isClass(exitCode) ? GENERIC[exitCode] : UNCLASSED;
|
|
299
|
+
}
|
|
300
|
+
function explain(command, code, exitCode) {
|
|
301
|
+
const words = wordsFor(command, code, exitCode);
|
|
302
|
+
const shown = paintable(code);
|
|
303
|
+
return {
|
|
304
|
+
command,
|
|
305
|
+
code,
|
|
306
|
+
exitCode,
|
|
307
|
+
sentence: fill(words.sentence, shown),
|
|
308
|
+
action: fill(words.action, shown)
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// src/tui/modal-host.ts
|
|
313
|
+
var TRUST_IDENTIFIER_FIELDS = [
|
|
314
|
+
"identity_id",
|
|
315
|
+
"device_id",
|
|
316
|
+
"device_pubkey",
|
|
317
|
+
"signal_identity_key"
|
|
318
|
+
];
|
|
319
|
+
var TRUST_MODAL_FOOTER = [
|
|
320
|
+
{ key: "y", label: "trust" },
|
|
321
|
+
{ key: "n", label: "reject" },
|
|
322
|
+
{ key: "esc", label: "cancel" }
|
|
323
|
+
];
|
|
324
|
+
var MODAL_PANEL_CHROME_X = 4;
|
|
325
|
+
var MODAL_PANEL_MIN_WIDTH = 56;
|
|
326
|
+
var MODAL_PANEL_MIN_HEIGHT = 14;
|
|
327
|
+
var MODAL_CHROME_ROWS = 4;
|
|
328
|
+
var MODAL_PANEL_MAX_WIDTH = 80;
|
|
329
|
+
var MODAL_PANEL_MAX_HEIGHT = 20;
|
|
330
|
+
var MODAL_PANEL_MARGIN = 4;
|
|
331
|
+
function resolveModalPanelSize(cols, rows) {
|
|
332
|
+
const available = (span, min, max) => Math.max(min, Math.min(Math.max(0, Math.floor(span) - MODAL_PANEL_MARGIN), max));
|
|
333
|
+
return {
|
|
334
|
+
width: available(cols, MODAL_PANEL_MIN_WIDTH, MODAL_PANEL_MAX_WIDTH),
|
|
335
|
+
height: available(rows, MODAL_PANEL_MIN_HEIGHT, MODAL_PANEL_MAX_HEIGHT)
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
function modalBodyRows(panelHeight) {
|
|
339
|
+
return Math.max(1, Math.floor(panelHeight) - MODAL_CHROME_ROWS);
|
|
340
|
+
}
|
|
341
|
+
function resolveModalInnerWidth(availableWidth) {
|
|
342
|
+
return Math.max(1, Math.floor(availableWidth) - MODAL_PANEL_CHROME_X);
|
|
343
|
+
}
|
|
344
|
+
function formatModalFooter(actions) {
|
|
345
|
+
return actions.map((action) => `[${action.key}] ${action.label}`).join(" ");
|
|
346
|
+
}
|
|
347
|
+
function trustModalIsComplete(modal) {
|
|
348
|
+
return TRUST_IDENTIFIER_FIELDS.every((field) => {
|
|
349
|
+
const value = modal.identifiers[field];
|
|
350
|
+
return typeof value === "string" && value.length > 0;
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
function renderModal(modal, viewport) {
|
|
354
|
+
const size = resolveModalPanelSize(viewport.cols, viewport.rows);
|
|
355
|
+
const inner = resolveModalInnerWidth(size.width);
|
|
356
|
+
const rule = "\u2500".repeat(Math.max(0, size.width - 2));
|
|
357
|
+
const body = [`profile: ${modal.profileLabel}`, `card: ${modal.cardPath}`];
|
|
358
|
+
for (const field of TRUST_IDENTIFIER_FIELDS) {
|
|
359
|
+
const value = modal.identifiers[field];
|
|
360
|
+
body.push(`${field}:`);
|
|
361
|
+
body.push(typeof value === "string" && value.length > 0 ? ` ${value}` : " (missing)");
|
|
362
|
+
}
|
|
363
|
+
const bodyRows2 = modalBodyRows(size.height);
|
|
364
|
+
const painted = body.slice(0, bodyRows2);
|
|
365
|
+
while (painted.length < bodyRows2) painted.push("");
|
|
366
|
+
const boxed = (text) => `\u2502 ${padOrClip(clipLine(text, inner), inner)} \u2502`;
|
|
367
|
+
const lines = [
|
|
368
|
+
`\u256D${rule}\u256E`,
|
|
369
|
+
boxed("Trust these contact identifiers?"),
|
|
370
|
+
...painted.map(boxed),
|
|
371
|
+
boxed(formatModalFooter(TRUST_MODAL_FOOTER)),
|
|
372
|
+
`\u2570${rule}\u256F`
|
|
373
|
+
];
|
|
374
|
+
while (lines.length < size.height) lines.push(boxed(""));
|
|
375
|
+
return lines.slice(0, size.height).map((line) => padOrClip(line, size.width));
|
|
376
|
+
}
|
|
377
|
+
function modalIntent(key) {
|
|
378
|
+
if (key.ctrl) return void 0;
|
|
379
|
+
switch (key.name) {
|
|
380
|
+
case "y":
|
|
381
|
+
return "trust-confirm";
|
|
382
|
+
case "n":
|
|
383
|
+
return "trust-cancel";
|
|
384
|
+
case "escape":
|
|
385
|
+
return "trust-cancel";
|
|
386
|
+
default:
|
|
387
|
+
return void 0;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// src/tui/pane-fit.ts
|
|
392
|
+
function rowLines(row) {
|
|
393
|
+
return typeof row === "string" ? [row] : row;
|
|
394
|
+
}
|
|
395
|
+
function truncationMarker(hidden) {
|
|
396
|
+
return `\u2026 ${String(hidden)} more not shown`;
|
|
397
|
+
}
|
|
398
|
+
function keepItems(rows, keep, room, budget) {
|
|
399
|
+
const kept = [];
|
|
400
|
+
let used = 0;
|
|
401
|
+
if (room <= 0) return { kept, used };
|
|
402
|
+
const ordered = keep === "tail" ? [...rows].reverse() : rows;
|
|
403
|
+
for (const row of ordered) {
|
|
404
|
+
const height = rowLines(row).length;
|
|
405
|
+
if (kept.length > 0 && used + height > budget) break;
|
|
406
|
+
kept.push(row);
|
|
407
|
+
used += height;
|
|
408
|
+
if (used >= budget) break;
|
|
409
|
+
}
|
|
410
|
+
if (keep === "tail") kept.reverse();
|
|
411
|
+
return { kept, used };
|
|
412
|
+
}
|
|
413
|
+
function fitPane(regions, limit, width) {
|
|
414
|
+
const tail = regions.tail ?? [];
|
|
415
|
+
const clip = (line) => clipLine(line, width);
|
|
416
|
+
const listLines = regions.rows.flatMap((row) => [...rowLines(row)]);
|
|
417
|
+
const all = [...regions.head, ...listLines, ...tail];
|
|
418
|
+
if (!Number.isFinite(limit)) return all.map(clip);
|
|
419
|
+
const cap = Math.max(0, Math.floor(limit));
|
|
420
|
+
if (all.length <= cap) return all.map(clip);
|
|
421
|
+
if (regions.rows.length === 0) return all.slice(0, cap).map(clip);
|
|
422
|
+
const room = Math.max(0, cap - 1);
|
|
423
|
+
const wanted = regions.head.length + tail.length;
|
|
424
|
+
const { kept, used } = keepItems(regions.rows, regions.keep, room, room - wanted);
|
|
425
|
+
const fixed = Math.max(0, Math.min(wanted, room - used));
|
|
426
|
+
const headRoom = Math.min(regions.head.length, fixed);
|
|
427
|
+
const tailRoom = Math.max(0, fixed - headRoom);
|
|
428
|
+
return [
|
|
429
|
+
...regions.head.slice(0, headRoom),
|
|
430
|
+
...kept.flatMap((row) => [...rowLines(row)]),
|
|
431
|
+
truncationMarker(regions.rows.length - kept.length),
|
|
432
|
+
...tail.slice(tail.length - tailRoom)
|
|
433
|
+
].slice(0, cap).map(clip);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// src/tui/history-pane.ts
|
|
437
|
+
var BODY_INDENT = " ";
|
|
438
|
+
var MAX_BODY_ROWS = 4;
|
|
439
|
+
var ELLIPSIS = "\u2026";
|
|
440
|
+
function bodyRows(body, width) {
|
|
441
|
+
const room = Math.max(1, Math.floor(width) - BODY_INDENT.length);
|
|
442
|
+
const points = codePoints(body);
|
|
443
|
+
if (points.length === 0) return [];
|
|
444
|
+
const wrapped = [];
|
|
445
|
+
for (let at = 0; at < points.length; at += room) wrapped.push(points.slice(at, at + room).join(""));
|
|
446
|
+
if (wrapped.length <= MAX_BODY_ROWS) return wrapped.map((row) => `${BODY_INDENT}${row}`);
|
|
447
|
+
const kept = wrapped.slice(0, MAX_BODY_ROWS);
|
|
448
|
+
const last = codePoints(kept[MAX_BODY_ROWS - 1] ?? "");
|
|
449
|
+
kept[MAX_BODY_ROWS - 1] = `${last.slice(0, Math.max(0, room - 1)).join("")}${ELLIPSIS}`;
|
|
450
|
+
return kept.map((row) => `${BODY_INDENT}${row}`);
|
|
451
|
+
}
|
|
452
|
+
function buildHistorySnapshot(source) {
|
|
453
|
+
const { entries, selectedContactId } = source;
|
|
454
|
+
if (selectedContactId === null) {
|
|
455
|
+
return { contactIdentityId: null, lines: [], withheldCount: entries.length };
|
|
456
|
+
}
|
|
457
|
+
const selected = entries.filter((entry) => entry.contactIdentityId === selectedContactId);
|
|
458
|
+
const lines = selected.map((entry) => ({
|
|
459
|
+
meta: `${entry.sequence} ${entry.direction} ${entry.messageId}`,
|
|
460
|
+
body: entry.plaintext
|
|
461
|
+
}));
|
|
462
|
+
return { contactIdentityId: selectedContactId, lines, withheldCount: entries.length - selected.length };
|
|
463
|
+
}
|
|
464
|
+
function formatHistoryLines(snapshot, width, limit = Number.POSITIVE_INFINITY) {
|
|
465
|
+
const head = [];
|
|
466
|
+
const rows = [];
|
|
467
|
+
if (snapshot.contactIdentityId === null) {
|
|
468
|
+
head.push("no contact selected \u2014 history is shown only on an explicit request");
|
|
469
|
+
} else {
|
|
470
|
+
head.push(`history with ${snapshot.contactIdentityId}`);
|
|
471
|
+
if (snapshot.lines.length === 0) head.push(" no entries for this contact");
|
|
472
|
+
else for (const entry of snapshot.lines) rows.push([` ${entry.meta}`, ...bodyRows(entry.body, width)]);
|
|
473
|
+
}
|
|
474
|
+
const tail = snapshot.withheldCount > 0 ? ["", `${snapshot.withheldCount} entries withheld (other contacts)`] : [];
|
|
475
|
+
return fitPane({ head, rows, tail, keep: "tail" }, limit, width);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// src/tui/mailbox-pane.ts
|
|
479
|
+
function countedValue(count, unit, absent) {
|
|
480
|
+
return count === null ? absent : `${String(count)} ${unit}`;
|
|
481
|
+
}
|
|
482
|
+
function buildMailboxSnapshot(source) {
|
|
483
|
+
const { mailbox, rejections } = source;
|
|
484
|
+
const rows = [
|
|
485
|
+
// Two rows, two different silences, and each says which one it is.
|
|
486
|
+
//
|
|
487
|
+
// The outbox row is empty because the frozen eight-command surface has nothing that reports a
|
|
488
|
+
// pending count (see `MailboxView`), so the row names the gap rather than disappearing: an
|
|
489
|
+
// operator who cannot see that the console has no view of its own outbox will assume it has one.
|
|
490
|
+
// The inbox row is empty only until the first poll, which is a different sentence.
|
|
491
|
+
{ label: "outbox", value: countedValue(mailbox.outboxPending, "pending", "no command reports an outbox count") },
|
|
492
|
+
// "received" is what the LAST poll accepted and committed, not a session or store total, so the
|
|
493
|
+
// row says which poll it is talking about. The `last poll` row below carries when that was.
|
|
494
|
+
{ label: "inbox", value: countedValue(mailbox.inboxReceived, "received by the last poll", "no poll has reported yet") },
|
|
495
|
+
// The relay's remaining-work signal is what tells the operator to poll again; a pane that hid
|
|
496
|
+
// it would make a partially drained mailbox look empty.
|
|
497
|
+
{ label: "more", value: mailbox.more ? "yes \u2014 poll again" : "no" },
|
|
498
|
+
{ label: "last poll", value: formatInstant(mailbox.lastPolledAtMs) }
|
|
499
|
+
];
|
|
500
|
+
return { rows, rejectionLines: rejections.map(formatRejectionLine), rejectionCount: rejections.length };
|
|
501
|
+
}
|
|
502
|
+
function formatMailboxLines(snapshot, width, limit = Number.POSITIVE_INFINITY) {
|
|
503
|
+
const head = snapshot.rows.map((row) => labelled(row.label, row.value));
|
|
504
|
+
if (snapshot.rejectionCount > 0) {
|
|
505
|
+
head.push("");
|
|
506
|
+
head.push(labelled("rejected", `${snapshot.rejectionCount} permanently unacceptable`));
|
|
507
|
+
}
|
|
508
|
+
const rows = snapshot.rejectionLines.map((rejection) => ` ${rejection}`);
|
|
509
|
+
return fitPane({ head, rows, keep: "tail" }, limit, width);
|
|
510
|
+
}
|
|
511
|
+
function formatRejectionLine(rejection) {
|
|
512
|
+
return `${rejection.envelopeId} ${rejection.code}`;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// src/tui/profiles-pane.ts
|
|
516
|
+
var SETUP_TITLES = [
|
|
517
|
+
["store key", ""],
|
|
518
|
+
["create profile", "init"],
|
|
519
|
+
["publish", "relay publish"],
|
|
520
|
+
["export card", "contact export"],
|
|
521
|
+
["import card", "contact import"],
|
|
522
|
+
["confirm", "doctor"]
|
|
523
|
+
];
|
|
524
|
+
var STEP_GUTTER = " ".repeat(8);
|
|
525
|
+
var TITLE_WIDTH = 16;
|
|
526
|
+
var COMMAND_WIDTH = 16;
|
|
527
|
+
function storeKeyRecipe(storeKeyEnv) {
|
|
528
|
+
return `export ${storeKeyEnv}="$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=')"`;
|
|
529
|
+
}
|
|
530
|
+
function stepStatus(outcome) {
|
|
531
|
+
if (outcome === void 0 || outcome === "pending") return "pending";
|
|
532
|
+
if (outcome === "ok") return "ok";
|
|
533
|
+
return `${outcome.failed} (exit ${String(outcome.exitCode)})`;
|
|
534
|
+
}
|
|
535
|
+
function stepHint(step, outcome) {
|
|
536
|
+
if (outcome === void 0 || outcome === "pending" || outcome === "ok") return void 0;
|
|
537
|
+
if (step !== 3 || outcome.exitCode !== 5) return void 0;
|
|
538
|
+
return "a file at that path already exists \u2014 `contact export` never overwrites one";
|
|
539
|
+
}
|
|
540
|
+
function buildSetupLines(profile) {
|
|
541
|
+
const setup = profile.setup ?? [];
|
|
542
|
+
const lines = ["registration \u2014 [enter] starts the next step"];
|
|
543
|
+
for (let step = 0; step < SETUP_STEPS; step += 1) {
|
|
544
|
+
const [title, command] = SETUP_TITLES[step] ?? ["", ""];
|
|
545
|
+
const prefix = `step ${String(step)} ${padOrClip(title, TITLE_WIDTH)}`;
|
|
546
|
+
if (step === 0) {
|
|
547
|
+
if (profile.storeKeyPresent === true) {
|
|
548
|
+
lines.push(`${prefix}${padOrClip("", COMMAND_WIDTH)}ok`);
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
lines.push(`${prefix}${profile.storeKeyEnv} is not set in this console's environment`);
|
|
552
|
+
lines.push(`${STEP_GUTTER}${padOrClip("", TITLE_WIDTH)}run this in your shell, then restart the console:`);
|
|
553
|
+
lines.push(`${STEP_GUTTER}${padOrClip("", TITLE_WIDTH)} ${storeKeyRecipe(profile.storeKeyEnv)}`);
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
lines.push(`${prefix}${padOrClip(command, COMMAND_WIDTH)}${stepStatus(setup[step])}`);
|
|
557
|
+
const hint = stepHint(step, setup[step]);
|
|
558
|
+
if (hint !== void 0) lines.push(`${STEP_GUTTER}${padOrClip("", TITLE_WIDTH)}${hint}`);
|
|
559
|
+
}
|
|
560
|
+
return lines;
|
|
561
|
+
}
|
|
562
|
+
function formatHealthLine(health) {
|
|
563
|
+
switch (health.status) {
|
|
564
|
+
case "healthy": {
|
|
565
|
+
const uptime = health.uptimeMs === null ? "" : `uptime ${health.uptimeMs} ms, `;
|
|
566
|
+
return `relay ${health.relayUrl} healthy (${uptime}checked ${formatInstant(health.checkedAtMs)})`;
|
|
567
|
+
}
|
|
568
|
+
case "unreachable":
|
|
569
|
+
return `relay ${health.relayUrl} unreachable (checked ${formatInstant(health.checkedAtMs)})`;
|
|
570
|
+
case "unknown":
|
|
571
|
+
default:
|
|
572
|
+
return `relay ${health.relayUrl} not yet checked`;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
function buildProfilesSnapshot(source) {
|
|
576
|
+
const { profile, contacts, health } = source;
|
|
577
|
+
const rows = [
|
|
578
|
+
{ label: "profile", value: profile.label },
|
|
579
|
+
{ label: "relay", value: profile.relayUrl },
|
|
580
|
+
// The NAME of the environment variable, so two profiles can be told apart. Never its value:
|
|
581
|
+
// there is no field on `ProfileView` that could hold one. See `tui.keyMaterial.test.ts`.
|
|
582
|
+
{ label: "store-key-env", value: profile.storeKeyEnv },
|
|
583
|
+
{ label: "identity", value: profile.identityId },
|
|
584
|
+
{ label: "device", value: profile.deviceId },
|
|
585
|
+
// `send` presupposes `relay publish`; an unpublished profile is refused
|
|
586
|
+
// UNAUTHORIZED_MAILBOX_ACCESS, and the operator should see why before sending. `doctor` does
|
|
587
|
+
// not report publication state, so like the roster this is what the SESSION observed — and it
|
|
588
|
+
// says so, rather than reporting a bare "no" about a profile that may well be published.
|
|
589
|
+
{ label: "published", value: profile.published ? "yes, by this session" : "not observed by this session" },
|
|
590
|
+
{ label: "directory", value: profile.profileDir }
|
|
591
|
+
];
|
|
592
|
+
const contactLines = contacts.map((contact) => `${contact.identityId} ${contact.deviceId}`);
|
|
593
|
+
const unseen = profile.contactCount - contacts.length;
|
|
594
|
+
const rosterDiscrepancy = unseen > 0 ? `+${unseen} pinned by doctor but not seen by this session (no CLI command lists contacts)` : null;
|
|
595
|
+
return {
|
|
596
|
+
rows,
|
|
597
|
+
contactLines,
|
|
598
|
+
rosterDiscrepancy,
|
|
599
|
+
healthLine: formatHealthLine(health),
|
|
600
|
+
setupLines: profileIsInSetup(profile) ? buildSetupLines(profile) : null
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
function formatProfilesLines(snapshot, width, limit = Number.POSITIVE_INFINITY) {
|
|
604
|
+
if (snapshot.setupLines !== null) {
|
|
605
|
+
return fitPane({ head: snapshot.setupLines, rows: [], tail: ["", snapshot.healthLine], keep: "head" }, limit, width);
|
|
606
|
+
}
|
|
607
|
+
const head = snapshot.rows.map((row) => labelled(row.label, row.value));
|
|
608
|
+
head.push("");
|
|
609
|
+
head.push(labelled("contacts", `${snapshot.contactLines.length} observed this session`));
|
|
610
|
+
if (snapshot.rosterDiscrepancy !== null) head.push(snapshot.rosterDiscrepancy);
|
|
611
|
+
const rows = snapshot.contactLines.map((contact) => ` ${contact}`);
|
|
612
|
+
return fitPane({ head, rows, tail: ["", snapshot.healthLine], keep: "head" }, limit, width);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// src/tui/shell-chrome.ts
|
|
616
|
+
var UNAUDITED_NOTICE = "UNAUDITED PROTOTYPE \u2014 not suitable for sensitive communication";
|
|
617
|
+
var MIN_VIEWPORT = { cols: 72, rows: 16 };
|
|
618
|
+
function isBelowMinViewport(viewport) {
|
|
619
|
+
return Math.floor(viewport.cols) < MIN_VIEWPORT.cols || Math.floor(viewport.rows) < MIN_VIEWPORT.rows;
|
|
620
|
+
}
|
|
621
|
+
function fitLine(text, cols) {
|
|
622
|
+
return padOrClip(text, cols);
|
|
623
|
+
}
|
|
624
|
+
var FIRST_OVERLAY_ROW = 1;
|
|
625
|
+
var HEAD_ROWS = 3;
|
|
626
|
+
var TAIL_ROWS = 2;
|
|
627
|
+
function headerLine(state) {
|
|
628
|
+
const profile = state.profiles[state.activeProfile];
|
|
629
|
+
const tabs = PANE_IDS.map((pane, index) => pane === state.pane ? `[${index + 1} ${pane}]` : ` ${index + 1} ${pane} `).join("");
|
|
630
|
+
return `echolet operator \xB7 ${profile?.label ?? "no profile"} \xB7${tabs}`;
|
|
631
|
+
}
|
|
632
|
+
var FOOTER_KEYS = [
|
|
633
|
+
{ label: "[1-5] pane", rank: 3 },
|
|
634
|
+
{ label: "[p] poll", rank: 4 },
|
|
635
|
+
{ label: "[d] doctor", rank: 5 },
|
|
636
|
+
{ label: "[r] publish", rank: 6 },
|
|
637
|
+
{ label: "[h] history", rank: 7 },
|
|
638
|
+
{ label: "[i] import", rank: 8 },
|
|
639
|
+
{ label: "[c] contact", rank: 9 },
|
|
640
|
+
{ label: "[t] profile", rank: 10 },
|
|
641
|
+
{ label: "[?] help", rank: 2 },
|
|
642
|
+
{ label: "[q] quit", rank: 1 }
|
|
643
|
+
];
|
|
644
|
+
var FOOTER_KEYS_HELP_OPEN = FOOTER_KEYS.map((entry) => entry.label === "[?] help" ? { label: "[?] close", rank: entry.rank } : entry);
|
|
645
|
+
var FOOTER_SEPARATOR = " ";
|
|
646
|
+
function fitFooter(entries, cols) {
|
|
647
|
+
const byRank = entries.map((_, index) => index).sort((left, right) => (entries[left]?.rank ?? 0) - (entries[right]?.rank ?? 0));
|
|
648
|
+
const paint = (chosen2) => [...chosen2].sort((left, right) => left - right).map((index) => entries[index]?.label ?? "").join(FOOTER_SEPARATOR);
|
|
649
|
+
const chosen = [];
|
|
650
|
+
for (const index of byRank) {
|
|
651
|
+
const candidate = paint([...chosen, index]);
|
|
652
|
+
if (codePoints(candidate).length > Math.max(0, Math.floor(cols))) break;
|
|
653
|
+
chosen.push(index);
|
|
654
|
+
}
|
|
655
|
+
return paint(chosen);
|
|
656
|
+
}
|
|
657
|
+
function footerLine(state, cols) {
|
|
658
|
+
if (state.modal !== void 0) return "[y] trust [n] reject [esc] cancel [ctrl-c] quit";
|
|
659
|
+
const input = state.input;
|
|
660
|
+
if (input !== void 0) {
|
|
661
|
+
return `${submitLabel(input)}[esc] cancel [ctrl-c] quit`;
|
|
662
|
+
}
|
|
663
|
+
return fitFooter(state.help === true ? FOOTER_KEYS_HELP_OPEN : FOOTER_KEYS, cols);
|
|
664
|
+
}
|
|
665
|
+
function submitLabel(input) {
|
|
666
|
+
switch (input.field) {
|
|
667
|
+
case "message":
|
|
668
|
+
return "[enter] send ";
|
|
669
|
+
case "relay-url":
|
|
670
|
+
case "export-path":
|
|
671
|
+
case "card-path":
|
|
672
|
+
return "[enter] run ";
|
|
673
|
+
default:
|
|
674
|
+
return "";
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
function composeLine(input, cols) {
|
|
678
|
+
const label = `${input.field} \u25B8 `;
|
|
679
|
+
const used = utf8Bytes(input.buffer);
|
|
680
|
+
const counter = used * 2 > input.maxBytes ? ` ${String(used)}/${String(input.maxBytes)} bytes` : "";
|
|
681
|
+
const room = Math.max(0, Math.floor(cols) - codePoints(label).length - codePoints(counter).length);
|
|
682
|
+
const points = codePoints(input.buffer);
|
|
683
|
+
const shown = points.length <= room ? input.buffer : points.slice(points.length - room).join("");
|
|
684
|
+
return `${label}${shown}${counter}`;
|
|
685
|
+
}
|
|
686
|
+
function activityLine(state) {
|
|
687
|
+
const latest = state.activity.at(-1);
|
|
688
|
+
const text = latest?.text ?? "no activity yet";
|
|
689
|
+
return state.busy ? `running\u2026 (busy \u2014 a command key starts nothing) ${text}` : text;
|
|
690
|
+
}
|
|
691
|
+
var HELP_LINES_HEAD = [
|
|
692
|
+
"key bindings",
|
|
693
|
+
" 1-5 select pane",
|
|
694
|
+
" enter start the next registration step (profiles pane, while one is left)",
|
|
695
|
+
" p poll",
|
|
696
|
+
" d doctor",
|
|
697
|
+
" r relay publish",
|
|
698
|
+
" h history for the selected contact",
|
|
699
|
+
" w write a message (history pane, with a contact selected)",
|
|
700
|
+
" i import the contact card named at startup",
|
|
701
|
+
" c next contact",
|
|
702
|
+
" t next profile",
|
|
703
|
+
" ? close this list"
|
|
704
|
+
];
|
|
705
|
+
function helpQuitLine(state) {
|
|
706
|
+
return state.modal !== void 0 || state.input !== void 0 ? " ctrl-c quit" : " q quit (ctrl-c also quits)";
|
|
707
|
+
}
|
|
708
|
+
function helpLines(state) {
|
|
709
|
+
return [...HELP_LINES_HEAD, helpQuitLine(state)];
|
|
710
|
+
}
|
|
711
|
+
function paneLines(state, pane, width, bodyRows2) {
|
|
712
|
+
if (state.help === true) {
|
|
713
|
+
const lines = helpLines(state);
|
|
714
|
+
return fitPane({ head: [lines[0] ?? ""], rows: lines.slice(1), keep: "head" }, bodyRows2, width);
|
|
715
|
+
}
|
|
716
|
+
const profile = state.profiles[state.activeProfile];
|
|
717
|
+
switch (pane) {
|
|
718
|
+
case "profiles": {
|
|
719
|
+
if (profile === void 0) return [clipLine("no profile configured \u2014 run init first", width)];
|
|
720
|
+
return formatProfilesLines(buildProfilesSnapshot({ profile, contacts: state.contacts, health: state.health }), width, bodyRows2);
|
|
721
|
+
}
|
|
722
|
+
case "mailbox":
|
|
723
|
+
return formatMailboxLines(buildMailboxSnapshot({ mailbox: state.mailbox, rejections: state.rejections }), width, bodyRows2);
|
|
724
|
+
case "history":
|
|
725
|
+
return formatHistoryLines(buildHistorySnapshot({ entries: state.history, selectedContactId: state.selectedContactId }), width, bodyRows2);
|
|
726
|
+
case "rejections": {
|
|
727
|
+
const snapshot = buildMailboxSnapshot({ mailbox: state.mailbox, rejections: state.rejections });
|
|
728
|
+
if (snapshot.rejectionCount === 0) return [clipLine("no permanently rejected envelopes in the last poll", width)];
|
|
729
|
+
return fitPane({
|
|
730
|
+
head: [`${snapshot.rejectionCount} rejected by the last poll`],
|
|
731
|
+
rows: snapshot.rejectionLines.map((line) => ` ${line}`),
|
|
732
|
+
// The untriaged rejections are the ones that arrived last; `poll` reports them in arrival
|
|
733
|
+
// order, so the tail is what the operator has not yet decided about.
|
|
734
|
+
keep: "tail"
|
|
735
|
+
}, bodyRows2, width);
|
|
736
|
+
}
|
|
737
|
+
case "health": {
|
|
738
|
+
const snapshot = buildProfilesSnapshot({
|
|
739
|
+
profile: profile ?? { label: "-", profileDir: "-", relayUrl: state.health.relayUrl, storeKeyEnv: "-", identityId: "-", deviceId: "-", contactCount: 0, published: false },
|
|
740
|
+
contacts: state.contacts,
|
|
741
|
+
health: state.health
|
|
742
|
+
});
|
|
743
|
+
return [clipLine(snapshot.healthLine, width), "", clipLine(`status ${state.health.status}`, width)];
|
|
744
|
+
}
|
|
745
|
+
default:
|
|
746
|
+
return [];
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
function tooSmallFrame(state, cols, rows) {
|
|
750
|
+
const composing = state.input !== void 0;
|
|
751
|
+
const ctrlCOnly = state.modal !== void 0 || composing;
|
|
752
|
+
const lines = [
|
|
753
|
+
...wrapToWidth(UNAUDITED_NOTICE, cols),
|
|
754
|
+
"",
|
|
755
|
+
...wrapToWidth(`${String(cols)}x${String(rows)} \u2014 this console needs ${String(MIN_VIEWPORT.cols)}x${String(MIN_VIEWPORT.rows)}`, cols),
|
|
756
|
+
// While a buffer is open `q` is text, and while a modal is open `q` answers nothing, so naming it
|
|
757
|
+
// here would be the lie `footerLine` refuses. Ctrl-C is the binding that survives both, so it is
|
|
758
|
+
// the one this frame offers.
|
|
759
|
+
...wrapToWidth(ctrlCOnly ? "resize, or press ctrl-c to quit" : "resize, or press q to quit", cols)
|
|
760
|
+
];
|
|
761
|
+
if (state.modal !== void 0) lines.push("", ...wrapToWidth("a trust decision is waiting \u2014 resize to answer it", cols));
|
|
762
|
+
if (composing) lines.push("", ...wrapToWidth("a message is being composed \u2014 resize to see and send it", cols));
|
|
763
|
+
return lines;
|
|
764
|
+
}
|
|
765
|
+
function wrapToWidth(text, cols) {
|
|
766
|
+
const points = codePoints(text);
|
|
767
|
+
if (points.length === 0) return [""];
|
|
768
|
+
const lines = [];
|
|
769
|
+
for (let at = 0; at < points.length; at += cols) lines.push(points.slice(at, at + cols).join(""));
|
|
770
|
+
return lines;
|
|
771
|
+
}
|
|
772
|
+
function renderFrame(state, viewport) {
|
|
773
|
+
const cols = Math.max(1, Math.floor(viewport.cols));
|
|
774
|
+
const rows = Math.max(1, Math.floor(viewport.rows));
|
|
775
|
+
if (isBelowMinViewport({ cols, rows })) {
|
|
776
|
+
const degraded = tooSmallFrame(state, cols, rows).slice(0, rows).map((line) => fitLine(line, cols));
|
|
777
|
+
while (degraded.length < rows) degraded.push(fitLine("", cols));
|
|
778
|
+
return degraded;
|
|
779
|
+
}
|
|
780
|
+
const head = [UNAUDITED_NOTICE, headerLine(state), "\u2500".repeat(cols)].slice(0, Math.min(HEAD_ROWS, rows));
|
|
781
|
+
const tail = [activityLine(state), footerLine(state, cols)].slice(0, Math.max(0, Math.min(TAIL_ROWS, rows - head.length)));
|
|
782
|
+
const bodyRows2 = Math.max(0, rows - head.length - tail.length);
|
|
783
|
+
const input = state.input;
|
|
784
|
+
const composeRows = input !== void 0 && bodyRows2 > 0 ? 1 : 0;
|
|
785
|
+
const paneRows = bodyRows2 - composeRows;
|
|
786
|
+
const body = paneLines(state, state.pane, cols, paneRows).slice(0, paneRows);
|
|
787
|
+
while (body.length < paneRows) body.push("");
|
|
788
|
+
if (input !== void 0 && composeRows === 1) body.push(composeLine(input, cols));
|
|
789
|
+
const frame = [...head, ...body, ...tail].map((line) => fitLine(line, cols));
|
|
790
|
+
return state.modal === void 0 ? frame : overlayModal(frame, state, { cols, rows });
|
|
791
|
+
}
|
|
792
|
+
function overlayModal(frame, state, viewport) {
|
|
793
|
+
const modal = state.modal;
|
|
794
|
+
if (modal === void 0) return frame;
|
|
795
|
+
const size = resolveModalPanelSize(viewport.cols, viewport.rows);
|
|
796
|
+
const panel = renderModal(modal, viewport);
|
|
797
|
+
const top = Math.min(
|
|
798
|
+
Math.max(FIRST_OVERLAY_ROW, Math.floor((viewport.rows - size.height) / 2)),
|
|
799
|
+
Math.max(FIRST_OVERLAY_ROW, viewport.rows - size.height)
|
|
800
|
+
);
|
|
801
|
+
const left = Math.max(0, Math.floor((viewport.cols - size.width) / 2));
|
|
802
|
+
const painted = [...frame];
|
|
803
|
+
panel.forEach((panelLine, index) => {
|
|
804
|
+
const row = top + index;
|
|
805
|
+
const base = painted[row];
|
|
806
|
+
if (base === void 0) return;
|
|
807
|
+
const cells = codePoints(base);
|
|
808
|
+
codePoints(panelLine).forEach((cell, offset) => {
|
|
809
|
+
const column = left + offset;
|
|
810
|
+
if (column >= 0 && column < cells.length) cells[column] = cell;
|
|
811
|
+
});
|
|
812
|
+
painted[row] = cells.join("");
|
|
813
|
+
});
|
|
814
|
+
return painted;
|
|
815
|
+
}
|
|
816
|
+
var ESC = String.fromCharCode(27);
|
|
817
|
+
var BOLD = `${ESC}[1m`;
|
|
818
|
+
var DIM = `${ESC}[2m`;
|
|
819
|
+
var RESET = `${ESC}[0m`;
|
|
820
|
+
function styleFrame(frame) {
|
|
821
|
+
return frame.map((line, index) => {
|
|
822
|
+
if (line.includes(UNAUDITED_NOTICE)) return `${BOLD}${line}${RESET}`;
|
|
823
|
+
if (index === 1) return `${BOLD}${line}${RESET}`;
|
|
824
|
+
return line.startsWith("\u2500") ? `${DIM}${line}${RESET}` : line;
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// src/tui/tui-shell.ts
|
|
829
|
+
function mapKey(key, state) {
|
|
830
|
+
if (state.modal !== void 0) {
|
|
831
|
+
if (key.ctrl) return key.name.toLowerCase() === "c" ? { kind: "quit" } : void 0;
|
|
832
|
+
const intent2 = modalIntent(key);
|
|
833
|
+
if (intent2 === "trust-confirm") return { kind: "trust-confirm" };
|
|
834
|
+
if (intent2 === "trust-cancel") return { kind: "trust-cancel" };
|
|
835
|
+
return void 0;
|
|
836
|
+
}
|
|
837
|
+
if (state.input !== void 0) return inputKey(key);
|
|
838
|
+
if (key.ctrl) return key.name.toLowerCase() === "c" ? { kind: "quit" } : void 0;
|
|
839
|
+
if (key.name === "q") return { kind: "quit" };
|
|
840
|
+
if (key.name === "?") return { kind: "toggle-help" };
|
|
841
|
+
const ordinal = /^[1-9]$/.test(key.name) ? Number(key.name) : 0;
|
|
842
|
+
const pane = PANE_IDS[ordinal - 1];
|
|
843
|
+
if (pane !== void 0) return { kind: "select-pane", pane };
|
|
844
|
+
const intent = paneKeyIntent(key, state);
|
|
845
|
+
if (state.busy && intent?.kind === "run") return void 0;
|
|
846
|
+
return intent;
|
|
847
|
+
}
|
|
848
|
+
function inputKey(key) {
|
|
849
|
+
if (key.ctrl) return key.name.toLowerCase() === "c" ? { kind: "quit" } : void 0;
|
|
850
|
+
if (key.sequence.startsWith(ESC2)) return key.sequence === ESC2 ? { kind: "input-cancel" } : void 0;
|
|
851
|
+
if (key.sequence === "\r" || key.sequence === "\n") return { kind: "input-submit" };
|
|
852
|
+
if (key.sequence === DEL) return { kind: "input-backspace" };
|
|
853
|
+
if (key.sequence === "") return void 0;
|
|
854
|
+
return { kind: "input-insert", text: key.sequence };
|
|
855
|
+
}
|
|
856
|
+
function paneKeyIntent(key, state) {
|
|
857
|
+
const profile = state.profiles[state.activeProfile];
|
|
858
|
+
if (profile === void 0) return void 0;
|
|
859
|
+
switch (key.name) {
|
|
860
|
+
case "p":
|
|
861
|
+
return { kind: "run", request: { command: "poll", profileDir: profile.profileDir } };
|
|
862
|
+
case "d":
|
|
863
|
+
return { kind: "run", request: { command: "doctor", profileDir: profile.profileDir } };
|
|
864
|
+
case "r":
|
|
865
|
+
return { kind: "run", request: { command: "relay publish", profileDir: profile.profileDir } };
|
|
866
|
+
case "h":
|
|
867
|
+
return state.selectedContactId === null ? void 0 : { kind: "run", request: { command: "history", profileDir: profile.profileDir, contactIdentityId: state.selectedContactId } };
|
|
868
|
+
case "i":
|
|
869
|
+
return profile.contactCardPath === void 0 ? void 0 : { kind: "run", request: { command: "contact import", profileDir: profile.profileDir, from: profile.contactCardPath } };
|
|
870
|
+
case "w":
|
|
871
|
+
return state.pane !== "history" || state.selectedContactId === null || state.busy || profile.published !== true ? void 0 : { kind: "input-open", field: "message" };
|
|
872
|
+
case "return":
|
|
873
|
+
return state.pane !== "profiles" || state.busy ? void 0 : setupStepIntent(profile);
|
|
874
|
+
case "c": {
|
|
875
|
+
const next = state.contacts[(state.contacts.findIndex((contact) => contact.identityId === state.selectedContactId) + 1) % Math.max(1, state.contacts.length)];
|
|
876
|
+
return next === void 0 ? void 0 : { kind: "select-contact", identityId: next.identityId };
|
|
877
|
+
}
|
|
878
|
+
case "t":
|
|
879
|
+
return state.profiles.length < 2 ? void 0 : { kind: "select-profile", index: (state.activeProfile + 1) % state.profiles.length };
|
|
880
|
+
default:
|
|
881
|
+
return void 0;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
function setupStepIntent(profile) {
|
|
885
|
+
const step = nextSetupStep(profile);
|
|
886
|
+
const profileDir = profile.profileDir;
|
|
887
|
+
switch (step) {
|
|
888
|
+
case 1:
|
|
889
|
+
return profile.relayUrl === "" ? { kind: "input-open", field: "relay-url" } : { kind: "run", request: { command: "init", profileDir, relayUrl: profile.relayUrl, storeKeyEnv: profile.storeKeyEnv } };
|
|
890
|
+
case 2:
|
|
891
|
+
return { kind: "run", request: { command: "relay publish", profileDir } };
|
|
892
|
+
case 3:
|
|
893
|
+
return { kind: "input-open", field: "export-path" };
|
|
894
|
+
case 4:
|
|
895
|
+
return { kind: "input-open", field: "card-path" };
|
|
896
|
+
case 5:
|
|
897
|
+
return { kind: "run", request: { command: "doctor", profileDir } };
|
|
898
|
+
default:
|
|
899
|
+
return void 0;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
function cardInstant(atMs) {
|
|
903
|
+
const at = Number.isFinite(atMs) ? atMs : 0;
|
|
904
|
+
return new Date(at).toISOString().replace(/[-:.]/g, "");
|
|
905
|
+
}
|
|
906
|
+
function initialBuffer(state, field) {
|
|
907
|
+
const profile = state.profiles[state.activeProfile];
|
|
908
|
+
if (profile === void 0) return "";
|
|
909
|
+
switch (field) {
|
|
910
|
+
case "export-path":
|
|
911
|
+
return `${profile.profileDir}/card-${cardInstant(state.observedAtMs ?? 0)}.json`;
|
|
912
|
+
case "card-path":
|
|
913
|
+
return profile.contactCardPath ?? "";
|
|
914
|
+
default:
|
|
915
|
+
return "";
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
function reduce(state, intent) {
|
|
919
|
+
switch (intent.kind) {
|
|
920
|
+
case "select-pane":
|
|
921
|
+
return { state: { ...state, pane: intent.pane }, effects: [] };
|
|
922
|
+
case "select-profile": {
|
|
923
|
+
const index = Number.isInteger(intent.index) && intent.index >= 0 && intent.index < state.profiles.length ? intent.index : state.activeProfile;
|
|
924
|
+
return { state: { ...state, activeProfile: index }, effects: [] };
|
|
925
|
+
}
|
|
926
|
+
case "select-contact":
|
|
927
|
+
return { state: { ...state, selectedContactId: intent.identityId }, effects: [] };
|
|
928
|
+
case "toggle-help":
|
|
929
|
+
return { state: { ...state, help: state.help !== true }, effects: [] };
|
|
930
|
+
case "run":
|
|
931
|
+
if (state.busy) return { state, effects: [] };
|
|
932
|
+
return { state: { ...state, busy: true }, effects: [{ kind: "run-cli", request: intent.request }] };
|
|
933
|
+
case "trust-confirm": {
|
|
934
|
+
const modal = state.modal;
|
|
935
|
+
if (modal === void 0 || modal.renderedAt === null || !trustModalIsComplete(modal)) {
|
|
936
|
+
return { state, effects: [] };
|
|
937
|
+
}
|
|
938
|
+
return { state: { ...state, modal: void 0 }, effects: [{ kind: "answer-trust-prompt", answer: true }] };
|
|
939
|
+
}
|
|
940
|
+
case "trust-cancel":
|
|
941
|
+
if (state.modal === void 0) return { state, effects: [] };
|
|
942
|
+
return { state: { ...state, modal: void 0 }, effects: [{ kind: "answer-trust-prompt", answer: false }] };
|
|
943
|
+
case "input-open":
|
|
944
|
+
if (state.modal !== void 0 || state.busy) return { state, effects: [] };
|
|
945
|
+
return {
|
|
946
|
+
state: {
|
|
947
|
+
...state,
|
|
948
|
+
// Unpainted, exactly like `buildTrustModal`'s `renderedAt: null`: the shell stamps it
|
|
949
|
+
// after the frame carrying the row was written, and never before.
|
|
950
|
+
input: { field: intent.field, buffer: initialBuffer(state, intent.field), renderedAt: null, maxBytes: inputMaxBytes(intent.field) }
|
|
951
|
+
},
|
|
952
|
+
effects: []
|
|
953
|
+
};
|
|
954
|
+
case "input-insert": {
|
|
955
|
+
const input = state.input;
|
|
956
|
+
if (input === void 0 || state.modal !== void 0) return { state, effects: [] };
|
|
957
|
+
const text = paintable(intent.text);
|
|
958
|
+
if (text === "") return { state, effects: [] };
|
|
959
|
+
const buffer = input.buffer + text;
|
|
960
|
+
if (utf8Bytes(buffer) > input.maxBytes) return { state, effects: [] };
|
|
961
|
+
return { state: { ...state, input: { ...input, buffer } }, effects: [] };
|
|
962
|
+
}
|
|
963
|
+
case "input-backspace": {
|
|
964
|
+
const input = state.input;
|
|
965
|
+
if (input === void 0 || state.modal !== void 0) return { state, effects: [] };
|
|
966
|
+
const points = [...input.buffer];
|
|
967
|
+
points.pop();
|
|
968
|
+
return { state: { ...state, input: { ...input, buffer: points.join("") } }, effects: [] };
|
|
969
|
+
}
|
|
970
|
+
case "input-cancel":
|
|
971
|
+
if (state.input === void 0) return { state, effects: [] };
|
|
972
|
+
return { state: { ...state, input: void 0 }, effects: [] };
|
|
973
|
+
case "input-submit": {
|
|
974
|
+
const input = state.input;
|
|
975
|
+
if (input === void 0 || state.modal !== void 0 || state.busy) return { state, effects: [] };
|
|
976
|
+
if (input.renderedAt === null) return { state, effects: [] };
|
|
977
|
+
const request = submitRequest(state, input);
|
|
978
|
+
if (request === void 0) return { state, effects: [] };
|
|
979
|
+
const profiles = request.command !== "init" ? state.profiles : state.profiles.map((profile, index) => index === state.activeProfile ? { ...profile, relayUrl: request.relayUrl } : profile);
|
|
980
|
+
return { state: { ...state, profiles, input: void 0, busy: true }, effects: [{ kind: "run-cli", request }] };
|
|
981
|
+
}
|
|
982
|
+
case "quit":
|
|
983
|
+
return { state, effects: [{ kind: "quit" }] };
|
|
984
|
+
default:
|
|
985
|
+
return { state, effects: [] };
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
function paintableIdentifiers(identifiers) {
|
|
989
|
+
const clean = (value) => value === void 0 ? void 0 : paintable(value);
|
|
990
|
+
return {
|
|
991
|
+
identity_id: clean(identifiers.identity_id),
|
|
992
|
+
device_id: clean(identifiers.device_id),
|
|
993
|
+
device_pubkey: clean(identifiers.device_pubkey),
|
|
994
|
+
signal_identity_key: clean(identifiers.signal_identity_key)
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
function submitRequest(state, input) {
|
|
998
|
+
const profile = state.profiles[state.activeProfile];
|
|
999
|
+
if (profile === void 0) return void 0;
|
|
1000
|
+
const value = input.buffer;
|
|
1001
|
+
const profileDir = profile.profileDir;
|
|
1002
|
+
switch (input.field) {
|
|
1003
|
+
case "message": {
|
|
1004
|
+
const to = state.selectedContactId;
|
|
1005
|
+
if (to === null || value === "" || profile.published !== true) return void 0;
|
|
1006
|
+
return { command: "send", profileDir, to, text: value };
|
|
1007
|
+
}
|
|
1008
|
+
case "relay-url":
|
|
1009
|
+
if (value === "" || profile.storeKeyPresent !== true) return void 0;
|
|
1010
|
+
return { command: "init", profileDir, relayUrl: value, storeKeyEnv: profile.storeKeyEnv };
|
|
1011
|
+
case "export-path":
|
|
1012
|
+
if (value === "") return void 0;
|
|
1013
|
+
return { command: "contact export", profileDir, out: value };
|
|
1014
|
+
case "card-path":
|
|
1015
|
+
if (value === "") return void 0;
|
|
1016
|
+
return { command: "contact import", profileDir, from: value };
|
|
1017
|
+
default:
|
|
1018
|
+
return void 0;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
var ESC2 = String.fromCharCode(27);
|
|
1022
|
+
var CTRL_C = String.fromCharCode(3);
|
|
1023
|
+
var DEL = String.fromCharCode(127);
|
|
1024
|
+
var ALTERNATE_SCREEN_ON = `${ESC2}[?1049h${ESC2}[?25l`;
|
|
1025
|
+
var ALTERNATE_SCREEN_OFF = `${ESC2}[?25h${ESC2}[?1049l`;
|
|
1026
|
+
var HOME = `${ESC2}[H${ESC2}[2J`;
|
|
1027
|
+
function decodeKey(chunk) {
|
|
1028
|
+
const sequence = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
1029
|
+
if (sequence === CTRL_C) return { name: "c", ctrl: true, sequence };
|
|
1030
|
+
if (sequence === ESC2) return { name: "escape", ctrl: false, sequence };
|
|
1031
|
+
if (sequence === "\r" || sequence === "\n") return { name: "return", ctrl: false, sequence };
|
|
1032
|
+
const first = [...sequence][0] ?? "";
|
|
1033
|
+
const code = first.charCodeAt(0);
|
|
1034
|
+
if (Number.isFinite(code) && code > 0 && code < 27) {
|
|
1035
|
+
return { name: String.fromCharCode(code + 96), ctrl: true, sequence };
|
|
1036
|
+
}
|
|
1037
|
+
return { name: first.toLowerCase(), ctrl: false, sequence };
|
|
1038
|
+
}
|
|
1039
|
+
function splitKeystrokes(chunk) {
|
|
1040
|
+
const points = [...chunk];
|
|
1041
|
+
const keys = [];
|
|
1042
|
+
for (let at = 0; at < points.length; at += 1) {
|
|
1043
|
+
const point = points[at] ?? "";
|
|
1044
|
+
if (point === ESC2) {
|
|
1045
|
+
keys.push(points.slice(at).join(""));
|
|
1046
|
+
return keys;
|
|
1047
|
+
}
|
|
1048
|
+
keys.push(point);
|
|
1049
|
+
}
|
|
1050
|
+
return keys;
|
|
1051
|
+
}
|
|
1052
|
+
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1053
|
+
var asNumber = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
1054
|
+
function readRejections(value) {
|
|
1055
|
+
if (!Array.isArray(value)) return [];
|
|
1056
|
+
return value.flatMap((entry) => {
|
|
1057
|
+
if (!isRecord(entry)) return [];
|
|
1058
|
+
const envelopeId = entry.envelopeId;
|
|
1059
|
+
const code = entry.code;
|
|
1060
|
+
return typeof envelopeId === "string" && typeof code === "string" ? [{ envelopeId: paintable(envelopeId), code: paintable(code) }] : [];
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
function readHistoryEntries(value) {
|
|
1064
|
+
if (!isRecord(value) || !Array.isArray(value.entries)) return [];
|
|
1065
|
+
return value.entries.flatMap((entry) => {
|
|
1066
|
+
if (!isRecord(entry)) return [];
|
|
1067
|
+
const { sequence, contactIdentityId, messageId, direction, plaintext, createdAtMs } = entry;
|
|
1068
|
+
if (typeof contactIdentityId !== "string" || typeof messageId !== "string" || typeof plaintext !== "string") return [];
|
|
1069
|
+
if (direction !== "inbound" && direction !== "outbound") return [];
|
|
1070
|
+
return [{
|
|
1071
|
+
sequence: asNumber(sequence, 0),
|
|
1072
|
+
contactIdentityId: paintable(contactIdentityId),
|
|
1073
|
+
messageId: paintable(messageId),
|
|
1074
|
+
direction,
|
|
1075
|
+
plaintext: paintable(plaintext),
|
|
1076
|
+
createdAtMs: asNumber(createdAtMs, 0)
|
|
1077
|
+
}];
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
var SETUP_STEP_OF = {
|
|
1081
|
+
init: 1,
|
|
1082
|
+
"relay publish": 2,
|
|
1083
|
+
"contact export": 3,
|
|
1084
|
+
"contact import": 4,
|
|
1085
|
+
doctor: 5
|
|
1086
|
+
};
|
|
1087
|
+
function stepSucceeded(request, outcome) {
|
|
1088
|
+
if (request.command !== "doctor") return true;
|
|
1089
|
+
return isRecord(outcome.data) && asNumber(outcome.data.contact_count, 0) > 0;
|
|
1090
|
+
}
|
|
1091
|
+
function reportedIdentity(request, outcome) {
|
|
1092
|
+
if (request.command !== "init" && request.command !== "doctor") return void 0;
|
|
1093
|
+
if (!outcome.ok || !isRecord(outcome.data)) return void 0;
|
|
1094
|
+
return typeof outcome.data.identity_id === "string" ? outcome.data.identity_id : void 0;
|
|
1095
|
+
}
|
|
1096
|
+
function foldSetup(state, request, outcome) {
|
|
1097
|
+
const step = SETUP_STEP_OF[request.command];
|
|
1098
|
+
const identity = reportedIdentity(request, outcome);
|
|
1099
|
+
const absent = !outcome.ok && outcome.code === "INVALID_CONFIGURATION" && outcome.exitCode === 2;
|
|
1100
|
+
return state.profiles.map((profile, index) => {
|
|
1101
|
+
const setup = profile.setup;
|
|
1102
|
+
if (index !== state.activeProfile || setup === void 0) return profile;
|
|
1103
|
+
const profileState = absent ? "absent" : identity !== void 0 ? "ready" : profile.state;
|
|
1104
|
+
if (step === void 0) return { ...profile, state: profileState };
|
|
1105
|
+
const value = outcome.ok ? stepSucceeded(request, outcome) ? "ok" : "pending" : { failed: paintable(outcome.code), exitCode: outcome.exitCode };
|
|
1106
|
+
const folded = setup.map((previous, at) => at === step ? value : at > step ? "pending" : previous);
|
|
1107
|
+
return { ...profile, state: profileState, setup: folded };
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
function reportedCount(value, previous) {
|
|
1111
|
+
return typeof value === "number" && Number.isFinite(value) ? value : previous;
|
|
1112
|
+
}
|
|
1113
|
+
function applyOutcome(state, request, outcome, atMs) {
|
|
1114
|
+
const health = outcome.ok ? { ...state.health, status: "healthy", checkedAtMs: atMs } : outcome.exitCode === 4 ? { ...state.health, status: "unreachable", checkedAtMs: atMs } : state.health;
|
|
1115
|
+
const touchedRelay = request.command === "poll" || request.command === "relay publish" || request.command === "send";
|
|
1116
|
+
const next = {
|
|
1117
|
+
...state,
|
|
1118
|
+
health: touchedRelay ? health : state.health,
|
|
1119
|
+
profiles: foldSetup(state, request, outcome),
|
|
1120
|
+
/*
|
|
1121
|
+
* The outbox figure, decided once for every command and every outcome, refusals included.
|
|
1122
|
+
*
|
|
1123
|
+
* No command in the frozen eight reports one (`MailboxView` enumerates what `doctor` does
|
|
1124
|
+
* report), so the figure a result carried is always the absence of one — there is no earlier
|
|
1125
|
+
* report here for a refusal to erase, which is why this is written unconditionally rather than
|
|
1126
|
+
* carried forward the way `inboxReceived` is. The day a command does report a pending count,
|
|
1127
|
+
* this line becomes `reportedCount(that field, state.mailbox.outboxPending)` and the rest of the
|
|
1128
|
+
* rule is already in place.
|
|
1129
|
+
*
|
|
1130
|
+
* What must never come back is the increment that stood here: `outboxPending + 1` on a
|
|
1131
|
+
* SUCCESSFUL send, which does not merely guess — it guesses in the direction opposite to the one
|
|
1132
|
+
* the store moved, since a delivered message leaves the pending set rather than joining it.
|
|
1133
|
+
*/
|
|
1134
|
+
mailbox: { ...state.mailbox, outboxPending: null }
|
|
1135
|
+
};
|
|
1136
|
+
if (!outcome.ok) return next;
|
|
1137
|
+
const data = outcome.data;
|
|
1138
|
+
switch (request.command) {
|
|
1139
|
+
// `init` returns the identical `summary()` shape `doctor` returns — `identity_id`, `device_id`,
|
|
1140
|
+
// `profile_id` — so the same case serves both (t35 §2.1). One fold, no second parser, and no
|
|
1141
|
+
// second place for a child's bytes to enter the state unfiltered.
|
|
1142
|
+
case "init":
|
|
1143
|
+
case "doctor": {
|
|
1144
|
+
if (!isRecord(data)) return next;
|
|
1145
|
+
const profiles = next.profiles.map((profile, index) => index !== next.activeProfile ? profile : {
|
|
1146
|
+
...profile,
|
|
1147
|
+
identityId: typeof data.identity_id === "string" ? paintable(data.identity_id) : profile.identityId,
|
|
1148
|
+
deviceId: typeof data.device_id === "string" ? paintable(data.device_id) : profile.deviceId,
|
|
1149
|
+
contactCount: asNumber(data.contact_count, profile.contactCount)
|
|
1150
|
+
});
|
|
1151
|
+
return { ...next, profiles };
|
|
1152
|
+
}
|
|
1153
|
+
case "poll": {
|
|
1154
|
+
if (!isRecord(data)) return next;
|
|
1155
|
+
return {
|
|
1156
|
+
...next,
|
|
1157
|
+
mailbox: {
|
|
1158
|
+
outboxPending: next.mailbox.outboxPending,
|
|
1159
|
+
// `received` VERBATIM, and never added to what a previous poll reported: it is "envelopes
|
|
1160
|
+
// accepted and committed by this poll" (`runtime/inbound.ts:27`), so a second poll that
|
|
1161
|
+
// accepts none honestly reports `0`. A result that carries no number at all reports
|
|
1162
|
+
// nothing, and the last poll's figure stands.
|
|
1163
|
+
inboxReceived: reportedCount(data.received, next.mailbox.inboxReceived),
|
|
1164
|
+
more: data.more === true,
|
|
1165
|
+
lastPolledAtMs: atMs
|
|
1166
|
+
},
|
|
1167
|
+
rejections: readRejections(data.rejected)
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
case "relay publish": {
|
|
1171
|
+
const profiles = next.profiles.map((profile, index) => index === next.activeProfile ? { ...profile, published: true } : profile);
|
|
1172
|
+
return { ...next, profiles };
|
|
1173
|
+
}
|
|
1174
|
+
case "send":
|
|
1175
|
+
return next;
|
|
1176
|
+
case "history":
|
|
1177
|
+
return { ...next, history: readHistoryEntries(data) };
|
|
1178
|
+
default:
|
|
1179
|
+
return next;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
function outcomeLine(command, outcome) {
|
|
1183
|
+
const head = `${command} \u2192 ${outcome.code} (exit ${String(outcome.exitCode)})`;
|
|
1184
|
+
if (outcome.ok) return head;
|
|
1185
|
+
const text = explain(command, outcome.code, outcome.exitCode);
|
|
1186
|
+
return `${head} ${text.sentence} ${text.action}`;
|
|
1187
|
+
}
|
|
1188
|
+
function runTuiShell(io, state) {
|
|
1189
|
+
let current = { ...state, observedAtMs: state.observedAtMs ?? io.now() };
|
|
1190
|
+
let running = true;
|
|
1191
|
+
const viewport = () => ({
|
|
1192
|
+
cols: Math.max(1, Math.floor(io.stdout.columns ?? MIN_VIEWPORT.cols)),
|
|
1193
|
+
rows: Math.max(1, Math.floor(io.stdout.rows ?? MIN_VIEWPORT.rows))
|
|
1194
|
+
});
|
|
1195
|
+
const paint = () => {
|
|
1196
|
+
const painted = viewport();
|
|
1197
|
+
io.stdout.write(`${HOME}${styleFrame(renderFrame(current, painted)).join("\r\n")}`);
|
|
1198
|
+
const modal = current.modal;
|
|
1199
|
+
if (modal !== void 0 && modal.renderedAt === null && !isBelowMinViewport(painted)) {
|
|
1200
|
+
current = { ...current, modal: { ...modal, renderedAt: io.now() } };
|
|
1201
|
+
}
|
|
1202
|
+
const input = current.input;
|
|
1203
|
+
if (input !== void 0 && input.renderedAt === null && !isBelowMinViewport(painted)) {
|
|
1204
|
+
current = { ...current, input: { ...input, renderedAt: io.now() } };
|
|
1205
|
+
}
|
|
1206
|
+
};
|
|
1207
|
+
const note = (text) => {
|
|
1208
|
+
current = { ...current, activity: [...current.activity, { at: io.now(), text: paintable(text) }].slice(-64) };
|
|
1209
|
+
};
|
|
1210
|
+
let confirmed;
|
|
1211
|
+
let inFlightCommand;
|
|
1212
|
+
const observeContact = (modal) => {
|
|
1213
|
+
const { identity_id: identityId, device_id: deviceId, device_pubkey: devicePubkey, signal_identity_key: signalIdentityKey } = modal.identifiers;
|
|
1214
|
+
if (identityId === void 0 || deviceId === void 0 || devicePubkey === void 0 || signalIdentityKey === void 0) return;
|
|
1215
|
+
if (current.contacts.some((contact2) => contact2.identityId === identityId)) return;
|
|
1216
|
+
const contact = { identityId, deviceId, devicePubkey, signalIdentityKey };
|
|
1217
|
+
current = { ...current, contacts: [...current.contacts, contact] };
|
|
1218
|
+
};
|
|
1219
|
+
const apply = async (effect) => {
|
|
1220
|
+
switch (effect.kind) {
|
|
1221
|
+
case "run-cli": {
|
|
1222
|
+
inFlightCommand = effect.request.command;
|
|
1223
|
+
const outcome = await io.runCli(effect.request);
|
|
1224
|
+
inFlightCommand = void 0;
|
|
1225
|
+
note(outcomeLine(effect.request.command, outcome));
|
|
1226
|
+
current = { ...applyOutcome(current, effect.request, outcome, io.now()), busy: false, observedAtMs: io.now() };
|
|
1227
|
+
if (effect.request.command === "contact import") {
|
|
1228
|
+
if (outcome.ok && confirmed !== void 0) observeContact(confirmed);
|
|
1229
|
+
confirmed = void 0;
|
|
1230
|
+
}
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
case "answer-trust-prompt":
|
|
1234
|
+
io.answerTrustPrompt(effect.answer);
|
|
1235
|
+
note(effect.answer ? "contact import \u2192 confirmed by operator" : "contact import \u2192 declined by operator");
|
|
1236
|
+
return;
|
|
1237
|
+
case "quit":
|
|
1238
|
+
running = false;
|
|
1239
|
+
return;
|
|
1240
|
+
default:
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
};
|
|
1244
|
+
return new Promise((settle) => {
|
|
1245
|
+
io.stdin.setRawMode?.(true);
|
|
1246
|
+
io.stdin.resume();
|
|
1247
|
+
io.stdout.write(ALTERNATE_SCREEN_ON);
|
|
1248
|
+
io.onTrustIdentifiers?.((input) => {
|
|
1249
|
+
current = {
|
|
1250
|
+
...current,
|
|
1251
|
+
modal: { kind: "trust", cardPath: input.cardPath, profileLabel: input.profileLabel, identifiers: paintableIdentifiers(input.identifiers), renderedAt: null }
|
|
1252
|
+
};
|
|
1253
|
+
paint();
|
|
1254
|
+
});
|
|
1255
|
+
const askWhatIsThere = () => {
|
|
1256
|
+
const profile = current.profiles[current.activeProfile];
|
|
1257
|
+
if (profile === void 0 || profile.state !== "unknown") return;
|
|
1258
|
+
const step = reduce(current, { kind: "run", request: { command: "doctor", profileDir: profile.profileDir } });
|
|
1259
|
+
current = step.state;
|
|
1260
|
+
paint();
|
|
1261
|
+
void (async () => {
|
|
1262
|
+
for (const effect of step.effects) await apply(effect);
|
|
1263
|
+
if (running) paint();
|
|
1264
|
+
})();
|
|
1265
|
+
};
|
|
1266
|
+
let finished = false;
|
|
1267
|
+
const finish = () => {
|
|
1268
|
+
if (finished) return;
|
|
1269
|
+
finished = true;
|
|
1270
|
+
io.stdin.setRawMode?.(false);
|
|
1271
|
+
io.stdin.pause();
|
|
1272
|
+
io.stdout.write(ALTERNATE_SCREEN_OFF);
|
|
1273
|
+
if (inFlightCommand !== void 0) {
|
|
1274
|
+
io.stdout.write(clipLine(`${inFlightCommand} is still running`, viewport().cols));
|
|
1275
|
+
}
|
|
1276
|
+
settle(0);
|
|
1277
|
+
};
|
|
1278
|
+
const handleKey = (sequence) => {
|
|
1279
|
+
const intent = mapKey(decodeKey(sequence), current);
|
|
1280
|
+
if (intent === void 0) return;
|
|
1281
|
+
const open = current.modal;
|
|
1282
|
+
const step = reduce(current, intent);
|
|
1283
|
+
if (open !== void 0 && step.effects.some((effect) => effect.kind === "answer-trust-prompt" && effect.answer)) {
|
|
1284
|
+
confirmed = open;
|
|
1285
|
+
}
|
|
1286
|
+
current = step.state;
|
|
1287
|
+
paint();
|
|
1288
|
+
void (async () => {
|
|
1289
|
+
for (const effect of step.effects) await apply(effect);
|
|
1290
|
+
if (running) paint();
|
|
1291
|
+
else finish();
|
|
1292
|
+
})();
|
|
1293
|
+
};
|
|
1294
|
+
io.stdin.on("data", (chunk) => {
|
|
1295
|
+
if (!running) return;
|
|
1296
|
+
const text = chunk.toString("utf8");
|
|
1297
|
+
for (const key of current.input === void 0 ? splitKeystrokes(text) : [text]) {
|
|
1298
|
+
if (!running) return;
|
|
1299
|
+
handleKey(key);
|
|
1300
|
+
}
|
|
1301
|
+
});
|
|
1302
|
+
paint();
|
|
1303
|
+
askWhatIsThere();
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// src/tui/main.ts
|
|
1308
|
+
var HERE = dirname(fileURLToPath(import.meta.url));
|
|
1309
|
+
var USAGE = [
|
|
1310
|
+
"echolet operator console \u2014 an UNAUDITED PROTOTYPE, not for sensitive communication.",
|
|
1311
|
+
"",
|
|
1312
|
+
"usage: echolet-tui --profile <dir> [options] [--profile <dir> [options] \u2026]",
|
|
1313
|
+
"",
|
|
1314
|
+
" --profile <dir> a profile directory; repeat for more than one profile",
|
|
1315
|
+
" --label <name> what to call the preceding profile on screen",
|
|
1316
|
+
" --relay-url <url> the relay the preceding profile is configured against",
|
|
1317
|
+
" --store-key-env <var> the NAME of the environment variable holding that profile's",
|
|
1318
|
+
" 32-byte store key. The value is never read here: it reaches",
|
|
1319
|
+
" the CLI through the inherited environment.",
|
|
1320
|
+
" --card <path> a contact card the preceding profile may import with [i]",
|
|
1321
|
+
" --cli <path> the CLI bundle to drive (default: cli.js beside this file)",
|
|
1322
|
+
" --help print this and exit",
|
|
1323
|
+
"",
|
|
1324
|
+
"Keys: [1-5] pane [enter] next registration step [p] poll [d] doctor",
|
|
1325
|
+
" [r] publish [h] history [i] import [c] contact [t] profile",
|
|
1326
|
+
" [?] help [q] quit"
|
|
1327
|
+
].join("\n");
|
|
1328
|
+
var HELP_FLAGS = /* @__PURE__ */ new Set(["--help", "-h"]);
|
|
1329
|
+
function wantsHelp(argv) {
|
|
1330
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1331
|
+
const token = argv[index] ?? "";
|
|
1332
|
+
if (HELP_FLAGS.has(token)) return true;
|
|
1333
|
+
index += 1;
|
|
1334
|
+
}
|
|
1335
|
+
return false;
|
|
1336
|
+
}
|
|
1337
|
+
function parseOptions(rawArgv) {
|
|
1338
|
+
const argv = rawArgv.map((token) => paintable(token));
|
|
1339
|
+
const profiles = [];
|
|
1340
|
+
let cliPath = resolve(HERE, "cli.js");
|
|
1341
|
+
const assign = (key, value) => {
|
|
1342
|
+
const current = profiles.at(-1);
|
|
1343
|
+
if (current === void 0) throw new Error(`${key} must follow a --profile`);
|
|
1344
|
+
const patched = { ...current };
|
|
1345
|
+
if (key === "--label") patched.label = value;
|
|
1346
|
+
else if (key === "--relay-url") patched.relayUrl = value;
|
|
1347
|
+
else if (key === "--store-key-env") patched.storeKeyEnv = value;
|
|
1348
|
+
else if (key === "--card") patched.contactCardPath = resolve(value);
|
|
1349
|
+
else throw new Error(`unknown option ${key}`);
|
|
1350
|
+
profiles[profiles.length - 1] = patched;
|
|
1351
|
+
};
|
|
1352
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1353
|
+
const flag = argv[index] ?? "";
|
|
1354
|
+
if (HELP_FLAGS.has(flag)) continue;
|
|
1355
|
+
const value = argv[index + 1];
|
|
1356
|
+
if (value === void 0) throw new Error(`${flag} needs a value`);
|
|
1357
|
+
index += 1;
|
|
1358
|
+
if (flag === "--profile") {
|
|
1359
|
+
profiles.push({
|
|
1360
|
+
label: `profile-${profiles.length + 1}`,
|
|
1361
|
+
profileDir: resolve(value),
|
|
1362
|
+
relayUrl: "",
|
|
1363
|
+
storeKeyEnv: "",
|
|
1364
|
+
identityId: "(run doctor)",
|
|
1365
|
+
deviceId: "(run doctor)",
|
|
1366
|
+
contactCount: 0,
|
|
1367
|
+
published: false,
|
|
1368
|
+
// Nothing has been observed about this profile yet, which is what makes the startup
|
|
1369
|
+
// `doctor` the one command the console runs unasked, and what puts the checklist on the
|
|
1370
|
+
// pane until a result says otherwise.
|
|
1371
|
+
state: "unknown",
|
|
1372
|
+
setup: PENDING_SETUP
|
|
1373
|
+
});
|
|
1374
|
+
} else if (flag === "--cli") {
|
|
1375
|
+
cliPath = resolve(value);
|
|
1376
|
+
} else {
|
|
1377
|
+
assign(flag, value);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
if (profiles.length === 0) throw new Error("at least one --profile <dir> is required");
|
|
1381
|
+
return { profiles, cliPath };
|
|
1382
|
+
}
|
|
1383
|
+
function withStoreKeyPresence(profile) {
|
|
1384
|
+
const present = Object.hasOwn(process.env, profile.storeKeyEnv);
|
|
1385
|
+
const setup = (profile.setup ?? PENDING_SETUP).map((outcome, step) => step === 0 ? present ? "ok" : "pending" : outcome);
|
|
1386
|
+
return { ...profile, storeKeyPresent: present, setup };
|
|
1387
|
+
}
|
|
1388
|
+
function readIdentifiers(text) {
|
|
1389
|
+
for (const line of text.split("\n")) {
|
|
1390
|
+
const trimmed = line.trim();
|
|
1391
|
+
if (!trimmed.startsWith("{")) continue;
|
|
1392
|
+
try {
|
|
1393
|
+
const parsed = JSON.parse(trimmed);
|
|
1394
|
+
if (typeof parsed !== "object" || parsed === null) continue;
|
|
1395
|
+
const record = parsed;
|
|
1396
|
+
if (typeof record.identity_id !== "string") continue;
|
|
1397
|
+
return {
|
|
1398
|
+
identity_id: record.identity_id,
|
|
1399
|
+
device_id: typeof record.device_id === "string" ? record.device_id : void 0,
|
|
1400
|
+
device_pubkey: typeof record.device_pubkey === "string" ? record.device_pubkey : void 0,
|
|
1401
|
+
signal_identity_key: typeof record.signal_identity_key === "string" ? record.signal_identity_key : void 0
|
|
1402
|
+
};
|
|
1403
|
+
} catch {
|
|
1404
|
+
continue;
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
return void 0;
|
|
1408
|
+
}
|
|
1409
|
+
async function main() {
|
|
1410
|
+
const argv = process.argv.slice(2);
|
|
1411
|
+
if (wantsHelp(argv)) {
|
|
1412
|
+
process.stdout.write(`${USAGE}
|
|
1413
|
+
`);
|
|
1414
|
+
return 0;
|
|
1415
|
+
}
|
|
1416
|
+
const options = parseOptions(argv);
|
|
1417
|
+
let trustListener;
|
|
1418
|
+
let awaitingTrust;
|
|
1419
|
+
const runCli = (request) => new Promise((settle) => {
|
|
1420
|
+
const child = spawn(process.execPath, [options.cliPath, ...buildArgv(request)], {
|
|
1421
|
+
env: process.env,
|
|
1422
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1423
|
+
});
|
|
1424
|
+
child.stdin.on("error", () => {
|
|
1425
|
+
});
|
|
1426
|
+
let stdout = "";
|
|
1427
|
+
let stderr = "";
|
|
1428
|
+
let announced = false;
|
|
1429
|
+
child.stdout.on("data", (chunk) => {
|
|
1430
|
+
stdout += chunk.toString("utf8");
|
|
1431
|
+
});
|
|
1432
|
+
child.stderr.on("data", (chunk) => {
|
|
1433
|
+
stderr += chunk.toString("utf8");
|
|
1434
|
+
if (announced || request.command !== "contact import") return;
|
|
1435
|
+
const identifiers = readIdentifiers(stderr);
|
|
1436
|
+
if (identifiers === void 0) return;
|
|
1437
|
+
announced = true;
|
|
1438
|
+
awaitingTrust = child.stdin;
|
|
1439
|
+
const profile = options.profiles.find((candidate) => candidate.profileDir === request.profileDir);
|
|
1440
|
+
trustListener?.({ cardPath: request.from, profileLabel: profile?.label ?? request.profileDir, identifiers });
|
|
1441
|
+
});
|
|
1442
|
+
const done = (exitCode) => {
|
|
1443
|
+
awaitingTrust = void 0;
|
|
1444
|
+
settle(parseCliOutcome(stdout, exitCode));
|
|
1445
|
+
};
|
|
1446
|
+
child.on("error", () => {
|
|
1447
|
+
done(5);
|
|
1448
|
+
});
|
|
1449
|
+
child.on("close", (code) => {
|
|
1450
|
+
done(code ?? 1);
|
|
1451
|
+
});
|
|
1452
|
+
if (request.command === "send") child.stdin.end(request.text, "utf8");
|
|
1453
|
+
else if (request.command !== "contact import") child.stdin.end();
|
|
1454
|
+
});
|
|
1455
|
+
const io = {
|
|
1456
|
+
stdout: process.stdout,
|
|
1457
|
+
stdin: process.stdin,
|
|
1458
|
+
now: () => Date.now(),
|
|
1459
|
+
runCli,
|
|
1460
|
+
answerTrustPrompt: (answer) => {
|
|
1461
|
+
awaitingTrust?.write(answer ? "y\n" : "n\n");
|
|
1462
|
+
},
|
|
1463
|
+
onTrustIdentifiers: (listener) => {
|
|
1464
|
+
trustListener = listener;
|
|
1465
|
+
}
|
|
1466
|
+
};
|
|
1467
|
+
return runTuiShell(io, createInitialState({ profiles: options.profiles.map(withStoreKeyPresence) }));
|
|
1468
|
+
}
|
|
1469
|
+
main().then(
|
|
1470
|
+
(code) => {
|
|
1471
|
+
process.exitCode = code;
|
|
1472
|
+
},
|
|
1473
|
+
(error) => {
|
|
1474
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}
|
|
1475
|
+
`);
|
|
1476
|
+
process.exitCode = 2;
|
|
1477
|
+
}
|
|
1478
|
+
);
|