@getvitops/utils 8.0.1 → 8.2.0-alpha.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/CHANGELOG.md +289 -8
- package/dist/{adc-BFrzpMHq.d.mts → adc-BoEoogyo.d.mts} +2 -3
- package/dist/adc-BoEoogyo.d.mts.map +1 -0
- package/dist/ads/index.d.mts +50 -17
- package/dist/ads/index.d.mts.map +1 -1
- package/dist/ads/index.mjs +77 -8
- package/dist/ads/index.mjs.map +1 -1
- package/dist/cloudflare-BiK61Oq4.d.mts.map +1 -1
- package/dist/{cloudflare-CgAsXiqe.mjs → cloudflare-CD8vwrgj.mjs} +2 -24
- package/dist/cloudflare-CD8vwrgj.mjs.map +1 -0
- package/dist/color/index.d.mts +34 -35
- package/dist/color/index.d.mts.map +1 -1
- package/dist/color/index.mjs +3 -1
- package/dist/color/index.mjs.map +1 -1
- package/dist/crm/index.d.mts +288 -0
- package/dist/crm/index.d.mts.map +1 -0
- package/dist/crm/index.mjs +731 -0
- package/dist/crm/index.mjs.map +1 -0
- package/dist/email/index.d.mts +216 -0
- package/dist/email/index.d.mts.map +1 -0
- package/dist/email/index.mjs +331 -0
- package/dist/email/index.mjs.map +1 -0
- package/dist/favicon.d.mts +8 -9
- package/dist/favicon.d.mts.map +1 -1
- package/dist/git/index.d.mts +65 -0
- package/dist/git/index.d.mts.map +1 -0
- package/dist/git/index.mjs +107 -0
- package/dist/git/index.mjs.map +1 -0
- package/dist/hosting/index.d.mts +184 -0
- package/dist/hosting/index.d.mts.map +1 -0
- package/dist/hosting/index.mjs +553 -0
- package/dist/hosting/index.mjs.map +1 -0
- package/dist/identity-BoRddTx3.mjs +35 -0
- package/dist/identity-BoRddTx3.mjs.map +1 -0
- package/dist/index.d.mts +24 -25
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +5 -5
- package/dist/index.mjs.map +1 -1
- package/dist/indexing/index.d.mts +23 -24
- package/dist/indexing/index.d.mts.map +1 -1
- package/dist/media/index.d.mts +24 -24
- package/dist/media/index.d.mts.map +1 -1
- package/dist/media/index.mjs +3 -3
- package/dist/media/index.mjs.map +1 -1
- package/dist/notify/index.d.mts +26 -15
- package/dist/notify/index.d.mts.map +1 -1
- package/dist/notify/index.mjs +50 -20
- package/dist/notify/index.mjs.map +1 -1
- package/dist/onboarding/index.d.mts +10 -141
- package/dist/onboarding/index.d.mts.map +1 -1
- package/dist/onboarding/index.mjs +2 -144
- package/dist/onboarding/index.mjs.map +1 -1
- package/dist/plan-BLKikoOG.mjs +150 -0
- package/dist/plan-BLKikoOG.mjs.map +1 -0
- package/dist/plan-Hq8Pb0Uw.d.mts +133 -0
- package/dist/plan-Hq8Pb0Uw.d.mts.map +1 -0
- package/dist/plan-format-A18SvJ7C.mjs +24 -0
- package/dist/plan-format-A18SvJ7C.mjs.map +1 -0
- package/dist/reviews/index.d.mts +12 -13
- package/dist/reviews/index.d.mts.map +1 -1
- package/dist/schema/index.d.mts +7 -7
- package/dist/schema/index.d.mts.map +1 -1
- package/dist/security/index.d.mts +2 -2
- package/dist/security/index.d.mts.map +1 -1
- package/dist/{token-Bz_-igw-.d.mts → token-DmmoQ7um.d.mts} +6 -3
- package/dist/token-DmmoQ7um.d.mts.map +1 -0
- package/dist/tracking/index.d.mts +43 -17
- package/dist/tracking/index.d.mts.map +1 -1
- package/dist/tracking/index.mjs +2 -1
- package/dist/tracking/index.mjs.map +1 -1
- package/dist/{types-CHHjzRrv.d.mts → types-7ELci8RQ.d.mts} +4 -2
- package/dist/types-7ELci8RQ.d.mts.map +1 -0
- package/package.json +18 -4
- package/dist/adc-BFrzpMHq.d.mts.map +0 -1
- package/dist/cloudflare-CgAsXiqe.mjs.map +0 -1
- package/dist/token-Bz_-igw-.d.mts.map +0 -1
- package/dist/types-CHHjzRrv.d.mts.map +0 -1
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
import { t as table } from "../plan-format-A18SvJ7C.mjs";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
//#region src/hosting/plan.ts
|
|
6
|
+
/**
|
|
7
|
+
* Every decision `vitops setup` makes, as one pure function.
|
|
8
|
+
*
|
|
9
|
+
* Same split as `onboarding/plan.ts` and `ads/plan.ts`: this module decides, from
|
|
10
|
+
* observed live state, what's left to do; `wrangler.ts`/`github.ts` execute a plan
|
|
11
|
+
* and decide nothing. That is what makes `--dry` a complete account of a run and
|
|
12
|
+
* `--check` an honest drift report with no network beyond the observation step.
|
|
13
|
+
*
|
|
14
|
+
* Idempotency lives here: a step whose desired state already holds resolves to
|
|
15
|
+
* `skip`, so a re-run of a fully-provisioned project is all skips by construction.
|
|
16
|
+
*
|
|
17
|
+
* `blocked` (ads' addition to onboarding's three-state `StepAction`) covers the one
|
|
18
|
+
* thing this command genuinely cannot do: mint a Cloudflare API token. That is
|
|
19
|
+
* config nothing here can supply — surfaced as blocked, carrying the manual path,
|
|
20
|
+
* never as a failure.
|
|
21
|
+
*
|
|
22
|
+
* Never destructive: no step here deletes a database, a bucket, a branch, a
|
|
23
|
+
* protection rule, or a DNS record — matching `onboarding/cloudflare.ts`'s DNS
|
|
24
|
+
* verbs, which have no update or delete either.
|
|
25
|
+
*/
|
|
26
|
+
const SECRETS_FOR = (setup) => setup.platform === "cloudflare" ? [
|
|
27
|
+
"DEV_D1_ID",
|
|
28
|
+
"CLOUDFLARE_ACCOUNT_ID",
|
|
29
|
+
"CLOUDFLARE_API_TOKEN"
|
|
30
|
+
] : [];
|
|
31
|
+
/** Plan the whole hosting sequence from setup + observed state. */
|
|
32
|
+
function plan(setup, state) {
|
|
33
|
+
const notes = [];
|
|
34
|
+
const repo = state.originRemote && state.repoNwo ? {
|
|
35
|
+
action: "skip",
|
|
36
|
+
detail: `repo already exists (${state.repoNwo})`
|
|
37
|
+
} : {
|
|
38
|
+
action: "create",
|
|
39
|
+
detail: `create GitHub repo "${setup.projectName}" (${setup.repoVisibility})`
|
|
40
|
+
};
|
|
41
|
+
const branches = state.branches.includes("main") && state.branches.includes("dev") ? {
|
|
42
|
+
action: "skip",
|
|
43
|
+
detail: "main + dev already exist"
|
|
44
|
+
} : {
|
|
45
|
+
action: "create",
|
|
46
|
+
detail: "push main + dev"
|
|
47
|
+
};
|
|
48
|
+
const defaultBranch = state.defaultBranch === "dev" ? {
|
|
49
|
+
action: "skip",
|
|
50
|
+
detail: "dev is already the default branch"
|
|
51
|
+
} : {
|
|
52
|
+
action: "update",
|
|
53
|
+
detail: "make dev the default branch"
|
|
54
|
+
};
|
|
55
|
+
const mainProtection = state.mainProtected ? {
|
|
56
|
+
action: "skip",
|
|
57
|
+
detail: "main is already protected"
|
|
58
|
+
} : {
|
|
59
|
+
action: "blocked",
|
|
60
|
+
detail: "requires GitHub Pro/Team on a private repo — set it manually in Settings → Branches (PR + review + the \"ci\" check required) if this stays blocked"
|
|
61
|
+
};
|
|
62
|
+
const prodEnvironment = state.environments.includes("production") ? {
|
|
63
|
+
action: "skip",
|
|
64
|
+
detail: "production environment already exists"
|
|
65
|
+
} : {
|
|
66
|
+
action: "blocked",
|
|
67
|
+
detail: "requires GitHub Pro/Team on a private repo — create a \"production\" environment gated on your admin collaborators manually if this stays blocked"
|
|
68
|
+
};
|
|
69
|
+
const devEnvironment = state.environments.includes("dev") ? {
|
|
70
|
+
action: "skip",
|
|
71
|
+
detail: "dev environment already exists"
|
|
72
|
+
} : {
|
|
73
|
+
action: "create",
|
|
74
|
+
detail: "create \"dev\" environment with no required reviewers (stays open)"
|
|
75
|
+
};
|
|
76
|
+
const d1 = setup.platform !== "cloudflare" || !setup.devResources ? {
|
|
77
|
+
action: "skip",
|
|
78
|
+
detail: "not a cloudflare project, or dev resources not requested"
|
|
79
|
+
} : state.d1Present ? {
|
|
80
|
+
action: "skip",
|
|
81
|
+
detail: `dev D1 already exists (${state.d1Id ?? "id unknown"})`
|
|
82
|
+
} : {
|
|
83
|
+
action: "create",
|
|
84
|
+
detail: `create D1 database "${setup.projectName}-dev"`
|
|
85
|
+
};
|
|
86
|
+
const r2 = setup.platform !== "cloudflare" || !setup.devResources ? {
|
|
87
|
+
action: "skip",
|
|
88
|
+
detail: "not a cloudflare project, or dev resources not requested"
|
|
89
|
+
} : state.r2Present ? {
|
|
90
|
+
action: "skip",
|
|
91
|
+
detail: `dev R2 bucket already exists`
|
|
92
|
+
} : {
|
|
93
|
+
action: "create",
|
|
94
|
+
detail: `create R2 bucket "${setup.projectName}-media-dev"`
|
|
95
|
+
};
|
|
96
|
+
const secrets = {};
|
|
97
|
+
for (const name of SECRETS_FOR(setup)) {
|
|
98
|
+
if (!state.ghAuthed || !state.originRemote) {
|
|
99
|
+
secrets[name] = {
|
|
100
|
+
action: "blocked",
|
|
101
|
+
detail: "no GitHub repo wired up yet — run the repo step first"
|
|
102
|
+
};
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (state.secretsPresent.includes(name)) {
|
|
106
|
+
secrets[name] = {
|
|
107
|
+
action: "skip",
|
|
108
|
+
detail: "already set"
|
|
109
|
+
};
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (name === "CLOUDFLARE_API_TOKEN") {
|
|
113
|
+
secrets[name] = {
|
|
114
|
+
action: "blocked",
|
|
115
|
+
detail: "tokens cannot be created via any API — create one at https://dash.cloudflare.com/profile/api-tokens (Workers + D1 + R2 edit), then `gh secret set CLOUDFLARE_API_TOKEN`"
|
|
116
|
+
};
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
secrets[name] = (name === "DEV_D1_ID" ? state.d1Id : name === "CLOUDFLARE_ACCOUNT_ID" ? state.accountId : void 0) ? {
|
|
120
|
+
action: "create",
|
|
121
|
+
detail: `set from provisioned/observed value`
|
|
122
|
+
} : {
|
|
123
|
+
action: "blocked",
|
|
124
|
+
detail: "value not yet known — provision the resource first"
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (!state.wranglerAuthed && setup.platform === "cloudflare" && setup.devResources) notes.push("wrangler is not logged in — run `npx wrangler login` first");
|
|
128
|
+
if (!state.ghAuthed) notes.push("gh is not logged in — run `gh auth login` first");
|
|
129
|
+
return {
|
|
130
|
+
repo,
|
|
131
|
+
branches,
|
|
132
|
+
defaultBranch,
|
|
133
|
+
mainProtection,
|
|
134
|
+
prodEnvironment,
|
|
135
|
+
devEnvironment,
|
|
136
|
+
d1,
|
|
137
|
+
r2,
|
|
138
|
+
secrets,
|
|
139
|
+
notes
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const allSteps = (p) => [
|
|
143
|
+
p.repo,
|
|
144
|
+
p.branches,
|
|
145
|
+
p.defaultBranch,
|
|
146
|
+
p.mainProtection,
|
|
147
|
+
p.prodEnvironment,
|
|
148
|
+
p.devEnvironment,
|
|
149
|
+
p.d1,
|
|
150
|
+
p.r2,
|
|
151
|
+
...Object.values(p.secrets)
|
|
152
|
+
];
|
|
153
|
+
/** Any step that isn't a skip — i.e. the live state has drifted from desired. */
|
|
154
|
+
function hasDrift(p) {
|
|
155
|
+
return allSteps(p).some((s) => s.action !== "skip");
|
|
156
|
+
}
|
|
157
|
+
/** Every blocked step, with a name — what `--check` reports and the CLI surfaces. */
|
|
158
|
+
function blockedSteps(p) {
|
|
159
|
+
return [
|
|
160
|
+
["repo", p.repo],
|
|
161
|
+
["branches", p.branches],
|
|
162
|
+
["defaultBranch", p.defaultBranch],
|
|
163
|
+
["mainProtection", p.mainProtection],
|
|
164
|
+
["prodEnvironment", p.prodEnvironment],
|
|
165
|
+
["devEnvironment", p.devEnvironment],
|
|
166
|
+
["d1", p.d1],
|
|
167
|
+
["r2", p.r2],
|
|
168
|
+
...Object.entries(p.secrets).map(([name, s]) => [`secret:${name}`, s])
|
|
169
|
+
].filter(([, s]) => s.action === "blocked").map(([name, s]) => ({
|
|
170
|
+
name,
|
|
171
|
+
detail: s.detail
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
/** Render a plan as the block `--dry` and `--check` print. */
|
|
175
|
+
function formatPlan(p) {
|
|
176
|
+
const glyph = (s) => s.action === "skip" ? "·" : s.action === "blocked" ? "?" : s.action === "update" ? "~" : "+";
|
|
177
|
+
const lines = [];
|
|
178
|
+
lines.push(`${glyph(p.repo)} repo ${p.repo.detail}`);
|
|
179
|
+
lines.push(`${glyph(p.branches)} branches ${p.branches.detail}`);
|
|
180
|
+
lines.push(`${glyph(p.defaultBranch)} default branch ${p.defaultBranch.detail}`);
|
|
181
|
+
lines.push(`${glyph(p.mainProtection)} main protection ${p.mainProtection.detail}`);
|
|
182
|
+
lines.push(`${glyph(p.prodEnvironment)} production env ${p.prodEnvironment.detail}`);
|
|
183
|
+
lines.push(`${glyph(p.devEnvironment)} dev env ${p.devEnvironment.detail}`);
|
|
184
|
+
lines.push(`${glyph(p.d1)} dev D1 ${p.d1.detail}`);
|
|
185
|
+
lines.push(`${glyph(p.r2)} dev R2 ${p.r2.detail}`);
|
|
186
|
+
for (const [name, s] of Object.entries(p.secrets)) lines.push(`${glyph(s)} secret ${name}${" ".repeat(Math.max(1, 11 - name.length))}${s.detail}`);
|
|
187
|
+
for (const n of p.notes) lines.push(`! ${n}`);
|
|
188
|
+
return lines.join("\n");
|
|
189
|
+
}
|
|
190
|
+
/** Render the end-of-run summary: one row per step, plus blocked reminders. */
|
|
191
|
+
function formatSummary(results) {
|
|
192
|
+
const headers = [
|
|
193
|
+
"STEP",
|
|
194
|
+
"STATUS",
|
|
195
|
+
"DETAIL"
|
|
196
|
+
];
|
|
197
|
+
const rows = results.map((r) => [
|
|
198
|
+
r.step,
|
|
199
|
+
r.status,
|
|
200
|
+
r.detail
|
|
201
|
+
]);
|
|
202
|
+
const reminders = results.filter((r) => r.status === "blocked").map((r) => ` ! ${r.step}: ${r.detail}`);
|
|
203
|
+
return table(headers, rows, reminders);
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Plan the domain-consistency check on its own — it has no executor, only an
|
|
207
|
+
* observation (`wrangler.jsonc` + the config), and it is never applied by this
|
|
208
|
+
* command. See `SETUP.html`'s "Domain strategy" stage: the actual cutover is a
|
|
209
|
+
* human, deliberate action.
|
|
210
|
+
*/
|
|
211
|
+
function planDomainConsistency(state) {
|
|
212
|
+
if (Boolean(state.customDomain)) {
|
|
213
|
+
if (!state.canonical || state.canonical !== `https://${state.customDomain}`) return {
|
|
214
|
+
action: "blocked",
|
|
215
|
+
detail: `wrangler.jsonc names a custom domain (${state.customDomain}) but site.domains.canonical doesn't match — a visitor sees the new site while <link rel=canonical> still points at the old one`
|
|
216
|
+
};
|
|
217
|
+
if (state.robots?.includes("noindex")) return {
|
|
218
|
+
action: "blocked",
|
|
219
|
+
detail: "custom domain is live but robots is still \"noindex,nofollow\" — flip it back after cutover"
|
|
220
|
+
};
|
|
221
|
+
return {
|
|
222
|
+
action: "skip",
|
|
223
|
+
detail: "custom domain configured and consistent"
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (!state.robots?.includes("noindex")) return {
|
|
227
|
+
action: "blocked",
|
|
228
|
+
detail: "still on *.workers.dev but robots does not say \"noindex,nofollow\" — a workers.dev URL is indexable, and this is what stops search engines treating it as a duplicate of the real site"
|
|
229
|
+
};
|
|
230
|
+
return {
|
|
231
|
+
action: "skip",
|
|
232
|
+
detail: "workers.dev phase, correctly noindexed"
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
//#endregion
|
|
237
|
+
//#region src/hosting/wrangler.ts
|
|
238
|
+
/**
|
|
239
|
+
* Cloudflare resources — dev D1 + R2, plus the account id. Executes a plan and
|
|
240
|
+
* decides nothing; every observation and mutation is a structured result, never a
|
|
241
|
+
* throw, so the CLI decides what a failure means.
|
|
242
|
+
*
|
|
243
|
+
* Shells out to `wrangler` and reads `gh`'s account-id output — there is no REST
|
|
244
|
+
* alternative that doesn't also need the same credential wrangler already holds
|
|
245
|
+
* from `wrangler login`. Same class of executor as `@getvitops/utils/media`
|
|
246
|
+
* shelling to `ffmpeg`: an external tool, not an npm dependency, with `execImpl`
|
|
247
|
+
* injected so the parsing is testable without a real Cloudflare account.
|
|
248
|
+
*/
|
|
249
|
+
const exec = promisify(execFile);
|
|
250
|
+
/** The real executor — `execFile` with an argv array, never a shell string. */
|
|
251
|
+
const realExec = async (cmd, args) => {
|
|
252
|
+
try {
|
|
253
|
+
const { stdout } = await exec(cmd, args, { maxBuffer: 16777216 });
|
|
254
|
+
return {
|
|
255
|
+
ok: true,
|
|
256
|
+
stdout
|
|
257
|
+
};
|
|
258
|
+
} catch (err) {
|
|
259
|
+
const e = err;
|
|
260
|
+
return {
|
|
261
|
+
ok: false,
|
|
262
|
+
stdout: e.stdout ?? "",
|
|
263
|
+
message: e.stderr || e.message || "command failed"
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
/** `wrangler whoami` — auth check + the 32-hex account id, from one call. */
|
|
268
|
+
async function wranglerWhoami(execImpl = realExec) {
|
|
269
|
+
const res = await execImpl("npx", ["wrangler", "whoami"]);
|
|
270
|
+
if (!res.ok) return { authed: false };
|
|
271
|
+
const accountId = /\b([0-9a-f]{32})\b/.exec(res.stdout)?.[1];
|
|
272
|
+
return {
|
|
273
|
+
authed: true,
|
|
274
|
+
...accountId ? { accountId } : {}
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Find an existing dev D1 database's id by name.
|
|
279
|
+
*
|
|
280
|
+
* `d1 create`'s own stdout carries the id, but its format has drifted across
|
|
281
|
+
* wrangler versions before — a re-list is the one thing guaranteed to still hold
|
|
282
|
+
* the answer, so both the "does it exist" check and the post-create fallback go
|
|
283
|
+
* through this.
|
|
284
|
+
*/
|
|
285
|
+
async function findD1(name, execImpl = realExec) {
|
|
286
|
+
const res = await execImpl("npx", [
|
|
287
|
+
"wrangler",
|
|
288
|
+
"d1",
|
|
289
|
+
"list",
|
|
290
|
+
"--json"
|
|
291
|
+
]);
|
|
292
|
+
if (!res.ok) return void 0;
|
|
293
|
+
try {
|
|
294
|
+
return JSON.parse(res.stdout).find((d) => d.name === name)?.uuid;
|
|
295
|
+
} catch {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/** Create a dev D1 database, then resolve its id via `findD1` (see its docblock). */
|
|
300
|
+
async function createD1(name, execImpl = realExec) {
|
|
301
|
+
const res = await execImpl("npx", [
|
|
302
|
+
"wrangler",
|
|
303
|
+
"d1",
|
|
304
|
+
"create",
|
|
305
|
+
name
|
|
306
|
+
]);
|
|
307
|
+
if (!res.ok) return {
|
|
308
|
+
ok: false,
|
|
309
|
+
message: res.message
|
|
310
|
+
};
|
|
311
|
+
const id = /"database_id":\s*"([0-9a-f-]{36})"/.exec(res.stdout)?.[1] ?? await findD1(name, execImpl);
|
|
312
|
+
if (!id) return {
|
|
313
|
+
ok: false,
|
|
314
|
+
message: "created, but could not determine the database id"
|
|
315
|
+
};
|
|
316
|
+
return {
|
|
317
|
+
ok: true,
|
|
318
|
+
id
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
async function findR2Bucket(name, execImpl = realExec) {
|
|
322
|
+
const res = await execImpl("npx", [
|
|
323
|
+
"wrangler",
|
|
324
|
+
"r2",
|
|
325
|
+
"bucket",
|
|
326
|
+
"list"
|
|
327
|
+
]);
|
|
328
|
+
if (!res.ok) return false;
|
|
329
|
+
return new RegExp(`name:\\s+${name}\\s*$`, "m").test(res.stdout);
|
|
330
|
+
}
|
|
331
|
+
async function createR2Bucket(name, execImpl = realExec) {
|
|
332
|
+
const res = await execImpl("npx", [
|
|
333
|
+
"wrangler",
|
|
334
|
+
"r2",
|
|
335
|
+
"bucket",
|
|
336
|
+
"create",
|
|
337
|
+
name
|
|
338
|
+
]);
|
|
339
|
+
return res.ok ? { ok: true } : {
|
|
340
|
+
ok: false,
|
|
341
|
+
message: res.message
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
//#endregion
|
|
346
|
+
//#region src/hosting/github.ts
|
|
347
|
+
async function ghAuthed(execImpl = realExec) {
|
|
348
|
+
return (await execImpl("gh", ["auth", "status"])).ok;
|
|
349
|
+
}
|
|
350
|
+
async function originRemote(execImpl = realExec) {
|
|
351
|
+
const res = await execImpl("git", [
|
|
352
|
+
"remote",
|
|
353
|
+
"get-url",
|
|
354
|
+
"origin"
|
|
355
|
+
]);
|
|
356
|
+
return res.ok ? res.stdout.trim() : void 0;
|
|
357
|
+
}
|
|
358
|
+
async function repoNwo(execImpl = realExec) {
|
|
359
|
+
const res = await execImpl("gh", [
|
|
360
|
+
"repo",
|
|
361
|
+
"view",
|
|
362
|
+
"--json",
|
|
363
|
+
"nameWithOwner"
|
|
364
|
+
]);
|
|
365
|
+
if (!res.ok) return void 0;
|
|
366
|
+
try {
|
|
367
|
+
return JSON.parse(res.stdout).nameWithOwner;
|
|
368
|
+
} catch {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
async function branches(execImpl = realExec) {
|
|
373
|
+
const res = await execImpl("git", [
|
|
374
|
+
"branch",
|
|
375
|
+
"--list",
|
|
376
|
+
"--format=%(refname:short)"
|
|
377
|
+
]);
|
|
378
|
+
if (!res.ok) return [];
|
|
379
|
+
return res.stdout.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
380
|
+
}
|
|
381
|
+
async function defaultBranch(nwo, execImpl = realExec) {
|
|
382
|
+
const res = await execImpl("gh", [
|
|
383
|
+
"repo",
|
|
384
|
+
"view",
|
|
385
|
+
nwo,
|
|
386
|
+
"--json",
|
|
387
|
+
"defaultBranchRef"
|
|
388
|
+
]);
|
|
389
|
+
if (!res.ok) return void 0;
|
|
390
|
+
try {
|
|
391
|
+
return JSON.parse(res.stdout).defaultBranchRef?.name;
|
|
392
|
+
} catch {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
async function mainProtected(nwo, execImpl = realExec) {
|
|
397
|
+
return (await execImpl("gh", ["api", `repos/${nwo}/branches/main/protection`])).ok;
|
|
398
|
+
}
|
|
399
|
+
async function environments(nwo, execImpl = realExec) {
|
|
400
|
+
const res = await execImpl("gh", ["api", `repos/${nwo}/environments`]);
|
|
401
|
+
if (!res.ok) return [];
|
|
402
|
+
try {
|
|
403
|
+
return (JSON.parse(res.stdout).environments ?? []).map((e) => e.name);
|
|
404
|
+
} catch {
|
|
405
|
+
return [];
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
async function secretsPresent(nwo, execImpl = realExec) {
|
|
409
|
+
const res = await execImpl("gh", ["api", `repos/${nwo}/actions/secrets`]);
|
|
410
|
+
if (!res.ok) return [];
|
|
411
|
+
try {
|
|
412
|
+
return (JSON.parse(res.stdout).secrets ?? []).map((s) => s.name);
|
|
413
|
+
} catch {
|
|
414
|
+
return [];
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
async function createRepo(name, visibility, execImpl = realExec) {
|
|
418
|
+
const res = await execImpl("gh", [
|
|
419
|
+
"repo",
|
|
420
|
+
"create",
|
|
421
|
+
name,
|
|
422
|
+
`--${visibility}`,
|
|
423
|
+
"--source=.",
|
|
424
|
+
"--remote=origin"
|
|
425
|
+
]);
|
|
426
|
+
return res.ok ? { ok: true } : {
|
|
427
|
+
ok: false,
|
|
428
|
+
message: res.message
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
async function pushBranches(execImpl = realExec) {
|
|
432
|
+
const res = await execImpl("git", [
|
|
433
|
+
"push",
|
|
434
|
+
"-u",
|
|
435
|
+
"origin",
|
|
436
|
+
"main",
|
|
437
|
+
"dev"
|
|
438
|
+
]);
|
|
439
|
+
return res.ok ? { ok: true } : {
|
|
440
|
+
ok: false,
|
|
441
|
+
message: res.message
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
async function setDefaultBranch(nwo, branch, execImpl = realExec) {
|
|
445
|
+
const res = await execImpl("gh", [
|
|
446
|
+
"repo",
|
|
447
|
+
"edit",
|
|
448
|
+
nwo,
|
|
449
|
+
`--default-branch=${branch}`
|
|
450
|
+
]);
|
|
451
|
+
return res.ok ? { ok: true } : {
|
|
452
|
+
ok: false,
|
|
453
|
+
message: res.message
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* `PUT .../branches/main/protection`. A 403 on Pro/Team-gated repos is expected.
|
|
458
|
+
*
|
|
459
|
+
* Every field goes through `gh api`'s `-f`/`-F` form flags rather than a JSON body
|
|
460
|
+
* on stdin — `gh api` supports nested keys (`a[b]=c`) and repeated array keys
|
|
461
|
+
* (`a[]=x`) this way, so no request needs a second I/O channel `ExecImpl` would
|
|
462
|
+
* have to carry just for this one call.
|
|
463
|
+
*/
|
|
464
|
+
async function protectMain(nwo, execImpl = realExec) {
|
|
465
|
+
const res = await execImpl("gh", [
|
|
466
|
+
"api",
|
|
467
|
+
"--method",
|
|
468
|
+
"PUT",
|
|
469
|
+
`repos/${nwo}/branches/main/protection`,
|
|
470
|
+
"-F",
|
|
471
|
+
"required_status_checks[strict]=true",
|
|
472
|
+
"-f",
|
|
473
|
+
"required_status_checks[contexts][]=ci",
|
|
474
|
+
"-F",
|
|
475
|
+
"enforce_admins=false",
|
|
476
|
+
"-F",
|
|
477
|
+
"required_pull_request_reviews[required_approving_review_count]=1",
|
|
478
|
+
"-F",
|
|
479
|
+
"restrictions=null",
|
|
480
|
+
"-F",
|
|
481
|
+
"allow_force_pushes=false",
|
|
482
|
+
"-F",
|
|
483
|
+
"allow_deletions=false"
|
|
484
|
+
]);
|
|
485
|
+
return res.ok ? { ok: true } : {
|
|
486
|
+
ok: false,
|
|
487
|
+
message: res.message
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
/** The repo's admin collaborators — numeric user ids, which is what the
|
|
491
|
+
* environment-reviewers API wants (not logins). */
|
|
492
|
+
async function adminCollaboratorIds(nwo, execImpl = realExec) {
|
|
493
|
+
const res = await execImpl("gh", ["api", `repos/${nwo}/collaborators?permission=admin`]);
|
|
494
|
+
if (!res.ok) return [];
|
|
495
|
+
try {
|
|
496
|
+
return JSON.parse(res.stdout).map((c) => c.id);
|
|
497
|
+
} catch {
|
|
498
|
+
return [];
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/** `PUT .../environments/production`, reviewers = current admin collaborators. */
|
|
502
|
+
async function createProdEnvironment(nwo, reviewerUserIds, execImpl = realExec) {
|
|
503
|
+
const res = await execImpl("gh", [
|
|
504
|
+
"api",
|
|
505
|
+
"--method",
|
|
506
|
+
"PUT",
|
|
507
|
+
`repos/${nwo}/environments/production`,
|
|
508
|
+
"-f",
|
|
509
|
+
`deployment_branch_policy[protected_branches]=true`,
|
|
510
|
+
"-f",
|
|
511
|
+
`deployment_branch_policy[custom_branch_policies]=false`,
|
|
512
|
+
...reviewerUserIds.flatMap((id) => [
|
|
513
|
+
"-F",
|
|
514
|
+
`reviewers[][id]=${id}`,
|
|
515
|
+
"-f",
|
|
516
|
+
"reviewers[][type]=User"
|
|
517
|
+
])
|
|
518
|
+
]);
|
|
519
|
+
return res.ok ? { ok: true } : {
|
|
520
|
+
ok: false,
|
|
521
|
+
message: res.message
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
/** `PUT .../environments/dev` with no reviewers — deliberately left open. */
|
|
525
|
+
async function createDevEnvironment(nwo, execImpl = realExec) {
|
|
526
|
+
const res = await execImpl("gh", [
|
|
527
|
+
"api",
|
|
528
|
+
"--method",
|
|
529
|
+
"PUT",
|
|
530
|
+
`repos/${nwo}/environments/dev`
|
|
531
|
+
]);
|
|
532
|
+
return res.ok ? { ok: true } : {
|
|
533
|
+
ok: false,
|
|
534
|
+
message: res.message
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
async function setSecret(name, value, execImpl = realExec) {
|
|
538
|
+
const res = await execImpl("gh", [
|
|
539
|
+
"secret",
|
|
540
|
+
"set",
|
|
541
|
+
name,
|
|
542
|
+
"--body",
|
|
543
|
+
value
|
|
544
|
+
]);
|
|
545
|
+
return res.ok ? { ok: true } : {
|
|
546
|
+
ok: false,
|
|
547
|
+
message: res.message
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
//#endregion
|
|
552
|
+
export { adminCollaboratorIds, blockedSteps, branches, createD1, createDevEnvironment, createProdEnvironment, createR2Bucket, createRepo, defaultBranch, environments, findD1, findR2Bucket, formatPlan, formatSummary, ghAuthed, hasDrift, mainProtected, originRemote, plan, planDomainConsistency, protectMain, pushBranches, realExec, repoNwo, secretsPresent, setDefaultBranch, setSecret, wranglerWhoami };
|
|
553
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../src/hosting/plan.ts","../../src/hosting/wrangler.ts","../../src/hosting/github.ts"],"sourcesContent":["/**\n * Every decision `vitops setup` makes, as one pure function.\n *\n * Same split as `onboarding/plan.ts` and `ads/plan.ts`: this module decides, from\n * observed live state, what's left to do; `wrangler.ts`/`github.ts` execute a plan\n * and decide nothing. That is what makes `--dry` a complete account of a run and\n * `--check` an honest drift report with no network beyond the observation step.\n *\n * Idempotency lives here: a step whose desired state already holds resolves to\n * `skip`, so a re-run of a fully-provisioned project is all skips by construction.\n *\n * `blocked` (ads' addition to onboarding's three-state `StepAction`) covers the one\n * thing this command genuinely cannot do: mint a Cloudflare API token. That is\n * config nothing here can supply — surfaced as blocked, carrying the manual path,\n * never as a failure.\n *\n * Never destructive: no step here deletes a database, a bucket, a branch, a\n * protection rule, or a DNS record — matching `onboarding/cloudflare.ts`'s DNS\n * verbs, which have no update or delete either.\n */\nimport { table } from '../plan-format.ts';\nimport type { DomainState, HostingSetup, HostingState } from './types.ts';\n\nexport type StepAction = 'create' | 'update' | 'skip' | 'blocked';\n\nexport interface StepPlan {\n action: StepAction;\n detail: string;\n}\n\nexport interface HostingPlan {\n repo: StepPlan;\n branches: StepPlan;\n defaultBranch: StepPlan;\n mainProtection: StepPlan;\n prodEnvironment: StepPlan;\n devEnvironment: StepPlan;\n d1: StepPlan;\n r2: StepPlan;\n /** One step per repo secret this command sets. */\n secrets: Record<string, StepPlan>;\n notes: string[];\n}\n\nconst SECRETS_FOR = (setup: HostingSetup): string[] =>\n setup.platform === 'cloudflare'\n ? ['DEV_D1_ID', 'CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_API_TOKEN']\n : [];\n\n/** Plan the whole hosting sequence from setup + observed state. */\nexport function plan(setup: HostingSetup, state: HostingState): HostingPlan {\n const notes: string[] = [];\n\n const repo: StepPlan =\n state.originRemote && state.repoNwo\n ? { action: 'skip', detail: `repo already exists (${state.repoNwo})` }\n : {\n action: 'create',\n detail: `create GitHub repo \"${setup.projectName}\" (${setup.repoVisibility})`,\n };\n\n const branches: StepPlan =\n state.branches.includes('main') && state.branches.includes('dev')\n ? { action: 'skip', detail: 'main + dev already exist' }\n : { action: 'create', detail: 'push main + dev' };\n\n const defaultBranch: StepPlan =\n state.defaultBranch === 'dev'\n ? { action: 'skip', detail: 'dev is already the default branch' }\n : { action: 'update', detail: 'make dev the default branch' };\n\n const mainProtection: StepPlan = state.mainProtected\n ? { action: 'skip', detail: 'main is already protected' }\n : {\n action: 'blocked',\n detail:\n 'requires GitHub Pro/Team on a private repo — set it manually in Settings → Branches ' +\n '(PR + review + the \"ci\" check required) if this stays blocked',\n };\n\n const prodEnvironment: StepPlan = state.environments.includes('production')\n ? { action: 'skip', detail: 'production environment already exists' }\n : {\n action: 'blocked',\n detail:\n 'requires GitHub Pro/Team on a private repo — create a \"production\" environment ' +\n 'gated on your admin collaborators manually if this stays blocked',\n };\n\n const devEnvironment: StepPlan = state.environments.includes('dev')\n ? { action: 'skip', detail: 'dev environment already exists' }\n : {\n action: 'create',\n detail: 'create \"dev\" environment with no required reviewers (stays open)',\n };\n\n const d1: StepPlan =\n setup.platform !== 'cloudflare' || !setup.devResources\n ? { action: 'skip', detail: 'not a cloudflare project, or dev resources not requested' }\n : state.d1Present\n ? { action: 'skip', detail: `dev D1 already exists (${state.d1Id ?? 'id unknown'})` }\n : { action: 'create', detail: `create D1 database \"${setup.projectName}-dev\"` };\n\n const r2: StepPlan =\n setup.platform !== 'cloudflare' || !setup.devResources\n ? { action: 'skip', detail: 'not a cloudflare project, or dev resources not requested' }\n : state.r2Present\n ? { action: 'skip', detail: `dev R2 bucket already exists` }\n : { action: 'create', detail: `create R2 bucket \"${setup.projectName}-media-dev\"` };\n\n const secrets: Record<string, StepPlan> = {};\n for (const name of SECRETS_FOR(setup)) {\n if (!state.ghAuthed || !state.originRemote) {\n secrets[name] = {\n action: 'blocked',\n detail: 'no GitHub repo wired up yet — run the repo step first',\n };\n continue;\n }\n if (state.secretsPresent.includes(name)) {\n secrets[name] = { action: 'skip', detail: 'already set' };\n continue;\n }\n if (name === 'CLOUDFLARE_API_TOKEN') {\n secrets[name] = {\n action: 'blocked',\n detail:\n 'tokens cannot be created via any API — create one at ' +\n 'https://dash.cloudflare.com/profile/api-tokens (Workers + D1 + R2 edit), then ' +\n '`gh secret set CLOUDFLARE_API_TOKEN`',\n };\n continue;\n }\n const value =\n name === 'DEV_D1_ID'\n ? state.d1Id\n : name === 'CLOUDFLARE_ACCOUNT_ID'\n ? state.accountId\n : undefined;\n secrets[name] = value\n ? { action: 'create', detail: `set from provisioned/observed value` }\n : { action: 'blocked', detail: 'value not yet known — provision the resource first' };\n }\n\n if (!state.wranglerAuthed && setup.platform === 'cloudflare' && setup.devResources)\n notes.push('wrangler is not logged in — run `npx wrangler login` first');\n if (!state.ghAuthed) notes.push('gh is not logged in — run `gh auth login` first');\n\n return {\n repo,\n branches,\n defaultBranch,\n mainProtection,\n prodEnvironment,\n devEnvironment,\n d1,\n r2,\n secrets,\n notes,\n };\n}\n\nconst allSteps = (p: HostingPlan): StepPlan[] => [\n p.repo,\n p.branches,\n p.defaultBranch,\n p.mainProtection,\n p.prodEnvironment,\n p.devEnvironment,\n p.d1,\n p.r2,\n ...Object.values(p.secrets),\n];\n\n/** Any step that isn't a skip — i.e. the live state has drifted from desired. */\nexport function hasDrift(p: HostingPlan): boolean {\n return allSteps(p).some((s) => s.action !== 'skip');\n}\n\n/** Every blocked step, with a name — what `--check` reports and the CLI surfaces. */\nexport function blockedSteps(p: HostingPlan): { name: string; detail: string }[] {\n const named: [string, StepPlan][] = [\n ['repo', p.repo],\n ['branches', p.branches],\n ['defaultBranch', p.defaultBranch],\n ['mainProtection', p.mainProtection],\n ['prodEnvironment', p.prodEnvironment],\n ['devEnvironment', p.devEnvironment],\n ['d1', p.d1],\n ['r2', p.r2],\n ...Object.entries(p.secrets).map(([name, s]) => [`secret:${name}`, s] as [string, StepPlan]),\n ];\n return named\n .filter(([, s]) => s.action === 'blocked')\n .map(([name, s]) => ({ name, detail: s.detail }));\n}\n\n/** Render a plan as the block `--dry` and `--check` print. */\nexport function formatPlan(p: HostingPlan): string {\n const glyph = (s: StepPlan) =>\n s.action === 'skip' ? '·' : s.action === 'blocked' ? '?' : s.action === 'update' ? '~' : '+';\n const lines: string[] = [];\n lines.push(`${glyph(p.repo)} repo ${p.repo.detail}`);\n lines.push(`${glyph(p.branches)} branches ${p.branches.detail}`);\n lines.push(`${glyph(p.defaultBranch)} default branch ${p.defaultBranch.detail}`);\n lines.push(`${glyph(p.mainProtection)} main protection ${p.mainProtection.detail}`);\n lines.push(`${glyph(p.prodEnvironment)} production env ${p.prodEnvironment.detail}`);\n lines.push(`${glyph(p.devEnvironment)} dev env ${p.devEnvironment.detail}`);\n lines.push(`${glyph(p.d1)} dev D1 ${p.d1.detail}`);\n lines.push(`${glyph(p.r2)} dev R2 ${p.r2.detail}`);\n for (const [name, s] of Object.entries(p.secrets))\n lines.push(`${glyph(s)} secret ${name}${' '.repeat(Math.max(1, 11 - name.length))}${s.detail}`);\n for (const n of p.notes) lines.push(`! ${n}`);\n return lines.join('\\n');\n}\n\n/** The outcome of one run, for the summary table. */\nexport interface HostingResult {\n step: string;\n status: 'created' | 'updated' | 'present' | 'blocked' | 'failed';\n detail: string;\n}\n\n/** Render the end-of-run summary: one row per step, plus blocked reminders. */\nexport function formatSummary(results: HostingResult[]): string {\n const headers = ['STEP', 'STATUS', 'DETAIL'];\n const rows = results.map((r) => [r.step, r.status, r.detail]);\n const reminders = results\n .filter((r) => r.status === 'blocked')\n .map((r) => ` ! ${r.step}: ${r.detail}`);\n return table(headers, rows, reminders);\n}\n\n/**\n * Plan the domain-consistency check on its own — it has no executor, only an\n * observation (`wrangler.jsonc` + the config), and it is never applied by this\n * command. See `SETUP.html`'s \"Domain strategy\" stage: the actual cutover is a\n * human, deliberate action.\n */\nexport function planDomainConsistency(state: DomainState): StepPlan {\n const cutover = Boolean(state.customDomain);\n if (cutover) {\n if (!state.canonical || state.canonical !== `https://${state.customDomain}`)\n return {\n action: 'blocked',\n detail: `wrangler.jsonc names a custom domain (${state.customDomain}) but site.domains.canonical doesn't match — a visitor sees the new site while <link rel=canonical> still points at the old one`,\n };\n if (state.robots?.includes('noindex'))\n return {\n action: 'blocked',\n detail:\n 'custom domain is live but robots is still \"noindex,nofollow\" — flip it back after cutover',\n };\n return { action: 'skip', detail: 'custom domain configured and consistent' };\n }\n if (!state.robots?.includes('noindex'))\n return {\n action: 'blocked',\n detail:\n 'still on *.workers.dev but robots does not say \"noindex,nofollow\" — a workers.dev URL is indexable, and this is what stops search engines treating it as a duplicate of the real site',\n };\n return { action: 'skip', detail: 'workers.dev phase, correctly noindexed' };\n}\n","/**\n * Cloudflare resources — dev D1 + R2, plus the account id. Executes a plan and\n * decides nothing; every observation and mutation is a structured result, never a\n * throw, so the CLI decides what a failure means.\n *\n * Shells out to `wrangler` and reads `gh`'s account-id output — there is no REST\n * alternative that doesn't also need the same credential wrangler already holds\n * from `wrangler login`. Same class of executor as `@getvitops/utils/media`\n * shelling to `ffmpeg`: an external tool, not an npm dependency, with `execImpl`\n * injected so the parsing is testable without a real Cloudflare account.\n */\nimport { execFile } from 'node:child_process';\nimport { promisify } from 'node:util';\n\nconst exec = promisify(execFile);\n\nexport interface ExecResult {\n ok: boolean;\n stdout: string;\n message?: string | undefined;\n}\n\nexport type ExecImpl = (cmd: string, args: string[]) => Promise<ExecResult>;\n\n/** The real executor — `execFile` with an argv array, never a shell string. */\nexport const realExec: ExecImpl = async (cmd, args) => {\n try {\n const { stdout } = await exec(cmd, args, { maxBuffer: 16 * 1024 * 1024 });\n return { ok: true, stdout };\n } catch (err) {\n const e = err as { stdout?: string; stderr?: string; message?: string };\n return {\n ok: false,\n stdout: e.stdout ?? '',\n message: e.stderr || e.message || 'command failed',\n };\n }\n};\n\n/** `wrangler whoami` — auth check + the 32-hex account id, from one call. */\nexport async function wranglerWhoami(\n execImpl: ExecImpl = realExec,\n): Promise<{ authed: boolean; accountId?: string }> {\n const res = await execImpl('npx', ['wrangler', 'whoami']);\n if (!res.ok) return { authed: false };\n const accountId = /\\b([0-9a-f]{32})\\b/.exec(res.stdout)?.[1];\n return { authed: true, ...(accountId ? { accountId } : {}) };\n}\n\n/**\n * Find an existing dev D1 database's id by name.\n *\n * `d1 create`'s own stdout carries the id, but its format has drifted across\n * wrangler versions before — a re-list is the one thing guaranteed to still hold\n * the answer, so both the \"does it exist\" check and the post-create fallback go\n * through this.\n */\nexport async function findD1(\n name: string,\n execImpl: ExecImpl = realExec,\n): Promise<string | undefined> {\n const res = await execImpl('npx', ['wrangler', 'd1', 'list', '--json']);\n if (!res.ok) return undefined;\n try {\n const list = JSON.parse(res.stdout) as { name: string; uuid: string }[];\n return list.find((d) => d.name === name)?.uuid;\n } catch {\n return undefined;\n }\n}\n\n/** Create a dev D1 database, then resolve its id via `findD1` (see its docblock). */\nexport async function createD1(\n name: string,\n execImpl: ExecImpl = realExec,\n): Promise<{ ok: boolean; id?: string | undefined; message?: string | undefined }> {\n const res = await execImpl('npx', ['wrangler', 'd1', 'create', name]);\n if (!res.ok) return { ok: false, message: res.message };\n const inline = /\"database_id\":\\s*\"([0-9a-f-]{36})\"/.exec(res.stdout)?.[1];\n const id = inline ?? (await findD1(name, execImpl));\n if (!id) return { ok: false, message: 'created, but could not determine the database id' };\n return { ok: true, id };\n}\n\nexport async function findR2Bucket(name: string, execImpl: ExecImpl = realExec): Promise<boolean> {\n const res = await execImpl('npx', ['wrangler', 'r2', 'bucket', 'list']);\n if (!res.ok) return false;\n return new RegExp(`name:\\\\s+${name}\\\\s*$`, 'm').test(res.stdout);\n}\n\nexport async function createR2Bucket(\n name: string,\n execImpl: ExecImpl = realExec,\n): Promise<{ ok: boolean; message?: string | undefined }> {\n const res = await execImpl('npx', ['wrangler', 'r2', 'bucket', 'create', name]);\n return res.ok ? { ok: true } : { ok: false, message: res.message };\n}\n","/**\n * GitHub repo, branches, environments and secrets. Executes a plan and decides\n * nothing — see `wrangler.ts`'s docblock for why this shells out (`gh` carries the\n * auth; there is no token-free REST alternative) and for the `execImpl` seam.\n *\n * Absorbs the behaviour of the templates' old `scripts/init-github.mjs`:\n * `dev` is left with **no required reviewers**, deliberately — that is what keeps\n * it the unprotected integration branch. `main` protection and the `production`\n * environment's reviewer policy both 403 on a private repo without GitHub\n * Pro/Team; that is reported as `blocked`, not `failed` — see `plan.ts`.\n */\nimport type { ExecImpl } from './wrangler.ts';\nimport { realExec } from './wrangler.ts';\n\nexport async function ghAuthed(execImpl: ExecImpl = realExec): Promise<boolean> {\n return (await execImpl('gh', ['auth', 'status'])).ok;\n}\n\nexport async function originRemote(execImpl: ExecImpl = realExec): Promise<string | undefined> {\n const res = await execImpl('git', ['remote', 'get-url', 'origin']);\n return res.ok ? res.stdout.trim() : undefined;\n}\n\nexport async function repoNwo(execImpl: ExecImpl = realExec): Promise<string | undefined> {\n const res = await execImpl('gh', ['repo', 'view', '--json', 'nameWithOwner']);\n if (!res.ok) return undefined;\n try {\n return (JSON.parse(res.stdout) as { nameWithOwner: string }).nameWithOwner;\n } catch {\n return undefined;\n }\n}\n\nexport async function branches(execImpl: ExecImpl = realExec): Promise<string[]> {\n const res = await execImpl('git', ['branch', '--list', '--format=%(refname:short)']);\n if (!res.ok) return [];\n return res.stdout\n .split('\\n')\n .map((l) => l.trim())\n .filter(Boolean);\n}\n\nexport async function defaultBranch(\n nwo: string,\n execImpl: ExecImpl = realExec,\n): Promise<string | undefined> {\n const res = await execImpl('gh', ['repo', 'view', nwo, '--json', 'defaultBranchRef']);\n if (!res.ok) return undefined;\n try {\n return (JSON.parse(res.stdout) as { defaultBranchRef?: { name: string } }).defaultBranchRef\n ?.name;\n } catch {\n return undefined;\n }\n}\n\nexport async function mainProtected(nwo: string, execImpl: ExecImpl = realExec): Promise<boolean> {\n return (await execImpl('gh', ['api', `repos/${nwo}/branches/main/protection`])).ok;\n}\n\nexport async function environments(nwo: string, execImpl: ExecImpl = realExec): Promise<string[]> {\n const res = await execImpl('gh', ['api', `repos/${nwo}/environments`]);\n if (!res.ok) return [];\n try {\n return (\n (JSON.parse(res.stdout) as { environments?: { name: string }[] }).environments ?? []\n ).map((e) => e.name);\n } catch {\n return [];\n }\n}\n\nexport async function secretsPresent(\n nwo: string,\n execImpl: ExecImpl = realExec,\n): Promise<string[]> {\n const res = await execImpl('gh', ['api', `repos/${nwo}/actions/secrets`]);\n if (!res.ok) return [];\n try {\n return ((JSON.parse(res.stdout) as { secrets?: { name: string }[] }).secrets ?? []).map(\n (s) => s.name,\n );\n } catch {\n return [];\n }\n}\n\n// ── Mutations ─────────────────────────────────────────────────────────────────\n\nexport async function createRepo(\n name: string,\n visibility: 'public' | 'private',\n execImpl: ExecImpl = realExec,\n): Promise<{ ok: boolean; message?: string | undefined }> {\n const res = await execImpl('gh', [\n 'repo',\n 'create',\n name,\n `--${visibility}`,\n '--source=.',\n '--remote=origin',\n ]);\n return res.ok ? { ok: true } : { ok: false, message: res.message };\n}\n\nexport async function pushBranches(\n execImpl: ExecImpl = realExec,\n): Promise<{ ok: boolean; message?: string | undefined }> {\n const res = await execImpl('git', ['push', '-u', 'origin', 'main', 'dev']);\n return res.ok ? { ok: true } : { ok: false, message: res.message };\n}\n\nexport async function setDefaultBranch(\n nwo: string,\n branch: string,\n execImpl: ExecImpl = realExec,\n): Promise<{ ok: boolean; message?: string | undefined }> {\n const res = await execImpl('gh', ['repo', 'edit', nwo, `--default-branch=${branch}`]);\n return res.ok ? { ok: true } : { ok: false, message: res.message };\n}\n\n/**\n * `PUT .../branches/main/protection`. A 403 on Pro/Team-gated repos is expected.\n *\n * Every field goes through `gh api`'s `-f`/`-F` form flags rather than a JSON body\n * on stdin — `gh api` supports nested keys (`a[b]=c`) and repeated array keys\n * (`a[]=x`) this way, so no request needs a second I/O channel `ExecImpl` would\n * have to carry just for this one call.\n */\nexport async function protectMain(\n nwo: string,\n execImpl: ExecImpl = realExec,\n): Promise<{ ok: boolean; message?: string | undefined }> {\n const res = await execImpl('gh', [\n 'api',\n '--method',\n 'PUT',\n `repos/${nwo}/branches/main/protection`,\n '-F',\n 'required_status_checks[strict]=true',\n '-f',\n 'required_status_checks[contexts][]=ci',\n '-F',\n 'enforce_admins=false',\n '-F',\n 'required_pull_request_reviews[required_approving_review_count]=1',\n '-F',\n 'restrictions=null',\n '-F',\n 'allow_force_pushes=false',\n '-F',\n 'allow_deletions=false',\n ]);\n return res.ok ? { ok: true } : { ok: false, message: res.message };\n}\n\n/** The repo's admin collaborators — numeric user ids, which is what the\n * environment-reviewers API wants (not logins). */\nexport async function adminCollaboratorIds(\n nwo: string,\n execImpl: ExecImpl = realExec,\n): Promise<number[]> {\n const res = await execImpl('gh', ['api', `repos/${nwo}/collaborators?permission=admin`]);\n if (!res.ok) return [];\n try {\n return (JSON.parse(res.stdout) as { id: number }[]).map((c) => c.id);\n } catch {\n return [];\n }\n}\n\n/** `PUT .../environments/production`, reviewers = current admin collaborators. */\nexport async function createProdEnvironment(\n nwo: string,\n reviewerUserIds: number[],\n execImpl: ExecImpl = realExec,\n): Promise<{ ok: boolean; message?: string | undefined }> {\n const res = await execImpl('gh', [\n 'api',\n '--method',\n 'PUT',\n `repos/${nwo}/environments/production`,\n '-f',\n `deployment_branch_policy[protected_branches]=true`,\n '-f',\n `deployment_branch_policy[custom_branch_policies]=false`,\n ...reviewerUserIds.flatMap((id) => [\n '-F',\n `reviewers[][id]=${id}`,\n '-f',\n 'reviewers[][type]=User',\n ]),\n ]);\n return res.ok ? { ok: true } : { ok: false, message: res.message };\n}\n\n/** `PUT .../environments/dev` with no reviewers — deliberately left open. */\nexport async function createDevEnvironment(\n nwo: string,\n execImpl: ExecImpl = realExec,\n): Promise<{ ok: boolean; message?: string | undefined }> {\n const res = await execImpl('gh', ['api', '--method', 'PUT', `repos/${nwo}/environments/dev`]);\n return res.ok ? { ok: true } : { ok: false, message: res.message };\n}\n\nexport async function setSecret(\n name: string,\n value: string,\n execImpl: ExecImpl = realExec,\n): Promise<{ ok: boolean; message?: string | undefined }> {\n const res = await execImpl('gh', ['secret', 'set', name, '--body', value]);\n return res.ok ? { ok: true } : { ok: false, message: res.message };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//#region src/tracking/identity.ts
|
|
2
|
+
function first(formData, ...keys) {
|
|
3
|
+
for (const k of keys) {
|
|
4
|
+
const v = formData[k]?.trim();
|
|
5
|
+
if (v) return v;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
function identifySubmitter(formData) {
|
|
9
|
+
const fullNameField = first(formData, "name", "full_name", "fullName", "fullname", "your-name");
|
|
10
|
+
const firstName = first(formData, "first_name", "firstName", "firstname", "given_name");
|
|
11
|
+
const lastName = first(formData, "last_name", "lastName", "lastname", "family_name", "surname");
|
|
12
|
+
const email = first(formData, "email", "email_address", "emailAddress");
|
|
13
|
+
const phone = first(formData, "phone", "phone_number", "phoneNumber", "tel");
|
|
14
|
+
const company = first(formData, "company", "company_name", "organization", "business");
|
|
15
|
+
let derivedFirst = firstName;
|
|
16
|
+
let derivedLast = lastName;
|
|
17
|
+
if (!derivedFirst && !derivedLast && fullNameField) {
|
|
18
|
+
const parts = fullNameField.split(/\s+/);
|
|
19
|
+
derivedFirst = parts[0];
|
|
20
|
+
derivedLast = parts.slice(1).join(" ") || void 0;
|
|
21
|
+
}
|
|
22
|
+
const joined = [firstName, lastName].filter(Boolean).join(" ");
|
|
23
|
+
return {
|
|
24
|
+
firstName: derivedFirst,
|
|
25
|
+
lastName: derivedLast,
|
|
26
|
+
fullName: fullNameField || joined || email || "Unknown",
|
|
27
|
+
email,
|
|
28
|
+
phone,
|
|
29
|
+
company
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
//#endregion
|
|
34
|
+
export { identifySubmitter as t };
|
|
35
|
+
//# sourceMappingURL=identity-BoRddTx3.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"identity-BoRddTx3.mjs","names":[],"sources":["../src/tracking/identity.ts"],"sourcesContent":["/**\n * Pulling a submitter's identity out of a form's fields.\n *\n * Extracted from `notify/render.ts`'s `submitterName()`, which needed only the\n * display name for a subject line. `crm/map.ts` needs the whole set — first\n * name, last name, email, phone, company — to fill a CRM record, and a form's\n * field names vary the same way `submitterName()` already accounted for\n * (`name` vs `first_name`/`last_name` vs neither). Two independent cascades\n * over the same handful of aliases is exactly the failure `render.ts`'s own\n * comment records: a downstream site had five of six forms carrying\n * `first_name`/`last_name`, and a cascade that only checked `name` read every\n * one of them as \"Unknown\". Keep this the one place that knows the aliases;\n * `submitterName()` and `crm/map.ts` both delegate to it.\n */\n\nexport interface SubmitterIdentity {\n firstName?: string | undefined;\n lastName?: string | undefined;\n /** The best available display name — a submitted full name, first + last\n * joined, or the email address as a last resort. Never empty. */\n fullName: string;\n email?: string | undefined;\n phone?: string | undefined;\n company?: string | undefined;\n}\n\nfunction first(formData: Record<string, string>, ...keys: string[]): string | undefined {\n for (const k of keys) {\n const v = formData[k]?.trim();\n if (v) return v;\n }\n return undefined;\n}\n\nexport function identifySubmitter(formData: Record<string, string>): SubmitterIdentity {\n const fullNameField = first(formData, 'name', 'full_name', 'fullName', 'fullname', 'your-name');\n const firstName = first(formData, 'first_name', 'firstName', 'firstname', 'given_name');\n const lastName = first(formData, 'last_name', 'lastName', 'lastname', 'family_name', 'surname');\n const email = first(formData, 'email', 'email_address', 'emailAddress');\n const phone = first(formData, 'phone', 'phone_number', 'phoneNumber', 'tel');\n const company = first(formData, 'company', 'company_name', 'organization', 'business');\n\n let derivedFirst = firstName;\n let derivedLast = lastName;\n if (!derivedFirst && !derivedLast && fullNameField) {\n const parts = fullNameField.split(/\\s+/);\n derivedFirst = parts[0];\n derivedLast = parts.slice(1).join(' ') || undefined;\n }\n\n const joined = [firstName, lastName].filter(Boolean).join(' ');\n const fullName = fullNameField || joined || email || 'Unknown';\n\n return {\n firstName: derivedFirst,\n lastName: derivedLast,\n fullName,\n email,\n phone,\n company,\n };\n}\n"],"mappings":";AA0BA,SAAS,MAAM,UAAkC,GAAG,MAAoC;CACtF,KAAK,MAAM,KAAK,MAAM;EACpB,MAAM,IAAI,SAAS,EAAE,EAAE,KAAK;EAC5B,IAAI,GAAG,OAAO;CAChB;AAEF;AAEA,SAAgB,kBAAkB,UAAqD;CACrF,MAAM,gBAAgB,MAAM,UAAU,QAAQ,aAAa,YAAY,YAAY,WAAW;CAC9F,MAAM,YAAY,MAAM,UAAU,cAAc,aAAa,aAAa,YAAY;CACtF,MAAM,WAAW,MAAM,UAAU,aAAa,YAAY,YAAY,eAAe,SAAS;CAC9F,MAAM,QAAQ,MAAM,UAAU,SAAS,iBAAiB,cAAc;CACtE,MAAM,QAAQ,MAAM,UAAU,SAAS,gBAAgB,eAAe,KAAK;CAC3E,MAAM,UAAU,MAAM,UAAU,WAAW,gBAAgB,gBAAgB,UAAU;CAErF,IAAI,eAAe;CACnB,IAAI,cAAc;CAClB,IAAI,CAAC,gBAAgB,CAAC,eAAe,eAAe;EAClD,MAAM,QAAQ,cAAc,MAAM,KAAK;EACvC,eAAe,MAAM;EACrB,cAAc,MAAM,MAAM,CAAC,CAAC,CAAC,KAAK,GAAG,KAAK;CAC5C;CAEA,MAAM,SAAS,CAAC,WAAW,QAAQ,CAAC,CAAC,OAAO,OAAO,CAAC,CAAC,KAAK,GAAG;CAG7D,OAAO;EACL,WAAW;EACX,UAAU;EACV,UALe,iBAAiB,UAAU,SAAS;EAMnD;EACA;EACA;CACF;AACF"}
|