@getnoan/wizard 0.1.5 → 0.1.6
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 +21 -3
- package/package.json +1 -1
- package/src/args.mjs +11 -2
- package/src/report.mjs +7 -0
- package/src/run.mjs +81 -3
- package/src/web.mjs +169 -0
package/README.md
CHANGED
|
@@ -8,7 +8,8 @@ npx -y @getnoan/wizard@latest
|
|
|
8
8
|
|
|
9
9
|
It takes your NOAN API key, points your coding assistants at the NOAN MCP server, installs the
|
|
10
10
|
NOAN skill, checks whether your fact layer has anything in it, and, if you want, sets up the six
|
|
11
|
-
open-source agents on your own GitHub account
|
|
11
|
+
open-source agents on your own GitHub account and gets the two web agents — booking pages and a
|
|
12
|
+
site chat — ready to deploy. Then it tells you what it did and what to do next.
|
|
12
13
|
Run it again any time; every write is a merge, and nothing you have is overwritten.
|
|
13
14
|
|
|
14
15
|
## What it does
|
|
@@ -41,7 +42,20 @@ Run it again any time; every write is a merge, and nothing you have is overwritt
|
|
|
41
42
|
running — `https://openrouter.ai/api`, say, or your own gateway — and the wizard verifies your key
|
|
42
43
|
against *that* endpoint, stores it as `LLM_API_KEY`, and records the endpoint as a repository
|
|
43
44
|
variable so the fork actually uses it. Unset, nothing changes: it asks for an Anthropic key.
|
|
44
|
-
6. **The
|
|
45
|
+
6. **The web agents** (optional, `--meetings`, `--chat`). Two agents run as always-on services on
|
|
46
|
+
your own domain rather than as scheduled jobs, so they live in their own repos:
|
|
47
|
+
[Verity Meetings](https://github.com/getnoan/verity-meetings) (booking pages) and
|
|
48
|
+
[Verity Chat](https://github.com/getnoan/verity-chat) (a chat widget for your website). For each
|
|
49
|
+
one you pick, the wizard forks the repo to your account, runs its seed scripts so its starting
|
|
50
|
+
instructions are in your workspace (in a stack of their own, apart from any other agent's), boots
|
|
51
|
+
it locally with no keys to prove it works, and writes a gitignored `.env` in the clone with the
|
|
52
|
+
block slugs. Deploying needs accounts only you can create — a host, a Supabase project, and for
|
|
53
|
+
Meetings, Google Calendar credentials — so the report hands your assistant the rest of the repo's
|
|
54
|
+
`INSTALL.md` and a one-click Render deploy link.
|
|
55
|
+
|
|
56
|
+
Your personal NOAN key is **not** written into either service's `.env`: these answer the open
|
|
57
|
+
internet, so each gets a NOAN key made for it alone. The report says so.
|
|
58
|
+
7. **The report.** What happened, and the one thing to do next.
|
|
45
59
|
|
|
46
60
|
## For a coding assistant
|
|
47
61
|
|
|
@@ -55,7 +69,9 @@ NOAN_API_KEY=npak_… npx -y @getnoan/wizard@latest --yes --json
|
|
|
55
69
|
(progress goes to stderr) with a `next` list an agent can act on, including the first-connect
|
|
56
70
|
hand-off when the workspace is empty. `--agents` adds the pack step; it reads `ANTHROPIC_API_KEY`,
|
|
57
71
|
`RESEND_API_KEY`, `DATABASE_URL`, `FIRECRAWL_API_KEY`, `MAIL_FROM`, `REPLY_TO` and `ESCALATE_TO`
|
|
58
|
-
from the environment and skips what is missing, saying so.
|
|
72
|
+
from the environment and skips what is missing, saying so. `--meetings` and `--chat` add the web
|
|
73
|
+
agents; the chat reads its model key from the same variables as the pack (or reuses the pack
|
|
74
|
+
step's in the same run), and a `next` item per service carries the deploy hand-off.
|
|
59
75
|
|
|
60
76
|
Exit codes: 0 done, 2 the key did not work, 3 cancelled, 1 anything else.
|
|
61
77
|
|
|
@@ -69,6 +85,8 @@ Exit codes: 0 done, 2 the key did not work, 3 cancelled, 1 anything else.
|
|
|
69
85
|
| `--global` | user-scope client config instead of project scope |
|
|
70
86
|
| `--dir <path>` | the project directory (default: current) |
|
|
71
87
|
| `--agents` / `--no-agents` | include or skip the agent pack step |
|
|
88
|
+
| `--meetings` / `--no-meetings` | include or skip Verity Meetings (booking pages) |
|
|
89
|
+
| `--chat` / `--no-chat` | include or skip Verity Chat (a chat widget for your site) |
|
|
72
90
|
| `--no-mcp`, `--no-skill` | skip a step |
|
|
73
91
|
| `--dry-run` | show every write without making it |
|
|
74
92
|
| `--no-telemetry` | send nothing about this run (also `NOAN_WIZARD_NO_TELEMETRY`, `DO_NOT_TRACK`, or any `CI`) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getnoan/wizard",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "One command from nothing to a grounded agent: sign in to NOAN, wire your coding assistant, install the skill, and set up the agent pack.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/args.mjs
CHANGED
|
@@ -11,7 +11,10 @@ What it does, in order:
|
|
|
11
11
|
4. Workspace checks whether your fact layer has anything in it; if not, hands the
|
|
12
12
|
seeding to your assistant (it has the procedure) rather than guessing
|
|
13
13
|
5. Agents optionally sets up the open-source agent pack on your GitHub account
|
|
14
|
-
6.
|
|
14
|
+
6. Web optionally forks Verity Meetings (booking pages) and Verity Chat (a chat
|
|
15
|
+
widget for your site), seeds their facts, proves each boots locally, and
|
|
16
|
+
hands the deploy to your assistant
|
|
17
|
+
7. Report says what it did, and what to do next
|
|
15
18
|
|
|
16
19
|
Options:
|
|
17
20
|
-y, --yes non-interactive: take every default, never prompt (for an agent mid-session)
|
|
@@ -21,6 +24,8 @@ Options:
|
|
|
21
24
|
instead of project scope (.mcp.json, .cursor/mcp.json, .vscode/mcp.json)
|
|
22
25
|
--dir <path> the project directory (default: the current directory)
|
|
23
26
|
--agents also set up the agent pack (default: ask; with --yes: skip unless given)
|
|
27
|
+
--meetings also set up Verity Meetings, booking pages on your own domain (same default)
|
|
28
|
+
--chat also set up Verity Chat, a chat widget for your website (same default)
|
|
24
29
|
--no-mcp skip the client wiring
|
|
25
30
|
--no-skill skip the skill install
|
|
26
31
|
--dry-run show every write without making it
|
|
@@ -32,7 +37,7 @@ Exit codes: 0 done · 2 the key did not work · 3 cancelled · 1 anything else
|
|
|
32
37
|
`;
|
|
33
38
|
|
|
34
39
|
export function parseArgs(argv) {
|
|
35
|
-
const a = { yes: false, json: false, apiKey: null, global: false, dir: process.cwd(), agents: null,
|
|
40
|
+
const a = { yes: false, json: false, apiKey: null, global: false, dir: process.cwd(), agents: null, meetings: null, chat: null,
|
|
36
41
|
mcp: true, skill: true, dryRun: false, telemetry: true, help: false, version: false, unknown: [] };
|
|
37
42
|
for (let i = 0; i < argv.length; i++) {
|
|
38
43
|
const t = argv[i];
|
|
@@ -46,6 +51,10 @@ export function parseArgs(argv) {
|
|
|
46
51
|
else if (t.startsWith("--dir=")) a.dir = t.slice(6);
|
|
47
52
|
else if (t === "--agents") a.agents = true;
|
|
48
53
|
else if (t === "--no-agents") a.agents = false;
|
|
54
|
+
else if (t === "--meetings") a.meetings = true;
|
|
55
|
+
else if (t === "--no-meetings") a.meetings = false;
|
|
56
|
+
else if (t === "--chat") a.chat = true;
|
|
57
|
+
else if (t === "--no-chat") a.chat = false;
|
|
49
58
|
else if (t === "--no-mcp") a.mcp = false;
|
|
50
59
|
else if (t === "--no-skill") a.skill = false;
|
|
51
60
|
else if (t === "--dry-run") a.dryRun = true;
|
package/src/report.mjs
CHANGED
|
@@ -15,6 +15,13 @@ export function renderReport(report, { json = false } = {}) {
|
|
|
15
15
|
if (s.skill?.rows) L.push(`skill: ${s.skill.rows.map(r => `${r.id} (${r.written} written)`).join(" · ")}; pointers: ${s.skill.pointers.map(p => p.action).join(", ")}`);
|
|
16
16
|
if (s.workspace) L.push(`facts: ${s.workspace.count ?? "?"} (${s.workspace.state})`);
|
|
17
17
|
if (s.agents && !s.agents.skipped) L.push(s.agents.ok ? `agents: ${s.agents.repo || s.agents.dest} — ${s.agents.agentName || "your agent"}, ${s.agents.secrets.length} secrets, ${s.agents.variables.length} variables, safe mode on` : `agents: not set up (${s.agents.reason})`);
|
|
18
|
+
if (s.web && !s.web.skipped) for (const [id, w] of Object.entries(s.web)) {
|
|
19
|
+
if (!w.ok) { L.push(`${id}: not set up (${w.reason})`); continue; }
|
|
20
|
+
if (report.dryRun) { L.push(`${id}: would fork to ${w.dest}, run ${w.seeds.length} seed(s), boot it locally and write its .env${w.missing?.length ? ` (still needs ${w.missing.join(", ")})` : ""}`); continue; }
|
|
21
|
+
const seeded = w.seeds.filter(r => r.action === "ran").length;
|
|
22
|
+
const local = w.local?.ok ? "boots locally" : w.local?.ok === false ? `local boot failed (${w.local.reason})` : "local boot not tried";
|
|
23
|
+
L.push(`${id}: ${w.repo || w.dest} — ${seeded}/${w.seeds.length} seeds ran, ${local}, .env written${w.missing?.length ? ` (still needs ${w.missing.join(", ")})` : ""}${w.deployUrl ? `, deploy: ${w.deployUrl}` : ""}`);
|
|
24
|
+
}
|
|
18
25
|
if (report.next?.length) { L.push("", "──────── next ────────"); for (const n of report.next) L.push(`• ${n.say}${n.why ? ` (${n.why})` : ""}`); }
|
|
19
26
|
L.push("");
|
|
20
27
|
return L.join("\n");
|
package/src/run.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { writeEnv, ensureGitignored } from "./env-file.mjs";
|
|
|
7
7
|
import { wireClients, MANUAL_CLIENTS } from "./clients.mjs";
|
|
8
8
|
import { fetchSkillFiles, skillTargets, installSkill, writePointers } from "./skill.mjs";
|
|
9
9
|
import * as pack from "./agents.mjs";
|
|
10
|
+
import * as web from "./web.mjs";
|
|
10
11
|
import { capture } from "./telemetry.mjs";
|
|
11
12
|
import { renderReport } from "./report.mjs";
|
|
12
13
|
|
|
@@ -86,17 +87,36 @@ export async function run(args) {
|
|
|
86
87
|
ui.step(5, "The agent pack");
|
|
87
88
|
let wantAgents = args.agents;
|
|
88
89
|
if (wantAgents == null) wantAgents = ui.interactive ? await ui.confirm("Set up the six open-source agents on your GitHub account too?", false) : false;
|
|
89
|
-
|
|
90
|
+
// Carries the model key and the agent's name from the pack step to the web step, so a run that
|
|
91
|
+
// does both asks once. Never put in the report: the report is printed, and --json is parsed.
|
|
92
|
+
const shared = {};
|
|
93
|
+
if (wantAgents) report.steps.agents = await setupAgents({ args, ui, dir, key, me, report, shared });
|
|
90
94
|
else { ui.info("skipped" + (args.yes && args.agents == null ? " (pass --agents to include it)" : "")); report.steps.agents = { skipped: true }; }
|
|
91
95
|
|
|
92
|
-
/* 6.
|
|
96
|
+
/* 6. web agents */
|
|
97
|
+
ui.step(6, "The web agents");
|
|
98
|
+
const wantWeb = [];
|
|
99
|
+
for (const [id, agent] of Object.entries(web.WEB_AGENTS)) {
|
|
100
|
+
let want = args[id];
|
|
101
|
+
if (want == null) want = ui.interactive ? await ui.confirm(`Set up ${agent.label} — ${agent.what}?`, false) : false;
|
|
102
|
+
if (want) wantWeb.push(id);
|
|
103
|
+
}
|
|
104
|
+
if (!wantWeb.length) {
|
|
105
|
+
ui.info("skipped" + (args.yes && args.meetings == null && args.chat == null ? " (pass --meetings and/or --chat to include them)" : ""));
|
|
106
|
+
report.steps.web = { skipped: true };
|
|
107
|
+
} else {
|
|
108
|
+
report.steps.web = {};
|
|
109
|
+
for (const id of wantWeb) report.steps.web[id] = await setupWeb({ id, args, ui, dir, key, me, report, shared });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* 7. report */
|
|
93
113
|
report.next.push({ id: "open", say: `Open ${APP_URL} to see the workspace your assistant is grounded in.` });
|
|
94
114
|
return finish(0);
|
|
95
115
|
}
|
|
96
116
|
|
|
97
117
|
async function ask(ui, q, o) { return ui.ask(q, o); }
|
|
98
118
|
|
|
99
|
-
async function setupAgents({ args, ui, dir, key, me, report }) {
|
|
119
|
+
async function setupAgents({ args, ui, dir, key, me, report, shared = {} }) {
|
|
100
120
|
const ready = pack.ghReady();
|
|
101
121
|
if (!ready.ok) { ui.warn(ready.reason); return { ok: false, reason: ready.reason }; }
|
|
102
122
|
const fork = pack.forkAndClone(dir, { dryRun: args.dryRun });
|
|
@@ -196,5 +216,63 @@ async function setupAgents({ args, ui, dir, key, me, report }) {
|
|
|
196
216
|
}
|
|
197
217
|
out.repo = pack.repoSlug(fork.dest);
|
|
198
218
|
out.agentName = identity.AGENT_NAME;
|
|
219
|
+
Object.assign(shared, { modelKey: anthropic, modelKeyName: mName, modelBase: mBase, agentName: identity.AGENT_NAME });
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** One web agent: fork, seed, prove it boots, write its .env, hand the deploy to the assistant.
|
|
224
|
+
* What it returns goes in the report, so it carries names and outcomes — never a key. */
|
|
225
|
+
async function setupWeb({ id, args, ui, dir, key, me, report, shared }) {
|
|
226
|
+
const agent = web.WEB_AGENTS[id];
|
|
227
|
+
ui.info(`${agent.label}:`);
|
|
228
|
+
const ready = pack.ghReady();
|
|
229
|
+
if (!ready.ok) { ui.warn(ready.reason); return { ok: false, reason: ready.reason }; }
|
|
230
|
+
const fork = web.forkAndCloneWeb(dir, agent, { dryRun: args.dryRun });
|
|
231
|
+
ui.ok(`${fork.action}: ${fork.dest}${fork.error ? ` — ${fork.error}` : ""}`);
|
|
232
|
+
if (fork.error) return { ok: false, reason: fork.error };
|
|
233
|
+
const out = { ok: true, dest: fork.dest, seeds: [], skipped: [] };
|
|
234
|
+
|
|
235
|
+
// Seeds run under the person's own key: setting up their workspace is theirs to do.
|
|
236
|
+
const seeds = web.runWebSeeds(fork.dest, agent, { NOAN_PERSONAL_API_KEY: key }, { dryRun: args.dryRun });
|
|
237
|
+
out.seeds = seeds.rows;
|
|
238
|
+
for (const r of seeds.rows) (r.action === "failed" || r.action === "missing" ? ui.warn : ui.ok)(`${r.seed}: ${r.action}${r.slugs != null ? ` (${r.slugs} slug(s))` : ""}${r.error ? ` — ${r.error}` : ""}`);
|
|
239
|
+
|
|
240
|
+
// INSTALL.md step 0, for them: the service boots with no keys at all.
|
|
241
|
+
if (args.dryRun) out.local = { ok: null, reason: "dry run" };
|
|
242
|
+
else if (existsSync(fork.dest)) {
|
|
243
|
+
out.local = await web.proveLocally(fork.dest, agent);
|
|
244
|
+
if (out.local.ok) ui.ok(`boots locally with no keys (${out.local.probes.join(", ")} answered)`);
|
|
245
|
+
else ui.warn(`did not boot locally: ${out.local.reason} — INSTALL.md step 0 runs the same thing by hand`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// The chat needs a model key; reuse the pack step's if this run had one, else env, else ask.
|
|
249
|
+
let modelKey = null, modelKeyName = "ANTHROPIC_API_KEY", modelBase = "";
|
|
250
|
+
if (agent.needsModelKey) {
|
|
251
|
+
if (shared.modelKey) ({ modelKey, modelKeyName, modelBase } = shared);
|
|
252
|
+
else {
|
|
253
|
+
try { modelBase = pack.modelBase(); } catch (e) { ui.warn(e.message); out.skipped.push({ name: "model key", why: e.message }); modelBase = null; }
|
|
254
|
+
if (modelBase !== null) {
|
|
255
|
+
modelKeyName = pack.modelKeyName();
|
|
256
|
+
modelKey = (process.env[modelKeyName] || process.env[modelKeyName === "LLM_API_KEY" ? "ANTHROPIC_API_KEY" : "LLM_API_KEY"] || "").trim()
|
|
257
|
+
|| (ui.interactive ? await ui.ask(`API key for ${modelBase ? new URL(modelBase).host : "Anthropic"} (the chat's model; blank to add it later)`, { secret: true }) : "");
|
|
258
|
+
if (modelKey) {
|
|
259
|
+
const verdict = await pack.verifyModelKey(modelKey, modelBase);
|
|
260
|
+
if (verdict === false) { ui.warn(`${modelKeyName} was not accepted by its service; not written`); out.skipped.push({ name: modelKeyName, why: "rejected" }); modelKey = null; }
|
|
261
|
+
} else out.skipped.push({ name: modelKeyName, why: "not provided" });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const agentName = shared.agentName || pack.agentIdentity({ name: process.env.AGENT_NAME }).AGENT_NAME;
|
|
267
|
+
const pairs = web.webEnv(agent, { slugs: seeds.slugs, company: me.project?.name || "", agentName, modelKey, modelKeyName, modelBase: modelBase || "" });
|
|
268
|
+
const env = web.writeWebEnv(fork.dest, pairs, { dryRun: args.dryRun });
|
|
269
|
+
ui.ok(`${args.dryRun ? "would write" : env.created ? "wrote" : env.changed ? "updated" : "kept"} ${env.path} (${env.keys.join(", ") || "nothing new"}); .gitignore: ${env.gitignore}`);
|
|
270
|
+
out.env = { path: env.path, keys: env.keys, gitignore: env.gitignore };
|
|
271
|
+
const missingModelKey = agent.needsModelKey && !modelKey ? modelKeyName : null;
|
|
272
|
+
if (missingModelKey) { ui.warn(`no ${missingModelKey} written: the chat cannot answer until it has one`); out.missing = [missingModelKey]; }
|
|
273
|
+
|
|
274
|
+
out.repo = args.dryRun ? null : pack.repoSlug(fork.dest);
|
|
275
|
+
out.deployUrl = web.renderDeployUrl(out.repo);
|
|
276
|
+
report.next.push(web.webNext(agent, { dest: fork.dest, deployUrl: out.deployUrl, missingModelKey }));
|
|
199
277
|
return out;
|
|
200
278
|
}
|
package/src/web.mjs
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The web-facing agents: getnoan/verity-meetings (booking pages) and getnoan/verity-chat (a chat
|
|
3
|
+
* widget for your site). They are always-on services on the user's own domain, not scheduled
|
|
4
|
+
* jobs, so they live in their own repos and deploy to a host (Render by default), not to Actions.
|
|
5
|
+
*
|
|
6
|
+
* The wizard does the part that needs no account the user has not already given it:
|
|
7
|
+
* fork and clone → seed the service's facts (under the user's own key, a human action) →
|
|
8
|
+
* prove the service boots locally with NO keys → write a gitignored .env in the clone with what
|
|
9
|
+
* is known → hand the rest to the coding assistant, INSTALL.md's remaining steps plus a Render
|
|
10
|
+
* deploy link.
|
|
11
|
+
* The rest — a host account, a Supabase project, Google calendar credentials, DNS, the embed tag on
|
|
12
|
+
* their site — needs the person, and INSTALL.md marks each of those HUMAN.
|
|
13
|
+
*
|
|
14
|
+
* The personal NOAN key is NOT written into the service's .env. That key reaches the whole
|
|
15
|
+
* workspace, and these services answer the open internet; each gets a key made for it, and the
|
|
16
|
+
* hand-off says so. The seeds are different: seeding is a person setting up their own workspace.
|
|
17
|
+
*/
|
|
18
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
|
+
import { createServer } from "node:net";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { gh, parseSeedOutput, randomSecret } from "./agents.mjs";
|
|
23
|
+
import { writeEnv, ensureGitignored } from "./env-file.mjs";
|
|
24
|
+
|
|
25
|
+
/** One entry per web agent. `boot` is INSTALL.md step 0 — the no-keys local run — and `probe` is
|
|
26
|
+
* what proves it came up; both are the repos' own CI checks, so a green CI there means this works. */
|
|
27
|
+
export const WEB_AGENTS = {
|
|
28
|
+
meetings: {
|
|
29
|
+
label: "Verity Meetings",
|
|
30
|
+
repo: "getnoan/verity-meetings",
|
|
31
|
+
dirName: "verity-meetings",
|
|
32
|
+
what: "booking pages on your own domain",
|
|
33
|
+
seeds: ["agents/seed-scheduling.mjs", "agents/seed-booking.mjs"],
|
|
34
|
+
boot: (port) => ({
|
|
35
|
+
args: ["booking/server.mjs"],
|
|
36
|
+
env: { BOOKING_ENABLED: "1", BOOKING_STORE: "memory", BOOKING_DRY_RUN: "1",
|
|
37
|
+
BOOKING_HOSTS: "founder@example.com,teammate@example.com",
|
|
38
|
+
BOOKING_PUBLIC_URL: `http://127.0.0.1:${port}`, PORT: String(port) },
|
|
39
|
+
}),
|
|
40
|
+
probe: ["/healthz", "/alex"],
|
|
41
|
+
needsModelKey: false,
|
|
42
|
+
// INSTALL.md steps left after the wizard: it has seeded (step 1's second half).
|
|
43
|
+
remaining: "step 2 (Google calendar credentials), step 3 (Supabase + schema.sql), step 4 (deploy), step 5 (DNS)",
|
|
44
|
+
after: "Then edit the Booking Types fact in NOAN: the starters use placeholder hosts, and a type whose host is not in BOOKING_HOSTS is not served.",
|
|
45
|
+
},
|
|
46
|
+
chat: {
|
|
47
|
+
label: "Verity Chat",
|
|
48
|
+
repo: "getnoan/verity-chat",
|
|
49
|
+
dirName: "verity-chat",
|
|
50
|
+
what: "a grounded chat widget for your website",
|
|
51
|
+
seeds: ["agents/seed-site-chat.mjs"],
|
|
52
|
+
boot: (port) => ({
|
|
53
|
+
args: ["--input-type=module", "-e", `const { server } = await import("./site-web/server.mjs"); server.listen(${port}, "127.0.0.1");`],
|
|
54
|
+
env: { NODE_ENV: "test" },
|
|
55
|
+
}),
|
|
56
|
+
probe: ["/healthz", "/widget.js"],
|
|
57
|
+
needsModelKey: true,
|
|
58
|
+
remaining: "step 3 (Supabase + schema.sql — the daily budget refuses every chat until it can meter), step 4 (deploy), step 5 (the embed tag on your site)",
|
|
59
|
+
after: "Set SITE_ALLOWED_ORIGINS to your site's origins and SITE_CHAT_GROUNDING_SLUGS to the blocks it may answer from.",
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Fork (or reuse) and clone a web agent's repo into dir/<dirName>. */
|
|
64
|
+
export function forkAndCloneWeb(dir, agent, { dryRun = false } = {}) {
|
|
65
|
+
const dest = path.join(dir, agent.dirName);
|
|
66
|
+
if (existsSync(path.join(dest, ".git"))) return { dest, action: "reused existing clone" };
|
|
67
|
+
if (dryRun) return { dest, action: "would fork and clone" };
|
|
68
|
+
const r = gh(["repo", "fork", agent.repo, "--clone", "--remote=false", dest]);
|
|
69
|
+
if (!r.ok) return { dest, action: "fork failed", error: r.err.split("\n")[0] };
|
|
70
|
+
return { dest, action: "forked and cloned" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Run a web agent's seed scripts from the repo root; collect the block slugs they print. */
|
|
74
|
+
export function runWebSeeds(dest, agent, env, { dryRun = false } = {}) {
|
|
75
|
+
const slugs = {}; const rows = [];
|
|
76
|
+
for (const s of agent.seeds) {
|
|
77
|
+
const p = path.join(dest, s);
|
|
78
|
+
// A dry run has no clone yet, so "missing" there would be a false alarm.
|
|
79
|
+
if (dryRun && !existsSync(dest)) { rows.push({ seed: s, action: "would run" }); continue; }
|
|
80
|
+
if (!existsSync(p)) { rows.push({ seed: s, action: "missing" }); continue; }
|
|
81
|
+
if (dryRun) { rows.push({ seed: s, action: "would run" }); continue; }
|
|
82
|
+
const r = spawnSync(process.execPath, [p], { cwd: dest, encoding: "utf8", env: { ...process.env, ...env } });
|
|
83
|
+
const found = parseSeedOutput((r.stdout || "") + "\n" + (r.stderr || ""));
|
|
84
|
+
Object.assign(slugs, found);
|
|
85
|
+
rows.push({ seed: s, action: r.status === 0 ? "ran" : "failed", slugs: Object.keys(found).length,
|
|
86
|
+
error: r.status === 0 ? undefined : (r.stderr || r.stdout || "").trim().split("\n").slice(-1)[0] });
|
|
87
|
+
}
|
|
88
|
+
return { slugs, rows };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function freePort() {
|
|
92
|
+
return new Promise((res, rej) => {
|
|
93
|
+
const s = createServer(); s.unref(); s.on("error", rej);
|
|
94
|
+
s.listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => res(port)); });
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** INSTALL.md step 0, run for the user: boot the service with no keys, hit its probes, stop it.
|
|
99
|
+
* The child gets PATH and HOME only — never the wizard's environment — so no key it holds can
|
|
100
|
+
* reach the service, and a block slug in the shell cannot switch booking out of its local mode. */
|
|
101
|
+
export async function proveLocally(dest, agent, { timeoutMs = 15000, fetchImpl = fetch } = {}) {
|
|
102
|
+
const port = await freePort();
|
|
103
|
+
const { args, env } = agent.boot(port);
|
|
104
|
+
const child = spawn(process.execPath, args, { cwd: dest, stdio: ["ignore", "ignore", "pipe"],
|
|
105
|
+
env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "", ...env } });
|
|
106
|
+
let stderr = ""; child.stderr.on("data", d => { stderr = (stderr + d).slice(-2000); });
|
|
107
|
+
const exited = new Promise(r => child.on("exit", code => r(code)));
|
|
108
|
+
const base = `http://127.0.0.1:${port}`;
|
|
109
|
+
try {
|
|
110
|
+
const deadline = Date.now() + timeoutMs;
|
|
111
|
+
let up = false;
|
|
112
|
+
while (Date.now() < deadline && !up) {
|
|
113
|
+
if (child.exitCode !== null) break;
|
|
114
|
+
try { up = (await fetchImpl(`${base}${agent.probe[0]}`, { signal: AbortSignal.timeout(1000) })).ok; } catch {}
|
|
115
|
+
if (!up) await new Promise(r => setTimeout(r, 300));
|
|
116
|
+
}
|
|
117
|
+
if (!up) return { ok: false, reason: child.exitCode !== null ? `exited (${child.exitCode}): ${stderr.trim().split("\n").slice(-1)[0] || "no output"}` : "did not answer in time" };
|
|
118
|
+
for (const p of agent.probe.slice(1)) {
|
|
119
|
+
const r = await fetchImpl(`${base}${p}`, { signal: AbortSignal.timeout(3000) }).catch(() => null);
|
|
120
|
+
if (!r?.ok) return { ok: false, reason: `${p} answered ${r ? r.status : "nothing"}` };
|
|
121
|
+
}
|
|
122
|
+
return { ok: true, probes: agent.probe };
|
|
123
|
+
} finally {
|
|
124
|
+
if (child.exitCode === null) { child.kill(); await Promise.race([exited, new Promise(r => setTimeout(r, 2000))]); }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Render's one-click deploy for a repo that carries a render.yaml. */
|
|
129
|
+
export const renderDeployUrl = (slug) => (slug ? `https://render.com/deploy?repo=https://github.com/${slug}` : null);
|
|
130
|
+
|
|
131
|
+
/** What goes in the clone's .env. Never the personal NOAN key (see the header). The model key goes
|
|
132
|
+
* under the name the service reads for the chosen endpoint (the pack's rule: ANTHROPIC_API_KEY for
|
|
133
|
+
* the default vendor, LLM_API_KEY plus ANTHROPIC_BASE_URL for anything else). */
|
|
134
|
+
export function webEnv(agent, { slugs = {}, company = "", agentName = "", modelKey = null, modelKeyName = "ANTHROPIC_API_KEY", modelBase = "" } = {}) {
|
|
135
|
+
return {
|
|
136
|
+
...slugs,
|
|
137
|
+
...(company && { COMPANY_NAME: company }),
|
|
138
|
+
...(agentName && { AGENT_NAME: agentName }),
|
|
139
|
+
...(agent === WEB_AGENTS.chat && { SESSION_SECRET: randomSecret() }),
|
|
140
|
+
...(agent.needsModelKey && modelKey && { [modelKeyName]: modelKey }),
|
|
141
|
+
...(agent.needsModelKey && modelKey && modelBase && { ANTHROPIC_BASE_URL: modelBase }),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Write the clone's .env: gitignored FIRST (the exported repos ship no .gitignore, and this file
|
|
146
|
+
* can hold a model key), and a SESSION_SECRET that is already there is kept — rotating it would
|
|
147
|
+
* sign out every visitor of a chat already deployed from it. */
|
|
148
|
+
export function writeWebEnv(dest, pairs, { dryRun = false } = {}) {
|
|
149
|
+
const gitignore = ensureGitignored(dest, { dryRun });
|
|
150
|
+
const envPath = path.join(dest, ".env");
|
|
151
|
+
const existing = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
|
|
152
|
+
const keep = { ...pairs };
|
|
153
|
+
if (/^\s*(?:export\s+)?SESSION_SECRET\s*=\s*\S/m.test(existing)) delete keep.SESSION_SECRET;
|
|
154
|
+
const env = writeEnv(dest, keep, { dryRun });
|
|
155
|
+
return { ...env, gitignore: gitignore.action, keys: Object.keys(keep) };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The hand-off line for the report's `next` list: what is left, and where to read how. */
|
|
159
|
+
export function webNext(agent, { dest, deployUrl, missingModelKey = null }) {
|
|
160
|
+
return {
|
|
161
|
+
id: `web-${agent.dirName}`,
|
|
162
|
+
say: `Finish ${agent.label} (${agent.what}): in ${dest}, follow INSTALL.md ${agent.remaining}.`
|
|
163
|
+
+ (missingModelKey ? ` It needs ${missingModelKey} too — the wizard had none to write.` : "")
|
|
164
|
+
+ (deployUrl ? ` One-click deploy: ${deployUrl}.` : "")
|
|
165
|
+
+ ` Create a NOAN API key for this service alone and set it as NOAN_AGENT_API_KEY in the deploy env — not your personal key.`
|
|
166
|
+
+ ` ${agent.after}`,
|
|
167
|
+
why: "the rest needs accounts only you can create; INSTALL.md marks those steps HUMAN",
|
|
168
|
+
};
|
|
169
|
+
}
|