@gambi97/keel-cli 0.3.2 → 0.3.4

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.
@@ -209,8 +209,11 @@ export async function configureRepo(ctx, answers, infisicalProjectId) {
209
209
  INFISICAL_HOST: answers.infisical.host,
210
210
  };
211
211
  await setVariables(ctx, variables);
212
- await configureEnvironments(ctx, answers);
213
- return protectMainBranch(ctx, answers);
212
+ const warnings = [
213
+ await configureEnvironments(ctx, answers),
214
+ await protectMainBranch(ctx, answers),
215
+ ].filter((w) => w !== undefined);
216
+ return warnings.length > 0 ? warnings.join('\n\n') : undefined;
214
217
  }
215
218
  async function setSecrets(ctx, secrets) {
216
219
  await sodium.ready;
@@ -251,16 +254,45 @@ async function setVariables(ctx, variables) {
251
254
  }
252
255
  }
253
256
  }
254
- /** Each environment gets a GitHub deployment environment; gated ones (prod) require approval. */
255
- async function configureEnvironments(ctx, answers) {
257
+ /**
258
+ * Each environment gets a GitHub deployment environment; gated ones (prod)
259
+ * require a reviewer approval. Required reviewers need GitHub Enterprise Cloud
260
+ * on private repositories — GitHub answers 422 there. Rather than strand the
261
+ * bootstrap at its last step, fall back to an ungated environment and warn:
262
+ * the environment still exists (deployments target it) but production will
263
+ * auto-deploy on merge. Like the branch-protection miss, this degrades to a
264
+ * warning instead of killing the run.
265
+ */
266
+ export async function configureEnvironments(ctx, answers) {
267
+ const ungated = [];
256
268
  for (const env of answers.environments) {
257
- await ctx.octokit.repos.createOrUpdateEnvironment({
258
- owner: ctx.owner,
259
- repo: ctx.repo,
260
- environment_name: env.githubEnvironment,
261
- ...(env.gated ? { reviewers: [{ type: 'User', id: ctx.ownerId }] } : {}),
262
- });
269
+ try {
270
+ await ctx.octokit.repos.createOrUpdateEnvironment({
271
+ owner: ctx.owner,
272
+ repo: ctx.repo,
273
+ environment_name: env.githubEnvironment,
274
+ ...(env.gated ? { reviewers: [{ type: 'User', id: ctx.ownerId }] } : {}),
275
+ });
276
+ }
277
+ catch (error) {
278
+ // Only the reviewer rule can trip the plan limit; re-throw anything else
279
+ // (and any failure on a non-gated environment, which asked for no rule).
280
+ if (!env.gated || error.status !== 422)
281
+ throw error;
282
+ await ctx.octokit.repos.createOrUpdateEnvironment({
283
+ owner: ctx.owner,
284
+ repo: ctx.repo,
285
+ environment_name: env.githubEnvironment,
286
+ });
287
+ ungated.push(env.githubEnvironment);
288
+ }
263
289
  }
290
+ if (ungated.length === 0)
291
+ return undefined;
292
+ return (`Created the ${ungated.join(', ')} environment(s) without the manual-approval gate: ` +
293
+ 'required reviewers need GitHub Enterprise Cloud on private repositories, so production ' +
294
+ 'will auto-deploy on merge to main. Make the repository public (reviewers are free there) ' +
295
+ 'or upgrade the plan, then re-run to add the gate.');
264
296
  }
265
297
  /**
266
298
  * Require a green plan on PRs and forbid force-pushes/deletion of main.
package/dist/config.js CHANGED
@@ -46,6 +46,17 @@ export const ENV_PRESETS = {
46
46
  'dev+staging+prod': ['dev', 'staging', 'prod'],
47
47
  };
48
48
  export const DEFAULT_ENV_PRESET = 'staging+prod';
49
+ /**
50
+ * Lock a resume's configuration to what the repo was generated with. Scaling
51
+ * is intentionally not carried: it only shapes the tfvars, which are already
52
+ * generated on any run that has a manifest, so post-generate steps never read it.
53
+ */
54
+ export function hydrateConfigFromManifest(partial, manifest) {
55
+ partial.region = manifest.region;
56
+ partial.environments = manifest.environments;
57
+ partial.objectStorage = manifest.options.objectStorage;
58
+ partial.basicAuth = manifest.options.basicAuth;
59
+ }
49
60
  export class ConfigError extends Error {
50
61
  }
51
62
  const PROJECT_NAME_RE = /^[a-z](?:[a-z0-9-]{0,48}[a-z0-9])?$/;
package/dist/generate.js CHANGED
@@ -29,6 +29,28 @@ function renderManifest(answers) {
29
29
  };
30
30
  return `${JSON.stringify(manifest, null, 2)}\n`;
31
31
  }
32
+ /**
33
+ * Read the committed manifest from a target directory, or undefined when there
34
+ * is none (a fresh run). Used by the resume paths to lock configuration to
35
+ * what the repo was generated with instead of asking for it again.
36
+ */
37
+ export function readManifest(targetDir) {
38
+ try {
39
+ const raw = readFileSync(join(targetDir, MANIFEST_FILE), 'utf8');
40
+ const m = JSON.parse(raw);
41
+ if (typeof m.projectName === 'string' &&
42
+ typeof m.region === 'string' &&
43
+ Array.isArray(m.environments) &&
44
+ typeof m.options?.objectStorage === 'boolean' &&
45
+ typeof m.options?.basicAuth === 'boolean') {
46
+ return m;
47
+ }
48
+ }
49
+ catch {
50
+ // No manifest or unreadable/invalid: treat as a fresh run.
51
+ }
52
+ return undefined;
53
+ }
32
54
  /** Files whose real name would confuse npm packaging are stored renamed. */
