@gambi97/keel-cli 0.3.1 → 0.3.3

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.
@@ -16,12 +16,17 @@ export class GitHubError extends Error {
16
16
  this.field = field;
17
17
  }
18
18
  }
19
- export async function createContext(github) {
19
+ /**
20
+ * Authenticate the token and confirm it carries the scopes keel needs. Split
21
+ * from createContext so the prompt can validate the token and list the user's
22
+ * repositories before a repository name even exists.
23
+ */
24
+ export async function authenticate(token) {
20
25
  // Silence Octokit's own request logging: expected non-2xx responses (a 404
21
26
  // for a repo that does not exist yet, a 401 for a bad token) are handled
22
27
  // here and would otherwise scribble over the prompt spinner.
23
28
  const octokit = new Octokit({
24
- auth: github.token,
29
+ auth: token,
25
30
  log: { debug: () => { }, info: () => { }, warn: () => { }, error: () => { } },
26
31
  });
27
32
  let login;
@@ -48,13 +53,25 @@ export async function createContext(github) {
48
53
  }
49
54
  }
50
55
  }
51
- return {
52
- octokit,
53
- owner: login,
54
- ownerId,
55
- repo: github.repoName,
56
- repoPrivate: github.repoPrivate,
57
- };
56
+ return { octokit, owner: login, ownerId };
57
+ }
58
+ export async function createContext(github) {
59
+ const identity = await authenticate(github.token);
60
+ return { ...identity, repo: github.repoName, repoPrivate: github.repoPrivate };
61
+ }
62
+ /**
63
+ * Repositories the user owns, newest first, flagged by emptiness. keel pushes a
64
+ * brand-new history, so only empty repos are reusable; `size === 0` is GitHub's
65
+ * cheap proxy for "no commits" (inspectRepo makes the authoritative call once a
66
+ * name is chosen). Paginated in full — the picker never silently truncates.
67
+ */
68
+ export async function listOwnedRepos(octokit) {
69
+ const repos = await octokit.paginate(octokit.repos.listForAuthenticatedUser, {
70
+ affiliation: 'owner',
71
+ sort: 'updated',
72
+ per_page: 100,
73
+ });
74
+ return repos.map((r) => ({ name: r.name, private: r.private, empty: r.size === 0 }));
58
75
  }
59
76
  /** Read-only look at the target repository: existence, push access, emptiness. */
60
77
  export async function inspectRepo(ctx) {
@@ -103,15 +120,28 @@ export async function ensureRepo(ctx) {
103
120
  if (inspected.state !== 'not-found') {
104
121
  return { created: false, url: inspected.url };
105
122
  }
106
- const { data } = await ctx.octokit.repos.createForAuthenticatedUser({
107
- name: ctx.repo,
108
- private: ctx.repoPrivate,
109
- description: 'Serverless infrastructure on Scaleway, generated by keel',
110
- has_wiki: false,
111
- has_projects: false,
112
- auto_init: false,
113
- });
114
- return { created: true, url: data.html_url };
123
+ try {
124
+ const { data } = await ctx.octokit.repos.createForAuthenticatedUser({
125
+ name: ctx.repo,
126
+ private: ctx.repoPrivate,
127
+ description: 'Serverless infrastructure on Scaleway, generated by keel',
128
+ has_wiki: false,
129
+ has_projects: false,
130
+ auto_init: false,
131
+ });
132
+ return { created: true, url: data.html_url };
133
+ }
134
+ catch (error) {
135
+ // inspectRepo said 404 but creation says the name is taken: a fine-grained
136
+ // token that cannot see the existing repo answers exactly this way. Map it
137
+ // to a re-askable error instead of dying on a raw Octokit failure.
138
+ if (error.status === 422) {
139
+ throw new GitHubError(`Repository ${ctx.owner}/${ctx.repo} already exists but the token cannot see it ` +
140
+ '(fine-grained tokens only see the repositories they were granted). Use a token ' +
141
+ 'with access to it, or a different repository name.', 'repo');
142
+ }
143
+ throw error;
144
+ }
115
145
  }
116
146
  /**
117
147
  * Push the generated repo over HTTPS. The token is handed to git through a
@@ -154,7 +184,10 @@ export function pushRepo(ctx, token, targetDir) {
154
184
  rmSync(askpassDir, { recursive: true, force: true });
155
185
  }
156
186
  }
157
- /** Encrypt and upload every CI secret, then set the plain variables. */
187
+ /**
188
+ * Encrypt and upload every CI secret, then set the plain variables. Returns a
189
+ * warning instead of failing when branch protection is unavailable.
190
+ */
158
191
  export async function configureRepo(ctx, answers, infisicalProjectId) {
159
192
  // The workflows map AWS_* (state backend) from the same SCW_* secrets, so
160
193
  // each credential is stored once and rotated in one place. The Record types
@@ -176,8 +209,11 @@ export async function configureRepo(ctx, answers, infisicalProjectId) {
176
209
  INFISICAL_HOST: answers.infisical.host,
177
210
  };
178
211
  await setVariables(ctx, variables);
179
- await configureEnvironments(ctx, answers);
180
- await 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;
181
217
  }
182
218
  async function setSecrets(ctx, secrets) {
183
219
  await sodium.ready;
@@ -218,33 +254,78 @@ async function setVariables(ctx, variables) {
218
254
  }
219
255
  }
220
256
  }
