@getdial/cli 0.19.1 → 0.20.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/dist/cli.js +18 -0
- package/dist/commands/listen/index.js +27 -1
- package/dist/commands/uninstall.js +25 -0
- package/dist/commands/update.js +42 -0
- package/dist/lib/listen-version.js +42 -0
- package/dist/lib/local-targets.js +11 -35
- package/dist/lib/ops/account.js +2 -2
- package/dist/lib/ops/uninstall.js +54 -0
- package/dist/lib/paths.js +0 -4
- package/dist/lib/skill-install.js +11 -0
- package/dist/lib/state.js +27 -69
- package/dist/lib/update.js +125 -0
- package/dist/lib/versioned-file.js +132 -0
- package/package.json +1 -1
- package/skills.tar.gz +0 -0
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
|
|
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
|
+
}
|
|
@@ -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 {
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/lib/ops/account.js
CHANGED
|
@@ -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:
|
|
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 {
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
33
|
+
return authFile.read();
|
|
70
34
|
}
|
|
71
35
|
export function writeAuth(auth) {
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
46
|
+
return pendingSignupFile.read();
|
|
89
47
|
}
|
|
90
48
|
export function writePendingSignup(p) {
|
|
91
|
-
|
|
49
|
+
pendingSignupFile.write(p);
|
|
92
50
|
}
|
|
93
51
|
export function clearPendingSignup() {
|
|
94
|
-
|
|
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
package/skills.tar.gz
CHANGED
|
Binary file
|