@gambi97/keel-cli 0.2.0 → 0.3.0
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/README.md +70 -25
- package/dist/bootstrap/github.js +84 -22
- package/dist/bootstrap/infisical.js +59 -22
- package/dist/bootstrap/scaleway.js +102 -17
- package/dist/config.js +14 -3
- package/dist/contracts.js +80 -0
- package/dist/generate.js +37 -3
- package/dist/index.js +216 -142
- package/dist/meta.js +12 -0
- package/dist/prompts.js +246 -66
- package/dist/ui.js +3 -1
- package/package.json +1 -1
- package/templates/.github/scripts/sync-secrets.sh +13 -4
- package/templates/.github/workflows/terraform-drift.yml +19 -1
- package/templates/.github/workflows/terraform-plan.yml +19 -1
- package/templates/README.md +29 -5
- package/templates/_gitignore +1 -1
- package/templates/main.tf +10 -0
- package/templates/modules/app_stack/main.tf +3 -0
- package/templates/outputs.tf +12 -3
- package/templates/variables.tf +5 -0
package/dist/config.js
CHANGED
|
@@ -29,7 +29,9 @@ const ENV_DEFAULTS = {
|
|
|
29
29
|
production: true,
|
|
30
30
|
gated: true,
|
|
31
31
|
minScale: 0,
|
|
32
|
-
|
|
32
|
+
// Start small: one instance is enough to go live and costs the least;
|
|
33
|
+
// raising the ceiling later is a one-line prod.tfvars change.
|
|
34
|
+
maxScale: 1,
|
|
33
35
|
},
|
|
34
36
|
};
|
|
35
37
|
/** Default min/max scale for an environment, for prompts and summaries. */
|
|
@@ -134,6 +136,7 @@ export function fromEnv(env) {
|
|
|
134
136
|
host: env.INFISICAL_HOST,
|
|
135
137
|
clientId: env.INFISICAL_CLIENT_ID,
|
|
136
138
|
clientSecret: env.INFISICAL_CLIENT_SECRET,
|
|
139
|
+
projectId: env.INFISICAL_PROJECT_ID,
|
|
137
140
|
},
|
|
138
141
|
github: {
|
|
139
142
|
token: env.GITHUB_TOKEN ?? env.GH_TOKEN,
|
|
@@ -186,6 +189,11 @@ export function resolveEnvironments(slugs, basicAuth, overrides) {
|
|
|
186
189
|
return slugs.map((slug) => {
|
|
187
190
|
const def = ENV_DEFAULTS[slug];
|
|
188
191
|
const ov = overrides[slug] ?? {};
|
|
192
|
+
const minScale = validateScale(ov.minScale ?? def.minScale, `${slug} min scale`);
|
|
193
|
+
const maxScale = validateScale(ov.maxScale ?? def.maxScale, `${slug} max scale`);
|
|
194
|
+
if (minScale > maxScale) {
|
|
195
|
+
throw new ConfigError(`Invalid ${slug} scaling: min scale (${minScale}) cannot exceed max scale (${maxScale}).`);
|
|
196
|
+
}
|
|
189
197
|
return {
|
|
190
198
|
slug,
|
|
191
199
|
displayName: def.displayName,
|
|
@@ -194,8 +202,8 @@ export function resolveEnvironments(slugs, basicAuth, overrides) {
|
|
|
194
202
|
gated: def.gated,
|
|
195
203
|
// Basic Auth is a non-production safety net; production is never gated by it.
|
|
196
204
|
basicAuth: def.production ? false : basicAuth,
|
|
197
|
-
minScale
|
|
198
|
-
maxScale
|
|
205
|
+
minScale,
|
|
206
|
+
maxScale,
|
|
199
207
|
};
|
|
200
208
|
});
|
|
201
209
|
}
|
|
@@ -223,6 +231,9 @@ export function finalizeAnswers(partial) {
|
|
|
223
231
|
clientId: requireString(partial.infisical.clientId, 'Infisical client ID'),
|
|
224
232
|
clientSecret: requireString(partial.infisical.clientSecret, 'Infisical client secret'),
|
|
225
233
|
projectName: partial.infisical.projectName?.trim() || projectName,
|
|
234
|
+
...(partial.infisical.projectId?.trim()
|
|
235
|
+
? { projectId: partial.infisical.projectId.trim() }
|
|
236
|
+
: {}),
|
|
226
237
|
},
|
|
227
238
|
github: {
|
|
228
239
|
token: requireString(partial.github.token, 'GitHub token'),
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Names shared between the bootstrap code and the generated repository.
|
|
3
|
+
*
|
|
4
|
+
* The CLI configures the user's account by name (required status checks,
|
|
5
|
+
* Actions secrets/variables, seeded Infisical secrets) and the generated
|
|
6
|
+
* workflows and Terraform reference the same names. A rename on one side
|
|
7
|
+
* without the other passes CI here and breaks at runtime in the user's
|
|
8
|
+
* account — so both sides import these constants, and contracts.test.ts
|
|
9
|
+
* renders the templates and asserts they still agree.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Job name of the plan workflow for one environment; branch protection
|
|
13
|
+
* requires exactly these contexts before a PR can merge.
|
|
14
|
+
*/
|
|
15
|
+
export function planStatusCheckContext(environment) {
|
|
16
|
+
return `plan (${environment})`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Version of the docking contract between keel and the generated repository
|
|
20
|
+
* (output naming, manifest schema, tfvars conventions). Recorded in the
|
|
21
|
+
* committed .keel/manifest.json; future tooling that extends a generated
|
|
22
|
+
* repo checks it before touching anything.
|
|
23
|
+
*/
|
|
24
|
+
export const CONTRACT_VERSION = 1;
|
|
25
|
+
/** Base Terraform output consumed by .github/scripts/sync-secrets.sh. */
|
|
26
|
+
export const INFISICAL_SECRETS_OUTPUT = 'infisical_secrets';
|
|
27
|
+
/**
|
|
28
|
+
* Regex (verbatim in sync-secrets.sh) matching the base output plus any
|
|
29
|
+
* module-contributed "infisical_secrets_<name>" output. A module added to a
|
|
30
|
+
* generated repo exposes its own map under that name and the pipeline syncs
|
|
31
|
+
* it — no edit to outputs.tf needed (file-additive extension).
|
|
32
|
+
*/
|
|
33
|
+
export const INFISICAL_SECRETS_OUTPUT_PATTERN = '^infisical_secrets(_[a-z0-9_]+)?$';
|
|
34
|
+
/**
|
|
35
|
+
* Keys always present in the synced map: seeded as placeholders by the CLI,
|
|
36
|
+
* produced by the root outputs, overwritten by the pipeline after each apply.
|
|
37
|
+
*/
|
|
38
|
+
export const BASE_SYNCED_KEYS = ['DATABASE_URL', 'APP_URL'];
|
|
39
|
+
/**
|
|
40
|
+
* Basic Auth crosses the boundary in three places: the CLI seeds these two
|
|
41
|
+
* secrets in Infisical (non-production), the generated app_stack injects the
|
|
42
|
+
* flag below, and the user's app enforces it. [user, password] in this order.
|
|
43
|
+
*/
|
|
44
|
+
export const BASIC_AUTH_SECRET_KEYS = ['BASIC_AUTH_USER', 'BASIC_AUTH_PASSWORD'];
|
|
45
|
+
export const BASIC_AUTH_FLAG = 'BASIC_AUTH_ENABLED';
|
|
46
|
+
/**
|
|
47
|
+
* Naming convention for per-environment resources (registry, namespaces,
|
|
48
|
+
* database, buckets…): mirrored by `local.name` in the app_stack template,
|
|
49
|
+
* which contracts.test.ts pins against this function.
|
|
50
|
+
*/
|
|
51
|
+
export function resourceName(projectName, envSlug) {
|
|
52
|
+
return `${projectName}-${envSlug}`;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Object Storage coordinates: seeded as placeholders by the CLI, produced by
|
|
56
|
+
* the infisical_secrets output, synced to Infisical by the pipeline.
|
|
57
|
+
*/
|
|
58
|
+
export const S3_SECRET_KEYS = [
|
|
59
|
+
'S3_BUCKET',
|
|
60
|
+
'S3_ENDPOINT',
|
|
61
|
+
'S3_REGION',
|
|
62
|
+
'S3_ACCESS_KEY',
|
|
63
|
+
'S3_SECRET_KEY',
|
|
64
|
+
];
|
|
65
|
+
/** Encrypted Actions secrets set by configureRepo, read by the workflows. */
|
|
66
|
+
export const CI_SECRET_NAMES = [
|
|
67
|
+
'SCW_ACCESS_KEY',
|
|
68
|
+
'SCW_SECRET_KEY',
|
|
69
|
+
'SCW_DEFAULT_PROJECT_ID',
|
|
70
|
+
'SCW_DEFAULT_ORGANIZATION_ID',
|
|
71
|
+
'INFISICAL_CLIENT_ID',
|
|
72
|
+
'INFISICAL_CLIENT_SECRET',
|
|
73
|
+
];
|
|
74
|
+
/** Plain Actions variables set by configureRepo, read by the workflows. */
|
|
75
|
+
export const CI_VARIABLE_NAMES = [
|
|
76
|
+
'TF_STATE_BUCKET',
|
|
77
|
+
'SCW_REGION',
|
|
78
|
+
'INFISICAL_PROJECT_ID',
|
|
79
|
+
'INFISICAL_HOST',
|
|
80
|
+
];
|
package/dist/generate.js
CHANGED
|
@@ -2,9 +2,33 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { CONTRACT_VERSION } from './contracts.js';
|
|
6
|
+
import { toolVersion } from './meta.js';
|
|
5
7
|
import { STATE_FILE } from './state.js';
|
|
6
8
|
export class GenerateError extends Error {
|
|
7
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Machine-readable record of how the repository was generated. Unlike the
|
|
12
|
+
* git-ignored .keel.json resume file, the manifest is committed: future
|
|
13
|
+
* tooling that extends a generated repo (e.g. `keel add`) reads it from a
|
|
14
|
+
* fresh clone to know which contract it is docking onto.
|
|
15
|
+
*/
|
|
16
|
+
export const MANIFEST_FILE = '.keel/manifest.json';
|
|
17
|
+
function renderManifest(answers) {
|
|
18
|
+
const manifest = {
|
|
19
|
+
keelVersion: toolVersion(),
|
|
20
|
+
contractVersion: CONTRACT_VERSION,
|
|
21
|
+
generatedAt: new Date().toISOString(),
|
|
22
|
+
projectName: answers.projectName,
|
|
23
|
+
region: answers.region,
|
|
24
|
+
environments: answers.environments.map((e) => e.slug),
|
|
25
|
+
options: {
|
|
26
|
+
objectStorage: answers.objectStorage,
|
|
27
|
+
basicAuth: answers.basicAuth,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
31
|
+
}
|
|
8
32
|
/** Files whose real name would confuse npm packaging are stored renamed. */
|
|
9
33
|
const RENAMES = {
|
|
10
34
|
_gitignore: '.gitignore',
|
|
@@ -19,8 +43,6 @@ export function tokenMap(answers) {
|
|
|
19
43
|
__PROJECT_NAME__: answers.projectName,
|
|
20
44
|
__REGION__: answers.region,
|
|
21
45
|
__TF_STATE_BUCKET__: answers.stateBucket,
|
|
22
|
-
// YAML inline list for the plan/drift job matrix, e.g. [staging, prod].
|
|
23
|
-
__ENV_MATRIX__: `[${slugs.join(', ')}]`,
|
|
24
46
|
// HCL list for the `environment` variable validation in variables.tf.
|
|
25
47
|
__ENV_SLUGS_TF__: `[${slugs.map((s) => `"${s}"`).join(', ')}]`,
|
|
26
48
|
};
|
|
@@ -37,7 +59,15 @@ function envTfvarsTokens(answers, env) {
|
|
|
37
59
|
__MAX_SCALE__: String(env.maxScale),
|
|
38
60
|
};
|
|
39
61
|
}
|
|
40
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* Templates rendered specially (per environment) instead of copied 1:1.
|
|
64
|
+
*
|
|
65
|
+
* Keep this set small: the templating here is plain token replacement by
|
|
66
|
+
* design. A conditional feature must become a Terraform variable rendered
|
|
67
|
+
* into tfvars (as enable_object_storage does), never a conditionally emitted
|
|
68
|
+
* file — the day a feature cannot be expressed that way is the day to adopt
|
|
69
|
+
* a real template engine, not to add another special case here.
|
|
70
|
+
*/
|
|
41
71
|
const SPECIAL_TEMPLATES = new Set(['env.tfvars', '.github/workflows/terraform-apply.yml']);
|
|
42
72
|
function isSpecial(rel) {
|
|
43
73
|
return SPECIAL_TEMPLATES.has(rel) || rel.startsWith('_partials/');
|
|
@@ -121,6 +151,10 @@ export function generateProject(answers, options = {}) {
|
|
|
121
151
|
const applyDestRel = '.github/workflows/terraform-apply.yml';
|
|
122
152
|
writeFileSync(join(target, applyDestRel), renderApplyWorkflow(source, answers, tokens));
|
|
123
153
|
written.push(applyDestRel);
|
|
154
|
+
// Committed manifest recording generator version, contract and options.
|
|
155
|
+
mkdirSync(join(target, '.keel'), { recursive: true });
|
|
156
|
+
writeFileSync(join(target, MANIFEST_FILE), renderManifest(answers));
|
|
157
|
+
written.push(MANIFEST_FILE);
|
|
124
158
|
// backend.hcl (git-ignored) so local terraform runs work out of the box.
|
|
125
159
|
cpSync(join(target, 'backend.hcl.example'), join(target, 'backend.hcl'));
|
|
126
160
|
if (options.git !== false) {
|
package/dist/index.js
CHANGED
|
@@ -2,92 +2,138 @@
|
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
4
|
import { parseArgs } from 'node:util';
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
6
5
|
import { ConfigError, finalizeAnswers, fromEnv, mergeAnswers, missingRequired, parseEnvironments, } from './config.js';
|
|
6
|
+
import { CI_SECRET_NAMES, CI_VARIABLE_NAMES, resourceName } from './contracts.js';
|
|
7
7
|
import { generateProject, GenerateError } from './generate.js';
|
|
8
|
+
import { toolVersion } from './meta.js';
|
|
8
9
|
import { confirmSummary, fillMissing } from './prompts.js';
|
|
9
|
-
import { isDone, loadState, markDone, STATE_FILE, stepData } from './state.js';
|
|
10
|
+
import { isDone, loadState, markDone, STATE_FILE, stepData, } from './state.js';
|
|
10
11
|
import { cancel, intro, log, outro, renderNextSteps, renderSummary, withSpinner } from './ui.js';
|
|
11
12
|
import { ensureStateBucket, validateScalewayCredentials } from './bootstrap/scaleway.js';
|
|
12
|
-
import { bootstrapInfisical,
|
|
13
|
-
import { configureRepo, createContext, ensureRepo, pushRepo, } from './bootstrap/github.js';
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
13
|
+
import { bootstrapInfisical, validateInfisical } from './bootstrap/infisical.js';
|
|
14
|
+
import { assertRepoUsable, configureRepo, createContext, ensureRepo, inspectRepo, pushRepo, } from './bootstrap/github.js';
|
|
15
|
+
/**
|
|
16
|
+
* Single source of truth for the CLI surface: parseArgs consumes these specs
|
|
17
|
+
* and --help is generated from CLI_HELP, whose keys the compiler checks
|
|
18
|
+
* against this table — a flag cannot be added without help text, or vice
|
|
19
|
+
* versa. (The README's CLI reference is the one remaining manual copy.)
|
|
20
|
+
*/
|
|
21
|
+
const CLI_OPTIONS = {
|
|
22
|
+
name: { type: 'string' },
|
|
23
|
+
dir: { type: 'string' },
|
|
24
|
+
region: { type: 'string' },
|
|
25
|
+
'scw-access-key': { type: 'string' },
|
|
26
|
+
'scw-secret-key': { type: 'string' },
|
|
27
|
+
'scw-project-id': { type: 'string' },
|
|
28
|
+
'scw-organization-id': { type: 'string' },
|
|
29
|
+
'infisical-host': { type: 'string' },
|
|
30
|
+
'infisical-client-id': { type: 'string' },
|
|
31
|
+
'infisical-client-secret': { type: 'string' },
|
|
32
|
+
'infisical-project-id': { type: 'string' },
|
|
33
|
+
'infisical-project-name': { type: 'string' },
|
|
34
|
+
'github-token': { type: 'string' },
|
|
35
|
+
'repo-name': { type: 'string' },
|
|
36
|
+
private: { type: 'boolean' },
|
|
37
|
+
public: { type: 'boolean' },
|
|
38
|
+
environments: { type: 'string' },
|
|
39
|
+
'basic-auth': { type: 'boolean' },
|
|
40
|
+
'no-basic-auth': { type: 'boolean' },
|
|
41
|
+
'object-storage': { type: 'boolean' },
|
|
42
|
+
'no-object-storage': { type: 'boolean' },
|
|
43
|
+
'dev-min-scale': { type: 'string' },
|
|
44
|
+
'dev-max-scale': { type: 'string' },
|
|
45
|
+
'staging-min-scale': { type: 'string' },
|
|
46
|
+
'staging-max-scale': { type: 'string' },
|
|
47
|
+
'prod-min-scale': { type: 'string' },
|
|
48
|
+
'prod-max-scale': { type: 'string' },
|
|
49
|
+
config: { type: 'string' },
|
|
50
|
+
advanced: { type: 'boolean' },
|
|
51
|
+
yes: { type: 'boolean' },
|
|
52
|
+
'dry-run': { type: 'boolean' },
|
|
53
|
+
help: { type: 'boolean' },
|
|
54
|
+
version: { type: 'boolean' },
|
|
55
|
+
};
|
|
56
|
+
/** Help text per flag; null hides it (help/version share a closing line). */
|
|
57
|
+
const CLI_HELP = {
|
|
58
|
+
name: { hint: '<name>', text: 'Project name (dns-safe)' },
|
|
59
|
+
dir: { hint: '<path>', text: 'Target directory (default: ./<name>)' },
|
|
60
|
+
region: { hint: '<region>', text: 'fr-par | nl-ams | pl-waw (default: fr-par)' },
|
|
61
|
+
'scw-access-key': { hint: '<key>', text: 'Scaleway access key (env SCW_ACCESS_KEY)' },
|
|
62
|
+
'scw-secret-key': { hint: '<key>', text: 'Scaleway secret key (env SCW_SECRET_KEY)' },
|
|
63
|
+
'scw-project-id': {
|
|
64
|
+
hint: '<id>',
|
|
65
|
+
text: 'Scaleway project ID (env SCW_DEFAULT_PROJECT_ID)',
|
|
66
|
+
},
|
|
67
|
+
'scw-organization-id': {
|
|
68
|
+
hint: '<id>',
|
|
69
|
+
text: 'Scaleway organization ID (env SCW_DEFAULT_ORGANIZATION_ID)',
|
|
70
|
+
},
|
|
71
|
+
'infisical-host': { hint: '<url>', text: 'Infisical host (env INFISICAL_HOST)' },
|
|
72
|
+
'infisical-client-id': {
|
|
73
|
+
hint: '<id>',
|
|
74
|
+
text: 'Machine identity client ID (env INFISICAL_CLIENT_ID)',
|
|
75
|
+
},
|
|
76
|
+
'infisical-client-secret': {
|
|
77
|
+
hint: '<s>',
|
|
78
|
+
text: 'Machine identity secret (env INFISICAL_CLIENT_SECRET)',
|
|
79
|
+
},
|
|
80
|
+
'infisical-project-id': {
|
|
81
|
+
hint: '<id>',
|
|
82
|
+
text: 'Existing Infisical project ID to reuse\n(env INFISICAL_PROJECT_ID; default: create by name)',
|
|
83
|
+
},
|
|
84
|
+
'infisical-project-name': { hint: '<n>', text: 'Infisical project name (default: project name)' },
|
|
85
|
+
'github-token': { hint: '<token>', text: 'GitHub token, repo+workflow (env GITHUB_TOKEN)' },
|
|
86
|
+
'repo-name': { hint: '<name>', text: 'GitHub repository name (default: project name)' },
|
|
87
|
+
private: { text: 'Create the GitHub repository as private' },
|
|
88
|
+
public: { text: 'Create the GitHub repository as public (default)' },
|
|
89
|
+
environments: {
|
|
90
|
+
hint: '<preset>',
|
|
91
|
+
text: 'prod | staging+prod | dev+staging+prod\n(or a list like "dev,staging,prod"; default staging+prod)',
|
|
92
|
+
},
|
|
93
|
+
'basic-auth': { text: 'Enable Basic Auth on non-production environments (default)' },
|
|
94
|
+
'no-basic-auth': { text: 'Disable Basic Auth on non-production environments' },
|
|
95
|
+
'object-storage': { text: 'Provision a per-environment Object Storage bucket' },
|
|
96
|
+
'no-object-storage': { text: 'Do not provision Object Storage (default)' },
|
|
97
|
+
'dev-min-scale': { hint: '<n>', text: 'Dev min instances (default 0)' },
|
|
98
|
+
'dev-max-scale': { hint: '<n>', text: 'Dev max instances (default 1)' },
|
|
99
|
+
'staging-min-scale': { hint: '<n>', text: 'Staging min instances (default 0)' },
|
|
100
|
+
'staging-max-scale': { hint: '<n>', text: 'Staging max instances (default 1)' },
|
|
101
|
+
'prod-min-scale': { hint: '<n>', text: 'Prod min instances (default 0)' },
|
|
102
|
+
'prod-max-scale': { hint: '<n>', text: 'Prod max instances (default 1)' },
|
|
103
|
+
config: { hint: '<file.json>', text: 'Read answers from a JSON config file' },
|
|
104
|
+
advanced: { text: 'Also ask scaling questions interactively' },
|
|
105
|
+
yes: { text: 'Accept defaults, no confirmation prompt' },
|
|
106
|
+
'dry-run': { text: 'Generate files locally, touch no account' },
|
|
107
|
+
help: null,
|
|
108
|
+
version: null,
|
|
109
|
+
};
|
|
110
|
+
const HELP_COLUMN = 33;
|
|
111
|
+
function buildHelp() {
|
|
112
|
+
const lines = [
|
|
113
|
+
'keel: generate and bootstrap serverless infra on Scaleway',
|
|
114
|
+
'',
|
|
115
|
+
'Usage:',
|
|
116
|
+
' npx @gambi97/keel-cli [options]',
|
|
117
|
+
'',
|
|
118
|
+
'Options:',
|
|
119
|
+
];
|
|
120
|
+
for (const [flag, entry] of Object.entries(CLI_HELP)) {
|
|
121
|
+
if (!entry)
|
|
122
|
+
continue;
|
|
123
|
+
const head = ` --${flag}${entry.hint ? ` ${entry.hint}` : ''}`;
|
|
124
|
+
const [first, ...rest] = entry.text.split('\n');
|
|
125
|
+
lines.push(head.padEnd(HELP_COLUMN) + first);
|
|
126
|
+
for (const continuation of rest) {
|
|
127
|
+
lines.push(' '.repeat(HELP_COLUMN) + continuation);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
lines.push(' --help, --version');
|
|
131
|
+
return `${lines.join('\n')}\n`;
|
|
132
|
+
}
|
|
51
133
|
function parseCli(argv) {
|
|
52
|
-
const { values } = parseArgs({
|
|
53
|
-
args: argv,
|
|
54
|
-
options: {
|
|
55
|
-
name: { type: 'string' },
|
|
56
|
-
dir: { type: 'string' },
|
|
57
|
-
region: { type: 'string' },
|
|
58
|
-
'scw-access-key': { type: 'string' },
|
|
59
|
-
'scw-secret-key': { type: 'string' },
|
|
60
|
-
'scw-project-id': { type: 'string' },
|
|
61
|
-
'scw-organization-id': { type: 'string' },
|
|
62
|
-
'infisical-host': { type: 'string' },
|
|
63
|
-
'infisical-client-id': { type: 'string' },
|
|
64
|
-
'infisical-client-secret': { type: 'string' },
|
|
65
|
-
'infisical-project-name': { type: 'string' },
|
|
66
|
-
'github-token': { type: 'string' },
|
|
67
|
-
'repo-name': { type: 'string' },
|
|
68
|
-
private: { type: 'boolean' },
|
|
69
|
-
public: { type: 'boolean' },
|
|
70
|
-
environments: { type: 'string' },
|
|
71
|
-
'basic-auth': { type: 'boolean' },
|
|
72
|
-
'no-basic-auth': { type: 'boolean' },
|
|
73
|
-
'object-storage': { type: 'boolean' },
|
|
74
|
-
'no-object-storage': { type: 'boolean' },
|
|
75
|
-
'dev-min-scale': { type: 'string' },
|
|
76
|
-
'dev-max-scale': { type: 'string' },
|
|
77
|
-
'staging-min-scale': { type: 'string' },
|
|
78
|
-
'staging-max-scale': { type: 'string' },
|
|
79
|
-
'prod-min-scale': { type: 'string' },
|
|
80
|
-
'prod-max-scale': { type: 'string' },
|
|
81
|
-
config: { type: 'string' },
|
|
82
|
-
advanced: { type: 'boolean' },
|
|
83
|
-
yes: { type: 'boolean' },
|
|
84
|
-
'dry-run': { type: 'boolean' },
|
|
85
|
-
help: { type: 'boolean' },
|
|
86
|
-
version: { type: 'boolean' },
|
|
87
|
-
},
|
|
88
|
-
});
|
|
134
|
+
const { values } = parseArgs({ args: argv, options: CLI_OPTIONS });
|
|
89
135
|
if (values.help) {
|
|
90
|
-
process.stdout.write(
|
|
136
|
+
process.stdout.write(buildHelp());
|
|
91
137
|
process.exit(0);
|
|
92
138
|
}
|
|
93
139
|
if (values.version) {
|
|
@@ -112,6 +158,7 @@ function parseCli(argv) {
|
|
|
112
158
|
host: values['infisical-host'],
|
|
113
159
|
clientId: values['infisical-client-id'],
|
|
114
160
|
clientSecret: values['infisical-client-secret'],
|
|
161
|
+
projectId: values['infisical-project-id'],
|
|
115
162
|
projectName: values['infisical-project-name'],
|
|
116
163
|
},
|
|
117
164
|
github: {
|
|
@@ -164,15 +211,6 @@ function normalizeConfigFile(raw) {
|
|
|
164
211
|
scaling: (obj.scaling ?? {}),
|
|
165
212
|
});
|
|
166
213
|
}
|
|
167
|
-
function toolVersion() {
|
|
168
|
-
try {
|
|
169
|
-
const pkg = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'));
|
|
170
|
-
return pkg.version;
|
|
171
|
-
}
|
|
172
|
-
catch {
|
|
173
|
-
return '0.0.0';
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
214
|
function checkEnvironment() {
|
|
177
215
|
const [major] = process.versions.node.split('.');
|
|
178
216
|
if (Number(major) < 18) {
|
|
@@ -187,66 +225,102 @@ function printDryRunPlan(answers) {
|
|
|
187
225
|
const ghEnvs = answers.environments.map((e) => e.githubEnvironment).join('/');
|
|
188
226
|
log.info([
|
|
189
227
|
'Dry run: generated the repository locally. A real run would additionally:',
|
|
190
|
-
` - Scaleway: create the Terraform state bucket "${answers.stateBucket}" (${answers.region})
|
|
191
|
-
|
|
192
|
-
|
|
228
|
+
` - Scaleway: create the Terraform state bucket "${answers.stateBucket}" (${answers.region}),`,
|
|
229
|
+
' restricted by a bucket policy to the identity behind your API key',
|
|
230
|
+
` - Infisical: ${answers.infisical.projectId
|
|
231
|
+
? `reuse project ${answers.infisical.projectId}`
|
|
232
|
+
: `create/reuse project "${answers.infisical.projectName}"`}, environments ${envList},`,
|
|
233
|
+
' seed BASIC_AUTH_USER/BASIC_AUTH_PASSWORD (non-prod) and DATABASE_URL/APP_URL placeholders' +
|
|
193
234
|
(answers.objectStorage ? ' and S3_* placeholders' : ''),
|
|
194
|
-
` - GitHub: create ${answers.github.repoPrivate ? 'private' : 'public'} repo "${answers.github.repoName}", push, set
|
|
195
|
-
`
|
|
235
|
+
` - GitHub: create ${answers.github.repoPrivate ? 'private' : 'public'} repo "${answers.github.repoName}", push, set ${CI_SECRET_NAMES.length} encrypted secrets,`,
|
|
236
|
+
` ${CI_VARIABLE_NAMES.length} variables, ${ghEnvs} environments and main branch protection`,
|
|
196
237
|
].join('\n'));
|
|
197
238
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
239
|
+
/**
|
|
240
|
+
* The bootstrap pipeline, in order. Each step is idempotent on the provider
|
|
241
|
+
* side and recorded in the state file, so a re-run after a failure skips what
|
|
242
|
+
* is done and resumes exactly where it stopped.
|
|
243
|
+
*/
|
|
244
|
+
const BOOTSTRAP_STEPS = [
|
|
245
|
+
{
|
|
246
|
+
name: 'scaleway-bucket',
|
|
247
|
+
label: () => 'Creating Terraform state bucket',
|
|
248
|
+
skipMessage: 'Scaleway state bucket: already done, skipping.',
|
|
249
|
+
run: async (answers) => {
|
|
250
|
+
const { policyWarning } = await ensureStateBucket(answers);
|
|
251
|
+
return policyWarning ? { warning: policyWarning } : undefined;
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
name: 'infisical',
|
|
256
|
+
label: () => 'Bootstrapping Infisical project and secrets',
|
|
257
|
+
skipMessage: 'Infisical project: already done, skipping.',
|
|
258
|
+
run: async (answers) => {
|
|
259
|
+
const { projectId } = await bootstrapInfisical(answers);
|
|
260
|
+
return { data: { projectId } };
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
name: 'github-repo',
|
|
265
|
+
label: (_answers, ctx) => `Creating GitHub repository ${ctx.owner}/${ctx.repo}`,
|
|
266
|
+
skipMessage: 'GitHub repository: already done, skipping.',
|
|
267
|
+
run: async (_answers, ctx) => {
|
|
268
|
+
const { url } = await ensureRepo(ctx);
|
|
269
|
+
return { data: { url } };
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
name: 'github-push',
|
|
274
|
+
label: () => 'Pushing generated code to GitHub',
|
|
275
|
+
skipMessage: 'GitHub push: already done, skipping.',
|
|
276
|
+
run: async (answers, ctx) => {
|
|
277
|
+
pushRepo(ctx, answers.github.token, answers.targetDir);
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
name: 'github-config',
|
|
282
|
+
label: () => 'Setting GitHub secrets, variables and branch protection',
|
|
283
|
+
skipMessage: 'GitHub configuration: already done, skipping.',
|
|
284
|
+
run: async (answers, ctx, state) => {
|
|
285
|
+
// Recorded by the infisical step (this run or a resumed one).
|
|
286
|
+
await configureRepo(ctx, answers, stepData(state, 'infisical', 'projectId'));
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
];
|
|
290
|
+
async function runBootstrap(answers, state, options) {
|
|
202
291
|
let ctx;
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
ctx = await createContext(answers);
|
|
207
|
-
});
|
|
208
|
-
if (!isDone(state, 'scaleway-bucket')) {
|
|
209
|
-
await withSpinner('Creating Terraform state bucket', () => ensureStateBucket(answers));
|
|
210
|
-
markDone(dir, state, 'scaleway-bucket');
|
|
211
|
-
}
|
|
212
|
-
else {
|
|
213
|
-
log.info('Scaleway state bucket: already done, skipping.');
|
|
214
|
-
}
|
|
215
|
-
let infisicalProjectId = stepData(state, 'infisical', 'projectId');
|
|
216
|
-
if (!infisicalProjectId) {
|
|
217
|
-
const result = await withSpinner('Bootstrapping Infisical project and secrets', () => bootstrapInfisical(answers));
|
|
218
|
-
infisicalProjectId = result.projectId;
|
|
219
|
-
markDone(dir, state, 'infisical', { projectId: infisicalProjectId });
|
|
220
|
-
}
|
|
221
|
-
else {
|
|
222
|
-
log.info('Infisical project: already done, skipping.');
|
|
223
|
-
}
|
|
224
|
-
let repoUrl = stepData(state, 'github-repo', 'url');
|
|
225
|
-
if (!repoUrl) {
|
|
226
|
-
const repo = await withSpinner(`Creating GitHub repository ${ctx.owner}/${ctx.repo}`, () => ensureRepo(ctx));
|
|
227
|
-
repoUrl = repo.url;
|
|
228
|
-
markDone(dir, state, 'github-repo', { url: repoUrl });
|
|
292
|
+
if (options.preValidated) {
|
|
293
|
+
// Interactive runs validated each provider inline while prompting; only
|
|
294
|
+
// the GitHub context (octokit + owner) needs to be rebuilt here.
|
|
295
|
+
ctx = await createContext(answers.github);
|
|
229
296
|
}
|
|
230
297
|
else {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
298
|
+
// All three credentials are checked before anything is created anywhere,
|
|
299
|
+
// so a bad token cannot leave a half-bootstrapped account behind.
|
|
300
|
+
await withSpinner('Validating Scaleway, Infisical and GitHub credentials', async () => {
|
|
301
|
+
await validateScalewayCredentials(answers.scaleway);
|
|
302
|
+
await validateInfisical(answers.infisical);
|
|
303
|
+
ctx = await createContext(answers.github);
|
|
304
|
+
// A repo with commits would make the push fail after the bucket and the
|
|
305
|
+
// Infisical project were already created: fail here instead. Skipped on
|
|
306
|
+
// resume, where the previous run's push is the reason it is non-empty.
|
|
307
|
+
if (!isDone(state, 'github-push')) {
|
|
308
|
+
const { state: repoState } = await inspectRepo(ctx);
|
|
309
|
+
assertRepoUsable(ctx, repoState);
|
|
310
|
+
}
|
|
236
311
|
});
|
|
237
|
-
markDone(dir, state, 'github-push');
|
|
238
|
-
}
|
|
239
|
-
else {
|
|
240
|
-
log.info('GitHub push: already done, skipping.');
|
|
241
312
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
313
|
+
for (const step of BOOTSTRAP_STEPS) {
|
|
314
|
+
if (isDone(state, step.name)) {
|
|
315
|
+
log.info(step.skipMessage);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const result = await withSpinner(step.label(answers, ctx), () => step.run(answers, ctx, state));
|
|
319
|
+
markDone(answers.targetDir, state, step.name, result?.data);
|
|
320
|
+
if (result?.warning)
|
|
321
|
+
log.warn(result.warning);
|
|
248
322
|
}
|
|
249
|
-
return
|
|
323
|
+
return stepData(state, 'github-repo', 'url');
|
|
250
324
|
}
|
|
251
325
|
async function main() {
|
|
252
326
|
const { partial, flags } = parseCli(process.argv.slice(2));
|
|
@@ -255,7 +329,7 @@ async function main() {
|
|
|
255
329
|
const interactive = process.stdin.isTTY && !flags.yes;
|
|
256
330
|
let collected = partial;
|
|
257
331
|
if (interactive) {
|
|
258
|
-
collected = await fillMissing(partial, { advanced: flags.advanced });
|
|
332
|
+
collected = await fillMissing(partial, { advanced: flags.advanced, dryRun: flags.dryRun });
|
|
259
333
|
}
|
|
260
334
|
else if (!flags.dryRun) {
|
|
261
335
|
const missing = missingRequired(partial);
|
|
@@ -305,8 +379,8 @@ async function main() {
|
|
|
305
379
|
outro('Dry run complete. No account was touched.');
|
|
306
380
|
return;
|
|
307
381
|
}
|
|
308
|
-
const repoUrl = await runBootstrap(answers, state);
|
|
309
|
-
outro(renderNextSteps(answers, repoUrl, `rg.${answers.region}.scw.cloud/${answers.projectName
|
|
382
|
+
const repoUrl = await runBootstrap(answers, state, { preValidated: interactive });
|
|
383
|
+
outro(renderNextSteps(answers, repoUrl, `rg.${answers.region}.scw.cloud/${resourceName(answers.projectName, answers.environments[0].slug)}`));
|
|
310
384
|
}
|
|
311
385
|
main().catch((error) => {
|
|
312
386
|
if (error instanceof ConfigError || error instanceof GenerateError) {
|
package/dist/meta.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
/** Version of the running keel package (dist/ sits next to package.json). */
|
|
4
|
+
export function toolVersion() {
|
|
5
|
+
try {
|
|
6
|
+
const pkg = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'));
|
|
7
|
+
return pkg.version;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return '0.0.0';
|
|
11
|
+
}
|
|
12
|
+
}
|