@edgehero/pi-dispatch 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/.env.example +160 -0
- package/deploy/com.pi-dispatch.worker.plist +66 -0
- package/deploy/nssm-install.cmd +59 -0
- package/deploy/receiver.service +36 -0
- package/deploy/worker-env-wrapper.cmd +50 -0
- package/deploy/worker-env-wrapper.sh +63 -0
- package/deploy/worker.service +55 -0
- package/package.json +83 -0
- package/src/azure-auth.mjs +61 -0
- package/src/azure-host.mjs +236 -0
- package/src/azure-identity.mjs +63 -0
- package/src/azure-prompt.mjs +118 -0
- package/src/branch.mjs +80 -0
- package/src/budget.mjs +179 -0
- package/src/cli.mjs +208 -0
- package/src/config.mjs +329 -0
- package/src/connection.mjs +40 -0
- package/src/cron.mjs +94 -0
- package/src/docker-run.mjs +119 -0
- package/src/doctor.mjs +1127 -0
- package/src/env-allowlist.mjs +198 -0
- package/src/env-file.mjs +153 -0
- package/src/exit-code.mjs +32 -0
- package/src/flow-gate.mjs +82 -0
- package/src/forgejo-auth.mjs +77 -0
- package/src/forgejo-host.mjs +172 -0
- package/src/forgejo-identity.mjs +74 -0
- package/src/forgejo-prompt.mjs +123 -0
- package/src/forges.mjs +148 -0
- package/src/get-token.mjs +226 -0
- package/src/git-dirty.mjs +16 -0
- package/src/github-app-setup.mjs +517 -0
- package/src/github-host.mjs +159 -0
- package/src/github-prompt.mjs +286 -0
- package/src/gitlab-auth.mjs +72 -0
- package/src/gitlab-host.mjs +200 -0
- package/src/gitlab-identity.mjs +61 -0
- package/src/gitlab-prompt.mjs +123 -0
- package/src/identity.mjs +57 -0
- package/src/image-preflight.mjs +180 -0
- package/src/import-pi.mjs +451 -0
- package/src/index.mjs +177 -0
- package/src/init.mjs +77 -0
- package/src/job-id.mjs +100 -0
- package/src/materialize.mjs +138 -0
- package/src/outbox.mjs +179 -0
- package/src/packages.mjs +188 -0
- package/src/pause-windows.mjs +218 -0
- package/src/prepare-github.mjs +260 -0
- package/src/prepare-local.mjs +76 -0
- package/src/prepare.mjs +199 -0
- package/src/pricing.mjs +168 -0
- package/src/processor.mjs +360 -0
- package/src/queue.mjs +152 -0
- package/src/run-container.mjs +133 -0
- package/src/run-history.mjs +534 -0
- package/src/runtime-settings.mjs +188 -0
- package/src/sandbox-cli.mjs +156 -0
- package/src/sandbox-store.mjs +269 -0
- package/src/sandbox.mjs +171 -0
- package/src/scheduler-stall-guard.mjs +67 -0
- package/src/schedules.mjs +62 -0
- package/src/service.mjs +677 -0
- package/src/session-key.mjs +108 -0
- package/src/session-store.mjs +249 -0
- package/src/start.mjs +502 -0
- package/src/subscriptions.mjs +208 -0
- package/src/triggers.mjs +491 -0
- package/src/up.mjs +315 -0
package/src/up.mjs
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pi-dispatch up` — the quickstart as ONE consented pass (issue #80). The README's five hand-typed
|
|
3
|
+
* chores (pull the job image, remember the re-tag, start Valkey, init, doctor) become a sequence, but
|
|
4
|
+
* every host mutation keeps a human in front of it: the EXACT command is printed, then a y/N prompt
|
|
5
|
+
* that defaults to No (`--yes` accepts them all, and the commands are still printed — consent is
|
|
6
|
+
* skippable, visibility is not).
|
|
7
|
+
*
|
|
8
|
+
* Doctrine this module must never drift from:
|
|
9
|
+
* - init's never-clobber is contractual: up always calls runInit, and it always leaves existing
|
|
10
|
+
* files (and an existing WEBHOOK_SECRET value) untouched — see env-file.mjs.
|
|
11
|
+
* - up only ever pulls the repo's OWN default image (ghcr.io/edgehero/pi-job:latest, re-tagged
|
|
12
|
+
* pi-job:latest). NEVER a trigger-named run.image: those are operator-declared and doctor's
|
|
13
|
+
* presence check covers them — a setup convenience must not become "pull whatever the triggers
|
|
14
|
+
* file happens to name" (the same reasoning as jobs running with --pull=never).
|
|
15
|
+
* - no secrets printed: the generated WEBHOOK_SECRET is announced, never echoed.
|
|
16
|
+
*
|
|
17
|
+
* Converge-style, not transactional: a declined or failed step is reported and the pass continues, so
|
|
18
|
+
* one flaky pull does not hide the doctor report that says what else is missing. Exit code mirrors
|
|
19
|
+
* doctor's: 0 unless the docker daemon was unreachable up front (nothing else can be probed, so up
|
|
20
|
+
* stops there with 1) or doctor itself returned nonzero (its code is returned verbatim).
|
|
21
|
+
*/
|
|
22
|
+
import { randomBytes } from "node:crypto";
|
|
23
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
24
|
+
import { chmodSync, existsSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { connect as netConnect } from "node:net";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { updateEnvFile } from "./env-file.mjs";
|
|
28
|
+
|
|
29
|
+
// The one image up may ever fetch, and the local name jobs run under. Literal on purpose (not
|
|
30
|
+
// env.PI_JOB_IMAGE): an operator who pointed PI_JOB_IMAGE elsewhere has outgrown the quickstart, and
|
|
31
|
+
// up pulling an arbitrary configured name would break the only-our-own-image doctrine above.
|
|
32
|
+
const UPSTREAM_IMAGE = "ghcr.io/edgehero/pi-job:latest";
|
|
33
|
+
const LOCAL_IMAGE = "pi-job:latest";
|
|
34
|
+
const PULL_ARGS = ["pull", UPSTREAM_IMAGE];
|
|
35
|
+
const TAG_ARGS = ["tag", UPSTREAM_IMAGE, LOCAL_IMAGE];
|
|
36
|
+
|
|
37
|
+
// deploy/docker-compose.yml's Valkey service, reproduced as one docker run: same image, AOF on
|
|
38
|
+
// (REQ-QUEUE-BURST-NO-DROP), bound to localhost only (the queue is not a public surface), same
|
|
39
|
+
// healthcheck, restart unless-stopped, and a named volume standing in for the compose volume.
|
|
40
|
+
const VALKEY_VOLUME_ARGS = ["volume", "create", "pi-dispatch-valkey-data"];
|
|
41
|
+
const VALKEY_RUN_ARGS = [
|
|
42
|
+
"run",
|
|
43
|
+
"-d",
|
|
44
|
+
"--name",
|
|
45
|
+
"pi-dispatch-valkey",
|
|
46
|
+
"--restart",
|
|
47
|
+
"unless-stopped",
|
|
48
|
+
"-p",
|
|
49
|
+
"127.0.0.1:6379:6379",
|
|
50
|
+
"-v",
|
|
51
|
+
"pi-dispatch-valkey-data:/data",
|
|
52
|
+
"--health-cmd",
|
|
53
|
+
"valkey-cli ping",
|
|
54
|
+
"--health-interval",
|
|
55
|
+
"10s",
|
|
56
|
+
"--health-timeout",
|
|
57
|
+
"3s",
|
|
58
|
+
"--health-retries",
|
|
59
|
+
"5",
|
|
60
|
+
"valkey/valkey:8",
|
|
61
|
+
"valkey-server",
|
|
62
|
+
"--appendonly",
|
|
63
|
+
"yes",
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
export async function runUp(argv = [], deps = {}) {
|
|
67
|
+
const {
|
|
68
|
+
env = process.env,
|
|
69
|
+
spawn = nodeSpawn,
|
|
70
|
+
out = (s) => process.stdout.write(s),
|
|
71
|
+
prompt = defaultPrompt,
|
|
72
|
+
fs = { existsSync, readFileSync, writeFileSync, renameSync, statSync, chmodSync },
|
|
73
|
+
probeTcp = defaultProbeTcp,
|
|
74
|
+
cwd = process.cwd(),
|
|
75
|
+
// Injected so tests can assert the secret never reaches output without fishing it back out of
|
|
76
|
+
// the written file. 32 bytes hex, matching doctor's `openssl rand -hex 32` fix line.
|
|
77
|
+
randomHex = () => randomBytes(32).toString("hex"),
|
|
78
|
+
} = deps;
|
|
79
|
+
let { runInitFn, runDoctorFn } = deps;
|
|
80
|
+
const yes = argv.includes("--yes");
|
|
81
|
+
const summary = [];
|
|
82
|
+
|
|
83
|
+
out("pi-dispatch up — one pass over the quickstart; every docker action asks first (--yes accepts)\n\n");
|
|
84
|
+
|
|
85
|
+
// (a) docker binary + daemon, before anything is offered: every mutation below runs through the
|
|
86
|
+
// docker CLI, so with the daemon down the prompts would only collect consent for failures.
|
|
87
|
+
// Distinguishes not-on-PATH from daemon-down exactly as doctor does (spawn error vs nonzero exit).
|
|
88
|
+
const dockerCode = await runCmd(spawn, "docker", ["version"]);
|
|
89
|
+
if (dockerCode !== 0) {
|
|
90
|
+
out(dockerCode === null ? "✗ Docker not found\n → install Docker — `docker` was not found on PATH\n" : "✗ Docker daemon not responding\n → start Docker (the daemon is not responding)\n");
|
|
91
|
+
out("\nup: cannot continue without Docker — fix the above, then re-run `pi-dispatch up`.\n");
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
out("✓ Docker daemon reachable\n");
|
|
95
|
+
|
|
96
|
+
// (b) the default job image. Presence first, so the happy path re-run prompts for nothing.
|
|
97
|
+
if (await runCmd(spawn, "docker", ["image", "inspect", LOCAL_IMAGE]) === 0) {
|
|
98
|
+
out(`✓ Job image present (${LOCAL_IMAGE})\n`);
|
|
99
|
+
summary.push(["job image", `already present (${LOCAL_IMAGE})`]);
|
|
100
|
+
} else {
|
|
101
|
+
const accepted = await consent(
|
|
102
|
+
`The default job image (${LOCAL_IMAGE}) is not on this host. up would run:`,
|
|
103
|
+
[`docker ${PULL_ARGS.join(" ")}`, `docker ${TAG_ARGS.join(" ")}`],
|
|
104
|
+
{ yes, out, prompt },
|
|
105
|
+
);
|
|
106
|
+
if (!accepted) {
|
|
107
|
+
out("skipped — pull it later with the two commands above (or build image/Dockerfile yourself)\n");
|
|
108
|
+
summary.push(["job image", "skipped (declined) — jobs run with --pull=never, so nothing fetches it later"]);
|
|
109
|
+
} else if (await runStreamed(spawn, "docker", PULL_ARGS, out) !== 0) {
|
|
110
|
+
out("✗ docker pull failed — continuing; doctor below will re-check the image\n");
|
|
111
|
+
summary.push(["job image", "pull FAILED — re-run `pi-dispatch up`, or pull by hand"]);
|
|
112
|
+
} else if (await runStreamed(spawn, "docker", TAG_ARGS, out) !== 0) {
|
|
113
|
+
out("✗ docker tag failed — continuing; doctor below will re-check the image\n");
|
|
114
|
+
summary.push(["job image", `pulled, but tagging as ${LOCAL_IMAGE} FAILED — re-run the tag command by hand`]);
|
|
115
|
+
} else {
|
|
116
|
+
out(`✓ pulled and tagged ${LOCAL_IMAGE}\n`);
|
|
117
|
+
summary.push(["job image", `pulled ${UPSTREAM_IMAGE} and tagged it ${LOCAL_IMAGE}`]);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// (c) Valkey. A bare TCP probe of the default bind, not a redis PING: dependency-free, and the
|
|
122
|
+
// honest claim is only "something is listening". If our own compose-named container is up, docker
|
|
123
|
+
// can say so; if a listener exists that we cannot name, up must NOT offer a second Valkey — the
|
|
124
|
+
// port is taken, and `docker run` would only fail after consent.
|
|
125
|
+
if (await probeTcp("127.0.0.1", 6379)) {
|
|
126
|
+
const ps = await runCmdCapture(spawn, "docker", ["ps", "--filter", "name=pi-dispatch-valkey", "--format", "{{.Names}}"]);
|
|
127
|
+
const ours = ps.code === 0 && ps.output.split("\n").map((l) => l.trim()).includes("pi-dispatch-valkey");
|
|
128
|
+
out(`✓ something is listening on 6379 — assuming your Valkey${ours ? " (it is the pi-dispatch-valkey container)" : ""}\n`);
|
|
129
|
+
summary.push(["valkey", ours ? "container pi-dispatch-valkey already running" : "port 6379 already has a listener — left alone"]);
|
|
130
|
+
} else {
|
|
131
|
+
const accepted = await consent(
|
|
132
|
+
"Nothing is listening on 127.0.0.1:6379. up would start Valkey (same semantics as deploy/docker-compose.yml):",
|
|
133
|
+
[`docker ${VALKEY_VOLUME_ARGS.join(" ")}`, `docker ${quoteArgs(VALKEY_RUN_ARGS)}`],
|
|
134
|
+
{ yes, out, prompt },
|
|
135
|
+
);
|
|
136
|
+
if (!accepted) {
|
|
137
|
+
out("skipped — start it later with `docker compose -f deploy/docker-compose.yml up -d`\n");
|
|
138
|
+
summary.push(["valkey", "skipped (declined) — the queue needs it before `pi-dispatch worker` can drain"]);
|
|
139
|
+
} else if (await runStreamed(spawn, "docker", VALKEY_VOLUME_ARGS, out) !== 0) {
|
|
140
|
+
out("✗ docker volume create failed — continuing; doctor below will re-check Valkey\n");
|
|
141
|
+
summary.push(["valkey", "volume create FAILED — `docker compose -f deploy/docker-compose.yml up -d` is the fallback"]);
|
|
142
|
+
} else if (await runStreamed(spawn, "docker", VALKEY_RUN_ARGS, out) !== 0) {
|
|
143
|
+
out("✗ docker run failed — continuing; doctor below will re-check Valkey\n");
|
|
144
|
+
summary.push(["valkey", "container start FAILED — `docker compose -f deploy/docker-compose.yml up -d` is the fallback"]);
|
|
145
|
+
} else {
|
|
146
|
+
out("✓ started Valkey (container pi-dispatch-valkey, AOF on, bound to 127.0.0.1)\n");
|
|
147
|
+
summary.push(["valkey", "started container pi-dispatch-valkey (durable: --appendonly yes, restart unless-stopped)"]);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// (d) init — always, and unconditionally safe to re-run: its never-clobber is contractual, so an
|
|
152
|
+
// existing file is only ever reported, never overwritten. No consent gate for the same reason —
|
|
153
|
+
// nothing the operator wrote can be lost here.
|
|
154
|
+
out("\ninit (never overwrites — an existing file is reported and kept):\n");
|
|
155
|
+
runInitFn ??= (await import("./init.mjs")).runInit;
|
|
156
|
+
runInitFn(cwd, { out });
|
|
157
|
+
summary.push(["init", "ran — existing files were kept untouched, missing ones scaffolded"]);
|
|
158
|
+
|
|
159
|
+
// (e) WEBHOOK_SECRET, only into a .env that exists (init just scaffolded one unless the operator
|
|
160
|
+
// keeps env elsewhere — a service-manager deployment gets no file invented for it). Same
|
|
161
|
+
// never-clobber contract at key granularity: a value the operator set survives. The value itself
|
|
162
|
+
// is NEVER printed — a webhook secret in a scrollback is a webhook secret in a pastebin.
|
|
163
|
+
const envPath = join(cwd, ".env");
|
|
164
|
+
if (fs.existsSync(envPath)) {
|
|
165
|
+
if (updateEnvFile(envPath, "WEBHOOK_SECRET", randomHex(), { fs }).changed) {
|
|
166
|
+
out("\n✓ generated WEBHOOK_SECRET into .env (32 random bytes, hex — value not shown)\n");
|
|
167
|
+
summary.push(["WEBHOOK_SECRET", "generated into .env (value not shown; the receiver verifies deliveries with it)"]);
|
|
168
|
+
} else {
|
|
169
|
+
out("\n✓ WEBHOOK_SECRET already set in .env — left untouched\n");
|
|
170
|
+
summary.push(["WEBHOOK_SECRET", "already set — left untouched"]);
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
summary.push(["WEBHOOK_SECRET", "no .env here — skipped (set it wherever your env lives)"]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// (f) doctor — always, verbatim: up converges what it can, doctor is the judge of what remains
|
|
177
|
+
// (provider key, forge env, overlay …), and its verdict is up's exit code.
|
|
178
|
+
out("\ndoctor:\n");
|
|
179
|
+
runDoctorFn ??= (await import("./doctor.mjs")).runDoctor;
|
|
180
|
+
const doctorCode = await runDoctorFn(env, { out });
|
|
181
|
+
|
|
182
|
+
// (g) the summary: what ran, what was skipped, what was already there — then the two commands
|
|
183
|
+
// that actually start work, so "up is green" flows straight into the first job.
|
|
184
|
+
out("\nup: summary\n");
|
|
185
|
+
for (const [name, note] of summary) {
|
|
186
|
+
out(` ${name.padEnd(15)} ${note}\n`);
|
|
187
|
+
}
|
|
188
|
+
out(`
|
|
189
|
+
Next:
|
|
190
|
+
edit ${envPath}
|
|
191
|
+
set ANTHROPIC_API_KEY (or your provider's key) — already logged into pi? leave it blank
|
|
192
|
+
pi-dispatch worker
|
|
193
|
+
drain the queue (keep it running in its own terminal, or as a service)
|
|
194
|
+
pi-dispatch run ./my-project --task "add type hints to utils.py"
|
|
195
|
+
queue your first job from another terminal
|
|
196
|
+
`);
|
|
197
|
+
return doctorCode;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Show the exact commands, then ask. Printing happens with or without `--yes`: consent is what the
|
|
202
|
+
* flag waives, never visibility — every host mutation is on screen before it runs. The prompt
|
|
203
|
+
* defaults to No; only an explicit y/yes (any case) accepts.
|
|
204
|
+
*/
|
|
205
|
+
async function consent(intro, commands, { yes, out, prompt }) {
|
|
206
|
+
out(`\n${intro}\n`);
|
|
207
|
+
for (const c of commands) out(` ${c}\n`);
|
|
208
|
+
if (yes) {
|
|
209
|
+
out("--yes: accepted\n");
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
const answer = await prompt("Proceed? [y/N] ");
|
|
213
|
+
return /^y(es)?$/i.test(String(answer ?? "").trim());
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Re-join an argv array for display, quoting the args that contain spaces (e.g. "valkey-cli ping"). */
|
|
217
|
+
function quoteArgs(args) {
|
|
218
|
+
return args.map((a) => (a.includes(" ") ? `"${a}"` : a)).join(" ");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Exit code of a spawned command; null when it could not launch (not on PATH) — mirrors doctor. */
|
|
222
|
+
function runCmd(spawn, cmd, args) {
|
|
223
|
+
return new Promise((resolve) => {
|
|
224
|
+
let child;
|
|
225
|
+
try {
|
|
226
|
+
child = spawn(cmd, args, { stdio: "ignore" });
|
|
227
|
+
} catch {
|
|
228
|
+
resolve(null);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
child.on("error", () => resolve(null)); // ENOENT etc. — the binary is not available
|
|
232
|
+
child.on("close", (code) => resolve(code));
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Run a CONSENTED command with its stdout+stderr streamed to `out` as it happens — a docker pull's
|
|
238
|
+
* progress is the operator's confirmation that the thing they approved is the thing running.
|
|
239
|
+
*/
|
|
240
|
+
function runStreamed(spawn, cmd, args, out) {
|
|
241
|
+
return new Promise((resolve) => {
|
|
242
|
+
let child;
|
|
243
|
+
try {
|
|
244
|
+
child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
245
|
+
} catch {
|
|
246
|
+
resolve(null);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
child.stdout?.on("data", (d) => out(String(d)));
|
|
250
|
+
child.stderr?.on("data", (d) => out(String(d)));
|
|
251
|
+
child.on("error", () => resolve(null));
|
|
252
|
+
child.on("close", (code) => resolve(code));
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Like runCmd but with stdout+stderr captured, for read-only lookups (docker ps). */
|
|
257
|
+
function runCmdCapture(spawn, cmd, args) {
|
|
258
|
+
return new Promise((resolve) => {
|
|
259
|
+
let child;
|
|
260
|
+
try {
|
|
261
|
+
child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
262
|
+
} catch {
|
|
263
|
+
resolve({ code: null, output: "" });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
let output = "";
|
|
267
|
+
child.stdout?.on("data", (d) => (output += d));
|
|
268
|
+
child.stderr?.on("data", (d) => (output += d));
|
|
269
|
+
child.on("error", () => resolve({ code: null, output }));
|
|
270
|
+
child.on("close", (code) => resolve({ code, output }));
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Is anything listening on host:port? A plain TCP connect, deliberately not a redis PING: up needs
|
|
276
|
+
* "occupied or free", and a protocol probe would add a dependency only to answer a question doctor
|
|
277
|
+
* already answers properly right afterwards.
|
|
278
|
+
*/
|
|
279
|
+
function defaultProbeTcp(host, port, timeoutMs = 1500) {
|
|
280
|
+
return new Promise((resolve) => {
|
|
281
|
+
const socket = netConnect({ host, port });
|
|
282
|
+
const done = (result) => {
|
|
283
|
+
socket.destroy();
|
|
284
|
+
resolve(result);
|
|
285
|
+
};
|
|
286
|
+
socket.setTimeout(timeoutMs, () => done(false));
|
|
287
|
+
socket.on("connect", () => done(true));
|
|
288
|
+
socket.on("error", () => done(false));
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Interactive y/N question on the real terminal; tests inject their own `prompt` instead.
|
|
294
|
+
*
|
|
295
|
+
* Non-TTY stdin is an immediate decline WITHOUT touching readline: against an already-ended stream
|
|
296
|
+
* (`up < /dev/null`, CI) `rl.question()`'s promise never settles and holds no handle, so Node would
|
|
297
|
+
* drain the event loop and exit 0 MID-SEQUENCE — silently skipping init and doctor while looking
|
|
298
|
+
* like success. The decline is printed so the transcript shows the question was asked and defaulted.
|
|
299
|
+
* A closed-mid-question readline (ctrl-D on a real terminal) declines the same way.
|
|
300
|
+
*/
|
|
301
|
+
export async function defaultPrompt(question, { input = process.stdin, output = process.stdout, isTTY = process.stdin.isTTY } = {}) {
|
|
302
|
+
if (!isTTY) {
|
|
303
|
+
output.write(`${question}(no interactive stdin — defaulting to No)\n`);
|
|
304
|
+
return "";
|
|
305
|
+
}
|
|
306
|
+
const { createInterface } = await import("node:readline/promises");
|
|
307
|
+
const rl = createInterface({ input, output });
|
|
308
|
+
try {
|
|
309
|
+
return await rl.question(question);
|
|
310
|
+
} catch {
|
|
311
|
+
return ""; // readline closed before an answer -- same contract as an empty answer: No
|
|
312
|
+
} finally {
|
|
313
|
+
rl.close();
|
|
314
|
+
}
|
|
315
|
+
}
|