wizards 0.3.7 → 0.4.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.
Files changed (72) hide show
  1. package/README.md +28 -14
  2. package/apps/runtime/dist/agents/studio-assistant.js +79 -7
  3. package/apps/runtime/dist/assistant/runs.js +24 -0
  4. package/apps/runtime/dist/auth/account.js +36 -4
  5. package/apps/runtime/dist/auth/device.js +101 -0
  6. package/apps/runtime/dist/auth/index.js +4 -4
  7. package/apps/runtime/dist/cli/account.js +289 -0
  8. package/apps/runtime/dist/cli/install-env.js +33 -0
  9. package/apps/runtime/dist/cli/main.js +17 -3
  10. package/apps/runtime/dist/cli/menu.js +19 -5
  11. package/apps/runtime/dist/cli/setup.js +9 -16
  12. package/apps/runtime/dist/cli/status.js +4 -1
  13. package/apps/runtime/dist/cli/ui.js +14 -0
  14. package/apps/runtime/dist/docs/user/assistant.md +25 -7
  15. package/apps/runtime/dist/docs/user/command-line.md +3 -2
  16. package/apps/runtime/dist/docs/user/create-account.md +47 -0
  17. package/apps/runtime/dist/docs/user/first-start.md +9 -1
  18. package/apps/runtime/dist/docs/user/index.md +16 -12
  19. package/apps/runtime/dist/docs/user/install.md +3 -1
  20. package/apps/runtime/dist/docs/user/local-only.md +38 -0
  21. package/apps/runtime/dist/docs/user/meta.json +3 -1
  22. package/apps/runtime/dist/docs/user/models.md +3 -2
  23. package/apps/runtime/dist/env.js +5 -0
  24. package/apps/runtime/dist/manage.js +16 -4
  25. package/apps/runtime/dist/models.js +6 -0
  26. package/apps/runtime/dist/plugins/pro.js +16 -16
  27. package/apps/runtime/dist/routes/studio.js +140 -40
  28. package/apps/runtime/dist/services/assistant-chats.js +49 -6
  29. package/apps/runtime/dist/services/cloud.js +10 -10
  30. package/apps/runtime/dist/services/onboarding.js +152 -0
  31. package/apps/web/dist/assets/{AboutDialog-C0dN86zd.js → AboutDialog-CPQiN7H9.js} +1 -1
  32. package/apps/web/dist/assets/{ChangelogDialog-Cv81t7tj.js → ChangelogDialog-DynvQuuR.js} +1 -1
  33. package/apps/web/dist/assets/ChatView-CKo_9IZD.js +10 -0
  34. package/apps/web/dist/assets/EditorPage-CDIJXC4T.js +3 -0
  35. package/apps/web/dist/assets/{FlowPreview-D4dp1MWS.js → FlowPreview-BZLnG7GV.js} +1 -1
  36. package/apps/web/dist/assets/{Harness-v7r0e6vK.js → Harness-CNh3nbB_.js} +2 -2
  37. package/apps/web/dist/assets/{NewWizardChannels-Be-7VhBi.js → NewWizardChannels-C1Ss9UMY.js} +1 -1
  38. package/apps/web/dist/assets/{NewWizardPage-C8hPvrK8.js → NewWizardPage-2qLAbvDL.js} +2 -2
  39. package/apps/web/dist/assets/OnboardingPage-C1FJwEsT.js +1 -0
  40. package/apps/web/dist/assets/ProjectPage-CGuLUuOs.js +1 -0
  41. package/apps/web/dist/assets/{PublicRunner-DeCpVfZP.js → PublicRunner-BKw1lOW4.js} +1 -1
  42. package/apps/web/dist/assets/{ReadOnly-B-UCgnqW.js → ReadOnly-DTjv2Jzm.js} +1 -1
  43. package/apps/web/dist/assets/RunDrawer-D1I5xqVf.js +4 -0
  44. package/apps/web/dist/assets/RunnerView-BPPmlv9z.js +1 -0
  45. package/apps/web/dist/assets/SettingsPage-zbomQfGy.js +1 -0
  46. package/apps/web/dist/assets/SetupPage-D3F7M6Pz.js +1 -0
  47. package/apps/web/dist/assets/{SharePage-B778xUFo.js → SharePage-BY49IJgT.js} +1 -1
  48. package/apps/web/dist/assets/{changelog-DCJo2mbx.js → changelog-DYZC2E6q.js} +1 -1
  49. package/apps/web/dist/assets/{circle-alert-DLvC9ePT.js → circle-alert-CVOH4wAv.js} +1 -1
  50. package/apps/web/dist/assets/{esm-BJskogLD.js → esm-BfXdeWaI.js} +1 -1
  51. package/apps/web/dist/assets/index-B9xVkr2X.css +1 -0
  52. package/apps/web/dist/assets/index-BH0PYgov.js +15 -0
  53. package/apps/web/dist/assets/{lock-DR7NakPn.js → lock-DHBh62LA.js} +1 -1
  54. package/apps/web/dist/assets/{phone-BORh3jfv.js → phone-Bt1cyWpY.js} +1 -1
  55. package/apps/web/dist/assets/{settings-menu-DV5D91TD.js → settings-menu-ace1vI3i.js} +1 -1
  56. package/apps/web/dist/assets/{ui-BOCXzzHi.js → ui-D2SqIZeQ.js} +2 -2
  57. package/apps/web/dist/assets/{upload-B3yv8gsk.js → upload-CCEq88l4.js} +1 -1
  58. package/apps/web/dist/index.html +3 -3
  59. package/apps/web/dist/install.sh +17 -7
  60. package/apps/web/dist/mcp/flow.html +3 -3
  61. package/node_modules/@engenty-wizards/shared/dist/assistant.d.ts +62 -1
  62. package/node_modules/@engenty-wizards/shared/dist/assistant.js +10 -0
  63. package/package.json +1 -1
  64. package/apps/web/dist/assets/ChatView-uogJ3nht.js +0 -10
  65. package/apps/web/dist/assets/EditorPage-n8YuTh6j.js +0 -3
  66. package/apps/web/dist/assets/ProjectPage-B686ArYy.js +0 -1
  67. package/apps/web/dist/assets/RunDrawer-C6pzi8AJ.js +0 -4
  68. package/apps/web/dist/assets/RunnerView-Deenymq0.js +0 -1
  69. package/apps/web/dist/assets/SettingsPage-CK6L0yEe.js +0 -1
  70. package/apps/web/dist/assets/SetupPage-9CH3qXdH.js +0 -1
  71. package/apps/web/dist/assets/index-D6hUM8NU.css +0 -1
  72. package/apps/web/dist/assets/index-Df6UxElH.js +0 -12
