@getdial/cli 0.34.1 → 0.35.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.
@@ -3,6 +3,8 @@ import { isDialError } from "../lib/ops/errors.js";
3
3
  import { readAuth, authFilePath } from "../lib/state.js";
4
4
  import { installSkill, isSupportedAgent, SUPPORTED_AGENTS, } from "../lib/skill-install.js";
5
5
  import { supervisorAvailability } from "../lib/supervisor/index.js";
6
+ import { baseUrl } from "../lib/api.js";
7
+ import { dashboardHint, dashboardUrl } from "../lib/dashboard.js";
6
8
  function maskApiKey(key) {
7
9
  return key.length >= 4 ? `sk_live_***${key.slice(-4)}` : "sk_live_***";
8
10
  }
@@ -60,8 +62,10 @@ export async function runOnboard(opts) {
60
62
  apiKeyMasked: maskApiKey(auth.apiKey),
61
63
  apiKeyPath: authFilePath(),
62
64
  accountId: auth.accountId,
65
+ email: auth.email || null,
63
66
  phoneNumber: auth.phoneNumber ?? null,
64
67
  phoneNumberId: auth.phoneNumberId ?? null,
68
+ dashboardUrl: dashboardUrl(baseUrl()),
65
69
  listen: {
66
70
  installed: false,
67
71
  autoInstalled: false,
@@ -87,6 +91,7 @@ export async function runOnboard(opts) {
87
91
  else if (r.unchanged)
88
92
  console.log(` skill (${r.agent}): already up to date → ${r.path}`);
89
93
  }
94
+ console.log(dashboardHint(dashboardUrl(baseUrl()), auth.email || null));
90
95
  }
91
96
  return 0;
92
97
  }
@@ -123,7 +128,7 @@ export async function runOnboard(opts) {
123
128
  console.error(`onboard failed: ${e.message}`);
124
129
  return 2;
125
130
  }
126
- const { apiKey, accountId, phoneNumber, phoneNumberId, apiKeyPath, skills, supervisor } = result;
131
+ const { apiKey, accountId, email, phoneNumber, phoneNumberId, apiKeyPath, skills, supervisor } = result;
127
132
  const masked = maskApiKey(apiKey);
