@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/prompts.js CHANGED
@@ -1,5 +1,10 @@
1
1
  import * as p from '@clack/prompts';
2
- import { ConfigError, DEFAULT_ENV_PRESET, DEFAULT_INFISICAL_HOST, DEFAULT_REGION, ENV_PRESETS, envDefaultScale, REGIONS, validateProjectName, validateScale, } from './config.js';
2
+ import { createContext, GitHubError, inspectRepo, assertRepoUsable } from './bootstrap/github.js';
3
+ import { InfisicalError, validateInfisical } from './bootstrap/infisical.js';
4
+ import { ScalewayError, validateScalewayCredentials } from './bootstrap/scaleway.js';
5
+ import { ConfigError, DEFAULT_ENV_PRESET, DEFAULT_INFISICAL_HOST, DEFAULT_REGION, ENV_PRESETS, envDefaultScale, REGIONS, validateProjectName, validateScale, validateUrl, } from './config.js';
6
+ import { isDone, loadState } from './state.js';
7
+ import { log } from './ui.js';
3
8
  function bail() {
4
9
  p.cancel('Cancelled. Nothing was created.');
5
10
  process.exit(1);
@@ -21,80 +26,58 @@ function validate(fn) {
21
26
  }
22
27
  };
23
28
  }
24
- /** Interactively fill everything still missing from flags/env/config file. */
29
+ const secret = (message) => ask(p.password({ message }));
30
+ const text = (message, placeholder) => ask(p.text({
31
+ message,
32
+ ...(placeholder ? { placeholder } : {}),
33
+ validate: (v) => (v.trim() ? undefined : 'Required.'),
34
+ }));
35
+ /**
36
+ * Interactively fill everything still missing from flags/env/config file.
37
+ *
38
+ * The project name comes first (it seeds the repo/bucket/project defaults),
39
+ * then the three provider blocks (GitHub, Infisical, Scaleway) — each closed
40
+ * by a read-only validation call that reports bad input immediately and
41
+ * re-asks only the offending value — and finally the configuration you want
42
+ * (region, environments, storage, scaling). Nothing is created here; creation
43
+ * starts only after the final confirmation.
44
+ */
25
45
  export async function fillMissing(partial, options) {
26
46
  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
- }));
47
+ await askProjectName(out);
48
+ // Each block opens with a "┌ <Provider>" section corner and closes with a
49
+ // "└ <Provider> connected …" line, so every question flows inside its own
50
+ // section and the next one opens only when the previous is verified.
51
+ if (!options.dryRun) {
52
+ await askGitHub(out);
53
+ await askInfisical(out);
54
+ await askScaleway(out);
40
55
  }
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.'),
56
+ // Configuration comes last: with the accounts verified, these are the only
57
+ // real choices left. In a dry run it is all that is asked after the name.
58
+ await askConfiguration(out, options);
59
+ return out;
60
+ }
61
+ async function askProjectName(out) {
62
+ if (out.projectName)
63
+ return;
64
+ out.projectName = await ask(p.text({
65
+ message: 'Project name (dns-safe, used for repo, bucket and resources)',
66
+ placeholder: 'my-app',
67
+ validate: validate(validateProjectName),
46
68
  }));
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
- }
69
+ }
70
+ /** Everything that shapes the infrastructure: region, environments, options. */
71
+ async function askConfiguration(out, options) {
72
+ p.intro('Configuration region, environments and options');
73
+ await askRegion(out);
87
74
  if (!out.environments || out.environments.length === 0) {
88
75
  const preset = await ask(p.select({
89
76
  message: 'Which environments do you want?',
90
77
  initialValue: DEFAULT_ENV_PRESET,
91
78
  options: [
92
79
  { value: 'prod', label: 'Production only', hint: 'single environment' },
93
- {
94
- value: 'staging+prod',
95
- label: 'Staging + Production',
96
- hint: 'recommended',
97
- },
80
+ { value: 'staging+prod', label: 'Staging + Production', hint: 'recommended' },
98
81
  { value: 'dev+staging+prod', label: 'Dev + Staging + Production' },
99
82
  ],
100
83
  }));
@@ -114,6 +97,17 @@ export async function fillMissing(partial, options) {
114
97
  initialValue: true,
115
98
  }));
116
99
  }