package/README.md CHANGED
@@ -44,37 +44,50 @@ A wizard in use: it asks on one page, then on the next, and ends with the result
44
44
  <img src="docs/assets/wizard.png" alt="A wizard in use on a phone, three pictures: which week and how many children, what is on the table, and the finished weekly plan with PDF, PNG and sharing">
45
45
  </p>
46
46
 
47
- ## Install
47
+ ## Get started
48
48
 
49
- On macOS or Linux, in a terminal:
49
+ Create an account at [engenty.ai](https://engenty.ai): it is in a closed beta, so "Get started"
50
+ takes your e-mail for an invitation, or your invitation code. Signed in, the studio asks who
51
+ your team is, reads your website into your space, takes first documents, and you build your
52
+ first wizard in the browser. Links run day and night on the team's credits.
53
+
54
+ ## Install it on your computer
55
+
56
+ The same account on your own machine: it thinks with your Claude, ChatGPT, Gemini or Cursor
57
+ subscription, reads your local files and browses for you, and what you publish keeps running in
58
+ the cloud behind the same links. On macOS or Linux, in a terminal:
50
59
 
51
60
  ```bash
52
61
  curl -fsSL https://engenty.ai/install.sh | bash
53
62
  ```
54
63
 
55
- It needs nothing but `curl` and asks for no password. It brings its own Node.js (a Node you
56
- already have is left alone), installs engenty wizards into `~/.engenty/wizards` and then walks
57
- you through the rest:
64
+ It needs nothing but `curl` and `tar` and asks for no password. It brings its own Node.js (a
65
+ Node you already have is left alone), installs engenty wizards into `~/.engenty/wizards` and
66
+ then walks you through the rest:
58
67
 
68
+ - **Your engenty account.** A code and the browser for the sign-in; your team's space comes
69
+ down to this computer. "Later" skips it, `wizards account sign-in` does it any time.
70
+ `--local` leaves the account out: nothing leaves the computer, links answer on it only
71
+ ([local only](https://engenty.ai/docs/user/local-only)).
59
72
  - **Something to think with.** It looks for Codex, Claude Code, Gemini CLI and Cursor Agent and
60
73
  offers to install one if there is none — first the one whose app (Claude, ChatGPT, Cursor) is
61
74
  on your Mac, as its subscription is most likely yours already. The first page of the studio
62
- installs them too. Or use an API key or [Ollama](https://ollama.com).
75
+ installs them too. Or use your account's credits, an API key or [Ollama](https://ollama.com).
63
76
  - **Google Chrome and ffmpeg** (optional): Chrome for PDF and PNG exports and for steps that use
64
77
  a browser, ffmpeg for MP4 videos of animated widgets.
65
78
  - **Start at login** (optional): engenty wizards starts in the background when you log in (a
66
79
  LaunchAgent on a Mac, a systemd user service on Linux). Links and AI clients then always
67
80
  reach it. `wizards autostart on` or `off` switches it later.
68
81
 
69
- Then it starts and the studio opens in your browser. Later, start it with:
82
+ It starts nothing. Afterwards:
70
83
 
71
84
  ```bash
72
- wizards
85
+ wizards start
73
86
  ```
74
87
 
75
- It runs while that terminal is open; Ctrl-C stops it. `wizards status` says what is
76
- installed and what runs, `wizards setup` runs the guided setup again, and
77
- `wizards --help` lists the rest.
88
+ starts it and opens the studio; it runs while that terminal is open, Ctrl-C stops it.
89
+ `wizards` opens a menu with all of it, `wizards status` says what is installed and what runs,
90
+ `wizards setup` runs the guided setup again, and `wizards --help` lists the rest.
78
91
 
79
92
  **Install it as an app.** In Chrome or Edge the studio installs as an app: its own window, its
80
93
  own icon in the Dock or taskbar. The studio offers it in a banner at the top; or click the install
@@ -99,9 +112,10 @@ machine; a checkout brings its own sandbox, the installed version has none yet.
99
112
 
100
113
  ## First start
101
114
 
102
- The setup asks what engenty should think with. Pick an AI client that is installed on your
103
- computer — engenty uses your existing subscription and signs it in for you in a terminal right
104
- on the page — or enter your own keys. It stays until a test call works, then the studio opens.
115
+ The setup shows your account at the top and asks what engenty should think with. Pick an AI
116
+ client that is installed on your computer — engenty uses your existing subscription and signs
117
+ it in for you in a terminal right on the page — your account's credits, or enter your own keys.
118
+ It stays until a test call works, then the studio opens.
105
119
 
106
120
  Change it later under Settings.
107
121
 
@@ -6,7 +6,7 @@ import { nanoid } from "nanoid";
6
6
  import { z } from "zod";
7
7
  import { knowsTools } from "../assistant/knows.js";
8
8
  import { PRIMER } from "../assistant/primer.js";
9
- import { attachTools, costOf, gatewayTools, textModel } from "../models.js";
9
+ import { attachTools, contextWindowOf, costOf, gatewayTools, textModel, usageOf, } from "../models.js";
10
10
  import { ServiceError } from "../services/errors.js";
11
11
  import { knowledgeCounts } from "../services/knowledge.js";
12
12
  import { addProjectFileFromUrl, projectFileContent, projectFiles, updateProjectFile, } from "../services/project-files.js";
@@ -16,8 +16,12 @@ import { categoryRows } from "../services/space-categories.js";
16
16
  import { currentTenant } from "../tenants/tenant.js";
17
17
  import { assistantPageOf, assistantToolsOf } from "../tools/plugin.js";
18
18
  import { readWebsite } from "./website.js";
19
+ /** How a message written meanwhile reaches the model. */
20
+ export function signalText(taken) {
21
+ return `The person wrote this while you were working. What they asked before still stands unless this changes it:\n\n${taken.map((s) => s.message).join("\n\n")}`;
22
+ }
19
23
  const MAX_STEPS = 24;
20
- const SYSTEM = `You are the assistant of the engenty wizards studio. You stand on every page of the studio except the wizard editor, which has a chat of its own: the space's pages, the settings, the plugins' pages. You help the person who works here understand the app, set it up and find their way.
24
+ const SYSTEM = `You are Goose, the assistant of the engenty wizards studio. You stand on every page of the studio except the wizard editor, which has a chat of its own: the space's pages, the settings, the plugins' pages. You help the person who works here understand the app, set it up and find their way.
21
25
 
22
26
  You talk to the person. Answer in their language, briefly and warmly: a few sentences, a short list where it helps. Never mention JSON, ids, keys or tool names. Call it a space (German „Space“), never a project: the tools say project, the app says space.
23
27
 
@@ -641,12 +645,29 @@ function partOf(route) {
641
645
  const m = route.match(/^\/space\/(info|knowledge|data|results)/);
642
646
  return m ? m[1] : route === "/space" ? "info" : null;
643
647
  }
648
+ /** How large each tool is in the prompt: its description and its schema, in characters. */
649
+ function toolSizes(tools) {
650
+ return Object.entries(tools)
651
+ .map(([name, tool]) => {
652
+ let schema = "";
653
+ try {
654
+ schema = JSON.stringify(tool?.inputSchema ? z.toJSONSchema(tool.inputSchema, { unrepresentable: "any" }) : {});
655
+ }
656
+ catch {
657
+ // A provider's own tool (web search) has no schema of ours.
658
+ }
659
+ return { name, chars: String(tool?.description ?? "").length + schema.length };
660
+ })
661
+ .sort((a, b) => b.chars - a.chars);
662
+ }
644
663
  /** One turn of the studio assistant. */
645
664
  export async function runStudioAssistant(input) {
646
- let changed = false;
665
+ // How often the space or a plugin's data changed: a part of the answer changed something when
666
+ // this grew during it.
667
+ let changes = 0;
647
668
  const resolved = await textModel("high");
648
669
  const wrote = () => {
649
- changed = true;
670
+ changes++;
650
671
  input.onChanged?.();
651
672
  };
652
673
  const cards = [];
@@ -746,6 +767,31 @@ export async function runStudioAssistant(input) {
746
767
  ]
747
768
  .filter(Boolean)
748
769
  .join("\n\n");
770
+ input.onPrompt?.({
771
+ model: resolved.ref,
772
+ system: SYSTEM,
773
+ context,
774
+ history: history.messages.map((m) => ({ role: m.role, chars: m.content.length })),
775
+ tools: toolSizes(tools),
776
+ });
777
+ let reply = "";
778
+ // Where the part the result holds begins: a message written meanwhile cuts the answer there.
779
+ const from = { reply: 0, cards: 0, toolCalls: 0, changes: 0 };
780
+ const cut = () => {
781
+ const part = {
782
+ reply: reply.slice(from.reply).trim(),
783
+ changed: changes > from.changes,
784
+ cards: cards.slice(from.cards),
785
+ toolCalls: toolCalls.slice(from.toolCalls),
786
+ };
787
+ Object.assign(from, {
788
+ reply: reply.length,
789
+ cards: cards.length,
790
+ toolCalls: toolCalls.length,
791
+ changes,
792
+ });
793
+ return part;
794
+ };
749
795
  const stream = await agent.stream([
750
796
  ...history.messages.map((m) => m.role === "user"
751
797
  ? { role: "user", content: m.content }
@@ -755,11 +801,37 @@ export async function runStudioAssistant(input) {
755
801
  maxSteps: MAX_STEPS,
756
802
  abortSignal: input.signal,
757
803
  modelSettings: { maxOutputTokens: 8000 },
804
+ // What the person wrote meanwhile comes before the next step, after the tools' results.
805
+ prepareStep: async ({ stepNumber, messageList }) => {
806
+ const taken = stepNumber > 0 ? (input.takeSignals?.() ?? []) : [];
807
+ if (!taken.length) {
808
+ return;
809
+ }
810
+ await input.onSignal?.(taken, cut());
811
+ messageList.add({ role: "user", content: signalText(taken) }, "input");
812
+ return { messageList };
813
+ },
758
814
  });
759
- let reply = "";
815
+ const window = contextWindowOf(resolved.ref);
816
+ // A step lasts from the end of the one before – its tools' work included – to its own end.
817
+ let stepStarted = Date.now();
760
818
  for await (const chunk of stream.fullStream) {
761
819
  const payload = chunk.payload ?? chunk;
762
- if (chunk.type === "text-delta") {
820
+ if (chunk.type === "step-finish" && input.onStep) {
821
+ const usage = usageOf(payload.output?.usage);
822
+ input.onStep({
823
+ model: resolved.ref,
824
+ inputTokens: usage.inputTokens ?? 0,
825
+ outputTokens: usage.outputTokens ?? 0,
826
+ cachedInputTokens: usage.cachedInputTokens ?? 0,
827
+ ms: Date.now() - stepStarted,
828
+ costUsd: costOf(resolved, payload.output?.usage),
829
+ tools: (payload.output?.toolCalls ?? []).map((c) => c.payload?.toolName ?? c.toolName ?? ""),
830
+ window,
831
+ });
832
+ stepStarted = Date.now();
833
+ }
834
+ else if (chunk.type === "text-delta") {
763
835
  const text = payload.text ?? payload.delta ?? "";
764
836
  reply += text;
765
837
  input.onText?.(text);
@@ -780,5 +852,5 @@ export async function runStudioAssistant(input) {
780
852
  }
781
853
  }
782
854
  const usage = (await stream.totalUsage) ?? (await stream.usage);
783
- return { reply: reply.trim(), changed, cards, toolCalls, costUsd: costOf(resolved, usage) };
855
+ return { ...cut(), costUsd: costOf(resolved, usage) };
784
856
  }
@@ -0,0 +1,24 @@
1
+ const running = new Map();
2
+ export function startRun(runId, userId) {
3
+ running.set(runId, { userId, inbox: [] });
4
+ }
5
+ export function endRun(runId) {
6
+ running.delete(runId);
7
+ }
8
+ /** Puts a message into the person's running turn; false when that turn is over. */
9
+ export function signalRun(userId, runId, signal) {
10
+ const run = running.get(runId);
11
+ if (!run || run.userId !== userId) {
12
+ return false;
13
+ }
14
+ run.inbox.push(signal);
15
+ return true;
16
+ }
17
+ /** The messages that came in since the turn last looked, oldest first. */
18
+ export function takeSignals(runId) {
19
+ const run = running.get(runId);
20
+ if (!run?.inbox.length) {
21
+ return [];
22
+ }
23
+ return run.inbox.splice(0);
24
+ }
@@ -9,9 +9,11 @@ import { deleteSetting, readSetting, writeSetting } from "../settings.js";
9
9
  * browser at the Manage-App, the code comes back to this server on the loopback interface. The
10
10
  * account is optional; it unlocks credits through the gateway and publishing to the cloud.
11
11
  */
12
- const CLIENT = { id: "wizards-desktop" };
12
+ export const CLIENT = { id: "wizards-desktop" };
13
13
  const REFRESH = "account.refresh";
14
- const SCOPE = "openid profile email offline_access wizards:read wizards:write wizards:publish";
14
+ export const SCOPE = "openid profile email offline_access wizards:read wizards:write wizards:publish";
15
+ /** The team of the linked account as the Manage-App last named it, for lines that must not wait on it. */
16
+ const TEAM = "account.team";
15
17
  /** Loopback redirect on the port this server listens on (RFC 8252). */
16
18
  function redirectUri() {
17
19
  return `http://127.0.0.1:${env.port}/api/auth/callback`;
@@ -48,7 +50,11 @@ export async function startLink(options = {}) {
48
50
  return signUp.toString();
49
51
  }
50
52
  let cached = null;
51
- async function keep(tokens) {
53
+ /**
54
+ * Keeps what the token endpoint handed out: the refresh token in the vault, the account in the
55
+ * settings. The browser flow (finishLink) and the device flow (device.ts) end here alike.
56
+ */
57
+ export async function keep(tokens) {
52
58
  const claims = unverifiedClaims(tokens.access_token);
53
59
  if (!claims) {
54
60
  throw new Error("The account's token names no tenant.");
@@ -128,6 +134,25 @@ export async function unlink() {
128
134
  cached = null;
129
135
  await vaultDelete(REFRESH);
130
136
  await deleteSetting("account");
137
+ await deleteSetting(TEAM);
138
+ }
139
+ /** The team as the Manage-App last named it: `wizards status` and the menu show it without a round trip. */
140
+ export function knownTeam() {
141
+ return readSetting(TEAM);
142
+ }
143
+ let rememberedTeam = null;
144
+ async function rememberTeam(tenant) {
145
+ const same = `${tenant.id}\n${tenant.name}\n${tenant.balanceCredits}`;
146
+ if (same === rememberedTeam) {
147
+ return;
148
+ }
149
+ rememberedTeam = same;
150
+ await writeSetting(TEAM, {
151
+ tenantId: tenant.id,
152
+ name: tenant.name,
153
+ balanceCredits: tenant.balanceCredits,
154
+ at: new Date().toISOString(),
155
+ });
131
156
  }
132
157
  /** Balance and tenants of the linked account, straight from the Manage-App. */
133
158
  export async function accountOverview() {
@@ -139,5 +164,12 @@ export async function accountOverview() {
139
164
  headers: { authorization: `Bearer ${token}` },
140
165
  signal: AbortSignal.timeout(10_000),
141
166
  }).catch(() => null);
142
- return res?.ok ? (await res.json()) : null;
167
+ if (!res?.ok) {
168
+ return null;
169
+ }
170
+ const overview = (await res.json());
171
+ if (overview?.tenant) {
172
+ await rememberTeam(overview.tenant).catch(() => undefined);
173
+ }
174
+ return overview;
143
175
  }
@@ -0,0 +1,101 @@
1
+ import { env } from "../env.js";
2
+ import { discovery, ManageError, tokenRequest } from "../manage.js";
3
+ import { CLIENT, keep, SCOPE } from "./account.js";
4
+ /**
5
+ * Signing in from a terminal (RFC 8628): the Manage-App hands out a code, the person types it
6
+ * on any browser at the account's /device page and allows it, and this process asks the token
7
+ * endpoint until they did. For `wizards setup` and `wizards account sign-in`; the studio keeps
8
+ * the browser flow of account.ts. Both end in the same keep(): the refresh token in the vault,
9
+ * the account in the settings, so the runtime picks it up wherever it runs.
10
+ *
11
+ * POST <account>/api/auth/device/code client_id, scope, resource → the codes
12
+ * POST <token endpoint of the discovery> grant_type device_code, every `interval` seconds,
13
+ * until tokens come, or the code expired or was refused
14
+ */
15
+ const GRANT = "urn:ietf:params:oauth:grant-type:device_code";
16
+ /** Why a sign-in ended without an account; the message is for the person. */
17
+ export class DeviceSignInError extends Error {
18
+ reason;
19
+ constructor(reason, message) {
20
+ super(message);
21
+ this.reason = reason;
22
+ }
23
+ }
24
+ /** Asks the Manage-App for a code to sign this install in with. */
25
+ export async function requestDeviceCode() {
26
+ const base = env.local.accountUrl;
27
+ // The discovery names the endpoint when the Manage-App advertises it.
28
+ const d = (await discovery(base).catch(() => null));
29
+ const res = await fetch(d?.device_authorization_endpoint ?? `${base}/api/auth/device/code`, {
30
+ method: "POST",
31
+ headers: { "content-type": "application/x-www-form-urlencoded" },
32
+ body: new URLSearchParams({
33
+ client_id: CLIENT.id,
34
+ scope: SCOPE,
35
+ resource: env.local.cloudUrl,
36
+ }),
37
+ signal: AbortSignal.timeout(15_000),
38
+ }).catch(() => null);
39
+ if (!res) {
40
+ throw new DeviceSignInError("unreachable", `${base} is not reachable right now.`);
41
+ }
42
+ const data = (await res.json().catch(() => ({})));
43
+ if (!(res.ok && data.device_code && data.user_code && data.verification_uri)) {
44
+ throw new DeviceSignInError("refused", data.error_description ||
45
+ (data.error
46
+ ? `The account refused the sign-in (${data.error}).`
47
+ : `The account did not hand out a code (${res.status}).`));
48
+ }
49
+ return {
50
+ deviceCode: data.device_code,
51
+ userCode: data.user_code,
52
+ verificationUri: data.verification_uri,
53
+ verificationUriComplete: data.verification_uri_complete ?? null,
54
+ expiresAt: Date.now() + (data.expires_in ?? 1800) * 1000,
55
+ interval: Math.max(1, data.interval ?? 5),
56
+ };
57
+ }
58
+ const pause = (ms) => new Promise((done) => setTimeout(done, ms));
59
+ /**
60
+ * Asks the token endpoint until the person allowed the code in the browser, and keeps the
61
+ * tokens as the browser flow does. `authorization_pending` asks again after `interval`;
62
+ * `slow_down` asks five seconds later each time (RFC 8628 §3.5); a network error is tried
63
+ * again a few times. `expired_token` and `access_denied` end it.
64
+ */
65
+ export async function waitForDeviceSignIn(code, options = {}) {
66
+ const wait = options.wait ?? pause;
67
+ let interval = code.interval;
68
+ let failures = 0;
69
+ for (;;) {
70
+ await wait(interval * 1000);
71
+ if (Date.now() >= code.expiresAt) {
72
+ throw new DeviceSignInError("expired", "The code has expired. Start the sign-in again.");
73
+ }
74
+ try {
75
+ const tokens = await tokenRequest({ grant_type: GRANT, device_code: code.deviceCode, resource: env.local.cloudUrl }, CLIENT, env.local.accountUrl);
76
+ return await keep(tokens);
77
+ }
78
+ catch (err) {
79
+ if (!(err instanceof ManageError)) {
80
+ if (++failures >= 5) {
81
+ throw new DeviceSignInError("unreachable", `${env.local.accountUrl} stopped answering. Start the sign-in again.`);
82
+ }
83
+ continue;
84
+ }
85
+ failures = 0;
86
+ switch (err.code) {
87
+ case "authorization_pending":
88
+ continue;
89
+ case "slow_down":
90
+ interval += 5;
91
+ continue;
92
+ case "expired_token":
93
+ throw new DeviceSignInError("expired", "The code has expired. Start the sign-in again.");
94
+ case "access_denied":
95
+ throw new DeviceSignInError("denied", "The sign-in was refused in the browser.");
96
+ default:
97
+ throw new DeviceSignInError("refused", `The account refused the sign-in (${err.code}).`);
98
+ }
99
+ }
100
+ }
101
+ }
@@ -273,7 +273,7 @@ export const authRoutes = new Hono()
273
273
  if (!managed) {
274
274
  // The linked account of a runtime that runs alone: the system browser lands here.
275
275
  if (!code) {
276
- return c.html(closePage("Anmeldung abgebrochen. Du kannst dieses Fenster schließen."), 400);
276
+ return c.html(closePage("Sign-in cancelled. You can close this window."), 400);
277
277
  }
278
278
  try {
279
279
  await finishLink(code, rawState);
@@ -281,11 +281,11 @@ export const authRoutes = new Hono()
281
281
  void firstSync();
282
282
  // The Pro modules the account's plan includes.
283
283
  void refreshPro();
284
- return c.html(closePage("Angemeldet. Du kannst dieses Fenster schließen."));
284
+ return c.html(closePage("Signed in. You can close this window."));
285
285
  }
286
286
  catch (err) {
287
287
  console.error("[account]", err);
288
- return c.html(closePage("Anmeldung hat nicht geklappt. Bitte noch einmal versuchen."), 400);
288
+ return c.html(closePage("The sign-in did not work. Please try again."), 400);
289
289
  }
290
290
  }
291
291
  const state = unseal(getCookie(c, STATE_COOKIE) ?? "");
@@ -322,7 +322,7 @@ export const authRoutes = new Hono()
322
322
  exp: Date.now() + 2 * 60_000,
323
323
  });
324
324
  const link = `engenty-wizards://signin?code=${encodeURIComponent(code)}`;
325
- return c.html(`${closePage("Angemeldet. Kehre jetzt zu engenty wizards zurück.")}
325
+ return c.html(`${closePage("Signed in. Go back to engenty wizards now.")}
326
326
  <script>location.href = ${JSON.stringify(link)};</script>`);
327
327
  }
328
328
  setSessionCookie(c, id);