wizards 0.3.8 → 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.
- package/README.md +28 -14
- package/apps/runtime/dist/auth/account.js +36 -4
- package/apps/runtime/dist/auth/device.js +101 -0
- package/apps/runtime/dist/auth/index.js +4 -4
- package/apps/runtime/dist/cli/account.js +289 -0
- package/apps/runtime/dist/cli/install-env.js +33 -0
- package/apps/runtime/dist/cli/main.js +17 -3
- package/apps/runtime/dist/cli/menu.js +19 -5
- package/apps/runtime/dist/cli/setup.js +9 -16
- package/apps/runtime/dist/cli/status.js +4 -1
- package/apps/runtime/dist/cli/ui.js +14 -0
- package/apps/runtime/dist/docs/user/command-line.md +3 -2
- package/apps/runtime/dist/docs/user/create-account.md +47 -0
- package/apps/runtime/dist/docs/user/first-start.md +9 -1
- package/apps/runtime/dist/docs/user/index.md +16 -12
- package/apps/runtime/dist/docs/user/install.md +3 -1
- package/apps/runtime/dist/docs/user/local-only.md +38 -0
- package/apps/runtime/dist/docs/user/meta.json +3 -1
- package/apps/runtime/dist/docs/user/models.md +3 -2
- package/apps/runtime/dist/manage.js +12 -0
- package/apps/runtime/dist/plugins/pro.js +16 -16
- package/apps/runtime/dist/routes/studio.js +41 -2
- package/apps/runtime/dist/services/cloud.js +10 -10
- package/apps/runtime/dist/services/onboarding.js +152 -0
- package/apps/web/dist/assets/{AboutDialog-ClV4gNiW.js → AboutDialog-CPQiN7H9.js} +1 -1
- package/apps/web/dist/assets/{ChangelogDialog-DsSEkayM.js → ChangelogDialog-DynvQuuR.js} +1 -1
- package/apps/web/dist/assets/{ChatView-BdYCnDLk.js → ChatView-CKo_9IZD.js} +1 -1
- package/apps/web/dist/assets/{EditorPage-7PZzkNBT.js → EditorPage-CDIJXC4T.js} +1 -1
- package/apps/web/dist/assets/{FlowPreview-C_x5Kc2j.js → FlowPreview-BZLnG7GV.js} +1 -1
- package/apps/web/dist/assets/{Harness-CvvtSTZC.js → Harness-CNh3nbB_.js} +1 -1
- package/apps/web/dist/assets/{NewWizardChannels-g1qCd_tQ.js → NewWizardChannels-C1Ss9UMY.js} +1 -1
- package/apps/web/dist/assets/{NewWizardPage-B3hswj5r.js → NewWizardPage-2qLAbvDL.js} +1 -1
- package/apps/web/dist/assets/OnboardingPage-C1FJwEsT.js +1 -0
- package/apps/web/dist/assets/{ProjectPage-CFDncRxT.js → ProjectPage-CGuLUuOs.js} +1 -1
- package/apps/web/dist/assets/{PublicRunner-C9N5r-sT.js → PublicRunner-BKw1lOW4.js} +1 -1
- package/apps/web/dist/assets/{ReadOnly-C1UcqOlS.js → ReadOnly-DTjv2Jzm.js} +1 -1
- package/apps/web/dist/assets/{RunDrawer-B8YzFvgz.js → RunDrawer-D1I5xqVf.js} +1 -1
- package/apps/web/dist/assets/{RunnerView-ii-PP5c-.js → RunnerView-BPPmlv9z.js} +1 -1
- package/apps/web/dist/assets/{SettingsPage-ClEP5Q0t.js → SettingsPage-zbomQfGy.js} +1 -1
- package/apps/web/dist/assets/SetupPage-D3F7M6Pz.js +1 -0
- package/apps/web/dist/assets/{SharePage-BV2S0g84.js → SharePage-BY49IJgT.js} +1 -1
- package/apps/web/dist/assets/changelog-DYZC2E6q.js +1 -0
- package/apps/web/dist/assets/{circle-alert-D-Q6DK7b.js → circle-alert-CVOH4wAv.js} +1 -1
- package/apps/web/dist/assets/{esm-CTna4iL0.js → esm-BfXdeWaI.js} +1 -1
- package/apps/web/dist/assets/index-B9xVkr2X.css +1 -0
- package/apps/web/dist/assets/index-BH0PYgov.js +15 -0
- package/apps/web/dist/assets/{lock-DwGN_KOC.js → lock-DHBh62LA.js} +1 -1
- package/apps/web/dist/assets/{phone-XIVIe17d.js → phone-Bt1cyWpY.js} +1 -1
- package/apps/web/dist/assets/{settings-menu-JuSRq6NP.js → settings-menu-ace1vI3i.js} +1 -1
- package/apps/web/dist/assets/{ui-DrjXmHdO.js → ui-D2SqIZeQ.js} +1 -1
- package/apps/web/dist/assets/{upload-CDxTpAqx.js → upload-CCEq88l4.js} +1 -1
- package/apps/web/dist/index.html +3 -3
- package/apps/web/dist/install.sh +17 -7
- package/apps/web/dist/mcp/flow.html +2 -2
- package/package.json +1 -1
- package/apps/web/dist/assets/SetupPage-CDFzo6LT.js +0 -1
- package/apps/web/dist/assets/changelog-wZI-UaUi.js +0 -1
- package/apps/web/dist/assets/index-DUjJx-uk.css +0 -1
- package/apps/web/dist/assets/index-DnQgOKOQ.js +0 -15
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
|
-
##
|
|
47
|
+
## Get started
|
|
48
48
|
|
|
49
|
-
|
|
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
|
|
56
|
-
already have is left alone), installs engenty wizards into `~/.engenty/wizards` and
|
|
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
|
-
|
|
82
|
+
It starts nothing. Afterwards:
|
|
70
83
|
|
|
71
84
|
```bash
|
|
72
|
-
wizards
|
|
85
|
+
wizards start
|
|
73
86
|
```
|
|
74
87
|
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
103
|
-
computer — engenty uses your existing subscription and signs
|
|
104
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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);
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import { accountOverview, knownTeam, linkedAccount, unlink, } from "../auth/account.js";
|
|
3
|
+
import { DeviceSignInError, requestDeviceCode, waitForDeviceSignIn } from "../auth/device.js";
|
|
4
|
+
import { migrateControlDb } from "../db/client.js";
|
|
5
|
+
import { env } from "../env.js";
|
|
6
|
+
import { featuresFile, fileFeatures, writeFeature } from "../features.js";
|
|
7
|
+
import { command, readInstallNote, writeInstallNote } from "./home.js";
|
|
8
|
+
import { openBrowser, runningRuntime, settings } from "./start.js";
|
|
9
|
+
import { bold, busy, cyan, dim, tilde } from "./ui.js";
|
|
10
|
+
/**
|
|
11
|
+
* The account of this install, from the terminal. `wizards account` says where it stands,
|
|
12
|
+
* `sign-in` runs the device flow (auth/device.ts), `sign-out` ends the link the way the
|
|
13
|
+
* studio does. The setup's first step is the same sign-in. `--local` keeps an install off the
|
|
14
|
+
* account altogether: `account=off` in the install's `.features` hides every account surface
|
|
15
|
+
* (packages/shared/src/feature-flags.ts), and a note in install.json says so.
|
|
16
|
+
*
|
|
17
|
+
* Everything here reads and writes the install's settings and vault through the runtime's own
|
|
18
|
+
* modules, with or without a runtime running (install-env.ts puts this process where the
|
|
19
|
+
* runtime is). A runtime that runs reads the account from the settings as it goes; what it
|
|
20
|
+
* holds from its start is brought in line by a restart, which is offered.
|
|
21
|
+
*/
|
|
22
|
+
export const INTRO = "Your team, its space and your wizards live in the account. This computer joins the space.";
|
|
23
|
+
let prepared = null;
|
|
24
|
+
/** The control database as the runtime keeps it: a fresh install has none until something opens it. */
|
|
25
|
+
function ready() {
|
|
26
|
+
prepared ??= migrateControlDb();
|
|
27
|
+
return prepared;
|
|
28
|
+
}
|
|
29
|
+
/** Set up with `--local`: the install's `.features` says `account=off`. */
|
|
30
|
+
export function isLocalOnly(file = featuresFile()) {
|
|
31
|
+
return fileFeatures(file).account === false;
|
|
32
|
+
}
|
|
33
|
+
/** `--local`: the switch into the file, the note into install.json. */
|
|
34
|
+
export function localOnly(paths, file = featuresFile()) {
|
|
35
|
+
writeFeature("account", false, file);
|
|
36
|
+
writeInstallNote({ ...readInstallNote(paths), local: true }, paths);
|
|
37
|
+
}
|
|
38
|
+
/** A sign-in ends local-only: the switch follows the default again, the note goes. */
|
|
39
|
+
export function joinedAccount(paths, file = featuresFile()) {
|
|
40
|
+
if (isLocalOnly(file)) {
|
|
41
|
+
writeFeature("account", null, file);
|
|
42
|
+
}
|
|
43
|
+
const note = readInstallNote(paths);
|
|
44
|
+
if (note.local) {
|
|
45
|
+
const { local: _, ...rest } = note;
|
|
46
|
+
writeInstallNote(rest, paths);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Where the account stands, from this machine alone: no round trip. */
|
|
50
|
+
export async function accountState() {
|
|
51
|
+
if (isLocalOnly()) {
|
|
52
|
+
return { kind: "local" };
|
|
53
|
+
}
|
|
54
|
+
await ready();
|
|
55
|
+
const account = await linkedAccount();
|
|
56
|
+
if (!account) {
|
|
57
|
+
return { kind: "none" };
|
|
58
|
+
}
|
|
59
|
+
return { kind: "linked", account, team: await knownTeam() };
|
|
60
|
+
}
|
|
61
|
+
/** "Matthias · team Platzer Fenster & Türen · 120 credits", as far as it is known. */
|
|
62
|
+
export function described(account, team, credits = true) {
|
|
63
|
+
const parts = [account.name || account.email];
|
|
64
|
+
if (team) {
|
|
65
|
+
parts.push(`team ${team.name}`);
|
|
66
|
+
if (credits) {
|
|
67
|
+
parts.push(`${team.balanceCredits} credits`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return parts.join(" · ");
|
|
71
|
+
}
|
|
72
|
+
/** One line for the menu and `wizards status`, after the symbol. */
|
|
73
|
+
export function describeAccount(state) {
|
|
74
|
+
switch (state.kind) {
|
|
75
|
+
case "local":
|
|
76
|
+
return "local only";
|
|
77
|
+
case "none":
|
|
78
|
+
return "not signed in";
|
|
79
|
+
default:
|
|
80
|
+
return described(state.account, state.team, false);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** The account's site, as people know it: engenty.ai for account.engenty.ai. */
|
|
84
|
+
const host = () => new URL(env.local.accountUrl).host.replace(/^account\./, "");
|
|
85
|
+
/** `wizards account`: where it stands, with the Manage-App's word when it answers. */
|
|
86
|
+
async function status() {
|
|
87
|
+
const state = await accountState();
|
|
88
|
+
if (state.kind === "local") {
|
|
89
|
+
console.log(`Local only (no account). ${dim(`\`${command()} account sign-in\` connects one.`)}`);
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
if (state.kind === "none") {
|
|
93
|
+
console.log(`Not signed in. ${dim(`\`${command()} account sign-in\``)}`);
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
const overview = await accountOverview();
|
|
97
|
+
const team = overview?.tenant ?? state.team;
|
|
98
|
+
console.log(`Signed in as ${described(state.account, team)} · ${host()}${overview ? "" : dim(` (${host()} did not answer${team ? "; this is from last time" : ""})`)}`);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The device flow on the terminal: the link and the code, the browser opened where there is
|
|
103
|
+
* one, then a wait while the person allows it. Null when it ended without an account.
|
|
104
|
+
*/
|
|
105
|
+
async function deviceSignIn() {
|
|
106
|
+
let code;
|
|
107
|
+
try {
|
|
108
|
+
code = await requestDeviceCode();
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
p.log.error(err.message);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const link = code.verificationUriComplete ?? code.verificationUri;
|
|
115
|
+
p.log.message(`Open ${cyan(link)} and type ${bold(code.userCode)}`);
|
|
116
|
+
if (openBrowser(link)) {
|
|
117
|
+
p.log.message(dim("The page opens in your browser."));
|
|
118
|
+
}
|
|
119
|
+
const spinner = busy("Waiting for you to allow it in the browser");
|
|
120
|
+
try {
|
|
121
|
+
const account = await waitForDeviceSignIn(code);
|
|
122
|
+
const overview = await accountOverview();
|
|
123
|
+
spinner.stop(`Signed in as ${described(account, overview?.tenant ?? null)}`);
|
|
124
|
+
return account;
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
spinner.fail(err instanceof DeviceSignInError
|
|
128
|
+
? err.message
|
|
129
|
+
: `The sign-in did not work. ${err.message}`);
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* A runtime that runs reads the account from the settings as it goes, but holds a few things
|
|
135
|
+
* from its start: the Pro modules it loaded, which source the models run on, what was sent to
|
|
136
|
+
* the cloud. A restart brings it in line; the person is asked, or told how.
|
|
137
|
+
*/
|
|
138
|
+
async function pickUp(paths, interactive, why) {
|
|
139
|
+
if (!(await runningRuntime(settings(paths).dataDir))) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const how = `${command()} restart`;
|
|
143
|
+
if (!interactive) {
|
|
144
|
+
p.log.info(`The studio that runs ${why} after ${cyan(how)}.`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const answer = await p.confirm({
|
|
148
|
+
message: `Restart the studio that runs, so it ${why}?`,
|
|
149
|
+
initialValue: true,
|
|
150
|
+
});
|
|
151
|
+
if (p.isCancel(answer) || !answer) {
|
|
152
|
+
p.log.info(dim(`Later: ${how}`));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// status.ts shows the account; loaded here when needed, so neither module waits on the other.
|
|
156
|
+
const { restart } = await import("./status.js");
|
|
157
|
+
await restart(paths);
|
|
158
|
+
}
|
|
159
|
+
const SIGNED_IN = "fetches the Pro modules of your plan and sends what is published to the cloud";
|
|
160
|
+
async function signIn(paths, interactive) {
|
|
161
|
+
await ready();
|
|
162
|
+
const linked = await linkedAccount();
|
|
163
|
+
if (linked) {
|
|
164
|
+
p.log.info(`Already signed in as ${described(linked, await knownTeam(), false)}. ${dim(`To switch: \`${command()} account sign-out\` first.`)}`);
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
const account = await deviceSignIn();
|
|
168
|
+
if (!account) {
|
|
169
|
+
return 1;
|
|
170
|
+
}
|
|
171
|
+
joinedAccount(paths);
|
|
172
|
+
p.log.message(dim("What you publish here runs in the cloud too."));
|
|
173
|
+
await pickUp(paths, interactive, SIGNED_IN);
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
async function signOut(paths, interactive) {
|
|
177
|
+
const state = await accountState();
|
|
178
|
+
if (state.kind !== "linked") {
|
|
179
|
+
console.log(state.kind === "local" ? "Local only: there is no account to sign out." : "Not signed in.");
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
const spinner = busy(`Signing out ${state.account.name || state.account.email}`);
|
|
183
|
+
await unlink();
|
|
184
|
+
// The same as the studio's sign-out (DELETE /api/studio/account): what was sent to the cloud
|
|
185
|
+
// is forgotten, the entitlement goes with the account's plan, and models that ran on its
|
|
186
|
+
// credits run on own keys again. This process never loaded Pro modules or models, so only the
|
|
187
|
+
// settings change here; a runtime that runs lets go of its modules on restart.
|
|
188
|
+
const [{ forgetCloud }, { refreshPro }, models] = await Promise.all([
|
|
189
|
+
import("../services/cloud.js"),
|
|
190
|
+
import("../plugins/pro.js"),
|
|
191
|
+
import("../models.js"),
|
|
192
|
+
]);
|
|
193
|
+
await forgetCloud();
|
|
194
|
+
await refreshPro();
|
|
195
|
+
await models.loadLocalModels();
|
|
196
|
+
if (models.localModelSettings().source === "account") {
|
|
197
|
+
await models.saveLocalModels({ source: "own" });
|
|
198
|
+
}
|
|
199
|
+
spinner.stop("Signed out. Your wizards and results stay on this computer.");
|
|
200
|
+
await pickUp(paths, interactive, "lets go of the account");
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
/** `wizards account [sign-in|sign-out]`. */
|
|
204
|
+
export async function accountCommand(paths, value, options) {
|
|
205
|
+
const interactive = !options.yes && Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
206
|
+
switch (value) {
|
|
207
|
+
case "":
|
|
208
|
+
return status();
|
|
209
|
+
case "sign-in":
|
|
210
|
+
case "signin":
|
|
211
|
+
case "login":
|
|
212
|
+
return signIn(paths, interactive);
|
|
213
|
+
case "sign-out":
|
|
214
|
+
case "signout":
|
|
215
|
+
case "logout":
|
|
216
|
+
return signOut(paths, interactive);
|
|
217
|
+
default:
|
|
218
|
+
console.error(`${command()} account [sign-in|sign-out]`);
|
|
219
|
+
return 2;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** The menu's "Account": where it stands, and the way in or out. */
|
|
223
|
+
export async function accountMenu(paths) {
|
|
224
|
+
await status();
|
|
225
|
+
const state = await accountState();
|
|
226
|
+
const action = await p.select({
|
|
227
|
+
message: "Account",
|
|
228
|
+
options: [
|
|
229
|
+
state.kind === "linked"
|
|
230
|
+
? { value: "sign-out", label: "Sign out", hint: "your wizards and results stay here" }
|
|
231
|
+
: { value: "sign-in", label: "Sign in", hint: "with a code, in your browser" },
|
|
232
|
+
{ value: "back", label: "Back" },
|
|
233
|
+
],
|
|
234
|
+
});
|
|
235
|
+
if (p.isCancel(action) || action === "back") {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (action === "sign-in") {
|
|
239
|
+
await signIn(paths, true);
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
await signOut(paths, true);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The setup's first step. `--local` keeps the install off the account; else the person is
|
|
247
|
+
* asked to sign in, unless the setup runs without a terminal (`--yes`), which says how to do
|
|
248
|
+
* it later.
|
|
249
|
+
*/
|
|
250
|
+
export async function accountStep(paths, options, interactive) {
|
|
251
|
+
if (options.local) {
|
|
252
|
+
localOnly(paths);
|
|
253
|
+
p.log.warn(`Local only. No account, nothing leaves this computer. Wizards, the space and results stay in ${tilde(settings(paths).dataDir)}. Links answer on this computer only.`);
|
|
254
|
+
p.log.message(dim(`Connect an account any time: ${command()} account`));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const state = await accountState();
|
|
258
|
+
if (state.kind === "local") {
|
|
259
|
+
p.log.info(`Local only (no account). ${dim(`\`${command()} account sign-in\` connects one.`)}`);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (state.kind === "linked") {
|
|
263
|
+
const overview = await accountOverview();
|
|
264
|
+
p.log.success(`Signed in as ${described(state.account, overview?.tenant ?? state.team)}`);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
p.note(INTRO, "Your engenty account");
|
|
268
|
+
if (!interactive) {
|
|
269
|
+
p.log.info(dim(`Sign in later: ${command()} account sign-in`));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const answer = await p.confirm({ message: "Sign in now?", initialValue: true });
|
|
273
|
+
if (p.isCancel(answer)) {
|
|
274
|
+
p.cancel(`Setup stopped. Run it again with \`${command()} setup\`.`);
|
|
275
|
+
return "cancelled";
|
|
276
|
+
}
|
|
277
|
+
if (!answer) {
|
|
278
|
+
p.log.info(dim(`Later: ${command()} account`));
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const account = await deviceSignIn();
|
|
282
|
+
if (!account) {
|
|
283
|
+
p.log.info(dim(`Later: ${command()} account sign-in`));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
joinedAccount(paths);
|
|
287
|
+
p.log.message(dim("What you publish here runs in the cloud too."));
|
|
288
|
+
await pickUp(paths, interactive, SIGNED_IN);
|
|
289
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { layout, readEnvFile } from "./home.js";
|
|
4
|
+
/**
|
|
5
|
+
* Imported first by the command: before the runtime's modules load, this process sees the
|
|
6
|
+
* install the way its runtime does. env.ts reads the process once, when it is first imported,
|
|
7
|
+
* and `.env` from the working directory; the runtime starts in the install's folder (start.ts)
|
|
8
|
+
* and so reads the install's `.env`, with its data folder beside it. The command goes there
|
|
9
|
+
* too — else `wizards account` or `wizards status` from another folder would open a `data/`
|
|
10
|
+
* of its own there, with a secret of its own, and a vault the runtime cannot read.
|
|
11
|
+
*
|
|
12
|
+
* What the terminal exports for other projects never reaches the runtime (environment.ts); a
|
|
13
|
+
* few keys that would make this process another runtime are taken from the install's `.env`
|
|
14
|
+
* alone. The rest the runtime inherits, so the terminal's word stands.
|
|
15
|
+
*/
|
|
16
|
+
const paths = layout();
|
|
17
|
+
const fileEnv = readEnvFile(paths.envFile);
|
|
18
|
+
/** The install's data folder, as start.ts hands it to the runtime: the terminal's word first. */
|
|
19
|
+
const given = process.env.DATA_DIR?.trim() || fileEnv.DATA_DIR?.trim();
|
|
20
|
+
process.env.DATA_DIR = given ? resolve(paths.home, given) : paths.data;
|
|
21
|
+
for (const key of ["MANAGE_URL", "APP_SECRET", "DATABASE_URL", "DATABASE_AUTH_TOKEN"]) {
|
|
22
|
+
process.env[key] = fileEnv[key] ?? "";
|
|
23
|
+
}
|
|
24
|
+
// Keys and the account's sign-in live in the Keychain on a Mac, as the runtime keeps them.
|
|
25
|
+
if (process.platform === "darwin" &&
|
|
26
|
+
process.env.SECRETS === undefined &&
|
|
27
|
+
fileEnv.SECRETS === undefined) {
|
|
28
|
+
process.env.SECRETS = "keychain";
|
|
29
|
+
}
|
|
30
|
+
/** Where the command was typed: `wizards validate ./my.wizard` means a path from there. */
|
|
31
|
+
export const startedIn = process.cwd();
|
|
32
|
+
mkdirSync(paths.home, { recursive: true });
|
|
33
|
+
process.chdir(paths.home);
|