@rtrentjones/greenlight 0.2.9 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +16 -12
  2. package/dist/bin.js +321 -225
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -1,24 +1,28 @@
1
1
  # @rtrentjones/greenlight
2
2
 
3
3
  The Greenlight CLI — setup and lifecycle for the [Greenlight](https://github.com/RTrentJones/greenlight)
4
- harness. Greenlight is a **clone-and-own** baseline that turns a domain + API tokens into a live
5
- personal site plus a self-verifying agentic deploy loop, with plug-and-play subdomain tools (web apps
6
- or MCP servers). It is provider-agnostic and free-tier-first, and it **edits declarative
7
- infrastructure-as-code — your CI/CD applies it**. It is not a hosted PaaS.
4
+ harness. Greenlight turns a domain + API tokens into a live personal site plus a self-verifying
5
+ agentic deploy loop, with plug-and-play subdomain tools (web apps or MCP servers). It is
6
+ provider-agnostic and free-tier-first, and it **edits declarative infrastructure-as-code — your CI/CD
7
+ applies it**. It is not a hosted PaaS.
8
8
 
9
- This is the **single published package**: the CLI, with the framework libraries
10
- (`shared`/`verify`/`adapters`/`loop`) bundled in. The Terraform modules are distributed as git tags
11
- (pinned in lockstep with this package's version); the skills ship as a Claude Code plugin.
9
+ **You install this CLI; you don't fork the framework.** `greenlight init` scaffolds a thin **wrapper
10
+ repo you own** (your manifest + content) that depends on this package and updates via
11
+ `pnpm update` no framework code to merge. This is the **single published package**: the CLI, with
12
+ the framework libraries (`shared`/`verify`/`adapters`/`loop`) bundled in. Terraform modules ship as
13
+ git tags (pinned in lockstep with this version); skills ship as a Claude Code plugin.
12
14
 
13
- ## Install
15
+ ## Quick start
14
16
 
15
17
  ```bash
16
- pnpm add @rtrentjones/greenlight # or npm i / yarn add
18
+ npx -y @rtrentjones/greenlight init --domain you.dev # scaffold the wrapper + gather base keys
19
+ pnpm greenlight add notes --lane mcp --target oci # add a tool: emit infra + gather ITS keys
20
+ git push # CI runs `terraform apply`
21
+ pnpm greenlight verify notes --env prod # the shared harness proves it
17
22
  ```
18
23
 
19
- A personal site is a **thin consumer** that depends on this package and owns only its manifest
20
- (`greenlight.config.ts`) + content. Update with `pnpm update @rtrentjones/greenlight` — no framework
21
- code to merge.
24
+ Full walkthrough: [getting-started.md](https://github.com/RTrentJones/greenlight/blob/main/docs/getting-started.md).
25
+ Update the mechanics later with `pnpm update @rtrentjones/greenlight`.
22
26
 
23
27
  Optional peer features lazy-load and degrade to a failing check if absent (never a crash):
24
28
 
package/dist/bin.js CHANGED
@@ -14,8 +14,8 @@ import "./chunk-6N7MD6FR.js";
14
14
  import "./chunk-QFKE5JKC.js";
15
15
 
16
16
  // src/commands/add.ts
17
- import { cpSync as cpSync2, existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
18
- import { join, resolve as resolve6 } from "path";
17
+ import { cpSync as cpSync2, existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
18
+ import { join, resolve as resolve5 } from "path";
19
19
 
20
20
  // src/asset-paths.ts
21
21
  import { existsSync } from "fs";
@@ -414,7 +414,7 @@ function tokensForTool(tool) {
414
414
  }
415
415
 
416
416
  // src/version.ts
417
- var MODULE_REF = "v0.2.9";
417
+ var MODULE_REF = "v0.2.10";
418
418
  var MODULE_SOURCE_BASE = "git::https://github.com/RTrentJones/greenlight.git//infra/modules";
419
419
  function moduleSource(module, ref = MODULE_REF) {
420
420
  return `${MODULE_SOURCE_BASE}/${module}?ref=${ref}`;
@@ -654,15 +654,87 @@ function providersForTool(tool) {
654
654
  return out;
655
655
  }
656
656
 
657
- // src/tokens.ts
658
- import { existsSync as existsSync4, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
659
- import { resolve as resolve4 } from "path";
660
- import { createInterface as createInterface2 } from "readline/promises";
657
+ // src/commands/agent.ts
658
+ import { cpSync, existsSync as existsSync3, mkdirSync, readFileSync, writeFileSync } from "fs";
659
+ import { resolve as resolve3 } from "path";
660
+
661
+ // src/agent-kit.ts
662
+ function recommendedMcp(tool) {
663
+ return mcpForTool(tool);
664
+ }
665
+ function mergeMcpServers(existing, add) {
666
+ const out = { mcpServers: { ...existing?.mcpServers ?? {} } };
667
+ for (const [name, val] of Object.entries(add)) {
668
+ if (out.mcpServers[name]) continue;
669
+ out.mcpServers[name] = typeof val === "string" ? { type: "http", url: val } : val;
670
+ }
671
+ return out;
672
+ }
673
+
674
+ // src/commands/agent.ts
675
+ var CLAUDE_BLOCK = `## Greenlight loop (deploy \u2192 verify \u2192 promote)
676
+
677
+ This repo uses Greenlight. Ship changes through the deploy-verify-promote skill:
678
+ branch \u2192 change \u2192 deploy preview \u2192 \`greenlight verify\` \u2192 beta \u2192 verify \u2192 \`greenlight promote\` \u2192 prod \u2192 verify.
679
+
680
+ Agentic kit:
681
+ - Skill: \`.claude/skills/deploy-verify-promote/SKILL.md\` (the loop).
682
+ - MCP servers: \`.mcp.json\` recommends the relevant providers \u2014 run \`/mcp\` to authenticate.
683
+ Vercel is OAuth; Supabase needs \`SUPABASE_ACCESS_TOKEN\` (+ \`SUPABASE_PROJECT_REF\`) in your env.
684
+ - Best-practice skills (one-time, user scope):
685
+ \`claude plugin marketplace add cloudflare/skills && claude plugin install cloudflare@cloudflare\`
686
+ `;
687
+ function materializeAgentKit(dir, tool) {
688
+ const src = skillAssetDir();
689
+ if (!existsSync3(src)) throw new Error(`skill asset not found at ${src}`);
690
+ const dest = resolve3(dir, ".claude/skills/deploy-verify-promote");
691
+ mkdirSync(dest, { recursive: true });
692
+ cpSync(src, dest, { recursive: true });
693
+ console.log("\u2714 .claude/skills/deploy-verify-promote/SKILL.md");
694
+ for (const pack of packsForTool(tool)) {
695
+ if (!pack.skill) continue;
696
+ const skillSrc = skillAssetDir(pack.skill);
697
+ if (!existsSync3(skillSrc)) continue;
698
+ const skillDest = resolve3(dir, ".claude/skills", pack.skill);
699
+ mkdirSync(skillDest, { recursive: true });
700
+ cpSync(skillSrc, skillDest, { recursive: true });
701
+ console.log(`\u2714 .claude/skills/${pack.skill}/SKILL.md`);
702
+ }
703
+ const mcpPath = resolve3(dir, ".mcp.json");
704
+ const existingMcp = existsSync3(mcpPath) ? JSON.parse(readFileSync(mcpPath, "utf8")) : null;
705
+ const servers = recommendedMcp(tool);
706
+ writeFileSync(mcpPath, `${JSON.stringify(mergeMcpServers(existingMcp, servers), null, 2)}
707
+ `);
708
+ console.log(`\u2714 .mcp.json (${Object.keys(servers).length} recommended MCP server(s))`);
709
+ const claudePath = resolve3(dir, "CLAUDE.md");
710
+ const marker = "Greenlight loop (deploy \u2192 verify \u2192 promote)";
711
+ const existing = existsSync3(claudePath) ? readFileSync(claudePath, "utf8") : "";
712
+ if (existing.includes(marker)) {
713
+ console.log("\xB7 CLAUDE.md already has the loop block");
714
+ } else {
715
+ writeFileSync(claudePath, existing ? `${existing.trimEnd()}
716
+
717
+ ${CLAUDE_BLOCK}` : CLAUDE_BLOCK);
718
+ console.log(`\u2714 CLAUDE.md (${existing ? "appended" : "created"})`);
719
+ }
720
+ }
721
+ async function agentCommand(args) {
722
+ if (args[0] !== "sync") {
723
+ console.log(
724
+ "usage: greenlight agent sync # write the loop skill + .mcp.json + CLAUDE.md block"
725
+ );
726
+ process.exit(args[0] ? 1 : 0);
727
+ }
728
+ materializeAgentKit(process.cwd());
729
+ console.log(
730
+ "\nNote: the Greenlight Claude Code plugin (user scope) is the preferred path; this sync is the fallback.\nRun `/mcp` to authenticate the MCP servers."
731
+ );
732
+ }
661
733
 
662
734
  // src/commands/secrets.ts
663
735
  import { execFileSync } from "child_process";
664
- import { existsSync as existsSync3, readFileSync } from "fs";
665
- import { resolve as resolve3 } from "path";
736
+ import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
737
+ import { resolve as resolve4 } from "path";
666
738
  import { createInterface } from "readline";
667
739
  function parseSecretsEnv(text) {
668
740
  const out = [];
@@ -688,7 +760,7 @@ function parseOciConfig(text) {
688
760
  return out;
689
761
  }
690
762
  function ociPrefill(configPath, keyPath) {
691
- const cfg = parseOciConfig(readFileSync(configPath, "utf8"));
763
+ const cfg = parseOciConfig(readFileSync2(configPath, "utf8"));
692
764
  const map = /* @__PURE__ */ new Map();
693
765
  const set = (k, v) => {
694
766
  if (v) map.set(k, v);
@@ -698,8 +770,8 @@ function ociPrefill(configPath, keyPath) {
698
770
  set("TF_VAR_OCI_TENANCY_OCID", cfg.tenancy);
699
771
  set("TF_VAR_OCI_REGION", cfg.region);
700
772
  const pem = keyPath ?? cfg.key_file;
701
- if (pem && existsSync3(pem)) {
702
- map.set("TF_VAR_OCI_PRIVATE_KEY", readFileSync(pem, "utf8"));
773
+ if (pem && existsSync4(pem)) {
774
+ map.set("TF_VAR_OCI_PRIVATE_KEY", readFileSync2(pem, "utf8"));
703
775
  } else if (pem) {
704
776
  console.log(` ! PEM not found at ${pem} \u2014 set TF_VAR_OCI_PRIVATE_KEY manually (--oci-key)`);
705
777
  }
@@ -733,11 +805,11 @@ function syncSecrets(opts) {
733
805
  "could not determine the repo \u2014 pass --repo owner/repo (no github.com origin remote)"
734
806
  );
735
807
  }
736
- const path = resolve3(opts.cwd, ".greenlight/secrets.env");
737
- if (!existsSync3(path)) {
808
+ const path = resolve4(opts.cwd, ".greenlight/secrets.env");
809
+ if (!existsSync4(path)) {
738
810
  throw new Error("no .greenlight/secrets.env \u2014 run `greenlight init` with tokens first");
739
811
  }
740
- const entries = parseSecretsEnv(readFileSync(path, "utf8"));
812
+ const entries = parseSecretsEnv(readFileSync2(path, "utf8"));
741
813
  const target = opts.env ? `env "${opts.env}"` : "repo";
742
814
  for (const { key, value } of entries) {
743
815
  const ghArgs = ["secret", "set", key, "--repo", repo];
@@ -879,7 +951,7 @@ async function secretsCommand(args) {
879
951
  if (!repo) throw new Error("could not determine the repo \u2014 pass --repo owner/repo");
880
952
  const ociConfig2 = flag(args, "--oci-config");
881
953
  const ociKey = flag(args, "--oci-key");
882
- const prefill = ociConfig2 ? ociPrefill(resolve3(process.cwd(), ociConfig2), ociKey && resolve3(process.cwd(), ociKey)) : void 0;
954
+ const prefill = ociConfig2 ? ociPrefill(resolve4(process.cwd(), ociConfig2), ociKey && resolve4(process.cwd(), ociKey)) : void 0;
883
955
  await gatherSecrets(name, repo, flag(args, "--env"), prefill);
884
956
  return;
885
957
  }
@@ -904,161 +976,6 @@ ${count} secret(s) synced. (Prefer GitHub OIDC over long-lived tokens where supp
904
976
  );
905
977
  }
906
978
 
907
- // src/tokens.ts
908
- var SECRETS_DIR = ".greenlight";
909
- var SECRETS_FILE = "secrets.env";
910
- function presentEnv(cwd) {
911
- const out = {};
912
- const p = resolve4(cwd, SECRETS_DIR, SECRETS_FILE);
913
- if (existsSync4(p)) {
914
- for (const { key, value } of parseSecretsEnv(readFileSync2(p, "utf8"))) out[key] = value;
915
- }
916
- for (const [k, v] of Object.entries(process.env)) {
917
- if (v !== void 0 && !(k in out)) out[k] = v;
918
- }
919
- return out;
920
- }
921
- function upsertSecret(cwd, key, value) {
922
- const dir = resolve4(cwd, SECRETS_DIR);
923
- mkdirSync(dir, { recursive: true });
924
- const p = resolve4(dir, SECRETS_FILE);
925
- const lines = existsSync4(p) ? readFileSync2(p, "utf8").split("\n") : [];
926
- const idx = lines.findIndex((l) => l.startsWith(`${key}=`));
927
- if (idx >= 0) lines[idx] = `${key}=${value}`;
928
- else {
929
- while (lines.length && (lines[lines.length - 1] ?? "").trim() === "") lines.pop();
930
- lines.push(`${key}=${value}`);
931
- }
932
- writeFileSync(p, `${lines.join("\n").replace(/\n*$/, "")}
933
- `, { mode: 384 });
934
- }
935
- async function ensureTokensForTool(cwd, tool, opts = {}) {
936
- const doVerify = opts.verify !== false;
937
- const interactive = Boolean(process.stdin.isTTY);
938
- const env = presentEnv(cwd);
939
- const results = [];
940
- const rl = interactive ? createInterface2({ input: process.stdin, output: process.stdout }) : null;
941
- try {
942
- for (const spec of tokensForTool(tool)) {
943
- let value = env[spec.envVar];
944
- if (value) {
945
- results.push({ envVar: spec.envVar, outcome: "present" });
946
- } else if (rl) {
947
- console.log(`
948
- ${spec.envVar} \u2014 ${spec.label}`);
949
- if (spec.scopes?.length) console.log(` scopes: ${spec.scopes.join(", ")}`);
950
- const entered = (await rl.question(` paste value${spec.optional ? " (optional, Enter to skip)" : ""}: `)).trim();
951
- if (!entered) {
952
- results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
953
- continue;
954
- }
955
- upsertSecret(cwd, spec.envVar, entered);
956
- env[spec.envVar] = entered;
957
- value = entered;
958
- results.push({ envVar: spec.envVar, outcome: "entered" });
959
- } else {
960
- results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
961
- continue;
962
- }
963
- if (value && doVerify && spec.verify) {
964
- let check;
965
- try {
966
- check = await spec.verify(value, env);
967
- } catch (e) {
968
- check = { ok: false, detail: e instanceof Error ? e.message : String(e) };
969
- }
970
- const last = results[results.length - 1];
971
- if (last) last.verify = check;
972
- if (!check.ok && !spec.optional) {
973
- throw new Error(
974
- `${spec.envVar} failed verification${check.detail ? ` (${check.detail})` : ""} \u2014 check the token's scopes (${spec.label}).`
975
- );
976
- }
977
- }
978
- }
979
- } finally {
980
- rl?.close();
981
- }
982
- return results;
983
- }
984
-
985
- // src/commands/agent.ts
986
- import { cpSync, existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
987
- import { resolve as resolve5 } from "path";
988
-
989
- // src/agent-kit.ts
990
- function recommendedMcp(tool) {
991
- return mcpForTool(tool);
992
- }
993
- function mergeMcpServers(existing, add) {
994
- const out = { mcpServers: { ...existing?.mcpServers ?? {} } };
995
- for (const [name, val] of Object.entries(add)) {
996
- if (out.mcpServers[name]) continue;
997
- out.mcpServers[name] = typeof val === "string" ? { type: "http", url: val } : val;
998
- }
999
- return out;
1000
- }
1001
-
1002
- // src/commands/agent.ts
1003
- var CLAUDE_BLOCK = `## Greenlight loop (deploy \u2192 verify \u2192 promote)
1004
-
1005
- This repo uses Greenlight. Ship changes through the deploy-verify-promote skill:
1006
- branch \u2192 change \u2192 deploy preview \u2192 \`greenlight verify\` \u2192 beta \u2192 verify \u2192 \`greenlight promote\` \u2192 prod \u2192 verify.
1007
-
1008
- Agentic kit:
1009
- - Skill: \`.claude/skills/deploy-verify-promote/SKILL.md\` (the loop).
1010
- - MCP servers: \`.mcp.json\` recommends the relevant providers \u2014 run \`/mcp\` to authenticate.
1011
- Vercel is OAuth; Supabase needs \`SUPABASE_ACCESS_TOKEN\` (+ \`SUPABASE_PROJECT_REF\`) in your env.
1012
- - Best-practice skills (one-time, user scope):
1013
- \`claude plugin marketplace add cloudflare/skills && claude plugin install cloudflare@cloudflare\`
1014
- `;
1015
- function materializeAgentKit(dir, tool) {
1016
- const src = skillAssetDir();
1017
- if (!existsSync5(src)) throw new Error(`skill asset not found at ${src}`);
1018
- const dest = resolve5(dir, ".claude/skills/deploy-verify-promote");
1019
- mkdirSync2(dest, { recursive: true });
1020
- cpSync(src, dest, { recursive: true });
1021
- console.log("\u2714 .claude/skills/deploy-verify-promote/SKILL.md");
1022
- for (const pack of packsForTool(tool)) {
1023
- if (!pack.skill) continue;
1024
- const skillSrc = skillAssetDir(pack.skill);
1025
- if (!existsSync5(skillSrc)) continue;
1026
- const skillDest = resolve5(dir, ".claude/skills", pack.skill);
1027
- mkdirSync2(skillDest, { recursive: true });
1028
- cpSync(skillSrc, skillDest, { recursive: true });
1029
- console.log(`\u2714 .claude/skills/${pack.skill}/SKILL.md`);
1030
- }
1031
- const mcpPath = resolve5(dir, ".mcp.json");
1032
- const existingMcp = existsSync5(mcpPath) ? JSON.parse(readFileSync3(mcpPath, "utf8")) : null;
1033
- const servers = recommendedMcp(tool);
1034
- writeFileSync2(mcpPath, `${JSON.stringify(mergeMcpServers(existingMcp, servers), null, 2)}
1035
- `);
1036
- console.log(`\u2714 .mcp.json (${Object.keys(servers).length} recommended MCP server(s))`);
1037
- const claudePath = resolve5(dir, "CLAUDE.md");
1038
- const marker = "Greenlight loop (deploy \u2192 verify \u2192 promote)";
1039
- const existing = existsSync5(claudePath) ? readFileSync3(claudePath, "utf8") : "";
1040
- if (existing.includes(marker)) {
1041
- console.log("\xB7 CLAUDE.md already has the loop block");
1042
- } else {
1043
- writeFileSync2(claudePath, existing ? `${existing.trimEnd()}
1044
-
1045
- ${CLAUDE_BLOCK}` : CLAUDE_BLOCK);
1046
- console.log(`\u2714 CLAUDE.md (${existing ? "appended" : "created"})`);
1047
- }
1048
- }
1049
- async function agentCommand(args) {
1050
- if (args[0] !== "sync") {
1051
- console.log(
1052
- "usage: greenlight agent sync # write the loop skill + .mcp.json + CLAUDE.md block"
1053
- );
1054
- process.exit(args[0] ? 1 : 0);
1055
- }
1056
- materializeAgentKit(process.cwd());
1057
- console.log(
1058
- "\nNote: the Greenlight Claude Code plugin (user scope) is the preferred path; this sync is the fallback.\nRun `/mcp` to authenticate the MCP servers."
1059
- );
1060
- }
1061
-
1062
979
  // src/commands/add.ts
1063
980
  function flag2(args, name) {
1064
981
  const i = args.indexOf(name);
@@ -1096,71 +1013,69 @@ async function addCommand(args) {
1096
1013
  const data = entry?.data ?? "none";
1097
1014
  const envs = entry?.envs ?? ["beta", "prod"];
1098
1015
  const toolInfo = { target, data };
1099
- const dest = resolve6(process.cwd(), "tools", name);
1100
- if (existsSync6(dest)) throw new Error(`tools/${name} already exists`);
1016
+ const dest = resolve5(process.cwd(), "tools", name);
1017
+ if (existsSync5(dest)) throw new Error(`tools/${name} already exists`);
1101
1018
  const src = templateDir(lane, target);
1102
- if (existsSync6(src)) {
1019
+ if (existsSync5(src)) {
1103
1020
  cpSync2(src, dest, { recursive: true });
1104
1021
  const pkgPath = join(dest, "package.json");
1105
- if (existsSync6(pkgPath)) {
1106
- const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
1022
+ if (existsSync5(pkgPath)) {
1023
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
1107
1024
  pkg.name = name;
1108
- writeFileSync3(pkgPath, `${JSON.stringify(pkg, null, 2)}
1025
+ writeFileSync2(pkgPath, `${JSON.stringify(pkg, null, 2)}
1109
1026
  `);
1110
1027
  }
1111
1028
  console.log(`\u2714 copied ${src} \u2192 tools/${name}`);
1112
1029
  } else {
1113
1030
  console.log(`! no template at ${src} \u2014 manifest entry added without scaffolding`);
1114
1031
  }
1115
- writeFileSync3(path, serializeConfig(next));
1032
+ writeFileSync2(path, serializeConfig(next));
1116
1033
  console.log(`\u2714 added "${name}" (${lane}/${target}) to the manifest`);
1117
1034
  const cwd = process.cwd();
1118
1035
  const providers = providersForTool(toolInfo);
1119
- const infraDir = resolve6(cwd, "infra");
1036
+ const infraDir = resolve5(cwd, "infra");
1120
1037
  const mainTf = join(infraDir, "main.tf");
1121
- if (!existsSync6(mainTf)) {
1122
- mkdirSync3(infraDir, { recursive: true });
1123
- writeFileSync3(mainTf, emitWrapperMainTf({ domain: config.domain, providers }));
1038
+ if (!existsSync5(mainTf)) {
1039
+ mkdirSync2(infraDir, { recursive: true });
1040
+ writeFileSync2(mainTf, emitWrapperMainTf({ domain: config.domain, providers }));
1124
1041
  console.log("\u2714 scaffolded infra/main.tf (providers + HCP backend placeholder)");
1125
1042
  } else if (providers.some((p) => p !== "cloudflare" && p !== "github")) {
1126
1043
  console.log(`\xB7 infra/main.tf exists \u2014 ensure it declares provider(s): ${providers.join(", ")}`);
1127
1044
  }
1128
1045
  const toolTf = join(infraDir, `${name}.tf`);
1129
- if (existsSync6(toolTf)) {
1046
+ if (existsSync5(toolTf)) {
1130
1047
  console.log(`\xB7 infra/${name}.tf exists \u2014 left as-is`);
1131
1048
  } else {
1132
- writeFileSync3(
1049
+ writeFileSync2(
1133
1050
  toolTf,
1134
1051
  emitToolTf({ name, domain: config.domain, lane, target, data, envs, port: entry?.port })
1135
1052
  );
1136
1053
  console.log(`\u2714 wrote infra/${name}.tf (modules: ${providers.join(", ")})`);
1137
1054
  }
1138
- if (!args.includes("--no-tokens")) {
1055
+ materializeAgentKit(cwd, toolInfo);
1056
+ const repo = flag2(args, "--repo") ?? detectRepo(cwd) ?? "";
1057
+ const gather = !args.includes("--no-tokens") && process.stdin.isTTY && repo !== "";
1058
+ if (gather) {
1059
+ console.log(`
1060
+ Gathering ${name}'s provider keys${repo ? ` \u2192 ${repo}` : ""}:`);
1139
1061
  try {
1140
- const outcomes = await ensureTokensForTool(cwd, toolInfo, {
1141
- verify: !args.includes("--no-verify")
1142
- });
1143
- const missing = outcomes.filter((o) => o.outcome === "missing").map((o) => o.envVar);
1144
- if (missing.length) {
1145
- console.log(
1146
- `! missing token(s): ${missing.join(", ")} \u2014 set in .greenlight/secrets.env, then \`greenlight secrets sync\``
1147
- );
1148
- }
1062
+ await gatherSecrets(name, repo, flag2(args, "--env"));
1149
1063
  } catch (e) {
1150
- console.log(`\u2716 ${e instanceof Error ? e.message : String(e)}`);
1064
+ console.log(`\u2716 secrets gather: ${e instanceof Error ? e.message : String(e)}`);
1065
+ console.log(` retry: greenlight secrets gather ${name}${repo ? ` --repo ${repo}` : ""}`);
1151
1066
  }
1152
1067
  }
1153
- materializeAgentKit(cwd, toolInfo);
1154
1068
  console.log(`
1155
- Next:
1069
+ Next:${gather ? "" : `
1070
+ greenlight secrets gather ${name}${repo ? ` --repo ${repo}` : " --repo <owner/repo>"} # this tool's keys \u2192 GitHub`}
1156
1071
  review infra/${name}.tf, then commit + push \u2192 CI (infra.yml) runs \`terraform apply\`
1157
1072
  greenlight preview ${name} # local build + serve + verify`);
1158
1073
  }
1159
1074
 
1160
1075
  // src/commands/adopt.ts
1161
1076
  import { execFileSync as execFileSync2 } from "child_process";
1162
- import { cpSync as cpSync3, existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync5, readdirSync, writeFileSync as writeFileSync4 } from "fs";
1163
- import { join as join2, resolve as resolve7 } from "path";
1077
+ import { cpSync as cpSync3, existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync4, readdirSync, writeFileSync as writeFileSync3 } from "fs";
1078
+ import { join as join2, resolve as resolve6 } from "path";
1164
1079
  var REF = MODULE_REF;
1165
1080
  function flag3(args, name) {
1166
1081
  const i = args.indexOf(name);
@@ -1176,7 +1091,7 @@ function mergePackageJson(existing, repoName, vendor) {
1176
1091
  }
1177
1092
  function vendorDeps(vendorDir) {
1178
1093
  const out = {};
1179
- if (!existsSync7(vendorDir)) return out;
1094
+ if (!existsSync6(vendorDir)) return out;
1180
1095
  for (const f of readdirSync(vendorDir)) {
1181
1096
  if (!f.endsWith(".tgz")) continue;
1182
1097
  const base = f.replace(/-\d+\.\d+\.\d+(-[\w.]+)?\.tgz$/, "");
@@ -1515,12 +1430,12 @@ export default [api, ...agentWeb];
1515
1430
  `;
1516
1431
  }
1517
1432
  function writeIfAbsent(path, contents, label) {
1518
- if (existsSync7(path)) {
1433
+ if (existsSync6(path)) {
1519
1434
  console.log(`\xB7 ${label} exists \u2014 left as-is`);
1520
1435
  return;
1521
1436
  }
1522
- mkdirSync4(resolve7(path, ".."), { recursive: true });
1523
- writeFileSync4(path, contents);
1437
+ mkdirSync3(resolve6(path, ".."), { recursive: true });
1438
+ writeFileSync3(path, contents);
1524
1439
  console.log(`\u2714 ${label}`);
1525
1440
  }
1526
1441
  async function adoptCommand(args) {
@@ -1556,10 +1471,10 @@ async function adoptWrapper(ctx) {
1556
1471
  const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
1557
1472
  const cwd = process.cwd();
1558
1473
  const toolRel = `tools/${name}`;
1559
- const dest = resolve7(cwd, toolRel);
1474
+ const dest = resolve6(cwd, toolRel);
1560
1475
  console.log(`adopting "${name}" (${lane}/${target}) as the submodule ${toolRel}
1561
1476
  `);
1562
- if (!existsSync7(dest)) {
1477
+ if (!existsSync6(dest)) {
1563
1478
  try {
1564
1479
  execFileSync2("git", ["submodule", "add", repoArg, toolRel], { cwd, stdio: "inherit" });
1565
1480
  console.log(`\u2714 git submodule add ${repoArg} ${toolRel}`);
@@ -1584,7 +1499,7 @@ async function adoptWrapper(ctx) {
1584
1499
  external: true,
1585
1500
  adopted: true
1586
1501
  });
1587
- writeFileSync4(regPath, serializeConfig(nextReg));
1502
+ writeFileSync3(regPath, serializeConfig(nextReg));
1588
1503
  console.log(
1589
1504
  `\u2714 ${existed ? "updated" : "registered"} "${name}" (external, dir ${toolRel}) in the wrapper manifest`
1590
1505
  );
@@ -1602,7 +1517,7 @@ async function adoptWrapper(ctx) {
1602
1517
  );
1603
1518
  }
1604
1519
  const providers = providersForTool({ target, data });
1605
- if (existsSync7(join2(cwd, "infra/main.tf")) && providers.some((p) => p !== "cloudflare" && p !== "github")) {
1520
+ if (existsSync6(join2(cwd, "infra/main.tf")) && providers.some((p) => p !== "cloudflare" && p !== "github")) {
1606
1521
  console.log(`\xB7 ensure infra/main.tf declares provider(s): ${providers.join(", ")}`);
1607
1522
  }
1608
1523
  materializeAgentKit(dest, { target, data });
@@ -1648,9 +1563,9 @@ Next:
1648
1563
  }
1649
1564
  async function adoptStandalone(ctx) {
1650
1565
  const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
1651
- const repo = resolve7(process.cwd(), repoArg);
1652
- if (!existsSync7(repo)) throw new Error(`no such repo: ${repo} (--standalone needs a local path)`);
1653
- const regVendor = resolve7(process.cwd(), "vendor");
1566
+ const repo = resolve6(process.cwd(), repoArg);
1567
+ if (!existsSync6(repo)) throw new Error(`no such repo: ${repo} (--standalone needs a local path)`);
1568
+ const regVendor = resolve6(process.cwd(), "vendor");
1654
1569
  const vendor = vendorDeps(regVendor);
1655
1570
  if (Object.keys(vendor).length === 0) {
1656
1571
  throw new Error(
@@ -1668,15 +1583,15 @@ async function adoptStandalone(ctx) {
1668
1583
  );
1669
1584
  const slug = parseRepo(safeGit(repo, ["remote", "get-url", "origin"])) ?? `OWNER/${name}`;
1670
1585
  const pkgPath = join2(repo, "package.json");
1671
- const existingPkg = existsSync7(pkgPath) ? JSON.parse(readFileSync5(pkgPath, "utf8")) : null;
1672
- writeFileSync4(
1586
+ const existingPkg = existsSync6(pkgPath) ? JSON.parse(readFileSync4(pkgPath, "utf8")) : null;
1587
+ writeFileSync3(
1673
1588
  pkgPath,
1674
1589
  `${JSON.stringify(mergePackageJson(existingPkg, name, vendor), null, 2)}
1675
1590
  `
1676
1591
  );
1677
1592
  console.log("\u2714 package.json (merged framework deps + overrides)");
1678
1593
  const repoVendor = join2(repo, "vendor");
1679
- mkdirSync4(repoVendor, { recursive: true });
1594
+ mkdirSync3(repoVendor, { recursive: true });
1680
1595
  for (const f of readdirSync(regVendor)) {
1681
1596
  if (f.endsWith(".tgz")) cpSync3(join2(regVendor, f), join2(repoVendor, f));
1682
1597
  }
@@ -1710,7 +1625,7 @@ async function adoptStandalone(ctx) {
1710
1625
  external: true,
1711
1626
  adopted: true
1712
1627
  });
1713
- writeFileSync4(regPath, serializeConfig(nextReg));
1628
+ writeFileSync3(regPath, serializeConfig(nextReg));
1714
1629
  console.log(`\u2714 registered "${name}" in ${regPath.replace(`${process.cwd()}/`, "")} (external)`);
1715
1630
  console.log(`
1716
1631
  Next (in the adopted repo):
@@ -1723,20 +1638,20 @@ Note: deploying ${target} needs the ${target} adapter (workers is built; oci/ver
1723
1638
  }
1724
1639
  function addGreenlightScript(dir) {
1725
1640
  const pkgPath = join2(dir, "package.json");
1726
- if (!existsSync7(pkgPath)) {
1641
+ if (!existsSync6(pkgPath)) {
1727
1642
  console.log(
1728
1643
  "\xB7 no package.json (non-Node tool) \u2014 run the loop via `npx @rtrentjones/greenlight`"
1729
1644
  );
1730
1645
  return;
1731
1646
  }
1732
- const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
1647
+ const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
1733
1648
  pkg.scripts = { ...pkg.scripts ?? {} };
1734
1649
  if (pkg.scripts.greenlight) {
1735
1650
  console.log("\xB7 package.json already has a greenlight script");
1736
1651
  return;
1737
1652
  }
1738
1653
  pkg.scripts.greenlight = "npx @rtrentjones/greenlight";
1739
- writeFileSync4(pkgPath, `${JSON.stringify(pkg, null, 2)}
1654
+ writeFileSync3(pkgPath, `${JSON.stringify(pkg, null, 2)}
1740
1655
  `);
1741
1656
  console.log("\u2714 package.json (greenlight script \u2014 runnable via npx post-publish)");
1742
1657
  }
@@ -1879,10 +1794,10 @@ async function deployCommand(args) {
1879
1794
  }
1880
1795
 
1881
1796
  // src/commands/doctor.ts
1882
- import { existsSync as existsSync8 } from "fs";
1797
+ import { existsSync as existsSync7 } from "fs";
1883
1798
  import { join as join4 } from "path";
1884
1799
  function dirCheck(label, dir) {
1885
- return existsSync8(dir) ? { name: `${label}: directory`, status: "ok" } : { name: `${label}: directory`, status: "fail", detail: `missing ${dir}` };
1800
+ return existsSync7(dir) ? { name: `${label}: directory`, status: "ok" } : { name: `${label}: directory`, status: "fail", detail: `missing ${dir}` };
1886
1801
  }
1887
1802
  function runDoctor(config, root) {
1888
1803
  const checks = [];
@@ -1904,8 +1819,8 @@ function runDoctor(config, root) {
1904
1819
  const vc = join4(dir, "verify.config.ts");
1905
1820
  checks.push({
1906
1821
  name: `${t.name}: verify.config.ts`,
1907
- status: existsSync8(vc) ? "ok" : "warn",
1908
- detail: existsSync8(vc) ? void 0 : "missing \u2014 verify will use the lane default"
1822
+ status: existsSync7(vc) ? "ok" : "warn",
1823
+ detail: existsSync7(vc) ? void 0 : "missing \u2014 verify will use the lane default"
1909
1824
  });
1910
1825
  }
1911
1826
  }
@@ -1951,10 +1866,180 @@ ${failed === 0 ? "\u2714 no failures" : `\u2718 ${failed} failure(s)`}`);
1951
1866
  import { existsSync as existsSync9, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
1952
1867
  import { resolve as resolve8 } from "path";
1953
1868
  import { createInterface as createInterface3 } from "readline/promises";
1869
+
1870
+ // src/tokens.ts
1871
+ import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
1872
+ import { resolve as resolve7 } from "path";
1873
+ import { createInterface as createInterface2 } from "readline/promises";
1874
+ var SECRETS_DIR = ".greenlight";
1875
+ var SECRETS_FILE = "secrets.env";
1876
+ function presentEnv(cwd) {
1877
+ const out = {};
1878
+ const p = resolve7(cwd, SECRETS_DIR, SECRETS_FILE);
1879
+ if (existsSync8(p)) {
1880
+ for (const { key, value } of parseSecretsEnv(readFileSync5(p, "utf8"))) out[key] = value;
1881
+ }
1882
+ for (const [k, v] of Object.entries(process.env)) {
1883
+ if (v !== void 0 && !(k in out)) out[k] = v;
1884
+ }
1885
+ return out;
1886
+ }
1887
+ function upsertSecret(cwd, key, value) {
1888
+ const dir = resolve7(cwd, SECRETS_DIR);
1889
+ mkdirSync4(dir, { recursive: true });
1890
+ const p = resolve7(dir, SECRETS_FILE);
1891
+ const lines = existsSync8(p) ? readFileSync5(p, "utf8").split("\n") : [];
1892
+ const idx = lines.findIndex((l) => l.startsWith(`${key}=`));
1893
+ if (idx >= 0) lines[idx] = `${key}=${value}`;
1894
+ else {
1895
+ while (lines.length && (lines[lines.length - 1] ?? "").trim() === "") lines.pop();
1896
+ lines.push(`${key}=${value}`);
1897
+ }
1898
+ writeFileSync4(p, `${lines.join("\n").replace(/\n*$/, "")}
1899
+ `, { mode: 384 });
1900
+ }
1901
+ async function ensureTokensForTool(cwd, tool, opts = {}) {
1902
+ const doVerify = opts.verify !== false;
1903
+ const interactive = Boolean(process.stdin.isTTY);
1904
+ const env = presentEnv(cwd);
1905
+ const results = [];
1906
+ const rl = interactive ? createInterface2({ input: process.stdin, output: process.stdout }) : null;
1907
+ try {
1908
+ for (const spec of tokensForTool(tool)) {
1909
+ let value = env[spec.envVar];
1910
+ if (value) {
1911
+ results.push({ envVar: spec.envVar, outcome: "present" });
1912
+ } else if (rl) {
1913
+ console.log(`
1914
+ ${spec.envVar} \u2014 ${spec.label}`);
1915
+ if (spec.scopes?.length) console.log(` scopes: ${spec.scopes.join(", ")}`);
1916
+ const entered = (await rl.question(` paste value${spec.optional ? " (optional, Enter to skip)" : ""}: `)).trim();
1917
+ if (!entered) {
1918
+ results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
1919
+ continue;
1920
+ }
1921
+ upsertSecret(cwd, spec.envVar, entered);
1922
+ env[spec.envVar] = entered;
1923
+ value = entered;
1924
+ results.push({ envVar: spec.envVar, outcome: "entered" });
1925
+ } else {
1926
+ results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
1927
+ continue;
1928
+ }
1929
+ if (value && doVerify && spec.verify) {
1930
+ let check;
1931
+ try {
1932
+ check = await spec.verify(value, env);
1933
+ } catch (e) {
1934
+ check = { ok: false, detail: e instanceof Error ? e.message : String(e) };
1935
+ }
1936
+ const last = results[results.length - 1];
1937
+ if (last) last.verify = check;
1938
+ if (!check.ok && !spec.optional) {
1939
+ throw new Error(
1940
+ `${spec.envVar} failed verification${check.detail ? ` (${check.detail})` : ""} \u2014 check the token's scopes (${spec.label}).`
1941
+ );
1942
+ }
1943
+ }
1944
+ }
1945
+ } finally {
1946
+ rl?.close();
1947
+ }
1948
+ return results;
1949
+ }
1950
+
1951
+ // src/commands/init.ts
1954
1952
  function flag5(args, name) {
1955
1953
  const i = args.indexOf(name);
1956
1954
  return i >= 0 ? args[i + 1] : void 0;
1957
1955
  }
1956
+ var NPM_DEP = `^${MODULE_REF.replace(/^v/, "")}`;
1957
+ function wrapperPackageJson(name) {
1958
+ return `${JSON.stringify(
1959
+ {
1960
+ name,
1961
+ private: true,
1962
+ type: "module",
1963
+ scripts: { greenlight: "greenlight" },
1964
+ dependencies: { "@rtrentjones/greenlight": NPM_DEP }
1965
+ },
1966
+ null,
1967
+ 2
1968
+ )}
1969
+ `;
1970
+ }
1971
+ var WRAPPER_GITIGNORE = `# Greenlight wrapper
1972
+ node_modules/
1973
+ .greenlight/ # gathered tokens \u2014 never committed
1974
+ .terraform/
1975
+ *.tfplan
1976
+ tf.plan
1977
+ dist/
1978
+ `;
1979
+ var WRAPPER_MISE = `[tools]
1980
+ node = "24"
1981
+ pnpm = "10.12.1"
1982
+ `;
1983
+ function wrapperInfraYml() {
1984
+ return `name: infra
1985
+
1986
+ # Apply the wrapper's Terraform on push to main (paths: infra/**). State + locking are in HCP
1987
+ # Terraform (set the cloud{} block in infra/main.tf); the run happens here with provider creds
1988
+ # from GitHub Actions secrets (populate them per tool via \`greenlight secrets gather\`).
1989
+ on:
1990
+ push:
1991
+ branches: [main]
1992
+ paths: ['infra/**']
1993
+ workflow_dispatch:
1994
+
1995
+ permissions:
1996
+ contents: read
1997
+
1998
+ concurrency:
1999
+ group: infra
2000
+ cancel-in-progress: false # never interrupt an in-flight apply
2001
+
2002
+ jobs:
2003
+ apply:
2004
+ runs-on: ubuntu-latest
2005
+ env:
2006
+ TF_TOKEN_app_terraform_io: \${{ secrets.TF_API_TOKEN }} # HCP state backend auth
2007
+ GITHUB_TOKEN: \${{ github.token }} # github provider (branch/protection); creates nothing risky
2008
+ CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
2009
+ TF_VAR_cloudflare_zone_id: \${{ secrets.TF_VAR_CLOUDFLARE_ZONE_ID }}
2010
+ TF_VAR_cloudflare_account_id: \${{ secrets.TF_VAR_CLOUDFLARE_ACCOUNT_ID }}
2011
+ # vercel (target: vercel tools)
2012
+ VERCEL_API_TOKEN: \${{ secrets.VERCEL_API_TOKEN }}
2013
+ # supabase (data: supabase tools)
2014
+ SUPABASE_ACCESS_TOKEN: \${{ secrets.SUPABASE_ACCESS_TOKEN }}
2015
+ TF_VAR_supabase_database_password: \${{ secrets.TF_VAR_SUPABASE_DATABASE_PASSWORD }}
2016
+ # oci (target: oci tools) \u2014 VCN/subnet/AD are IaC; only auth (+ optional compartment) here
2017
+ TF_VAR_oci_tenancy_ocid: \${{ secrets.TF_VAR_OCI_TENANCY_OCID }}
2018
+ TF_VAR_oci_user_ocid: \${{ secrets.TF_VAR_OCI_USER_OCID }}
2019
+ TF_VAR_oci_fingerprint: \${{ secrets.TF_VAR_OCI_FINGERPRINT }}
2020
+ TF_VAR_oci_private_key: \${{ secrets.TF_VAR_OCI_PRIVATE_KEY }}
2021
+ TF_VAR_oci_region: \${{ secrets.TF_VAR_OCI_REGION }}
2022
+ TF_VAR_oci_compartment_id: \${{ secrets.TF_VAR_OCI_COMPARTMENT_ID }}
2023
+ steps:
2024
+ - uses: actions/checkout@v4
2025
+ - uses: hashicorp/setup-terraform@v3
2026
+ with:
2027
+ terraform_version: '~1.10'
2028
+ terraform_wrapper: false
2029
+ - run: terraform -chdir=infra init -input=false
2030
+ - run: terraform -chdir=infra plan -input=false -out=tf.plan
2031
+ - run: terraform -chdir=infra apply -input=false tf.plan
2032
+ `;
2033
+ }
2034
+ function scaffoldIfAbsent(path, contents, label) {
2035
+ if (existsSync9(path)) {
2036
+ console.log(`\xB7 ${label} exists \u2014 left as-is`);
2037
+ return;
2038
+ }
2039
+ mkdirSync5(resolve8(path, ".."), { recursive: true });
2040
+ writeFileSync5(path, contents);
2041
+ console.log(`\u2714 wrote ${label}`);
2042
+ }
1958
2043
  var TOKEN_FLAGS = {
1959
2044
  "--cf-token": "CLOUDFLARE_API_TOKEN",
1960
2045
  "--github-token": "GITHUB_TOKEN",
@@ -1979,6 +2064,16 @@ async function initCommand(args) {
1979
2064
  }
1980
2065
  writeFileSync5(configPath, scaffoldConfig(domain));
1981
2066
  console.log(`\u2714 wrote greenlight.config.ts (domain: ${domain})`);
2067
+ const repoName = domain.replace(/\./g, "-");
2068
+ scaffoldIfAbsent(
2069
+ resolve8(cwd, ".github/workflows/infra.yml"),
2070
+ wrapperInfraYml(),
2071
+ ".github/workflows/infra.yml (HCP-backed terraform apply on push)"
2072
+ );
2073
+ scaffoldIfAbsent(resolve8(cwd, ".gitignore"), WRAPPER_GITIGNORE, ".gitignore");
2074
+ scaffoldIfAbsent(resolve8(cwd, "package.json"), wrapperPackageJson(repoName), "package.json");
2075
+ scaffoldIfAbsent(resolve8(cwd, "mise.toml"), WRAPPER_MISE, "mise.toml");
2076
+ scaffoldIfAbsent(resolve8(cwd, ".node-version"), "24\n", ".node-version");
1982
2077
  const secrets = [];
1983
2078
  for (const [f, key] of Object.entries(TOKEN_FLAGS)) {
1984
2079
  const v = flag5(args, f);
@@ -2012,10 +2107,11 @@ async function initCommand(args) {
2012
2107
  }
2013
2108
  console.log(`
2014
2109
  Next:
2015
- greenlight add <name> --lane mcp --target oci # scaffold a tool${pushed ? "" : "\n greenlight secrets sync # push tokens to GitHub Actions"}
2016
- greenlight doctor # check consistency
2017
- terraform -chdir=infra apply # branches/protection/DNS (module "repo"/"tool")
2018
- greenlight deploy blog --env prod # first live deploy`);
2110
+ 1. greenlight add <name> --lane <lane> --target <target> # scaffold a tool, emit infra, and
2111
+ # gather THAT tool's keys \u2192 GitHub${pushed ? "" : "\n (run `greenlight secrets sync` if base tokens were not pushed)"}
2112
+ 2. set the HCP backend (cloud{} org + workspace) in infra/main.tf # docs/terraform-state-r2.md
2113
+ 3. commit + push \u2192 CI (.github/workflows/infra.yml) runs \`terraform apply\`
2114
+ 4. greenlight verify <name> --env prod | greenlight doctor`);
2019
2115
  }
2020
2116
 
2021
2117
  // src/commands/preview.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtrentjones/greenlight",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "Greenlight CLI — setup and lifecycle for the harness.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,10 +31,10 @@
31
31
  "@anthropic-ai/sdk": "^0.69.0"
32
32
  },
33
33
  "devDependencies": {
34
- "@rtrentjones/greenlight-adapters": "0.2.4",
35
34
  "@rtrentjones/greenlight-loop": "0.2.4",
36
35
  "@rtrentjones/greenlight-shared": "0.2.4",
37
- "@rtrentjones/greenlight-verify": "0.2.4"
36
+ "@rtrentjones/greenlight-verify": "0.2.4",
37
+ "@rtrentjones/greenlight-adapters": "0.2.4"
38
38
  },
39
39
  "scripts": {
40
40
  "build": "node scripts/copy-assets.mjs && tsup",