@getdial/cli 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/README.md +19 -0
- package/dist/cli.js +70 -0
- package/dist/commands/doctor.js +95 -0
- package/dist/commands/listen/index.js +29 -0
- package/dist/commands/listen/install.js +35 -0
- package/dist/commands/listen/status.js +37 -0
- package/dist/commands/listen/uninstall.js +19 -0
- package/dist/commands/onboard.js +77 -0
- package/dist/commands/signup.js +36 -0
- package/dist/commands/wait-for.js +119 -0
- package/dist/lib/api.js +51 -0
- package/dist/lib/event-filter.js +33 -0
- package/dist/lib/log.js +34 -0
- package/dist/lib/paths.js +24 -0
- package/dist/lib/pubnub.js +103 -0
- package/dist/lib/state.js +95 -0
- package/dist/lib/supervisor/index.js +94 -0
- package/dist/lib/supervisor/launchd.js +145 -0
- package/dist/lib/supervisor/systemd.js +90 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# @getdial/cli
|
|
2
|
+
|
|
3
|
+
The Dial CLI.
|
|
4
|
+
|
|
5
|
+
Install via the bootstrap script:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
curl -fsSL https://dial.up.railway.app/install | bash
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or directly from npm:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g @getdial/cli
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires Node 22+.
|
|
18
|
+
|
|
19
|
+
See `docs/superpowers/specs/2026-05-25-dial-cli-and-listen-service-design.md` for design.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { runDoctor } from "./commands/doctor.js";
|
|
4
|
+
import { runSignup } from "./commands/signup.js";
|
|
5
|
+
import { runOnboard } from "./commands/onboard.js";
|
|
6
|
+
import { runListen } from "./commands/listen/index.js";
|
|
7
|
+
import { runListenInstall } from "./commands/listen/install.js";
|
|
8
|
+
import { runListenUninstall } from "./commands/listen/uninstall.js";
|
|
9
|
+
import { runListenStatus } from "./commands/listen/status.js";
|
|
10
|
+
import { runWaitFor } from "./commands/wait-for.js";
|
|
11
|
+
const program = new Command();
|
|
12
|
+
program
|
|
13
|
+
.name("dial")
|
|
14
|
+
.description("Dial CLI — set up your account and run the listen service.")
|
|
15
|
+
.version("0.1.0");
|
|
16
|
+
program
|
|
17
|
+
.command("doctor")
|
|
18
|
+
.description("Report state and what to do next.")
|
|
19
|
+
.option("--json", "machine-readable output")
|
|
20
|
+
.action(async (opts) => process.exit(await runDoctor({ json: !!opts.json })));
|
|
21
|
+
program
|
|
22
|
+
.command("signup <email>")
|
|
23
|
+
.description("Request an email OTP for the given address.")
|
|
24
|
+
.option("--force", "overwrite any pending signup")
|
|
25
|
+
.option("--json", "machine-readable output")
|
|
26
|
+
.action(async (email, opts) => process.exit(await runSignup(email, { force: !!opts.force, json: !!opts.json })));
|
|
27
|
+
program
|
|
28
|
+
.command("onboard")
|
|
29
|
+
.description("Verify the OTP and finish onboarding.")
|
|
30
|
+
.option("--verification-id <id>", "explicit verification id (falls back to local pending signup)")
|
|
31
|
+
.requiredOption("--code <code>", "6-digit OTP from your email")
|
|
32
|
+
.option("--json", "machine-readable output")
|
|
33
|
+
.action(async (opts) => process.exit(await runOnboard({ verificationId: opts.verificationId, code: opts.code, json: !!opts.json })));
|
|
34
|
+
const listen = program
|
|
35
|
+
.command("listen")
|
|
36
|
+
.description("Run the listen worker (used by launchd/systemd).")
|
|
37
|
+
.action(async () => process.exit(await runListen()));
|
|
38
|
+
listen
|
|
39
|
+
.command("install")
|
|
40
|
+
.description("Install the listen daemon (launchd or systemd user unit).")
|
|
41
|
+
.option("--json", "machine-readable output")
|
|
42
|
+
.action(async (opts) => process.exit(await runListenInstall({ json: !!opts.json })));
|
|
43
|
+
listen
|
|
44
|
+
.command("uninstall")
|
|
45
|
+
.description("Stop and remove the listen daemon.")
|
|
46
|
+
.option("--json", "machine-readable output")
|
|
47
|
+
.action(async (opts) => process.exit(await runListenUninstall({ json: !!opts.json })));
|
|
48
|
+
listen
|
|
49
|
+
.command("status")
|
|
50
|
+
.description("Report listen daemon state and last events.")
|
|
51
|
+
.option("--json", "machine-readable output")
|
|
52
|
+
.action(async (opts) => process.exit(await runListenStatus({ json: !!opts.json })));
|
|
53
|
+
program
|
|
54
|
+
.command("wait-for <event-type>")
|
|
55
|
+
.description("Wait for the next matching event in the listen log (e.g. call.ended, message.received).")
|
|
56
|
+
.option("-f, --field <name=value>", "exact match on a top-level field (repeatable)", (v, prev = []) => [...prev, v], [])
|
|
57
|
+
.option("-r, --regex <name=pattern>", "regex match on a top-level field (repeatable). Pattern can be /re/flags or a bare RE.", (v, prev = []) => [...prev, v], [])
|
|
58
|
+
.option("-t, --timeout <seconds>", "timeout in seconds (default 30)", (v) => parseInt(v, 10), 30)
|
|
59
|
+
.option("--json", "machine-readable output")
|
|
60
|
+
.action(async (eventType, opts) => process.exit(await runWaitFor({
|
|
61
|
+
eventType,
|
|
62
|
+
fields: opts.field,
|
|
63
|
+
regexes: opts.regex,
|
|
64
|
+
timeoutSeconds: opts.timeout,
|
|
65
|
+
json: !!opts.json,
|
|
66
|
+
})));
|
|
67
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
68
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
69
|
+
process.exit(2);
|
|
70
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { readPendingSignup, readAuth } from "../lib/state.js";
|
|
2
|
+
import { apiGet, baseUrl, pingBackend } from "../lib/api.js";
|
|
3
|
+
import { supervisorStatus, lastEventAtFromLog } from "../lib/supervisor/index.js";
|
|
4
|
+
import { paths } from "../lib/paths.js";
|
|
5
|
+
const OTP_EXPIRY_MS = 10 * 60 * 1000;
|
|
6
|
+
async function buildReport() {
|
|
7
|
+
const ping = await pingBackend();
|
|
8
|
+
const auth = readAuth();
|
|
9
|
+
const pending = readPendingSignup();
|
|
10
|
+
let keyValid = null;
|
|
11
|
+
if (auth?.api_key) {
|
|
12
|
+
const res = await apiGet("/api/v1/account", auth.api_key);
|
|
13
|
+
keyValid = res.ok;
|
|
14
|
+
}
|
|
15
|
+
const pendingAgeMs = pending ? Date.now() - Date.parse(pending.created_at) : null;
|
|
16
|
+
const pendingExpired = pendingAgeMs == null ? null : pendingAgeMs > OTP_EXPIRY_MS;
|
|
17
|
+
let listenState = { installed: false, running: false, last_event_at: null };
|
|
18
|
+
try {
|
|
19
|
+
const s = supervisorStatus();
|
|
20
|
+
listenState = {
|
|
21
|
+
installed: s.installed,
|
|
22
|
+
running: s.running,
|
|
23
|
+
last_event_at: lastEventAtFromLog(paths().listenLog),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// unsupported platform — leave defaults
|
|
28
|
+
}
|
|
29
|
+
let nextStep;
|
|
30
|
+
if (!auth) {
|
|
31
|
+
if (pending && pendingExpired === false)
|
|
32
|
+
nextStep = "onboard";
|
|
33
|
+
else if (pending && pendingExpired)
|
|
34
|
+
nextStep = "resend_otp";
|
|
35
|
+
else
|
|
36
|
+
nextStep = "signup";
|
|
37
|
+
}
|
|
38
|
+
else if (keyValid === false) {
|
|
39
|
+
nextStep = "signup";
|
|
40
|
+
}
|
|
41
|
+
else if (!listenState.installed || !listenState.running) {
|
|
42
|
+
nextStep = "install_listen";
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
nextStep = "ready";
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
cli: { version: "0.1.0", node: process.versions.node },
|
|
49
|
+
backend: { url: baseUrl(), reachable: ping.reachable, latency_ms: ping.latencyMs },
|
|
50
|
+
auth: {
|
|
51
|
+
signed_in: Boolean(auth),
|
|
52
|
+
email: auth?.email ?? null,
|
|
53
|
+
account_id: auth?.account_id ?? null,
|
|
54
|
+
api_key_present: Boolean(auth?.api_key),
|
|
55
|
+
api_key_fingerprint: auth?.api_key ? auth.api_key.slice(-4) : null,
|
|
56
|
+
key_valid: keyValid,
|
|
57
|
+
},
|
|
58
|
+
pending_otp: {
|
|
59
|
+
verification_id: pending?.verification_id ?? null,
|
|
60
|
+
age_seconds: pendingAgeMs == null ? null : Math.round(pendingAgeMs / 1000),
|
|
61
|
+
expired: pendingExpired,
|
|
62
|
+
},
|
|
63
|
+
listen: listenState,
|
|
64
|
+
next_step: nextStep,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function humanRender(r) {
|
|
68
|
+
const lines = [];
|
|
69
|
+
lines.push(`dial ${r.cli.version} (node ${r.cli.node})`);
|
|
70
|
+
lines.push(`backend: ${r.backend.url} ${r.backend.reachable ? `reachable${r.backend.latency_ms != null ? ` (${r.backend.latency_ms}ms)` : ""}` : "UNREACHABLE"}`);
|
|
71
|
+
if (r.auth.signed_in) {
|
|
72
|
+
lines.push(`auth: signed in as ${r.auth.email} (account ${r.auth.account_id}, key sk_live_***${r.auth.api_key_fingerprint})${r.auth.key_valid === false ? " [key rejected by backend]" : ""}`);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
lines.push(`auth: not signed in`);
|
|
76
|
+
}
|
|
77
|
+
if (r.pending_otp.verification_id) {
|
|
78
|
+
lines.push(`pending otp: ${r.pending_otp.age_seconds}s old${r.pending_otp.expired ? " (EXPIRED)" : ""}`);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
lines.push(`pending otp: none`);
|
|
82
|
+
}
|
|
83
|
+
lines.push(`listen: ${r.listen.installed ? (r.listen.running ? "running" : "installed (stopped)") : "not installed"}${r.listen.last_event_at ? `, last event ${r.listen.last_event_at}` : ""}`);
|
|
84
|
+
lines.push("");
|
|
85
|
+
lines.push(`next: ${r.next_step}`);
|
|
86
|
+
return lines.join("\n");
|
|
87
|
+
}
|
|
88
|
+
export async function runDoctor(opts) {
|
|
89
|
+
const report = await buildReport();
|
|
90
|
+
if (opts.json)
|
|
91
|
+
console.log(JSON.stringify(report, null, 2));
|
|
92
|
+
else
|
|
93
|
+
console.log(humanRender(report));
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
+
import { readAuth } from "../../lib/state.js";
|
|
3
|
+
import { startWorker } from "../../lib/pubnub.js";
|
|
4
|
+
import { appendJsonl } from "../../lib/log.js";
|
|
5
|
+
import { paths } from "../../lib/paths.js";
|
|
6
|
+
function isSupervised() {
|
|
7
|
+
return Boolean(process.env.LAUNCHD_SOCKET || process.env.LAUNCH_DAEMON || process.env.INVOCATION_ID);
|
|
8
|
+
}
|
|
9
|
+
export async function runListen() {
|
|
10
|
+
const auth = readAuth();
|
|
11
|
+
if (!auth) {
|
|
12
|
+
appendJsonl(paths().listenLog, { ts: new Date().toISOString(), lifecycle: "startup", ok: false, error: "no auth.json" });
|
|
13
|
+
if (isSupervised()) {
|
|
14
|
+
await delay(30_000);
|
|
15
|
+
}
|
|
16
|
+
return 1;
|
|
17
|
+
}
|
|
18
|
+
appendJsonl(paths().listenLog, { ts: new Date().toISOString(), lifecycle: "startup", ok: true, account_id: auth.account_id });
|
|
19
|
+
const ctrl = startWorker(auth.api_key, auth.account_id);
|
|
20
|
+
const onSignal = async (sig) => {
|
|
21
|
+
appendJsonl(paths().listenLog, { ts: new Date().toISOString(), lifecycle: "shutdown", signal: sig });
|
|
22
|
+
await ctrl.stop();
|
|
23
|
+
};
|
|
24
|
+
process.on("SIGTERM", onSignal);
|
|
25
|
+
process.on("SIGINT", onSignal);
|
|
26
|
+
await ctrl.whenStopped;
|
|
27
|
+
const code = process.exitCode;
|
|
28
|
+
return typeof code === "number" ? code : 0;
|
|
29
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readAuth } from "../../lib/state.js";
|
|
2
|
+
import { installSupervised } from "../../lib/supervisor/index.js";
|
|
3
|
+
function resolveDialPath() {
|
|
4
|
+
return process.env.DIAL_BIN_OVERRIDE ?? process.argv[1] ?? "dial";
|
|
5
|
+
}
|
|
6
|
+
export async function runListenInstall(opts) {
|
|
7
|
+
const auth = readAuth();
|
|
8
|
+
if (!auth) {
|
|
9
|
+
if (opts.json)
|
|
10
|
+
console.log(JSON.stringify({ ok: false, code: "not_signed_in" }));
|
|
11
|
+
else
|
|
12
|
+
console.error("Not signed in. Run `dial signup` and `dial onboard` first.");
|
|
13
|
+
return 1;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const result = installSupervised(resolveDialPath());
|
|
17
|
+
if (opts.json)
|
|
18
|
+
console.log(JSON.stringify({ ok: true, changed: result.changed, unit_path: result.unitPath, warnings: result.warnings }));
|
|
19
|
+
else {
|
|
20
|
+
console.log(`listen service installed${result.changed ? "" : " (no change)"}.`);
|
|
21
|
+
console.log(` unit: ${result.unitPath}`);
|
|
22
|
+
for (const w of result.warnings)
|
|
23
|
+
console.log(` ! ${w}`);
|
|
24
|
+
}
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
29
|
+
if (opts.json)
|
|
30
|
+
console.log(JSON.stringify({ ok: false, code: "install_failed", error: msg }));
|
|
31
|
+
else
|
|
32
|
+
console.error(`listen install failed: ${msg}`);
|
|
33
|
+
return 2;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { lastEventAtFromLog, supervisorStatus } from "../../lib/supervisor/index.js";
|
|
3
|
+
import { paths } from "../../lib/paths.js";
|
|
4
|
+
export async function runListenStatus(opts) {
|
|
5
|
+
const s = supervisorStatus();
|
|
6
|
+
const lastEventAt = lastEventAtFromLog(paths().listenLog);
|
|
7
|
+
let lastEvents = [];
|
|
8
|
+
try {
|
|
9
|
+
const raw = readFileSync(paths().listenLog, "utf8");
|
|
10
|
+
const lines = raw.trim().split("\n").filter(Boolean);
|
|
11
|
+
lastEvents = lines.slice(-5).map((l) => { try {
|
|
12
|
+
return JSON.parse(l);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return l;
|
|
16
|
+
} });
|
|
17
|
+
}
|
|
18
|
+
catch { /* ignore */ }
|
|
19
|
+
const out = {
|
|
20
|
+
installed: s.installed,
|
|
21
|
+
running: s.running,
|
|
22
|
+
pid: s.pid,
|
|
23
|
+
unit_path: s.unitPath,
|
|
24
|
+
last_event_at: lastEventAt,
|
|
25
|
+
last_events: lastEvents,
|
|
26
|
+
};
|
|
27
|
+
if (opts.json)
|
|
28
|
+
console.log(JSON.stringify(out, null, 2));
|
|
29
|
+
else {
|
|
30
|
+
console.log(`installed: ${out.installed}`);
|
|
31
|
+
console.log(`running: ${out.running}`);
|
|
32
|
+
console.log(`pid: ${out.pid ?? "-"}`);
|
|
33
|
+
console.log(`unit: ${out.unit_path}`);
|
|
34
|
+
console.log(`last event at: ${out.last_event_at ?? "-"}`);
|
|
35
|
+
}
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { uninstallSupervised } from "../../lib/supervisor/index.js";
|
|
2
|
+
export async function runListenUninstall(opts) {
|
|
3
|
+
try {
|
|
4
|
+
uninstallSupervised();
|
|
5
|
+
if (opts.json)
|
|
6
|
+
console.log(JSON.stringify({ ok: true }));
|
|
7
|
+
else
|
|
8
|
+
console.log("listen service uninstalled.");
|
|
9
|
+
return 0;
|
|
10
|
+
}
|
|
11
|
+
catch (err) {
|
|
12
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
13
|
+
if (opts.json)
|
|
14
|
+
console.log(JSON.stringify({ ok: false, error: msg }));
|
|
15
|
+
else
|
|
16
|
+
console.error(`listen uninstall failed: ${msg}`);
|
|
17
|
+
return 2;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readPendingSignup, clearPendingSignup, writeAuth } from "../lib/state.js";
|
|
2
|
+
import { apiPost } from "../lib/api.js";
|
|
3
|
+
export async function runOnboard(opts) {
|
|
4
|
+
let verificationId = opts.verificationId;
|
|
5
|
+
let email = null;
|
|
6
|
+
if (!verificationId) {
|
|
7
|
+
const pending = readPendingSignup();
|
|
8
|
+
if (!pending) {
|
|
9
|
+
if (opts.json)
|
|
10
|
+
console.log(JSON.stringify({ ok: false, code: "no_pending_signup" }));
|
|
11
|
+
else
|
|
12
|
+
console.error("No pending signup. Run `dial signup <email>` first, or pass --verification-id.");
|
|
13
|
+
return 1;
|
|
14
|
+
}
|
|
15
|
+
verificationId = pending.verification_id;
|
|
16
|
+
email = pending.email;
|
|
17
|
+
}
|
|
18
|
+
const res = await apiPost("/api/v1/auth/verify", { verification_id: verificationId, code: opts.code });
|
|
19
|
+
if (!res.ok) {
|
|
20
|
+
if (opts.json)
|
|
21
|
+
console.log(JSON.stringify({ ok: false, code: "verify_failed", status: res.status, error: res.error }));
|
|
22
|
+
else
|
|
23
|
+
console.error(`onboard failed: ${res.error}`);
|
|
24
|
+
return res.status === 401 ? 1 : 2;
|
|
25
|
+
}
|
|
26
|
+
const apiKey = res.data.api_key ?? null;
|
|
27
|
+
if (!apiKey || !res.data.account_id) {
|
|
28
|
+
if (opts.json)
|
|
29
|
+
console.log(JSON.stringify({ ok: false, code: "missing_api_key", error: "backend returned no api_key" }));
|
|
30
|
+
else
|
|
31
|
+
console.error("onboard failed: backend returned no api_key");
|
|
32
|
+
return 2;
|
|
33
|
+
}
|
|
34
|
+
writeAuth({
|
|
35
|
+
api_key: apiKey,
|
|
36
|
+
account_id: res.data.account_id,
|
|
37
|
+
email: email ?? "",
|
|
38
|
+
phone_number: res.data.phone_number ?? null,
|
|
39
|
+
phone_number_id: res.data.phone_number_id ?? null,
|
|
40
|
+
});
|
|
41
|
+
clearPendingSignup();
|
|
42
|
+
let listenStatus = { installed: false, warnings: [], error: null };
|
|
43
|
+
try {
|
|
44
|
+
const { installSupervised } = await import("../lib/supervisor/index.js");
|
|
45
|
+
const dialPath = process.env.DIAL_BIN_OVERRIDE ?? process.argv[1] ?? "dial";
|
|
46
|
+
const result = installSupervised(dialPath);
|
|
47
|
+
listenStatus = { installed: true, warnings: result.warnings, error: null };
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
listenStatus = { installed: false, warnings: [], error: err instanceof Error ? err.message : String(err) };
|
|
51
|
+
}
|
|
52
|
+
if (opts.json) {
|
|
53
|
+
console.log(JSON.stringify({
|
|
54
|
+
ok: true,
|
|
55
|
+
api_key: apiKey,
|
|
56
|
+
account_id: res.data.account_id,
|
|
57
|
+
phone_number: res.data.phone_number ?? null,
|
|
58
|
+
phone_number_id: res.data.phone_number_id ?? null,
|
|
59
|
+
listen: listenStatus,
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
console.log("onboarded.");
|
|
64
|
+
console.log(` api key: ${apiKey} (save this — shown once)`);
|
|
65
|
+
if (res.data.phone_number)
|
|
66
|
+
console.log(` phone number: ${res.data.phone_number}`);
|
|
67
|
+
if (listenStatus.installed) {
|
|
68
|
+
console.log(` listen: installed and running (logs: ~/.local/state/dial/listen.log)`);
|
|
69
|
+
for (const w of listenStatus.warnings)
|
|
70
|
+
console.log(` ! ${w}`);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
console.log(` listen: NOT installed (${listenStatus.error}). Run \`dial listen install\` manually.`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readPendingSignup, writePendingSignup } from "../lib/state.js";
|
|
2
|
+
import { apiPost } from "../lib/api.js";
|
|
3
|
+
const PENDING_FRESH_MS = 10 * 60 * 1000;
|
|
4
|
+
export async function runSignup(email, opts) {
|
|
5
|
+
const existing = readPendingSignup();
|
|
6
|
+
if (existing && !opts.force) {
|
|
7
|
+
const age = Date.now() - Date.parse(existing.created_at);
|
|
8
|
+
if (Number.isFinite(age) && age < PENDING_FRESH_MS) {
|
|
9
|
+
const ageS = Math.round(age / 1000);
|
|
10
|
+
if (opts.json) {
|
|
11
|
+
console.log(JSON.stringify({ ok: false, code: "pending_exists", verification_id: existing.verification_id, email: existing.email, age_seconds: ageS }));
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
console.error(`A pending OTP for ${existing.email} is still fresh (${ageS}s old). Use \`dial onboard --code <code>\` or re-run with --force to start a new one.`);
|
|
15
|
+
}
|
|
16
|
+
return 3;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const res = await apiPost("/api/v1/auth/signup", { email });
|
|
20
|
+
if (!res.ok) {
|
|
21
|
+
if (opts.json)
|
|
22
|
+
console.log(JSON.stringify({ ok: false, code: "signup_failed", status: res.status, error: res.error }));
|
|
23
|
+
else
|
|
24
|
+
console.error(`signup failed: ${res.error}`);
|
|
25
|
+
return 2;
|
|
26
|
+
}
|
|
27
|
+
writePendingSignup({ verification_id: res.data.verification_id, email, created_at: new Date().toISOString() });
|
|
28
|
+
if (opts.json) {
|
|
29
|
+
console.log(JSON.stringify({ ok: true, verification_id: res.data.verification_id, email }));
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
console.log(`OTP sent to ${email}.`);
|
|
33
|
+
console.log(`Run \`dial onboard --code <code>\` once you have it (verification_id is stored locally).`);
|
|
34
|
+
}
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, statSync } from "node:fs";
|
|
2
|
+
import { paths } from "../lib/paths.js";
|
|
3
|
+
import { supervisorStatus } from "../lib/supervisor/index.js";
|
|
4
|
+
import { matches, parseFieldArg, parseRegexArg } from "../lib/event-filter.js";
|
|
5
|
+
const POLL_INTERVAL_MS = 200;
|
|
6
|
+
export async function runWaitFor(opts) {
|
|
7
|
+
const status = supervisorStatus();
|
|
8
|
+
if (!status.installed || !status.running) {
|
|
9
|
+
const hint = !status.installed
|
|
10
|
+
? "Run `dial listen install` (or rerun `dial onboard`)."
|
|
11
|
+
: "It's installed but not running. Reinstall with `dial listen install` to restart it.";
|
|
12
|
+
if (opts.json) {
|
|
13
|
+
console.log(JSON.stringify({
|
|
14
|
+
ok: false,
|
|
15
|
+
reason: "listen_not_running",
|
|
16
|
+
installed: status.installed,
|
|
17
|
+
running: status.running,
|
|
18
|
+
pid: status.pid,
|
|
19
|
+
unit_path: status.unitPath,
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
console.error(`listen daemon is not running (installed=${status.installed}, running=${status.running}). ${hint}`);
|
|
24
|
+
}
|
|
25
|
+
return 3;
|
|
26
|
+
}
|
|
27
|
+
const spec = {
|
|
28
|
+
eventType: opts.eventType,
|
|
29
|
+
fields: opts.fields.map(parseFieldArg),
|
|
30
|
+
regexes: opts.regexes.map(parseRegexArg),
|
|
31
|
+
};
|
|
32
|
+
const file = paths().listenLog;
|
|
33
|
+
const startPos = currentSize(file);
|
|
34
|
+
const deadline = Date.now() + opts.timeoutSeconds * 1000;
|
|
35
|
+
let pos = startPos;
|
|
36
|
+
while (Date.now() < deadline) {
|
|
37
|
+
const size = currentSize(file);
|
|
38
|
+
if (size < pos)
|
|
39
|
+
pos = 0; // log rotated
|
|
40
|
+
if (size > pos) {
|
|
41
|
+
const chunk = readRange(file, pos, size - pos);
|
|
42
|
+
pos = size;
|
|
43
|
+
for (const { line, obj } of parseLines(chunk)) {
|
|
44
|
+
if (obj && matches(obj, spec)) {
|
|
45
|
+
process.stdout.write(line + "\n");
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
await sleep(POLL_INTERVAL_MS);
|
|
51
|
+
}
|
|
52
|
+
const fallback = findLatestMatchInFile(file, spec);
|
|
53
|
+
if (fallback) {
|
|
54
|
+
if (opts.json) {
|
|
55
|
+
console.log(JSON.stringify({ ok: false, timeout: true, source: "log", event: fallback.obj }));
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
console.error(`timed out after ${opts.timeoutSeconds}s; latest matching entry in log:`);
|
|
59
|
+
process.stdout.write(fallback.line + "\n");
|
|
60
|
+
}
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
if (opts.json) {
|
|
64
|
+
console.log(JSON.stringify({ ok: false, timeout: true, source: null, event: null }));
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
console.error(`timed out after ${opts.timeoutSeconds}s; no matching ${opts.eventType} entry in log.`);
|
|
68
|
+
}
|
|
69
|
+
return 2;
|
|
70
|
+
}
|
|
71
|
+
function currentSize(file) {
|
|
72
|
+
try {
|
|
73
|
+
return statSync(file).size;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function readRange(file, offset, length) {
|
|
80
|
+
const fd = openSync(file, "r");
|
|
81
|
+
try {
|
|
82
|
+
const buf = Buffer.alloc(length);
|
|
83
|
+
readSync(fd, buf, 0, length, offset);
|
|
84
|
+
return buf.toString("utf8");
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
closeSync(fd);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function parseLines(chunk) {
|
|
91
|
+
return chunk.split("\n").filter(Boolean).map((line) => {
|
|
92
|
+
try {
|
|
93
|
+
return { line, obj: JSON.parse(line) };
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return { line, obj: null };
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
function findLatestMatchInFile(file, spec) {
|
|
101
|
+
if (!existsSync(file))
|
|
102
|
+
return null;
|
|
103
|
+
const raw = readFileSync(file, "utf8");
|
|
104
|
+
const lines = raw.split("\n").filter(Boolean);
|
|
105
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
106
|
+
try {
|
|
107
|
+
const obj = JSON.parse(lines[i]);
|
|
108
|
+
if (matches(obj, spec))
|
|
109
|
+
return { line: lines[i], obj };
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
function sleep(ms) {
|
|
118
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
119
|
+
}
|
package/dist/lib/api.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { request } from "undici";
|
|
2
|
+
import { logger } from "./log.js";
|
|
3
|
+
const DEFAULT_BASE = "https://dial.up.railway.app";
|
|
4
|
+
export function baseUrl() {
|
|
5
|
+
return process.env.DIAL_API_URL ?? DEFAULT_BASE;
|
|
6
|
+
}
|
|
7
|
+
export async function apiPost(path, body, apiKey) {
|
|
8
|
+
return apiRequest("POST", path, body, apiKey);
|
|
9
|
+
}
|
|
10
|
+
export async function apiGet(path, apiKey) {
|
|
11
|
+
return apiRequest("GET", path, undefined, apiKey);
|
|
12
|
+
}
|
|
13
|
+
async function apiRequest(method, path, body, apiKey) {
|
|
14
|
+
const url = `${baseUrl()}${path}`;
|
|
15
|
+
const headers = { "content-type": "application/json" };
|
|
16
|
+
if (apiKey)
|
|
17
|
+
headers.authorization = `Bearer ${apiKey}`;
|
|
18
|
+
try {
|
|
19
|
+
const res = await request(url, {
|
|
20
|
+
method,
|
|
21
|
+
headers,
|
|
22
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
23
|
+
});
|
|
24
|
+
const text = await res.body.text();
|
|
25
|
+
let parsed = null;
|
|
26
|
+
try {
|
|
27
|
+
parsed = text ? JSON.parse(text) : null;
|
|
28
|
+
}
|
|
29
|
+
catch { /* keep raw */ }
|
|
30
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
31
|
+
return { ok: true, status: res.statusCode, data: parsed };
|
|
32
|
+
}
|
|
33
|
+
const errMsg = parsed?.error ?? text ?? `HTTP ${res.statusCode}`;
|
|
34
|
+
return { ok: false, status: res.statusCode, error: errMsg };
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export async function pingBackend() {
|
|
41
|
+
const start = Date.now();
|
|
42
|
+
try {
|
|
43
|
+
const res = await request(`${baseUrl()}/`, { method: "GET" });
|
|
44
|
+
await res.body.dump();
|
|
45
|
+
return { reachable: res.statusCode < 500, latencyMs: Date.now() - start };
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
logger.warn({ err, url: baseUrl() }, "backend unreachable");
|
|
49
|
+
return { reachable: false, latencyMs: null };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function parseFieldArg(input) {
|
|
2
|
+
const eq = input.indexOf("=");
|
|
3
|
+
if (eq < 1)
|
|
4
|
+
throw new Error(`invalid --field "${input}": expected name=value`);
|
|
5
|
+
return { name: input.slice(0, eq), value: input.slice(eq + 1) };
|
|
6
|
+
}
|
|
7
|
+
export function parseRegexArg(input) {
|
|
8
|
+
const eq = input.indexOf("=");
|
|
9
|
+
if (eq < 1)
|
|
10
|
+
throw new Error(`invalid --regex "${input}": expected name=pattern`);
|
|
11
|
+
const name = input.slice(0, eq);
|
|
12
|
+
const raw = input.slice(eq + 1);
|
|
13
|
+
// Support /pattern/flags as well as bare pattern.
|
|
14
|
+
const m = /^\/(.+)\/([gimsuy]*)$/.exec(raw);
|
|
15
|
+
const regex = m ? new RegExp(m[1], m[2]) : new RegExp(raw);
|
|
16
|
+
return { name, regex };
|
|
17
|
+
}
|
|
18
|
+
export function matches(obj, spec) {
|
|
19
|
+
if (!obj || typeof obj !== "object")
|
|
20
|
+
return false;
|
|
21
|
+
const record = obj;
|
|
22
|
+
if (record.type !== spec.eventType)
|
|
23
|
+
return false;
|
|
24
|
+
for (const f of spec.fields) {
|
|
25
|
+
if (String(record[f.name] ?? "") !== f.value)
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
for (const r of spec.regexes) {
|
|
29
|
+
if (!r.regex.test(String(record[r.name] ?? "")))
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
return true;
|
|
33
|
+
}
|
package/dist/lib/log.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import pino from "pino";
|
|
4
|
+
export function appendJsonl(file, obj) {
|
|
5
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
6
|
+
appendFileSync(file, JSON.stringify(obj) + "\n");
|
|
7
|
+
}
|
|
8
|
+
export const logger = pino({
|
|
9
|
+
level: process.env.DIAL_LOG_LEVEL ?? "warn",
|
|
10
|
+
base: { name: "dial-cli" },
|
|
11
|
+
}, pino.transport({
|
|
12
|
+
target: "pino-pretty",
|
|
13
|
+
options: {
|
|
14
|
+
destination: 2, // stderr
|
|
15
|
+
colorize: true,
|
|
16
|
+
translateTime: "SYS:HH:MM:ss.l",
|
|
17
|
+
ignore: "pid,hostname,name",
|
|
18
|
+
},
|
|
19
|
+
}));
|
|
20
|
+
export function rotateIfLarge(file, maxBytes) {
|
|
21
|
+
let size = 0;
|
|
22
|
+
try {
|
|
23
|
+
size = statSync(file).size;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (size <= maxBytes)
|
|
29
|
+
return;
|
|
30
|
+
const raw = readFileSync(file, "utf8");
|
|
31
|
+
const lines = raw.split("\n").filter(Boolean);
|
|
32
|
+
const keep = lines.slice(Math.floor(lines.length / 2));
|
|
33
|
+
writeFileSync(file, keep.join("\n") + "\n");
|
|
34
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
export function paths() {
|
|
4
|
+
const home = process.env.HOME ?? homedir();
|
|
5
|
+
const configHome = process.env.XDG_CONFIG_HOME ?? join(home, ".config");
|
|
6
|
+
const dataHome = process.env.XDG_DATA_HOME ?? join(home, ".local", "share");
|
|
7
|
+
const stateHome = process.env.XDG_STATE_HOME ?? join(home, ".local", "state");
|
|
8
|
+
const configDir = join(configHome, "dial");
|
|
9
|
+
const dataDir = join(dataHome, "dial");
|
|
10
|
+
const stateDir = join(stateHome, "dial");
|
|
11
|
+
return {
|
|
12
|
+
configDir,
|
|
13
|
+
dataDir,
|
|
14
|
+
stateDir,
|
|
15
|
+
configFile: join(configDir, "config.json"),
|
|
16
|
+
authFile: join(dataDir, "auth.json"),
|
|
17
|
+
pendingSignupFile: join(dataDir, "pending-signup.json"),
|
|
18
|
+
listenLog: join(stateDir, "listen.log"),
|
|
19
|
+
listenOutLog: join(stateDir, "listen.out.log"),
|
|
20
|
+
listenErrLog: join(stateDir, "listen.err.log"),
|
|
21
|
+
listenPid: join(stateDir, "listen.pid"),
|
|
22
|
+
cliLog: join(stateDir, "cli.log"),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import PubNub from "pubnub";
|
|
2
|
+
import { apiPost } from "./api.js";
|
|
3
|
+
import { appendJsonl, rotateIfLarge } from "./log.js";
|
|
4
|
+
import { paths } from "./paths.js";
|
|
5
|
+
const MAX_LOG_BYTES = 10 * 1024 * 1024;
|
|
6
|
+
const SUBSCRIBE_PATH = "/api/v1/listen/subscribe";
|
|
7
|
+
const REFRESH_FAILURES_BEFORE_EXIT = 3;
|
|
8
|
+
export async function fetchSubscribeCreds(apiKey) {
|
|
9
|
+
const res = await apiPost(SUBSCRIBE_PATH, {}, apiKey);
|
|
10
|
+
if (!res.ok)
|
|
11
|
+
throw new Error(`subscribe failed: ${res.error} (status ${res.status})`);
|
|
12
|
+
return res.data;
|
|
13
|
+
}
|
|
14
|
+
export function startWorker(apiKey, accountId) {
|
|
15
|
+
const logFile = paths().listenLog;
|
|
16
|
+
let pn = null;
|
|
17
|
+
let refreshTimer = null;
|
|
18
|
+
let stopped = false;
|
|
19
|
+
let consecutiveFailures = 0;
|
|
20
|
+
let resolveStopped;
|
|
21
|
+
const whenStopped = new Promise((r) => { resolveStopped = r; });
|
|
22
|
+
function logLine(obj) {
|
|
23
|
+
rotateIfLarge(logFile, MAX_LOG_BYTES);
|
|
24
|
+
appendJsonl(logFile, obj);
|
|
25
|
+
}
|
|
26
|
+
async function refresh(creds) {
|
|
27
|
+
try {
|
|
28
|
+
const next = await fetchSubscribeCreds(apiKey);
|
|
29
|
+
pn?.setToken(next.token);
|
|
30
|
+
consecutiveFailures = 0;
|
|
31
|
+
logLine({ ts: new Date().toISOString(), lifecycle: "token_refresh", ok: true });
|
|
32
|
+
scheduleRefresh(next);
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
consecutiveFailures += 1;
|
|
36
|
+
logLine({ ts: new Date().toISOString(), lifecycle: "token_refresh", ok: false, error: err instanceof Error ? err.message : String(err), consecutive_failures: consecutiveFailures });
|
|
37
|
+
if (consecutiveFailures >= REFRESH_FAILURES_BEFORE_EXIT) {
|
|
38
|
+
logLine({ ts: new Date().toISOString(), lifecycle: "shutdown", reason: "refresh_failures_exceeded" });
|
|
39
|
+
await stop();
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const backoff = Math.min(60, Math.pow(2, consecutiveFailures)) * 1000;
|
|
44
|
+
refreshTimer = setTimeout(() => refresh(creds), backoff);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function scheduleRefresh(creds) {
|
|
48
|
+
const ms = Math.max(60, Math.floor(creds.ttl_seconds / 2)) * 1000;
|
|
49
|
+
refreshTimer = setTimeout(() => refresh(creds), ms);
|
|
50
|
+
}
|
|
51
|
+
async function stop() {
|
|
52
|
+
if (stopped)
|
|
53
|
+
return whenStopped;
|
|
54
|
+
stopped = true;
|
|
55
|
+
if (refreshTimer)
|
|
56
|
+
clearTimeout(refreshTimer);
|
|
57
|
+
try {
|
|
58
|
+
pn?.unsubscribeAll();
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
logLine({ ts: new Date().toISOString(), lifecycle: "shutdown_error", phase: "unsubscribeAll", error: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : null });
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
pn?.destroy?.();
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
logLine({ ts: new Date().toISOString(), lifecycle: "shutdown_error", phase: "destroy", error: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : null });
|
|
68
|
+
}
|
|
69
|
+
resolveStopped();
|
|
70
|
+
return whenStopped;
|
|
71
|
+
}
|
|
72
|
+
(async () => {
|
|
73
|
+
let creds;
|
|
74
|
+
try {
|
|
75
|
+
creds = await fetchSubscribeCreds(apiKey);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
logLine({ ts: new Date().toISOString(), lifecycle: "startup", ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
resolveStopped();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
pn = new PubNub({
|
|
84
|
+
subscribeKey: creds.subscribe_key,
|
|
85
|
+
userId: `dial-cli-${accountId}`,
|
|
86
|
+
ssl: true,
|
|
87
|
+
authKey: creds.token,
|
|
88
|
+
});
|
|
89
|
+
pn.setToken(creds.token);
|
|
90
|
+
pn.addListener({
|
|
91
|
+
message: (ev) => {
|
|
92
|
+
logLine({ ts: new Date().toISOString(), ...ev.message });
|
|
93
|
+
},
|
|
94
|
+
status: (s) => {
|
|
95
|
+
logLine({ ts: new Date().toISOString(), lifecycle: "status", category: s.category, operation: s.operation ?? null });
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
pn.subscribe({ channels: [creds.channel] });
|
|
99
|
+
logLine({ ts: new Date().toISOString(), lifecycle: "subscribed", channel: creds.channel });
|
|
100
|
+
scheduleRefresh(creds);
|
|
101
|
+
})();
|
|
102
|
+
return { stop, whenStopped };
|
|
103
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, unlinkSync, statSync, chmodSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { paths } from "./paths.js";
|
|
5
|
+
import { logger } from "./log.js";
|
|
6
|
+
export const AuthSchema = z.object({
|
|
7
|
+
api_key: z.string(),
|
|
8
|
+
account_id: z.string(),
|
|
9
|
+
email: z.string(),
|
|
10
|
+
phone_number: z.string().nullable(),
|
|
11
|
+
phone_number_id: z.string().nullable(),
|
|
12
|
+
});
|
|
13
|
+
export const PendingSignupSchema = z.object({
|
|
14
|
+
verification_id: z.string(),
|
|
15
|
+
email: z.string(),
|
|
16
|
+
created_at: z.string(),
|
|
17
|
+
});
|
|
18
|
+
const CHMOD_UNSUPPORTED_CODES = new Set(["ENOTSUP", "EOPNOTSUPP", "EPERM"]);
|
|
19
|
+
function ensureDir(path) {
|
|
20
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
21
|
+
try {
|
|
22
|
+
chmodSync(dirname(path), 0o700);
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
const code = err.code;
|
|
26
|
+
if (code && CHMOD_UNSUPPORTED_CODES.has(code)) {
|
|
27
|
+
logger.warn({ err, code, path: dirname(path) }, "chmod 0700 unsupported, continuing");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function readSecure(path, schema) {
|
|
34
|
+
let stat;
|
|
35
|
+
try {
|
|
36
|
+
stat = statSync(path);
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
if (err.code === "ENOENT")
|
|
40
|
+
return null;
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
const mode = stat.mode & 0o777;
|
|
44
|
+
if (mode & 0o077) {
|
|
45
|
+
throw new Error(`${path} has insecure permissions (mode ${mode.toString(8)})`);
|
|
46
|
+
}
|
|
47
|
+
const raw = readFileSync(path, "utf8");
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(raw);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
logger.warn({ err, path }, "failed to parse state file");
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const result = schema.safeParse(parsed);
|
|
57
|
+
if (!result.success) {
|
|
58
|
+
logger.warn({ path, issues: result.error.issues }, "state file did not match expected schema");
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return result.data;
|
|
62
|
+
}
|
|
63
|
+
function writeSecure(path, data) {
|
|
64
|
+
ensureDir(path);
|
|
65
|
+
writeFileSync(path, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
66
|
+
chmodSync(path, 0o600);
|
|
67
|
+
}
|
|
68
|
+
export function readAuth() {
|
|
69
|
+
return readSecure(paths().authFile, AuthSchema);
|
|
70
|
+
}
|
|
71
|
+
export function writeAuth(auth) {
|
|
72
|
+
writeSecure(paths().authFile, auth);
|
|
73
|
+
}
|
|
74
|
+
function unlinkIfExists(path) {
|
|
75
|
+
try {
|
|
76
|
+
unlinkSync(path);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
if (err.code === "ENOENT")
|
|
80
|
+
return;
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export function clearAuth() {
|
|
85
|
+
unlinkIfExists(paths().authFile);
|
|
86
|
+
}
|
|
87
|
+
export function readPendingSignup() {
|
|
88
|
+
return readSecure(paths().pendingSignupFile, PendingSignupSchema);
|
|
89
|
+
}
|
|
90
|
+
export function writePendingSignup(p) {
|
|
91
|
+
writeSecure(paths().pendingSignupFile, p);
|
|
92
|
+
}
|
|
93
|
+
export function clearPendingSignup() {
|
|
94
|
+
unlinkIfExists(paths().pendingSignupFile);
|
|
95
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { userInfo } from "node:os";
|
|
3
|
+
import { paths } from "../paths.js";
|
|
4
|
+
import { logger } from "../log.js";
|
|
5
|
+
import { LAUNCHD_LABEL, launchctlBootoutSilent, launchctlLoad, launchctlStatus, launchctlUnload, launchdPlistPath, renderLaunchdPlist, writeLaunchdPlist } from "./launchd.js";
|
|
6
|
+
import { lingerEnabled, renderSystemdUnit, systemctlDisable, systemctlEnableAndStart, systemctlStatus, systemdUnitPath, writeSystemdUnit } from "./systemd.js";
|
|
7
|
+
export function currentPlatform() {
|
|
8
|
+
if (process.platform === "darwin")
|
|
9
|
+
return "darwin";
|
|
10
|
+
if (process.platform === "linux")
|
|
11
|
+
return "linux";
|
|
12
|
+
throw new Error(`Unsupported platform: ${process.platform} (macOS and Linux only)`);
|
|
13
|
+
}
|
|
14
|
+
export function installSupervised(programPath) {
|
|
15
|
+
const platform = currentPlatform();
|
|
16
|
+
const p = paths();
|
|
17
|
+
if (platform === "darwin") {
|
|
18
|
+
const xml = renderLaunchdPlist({
|
|
19
|
+
label: LAUNCHD_LABEL,
|
|
20
|
+
programPath,
|
|
21
|
+
stdoutPath: p.listenOutLog,
|
|
22
|
+
stderrPath: p.listenErrLog,
|
|
23
|
+
});
|
|
24
|
+
const { path, changed } = writeLaunchdPlist(xml);
|
|
25
|
+
if (changed) {
|
|
26
|
+
// Boot the prior service out (if any) without deleting the freshly written plist.
|
|
27
|
+
launchctlBootoutSilent();
|
|
28
|
+
}
|
|
29
|
+
launchctlLoad(path);
|
|
30
|
+
return { changed, warnings: [], unitPath: path };
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
const unit = renderSystemdUnit({ programPath });
|
|
34
|
+
const { path, changed } = writeSystemdUnit(unit);
|
|
35
|
+
systemctlEnableAndStart();
|
|
36
|
+
const warnings = [];
|
|
37
|
+
if (!lingerEnabled(userInfo().username)) {
|
|
38
|
+
warnings.push("Run `loginctl enable-linger $USER` so the listen service survives logout.");
|
|
39
|
+
}
|
|
40
|
+
return { changed, warnings, unitPath: path };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function uninstallSupervised() {
|
|
44
|
+
const platform = currentPlatform();
|
|
45
|
+
if (platform === "darwin") {
|
|
46
|
+
launchctlUnload(launchdPlistPath());
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
systemctlDisable();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function supervisorStatus() {
|
|
53
|
+
const platform = currentPlatform();
|
|
54
|
+
if (platform === "darwin") {
|
|
55
|
+
const path = launchdPlistPath();
|
|
56
|
+
const installed = existsSync(path);
|
|
57
|
+
const s = launchctlStatus();
|
|
58
|
+
return { installed, running: s.running, pid: s.pid, unitPath: path };
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
const path = systemdUnitPath();
|
|
62
|
+
const installed = existsSync(path);
|
|
63
|
+
const s = systemctlStatus();
|
|
64
|
+
return { installed, running: s.running, pid: s.pid, unitPath: path };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function lastEventAtFromLog(file) {
|
|
68
|
+
let buf;
|
|
69
|
+
try {
|
|
70
|
+
if (!statSync(file).isFile())
|
|
71
|
+
return null;
|
|
72
|
+
buf = readFileSync(file, "utf8");
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
if (err.code === "ENOENT")
|
|
76
|
+
return null;
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
const lines = buf.trim().split("\n").filter(Boolean);
|
|
80
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
81
|
+
let obj;
|
|
82
|
+
try {
|
|
83
|
+
obj = JSON.parse(lines[i]);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
logger.warn({ err, file, lineIndex: i }, "skipping malformed JSONL line in listen log");
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const t = obj.occurred_at ?? obj.ts;
|
|
90
|
+
if (t)
|
|
91
|
+
return t;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { logger } from "../log.js";
|
|
6
|
+
function isLaunchctlNotFound(err) {
|
|
7
|
+
const stderr = err.stderr;
|
|
8
|
+
const text = stderr ? stderr.toString() : "";
|
|
9
|
+
return /No such process|could not find specified service|not loaded/i.test(text);
|
|
10
|
+
}
|
|
11
|
+
function isEnoent(err) {
|
|
12
|
+
return err.code === "ENOENT";
|
|
13
|
+
}
|
|
14
|
+
// Coerce Buffer fields on Error objects to strings so pino doesn't dump raw byte arrays.
|
|
15
|
+
function redactBuffers(err) {
|
|
16
|
+
if (!err || typeof err !== "object")
|
|
17
|
+
return err;
|
|
18
|
+
const e = err;
|
|
19
|
+
const redacted = { ...e };
|
|
20
|
+
for (const k of ["stderr", "stdout", "output"]) {
|
|
21
|
+
const v = e[k];
|
|
22
|
+
if (Buffer.isBuffer(v))
|
|
23
|
+
redacted[k] = v.toString().trim();
|
|
24
|
+
else if (Array.isArray(v))
|
|
25
|
+
redacted[k] = v.map((x) => (Buffer.isBuffer(x) ? x.toString().trim() : x));
|
|
26
|
+
}
|
|
27
|
+
return redacted;
|
|
28
|
+
}
|
|
29
|
+
export const LAUNCHD_LABEL = "ai.getdial.listen";
|
|
30
|
+
export function launchdPlistPath() {
|
|
31
|
+
return join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
32
|
+
}
|
|
33
|
+
export function renderLaunchdPlist(params) {
|
|
34
|
+
// The dial script uses `#!/usr/bin/env node`. launchd starts with a minimal PATH,
|
|
35
|
+
// so we must prepend the directory of the currently running node (e.g. nvm's bin dir)
|
|
36
|
+
// so the shebang can resolve. Falls back to /usr/local/bin which is where Homebrew puts node.
|
|
37
|
+
const nodeDir = dirname(process.execPath);
|
|
38
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
39
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
40
|
+
<plist version="1.0">
|
|
41
|
+
<dict>
|
|
42
|
+
<key>Label</key>
|
|
43
|
+
<string>${params.label}</string>
|
|
44
|
+
<key>ProgramArguments</key>
|
|
45
|
+
<array>
|
|
46
|
+
<string>${params.programPath}</string>
|
|
47
|
+
<string>listen</string>
|
|
48
|
+
</array>
|
|
49
|
+
<key>RunAtLoad</key>
|
|
50
|
+
<true/>
|
|
51
|
+
<key>KeepAlive</key>
|
|
52
|
+
<true/>
|
|
53
|
+
<key>StandardOutPath</key>
|
|
54
|
+
<string>${params.stdoutPath}</string>
|
|
55
|
+
<key>StandardErrorPath</key>
|
|
56
|
+
<string>${params.stderrPath}</string>
|
|
57
|
+
<key>EnvironmentVariables</key>
|
|
58
|
+
<dict>
|
|
59
|
+
<key>PATH</key>
|
|
60
|
+
<string>${nodeDir}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
|
|
61
|
+
</dict>
|
|
62
|
+
</dict>
|
|
63
|
+
</plist>
|
|
64
|
+
`;
|
|
65
|
+
}
|
|
66
|
+
export function writeLaunchdPlist(contents) {
|
|
67
|
+
const path = launchdPlistPath();
|
|
68
|
+
mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true });
|
|
69
|
+
const prior = existsSync(path) ? readFileSync(path, "utf8") : null;
|
|
70
|
+
if (prior === contents)
|
|
71
|
+
return { path, changed: false };
|
|
72
|
+
writeFileSync(path, contents);
|
|
73
|
+
return { path, changed: true };
|
|
74
|
+
}
|
|
75
|
+
export function launchctlBootoutSilent() {
|
|
76
|
+
const uid = process.getuid?.() ?? 0;
|
|
77
|
+
try {
|
|
78
|
+
execFileSync("launchctl", ["bootout", `gui/${uid}/${LAUNCHD_LABEL}`], { stdio: "pipe" });
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Best-effort cleanup before install: launchctl bootout exits non-zero with
|
|
82
|
+
// ambiguous "Boot-out failed: 5: Input/output error" when nothing is loaded.
|
|
83
|
+
// We can't reliably distinguish "not loaded" from real errors, and any real
|
|
84
|
+
// problem will surface on the next bootstrap. Ignore.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function launchctlLoad(plistPath) {
|
|
88
|
+
const uid = process.getuid?.() ?? 0;
|
|
89
|
+
try {
|
|
90
|
+
execFileSync("launchctl", ["bootstrap", `gui/${uid}`, plistPath], { stdio: "pipe" });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
catch (bootstrapErr) {
|
|
94
|
+
logger.warn({ err: redactBuffers(bootstrapErr) }, "launchctl bootstrap failed, falling back to legacy load");
|
|
95
|
+
}
|
|
96
|
+
// launchctl load -w prints "Load failed: ..." to stderr but exits 0,
|
|
97
|
+
// so we must inspect stderr ourselves to detect the failure.
|
|
98
|
+
const r = spawnSync("launchctl", ["load", "-w", plistPath], { encoding: "utf8" });
|
|
99
|
+
const stderr = (r.stderr ?? "").trim();
|
|
100
|
+
if (r.error)
|
|
101
|
+
throw r.error;
|
|
102
|
+
if (r.status !== 0 || /Load failed|error/i.test(stderr)) {
|
|
103
|
+
throw new Error(`launchctl load -w ${plistPath} failed: ${stderr || `exit ${r.status}`}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export function launchctlUnload(plistPath) {
|
|
107
|
+
const uid = process.getuid?.() ?? 0;
|
|
108
|
+
try {
|
|
109
|
+
execFileSync("launchctl", ["bootout", `gui/${uid}/${LAUNCHD_LABEL}`], { stdio: "pipe" });
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
if (!isLaunchctlNotFound(err)) {
|
|
113
|
+
// bootout missing on older macOS — try legacy unload before giving up
|
|
114
|
+
try {
|
|
115
|
+
execFileSync("launchctl", ["unload", plistPath], { stdio: "pipe" });
|
|
116
|
+
}
|
|
117
|
+
catch (unloadErr) {
|
|
118
|
+
if (!isLaunchctlNotFound(unloadErr))
|
|
119
|
+
throw unloadErr;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
unlinkSync(plistPath);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
if (!isEnoent(err))
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export function launchctlStatus() {
|
|
132
|
+
try {
|
|
133
|
+
const out = execFileSync("launchctl", ["list"], { stdio: ["ignore", "pipe", "ignore"] }).toString();
|
|
134
|
+
const line = out.split("\n").find((l) => l.endsWith(`\t${LAUNCHD_LABEL}`) || l.endsWith(` ${LAUNCHD_LABEL}`));
|
|
135
|
+
if (!line)
|
|
136
|
+
return { running: false, pid: null };
|
|
137
|
+
const cols = line.split(/\s+/);
|
|
138
|
+
const pid = parseInt(cols[0], 10);
|
|
139
|
+
return { running: Number.isFinite(pid) && pid > 0, pid: Number.isFinite(pid) && pid > 0 ? pid : null };
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
logger.warn({ err: redactBuffers(err) }, "launchctl list failed");
|
|
143
|
+
return { running: false, pid: null };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { logger } from "../log.js";
|
|
6
|
+
function isSystemctlNotLoaded(err) {
|
|
7
|
+
const stderr = err.stderr;
|
|
8
|
+
const text = stderr ? stderr.toString() : "";
|
|
9
|
+
return /not loaded|does not exist|Unit .* not found/i.test(text);
|
|
10
|
+
}
|
|
11
|
+
function isEnoent(err) {
|
|
12
|
+
return err.code === "ENOENT";
|
|
13
|
+
}
|
|
14
|
+
export const SYSTEMD_UNIT_NAME = "dial-listen.service";
|
|
15
|
+
export function systemdUnitPath() {
|
|
16
|
+
return join(homedir(), ".config", "systemd", "user", SYSTEMD_UNIT_NAME);
|
|
17
|
+
}
|
|
18
|
+
export function renderSystemdUnit(params) {
|
|
19
|
+
// systemd user units start with a minimal PATH; include node's bin dir so
|
|
20
|
+
// dial's `#!/usr/bin/env node` shebang resolves.
|
|
21
|
+
const nodeDir = dirname(process.execPath);
|
|
22
|
+
return `[Unit]
|
|
23
|
+
Description=Dial listen service
|
|
24
|
+
After=network-online.target
|
|
25
|
+
Wants=network-online.target
|
|
26
|
+
|
|
27
|
+
[Service]
|
|
28
|
+
Type=simple
|
|
29
|
+
Environment="PATH=${nodeDir}:/usr/local/bin:/usr/bin:/bin"
|
|
30
|
+
ExecStart=${params.programPath} listen
|
|
31
|
+
Restart=on-failure
|
|
32
|
+
RestartSec=10
|
|
33
|
+
|
|
34
|
+
[Install]
|
|
35
|
+
WantedBy=default.target
|
|
36
|
+
`;
|
|
37
|
+
}
|
|
38
|
+
export function writeSystemdUnit(contents) {
|
|
39
|
+
const path = systemdUnitPath();
|
|
40
|
+
mkdirSync(join(homedir(), ".config", "systemd", "user"), { recursive: true });
|
|
41
|
+
const prior = existsSync(path) ? readFileSync(path, "utf8") : null;
|
|
42
|
+
if (prior === contents)
|
|
43
|
+
return { path, changed: false };
|
|
44
|
+
writeFileSync(path, contents);
|
|
45
|
+
return { path, changed: true };
|
|
46
|
+
}
|
|
47
|
+
export function systemctlEnableAndStart() {
|
|
48
|
+
execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "pipe" });
|
|
49
|
+
execFileSync("systemctl", ["--user", "enable", "--now", SYSTEMD_UNIT_NAME], { stdio: "pipe" });
|
|
50
|
+
}
|
|
51
|
+
export function systemctlDisable() {
|
|
52
|
+
try {
|
|
53
|
+
execFileSync("systemctl", ["--user", "disable", "--now", SYSTEMD_UNIT_NAME], { stdio: "pipe" });
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
if (!isSystemctlNotLoaded(err))
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
unlinkSync(systemdUnitPath());
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (!isEnoent(err))
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "pipe" });
|
|
67
|
+
}
|
|
68
|
+
export function systemctlStatus() {
|
|
69
|
+
try {
|
|
70
|
+
const out = execFileSync("systemctl", ["--user", "show", SYSTEMD_UNIT_NAME, "--property=ActiveState,MainPID"], { stdio: ["ignore", "pipe", "ignore"] }).toString();
|
|
71
|
+
const active = /ActiveState=active/.test(out);
|
|
72
|
+
const m = out.match(/MainPID=(\d+)/);
|
|
73
|
+
const pid = m ? parseInt(m[1], 10) : 0;
|
|
74
|
+
return { running: active, pid: active && pid > 0 ? pid : null };
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
logger.warn({ err }, "systemctl show failed");
|
|
78
|
+
return { running: false, pid: null };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export function lingerEnabled(user) {
|
|
82
|
+
try {
|
|
83
|
+
const out = execFileSync("loginctl", ["show-user", user, "--property=Linger"], { stdio: ["ignore", "pipe", "ignore"] }).toString();
|
|
84
|
+
return /Linger=yes/.test(out);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
logger.warn({ err, user }, "loginctl show-user failed; assuming linger disabled");
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@getdial/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Dial CLI — install, sign up, and run the local listen service.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bin": {
|
|
7
|
+
"dial": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"preferGlobal": true,
|
|
10
|
+
"type": "module",
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=22"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p .",
|
|
20
|
+
"test": "node --import tsx --test",
|
|
21
|
+
"clean": "rm -rf dist"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"commander": "^14.0.3",
|
|
25
|
+
"pino": "^10.3.1",
|
|
26
|
+
"pino-pretty": "^13.1.3",
|
|
27
|
+
"pubnub": "^11.0.1",
|
|
28
|
+
"undici": "^8.3.0",
|
|
29
|
+
"zod": "^4.4.3"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^22.10.0",
|
|
33
|
+
"tsx": "^4.22.3",
|
|
34
|
+
"typescript": "^5.6.0"
|
|
35
|
+
}
|
|
36
|
+
}
|