@gambi97/keel-cli 0.3.5 → 0.3.6

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.
@@ -24,18 +24,21 @@ export class GitHubError extends Error {
24
24
  export async function authenticate(token) {
25
25
  // Silence Octokit's own request logging: expected non-2xx responses (a 404
26
26
  // for a repo that does not exist yet, a 401 for a bad token) are handled
27
- // here and would otherwise scribble over the prompt spinner.
27
+ // here and would otherwise scribble over the prompt spinner. The same sink
28
+ // is passed at the request layer too: @octokit/request reads
29
+ // `request.log` (falling back to console) for deprecation notices carried
30
+ // in response headers, bypassing the instance logger.
31
+ const silentLog = { debug: () => { }, info: () => { }, warn: () => { }, error: () => { } };
28
32
  const octokit = new Octokit({
29
33
  auth: token,
30
- log: { debug: () => { }, info: () => { }, warn: () => { }, error: () => { } },
34
+ log: silentLog,
35
+ request: { log: silentLog },
31
36
  });
32
37
  let login;
33
- let ownerId;
34
38
  let scopes;
35
39
  try {
36
40
  const { data, headers } = await octokit.users.getAuthenticated();
37
41
  login = data.login;
38
- ownerId = data.id;
39
42
  scopes = headers['x-oauth-scopes'];
40
43
  }
41
44
  catch {
@@ -53,7 +56,7 @@ export async function authenticate(token) {
53
56
  }
54
57
  }
55
58
  }
56
- return { octokit, owner: login, ownerId };
59
+ return { octokit, owner: login };
57
60
  }
58
61
  export async function createContext(github) {
59
62
  const identity = await authenticate(github.token);
@@ -209,11 +212,8 @@ export async function configureRepo(ctx, answers, infisicalProjectId) {
209
212
  INFISICAL_HOST: answers.infisical.host,
210
213
  };
211
214
  await setVariables(ctx, variables);
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;
215
+ await configureEnvironments(ctx, answers);
216
+ return protectMainBranch(ctx, answers);
217
217
  }
218
218
  async function setSecrets(ctx, secrets) {
219
219
  await sodium.ready;
@@ -255,44 +255,20 @@ async function setVariables(ctx, variables) {
255
255
  }
256
256
  }
257
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 repositoriesGitHub 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.
258
+ * Each environment gets a GitHub deployment environment, used by the apply
259
+ * workflow for deployment tracking and history. No reviewer gate is requested:
260
+ * production is promoted by a version tag the tag IS the gate, free on every
261
+ * plan, while required reviewers would need GitHub Enterprise Cloud on private
262
+ * repositories and would only duplicate what the tag already enforces.
265
263
  */
266
264
  export async function configureEnvironments(ctx, answers) {
267
- const ungated = [];
268
265
  for (const env of answers.environments) {
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
- }
266
+ await ctx.octokit.repos.createOrUpdateEnvironment({
267
+ owner: ctx.owner,
268
+ repo: ctx.repo,
269
+ environment_name: env.githubEnvironment,
270
+ });
289
271
  }
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.');
296
272
  }
297
273
  /**
298
274
  * Require a green plan on PRs and forbid force-pushes/deletion of main.
@@ -33,6 +33,26 @@ export async function validateScalewayCredentials(scaleway) {
33
33
  `not ${organizationId}. Check --scw-organization-id.`, 'organization');
34
34
  }
35
35
  }
36
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
37
+ /**
38
+ * Bucket-metadata calls made right after CreateBucket can race its propagation
39
+ * and answer NoSuchBucket for a bucket that provably exists (we created or
40
+ * Head-checked it moments before). Retry exactly that error with a short
41
+ * backoff; anything else is a real failure and is thrown immediately.
42
+ */
43
+ export async function retryWhileBucketPropagates(fn, attempts = 5, baseDelayMs = 1000) {
44
+ for (let attempt = 1;; attempt++) {
45
+ try {
46
+ return await fn();
47
+ }
48
+ catch (error) {
49
+ if (error.name !== 'NoSuchBucket' || attempt >= attempts) {
50
+ throw error;
51
+ }
52
+ await sleep(attempt * baseDelayMs);
53
+ }
54
+ }
55
+ }
36
56
  function s3Client(answers) {
37
57
  return new S3Client({
38
58
  region: answers.region,
@@ -107,13 +127,14 @@ async function lockDownStateBucket(client, bucket, answers) {
107
127
  ],
108
128
  };
109
129
  try {
110
- await client.send(new PutBucketPolicyCommand({ Bucket: bucket, Policy: JSON.stringify(policy) }));
130
+ await retryWhileBucketPropagates(() => client.send(new PutBucketPolicyCommand({ Bucket: bucket, Policy: JSON.stringify(policy) })));
111
131
  return undefined;
112
132
  }
113
133
  catch (error) {
114
134
  return (`Could not apply a policy to bucket "${bucket}" ` +
115
135
  `(${error instanceof Error ? error.message : String(error)}); ` +
116
- 'the state bucket was NOT restricted to your identity.');
136
+ 'the state bucket was NOT restricted to your identity. ' +
137
+ 'Re-run keel to retry this step.');
117
138
  }
118
139
  }
