@smoothbricks/cli 0.7.0 → 0.9.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.
@@ -0,0 +1,253 @@
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 { parse } from 'smol-toml';
23
+ // ── runtime narrowing (wrangler JSON + parsed toml are external data — no casts) ─
24
+ export function isRecord(v) {
25
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
26
+ }
27
+ function escapeRe(s) {
28
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
29
+ }
30
+ // ── pure wrangler.toml editors (no I/O — unit-testable) ──────────────────────
31
+ /** First `[env.<name>]` header, or `null` when the config declares no envs (top-level bindings only). */
32
+ export function firstWranglerEnv(toml) {
33
+ const match = toml.match(/^\s*\[env\.([A-Za-z0-9_-]+)/m);
34
+ return match ? match[1] : null;
35
+ }
36
+ /** True if the toml declares `[env.<env>]`. */
37
+ export function hasEnvBlock(toml, env) {
38
+ return new RegExp(`^\\[env\\.${escapeRe(env)}\\]`, 'm').test(toml);
39
+ }
40
+ /** A `[env.<env>.vars]` value, or null if unset/empty. Reads committed public config. */
41
+ export function getVar(toml, env, name) {
42
+ const block = envRecord(toml, env);
43
+ const vars = isRecord(block?.vars) ? block.vars : {};
44
+ const value = vars[name];
45
+ return typeof value === 'string' && value ? value : null;
46
+ }
47
+ /** Set a `[env.<env>.vars]` value, preserving any trailing comment. Throws if the key isn't present (scaffold first). */
48
+ export function setVar(toml, env, name, value) {
49
+ const re = new RegExp(`(\\[env\\.${escapeRe(env)}\\.vars\\][\\s\\S]*?\\n${escapeRe(name)} = )"[^"]*"`);
50
+ if (!re.test(toml)) {
51
+ throw new Error(`vars key "${name}" not found under [env.${env}.vars]`);
52
+ }
53
+ return toml.replace(re, (_m, prefix) => `${prefix}${JSON.stringify(value)}`);
54
+ }
55
+ /** The id written for `[[env.<env>.kv_namespaces]]` binding, or null if absent/empty. */
56
+ export function getKvId(toml, env, binding) {
57
+ const block = envRecord(toml, env);
58
+ const rows = isRecord(block) && Array.isArray(block.kv_namespaces) ? block.kv_namespaces : [];
59
+ for (const row of rows) {
60
+ if (isRecord(row) && row.binding === binding && typeof row.id === 'string') {
61
+ return row.id || null;
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+ /** Rewrite the `id = ...` line of the `[[env.<env>.kv_namespaces]]` table whose binding matches. Throws if absent. */
67
+ export function setKvId(toml, env, binding, id, title) {
68
+ const re = new RegExp(`(\\[\\[env\\.${escapeRe(env)}\\.kv_namespaces\\]\\]\\s*\\n\\s*binding = "${escapeRe(binding)}"\\s*\\n\\s*id = )[^\\n]*`);
69
+ if (!re.test(toml)) {
70
+ throw new Error(`kv_namespaces binding "${binding}" not found under [env.${env}]`);
71
+ }
72
+ return toml.replace(re, `$1"${id}" # ${title}`);
73
+ }
74
+ /**
75
+ * Append a copy of the whole `[env.<from>]` … block-group (every `[env.<from>...]`
76
+ * table up to the next non-`from` env or EOF) rewritten for `<to>`. Returns the new
77
+ * toml. Throws if `<from>` is absent or `<to>` already exists (blank/fill instead).
78
+ */
79
+ export function cloneEnvBlock(toml, from, to) {
80
+ if (!hasEnvBlock(toml, from))
81
+ throw new Error(`source [env.${from}] not found`);
82
+ if (hasEnvBlock(toml, to))
83
+ throw new Error(`target [env.${to}] already exists — blank/fill it instead`);
84
+ const lines = toml.split('\n');
85
+ const isFromHeader = (l) => new RegExp(`^\\[+env\\.${escapeRe(from)}(\\.|\\])`).test(l);
86
+ const isAnyEnvHeader = (l) => /^\[+env\.[A-Za-z0-9_-]+(\.|\])/.test(l);
87
+ const out = [];
88
+ let capturing = false;
89
+ for (const line of lines) {
90
+ if (isFromHeader(line)) {
91
+ capturing = true;
92
+ out.push(line);
93
+ }
94
+ else if (capturing && isAnyEnvHeader(line) && !isFromHeader(line)) {
95
+ capturing = false; // a different env began — stop
96
+ }
97
+ else if (capturing) {
98
+ out.push(line);
99
+ }
100
+ }
101
+ const cloned = out
102
+ .map((l) => l.replace(new RegExp(`(\\[+env\\.)${escapeRe(from)}(\\.|\\])`, 'g'), `$1${to}$2`))
103
+ .join('\n');
104
+ return `${toml.replace(/\s*$/, '')}\n\n${cloned}\n`;
105
+ }
106
+ /** Blank every `NAME = "..."` under `[env.<env>.vars]` whose key is in `keys` (env-specific values to re-fill). */
107
+ export function blankEnvVars(toml, env, keys) {
108
+ let out = toml;
109
+ for (const key of keys) {
110
+ const re = new RegExp(`(\\[env\\.${escapeRe(env)}\\.vars\\][\\s\\S]*?\\n${escapeRe(key)} = )"[^"]*"`);
111
+ if (re.test(out))
112
+ out = out.replace(re, '$1""');
113
+ }
114
+ return out;
115
+ }
116
+ /** Blank every `id = ...` line under the env's `[[env.<env>.kv_namespaces]]` tables (per-env resource ids). */
117
+ export function blankKvIds(toml, env) {
118
+ const re = new RegExp(`(\\[\\[env\\.${escapeRe(env)}\\.kv_namespaces\\]\\]\\s*\\n\\s*binding = "[^"]*"\\s*\\n\\s*id = )[^\\n]*`, 'g');
119
+ return toml.replace(re, '$1""');
120
+ }
121
+ /** Parse-guard: throws if `toml` no longer parses, so a bad edit can't be written to disk. */
122
+ export function assertToml(toml) {
123
+ parse(toml);
124
+ }
125
+ /** Persist a toml string after confirming it still parses. */
126
+ export function saveToml(path, toml) {
127
+ assertToml(toml);
128
+ writeFileSync(path, toml);
129
+ }
130
+ function envRecord(toml, env) {
131
+ const parsed = parse(toml);
132
+ const envs = isRecord(parsed) && isRecord(parsed.env) ? parsed.env : {};
133
+ const block = envs[env];
134
+ return isRecord(block) ? block : null;
135
+ }
136
+ /** Secret NAMES from a `.dev.vars.example` file (KEY=... / KEY="..."), ignoring comments/blanks. */
137
+ export function parseDevVarsExample(text) {
138
+ const names = [];
139
+ for (const raw of text.split('\n')) {
140
+ const line = raw.trim();
141
+ if (!line || line.startsWith('#'))
142
+ continue;
143
+ const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=/);
144
+ if (m)
145
+ names.push(m[1]);
146
+ }
147
+ return names;
148
+ }
149
+ /**
150
+ * Read the vars/secrets split for `env` from `<root>/wrangler.toml` (public var
151
+ * keys) + `<root>/.dev.vars.example` (secret names). This is the "what to prompt
152
+ * for" manifest a prepare-env script derives its work from.
153
+ */
154
+ export function readManifest(root, env) {
155
+ const toml = readFileSync(join(root, 'wrangler.toml'), 'utf8');
156
+ const block = envRecord(toml, env);
157
+ const varsRec = isRecord(block?.vars) ? block.vars : {};
158
+ const vars = Object.keys(varsRec);
159
+ const kvRows = isRecord(block) && Array.isArray(block.kv_namespaces) ? block.kv_namespaces : [];
160
+ const kvBindings = kvRows.flatMap((row) => (isRecord(row) && typeof row.binding === 'string' ? [row.binding] : []));
161
+ const examplePath = join(root, '.dev.vars.example');
162
+ const secrets = existsSync(examplePath) ? parseDevVarsExample(readFileSync(examplePath, 'utf8')) : [];
163
+ return { kvBindings, vars, secrets };
164
+ }
165
+ // ── PEM (a standard, reusable) ───────────────────────────────────────────────
166
+ /** True if `value` looks like a PEM block (any `-----BEGIN ...-----`). */
167
+ export function isPem(value) {
168
+ return /-----BEGIN [A-Z ]+-----/.test(value);
169
+ }
170
+ /** Read a `.pem` file (expanding a leading `~`) and assert it's a PEM block. Throws otherwise. */
171
+ export function readPemFile(path) {
172
+ const resolved = path.replace(/^~/, process.env.HOME ?? '~');
173
+ const content = readFileSync(resolved, 'utf8');
174
+ if (!isPem(content))
175
+ throw new Error(`${path} is not a PEM (no -----BEGIN block)`);
176
+ return content;
177
+ }
178
+ /** Heuristic: does this secret name suggest file input (PEM/key)? Used to preselect a file prompt — never to change semantics. */
179
+ export function looksLikeFileSecret(name) {
180
+ return /_PEM$|_KEY$|_CERT$/.test(name);
181
+ }
182
+ function asKvNamespaces(json) {
183
+ if (!Array.isArray(json))
184
+ return [];
185
+ const out = [];
186
+ for (const row of json) {
187
+ if (isRecord(row) && typeof row.id === 'string' && typeof row.title === 'string') {
188
+ out.push({ id: row.id, title: row.title });
189
+ }
190
+ }
191
+ return out;
192
+ }
193
+ function asSecretNames(json) {
194
+ if (!Array.isArray(json))
195
+ return [];
196
+ return json.flatMap((row) => (isRecord(row) && typeof row.name === 'string' ? [row.name] : []));
197
+ }
198
+ /** Best-effort ERROR line from a Bun ShellError (thrown on non-zero exit). */
199
+ export function errText(err) {
200
+ if (!isRecord(err))
201
+ return 'unknown error';
202
+ const stderr = 'stderr' in err && err.stderr != null ? String(err.stderr) : '';
203
+ const exit = 'exitCode' in err ? String(err.exitCode) : '?';
204
+ return stderr.split('\n').find((l) => l.includes('ERROR')) ?? `exit ${exit}`;
205
+ }
206
+ /** Live KV namespaces on the account (`kv namespace list` emits a pure JSON array); `[]` when offline/unauthenticated. */
207
+ export async function listNamespaces(cwd) {
208
+ try {
209
+ return asKvNamespaces(await $ `bunx wrangler kv namespace list`.cwd(cwd).quiet().json());
210
+ }
211
+ catch {
212
+ return [];
213
+ }
214
+ }
215
+ /**
216
+ * Reuse the namespace whose title exactly matches `logical`, else create it. The
217
+ * id always comes from the structured `list` JSON (create prints freeform text),
218
+ * so we re-list rather than scrape. Returns the id, or null on failure.
219
+ */
220
+ export async function ensureNamespace(logical, existing, cwd) {
221
+ const match = existing.find((n) => n.title === logical);
222
+ if (match)
223
+ return match.id;
224
+ try {
225
+ await $ `bunx wrangler kv namespace create ${logical}`.cwd(cwd).quiet();
226
+ }
227
+ catch (err) {
228
+ return errText(err).includes('already') ? (await reList(logical, cwd)) : null;
229
+ }
230
+ return reList(logical, cwd);
231
+ }
232
+ async function reList(logical, cwd) {
233
+ return (await listNamespaces(cwd)).find((n) => n.title === logical)?.id ?? null;
234
+ }
235
+ /** Current secret names on the worker for `env`; `[]` when none/unreachable (Cloudflare allows setting pre-deploy). */
236
+ export async function listSecretNames(env, cwd) {
237
+ try {
238
+ return asSecretNames(await $ `bunx wrangler secret list --format json --env ${env}`.cwd(cwd).quiet().json());
239
+ }
240
+ catch {
241
+ return [];
242
+ }
243
+ }
244
+ /** `wrangler secret put <name> --env <env>` piping `value` on stdin (never a CLI arg). Returns success. */
245
+ export async function putSecret(name, value, env, cwd) {
246
+ try {
247
+ await $ `bunx wrangler secret put ${name} --env ${env} < ${Buffer.from(value)}`.cwd(cwd).quiet();
248
+ return true;
249
+ }
250
+ catch {
251
+ return false;
252
+ }
253
+ }
@@ -0,0 +1,7 @@
1
+ export interface ScaffoldOptions {
2
+ /** Overwrite an existing scripts/prepare-env.ts. */
3
+ force?: boolean;
4
+ }
5
+ /** Write the starter script + wire the `prepare-env` nx target. Returns the script path. */
6
+ export declare function scaffold(root: string, project: string, options?: ScaffoldOptions): string;
7
+ //# sourceMappingURL=scaffold.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scaffold.d.ts","sourceRoot":"","sources":["../../src/wrangler/scaffold.ts"],"names":[],"mappings":"AAYA,MAAM,WAAW,eAAe;IAC9B,oDAAoD;IACpD,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAmMD,4FAA4F;AAC5F,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,GAAE,eAAoB,GAAG,MAAM,CA2B7F"}
@@ -0,0 +1,228 @@
1
+ /**
2
+ * `smoo wrangler scaffold <project>` — write a starter `scripts/prepare-env.ts`
3
+ * into a wrangler project and wire its `prepare-env` nx target. The generated
4
+ * script imports the `@smoothbricks/cli/wrangler/prepare-env` primitives and
5
+ * derives its work at runtime from the project's manifest (wrangler.toml [vars] +
6
+ * .dev.vars.example), so it never drifts from what the worker declares.
7
+ */
8
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
9
+ import { join, relative } from 'node:path';
10
+ import { getOrCreateRecord, recordProperty, writeJsonObject } from '../lib/json.js';
11
+ import { getWorkspacePackageManifests } from '../lib/workspace.js';
12
+ /** Locate a workspace package by nx name, package name, or relative path; must have a wrangler.toml. */
13
+ function resolveProject(root, project) {
14
+ const manifests = getWorkspacePackageManifests(root);
15
+ const match = manifests.find((m) => {
16
+ const nx = recordProperty(m.json, 'nx');
17
+ const nxName = nx && typeof nx.name === 'string' ? nx.name : null;
18
+ return nxName === project || m.name === project || relative(root, m.path) === project;
19
+ });
20
+ if (!match) {
21
+ const known = manifests.map((m) => recordProperty(m.json, 'nx')?.name ?? m.name).join(', ');
22
+ throw new Error(`project "${project}" not found. Known: ${known}`);
23
+ }
24
+ const dir = match.path; // WorkspacePackageManifest.path is absolute
25
+ if (!existsSync(join(dir, 'wrangler.toml'))) {
26
+ throw new Error(`"${project}" has no wrangler.toml — not a wrangler project`);
27
+ }
28
+ return { dir, label: relative(root, dir), packageJsonPath: match.packageJsonPath, json: match.json };
29
+ }
30
+ /** The generated starter script — generic, manifest-driven, prompt-batteries included. */
31
+ function renderScript(projectName) {
32
+ return `#!/usr/bin/env bun
33
+ /**
34
+ * Prepare a deploy environment for ${projectName}. Generated by \`smoo wrangler scaffold\`.
35
+ *
36
+ * Provisions what \`wrangler deploy\` assumes already exists, idempotently:
37
+ * - KV namespaces the worker binds (ids written into wrangler.toml)
38
+ * - public [env.<env>.vars] (committed config the deploy applies)
39
+ * - secrets (wrangler secret put; values never touch the repo)
40
+ *
41
+ * Work is derived from the manifest (wrangler.toml [vars] + .dev.vars.example),
42
+ * so it stays in sync with what the worker declares. Customize the app-specific
43
+ * bits below (KV title convention; any secret that feeds >1 binding).
44
+ *
45
+ * Usage:
46
+ * bun scripts/prepare-env.ts <env> # provision (staging | production | …)
47
+ * bun scripts/prepare-env.ts <env> --check # report state, no writes
48
+ */
49
+ import { readFileSync } from 'node:fs';
50
+ import { resolve } from 'node:path';
51
+ import * as p from '@clack/prompts';
52
+ import * as pe from '@smoothbricks/cli/wrangler/prepare-env';
53
+
54
+ const ROOT = resolve(import.meta.dir, '..');
55
+ const WRANGLER = resolve(ROOT, 'wrangler.toml');
56
+ const readToml = () => readFileSync(WRANGLER, 'utf8');
57
+
58
+ // App-specific: the account-level logical title for a KV binding. Default is
59
+ // lowercase + underscores→dashes; edit if your namespaces are named differently.
60
+ function kvTitle(binding: string, env: string): string {
61
+ return \`\${binding.toLowerCase().replace(/_/g, '-')}-\${env}\`;
62
+ }
63
+
64
+ function bail<T>(v: T | symbol): asserts v is T {
65
+ if (p.isCancel(v)) {
66
+ p.cancel('Aborted — anything already provisioned stays; re-run to continue (idempotent).');
67
+ process.exit(1);
68
+ }
69
+ }
70
+
71
+ async function checkMode(env: string): Promise<void> {
72
+ const toml = readToml();
73
+ const { kvBindings, vars, secrets } = pe.readManifest(ROOT, env);
74
+ p.log.info(\`env block [env.\${env}]: \${pe.hasEnvBlock(toml, env) ? '✓ present' : '✗ missing'}\`);
75
+ const live = await pe.listNamespaces(ROOT);
76
+ for (const binding of kvBindings) {
77
+ const inToml = pe.getKvId(toml, env, binding);
78
+ const liveId = live.find((n) => n.title === kvTitle(binding, env))?.id ?? null;
79
+ p.log.message(\` KV \${binding}: \${!liveId ? '✗ not created' : inToml === liveId ? \`✓ \${liveId}\` : \`⚠ drift (live \${liveId}, toml \${inToml ?? 'unset'})\`}\`);
80
+ }
81
+ for (const name of vars) {
82
+ p.log.message(\` var \${name}: \${pe.getVar(toml, env, name) ? '✓ set' : '✗ empty'}\`);
83
+ }
84
+ const set = await pe.listSecretNames(env, ROOT);
85
+ for (const name of secrets) {
86
+ p.log.message(\` secret \${name}: \${set.includes(name) ? '✓ set' : '✗ missing'}\`);
87
+ }
88
+ }
89
+
90
+ async function main(): Promise<void> {
91
+ const env = process.argv.slice(2).find((a) => !a.startsWith('-'));
92
+ if (!env) {
93
+ console.error('usage: bun scripts/prepare-env.ts <env> [--check]');
94
+ process.exit(1);
95
+ }
96
+ const check = process.argv.includes('--check');
97
+ p.intro(\`${projectName} · prepare \${env}\${check ? ' (check)' : ''}\`);
98
+
99
+ if (check) {
100
+ await checkMode(env);
101
+ p.outro(\`check complete for "\${env}" — nothing changed\`);
102
+ return;
103
+ }
104
+
105
+ let toml = readToml();
106
+
107
+ // 0. Env block. If missing, offer to clone an existing env and blank its
108
+ // per-env values (KV ids), then fill below.
109
+ if (!pe.hasEnvBlock(toml, env)) {
110
+ const from = pe.firstWranglerEnv(toml);
111
+ if (from && (await p.confirm({ message: \`No [env.\${env}] — clone from [env.\${from}]?\` })) === true) {
112
+ toml = pe.blankKvIds(pe.cloneEnvBlock(toml, from, env), env);
113
+ pe.saveToml(WRANGLER, toml);
114
+ p.log.success(\`cloned [env.\${from}] → [env.\${env}] (KV ids blanked; edit domain-specific vars below)\`);
115
+ } else {
116
+ p.log.warn(\`Add an [env.\${env}] block to wrangler.toml first, then re-run.\`);
117
+ return;
118
+ }
119
+ }
120
+
121
+ const { kvBindings, vars, secrets } = pe.readManifest(ROOT, env);
122
+
123
+ // 1. KV namespaces → ids into the toml.
124
+ const live = await pe.listNamespaces(ROOT);
125
+ for (const binding of kvBindings) {
126
+ const s = p.spinner();
127
+ s.start(\`KV \${binding}\`);
128
+ const id = await pe.ensureNamespace(kvTitle(binding, env), live, ROOT);
129
+ if (!id) { s.stop(\`✗ \${binding}\`); continue; }
130
+ toml = pe.setKvId(toml, env, binding, id, kvTitle(binding, env));
131
+ pe.saveToml(WRANGLER, toml);
132
+ s.stop(\`✓ \${binding} → \${id}\`);
133
+ }
134
+
135
+ // 2. Public vars → [env.<env>.vars] (committed, applied by deploy). Enter keeps
136
+ // current; a same-named env var overrides (CI).
137
+ if ((await p.confirm({ message: \`Set public vars for "\${env}"?\` })) === true) {
138
+ for (const name of vars) {
139
+ const current = pe.getVar(toml, env, name);
140
+ const preset = process.env[name]?.trim();
141
+ let value: string;
142
+ if (preset) {
143
+ value = preset;
144
+ p.log.info(\`\${name}: from environment\`);
145
+ } else {
146
+ const raw = await p.text({ message: \`\${name}:\`, placeholder: current ? 'Enter to keep current' : 'Enter to skip', initialValue: current ?? '' });
147
+ bail(raw);
148
+ value = raw.trim();
149
+ }
150
+ if (!value || value === current) continue;
151
+ toml = pe.setVar(toml, env, name, value);
152
+ pe.saveToml(WRANGLER, toml);
153
+ }
154
+ }
155
+
156
+ // 3. Secrets → wrangler secret put (values never in the repo). File-or-paste;
157
+ // PEM validated by content. Skip-if-set unless you choose to replace.
158
+ const alreadySet = await pe.listSecretNames(env, ROOT);
159
+ if ((await p.confirm({ message: \`Set secrets for "\${env}"?\` })) === true) {
160
+ for (const name of secrets) {
161
+ const preset = process.env[name]?.trim();
162
+ if (alreadySet.includes(name) && !preset) {
163
+ const replace = await p.confirm({ message: \`\${name} already set — replace?\`, initialValue: false });
164
+ bail(replace);
165
+ if (!replace) { p.log.info(\`kept \${name}\`); continue; }
166
+ }
167
+ let value: string;
168
+ if (preset) {
169
+ value = preset;
170
+ } else if (pe.looksLikeFileSecret(name)) {
171
+ const path = await p.text({ message: \`\${name} — path to file:\`, placeholder: 'Enter to skip' });
172
+ bail(path);
173
+ if (!path.trim()) continue;
174
+ value = pe.readPemFile(path.trim());
175
+ } else {
176
+ const raw = await p.password({ message: \`\${name}:\` });
177
+ bail(raw);
178
+ value = raw.trim();
179
+ if (!value) continue;
180
+ if (pe.isPem(value) === false && /KEY|PEM|CERT/.test(name) && value.startsWith('~')) {
181
+ value = pe.readPemFile(value);
182
+ }
183
+ }
184
+ const s = p.spinner();
185
+ s.start(\`secret \${name}\`);
186
+ const ok = await pe.putSecret(name, value, env, ROOT);
187
+ s.stop(\`\${ok ? '✓' : '✗'} \${name}\`);
188
+ }
189
+ // Verify: re-list and flag any we set that didn't land.
190
+ const after = await pe.listSecretNames(env, ROOT);
191
+ const missing = secrets.filter((n) => !after.includes(n));
192
+ if (missing.length) p.log.warn(\`not listed afterwards — verify manually: \${missing.join(', ')}\`);
193
+ }
194
+
195
+ p.outro(\`"\${env}" prepared — re-run anytime (idempotent) · inspect with --check · then deploy\`);
196
+ }
197
+
198
+ if (import.meta.main) {
199
+ await main();
200
+ }
201
+ `;
202
+ }
203
+ /** Write the starter script + wire the `prepare-env` nx target. Returns the script path. */
204
+ export function scaffold(root, project, options = {}) {
205
+ const { dir, label, packageJsonPath, json } = resolveProject(root, project);
206
+ const scriptsDir = join(dir, 'scripts');
207
+ const scriptPath = join(scriptsDir, 'prepare-env.ts');
208
+ const existed = existsSync(scriptPath);
209
+ if (existed && !options.force) {
210
+ throw new Error(`${label}/scripts/prepare-env.ts already exists — pass --force to overwrite`);
211
+ }
212
+ mkdirSync(scriptsDir, { recursive: true });
213
+ writeFileSync(scriptPath, renderScript(project));
214
+ console.log(`${existed ? 'overwrote' : 'created'} ${label}/scripts/prepare-env.ts`);
215
+ // Wire the nx target (idempotent).
216
+ const nx = getOrCreateRecord(json, 'nx');
217
+ const targets = getOrCreateRecord(nx, 'targets');
218
+ if (!recordProperty(targets, 'prepare-env')) {
219
+ targets['prepare-env'] = {
220
+ executor: 'nx:run-commands',
221
+ options: { command: 'bun scripts/prepare-env.ts', cwd: '{projectRoot}' },
222
+ };
223
+ writeJsonObject(packageJsonPath, json);
224
+ console.log(`updated ${label}/package.json prepare-env target`);
225
+ }
226
+ console.log(`\nnext: fill secret NAMES in ${label}/.dev.vars.example, then\n bunx nx run ${project}:prepare-env -- <env>`);
227
+ return scriptPath;
228
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smoothbricks/cli",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "SmoothBricks monorepo automation CLI",
6
6
  "bin": {
@@ -33,6 +33,13 @@
33
33
  "development": "./src/monorepo/git-config.ts",
34
34
  "import": "./dist/monorepo/git-config.js",
35
35
  "default": "./dist/monorepo/git-config.js"
36
+ },
37
+ "./wrangler/prepare-env": {
38
+ "types": "./dist/wrangler/prepare-env.d.ts",
39
+ "bun": "./src/wrangler/prepare-env.ts",
40
+ "development": "./src/wrangler/prepare-env.ts",
41
+ "import": "./dist/wrangler/prepare-env.js",
42
+ "default": "./dist/wrangler/prepare-env.js"
36
43
  }
37
44
  },
38
45
  "files": [
@@ -51,6 +58,7 @@
51
58
  "publint": "^0.3.18",
52
59
  "semver": "^7.7.4",
53
60
  "sherif": "^1.11.1",
61
+ "smol-toml": "^1.6.1",
54
62
  "tslib": "^2.8.1"
55
63
  },
56
64
  "devDependencies": {
package/src/cli.ts CHANGED
@@ -3,6 +3,7 @@ import { variants } from './generate/index.js';
3
3
  import { cliPackageVersion } from './lib/cli-package.js';
4
4
  import { findRepoRoot } from './lib/run.js';
5
5
  import { resolvePrConflicts } from './pr/index.js';
6
+ import { scaffold } from './wrangler/scaffold.js';
6
7
 
7
8
  export async function runCli(argv = process.argv.slice(2)): Promise<void> {
8
9
  const program = buildProgram();
@@ -354,6 +355,15 @@ function buildProgram(): Command {
354
355
  }
355
356
  });
356
357
 
358
+ const wrangler = program.command('wrangler').description('Cloudflare wrangler project helpers');
359
+ wrangler
360
+ .command('scaffold <project>')
361
+ .description('Write a starter scripts/prepare-env.ts (manifest-driven) and wire its nx target')
362
+ .option('--force', 'overwrite an existing scripts/prepare-env.ts')
363
+ .action(async (project: string, options: { force?: boolean }) => {
364
+ scaffold(await findRepoRoot(), project, { force: options.force });
365
+ });
366
+
357
367
  return program;
358
368
  }
359
369
 
@@ -1,5 +1,13 @@
1
1
  import { describe, expect, it } from 'bun:test';
2
- import { LOCAL_SECTION_MARKER, splitLocalSectionForTest } from './managed-files.js';
2
+ import fc from 'fast-check';
3
+ import {
4
+ extractInlineLocalBlocksForTest,
5
+ INLINE_LOCAL_BEGIN,
6
+ INLINE_LOCAL_END,
7
+ LOCAL_SECTION_MARKER,
8
+ reinsertInlineLocalBlocksForTest,
9
+ splitLocalSectionForTest,
10
+ } from './managed-files.js';
3
11
 
4
12
  const MANAGED = '# managed content\npath merge=driver\n';
5
13
 
@@ -26,3 +34,95 @@ describe('managed-file local sections', () => {
26
34
  expect(localTail.startsWith(LOCAL_SECTION_MARKER)).toBe(true);
27
35
  });
28
36
  });
