@gambi97/keel-cli 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +401 -0
- package/dist/bootstrap/github.js +190 -0
- package/dist/bootstrap/infisical.js +95 -0
- package/dist/bootstrap/scaleway.js +65 -0
- package/dist/config.js +153 -0
- package/dist/generate.js +104 -0
- package/dist/index.js +294 -0
- package/dist/prompts.js +109 -0
- package/dist/state.js +29 -0
- package/dist/ui.js +76 -0
- package/package.json +61 -0
- package/templates/.github/scripts/sync-database-url.sh +49 -0
- package/templates/.github/workflows/terraform-apply.yml +103 -0
- package/templates/.github/workflows/terraform-drift.yml +91 -0
- package/templates/.github/workflows/terraform-plan.yml +63 -0
- package/templates/LICENSE +21 -0
- package/templates/README.md +164 -0
- package/templates/_gitignore +28 -0
- package/templates/backend.hcl.example +9 -0
- package/templates/backend.tf +19 -0
- package/templates/main.tf +28 -0
- package/templates/modules/app_stack/main.tf +70 -0
- package/templates/modules/app_stack/outputs.tf +23 -0
- package/templates/modules/app_stack/variables.tf +55 -0
- package/templates/modules/app_stack/versions.tf +7 -0
- package/templates/outputs.tf +20 -0
- package/templates/prod.tfvars +7 -0
- package/templates/providers.tf +16 -0
- package/templates/staging.tfvars +7 -0
- package/templates/variables.tf +96 -0
- package/templates/versions.tf +15 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { ConfigError, finalizeAnswers, fromEnv, mergeAnswers, missingRequired, } from './config.js';
|
|
7
|
+
import { generateProject, GenerateError } from './generate.js';
|
|
8
|
+
import { confirmSummary, fillMissing } from './prompts.js';
|
|
9
|
+
import { isDone, loadState, markDone, STATE_FILE, stepData } from './state.js';
|
|
10
|
+
import { cancel, intro, log, outro, renderNextSteps, renderSummary, withSpinner } from './ui.js';
|
|
11
|
+
import { ensureStateBucket, validateScalewayCredentials } from './bootstrap/scaleway.js';
|
|
12
|
+
import { bootstrapInfisical, login as infisicalLogin } from './bootstrap/infisical.js';
|
|
13
|
+
import { configureRepo, createContext, ensureRepo, pushRepo, } from './bootstrap/github.js';
|
|
14
|
+
const HELP = `keel: generate and bootstrap serverless infra on Scaleway
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
npx @gambi97/keel-cli [options]
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
--name <name> Project name (dns-safe)
|
|
21
|
+
--dir <path> Target directory (default: ./<name>)
|
|
22
|
+
--region <region> fr-par | nl-ams | pl-waw (default: fr-par)
|
|
23
|
+
--scw-access-key <key> Scaleway access key (env SCW_ACCESS_KEY)
|
|
24
|
+
--scw-secret-key <key> Scaleway secret key (env SCW_SECRET_KEY)
|
|
25
|
+
--scw-project-id <id> Scaleway project ID (env SCW_DEFAULT_PROJECT_ID)
|
|
26
|
+
--scw-organization-id <id> Scaleway organization ID (env SCW_DEFAULT_ORGANIZATION_ID)
|
|
27
|
+
--infisical-host <url> Infisical host (env INFISICAL_HOST)
|
|
28
|
+
--infisical-client-id <id> Machine identity client ID (env INFISICAL_CLIENT_ID)
|
|
29
|
+
--infisical-client-secret <s> Machine identity secret (env INFISICAL_CLIENT_SECRET)
|
|
30
|
+
--infisical-project-name <n> Infisical project (default: project name)
|
|
31
|
+
--github-token <token> GitHub token, repo+workflow (env GITHUB_TOKEN)
|
|
32
|
+
--repo-name <name> GitHub repository name (default: project name)
|
|
33
|
+
--private / --public GitHub repository visibility (default: public)
|
|
34
|
+
--no-basic-auth Disable Basic Auth on staging
|
|
35
|
+
--staging-min-scale <n> Staging min instances (default 0)
|
|
36
|
+
--staging-max-scale <n> Staging max instances (default 1)
|
|
37
|
+
--prod-min-scale <n> Prod min instances (default 0)
|
|
38
|
+
--prod-max-scale <n> Prod max instances (default 2)
|
|
39
|
+
--config <file.json> Read answers from a JSON config file
|
|
40
|
+
--advanced Also ask scaling questions interactively
|
|
41
|
+
--yes Accept defaults, no confirmation prompt
|
|
42
|
+
--dry-run Generate files locally, touch no account
|
|
43
|
+
--help, --version
|
|
44
|
+
`;
|
|
45
|
+
function parseCli(argv) {
|
|
46
|
+
const { values } = parseArgs({
|
|
47
|
+
args: argv,
|
|
48
|
+
options: {
|
|
49
|
+
name: { type: 'string' },
|
|
50
|
+
dir: { type: 'string' },
|
|
51
|
+
region: { type: 'string' },
|
|
52
|
+
'scw-access-key': { type: 'string' },
|
|
53
|
+
'scw-secret-key': { type: 'string' },
|
|
54
|
+
'scw-project-id': { type: 'string' },
|
|
55
|
+
'scw-organization-id': { type: 'string' },
|
|
56
|
+
'infisical-host': { type: 'string' },
|
|
57
|
+
'infisical-client-id': { type: 'string' },
|
|
58
|
+
'infisical-client-secret': { type: 'string' },
|
|
59
|
+
'infisical-project-name': { type: 'string' },
|
|
60
|
+
'github-token': { type: 'string' },
|
|
61
|
+
'repo-name': { type: 'string' },
|
|
62
|
+
private: { type: 'boolean' },
|
|
63
|
+
public: { type: 'boolean' },
|
|
64
|
+
'basic-auth': { type: 'boolean' },
|
|
65
|
+
'no-basic-auth': { type: 'boolean' },
|
|
66
|
+
'staging-min-scale': { type: 'string' },
|
|
67
|
+
'staging-max-scale': { type: 'string' },
|
|
68
|
+
'prod-min-scale': { type: 'string' },
|
|
69
|
+
'prod-max-scale': { type: 'string' },
|
|
70
|
+
config: { type: 'string' },
|
|
71
|
+
advanced: { type: 'boolean' },
|
|
72
|
+
yes: { type: 'boolean' },
|
|
73
|
+
'dry-run': { type: 'boolean' },
|
|
74
|
+
help: { type: 'boolean' },
|
|
75
|
+
version: { type: 'boolean' },
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
if (values.help) {
|
|
79
|
+
process.stdout.write(HELP);
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
82
|
+
if (values.version) {
|
|
83
|
+
process.stdout.write(`${toolVersion()}\n`);
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
86
|
+
const fileAnswers = values.config
|
|
87
|
+
? normalizeConfigFile(JSON.parse(readFileSync(values.config, 'utf8')))
|
|
88
|
+
: { scaleway: {}, infisical: {}, github: {}, scaling: {} };
|
|
89
|
+
const num = (v) => (v === undefined ? undefined : Number(v));
|
|
90
|
+
const flagAnswers = {
|
|
91
|
+
projectName: values.name,
|
|
92
|
+
targetDir: values.dir,
|
|
93
|
+
region: values.region,
|
|
94
|
+
scaleway: {
|
|
95
|
+
accessKey: values['scw-access-key'],
|
|
96
|
+
secretKey: values['scw-secret-key'],
|
|
97
|
+
projectId: values['scw-project-id'],
|
|
98
|
+
organizationId: values['scw-organization-id'],
|
|
99
|
+
},
|
|
100
|
+
infisical: {
|
|
101
|
+
host: values['infisical-host'],
|
|
102
|
+
clientId: values['infisical-client-id'],
|
|
103
|
+
clientSecret: values['infisical-client-secret'],
|
|
104
|
+
projectName: values['infisical-project-name'],
|
|
105
|
+
},
|
|
106
|
+
github: {
|
|
107
|
+
token: values['github-token'],
|
|
108
|
+
repoName: values['repo-name'],
|
|
109
|
+
repoPrivate: values.private ? true : values.public ? false : undefined,
|
|
110
|
+
},
|
|
111
|
+
basicAuthStaging: values['no-basic-auth'] ? false : values['basic-auth'],
|
|
112
|
+
scaling: {
|
|
113
|
+
stagingMinScale: num(values['staging-min-scale']),
|
|
114
|
+
stagingMaxScale: num(values['staging-max-scale']),
|
|
115
|
+
prodMinScale: num(values['prod-min-scale']),
|
|
116
|
+
prodMaxScale: num(values['prod-max-scale']),
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
partial: mergeAnswers(fromEnv(process.env), fileAnswers, flagAnswers),
|
|
121
|
+
flags: {
|
|
122
|
+
yes: values.yes ?? false,
|
|
123
|
+
dryRun: values['dry-run'] ?? false,
|
|
124
|
+
advanced: values.advanced ?? false,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** Config files use the same nested shape as PartialAnswers. */
|
|
129
|
+
function normalizeConfigFile(raw) {
|
|
130
|
+
const obj = (raw ?? {});
|
|
131
|
+
return mergeAnswers({
|
|
132
|
+
projectName: obj.projectName,
|
|
133
|
+
region: obj.region,
|
|
134
|
+
targetDir: obj.targetDir,
|
|
135
|
+
basicAuthStaging: obj.basicAuthStaging,
|
|
136
|
+
scaleway: (obj.scaleway ?? {}),
|
|
137
|
+
infisical: (obj.infisical ?? {}),
|
|
138
|
+
github: (obj.github ?? {}),
|
|
139
|
+
scaling: (obj.scaling ?? {}),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
function toolVersion() {
|
|
143
|
+
try {
|
|
144
|
+
const pkg = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'));
|
|
145
|
+
return pkg.version;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return '0.0.0';
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function checkEnvironment() {
|
|
152
|
+
const [major] = process.versions.node.split('.');
|
|
153
|
+
if (Number(major) < 18) {
|
|
154
|
+
cancel(`Node.js >= 18 is required (found ${process.versions.node}).`);
|
|
155
|
+
}
|
|
156
|
+
if (spawnSync('git', ['--version'], { stdio: 'ignore' }).status !== 0) {
|
|
157
|
+
cancel('git is required but was not found on PATH.');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function printDryRunPlan(answers) {
|
|
161
|
+
log.info([
|
|
162
|
+
'Dry run: generated the repository locally. A real run would additionally:',
|
|
163
|
+
` - Scaleway: create Object Storage bucket "${answers.stateBucket}" (${answers.region})`,
|
|
164
|
+
` - Infisical: create/reuse project "${answers.infisical.projectName}", environments staging/prod,`,
|
|
165
|
+
' seed BASIC_AUTH_USER/BASIC_AUTH_PASSWORD (staging) and DATABASE_URL placeholders',
|
|
166
|
+
` - GitHub: create ${answers.github.repoPrivate ? 'private' : 'public'} repo "${answers.github.repoName}", push, set 6 encrypted secrets,`,
|
|
167
|
+
' 4 variables, staging/production environments and main branch protection',
|
|
168
|
+
].join('\n'));
|
|
169
|
+
}
|
|
170
|
+
async function runBootstrap(answers, state) {
|
|
171
|
+
const dir = answers.targetDir;
|
|
172
|
+
// All three credentials are checked before anything is created anywhere,
|
|
173
|
+
// so a bad token cannot leave a half-bootstrapped account behind.
|
|
174
|
+
let ctx;
|
|
175
|
+
await withSpinner('Validating Scaleway, Infisical and GitHub credentials', async () => {
|
|
176
|
+
await validateScalewayCredentials(answers);
|
|
177
|
+
await infisicalLogin(answers);
|
|
178
|
+
ctx = await createContext(answers);
|
|
179
|
+
});
|
|
180
|
+
if (!isDone(state, 'scaleway-bucket')) {
|
|
181
|
+
await withSpinner('Creating Terraform state bucket', () => ensureStateBucket(answers));
|
|
182
|
+
markDone(dir, state, 'scaleway-bucket');
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
log.info('Scaleway state bucket: already done, skipping.');
|
|
186
|
+
}
|
|
187
|
+
let infisicalProjectId = stepData(state, 'infisical', 'projectId');
|
|
188
|
+
if (!infisicalProjectId) {
|
|
189
|
+
const result = await withSpinner('Bootstrapping Infisical project and secrets', () => bootstrapInfisical(answers));
|
|
190
|
+
infisicalProjectId = result.projectId;
|
|
191
|
+
markDone(dir, state, 'infisical', { projectId: infisicalProjectId });
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
log.info('Infisical project: already done, skipping.');
|
|
195
|
+
}
|
|
196
|
+
let repoUrl = stepData(state, 'github-repo', 'url');
|
|
197
|
+
if (!repoUrl) {
|
|
198
|
+
const repo = await withSpinner(`Creating GitHub repository ${ctx.owner}/${ctx.repo}`, () => ensureRepo(ctx));
|
|
199
|
+
repoUrl = repo.url;
|
|
200
|
+
markDone(dir, state, 'github-repo', { url: repoUrl });
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
log.info('GitHub repository: already done, skipping.');
|
|
204
|
+
}
|
|
205
|
+
if (!isDone(state, 'github-push')) {
|
|
206
|
+
await withSpinner('Pushing generated code to GitHub', async () => {
|
|
207
|
+
pushRepo(ctx, answers.github.token, dir);
|
|
208
|
+
});
|
|
209
|
+
markDone(dir, state, 'github-push');
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
log.info('GitHub push: already done, skipping.');
|
|
213
|
+
}
|
|
214
|
+
if (!isDone(state, 'github-config')) {
|
|
215
|
+
await withSpinner('Setting GitHub secrets, variables and branch protection', () => configureRepo(ctx, answers, infisicalProjectId));
|
|
216
|
+
markDone(dir, state, 'github-config');
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
log.info('GitHub configuration: already done, skipping.');
|
|
220
|
+
}
|
|
221
|
+
return repoUrl;
|
|
222
|
+
}
|
|
223
|
+
async function main() {
|
|
224
|
+
const { partial, flags } = parseCli(process.argv.slice(2));
|
|
225
|
+
intro(toolVersion());
|
|
226
|
+
checkEnvironment();
|
|
227
|
+
const interactive = process.stdin.isTTY && !flags.yes;
|
|
228
|
+
let collected = partial;
|
|
229
|
+
if (interactive) {
|
|
230
|
+
collected = await fillMissing(partial, { advanced: flags.advanced });
|
|
231
|
+
}
|
|
232
|
+
else if (!flags.dryRun) {
|
|
233
|
+
const missing = missingRequired(partial);
|
|
234
|
+
if (missing.length > 0) {
|
|
235
|
+
cancel(`Non-interactive run is missing required values:\n - ${missing.join('\n - ')}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// In a dry run, missing credentials are replaced by placeholders: nothing
|
|
239
|
+
// will be validated against or sent to any account.
|
|
240
|
+
if (flags.dryRun) {
|
|
241
|
+
collected = mergeAnswers({
|
|
242
|
+
scaleway: {
|
|
243
|
+
accessKey: 'dry-run',
|
|
244
|
+
secretKey: 'dry-run',
|
|
245
|
+
projectId: 'dry-run',
|
|
246
|
+
organizationId: 'dry-run',
|
|
247
|
+
},
|
|
248
|
+
infisical: { clientId: 'dry-run', clientSecret: 'dry-run' },
|
|
249
|
+
github: { token: 'dry-run' },
|
|
250
|
+
scaling: {},
|
|
251
|
+
}, collected);
|
|
252
|
+
if (!collected.projectName)
|
|
253
|
+
collected.projectName = 'my-app';
|
|
254
|
+
}
|
|
255
|
+
const answers = finalizeAnswers(collected);
|
|
256
|
+
const summary = renderSummary(answers, flags.dryRun);
|
|
257
|
+
if (interactive) {
|
|
258
|
+
const confirmed = await confirmSummary(summary);
|
|
259
|
+
if (!confirmed)
|
|
260
|
+
cancel('Aborted. Nothing was created.');
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
log.info(summary);
|
|
264
|
+
}
|
|
265
|
+
const state = loadState(answers.targetDir, answers.projectName);
|
|
266
|
+
if (!isDone(state, 'generate')) {
|
|
267
|
+
await withSpinner(`Generating repository in ./${answers.targetDir}`, async () => {
|
|
268
|
+
generateProject(answers);
|
|
269
|
+
});
|
|
270
|
+
markDone(answers.targetDir, state, 'generate');
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
log.info('Generation: already done, skipping.');
|
|
274
|
+
}
|
|
275
|
+
if (flags.dryRun) {
|
|
276
|
+
printDryRunPlan(answers);
|
|
277
|
+
outro('Dry run complete. No account was touched.');
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const repoUrl = await runBootstrap(answers, state);
|
|
281
|
+
outro(renderNextSteps(answers, repoUrl, `rg.${answers.region}.scw.cloud/${answers.projectName}-staging`));
|
|
282
|
+
}
|
|
283
|
+
main().catch((error) => {
|
|
284
|
+
if (error instanceof ConfigError || error instanceof GenerateError) {
|
|
285
|
+
log.error(error.message);
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
log.error(error instanceof Error ? error.message : String(error));
|
|
289
|
+
log.warn('The run stopped before completing. Completed steps are recorded in ' +
|
|
290
|
+
`${STATE_FILE} inside the project directory: re-run the same ` +
|
|
291
|
+
'command to resume from where it failed. Already-created resources are reused, never duplicated.');
|
|
292
|
+
}
|
|
293
|
+
process.exit(1);
|
|
294
|
+
});
|
package/dist/prompts.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import { ConfigError, DEFAULT_INFISICAL_HOST, DEFAULT_REGION, REGIONS, validateProjectName, validateScale, } from './config.js';
|
|
3
|
+
function bail() {
|
|
4
|
+
p.cancel('Cancelled. Nothing was created.');
|
|
5
|
+
process.exit(1);
|
|
6
|
+
}
|
|
7
|
+
async function ask(promise) {
|
|
8
|
+
const value = await promise;
|
|
9
|
+
if (p.isCancel(value))
|
|
10
|
+
bail();
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
function validate(fn) {
|
|
14
|
+
return (value) => {
|
|
15
|
+
try {
|
|
16
|
+
fn(value);
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
return error instanceof ConfigError ? error.message : 'Invalid value.';
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** Interactively fill everything still missing from flags/env/config file. */
|
|
25
|
+
export async function fillMissing(partial, options) {
|
|
26
|
+
const out = structuredClone(partial);
|
|
27
|
+
if (!out.projectName) {
|
|
28
|
+
out.projectName = await ask(p.text({
|
|
29
|
+
message: 'Project name (dns-safe, used for repo, bucket and resources)',
|
|
30
|
+
placeholder: 'my-app',
|
|
31
|
+
validate: validate(validateProjectName),
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
if (!out.region) {
|
|
35
|
+
out.region = await ask(p.select({
|
|
36
|
+
message: 'Scaleway region',
|
|
37
|
+
initialValue: DEFAULT_REGION,
|
|
38
|
+
options: REGIONS.map((r) => ({ value: r, label: r })),
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
const secret = (message) => ask(p.password({ message }));
|
|
42
|
+
const text = (message, placeholder) => ask(p.text({
|
|
43
|
+
message,
|
|
44
|
+
...(placeholder ? { placeholder } : {}),
|
|
45
|
+
validate: (v) => (v.trim() ? undefined : 'Required.'),
|
|
46
|
+
}));
|
|
47
|
+
if (!out.scaleway.accessKey)
|
|
48
|
+
out.scaleway.accessKey = await text('Scaleway access key');
|
|
49
|
+
if (!out.scaleway.secretKey)
|
|
50
|
+
out.scaleway.secretKey = await secret('Scaleway secret key');
|
|
51
|
+
if (!out.scaleway.projectId)
|
|
52
|
+
out.scaleway.projectId = await text('Scaleway project ID');
|
|
53
|
+
if (!out.scaleway.organizationId)
|
|
54
|
+
out.scaleway.organizationId = await text('Scaleway organization ID');
|
|
55
|
+
if (!out.infisical.host) {
|
|
56
|
+
out.infisical.host = await ask(p.text({ message: 'Infisical host', initialValue: DEFAULT_INFISICAL_HOST }));
|
|
57
|
+
}
|
|
58
|
+
if (!out.infisical.clientId)
|
|
59
|
+
out.infisical.clientId = await text('Infisical machine identity client ID');
|
|
60
|
+
if (!out.infisical.clientSecret)
|
|
61
|
+
out.infisical.clientSecret = await secret('Infisical machine identity client secret');
|
|
62
|
+
if (!out.infisical.projectName) {
|
|
63
|
+
out.infisical.projectName = await ask(p.text({
|
|
64
|
+
message: 'Infisical project name (existing project is reused, otherwise created)',
|
|
65
|
+
initialValue: out.projectName,
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
if (!out.github.token)
|
|
69
|
+
out.github.token = await secret('GitHub token (scopes: repo, workflow)');
|
|
70
|
+
if (!out.github.repoName) {
|
|
71
|
+
out.github.repoName = await ask(p.text({
|
|
72
|
+
message: 'GitHub repository name',
|
|
73
|
+
initialValue: out.projectName,
|
|
74
|
+
validate: validate(validateProjectName),
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
if (out.github.repoPrivate === undefined) {
|
|
78
|
+
out.github.repoPrivate = await ask(p.select({
|
|
79
|
+
message: 'Repository visibility',
|
|
80
|
+
initialValue: false,
|
|
81
|
+
options: [
|
|
82
|
+
{ value: false, label: 'Public', hint: 'the infra contains no secrets' },
|
|
83
|
+
{ value: true, label: 'Private' },
|
|
84
|
+
],
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
if (out.basicAuthStaging === undefined) {
|
|
88
|
+
out.basicAuthStaging = await ask(p.confirm({
|
|
89
|
+
message: 'Protect staging with Basic Auth (enforced by your app)?',
|
|
90
|
+
initialValue: true,
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
if (options.advanced) {
|
|
94
|
+
const scale = async (message, initial) => Number(await ask(p.text({
|
|
95
|
+
message,
|
|
96
|
+
initialValue: String(initial),
|
|
97
|
+
validate: validate((v) => validateScale(v, 'scale')),
|
|
98
|
+
})));
|
|
99
|
+
out.scaling.stagingMinScale = await scale('Staging min scale', out.scaling.stagingMinScale ?? 0);
|
|
100
|
+
out.scaling.stagingMaxScale = await scale('Staging max scale', out.scaling.stagingMaxScale ?? 1);
|
|
101
|
+
out.scaling.prodMinScale = await scale('Prod min scale', out.scaling.prodMinScale ?? 0);
|
|
102
|
+
out.scaling.prodMaxScale = await scale('Prod max scale', out.scaling.prodMaxScale ?? 2);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
export async function confirmSummary(summary) {
|
|
107
|
+
p.note(summary, 'About to create');
|
|
108
|
+
return ask(p.confirm({ message: 'Proceed? No account is touched before this point.' }));
|
|
109
|
+
}
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export const STATE_FILE = '.keel.json';
|
|
4
|
+
export function loadState(targetDir, projectName) {
|
|
5
|
+
try {
|
|
6
|
+
const raw = readFileSync(join(targetDir, STATE_FILE), 'utf8');
|
|
7
|
+
const parsed = JSON.parse(raw);
|
|
8
|
+
if (parsed.version === 1 && parsed.projectName === projectName) {
|
|
9
|
+
return parsed;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
// No resume file or unreadable: start fresh.
|
|
14
|
+
}
|
|
15
|
+
return { version: 1, projectName, steps: {} };
|
|
16
|
+
}
|
|
17
|
+
export function saveState(targetDir, state) {
|
|
18
|
+
writeFileSync(join(targetDir, STATE_FILE), `${JSON.stringify(state, null, 2)}\n`);
|
|
19
|
+
}
|
|
20
|
+
export function isDone(state, step) {
|
|
21
|
+
return state.steps[step] !== undefined;
|
|
22
|
+
}
|
|
23
|
+
export function markDone(targetDir, state, step, data) {
|
|
24
|
+
state.steps[step] = data ? { data } : {};
|
|
25
|
+
saveState(targetDir, state);
|
|
26
|
+
}
|
|
27
|
+
export function stepData(state, step, key) {
|
|
28
|
+
return state.steps[step]?.data?.[key];
|
|
29
|
+
}
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
export const log = p.log;
|
|
3
|
+
export function intro(version) {
|
|
4
|
+
p.intro(`keel v${version}`);
|
|
5
|
+
}
|
|
6
|
+
export function outro(message) {
|
|
7
|
+
p.outro(message);
|
|
8
|
+
}
|
|
9
|
+
export function cancel(message) {
|
|
10
|
+
p.cancel(message);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
/** Show only a hint of a secret, never the value itself. */
|
|
14
|
+
export function redact(secret) {
|
|
15
|
+
if (secret.length <= 6)
|
|
16
|
+
return '***';
|
|
17
|
+
return `${secret.slice(0, 4)}…(redacted)`;
|
|
18
|
+
}
|
|
19
|
+
export async function withSpinner(label, fn) {
|
|
20
|
+
const spinner = p.spinner();
|
|
21
|
+
spinner.start(label);
|
|
22
|
+
try {
|
|
23
|
+
const result = await fn();
|
|
24
|
+
spinner.stop(`${label} ✓`);
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
spinner.stop(`${label} ✗`, 1);
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function renderSummary(answers, dryRun) {
|
|
33
|
+
const lines = [
|
|
34
|
+
`Project ${answers.projectName}`,
|
|
35
|
+
`Directory ./${answers.targetDir}`,
|
|
36
|
+
`Region ${answers.region}`,
|
|
37
|
+
'',
|
|
38
|
+
'Scaleway',
|
|
39
|
+
` access key ${redact(answers.scaleway.accessKey)}`,
|
|
40
|
+
` project ${answers.scaleway.projectId}`,
|
|
41
|
+
` state bucket ${answers.stateBucket} (will be created)`,
|
|
42
|
+
'',
|
|
43
|
+
'GitHub',
|
|
44
|
+
` repository ${answers.github.repoName} (${answers.github.repoPrivate ? 'private' : 'public'}, will be created + pushed)`,
|
|
45
|
+
` secrets SCW_*, INFISICAL_* (encrypted)`,
|
|
46
|
+
` variables TF_STATE_BUCKET, SCW_REGION, INFISICAL_PROJECT_ID, INFISICAL_HOST`,
|
|
47
|
+
'',
|
|
48
|
+
'Infisical',
|
|
49
|
+
` host ${answers.infisical.host}`,
|
|
50
|
+
` project ${answers.infisical.projectName} (created or reused)`,
|
|
51
|
+
` environments staging, prod (+ placeholder secrets)`,
|
|
52
|
+
'',
|
|
53
|
+
`Basic Auth on staging ${answers.basicAuthStaging ? 'enabled' : 'disabled'}`,
|
|
54
|
+
`Scaling staging ${answers.scaling.stagingMinScale}-${answers.scaling.stagingMaxScale} instances`,
|
|
55
|
+
`Scaling prod ${answers.scaling.prodMinScale}-${answers.scaling.prodMaxScale} instances`,
|
|
56
|
+
];
|
|
57
|
+
if (dryRun) {
|
|
58
|
+
lines.push('', 'DRY RUN: files will be generated locally, no account will be touched.');
|
|
59
|
+
}
|
|
60
|
+
return lines.join('\n');
|
|
61
|
+
}
|
|
62
|
+
export function renderNextSteps(answers, repoUrl, containerUrlHint) {
|
|
63
|
+
return [
|
|
64
|
+
'Next steps:',
|
|
65
|
+
'',
|
|
66
|
+
` 1. Add a Dockerfile to your application and push its image to the`,
|
|
67
|
+
` registry created by the first pipeline run (${containerUrlHint}).`,
|
|
68
|
+
` 2. Replace the placeholder secrets in Infisical (${answers.infisical.host})`,
|
|
69
|
+
` project "${answers.infisical.projectName}" with real values.`,
|
|
70
|
+
` 3. Push to main (or merge a PR) to trigger the first terraform apply:`,
|
|
71
|
+
` ${repoUrl}/actions`,
|
|
72
|
+
` 4. Set container_image in staging.tfvars / prod.tfvars once an image exists.`,
|
|
73
|
+
'',
|
|
74
|
+
`Repository: ${repoUrl}`,
|
|
75
|
+
].join('\n');
|
|
76
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gambi97/keel-cli",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "One command from zero to a production-shaped serverless infrastructure on Scaleway (Terraform + GitHub Actions + Infisical). Only Node required.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"keel",
|
|
7
|
+
"scaleway",
|
|
8
|
+
"terraform",
|
|
9
|
+
"serverless",
|
|
10
|
+
"infisical",
|
|
11
|
+
"github-actions",
|
|
12
|
+
"scaffold",
|
|
13
|
+
"infrastructure"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/Gambi97/keel.git"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/Gambi97/keel#readme",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/Gambi97/keel/issues"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"bin": {
|
|
26
|
+
"keel": "dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"templates"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18.17.0"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc",
|
|
37
|
+
"typecheck": "tsc --noEmit",
|
|
38
|
+
"lint": "eslint src",
|
|
39
|
+
"format": "prettier --write .",
|
|
40
|
+
"format:check": "prettier --check .",
|
|
41
|
+
"test": "vitest run",
|
|
42
|
+
"verify:templates": "node scripts/verify-templates.mjs",
|
|
43
|
+
"prepare": "npm run build",
|
|
44
|
+
"prepublishOnly": "npm run lint && npm run test"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@aws-sdk/client-s3": "^3.600.0",
|
|
48
|
+
"@clack/prompts": "^0.11.0",
|
|
49
|
+
"@octokit/rest": "^21.0.0",
|
|
50
|
+
"libsodium-wrappers": "^0.7.13"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/libsodium-wrappers": "^0.7.14",
|
|
54
|
+
"@types/node": "^20.14.0",
|
|
55
|
+
"eslint": "^9.0.0",
|
|
56
|
+
"prettier": "^3.3.0",
|
|
57
|
+
"typescript": "^5.5.0",
|
|
58
|
+
"typescript-eslint": "^8.0.0",
|
|
59
|
+
"vitest": "^3.0.0"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Pushes the ready-to-use database connection string (dedicated IAM
|
|
3
|
+
# credential included) to Infisical as DATABASE_URL.
|
|
4
|
+
# Usage: sync-database-url.sh <staging|prod>
|
|
5
|
+
# Expects: INFISICAL_HOST, INFISICAL_CLIENT_ID, INFISICAL_CLIENT_SECRET,
|
|
6
|
+
# INFISICAL_PROJECT_ID in the environment; terraform init already run.
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
|
|
9
|
+
ENVIRONMENT="${1:?usage: sync-database-url.sh <staging|prod>}"
|
|
10
|
+
|
|
11
|
+
DB_URL="$(terraform output -raw database_url)"
|
|
12
|
+
if [ -z "$DB_URL" ]; then
|
|
13
|
+
echo "No database_url in outputs, skipping Infisical sync."
|
|
14
|
+
exit 0
|
|
15
|
+
fi
|
|
16
|
+
echo "::add-mask::$DB_URL"
|
|
17
|
+
|
|
18
|
+
ACCESS_TOKEN="$(curl -sS --fail-with-body -X POST \
|
|
19
|
+
"$INFISICAL_HOST/api/v1/auth/universal-auth/login" \
|
|
20
|
+
-H "Content-Type: application/json" \
|
|
21
|
+
-d "{\"clientId\":\"$INFISICAL_CLIENT_ID\",\"clientSecret\":\"$INFISICAL_CLIENT_SECRET\"}" \
|
|
22
|
+
| jq -r .accessToken)"
|
|
23
|
+
echo "::add-mask::$ACCESS_TOKEN"
|
|
24
|
+
|
|
25
|
+
payload="$(jq -n \
|
|
26
|
+
--arg workspaceId "$INFISICAL_PROJECT_ID" \
|
|
27
|
+
--arg environment "$ENVIRONMENT" \
|
|
28
|
+
--arg value "$DB_URL" \
|
|
29
|
+
'{workspaceId: $workspaceId, environment: $environment, secretPath: "/", secretValue: $value, type: "shared"}')"
|
|
30
|
+
|
|
31
|
+
# Update the placeholder seeded at bootstrap; create the secret if missing.
|
|
32
|
+
status="$(curl -sS -o /dev/null -w "%{http_code}" -X PATCH \
|
|
33
|
+
"$INFISICAL_HOST/api/v3/secrets/raw/DATABASE_URL" \
|
|
34
|
+
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
|
35
|
+
-H "Content-Type: application/json" \
|
|
36
|
+
-d "$payload")"
|
|
37
|
+
|
|
38
|
+
if [ "$status" = "404" ]; then
|
|
39
|
+
curl -sS --fail-with-body -o /dev/null -X POST \
|
|
40
|
+
"$INFISICAL_HOST/api/v3/secrets/raw/DATABASE_URL" \
|
|
41
|
+
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
|
42
|
+
-H "Content-Type: application/json" \
|
|
43
|
+
-d "$payload"
|
|
44
|
+
elif [ "$status" -ge 300 ]; then
|
|
45
|
+
echo "Failed to update DATABASE_URL in Infisical (HTTP $status)" >&2
|
|
46
|
+
exit 1
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
echo "DATABASE_URL synced to Infisical ($ENVIRONMENT)."
|