@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
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, expect, it, spyOn } from 'bun:test';
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { scaffold } from './scaffold.js';
|
|
6
|
+
|
|
7
|
+
describe('scaffold', () => {
|
|
8
|
+
it('writes the manifest-driven starter script and wires the prepare-env nx target', async () => {
|
|
9
|
+
const root = await createWorkspace([{ dir: 'worker', name: '@acme/worker', toml: '[env.production]\n' }]);
|
|
10
|
+
captureConsoleLogs();
|
|
11
|
+
try {
|
|
12
|
+
const scriptPath = scaffold(root, '@acme/worker');
|
|
13
|
+
|
|
14
|
+
// Returns the absolute path it wrote.
|
|
15
|
+
expect(scriptPath).toBe(join(root, 'packages/worker/scripts/prepare-env.ts'));
|
|
16
|
+
|
|
17
|
+
// The generated script imports the primitives module and derives work from the manifest.
|
|
18
|
+
const script = await readFile(scriptPath, 'utf8');
|
|
19
|
+
expect(script).toContain('@smoothbricks/cli/wrangler/prepare-env');
|
|
20
|
+
expect(script).toContain('readManifest');
|
|
21
|
+
|
|
22
|
+
// The nx target is wired to run the script from the project root.
|
|
23
|
+
const pkg = await readJson(join(root, 'packages/worker/package.json'));
|
|
24
|
+
expect(nxTargets(pkg)['prepare-env']).toEqual({
|
|
25
|
+
executor: 'nx:run-commands',
|
|
26
|
+
options: { command: 'bun scripts/prepare-env.ts', cwd: '{projectRoot}' },
|
|
27
|
+
});
|
|
28
|
+
} finally {
|
|
29
|
+
console.log = originalConsoleLog;
|
|
30
|
+
await rm(root, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('resolves the same project by nx name, package name, and relative path', async () => {
|
|
35
|
+
const root = await createWorkspace([
|
|
36
|
+
{ dir: 'worker', name: '@acme/worker', toml: '[env.production]\n', nx: { name: 'my-worker' } },
|
|
37
|
+
]);
|
|
38
|
+
captureConsoleLogs();
|
|
39
|
+
try {
|
|
40
|
+
const expected = join(root, 'packages/worker/scripts/prepare-env.ts');
|
|
41
|
+
const byNxName = scaffold(root, 'my-worker');
|
|
42
|
+
const byPackageName = scaffold(root, '@acme/worker', { force: true });
|
|
43
|
+
const byRelativePath = scaffold(root, 'packages/worker', { force: true });
|
|
44
|
+
|
|
45
|
+
expect(byNxName).toBe(expected);
|
|
46
|
+
expect(byPackageName).toBe(expected);
|
|
47
|
+
expect(byRelativePath).toBe(expected);
|
|
48
|
+
} finally {
|
|
49
|
+
console.log = originalConsoleLog;
|
|
50
|
+
await rm(root, { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('throws when the project is unknown, naming the known projects', async () => {
|
|
55
|
+
const root = await createWorkspace([
|
|
56
|
+
{ dir: 'worker', name: '@acme/worker', toml: '[env.production]\n', nx: { name: 'my-worker' } },
|
|
57
|
+
]);
|
|
58
|
+
try {
|
|
59
|
+
let message = '';
|
|
60
|
+
try {
|
|
61
|
+
scaffold(root, 'ghost');
|
|
62
|
+
} catch (error) {
|
|
63
|
+
message = error instanceof Error ? error.message : String(error);
|
|
64
|
+
}
|
|
65
|
+
expect(message).toContain('not found');
|
|
66
|
+
expect(message).toContain('my-worker');
|
|
67
|
+
} finally {
|
|
68
|
+
await rm(root, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('throws when the resolved package has no wrangler.toml', async () => {
|
|
73
|
+
const root = await createWorkspace([{ dir: 'plain', name: '@acme/plain' }]);
|
|
74
|
+
try {
|
|
75
|
+
expect(() => scaffold(root, '@acme/plain')).toThrow('has no wrangler.toml');
|
|
76
|
+
} finally {
|
|
77
|
+
await rm(root, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('throws on a second scaffold without force, pointing at --force', async () => {
|
|
82
|
+
const root = await createWorkspace([{ dir: 'worker', name: '@acme/worker', toml: '[env.production]\n' }]);
|
|
83
|
+
captureConsoleLogs();
|
|
84
|
+
try {
|
|
85
|
+
scaffold(root, '@acme/worker');
|
|
86
|
+
expect(() => scaffold(root, '@acme/worker')).toThrow('--force');
|
|
87
|
+
} finally {
|
|
88
|
+
console.log = originalConsoleLog;
|
|
89
|
+
await rm(root, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('overwrites an existing script when force is set', async () => {
|
|
94
|
+
const root = await createWorkspace([{ dir: 'worker', name: '@acme/worker', toml: '[env.production]\n' }]);
|
|
95
|
+
const logs = captureConsoleLogs();
|
|
96
|
+
try {
|
|
97
|
+
const scriptPath = scaffold(root, '@acme/worker');
|
|
98
|
+
await writeFile(scriptPath, 'stale hand-edited content\n');
|
|
99
|
+
|
|
100
|
+
const again = scaffold(root, '@acme/worker', { force: true });
|
|
101
|
+
expect(again).toBe(scriptPath);
|
|
102
|
+
|
|
103
|
+
// The stale content is replaced by a freshly rendered script.
|
|
104
|
+
const script = await readFile(scriptPath, 'utf8');
|
|
105
|
+
expect(script).not.toContain('stale hand-edited content');
|
|
106
|
+
expect(script).toContain('@smoothbricks/cli/wrangler/prepare-env');
|
|
107
|
+
expect(logs.some((line) => line.startsWith('overwrote'))).toBe(true);
|
|
108
|
+
} finally {
|
|
109
|
+
console.log = originalConsoleLog;
|
|
110
|
+
await rm(root, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('preserves a hand-customized prepare-env target and never duplicates it', async () => {
|
|
115
|
+
const custom = {
|
|
116
|
+
executor: 'nx:run-commands',
|
|
117
|
+
options: { command: 'bun scripts/prepare-env.ts --custom', cwd: '{projectRoot}' },
|
|
118
|
+
};
|
|
119
|
+
const root = await createWorkspace([
|
|
120
|
+
{
|
|
121
|
+
dir: 'worker',
|
|
122
|
+
name: '@acme/worker',
|
|
123
|
+
toml: '[env.production]\n',
|
|
124
|
+
nx: { targets: { 'prepare-env': { ...custom } } },
|
|
125
|
+
},
|
|
126
|
+
]);
|
|
127
|
+
captureConsoleLogs();
|
|
128
|
+
try {
|
|
129
|
+
scaffold(root, '@acme/worker', { force: true });
|
|
130
|
+
|
|
131
|
+
// The idempotent guard leaves an already-wired target exactly as authored.
|
|
132
|
+
const target = nxTargets(await readJson(join(root, 'packages/worker/package.json')))['prepare-env'];
|
|
133
|
+
expect(target).toEqual(custom);
|
|
134
|
+
// Still a single target object, not wrapped into an array or duplicated.
|
|
135
|
+
expect(Array.isArray(target)).toBe(false);
|
|
136
|
+
} finally {
|
|
137
|
+
console.log = originalConsoleLog;
|
|
138
|
+
await rm(root, { recursive: true, force: true });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
async function createWorkspace(
|
|
144
|
+
packages: Array<{ dir: string; name: string; toml?: string; nx?: Record<string, unknown> }>,
|
|
145
|
+
): Promise<string> {
|
|
146
|
+
const root = await mkdtemp(join(tmpdir(), 'smoo-scaffold-'));
|
|
147
|
+
await writeJson(join(root, 'package.json'), {
|
|
148
|
+
name: '@acme/codebase',
|
|
149
|
+
version: '0.0.0',
|
|
150
|
+
private: true,
|
|
151
|
+
workspaces: ['packages/*'],
|
|
152
|
+
});
|
|
153
|
+
for (const pkg of packages) {
|
|
154
|
+
await writeJson(join(root, `packages/${pkg.dir}/package.json`), {
|
|
155
|
+
name: pkg.name,
|
|
156
|
+
version: '0.0.0',
|
|
157
|
+
private: true,
|
|
158
|
+
...(pkg.nx ? { nx: pkg.nx } : {}),
|
|
159
|
+
});
|
|
160
|
+
if (pkg.toml !== undefined) {
|
|
161
|
+
await writeFile(join(root, `packages/${pkg.dir}/wrangler.toml`), pkg.toml);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return root;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function nxTargets(pkg: Record<string, unknown>): Record<string, unknown> {
|
|
168
|
+
const nx = pkg.nx;
|
|
169
|
+
if (!isRecord(nx) || !isRecord(nx.targets)) {
|
|
170
|
+
throw new Error('nx.targets not found');
|
|
171
|
+
}
|
|
172
|
+
return nx.targets;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
176
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function readJson(path: string): Promise<Record<string, unknown>> {
|
|
180
|
+
const parsed: unknown = JSON.parse(await readFile(path, 'utf8'));
|
|
181
|
+
if (!isRecord(parsed)) {
|
|
182
|
+
throw new Error('expected JSON object');
|
|
183
|
+
}
|
|
184
|
+
return parsed;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function writeJson(path: string, value: Record<string, unknown>): Promise<void> {
|
|
188
|
+
await mkdir(join(path, '..'), { recursive: true });
|
|
189
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const originalConsoleLog = console.log;
|
|
193
|
+
|
|
194
|
+
function captureConsoleLogs(): string[] {
|
|
195
|
+
const logs: string[] = [];
|
|
196
|
+
spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
|
|
197
|
+
logs.push(args.join(' '));
|
|
198
|
+
});
|
|
199
|
+
return logs;
|
|
200
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
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
|
+
|
|
13
|
+
export interface ScaffoldOptions {
|
|
14
|
+
/** Overwrite an existing scripts/prepare-env.ts. */
|
|
15
|
+
force?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Locate a workspace package by nx name, package name, or relative path; must have a wrangler.toml. */
|
|
19
|
+
function resolveProject(root: string, project: string): { dir: string; label: string; packageJsonPath: string; json: Record<string, unknown> } {
|
|
20
|
+
const manifests = getWorkspacePackageManifests(root);
|
|
21
|
+
const match = manifests.find((m) => {
|
|
22
|
+
const nx = recordProperty(m.json, 'nx');
|
|
23
|
+
const nxName = nx && typeof nx.name === 'string' ? nx.name : null;
|
|
24
|
+
return nxName === project || m.name === project || relative(root, m.path) === project;
|
|
25
|
+
});
|
|
26
|
+
if (!match) {
|
|
27
|
+
const known = manifests.map((m) => recordProperty(m.json, 'nx')?.name ?? m.name).join(', ');
|
|
28
|
+
throw new Error(`project "${project}" not found. Known: ${known}`);
|
|
29
|
+
}
|
|
30
|
+
const dir = match.path; // WorkspacePackageManifest.path is absolute
|
|
31
|
+
if (!existsSync(join(dir, 'wrangler.toml'))) {
|
|
32
|
+
throw new Error(`"${project}" has no wrangler.toml — not a wrangler project`);
|
|
33
|
+
}
|
|
34
|
+
return { dir, label: relative(root, dir), packageJsonPath: match.packageJsonPath, json: match.json };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The generated starter script — generic, manifest-driven, prompt-batteries included. */
|
|
38
|
+
function renderScript(projectName: string): string {
|
|
39
|
+
return `#!/usr/bin/env bun
|
|
40
|
+
/**
|
|
41
|
+
* Prepare a deploy environment for ${projectName}. Generated by \`smoo wrangler scaffold\`.
|
|
42
|
+
*
|
|
43
|
+
* Provisions what \`wrangler deploy\` assumes already exists, idempotently:
|
|
44
|
+
* - KV namespaces the worker binds (ids written into wrangler.toml)
|
|
45
|
+
* - public [env.<env>.vars] (committed config the deploy applies)
|
|
46
|
+
* - secrets (wrangler secret put; values never touch the repo)
|
|
47
|
+
*
|
|
48
|
+
* Work is derived from the manifest (wrangler.toml [vars] + .dev.vars.example),
|
|
49
|
+
* so it stays in sync with what the worker declares. Customize the app-specific
|
|
50
|
+
* bits below (KV title convention; any secret that feeds >1 binding).
|
|
51
|
+
*
|
|
52
|
+
* Usage:
|
|
53
|
+
* bun scripts/prepare-env.ts <env> # provision (staging | production | …)
|
|
54
|
+
* bun scripts/prepare-env.ts <env> --check # report state, no writes
|
|
55
|
+
*/
|
|
56
|
+
import { readFileSync } from 'node:fs';
|
|
57
|
+
import { resolve } from 'node:path';
|
|
58
|
+
import * as p from '@clack/prompts';
|
|
59
|
+
import * as pe from '@smoothbricks/cli/wrangler/prepare-env';
|
|
60
|
+
|
|
61
|
+
const ROOT = resolve(import.meta.dir, '..');
|
|
62
|
+
const WRANGLER = resolve(ROOT, 'wrangler.toml');
|
|
63
|
+
const readToml = () => readFileSync(WRANGLER, 'utf8');
|
|
64
|
+
|
|
65
|
+
// App-specific: the account-level logical title for a KV binding. Default is
|
|
66
|
+
// lowercase + underscores→dashes; edit if your namespaces are named differently.
|
|
67
|
+
function kvTitle(binding: string, env: string): string {
|
|
68
|
+
return \`\${binding.toLowerCase().replace(/_/g, '-')}-\${env}\`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function bail<T>(v: T | symbol): asserts v is T {
|
|
72
|
+
if (p.isCancel(v)) {
|
|
73
|
+
p.cancel('Aborted — anything already provisioned stays; re-run to continue (idempotent).');
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function checkMode(env: string): Promise<void> {
|
|
79
|
+
const toml = readToml();
|
|
80
|
+
const { kvBindings, vars, secrets } = pe.readManifest(ROOT, env);
|
|
81
|
+
p.log.info(\`env block [env.\${env}]: \${pe.hasEnvBlock(toml, env) ? '✓ present' : '✗ missing'}\`);
|
|
82
|
+
const live = await pe.listNamespaces(ROOT);
|
|
83
|
+
for (const binding of kvBindings) {
|
|
84
|
+
const inToml = pe.getKvId(toml, env, binding);
|
|
85
|
+
const liveId = live.find((n) => n.title === kvTitle(binding, env))?.id ?? null;
|
|
86
|
+
p.log.message(\` KV \${binding}: \${!liveId ? '✗ not created' : inToml === liveId ? \`✓ \${liveId}\` : \`⚠ drift (live \${liveId}, toml \${inToml ?? 'unset'})\`}\`);
|
|
87
|
+
}
|
|
88
|
+
for (const name of vars) {
|
|
89
|
+
p.log.message(\` var \${name}: \${pe.getVar(toml, env, name) ? '✓ set' : '✗ empty'}\`);
|
|
90
|
+
}
|
|
91
|
+
const set = await pe.listSecretNames(env, ROOT);
|
|
92
|
+
for (const name of secrets) {
|
|
93
|
+
p.log.message(\` secret \${name}: \${set.includes(name) ? '✓ set' : '✗ missing'}\`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function main(): Promise<void> {
|
|
98
|
+
const env = process.argv.slice(2).find((a) => !a.startsWith('-'));
|
|
99
|
+
if (!env) {
|
|
100
|
+
console.error('usage: bun scripts/prepare-env.ts <env> [--check]');
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
const check = process.argv.includes('--check');
|
|
104
|
+
p.intro(\`${projectName} · prepare \${env}\${check ? ' (check)' : ''}\`);
|
|
105
|
+
|
|
106
|
+
if (check) {
|
|
107
|
+
await checkMode(env);
|
|
108
|
+
p.outro(\`check complete for "\${env}" — nothing changed\`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let toml = readToml();
|
|
113
|
+
|
|
114
|
+
// 0. Env block. If missing, offer to clone an existing env and blank its
|
|
115
|
+
// per-env values (KV ids), then fill below.
|
|
116
|
+
if (!pe.hasEnvBlock(toml, env)) {
|
|
117
|
+
const from = pe.firstWranglerEnv(toml);
|
|
118
|
+
if (from && (await p.confirm({ message: \`No [env.\${env}] — clone from [env.\${from}]?\` })) === true) {
|
|
119
|
+
toml = pe.blankKvIds(pe.cloneEnvBlock(toml, from, env), env);
|
|
120
|
+
pe.saveToml(WRANGLER, toml);
|
|
121
|
+
p.log.success(\`cloned [env.\${from}] → [env.\${env}] (KV ids blanked; edit domain-specific vars below)\`);
|
|
122
|
+
} else {
|
|
123
|
+
p.log.warn(\`Add an [env.\${env}] block to wrangler.toml first, then re-run.\`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const { kvBindings, vars, secrets } = pe.readManifest(ROOT, env);
|
|
129
|
+
|
|
130
|
+
// 1. KV namespaces → ids into the toml.
|
|
131
|
+
const live = await pe.listNamespaces(ROOT);
|
|
132
|
+
for (const binding of kvBindings) {
|
|
133
|
+
const s = p.spinner();
|
|
134
|
+
s.start(\`KV \${binding}\`);
|
|
135
|
+
const id = await pe.ensureNamespace(kvTitle(binding, env), live, ROOT);
|
|
136
|
+
if (!id) { s.stop(\`✗ \${binding}\`); continue; }
|
|
137
|
+
toml = pe.setKvId(toml, env, binding, id, kvTitle(binding, env));
|
|
138
|
+
pe.saveToml(WRANGLER, toml);
|
|
139
|
+
s.stop(\`✓ \${binding} → \${id}\`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 2. Public vars → [env.<env>.vars] (committed, applied by deploy). Enter keeps
|
|
143
|
+
// current; a same-named env var overrides (CI).
|
|
144
|
+
if ((await p.confirm({ message: \`Set public vars for "\${env}"?\` })) === true) {
|
|
145
|
+
for (const name of vars) {
|
|
146
|
+
const current = pe.getVar(toml, env, name);
|
|
147
|
+
const preset = process.env[name]?.trim();
|
|
148
|
+
let value: string;
|
|
149
|
+
if (preset) {
|
|
150
|
+
value = preset;
|
|
151
|
+
p.log.info(\`\${name}: from environment\`);
|
|
152
|
+
} else {
|
|
153
|
+
const raw = await p.text({ message: \`\${name}:\`, placeholder: current ? 'Enter to keep current' : 'Enter to skip', initialValue: current ?? '' });
|
|
154
|
+
bail(raw);
|
|
155
|
+
value = raw.trim();
|
|
156
|
+
}
|
|
157
|
+
if (!value || value === current) continue;
|
|
158
|
+
toml = pe.setVar(toml, env, name, value);
|
|
159
|
+
pe.saveToml(WRANGLER, toml);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 3. Secrets → wrangler secret put (values never in the repo). File-or-paste;
|
|
164
|
+
// PEM validated by content. Skip-if-set unless you choose to replace.
|
|
165
|
+
const alreadySet = await pe.listSecretNames(env, ROOT);
|
|
166
|
+
if ((await p.confirm({ message: \`Set secrets for "\${env}"?\` })) === true) {
|
|
167
|
+
for (const name of secrets) {
|
|
168
|
+
const preset = process.env[name]?.trim();
|
|
169
|
+
if (alreadySet.includes(name) && !preset) {
|
|
170
|
+
const replace = await p.confirm({ message: \`\${name} already set — replace?\`, initialValue: false });
|
|
171
|
+
bail(replace);
|
|
172
|
+
if (!replace) { p.log.info(\`kept \${name}\`); continue; }
|
|
173
|
+
}
|
|
174
|
+
let value: string;
|
|
175
|
+
if (preset) {
|
|
176
|
+
value = preset;
|
|
177
|
+
} else if (pe.looksLikeFileSecret(name)) {
|
|
178
|
+
const path = await p.text({ message: \`\${name} — path to file:\`, placeholder: 'Enter to skip' });
|
|
179
|
+
bail(path);
|
|
180
|
+
if (!path.trim()) continue;
|
|
181
|
+
value = pe.readPemFile(path.trim());
|
|
182
|
+
} else {
|
|
183
|
+
const raw = await p.password({ message: \`\${name}:\` });
|
|
184
|
+
bail(raw);
|
|
185
|
+
value = raw.trim();
|
|
186
|
+
if (!value) continue;
|
|
187
|
+
if (pe.isPem(value) === false && /KEY|PEM|CERT/.test(name) && value.startsWith('~')) {
|
|
188
|
+
value = pe.readPemFile(value);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const s = p.spinner();
|
|
192
|
+
s.start(\`secret \${name}\`);
|
|
193
|
+
const ok = await pe.putSecret(name, value, env, ROOT);
|
|
194
|
+
s.stop(\`\${ok ? '✓' : '✗'} \${name}\`);
|
|
195
|
+
}
|
|
196
|
+
// Verify: re-list and flag any we set that didn't land.
|
|
197
|
+
const after = await pe.listSecretNames(env, ROOT);
|
|
198
|
+
const missing = secrets.filter((n) => !after.includes(n));
|
|
199
|
+
if (missing.length) p.log.warn(\`not listed afterwards — verify manually: \${missing.join(', ')}\`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
p.outro(\`"\${env}" prepared — re-run anytime (idempotent) · inspect with --check · then deploy\`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (import.meta.main) {
|
|
206
|
+
await main();
|
|
207
|
+
}
|
|
208
|
+
`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Write the starter script + wire the `prepare-env` nx target. Returns the script path. */
|
|
212
|
+
export function scaffold(root: string, project: string, options: ScaffoldOptions = {}): string {
|
|
213
|
+
const { dir, label, packageJsonPath, json } = resolveProject(root, project);
|
|
214
|
+
const scriptsDir = join(dir, 'scripts');
|
|
215
|
+
const scriptPath = join(scriptsDir, 'prepare-env.ts');
|
|
216
|
+
|
|
217
|
+
const existed = existsSync(scriptPath);
|
|
218
|
+
if (existed && !options.force) {
|
|
219
|
+
throw new Error(`${label}/scripts/prepare-env.ts already exists — pass --force to overwrite`);
|
|
220
|
+
}
|
|
221
|
+
mkdirSync(scriptsDir, { recursive: true });
|
|
222
|
+
writeFileSync(scriptPath, renderScript(project));
|
|
223
|
+
console.log(`${existed ? 'overwrote' : 'created'} ${label}/scripts/prepare-env.ts`);
|
|
224
|
+
|
|
225
|
+
// Wire the nx target (idempotent).
|
|
226
|
+
const nx = getOrCreateRecord(json, 'nx');
|
|
227
|
+
const targets = getOrCreateRecord(nx, 'targets');
|
|
228
|
+
if (!recordProperty(targets, 'prepare-env')) {
|
|
229
|
+
targets['prepare-env'] = {
|
|
230
|
+
executor: 'nx:run-commands',
|
|
231
|
+
options: { command: 'bun scripts/prepare-env.ts', cwd: '{projectRoot}' },
|
|
232
|
+
};
|
|
233
|
+
writeJsonObject(packageJsonPath, json);
|
|
234
|
+
console.log(`updated ${label}/package.json prepare-env target`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
console.log(`\nnext: fill secret NAMES in ${label}/.dev.vars.example, then\n bunx nx run ${project}:prepare-env -- <env>`);
|
|
238
|
+
return scriptPath;
|
|
239
|
+
}
|