119
140
  /**
@@ -144,10 +165,10 @@ export async function ensureStateBucket(answers) {
144
165
  try {
145
166
  await client.send(new CreateBucketCommand({ Bucket: bucket }));
146
167
  // Versioning protects the state history against accidental overwrites.
147
- await client.send(new PutBucketVersioningCommand({
168
+ await retryWhileBucketPropagates(() => client.send(new PutBucketVersioningCommand({
148
169
  Bucket: bucket,
149
170
  VersioningConfiguration: { Status: 'Enabled' },
150
- }));
171
+ })));
151
172
  created = true;
152
173
  }
153
174
  catch (createError) {
package/dist/config.js CHANGED
@@ -11,7 +11,6 @@ const ENV_DEFAULTS = {
11
11
  displayName: 'Development',
12
12
  githubEnvironment: 'dev',
13
13
  production: false,
14
- gated: false,
15
14
  minScale: 0,
16
15
  maxScale: 1,
17
16
  },
@@ -19,7 +18,6 @@ const ENV_DEFAULTS = {
19
18
  displayName: 'Staging',
20
19
  githubEnvironment: 'staging',
21
20
  production: false,
22
- gated: false,
23
21
  minScale: 0,
24
22
  maxScale: 1,
25
23
  },
@@ -27,7 +25,6 @@ const ENV_DEFAULTS = {
27
25
  displayName: 'Production',
28
26
  githubEnvironment: 'production',
29
27
  production: true,
30
- gated: true,
31
28
  minScale: 0,
32
29
  // Start small: one instance is enough to go live and costs the least;
33
30
  // raising the ceiling later is a one-line prod.tfvars change.
@@ -50,12 +47,25 @@ export const DEFAULT_ENV_PRESET = 'staging+prod';
50
47
  * Lock a resume's configuration to what the repo was generated with. Scaling
51
48
  * is intentionally not carried: it only shapes the tfvars, which are already
52
49
  * generated on any run that has a manifest, so post-generate steps never read it.
50
+ *
51
+ * The GitHub repo identity is locked too: on resume the target repository is
52
+ * already decided (keel created and pushed it), so the picker must not run and
53
+ * must never frame the project's own — now non-empty — repo as "create a new
54
+ * one". Older manifests without a github block fall back to the project name,
55
+ * which is the repo default and the only repository the project could own. An
56
+ * explicit --repo-name still wins (the established flag-precedence rule).
53
57
  */
54
58
  export function hydrateConfigFromManifest(partial, manifest) {
55
59
  partial.region = manifest.region;
56
60
  partial.environments = manifest.environments;
57
61
  partial.objectStorage = manifest.options.objectStorage;
58
62
  partial.basicAuth = manifest.options.basicAuth;
63
+ if (!partial.github.repoName) {
64
+ partial.github.repoName = manifest.github?.repoName ?? manifest.projectName;
65
+ }
66
+ if (partial.github.repoPrivate === undefined && manifest.github?.repoPrivate !== undefined) {
67
+ partial.github.repoPrivate = manifest.github.repoPrivate;
68
+ }
59
69
  }
60
70
  export class ConfigError extends Error {
61
71
  }
@@ -210,8 +220,7 @@ export function resolveEnvironments(slugs, basicAuth, overrides) {
210
220
  displayName: def.displayName,
211
221
  githubEnvironment: def.githubEnvironment,
212
222
  production: def.production,
213
- gated: def.gated,
214
- // Basic Auth is a non-production safety net; production is never gated by it.
223
+ // Basic Auth is a non-production safety net; production never has it.
215
224
  basicAuth: def.production ? false : basicAuth,
216
225
  minScale,
217
226
  maxScale,
package/dist/generate.js CHANGED
@@ -26,6 +26,12 @@ function renderManifest(answers) {
26
26
  objectStorage: answers.objectStorage,
27
27
  basicAuth: answers.basicAuth,
28
28
  },
29
+ // Recorded so a resume locks the repository instead of re-running the
30
+ // picker; see hydrateConfigFromManifest.
31
+ github: {
32
+ repoName: answers.github.repoName,
33
+ repoPrivate: answers.github.repoPrivate,
34
+ },
29
35
  };
