@smoothbricks/cli 0.9.0 → 0.10.1

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.
@@ -19,29 +19,78 @@
19
19
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
20
20
  import { join } from 'node:path';
21
21
  import { $ } from 'bun';
22
- import { parse } from 'smol-toml';
22
+ import { type AST, getStaticTOMLValue, parseTOML } from 'toml-eslint-parser';
23
23
 
24
- // ── runtime narrowing (wrangler JSON + parsed toml are external data — no casts)
24
+ // ── runtime narrowing (wrangler JSON is external data — no casts) ─────────────
25
25
 
26
26
  export function isRecord(v: unknown): v is Record<string, unknown> {
27
27
  return typeof v === 'object' && v !== null && !Array.isArray(v);
28
28
  }
29
29
 
30
- function escapeRe(s: string): string {
31
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
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;
32
70
  }
33
71
 
34
- // ── pure wrangler.toml editors (no I/O unit-testable) ──────────────────────
72
+ function splice(toml: string, start: number, end: number, text: string): string {
73
+ return toml.slice(0, start) + text + toml.slice(end);
74
+ }
35
75
 
36
- /** First `[env.<name>]` header, or `null` when the config declares no envs (top-level bindings only). */
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. */
37
83
  export function firstWranglerEnv(toml: string): string | null {
38
- const match = toml.match(/^\s*\[env\.([A-Za-z0-9_-]+)/m);
39
- return match ? match[1] : null;
84
+ const envs = tomlValue(toml).env;
85
+ if (!isRecord(envs)) return null;
86
+ const [first] = Object.keys(envs);
87
+ return first ?? null;
40
88
  }
41
89
 
42
- /** True if the toml declares `[env.<env>]`. */
90
+ /** True if the toml declares `[env.<env>]` (or any `[env.<env>.*]` sub-table). */
43
91
  export function hasEnvBlock(toml: string, env: string): boolean {
44
- return new RegExp(`^\\[env\\.${escapeRe(env)}\\]`, 'm').test(toml);
92
+ const envs = tomlValue(toml).env;
93
+ return isRecord(envs) && env in envs;
45
94
  }
46
95
 
47
96
  /** A `[env.<env>.vars]` value, or null if unset/empty. Reads committed public config. */
@@ -52,13 +101,12 @@ export function getVar(toml: string, env: string, name: string): string | null {
52
101
  return typeof value === 'string' && value ? value : null;
53
102
  }
54
103
 
55
- /** Set a `[env.<env>.vars]` value, preserving any trailing comment. Throws if the key isn't present (scaffold first). */
104
+ /** Set a `[env.<env>.vars]` value (JSON-encoded), preserving any trailing comment. Throws if the key is absent (scaffold first). */
56
105
  export function setVar(toml: string, env: string, name: string, value: string): string {
57
- const re = new RegExp(`(\\[env\\.${escapeRe(env)}\\.vars\\][\\s\\S]*?\\n${escapeRe(name)} = )"[^"]*"`);
58
- if (!re.test(toml)) {
59
- throw new Error(`vars key "${name}" not found under [env.${env}.vars]`);
60
- }
61
- return toml.replace(re, (_m: string, prefix: string) => `${prefix}${JSON.stringify(value)}`);
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));
62
110
  }
63
111
 
64
112
  /** The id written for `[[env.<env>.kv_namespaces]]` binding, or null if absent/empty. */
@@ -66,75 +114,101 @@ export function getKvId(toml: string, env: string, binding: string): string | nu
66
114
  const block = envRecord(toml, env);
67
115
  const rows = isRecord(block) && Array.isArray(block.kv_namespaces) ? block.kv_namespaces : [];
68
116
  for (const row of rows) {
69
- if (isRecord(row) && row.binding === binding && typeof row.id === 'string') {
70
- return row.id || null;
71
- }
117
+ if (isRecord(row) && row.binding === binding && typeof row.id === 'string') return row.id || null;
72
118
  }
73
119
  return null;
74
120
  }
75
121
 
76
- /** Rewrite the `id = ...` line of the `[[env.<env>.kv_namespaces]]` table whose binding matches. Throws if absent. */
122
+ /** Rewrite the `id = ...` line (value + trailing comment) of the kv_namespaces table whose binding matches. Throws if absent. */
77
123
  export function setKvId(toml: string, env: string, binding: string, id: string, title: string): string {
78
- const re = new RegExp(
79
- `(\\[\\[env\\.${escapeRe(env)}\\.kv_namespaces\\]\\]\\s*\\n\\s*binding = "${escapeRe(binding)}"\\s*\\n\\s*id = )[^\\n]*`,
80
- );
81
- if (!re.test(toml)) {
82
- throw new Error(`kv_namespaces binding "${binding}" not found under [env.${env}]`);
83
- }
84
- return toml.replace(re, `$1"${id}" # ${title}`);
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}`);
85
127
  }
86
128
 
87
129
  /**
88
- * Append a copy of the whole `[env.<from>]` block-group (every `[env.<from>...]`
89
- * table up to the next non-`from` env or EOF) rewritten for `<to>`. Returns the new
90
- * toml. Throws if `<from>` is absent or `<to>` already exists (blank/fill instead).
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.
91
132
  */
92
133
  export function cloneEnvBlock(toml: string, from: string, to: string): string {
93
134
  if (!hasEnvBlock(toml, from)) throw new Error(`source [env.${from}] not found`);
94
135
  if (hasEnvBlock(toml, to)) throw new Error(`target [env.${to}] already exists — blank/fill it instead`);
95
- const lines = toml.split('\n');
96
- const isFromHeader = (l: string) => new RegExp(`^\\[+env\\.${escapeRe(from)}(\\.|\\])`).test(l);
97
- const isAnyEnvHeader = (l: string) => /^\[+env\.[A-Za-z0-9_-]+(\.|\])/.test(l);
98
- const out: string[] = [];
99
- let capturing = false;
100
- for (const line of lines) {
101
- if (isFromHeader(line)) {
102
- capturing = true;
103
- out.push(line);
104
- } else if (capturing && isAnyEnvHeader(line) && !isFromHeader(line)) {
105
- capturing = false; // a different env began — stop
106
- } else if (capturing) {
107
- out.push(line);
108
- }
109
- }
110
- const cloned = out
111
- .map((l) => l.replace(new RegExp(`(\\[+env\\.)${escapeRe(from)}(\\.|\\])`, 'g'), `$1${to}$2`))
112
- .join('\n');
113
- return `${toml.replace(/\s*$/, '')}\n\n${cloned}\n`;
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`;
114
144
  }
