@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.
@@ -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.8.0",
3
+ "version": "0.10.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
+ "toml-eslint-parser": "^1.0.3",
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
 
@@ -6,6 +6,24 @@ import type { GitReleaseTagInfo } from '../../core.js';
6
6
 
7
7
  const GIT_TIMEOUT_MS = 10_000;
8
8
 
9
+ // Isolate fixture git from the runner environment and skip fsync. These are
10
+ // throwaway temp repos (deleted in withFixtureRepo's finally) so durability is
11
+ // irrelevant — fsync is pure cost. On GitHub Actions the ~20 sequential git
12
+ // spawns in a push test otherwise sum past the 30s cap: fsync on the runner's
13
+ // disk is the dominant cost, and inheriting the runner's global/system config
14
+ // (credential helpers, url.*.insteadOf rewrites, fsmonitor) slows every spawn
15
+ // and risks a credential-prompt hang. Unknown core.* keys are ignored by older
16
+ // git, so this is a harmless speedup locally and the real fix on CI.
17
+ const GIT_FIXTURE_ENV: Record<string, string> = {
18
+ GIT_CONFIG_GLOBAL: '/dev/null',
19
+ GIT_CONFIG_SYSTEM: '/dev/null',
20
+ GIT_TERMINAL_PROMPT: '0',
21
+ GIT_OPTIONAL_LOCKS: '0',
22
+ GIT_CONFIG_COUNT: '1',
23
+ GIT_CONFIG_KEY_0: 'core.fsync',
24
+ GIT_CONFIG_VALUE_0: 'none',
25
+ };
26
+
9
27
  export async function withFixtureRepo(fn: (root: string) => Promise<void>): Promise<void> {
10
28
  const root = await mkdtemp(join(tmpdir(), 'smoo-release-test-'));
11
29
  try {
@@ -86,7 +104,7 @@ export async function gitSucceeds(root: string, args: string[]): Promise<boolean
86
104
  async function gitResult(root: string, args: string[], env?: Record<string, string>): Promise<GitResult> {
87
105
  const proc = Bun.spawn(['git', ...args], {
88
106
  cwd: root,
89
- env: { ...process.env, ...env },
107
+ env: { ...process.env, ...GIT_FIXTURE_ENV, ...env },
90
108
  stdout: 'pipe',
91
109
  stderr: 'pipe',
92
110
  });
@@ -0,0 +1,347 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import {
6
+ assertToml,
7
+ blankD1Ids,
8
+ blankEnvVars,
9
+ blankKvIds,
10
+ cloneEnvBlock,
11
+ firstWranglerEnv,
12
+ getD1Id,
13
+ getD1Name,
14
+ getKvId,
15
+ getVar,
16
+ hasEnvBlock,
17
+ isPem,
18
+ looksLikeFileSecret,
19
+ parseDevVarsExample,
20
+ readManifest,
21
+ readPemFile,
22
+ setD1Id,
23
+ setKvId,
24
+ setVar,
25
+ } from './prepare-env.js';
26
+
27
+ // The wrangler shell-outs (listNamespaces / ensureNamespace / listSecretNames /
28
+ // putSecret) invoke `bunx wrangler` against a live account and are integration-
29
+ // only — deliberately not exercised here. Everything below is a pure primitive.
30
+
31
+ describe('firstWranglerEnv', () => {
32
+ it('returns the name of the first [env.<name>] header', () => {
33
+ expect(firstWranglerEnv('name = "svc"\n\n[env.staging]\n\n[env.production]\n')).toBe('staging');
34
+ });
35
+
36
+ it('derives the env from a nested [env.<name>.vars] sub-table', () => {
37
+ expect(firstWranglerEnv('[env.prod.vars]\nFOO = "bar"\n')).toBe('prod');
38
+ });
39
+
40
+ it('returns null when the toml declares no env block', () => {
41
+ expect(firstWranglerEnv('name = "svc"\nmain = "src/index.ts"\n')).toBeNull();
42
+ });
43
+ });
44
+
45
+ describe('hasEnvBlock', () => {
46
+ const toml = 'name = "svc"\n\n[env.production]\nroute = "x"\n';
47
+
48
+ it('is true for a declared [env.<env>] block', () => {
49
+ expect(hasEnvBlock(toml, 'production')).toBe(true);
50
+ });
51
+
52
+ it('is false for an env the toml never declares', () => {
53
+ expect(hasEnvBlock(toml, 'staging')).toBe(false);
54
+ });
55
+ });
56
+
57
+ describe('getVar / setVar', () => {
58
+ const toml =
59
+ 'name = "svc"\n\n[env.staging.vars]\nTOKEN = "staging-token"\n\n[env.production.vars]\nTOKEN = "production-token"\nEMPTY = ""\n';
60
+
61
+ it('reads a set value and returns null for empty or absent keys', () => {
62
+ expect(getVar(toml, 'production', 'TOKEN')).toBe('production-token');
63
+ expect(getVar(toml, 'production', 'EMPTY')).toBeNull();
64
+ expect(getVar(toml, 'production', 'MISSING')).toBeNull();
65
+ });
66
+
67
+ it('rewrites the target value while preserving a trailing comment', () => {
68
+ const src = '[env.production.vars]\nAPI_URL = "https://old.example.com" # keep this comment\n';
69
+ const out = setVar(src, 'production', 'API_URL', 'https://new.example.com');
70
+ expect(out).toContain('# keep this comment');
71
+ expect(getVar(out, 'production', 'API_URL')).toBe('https://new.example.com');
72
+ });
73
+
74
+ it('edits only the target env block, leaving a same-named key under another env untouched', () => {
75
+ const out = setVar(toml, 'production', 'TOKEN', 'rotated-prod');
76
+ expect(getVar(out, 'production', 'TOKEN')).toBe('rotated-prod');
77
+ expect(getVar(out, 'staging', 'TOKEN')).toBe('staging-token');
78
+ });
79
+
80
+ it('JSON-encodes a value with $ and quotes so the result still parses', () => {
81
+ const src = '[env.production.vars]\nSECRET_HINT = "placeholder"\n';
82
+ const value = 'a$b"c';
83
+ const out = setVar(src, 'production', 'SECRET_HINT', value);
84
+ expect(() => assertToml(out)).not.toThrow();
85
+ expect(getVar(out, 'production', 'SECRET_HINT')).toBe(value);
86
+ });
87
+
88
+ it('throws when the vars key is absent (scaffold-first contract)', () => {
89
+ expect(() => setVar(toml, 'production', 'NOT_THERE', 'x')).toThrow();
90
+ });
91
+ });
92
+
93
+ describe('getKvId / setKvId', () => {
94
+ const toml =
95
+ 'name = "svc"\n\n' +
96
+ '[[env.production.kv_namespaces]]\nbinding = "CACHE"\nid = "cache-id-123"\n\n' +
97
+ '[[env.production.kv_namespaces]]\nbinding = "SESSIONS"\nid = ""\n';
98
+
99
+ it('reads the id for a matching binding and null for empty/absent bindings', () => {
100
+ expect(getKvId(toml, 'production', 'CACHE')).toBe('cache-id-123');
101
+ expect(getKvId(toml, 'production', 'SESSIONS')).toBeNull();
102
+ expect(getKvId(toml, 'production', 'MISSING')).toBeNull();
103
+ });
104
+
105
+ it('writes "<id>" # <title> against the right binding, leaving siblings intact', () => {
106
+ const out = setKvId(toml, 'production', 'SESSIONS', 'sess-id-456', 'svc-sessions-production');
107
+ expect(out).toContain('# svc-sessions-production');
108
+ expect(getKvId(out, 'production', 'SESSIONS')).toBe('sess-id-456');
109
+ expect(getKvId(out, 'production', 'CACHE')).toBe('cache-id-123');
110
+ });
111
+
112
+ it('throws when the binding block is absent', () => {
113
+ expect(() => setKvId(toml, 'production', 'MISSING', 'x', 't')).toThrow();
114
+ });
115
+ });
116
+
117
+ describe('cloneEnvBlock', () => {
118
+ const staging =
119
+ 'name = "svc"\n\n' +
120
+ '[env.staging]\nroute = "staging.example.com"\n\n' +
121
+ '[env.staging.vars]\nLEVEL = "debug"\n\n' +
122
+ '[[env.staging.kv_namespaces]]\nbinding = "CACHE"\nid = "staging-cache"\n';
123
+
124
+ it('appends a rewritten copy while leaving the source block intact', () => {
125
+ const out = cloneEnvBlock(staging, 'staging', 'production');
126
+ expect(() => assertToml(out)).not.toThrow();
127
+ expect(hasEnvBlock(out, 'production')).toBe(true);
128
+ // source untouched
129
+ expect(getVar(out, 'staging', 'LEVEL')).toBe('debug');
130
+ expect(getKvId(out, 'staging', 'CACHE')).toBe('staging-cache');
131
+ // cloned vars + kv tables present under the new env
132
+ expect(getVar(out, 'production', 'LEVEL')).toBe('debug');
133
+ expect(getKvId(out, 'production', 'CACHE')).toBe('staging-cache');
134
+ });
135
+
136
+ it('throws when the source env is missing', () => {
137
+ expect(() => cloneEnvBlock(staging, 'qa', 'production')).toThrow();
138
+ });
139
+
140
+ it('throws when the target env already exists', () => {
141
+ const withBoth = `${staging}\n[env.production]\nroute = "prod.example.com"\n`;
142
+ expect(() => cloneEnvBlock(withBoth, 'staging', 'production')).toThrow();
143
+ });
144
+ });
145
+
146
+ describe('blankEnvVars / blankKvIds', () => {
147
+ it('blanks the named vars and leaves the others untouched', () => {
148
+ const toml = '[env.production.vars]\nKEEP = "keep-value"\nDROP_A = "a"\nDROP_B = "b"\n';
149
+ const out = blankEnvVars(toml, 'production', ['DROP_A', 'DROP_B']);
150
+ expect(getVar(out, 'production', 'DROP_A')).toBeNull();
151
+ expect(getVar(out, 'production', 'DROP_B')).toBeNull();
152
+ expect(getVar(out, 'production', 'KEEP')).toBe('keep-value');
153
+ });
154
+
155
+ it('blanks every kv id under the env while preserving the bindings', () => {
156
+ const toml =
157
+ '[[env.production.kv_namespaces]]\nbinding = "CACHE"\nid = "cache-id"\n\n' +
158
+ '[[env.production.kv_namespaces]]\nbinding = "SESSIONS"\nid = "sess-id"\n';
159
+ const out = blankKvIds(toml, 'production');
160
+ expect(getKvId(out, 'production', 'CACHE')).toBeNull();
161
+ expect(getKvId(out, 'production', 'SESSIONS')).toBeNull();
162
+ // bindings survived: setKvId still resolves them and re-fills an id
163
+ const refilled = setKvId(out, 'production', 'CACHE', 'new-cache', 'title');
164
+ expect(getKvId(refilled, 'production', 'CACHE')).toBe('new-cache');
165
+ });
166
+ });
167
+
168
+ // D1 shell-outs (listD1Databases / ensureD1Database) invoke `bunx wrangler d1`
169
+ // against a live account and are integration-only — deliberately not exercised.
170
+
171
+ describe('getD1Id / getD1Name', () => {
172
+ const toml =
173
+ '[env.staging]\n\n' +
174
+ '[[env.staging.d1_databases]]\nbinding = "DB"\ndatabase_id = "abc123"\ndatabase_name = "app-staging-db"\n\n' +
175
+ '[env.production]\n\n' +
176
+ '[[env.production.d1_databases]]\nbinding = "DB"\ndatabase_id = ""\ndatabase_name = "app-db"\n';
177
+
178
+ it('reads the id for a matching binding and null for empty id, absent binding, and absent env', () => {
179
+ expect(getD1Id(toml, 'staging', 'DB')).toBe('abc123');
180
+ expect(getD1Id(toml, 'production', 'DB')).toBeNull(); // production id is ""
181
+ expect(getD1Id(toml, 'staging', 'MISSING')).toBeNull(); // absent binding
182
+ expect(getD1Id(toml, 'qa', 'DB')).toBeNull(); // absent env
183
+ });
184
+
185
+ it('reads the database_name for a matching binding and null when the binding or env is absent', () => {
186
+ expect(getD1Name(toml, 'staging', 'DB')).toBe('app-staging-db');
187
+ expect(getD1Name(toml, 'production', 'DB')).toBe('app-db'); // present even though its id is blank
188
+ expect(getD1Name(toml, 'staging', 'MISSING')).toBeNull();
189
+ expect(getD1Name(toml, 'qa', 'DB')).toBeNull();
190
+ });
191
+ });
192
+
193
+ describe('setD1Id', () => {
194
+ const toml =
195
+ '[[env.staging.d1_databases]]\nbinding = "DB"\ndatabase_id = ""\ndatabase_name = "app-staging-db"\n\n' +
196
+ '[[env.staging.d1_databases]]\nbinding = "ANALYTICS"\ndatabase_id = ""\ndatabase_name = "app-staging-analytics"\n\n' +
197
+ '[[env.production.d1_databases]]\nbinding = "DB"\ndatabase_id = "prod-existing"\ndatabase_name = "app-db"\n';
198
+
199
+ it('writes "<id>" # <name> against the matching env+binding, leaving siblings and other envs intact', () => {
200
+ const out = setD1Id(toml, 'staging', 'DB', 'new-staging-id', 'app-staging-db');
201
+ expect(out).toContain('"new-staging-id" # app-staging-db');
202
+ expect(getD1Id(out, 'staging', 'DB')).toBe('new-staging-id'); // round-trip
203
+ expect(getD1Id(out, 'staging', 'ANALYTICS')).toBeNull(); // sibling binding untouched
204
+ expect(getD1Id(out, 'production', 'DB')).toBe('prod-existing'); // other env untouched
205
+ });
206
+
207
+ it('tolerates database_name preceding database_id and preserves the name', () => {
208
+ const nameFirst =
209
+ '[[env.staging.d1_databases]]\nbinding = "DB"\ndatabase_name = "app-staging-db"\ndatabase_id = "old"\n';
210
+ const out = setD1Id(nameFirst, 'staging', 'DB', 'fresh', 'app-staging-db');
211
+ expect(getD1Id(out, 'staging', 'DB')).toBe('fresh');
212
+ expect(getD1Name(out, 'staging', 'DB')).toBe('app-staging-db'); // name line not clobbered
213
+ expect(() => assertToml(out)).not.toThrow();
214
+ });
215
+
216
+ it('throws when the binding or the env is absent', () => {
217
+ expect(() => setD1Id(toml, 'staging', 'MISSING', 'x', 'n')).toThrow();
218
+ expect(() => setD1Id(toml, 'qa', 'DB', 'x', 'n')).toThrow();
219
+ });
220
+ });
221
+
222
+ describe('blankD1Ids', () => {
223
+ const toml =
224
+ '[[env.staging.d1_databases]]\nbinding = "DB"\ndatabase_id = "staging-db-id"\ndatabase_name = "app-staging-db"\n\n' +
225
+ '[[env.staging.d1_databases]]\nbinding = "ANALYTICS"\ndatabase_id = "staging-an-id"\ndatabase_name = "app-staging-analytics"\n\n' +
226
+ '[[env.production.d1_databases]]\nbinding = "DB"\ndatabase_id = "prod-db-id"\ndatabase_name = "app-db"\n';
227
+
228
+ it('blanks every d1 id under the env while names, bindings, and the other env survive', () => {
229
+ const out = blankD1Ids(toml, 'staging');
230
+ expect(getD1Id(out, 'staging', 'DB')).toBeNull();
231
+ expect(getD1Id(out, 'staging', 'ANALYTICS')).toBeNull();
232
+ // names survive the blanking
233
+ expect(getD1Name(out, 'staging', 'DB')).toBe('app-staging-db');
234
+ expect(getD1Name(out, 'staging', 'ANALYTICS')).toBe('app-staging-analytics');
235
+ // other env's id is untouched
236
+ expect(getD1Id(out, 'production', 'DB')).toBe('prod-db-id');
237
+ // bindings survived: setD1Id still resolves them and re-fills an id
238
+ const refilled = setD1Id(out, 'staging', 'DB', 're-provisioned', 'app-staging-db');
239
+ expect(getD1Id(refilled, 'staging', 'DB')).toBe('re-provisioned');
240
+ });
241
+ });
242
+
243
+ describe('assertToml', () => {
244
+ it('returns for valid toml', () => {
245
+ expect(() => assertToml('name = "svc"\n[env.production]\n')).not.toThrow();
246
+ });
247
+
248
+ it('throws for malformed toml', () => {
249
+ expect(() => assertToml('[env.\nbad')).toThrow();
250
+ });
251
+ });
252
+
253
+ describe('parseDevVarsExample', () => {
254
+ it('extracts KEY names from KEY= and KEY="" lines, ignoring comments and blanks', () => {
255
+ const text = '# secrets\nAPI_KEY=\n\nJWT_SECRET=""\n# trailing note\n';
256
+ expect(parseDevVarsExample(text)).toEqual(['API_KEY', 'JWT_SECRET']);
257
+ });
258
+ });
259
+
260
+ describe('readManifest', () => {
261
+ const toml =
262
+ 'name = "svc"\n\n' +
263
+ '[env.staging.vars]\nPUBLIC_URL = "https://staging"\nFEATURE_FLAG = "on"\n\n' +
264
+ '[[env.staging.kv_namespaces]]\nbinding = "CACHE"\nid = "abc"\n\n' +
265
+ '[[env.staging.kv_namespaces]]\nbinding = "SESSIONS"\nid = "def"\n';
266
+
267
+ it('splits vars, kv bindings, and secrets for the requested env', async () => {
268
+ const root = await mkdtemp(join(tmpdir(), 'smoo-prepare-env-'));
269
+ try {
270
+ await writeFile(join(root, 'wrangler.toml'), toml);
271
+ await writeFile(join(root, '.dev.vars.example'), '# secrets\nAPI_KEY=\nJWT_SECRET=""\n');
272
+ expect(readManifest(root, 'staging')).toEqual({
273
+ kvBindings: ['CACHE', 'SESSIONS'],
274
+ d1Bindings: [],
275
+ vars: ['PUBLIC_URL', 'FEATURE_FLAG'],
276
+ secrets: ['API_KEY', 'JWT_SECRET'],
277
+ });
278
+ } finally {
279
+ await rm(root, { recursive: true, force: true });
280
+ }
281
+ });
282
+
283
+ it('returns an empty secrets list when .dev.vars.example is absent', async () => {
284
+ const root = await mkdtemp(join(tmpdir(), 'smoo-prepare-env-'));
285
+ try {
286
+ await writeFile(join(root, 'wrangler.toml'), toml);
287
+ const manifest = readManifest(root, 'staging');
288
+ expect(manifest.secrets).toEqual([]);
289
+ expect(manifest.vars).toEqual(['PUBLIC_URL', 'FEATURE_FLAG']);
290
+ expect(manifest.kvBindings).toEqual(['CACHE', 'SESSIONS']);
291
+ } finally {
292
+ await rm(root, { recursive: true, force: true });
293
+ }
294
+ });
295
+
296
+ it('reports d1 bindings alongside kv bindings and vars without dropping the existing fields', async () => {
297
+ const withD1 =
298
+ 'name = "svc"\n\n' +
299
+ '[env.staging.vars]\nPUBLIC_URL = "https://staging"\n\n' +
300
+ '[[env.staging.kv_namespaces]]\nbinding = "CACHE"\nid = "abc"\n\n' +
301
+ '[[env.staging.d1_databases]]\nbinding = "DB"\ndatabase_id = "id-1"\ndatabase_name = "app-staging-db"\n\n' +
302
+ '[[env.staging.d1_databases]]\nbinding = "ANALYTICS"\ndatabase_id = ""\ndatabase_name = "app-analytics-db"\n';
303
+ const root = await mkdtemp(join(tmpdir(), 'smoo-prepare-env-'));
304
+ try {
305
+ await writeFile(join(root, 'wrangler.toml'), withD1);
306
+ expect(readManifest(root, 'staging')).toEqual({
307
+ kvBindings: ['CACHE'],
308
+ d1Bindings: ['DB', 'ANALYTICS'],
309
+ vars: ['PUBLIC_URL'],
310
+ secrets: [],
311
+ });
312
+ } finally {
313
+ await rm(root, { recursive: true, force: true });
314
+ }
315
+ });
316
+ });
317
+
318
+ describe('isPem / readPemFile / looksLikeFileSecret', () => {
319
+ it('recognizes a -----BEGIN block and rejects arbitrary text', () => {
320
+ expect(isPem('-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n')).toBe(true);
321
+ expect(isPem('-----BEGIN RSA PRIVATE KEY-----\n')).toBe(true);
322
+ expect(isPem('just a plain secret')).toBe(false);
323
+ });
324
+
325
+ it('reads a PEM file and throws on a non-PEM file', async () => {
326
+ const root = await mkdtemp(join(tmpdir(), 'smoo-prepare-env-'));
327
+ try {
328
+ const pem = '-----BEGIN CERTIFICATE-----\nMIIBcontent\n-----END CERTIFICATE-----\n';
329
+ const pemPath = join(root, 'cert.pem');
330
+ const plainPath = join(root, 'plain.txt');
331
+ await writeFile(pemPath, pem);
332
+ await writeFile(plainPath, 'not a pem at all\n');
333
+ expect(readPemFile(pemPath)).toBe(pem);
334
+ expect(() => readPemFile(plainPath)).toThrow();
335
+ } finally {
336
+ await rm(root, { recursive: true, force: true });
337
+ }
338
+ });
339
+
340
+ it('flags _PEM/_KEY/_CERT suffixes as file secrets and nothing else', () => {
341
+ expect(looksLikeFileSecret('SIGNING_PEM')).toBe(true);
342
+ expect(looksLikeFileSecret('SIGNING_KEY')).toBe(true);
343
+ expect(looksLikeFileSecret('SIGNING_CERT')).toBe(true);
344
+ expect(looksLikeFileSecret('API_TOKEN')).toBe(false);
345
+ expect(looksLikeFileSecret('PEM_HEADER')).toBe(false);
346
+ });
347
+ });