30
36
  return `${JSON.stringify(manifest, null, 2)}\n`;
31
37
  }
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import { CI_SECRET_NAMES, CI_VARIABLE_NAMES, resourceName } from './contracts.js
7
7
  import { generateProject, GenerateError, readManifest } from './generate.js';
8
8
  import { toolVersion } from './meta.js';
9
9
  import { confirmSummary, fillMissing } from './prompts.js';
10
- import { isDone, loadState, markDone, STATE_FILE, stepData, } from './state.js';
10
+ import { isDone, loadState, markDone, STATE_FILE, stepData, stepWarning, } from './state.js';
11
11
  import { cancel, intro, log, outro, renderNextSteps, renderSummary, withSpinner } from './ui.js';
12
12
  import { ensureStateBucket, validateScalewayCredentials } from './bootstrap/scaleway.js';
13
13
  import { bootstrapInfisical, validateInfisical } from './bootstrap/infisical.js';
@@ -316,8 +316,13 @@ async function runBootstrap(answers, state, options) {
316
316
  log.info(step.skipMessage);
317
317
  continue;
318
318
  }
319
+ // A step that degraded with a warning last run is re-run (idempotent) so
320
+ // the degraded part can heal — e.g. a bucket policy that raced creation.
321
+ if (stepWarning(state, step.name)) {
322
+ log.info('Previous run finished this step with a warning — retrying it.');
323
+ }
319
324
  const result = await withSpinner(step.label(answers, ctx), () => step.run(answers, ctx, state));
320
- markDone(answers.targetDir, state, step.name, result?.data);
325
+ markDone(answers.targetDir, state, step.name, result?.data, result?.warning);
321
326
  if (result?.warning)
322
327
  log.warn(result.warning);
323
328
  }
package/dist/prompts.js CHANGED
@@ -55,7 +55,7 @@ export async function fillMissing(partial, options) {
55
55
  const resuming = manifest !== undefined && manifest.projectName === out.projectName;
56
56
  if (manifest && resuming) {
57
57
  hydrateConfigFromManifest(out, manifest);
58
- log.info(`Resuming "${out.projectName}" — region, environments and options are locked to its .keel manifest.`);
58
+ log.info(`Resuming "${out.projectName}" — repository, region, environments and options are locked to its .keel manifest.`);
59
59
  }
60
60
  // Each block opens with a "┌ <Provider>" section corner and closes with a
61
61
  // "└ <Provider> connected — …" line, so every question flows inside its own
package/dist/state.js CHANGED
@@ -18,10 +18,15 @@ export function saveState(targetDir, state) {
18
18
  writeFileSync(join(targetDir, STATE_FILE), `${JSON.stringify(state, null, 2)}\n`);
19
19
  }
20
20
  export function isDone(state, step) {
21
- return state.steps[step] !== undefined;
21
+ const record = state.steps[step];
22
+ return record !== undefined && record.warning === undefined;
22
23
  }
23
- export function markDone(targetDir, state, step, data) {
24
- state.steps[step] = data ? { data } : {};
24
+ /** The warning a previous run recorded for this step, if it degraded. */
25
+ export function stepWarning(state, step) {
26
+ return state.steps[step]?.warning;
27
+ }
28
+ export function markDone(targetDir, state, step, data, warning) {
29
+ state.steps[step] = { ...(data ? { data } : {}), ...(warning ? { warning } : {}) };
25
30
  saveState(targetDir, state);
26
31
  }
27
32
  export function stepData(state, step, key) {
package/dist/ui.js CHANGED
@@ -59,7 +59,7 @@ export function renderSummary(answers, dryRun) {
59
59
  for (const env of answers.environments) {
60
60
  const flags = [
61
61
  `scale ${env.minScale}-${env.maxScale}`,
62
- env.gated ? 'manual approval' : 'auto-deploy',
62
+ env.production ? 'deploys on version tag' : 'deploys on merge to main',
63
63
  env.basicAuth ? 'basic auth' : null,
64
64
  ]
65
65
  .filter(Boolean)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gambi97/keel-cli",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
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",