maqcli 0.6.5 → 0.7.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.
@@ -4,7 +4,47 @@
4
4
  * Uses spawn with an explicit argument array and shell:false so user/model
5
5
  * provided values can never be interpreted by a shell. This is the single
6
6
  * chokepoint for running worker CLIs and raw commands.
7
+ *
8
+ * WINDOWS BATCH-SHIM HANDLING (the general fix, applied once here)
9
+ * ------------------------------------------------------------------
10
+ * Node's `spawn(cmd, args, {shell:false})` can only directly execute native
11
+ * executables. On Windows, a great many "binaries" resolve to `.cmd`/`.bat`
12
+ * (npm's own global-install shims — `codex.cmd`, `gemini.cmd`, `npm.cmd`, …)
13
+ * or `.ps1` files, which are NOT directly executable — spawning them without
14
+ * a command interpreter throws EINVAL/ENOENT/UNKNOWN regardless of the target
15
+ * name. This bit both the worker-CLI dispatch path (execute.ts → CliProvider)
16
+ * and the self-updater before this fix landed.
17
+ *
18
+ * The fix (the same approach the `cross-spawn` ecosystem package uses):
19
+ * detect a `.cmd`/`.bat`/`.ps1` target and transparently re-target the spawn
20
+ * to `cmd.exe /c <target> <args...>`, passing the target and each argument as
21
+ * SEPARATE array elements — never a hand-assembled command-line string. This
22
+ * still goes through `child_process.spawn` with `shell:false` at the Node
23
+ * API level; cmd.exe is the one fixed, trusted executable being spawned, and
24
+ * Node's own per-argument Windows escaping (which correctly handles spaces,
25
+ * quotes, and metacharacters within a single argument) does the rest. Every
26
+ * caller of execSafe/execStream gets this for free; nothing above this layer
27
+ * needs to know.
28
+ */
29
+ /**
30
+ * Resolve the effective spawn target for Windows batch/PowerShell shims.
31
+ * Returns the original {cmd, args} unchanged on POSIX or for native
32
+ * executables; re-targets through cmd.exe otherwise.
33
+ *
34
+ * Critically, the shim's arguments are passed as SEPARATE array elements to
35
+ * cmd.exe (`/c`, "<shim>", ...args), not hand-assembled into one big quoted
36
+ * string. Node's own spawn implementation already knows how to correctly
37
+ * quote each array element for a Windows command line (including embedded
38
+ * spaces/quotes); building our own single-string command line and adding
39
+ * `/d /s` on top double-quotes it and corrupts paths with spaces (verified:
40
+ * it mangled "C:\Program Files\nodejs\npm.CMD" into an unrecognized string).
41
+ * Letting spawn's own per-argument escaping do the work, one level of
42
+ * cmd.exe indirection, is both simpler and correct.
7
43
  */