100
+ // Production is the one scaling knob worth surfacing at setup: everything
101
+ // scales to zero when idle, and this is the ceiling. Staging/dev stay 0-1;
102
+ // full per-environment control lives behind --advanced.
103
+ if (!options.advanced && slugs.includes('prod') && out.scaling.prod?.maxScale === undefined) {
104
+ const max = await ask(p.text({
105
+ message: 'Maximum production instances (idle scales to zero; raise later in prod.tfvars)',
106
+ initialValue: String(envDefaultScale('prod').maxScale),
107
+ validate: validate((v) => validateScale(v, 'prod max scale')),
108
+ }));
109
+ out.scaling.prod = { ...(out.scaling.prod ?? {}), maxScale: Number(max) };
110
+ }
117
111
  if (options.advanced) {
118
112
  const scale = async (message, initial) => Number(await ask(p.text({
119
113
  message,
@@ -128,7 +122,193 @@ export async function fillMissing(partial, options) {
128
122
  out.scaling[slug] = current;
129
123
  }
130
124
  }
131
- return out;
125
+ }
126
+ /** GitHub block: repo + token, then verify token, scopes and repo state. */
127
+ async function askGitHub(out) {
128
+ // When resuming a run that already pushed, the repo legitimately has
129
+ // commits: a non-empty repo must not block the resume.
130
+ const targetDir = out.targetDir?.trim() || out.projectName;
131
+ const alreadyPushed = isDone(loadState(targetDir, out.projectName), 'github-push');
132
+ p.intro('GitHub — repository, visibility and token');
133
+ for (;;) {
134
+ if (!out.github.repoName) {
135
+ out.github.repoName = await ask(p.text({
136
+ message: 'GitHub repository name (a new empty repository works best)',
137
+ initialValue: out.projectName,
138
+ validate: validate(validateProjectName),
139
+ }));
140
+ }
141
+ if (out.github.repoPrivate === undefined) {
142
+ out.github.repoPrivate = await ask(p.select({
143
+ message: 'Repository visibility',
144
+ initialValue: false,
145
+ options: [
146
+ { value: false, label: 'Public', hint: 'the infra contains no secrets' },
147
+ { value: true, label: 'Private' },
148
+ ],
149
+ }));
150
+ }
151
+ if (!out.github.token) {
152
+ out.github.token = await secret('GitHub token (scopes: repo, workflow)');
153
+ }
154
+ try {
155
+ const ctx = await createContext({
156
+ token: out.github.token,
157
+ repoName: out.github.repoName,
158
+ repoPrivate: out.github.repoPrivate ?? false,
159
+ });
160
+ const { state } = await inspectRepo(ctx);
161
+ if (!(state === 'non-empty' && alreadyPushed)) {
162
+ assertRepoUsable(ctx, state);
163
+ }
164
+ const repo = `${ctx.owner}/${out.github.repoName}`;
165
+ p.outro(state === 'not-found'
166
+ ? `GitHub connected — ${repo} will be created after confirmation.`
167
+ : state === 'non-empty'
168
+ ? `GitHub connected — resuming, ${repo} was already pushed.`
169
+ : `GitHub connected — existing empty repository ${repo} will be reused.`);
170
+ return;
171
+ }
172
+ catch (error) {
173
+ if (!(error instanceof GitHubError))
174
+ throw error;
175
+ log.error(error.message);
176
+ if (error.field === 'repo') {
177
+ out.github.repoName = undefined;
178
+ }
179
+ else {
180
+ out.github.token = undefined;
181
+ }
182
+ }
183
+ }
184
+ }
185
+ /** Infisical block: host + machine identity, then verify login and project. */
186
+ async function askInfisical(out) {
187
+ p.intro('Infisical — secret-manager project and machine identity');
188
+ if (!out.infisical.host) {
189
+ const choice = await ask(p.select({
190
+ message: 'Infisical host',
191
+ initialValue: 'us',
192
+ options: [
193
+ { value: 'us', label: 'US — app.infisical.com', hint: 'default' },
194
+ { value: 'eu', label: 'EU — eu.infisical.com' },
195
+ { value: 'other', label: 'Other (self-hosted)' },
196
+ ],
197
+ }));
198
+ if (choice === 'us')
199
+ out.infisical.host = DEFAULT_INFISICAL_HOST;
200
+ else if (choice === 'eu')
201
+ out.infisical.host = 'https://eu.infisical.com';
202
+ else {
203
+ out.infisical.host = await ask(p.text({
204
+ message: 'Infisical host URL',
205
+ placeholder: 'https://infisical.example.com',
206
+ validate: validate((v) => validateUrl(v, 'Infisical host')),
207
+ }));
208
+ }
209
+ }
210
+ for (;;) {
211
+ if (!out.infisical.clientId) {
212
+ out.infisical.clientId = await text('Infisical machine identity client ID');
213
+ }
214
+ if (!out.infisical.clientSecret) {
215
+ out.infisical.clientSecret = await secret('Infisical machine identity client secret');
216
+ }
217
+ if (!out.infisical.projectId && !out.infisical.projectName) {
218
+ const id = await ask(p.text({
219
+ message: `Infisical project ID to reuse (leave empty to create "${out.projectName}")`,
220
+ placeholder: 'empty: create a new project',
221
+ defaultValue: '',
222
+ }));
223
+ if (id.trim())
224
+ out.infisical.projectId = id.trim();
225
+ else
226
+ out.infisical.projectName = out.projectName;
227
+ }
228
+ try {
229
+ const { projectName } = await validateInfisical({
230
+ host: out.infisical.host,
231
+ clientId: out.infisical.clientId,
232
+ clientSecret: out.infisical.clientSecret,
233
+ projectId: out.infisical.projectId,
234
+ });
235
+ if (projectName)
236
+ out.infisical.projectName = projectName;
237
+ p.outro(out.infisical.projectId
238
+ ? `Infisical connected — existing project "${projectName}" (${out.infisical.projectId}) will be reused.`
239
+ : `Infisical connected — project "${out.infisical.projectName}" will be created after confirmation.`);
240
+ return;
241
+ }
242
+ catch (error) {
243
+ if (!(error instanceof InfisicalError))
244
+ throw error;
245
+ log.error(error.message);
246
+ if (error.field === 'project') {
247
+ out.infisical.projectId = undefined;
248
+ out.infisical.projectName = undefined;
249
+ }
250
+ else {
251
+ out.infisical.clientId = undefined;
252
+ out.infisical.clientSecret = undefined;
253
+ }
254
+ }
255
+ }
256
+ }
257
+ async function askRegion(out) {
258
+ if (out.region)
259
+ return;
260
+ out.region = await ask(p.select({
261
+ message: 'Scaleway region',
262
+ initialValue: DEFAULT_REGION,
263
+ options: REGIONS.map((r) => ({ value: r, label: r })),
264
+ }));
265
+ }
266
+ /** Scaleway block: keys + IDs, then verify with a read-only API call. */
267
+ async function askScaleway(out) {
268
+ p.intro('Scaleway — account keys and project');
269
+ for (;;) {
270
+ if (!out.scaleway.accessKey)
271
+ out.scaleway.accessKey = await text('Scaleway access key');
272
+ if (!out.scaleway.secretKey)
273
+ out.scaleway.secretKey = await secret('Scaleway secret key');
274
+ if (!out.scaleway.projectId)
275
+ out.scaleway.projectId = await text('Scaleway project ID');
276
+ if (!out.scaleway.organizationId) {
277
+ out.scaleway.organizationId = await text('Scaleway organization ID');
278
+ }
279
+ try {
280
+ await validateScalewayCredentials({
281
+ secretKey: out.scaleway.secretKey,
282
+ projectId: out.scaleway.projectId,
283
+ organizationId: out.scaleway.organizationId,
284
+ });
285
+ p.outro('Scaleway connected — credentials valid and project reachable.');
286
+ return;
287
+ }
288
+ catch (error) {
289
+ if (!(error instanceof ScalewayError))
290
+ throw error;
291
+ log.error(error.message);
292
+ switch (error.code) {
293
+ case 'auth':
294
+ out.scaleway.accessKey = undefined;
295
+ out.scaleway.secretKey = undefined;
296
+ break;
297
+ case 'project':
298
+ out.scaleway.projectId = undefined;
299
+ break;
300
+ case 'organization':
301
+ out.scaleway.organizationId = undefined;
302
+ break;
303
+ default: {
304
+ // Transient API error: nothing to correct, offer a plain retry.
305
+ const retry = await ask(p.confirm({ message: 'Scaleway API error. Retry?' }));
306
+ if (!retry)
307
+ bail();
308
+ }
309
+ }
310
+ }
311
+ }
132
312
  }
133
313
  export async function confirmSummary(summary) {
134
314
  p.note(summary, 'About to create');
package/dist/ui.js CHANGED
@@ -48,7 +48,9 @@ export function renderSummary(answers, dryRun) {
48
48
  '',
49
49
  'Infisical',
50
50
  ` host ${answers.infisical.host}`,
51
- ` project ${answers.infisical.projectName} (created or reused)`,
51
+ ` project ${answers.infisical.projectId
52
+ ? `${answers.infisical.projectName} (existing, ${answers.infisical.projectId})`
53
+ : `${answers.infisical.projectName} (will be created)`}`,
52
54
  ` environments ${envSlugs} (+ placeholder secrets)`,
53
55
  '',
54
56
  `Object Storage ${answers.objectStorage ? 'enabled (per-environment bucket + S3_* secrets)' : 'disabled'}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gambi97/keel-cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "One command from zero to a production-shaped serverless infrastructure on Scaleway (Terraform + GitHub Actions + Infisical). Only Node required.",
5
5
  "keywords": [
6
6
  "keel",
@@ -29,13 +29,31 @@ env:
29
29
  TF_IN_AUTOMATION: "true"
30
30
 
31
31
  jobs:
32
+ discover:
33
+ name: discover environments
34
+ runs-on: ubuntu-latest
35
+ outputs:
36
+ environments: ${{ steps.list.outputs.environments }}
37
+ steps:
38
+ - uses: actions/checkout@v4
39
+
40
+ # Every <env>.tfvars at the repo root is an environment: adding one to
41
+ # the drift matrix is just adding its tfvars file, no workflow edit.
42
+ - name: List environments from tfvars files
43
+ id: list
44
+ run: |
45
+ environments=$(ls *.tfvars | sed 's/\.tfvars$//' \
46
+ | jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')
47
+ echo "environments=$environments" >> "$GITHUB_OUTPUT"
48
+
32
49
  drift:
33
50
  name: drift (${{ matrix.environment }})
51
+ needs: discover
34
52
  runs-on: ubuntu-latest
35
53
  strategy:
36
54
  fail-fast: false
37
55
  matrix:
38
- environment: __ENV_MATRIX__
56
+ environment: ${{ fromJSON(needs.discover.outputs.environments) }}
39
57
  steps:
40
58
  - uses: actions/checkout@v4
41
59
 
@@ -23,13 +23,31 @@ env:
23
23
  TF_IN_AUTOMATION: "true"
24
24
 
25
25
  jobs:
26
+ discover:
27
+ name: discover environments
28
+ runs-on: ubuntu-latest
29
+ outputs:
30
+ environments: ${{ steps.list.outputs.environments }}
31
+ steps:
32
+ - uses: actions/checkout@v4
33
+
34
+ # Every <env>.tfvars at the repo root is an environment: adding one to
35
+ # the plan matrix is just adding its tfvars file, no workflow edit.
36
+ - name: List environments from tfvars files
37
+ id: list
38
+ run: |
39
+ environments=$(ls *.tfvars | sed 's/\.tfvars$//' \
40
+ | jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')
41
+ echo "environments=$environments" >> "$GITHUB_OUTPUT"
42
+
26
43
  plan:
27
44
  name: plan (${{ matrix.environment }})
45
+ needs: discover
28
46
  runs-on: ubuntu-latest
29
47
  strategy:
30
48
  fail-fast: false
31
49
  matrix:
32
- environment: __ENV_MATRIX__
50
+ environment: ${{ fromJSON(needs.discover.outputs.environments) }}
33
51
  steps:
34
52
  - uses: actions/checkout@v4
35
53
 
@@ -114,10 +114,13 @@ environment enables it is the `enable_basic_auth` flag in its `<env>.tfvars`.
114
114
  secrets (repo Settings > Secrets and variables > Actions).
115
115
  - **Add a custom domain**: add a `scaleway_container_domain` resource in
116
116
  `modules/app_stack` pointing at the container, plus your DNS record.
117
- - **Add an environment**: add a `<env>.tfvars`, an Infisical environment with
118
- the same slug, an entry in the plan/drift matrix and a job in
117
+ - **Add an environment**: add a `<env>.tfvars` at the repo root (the plan and
118
+ drift workflows discover environments from the tfvars files automatically),
119
+ create an Infisical environment with the same slug, and add a job in
119
120
  `.github/workflows/terraform-apply.yml` (mirror an existing one and set its
120
- `needs:` to chain after the previous environment).
121
+ `needs:` to chain after the previous environment). Note: the new `plan
122
+ (<env>)` check becomes required on `main` only after you add it in the
123
+ branch-protection settings.
121
124
  - **Pin provider versions**: after any local `terraform init`, commit the
122
125
  generated `.terraform.lock.hcl` so CI resolves the exact same provider
123
126
  builds on every run.
package/templates/main.tf CHANGED
@@ -3,6 +3,16 @@ data "infisical_secrets" "app" {
3
3
  env_slug = var.environment
4
4
  workspace_id = var.infisical_project_id
5
5
  folder_path = "/"
6
+
7
+ lifecycle {
8
+ # Guards against applying one environment's tfvars into another
9
+ # environment's state (e.g. prod.tfvars while the staging workspace is
10
+ # selected), which would create prod-named resources in staging state.
11
+ precondition {
12
+ condition = terraform.workspace == var.environment
13
+ error_message = "The selected Terraform workspace must match var.environment: run `terraform workspace select <env>` before plan/apply with <env>.tfvars."
14
+ }
15
+ }
6
16
  }
7
17
 
8
18
  module "app_stack" {