@gambi97/keel-cli 0.3.1 → 0.3.2
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/bootstrap/github.js +86 -37
- package/dist/bootstrap/infisical.js +13 -3
- package/dist/bootstrap/scaleway.js +24 -7
- package/dist/index.js +2 -1
- package/dist/prompts.js +77 -11
- package/package.json +1 -1
package/dist/bootstrap/github.js
CHANGED
|
@@ -16,12 +16,17 @@ export class GitHubError extends Error {
|
|
|
16
16
|
this.field = field;
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
-
|
|
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:
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
/**
|
|
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
|
|
@@ -177,7 +210,7 @@ export async function configureRepo(ctx, answers, infisicalProjectId) {
|
|
|
177
210
|
};
|
|
178
211
|
await setVariables(ctx, variables);
|
|
179
212
|
await configureEnvironments(ctx, answers);
|
|
180
|
-
|
|
213
|
+
return protectMainBranch(ctx, answers);
|
|
181
214
|
}
|
|
182
215
|
async function setSecrets(ctx, secrets) {
|
|
183
216
|
await sodium.ready;
|
|
@@ -229,22 +262,38 @@ async function configureEnvironments(ctx, answers) {
|
|
|
229
262
|
});
|
|
230
263
|
}
|
|
231
264
|
}
|
|
232
|
-
/**
|
|
265
|
+
/**
|
|
266
|
+
* Require a green plan on PRs and forbid force-pushes/deletion of main.
|
|
267
|
+
* Branch protection needs GitHub Pro on private repositories: failing here
|
|
268
|
+
* would strand the bootstrap at its very last step with everything else
|
|
269
|
+
* already created, so — like the state bucket policy — the miss degrades to
|
|
270
|
+
* a warning instead of killing the run.
|
|
271
|
+
*/
|
|
233
272
|
async function protectMainBranch(ctx, answers) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
273
|
+
try {
|
|
274
|
+
await ctx.octokit.repos.updateBranchProtection({
|
|
275
|
+
owner: ctx.owner,
|
|
276
|
+
repo: ctx.repo,
|
|
277
|
+
branch: 'main',
|
|
278
|
+
required_status_checks: {
|
|
279
|
+
strict: false,
|
|
280
|
+
// Must match the job names produced by the plan workflow; the shared
|
|
281
|
+
// format lives in contracts.ts and is tested against the template.
|
|
282
|
+
contexts: answers.environments.map((env) => planStatusCheckContext(env.slug)),
|
|
283
|
+
},
|
|
284
|
+
enforce_admins: false,
|
|
285
|
+
required_pull_request_reviews: null,
|
|
286
|
+
restrictions: null,
|
|
287
|
+
allow_force_pushes: false,
|
|
288
|
+
allow_deletions: false,
|
|
289
|
+
});
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
if (error.status !== 403)
|
|
294
|
+
throw error;
|
|
295
|
+
return (`Could not protect the main branch of ${ctx.owner}/${ctx.repo}: GitHub requires a ` +
|
|
296
|
+
'paid plan for branch protection on private repositories. PRs stay mergeable without ' +
|
|
297
|
+
'a green plan — make the repository public or upgrade, then re-run to add protection.');
|
|
298
|
+
}
|
|
250
299
|
}
|
|
@@ -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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
/**
|
|
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 —
|
|
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
|
-
|
|
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