@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.
- package/dist/bootstrap/github.js +20 -44
- package/dist/bootstrap/scaleway.js +25 -4
- package/dist/config.js +14 -5
- package/dist/generate.js +6 -0
- package/dist/index.js +7 -2
- package/dist/prompts.js +1 -1
- package/dist/state.js +8 -3
- package/dist/ui.js +1 -1
- package/package.json +1 -1
package/dist/bootstrap/github.js
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
213
|
-
|
|
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
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
+
const record = state.steps[step];
|
|
22
|
+
return record !== undefined && record.warning === undefined;
|
|
22
23
|
}
|
|
23
|
-
|
|
24
|
-
|
|
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.
|
|
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