@engenty/wizards-runtime 0.7.9 → 0.7.10

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.
Files changed (46) hide show
  1. package/README.md +2 -1
  2. package/apps/runtime/dist/app.js +2 -2
  3. package/apps/runtime/dist/cli/autostart.js +90 -7
  4. package/apps/runtime/dist/cli/autoupdate.js +18 -5
  5. package/apps/runtime/dist/cli/clients.js +25 -6
  6. package/apps/runtime/dist/cli/connect.js +7 -2
  7. package/apps/runtime/dist/cli/environment.js +33 -2
  8. package/apps/runtime/dist/cli/exec.js +38 -1
  9. package/apps/runtime/dist/cli/home.js +15 -0
  10. package/apps/runtime/dist/cli/machine.js +18 -8
  11. package/apps/runtime/dist/cli/processes.js +23 -3
  12. package/apps/runtime/dist/cli/setup.js +11 -1
  13. package/apps/runtime/dist/cli/start.js +33 -7
  14. package/apps/runtime/dist/cli/uninstall.js +100 -6
  15. package/apps/runtime/dist/cli/update.js +28 -12
  16. package/apps/runtime/dist/cli/versions.js +16 -4
  17. package/apps/runtime/dist/docs/user/install.md +18 -8
  18. package/apps/runtime/dist/harness/env.js +41 -8
  19. package/apps/runtime/dist/harness/index.js +10 -1
  20. package/apps/runtime/dist/harness/model.js +1 -1
  21. package/apps/runtime/dist/render/browser-path.js +43 -11
  22. package/apps/runtime/dist/update.js +4 -0
  23. package/apps/web/dist/assets/{AboutDialog-CShc0mRo.js → AboutDialog-B2LuZ_mC.js} +1 -1
  24. package/apps/web/dist/assets/{ChangelogDialog-C4dVSwtN.js → ChangelogDialog-B0lS0BdL.js} +1 -1
  25. package/apps/web/dist/assets/{ChatView-KkOboyOo.js → ChatView-BcPDcU2d.js} +1 -1
  26. package/apps/web/dist/assets/{EditorPage-lQ3YgsFT.js → EditorPage-erduYKNM.js} +1 -1
  27. package/apps/web/dist/assets/{Harness-D6mgLWTb.js → Harness-Dv9ln5xB.js} +1 -1
  28. package/apps/web/dist/assets/{NewWizardChannels-CPlh_37J.js → NewWizardChannels-JveYyh-U.js} +1 -1
  29. package/apps/web/dist/assets/{NewWizardPage-D0FVJtXK.js → NewWizardPage-Dfk-WOhX.js} +1 -1
  30. package/apps/web/dist/assets/{OnboardingPage-BXPOMrQR.js → OnboardingPage-CT9dL4ke.js} +1 -1
  31. package/apps/web/dist/assets/{ProjectPage-DroTprqs.js → ProjectPage-5_Wsk9Hv.js} +1 -1
  32. package/apps/web/dist/assets/{PublicRunner-j3qoKjQQ.js → PublicRunner-Cw1WzTze.js} +1 -1
  33. package/apps/web/dist/assets/{ReadOnly-CKgSQ2z1.js → ReadOnly-S9vGVQHr.js} +1 -1
  34. package/apps/web/dist/assets/{RunDrawer-CJZ_ISAd.js → RunDrawer-O4rEFCL8.js} +1 -1
  35. package/apps/web/dist/assets/{SettingsPage-BHZ0Z-og.js → SettingsPage-DmhN7OTy.js} +1 -1
  36. package/apps/web/dist/assets/{SetupPage-CDkYkjJX.js → SetupPage-BlWd7Uln.js} +1 -1
  37. package/apps/web/dist/assets/{SharePage-BlVjVjHG.js → SharePage-ktqm-jWI.js} +1 -1
  38. package/apps/web/dist/assets/changelog-FJJS0Tpt.js +1 -0
  39. package/apps/web/dist/assets/{index-C61HAltK.js → index-xH9uN8Nh.js} +3 -3
  40. package/apps/web/dist/assets/{oss-credits-qGhcbqiM.js → oss-credits-Bc05Qv5H.js} +1 -1
  41. package/apps/web/dist/index.html +1 -1
  42. package/apps/web/dist/install.ps1 +307 -0
  43. package/bin/wizards.mjs +59 -35
  44. package/package.json +2 -5
  45. package/plugin/.claude-plugin/plugin.json +1 -1
  46. package/apps/web/dist/assets/changelog-Be0i5Jjw.js +0 -1
