@smoothbricks/cli 0.8.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.
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +9 -0
- package/dist/wrangler/prepare-env.d.ts +68 -0
- package/dist/wrangler/prepare-env.d.ts.map +1 -0
- package/dist/wrangler/prepare-env.js +253 -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 +246 -0
- package/src/wrangler/prepare-env.ts +290 -0
- package/src/wrangler/scaffold.test.ts +200 -0
- package/src/wrangler/scaffold.ts +239 -0
|
@@ -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,246 @@
|
|
|
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
|
+
blankEnvVars,
|
|
8
|
+
blankKvIds,
|
|
9
|
+
cloneEnvBlock,
|
|
10
|
+
firstWranglerEnv,
|
|
11
|
+
getKvId,
|
|
12
|
+
getVar,
|
|
13
|
+
hasEnvBlock,
|
|
14
|
+
isPem,
|
|
15
|
+
looksLikeFileSecret,
|
|
16
|
+
parseDevVarsExample,
|
|
17
|
+
readManifest,
|
|
18
|
+
readPemFile,
|
|
19
|
+
setKvId,
|
|
20
|
+
setVar,
|
|
21
|
+
} from './prepare-env.js';
|
|
22
|
+
|
|
23
|
+
// The wrangler shell-outs (listNamespaces / ensureNamespace / listSecretNames /
|
|
24
|
+
// putSecret) invoke `bunx wrangler` against a live account and are integration-
|
|
25
|
+
// only — deliberately not exercised here. Everything below is a pure primitive.
|
|
26
|
+
|
|
27
|
+
describe('firstWranglerEnv', () => {
|
|
28
|
+
it('returns the name of the first [env.<name>] header', () => {
|
|
29
|
+
expect(firstWranglerEnv('name = "svc"\n\n[env.staging]\n\n[env.production]\n')).toBe('staging');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('derives the env from a nested [env.<name>.vars] sub-table', () => {
|
|
33
|
+
expect(firstWranglerEnv('[env.prod.vars]\nFOO = "bar"\n')).toBe('prod');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns null when the toml declares no env block', () => {
|
|
37
|
+
expect(firstWranglerEnv('name = "svc"\nmain = "src/index.ts"\n')).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('hasEnvBlock', () => {
|
|
42
|
+
const toml = 'name = "svc"\n\n[env.production]\nroute = "x"\n';
|
|
43
|
+
|
|
44
|
+
it('is true for a declared [env.<env>] block', () => {
|
|
45
|
+
expect(hasEnvBlock(toml, 'production')).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('is false for an env the toml never declares', () => {
|
|
49
|
+
expect(hasEnvBlock(toml, 'staging')).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('getVar / setVar', () => {
|
|
54
|
+
const toml =
|
|
55
|
+
'name = "svc"\n\n[env.staging.vars]\nTOKEN = "staging-token"\n\n[env.production.vars]\nTOKEN = "production-token"\nEMPTY = ""\n';
|
|
56
|
+
|
|
57
|
+
it('reads a set value and returns null for empty or absent keys', () => {
|
|
58
|
+
expect(getVar(toml, 'production', 'TOKEN')).toBe('production-token');
|
|
59
|
+
expect(getVar(toml, 'production', 'EMPTY')).toBeNull();
|
|
60
|
+
expect(getVar(toml, 'production', 'MISSING')).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('rewrites the target value while preserving a trailing comment', () => {
|
|
64
|
+
const src = '[env.production.vars]\nAPI_URL = "https://old.example.com" # keep this comment\n';
|
|
65
|
+
const out = setVar(src, 'production', 'API_URL', 'https://new.example.com');
|
|
66
|
+
expect(out).toContain('# keep this comment');
|
|
67
|
+
expect(getVar(out, 'production', 'API_URL')).toBe('https://new.example.com');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('edits only the target env block, leaving a same-named key under another env untouched', () => {
|
|
71
|
+
const out = setVar(toml, 'production', 'TOKEN', 'rotated-prod');
|
|
72
|
+
expect(getVar(out, 'production', 'TOKEN')).toBe('rotated-prod');
|
|
73
|
+
expect(getVar(out, 'staging', 'TOKEN')).toBe('staging-token');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('JSON-encodes a value with $ and quotes so the result still parses', () => {
|
|
77
|
+
const src = '[env.production.vars]\nSECRET_HINT = "placeholder"\n';
|
|
78
|
+
const value = 'a$b"c';
|
|
79
|
+
const out = setVar(src, 'production', 'SECRET_HINT', value);
|
|
80
|
+
expect(() => assertToml(out)).not.toThrow();
|
|
81
|
+
expect(getVar(out, 'production', 'SECRET_HINT')).toBe(value);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('throws when the vars key is absent (scaffold-first contract)', () => {
|
|
85
|
+
expect(() => setVar(toml, 'production', 'NOT_THERE', 'x')).toThrow();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('getKvId / setKvId', () => {
|
|
90
|
+
const toml =
|
|
91
|
+
'name = "svc"\n\n' +
|
|
92
|
+
'[[env.production.kv_namespaces]]\nbinding = "CACHE"\nid = "cache-id-123"\n\n' +
|
|
93
|
+
'[[env.production.kv_namespaces]]\nbinding = "SESSIONS"\nid = ""\n';
|
|
94
|
+
|
|
95
|
+
it('reads the id for a matching binding and null for empty/absent bindings', () => {
|
|
96
|
+
expect(getKvId(toml, 'production', 'CACHE')).toBe('cache-id-123');
|
|
97
|
+
expect(getKvId(toml, 'production', 'SESSIONS')).toBeNull();
|
|
98
|
+
expect(getKvId(toml, 'production', 'MISSING')).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('writes "<id>" # <title> against the right binding, leaving siblings intact', () => {
|
|
102
|
+
const out = setKvId(toml, 'production', 'SESSIONS', 'sess-id-456', 'svc-sessions-production');
|
|
103
|
+
expect(out).toContain('# svc-sessions-production');
|
|
104
|
+
expect(getKvId(out, 'production', 'SESSIONS')).toBe('sess-id-456');
|
|
105
|
+
expect(getKvId(out, 'production', 'CACHE')).toBe('cache-id-123');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('throws when the binding block is absent', () => {
|
|
109
|
+
expect(() => setKvId(toml, 'production', 'MISSING', 'x', 't')).toThrow();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('cloneEnvBlock', () => {
|
|
114
|
+
const staging =
|
|
115
|
+
'name = "svc"\n\n' +
|
|
116
|
+
'[env.staging]\nroute = "staging.example.com"\n\n' +
|
|
117
|
+
'[env.staging.vars]\nLEVEL = "debug"\n\n' +
|
|
118
|
+
'[[env.staging.kv_namespaces]]\nbinding = "CACHE"\nid = "staging-cache"\n';
|
|
119
|
+
|
|
120
|
+
it('appends a rewritten copy while leaving the source block intact', () => {
|
|
121
|
+
const out = cloneEnvBlock(staging, 'staging', 'production');
|
|
122
|
+
expect(() => assertToml(out)).not.toThrow();
|
|
123
|
+
expect(hasEnvBlock(out, 'production')).toBe(true);
|
|
124
|
+
// source untouched
|
|
125
|
+
expect(getVar(out, 'staging', 'LEVEL')).toBe('debug');
|
|
126
|
+
expect(getKvId(out, 'staging', 'CACHE')).toBe('staging-cache');
|
|
127
|
+
// cloned vars + kv tables present under the new env
|
|
128
|
+
expect(getVar(out, 'production', 'LEVEL')).toBe('debug');
|
|
129
|
+
expect(getKvId(out, 'production', 'CACHE')).toBe('staging-cache');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('throws when the source env is missing', () => {
|
|
133
|
+
expect(() => cloneEnvBlock(staging, 'qa', 'production')).toThrow();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('throws when the target env already exists', () => {
|
|
137
|
+
const withBoth = `${staging}\n[env.production]\nroute = "prod.example.com"\n`;
|
|
138
|
+
expect(() => cloneEnvBlock(withBoth, 'staging', 'production')).toThrow();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('blankEnvVars / blankKvIds', () => {
|
|
143
|
+
it('blanks the named vars and leaves the others untouched', () => {
|
|
144
|
+
const toml = '[env.production.vars]\nKEEP = "keep-value"\nDROP_A = "a"\nDROP_B = "b"\n';
|
|
145
|
+
const out = blankEnvVars(toml, 'production', ['DROP_A', 'DROP_B']);
|
|
146
|
+
expect(getVar(out, 'production', 'DROP_A')).toBeNull();
|
|
147
|
+
expect(getVar(out, 'production', 'DROP_B')).toBeNull();
|
|
148
|
+
expect(getVar(out, 'production', 'KEEP')).toBe('keep-value');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('blanks every kv id under the env while preserving the bindings', () => {
|
|
152
|
+
const toml =
|
|
153
|
+
'[[env.production.kv_namespaces]]\nbinding = "CACHE"\nid = "cache-id"\n\n' +
|
|
154
|
+
'[[env.production.kv_namespaces]]\nbinding = "SESSIONS"\nid = "sess-id"\n';
|
|
155
|
+
const out = blankKvIds(toml, 'production');
|
|
156
|
+
expect(getKvId(out, 'production', 'CACHE')).toBeNull();
|
|
157
|
+
expect(getKvId(out, 'production', 'SESSIONS')).toBeNull();
|
|
158
|
+
// bindings survived: setKvId still resolves them and re-fills an id
|
|
159
|
+
const refilled = setKvId(out, 'production', 'CACHE', 'new-cache', 'title');
|
|
160
|
+
expect(getKvId(refilled, 'production', 'CACHE')).toBe('new-cache');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('assertToml', () => {
|
|
165
|
+
it('returns for valid toml', () => {
|
|
166
|
+
expect(() => assertToml('name = "svc"\n[env.production]\n')).not.toThrow();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('throws for malformed toml', () => {
|
|
170
|
+
expect(() => assertToml('[env.\nbad')).toThrow();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('parseDevVarsExample', () => {
|
|
175
|
+
it('extracts KEY names from KEY= and KEY="" lines, ignoring comments and blanks', () => {
|
|
176
|
+
const text = '# secrets\nAPI_KEY=\n\nJWT_SECRET=""\n# trailing note\n';
|
|
177
|
+
expect(parseDevVarsExample(text)).toEqual(['API_KEY', 'JWT_SECRET']);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('readManifest', () => {
|
|
182
|
+
const toml =
|
|
183
|
+
'name = "svc"\n\n' +
|
|
184
|
+
'[env.staging.vars]\nPUBLIC_URL = "https://staging"\nFEATURE_FLAG = "on"\n\n' +
|
|
185
|
+
'[[env.staging.kv_namespaces]]\nbinding = "CACHE"\nid = "abc"\n\n' +
|
|
186
|
+
'[[env.staging.kv_namespaces]]\nbinding = "SESSIONS"\nid = "def"\n';
|
|
187
|
+
|
|
188
|
+
it('splits vars, kv bindings, and secrets for the requested env', async () => {
|
|
189
|
+
const root = await mkdtemp(join(tmpdir(), 'smoo-prepare-env-'));
|
|
190
|
+
try {
|
|
191
|
+
await writeFile(join(root, 'wrangler.toml'), toml);
|
|
192
|
+
await writeFile(join(root, '.dev.vars.example'), '# secrets\nAPI_KEY=\nJWT_SECRET=""\n');
|
|
193
|
+
expect(readManifest(root, 'staging')).toEqual({
|
|
194
|
+
kvBindings: ['CACHE', 'SESSIONS'],
|
|
195
|
+
vars: ['PUBLIC_URL', 'FEATURE_FLAG'],
|
|
196
|
+
secrets: ['API_KEY', 'JWT_SECRET'],
|
|
197
|
+
});
|
|
198
|
+
} finally {
|
|
199
|
+
await rm(root, { recursive: true, force: true });
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('returns an empty secrets list when .dev.vars.example is absent', async () => {
|
|
204
|
+
const root = await mkdtemp(join(tmpdir(), 'smoo-prepare-env-'));
|
|
205
|
+
try {
|
|
206
|
+
await writeFile(join(root, 'wrangler.toml'), toml);
|
|
207
|
+
const manifest = readManifest(root, 'staging');
|
|
208
|
+
expect(manifest.secrets).toEqual([]);
|
|
209
|
+
expect(manifest.vars).toEqual(['PUBLIC_URL', 'FEATURE_FLAG']);
|
|
210
|
+
expect(manifest.kvBindings).toEqual(['CACHE', 'SESSIONS']);
|
|
211
|
+
} finally {
|
|
212
|
+
await rm(root, { recursive: true, force: true });
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('isPem / readPemFile / looksLikeFileSecret', () => {
|
|
218
|
+
it('recognizes a -----BEGIN block and rejects arbitrary text', () => {
|
|
219
|
+
expect(isPem('-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n')).toBe(true);
|
|
220
|
+
expect(isPem('-----BEGIN RSA PRIVATE KEY-----\n')).toBe(true);
|
|
221
|
+
expect(isPem('just a plain secret')).toBe(false);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('reads a PEM file and throws on a non-PEM file', async () => {
|
|
225
|
+
const root = await mkdtemp(join(tmpdir(), 'smoo-prepare-env-'));
|
|
226
|
+
try {
|
|
227
|
+
const pem = '-----BEGIN CERTIFICATE-----\nMIIBcontent\n-----END CERTIFICATE-----\n';
|
|
228
|
+
const pemPath = join(root, 'cert.pem');
|
|
229
|
+
const plainPath = join(root, 'plain.txt');
|
|
230
|
+
await writeFile(pemPath, pem);
|
|
231
|
+
await writeFile(plainPath, 'not a pem at all\n');
|
|
232
|
+
expect(readPemFile(pemPath)).toBe(pem);
|
|
233
|
+
expect(() => readPemFile(plainPath)).toThrow();
|
|
234
|
+
} finally {
|
|
235
|
+
await rm(root, { recursive: true, force: true });
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('flags _PEM/_KEY/_CERT suffixes as file secrets and nothing else', () => {
|
|
240
|
+
expect(looksLikeFileSecret('SIGNING_PEM')).toBe(true);
|
|
241
|
+
expect(looksLikeFileSecret('SIGNING_KEY')).toBe(true);
|
|
242
|
+
expect(looksLikeFileSecret('SIGNING_CERT')).toBe(true);
|
|
243
|
+
expect(looksLikeFileSecret('API_TOKEN')).toBe(false);
|
|
244
|
+
expect(looksLikeFileSecret('PEM_HEADER')).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -0,0 +1,290 @@
|
|
|
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
|
+
|
|
24
|
+
// ── runtime narrowing (wrangler JSON + parsed toml are 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
|
+
function escapeRe(s: string): string {
|
|
31
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── pure wrangler.toml editors (no I/O — unit-testable) ──────────────────────
|
|
35
|
+
|
|
36
|
+
/** First `[env.<name>]` header, or `null` when the config declares no envs (top-level bindings only). */
|
|
37
|
+
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;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True if the toml declares `[env.<env>]`. */
|
|
43
|
+
export function hasEnvBlock(toml: string, env: string): boolean {
|
|
44
|
+
return new RegExp(`^\\[env\\.${escapeRe(env)}\\]`, 'm').test(toml);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A `[env.<env>.vars]` value, or null if unset/empty. Reads committed public config. */
|
|
48
|
+
export function getVar(toml: string, env: string, name: string): string | null {
|
|
49
|
+
const block = envRecord(toml, env);
|
|
50
|
+
const vars = isRecord(block?.vars) ? block.vars : {};
|
|
51
|
+
const value = vars[name];
|
|
52
|
+
return typeof value === 'string' && value ? value : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Set a `[env.<env>.vars]` value, preserving any trailing comment. Throws if the key isn't present (scaffold first). */
|
|
56
|
+
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)}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The id written for `[[env.<env>.kv_namespaces]]` binding, or null if absent/empty. */
|
|
65
|
+
export function getKvId(toml: string, env: string, binding: string): string | null {
|
|
66
|
+
const block = envRecord(toml, env);
|
|
67
|
+
const rows = isRecord(block) && Array.isArray(block.kv_namespaces) ? block.kv_namespaces : [];
|
|
68
|
+
for (const row of rows) {
|
|
69
|
+
if (isRecord(row) && row.binding === binding && typeof row.id === 'string') {
|
|
70
|
+
return row.id || null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Rewrite the `id = ...` line of the `[[env.<env>.kv_namespaces]]` table whose binding matches. Throws if absent. */
|
|
77
|
+
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}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
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).
|
|
91
|
+
*/
|
|
92
|
+
export function cloneEnvBlock(toml: string, from: string, to: string): string {
|
|
93
|
+
if (!hasEnvBlock(toml, from)) throw new Error(`source [env.${from}] not found`);
|
|
94
|
+
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`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Blank every `NAME = "..."` under `[env.<env>.vars]` whose key is in `keys` (env-specific values to re-fill). */
|
|
117
|
+
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;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Blank every `id = ...` line under the env's `[[env.<env>.kv_namespaces]]` tables (per-env resource ids). */
|
|
127
|
+
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""');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Parse-guard: throws if `toml` no longer parses, so a bad edit can't be written to disk. */
|
|
136
|
+
export function assertToml(toml: string): void {
|
|
137
|
+
parse(toml);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Persist a toml string after confirming it still parses. */
|
|
141
|
+
export function saveToml(path: string, toml: string): void {
|
|
142
|
+
assertToml(toml);
|
|
143
|
+
writeFileSync(path, toml);
|
|
144
|
+
}
|
|
145
|
+
|
|
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
|
+
// ── manifest: what the worker needs, split into vars vs secrets ──────────────
|
|
154
|
+
|
|
155
|
+
export interface Manifest {
|
|
156
|
+
/** KV binding names declared under `[[env.<env>.kv_namespaces]]` (resources to provision). */
|
|
157
|
+
kvBindings: string[];
|
|
158
|
+
/** Public var names declared in `[env.<env>.vars]` (committed config). */
|
|
159
|
+
vars: string[];
|
|
160
|
+
/** Secret names declared in `.dev.vars.example` (values live on the worker, never in the repo). */
|
|
161
|
+
secrets: string[];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Secret NAMES from a `.dev.vars.example` file (KEY=... / KEY="..."), ignoring comments/blanks. */
|
|
165
|
+
export function parseDevVarsExample(text: string): string[] {
|
|
166
|
+
const names: string[] = [];
|
|
167
|
+
for (const raw of text.split('\n')) {
|
|
168
|
+
const line = raw.trim();
|
|
169
|
+
if (!line || line.startsWith('#')) continue;
|
|
170
|
+
const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=/);
|
|
171
|
+
if (m) names.push(m[1]);
|
|
172
|
+
}
|
|
173
|
+
return names;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Read the vars/secrets split for `env` from `<root>/wrangler.toml` (public var
|
|
178
|
+
* keys) + `<root>/.dev.vars.example` (secret names). This is the "what to prompt
|
|
179
|
+
* for" manifest a prepare-env script derives its work from.
|
|
180
|
+
*/
|
|
181
|
+
export function readManifest(root: string, env: string): Manifest {
|
|
182
|
+
const toml = readFileSync(join(root, 'wrangler.toml'), 'utf8');
|
|
183
|
+
const block = envRecord(toml, env);
|
|
184
|
+
const varsRec = isRecord(block?.vars) ? block.vars : {};
|
|
185
|
+
const vars = Object.keys(varsRec);
|
|
186
|
+
const kvRows = isRecord(block) && Array.isArray(block.kv_namespaces) ? block.kv_namespaces : [];
|
|
187
|
+
const kvBindings = kvRows.flatMap((row) => (isRecord(row) && typeof row.binding === 'string' ? [row.binding] : []));
|
|
188
|
+
const examplePath = join(root, '.dev.vars.example');
|
|
189
|
+
const secrets = existsSync(examplePath) ? parseDevVarsExample(readFileSync(examplePath, 'utf8')) : [];
|
|
190
|
+
return { kvBindings, vars, secrets };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── PEM (a standard, reusable) ───────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
/** True if `value` looks like a PEM block (any `-----BEGIN ...-----`). */
|
|
196
|
+
export function isPem(value: string): boolean {
|
|
197
|
+
return /-----BEGIN [A-Z ]+-----/.test(value);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Read a `.pem` file (expanding a leading `~`) and assert it's a PEM block. Throws otherwise. */
|
|
201
|
+
export function readPemFile(path: string): string {
|
|
202
|
+
const resolved = path.replace(/^~/, process.env.HOME ?? '~');
|
|
203
|
+
const content = readFileSync(resolved, 'utf8');
|
|
204
|
+
if (!isPem(content)) throw new Error(`${path} is not a PEM (no -----BEGIN block)`);
|
|
205
|
+
return content;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Heuristic: does this secret name suggest file input (PEM/key)? Used to preselect a file prompt — never to change semantics. */
|
|
209
|
+
export function looksLikeFileSecret(name: string): boolean {
|
|
210
|
+
return /_PEM$|_KEY$|_CERT$/.test(name);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── wrangler shell-outs (Bun $ + structured JSON), run at `cwd` ───────────────
|
|
214
|
+
|
|
215
|
+
export interface KvNamespace {
|
|
216
|
+
id: string;
|
|
217
|
+
title: string;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function asKvNamespaces(json: unknown): KvNamespace[] {
|
|
221
|
+
if (!Array.isArray(json)) return [];
|
|
222
|
+
const out: KvNamespace[] = [];
|
|
223
|
+
for (const row of json) {
|
|
224
|
+
if (isRecord(row) && typeof row.id === 'string' && typeof row.title === 'string') {
|
|
225
|
+
out.push({ id: row.id, title: row.title });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function asSecretNames(json: unknown): string[] {
|
|
232
|
+
if (!Array.isArray(json)) return [];
|
|
233
|
+
return json.flatMap((row) => (isRecord(row) && typeof row.name === 'string' ? [row.name] : []));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Best-effort ERROR line from a Bun ShellError (thrown on non-zero exit). */
|
|
237
|
+
export function errText(err: unknown): string {
|
|
238
|
+
if (!isRecord(err)) return 'unknown error';
|
|
239
|
+
const stderr = 'stderr' in err && err.stderr != null ? String(err.stderr) : '';
|
|
240
|
+
const exit = 'exitCode' in err ? String(err.exitCode) : '?';
|
|
241
|
+
return stderr.split('\n').find((l) => l.includes('ERROR')) ?? `exit ${exit}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Live KV namespaces on the account (`kv namespace list` emits a pure JSON array); `[]` when offline/unauthenticated. */
|
|
245
|
+
export async function listNamespaces(cwd: string): Promise<KvNamespace[]> {
|
|
246
|
+
try {
|
|
247
|
+
return asKvNamespaces(await $`bunx wrangler kv namespace list`.cwd(cwd).quiet().json());
|
|
248
|
+
} catch {
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Reuse the namespace whose title exactly matches `logical`, else create it. The
|
|
255
|
+
* id always comes from the structured `list` JSON (create prints freeform text),
|
|
256
|
+
* so we re-list rather than scrape. Returns the id, or null on failure.
|
|
257
|
+
*/
|
|
258
|
+
export async function ensureNamespace(logical: string, existing: KvNamespace[], cwd: string): Promise<string | null> {
|
|
259
|
+
const match = existing.find((n) => n.title === logical);
|
|
260
|
+
if (match) return match.id;
|
|
261
|
+
try {
|
|
262
|
+
await $`bunx wrangler kv namespace create ${logical}`.cwd(cwd).quiet();
|
|
263
|
+
} catch (err) {
|
|
264
|
+
return errText(err).includes('already') ? (await reList(logical, cwd)) : null;
|
|
265
|
+
}
|
|
266
|
+
return reList(logical, cwd);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function reList(logical: string, cwd: string): Promise<string | null> {
|
|
270
|
+
return (await listNamespaces(cwd)).find((n) => n.title === logical)?.id ?? null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Current secret names on the worker for `env`; `[]` when none/unreachable (Cloudflare allows setting pre-deploy). */
|
|
274
|
+
export async function listSecretNames(env: string, cwd: string): Promise<string[]> {
|
|
275
|
+
try {
|
|
276
|
+
return asSecretNames(await $`bunx wrangler secret list --format json --env ${env}`.cwd(cwd).quiet().json());
|
|
277
|
+
} catch {
|
|
278
|
+
return [];
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** `wrangler secret put <name> --env <env>` piping `value` on stdin (never a CLI arg). Returns success. */
|
|
283
|
+
export async function putSecret(name: string, value: string, env: string, cwd: string): Promise<boolean> {
|
|
284
|
+
try {
|
|
285
|
+
await $`bunx wrangler secret put ${name} --env ${env} < ${Buffer.from(value)}`.cwd(cwd).quiet();
|
|
286
|
+
return true;
|
|
287
|
+
} catch {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
}
|