@getdial/cli 0.1.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
+ import { VERSION } from "./lib/version.js";
3
4
  import { runDoctor } from "./commands/doctor.js";
4
5
  import { runSignup } from "./commands/signup.js";
5
6
  import { runOnboard } from "./commands/onboard.js";
@@ -8,11 +9,14 @@ import { runListenInstall } from "./commands/listen/install.js";
8
9
  import { runListenUninstall } from "./commands/listen/uninstall.js";
9
10
  import { runListenStatus } from "./commands/listen/status.js";
10
11
  import { runWaitFor } from "./commands/wait-for.js";
12
+ import { runTest2faStart } from "./commands/test-2fa/start.js";
13
+ import { runTest2faVerify } from "./commands/test-2fa/verify.js";
14
+ import { runTest2faRun } from "./commands/test-2fa/run.js";
11
15
  const program = new Command();
12
16
  program
13
17
  .name("dial")
14
18
  .description("Dial CLI — set up your account and run the listen service.")
15
- .version("0.1.0");
19
+ .version(VERSION);
16
20
  program
17
21
  .command("doctor")
18
22
  .description("Report state and what to do next.")
@@ -50,6 +54,31 @@ listen
50
54
  .description("Report listen daemon state and last events.")
51
55
  .option("--json", "machine-readable output")
52
56
  .action(async (opts) => process.exit(await runListenStatus({ json: !!opts.json })));
57
+ const test2fa = program
58
+ .command("test-2fa")
59
+ .description("Drive the dashboard test-2fa loop (sends a fake SMS to your Dial number and verifies it).");
60
+ test2fa
61
+ .command("start")
62
+ .description("Trigger /api/v1/test/2fa for your default number. Use --number-id to override.")
63
+ .option("--number-id <id>", "phone_number_id to send the test SMS to (defaults to onboard's number)")
64
+ .option("--json", "machine-readable output")
65
+ .action(async (opts) => process.exit(await runTest2faStart({ numberId: opts.numberId, json: !!opts.json })));
66
+ test2fa
67
+ .command("verify <session-id> <code>")
68
+ .description("Submit the 6-digit code to /api/v1/test/2fa/<session>/verify.")
69
+ .option("--json", "machine-readable output")
70
+ .action(async (sessionId, code, opts) => process.exit(await runTest2faVerify({ sessionId, code, json: !!opts.json })));
71
+ test2fa
72
+ .command("run")
73
+ .description("Run the full loop: start, wait for the SMS in listen.log, parse code, verify.")
74
+ .option("--number-id <id>", "phone_number_id to send the test SMS to (defaults to onboard's number)")
75
+ .option("-t, --timeout <seconds>", "max seconds to wait for the SMS event (default 60)", (v) => parseInt(v, 10), 60)
76
+ .option("--json", "machine-readable output")
77
+ .action(async (opts) => process.exit(await runTest2faRun({
78
+ numberId: opts.numberId,
79
+ timeoutSeconds: opts.timeout,
80
+ json: !!opts.json,
81
+ })));
53
82
  program
54
83
  .command("wait-for <event-type>")
55
84
  .description("Wait for the next matching event in the listen log (e.g. call.ended, message.received).")
@@ -2,6 +2,7 @@ import { readPendingSignup, readAuth } from "../lib/state.js";
2
2
  import { apiGet, baseUrl, pingBackend } from "../lib/api.js";
3
3
  import { supervisorStatus, lastEventAtFromLog } from "../lib/supervisor/index.js";
4
4
  import { paths } from "../lib/paths.js";
5
+ import { VERSION } from "../lib/version.js";
5
6
  const OTP_EXPIRY_MS = 10 * 60 * 1000;