package/README.md CHANGED
@@ -57,7 +57,8 @@ the cloud behind the same links. On macOS or Linux, in a terminal:
57
57
  curl -fsSL https://engenty.ai/install.sh | bash
58
58
  ```
59
59
 
60
- With Node.js 20 or newer on the machine, `npx wizards` does the same.
60
+ On Windows, in PowerShell: `irm https://engenty.ai/install.ps1 | iex`. With Node.js 20 or newer on
61
+ the machine, `npx wizards` does the same.
61
62
 
62
63
  It needs nothing but `curl` and `tar` and asks for no password. It brings its own Node.js (a
63
64
  Node you already have is left alone), installs engenty wizards into `~/.engenty/wizards` and
@@ -305,13 +305,13 @@ async function previewFor(path) {
305
305
  /**
306
306
  * Production: the built app. Its files and the studio's pages sit below `/studio`; the public
307
307
  * pages are `/w/<token>` and `/s/<token>`. Of the root only what others fetch there is served:
308
- * the installer, the embed script for websites, and the service worker of public wizards. The
308
+ * the installers, the embed script for websites, and the service worker of public wizards. The
309
309
  * rest of the root belongs to others (the landing page); a runtime alone sends `/` to the studio.
310
310
  * The app's files are served by both halves: a wizard's page loads its scripts from `/studio`
311
311
  * too. Which pages a half answers follows its role.
312
312
  */
313
313
  const webDir = resolve(dirname(fileURLToPath(import.meta.url)), "../../web/dist");
314
- const STUDIO_FILES = ["/install.sh"];
314
+ const STUDIO_FILES = ["/install.sh", "/install.ps1"];
315
315
  const DOORS_FILES = ["/embed.js", "/sw.js"];
316
316
  const PAGES = new RegExp(`^/(${[...(serves.studio ? ["studio(/|$)"] : []), ...(serves.doors ? ["w/", "s/"] : [])].join("|")})`);
317
317
  if (existsSync(webDir)) {
@@ -3,14 +3,15 @@ import { lstatSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { promisify } from "node:util";
6
- import { installedByScript } from "./home.js";
6
+ import { installedByScript, installedScript } from "./home.js";
7
7
  import { findOnPath } from "./machine.js";
8
8
  const run = promisify(execFile);
9
9
  /**
10
10
  * engenty wizards starting by itself when the person logs in, so that links and AI clients
11
11
  * reach it without anybody starting it: `wizards start --no-open`, on a Mac from a
12
- * LaunchAgent, on Linux from a systemd user service. Only for an install made by install.sh,
13
- * whose command stays where it is.
12
+ * LaunchAgent, on Linux from a systemd user service, on Windows from a scheduled task (or, where
13
+ * Windows refuses one without an administrator, a shortcut in the Startup folder). Only for an
14
+ * install made by the installer, whose command stays where it is.
14
15
  */
15
16
  export const LOGIN_LABEL = "com.engenty.wizards.login";
16
17
  /** The LaunchAgent. */
@@ -80,6 +81,82 @@ async function systemdHere() {
80
81
  return false;
81
82
  }
82
83
  }
84
+ const TASK = "engenty wizards";
85
+ const TASK_HOW = `a scheduled task, ${TASK}`;
86
+ const STARTUP_HOW = "a shortcut in the Startup folder";
87
+ /** The shortcut Windows starts at login when no task may be made. */
88
+ function startupShortcut(env = process.env) {
89
+ return join(env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `${TASK}.lnk`);
90
+ }
91
+ /**
92
+ * What Windows starts: the install's Node and script through conhost without a window — Node is
93
+ * a console program, and the task or shortcut would otherwise open a console at every login.
94
+ */
95
+ export function windowsStart(paths) {
96
+ const { node, script } = installedScript(paths, "win32");
97
+ return {
98
+ file: join(process.env.SystemRoot ?? "C:\\Windows", "System32", "conhost.exe"),
99
+ args: ["--headless", node, script, "start", "--no-open"],
100
+ };
101
+ }
102
+ const quoted = (arg) => (/[\s"]/.test(arg) ? `"${arg.replaceAll('"', '\\"')}"` : arg);
103
+ async function windowsState() {
104
+ try {
105
+ await run("schtasks", ["/Query", "/TN", TASK], { timeout: 10_000 });
106
+ return { on: true, how: TASK_HOW };
107
+ }
108
+ catch {
109
+ return present(startupShortcut())
110
+ ? { on: true, how: STARTUP_HOW }
111
+ : { on: false, how: TASK_HOW };
112
+ }
113
+ }
114
+ /** The Windows login item, as the uninstall names it; null without one. */
115
+ export async function windowsLoginItem() {
116
+ const state = await windowsState();
117
+ if (!("on" in state) || !state.on) {
118
+ return null;
119
+ }
120
+ return state.how === STARTUP_HOW ? startupShortcut() : `the scheduled task “${TASK}”`;
121
+ }
122
+ /** Takes the scheduled task and the Startup shortcut away. */
123
+ export async function removeWindowsLoginItem() {
124
+ await run("schtasks", ["/Delete", "/F", "/TN", TASK], { timeout: 10_000 }).catch(() => undefined);
125
+ rmSync(startupShortcut(), { force: true });
126
+ }
127
+ async function setWindows(paths, on) {
128
+ await run("schtasks", ["/Delete", "/F", "/TN", TASK], { timeout: 10_000 }).catch(() => undefined);
129
+ rmSync(startupShortcut(), { force: true });
130
+ if (!on) {
131
+ return windowsState();
132
+ }
133
+ // Takes effect at the next login, as on a Mac; nothing is started now.
134
+ const { file, args } = windowsStart(paths);
135
+ const command = [file, ...args].map(quoted).join(" ");
136
+ try {
137
+ await run("schtasks", ["/Create", "/F", "/TN", TASK, "/SC", "ONLOGON", "/RL", "LIMITED", "/TR", command], {
138
+ timeout: 15_000,
139
+ });
140
+ }
141
+ catch {
142
+ // A task at logon may need an administrator; the Startup folder needs none.
143
+ const ps = (text) => `'${text.replaceAll("'", "''")}'`;
144
+ await run("powershell.exe", [
145
+ "-NoProfile",
146
+ "-Command",
147
+ [
148
+ "$s = (New-Object -ComObject WScript.Shell).CreateShortcut(" +
149
+ ps(startupShortcut()) +
150
+ ")",
151
+ `$s.TargetPath = ${ps(file)}`,
152
+ `$s.Arguments = ${ps(args.map(quoted).join(" "))}`,
153
+ `$s.WorkingDirectory = ${ps(paths.home)}`,
154
+ "$s.Save()",
155
+ ].join("; "),
156
+ ], { timeout: 15_000 });
157
+ }
158
+ return windowsState();
159
+ }
83
160
  /**
84
161
  * Whether a file is there. Unlike existsSync, a folder it may not look into (a sandbox, its
85
162
  * permissions) is an error, not "no file": `autostart off` must not say it's off then.
@@ -102,10 +179,13 @@ export function present(file) {
102
179
  * write one.
103
180
  */
104
181
  export async function autostartState(paths) {
105
- if (process.platform !== "darwin" && process.platform !== "linux") {
106
- return {
107
- unavailable: process.platform === "win32" ? "not on Windows" : `not on ${process.platform}`,
108
- };
182
+ if (!["darwin", "linux", "win32"].includes(process.platform)) {
183
+ return { unavailable: `not on ${process.platform}` };
184
+ }
185
+ if (process.platform === "win32") {
186
+ return installedByScript(paths)
187
+ ? windowsState()
188
+ : { unavailable: "only for installs made with the install script" };
109
189
  }
110
190
  const file = process.platform === "darwin" ? LOGIN_ITEM : unitFile();
111
191
  const there = present(file);
@@ -153,6 +233,9 @@ export async function setAutostart(paths, on) {
153
233
  if ("unavailable" in state) {
154
234
  return state;
155
235
  }
236
+ if (process.platform === "win32") {
237
+ return setWindows(paths, on);
238
+ }
156
239
  if (process.platform === "darwin") {
157
240
  // Takes effect at the next login; nothing is started or stopped now.
158
241
  if (on) {
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
- import { join } from "node:path";
3
+ import { delimiter, join } from "node:path";
4
+ import { withPath } from "./environment.js";
4
5
  import { readEnvFile, readInstallNote } from "./home.js";
5
6
  import { currentVersion, promote, prune, RUNTIME_PACKAGE, runtimeDir, switchTo, versionIn, } from "./versions.js";
6
7
  /**
@@ -92,14 +93,26 @@ async function latestVersion(deps, env) {
92
93
  }
93
94
  /** npm of the install's own Node, as install.sh runs it; its output goes to logs/update.log. */
94
95
  async function npmInstall(paths, prefix, spec) {
95
- const bin = join(paths.tools, "node", "bin");
96
+ // On Windows Node sits in the folder itself, and npm is its npm-cli.js (npm.cmd starts only
97
+ // through cmd.exe).
98
+ const windows = process.platform === "win32";
99
+ const bin = windows ? join(paths.tools, "node") : join(paths.tools, "node", "bin");
96
100
  mkdirSync(paths.logs, { recursive: true });
97
101
  const log = openSync(join(paths.logs, "update.log"), "a");
98
- const args = "install --omit=dev --ignore-scripts --legacy-peer-deps --no-audit --no-fund --loglevel=error";
102
+ const args = [
103
+ ..."install --omit=dev --ignore-scripts --legacy-peer-deps --no-audit --no-fund --loglevel=error".split(" "),
104
+ "--prefix",
105
+ prefix,
106
+ spec,
107
+ ];
108
+ const [command, all] = windows
109
+ ? [join(bin, "node.exe"), [join(bin, "node_modules", "npm", "bin", "npm-cli.js"), ...args]]
110
+ : [join(bin, "npm"), args];
99
111
  const code = await new Promise((done) => {
100
- const child = spawn(join(bin, "npm"), [...args.split(" "), "--prefix", prefix, spec], {
112
+ const child = spawn(command, all, {
101
113
  stdio: ["ignore", log, log],
102
- env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? ""}` },
114
+ env: withPath(process.env, [bin, process.env.PATH ?? ""].join(delimiter)),
115
+ windowsHide: true,
103
116
  });
104
117
  child.on("error", () => done(127));
105
118
  child.on("exit", (exit) => done(exit ?? 1));
@@ -1,16 +1,31 @@
1
1
  import { mkdirSync } from "node:fs";
2
2
  import { delimiter, dirname, join } from "node:path";
3
- import { runtimePath } from "./environment.js";
3
+ import { runtimePath, withPath } from "./environment.js";
4
4
  import { runLogged } from "./exec.js";
5
5
  import { findOnPath } from "./machine.js";
6
6
  /** A word for the shell: quoted when it holds anything but plain characters. */
7
7
  const shellWord = (word) => /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`;
8
- /** The command the setup runs for a client, as the person would type it. */
9
- export function installCommand(client, paths) {
10
- return client.install.kind === "npm"
11
- ? `npm install --global --prefix ${shellWord(paths.clients)} ${client.install.pkg}`
8
+ /** A word for PowerShell: in single quotes, which take everything but a single quote as it is. */
9
+ const powershellWord = (word) => /^[\w@%+=:,./\\-]+$/.test(word) ? word : `'${word.replaceAll("'", "''")}'`;
10
+ /** The command the setup runs for a client, as the person would type it (PowerShell on Windows). */
11
+ export function installCommand(client, paths, platform = process.platform) {
12
+ const windows = platform === "win32";
13
+ if (client.install.kind === "npm") {
14
+ const prefix = windows ? powershellWord(paths.clients) : shellWord(paths.clients);
15
+ return `npm install --global --prefix ${prefix} ${client.install.pkg}`;
16
+ }
17
+ return windows
18
+ ? `irm ${powershellWord(client.install.windows)} | iex`
12
19
  : `curl -fsSL ${client.install.url} | bash`;
13
20
  }
21
+ /** PowerShell, for a command written for it: without the person's profile, scripts allowed. */
22
+ export const POWERSHELL = [
23
+ "powershell.exe",
24
+ "-NoProfile",
25
+ "-ExecutionPolicy",
26
+ "Bypass",
27
+ "-Command",
28
+ ];
14
29
  /**
15
30
  * Installs an AI client. One that comes from npm goes into the install's own prefix
16
31
  * (`clients/`), with the npm of the Node that runs this command: nothing of the person's own
@@ -18,7 +33,11 @@ export function installCommand(client, paths) {
18
33
  */
19
34
  export function installClient(client, paths, onLine) {
20
35
  const logFile = join(paths.logs, "install.log");
21
- const env = { ...process.env, PATH: runtimePath(paths, process.env) };
36
+ const env = withPath(process.env, runtimePath(paths, process.env));
37
+ if (client.install.kind === "script" && process.platform === "win32") {
38
+ const [shell, ...args] = POWERSHELL;
39
+ return runLogged(shell, [...args, installCommand(client, paths)], { env, logFile, onLine });
40
+ }
22
41
  if (client.install.kind === "script") {
23
42
  return runLogged("bash", ["-c", `set -o pipefail; curl -fsSL ${client.install.url} | bash`], {
24
43
  env,
@@ -3,7 +3,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from
3
3
  import { homedir } from "node:os";
4
4
  import { delimiter, dirname, join } from "node:path";
5
5
  import { promisify } from "node:util";
6
- import { installedByScript, packageRoot } from "./home.js";
6
+ import { commandFile, installedByScript, installedScript, packageRoot, } from "./home.js";
7
7
  import { findOnPath, macAppPresent } from "./machine.js";
8
8
  import { bad, cmd, dim, ok, tilde } from "./ui.js";
9
9
  const run = promisify(execFile);
@@ -23,8 +23,13 @@ export const SERVER_NAME = "engenty-wizards";
23
23
  export function serverCommand(paths, script = join(packageRoot, "bin", "wizards.mjs")) {
24
24
  const moved = dirname(paths.home) !== join(homedir(), ".engenty");
25
25
  const env = moved ? { env: { ENGENTY_HOME: dirname(paths.home) } } : {};
26
+ if (installedByScript(paths) && process.platform === "win32") {
27
+ // Not wizards.cmd: an app starts a .cmd file only through cmd.exe, if at all.
28
+ const { node, script: installed } = installedScript(paths);
29
+ return { command: node, args: [installed, "mcp"], ...env };
30
+ }
26
31
  if (installedByScript(paths)) {
27
- return { command: join(paths.bin, "wizards"), args: ["mcp"], ...env };
32
+ return { command: commandFile(paths), args: ["mcp"], ...env };
28
33
  }
29
34
  return { command: process.execPath, args: [script, "mcp"], ...env };
30
35
  }
@@ -1,4 +1,4 @@
1
- import { basename, delimiter, dirname, join } from "node:path";
1
+ import { basename, delimiter, dirname, join, win32 } from "node:path";
2
2
  import { isCI } from "./autoupdate.js";
3
3
  /**
4
4
  * Variables of the terminal the runtime inherits. Everything else it gets comes from the
@@ -59,7 +59,24 @@ export function inherited(env) {
59
59
  * The PATH of the runtime: AI clients the setup installed and the Node that runs this command
60
60
  * come first — a client installed with npm is a script that asks for `node`.
61
61
  */
62
- export function runtimePath(paths, env, node = process.execPath) {
62
+ export function runtimePath(paths, env, node = process.execPath, platform = process.platform) {
63
+ if (platform === "win32") {
64
+ // npm puts a prefix's commands in the prefix itself on Windows. Claude Code installs into
65
+ // ~\.local\bin, Cursor Agent into %LOCALAPPDATA%\cursor-agent and Antigravity into
66
+ // %LOCALAPPDATA%\agy\bin; each adds that to the user's PATH, which a process started before
67
+ // the install does not see.
68
+ const p = win32;
69
+ const home = env.USERPROFILE ?? "";
70
+ const entries = [
71
+ paths.clients,
72
+ p.dirname(node),
73
+ ...(env.PATH ?? env.Path ?? "").split(";"),
74
+ home && p.join(home, ".local", "bin"),
75
+ env.LOCALAPPDATA && p.join(env.LOCALAPPDATA, "cursor-agent"),
76
+ env.LOCALAPPDATA && p.join(env.LOCALAPPDATA, "agy", "bin"),
77
+ ];
78
+ return [...new Set(entries.filter(Boolean))].join(";");
79
+ }
63
80
  const entries = [
64
81
  join(paths.clients, "bin"),
65
82
  dirname(node),
@@ -68,6 +85,20 @@ export function runtimePath(paths, env, node = process.execPath) {
68
85
  ];
69
86
  return [...new Set(entries.filter(Boolean))].join(delimiter);
70
87
  }
88
+ /**
89
+ * An environment with this PATH, once: Windows names it Path, and a copy holding both leaves to
90
+ * chance which one a program gets.
91
+ */
92
+ export function withPath(env, path) {
93
+ const out = {};
94
+ for (const [key, value] of Object.entries(env)) {
95
+ if (key.toUpperCase() !== "PATH") {
96
+ out[key] = value;
97
+ }
98
+ }
99
+ out.PATH = path;
100
+ return out;
101
+ }
71
102
  /**
72
103
  * The environment the command line starts the runtime with. `fileEnv` is the install's `.env`:
73
104
  * the runtime reads it itself, it is only looked at here so that a default does not override it.
@@ -1,6 +1,8 @@
1
- import { spawn } from "node:child_process";
2
1
  import { appendFileSync, mkdirSync } from "node:fs";
3
2
  import { dirname } from "node:path";
3
+ // cross-spawn: on Windows a command from npm is a .cmd file, which only cmd.exe starts, with
4
+ // its arguments quoted the way cmd.exe reads them. Elsewhere it is child_process.spawn.
5
+ import spawn from "cross-spawn";
4
6
  /**
5
7
  * Runs a command; what it prints goes line by line to `onLine` and into the log file.
6
8
  * Never rejects for a command that fails: the caller reads `code`.
@@ -43,3 +45,38 @@ export function runLogged(command, args, options = {}) {
43
45
  child.on("close", (code) => resolve({ code: code ?? 1, tail: tail.trim() }));
44
46
  });
45
47
  }
48
+ /**
49
+ * Runs a file and gives what it printed, like execFile: it rejects when the file is not there,
50
+ * ends with another code than 0, or takes longer than `timeout`.
51
+ */
52
+ export function runFile(file, args, options = {}) {
53
+ return new Promise((resolve, reject) => {
54
+ const child = spawn(file, args, {
55
+ env: options.env,
56
+ cwd: options.cwd,
57
+ stdio: ["ignore", "pipe", "pipe"],
58
+ timeout: options.timeout,
59
+ });
60
+ let stdout = "";
61
+ let stderr = "";
62
+ child.stdout?.on("data", (chunk) => {
63
+ stdout += chunk.toString("utf8");
64
+ });
65
+ child.stderr?.on("data", (chunk) => {
66
+ stderr += chunk.toString("utf8");
67
+ });
68
+ child.on("error", reject);
69
+ child.on("close", (code, signal) => {
70
+ if (code === 0) {
71
+ resolve({ stdout, stderr });
72
+ }
73
+ else {
74
+ reject(Object.assign(new Error(`${file} ended with ${signal ?? code}`), {
75
+ code,
76
+ stdout,
77
+ stderr,
78
+ }));
79
+ }
80
+ });
81
+ });
82
+ }
@@ -43,6 +43,21 @@ export function packageVersion() {
43
43
  }
44
44
  return running ?? "0.0.0";
45
45
  }
46
+ /** The `wizards` command install.sh (on Windows install.ps1) writes: `bin/wizards`, `bin\\wizards.cmd`. */
47
+ export function commandFile(paths, platform = process.platform) {
48
+ return join(paths.bin, platform === "win32" ? "wizards.cmd" : "wizards");
49
+ }
50
+ /**
51
+ * The Node and the script of an install made by the installer: what its command runs. They stay
52
+ * where they are across updates (`tools/node` follows the Node of the version).
53
+ */
54
+ export function installedScript(paths, platform = process.platform) {
55
+ const win = platform === "win32";
56
+ return {
57
+ node: join(paths.tools, "node", win ? "node.exe" : "bin/node"),
58
+ script: join(paths.runtime, "current", "node_modules", "@engenty", "wizards-runtime", "bin", "wizards.mjs"),
59
+ };
60
+ }
46
61
  /** How this copy got here: by install.sh into the home folder, or by npm/npx or a checkout. */
47
62
  export function installedByScript(paths = layout()) {
48
63
  try {
@@ -1,13 +1,11 @@
1
- import { execFile } from "node:child_process";
2
1
  import { existsSync } from "node:fs";
3
2
  import { homedir } from "node:os";
4
3
  import { join } from "node:path";
5
- import { promisify } from "node:util";
6
4
  // The search for a browser is shared with env.ts, so the runtime uses the one the setup found.
7
5
  import { findChrome, findOnPath } from "../render/browser-path.js";
8
- import { runtimePath } from "./environment.js";
6
+ import { runtimePath, withPath } from "./environment.js";
7
+ import { runFile as run } from "./exec.js";
9
8
  export { findChrome, findOnPath };
10
- const run = promisify(execFile);
11
9
  /** The AI clients the runtime can think with, in the order its own setup offers them. */
12
10
  export const CLIENTS = [
13
11
  {
@@ -23,7 +21,11 @@ export const CLIENTS = [
23
21
  name: "Claude Code",
24
22
  bin: "claude",
25
23
  account: "Claude",
26
- install: { kind: "script", url: "https://claude.ai/install.sh" },
24
+ install: {
25
+ kind: "script",
26
+ url: "https://claude.ai/install.sh",
27
+ windows: "https://claude.ai/install.ps1",
28
+ },
27
29
  app: "Claude",
28
30
  },
29
31
  {
@@ -31,7 +33,11 @@ export const CLIENTS = [
31
33
  name: "Antigravity",
32
34
  bin: "agy",
33
35
  account: "Google AI",
34
- install: { kind: "script", url: "https://antigravity.google/cli/install.sh" },
36
+ install: {
37
+ kind: "script",
38
+ url: "https://antigravity.google/cli/install.sh",
39
+ windows: "https://antigravity.google/cli/install.ps1",
40
+ },
35
41
  app: "Antigravity",
36
42
  },
37
43
  {
@@ -39,7 +45,11 @@ export const CLIENTS = [
39
45
  name: "Cursor Agent",
40
46
  bin: "cursor-agent",
41
47
  account: "Cursor",
42
- install: { kind: "script", url: "https://cursor.com/install" },
48
+ install: {
49
+ kind: "script",
50
+ url: "https://cursor.com/install",
51
+ windows: "https://cursor.com/install?win32=true",
52
+ },
43
53
  app: "Cursor",
44
54
  },
45
55
  ];
@@ -68,7 +78,7 @@ async function versionOf(file, path) {
68
78
  try {
69
79
  const { stdout } = await run(file, ["--version"], {
70
80
  timeout: 10_000,
71
- env: { ...process.env, PATH: path, TERM: "dumb" },
81
+ env: { ...withPath(process.env, path), TERM: "dumb" },
72
82
  });
73
83
  const line = stdout.trim().split("\n").pop()?.trim() ?? "";
74
84
  return line.match(/\d+\.\d+[\w.+-]*/)?.[0] ?? (line || null);
@@ -1,7 +1,25 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { sep } from "node:path";
3
- /** Every process, as `ps` lists it; empty where there is no `ps` (Windows). */
3
+ /** Every process, as `ps` lists it, on Windows as Windows' own process table does. */
4
4
  function listProcesses() {
5
+ if (process.platform === "win32") {
6
+ try {
7
+ const json = execFileSync("powershell.exe", [
8
+ "-NoProfile",
9
+ "-Command",
10
+ "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress",
11
+ ], { encoding: "utf8", windowsHide: true, maxBuffer: 32 * 1024 * 1024 });
12
+ const rows = JSON.parse(json);
13
+ return rows.map((row) => ({
14
+ pid: row.ProcessId,
15
+ ppid: row.ParentProcessId,
16
+ command: row.CommandLine ?? "",
17
+ }));
18
+ }
19
+ catch {
20
+ return [];
21
+ }
22
+ }
5
23
  try {
6
24
  return execFileSync("ps", ["-axww", "-o", "pid=,ppid=,command="], { encoding: "utf8" })
7
25
  .split("\n")
@@ -17,14 +35,16 @@ function listProcesses() {
17
35
  * The processes in `list` that run from `home`, apart from `self` and the processes that started
18
36
  * it (npx, a shell).
19
37
  */
20
- export function wizardsProcesses(home, list, self = process.pid) {
38
+ export function wizardsProcesses(home, list, self = process.pid, platform = process.platform) {
39
+ // Windows paths are the same in any case.
40
+ const fold = (text) => (platform === "win32" ? text.toLowerCase() : text);
21
41
  const parents = new Map(list.map((entry) => [entry.pid, entry.ppid]));
22
42
  const skip = new Set();
23
43
  for (let at = self; at && !skip.has(at); at = parents.get(at)) {
24
44
  skip.add(at);
25
45
  }
26
46
  return list
27
- .filter((entry) => !skip.has(entry.pid) && entry.command.includes(home + sep))
47
+ .filter((entry) => !skip.has(entry.pid) && fold(entry.command).includes(fold(home + sep)))
28
48
  .map(({ pid, command }) => {
29
49
  const args = command.split(/\s+/);
30
50
  const kind = /apps[\\/]runtime[\\/]dist[\\/]index\.js/.test(command)
@@ -8,7 +8,7 @@ import { autostartForStatus, setAutostart } from "./autostart.js";
8
8
  import { installClient, installCommand } from "./clients.js";
9
9
  import { appStates, connectApps } from "./connect.js";
10
10
  import { runLogged } from "./exec.js";
11
- import { command, installedByScript, isCheckout, packageVersion, readInstallNote, writeInstallNote, } from "./home.js";
11
+ import { command, installedByScript, isCheckout, layout, packageVersion, readInstallNote, writeInstallNote, } from "./home.js";
12
12
  import { CLIENTS, detectClients, findChrome, findFfmpeg, findOnPath, suggestedClient, vendorApp, } from "./machine.js";
13
13
  import { appAsksForPassword, DECISION_MODEL_GB, decisionModelProblem, freeGb, installOllama, ollamaState, pullModel, servesDecisions, startOllama, } from "./ollama.js";
14
14
  import { runningRuntime, settings } from "./start.js";
@@ -320,6 +320,16 @@ function pathLine() {
320
320
  }
321
321
  /** An install made by install.sh has its command in `~/.local/bin`: is that on the PATH? */
322
322
  async function pathStep(interactive) {
323
+ if (process.platform === "win32") {
324
+ // install.ps1 put the command's folder on the user's PATH; a terminal opened before it
325
+ // does not see that.
326
+ const bin = layout().bin.toLowerCase();
327
+ const path = (process.env.PATH ?? "").toLowerCase().split(";");
328
+ if (installedByScript() && !path.includes(bin)) {
329
+ p.log.info(`Open a new terminal to use the ${cyan("wizards")} command.`);
330
+ }
331
+ return;
332
+ }
323
333
  const local = join(homedir(), ".local", "bin");
324
334
  if (!installedByScript() || (process.env.PATH ?? "").split(delimiter).includes(local)) {
325
335
  return;
@@ -8,11 +8,12 @@ import { readRunning } from "../running.js";
8
8
  import { RESTART_EXIT } from "../update.js";
9
9
  import { applyPendingUpdate, autoUpdatePolicy, notesUrl } from "./autoupdate.js";
10
10
  import { runtimeEnv } from "./environment.js";
11
- import { installedByScript, packageRoot, readEnvFile } from "./home.js";
11
+ import { commandFile, installedByScript, installedScript, packageRoot, readEnvFile, } from "./home.js";
12
12
  import { findOnPath } from "./machine.js";
13
13
  import { cmd, cyan, dim, live, tilde } from "./ui.js";
14
14
  const DEFAULT_PORT = 24368;
15
- const START_TIMEOUT_MS = 60_000;
15
+ // Windows on Arm runs the runtime's x64 Node emulated, and a first start there takes longer.
16
+ const START_TIMEOUT_MS = process.platform === "win32" ? 180_000 : 60_000;
16
17
  /** The settings this install starts with: its `.env`, overridden by the terminal's variables. */
17
18
  export function settings(paths, env = process.env) {
18
19
  const fileEnv = readEnvFile(paths.envFile);
@@ -83,6 +84,21 @@ export function entryUrl(running, paths, env = process.env) {
83
84
  }
84
85
  /** Opens an address in the person's browser; false when this machine has none to open it with. */
85
86
  export function openBrowser(url) {
87
+ if (process.platform === "win32") {
88
+ // The default browser, through the shell's own handler: no cmd.exe, which would read the
89
+ // link's & as the start of another command.
90
+ const rundll32 = join(process.env.SystemRoot || "C:\\Windows", "System32", "rundll32.exe");
91
+ try {
92
+ spawn(rundll32, ["url.dll,FileProtocolHandler", url], {
93
+ stdio: "ignore",
94
+ detached: true,
95
+ }).unref();
96
+ return true;
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ }
86
102
  const opener = process.platform === "darwin"
87
103
  ? "open"
88
104
  : process.env.DISPLAY || process.env.WAYLAND_DISPLAY
@@ -111,16 +127,26 @@ function show(url, entry, open) {
111
127
  console.log(dim(" The link signs in one browser and works only once."));
112
128
  }
113
129
  }
114
- /** The command of this install, as it is on disk now: `wizards <args>`. */
130
+ /**
131
+ * The command of this install, as it is on disk now: `wizards <args>`. On Windows its Node and
132
+ * script, which is what wizards.cmd runs, without a console window of its own.
133
+ */
115
134
  function spawnCommand(paths, args, options) {
116
- const wrapper = join(paths.bin, "wizards");
135
+ const wrapper = commandFile(paths);
136
+ if (existsSync(wrapper) && process.platform === "win32") {
137
+ const { node, script } = installedScript(paths);
138
+ return spawn(node, [script, ...args], { ...options, windowsHide: true });
139
+ }
117
140
  return existsSync(wrapper)
118
141
  ? spawn(wrapper, args, options)
119
- : spawn(process.execPath, [join(packageRoot, "bin/wizards.mjs"), ...args], options);
142
+ : spawn(process.execPath, [join(packageRoot, "bin/wizards.mjs"), ...args], {
143
+ ...options,
144
+ windowsHide: true,
145
+ });
120
146
  }
121
147
  /**
122
148
  * `wizards start --no-open` detached from this terminal, its output in logs/runtime.log; the
123
- * runtime once it answers, or null when it ended or did not answer within a minute.
149
+ * runtime once it answers, or null when it ended or did not answer in time.
124
150
  */
125
151
  export async function startInBackground(paths) {
126
152
  const { dataDir } = settings(paths);
@@ -226,7 +252,7 @@ export async function start(paths, options) {
226
252
  while (exited === null && !(await healthy(port))) {
227
253
  if (Date.now() - began > START_TIMEOUT_MS) {
228
254
  child.kill("SIGTERM");
229
- console.error("engenty wizards didn’t respond within 60 seconds.");
255
+ console.error(`engenty wizards didn’t respond within ${START_TIMEOUT_MS / 1000} seconds.`);
230
256
  return 1;
231
257
  }
232
258
  await new Promise((wait) => setTimeout(wait, 150));