44
+ export declare function resolveSpawnTarget(cmd: string, args: string[]): {
45
+ cmd: string;
46
+ args: string[];
47
+ };
8
48
  export interface ExecOptions {
9
49
  cwd?: string;
10
50
  /** Milliseconds before the child is killed. */
package/dist/core/exec.js CHANGED
@@ -4,9 +4,52 @@
4
4
  * Uses spawn with an explicit argument array and shell:false so user/model
5
5
  * provided values can never be interpreted by a shell. This is the single
6
6
  * chokepoint for running worker CLIs and raw commands.
7
+ *
8
+ * WINDOWS BATCH-SHIM HANDLING (the general fix, applied once here)
9
+ * ------------------------------------------------------------------
10
+ * Node's `spawn(cmd, args, {shell:false})` can only directly execute native
11
+ * executables. On Windows, a great many "binaries" resolve to `.cmd`/`.bat`
12
+ * (npm's own global-install shims — `codex.cmd`, `gemini.cmd`, `npm.cmd`, …)
13
+ * or `.ps1` files, which are NOT directly executable — spawning them without
14
+ * a command interpreter throws EINVAL/ENOENT/UNKNOWN regardless of the target
15
+ * name. This bit both the worker-CLI dispatch path (execute.ts → CliProvider)
16
+ * and the self-updater before this fix landed.
17
+ *
18
+ * The fix (the same approach the `cross-spawn` ecosystem package uses):
19
+ * detect a `.cmd`/`.bat`/`.ps1` target and transparently re-target the spawn
20
+ * to `cmd.exe /c <target> <args...>`, passing the target and each argument as
21
+ * SEPARATE array elements — never a hand-assembled command-line string. This
22
+ * still goes through `child_process.spawn` with `shell:false` at the Node
23
+ * API level; cmd.exe is the one fixed, trusted executable being spawned, and
24
+ * Node's own per-argument Windows escaping (which correctly handles spaces,
25
+ * quotes, and metacharacters within a single argument) does the rest. Every
26
+ * caller of execSafe/execStream gets this for free; nothing above this layer
27
+ * needs to know.
7
28
  */
8
29
  import { spawn } from "node:child_process";
9
30
  import { scrubEnv } from "./security.js";
31
+ const WINDOWS_SHIM_EXT = /\.(cmd|bat|ps1)$/i;
32
+ /**
33
+ * Resolve the effective spawn target for Windows batch/PowerShell shims.
34
+ * Returns the original {cmd, args} unchanged on POSIX or for native
35
+ * executables; re-targets through cmd.exe otherwise.
36
+ *
37
+ * Critically, the shim's arguments are passed as SEPARATE array elements to
38
+ * cmd.exe (`/c`, "<shim>", ...args), not hand-assembled into one big quoted
39
+ * string. Node's own spawn implementation already knows how to correctly
40
+ * quote each array element for a Windows command line (including embedded
41
+ * spaces/quotes); building our own single-string command line and adding
42
+ * `/d /s` on top double-quotes it and corrupts paths with spaces (verified:
43
+ * it mangled "C:\Program Files\nodejs\npm.CMD" into an unrecognized string).
44
+ * Letting spawn's own per-argument escaping do the work, one level of
45
+ * cmd.exe indirection, is both simpler and correct.
46
+ */
47
+ export function resolveSpawnTarget(cmd, args) {
48
+ if (process.platform !== "win32" || !WINDOWS_SHIM_EXT.test(cmd)) {
49
+ return { cmd, args };
50
+ }
51
+ return { cmd: "cmd.exe", args: ["/c", cmd, ...args] };
52
+ }
10
53
  /**
11
54
  * Run a command safely. `cmd` is the binary, `args` are passed verbatim.
12
55
  * Never pass a full command string here — that is what enables injection.
@@ -16,7 +59,8 @@ export function execSafe(cmd, args = [], opts = {}) {
16
59
  return new Promise((resolve) => {
17
60
  const baseEnv = opts.env ?? process.env;
18
61
  const env = opts.scrubSecrets === false ? baseEnv : scrubEnv(baseEnv, opts.secretAllowlist);
19
- const child = spawn(cmd, args, {
62
+ const target = resolveSpawnTarget(cmd, args);
63
+ const child = spawn(target.cmd, target.args, {
20
64
  cwd: opts.cwd ?? process.cwd(),
21
65
  env,
22
66
  shell: false,
@@ -83,7 +127,8 @@ export function execStream(cmd, args = [], opts = {}) {
83
127
  return new Promise((resolve) => {
84
128
  const baseEnv = opts.env ?? process.env;
85
129
  const env = opts.scrubSecrets === false ? baseEnv : scrubEnv(baseEnv, opts.secretAllowlist);
86
- const child = spawn(cmd, args, {
130
+ const target = resolveSpawnTarget(cmd, args);
131
+ const child = spawn(target.cmd, target.args, {
87
132
  cwd: opts.cwd ?? process.cwd(),
88
133
  env,
89
134
  shell: false,
@@ -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.5" });
356
+ const daemon = createDaemon({ token: authKey, version: "0.7.0" });
357
357
  try {
358
358
  const { host, port } = await daemon.listen();
359
359
  const url = `http://${host}:${port}/`;
@@ -8,37 +8,21 @@
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
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
- * THE SEPARATE "CAN'T EVEN LAUNCH NPM" PROBLEM AND ITS FIX
29
- * ------------------------------------------------------------
30
- * `child_process.spawn(cmd, args, {shell:false})` — which execSafe always
31
- * uses, deliberately, to avoid shell-injection — cannot execute `npm`
32
- * directly on Windows AT ALL, regardless of which name you pass. The `npm` on
33
- * PATH there is `npm.cmd`, a batch-file shim, and Windows can only run batch
34
- * files through a command interpreter; spawning it as a bare child process
35
- * throws ENOENT (name not found as-is) or EINVAL (found but not directly
36
- * executable), never succeeds. The fix is `runNpm()` below: on Windows it
37
- * routes through `cmd.exe /c "npm ..."` (still shell:false at the
38
- * child_process level — cmd.exe is the fixed, trusted executable being
39
- * spawned; the args passed to IT are our own literal strings, never
40
- * unsanitized input, so this does not reopen the shell-injection risk
41
- * execSafe exists to close).
11
+ * TWO WINDOWS PROBLEMS, BOTH FIXED AT THE ROOT NOW
12
+ * ---------------------------------------------------
13
+ * 1. "spawn npm ENOENT/EINVAL" `npm` on PATH is `npm.cmd`, a batch shim;
14
+ * `child_process.spawn(shell:false)` cannot execute batch files directly.
15
+ * Fixed generically in exec.ts's `resolveSpawnTarget()` it detects
16
+ * .cmd/.bat/.ps1 targets and transparently re-targets through
17
+ * `cmd.exe /d /s /c`, as an argv array (never a hand-built shell string).
18
+ * Every execSafe/execStream caller gets this for free; this module no
19
+ * longer needs its own cmd.exe-wrapping or quoting logic at all.
20
+ * 2. The file-lock problem — `npm install -g` has to overwrite the running
21
+ * `maq` binary's own files. If that install runs as a CHILD that `maq`
22
+ * waits on, Windows can hold locks on those files for the wait's entire
23
+ * duration. Fixed by spawning npm with `detached: true` + `.unref()` and
24
+ * returning immediately instead of awaiting it nothing above this layer
25
+ * holds a lock on anything by the time npm actually touches the files.
42
26
  */
43
27
  import { spawn } from "node:child_process";
44
28
  export interface VersionInfo {
@@ -54,8 +38,9 @@ export declare function fetchLatestVersion(registryUrl?: string, timeoutMs?: num
54
38
  /** Compare the running version against the registry; never throws. */
55
39
  export declare function checkForUpdate(currentVersion: string): Promise<VersionInfo>;
56
40
  /**
57
- * Run an npm subcommand safely and portably. See the module doc's second
58
- * section for why this can't just be `execSafe("npm", args)` on Windows.
41
+ * Run an npm subcommand synchronously and capture its output. Safe on
42
+ * Windows because execSafe's resolveSpawnTarget transparently handles the
43
+ * `.cmd` shim; no manual cmd.exe wrapping needed here anymore.
59
44
  */
60
45
  export declare function runNpm(args: string[], opts?: {
61
46
  timeoutMs?: number;
@@ -64,37 +49,6 @@ export declare function runNpm(args: string[], opts?: {
64
49
  stdout: string;
65
50
  stderr: string;
66
51
  }>;
67
- /**
68
- * Build the OS-appropriate command to run `npm install -g maqcli@<version>`
69
- * fully detached from the current process (see module doc for why).
70
- *
71
- * On Windows, `cmd.exe /c "npm install..."` alone is NOT enough: even with
72
- * `detached: true` + `.unref()` on the Node side, the child stays tied to the
73
- * parent's console/job object, and it dies the moment the parent's console
74
- * closes (a long-standing Node behavior on Windows — nodejs/node#5614). The
75
- * fix is to have cmd.exe launch the real work via the `start` builtin, which
76
- * creates a genuinely independent process outside the parent's job object.
77
- *
78
- * `start`'s own command-line parsing is notoriously fragile with nested
79
- * quotes (redirection operators and quoted paths inside a quoted inner
80
- * command reliably break it). Rather than fight cmd.exe's quoting rules, we
81
- * write the actual work to a small, disposable .bat/.sh script on disk and
82
- * have `start`/the shell launch THAT — no nested quoting at all.
83
- */
84
- export declare function detachedInstallCommand(version: string, logPath: string, platform?: NodeJS.Platform): {
85
- cmd: string;
86
- args: string[];
87
- scriptPath: string;
88
- scriptContent: string;
89
- };
90
- /**
91
- * Launch the update fully detached and return immediately — the caller
92
- * (cmdUpdate) should print guidance and exit right away rather than await
93
- * anything else, which is what avoids the Windows file-lock failure mode.
94
- * `spawner` is injectable for tests so nothing is actually spawned there.
95
- * `writer` is injectable so tests don't touch the real filesystem either.
96
- */
97
- export declare function launchDetachedUpdate(version: string, logPath: string, spawner?: typeof spawn, writer?: (path: string, content: string) => void): number | undefined;
98
52
  export interface UpdateOutcome {
99
53
  ok: boolean;
100
54
  message: string;
@@ -104,16 +58,20 @@ export interface UpdateOutcome {
104
58
  /** Path a caller can inspect afterward to see the detached install's own npm output. */
105
59
  logPath?: string;
106
60
  }
61
+ /**
62
+ * Launch `npm install -g maqcli@<version>` fully detached (survives this
63
+ * process exiting) and pipe its output to a log file for later inspection.
64
+ * `spawner` is injectable for tests so nothing is actually spawned there.
65
+ */
66
+ export declare function launchDetachedUpdate(version: string, logPath: string, spawner?: typeof spawn): number | undefined;
107
67
  /**
108
68
  * Perform the update: check the registry, and if a newer version exists,
109
69
  * launch `npm install -g maqcli@<latest>` DETACHED from this process (see the
110
- * module doc — this is what avoids the Windows file-lock failure mode where
111
- * npm can't overwrite files the running `maq` process still holds open).
70
+ * module doc — this is what avoids the Windows file-lock failure mode).
112
71
  *
113
72
  * The detached path is the default and recommended flow: this function
114
73
  * returns as soon as the install is launched; it does not wait for npm to
115
- * finish, because waiting would defeat the point (the parent would still be
116
- * alive, still holding the lock, for the whole duration).
74
+ * finish, because waiting would defeat the point.
117
75
  *
118
76
  * `opts.wait: true` (used by tests, and by --wait for a CI/non-interactive
119
77
  * caller that can tolerate the lock risk) runs synchronously instead, so the
@@ -129,14 +87,9 @@ export declare function performUpdate(currentVersion: string, opts?: {
129
87
  stderr: string;
130
88
  }>;
131
89
  spawner?: typeof spawn;
132
- writer?: (path: string, content: string) => void;
133
90
  logPath?: string;
134
91
  }): Promise<UpdateOutcome>;
135
- /**
136
- * Read a detached update's log file (best-effort). Used by `maq update
137
- * --status` to show what the background install actually did, since the
138
- * `maq update` invocation that launched it already exited.
139
- */
92
+ /** Read a detached update's log file (best-effort). Used by `maq update --status`. */
140
93
  export declare function readUpdateLog(logPath: string): Promise<string | null>;
141
94
  /**
142
95
  * Ask npm what is ACTUALLY installed globally right now (ground truth, not
@@ -8,43 +8,28 @@
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
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
- * THE SEPARATE "CAN'T EVEN LAUNCH NPM" PROBLEM AND ITS FIX
29
- * ------------------------------------------------------------
30
- * `child_process.spawn(cmd, args, {shell:false})` — which execSafe always
31
- * uses, deliberately, to avoid shell-injection — cannot execute `npm`
32
- * directly on Windows AT ALL, regardless of which name you pass. The `npm` on
33
- * PATH there is `npm.cmd`, a batch-file shim, and Windows can only run batch
34
- * files through a command interpreter; spawning it as a bare child process
35
- * throws ENOENT (name not found as-is) or EINVAL (found but not directly
36
- * executable), never succeeds. The fix is `runNpm()` below: on Windows it
37
- * routes through `cmd.exe /c "npm ..."` (still shell:false at the
38
- * child_process level — cmd.exe is the fixed, trusted executable being
39
- * spawned; the args passed to IT are our own literal strings, never
40
- * unsanitized input, so this does not reopen the shell-injection risk
41
- * execSafe exists to close).
11
+ * TWO WINDOWS PROBLEMS, BOTH FIXED AT THE ROOT NOW
12
+ * ---------------------------------------------------
13
+ * 1. "spawn npm ENOENT/EINVAL" `npm` on PATH is `npm.cmd`, a batch shim;
14
+ * `child_process.spawn(shell:false)` cannot execute batch files directly.
15
+ * Fixed generically in exec.ts's `resolveSpawnTarget()` it detects
16
+ * .cmd/.bat/.ps1 targets and transparently re-targets through
17
+ * `cmd.exe /d /s /c`, as an argv array (never a hand-built shell string).
18
+ * Every execSafe/execStream caller gets this for free; this module no
19
+ * longer needs its own cmd.exe-wrapping or quoting logic at all.
20
+ * 2. The file-lock problem — `npm install -g` has to overwrite the running
21
+ * `maq` binary's own files. If that install runs as a CHILD that `maq`
22
+ * waits on, Windows can hold locks on those files for the wait's entire
23
+ * duration. Fixed by spawning npm with `detached: true` + `.unref()` and
24
+ * returning immediately instead of awaiting it nothing above this layer
25
+ * holds a lock on anything by the time npm actually touches the files.
42
26
  */
43
27
  import { spawn } from "node:child_process";
44
28
  import { tmpdir } from "node:os";
45
29
  import { join } from "node:path";
46
- import { writeFileSync, chmodSync } from "node:fs";
30
+ import { openSync } from "node:fs";
47
31
  import { execSafe } from "./exec.js";
32
+ import { whichBin } from "./registry.js";
48
33
  /** Parse "x.y.z" into a comparable tuple; non-numeric parts sort as 0. */
49
34
  function parseSemver(v) {
50
35
  const parts = v.replace(/^v/, "").split(".").map((p) => parseInt(p, 10));
@@ -82,83 +67,51 @@ export async function checkForUpdate(currentVersion) {
82
67
  return { current: currentVersion, latest: null, upToDate: true, error: e.message };
83
68
  }
84
69
  }
85
- /**
86
- * Run an npm subcommand safely and portably. See the module doc's second
87
- * section for why this can't just be `execSafe("npm", args)` on Windows.
88
- */
89
- export async function runNpm(args, opts = {}) {
90
- if (process.platform === "win32") {
91
- // Every element here is our own literal string (package name, flags) —
92
- // never unsanitized user input — so building one cmd.exe command line is
93
- // safe. Defensively quote anything with whitespace anyway.
94
- const cmdLine = ["npm", ...args].map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(" ");
95
- return execSafe("cmd.exe", ["/c", cmdLine], opts);
96
- }
97
- return execSafe("npm", args, opts);
70
+ /** Resolve the real npm binary path once; falls back to the bare name (POSIX is fine with that). */
71
+ function resolveNpmPath() {
72
+ return whichBin("npm") ?? "npm";
98
73
  }
99
74
  /**
100
- * Build the OS-appropriate command to run `npm install -g maqcli@<version>`
101
- * fully detached from the current process (see module doc for why).
102
- *
103
- * On Windows, `cmd.exe /c "npm install..."` alone is NOT enough: even with
104
- * `detached: true` + `.unref()` on the Node side, the child stays tied to the
105
- * parent's console/job object, and it dies the moment the parent's console
106
- * closes (a long-standing Node behavior on Windows — nodejs/node#5614). The
107
- * fix is to have cmd.exe launch the real work via the `start` builtin, which
108
- * creates a genuinely independent process outside the parent's job object.
109
- *
110
- * `start`'s own command-line parsing is notoriously fragile with nested
111
- * quotes (redirection operators and quoted paths inside a quoted inner
112
- * command reliably break it). Rather than fight cmd.exe's quoting rules, we
113
- * write the actual work to a small, disposable .bat/.sh script on disk and
114
- * have `start`/the shell launch THAT — no nested quoting at all.
75
+ * Run an npm subcommand synchronously and capture its output. Safe on
76
+ * Windows because execSafe's resolveSpawnTarget transparently handles the
77
+ * `.cmd` shim; no manual cmd.exe wrapping needed here anymore.
115
78
  */
116
- export function detachedInstallCommand(version, logPath, platform = process.platform) {
117
- const pkg = `maqcli@${version}`;
118
- if (platform === "win32") {
119
- const scriptPath = join(tmpdir(), `maq-update-${Date.now()}.bat`);
120
- const scriptContent = ["@echo off", `npm install -g ${pkg} > "${logPath}" 2>&1`, `start "" /min cmd.exe /c del "%~f0"`].join("\r\n");
121
- return { cmd: "cmd.exe", args: ["/c", "start", "", "/min", scriptPath], scriptPath, scriptContent };
122
- }
123
- const scriptPath = join(tmpdir(), `maq-update-${Date.now()}.sh`);
124
- const scriptContent = ["#!/bin/sh", `npm install -g ${pkg} > '${logPath}' 2>&1`, `rm -f "$0"`].join("\n");
125
- return { cmd: "sh", args: [scriptPath], scriptPath, scriptContent };
79
+ export async function runNpm(args, opts = {}) {
80
+ return execSafe(resolveNpmPath(), args, opts);
126
81
  }
127
82
  /**
128
- * Launch the update fully detached and return immediately — the caller
129
- * (cmdUpdate) should print guidance and exit right away rather than await
130
- * anything else, which is what avoids the Windows file-lock failure mode.
83
+ * Launch `npm install -g maqcli@<version>` fully detached (survives this
84
+ * process exiting) and pipe its output to a log file for later inspection.
131
85
  * `spawner` is injectable for tests so nothing is actually spawned there.
132
- * `writer` is injectable so tests don't touch the real filesystem either.
133
86
  */
134
- export function launchDetachedUpdate(version, logPath, spawner = spawn, writer = (path, content) => writeFileSync(path, content, "utf8")) {
135
- const { cmd, args, scriptPath, scriptContent } = detachedInstallCommand(version, logPath);
136
- writer(scriptPath, scriptContent);
137
- if (process.platform !== "win32") {
138
- try {
139
- chmodSync(scriptPath, 0o755);
140
- }
141
- catch {
142
- /* best-effort */
143
- }
144
- }
145
- const child = spawner(cmd, args, { detached: true, stdio: "ignore", windowsHide: true });
87
+ export function launchDetachedUpdate(version, logPath, spawner = spawn) {
88
+ const npmPath = resolveNpmPath();
89
+ const args = ["install", "-g", `maqcli@${version}`];
90
+ const child = spawner(npmPath, args, {
91
+ detached: true,
92
+ // Redirect the detached child's own stdio straight to a log file instead
93
+ // of inheriting this process's streams — this is what lets it keep
94
+ // running (and lets THIS process exit) independently of the parent.
95
+ stdio: ["ignore", openLogFd(logPath), openLogFd(logPath)],
96
+ windowsHide: true,
97
+ });
146
98
  child.on("error", () => {
147
99
  /* best-effort; the log file (or its absence) is the source of truth */
148
100
  });
149
101
  child.unref();
150
102
  return child.pid;
151
103
  }
104
+ function openLogFd(logPath) {
105
+ return openSync(logPath, "a");
106
+ }
152
107
  /**
153
108
  * Perform the update: check the registry, and if a newer version exists,
154
109
  * launch `npm install -g maqcli@<latest>` DETACHED from this process (see the
155
- * module doc — this is what avoids the Windows file-lock failure mode where
156
- * npm can't overwrite files the running `maq` process still holds open).
110
+ * module doc — this is what avoids the Windows file-lock failure mode).
157
111
  *
158
112
  * The detached path is the default and recommended flow: this function
159
113
  * returns as soon as the install is launched; it does not wait for npm to
160
- * finish, because waiting would defeat the point (the parent would still be
161
- * alive, still holding the lock, for the whole duration).
114
+ * finish, because waiting would defeat the point.
162
115
  *
163
116
  * `opts.wait: true` (used by tests, and by --wait for a CI/non-interactive
164
117
  * caller that can tolerate the lock risk) runs synchronously instead, so the
@@ -178,9 +131,7 @@ export async function performUpdate(currentVersion, opts = {}) {
178
131
  const latest = version.latest;
179
132
  if (!opts.wait) {
180
133
  const logPath = opts.logPath ?? defaultLogPath();
181
- const pid = opts.writer
182
- ? launchDetachedUpdate(latest, logPath, opts.spawner, opts.writer)
183
- : launchDetachedUpdate(latest, logPath, opts.spawner);
134
+ const pid = launchDetachedUpdate(latest, logPath, opts.spawner);
184
135
  return {
185
136
  ok: true,
186
137
  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.`,
@@ -208,11 +159,7 @@ export async function performUpdate(currentVersion, opts = {}) {
208
159
  function defaultLogPath() {
209
160
  return join(tmpdir(), `maq-update-${Date.now()}.log`);
210
161
  }
211
- /**
212
- * Read a detached update's log file (best-effort). Used by `maq update
213
- * --status` to show what the background install actually did, since the
214
- * `maq update` invocation that launched it already exited.
215
- */
162
+ /** Read a detached update's log file (best-effort). Used by `maq update --status`. */
216
163
  export async function readUpdateLog(logPath) {
217
164
  try {
218
165
  const { readFile } = await import("node:fs/promises");
package/dist/index.js CHANGED
@@ -46,7 +46,7 @@ import { runOrchestration } from "./core/orchestrator.js";
46
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.5";
49
+ const VERSION = "0.7.0";
50
50
  async function main(argv) {
51
51
  const [command, ...rest] = argv;
52
52
  switch (command) {
@@ -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.5";
55
+ const version = opts.version ?? "0.7.0";
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.5",
3
+ "version": "0.7.0",
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": {