maqcli 0.6.1 → 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.
@@ -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.1" });
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}/`;
@@ -7,7 +7,25 @@
7
7
  * Network access here is a deliberate, user-initiated exception to the
8
8
  * default-deny egress posture (core/security.ts) — it only ever talks to the
9
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.
10
27
  */
28
+ import { spawn } from "node:child_process";
11
29
  export interface VersionInfo {
12
30
  current: string;
13
31
  latest: string | null;
@@ -24,17 +42,78 @@ export interface UpdateOutcome {
24
42
  ok: boolean;
25
43
  message: string;
26
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;
27
76
  }
28
77
  /**
29
78
  * Perform the update: check the registry, and if a newer version exists,
30
- * run `npm install -g maqcli@<latest>`. `runner` is injectable for tests
31
- * it defaults to execSafe (shell:false, argument array; never string-built).
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.
32
91
  */
33
92
  export declare function performUpdate(currentVersion: string, opts?: {
34
93
  checkOnly?: boolean;
94
+ /** Run synchronously in-process instead of detaching (tests; --wait). */
95
+ wait?: boolean;
35
96
  runner?: (cmd: string, args: string[]) => Promise<{
36
97
  code: number | null;
37
98
  stdout: string;
38
99
  stderr: string;
39
100
  }>;
101
+ spawner?: typeof spawn;
102
+ logPath?: string;
40
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>;
@@ -7,7 +7,27 @@
7
7
  * Network access here is a deliberate, user-initiated exception to the
8
8
  * default-deny egress posture (core/security.ts) — it only ever talks to the
9
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.
10
27
  */
28
+ import { spawn } from "node:child_process";
29
+ import { tmpdir } from "node:os";
30
+ import { join } from "node:path";
11
31
  import { execSafe } from "./exec.js";
12
32
  /** Parse "x.y.z" into a comparable tuple; non-numeric parts sort as 0. */
13
33
  function parseSemver(v) {
@@ -46,10 +66,50 @@ export async function checkForUpdate(currentVersion) {
46
66
  return { current: currentVersion, latest: null, upToDate: true, error: e.message };
47
67
  }
48
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
+ }
49
99
  /**
50
100
  * Perform the update: check the registry, and if a newer version exists,
51
- * run `npm install -g maqcli@<latest>`. `runner` is injectable for tests
52
- * it defaults to execSafe (shell:false, argument array; never string-built).
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.
53
113
  */
54
114
  export async function performUpdate(currentVersion, opts = {}) {
55
115
  const version = await checkForUpdate(currentVersion);
@@ -62,14 +122,64 @@ export async function performUpdate(currentVersion, opts = {}) {
62
122
  if (opts.checkOnly) {
63
123
  return { ok: true, message: `update available: v${version.current} -> v${version.latest}`, version };
64
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.
65
141
  const runner = opts.runner ?? (async (cmd, args) => execSafe(cmd, args, { timeoutMs: 120000 }));
66
- const outcome = await runner("npm", ["install", "-g", `maqcli@${version.latest}`]);
142
+ const outcome = await runner("npm", ["install", "-g", `maqcli@${latest}`]);
67
143
  if (outcome.code !== 0) {
68
144
  return {
69
145
  ok: false,
70
- message: `update to v${version.latest} failed (exit ${outcome.code}): ${outcome.stderr.slice(0, 400) || outcome.stdout.slice(0, 400)}`,
146
+ message: `update to v${latest} failed (exit ${outcome.code}): ${outcome.stderr.slice(0, 400) || outcome.stdout.slice(0, 400)}`,
71
147
  version,
72
148
  };
73
149
  }
74
- return { ok: true, message: `updated v${version.current} -> v${version.latest}`, version };
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
+ }
75
185
  }
package/dist/index.js CHANGED
@@ -43,10 +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 } from "./core/update.js";
46
+ import { performUpdate, installedGlobalVersion } from "./core/update.js";
47
47
  import { checkProtectedPath, scanForInjection, securityLog, securityRules, } from "./core/security.js";
48
48
  import { readFileSync, statSync } from "node:fs";
49
- const VERSION = "0.6.1";
49
+ const VERSION = "0.6.2";
50
50
  async function main(argv) {
51
51
  const [command, ...rest] = argv;
52
52
  switch (command) {
@@ -432,22 +432,56 @@ function cmdSecurity(args) {
432
432
  async function cmdUpdate(args) {
433
433
  const { values } = parseArgs({
434
434
  args,
435
- options: { ...commonFlags(), check: { type: "boolean", default: false } },
435
+ options: {
436
+ ...commonFlags(),
437
+ check: { type: "boolean", default: false },
438
+ wait: { type: "boolean", default: false },
439
+ status: { type: "boolean", default: false },
440
+ },
436
441
  allowPositionals: true,
437
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
+ }
438
460
  logger.out(`current version: ${VERSION}`);
439
461
  logger.out(values.check ? "checking npm for a newer version…" : "checking npm and updating if a newer version is available…");
440
- const outcome = await performUpdate(VERSION, { checkOnly: values.check });
462
+ const outcome = await performUpdate(VERSION, { checkOnly: values.check, wait: values.wait });
441
463
  if (values.json) {
442
464
  logger.out(JSON.stringify(outcome, null, 0));
443
465
  return outcome.ok ? 0 : 1;
444
466
  }
445
467
  logger.out(outcome.message);
446
- if (outcome.ok && outcome.version && !outcome.version.upToDate && !values.check) {
447
- logger.out("restart your terminal (or open a new one) to pick up the updated binary.");
448
- }
449
- if (outcome.ok && outcome.version && !outcome.version.upToDate && values.check) {
450
- logger.out("run 'maq update' (without --check) to install it.");
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
+ }
451
485
  }
452
486
  return outcome.ok ? 0 : 1;
453
487
  }
@@ -1202,7 +1236,7 @@ function printHelp() {
1202
1236
  ' completion <shell> Shell completions (bash/zsh/fish/powershell)',
1203
1237
  " audit verify <run-dir> Verify a run's hash-chained audit log",
1204
1238
  " security [report|rules|scan <path>|add|remove] Enforced security rules + recent findings",
1205
- " update [--check] Check npm for a newer maqcli and self-update",
1239
+ " update [--check|--wait|--status] Check npm for a newer maqcli and self-update (detached; avoids file locks)",
1206
1240
  " config [get|set|path|reset] Read or update ~/.maqcli/config.json",
1207
1241
  " version | help [<topic>]",
1208
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.1";
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.1",
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": {