221
- /** Each environment gets a GitHub deployment environment; gated ones (prod) require approval. */
222
- 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 = [];
223
268
  for (const env of answers.environments) {
224
- await ctx.octokit.repos.createOrUpdateEnvironment({
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
+ }
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.');
296
+ }
297
+ /**
298
+ * Require a green plan on PRs and forbid force-pushes/deletion of main.
299
+ * Branch protection needs GitHub Pro on private repositories: failing here
300
+ * would strand the bootstrap at its very last step with everything else
301
+ * already created, so — like the state bucket policy — the miss degrades to
302
+ * a warning instead of killing the run.
303
+ */
304
+ async function protectMainBranch(ctx, answers) {
305
+ try {
306
+ await ctx.octokit.repos.updateBranchProtection({
225
307
  owner: ctx.owner,
226
308
  repo: ctx.repo,
227
- environment_name: env.githubEnvironment,
228
- ...(env.gated ? { reviewers: [{ type: 'User', id: ctx.ownerId }] } : {}),
309
+ branch: 'main',
310
+ required_status_checks: {
311
+ strict: false,
312
+ // Must match the job names produced by the plan workflow; the shared
313
+ // format lives in contracts.ts and is tested against the template.
314
+ contexts: answers.environments.map((env) => planStatusCheckContext(env.slug)),
315
+ },
316
+ enforce_admins: false,
317
+ required_pull_request_reviews: null,
318
+ restrictions: null,
319
+ allow_force_pushes: false,
320
+ allow_deletions: false,
229
321
  });
322
+ return undefined;
323
+ }
324
+ catch (error) {
325
+ if (error.status !== 403)
326
+ throw error;
327
+ return (`Could not protect the main branch of ${ctx.owner}/${ctx.repo}: GitHub requires a ` +
328
+ 'paid plan for branch protection on private repositories. PRs stay mergeable without ' +
329
+ 'a green plan — make the repository public or upgrade, then re-run to add protection.');
230
330
  }
231
- }
232
- /** Require a green plan on PRs and forbid force-pushes/deletion of main. */
233
- async function protectMainBranch(ctx, answers) {
234
- await ctx.octokit.repos.updateBranchProtection({
235
- owner: ctx.owner,
236
- repo: ctx.repo,
237
- branch: 'main',
238
- required_status_checks: {
239
- strict: false,
240
- // Must match the job names produced by the plan workflow; the shared
241
- // format lives in contracts.ts and is tested against the template.
242
- contexts: answers.environments.map((env) => planStatusCheckContext(env.slug)),
243
- },
244
- enforce_admins: false,
245
- required_pull_request_reviews: null,
246
- restrictions: null,
247
- allow_force_pushes: false,
248
- allow_deletions: false,
249
- });
250
331
  }
@@ -60,10 +60,20 @@ async function ensureEnvironment(host, token, project, slug, name) {
60
60
  if (project.environments?.some((e) => e.slug === slug))
61
61
  return;
62
62
  const { status, data } = await api(host, `/api/v1/workspace/${project.id}/environments`, { method: 'POST', token, body: { name, slug } });
63
- // 400 usually means the environment already exists: fine for idempotency.
64
- if (status !== 200 && status !== 400) {
65
- throw new InfisicalError(`Could not create Infisical environment "${slug}" (HTTP ${status}${data.message ? `: ${data.message}` : ''}).`);
63
+ if (status === 200)
64
+ return;
65
+ // A 400 usually means the environment already exists (fine for idempotency),
66
+ // but it is also what plan limits and rejected slugs answer: trust the
67
+ // message when it says "exists", otherwise re-fetch and check for real —
68
+ // swallowing it blindly would surface later as a confusing seeding failure.
69
+ if (status === 400) {
70
+ if (/exist/i.test(data.message ?? ''))
71
+ return;
72
+ const fresh = await findProjectById(host, token, project.id);
73
+ if (fresh?.environments?.some((e) => e.slug === slug))
74
+ return;
66
75
  }
76
+ throw new InfisicalError(`Could not create Infisical environment "${slug}" (HTTP ${status}${data.message ? `: ${data.message}` : ''}).`);
67
77
  }
68
78
  async function seedSecret(host, token, projectId, environment, name, value) {
69
79
  const { status, data } = await api(host, `/api/v3/secrets/raw/${name}`, {
@@ -137,13 +137,30 @@ export async function ensureStateBucket(answers) {
137
137
  if (status !== 404 && status !== 301) {
138
138
  throw error;
139
139
  }
140
- await client.send(new CreateBucketCommand({ Bucket: bucket }));
141
- // Versioning protects the state history against accidental overwrites.
142
- await client.send(new PutBucketVersioningCommand({
143
- Bucket: bucket,
144
- VersioningConfiguration: { Status: 'Enabled' },
145
- }));
146
- created = true;
140
+ // HeadBucket said "missing", but on Scaleway it can 404/301 a bucket we
141
+ // actually own (region/endpoint quirks, eventual consistency). CreateBucket
142
+ // is the authoritative check: treat AlreadyOwnedByYou as a resume, and
143
+ // AlreadyExists (owned by someone else) as the name clash Head would report.
144
+ try {
145
+ await client.send(new CreateBucketCommand({ Bucket: bucket }));
146
+ // Versioning protects the state history against accidental overwrites.
147
+ await client.send(new PutBucketVersioningCommand({
148
+ Bucket: bucket,
149
+ VersioningConfiguration: { Status: 'Enabled' },
150
+ }));
151
+ created = true;
152
+ }
153
+ catch (createError) {
154
+ const name = createError.name;
155
+ if (name === 'BucketAlreadyExists') {
156
+ throw new ScalewayError(`Bucket "${bucket}" already exists but is owned by another account. ` +
157
+ 'Choose a different project name.');
158
+ }
159
+ if (name !== 'BucketAlreadyOwnedByYou') {
160
+ throw createError;
161
+ }
162
+ // Already ours from an earlier run: fall through to (re)apply the policy.
163
+ }
147
164
  }
148
165
  const policyWarning = await lockDownStateBucket(client, bucket, answers);
149
166
  return policyWarning ? { created, policyWarning } : { created };
package/dist/index.js CHANGED
@@ -283,7 +283,8 @@ const BOOTSTRAP_STEPS = [
283
283
  skipMessage: 'GitHub configuration: already done, skipping.',
284
284
  run: async (answers, ctx, state) => {
285
285
  // Recorded by the infisical step (this run or a resumed one).
286
- await configureRepo(ctx, answers, stepData(state, 'infisical', 'projectId'));
286
+ const warning = await configureRepo(ctx, answers, stepData(state, 'infisical', 'projectId'));
287
+ return warning ? { warning } : undefined;
287
288
  },
288
289
  },
289
290
  ];
package/dist/prompts.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as p from '@clack/prompts';
2
- import { createContext, GitHubError, inspectRepo, assertRepoUsable } from './bootstrap/github.js';
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
5
  import { ConfigError, DEFAULT_ENV_PRESET, DEFAULT_INFISICAL_HOST, DEFAULT_REGION, ENV_PRESETS, envDefaultScale, REGIONS, validateProjectName, validateScale, validateUrl, } from './config.js';
@@ -123,21 +123,88 @@ async function askConfiguration(out, options) {
123
123
  }
124
124
  }
125
125
  }
126
- /** GitHub block: repo + token, then verify token, scopes and repo state. */
126
+ /**
127
+ * Decide the target repository once the token is known: create a new one, or
128
+ * reuse an existing empty repo picked from a list. keel pushes a brand-new
129
+ * history, so only empty repos are offered — a name typed for a new repo is
130
+ * still validated. Sets repoName (and, for an existing pick, its real
131
+ * visibility) on `out`.
132
+ */
133
+ async function chooseRepo(out, identity) {
134
+ const CREATE_NEW = ' create-new'; // sentinel: a leading space is never a valid repo name
135
+ const spin = p.spinner();
136
+ spin.start('Fetching your GitHub repositories');
137
+ let reusable = [];
138
+ try {
139
+ const repos = await listOwnedRepos(identity.octokit);
140
+ reusable = repos.filter((r) => r.empty);
141
+ spin.stop(reusable.length
142
+ ? `Found ${reusable.length} empty repositor${reusable.length === 1 ? 'y' : 'ies'} you can reuse`
143
+ : 'No empty repositories to reuse — keel will create a new one');
144
+ }
145
+ catch {
146
+ spin.stop('Could not list repositories; you can still name a new one');
147
+ }
148
+ const choice = reusable.length
149
+ ? await ask(p.select({
150
+ message: 'Repository',
151
+ initialValue: CREATE_NEW,
152
+ options: [
153
+ {
154
+ value: CREATE_NEW,
155
+ label: 'Create a new repository',
156
+ hint: 'keel creates it for you',
157
+ },
158
+ ...reusable.map((r) => ({
159
+ value: r.name,
160
+ label: r.name,
161
+ hint: `${r.private ? 'private' : 'public'}, empty — reuse it`,
162
+ })),
163
+ ],
164
+ }))
165
+ : CREATE_NEW;
166
+ if (choice !== CREATE_NEW) {
167
+ const picked = reusable.find((r) => r.name === choice);
168
+ out.github.repoName = picked.name;
169
+ out.github.repoPrivate = picked.private; // keep the repo's existing visibility
170
+ return;
171
+ }
172
+ out.github.repoName = await ask(p.text({
173
+ message: 'New repository name',
174
+ initialValue: out.projectName,
175
+ validate: validate(validateProjectName),
176
+ }));
177
+ }
178
+ /** GitHub block: token first (so we can list repos), then repo choice and state. */
127
179
  async function askGitHub(out) {
128
180
  // When resuming a run that already pushed, the repo legitimately has
129
181
  // commits: a non-empty repo must not block the resume.
130
182
  const targetDir = out.targetDir?.trim() || out.projectName;
131
183
  const alreadyPushed = isDone(loadState(targetDir, out.projectName), 'github-push');
132
- p.intro('GitHub — repository, visibility and token');
184
+ p.intro('GitHub — token, repository and visibility');
133
185
  for (;;) {
186
+ // Token first: authenticating up front lets keel list your repositories so
187
+ // you can pick one instead of typing its name.
188
+ if (!out.github.token) {
189
+ out.github.token = await secret('GitHub token (scopes: repo, workflow)');
190
+ }
191
+ let identity;
192
+ try {
193
+ identity = await authenticate(out.github.token);
194
+ }
195
+ catch (error) {
196
+ if (!(error instanceof GitHubError))
197
+ throw error;
198
+ log.error(error.message);
199
+ out.github.token = undefined;
200
+ continue;
201
+ }
202
+ // New-or-existing selector. Skipped when a name is already fixed by flags,
203
+ // --config or a resumed run.
134
204
  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
- }));
205
+ await chooseRepo(out, identity);
140
206
  }
207
+ // Visibility. An existing pick carries its own; only a new repo asks.
141
208
  if (out.github.repoPrivate === undefined) {
142
209
  out.github.repoPrivate = await ask(p.select({
143
210
  message: 'Repository visibility',
@@ -148,9 +215,6 @@ async function askGitHub(out) {
148
215
  ],
149
216
  }));
150
217
  }
151
- if (!out.github.token) {
152
- out.github.token = await secret('GitHub token (scopes: repo, workflow)');
153
- }
154
218
  try {
155
219
  const ctx = await createContext({
156
220
  token: out.github.token,
@@ -174,7 +238,9 @@ async function askGitHub(out) {
174
238
  throw error;
175
239
  log.error(error.message);
176
240
  if (error.field === 'repo') {
241
+ // Bad repo choice: re-run the selector and re-derive its visibility.
177
242
  out.github.repoName = undefined;
243
+ out.github.repoPrivate = undefined;
178
244
  }
179
245
  else {
180
246
  out.github.token = undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gambi97/keel-cli",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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.