@engenty/wizards-runtime 0.7.8 → 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 (113) 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/clients.js +118 -0
  18. package/apps/runtime/dist/db/client.js +37 -3
  19. package/apps/runtime/dist/db/migrations/tenant/0017_run_client.sql +1 -0
  20. package/apps/runtime/dist/db/migrations/tenant/meta/0017_snapshot.json +3333 -0
  21. package/apps/runtime/dist/db/migrations/tenant/meta/_journal.json +7 -0
  22. package/apps/runtime/dist/db/schema.js +2 -0
  23. package/apps/runtime/dist/docs/dev/api.md +39 -0
  24. package/apps/runtime/dist/docs/dev/index.md +1 -0
  25. package/apps/runtime/dist/docs/dev/integration/openui.md +5 -3
  26. package/apps/runtime/dist/docs/dev/meta.json +1 -1
  27. package/apps/runtime/dist/docs/user/ai-apps.md +14 -3
  28. package/apps/runtime/dist/docs/user/install.md +18 -8
  29. package/apps/runtime/dist/engine/runner.js +1 -0
  30. package/apps/runtime/dist/harness/env.js +41 -8
  31. package/apps/runtime/dist/harness/index.js +10 -1
  32. package/apps/runtime/dist/harness/model.js +1 -1
  33. package/apps/runtime/dist/index.js +13 -4
  34. package/apps/runtime/dist/plugin.js +24 -8
  35. package/apps/runtime/dist/render/browser-path.js +43 -11
  36. package/apps/runtime/dist/routes/runs.js +9 -0
  37. package/apps/runtime/dist/update.js +4 -0
  38. package/apps/web/dist/assets/AboutDialog-B2LuZ_mC.js +1 -0
  39. package/apps/web/dist/assets/ChangelogDialog-B0lS0BdL.js +1 -0
  40. package/apps/web/dist/assets/ChatView-BcPDcU2d.js +10 -0
  41. package/apps/web/dist/assets/EditorPage-erduYKNM.js +4 -0
  42. package/apps/web/dist/assets/{FlowPreview-Dx8AjXvE.js → FlowPreview-oX_Emm7u.js} +1 -1
  43. package/apps/web/dist/assets/{Harness-NvIb36TG.js → Harness-Dv9ln5xB.js} +2 -2
  44. package/apps/web/dist/assets/{NewWizardChannels-B2YgtJlt.js → NewWizardChannels-JveYyh-U.js} +1 -1
  45. package/apps/web/dist/assets/NewWizardPage-Dfk-WOhX.js +7 -0
  46. package/apps/web/dist/assets/OnboardingPage-CT9dL4ke.js +1 -0
  47. package/apps/web/dist/assets/ProjectPage-5_Wsk9Hv.js +1 -0
  48. package/apps/web/dist/assets/PublicRunner-Cw1WzTze.js +1 -0
  49. package/apps/web/dist/assets/{ReadOnly-CGNHlgUT.js → ReadOnly-S9vGVQHr.js} +1 -1
  50. package/apps/web/dist/assets/RunDrawer-O4rEFCL8.js +4 -0
  51. package/apps/web/dist/assets/RunnerView-BW1h4x63.js +1 -0
  52. package/apps/web/dist/assets/SettingsPage-DmhN7OTy.js +1 -0
  53. package/apps/web/dist/assets/SetupPage-BlWd7Uln.js +1 -0
  54. package/apps/web/dist/assets/{SharePage-hG7Z6SLy.js → SharePage-ktqm-jWI.js} +1 -1
  55. package/apps/web/dist/assets/changelog-FJJS0Tpt.js +1 -0
  56. package/apps/web/dist/assets/engenty-WpMi3xwq.js +1 -0
  57. package/apps/web/dist/assets/{esm-DVk3RJij.js → esm-C-ZWeuhU.js} +1 -1
  58. package/apps/web/dist/assets/{fluffy-engenty-ClRou7yu.js → fluffy-engenty-DPNHytrz.js} +135 -16
  59. package/apps/web/dist/assets/goose-helmet-Cvc7KjUg.bin +0 -0
  60. package/apps/web/dist/assets/goose-helmet-livery-CZD9u0XS.webp +0 -0
  61. package/apps/web/dist/assets/index-C4aHMcg9.css +1 -0
  62. package/apps/web/dist/assets/index-xH9uN8Nh.js +14 -0
  63. package/apps/web/dist/assets/oss-credits-Bc05Qv5H.js +1 -0
  64. package/apps/web/dist/assets/{phone-CXXHXYnA.js → phone-BjmhIKuT.js} +1 -1
  65. package/apps/web/dist/assets/{settings-menu-DXzZCIMx.js → settings-menu-Dpkp3f56.js} +1 -1
  66. package/apps/web/dist/assets/{ui-DQ4L2npj.js → ui-DMmT-cSO.js} +4 -4
  67. package/apps/web/dist/assets/{upload-BnYHIfu5.js → upload-JX9BcMTq.js} +1 -1
  68. package/apps/web/dist/assets/{wrench-BrUJ7SV6.js → wrench-BSxnPs2_.js} +1 -1
  69. package/apps/web/dist/index.html +4 -4
  70. package/apps/web/dist/install.ps1 +307 -0
  71. package/apps/web/dist/mcp/flow.html +141 -22
  72. package/bin/wizards.mjs +59 -35
  73. package/node_modules/@engenty/wizards/package.json +1 -4
  74. package/node_modules/@engenty-wizards/shared/dist/claude-plugin.d.ts +7 -0
  75. package/node_modules/@engenty-wizards/shared/dist/claude-plugin.js +10 -0
  76. package/node_modules/@engenty-wizards/shared/dist/run.d.ts +2 -0
  77. package/node_modules/@engenty-wizards/shared/dist/run.js +4 -0
  78. package/package.json +2 -5
  79. package/plugin/.claude-plugin/plugin.json +28 -6
  80. package/plugin/README.md +58 -0
  81. package/plugin/hooks/engenty.ts +140 -0
  82. package/plugin/hooks/hooks.json +1 -0
  83. package/plugin/hooks/pane.test.tsx +177 -0
  84. package/plugin/hooks/register.tsx +396 -0
  85. package/plugin/types/index.d.ts +44 -0
  86. package/apps/web/dist/assets/AboutDialog-ByZyHXDj.js +0 -1
  87. package/apps/web/dist/assets/ChangelogDialog-QHYP0jAv.js +0 -1
  88. package/apps/web/dist/assets/ChatView-DupTtI8K.js +0 -10
  89. package/apps/web/dist/assets/EditorPage-k6qouLeS.js +0 -4
  90. package/apps/web/dist/assets/NewWizardPage-BgLY6SL6.js +0 -7
  91. package/apps/web/dist/assets/OnboardingPage-CxhwefJU.js +0 -1
  92. package/apps/web/dist/assets/ProjectPage-6ceSSS7W.js +0 -1
  93. package/apps/web/dist/assets/PublicRunner-6lQvReDx.js +0 -1
  94. package/apps/web/dist/assets/RunDrawer-TVGNnWyx.js +0 -4
  95. package/apps/web/dist/assets/RunnerView-DInSK0VZ.js +0 -1
  96. package/apps/web/dist/assets/SettingsPage-D3k5pXN5.js +0 -1
  97. package/apps/web/dist/assets/SetupPage-Bj5frdzY.js +0 -1
  98. package/apps/web/dist/assets/changelog-CO9vKjV0.js +0 -1
  99. package/apps/web/dist/assets/engenty-eXZ-EAwd.js +0 -1
  100. package/apps/web/dist/assets/index-DfaoPjNW.css +0 -1
  101. package/apps/web/dist/assets/index-DlDsr9Lc.js +0 -14
  102. package/apps/web/dist/assets/oss-credits-X2JPiMOR.js +0 -1
  103. package/node_modules/@engenty/wizards/dist/openui/Wizard.d.ts +0 -20
  104. package/node_modules/@engenty/wizards/dist/openui/Wizard.js +0 -62
  105. package/node_modules/@engenty/wizards/dist/openui/index.d.ts +0 -9
  106. package/node_modules/@engenty/wizards/dist/openui/index.js +0 -9
  107. package/node_modules/@engenty/wizards/dist/openui/message.d.ts +0 -11
  108. package/node_modules/@engenty/wizards/dist/openui/message.js +0 -69
  109. package/node_modules/@engenty/wizards/dist/openui/run.d.ts +0 -8
  110. package/node_modules/@engenty/wizards/dist/openui/run.js +0 -137
  111. package/node_modules/@engenty/wizards/dist/openui/server.d.ts +0 -37
  112. package/node_modules/@engenty/wizards/dist/openui/server.js +0 -77
  113. package/plugin/.mcp.json +0 -8