6
7
  async function buildReport() {
7
8
  const ping = await pingBackend();
@@ -45,7 +46,7 @@ async function buildReport() {
45
46
  nextStep = "ready";
46
47
  }
47
48
  return {
48
- cli: { version: "0.1.0", node: process.versions.node },
49
+ cli: { version: VERSION, node: process.versions.node },
49
50
  backend: { url: baseUrl(), reachable: ping.reachable, latency_ms: ping.latencyMs },
50
51
  auth: {
51
52
  signed_in: Boolean(auth),
@@ -0,0 +1,84 @@
1
+ import { readAuth } from "../../lib/state.js";
2
+ import { apiPost } from "../../lib/api.js";
3
+ import { paths } from "../../lib/paths.js";
4
+ import { supervisorStatus } from "../../lib/supervisor/index.js";
5
+ import { currentSize, tailUntilMatch } from "../../lib/log-tail.js";
6
+ import { parseRegexArg } from "../../lib/event-filter.js";
7
+ export async function runTest2faRun(opts) {
8
+ const auth = readAuth();
9
+ if (!auth) {
10
+ return fail(opts.json, "not_signed_in", "Not signed in. Run `dial signup` and `dial onboard` first.", 1);
11
+ }
12
+ const supervisor = supervisorStatus();
13
+ if (!supervisor.installed || !supervisor.running) {
14
+ return fail(opts.json, "listen_not_running", `Listen daemon is not running (installed=${supervisor.installed}, running=${supervisor.running}). Run \`dial listen install\`.`, 3);
15
+ }
16
+ const numberId = opts.numberId ?? auth.phone_number_id;
17
+ if (!numberId) {
18
+ return fail(opts.json, "no_number", "No default phone_number_id in auth. Pass --number-id <id>.", 1);
19
+ }
20
+ // Start listening BEFORE triggering the send so we don't miss the event.
21
+ const logFile = paths().listenLog;
22
+ const startOffset = currentSize(logFile);
23
+ const startedAt = Date.now();
24
+ const startRes = await apiPost("/api/v1/test/2fa", { phone_number_id: numberId }, auth.api_key);
25
+ if (!startRes.ok) {
26
+ return fail(opts.json, "start_failed", startRes.error, 2, { status: startRes.status });
27
+ }
28
+ // The test backend sends "Your Dial verification code is NNNNNN" — match the body.
29
+ const spec = {
30
+ eventType: "message.received",
31
+ fields: [{ name: "channel", value: "sms" }],
32
+ regexes: [parseRegexArg("body=/Your Dial verification code is \\d{6}/")],
33
+ };
34
+ const hit = await tailUntilMatch(logFile, spec, startOffset, opts.timeoutSeconds * 1000);
35
+ if (!hit) {
36
+ return fail(opts.json, "sms_timeout", `Timed out after ${opts.timeoutSeconds}s waiting for the SMS event in the listen log.`, 2, { session_id: startRes.data.session_id });
37
+ }
38
+ const body = String(hit.obj.body ?? "");
39
+ const codeMatch = /\b\d{6}\b/.exec(body);
40
+ if (!codeMatch) {
41
+ return fail(opts.json, "no_code_in_body", `Could not extract 6-digit code from SMS body: ${body}`, 2, {
42
+ session_id: startRes.data.session_id,
43
+ body,
44
+ });
45
+ }
46
+ const code = codeMatch[0];
47
+ const verifyRes = await apiPost(`/api/v1/test/2fa/${encodeURIComponent(startRes.data.session_id)}/verify`, { code }, auth.api_key);
48
+ if (!verifyRes.ok) {
49
+ return fail(opts.json, "verify_failed", verifyRes.error, 2, {
50
+ status: verifyRes.status,
51
+ session_id: startRes.data.session_id,
52
+ code,
53
+ });
54
+ }
55
+ const totalMs = Date.now() - startedAt;
56
+ if (opts.json) {
57
+ console.log(JSON.stringify({
58
+ ok: true,
59
+ verified: verifyRes.data.verified,
60
+ session_id: startRes.data.session_id,
61
+ code,
62
+ number_id: numberId,
63
+ total_ms: totalMs,
64
+ }));
65
+ }
66
+ else if (verifyRes.data.verified) {
67
+ console.log(`verified.`);
68
+ console.log(` session: ${startRes.data.session_id}`);
69
+ console.log(` code: ${code}`);
70
+ console.log(` total time: ${totalMs}ms`);
71
+ }
72
+ else {
73
+ console.error(`Backend rejected the extracted code "${code}" (verified=false).`);
74
+ return 2;
75
+ }
76
+ return 0;
77
+ }
78
+ function fail(json, code, message, exitCode, extra) {
79
+ if (json)
80
+ console.log(JSON.stringify({ ok: false, code, message, ...extra }));
81
+ else
82
+ console.error(message);
83
+ return exitCode;
84
+ }
@@ -0,0 +1,38 @@
1
+ import { readAuth } from "../../lib/state.js";
2
+ import { apiPost } from "../../lib/api.js";
3
+ export async function runTest2faStart(opts) {
4
+ const auth = readAuth();
5
+ if (!auth) {
6
+ fail(opts.json, "not_signed_in", "Not signed in. Run `dial signup` and `dial onboard` first.");
7
+ return 1;
8
+ }
9
+ const numberId = opts.numberId ?? auth.phone_number_id;
10
+ if (!numberId) {
11
+ fail(opts.json, "no_number", "No default phone_number_id in auth. Pass --number-id <id>.");
12
+ return 1;
13
+ }
14
+ const res = await apiPost("/api/v1/test/2fa", { phone_number_id: numberId }, auth.api_key);
15
+ if (!res.ok) {
16
+ fail(opts.json, "start_failed", res.error, { status: res.status });
17
+ return 2;
18
+ }
19
+ if (opts.json) {
20
+ console.log(JSON.stringify({ ok: true, session_id: res.data.session_id, otp_message: res.data.otp_message, number_id: numberId }));
21
+ }
22
+ else {
23
+ console.log(`test 2fa session started.`);
24
+ console.log(` session: ${res.data.session_id}`);
25
+ console.log(` expected SMS: ${res.data.otp_message}`);
26
+ console.log(` to number: ${numberId}`);
27
+ console.log(``);
28
+ console.log(`Wait for it with: dial wait-for message.received -f channel=sms --json`);
29
+ console.log(`Or run the whole loop: dial test-2fa run`);
30
+ }
31
+ return 0;
32
+ }
33
+ function fail(json, code, message, extra) {
34
+ if (json)
35
+ console.log(JSON.stringify({ ok: false, code, message, ...extra }));
36
+ else
37
+ console.error(message);
38
+ }
@@ -0,0 +1,31 @@
1
+ import { readAuth } from "../../lib/state.js";
2
+ import { apiPost } from "../../lib/api.js";
3
+ export async function runTest2faVerify(opts) {
4
+ const auth = readAuth();
5
+ if (!auth) {
6
+ fail(opts.json, "not_signed_in", "Not signed in. Run `dial signup` and `dial onboard` first.");
7
+ return 1;
8
+ }
9
+ const res = await apiPost(`/api/v1/test/2fa/${encodeURIComponent(opts.sessionId)}/verify`, { code: opts.code }, auth.api_key);
10
+ if (!res.ok) {
11
+ fail(opts.json, "verify_failed", res.error, { status: res.status });
12
+ return 2;
13
+ }
14
+ if (opts.json) {
15
+ console.log(JSON.stringify({ ok: true, verified: res.data.verified, session_id: opts.sessionId }));
16
+ }
17
+ else if (res.data.verified) {
18
+ console.log("verified.");
19
+ }
20
+ else {
21
+ console.log("not verified.");
22
+ return 2;
23
+ }
24
+ return 0;
25
+ }
26
+ function fail(json, code, message, extra) {
27
+ if (json)
28
+ console.log(JSON.stringify({ ok: false, code, message, ...extra }));
29
+ else
30
+ console.error(message);
31
+ }
@@ -1,8 +1,7 @@
1
- import { closeSync, existsSync, openSync, readFileSync, readSync, statSync } from "node:fs";
2
1
  import { paths } from "../lib/paths.js";
3
2
  import { supervisorStatus } from "../lib/supervisor/index.js";
4
- import { matches, parseFieldArg, parseRegexArg } from "../lib/event-filter.js";
5
- const POLL_INTERVAL_MS = 200;
3
+ import { parseFieldArg, parseRegexArg } from "../lib/event-filter.js";
4
+ import { currentSize, findLatestMatch, tailUntilMatch } from "../lib/log-tail.js";
6
5
  export async function runWaitFor(opts) {
7
6
  const status = supervisorStatus();
8
7
  if (!status.installed || !status.running) {
@@ -30,26 +29,13 @@ export async function runWaitFor(opts) {
30
29
  regexes: opts.regexes.map(parseRegexArg),
31
30
  };
32
31
  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);
32
+ const startOffset = currentSize(file);
33
+ const hit = await tailUntilMatch(file, spec, startOffset, opts.timeoutSeconds * 1000);
34
+ if (hit) {
35
+ process.stdout.write(hit.line + "\n");
36
+ return 0;
51
37
  }
52
- const fallback = findLatestMatchInFile(file, spec);
38
+ const fallback = findLatestMatch(file, spec);
53
39
  if (fallback) {
54
40
  if (opts.json) {
55
41
  console.log(JSON.stringify({ ok: false, timeout: true, source: "log", event: fallback.obj }));
@@ -68,52 +54,3 @@ export async function runWaitFor(opts) {
68
54
  }
69
55
  return 2;
70
56
  }
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
- }
@@ -0,0 +1,82 @@
1
+ import { closeSync, existsSync, openSync, readFileSync, readSync, statSync } from "node:fs";
2
+ import { matches } from "./event-filter.js";
3
+ const POLL_INTERVAL_MS = 200;
4
+ /**
5
+ * Polls a JSONL file from the given byte offset and resolves with the first
6
+ * parsed line that satisfies `spec`. Returns null on timeout.
7
+ *
8
+ * Designed for the listen daemon's log: append-only, line-delimited JSON,
9
+ * occasionally truncated by `rotateIfLarge` (handled by resetting position).
10
+ */
11
+ export async function tailUntilMatch(file, spec, startOffset, timeoutMs) {
12
+ const deadline = Date.now() + timeoutMs;
13
+ let pos = startOffset;
14
+ while (Date.now() < deadline) {
15
+ const size = currentSize(file);
16
+ if (size < pos)
17
+ pos = 0; // log rotated
18
+ if (size > pos) {
19
+ const chunk = readRange(file, pos, size - pos);
20
+ pos = size;
21
+ for (const hit of parseLines(chunk)) {
22
+ if (hit && matches(hit.obj, spec))
23
+ return hit;
24
+ }
25
+ }
26
+ await sleep(POLL_INTERVAL_MS);
27
+ }
28
+ return null;
29
+ }
30
+ /**
31
+ * Walks the file from the end backward and returns the most recent line that
32
+ * matches `spec`. Useful as a fallback after a tail timeout.
33
+ */
34
+ export function findLatestMatch(file, spec) {
35
+ if (!existsSync(file))
36
+ return null;
37
+ const raw = readFileSync(file, "utf8");
38
+ const lines = raw.split("\n").filter(Boolean);
39
+ for (let i = lines.length - 1; i >= 0; i--) {
40
+ try {
41
+ const obj = JSON.parse(lines[i]);
42
+ if (matches(obj, spec))
43
+ return { line: lines[i], obj };
44
+ }
45
+ catch {
46
+ continue;
47
+ }
48
+ }
49
+ return null;
50
+ }
51
+ export function currentSize(file) {
52
+ try {
53
+ return statSync(file).size;
54
+ }
55
+ catch {
56
+ return 0;
57
+ }
58
+ }
59
+ function readRange(file, offset, length) {
60
+ const fd = openSync(file, "r");
61
+ try {
62
+ const buf = Buffer.alloc(length);
63
+ readSync(fd, buf, 0, length, offset);
64
+ return buf.toString("utf8");
65
+ }
66
+ finally {
67
+ closeSync(fd);
68
+ }
69
+ }
70
+ function parseLines(chunk) {
71
+ return chunk.split("\n").filter(Boolean).map((line) => {
72
+ try {
73
+ return { line, obj: JSON.parse(line) };
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ });
79
+ }
80
+ function sleep(ms) {
81
+ return new Promise((r) => setTimeout(r, ms));
82
+ }
@@ -0,0 +1,8 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname, join } from "node:path";
4
+ // Resolves the published package.json regardless of where the compiled
5
+ // module lands (dist/lib/version.js → ../../package.json at the package root).
6
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
7
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
8
+ export const VERSION = pkg.version;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getdial/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.2",
4
4
  "description": "Dial CLI — install, sign up, and run the local listen service.",
5
5
  "license": "MIT",
6
6
  "bin": {