128
133
  if (opts.json) {
129
134
  console.log(JSON.stringify({
@@ -132,8 +137,10 @@ export async function runOnboard(opts) {
132
137
  apiKeyMasked: masked,
133
138
  apiKeyPath,
134
139
  accountId,
140
+ email,
135
141
  phoneNumber,
136
142
  phoneNumberId,
143
+ dashboardUrl: result.dashboardUrl,
137
144
  listen: {
138
145
  installed: false,
139
146
  autoInstalled: false,
@@ -177,6 +184,9 @@ export async function runOnboard(opts) {
177
184
  console.log(` skill (${r.agent}): already up to date → ${r.path}`);
178
185
  }
179
186
  }
187
+ // Part of the summary, deliberately above the finalization block so that
188
+ // block stays the last thing an agent reads.
189
+ console.log(dashboardHint(result.dashboardUrl, email));
180
190
  console.log(``);
181
191
  if (!supervisor.available) {
182
192
  console.log(`listen service: not available on this machine (${supervisor.reason}).`);
@@ -0,0 +1,38 @@
1
+ // Where a user manages the account in a browser, derived from whichever API base
2
+ // the CLI is pointed at — so a dev pointing DIAL_API_URL at localhost gets the
3
+ // local dashboard, not production's.
4
+ //
5
+ // Deliberately import-free: api.ts installs a global undici dispatcher at import
6
+ // time, and this is pure string work that shouldn't drag that into its own test.
7
+ // Callers pass `baseUrl()` in.
8
+ /**
9
+ * The dashboard URL for an API base. The web app lives on the same host as the API
10
+ * minus its `api.` label (`api.getdial.ai` → `getdial.ai`); a base without that
11
+ * label — staging, or a localhost dev server serving both — is used as-is.
12
+ */
13
+ export function dashboardUrl(apiBase) {
14
+ const url = new URL(apiBase);
15
+ // Strip only a leading `api.` LABEL. A substring check would turn a host like
16
+ // `apiary.example.com` into `ary.example.com`.
17
+ if (url.hostname.startsWith("api."))
18
+ url.hostname = url.hostname.slice("api.".length);
19
+ // `pathname` is "/" for a bare origin and keeps a trailing slash when one was
20
+ // given, so join on a trimmed copy rather than concatenating blindly.
21
+ url.pathname = `${url.pathname.replace(/\/+$/, "")}/dashboard`;
22
+ return url.toString();
23
+ }
24
+ /**
25
+ * The closing line of `dial onboard`: where to manage the account, and which
26
+ * address signs in there.
27
+ *
28
+ * The address matters more than it looks. Sign-in is an emailed code, and when an
29
+ * agent onboarded on the user's behalf the user usually doesn't know which address
30
+ * it used — that's the single biggest reason they never reach the dashboard. Omitted
31
+ * when the CLI genuinely doesn't know it (an explicit `--verification-id` with no
32
+ * pending signup), since offering to sign in as nobody is worse than saying nothing.
33
+ */
34
+ export function dashboardHint(url, email) {
35
+ return email
36
+ ? `manage your account: ${url} (sign in with ${email} — it emails you a code)`
37
+ : `manage your account: ${url} (sign in with the email this account was created under)`;
38
+ }
@@ -1,5 +1,6 @@
1
1
  import { readAuth, readPendingSignup, writePendingSignup, clearPendingSignup, writeAuth, authFilePath, } from "../state.js";
2
2
  import { apiGet, apiPost, baseUrl, pingBackend } from "../api.js";
3
+ import { dashboardUrl } from "../dashboard.js";
3
4
  import { supervisorStatus, lastEventAtFromLog, supervisorAvailability, } from "../supervisor/index.js";
4
5
  import { paths } from "../paths.js";
5
6
  import { VERSION } from "../version.js";
@@ -167,8 +168,10 @@ export async function onboard(opts) {
167
168
  apiKeyFingerprint: apiKey.slice(-4),
168
169
  apiKeyPath: authFilePath(),
169
170
  accountId: res.data.accountId,
171
+ email,
170
172
  phoneNumber: res.data.phoneNumber ?? null,
171
173
  phoneNumberId: res.data.phoneNumberId ?? null,
174
+ dashboardUrl: dashboardUrl(baseUrl()),
172
175
  skills,
173
176
  supervisor: supervisorAvailability(),
174
177
  };
@@ -1,4 +1,5 @@
1
- import { readFileSync } from "node:fs";
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { join } from "node:path";
3
4
  import { paths } from "./paths.js";
4
5
  import { logger } from "./log.js";
@@ -7,23 +8,60 @@ import { logger } from "./log.js";
7
8
  // X-Dial-Ref-Params header. The CLI forwards the file verbatim — no parsing, no
8
9
  // allowlist here; the server decodes + validates. Cached per process (the file is
9
10
  // write-once and stable for the CLI's lifetime).
11
+ //
12
+ // If the file has no dial_attribution_id, one is minted and appended. That covers
13
+ // every install path — `curl … | bash` writes an id itself, but `npm install -g`,
14
+ // Homebrew, a prebaked image, or an agent installing the CLI do not, and those
15
+ // machines had no attribution spine at all until their eventual signup. Doing it
16
+ // here rather than in an installer means there is exactly one code path to cover,
17
+ // since every API request already passes through this function.
18
+ const ATTRIBUTION_KEY = "dial_attribution_id";
10
19
  let cache;
20
+ /** True when the file already carries an attribution id line. */
21
+ function hasAttributionId(text) {
22
+ return text.split("\n").some((l) => l.trim().startsWith(`${ATTRIBUTION_KEY}=`));
23
+ }
24
+ /**
25
+ * Append an attribution id to `text` and persist it. Write-once per key, mirroring
26
+ * the installer's add_ref: a real browser-originated id is never overwritten (the
27
+ * caller checks first).
28
+ *
29
+ * A write failure is warned and swallowed — the id is still returned so a signup in
30
+ * this same process aliases correctly. An unwritable dataDir is already-broken
31
+ * territory the CLI surfaces elsewhere.
32
+ */
33
+ function mintAttributionId(file, text) {
34
+ const separator = text.length > 0 && !text.endsWith("\n") ? "\n" : "";
35
+ const next = `${text}${separator}${ATTRIBUTION_KEY}=${randomUUID()}\n`;
36
+ try {
37
+ mkdirSync(paths().dataDir, { recursive: true });
38
+ writeFileSync(file, next, "utf8");
39
+ }
40
+ catch (err) {
41
+ logger.warn({ err }, "couldn't persist the attribution id; using it for this process only");
42
+ }
43
+ return next;
44
+ }
11
45
  function compute() {
12
46
  const file = join(paths().dataDir, "ref-params.txt");
47
+ let text;
13
48
  try {
14
- const text = readFileSync(file, "utf8");
15
- if (!text.trim())
16
- return null;
17
- return Buffer.from(text, "utf8").toString("base64");
49
+ text = readFileSync(file, "utf8");
18
50
  }
19
51
  catch (err) {
20
52
  // No file is the normal case (the user never went through an attributed
21
- // install) — not an error worth logging. Anything else is unexpected.
22
- if (err?.code === "ENOENT")
23
- return null;
24
- logger.warn({ err }, "failed to read ref-params.txt");
25
- return null;
53
+ // install) — not an error worth logging. Anything else is unexpected, but
54
+ // still recoverable: treat it as empty and mint below.
55
+ if (err?.code !== "ENOENT") {
56
+ logger.warn({ err }, "failed to read ref-params.txt");
57
+ }
58
+ text = "";
26
59
  }
60
+ if (!hasAttributionId(text))
61
+ text = mintAttributionId(file, text);
62
+ if (!text.trim())
63
+ return null;
64
+ return Buffer.from(text, "utf8").toString("base64");
27
65
  }
28
66
  /** Base64 of ref-params.txt for the X-Dial-Ref-Params header, or null if absent. */
29
67
  export function refParamsHeader() {
@@ -4,6 +4,8 @@ import { onboard } from "../../lib/ops/account.js";
4
4
  import { readAuth, authFilePath } from "../../lib/state.js";
5
5
  import { installSkill, isSupportedAgent, SUPPORTED_AGENTS, } from "../../lib/skill-install.js";
6
6
  import { supervisorAvailability } from "../../lib/supervisor/index.js";
7
+ import { baseUrl } from "../../lib/api.js";
8
+ import { dashboardUrl } from "../../lib/dashboard.js";
7
9
  const inputSchema = {
8
10
  code: z
9
11
  .string()
@@ -34,8 +36,15 @@ export const onboardTool = {
34
36
  apiKeyFingerprint: z.string().describe("Last 4 chars of the saved API key"),
35
37
  apiKeyPath: z.string().describe("Where the key was saved"),
36
38
  accountId: z.string(),
39
+ email: z
40
+ .string()
41
+ .nullable()
42
+ .describe("Address that signs in to the dashboard (a code is emailed to it). Null when the CLI never saw the signup — an explicit verificationId. Pass it on to the user: after an agent onboards for them, they usually don't know which address was used."),
37
43
  phoneNumber: z.string().nullable(),
38
44
  phoneNumberId: z.string().nullable(),
45
+ dashboardUrl: z
46
+ .string()
47
+ .describe("Where the user manages the account in a browser. Tell them about it once onboarding succeeds, then keep working from the CLI — it's only needed for paying, team sharing, and carrier (10DLC) registration."),
39
48
  skills: z.array(z.object({}).passthrough()).describe("Per-agent skill install results"),
40
49
  supervisor: z.object({}).passthrough().describe("Listen daemon availability on this machine"),
41
50
  listenAvailable: z.boolean(),
@@ -75,8 +84,12 @@ export const onboardTool = {
75
84
  apiKeyFingerprint: auth.apiKey.slice(-4),
76
85
  apiKeyPath: authFilePath(),
77
86
  accountId: auth.accountId,
87
+ // Built by hand rather than spread, so both fields have to be added here
88
+ // explicitly to match the full-onboard path below.
89
+ email: auth.email || null,
78
90
  phoneNumber: auth.phoneNumber ?? null,
79
91
  phoneNumberId: auth.phoneNumberId ?? null,
92
+ dashboardUrl: dashboardUrl(baseUrl()),
80
93
  skills,
81
94
  supervisor,
82
95
  listenAvailable: supervisor.available,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getdial/cli",
3
- "version": "0.34.1",
3
+ "version": "0.35.0",
4
4
  "description": "Dial CLI — install, sign up, and run the local listen service.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/skills.tar.gz CHANGED
Binary file