@@ -1,13 +1,19 @@
1
- import { spawnSync } from "node:child_process";
1
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
2
2
  import { existsSync, lstatSync, readdirSync, readlinkSync, rmdirSync, rmSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join, resolve, sep } from "node:path";
5
5
  import * as p from "@clack/prompts";
6
- import { LOGIN_LABEL, present, removeUnit, UNIT, unloadLoginItem } from "./autostart.js";
6
+ import { LOGIN_LABEL, present, removeUnit, removeWindowsLoginItem, UNIT, unloadLoginItem, windowsLoginItem, } from "./autostart.js";
7
7
  import { apps as allApps } from "./connect.js";
8
8
  import { findWizardsProcesses, stopProcesses } from "./processes.js";
9
9
  import { settings } from "./start.js";
10
10
  import { bad, cyan, dim, no, ok } from "./ui.js";
11
+ /** The user's own PATH from the registry, through PowerShell. */
12
+ function userPath() {
13
+ const out = execFileSync("powershell.exe", ["-NoProfile", "-Command", '[Environment]::GetEnvironmentVariable("Path", "User")'], { encoding: "utf8", windowsHide: true });
14
+ return out.trim().split(";").filter(Boolean);
15
+ }
16
+ const sameDir = (a, b) => a.replace(/[\\/]+$/, "").toLowerCase() === b.replace(/[\\/]+$/, "").toLowerCase();
11
17
  export const realSystem = () => ({
12
18
  userHome: homedir(),
13
19
  platform: process.platform,
@@ -18,6 +24,56 @@ export const realSystem = () => ({
18
24
  stop: (pids) => stopProcesses(pids),
19
25
  unloadLoginItem,
20
26
  removeUnit,
27
+ windowsLoginItem,
28
+ removeWindowsLoginItem,
29
+ onUserPath: (dir) => {
30
+ try {
31
+ return userPath().some((entry) => sameDir(entry, dir));
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ },
37
+ removeFromUserPath: (dir) => {
38
+ const kept = userPath().filter((entry) => !sameDir(entry, dir));
39
+ const value = kept.join(";").replaceAll("'", "''");
40
+ execFileSync("powershell.exe", [
41
+ "-NoProfile",
42
+ "-Command",
43
+ `[Environment]::SetEnvironmentVariable("Path", '${value}', "User")`,
44
+ ], { windowsHide: true });
45
+ },
46
+ deleteAfterExit: (paths, ifEmpty) => {
47
+ // cmd.exe waits a moment for this process to end, then deletes what it held. Should it end
48
+ // with this terminal's session (an ssh session ends what it started), the next login does it.
49
+ const runOnce = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce";
50
+ const removals = [
51
+ ...paths.map((path) => `rmdir /s /q "${path}" 2>nul & del /f /q "${path}" 2>nul`),
52
+ // Without /s: a folder that still holds the data the person kept stays.
53
+ ...ifEmpty.map((dir) => `rmdir "${dir}" 2>nul`),
54
+ ];
55
+ const command = `cmd.exe /d /c ${removals.join(" & ")}`;
56
+ try {
57
+ execFileSync("reg", ["add", runOnce, "/v", "engenty-wizards-uninstall", "/d", command, "/f"], { windowsHide: true, stdio: "ignore" });
58
+ }
59
+ catch {
60
+ // The command below still runs.
61
+ }
62
+ // Tried every second for a minute, as this process may still take a moment to end. Done
63
+ // here, the entry goes too: a reinstall before the next login must not lose its files.
64
+ const gone = paths.map((path) => `if not exist "${path}"`).join(" ");
65
+ const now = [
66
+ `for /l %i in (1,1,60) do @(ping -n 2 127.0.0.1 >nul & ${removals.join(" & ")})`,
67
+ `${gone} reg delete ${runOnce} /v engenty-wizards-uninstall /f >nul 2>&1`,
68
+ ].join(" & ");
69
+ // As Node passes a shell command to cmd.exe: whole, in quotes, read as it is.
70
+ spawn("cmd.exe", ["/d", "/s", "/c", `"${now}"`], {
71
+ detached: true,
72
+ stdio: "ignore",
73
+ windowsHide: true,
74
+ windowsVerbatimArguments: true,
75
+ }).unref();
76
+ },
21
77
  });
22
78
  /** Parts of the install folder that are the person's: kept or deleted with the data. */
23
79
  const WITH_DATA = [".env", ".env.local", "plugins"];
@@ -62,6 +118,9 @@ export async function makePlan(paths, system) {
62
118
  // Can't look: count it as there, so the uninstall tries and says so.
63
119
  loginItem = item;
64
120
  }
121
+ if (system.platform === "win32") {
122
+ loginItem = await system.windowsLoginItem();
123
+ }
65
124
  const { dataDir } = settings(paths, system.env);
66
125
  const program = [];
67
126
  const data = existsSync(dataDir) ? [dataDir] : [];
@@ -80,6 +139,7 @@ export async function makePlan(paths, system) {
80
139
  processes: system.processes(paths.home),
81
140
  program,
82
141
  links: ourLinks(paths.home, system),
142
+ pathEntry: system.platform === "win32" && system.onUserPath(paths.bin) ? paths.bin : null,
83
143
  data,
84
144
  };
85
145
  }
@@ -104,6 +164,7 @@ export async function uninstall(paths, options, system = realSystem()) {
104
164
  ...(plan.processes.length ? [`Stop ${processes(plan.processes.length)}`] : []),
105
165
  ...(plan.program.length ? [`Delete the program in ${t(plan.home)}${size(plan.home)}`] : []),
106
166
  ...plan.links.map((link) => `Delete the command ${t(link)}`),
167
+ ...(plan.pathEntry ? [`Take ${t(plan.pathEntry)} off your PATH`] : []),
107
168
  ];
108
169
  if (!steps.length && !plan.data.length) {
109
170
  console.log(`${ok} engenty wizards isn’t installed here.`);
@@ -153,7 +214,10 @@ export async function uninstall(paths, options, system = realSystem()) {
153
214
  }
154
215
  if (plan.loginItem) {
155
216
  try {
156
- if (system.platform === "darwin") {
217
+ if (system.platform === "win32") {
218
+ await system.removeWindowsLoginItem();
219
+ }
220
+ else if (system.platform === "darwin") {
157
221
  await system.unloadLoginItem();
158
222
  rmSync(plan.loginItem, { force: true });
159
223
  }
@@ -173,14 +237,37 @@ export async function uninstall(paths, options, system = realSystem()) {
173
237
  console.log(`${ok} Stopped ${processes(running.length)}`);
174
238
  }
175
239
  }
176
- for (const path of [...plan.program, ...plan.links, ...(deleteData ? plan.data : [])]) {
240
+ // Looked at again now that nothing runs: what a process wrote meanwhile (the runtime's first
241
+ // update check writes update.json) goes too.
242
+ const now = await makePlan(paths, system);
243
+ // Windows keeps the files of a running program: the Node this command runs on, if it is the
244
+ // install's own, goes once the command has ended.
245
+ const later = [];
246
+ for (const path of [...now.program, ...now.links, ...(deleteData ? now.data : [])]) {
177
247
  try {
178
248
  rmSync(path, { recursive: true, force: true });
179
249
  }
180
250
  catch (error) {
181
- failed(`Couldn’t delete ${t(path)}`, error);
251
+ if (system.platform === "win32") {
252
+ later.push(path);
253
+ }
254
+ else {
255
+ failed(`Couldn’t delete ${t(path)}`, error);
256
+ }
182
257
  }
183
258
  }
259
+ if (plan.pathEntry) {
260
+ try {
261
+ system.removeFromUserPath(plan.pathEntry);
262
+ console.log(`${ok} Took ${t(plan.pathEntry)} off your PATH`);
263
+ }
264
+ catch (error) {
265
+ failed("The PATH", error);
266
+ }
267
+ }
268
+ if (later.length) {
269
+ console.log(`${ok} ${later.map(t).join(", ")} go${later.length === 1 ? "es" : ""} once this command has ended`);
270
+ }
184
271
  for (const dir of [plan.home, join(system.userHome, ".engenty")]) {
185
272
  try {
186
273
  rmdirSync(dir);
@@ -195,8 +282,15 @@ export async function uninstall(paths, options, system = realSystem()) {
195
282
  ...after.apps.map((app) => `${app.name} still lists the wizards: ${cyan(`npx wizards disconnect ${app.id}`)}`),
196
283
  ...(after.loginItem ? [t(after.loginItem)] : []),
197
284
  ...after.processes.map((proc) => `process ${proc.pid}: ${cyan(`kill -9 ${proc.pid}`)}`),
198
- ...[...after.program, ...after.links, ...(deleteData ? after.data : [])].map(t),
285
+ ...(after.pathEntry ? [`${t(after.pathEntry)} on your PATH`] : []),
286
+ ...[...after.program, ...after.links, ...(deleteData ? after.data : [])]
287
+ .filter((path) => !later.includes(path))
288
+ .map(t),
199
289
  ];
290
+ // Started after the second look, which would count it among the install's processes.
291
+ if (later.length) {
292
+ system.deleteAfterExit(later, [plan.home, join(system.userHome, ".engenty")]);
293
+ }
200
294
  if (!deleteData && after.data.length) {
201
295
  console.log(`${ok} Kept your data: ${after.data.map(t).join(", ")}`);
202
296
  }
@@ -3,12 +3,13 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { autoUpdatePolicy, noteUpdated } from "./autoupdate.js";
5
5
  import { installedByScript, isCheckout, readInstallNote, writeInstallNote, } from "./home.js";
6
- import { runningRuntime, settings } from "./start.js";
7
- import { restart } from "./status.js";
6
+ import { runningRuntime, settings, startInBackground } from "./start.js";
7
+ import { restart, stop } from "./status.js";
8
8
  import { cmd, cyan, no, ok } from "./ui.js";
9
9
  import { currentVersion } from "./versions.js";
10
+ const windows = process.platform === "win32";
10
11
  /** The installer this install was made with; it also updates one. */
11
- const INSTALLER = "https://engenty.ai/install.sh";
12
+ const INSTALLER = windows ? "https://engenty.ai/install.ps1" : "https://engenty.ai/install.sh";
12
13
  async function installer(into) {
13
14
  const source = process.env.ENGENTY_WIZARDS_INSTALLER?.trim() || INSTALLER;
14
15
  if (!/^https?:\/\//.test(source)) {
@@ -17,13 +18,24 @@ async function installer(into) {
17
18
  const response = await fetch(source);
18
19
  const text = response.ok ? await response.text() : "";
19
20
  // The address answers with the studio's page while a server has no installer yet.
20
- if (!text.startsWith("#!")) {
21
+ if (!text.startsWith(windows ? "# engenty wizards" : "#!")) {
21
22
  throw new Error(`${source} didn’t return the installer.`);
22
23
  }
23
- const file = join(into, "install.sh");
24
+ const file = join(into, windows ? "install.ps1" : "install.sh");
24
25
  writeFileSync(file, text, { mode: 0o700 });
25
26
  return file;
26
27
  }
28
+ /** The installer, without its questions. */
29
+ function runInstaller(script) {
30
+ const [command, args] = windows
31
+ ? ["powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script, "-NoSetup"]]
32
+ : ["bash", [script, "--no-setup"]];
33
+ return new Promise((done) => {
34
+ const child = spawn(command, args, { stdio: "inherit" });
35
+ child.on("error", () => done(127));
36
+ child.on("exit", (exit) => done(exit ?? 1));
37
+ });
38
+ }
27
39
  /**
28
40
  * Brings an install made by install.sh to the newest version by running the installer again
29
41
  * without its questions: Node, the runtime and the command. With `restart`, a runtime that runs
@@ -37,14 +49,15 @@ export async function update(paths, options = {}) {
37
49
  return 0;
38
50
  }
39
51
  const before = currentVersion(paths);
52
+ // Windows keeps the files of a running program locked, its native modules among them: the
53
+ // runtime stops for the update and starts again after it, as the new version.
54
+ const stopped = windows && (await runningRuntime(settings(paths).dataDir)) !== null;
55
+ if (stopped && (await stop(paths)) !== 0) {
56
+ return 1;
57
+ }
40
58
  const tmp = mkdtempSync(join(paths.home, "update-"));
41
59
  try {
42
- const script = await installer(tmp);
43
- const code = await new Promise((done) => {
44
- const child = spawn("bash", [script, "--no-setup"], { stdio: "inherit" });
45
- child.on("error", () => done(127));
46
- child.on("exit", (exit) => done(exit ?? 1));
47
- });
60
+ const code = await runInstaller(await installer(tmp));
48
61
  if (code !== 0) {
49
62
  return code;
50
63
  }
@@ -55,12 +68,15 @@ export async function update(paths, options = {}) {
55
68
  }
56
69
  finally {
57
70
  rmSync(tmp, { recursive: true, force: true });
71
+ if (stopped) {
72
+ await startInBackground(paths);
73
+ }
58
74
  }
59
75
  const after = currentVersion(paths);
60
76
  if (before && after) {
61
77
  noteUpdated(paths, before, after);
62
78
  }
63
- if (await runningRuntime(settings(paths).dataDir)) {
79
+ if (!stopped && (await runningRuntime(settings(paths).dataDir))) {
64
80
  if (options.restart) {
65
81
  return restart(paths);
66
82
  }
@@ -1,5 +1,5 @@
1
1
  import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, symlinkSync, } from "node:fs";
2
- import { join } from "node:path";
2
+ import { dirname, join, resolve } from "node:path";
3
3
  /**
4
4
  * The runtimes of an install made by install.sh, one folder per version, side by side:
5
5
  *
@@ -8,6 +8,8 @@ import { join } from "node:path";
8
8
  * runtime/current → <version>, the one `bin/wizards` starts
9
9
  * runtime/node_modules → current/node_modules, for commands written before the versions
10
10
  *
11
+ * On Windows the links are junctions, which need no administrator (install.ps1 makes them too).
12
+ *
11
13
  * Up to 0.6.2 `runtime/` itself was the prefix; `switchTo` moves such an install into its
12
14
  * version folder first. A process keeps the files of the version it started with.
13
15
  */
@@ -48,7 +50,8 @@ const isLink = (path) => {
48
50
  /** The version `bin/wizards` starts; null without a runtime. */
49
51
  export function currentVersion(paths) {
50
52
  if (isLink(currentLink(paths))) {
51
- return versionIn(join(paths.runtime, readlinkSync(currentLink(paths))));
53
+ // A junction names its target in full; a link here names the version folder alone.
54
+ return versionIn(resolve(paths.runtime, readlinkSync(currentLink(paths))));
52
55
  }
53
56
  return versionIn(paths.runtime);
54
57
  }
@@ -63,8 +66,16 @@ export function installedVersions(paths) {
63
66
  return [];
64
67
  }
65
68
  }
66
- /** Points `link` at `target` in one step: a new link renamed over the old one. */
69
+ /**
70
+ * Points `link` at `target` in one step: a new link renamed over the old one. On Windows a
71
+ * junction, with its target in full, which takes the old one's place.
72
+ */
67
73
  function link(target, path) {
74
+ if (process.platform === "win32") {
75
+ rmSync(path, { force: true });
76
+ symlinkSync(resolve(dirname(path), target), path, "junction");
77
+ return;
78
+ }
68
79
  const tmp = `${path}.${process.pid}.tmp`;
69
80
  rmSync(tmp, { force: true });
70
81
  symlinkSync(target, tmp);
@@ -115,7 +126,8 @@ export function promote(paths, staged, version) {
115
126
  }
116
127
  // As install.sh leaves it: a command of 0.7.4 or older finds the runtime under its old name.
117
128
  const legacy = join(staged, "node_modules", LEGACY_PACKAGE);
118
- if (!existsSync(legacy)) {
129
+ // Windows has no command of 0.7.4 or older.
130
+ if (!existsSync(legacy) && process.platform !== "win32") {
119
131
  symlinkSync(RUNTIME_PACKAGE, legacy);
120
132
  }
121
133
  rmSync(runtimeDir(paths, version), { recursive: true, force: true });
@@ -0,0 +1,118 @@
1
+ import { env } from "./env.js";
2
+ import { allowed } from "./limits.js";
3
+ import { managed } from "./manage.js";
4
+ /**
5
+ * The clients of the public run API: who starts runs here (docs/content/dev/api.md,
6
+ * docs/plans/api-clients.md). A client names itself in the `x-wizards-client` header: `web` (the
7
+ * runner in a browser), `app` (the mobile app), `cli`, or the id of a client registered at the
8
+ * Manage-App. The id is no secret: it carries the terms, the limits and the revocation of a
9
+ * client, not its proof. The run keeps it.
10
+ *
11
+ * A managed runtime fetches the list of clients from its Manage-App at the start and once an
12
+ * hour, and keeps the last list when a fetch fails. Without a list every client may start runs:
13
+ * the Manage-App being down must not close the wizards. A runtime that runs alone fetches
14
+ * nothing and refuses nothing — its API is the person's own.
15
+ */
16
+ export const CLIENT_HEADER = "x-wizards-client";
17
+ /** The clients this repository ships: the runner, the mobile app, the command line. */
18
+ export const OWN_CLIENTS = ["web", "app", "cli"];
19
+ const ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
20
+ /** The client a request names, or null when it names none or a malformed one. */
21
+ export function clientOf(c) {
22
+ const id = c.req.header(CLIENT_HEADER)?.trim().toLowerCase() ?? "";
23
+ return ID.test(id) ? id : null;
24
+ }
25
+ let list = null;
26
+ /** The list in force; tests set it, a managed runtime fetches it. */
27
+ export function setClientList(next) {
28
+ list = next;
29
+ }
30
+ const HOUR = 3600_000;
31
+ /**
32
+ * Why `client` may not start a run now, or null when it may. Counts the try against the
33
+ * client's hourly cap, so call it once per start.
34
+ */
35
+ export function clientRefused(client) {
36
+ if (!list) {
37
+ return null;
38
+ }
39
+ const known = list.clients.find((c) => c.id === client);
40
+ const cap = known ? known.runsPerHour : list.unknown.runsPerHour;
41
+ if (known ? known.revoked : !list.unknown.allow) {
42
+ return {
43
+ status: 403,
44
+ code: "client_refused",
45
+ note: "clientRefused",
46
+ };
47
+ }
48
+ if (cap !== null && !allowed(`client:${client ?? "unknown"}`, cap, HOUR)) {
49
+ return {
50
+ status: 429,
51
+ code: "client_limit",
52
+ note: "clientLimit",
53
+ };
54
+ }
55
+ return null;
56
+ }
57
+ /** The list as the Manage-App sends it, checked; throws on a shape this runtime does not know. */
58
+ export function parseClientList(body) {
59
+ const raw = body;
60
+ if (!Array.isArray(raw?.clients)) {
61
+ throw new Error("no clients");
62
+ }
63
+ const clients = raw.clients.map((c) => {
64
+ if (typeof c?.id !== "string" || !ID.test(c.id)) {
65
+ throw new Error("bad client id");
66
+ }
67
+ return {
68
+ id: c.id,
69
+ name: typeof c.name === "string" ? c.name : c.id,
70
+ runsPerHour: typeof c.runsPerHour === "number" && c.runsPerHour >= 0 ? c.runsPerHour : null,
71
+ revoked: c.revoked === true,
72
+ };
73
+ });
74
+ const unknown = (raw.unknown ?? {});
75
+ return {
76
+ clients,
77
+ unknown: {
78
+ allow: unknown.allow !== false,
79
+ runsPerHour: typeof unknown.runsPerHour === "number" && unknown.runsPerHour >= 0
80
+ ? unknown.runsPerHour
81
+ : null,
82
+ },
83
+ };
84
+ }
85
+ /**
86
+ * Fetches the Manage-App's list. A Manage-App without the call (404) means no list: everything
87
+ * may start runs. Any other failure keeps the list from before.
88
+ */
89
+ export async function loadClientList() {
90
+ if (!managed) {
91
+ return;
92
+ }
93
+ try {
94
+ const res = await fetch(`${env.manage.url}/v1/clients`, {
95
+ headers: { authorization: `Bearer ${env.manage.serviceKey}` },
96
+ signal: AbortSignal.timeout(10_000),
97
+ });
98
+ if (res.status === 404) {
99
+ list = null;
100
+ return;
101
+ }
102
+ if (!res.ok) {
103
+ throw new Error(`HTTP ${res.status}`);
104
+ }
105
+ list = parseClientList(await res.json());
106
+ }
107
+ catch (err) {
108
+ console.warn(`[clients] the Manage-App's client list was not read: ${err.message}`);
109
+ }
110
+ }
111
+ /** A managed runtime: the list now, and again every hour. */
112
+ export function startClientChecks() {
113
+ if (!managed) {
114
+ return;
115
+ }
116
+ void loadClientList();
117
+ setInterval(() => void loadClientList(), HOUR).unref();
118
+ }
@@ -21,8 +21,10 @@ function open(url, authToken) {
21
21
  }
22
22
  async function prepare(client, url) {
23
23
  if (url.startsWith("file:")) {
24
- await client.execute("PRAGMA journal_mode = WAL");
24
+ // The wait first: studio, doors and runner share the files, and switching to WAL waits
25
+ // too while another process recovers the log after a restart.
25
26
  await client.execute("PRAGMA busy_timeout = 5000");
27
+ await client.execute("PRAGMA journal_mode = WAL");
26
28
  }
27
29
  await client.execute("PRAGMA foreign_keys = ON");
28
30
  }
@@ -52,6 +54,28 @@ function tenantUrl(id, dbUrl) {
52
54
  }
53
55
  return `file:${join(env.dataDir, "tenants", `${id}.db`)}`;
54
56
  }
57
+ function isBusy(err) {
58
+ for (let e = err; e; e = e.cause) {
59
+ if (String(e.code ?? "").startsWith("SQLITE_BUSY")) {
60
+ return true;
61
+ }
62
+ }
63
+ return false;
64
+ }
65
+ /** `fn` again while another process holds the database: 1, 2, 4, 8 and 16 seconds apart. */
66
+ async function whileBusy(fn) {
67
+ for (let attempt = 0;; attempt++) {
68
+ try {
69
+ return await fn();
70
+ }
71
+ catch (err) {
72
+ if (attempt >= 5 || !isBusy(err)) {
73
+ throw err;
74
+ }
75
+ await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt));
76
+ }
77
+ }
78
+ }
55
79
  async function openTenant(id) {
56
80
  let row = await controlDb.query.tenant.findFirst({ where: eq(control.tenant.id, id) });
57
81
  if (!row) {
@@ -61,10 +85,20 @@ async function openTenant(id) {
61
85
  const url = tenantUrl(id, row.dbUrl);
62
86
  const client = open(url, url.startsWith("file:") ? "" : env.tenantDbAuthToken);
63
87
  clients.set(id, client);
64
- await prepare(client, url);
65
88
  const db = makeTenantDb(client);
66
89
  // Every database is brought to the current schema when it is first opened.
67
- await migrate(db, { migrationsFolder: migrationsFolder("tenant") });
90
+ try {
91
+ await whileBusy(async () => {
92
+ await prepare(client, url);
93
+ await migrate(db, { migrationsFolder: migrationsFolder("tenant") });
94
+ });
95
+ }
96
+ catch (err) {
97
+ // The next use opens it afresh (tenantDb forgets the failed attempt).
98
+ clients.delete(id);
99
+ client.close();
100
+ throw err;
101
+ }
68
102
  for (const hook of openHooks) {
69
103
  await hook(id, client);
70
104
  }
@@ -0,0 +1 @@
1
+ ALTER TABLE `run` ADD `client` text;