@getdial/cli 0.19.1 → 0.20.1

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
@@ -22,12 +22,20 @@ import { runLocalTargetAddCmd } from "./commands/local-target/add-cmd.js";
22
22
  import { runLocalTargetRemove } from "./commands/local-target/remove.js";
23
23
  import { runLocalTargetList } from "./commands/local-target/list.js";
24
24
  import { runMcp } from "./commands/mcp.js";
25
+ import { runUpdate } from "./commands/update.js";
26
+ import { runUninstall } from "./commands/uninstall.js";
27
+ import { maybeAutoUpdate } from "./lib/update.js";
25
28
  const program = new Command();
26
29
  program
27
30
  .name("dial")
28
31
  .description("Dial CLI — set up your account and run the listen service.")
29
32
  .version(VERSION)
30
33
  .enablePositionalOptions();
34
+ // Hourly detached self-update; a no-op for exempt commands, non-npm installs,
35
+ // fresh stamps, and DIAL_NO_AUTO_UPDATE=1. Never touches stdout/stderr.
36
+ program.hook("preAction", (_thisCommand, actionCommand) => {
37
+ maybeAutoUpdate(actionCommand.name());
38
+ });
31
39
  program
32
40
  .command("doctor")
33
41
  .description("Report state and what to do next.")
@@ -243,6 +251,16 @@ program
243
251
  .command("mcp")
244
252
  .description("Run a local stdio MCP server exposing Dial as agent tools (reuses your saved API key).")
245
253
  .action(async () => process.exit(await runMcp()));
