maqcli 0.6.0 → 0.6.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.
@@ -42,6 +42,7 @@ export const maqCommands = [
42
42
  { name: "flow", category: "control", summary: "Scheduled agent sessions (run under the daemon).", usage: "maq flow [list|add|remove]", needsInput: "none", args: [] },
43
43
  { name: "audit", category: "control", summary: "Verify a run's hash-chained audit log.", usage: "maq audit verify <run-dir>", needsInput: "none", args: [] },
44
44
  { name: "security", category: "system", summary: "Enforced security rules (protected paths, egress allowlist, injection scanning) + recent findings.", usage: "maq security [report|rules|scan <path>]", needsInput: "none", args: [] },
45
+ { name: "update", category: "system", summary: "Check npm for a newer maqcli and self-update.", usage: "maq update [--check]", needsInput: "none", args: [{ name: "check", type: "boolean", description: "only check, don't install" }] },
45
46
  ];
46
47
  /** Catalog of AI worker CLIs' own commands/flags (verified 2026-07-01). */
47
48
  export const aiCliCatalog = [
@@ -353,7 +353,7 @@ async function connectMobile(rl) {
353
353
  async function launchUi(authKey) {
354
354
  // Reuse the daemon; open its landing page. Import lazily to avoid a cycle.
355
355
  const { createDaemon } = await import("../server/daemon.js");
356
- const daemon = createDaemon({ token: authKey, version: "0.6.0" });
356
+ const daemon = createDaemon({ token: authKey, version: "0.6.2" });
357
357
  try {
358
358
  const { host, port } = await daemon.listen();
359
359
  const url = `http://${host}:${port}/`;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Self-update — lets a user run `maq update` from inside the CLI instead of
3
+ * remembering the npm incantation. Checks the npm registry for the latest
4
+ * published version, compares it to the running version, and (unless
5
+ * --check-only) re-installs globally via npm.
6
+ *
7
+ * Network access here is a deliberate, user-initiated exception to the
8
+ * default-deny egress posture (core/security.ts) — it only ever talks to the
9
+ * npm registry, and only when the user explicitly runs this command.
10
+ *
11
+ * THE WINDOWS FILE-LOCK PROBLEM AND ITS FIX
12
+ * ------------------------------------------
13
+ * `npm install -g` has to overwrite the `maq`/`maqcli` shim and the running
14
+ * package's files. If that `npm install` runs as a CHILD of the currently
15
+ * executing `maq` process (i.e. `maq` spawns it and waits), Windows can hold
16
+ * file/handle locks on the very binary being replaced for as long as the
17
+ * parent process tree is alive — npm's overwrite can fail or silently leave a
18
+ * half-updated install. Waiting-for-the-child is the actual cause, not npm
19
+ * itself.
20
+ *
21
+ * The fix: spawn the npm install fully DETACHED (`detached: true`,
22
+ * `stdio: "ignore"`, `.unref()`) so it is not a child of `maq` in any way
23
+ * meaningful to the OS's lock semantics, then have `maq` EXIT IMMEDIATELY
24
+ * instead of waiting on it. By the time npm actually touches the shim files,
25
+ * the process that used to hold them has already terminated. This is the same
26
+ * pattern self-updating CLIs (rustup, deno, bun) use on Windows.
27
+ */
28
+ import { spawn } from "node:child_process";
29
+ export interface VersionInfo {
30
+ current: string;
31
+ latest: string | null;
32
+ upToDate: boolean;
33
+ error?: string;
34
+ }
35
+ /** Returns true if `latest` is strictly newer than `current`. */
36
+ export declare function isNewer(current: string, latest: string): boolean;
37
+ /** Query the npm registry for the latest published version of `maqcli`. */
38
+ export declare function fetchLatestVersion(registryUrl?: string, timeoutMs?: number): Promise<string>;
39
+ /** Compare the running version against the registry; never throws. */
40
+ export declare function checkForUpdate(currentVersion: string): Promise<VersionInfo>;
41
+ export interface UpdateOutcome {
42
+ ok: boolean;
43
+ message: string;
44
+ version?: VersionInfo;
45
+ /** Set when the update was launched detached; the caller should exit(0) promptly instead of doing more work. */
46
+ detachedPid?: number;
47
+ /** Path a --wait caller can poll to see the detached install's result. */
48
+ logPath?: string;
49
+ }
50
+ /**
51
+ * Build the OS-appropriate command to run `npm install -g maqcli@<version>`
52
+ * fully detached from the current process (see module doc for why). On
53
+ * Windows this wraps npm in a new cmd.exe so the child truly detaches from
54
+ * the parent's console/job object; on POSIX, npm itself is spawned directly
55
+ * with `detached: true`.
56
+ */
57
+ export declare function detachedInstallCommand(version: string, logPath: string, platform?: NodeJS.Platform): {
58
+ cmd: string;
59
+ args: string[];
60
+ };
61
+ /**
62
+ * Launch the update fully detached and return immediately — the caller
63
+ * (cmdUpdate) should print guidance and exit right away rather than await
64
+ * anything else, which is what avoids the Windows file-lock failure mode.
65
+ * `spawner` is injectable for tests so nothing is actually spawned there.
66
+ */
67
+ export declare function launchDetachedUpdate(version: string, logPath: string, spawner?: typeof spawn): number | undefined;
68
+ export interface UpdateOutcome {
69
+ ok: boolean;
70
+ message: string;
71
+ version?: VersionInfo;
72
+ /** Set when the update was launched detached; the caller should exit(0) promptly instead of doing more work. */
73
+ detachedPid?: number;
74
+ /** Path a caller can inspect afterward to see the detached install's own npm output. */
75
+ logPath?: string;
76
+ }
77
+ /**
78
+ * Perform the update: check the registry, and if a newer version exists,
79
+ * launch `npm install -g maqcli@<latest>` DETACHED from this process (see the
80
+ * module doc — this is what avoids the Windows file-lock failure mode where
81
+ * npm can't overwrite files the running `maq` process still holds open).
82
+ *
83
+ * The detached path is the default and recommended flow: this function
84
+ * returns as soon as the install is launched; it does not wait for npm to
85
+ * finish, because waiting would defeat the point (the parent would still be
86
+ * alive, still holding the lock, for the whole duration).
87
+ *
88
+ * `opts.wait: true` (used by tests, and by --wait for a CI/non-interactive
89
+ * caller that can tolerate the lock risk) runs synchronously via `runner`
90
+ * instead, so the outcome is available in the same process.
91
+ */
92
+ export declare function performUpdate(currentVersion: string, opts?: {
93
+ checkOnly?: boolean;
94
+ /** Run synchronously in-process instead of detaching (tests; --wait). */
95
+ wait?: boolean;
96
+ runner?: (cmd: string, args: string[]) => Promise<{
97
+ code: number | null;
98
+ stdout: string;
99
+ stderr: string;
100
+ }>;
101
+ spawner?: typeof spawn;
102
+ logPath?: string;
103
+ }): Promise<UpdateOutcome>;
104
+ /**
105
+ * Read a detached update's log file (best-effort). Used by `maq update
106
+ * --status` to show what the background install actually did, since the
107
+ * `maq update` invocation that launched it already exited.
108
+ */
109
+ export declare function readUpdateLog(logPath: string): Promise<string | null>;
110
+ /**
111
+ * Ask npm what is ACTUALLY installed globally right now (ground truth, not
112
+ * "what did we ask for") — this is what confirms a detached update landed,
113
+ * independent of which process/terminal you're asking from.
114
+ */
115
+ export declare function installedGlobalVersion(runner?: (cmd: string, args: string[]) => Promise<{
116
+ code: number | null;
117
+ stdout: string;
118
+ stderr: string;
119
+ }>): Promise<string | null>;
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Self-update — lets a user run `maq update` from inside the CLI instead of
3
+ * remembering the npm incantation. Checks the npm registry for the latest
4
+ * published version, compares it to the running version, and (unless
5
+ * --check-only) re-installs globally via npm.
6
+ *
7
+ * Network access here is a deliberate, user-initiated exception to the
8
+ * default-deny egress posture (core/security.ts) — it only ever talks to the
9
+ * npm registry, and only when the user explicitly runs this command.
10
+ *
11
+ * THE WINDOWS FILE-LOCK PROBLEM AND ITS FIX
12
+ * ------------------------------------------
13
+ * `npm install -g` has to overwrite the `maq`/`maqcli` shim and the running
14
+ * package's files. If that `npm install` runs as a CHILD of the currently
15
+ * executing `maq` process (i.e. `maq` spawns it and waits), Windows can hold
16
+ * file/handle locks on the very binary being replaced for as long as the
17
+ * parent process tree is alive — npm's overwrite can fail or silently leave a
18
+ * half-updated install. Waiting-for-the-child is the actual cause, not npm
19
+ * itself.
20
+ *
21
+ * The fix: spawn the npm install fully DETACHED (`detached: true`,
22
+ * `stdio: "ignore"`, `.unref()`) so it is not a child of `maq` in any way
23
+ * meaningful to the OS's lock semantics, then have `maq` EXIT IMMEDIATELY
24
+ * instead of waiting on it. By the time npm actually touches the shim files,
25
+ * the process that used to hold them has already terminated. This is the same
26
+ * pattern self-updating CLIs (rustup, deno, bun) use on Windows.
27
+ */
28
+ import { spawn } from "node:child_process";
29
+ import { tmpdir } from "node:os";
30
+ import { join } from "node:path";
31
+ import { execSafe } from "./exec.js";
32
+ /** Parse "x.y.z" into a comparable tuple; non-numeric parts sort as 0. */
33
+ function parseSemver(v) {
34
+ const parts = v.replace(/^v/, "").split(".").map((p) => parseInt(p, 10));
35
+ return [parts[0] || 0, parts[1] || 0, parts[2] || 0];
36
+ }
37
+ /** Returns true if `latest` is strictly newer than `current`. */
38
+ export function isNewer(current, latest) {
39
+ const a = parseSemver(current);
40
+ const b = parseSemver(latest);
41
+ for (let i = 0; i < 3; i++) {
42
+ if (b[i] > a[i])
43
+ return true;
44
+ if (b[i] < a[i])
45
+ return false;
46
+ }
47
+ return false;
48
+ }
49
+ /** Query the npm registry for the latest published version of `maqcli`. */
50
+ export async function fetchLatestVersion(registryUrl = "https://registry.npmjs.org/maqcli/latest", timeoutMs = 8000) {
51
+ const res = await fetch(registryUrl, { signal: AbortSignal.timeout(timeoutMs) });
52
+ if (!res.ok)
53
+ throw new Error(`registry returned ${res.status}`);
54
+ const body = (await res.json());
55
+ if (!body.version)
56
+ throw new Error("registry response missing a version field");
57
+ return body.version;
58
+ }
59
+ /** Compare the running version against the registry; never throws. */
60
+ export async function checkForUpdate(currentVersion) {
61
+ try {
62
+ const latest = await fetchLatestVersion();
63
+ return { current: currentVersion, latest, upToDate: !isNewer(currentVersion, latest) };
64
+ }
65
+ catch (e) {
66
+ return { current: currentVersion, latest: null, upToDate: true, error: e.message };
67
+ }
68
+ }
69
+ /**
70
+ * Build the OS-appropriate command to run `npm install -g maqcli@<version>`
71
+ * fully detached from the current process (see module doc for why). On
72
+ * Windows this wraps npm in a new cmd.exe so the child truly detaches from
73
+ * the parent's console/job object; on POSIX, npm itself is spawned directly
74
+ * with `detached: true`.
75
+ */
76
+ export function detachedInstallCommand(version, logPath, platform = process.platform) {
77
+ const pkg = `maqcli@${version}`;
78
+ if (platform === "win32") {
79
+ // /c runs and closes; redirection captures npm's own output for later inspection.
80
+ return { cmd: "cmd.exe", args: ["/c", `npm install -g ${pkg} > "${logPath}" 2>&1`] };
81
+ }
82
+ return { cmd: "sh", args: ["-c", `npm install -g ${pkg} > '${logPath}' 2>&1`] };
83
+ }
84
+ /**
85
+ * Launch the update fully detached and return immediately — the caller
86
+ * (cmdUpdate) should print guidance and exit right away rather than await
87
+ * anything else, which is what avoids the Windows file-lock failure mode.
88
+ * `spawner` is injectable for tests so nothing is actually spawned there.
89
+ */
90
+ export function launchDetachedUpdate(version, logPath, spawner = spawn) {
91
+ const { cmd, args } = detachedInstallCommand(version, logPath);
92
+ const child = spawner(cmd, args, { detached: true, stdio: "ignore", windowsHide: true });
93
+ child.on("error", () => {
94
+ /* best-effort; the log file (or its absence) is the source of truth */
95
+ });
96
+ child.unref();
97
+ return child.pid;
98
+ }
99
+ /**
100
+ * Perform the update: check the registry, and if a newer version exists,
101
+ * launch `npm install -g maqcli@<latest>` DETACHED from this process (see the
102
+ * module doc — this is what avoids the Windows file-lock failure mode where
103
+ * npm can't overwrite files the running `maq` process still holds open).
104
+ *
105
+ * The detached path is the default and recommended flow: this function
106
+ * returns as soon as the install is launched; it does not wait for npm to
107
+ * finish, because waiting would defeat the point (the parent would still be
108
+ * alive, still holding the lock, for the whole duration).
109
+ *
110
+ * `opts.wait: true` (used by tests, and by --wait for a CI/non-interactive
111
+ * caller that can tolerate the lock risk) runs synchronously via `runner`
112
+ * instead, so the outcome is available in the same process.
113
+ */
114
+ export async function performUpdate(currentVersion, opts = {}) {
115
+ const version = await checkForUpdate(currentVersion);
116
+ if (version.error) {
117
+ return { ok: false, message: `could not check for updates: ${version.error}`, version };
118
+ }
119
+ if (version.upToDate) {
120
+ return { ok: true, message: `already up to date (v${version.current})`, version };
121
+ }
122
+ if (opts.checkOnly) {
123
+ return { ok: true, message: `update available: v${version.current} -> v${version.latest}`, version };
124
+ }
125
+ const latest = version.latest;
126
+ if (!opts.wait) {
127
+ const logPath = opts.logPath ?? defaultLogPath();
128
+ const pid = launchDetachedUpdate(latest, logPath, opts.spawner);
129
+ return {
130
+ ok: true,
131
+ message: `update to v${latest} launched in the background (detached, avoids file locks). Open a new terminal in a few seconds to pick it up.`,
132
+ version,
133
+ detachedPid: pid,
134
+ logPath,
135
+ };
136
+ }
137
+ // --wait / test path: run synchronously in this process. On Windows this
138
+ // can hit the file-lock failure mode described above if `maq` itself is
139
+ // the thing being replaced; it's kept for CI and for tests with a stubbed
140
+ // runner, not as the default interactive experience.
141
+ const runner = opts.runner ?? (async (cmd, args) => execSafe(cmd, args, { timeoutMs: 120000 }));
142
+ const outcome = await runner("npm", ["install", "-g", `maqcli@${latest}`]);
143
+ if (outcome.code !== 0) {
144
+ return {
145
+ ok: false,
146
+ message: `update to v${latest} failed (exit ${outcome.code}): ${outcome.stderr.slice(0, 400) || outcome.stdout.slice(0, 400)}`,
147
+ version,
148
+ };
149
+ }
150
+ return { ok: true, message: `updated v${version.current} -> v${latest}`, version };
151
+ }
152
+ function defaultLogPath() {
153
+ return join(tmpdir(), `maq-update-${Date.now()}.log`);
154
+ }
155
+ /**
156
+ * Read a detached update's log file (best-effort). Used by `maq update
157
+ * --status` to show what the background install actually did, since the
158
+ * `maq update` invocation that launched it already exited.
159
+ */
160
+ export async function readUpdateLog(logPath) {
161
+ try {
162
+ const { readFile } = await import("node:fs/promises");
163
+ return await readFile(logPath, "utf8");
164
+ }
165
+ catch {
166
+ return null;
167
+ }
168
+ }
169
+ /**
170
+ * Ask npm what is ACTUALLY installed globally right now (ground truth, not
171
+ * "what did we ask for") — this is what confirms a detached update landed,
172
+ * independent of which process/terminal you're asking from.
173
+ */
174
+ export async function installedGlobalVersion(runner = (cmd, args) => execSafe(cmd, args, { timeoutMs: 15000 })) {
175
+ const outcome = await runner("npm", ["list", "-g", "maqcli", "--depth=0", "--json"]);
176
+ if (outcome.code !== 0)
177
+ return null;
178
+ try {
179
+ const parsed = JSON.parse(outcome.stdout);
180
+ return parsed.dependencies?.maqcli?.version ?? null;
181
+ }
182
+ catch {
183
+ return null;
184
+ }
185
+ }
package/dist/index.js CHANGED
@@ -43,9 +43,10 @@ import { runInit } from "./core/init-wizard.js";
43
43
  import { CostTracker } from "./core/cost-tracker.js";
44
44
  import { runLauncher } from "./core/launcher.js";
45
45
  import { runOrchestration } from "./core/orchestrator.js";
46
+ import { performUpdate, installedGlobalVersion } from "./core/update.js";
46
47
  import { checkProtectedPath, scanForInjection, securityLog, securityRules, } from "./core/security.js";
47
48
  import { readFileSync, statSync } from "node:fs";
48
- const VERSION = "0.6.0";
49
+ const VERSION = "0.6.2";
49
50
  async function main(argv) {
50
51
  const [command, ...rest] = argv;
51
52
  switch (command) {
@@ -79,6 +80,8 @@ async function main(argv) {
79
80
  return cmdOrchestrate(rest);
80
81
  case "security":
81
82
  return cmdSecurity(rest);
83
+ case "update":
84
+ return cmdUpdate(rest);
82
85
  case "verify":
83
86
  return cmdVerify(rest);
84
87
  case "serve":
@@ -426,6 +429,62 @@ function cmdSecurity(args) {
426
429
  return 0;
427
430
  }
428
431
  /** Machine-readable rule set — shared shape with the daemon's GET /v1/security. */
432
+ async function cmdUpdate(args) {
433
+ const { values } = parseArgs({
434
+ args,
435
+ options: {
436
+ ...commonFlags(),
437
+ check: { type: "boolean", default: false },
438
+ wait: { type: "boolean", default: false },
439
+ status: { type: "boolean", default: false },
440
+ },
441
+ allowPositionals: true,
442
+ });
443
+ if (values.status) {
444
+ const installed = await installedGlobalVersion();
445
+ if (values.json) {
446
+ logger.out(JSON.stringify({ runningVersion: VERSION, installedGlobalVersion: installed }, null, 0));
447
+ return 0;
448
+ }
449
+ logger.out(`this terminal's maq: v${VERSION}`);
450
+ logger.out(`globally installed maq: ${installed ? "v" + installed : "unknown (npm list failed)"}`);
451
+ if (installed && installed !== VERSION) {
452
+ logger.out("\nThese differ — a background update likely finished. Open a NEW terminal window");
453
+ logger.out("(this one is still running the old code in memory) to pick up the new version.");
454
+ }
455
+ else if (installed) {
456
+ logger.out("\nUp to date and consistent.");
457
+ }
458
+ return 0;
459
+ }
460
+ logger.out(`current version: ${VERSION}`);
461
+ logger.out(values.check ? "checking npm for a newer version…" : "checking npm and updating if a newer version is available…");
462
+ const outcome = await performUpdate(VERSION, { checkOnly: values.check, wait: values.wait });
463
+ if (values.json) {
464
+ logger.out(JSON.stringify(outcome, null, 0));
465
+ return outcome.ok ? 0 : 1;
466
+ }
467
+ logger.out(outcome.message);
468
+ if (outcome.ok && outcome.version && !outcome.version.upToDate) {
469
+ if (values.check) {
470
+ logger.out("run 'maq update' (without --check) to install it.");
471
+ }
472
+ else if (outcome.detachedPid) {
473
+ // Detached path: THIS is the fix for the Windows file-lock problem —
474
+ // maq is exiting now, on purpose, so nothing holds a lock on the files
475
+ // npm is about to overwrite. Give the user a concrete way to confirm it
476
+ // landed instead of leaving them guessing.
477
+ logger.out("");
478
+ logger.out(` install log: ${outcome.logPath}`);
479
+ logger.out(" check progress any time with: maq update --status");
480
+ logger.out(" (open a NEW terminal window before running that — this one will keep showing the old version)");
481
+ }
482
+ else {
483
+ logger.out("restart your terminal (or open a new one) to pick up the updated binary.");
484
+ }
485
+ }
486
+ return outcome.ok ? 0 : 1;
487
+ }
429
488
  async function cmdServe(args) {
430
489
  const { values } = parseArgs({
431
490
  args,
@@ -1177,6 +1236,7 @@ function printHelp() {
1177
1236
  ' completion <shell> Shell completions (bash/zsh/fish/powershell)',
1178
1237
  " audit verify <run-dir> Verify a run's hash-chained audit log",
1179
1238
  " security [report|rules|scan <path>|add|remove] Enforced security rules + recent findings",
1239
+ " update [--check|--wait|--status] Check npm for a newer maqcli and self-update (detached; avoids file locks)",
1180
1240
  " config [get|set|path|reset] Read or update ~/.maqcli/config.json",
1181
1241
  " version | help [<topic>]",
1182
1242
  "",
@@ -52,7 +52,7 @@ export function createDaemon(opts = {}) {
52
52
  const host = opts.host ?? process.env.MAQ_HOST ?? "127.0.0.1";
53
53
  const port = opts.port ?? Number(process.env.MAQ_PORT ?? 7717);
54
54
  const token = opts.token ?? process.env.MAQ_TOKEN ?? generateToken();
55
- const version = opts.version ?? "0.6.0";
55
+ const version = opts.version ?? "0.6.2";
56
56
  const corsOrigin = opts.corsOrigin ?? process.env.MAQ_CORS_ORIGIN;
57
57
  const registry = opts.registry ?? new SessionRegistry();
58
58
  const interactive = new InteractiveRegistry();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "maqcli",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "MAQ master orchestrator — a token-efficient, agent-agnostic supervisor CLI that sits on top of any worker CLI (AI or not) via a Scout -> Plan -> Execute -> Verify pipeline.",
5
5
  "type": "module",
6
6
  "bin": {