@gambi97/keel-cli 0.2.0 → 0.2.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/dist/index.js CHANGED
@@ -4,90 +4,136 @@ import { spawnSync } from 'node:child_process';
4
4
  import { parseArgs } from 'node:util';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { ConfigError, finalizeAnswers, fromEnv, mergeAnswers, missingRequired, parseEnvironments, } from './config.js';
7
+ import { CI_SECRET_NAMES, CI_VARIABLE_NAMES } from './contracts.js';
7
8
  import { generateProject, GenerateError } from './generate.js';
8
9
  import { confirmSummary, fillMissing } from './prompts.js';
9
- import { isDone, loadState, markDone, STATE_FILE, stepData } from './state.js';
10
+ import { isDone, loadState, markDone, STATE_FILE, stepData, } from './state.js';
10
11
  import { cancel, intro, log, outro, renderNextSteps, renderSummary, withSpinner } from './ui.js';
11
12
  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
- --environments <preset> prod | staging+prod | dev+staging+prod
35
- (or a list like "dev,staging,prod"; default staging+prod)
36
- --no-basic-auth Disable Basic Auth on non-production environments
37
- --object-storage Provision a per-environment Object Storage bucket
38
- --no-object-storage Do not provision Object Storage (default)
39
- --dev-min-scale <n> Dev min instances (default 0)
40
- --dev-max-scale <n> Dev max instances (default 1)
41
- --staging-min-scale <n> Staging min instances (default 0)
42
- --staging-max-scale <n> Staging max instances (default 1)
43
- --prod-min-scale <n> Prod min instances (default 0)
44
- --prod-max-scale <n> Prod max instances (default 2)
45
- --config <file.json> Read answers from a JSON config file
46
- --advanced Also ask scaling questions interactively
47
- --yes Accept defaults, no confirmation prompt
48
- --dry-run Generate files locally, touch no account
49
- --help, --version
50
- `;
13
+ import { bootstrapInfisical, validateInfisical } from './bootstrap/infisical.js';
14
+ import { assertRepoUsable, configureRepo, createContext, ensureRepo, inspectRepo, pushRepo, } from './bootstrap/github.js';
15
+ /**
16
+ * Single source of truth for the CLI surface: parseArgs consumes these specs
17
+ * and --help is generated from CLI_HELP, whose keys the compiler checks
18
+ * against this table — a flag cannot be added without help text, or vice
19
+ * versa. (The README's CLI reference is the one remaining manual copy.)
20
+ */
21
+ const CLI_OPTIONS = {
22
+ name: { type: 'string' },
23
+ dir: { type: 'string' },
24
+ region: { type: 'string' },
25
+ 'scw-access-key': { type: 'string' },
26
+ 'scw-secret-key': { type: 'string' },
27
+ 'scw-project-id': { type: 'string' },
28
+ 'scw-organization-id': { type: 'string' },
29
+ 'infisical-host': { type: 'string' },
30
+ 'infisical-client-id': { type: 'string' },
31
+ 'infisical-client-secret': { type: 'string' },
32
+ 'infisical-project-id': { type: 'string' },
33
+ 'infisical-project-name': { type: 'string' },
34
+ 'github-token': { type: 'string' },
35
+ 'repo-name': { type: 'string' },
36
+ private: { type: 'boolean' },
37
+ public: { type: 'boolean' },
38
+ environments: { type: 'string' },
39
+ 'basic-auth': { type: 'boolean' },
40
+ 'no-basic-auth': { type: 'boolean' },
41
+ 'object-storage': { type: 'boolean' },
42
+ 'no-object-storage': { type: 'boolean' },
43
+ 'dev-min-scale': { type: 'string' },
44
+ 'dev-max-scale': { type: 'string' },
45
+ 'staging-min-scale': { type: 'string' },
46
+ 'staging-max-scale': { type: 'string' },
47
+ 'prod-min-scale': { type: 'string' },
48
+ 'prod-max-scale': { type: 'string' },
49
+ config: { type: 'string' },
50
+ advanced: { type: 'boolean' },
51
+ yes: { type: 'boolean' },
52
+ 'dry-run': { type: 'boolean' },
53
+ help: { type: 'boolean' },
54
+ version: { type: 'boolean' },
55
+ };
56
+ /** Help text per flag; null hides it (help/version share a closing line). */
57
+ const CLI_HELP = {
58
+ name: { hint: '<name>', text: 'Project name (dns-safe)' },
59
+ dir: { hint: '<path>', text: 'Target directory (default: ./<name>)' },
60
+ region: { hint: '<region>', text: 'fr-par | nl-ams | pl-waw (default: fr-par)' },
61
+ 'scw-access-key': { hint: '<key>', text: 'Scaleway access key (env SCW_ACCESS_KEY)' },
62
+ 'scw-secret-key': { hint: '<key>', text: 'Scaleway secret key (env SCW_SECRET_KEY)' },
63
+ 'scw-project-id': {
64
+ hint: '<id>',
65
+ text: 'Scaleway project ID (env SCW_DEFAULT_PROJECT_ID)',
66
+ },
67
+ 'scw-organization-id': {
68
+ hint: '<id>',
69
+ text: 'Scaleway organization ID (env SCW_DEFAULT_ORGANIZATION_ID)',
70
+ },
71
+ 'infisical-host': { hint: '<url>', text: 'Infisical host (env INFISICAL_HOST)' },
72
+ 'infisical-client-id': {
73
+ hint: '<id>',
74
+ text: 'Machine identity client ID (env INFISICAL_CLIENT_ID)',
75
+ },
76
+ 'infisical-client-secret': {
77
+ hint: '<s>',
78
+ text: 'Machine identity secret (env INFISICAL_CLIENT_SECRET)',
79
+ },
80
+ 'infisical-project-id': {
81
+ hint: '<id>',
82
+ text: 'Existing Infisical project ID to reuse\n(env INFISICAL_PROJECT_ID; default: create by name)',
83
+ },
84
+ 'infisical-project-name': { hint: '<n>', text: 'Infisical project name (default: project name)' },
85
+ 'github-token': { hint: '<token>', text: 'GitHub token, repo+workflow (env GITHUB_TOKEN)' },
86
+ 'repo-name': { hint: '<name>', text: 'GitHub repository name (default: project name)' },
87
+ private: { text: 'Create the GitHub repository as private' },
88
+ public: { text: 'Create the GitHub repository as public (default)' },
89
+ environments: {
90
+ hint: '<preset>',
91
+ text: 'prod | staging+prod | dev+staging+prod\n(or a list like "dev,staging,prod"; default staging+prod)',
92
+ },
93
+ 'basic-auth': { text: 'Enable Basic Auth on non-production environments (default)' },
94
+ 'no-basic-auth': { text: 'Disable Basic Auth on non-production environments' },
95
+ 'object-storage': { text: 'Provision a per-environment Object Storage bucket' },
96
+ 'no-object-storage': { text: 'Do not provision Object Storage (default)' },
97
+ 'dev-min-scale': { hint: '<n>', text: 'Dev min instances (default 0)' },
98
+ 'dev-max-scale': { hint: '<n>', text: 'Dev max instances (default 1)' },
99
+ 'staging-min-scale': { hint: '<n>', text: 'Staging min instances (default 0)' },
100
+ 'staging-max-scale': { hint: '<n>', text: 'Staging max instances (default 1)' },
101
+ 'prod-min-scale': { hint: '<n>', text: 'Prod min instances (default 0)' },
102
+ 'prod-max-scale': { hint: '<n>', text: 'Prod max instances (default 1)' },
103
+ config: { hint: '<file.json>', text: 'Read answers from a JSON config file' },
104
+ advanced: { text: 'Also ask scaling questions interactively' },
105
+ yes: { text: 'Accept defaults, no confirmation prompt' },
106
+ 'dry-run': { text: 'Generate files locally, touch no account' },
107
+ help: null,
108
+ version: null,
109
+ };
110
+ const HELP_COLUMN = 33;
111
+ function buildHelp() {
112
+ const lines = [
113
+ 'keel: generate and bootstrap serverless infra on Scaleway',
114
+ '',
115
+ 'Usage:',
116
+ ' npx @gambi97/keel-cli [options]',
117
+ '',
118
+ 'Options:',
119
+ ];
120
+ for (const [flag, entry] of Object.entries(CLI_HELP)) {
121
+ if (!entry)
122
+ continue;
123
+ const head = ` --${flag}${entry.hint ? ` ${entry.hint}` : ''}`;
124
+ const [first, ...rest] = entry.text.split('\n');
125
+ lines.push(head.padEnd(HELP_COLUMN) + first);
126
+ for (const continuation of rest) {
127
+ lines.push(' '.repeat(HELP_COLUMN) + continuation);
128
+ }
129
+ }
130
+ lines.push(' --help, --version');
131
+ return `${lines.join('\n')}\n`;
132
+ }
51
133
  function parseCli(argv) {
52
- const { values } = parseArgs({
53
- args: argv,
54
- options: {
55
- name: { type: 'string' },
56
- dir: { type: 'string' },
57
- region: { type: 'string' },
58
- 'scw-access-key': { type: 'string' },
59
- 'scw-secret-key': { type: 'string' },
60
- 'scw-project-id': { type: 'string' },
61
- 'scw-organization-id': { type: 'string' },
62
- 'infisical-host': { type: 'string' },
63
- 'infisical-client-id': { type: 'string' },
64
- 'infisical-client-secret': { type: 'string' },
65
- 'infisical-project-name': { type: 'string' },
66
- 'github-token': { type: 'string' },
67
- 'repo-name': { type: 'string' },
68
- private: { type: 'boolean' },
69
- public: { type: 'boolean' },
70
- environments: { type: 'string' },
71
- 'basic-auth': { type: 'boolean' },
72
- 'no-basic-auth': { type: 'boolean' },
73
- 'object-storage': { type: 'boolean' },
74
- 'no-object-storage': { type: 'boolean' },
75
- 'dev-min-scale': { type: 'string' },
76
- 'dev-max-scale': { type: 'string' },
77
- 'staging-min-scale': { type: 'string' },
78
- 'staging-max-scale': { type: 'string' },
79
- 'prod-min-scale': { type: 'string' },
80
- 'prod-max-scale': { type: 'string' },
81
- config: { type: 'string' },
82
- advanced: { type: 'boolean' },
83
- yes: { type: 'boolean' },
84
- 'dry-run': { type: 'boolean' },
85
- help: { type: 'boolean' },
86
- version: { type: 'boolean' },
87
- },
88
- });
134
+ const { values } = parseArgs({ args: argv, options: CLI_OPTIONS });
89
135
  if (values.help) {
90
- process.stdout.write(HELP);
136
+ process.stdout.write(buildHelp());
91
137
  process.exit(0);
92
138
  }
93
139
  if (values.version) {
@@ -112,6 +158,7 @@ function parseCli(argv) {
112
158
  host: values['infisical-host'],
113
159
  clientId: values['infisical-client-id'],
114
160
  clientSecret: values['infisical-client-secret'],
161
+ projectId: values['infisical-project-id'],
115
162
  projectName: values['infisical-project-name'],
116
163
  },
117
164
  github: {
@@ -187,66 +234,102 @@ function printDryRunPlan(answers) {
187
234
  const ghEnvs = answers.environments.map((e) => e.githubEnvironment).join('/');
188
235
  log.info([
189
236
  'Dry run: generated the repository locally. A real run would additionally:',
190
- ` - Scaleway: create the Terraform state bucket "${answers.stateBucket}" (${answers.region})`,
191
- ` - Infisical: create/reuse project "${answers.infisical.projectName}", environments ${envList},`,
237
+ ` - Scaleway: create the Terraform state bucket "${answers.stateBucket}" (${answers.region}),`,
238
+ ' restricted by a bucket policy to the identity behind your API key',
239
+ ` - Infisical: ${answers.infisical.projectId
240
+ ? `reuse project ${answers.infisical.projectId}`
241
+ : `create/reuse project "${answers.infisical.projectName}"`}, environments ${envList},`,
192
242
  ' seed BASIC_AUTH_USER/BASIC_AUTH_PASSWORD (non-prod) and DATABASE_URL placeholders' +
193
243
  (answers.objectStorage ? ' and S3_* placeholders' : ''),
194
- ` - GitHub: create ${answers.github.repoPrivate ? 'private' : 'public'} repo "${answers.github.repoName}", push, set 6 encrypted secrets,`,
195
- ` 4 variables, ${ghEnvs} environments and main branch protection`,
244
+ ` - GitHub: create ${answers.github.repoPrivate ? 'private' : 'public'} repo "${answers.github.repoName}", push, set ${CI_SECRET_NAMES.length} encrypted secrets,`,
245
+ ` ${CI_VARIABLE_NAMES.length} variables, ${ghEnvs} environments and main branch protection`,
196
246
  ].join('\n'));
197
247
  }
198
- async function runBootstrap(answers, state) {
199
- const dir = answers.targetDir;
200
- // All three credentials are checked before anything is created anywhere,
201
- // so a bad token cannot leave a half-bootstrapped account behind.
248
+ /**
249
+ * The bootstrap pipeline, in order. Each step is idempotent on the provider
250
+ * side and recorded in the state file, so a re-run after a failure skips what
251
+ * is done and resumes exactly where it stopped.
252
+ */
253
+ const BOOTSTRAP_STEPS = [
254
+ {
255
+ name: 'scaleway-bucket',
256
+ label: () => 'Creating Terraform state bucket',
257
+ skipMessage: 'Scaleway state bucket: already done, skipping.',
258
+ run: async (answers) => {
259
+ const { policyWarning } = await ensureStateBucket(answers);
260
+ return policyWarning ? { warning: policyWarning } : undefined;
261
+ },
262
+ },
263
+ {
264
+ name: 'infisical',
265
+ label: () => 'Bootstrapping Infisical project and secrets',
266
+ skipMessage: 'Infisical project: already done, skipping.',
267
+ run: async (answers) => {
268
+ const { projectId } = await bootstrapInfisical(answers);
269
+ return { data: { projectId } };
270
+ },
271
+ },
272
+ {
273
+ name: 'github-repo',
274
+ label: (_answers, ctx) => `Creating GitHub repository ${ctx.owner}/${ctx.repo}`,
275
+ skipMessage: 'GitHub repository: already done, skipping.',
276
+ run: async (_answers, ctx) => {
277
+ const { url } = await ensureRepo(ctx);
278
+ return { data: { url } };
279
+ },
280
+ },
281
+ {
282
+ name: 'github-push',
283
+ label: () => 'Pushing generated code to GitHub',
284
+ skipMessage: 'GitHub push: already done, skipping.',
285
+ run: async (answers, ctx) => {
286
+ pushRepo(ctx, answers.github.token, answers.targetDir);
287
+ },
288
+ },
289
+ {
290
+ name: 'github-config',
291
+ label: () => 'Setting GitHub secrets, variables and branch protection',
292
+ skipMessage: 'GitHub configuration: already done, skipping.',
293
+ run: async (answers, ctx, state) => {
294
+ // Recorded by the infisical step (this run or a resumed one).
295
+ await configureRepo(ctx, answers, stepData(state, 'infisical', 'projectId'));
296
+ },
297
+ },
298
+ ];
299
+ async function runBootstrap(answers, state, options) {
202
300
  let ctx;
203
- await withSpinner('Validating Scaleway, Infisical and GitHub credentials', async () => {
204
- await validateScalewayCredentials(answers);
205
- await infisicalLogin(answers);
206
- ctx = await createContext(answers);
207
- });
208
- if (!isDone(state, 'scaleway-bucket')) {
209
- await withSpinner('Creating Terraform state bucket', () => ensureStateBucket(answers));
210
- markDone(dir, state, 'scaleway-bucket');
301
+ if (options.preValidated) {
302
+ // Interactive runs validated each provider inline while prompting; only
303
+ // the GitHub context (octokit + owner) needs to be rebuilt here.
304
+ ctx = await createContext(answers.github);
211
305
  }
212
306
  else {
213
- log.info('Scaleway state bucket: already done, skipping.');
214
- }
215
- let infisicalProjectId = stepData(state, 'infisical', 'projectId');
216
- if (!infisicalProjectId) {
217
- const result = await withSpinner('Bootstrapping Infisical project and secrets', () => bootstrapInfisical(answers));
218
- infisicalProjectId = result.projectId;
219
- markDone(dir, state, 'infisical', { projectId: infisicalProjectId });
220
- }
221
- else {
222
- log.info('Infisical project: already done, skipping.');
223
- }
224
- let repoUrl = stepData(state, 'github-repo', 'url');
225
- if (!repoUrl) {
226
- const repo = await withSpinner(`Creating GitHub repository ${ctx.owner}/${ctx.repo}`, () => ensureRepo(ctx));
227
- repoUrl = repo.url;
228
- markDone(dir, state, 'github-repo', { url: repoUrl });
229
- }
230
- else {
231
- log.info('GitHub repository: already done, skipping.');
232
- }
233
- if (!isDone(state, 'github-push')) {
234
- await withSpinner('Pushing generated code to GitHub', async () => {
235
- pushRepo(ctx, answers.github.token, dir);
307
+ // All three credentials are checked before anything is created anywhere,
308
+ // so a bad token cannot leave a half-bootstrapped account behind.
309
+ await withSpinner('Validating Scaleway, Infisical and GitHub credentials', async () => {
310
+ await validateScalewayCredentials(answers.scaleway);
311
+ await validateInfisical(answers.infisical);
312
+ ctx = await createContext(answers.github);
313
+ // A repo with commits would make the push fail after the bucket and the
314
+ // Infisical project were already created: fail here instead. Skipped on
315
+ // resume, where the previous run's push is the reason it is non-empty.
316
+ if (!isDone(state, 'github-push')) {
317
+ const { state: repoState } = await inspectRepo(ctx);
318
+ assertRepoUsable(ctx, repoState);
319
+ }
236
320
  });
237
- markDone(dir, state, 'github-push');
238
321
  }
239
- else {
240
- log.info('GitHub push: already done, skipping.');
241
- }
242
- if (!isDone(state, 'github-config')) {
243
- await withSpinner('Setting GitHub secrets, variables and branch protection', () => configureRepo(ctx, answers, infisicalProjectId));
244
- markDone(dir, state, 'github-config');
245
- }
246
- else {
247
- log.info('GitHub configuration: already done, skipping.');
322
+ for (const step of BOOTSTRAP_STEPS) {
323
+ if (isDone(state, step.name)) {
324
+ log.info(step.skipMessage);
325
+ continue;
326
+ }
327
+ const result = await withSpinner(step.label(answers, ctx), () => step.run(answers, ctx, state));
328
+ markDone(answers.targetDir, state, step.name, result?.data);
329
+ if (result?.warning)
330
+ log.warn(result.warning);
248
331
  }
249
- return repoUrl;
332
+ return stepData(state, 'github-repo', 'url');
250
333
  }
251
334
  async function main() {
252
335
  const { partial, flags } = parseCli(process.argv.slice(2));
@@ -255,7 +338,7 @@ async function main() {
255
338
  const interactive = process.stdin.isTTY && !flags.yes;
256
339
  let collected = partial;
257
340
  if (interactive) {
258
- collected = await fillMissing(partial, { advanced: flags.advanced });
341
+ collected = await fillMissing(partial, { advanced: flags.advanced, dryRun: flags.dryRun });
259
342
  }
260
343
  else if (!flags.dryRun) {
261
344
  const missing = missingRequired(partial);
@@ -305,7 +388,7 @@ async function main() {
305
388
  outro('Dry run complete. No account was touched.');
306
389
  return;
307
390
  }
308
- const repoUrl = await runBootstrap(answers, state);
391
+ const repoUrl = await runBootstrap(answers, state, { preValidated: interactive });
309
392
  outro(renderNextSteps(answers, repoUrl, `rg.${answers.region}.scw.cloud/${answers.projectName}-${answers.environments[0].slug}`));
310
393
  }
311
394
  main().catch((error) => {