@4xeoz/re-entry 0.2.3 → 0.2.5
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/README.md +11 -7
- package/package.json +1 -1
- package/src/browser-prompt.mjs +1 -1
- package/src/main.mjs +103 -59
- package/src/terminal-ui.mjs +58 -31
- package/src/workspace-picker.mjs +120 -46
package/README.md
CHANGED
|
@@ -19,23 +19,27 @@ npx @4xeoz/re-entry install
|
|
|
19
19
|
This uses the deployed Re-entry Cloud Receiver by default:
|
|
20
20
|
`https://reentry-cloud.vercel.app`.
|
|
21
21
|
|
|
22
|
-
The interactive CLI
|
|
23
|
-
the
|
|
22
|
+
The interactive CLI first offers Desktop, the current folder, or a small folder browser. Choose
|
|
23
|
+
with ↑/↓ and Enter. If you want to skip the picker, pass the workspace directly:
|
|
24
24
|
|
|
25
25
|
```sh
|
|
26
26
|
npx @4xeoz/re-entry install \
|
|
27
27
|
--codex-cd /absolute/path/to/your/project
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
The guided screen stays intentionally small: **Workspace → System check → Connect Re-entry**. It
|
|
31
|
+
shows one clear next command when setup finishes; internal Connector IDs, credential paths, and log
|
|
32
|
+
paths stay out of the normal success screen.
|
|
33
|
+
|
|
30
34
|
Run this from the Host project directory, your home directory, or another normal working
|
|
31
35
|
directory—not from a checked-out `runtime/local-connector` package directory. npm can treat that
|
|
32
36
|
source directory as the package itself and fail to create the temporary executable link.
|
|
33
37
|
|
|
34
|
-
If npm
|
|
35
|
-
|
|
38
|
+
If your npm installation cannot create the temporary `npx` executable, use the one-time global
|
|
39
|
+
installation instead:
|
|
36
40
|
|
|
37
41
|
```sh
|
|
38
|
-
npm install --global @4xeoz/re-entry
|
|
42
|
+
npm install --global @4xeoz/re-entry
|
|
39
43
|
re-entry install --codex-cd /absolute/path/to/your/project
|
|
40
44
|
```
|
|
41
45
|
|
|
@@ -51,8 +55,8 @@ npx --yes --package=@4xeoz/re-entry re-entry install \
|
|
|
51
55
|
`re-entry install` performs the whole user setup:
|
|
52
56
|
|
|
53
57
|
```text
|
|
54
|
-
|
|
55
|
-
->
|
|
58
|
+
choose Desktop, the current folder, or another Codex workspace
|
|
59
|
+
-> check Node + find Codex + validate the selected directory
|
|
56
60
|
-> request a device authorization from Re-entry
|
|
57
61
|
-> show the verification URL and wait for the user to press Enter
|
|
58
62
|
-> open Re-entry in the default browser
|
package/package.json
CHANGED
package/src/browser-prompt.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import process from "node:process";
|
|
|
8
8
|
export async function waitForEnterToOpenBrowser(options = {}) {
|
|
9
9
|
const input = options.input ?? process.stdin;
|
|
10
10
|
const output = options.output ?? process.stdout;
|
|
11
|
-
const prompt = options.prompt ?? " Press Enter to open Re-entry
|
|
11
|
+
const prompt = options.prompt ?? "\n Press Enter to open Re-entry → ";
|
|
12
12
|
const readline = createInterface({ input, output });
|
|
13
13
|
try {
|
|
14
14
|
await readline.question(prompt);
|
package/src/main.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import { createTerminalUi } from "./terminal-ui.mjs";
|
|
|
32
32
|
const DEFAULT_RECEIVER_ORIGIN = process.env.REENTRY_RECEIVER_ORIGIN ?? "https://reentry-cloud.vercel.app";
|
|
33
33
|
const DEFAULT_POLL_INTERVAL_MS = 5_000;
|
|
34
34
|
const DEFAULT_MAX_CONSECUTIVE_ERRORS = 5;
|
|
35
|
+
const DEFAULT_PAIRING_REQUEST_TIMEOUT_MS = 20_000;
|
|
35
36
|
const CONNECTOR_VERSION = readConnectorVersion();
|
|
36
37
|
|
|
37
38
|
async function main() {
|
|
@@ -148,9 +149,12 @@ function readConnectorVersion() {
|
|
|
148
149
|
}
|
|
149
150
|
|
|
150
151
|
function doctor(flags, ui) {
|
|
151
|
-
if (ui.interactive)
|
|
152
|
+
if (ui.interactive) {
|
|
153
|
+
ui.begin("System check", "Confirm that this Mac is ready for Re-entry.");
|
|
154
|
+
ui.section("CHECK", "Requirements");
|
|
155
|
+
}
|
|
152
156
|
const readiness = inspectReadiness(flags);
|
|
153
|
-
showReadiness(readiness, ui);
|
|
157
|
+
showReadiness(readiness, ui, { detailed: true });
|
|
154
158
|
if (!ui.interactive) {
|
|
155
159
|
process.stdout.write(`${JSON.stringify({
|
|
156
160
|
event: "connector_ready",
|
|
@@ -178,21 +182,26 @@ async function withWorkspaceDirectory(flags, ui) {
|
|
|
178
182
|
if (flags["codex-cd"] !== undefined || !ui.interactive) {
|
|
179
183
|
return { ...flags, "codex-cd": flags["codex-cd"] ?? process.cwd() };
|
|
180
184
|
}
|
|
181
|
-
ui.info("Workspace", "choose the folder Codex will open");
|
|
182
185
|
const selected = await chooseWorkspaceDirectory({ startDirectory: process.cwd() });
|
|
183
186
|
if (!selected) throw cliFailure("connector_codex_cd_missing");
|
|
184
|
-
ui.success("Workspace", selected);
|
|
185
187
|
return { ...flags, "codex-cd": selected };
|
|
186
188
|
}
|
|
187
189
|
|
|
188
|
-
function showReadiness(readiness, ui) {
|
|
190
|
+
function showReadiness(readiness, ui, options = {}) {
|
|
189
191
|
if (!ui.interactive) return;
|
|
190
|
-
ui.success("Node.js", process.versions.node);
|
|
191
|
-
ui.success(
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
192
|
+
ui.success("Node.js", `v${process.versions.node}`);
|
|
193
|
+
ui.success(
|
|
194
|
+
"Codex",
|
|
195
|
+
options.detailed
|
|
196
|
+
? `${readiness.installation.version} · ${readiness.installation.executable}`
|
|
197
|
+
: "ready",
|
|
198
|
+
);
|
|
199
|
+
if (options.includeWorkspace !== false) {
|
|
200
|
+
if (readiness.workingDirectory) {
|
|
201
|
+
ui.success("Workspace", readiness.workingDirectory);
|
|
202
|
+
} else {
|
|
203
|
+
ui.info("Workspace", "selected automatically when Re-entry starts");
|
|
204
|
+
}
|
|
196
205
|
}
|
|
197
206
|
}
|
|
198
207
|
|
|
@@ -207,20 +216,23 @@ async function pair(flags, ui, options = {}) {
|
|
|
207
216
|
}
|
|
208
217
|
const userCode = flags.code ?? await askForPairingCode(ui);
|
|
209
218
|
if (ui.interactive) ui.step("Pairing code", "received");
|
|
210
|
-
const client = new LocalConnectorPairingClient({
|
|
219
|
+
const client = new LocalConnectorPairingClient({
|
|
220
|
+
baseUrl: receiver,
|
|
221
|
+
requestTimeoutMs: DEFAULT_PAIRING_REQUEST_TIMEOUT_MS,
|
|
222
|
+
});
|
|
223
|
+
if (ui.interactive) ui.wait("Contacting Re-entry…");
|
|
211
224
|
const credentials = await client.pair({ userCode }, async ({ verificationUri }) => {
|
|
212
225
|
if (ui.interactive) {
|
|
213
|
-
ui.
|
|
214
|
-
ui.info("
|
|
226
|
+
ui.stopWait("Secure link", "ready");
|
|
227
|
+
ui.info("Browser link", verificationUri);
|
|
215
228
|
await waitForEnterToOpenBrowser();
|
|
216
|
-
ui.
|
|
217
|
-
ui.wait("Waiting for you to approve this Mac…");
|
|
229
|
+
ui.wait("Waiting for approval in your browser…");
|
|
218
230
|
} else {
|
|
219
231
|
process.stdout.write(`${JSON.stringify({ event: "pairing_waiting", verification_uri: verificationUri })}\n`);
|
|
220
232
|
}
|
|
221
233
|
});
|
|
222
234
|
if (ui.interactive) {
|
|
223
|
-
ui.stopWait("
|
|
235
|
+
ui.stopWait("Approved", "this Mac is connected");
|
|
224
236
|
if (!credentials.browserOpened) {
|
|
225
237
|
ui.warning("Browser", "did not open automatically; use the URL above");
|
|
226
238
|
}
|
|
@@ -234,7 +246,8 @@ async function pair(flags, ui, options = {}) {
|
|
|
234
246
|
connector_expires_at: credentials.connector_expires_at,
|
|
235
247
|
});
|
|
236
248
|
if (ui.interactive) {
|
|
237
|
-
ui.
|
|
249
|
+
ui.complete("Pairing complete", "Re-entry can now deliver approved work to this Mac.");
|
|
250
|
+
ui.next("re-entry start", "Wait for approved work in this terminal.");
|
|
238
251
|
} else {
|
|
239
252
|
process.stdout.write(`${JSON.stringify({ event: "connector_paired", connector_id: credentials.connector_id })}\n`);
|
|
240
253
|
}
|
|
@@ -251,8 +264,10 @@ async function connect(flags, ui, options = {}) {
|
|
|
251
264
|
const currentIsValid = current && Date.parse(current.connector_expires_at) > Date.now();
|
|
252
265
|
if (currentIsValid && current.receiver_origin === receiver) {
|
|
253
266
|
if (ui.interactive) {
|
|
254
|
-
ui.success("
|
|
255
|
-
|
|
267
|
+
ui.success("Account", "already connected");
|
|
268
|
+
if (!options.guidedInstall) {
|
|
269
|
+
ui.next("re-entry start", "Wait for approved work in this terminal.");
|
|
270
|
+
}
|
|
256
271
|
} else {
|
|
257
272
|
process.stdout.write(`${JSON.stringify({
|
|
258
273
|
event: "connector_already_connected",
|
|
@@ -268,19 +283,22 @@ async function connect(flags, ui, options = {}) {
|
|
|
268
283
|
}
|
|
269
284
|
|
|
270
285
|
if (ui.interactive) {
|
|
271
|
-
ui.
|
|
272
|
-
ui.
|
|
286
|
+
if (!options.guidedInstall) ui.info("Re-entry", displayReceiver(receiver));
|
|
287
|
+
ui.info("This Mac", flags["device-name"] ?? defaultDeviceName());
|
|
273
288
|
}
|
|
274
|
-
const client = new LocalConnectorPairingClient({
|
|
289
|
+
const client = new LocalConnectorPairingClient({
|
|
290
|
+
baseUrl: receiver,
|
|
291
|
+
requestTimeoutMs: DEFAULT_PAIRING_REQUEST_TIMEOUT_MS,
|
|
292
|
+
});
|
|
293
|
+
if (ui.interactive) ui.wait("Contacting Re-entry…");
|
|
275
294
|
const credentials = await client.connect(
|
|
276
295
|
{ deviceName: flags["device-name"] ?? defaultDeviceName() },
|
|
277
296
|
async ({ verificationUri }) => {
|
|
278
297
|
if (ui.interactive) {
|
|
279
|
-
ui.
|
|
280
|
-
ui.info("
|
|
298
|
+
ui.stopWait("Secure link", "ready");
|
|
299
|
+
ui.info("Browser link", verificationUri);
|
|
281
300
|
await waitForEnterToOpenBrowser();
|
|
282
|
-
ui.
|
|
283
|
-
ui.wait("Waiting for you to connect this Mac…");
|
|
301
|
+
ui.wait("Waiting for approval in your browser…");
|
|
284
302
|
} else {
|
|
285
303
|
process.stdout.write(`${JSON.stringify({
|
|
286
304
|
event: "connector_authorization_waiting",
|
|
@@ -290,7 +308,7 @@ async function connect(flags, ui, options = {}) {
|
|
|
290
308
|
},
|
|
291
309
|
);
|
|
292
310
|
if (ui.interactive) {
|
|
293
|
-
ui.stopWait("
|
|
311
|
+
ui.stopWait("Account", "connected");
|
|
294
312
|
if (!credentials.browserOpened) {
|
|
295
313
|
ui.warning("Browser", "did not open automatically; use the URL shown above");
|
|
296
314
|
}
|
|
@@ -304,8 +322,10 @@ async function connect(flags, ui, options = {}) {
|
|
|
304
322
|
};
|
|
305
323
|
await store.save(saved);
|
|
306
324
|
if (ui.interactive) {
|
|
307
|
-
|
|
308
|
-
|
|
325
|
+
if (!options.guidedInstall) {
|
|
326
|
+
ui.complete("This Mac is connected", "Re-entry can now route approved work here.");
|
|
327
|
+
ui.next("re-entry start", "Wait for approved work in this terminal.");
|
|
328
|
+
}
|
|
309
329
|
} else {
|
|
310
330
|
process.stdout.write(`${JSON.stringify({
|
|
311
331
|
event: "connector_connected",
|
|
@@ -316,22 +336,32 @@ async function connect(flags, ui, options = {}) {
|
|
|
316
336
|
}
|
|
317
337
|
|
|
318
338
|
async function status(flags, ui) {
|
|
319
|
-
if (ui.interactive) ui.begin("
|
|
339
|
+
if (ui.interactive) ui.begin("Status", "A quick check of this Mac and Re-entry.");
|
|
320
340
|
const credentialFile = flags["credential-file"] ?? defaultCredentialFile();
|
|
321
341
|
const credentials = await new LocalConnectorCredentialStore({ filename: credentialFile }).load();
|
|
322
342
|
const readiness = inspectReadiness(flags);
|
|
323
343
|
const service = await inspectMacConnectorService();
|
|
324
344
|
const receiverReady = credentials ? await inspectReceiver(credentials.receiver_origin) : false;
|
|
325
|
-
showReadiness(readiness, ui);
|
|
326
345
|
const connected = Boolean(credentials && Date.parse(credentials.connector_expires_at) > Date.now());
|
|
327
346
|
if (ui.interactive) {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
else ui.warning("
|
|
333
|
-
if (
|
|
334
|
-
else if (
|
|
347
|
+
ui.section("SYSTEM", "This Mac");
|
|
348
|
+
showReadiness(readiness, ui);
|
|
349
|
+
ui.section("CONNECTION", "Re-entry");
|
|
350
|
+
if (connected) ui.success("Account", "connected");
|
|
351
|
+
else ui.warning("Account", "not connected");
|
|
352
|
+
if (service.running) ui.success("Background", "running");
|
|
353
|
+
else if (service.installed) ui.warning("Background", "stopped");
|
|
354
|
+
else ui.warning("Background", "not installed");
|
|
355
|
+
if (connected && receiverReady) ui.success("Cloud", "online");
|
|
356
|
+
else if (connected) ui.warning("Cloud", "unavailable");
|
|
357
|
+
|
|
358
|
+
if (!connected || !service.running) {
|
|
359
|
+
ui.next("re-entry install", "Finish setup and start Re-entry in the background.");
|
|
360
|
+
} else if (!receiverReady) {
|
|
361
|
+
ui.next("re-entry status", "Check again when the Re-entry Cloud service is available.");
|
|
362
|
+
} else {
|
|
363
|
+
ui.complete("Everything looks good", "Re-entry is ready for approved work.");
|
|
364
|
+
}
|
|
335
365
|
} else {
|
|
336
366
|
process.stdout.write(`${JSON.stringify({
|
|
337
367
|
event: "connector_status",
|
|
@@ -355,8 +385,9 @@ async function stop(flags, ui) {
|
|
|
355
385
|
const result = await stopMacConnectorService();
|
|
356
386
|
if (ui.interactive) {
|
|
357
387
|
if (!result.supported) ui.warning("Platform", "background service control currently supports macOS only");
|
|
358
|
-
else if (result.stopped) ui.
|
|
388
|
+
else if (result.stopped) ui.complete("Re-entry is paused", "Your account connection is still saved.");
|
|
359
389
|
else ui.info("Already stopped", "no running background Connector was found");
|
|
390
|
+
if (result.supported) ui.next("re-entry install", "Start Re-entry in the background again.");
|
|
360
391
|
} else {
|
|
361
392
|
process.stdout.write(`${JSON.stringify({
|
|
362
393
|
event: "connector_stopped",
|
|
@@ -378,8 +409,8 @@ async function uninstall(flags, ui) {
|
|
|
378
409
|
credentialFile: flags["credential-file"] ?? defaultCredentialFile(),
|
|
379
410
|
});
|
|
380
411
|
if (ui.interactive) {
|
|
381
|
-
ui.
|
|
382
|
-
ui.
|
|
412
|
+
ui.complete("Removed from this Mac", "The local service, connection, and logs are gone.");
|
|
413
|
+
ui.next("npx @4xeoz/re-entry install", "Connect this Mac again whenever you are ready.");
|
|
383
414
|
} else {
|
|
384
415
|
process.stdout.write(`${JSON.stringify({
|
|
385
416
|
event: "connector_uninstalled",
|
|
@@ -390,16 +421,18 @@ async function uninstall(flags, ui) {
|
|
|
390
421
|
}
|
|
391
422
|
|
|
392
423
|
async function install(flags, ui) {
|
|
393
|
-
if (ui.interactive) {
|
|
394
|
-
ui.begin("Install Re-entry", "Check Codex, connect your account, then start at login");
|
|
395
|
-
ui.info("Setup", "choose a workspace, approve this Mac, then leave the Connector running");
|
|
396
|
-
}
|
|
397
424
|
const runtimeFlags = await withWorkspaceDirectory(flags, ui);
|
|
398
|
-
if (ui.interactive) ui.info("Receiver", runtimeFlags.receiver ?? DEFAULT_RECEIVER_ORIGIN);
|
|
399
425
|
const readiness = inspectReadiness(runtimeFlags);
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
426
|
+
if (ui.interactive) {
|
|
427
|
+
ui.begin("Set up this Mac", "Three quick steps. You only do this once.");
|
|
428
|
+
ui.section("1 OF 3", "Workspace");
|
|
429
|
+
ui.success("Selected", readiness.workingDirectory);
|
|
430
|
+
ui.section("2 OF 3", "System check");
|
|
431
|
+
showReadiness(readiness, ui, { includeWorkspace: false });
|
|
432
|
+
ui.section("3 OF 3", "Connect Re-entry", "Approve this Mac in your browser.");
|
|
433
|
+
}
|
|
434
|
+
const credentials = await connect(runtimeFlags, ui, { quietHeader: true, guidedInstall: true });
|
|
435
|
+
if (ui.interactive) ui.wait("Starting Re-entry in the background…");
|
|
403
436
|
const service = await installMacConnectorService({
|
|
404
437
|
nodeExecutable: process.execPath,
|
|
405
438
|
entrypoint: fileURLToPath(import.meta.url),
|
|
@@ -407,11 +440,9 @@ async function install(flags, ui) {
|
|
|
407
440
|
credentialFile: flags["credential-file"] ?? defaultCredentialFile(),
|
|
408
441
|
});
|
|
409
442
|
if (ui.interactive) {
|
|
410
|
-
ui.stopWait("
|
|
411
|
-
ui.
|
|
412
|
-
ui.
|
|
413
|
-
ui.info("Commands", "status to inspect · stop to pause · uninstall to remove local setup");
|
|
414
|
-
ui.info("You are done", "the Connector now waits in the background");
|
|
443
|
+
ui.stopWait("Background", "running at login");
|
|
444
|
+
ui.complete("You're all set", "Re-entry is connected and waiting for approved work.");
|
|
445
|
+
ui.next("re-entry status", "Check the connection at any time.");
|
|
415
446
|
} else {
|
|
416
447
|
process.stdout.write(`${JSON.stringify({
|
|
417
448
|
event: "connector_service_installed",
|
|
@@ -424,12 +455,13 @@ async function install(flags, ui) {
|
|
|
424
455
|
}
|
|
425
456
|
|
|
426
457
|
async function start(flags, ui) {
|
|
427
|
-
if (ui.interactive) {
|
|
428
|
-
ui.begin("Re-entry is starting", "Connect once, then wait quietly for work you approve");
|
|
429
|
-
}
|
|
430
458
|
const runtimeFlags = await withWorkspaceDirectory(flags, ui);
|
|
431
459
|
const readiness = inspectReadiness(runtimeFlags);
|
|
432
|
-
|
|
460
|
+
if (ui.interactive) {
|
|
461
|
+
ui.begin("Start Re-entry", "Wait for work you have approved.");
|
|
462
|
+
ui.section("READY", "This Mac");
|
|
463
|
+
showReadiness(readiness, ui);
|
|
464
|
+
}
|
|
433
465
|
if (!ui.interactive) {
|
|
434
466
|
process.stdout.write(`${JSON.stringify({
|
|
435
467
|
event: "connector_ready",
|
|
@@ -715,6 +747,14 @@ function defaultDeviceName() {
|
|
|
715
747
|
: "This Mac";
|
|
716
748
|
}
|
|
717
749
|
|
|
750
|
+
function displayReceiver(origin) {
|
|
751
|
+
try {
|
|
752
|
+
return new URL(origin).host;
|
|
753
|
+
} catch {
|
|
754
|
+
return origin;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
718
758
|
function readBoundedNumber(value, minimum, maximum, code) {
|
|
719
759
|
const number = typeof value === "number" ? value : Number(value);
|
|
720
760
|
if (!Number.isSafeInteger(number) || number < minimum || number > maximum) {
|
|
@@ -802,6 +842,10 @@ function errorHint(error) {
|
|
|
802
842
|
pairing_code_invalid: "use the 16-character code returned by the Host, for example ABCD-EFGH-IJKL-MNOP",
|
|
803
843
|
host_subject_already_paired: "use the existing Connector credential or revoke/reset the preview pairing",
|
|
804
844
|
pairing_expired: "ask the Host backend for a new pairing code",
|
|
845
|
+
pairing_request_timeout: "the Receiver took too long to answer; check your connection and run the command again",
|
|
846
|
+
pairing_network_error: "check your internet connection and the Receiver address, then try again",
|
|
847
|
+
workspace_directory_unavailable: "choose a readable folder or pass --codex-cd /absolute/path",
|
|
848
|
+
workspace_selection_cancelled: "run the command again when you are ready to choose a workspace",
|
|
805
849
|
device_authorization_expired: "run `re-entry connect` again and approve within ten minutes",
|
|
806
850
|
device_authorization_denied: "run `re-entry connect` again when you are ready to approve this Mac",
|
|
807
851
|
connector_poll_interval_invalid: "use a polling interval between 1000 and 60000 milliseconds",
|
package/src/terminal-ui.mjs
CHANGED
|
@@ -8,19 +8,12 @@ const YELLOW = "\u001b[33m";
|
|
|
8
8
|
const RED = "\u001b[31m";
|
|
9
9
|
const CYAN = "\u001b[36m";
|
|
10
10
|
const SPINNER_FRAMES = ["·", "✦", "✧", "✦"];
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
|
|
17
|
-
];
|
|
18
|
-
const REENTRY_PANEL = [
|
|
19
|
-
" +-----------------------------------------------+",
|
|
20
|
-
" | RE-ENTRY |",
|
|
21
|
-
" | LOCAL CONNECTOR |",
|
|
22
|
-
" +-----------------------------------------------+",
|
|
23
|
-
];
|
|
11
|
+
const RULE = " ─────────────────────────────────────────────";
|
|
12
|
+
|
|
13
|
+
export const REENTRY_WORDMARK = Object.freeze([
|
|
14
|
+
" █▀█ █▀▀ █▀▀ █▄░█ ▀█▀ █▀█ █▄█",
|
|
15
|
+
" █▀▄ ██▄ ▀ ██▄ █░▀█ ░█░ █▀▄ ░█░",
|
|
16
|
+
]);
|
|
24
17
|
|
|
25
18
|
/**
|
|
26
19
|
* Small dependency-free terminal presentation for the Local Connector CLI.
|
|
@@ -36,7 +29,7 @@ export function createTerminalUi(options = {}) {
|
|
|
36
29
|
let spinnerFrame = 0;
|
|
37
30
|
|
|
38
31
|
const style = (value, code) => color ? `${code}${value}${RESET}` : value;
|
|
39
|
-
const write = (value) => output.write(`${value}\n`);
|
|
32
|
+
const write = (value = "") => output.write(`${value}\n`);
|
|
40
33
|
const clearSpinnerLine = () => {
|
|
41
34
|
if (spinnerTimer === null) return;
|
|
42
35
|
output.write("\r\u001b[2K");
|
|
@@ -44,44 +37,56 @@ export function createTerminalUi(options = {}) {
|
|
|
44
37
|
spinnerTimer = null;
|
|
45
38
|
};
|
|
46
39
|
const renderSpinner = () => {
|
|
47
|
-
output.write(`\r\u001b[2K ${style(SPINNER_FRAMES[spinnerFrame], CYAN)}
|
|
40
|
+
output.write(`\r\u001b[2K ${style(SPINNER_FRAMES[spinnerFrame], CYAN)} ${spinnerMessage}`);
|
|
48
41
|
spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
49
42
|
};
|
|
43
|
+
const renderState = (symbol, symbolColor, label, detail) => {
|
|
44
|
+
clearSpinnerLine();
|
|
45
|
+
write(` ${style(symbol, symbolColor)} ${style(label, BOLD)}${detail ? ` ${style(detail, DIM)}` : ""}`);
|
|
46
|
+
};
|
|
50
47
|
|
|
51
48
|
return Object.freeze({
|
|
52
49
|
interactive,
|
|
53
50
|
|
|
54
51
|
begin(title, subtitle) {
|
|
55
52
|
if (!interactive) return;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
write(
|
|
59
|
-
|
|
60
|
-
write(
|
|
61
|
-
write(` ${style("RE-ENTRY", BOLD)} ${style("LOCAL CONNECTOR", DIM)}`);
|
|
53
|
+
clearSpinnerLine();
|
|
54
|
+
write();
|
|
55
|
+
for (const line of REENTRY_WORDMARK) write(style(line, `${BOLD}${CYAN}`));
|
|
56
|
+
write(` ${style("LOCAL CONNECTOR", DIM)}`);
|
|
57
|
+
write();
|
|
62
58
|
write(` ${style(title, BOLD)}`);
|
|
63
59
|
if (subtitle) write(` ${style(subtitle, DIM)}`);
|
|
64
|
-
write(
|
|
60
|
+
write(RULE);
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
section(step, title, detail) {
|
|
64
|
+
if (!interactive) return;
|
|
65
|
+
clearSpinnerLine();
|
|
66
|
+
write();
|
|
67
|
+
write(` ${style(step, CYAN)} ${style(title, BOLD)}`);
|
|
68
|
+
if (detail) write(` ${style(detail, DIM)}`);
|
|
69
|
+
write();
|
|
65
70
|
},
|
|
66
71
|
|
|
67
72
|
step(label, detail) {
|
|
68
73
|
if (!interactive) return;
|
|
69
|
-
|
|
74
|
+
renderState("→", CYAN, label, detail);
|
|
70
75
|
},
|
|
71
76
|
|
|
72
77
|
success(label, detail) {
|
|
73
78
|
if (!interactive) return;
|
|
74
|
-
|
|
79
|
+
renderState("✓", GREEN, label, detail);
|
|
75
80
|
},
|
|
76
81
|
|
|
77
82
|
info(label, detail) {
|
|
78
83
|
if (!interactive) return;
|
|
79
|
-
|
|
84
|
+
renderState("·", CYAN, label, detail);
|
|
80
85
|
},
|
|
81
86
|
|
|
82
87
|
warning(label, detail) {
|
|
83
88
|
if (!interactive) return;
|
|
84
|
-
|
|
89
|
+
renderState("!", YELLOW, label, detail);
|
|
85
90
|
},
|
|
86
91
|
|
|
87
92
|
wait(message) {
|
|
@@ -97,16 +102,38 @@ export function createTerminalUi(options = {}) {
|
|
|
97
102
|
stopWait(label, detail, outcome = "success") {
|
|
98
103
|
if (!interactive) return;
|
|
99
104
|
clearSpinnerLine();
|
|
100
|
-
if (outcome === "warning")
|
|
101
|
-
else if (outcome === "info")
|
|
102
|
-
else
|
|
105
|
+
if (outcome === "warning") renderState("!", YELLOW, label, detail);
|
|
106
|
+
else if (outcome === "info") renderState("·", CYAN, label, detail);
|
|
107
|
+
else renderState("✓", GREEN, label, detail);
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
complete(title, detail) {
|
|
111
|
+
if (!interactive) return;
|
|
112
|
+
clearSpinnerLine();
|
|
113
|
+
write();
|
|
114
|
+
write(` ${style("✓", GREEN)} ${style(title, BOLD)}`);
|
|
115
|
+
if (detail) write(` ${style(detail, DIM)}`);
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
next(command, detail) {
|
|
119
|
+
if (!interactive) return;
|
|
120
|
+
clearSpinnerLine();
|
|
121
|
+
write();
|
|
122
|
+
write(` ${style("NEXT", CYAN)}`);
|
|
123
|
+
write(` ${style("$", CYAN)} ${style(command, BOLD)}`);
|
|
124
|
+
if (detail) write(` ${style(detail, DIM)}`);
|
|
125
|
+
write();
|
|
103
126
|
},
|
|
104
127
|
|
|
105
128
|
error(label, detail, hint) {
|
|
106
129
|
if (!interactive) return;
|
|
107
130
|
clearSpinnerLine();
|
|
108
|
-
errorOutput.write(
|
|
109
|
-
if (
|
|
131
|
+
errorOutput.write(`\n ${style("✕", RED)} ${style(label, BOLD)}\n`);
|
|
132
|
+
if (detail) errorOutput.write(` ${style(detail, DIM)}\n`);
|
|
133
|
+
if (hint) {
|
|
134
|
+
errorOutput.write(`\n ${style("NEXT", CYAN)}\n`);
|
|
135
|
+
errorOutput.write(` ${style("→", CYAN)} ${hint}\n\n`);
|
|
136
|
+
}
|
|
110
137
|
},
|
|
111
138
|
|
|
112
139
|
close() {
|
package/src/workspace-picker.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { readdir } from "node:fs/promises";
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { emitKeypressEvents } from "node:readline";
|
|
5
5
|
import process from "node:process";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
import { REENTRY_WORDMARK } from "./terminal-ui.mjs";
|
|
8
|
+
|
|
9
|
+
const MAX_VISIBLE_DIRECTORIES = 12;
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Let an interactive user choose the directory passed to Codex as its workspace.
|
|
@@ -19,19 +21,47 @@ export async function chooseWorkspaceDirectory(options = {}) {
|
|
|
19
21
|
|
|
20
22
|
const homeDirectory = resolve(options.homeDirectory ?? homedir());
|
|
21
23
|
const preferredDirectory = resolve(options.startDirectory ?? process.cwd());
|
|
22
|
-
let current = homeDirectory;
|
|
23
|
-
const shortcuts = shortcutDirectories(homeDirectory);
|
|
24
24
|
let selected = 0;
|
|
25
25
|
const wasRaw = Boolean(input.isRaw);
|
|
26
26
|
emitKeypressEvents(input);
|
|
27
27
|
input.setRawMode(true);
|
|
28
28
|
|
|
29
29
|
try {
|
|
30
|
+
const quickChoices = await createQuickChoices({ homeDirectory, preferredDirectory });
|
|
31
|
+
while (true) {
|
|
32
|
+
renderPicker(output, {
|
|
33
|
+
title: "Choose where Codex should open for approved work.",
|
|
34
|
+
choices: quickChoices,
|
|
35
|
+
selected,
|
|
36
|
+
});
|
|
37
|
+
const key = await readKey(input);
|
|
38
|
+
if (key.name === "up") {
|
|
39
|
+
selected = (selected - 1 + quickChoices.length) % quickChoices.length;
|
|
40
|
+
} else if (key.name === "down") {
|
|
41
|
+
selected = (selected + 1) % quickChoices.length;
|
|
42
|
+
} else if (key.name === "return" || key.name === "enter") {
|
|
43
|
+
const choice = quickChoices[selected];
|
|
44
|
+
if (choice.action === "use") return choice.path;
|
|
45
|
+
if (choice.action === "cancel") throw cancelledFailure();
|
|
46
|
+
break;
|
|
47
|
+
} else if (key.name === "escape" || key.sequence === "\u0003") {
|
|
48
|
+
throw cancelledFailure();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let current = homeDirectory;
|
|
53
|
+
selected = 0;
|
|
30
54
|
while (true) {
|
|
31
55
|
const directories = await readableDirectories(current);
|
|
32
|
-
const choices =
|
|
56
|
+
const choices = createBrowserChoices({ current, directories });
|
|
33
57
|
selected = Math.min(selected, choices.length - 1);
|
|
34
|
-
renderPicker(output,
|
|
58
|
+
renderPicker(output, {
|
|
59
|
+
title: "Choose another folder.",
|
|
60
|
+
current,
|
|
61
|
+
choices,
|
|
62
|
+
selected,
|
|
63
|
+
truncated: directories.truncated,
|
|
64
|
+
});
|
|
35
65
|
const key = await readKey(input);
|
|
36
66
|
if (key.name === "up") {
|
|
37
67
|
selected = (selected - 1 + choices.length) % choices.length;
|
|
@@ -40,72 +70,112 @@ export async function chooseWorkspaceDirectory(options = {}) {
|
|
|
40
70
|
} else if (key.name === "return" || key.name === "enter") {
|
|
41
71
|
const choice = choices[selected];
|
|
42
72
|
if (choice.action === "use") return choice.path;
|
|
43
|
-
if (choice.action === "cancel")
|
|
44
|
-
throw pickerFailure("workspace_selection_cancelled", "Workspace selection was cancelled");
|
|
45
|
-
}
|
|
73
|
+
if (choice.action === "cancel") throw cancelledFailure();
|
|
46
74
|
current = choice.path;
|
|
47
75
|
selected = 0;
|
|
48
76
|
} else if (key.name === "escape" || key.sequence === "\u0003") {
|
|
49
|
-
throw
|
|
77
|
+
throw cancelledFailure();
|
|
50
78
|
}
|
|
51
79
|
}
|
|
52
80
|
} finally {
|
|
53
81
|
input.setRawMode(wasRaw);
|
|
54
|
-
output.write("\
|
|
82
|
+
output.write("\u001b[2J\u001b[H");
|
|
55
83
|
}
|
|
56
84
|
}
|
|
57
85
|
|
|
58
|
-
function
|
|
86
|
+
async function createQuickChoices({ homeDirectory, preferredDirectory }) {
|
|
59
87
|
const choices = [];
|
|
60
|
-
|
|
61
|
-
|
|
88
|
+
const desktop = join(homeDirectory, "Desktop");
|
|
89
|
+
if (await isDirectory(desktop)) {
|
|
90
|
+
choices.push({
|
|
91
|
+
label: "Use Desktop (recommended)",
|
|
92
|
+
detail: desktop,
|
|
93
|
+
action: "use",
|
|
94
|
+
path: desktop,
|
|
95
|
+
});
|
|
62
96
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
97
|
+
if (preferredDirectory !== desktop && await isDirectory(preferredDirectory)) {
|
|
98
|
+
choices.push({
|
|
99
|
+
label: "Use current folder",
|
|
100
|
+
detail: preferredDirectory,
|
|
101
|
+
action: "use",
|
|
102
|
+
path: preferredDirectory,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (choices.length === 0) {
|
|
106
|
+
choices.push({
|
|
107
|
+
label: "Use Home",
|
|
108
|
+
detail: homeDirectory,
|
|
109
|
+
action: "use",
|
|
110
|
+
path: homeDirectory,
|
|
111
|
+
});
|
|
70
112
|
}
|
|
113
|
+
choices.push({ label: "Choose another folder…", action: "browse", path: homeDirectory });
|
|
114
|
+
choices.push({ label: "Cancel", action: "cancel", path: null });
|
|
115
|
+
return choices;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function createBrowserChoices({ current, directories }) {
|
|
119
|
+
const choices = [
|
|
120
|
+
{ label: "Use this folder", detail: current, action: "use", path: current },
|
|
121
|
+
];
|
|
71
122
|
const parent = dirname(current);
|
|
72
|
-
if (parent !== current)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
choices.push({ label:
|
|
123
|
+
if (parent !== current) {
|
|
124
|
+
choices.push({ label: "Go up", detail: parent, action: "open", path: parent });
|
|
125
|
+
}
|
|
126
|
+
for (const directory of directories.items) {
|
|
127
|
+
choices.push({ label: directory.name, action: "open", path: directory.path });
|
|
77
128
|
}
|
|
78
129
|
choices.push({ label: "Cancel", action: "cancel", path: null });
|
|
79
130
|
return choices;
|
|
80
131
|
}
|
|
81
132
|
|
|
82
|
-
async function
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
.map((entry) => ({ name: entry.name, path: join(directory, entry.name) }));
|
|
133
|
+
async function isDirectory(path) {
|
|
134
|
+
try {
|
|
135
|
+
return (await stat(path)).isDirectory();
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
89
139
|
}
|
|
90
140
|
|
|
91
|
-
function
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
141
|
+
async function readableDirectories(directory) {
|
|
142
|
+
let entries;
|
|
143
|
+
try {
|
|
144
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
145
|
+
} catch (error) {
|
|
146
|
+
const failure = pickerFailure("workspace_directory_unavailable", `Cannot open ${directory}`);
|
|
147
|
+
failure.cause = error;
|
|
148
|
+
throw failure;
|
|
149
|
+
}
|
|
150
|
+
const directories = entries
|
|
151
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
|
|
152
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
153
|
+
return {
|
|
154
|
+
items: directories
|
|
155
|
+
.slice(0, MAX_VISIBLE_DIRECTORIES)
|
|
156
|
+
.map((entry) => ({ name: entry.name, path: join(directory, entry.name) })),
|
|
157
|
+
truncated: directories.length > MAX_VISIBLE_DIRECTORIES,
|
|
158
|
+
};
|
|
98
159
|
}
|
|
99
160
|
|
|
100
|
-
function renderPicker(output, current, choices, selected) {
|
|
161
|
+
function renderPicker(output, { title, current, choices, selected, truncated = false }) {
|
|
101
162
|
output.write("\u001b[2J\u001b[H");
|
|
102
|
-
output.write(
|
|
103
|
-
output.write("
|
|
104
|
-
output.write(
|
|
163
|
+
for (const line of REENTRY_WORDMARK) output.write(`${line}\n`);
|
|
164
|
+
output.write(" LOCAL CONNECTOR\n\n");
|
|
165
|
+
output.write(" 1 OF 3 WORKSPACE\n");
|
|
166
|
+
output.write(` ${title}\n`);
|
|
167
|
+
if (current) output.write(` Current: ${current}\n`);
|
|
168
|
+
output.write(" ─────────────────────────────────────────────\n");
|
|
169
|
+
output.write("\n");
|
|
105
170
|
for (let index = 0; index < choices.length; index += 1) {
|
|
106
|
-
|
|
171
|
+
const choice = choices[index];
|
|
172
|
+
output.write(` ${index === selected ? "❯" : " "} ${choice.label}\n`);
|
|
173
|
+
if (choice.detail) output.write(` ${choice.detail}\n`);
|
|
174
|
+
}
|
|
175
|
+
if (truncated) {
|
|
176
|
+
output.write(`\n Showing the first ${MAX_VISIBLE_DIRECTORIES} folders.\n`);
|
|
107
177
|
}
|
|
108
|
-
output.write("\n
|
|
178
|
+
output.write("\n ↑↓ Move Enter Select Esc Cancel");
|
|
109
179
|
}
|
|
110
180
|
|
|
111
181
|
function readKey(input) {
|
|
@@ -118,6 +188,10 @@ function readKey(input) {
|
|
|
118
188
|
});
|
|
119
189
|
}
|
|
120
190
|
|
|
191
|
+
function cancelledFailure() {
|
|
192
|
+
return pickerFailure("workspace_selection_cancelled", "Workspace selection was cancelled");
|
|
193
|
+
}
|
|
194
|
+
|
|
121
195
|
function pickerFailure(code, message) {
|
|
122
196
|
const error = new Error(message);
|
|
123
197
|
error.code = code;
|