@smoothbricks/cli 0.8.0 → 0.10.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/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +9 -0
- package/dist/wrangler/prepare-env.d.ts +89 -0
- package/dist/wrangler/prepare-env.d.ts.map +1 -0
- package/dist/wrangler/prepare-env.js +363 -0
- package/dist/wrangler/scaffold.d.ts +7 -0
- package/dist/wrangler/scaffold.d.ts.map +1 -0
- package/dist/wrangler/scaffold.js +228 -0
- package/package.json +9 -1
- package/src/cli.ts +10 -0
- package/src/release/__tests__/helpers/fixture-repo.ts +19 -1
- package/src/wrangler/prepare-env.test.ts +347 -0
- package/src/wrangler/prepare-env.ts +406 -0
- package/src/wrangler/scaffold.test.ts +200 -0
- package/src/wrangler/scaffold.ts +239 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composable primitives for writing a project's own `prepare-env` script — the
|
|
3
|
+
* provisioning half of the wrangler setup flow (the `wrangler` monorepo pack owns
|
|
4
|
+
* the build-time half: the `wrangler-types` target + `.dev.vars.example`).
|
|
5
|
+
*
|
|
6
|
+
* `wrangler types --env-file .dev.vars.example` emits a machine-readable manifest
|
|
7
|
+
* that distinguishes committed public **vars** (literal-typed in `[env.<env>.vars]`)
|
|
8
|
+
* from **secrets** (string-typed, names in `.dev.vars.example`, values never in the
|
|
9
|
+
* repo). `readManifest` reads that split back so a script knows what to prompt for;
|
|
10
|
+
* the toml editors clone/blank/fill env blocks; the wrangler shell-outs provision.
|
|
11
|
+
*
|
|
12
|
+
* Import from a target's `scripts/prepare-env.ts`:
|
|
13
|
+
* import * as pe from '@smoothbricks/cli/wrangler/prepare-env';
|
|
14
|
+
* Scaffold a starter with: smoo wrangler scaffold <project>
|
|
15
|
+
*
|
|
16
|
+
* Prompt-agnostic by design: the module does the toml + wrangler work; the script
|
|
17
|
+
* brings its own prompts (e.g. @clack/prompts). Bun-only (uses `bun`'s `$`).
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { $ } from 'bun';
|
|
22
|
+
import { type AST, getStaticTOMLValue, parseTOML } from 'toml-eslint-parser';
|
|
23
|
+
|
|
24
|
+
// ── runtime narrowing (wrangler JSON is external data — no casts) ─────────────
|
|
25
|
+
|
|
26
|
+
export function isRecord(v: unknown): v is Record<string, unknown> {
|
|
27
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── wrangler.toml editors (CST: read via getStaticTOMLValue, write via
|
|
31
|
+
// node-range splice — comment/format-preserving by construction, no regex) ─────
|
|
32
|
+
|
|
33
|
+
/** All top-level tables (`[env.x]`, `[env.x.vars]`, `[[env.x.d1_databases]]`, …) — each carries `resolvedKey` (incl. array index). */
|
|
34
|
+
function tables(toml: string): AST.TOMLTable[] {
|
|
35
|
+
return parseTOML(toml).body[0].body.filter((n): n is AST.TOMLTable => n.type === 'TOMLTable');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Whole doc as a JS object (comments/format dropped — reads only). */
|
|
39
|
+
function tomlValue(toml: string): Record<string, unknown> {
|
|
40
|
+
const value = getStaticTOMLValue(parseTOML(toml));
|
|
41
|
+
return isRecord(value) ? value : {};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function envRecord(toml: string, env: string): Record<string, unknown> | null {
|
|
45
|
+
const envs = tomlValue(toml).env;
|
|
46
|
+
const block = isRecord(envs) ? envs[env] : undefined;
|
|
47
|
+
return isRecord(block) ? block : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sameKey(a: (string | number)[], b: (string | number)[]): boolean {
|
|
51
|
+
return a.length === b.length && a.every((x, i) => x === b[i]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The `key = value` node named `key` in a table (single-segment keys; matched by source text). */
|
|
55
|
+
function kvIn(toml: string, table: AST.TOMLTable, key: string): AST.TOMLKeyValue | null {
|
|
56
|
+
for (const kv of table.body) {
|
|
57
|
+
if (toml.slice(kv.key.range[0], kv.key.range[1]).trim() === key) return kv;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The `<field>` KV of the `[[env.<env>.<arrayKey>]]` table whose `binding` matches, or null. */
|
|
63
|
+
function arrayTableKv(toml: string, env: string, arrayKey: string, binding: string, field: string): AST.TOMLKeyValue | null {
|
|
64
|
+
const block = envRecord(toml, env);
|
|
65
|
+
const rows = isRecord(block) && Array.isArray(block[arrayKey]) ? block[arrayKey] : [];
|
|
66
|
+
const index = rows.findIndex((r) => isRecord(r) && r.binding === binding);
|
|
67
|
+
if (index < 0) return null;
|
|
68
|
+
const table = tables(toml).find((t) => t.kind === 'array' && sameKey(t.resolvedKey, ['env', env, arrayKey, index]));
|
|
69
|
+
return table ? kvIn(toml, table, field) : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function splice(toml: string, start: number, end: number, text: string): string {
|
|
73
|
+
return toml.slice(0, start) + text + toml.slice(end);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** End of the physical line containing `offset` (exclusive of the newline). */
|
|
77
|
+
function endOfLine(toml: string, offset: number): number {
|
|
78
|
+
const nl = toml.indexOf('\n', offset);
|
|
79
|
+
return nl === -1 ? toml.length : nl;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** First `[env.<name>]` (source order; also derived from a nested `[env.<name>.*]`), or null when no env declared. */
|
|
83
|
+
export function firstWranglerEnv(toml: string): string | null {
|
|
84
|
+
const envs = tomlValue(toml).env;
|
|
85
|
+
if (!isRecord(envs)) return null;
|
|
86
|
+
const [first] = Object.keys(envs);
|
|
87
|
+
return first ?? null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** True if the toml declares `[env.<env>]` (or any `[env.<env>.*]` sub-table). */
|
|
91
|
+
export function hasEnvBlock(toml: string, env: string): boolean {
|
|
92
|
+
const envs = tomlValue(toml).env;
|
|
93
|
+
return isRecord(envs) && env in envs;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A `[env.<env>.vars]` value, or null if unset/empty. Reads committed public config. */
|
|
97
|
+
export function getVar(toml: string, env: string, name: string): string | null {
|
|
98
|
+
const block = envRecord(toml, env);
|
|
99
|
+
const vars = isRecord(block?.vars) ? block.vars : {};
|
|
100
|
+
const value = vars[name];
|
|
101
|
+
return typeof value === 'string' && value ? value : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Set a `[env.<env>.vars]` value (JSON-encoded), preserving any trailing comment. Throws if the key is absent (scaffold first). */
|
|
105
|
+
export function setVar(toml: string, env: string, name: string, value: string): string {
|
|
106
|
+
const table = tables(toml).find((t) => sameKey(t.resolvedKey, ['env', env, 'vars']));
|
|
107
|
+
const kv = table ? kvIn(toml, table, name) : null;
|
|
108
|
+
if (!kv) throw new Error(`vars key "${name}" not found under [env.${env}.vars]`);
|
|
109
|
+
return splice(toml, kv.value.range[0], kv.value.range[1], JSON.stringify(value));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The id written for `[[env.<env>.kv_namespaces]]` binding, or null if absent/empty. */
|
|
113
|
+
export function getKvId(toml: string, env: string, binding: string): string | null {
|
|
114
|
+
const block = envRecord(toml, env);
|
|
115
|
+
const rows = isRecord(block) && Array.isArray(block.kv_namespaces) ? block.kv_namespaces : [];
|
|
116
|
+
for (const row of rows) {
|
|
117
|
+
if (isRecord(row) && row.binding === binding && typeof row.id === 'string') return row.id || null;
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Rewrite the `id = ...` line (value + trailing comment) of the kv_namespaces table whose binding matches. Throws if absent. */
|
|
123
|
+
export function setKvId(toml: string, env: string, binding: string, id: string, title: string): string {
|
|
124
|
+
const kv = arrayTableKv(toml, env, 'kv_namespaces', binding, 'id');
|
|
125
|
+
if (!kv) throw new Error(`kv_namespaces binding "${binding}" not found under [env.${env}]`);
|
|
126
|
+
return splice(toml, kv.value.range[0], endOfLine(toml, kv.value.range[1]), `${JSON.stringify(id)} # ${title}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Append a copy of every `[env.<from>...]` table rewritten for `<to>` (headers
|
|
131
|
+
* only — values copied verbatim). Throws if `<from>` is absent or `<to>` exists.
|
|
132
|
+
*/
|
|
133
|
+
export function cloneEnvBlock(toml: string, from: string, to: string): string {
|
|
134
|
+
if (!hasEnvBlock(toml, from)) throw new Error(`source [env.${from}] not found`);
|
|
135
|
+
if (hasEnvBlock(toml, to)) throw new Error(`target [env.${to}] already exists — blank/fill it instead`);
|
|
136
|
+
const cloned = tables(toml)
|
|
137
|
+
.filter((t) => t.resolvedKey[0] === 'env' && t.resolvedKey[1] === from)
|
|
138
|
+
.map((t) => {
|
|
139
|
+
const seg = t.key.keys[1].range; // the `<from>` segment of this table's header
|
|
140
|
+
const text = toml.slice(t.range[0], t.range[1]);
|
|
141
|
+
return text.slice(0, seg[0] - t.range[0]) + to + text.slice(seg[1] - t.range[0]);
|
|
142
|
+
});
|
|
143
|
+
return `${toml.replace(/\s*$/, '')}\n\n${cloned.join('\n\n')}\n`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Blank the named `[env.<env>.vars]` values to `""` (preserving comments), skipping absent keys. */
|
|
147
|
+
export function blankEnvVars(toml: string, env: string, keys: string[]): string {
|
|
148
|
+
const table = tables(toml).find((t) => sameKey(t.resolvedKey, ['env', env, 'vars']));
|
|
149
|
+
if (!table) return toml;
|
|
150
|
+
const edits = keys
|
|
151
|
+
.map((key) => kvIn(toml, table, key))
|
|
152
|
+
.flatMap((kv) => (kv ? [{ start: kv.value.range[0], end: kv.value.range[1] }] : []));
|
|
153
|
+
return applyBlanks(toml, edits);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Blank every kv_namespaces `id` under the env to `""` (drops trailing comment; binding/name survive). */
|
|
157
|
+
export function blankKvIds(toml: string, env: string): string {
|
|
158
|
+
return blankArrayField(toml, env, 'kv_namespaces', 'id');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The database_id written for `[[env.<env>.d1_databases]]` binding, or null if absent/empty. */
|
|
162
|
+
export function getD1Id(toml: string, env: string, binding: string): string | null {
|
|
163
|
+
const block = envRecord(toml, env);
|
|
164
|
+
const rows = isRecord(block) && Array.isArray(block.d1_databases) ? block.d1_databases : [];
|
|
165
|
+
for (const row of rows) {
|
|
166
|
+
if (isRecord(row) && row.binding === binding && typeof row.database_id === 'string') return row.database_id || null;
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The `database_name` for `[[env.<env>.d1_databases]]` binding, or null. Source of truth for which DB to create/reuse. */
|
|
172
|
+
export function getD1Name(toml: string, env: string, binding: string): string | null {
|
|
173
|
+
const block = envRecord(toml, env);
|
|
174
|
+
const rows = isRecord(block) && Array.isArray(block.d1_databases) ? block.d1_databases : [];
|
|
175
|
+
for (const row of rows) {
|
|
176
|
+
if (isRecord(row) && row.binding === binding && typeof row.database_name === 'string') return row.database_name || null;
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Rewrite the `database_id = ...` line (value + trailing comment) of the d1 table whose binding matches; the name line is untouched. Throws if absent. */
|
|
182
|
+
export function setD1Id(toml: string, env: string, binding: string, id: string, name: string): string {
|
|
183
|
+
const kv = arrayTableKv(toml, env, 'd1_databases', binding, 'database_id');
|
|
184
|
+
if (!kv) throw new Error(`d1_databases binding "${binding}" not found under [env.${env}]`);
|
|
185
|
+
return splice(toml, kv.value.range[0], endOfLine(toml, kv.value.range[1]), `${JSON.stringify(id)} # ${name}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Blank every d1 `database_id` under the env to `""` (drops trailing comment; binding/name survive). */
|
|
189
|
+
export function blankD1Ids(toml: string, env: string): string {
|
|
190
|
+
return blankArrayField(toml, env, 'd1_databases', 'database_id');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Blank one field across every `[[env.<env>.<arrayKey>]]` table (value..EOL -> `""`). */
|
|
194
|
+
function blankArrayField(toml: string, env: string, arrayKey: string, field: string): string {
|
|
195
|
+
const edits = tables(toml)
|
|
196
|
+
.filter((t) => t.kind === 'array' && t.resolvedKey[0] === 'env' && t.resolvedKey[1] === env && t.resolvedKey[2] === arrayKey)
|
|
197
|
+
.map((t) => kvIn(toml, t, field))
|
|
198
|
+
.flatMap((kv) => (kv ? [{ start: kv.value.range[0], end: endOfLine(toml, kv.value.range[1]) }] : []));
|
|
199
|
+
return applyBlanks(toml, edits);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Apply `""` at each range, descending so earlier offsets stay valid. */
|
|
203
|
+
function applyBlanks(toml: string, edits: { start: number; end: number }[]): string {
|
|
204
|
+
let out = toml;
|
|
205
|
+
for (const e of [...edits].sort((a, b) => b.start - a.start)) out = splice(out, e.start, e.end, '""');
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Parse-guard: throws if `toml` no longer parses, so a bad edit can't be written to disk. */
|
|
210
|
+
export function assertToml(toml: string): void {
|
|
211
|
+
parseTOML(toml);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Persist a toml string after confirming it still parses. */
|
|
215
|
+
export function saveToml(path: string, toml: string): void {
|
|
216
|
+
assertToml(toml);
|
|
217
|
+
writeFileSync(path, toml);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── manifest: what the worker needs, split into vars vs secrets ──────────────
|
|
221
|
+
|
|
222
|
+
export interface Manifest {
|
|
223
|
+
/** KV binding names declared under `[[env.<env>.kv_namespaces]]` (resources to provision). */
|
|
224
|
+
kvBindings: string[];
|
|
225
|
+
/** D1 binding names declared under `[[env.<env>.d1_databases]]` (resources to provision). */
|
|
226
|
+
d1Bindings: string[];
|
|
227
|
+
/** Public var names declared in `[env.<env>.vars]` (committed config). */
|
|
228
|
+
vars: string[];
|
|
229
|
+
/** Secret names declared in `.dev.vars.example` (values live on the worker, never in the repo). */
|
|
230
|
+
secrets: string[];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Secret NAMES from a `.dev.vars.example` file (KEY=... / KEY="..."), ignoring comments/blanks. */
|
|
234
|
+
export function parseDevVarsExample(text: string): string[] {
|
|
235
|
+
const names: string[] = [];
|
|
236
|
+
for (const raw of text.split('\n')) {
|
|
237
|
+
const line = raw.trim();
|
|
238
|
+
if (!line || line.startsWith('#')) continue;
|
|
239
|
+
const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=/);
|
|
240
|
+
if (m) names.push(m[1]);
|
|
241
|
+
}
|
|
242
|
+
return names;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Read the vars/secrets split for `env` from `<root>/wrangler.toml` (public var
|
|
247
|
+
* keys) + `<root>/.dev.vars.example` (secret names). This is the "what to prompt
|
|
248
|
+
* for" manifest a prepare-env script derives its work from.
|
|
249
|
+
*/
|
|
250
|
+
export function readManifest(root: string, env: string): Manifest {
|
|
251
|
+
const toml = readFileSync(join(root, 'wrangler.toml'), 'utf8');
|
|
252
|
+
const block = envRecord(toml, env);
|
|
253
|
+
const varsRec = isRecord(block?.vars) ? block.vars : {};
|
|
254
|
+
const vars = Object.keys(varsRec);
|
|
255
|
+
const kvRows = isRecord(block) && Array.isArray(block.kv_namespaces) ? block.kv_namespaces : [];
|
|
256
|
+
const kvBindings = kvRows.flatMap((row) => (isRecord(row) && typeof row.binding === 'string' ? [row.binding] : []));
|
|
257
|
+
const d1Rows = isRecord(block) && Array.isArray(block.d1_databases) ? block.d1_databases : [];
|
|
258
|
+
const d1Bindings = d1Rows.flatMap((row) => (isRecord(row) && typeof row.binding === 'string' ? [row.binding] : []));
|
|
259
|
+
const examplePath = join(root, '.dev.vars.example');
|
|
260
|
+
const secrets = existsSync(examplePath) ? parseDevVarsExample(readFileSync(examplePath, 'utf8')) : [];
|
|
261
|
+
return { kvBindings, d1Bindings, vars, secrets };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── PEM (a standard, reusable) ───────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
/** True if `value` looks like a PEM block (any `-----BEGIN ...-----`). */
|
|
267
|
+
export function isPem(value: string): boolean {
|
|
268
|
+
return /-----BEGIN [A-Z ]+-----/.test(value);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Read a `.pem` file (expanding a leading `~`) and assert it's a PEM block. Throws otherwise. */
|
|
272
|
+
export function readPemFile(path: string): string {
|
|
273
|
+
const resolved = path.replace(/^~/, process.env.HOME ?? '~');
|
|
274
|
+
const content = readFileSync(resolved, 'utf8');
|
|
275
|
+
if (!isPem(content)) throw new Error(`${path} is not a PEM (no -----BEGIN block)`);
|
|
276
|
+
return content;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Heuristic: does this secret name suggest file input (PEM/key)? Used to preselect a file prompt — never to change semantics. */
|
|
280
|
+
export function looksLikeFileSecret(name: string): boolean {
|
|
281
|
+
return /_PEM$|_KEY$|_CERT$/.test(name);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── wrangler shell-outs (Bun $ + structured JSON), run at `cwd` ───────────────
|
|
285
|
+
|
|
286
|
+
export interface KvNamespace {
|
|
287
|
+
id: string;
|
|
288
|
+
title: string;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function asKvNamespaces(json: unknown): KvNamespace[] {
|
|
292
|
+
if (!Array.isArray(json)) return [];
|
|
293
|
+
const out: KvNamespace[] = [];
|
|
294
|
+
for (const row of json) {
|
|
295
|
+
if (isRecord(row) && typeof row.id === 'string' && typeof row.title === 'string') {
|
|
296
|
+
out.push({ id: row.id, title: row.title });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function asSecretNames(json: unknown): string[] {
|
|
303
|
+
if (!Array.isArray(json)) return [];
|
|
304
|
+
return json.flatMap((row) => (isRecord(row) && typeof row.name === 'string' ? [row.name] : []));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Best-effort ERROR line from a Bun ShellError (thrown on non-zero exit). */
|
|
308
|
+
export function errText(err: unknown): string {
|
|
309
|
+
if (!isRecord(err)) return 'unknown error';
|
|
310
|
+
const stderr = 'stderr' in err && err.stderr != null ? String(err.stderr) : '';
|
|
311
|
+
const exit = 'exitCode' in err ? String(err.exitCode) : '?';
|
|
312
|
+
return stderr.split('\n').find((l) => l.includes('ERROR')) ?? `exit ${exit}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Live KV namespaces on the account (`kv namespace list` emits a pure JSON array); `[]` when offline/unauthenticated. */
|
|
316
|
+
export async function listNamespaces(cwd: string): Promise<KvNamespace[]> {
|
|
317
|
+
try {
|
|
318
|
+
return asKvNamespaces(await $`bunx wrangler kv namespace list`.cwd(cwd).quiet().json());
|
|
319
|
+
} catch {
|
|
320
|
+
return [];
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Reuse the namespace whose title exactly matches `logical`, else create it. The
|
|
326
|
+
* id always comes from the structured `list` JSON (create prints freeform text),
|
|
327
|
+
* so we re-list rather than scrape. Returns the id, or null on failure.
|
|
328
|
+
*/
|
|
329
|
+
export async function ensureNamespace(logical: string, existing: KvNamespace[], cwd: string): Promise<string | null> {
|
|
330
|
+
const match = existing.find((n) => n.title === logical);
|
|
331
|
+
if (match) return match.id;
|
|
332
|
+
try {
|
|
333
|
+
await $`bunx wrangler kv namespace create ${logical}`.cwd(cwd).quiet();
|
|
334
|
+
} catch (err) {
|
|
335
|
+
return errText(err).includes('already') ? (await reList(logical, cwd)) : null;
|
|
336
|
+
}
|
|
337
|
+
return reList(logical, cwd);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function reList(logical: string, cwd: string): Promise<string | null> {
|
|
341
|
+
return (await listNamespaces(cwd)).find((n) => n.title === logical)?.id ?? null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export interface D1Database {
|
|
345
|
+
uuid: string;
|
|
346
|
+
name: string;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function asD1Databases(json: unknown): D1Database[] {
|
|
350
|
+
if (!Array.isArray(json)) return [];
|
|
351
|
+
const out: D1Database[] = [];
|
|
352
|
+
for (const row of json) {
|
|
353
|
+
if (isRecord(row) && typeof row.uuid === 'string' && typeof row.name === 'string') {
|
|
354
|
+
out.push({ uuid: row.uuid, name: row.name });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Live D1 databases on the account (`d1 list --json`); `[]` when offline/unauthenticated. */
|
|
361
|
+
export async function listD1Databases(cwd: string): Promise<D1Database[]> {
|
|
362
|
+
try {
|
|
363
|
+
return asD1Databases(await $`bunx wrangler d1 list --json`.cwd(cwd).quiet().json());
|
|
364
|
+
} catch {
|
|
365
|
+
return [];
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Reuse the D1 database whose name matches, else create it. The uuid comes from
|
|
371
|
+
* the structured `list` JSON (create prints freeform text), so we re-list rather
|
|
372
|
+
* than scrape. Returns the uuid, or null on failure.
|
|
373
|
+
*/
|
|
374
|
+
export async function ensureD1Database(name: string, existing: D1Database[], cwd: string): Promise<string | null> {
|
|
375
|
+
const match = existing.find((d) => d.name === name);
|
|
376
|
+
if (match) return match.uuid;
|
|
377
|
+
try {
|
|
378
|
+
await $`bunx wrangler d1 create ${name}`.cwd(cwd).quiet();
|
|
379
|
+
} catch (err) {
|
|
380
|
+
return errText(err).includes('already') ? reListD1(name, cwd) : null;
|
|
381
|
+
}
|
|
382
|
+
return reListD1(name, cwd);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function reListD1(name: string, cwd: string): Promise<string | null> {
|
|
386
|
+
return (await listD1Databases(cwd)).find((d) => d.name === name)?.uuid ?? null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Current secret names on the worker for `env`; `[]` when none/unreachable (Cloudflare allows setting pre-deploy). */
|
|
390
|
+
export async function listSecretNames(env: string, cwd: string): Promise<string[]> {
|
|
391
|
+
try {
|
|
392
|
+
return asSecretNames(await $`bunx wrangler secret list --format json --env ${env}`.cwd(cwd).quiet().json());
|
|
393
|
+
} catch {
|
|
394
|
+
return [];
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** `wrangler secret put <name> --env <env>` piping `value` on stdin (never a CLI arg). Returns success. */
|
|
399
|
+
export async function putSecret(name: string, value: string, env: string, cwd: string): Promise<boolean> {
|
|
400
|
+
try {
|
|
401
|
+
await $`bunx wrangler secret put ${name} --env ${env} < ${Buffer.from(value)}`.cwd(cwd).quiet();
|
|
402
|
+
return true;
|
|
403
|
+
} catch {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, expect, it, spyOn } from 'bun:test';
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { scaffold } from './scaffold.js';
|
|
6
|
+
|
|
7
|
+
describe('scaffold', () => {
|
|
8
|
+
it('writes the manifest-driven starter script and wires the prepare-env nx target', async () => {
|
|
9
|
+
const root = await createWorkspace([{ dir: 'worker', name: '@acme/worker', toml: '[env.production]\n' }]);
|
|
10
|
+
captureConsoleLogs();
|
|
11
|
+
try {
|
|
12
|
+
const scriptPath = scaffold(root, '@acme/worker');
|
|
13
|
+
|
|
14
|
+
// Returns the absolute path it wrote.
|
|
15
|
+
expect(scriptPath).toBe(join(root, 'packages/worker/scripts/prepare-env.ts'));
|
|
16
|
+
|
|
17
|
+
// The generated script imports the primitives module and derives work from the manifest.
|
|
18
|
+
const script = await readFile(scriptPath, 'utf8');
|
|
19
|
+
expect(script).toContain('@smoothbricks/cli/wrangler/prepare-env');
|
|
20
|
+
expect(script).toContain('readManifest');
|
|
21
|
+
|
|
22
|
+
// The nx target is wired to run the script from the project root.
|
|
23
|
+
const pkg = await readJson(join(root, 'packages/worker/package.json'));
|
|
24
|
+
expect(nxTargets(pkg)['prepare-env']).toEqual({
|
|
25
|
+
executor: 'nx:run-commands',
|
|
26
|
+
options: { command: 'bun scripts/prepare-env.ts', cwd: '{projectRoot}' },
|
|
27
|
+
});
|
|
28
|
+
} finally {
|
|
29
|
+
console.log = originalConsoleLog;
|
|
30
|
+
await rm(root, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('resolves the same project by nx name, package name, and relative path', async () => {
|
|
35
|
+
const root = await createWorkspace([
|
|
36
|
+
{ dir: 'worker', name: '@acme/worker', toml: '[env.production]\n', nx: { name: 'my-worker' } },
|
|
37
|
+
]);
|
|
38
|
+
captureConsoleLogs();
|
|
39
|
+
try {
|
|
40
|
+
const expected = join(root, 'packages/worker/scripts/prepare-env.ts');
|
|
41
|
+
const byNxName = scaffold(root, 'my-worker');
|
|
42
|
+
const byPackageName = scaffold(root, '@acme/worker', { force: true });
|
|
43
|
+
const byRelativePath = scaffold(root, 'packages/worker', { force: true });
|
|
44
|
+
|
|
45
|
+
expect(byNxName).toBe(expected);
|
|
46
|
+
expect(byPackageName).toBe(expected);
|
|
47
|
+
expect(byRelativePath).toBe(expected);
|
|
48
|
+
} finally {
|
|
49
|
+
console.log = originalConsoleLog;
|
|
50
|
+
await rm(root, { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('throws when the project is unknown, naming the known projects', async () => {
|
|
55
|
+
const root = await createWorkspace([
|
|
56
|
+
{ dir: 'worker', name: '@acme/worker', toml: '[env.production]\n', nx: { name: 'my-worker' } },
|
|
57
|
+
]);
|
|
58
|
+
try {
|
|
59
|
+
let message = '';
|
|
60
|
+
try {
|
|
61
|
+
scaffold(root, 'ghost');
|
|
62
|
+
} catch (error) {
|
|
63
|
+
message = error instanceof Error ? error.message : String(error);
|
|
64
|
+
}
|
|
65
|
+
expect(message).toContain('not found');
|
|
66
|
+
expect(message).toContain('my-worker');
|
|
67
|
+
} finally {
|
|
68
|
+
await rm(root, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('throws when the resolved package has no wrangler.toml', async () => {
|
|
73
|
+
const root = await createWorkspace([{ dir: 'plain', name: '@acme/plain' }]);
|
|
74
|
+
try {
|
|
75
|
+
expect(() => scaffold(root, '@acme/plain')).toThrow('has no wrangler.toml');
|
|
76
|
+
} finally {
|
|
77
|
+
await rm(root, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('throws on a second scaffold without force, pointing at --force', async () => {
|
|
82
|
+
const root = await createWorkspace([{ dir: 'worker', name: '@acme/worker', toml: '[env.production]\n' }]);
|
|
83
|
+
captureConsoleLogs();
|
|
84
|
+
try {
|
|
85
|
+
scaffold(root, '@acme/worker');
|
|
86
|
+
expect(() => scaffold(root, '@acme/worker')).toThrow('--force');
|
|
87
|
+
} finally {
|
|
88
|
+
console.log = originalConsoleLog;
|
|
89
|
+
await rm(root, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('overwrites an existing script when force is set', async () => {
|
|
94
|
+
const root = await createWorkspace([{ dir: 'worker', name: '@acme/worker', toml: '[env.production]\n' }]);
|
|
95
|
+
const logs = captureConsoleLogs();
|
|
96
|
+
try {
|
|
97
|
+
const scriptPath = scaffold(root, '@acme/worker');
|
|
98
|
+
await writeFile(scriptPath, 'stale hand-edited content\n');
|
|
99
|
+
|
|
100
|
+
const again = scaffold(root, '@acme/worker', { force: true });
|
|
101
|
+
expect(again).toBe(scriptPath);
|
|
102
|
+
|
|
103
|
+
// The stale content is replaced by a freshly rendered script.
|
|
104
|
+
const script = await readFile(scriptPath, 'utf8');
|
|
105
|
+
expect(script).not.toContain('stale hand-edited content');
|
|
106
|
+
expect(script).toContain('@smoothbricks/cli/wrangler/prepare-env');
|
|
107
|
+
expect(logs.some((line) => line.startsWith('overwrote'))).toBe(true);
|
|
108
|
+
} finally {
|
|
109
|
+
console.log = originalConsoleLog;
|
|
110
|
+
await rm(root, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('preserves a hand-customized prepare-env target and never duplicates it', async () => {
|
|
115
|
+
const custom = {
|
|
116
|
+
executor: 'nx:run-commands',
|
|
117
|
+
options: { command: 'bun scripts/prepare-env.ts --custom', cwd: '{projectRoot}' },
|
|
118
|
+
};
|
|
119
|
+
const root = await createWorkspace([
|
|
120
|
+
{
|
|
121
|
+
dir: 'worker',
|
|
122
|
+
name: '@acme/worker',
|
|
123
|
+
toml: '[env.production]\n',
|
|
124
|
+
nx: { targets: { 'prepare-env': { ...custom } } },
|
|
125
|
+
},
|
|
126
|
+
]);
|
|
127
|
+
captureConsoleLogs();
|
|
128
|
+
try {
|
|
129
|
+
scaffold(root, '@acme/worker', { force: true });
|
|
130
|
+
|
|
131
|
+
// The idempotent guard leaves an already-wired target exactly as authored.
|
|
132
|
+
const target = nxTargets(await readJson(join(root, 'packages/worker/package.json')))['prepare-env'];
|
|
133
|
+
expect(target).toEqual(custom);
|
|
134
|
+
// Still a single target object, not wrapped into an array or duplicated.
|
|
135
|
+
expect(Array.isArray(target)).toBe(false);
|
|
136
|
+
} finally {
|
|
137
|
+
console.log = originalConsoleLog;
|
|
138
|
+
await rm(root, { recursive: true, force: true });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
async function createWorkspace(
|
|
144
|
+
packages: Array<{ dir: string; name: string; toml?: string; nx?: Record<string, unknown> }>,
|
|
145
|
+
): Promise<string> {
|
|
146
|
+
const root = await mkdtemp(join(tmpdir(), 'smoo-scaffold-'));
|
|
147
|
+
await writeJson(join(root, 'package.json'), {
|
|
148
|
+
name: '@acme/codebase',
|
|
149
|
+
version: '0.0.0',
|
|
150
|
+
private: true,
|
|
151
|
+
workspaces: ['packages/*'],
|
|
152
|
+
});
|
|
153
|
+
for (const pkg of packages) {
|
|
154
|
+
await writeJson(join(root, `packages/${pkg.dir}/package.json`), {
|
|
155
|
+
name: pkg.name,
|
|
156
|
+
version: '0.0.0',
|
|
157
|
+
private: true,
|
|
158
|
+
...(pkg.nx ? { nx: pkg.nx } : {}),
|
|
159
|
+
});
|
|
160
|
+
if (pkg.toml !== undefined) {
|
|
161
|
+
await writeFile(join(root, `packages/${pkg.dir}/wrangler.toml`), pkg.toml);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return root;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function nxTargets(pkg: Record<string, unknown>): Record<string, unknown> {
|
|
168
|
+
const nx = pkg.nx;
|
|
169
|
+
if (!isRecord(nx) || !isRecord(nx.targets)) {
|
|
170
|
+
throw new Error('nx.targets not found');
|
|
171
|
+
}
|
|
172
|
+
return nx.targets;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
176
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function readJson(path: string): Promise<Record<string, unknown>> {
|
|
180
|
+
const parsed: unknown = JSON.parse(await readFile(path, 'utf8'));
|
|
181
|
+
if (!isRecord(parsed)) {
|
|
182
|
+
throw new Error('expected JSON object');
|
|
183
|
+
}
|
|
184
|
+
return parsed;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function writeJson(path: string, value: Record<string, unknown>): Promise<void> {
|
|
188
|
+
await mkdir(join(path, '..'), { recursive: true });
|
|
189
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const originalConsoleLog = console.log;
|
|
193
|
+
|
|
194
|
+
function captureConsoleLogs(): string[] {
|
|
195
|
+
const logs: string[] = [];
|
|
196
|
+
spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
|
|
197
|
+
logs.push(args.join(' '));
|
|
198
|
+
});
|
|
199
|
+
return logs;
|
|
200
|
+
}
|