@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.
- package/dist/bootstrap/github.js +42 -10
- package/dist/config.js +11 -0
- package/dist/generate.js +22 -0
- package/dist/index.js +12 -2
- package/dist/prompts.js +17 -2
- package/package.json +1 -1
- package/templates/README.md +6 -0
package/dist/bootstrap/github.js
CHANGED
|
@@ -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
|
-
|
|
213
|
-
|
|
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
|
-
/**
|
|
255
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
package/templates/README.md
CHANGED
|
@@ -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.
|