254
+ program
255
+ .command("update")
256
+ .description("Update the CLI to the latest published version (global npm installs).")
257
+ .option("--json", "machine-readable output")
258
+ .action(async (opts) => process.exit(await runUpdate({ json: !!opts.json })));
259
+ program
260
+ .command("uninstall")
261
+ .description("Remove the listen daemon, agent skills, and all local Dial state, then print how to remove the package.")
262
+ .option("--json", "machine-readable output")
263
+ .action(async (opts) => process.exit(await runUninstall({ json: !!opts.json })));
246
264
  program.parseAsync(process.argv).catch((err) => {
247
265
  console.error(err instanceof Error ? err.message : String(err));
248
266
  process.exit(2);
@@ -3,19 +3,22 @@ import { readAuth } from "../../lib/state.js";
3
3
  import { startWorker } from "../../lib/pubnub.js";
4
4
  import { appendJsonl } from "../../lib/log.js";
5
5
  import { paths } from "../../lib/paths.js";
6
+ import { nextSkewState, recordListenVersion, safeInstalledVersion, VERSION_POLL_INTERVAL_MS, } from "../../lib/listen-version.js";
7
+ import { VERSION } from "../../lib/version.js";
6
8
  function isSupervised() {
7
9
  return Boolean(process.env.LAUNCHD_SOCKET || process.env.LAUNCH_DAEMON || process.env.INVOCATION_ID);
8
10
  }
9
11
  export async function runListen() {
10
12
  const auth = readAuth();
11
13
  if (!auth) {
12
- appendJsonl(paths().listenLog, { ts: new Date().toISOString(), lifecycle: "startup", ok: false, error: "no auth.json" });
14
+ appendJsonl(paths().listenLog, { ts: new Date().toISOString(), lifecycle: "startup", ok: false, error: "no saved auth" });
13
15
  if (isSupervised()) {
14
16
  await delay(30_000);
15
17
  }
16
18
  return 1;
17
19
  }
18
20
  appendJsonl(paths().listenLog, { ts: new Date().toISOString(), lifecycle: "startup", ok: true, accountId: auth.accountId });
21
+ recordListenVersion();
19
22
  const ctrl = startWorker(auth.apiKey, auth.accountId);
20
23
  const onSignal = async (sig) => {
21
24
  appendJsonl(paths().listenLog, { ts: new Date().toISOString(), lifecycle: "shutdown", signal: sig });
@@ -23,7 +26,30 @@ export async function runListen() {
23
26
  };
24
27
  process.on("SIGTERM", onSignal);
25
28
  process.on("SIGINT", onSignal);
29
+ // When an update replaces the installed CLI, drain and exit non-zero so the
30
+ // supervisor (launchd KeepAlive / systemd on-failure) relaunches us onto the
31
+ // new code. Exit 1 restarts under both supervisors.
32
+ let skew = { streak: 0 };
33
+ const versionPoll = setInterval(() => {
34
+ const installed = safeInstalledVersion();
35
+ const decision = nextSkewState(skew, VERSION, installed);
36
+ skew = decision.state;
37
+ if (decision.restart) {
38
+ clearInterval(versionPoll);
39
+ appendJsonl(paths().listenLog, {
40
+ ts: new Date().toISOString(),
41
+ lifecycle: "restart",
42
+ reason: "version-skew",
43
+ running: VERSION,
44
+ installed,
45
+ });
46
+ process.exitCode = 1;
47
+ void ctrl.stop();
48
+ }
49
+ }, VERSION_POLL_INTERVAL_MS);
50
+ versionPoll.unref();
26
51
  await ctrl.whenStopped;
52
+ clearInterval(versionPoll);
27
53
  const code = process.exitCode;
28
54
  return typeof code === "number" ? code : 0;
29
55
  }
@@ -0,0 +1,25 @@
1
+ import { uninstallEverything } from "../lib/ops/uninstall.js";
2
+ export async function runUninstall(opts) {
3
+ const report = uninstallEverything();
4
+ if (opts.json) {
5
+ console.log(JSON.stringify(report));
6
+ return report.ok ? 0 : 2;
7
+ }
8
+ const daemonLine = report.daemon.status === "removed"
9
+ ? "removed"
10
+ : report.daemon.status === "skipped"
11
+ ? `skipped (${report.daemon.reason})`
12
+ : `failed (${report.daemon.reason})`;
13
+ console.log(`listen daemon: ${daemonLine}`);
14
+ const removedSkills = report.skills.filter((s) => s.removed);
15
+ console.log(removedSkills.length
16
+ ? `agent skills: removed from ${removedSkills.map((s) => s.agent).join(", ")}`
17
+ : "agent skills: none installed");
18
+ const removedDirs = report.dirs.filter((d) => d.removed);
19
+ console.log(removedDirs.length ? `state dirs: removed ${removedDirs.map((d) => d.path).join(", ")}` : "state dirs: none present");
20
+ for (const e of report.errors) {
21
+ console.error(`error in ${e.step}: ${e.message}`);
22
+ }
23
+ console.log(`\nDial is removed from this machine. Finish with:\n ${report.hint}`);
24
+ return report.ok ? 0 : 2;
25
+ }
@@ -0,0 +1,42 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { detectInstallKind, installedVersion, npmUpdateCommand } from "../lib/update.js";
3
+ import { VERSION } from "../lib/version.js";
4
+ export async function runUpdate(opts) {
5
+ const kind = detectInstallKind(process.argv[1] ?? "");
6
+ if (kind !== "global-npm") {
7
+ const guidance = kind === "npx"
8
+ ? "npx already runs the latest version on each invocation — nothing to update."
9
+ : "this dial is not a global npm install (source checkout or custom binary) — update it the way it was installed.";
10
+ if (opts.json)
11
+ console.log(JSON.stringify({ ok: false, error: "not_updatable", kind, message: guidance }));
12
+ else
13
+ console.error(`update unavailable: ${guidance}`);
14
+ return 2;
15
+ }
16
+ const previous = VERSION;
17
+ const { command, args } = npmUpdateCommand();
18
+ try {
19
+ execFileSync(command, args, { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" });
20
+ }
21
+ catch (err) {
22
+ const e = err;
23
+ const detail = (e.stderr?.trim() || e.message).trim();
24
+ const hint = detail.includes("EACCES")
25
+ ? " (permission denied — fix your npm prefix or re-run with elevated permissions)"
26
+ : "";
27
+ if (opts.json)
28
+ console.log(JSON.stringify({ ok: false, error: "npm_failed", message: `${detail}${hint}` }));
29
+ else
30
+ console.error(`update failed: ${detail}${hint}`);
31
+ return 2;
32
+ }
33
+ const installed = installedVersion();
34
+ const updated = installed !== previous;
35
+ if (opts.json)
36
+ console.log(JSON.stringify({ ok: true, previous, installed, updated }));
37
+ else if (updated)
38
+ console.log(`updated ${previous} → ${installed}. The new version applies from your next dial command.`);
39
+ else
40
+ console.log(`already up to date (${previous}).`);
41
+ return 0;
42
+ }
package/dist/lib/api.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { request } from "undici";
2
2
  import { logger } from "./log.js";
3
- const DEFAULT_BASE = "https://dial.up.railway.app";
3
+ const DEFAULT_BASE = "https://getdial.ai";
4
4
  export function baseUrl() {
5
5
  return process.env.DIAL_API_URL ?? DEFAULT_BASE;
6
6
  }
@@ -0,0 +1,42 @@
1
+ import { z } from "zod";
2
+ import { logger } from "./log.js";
3
+ import { paths } from "./paths.js";
4
+ import { installedVersion } from "./update.js";
5
+ import { defineVersionedFile } from "./versioned-file.js";
6
+ import { VERSION } from "./version.js";
7
+ export const VERSION_POLL_INTERVAL_MS = 60 * 1000;
8
+ /** Mismatches must hold this many consecutive ticks — npm may be mid-replace. */
9
+ export const SKEW_CONFIRM_TICKS = 2;
10
+ const listenVersionFile = defineVersionedFile({
11
+ dir: () => paths().stateDir,
12
+ base: "listen-version",
13
+ version: 1,
14
+ schema: z.object({ version: z.string(), pid: z.number(), startedAt: z.string() }),
15
+ migrations: {},
16
+ });
17
+ /** Non-blocking startup side effect: failure is a warning, never an abort. */
18
+ export function recordListenVersion(now = new Date()) {
19
+ try {
20
+ listenVersionFile.write({ version: VERSION, pid: process.pid, startedAt: now.toISOString() });
21
+ }
22
+ catch (err) {
23
+ logger.warn({ err }, "could not record the listen service version");
24
+ }
25
+ }
26
+ /** installedVersion() that reports unreadable/unparsable as null instead of throwing. */
27
+ export function safeInstalledVersion() {
28
+ try {
29
+ return installedVersion();
30
+ }
31
+ catch (err) {
32
+ logger.warn({ err }, "could not read the installed CLI version");
33
+ return null;
34
+ }
35
+ }
36
+ export function nextSkewState(state, running, installed) {
37
+ if (installed === null || installed === running) {
38
+ return { state: { streak: 0 }, restart: false };
39
+ }
40
+ const streak = state.streak + 1;
41
+ return { state: { streak }, restart: streak >= SKEW_CONFIRM_TICKS };
42
+ }
@@ -1,7 +1,7 @@
1
- import { mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
2
- import { dirname, isAbsolute } from "node:path";
1
+ import { isAbsolute } from "node:path";
3
2
  import { z } from "zod";
4
3
  import { paths } from "./paths.js";
4
+ import { defineVersionedFile } from "./versioned-file.js";
5
5
  const LOOPBACK_HOSTS = new Set(["127.0.0.1", "0.0.0.0", "localhost", "::1", "[::1]"]);
6
6
  export const DEFAULT_TIMEOUT_SECONDS = 5;
7
7
  export const DEFAULT_SIGNATURE_HEADER = "X-Dial-Signature";
@@ -49,42 +49,18 @@ export function assertLoopbackUrl(raw) {
49
49
  export function targetId(t) {
50
50
  return t.kind === "url" ? t.url : t.path;
51
51
  }
52
- function ensureDir(file) {
53
- mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
54
- }
52
+ const registryFile = defineVersionedFile({
53
+ dir: () => paths().configDir,
54
+ base: "local-targets",
55
+ version: 1,
56
+ schema: RegistrySchema,
57
+ migrations: { 0: (legacy) => legacy },
58
+ });
55
59
  function readRegistry() {
56
- const file = paths().localTargetsFile;
57
- let raw;
58
- try {
59
- raw = readFileSync(file, "utf8");
60
- }
61
- catch (err) {
62
- if (err.code === "ENOENT")
63
- return { targets: [] };
64
- throw err;
65
- }
66
- let parsed;
67
- try {
68
- parsed = JSON.parse(raw);
69
- }
70
- catch {
71
- return { targets: [] };
72
- }
73
- const result = RegistrySchema.safeParse(parsed);
74
- if (!result.success)
75
- return { targets: [] };
76
- return result.data;
60
+ return registryFile.read() ?? { targets: [] };
77
61
  }
78
62
  function writeRegistry(reg) {
79
- const file = paths().localTargetsFile;
80
- ensureDir(file);
81
- writeFileSync(file, JSON.stringify(reg, null, 2), { mode: 0o600 });
82
- try {
83
- chmodSync(file, 0o600);
84
- }
85
- catch {
86
- // chmod can fail on some filesystems; the create-mode is the important guarantee
87
- }
63
+ registryFile.write(reg);
88
64
  }
89
65
  export function listTargets() {
90
66
  return readRegistry().targets;
@@ -1,4 +1,4 @@
1
- import { readAuth, readPendingSignup, writePendingSignup, clearPendingSignup, writeAuth } from "../state.js";
1
+ import { readAuth, readPendingSignup, writePendingSignup, clearPendingSignup, writeAuth, authFilePath } from "../state.js";
2
2
  import { apiGet, apiPost, baseUrl, pingBackend } from "../api.js";
3
3
  import { supervisorStatus, lastEventAtFromLog, supervisorAvailability } from "../supervisor/index.js";
4
4
  import { paths } from "../paths.js";
@@ -126,7 +126,7 @@ export async function onboard(opts) {
126
126
  return {
127
127
  apiKey,
128
128
  apiKeyFingerprint: apiKey.slice(-4),
129
- apiKeyPath: paths().authFile,
129
+ apiKeyPath: authFilePath(),
130
130
  accountId: res.data.accountId,
131
131
  phoneNumber: res.data.phoneNumber ?? null,
132
132
  phoneNumberId: res.data.phoneNumberId ?? null,
@@ -0,0 +1,54 @@
1
+ import { existsSync, rmSync } from "node:fs";
2
+ import { paths } from "../paths.js";
3
+ import { SUPPORTED_AGENTS, uninstallSkill } from "../skill-install.js";
4
+ import { supervisorAvailability, uninstallSupervised } from "../supervisor/index.js";
5
+ export const UNINSTALL_HINT = "npm uninstall -g @getdial/cli";
6
+ /**
7
+ * Full local teardown, best-effort: every step runs even if an earlier one
8
+ * fails, and failures are collected into `errors`. Spec §A.
9
+ */
10
+ export function uninstallEverything(deps = {}) {
11
+ const availability = deps.availability ?? supervisorAvailability;
12
+ const uninstallDaemon = deps.uninstallDaemon ?? uninstallSupervised;
13
+ const errors = [];
14
+ let daemon;
15
+ const supervisor = availability();
16
+ if (!supervisor.available) {
17
+ daemon = { status: "skipped", reason: supervisor.reason };
18
+ }
19
+ else {
20
+ try {
21
+ uninstallDaemon();
22
+ daemon = { status: "removed" };
23
+ }
24
+ catch (err) {
25
+ const message = err instanceof Error ? err.message : String(err);
26
+ daemon = { status: "failed", reason: message };
27
+ errors.push({ step: "daemon", message });
28
+ }
29
+ }
30
+ const skills = [];
31
+ for (const agent of SUPPORTED_AGENTS) {
32
+ try {
33
+ skills.push(uninstallSkill(agent, { home: deps.home, cwd: deps.cwd }));
34
+ }
35
+ catch (err) {
36
+ errors.push({ step: `skill:${agent}`, message: err instanceof Error ? err.message : String(err) });
37
+ }
38
+ }
39
+ const p = paths();
40
+ const dirs = [];
41
+ for (const dir of [p.configDir, p.dataDir, p.stateDir]) {
42
+ try {
43
+ const existed = existsSync(dir);
44
+ if (existed)
45
+ rmSync(dir, { recursive: true, force: true });
46
+ dirs.push({ path: dir, removed: existed });
47
+ }
48
+ catch (err) {
49
+ dirs.push({ path: dir, removed: false });
50
+ errors.push({ step: `dir:${dir}`, message: err instanceof Error ? err.message : String(err) });
51
+ }
52
+ }
53
+ return { ok: errors.length === 0, daemon, skills, dirs, hint: UNINSTALL_HINT, errors };
54
+ }
package/dist/lib/paths.js CHANGED
@@ -12,10 +12,6 @@ export function paths() {
12
12
  configDir,
13
13
  dataDir,
14
14
  stateDir,
15
- configFile: join(configDir, "config.json"),
16
- authFile: join(dataDir, "auth.json"),
17
- pendingSignupFile: join(dataDir, "pending-signup.json"),
18
- localTargetsFile: join(configDir, "local-targets.json"),
19
15
  listenLog: join(stateDir, "listen.log"),
20
16
  listenOutLog: join(stateDir, "listen.out.log"),
21
17
  listenErrLog: join(stateDir, "listen.err.log"),
@@ -78,6 +78,17 @@ export function readSkillMarkdown(tarball = tarballPath()) {
78
78
  rmSync(tmp, { recursive: true, force: true });
79
79
  }
80
80
  }
81
+ /** Removes the agent's installed `dial-cli` skill directory, if present. */
82
+ export function uninstallSkill(agent, opts = {}) {
83
+ const home = opts.home ?? process.env.HOME ?? homedir();
84
+ const cwd = opts.cwd ?? process.cwd();
85
+ const skillDir = dirname(targetPath(agent, home, cwd));
86
+ if (!existsSync(skillDir)) {
87
+ return { agent, path: skillDir, removed: false };
88
+ }
89
+ rmSync(skillDir, { recursive: true, force: true });
90
+ return { agent, path: skillDir, removed: true };
91
+ }
81
92
  export function installSkill(agent, opts = {}) {
82
93
  const home = opts.home ?? process.env.HOME ?? homedir();
83
94
  const cwd = opts.cwd ?? process.cwd();
package/dist/lib/state.js CHANGED
@@ -1,8 +1,6 @@
1
- import { mkdirSync, readFileSync, writeFileSync, unlinkSync, statSync, chmodSync } from "node:fs";
2
- import { dirname } from "node:path";
3
1
  import { z } from "zod";
4
2
  import { paths } from "./paths.js";
5
- import { logger } from "./log.js";
3
+ import { defineVersionedFile } from "./versioned-file.js";
6
4
  export const AuthSchema = z.object({
7
5
  apiKey: z.string(),
8
6
  accountId: z.string(),
@@ -15,81 +13,41 @@ export const PendingSignupSchema = z.object({
15
13
  email: z.string(),
16
14
  createdAt: z.string(),
17
15
  });
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
- }
16
+ const authFile = defineVersionedFile({
17
+ dir: () => paths().dataDir,
18
+ base: "auth",
19
+ version: 1,
20
+ schema: AuthSchema,
21
+ migrations: { 0: (legacy) => legacy },
22
+ secure: true,
23
+ });
24
+ const pendingSignupFile = defineVersionedFile({
25
+ dir: () => paths().dataDir,
26
+ base: "pending-signup",
27
+ version: 1,
28
+ schema: PendingSignupSchema,
29
+ migrations: { 0: (legacy) => legacy },
30
+ secure: true,
31
+ });
68
32
  export function readAuth() {
69
- return readSecure(paths().authFile, AuthSchema);
33
+ return authFile.read();
70
34
  }
71
35
  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
- }
36
+ authFile.write(auth);
83
37
  }
84
38
  export function clearAuth() {
85
- unlinkIfExists(paths().authFile);
39
+ authFile.clear();
40
+ }
41
+ /** Where the API key lives, for display (doctor). */
42
+ export function authFilePath() {
43
+ return authFile.path;
86
44
  }
87
45
  export function readPendingSignup() {
88
- return readSecure(paths().pendingSignupFile, PendingSignupSchema);
46
+ return pendingSignupFile.read();
89
47
  }
90
48
  export function writePendingSignup(p) {
91
- writeSecure(paths().pendingSignupFile, p);
49
+ pendingSignupFile.write(p);
92
50
  }
93
51
  export function clearPendingSignup() {
94
- unlinkIfExists(paths().pendingSignupFile);
52
+ pendingSignupFile.clear();
95
53
  }
@@ -0,0 +1,125 @@
1
+ import { spawn } from "node:child_process";
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync } from "node:fs";
3
+ import { dirname, join, resolve, sep } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { z } from "zod";
6
+ import { appendJsonl, logger } from "./log.js";
7
+ import { paths } from "./paths.js";
8
+ import { defineVersionedFile } from "./versioned-file.js";
9
+ export const AUTO_UPDATE_INTERVAL_MS = 60 * 60 * 1000;
10
+ export const AUTO_UPDATE_EXEMPT_COMMANDS = new Set(["update", "uninstall"]);
11
+ export function detectInstallKind(scriptPath, binOverride = process.env.DIAL_BIN_OVERRIDE) {
12
+ if (binOverride)
13
+ return "other";
14
+ let real = scriptPath;
15
+ try {
16
+ real = realpathSync(scriptPath);
17
+ }
18
+ catch (err) {
19
+ logger.debug({ err, scriptPath }, "could not resolve script path, classifying as-is");
20
+ }
21
+ if (/[/\\]_npx[/\\]/.test(real))
22
+ return "npx";
23
+ if (real.includes(`${sep}node_modules${sep}@getdial${sep}cli${sep}`))
24
+ return "global-npm";
25
+ return "other";
26
+ }
27
+ const updateCheckFile = defineVersionedFile({
28
+ dir: () => paths().stateDir,
29
+ base: "update-check",
30
+ version: 1,
31
+ schema: z.object({ lastAttemptAt: z.string() }),
32
+ migrations: {},
33
+ });
34
+ /** Attempt-based throttle: a persistently failing npm still only retries hourly. */
35
+ export function updateCheckDue(now) {
36
+ const stamp = updateCheckFile.read();
37
+ if (!stamp)
38
+ return true;
39
+ const last = Date.parse(stamp.lastAttemptAt);
40
+ if (Number.isNaN(last))
41
+ return true;
42
+ return now.getTime() - last >= AUTO_UPDATE_INTERVAL_MS;
43
+ }
44
+ export function recordUpdateAttempt(now) {
45
+ updateCheckFile.write({ lastAttemptAt: now.toISOString() });
46
+ }
47
+ export function shouldAutoUpdate(input) {
48
+ if (input.env.DIAL_NO_AUTO_UPDATE === "1")
49
+ return false;
50
+ if (AUTO_UPDATE_EXEMPT_COMMANDS.has(input.command))
51
+ return false;
52
+ if (detectInstallKind(input.scriptPath, input.env.DIAL_BIN_OVERRIDE) !== "global-npm")
53
+ return false;
54
+ return updateCheckDue(input.now);
55
+ }
56
+ /** Prefers the npm sitting next to the running node, like resolveListenCommand does for npx. */
57
+ export function npmUpdateCommand() {
58
+ const sibling = join(dirname(process.execPath), "npm");
59
+ return {
60
+ command: existsSync(sibling) ? sibling : "npm",
61
+ args: ["install", "-g", "@getdial/cli@latest"],
62
+ };
63
+ }
64
+ function packageJsonPath() {
65
+ // dist/lib/update.js (or src/lib/update.ts under tsx) → ../../package.json.
66
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
67
+ }
68
+ /**
69
+ * The version installed on disk right now — unlike VERSION, which is whatever
70
+ * was on disk when this process started. The two diverge after an update.
71
+ */
72
+ export function installedVersion() {
73
+ const pkg = JSON.parse(readFileSync(packageJsonPath(), "utf8"));
74
+ return pkg.version;
75
+ }
76
+ /**
77
+ * Failures of the background path go to cli.log, never stdout/stderr — the
78
+ * hook runs under --json consumers and the MCP stdio server.
79
+ */
80
+ function logUpdateFailure(context, err) {
81
+ const line = {
82
+ ts: new Date().toISOString(),
83
+ source: "auto-update",
84
+ context,
85
+ error: err instanceof Error ? (err.stack ?? err.message) : String(err),
86
+ };
87
+ try {
88
+ appendJsonl(paths().cliLog, line);
89
+ }
90
+ catch (logErr) {
91
+ logger.warn({ err, logErr, context }, "auto-update failed and could not write cli.log");
92
+ }
93
+ }
94
+ /** Spawns the npm update fully detached, stdio appended to cli.log. Never throws. */
95
+ export function spawnDetachedUpdate() {
96
+ try {
97
+ const p = paths();
98
+ mkdirSync(p.stateDir, { recursive: true });
99
+ const fd = openSync(p.cliLog, "a");
100
+ const { command, args } = npmUpdateCommand();
101
+ const child = spawn(command, args, { detached: true, stdio: ["ignore", fd, fd] });
102
+ child.unref();
103
+ closeSync(fd);
104
+ }
105
+ catch (err) {
106
+ logUpdateFailure("spawn", err);
107
+ }
108
+ }
109
+ /**
110
+ * The hourly background check, called from the CLI's preAction hook. Records
111
+ * the attempt before spawning and never throws — a broken update path must
112
+ * not break the command the user actually ran.
113
+ */
114
+ export function maybeAutoUpdate(command) {
115
+ try {
116
+ const now = new Date();
117
+ if (!shouldAutoUpdate({ command, scriptPath: process.argv[1] ?? "", env: process.env, now }))
118
+ return;
119
+ recordUpdateAttempt(now);
120
+ spawnDetachedUpdate();
121
+ }
122
+ catch (err) {
123
+ logUpdateFailure(`check:${command}`, err);
124
+ }
125
+ }
@@ -0,0 +1,132 @@
1
+ import { chmodSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { logger } from "./log.js";
4
+ const CHMOD_UNSUPPORTED_CODES = new Set(["ENOTSUP", "EOPNOTSUPP", "EPERM"]);
5
+ function ensureDir(dir) {
6
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
7
+ try {
8
+ chmodSync(dir, 0o700);
9
+ }
10
+ catch (err) {
11
+ const code = err.code;
12
+ if (code && CHMOD_UNSUPPORTED_CODES.has(code)) {
13
+ logger.warn({ err, code, path: dir }, "chmod 0700 unsupported, continuing");
14
+ return;
15
+ }
16
+ throw err;
17
+ }
18
+ }
19
+ function statIfExists(path) {
20
+ try {
21
+ return statSync(path);
22
+ }
23
+ catch (err) {
24
+ if (err.code === "ENOENT")
25
+ return null;
26
+ throw err;
27
+ }
28
+ }
29
+ function unlinkIfExists(path) {
30
+ try {
31
+ unlinkSync(path);
32
+ }
33
+ catch (err) {
34
+ if (err.code === "ENOENT")
35
+ return;
36
+ throw err;
37
+ }
38
+ }
39
+ export function defineVersionedFile(opts) {
40
+ const mode = opts.mode ?? 0o600;
41
+ /** v0 is the legacy unversioned `<base>.json`. */
42
+ const filePath = (version) => join(opts.dir(), version === 0 ? `${opts.base}.json` : `${opts.base}.v${version}.json`);
43
+ function parseAt(path) {
44
+ if (opts.secure) {
45
+ const fileMode = statSync(path).mode & 0o777;
46
+ if (fileMode & 0o077) {
47
+ throw new Error(`${path} has insecure permissions (mode ${fileMode.toString(8)})`);
48
+ }
49
+ }
50
+ try {
51
+ return JSON.parse(readFileSync(path, "utf8"));
52
+ }
53
+ catch (err) {
54
+ logger.warn({ err, path }, "failed to parse state file");
55
+ return null;
56
+ }
57
+ }
58
+ function validate(value, path) {
59
+ const result = opts.schema.safeParse(value);
60
+ if (!result.success) {
61
+ logger.warn({ path, issues: result.error.issues }, "state file did not match expected schema");
62
+ return null;
63
+ }
64
+ return result.data;
65
+ }
66
+ function write(value) {
67
+ ensureDir(opts.dir());
68
+ const path = filePath(opts.version);
69
+ const tmp = `${path}.tmp`;
70
+ writeFileSync(tmp, JSON.stringify(value, null, 2), { mode });
71
+ try {
72
+ chmodSync(tmp, mode);
73
+ }
74
+ catch (err) {
75
+ const code = err.code;
76
+ if (!code || !CHMOD_UNSUPPORTED_CODES.has(code))
77
+ throw err;
78
+ logger.warn({ err, code, path: tmp }, "chmod unsupported, relying on create mode");
79
+ }
80
+ renameSync(tmp, path);
81
+ }
82
+ function migrateFrom(found, payload, foundPath) {
83
+ let value = payload;
84
+ for (let step = found; step < opts.version; step++) {
85
+ const migration = opts.migrations[step];
86
+ if (!migration) {
87
+ logger.warn({ path: foundPath, from: step, to: step + 1 }, "no migration registered for state file version");
88
+ return null;
89
+ }
90
+ try {
91
+ value = migration(value);
92
+ }
93
+ catch (err) {
94
+ logger.warn({ err, path: foundPath, from: step, to: step + 1 }, "state file migration failed");
95
+ return null;
96
+ }
97
+ }
98
+ const migrated = validate(value, foundPath);
99
+ if (migrated === null)
100
+ return null;
101
+ write(migrated);
102
+ unlinkIfExists(foundPath);
103
+ return migrated;
104
+ }
105
+ function read() {
106
+ for (let version = opts.version; version >= 0; version--) {
107
+ const path = filePath(version);
108
+ if (!statIfExists(path))
109
+ continue;
110
+ const payload = parseAt(path);
111
+ if (payload === null)
112
+ return null;
113
+ if (version === opts.version)
114
+ return validate(payload, path);
115
+ return migrateFrom(version, payload, path);
116
+ }
117
+ return null;
118
+ }
119
+ function clear() {
120
+ for (let version = opts.version; version >= 0; version--) {
121
+ unlinkIfExists(filePath(version));
122
+ }
123
+ }
124
+ return {
125
+ read,
126
+ write,
127
+ clear,
128
+ get path() {
129
+ return filePath(opts.version);
130
+ },
131
+ };
132
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getdial/cli",
3
- "version": "0.19.1",
3
+ "version": "0.20.1",
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