@getnoan/wizard 0.1.7 → 0.1.8
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 +5 -1
- package/package.json +1 -1
- package/src/agents.mjs +33 -1
- package/src/env-file.mjs +8 -0
- package/src/run.mjs +12 -4
package/README.md
CHANGED
|
@@ -46,7 +46,11 @@ Run it again any time; every write is a merge, and nothing you have is overwritt
|
|
|
46
46
|
to your account, verifies and stores your model and Resend keys (and optionally a Postgres URL
|
|
47
47
|
and a Firecrawl key) as repository secrets, runs the pack's seed scripts so each agent's starting
|
|
48
48
|
instructions are in your workspace, records the name and the block slugs as repository variables,
|
|
49
|
-
and triggers one dry run. Safe mode stays on: the wizard never sets `DRY_RUN` to 0. That switch is yours.
|
|
49
|
+
and triggers one dry run. Safe mode stays on: the wizard never sets `DRY_RUN` to 0, and a re-run keeps the fork's `DRY_RUN`, and its `STATE_BACKEND` unless you give a Postgres URL. That switch is yours.
|
|
50
|
+
|
|
51
|
+
The newsletter signs unsubscribe links with `NEWSLETTER_UNSUB_SECRET`, and your site has to check
|
|
52
|
+
them with the same value. The wizard creates it once, stores it as a repository secret and in
|
|
53
|
+
`noan-agent-pack/.env` so you can copy it to your site, and keeps it on every re-run.
|
|
50
54
|
|
|
51
55
|
The model does not have to be Anthropic's. Set `ANTHROPIC_BASE_URL` in the environment before
|
|
52
56
|
running — `https://openrouter.ai/api`, say, or your own gateway — and the wizard verifies your key
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getnoan/wizard",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
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/agents.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { spawnSync } from "node:child_process";
|
|
|
9
9
|
import { existsSync, readFileSync } from "node:fs";
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
import { randomBytes } from "node:crypto";
|
|
12
|
-
import { writeEnv } from "./env-file.mjs";
|
|
12
|
+
import { writeEnv, readEnvValue } from "./env-file.mjs";
|
|
13
13
|
|
|
14
14
|
export const PACK_REPO = "getnoan/agent-pack";
|
|
15
15
|
|
|
@@ -130,6 +130,23 @@ export function existingVariables(dest, dryRun) {
|
|
|
130
130
|
const r = spawnSync("gh", ["variable", "list", "--json", "name", "-q", ".[].name"], { cwd: dest, encoding: "utf8" });
|
|
131
131
|
return new Set(r.status === 0 ? r.stdout.split("\n").map(s => s.trim()).filter(Boolean) : []);
|
|
132
132
|
}
|
|
133
|
+
/** The variables a re-run must not undo. DRY_RUN=1 only on a fork that has none, since it's the
|
|
134
|
+
* user's switch for taking agents live. STATE_BACKEND follows a Postgres URL given on this run,
|
|
135
|
+
* else keeps the fork's, so leaving the prompt blank doesn't drop the agents' state ledger. */
|
|
136
|
+
export function forkStateVars(have, { db = "" } = {}) {
|
|
137
|
+
const set = {}, kept = [];
|
|
138
|
+
if (have.has("DRY_RUN")) kept.push("DRY_RUN"); else set.DRY_RUN = "1";
|
|
139
|
+
if (db) set.STATE_BACKEND = "postgres";
|
|
140
|
+
else if (have.has("STATE_BACKEND")) kept.push("STATE_BACKEND");
|
|
141
|
+
else set.STATE_BACKEND = "local";
|
|
142
|
+
return { set, kept };
|
|
143
|
+
}
|
|
144
|
+
/** The repository secrets already set on the fork, by name. Same rules as existingVariables. */
|
|
145
|
+
export function existingSecrets(dest, dryRun) {
|
|
146
|
+
if (dryRun) return new Set();
|
|
147
|
+
const r = spawnSync("gh", ["secret", "list", "--json", "name", "-q", ".[].name"], { cwd: dest, encoding: "utf8" });
|
|
148
|
+
return new Set(r.status === 0 ? r.stdout.split("\n").map(s => s.trim()).filter(Boolean) : []);
|
|
149
|
+
}
|
|
133
150
|
export function setVariable(dest, name, value, dryRun) {
|
|
134
151
|
if (dryRun) return { name, action: "would set" };
|
|
135
152
|
const r = spawnSync("gh", ["variable", "set", name, "--body", value], { cwd: dest, encoding: "utf8" });
|
|
@@ -196,5 +213,20 @@ export function dispatchDryRun(dest, workflow, dryRun) {
|
|
|
196
213
|
|
|
197
214
|
export const randomSecret = () => randomBytes(24).toString("hex");
|
|
198
215
|
|
|
216
|
+
export const UNSUB_SECRET = "NEWSLETTER_UNSUB_SECRET";
|
|
217
|
+
|
|
218
|
+
/** The newsletter signs every unsubscribe link with this secret and the user's own site checks it
|
|
219
|
+
* with the same value. A GitHub secret can't be read back, so the clone's .env holds the copy the
|
|
220
|
+
* user reads. An existing value is kept: a new one breaks every unsubscribe link already sent. */
|
|
221
|
+
export function unsubSecret(dest, forkSecrets, { dryRun = false } = {}) {
|
|
222
|
+
const onFork = forkSecrets.has(UNSUB_SECRET);
|
|
223
|
+
const local = readEnvValue(dest, UNSUB_SECRET);
|
|
224
|
+
if (local) return { value: local, setOnFork: !onFork, action: onFork ? "kept" : "set from .env" };
|
|
225
|
+
if (onFork) return { value: null, setOnFork: false, action: "kept, not in .env" };
|
|
226
|
+
const value = randomSecret();
|
|
227
|
+
writeEnv(dest, { [UNSUB_SECRET]: value }, { dryRun });
|
|
228
|
+
return { value, setOnFork: true, action: "created" };
|
|
229
|
+
}
|
|
230
|
+
|
|
199
231
|
/** Write the pack's .env in the clone so the seeds (and a local run) have what they need. */
|
|
200
232
|
export function writePackEnv(dest, pairs, dryRun) { return writeEnv(dest, pairs, { dryRun }); }
|
package/src/env-file.mjs
CHANGED
|
@@ -16,6 +16,14 @@ export function upsertEnv(text, pairs) {
|
|
|
16
16
|
return out.join("\n") + "\n";
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** One key's value from dir/.env, "" when the file or the key is missing. */
|
|
20
|
+
export function readEnvValue(dir, key) {
|
|
21
|
+
const p = path.join(dir, ".env");
|
|
22
|
+
if (!existsSync(p)) return "";
|
|
23
|
+
const m = readFileSync(p, "utf8").match(new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=\\s*(\\S+)`, "m"));
|
|
24
|
+
return m ? m[1].replace(/^["']|["']$/g, "") : "";
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
export function writeEnv(dir, pairs, { dryRun = false } = {}) {
|
|
20
28
|
const p = path.join(dir, ".env");
|
|
21
29
|
const before = existsSync(p) ? readFileSync(p, "utf8") : "";
|
package/src/run.mjs
CHANGED
|
@@ -204,12 +204,21 @@ async function setupAgents({ args, ui, dir, key, me, report, shared = {} }) {
|
|
|
204
204
|
const runKey = roles.runAs === "agent" ? agentKey : key;
|
|
205
205
|
|
|
206
206
|
const secrets = { NOAN_PERSONAL_API_KEY: runKey, ...(anthropic && { [mName]: anthropic }), ...(resend && { RESEND_API_KEY: resend }),
|
|
207
|
-
...(db && { DATABASE_URL: db }), ...(firecrawl && { FIRECRAWL_API_KEY: firecrawl })
|
|
207
|
+
...(db && { DATABASE_URL: db }), ...(firecrawl && { FIRECRAWL_API_KEY: firecrawl }) };
|
|
208
|
+
const unsub = pack.unsubSecret(fork.dest, pack.existingSecrets(fork.dest, args.dryRun), { dryRun: args.dryRun });
|
|
209
|
+
if (unsub.setOnFork) secrets[pack.UNSUB_SECRET] = unsub.value;
|
|
208
210
|
for (const [n, v] of Object.entries(secrets)) { const r = pack.setSecret(fork.dest, n, v, args.dryRun); out.secrets.push(r); ui.ok(`secret ${n}: ${r.action}${r.error ? ` — ${r.error}` : ""}`); }
|
|
211
|
+
if (!unsub.setOnFork) { out.secrets.push({ name: pack.UNSUB_SECRET, action: unsub.action }); ui.ok(`secret ${pack.UNSUB_SECRET}: ${unsub.action}`); }
|
|
212
|
+
if (unsub.action === "kept, not in .env") {
|
|
213
|
+
ui.warn(`${pack.UNSUB_SECRET} is on the fork but not in ${fork.dest}/.env, so your site can't check newsletter unsubscribe links with it`);
|
|
214
|
+
report.next.push({ id: "unsub-secret", say: `Before sending a newsletter, pick a new ${pack.UNSUB_SECRET} and set it in ${fork.dest}/.env, as the fork's secret, and on your site.`, why: "the site checks unsubscribe links with the same value the agents sign them with" });
|
|
215
|
+
}
|
|
209
216
|
// The endpoint is a repository VARIABLE, not a secret: without it the fork holds a gateway key
|
|
210
217
|
// and still calls the default vendor, which is the same "documented is not delivered" trap the
|
|
211
218
|
// pack's own workflows had.
|
|
212
|
-
const
|
|
219
|
+
const have = pack.existingVariables(fork.dest, args.dryRun);
|
|
220
|
+
const forkState = pack.forkStateVars(have, { db });
|
|
221
|
+
const vars = { ...forkState.set, ...(mBase && { ANTHROPIC_BASE_URL: mBase }), ...(mailFrom && { MAIL_FROM: mailFrom }), ...(replyTo && { REPLY_TO: replyTo }), ...(escalateTo && { ESCALATE_TO: escalateTo }),
|
|
213
222
|
...identity,
|
|
214
223
|
COMPANY_NAME: me.project?.name || "", ...(roles.agentIds && { AGENT_IDENTITY_IDS: roles.agentIds }), ...(roles.commanders && { COMMANDERS: roles.commanders }) };
|
|
215
224
|
// The seeds need the key in the clone's .env; the pack's own .env.example documents the rest.
|
|
@@ -231,7 +240,6 @@ async function setupAgents({ args, ui, dir, key, me, report, shared = {} }) {
|
|
|
231
240
|
}
|
|
232
241
|
// Who gets work handed back (agent role only, see agentRoles). An env value wins; one already
|
|
233
242
|
// set on the fork is kept, since it is a list a person may have extended with teammates.
|
|
234
|
-
const have = pack.existingVariables(fork.dest, args.dryRun);
|
|
235
243
|
const handBackKept = [], handBackToYou = [];
|
|
236
244
|
for (const [n, v] of Object.entries(roles.handBack)) {
|
|
237
245
|
const given = (process.env[n] || "").trim();
|
|
@@ -243,7 +251,7 @@ async function setupAgents({ args, ui, dir, key, me, report, shared = {} }) {
|
|
|
243
251
|
for (const [n, v] of Object.entries(vars)) { if (!v) continue; const r = pack.setVariable(fork.dest, n, v, args.dryRun); out.variables.push(r); }
|
|
244
252
|
if (handBackToYou.length) ui.ok(`work the agents hand back goes to ${me.identity?.email || "you"} (fact review, parked tasks, support follow-ups)`);
|
|
245
253
|
if (handBackKept.length) ui.ok(`kept the fork's own ${handBackKept.join(", ")}`);
|
|
246
|
-
ui.ok(`${out.variables.length} repository variable(s) ${args.dryRun ? "to set" : "set"} (DRY_RUN=1: every agent stays in safe mode)`);
|
|
254
|
+
ui.ok(`${out.variables.length} repository variable(s) ${args.dryRun ? "to set" : "set"}${forkState.set.DRY_RUN ? " (DRY_RUN=1: every agent stays in safe mode)" : ""}${forkState.kept.length ? `; kept the fork's own ${forkState.kept.join(", ")}` : ""}`);
|
|
247
255
|
// No point dispatching a run that will only fail for a missing key: say what is missing instead.
|
|
248
256
|
if (!anthropic || !resend) {
|
|
249
257
|
const missing = [!anthropic && mName, !resend && "RESEND_API_KEY"].filter(Boolean);
|