33
55
  const RENAMES = {
34
56
  _gitignore: '.gitignore',
package/dist/index.js CHANGED
@@ -2,9 +2,9 @@
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import { parseArgs } from 'node:util';
5
- import { ConfigError, finalizeAnswers, fromEnv, mergeAnswers, missingRequired, parseEnvironments, } from './config.js';
5
+ import { ConfigError, finalizeAnswers, fromEnv, hydrateConfigFromManifest, mergeAnswers, missingRequired, parseEnvironments, } from './config.js';
6
6
  import { CI_SECRET_NAMES, CI_VARIABLE_NAMES, resourceName } from './contracts.js';
7
- import { generateProject, GenerateError } from './generate.js';
7
+ import { generateProject, GenerateError, readManifest } from './generate.js';
8
8
  import { toolVersion } from './meta.js';
9
9
  import { confirmSummary, fillMissing } from './prompts.js';
10
10
  import { isDone, loadState, markDone, STATE_FILE, stepData, } from './state.js';
@@ -333,6 +333,16 @@ async function main() {
333
333
  collected = await fillMissing(partial, { advanced: flags.advanced, dryRun: flags.dryRun });
334
334
  }
335
335
  else if (!flags.dryRun) {
336
+ // Resuming non-interactively: lock configuration to the committed manifest
337
+ // so flags/defaults can't silently diverge from the generated repo (e.g. a
338
+ // resume without --object-storage must not flip it off). Same source of
339
+ // truth as the interactive path.
340
+ const resumeDir = partial.targetDir?.trim() || partial.projectName;
341
+ const manifest = resumeDir ? readManifest(resumeDir) : undefined;
342
+ if (manifest && manifest.projectName === partial.projectName) {
343
+ hydrateConfigFromManifest(partial, manifest);
344
+ log.info(`Resuming "${manifest.projectName}" — configuration locked to its .keel manifest.`);
345
+ }
336
346
  const missing = missingRequired(partial);
337
347
  if (missing.length > 0) {
338
348
  cancel(`Non-interactive run is missing required values:\n - ${missing.join('\n - ')}`);
package/dist/prompts.js CHANGED
@@ -2,7 +2,8 @@ import * as p from '@clack/prompts';
2
2
  import { authenticate, createContext, GitHubError, inspectRepo, assertRepoUsable, listOwnedRepos, } from './bootstrap/github.js';
3
3
  import { InfisicalError, validateInfisical } from './bootstrap/infisical.js';
4
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';
5
+ import { ConfigError, DEFAULT_ENV_PRESET, DEFAULT_INFISICAL_HOST, DEFAULT_REGION, ENV_PRESETS, envDefaultScale, hydrateConfigFromManifest, REGIONS, validateProjectName, validateScale, validateUrl, } from './config.js';
6
+ import { readManifest } from './generate.js';
6
7
  import { isDone, loadState } from './state.js';
7
8
  import { log } from './ui.js';
8
9
  function bail() {
@@ -45,6 +46,17 @@ const text = (message, placeholder) => ask(p.text({
45
46
  export async function fillMissing(partial, options) {
46
47
  const out = structuredClone(partial);
47
48
  await askProjectName(out);
49
+ // Resuming an existing project: its configuration is frozen (the repo is
50
+ // already generated with it), so lock it from the committed manifest instead
51
+ // of asking again. Credentials are never persisted, so the provider blocks
52
+ // still run — set SCW_*/INFISICAL_*/GITHUB_TOKEN in the env to skip typing.
53
+ const resumeDir = out.targetDir?.trim() || out.projectName;
54
+ const manifest = readManifest(resumeDir);
55
+ const resuming = manifest !== undefined && manifest.projectName === out.projectName;
56
+ if (manifest && resuming) {
57
+ hydrateConfigFromManifest(out, manifest);
58
+ log.info(`Resuming "${out.projectName}" — region, environments and options are locked to its .keel manifest.`);
59
+ }
48
60
  // Each block opens with a "┌ <Provider>" section corner and closes with a
49
61
  // "└ <Provider> connected — …" line, so every question flows inside its own
50
62
  // section and the next one opens only when the previous is verified.
@@ -55,7 +67,10 @@ export async function fillMissing(partial, options) {
55
67
  }
56
68
  // Configuration comes last: with the accounts verified, these are the only
57
69
  // real choices left. In a dry run it is all that is asked after the name.
58
- await askConfiguration(out, options);
70
+ // On resume it is skipped entirely — the manifest already fixed it.
71
+ if (!resuming) {
72
+ await askConfiguration(out, options);
73
+ }
59
74
  return out;
60
75
  }
61
76
  async function askProjectName(out) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gambi97/keel-cli",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
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",
@@ -197,3 +197,9 @@ Avoid local applies: they race against CI on the same state.
197
197
  - **Apply fails creating IAM resources**: the CI Scaleway key needs IAM
198
198
  permissions (IAMManager) to create the app's database (and Object Storage)
199
199
  credential.
200
+ - **Apply fails with "expires_at ... organization security settings require an
201
+ expiration date for API keys"**: the app's database and Object Storage
202
+ credentials are long-lived service credentials — the container reads them at
203
+ runtime, so they intentionally never expire. If your Scaleway organization
204
+ enforces API-key expiration, exempt these service applications or turn the
205
+ policy off (Console → Organization → Security), then re-run the apply.