37
+
38
+ describe('managed-file inline local blocks', () => {
39
+ it('content with no inline markers extracts as fully managed, no blocks', () => {
40
+ const { withoutInline, blocks } = extractInlineLocalBlocksForTest(MANAGED);
41
+ expect(withoutInline).toBe(MANAGED);
42
+ expect(blocks).toEqual([]);
43
+ });
44
+
45
+ it('a wrapped block is extracted and removed, anchored on the preceding line', () => {
46
+ const current = ['a:', ' - one', ' - two', INLINE_LOCAL_BEGIN, ' - repo-owned', INLINE_LOCAL_END, 'b:'].join(
47
+ '\n',
48
+ );
49
+ const { withoutInline, blocks } = extractInlineLocalBlocksForTest(current);
50
+ expect(withoutInline).toBe(['a:', ' - one', ' - two', 'b:'].join('\n'));
51
+ expect(blocks).toEqual([{ anchor: ' - two', lines: ' - repo-owned' }]);
52
+ });
53
+
54
+ it('multiple blocks each anchor to their own preceding line', () => {
55
+ const current = [
56
+ 'x',
57
+ INLINE_LOCAL_BEGIN,
58
+ 'first',
59
+ INLINE_LOCAL_END,
60
+ 'y',
61
+ INLINE_LOCAL_BEGIN,
62
+ 'second',
63
+ INLINE_LOCAL_END,
64
+ 'z',
65
+ ].join('\n');
66
+ const { withoutInline, blocks } = extractInlineLocalBlocksForTest(current);
67
+ expect(withoutInline).toBe(['x', 'y', 'z'].join('\n'));
68
+ expect(blocks).toEqual([
69
+ { anchor: 'x', lines: 'first' },
70
+ { anchor: 'y', lines: 'second' },
71
+ ]);
72
+ });
73
+
74
+ it('a begin marker with no preceding line throws rather than silently dropping content', () => {
75
+ const current = [INLINE_LOCAL_BEGIN, 'orphan', INLINE_LOCAL_END].join('\n');
76
+ expect(() => extractInlineLocalBlocksForTest(current)).toThrow(/no preceding anchor/);
77
+ });
78
+
79
+ it('an unterminated begin marker throws rather than silently dropping content', () => {
80
+ const current = ['a', INLINE_LOCAL_BEGIN, 'unterminated'].join('\n');
81
+ expect(() => extractInlineLocalBlocksForTest(current)).toThrow(/no matching/);
82
+ });
83
+
84
+ it('reinserting into fresh content with no blocks is a no-op', () => {
85
+ expect(reinsertInlineLocalBlocksForTest(MANAGED, [])).toBe(MANAGED);
86
+ });
87
+
88
+ it('reinserting splices the block back after its anchor in freshly rendered content', () => {
89
+ const fresh = ['a:', ' - one', ' - two', 'b:'].join('\n');
90
+ const result = reinsertInlineLocalBlocksForTest(fresh, [{ anchor: ' - two', lines: ' - repo-owned' }]);
91
+ expect(result).toBe(
92
+ ['a:', ' - one', ' - two', INLINE_LOCAL_BEGIN, ' - repo-owned', INLINE_LOCAL_END, 'b:'].join('\n'),
93
+ );
94
+ });
95
+
96
+ it("reinserting refuses when the anchor no longer appears — never silently drops the repo's customization", () => {
97
+ const fresh = ['a:', ' - one', 'b:'].join('\n'); // ' - two' is gone
98
+ expect(() => reinsertInlineLocalBlocksForTest(fresh, [{ anchor: ' - two', lines: ' - repo-owned' }])).toThrow(
99
+ /no longer matches/,
100
+ );
101
+ });
102
+
103
+ it('property: extract then reinsert round-trips to the original for unique, single-occurrence anchors', () => {
104
+ // Lines that can serve as anchors: non-empty, not a marker, and each drawn
105
+ // from a small alphabet so uniqueness is checkable — the round-trip only
106
+ // holds when an anchor line occurs exactly once in the managed section
107
+ // (reinsert finds the FIRST occurrence by design, same as the real file
108
+ // shape: comments/patterns are unique in practice).
109
+ const linePool = fc.constantFrom('alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta');
110
+ fc.assert(
111
+ fc.property(
112
+ fc.uniqueArray(linePool, { minLength: 1, maxLength: 6 }),
113
+ fc.array(fc.string(), { maxLength: 3 }),
114
+ (anchors, blockLinesFlat) => {
115
+ const blockLines = blockLinesFlat.length > 0 ? [blockLinesFlat.join('|') || 'x'] : ['x'];
116
+ const fresh = anchors.join('\n');
117
+ const withBlocks = anchors
118
+ .map((anchor) => [anchor, INLINE_LOCAL_BEGIN, ...blockLines, INLINE_LOCAL_END].join('\n'))
119
+ .join('\n');
120
+ const { withoutInline, blocks } = extractInlineLocalBlocksForTest(withBlocks);
121
+ expect(withoutInline).toBe(fresh);
122
+ const reinserted = reinsertInlineLocalBlocksForTest(fresh, blocks);
123
+ expect(reinserted).toBe(withBlocks);
124
+ },
125
+ ),
126
+ );
127
+ });
128
+ });