115
145
 
116
- /** Blank every `NAME = "..."` under `[env.<env>.vars]` whose key is in `keys` (env-specific values to re-fill). */
146
+ /** Blank the named `[env.<env>.vars]` values to `""` (preserving comments), skipping absent keys. */
117
147
  export function blankEnvVars(toml: string, env: string, keys: string[]): string {
118
- let out = toml;
119
- for (const key of keys) {
120
- const re = new RegExp(`(\\[env\\.${escapeRe(env)}\\.vars\\][\\s\\S]*?\\n${escapeRe(key)} = )"[^"]*"`);
121
- if (re.test(out)) out = out.replace(re, '$1""');
122
- }
123
- return out;
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);
124
154
  }
125
155
 
126
- /** Blank every `id = ...` line under the env's `[[env.<env>.kv_namespaces]]` tables (per-env resource ids). */
156
+ /** Blank every kv_namespaces `id` under the env to `""` (drops trailing comment; binding/name survive). */
127
157
  export function blankKvIds(toml: string, env: string): string {
128
- const re = new RegExp(
129
- `(\\[\\[env\\.${escapeRe(env)}\\.kv_namespaces\\]\\]\\s*\\n\\s*binding = "[^"]*"\\s*\\n\\s*id = )[^\\n]*`,
130
- 'g',
131
- );
132
- return toml.replace(re, '$1""');
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;
133
207
  }
134
208
 
135
209
  /** Parse-guard: throws if `toml` no longer parses, so a bad edit can't be written to disk. */
136
210
  export function assertToml(toml: string): void {
137
- parse(toml);
211
+ parseTOML(toml);
138
212
  }
139
213
 
140
214
  /** Persist a toml string after confirming it still parses. */
@@ -143,18 +217,13 @@ export function saveToml(path: string, toml: string): void {
143
217
  writeFileSync(path, toml);
144
218
  }
145
219
 
146
- function envRecord(toml: string, env: string): Record<string, unknown> | null {
147
- const parsed = parse(toml);
148
- const envs = isRecord(parsed) && isRecord(parsed.env) ? parsed.env : {};
149
- const block = envs[env];
150
- return isRecord(block) ? block : null;
151
- }
152
-
153
220
  // ── manifest: what the worker needs, split into vars vs secrets ──────────────
154
221
 
155
222
  export interface Manifest {
156
223
  /** KV binding names declared under `[[env.<env>.kv_namespaces]]` (resources to provision). */
157
224
  kvBindings: string[];
225
+ /** D1 binding names declared under `[[env.<env>.d1_databases]]` (resources to provision). */
226
+ d1Bindings: string[];
158
227
  /** Public var names declared in `[env.<env>.vars]` (committed config). */
159
228
  vars: string[];
160
229
  /** Secret names declared in `.dev.vars.example` (values live on the worker, never in the repo). */
@@ -185,9 +254,11 @@ export function readManifest(root: string, env: string): Manifest {
185
254
  const vars = Object.keys(varsRec);
186
255
  const kvRows = isRecord(block) && Array.isArray(block.kv_namespaces) ? block.kv_namespaces : [];
187
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] : []));
188
259
  const examplePath = join(root, '.dev.vars.example');
189
260
  const secrets = existsSync(examplePath) ? parseDevVarsExample(readFileSync(examplePath, 'utf8')) : [];
190
- return { kvBindings, vars, secrets };
261
+ return { kvBindings, d1Bindings, vars, secrets };
191
262
  }
192
263
 
193
264
  // ── PEM (a standard, reusable) ───────────────────────────────────────────────
@@ -270,6 +341,51 @@ async function reList(logical: string, cwd: string): Promise<string | null> {
270
341
  return (await listNamespaces(cwd)).find((n) => n.title === logical)?.id ?? null;
271
342
  }
272
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
+
273
389
  /** Current secret names on the worker for `env`; `[]` when none/unreachable (Cloudflare allows setting pre-deploy). */
274
390
  export async function listSecretNames(env: string, cwd: string): Promise<string[]> {
275
391
  try {