@gambi97/keel-cli 0.1.1
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/LICENSE +21 -0
- package/README.md +401 -0
- package/dist/bootstrap/github.js +190 -0
- package/dist/bootstrap/infisical.js +95 -0
- package/dist/bootstrap/scaleway.js +65 -0
- package/dist/config.js +153 -0
- package/dist/generate.js +104 -0
- package/dist/index.js +294 -0
- package/dist/prompts.js +109 -0
- package/dist/state.js +29 -0
- package/dist/ui.js +76 -0
- package/package.json +61 -0
- package/templates/.github/scripts/sync-database-url.sh +49 -0
- package/templates/.github/workflows/terraform-apply.yml +103 -0
- package/templates/.github/workflows/terraform-drift.yml +91 -0
- package/templates/.github/workflows/terraform-plan.yml +63 -0
- package/templates/LICENSE +21 -0
- package/templates/README.md +164 -0
- package/templates/_gitignore +28 -0
- package/templates/backend.hcl.example +9 -0
- package/templates/backend.tf +19 -0
- package/templates/main.tf +28 -0
- package/templates/modules/app_stack/main.tf +70 -0
- package/templates/modules/app_stack/outputs.tf +23 -0
- package/templates/modules/app_stack/variables.tf +55 -0
- package/templates/modules/app_stack/versions.tf +7 -0
- package/templates/outputs.tf +20 -0
- package/templates/prod.tfvars +7 -0
- package/templates/providers.tf +16 -0
- package/templates/staging.tfvars +7 -0
- package/templates/variables.tf +96 -0
- package/templates/versions.tf +15 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
export class InfisicalError extends Error {
|
|
3
|
+
}
|
|
4
|
+
async function api(host, path, options = {}) {
|
|
5
|
+
const response = await fetch(`${host}${path}`, {
|
|
6
|
+
method: options.method ?? 'GET',
|
|
7
|
+
headers: {
|
|
8
|
+
'Content-Type': 'application/json',
|
|
9
|
+
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
|
10
|
+
},
|
|
11
|
+
...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}),
|
|
12
|
+
});
|
|
13
|
+
const text = await response.text();
|
|
14
|
+
let data;
|
|
15
|
+
try {
|
|
16
|
+
data = (text ? JSON.parse(text) : {});
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
data = {};
|
|
20
|
+
}
|
|
21
|
+
return { status: response.status, data };
|
|
22
|
+
}
|
|
23
|
+
export async function login(answers) {
|
|
24
|
+
const { host, clientId, clientSecret } = answers.infisical;
|
|
25
|
+
const { status, data } = await api(host, '/api/v1/auth/universal-auth/login', { method: 'POST', body: { clientId, clientSecret } });
|
|
26
|
+
if (status !== 200 || !data.accessToken) {
|
|
27
|
+
throw new InfisicalError(`Infisical Universal Auth login failed (HTTP ${status}${data.message ? `: ${data.message}` : ''}). ` +
|
|
28
|
+
'Check the machine identity client ID/secret and that Universal Auth is enabled.');
|
|
29
|
+
}
|
|
30
|
+
return data.accessToken;
|
|
31
|
+
}
|
|
32
|
+
async function findProject(host, token, name) {
|
|
33
|
+
const { status, data } = await api(host, '/api/v1/workspace', { token });
|
|
34
|
+
if (status !== 200)
|
|
35
|
+
return undefined;
|
|
36
|
+
return data.workspaces?.find((w) => w.name === name);
|
|
37
|
+
}
|
|
38
|
+
async function createProject(host, token, name) {
|
|
39
|
+
const { status, data } = await api(host, '/api/v2/workspace', { method: 'POST', token, body: { projectName: name } });
|
|
40
|
+
if (status !== 200 || !data.project) {
|
|
41
|
+
throw new InfisicalError(`Could not create Infisical project "${name}" (HTTP ${status}${data.message ? `: ${data.message}` : ''}). ` +
|
|
42
|
+
'The machine identity needs permission to create projects.');
|
|
43
|
+
}
|
|
44
|
+
return data.project;
|
|
45
|
+
}
|
|
46
|
+
async function ensureEnvironment(host, token, project, slug) {
|
|
47
|
+
if (project.environments?.some((e) => e.slug === slug))
|
|
48
|
+
return;
|
|
49
|
+
const name = slug === 'prod' ? 'Production' : 'Staging';
|
|
50
|
+
const { status, data } = await api(host, `/api/v1/workspace/${project.id}/environments`, { method: 'POST', token, body: { name, slug } });
|
|
51
|
+
// 400 usually means the environment already exists: fine for idempotency.
|
|
52
|
+
if (status !== 200 && status !== 400) {
|
|
53
|
+
throw new InfisicalError(`Could not create Infisical environment "${slug}" (HTTP ${status}${data.message ? `: ${data.message}` : ''}).`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function seedSecret(host, token, projectId, environment, name, value) {
|
|
57
|
+
const { status, data } = await api(host, `/api/v3/secrets/raw/${name}`, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
token,
|
|
60
|
+
body: {
|
|
61
|
+
workspaceId: projectId,
|
|
62
|
+
environment,
|
|
63
|
+
secretPath: '/',
|
|
64
|
+
secretValue: value,
|
|
65
|
+
type: 'shared',
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
if (status === 400 && /exist/i.test(data.message ?? '')) {
|
|
69
|
+
return; // Already seeded on a previous run: never overwrite.
|
|
70
|
+
}
|
|
71
|
+
if (status !== 200) {
|
|
72
|
+
throw new InfisicalError(`Could not seed secret ${name} in ${environment} (HTTP ${status}${data.message ? `: ${data.message}` : ''}).`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Create/reuse the project, ensure staging+prod, seed placeholder secrets. */
|
|
76
|
+
export async function bootstrapInfisical(answers) {
|
|
77
|
+
const { host, projectName } = answers.infisical;
|
|
78
|
+
const token = await login(answers);
|
|
79
|
+
let createdProject = false;
|
|
80
|
+
let project = await findProject(host, token, projectName);
|
|
81
|
+
if (!project) {
|
|
82
|
+
project = await createProject(host, token, projectName);
|
|
83
|
+
createdProject = true;
|
|
84
|
+
}
|
|
85
|
+
await ensureEnvironment(host, token, project, 'staging');
|
|
86
|
+
await ensureEnvironment(host, token, project, 'prod');
|
|
87
|
+
if (answers.basicAuthStaging) {
|
|
88
|
+
await seedSecret(host, token, project.id, 'staging', 'BASIC_AUTH_USER', 'staging');
|
|
89
|
+
await seedSecret(host, token, project.id, 'staging', 'BASIC_AUTH_PASSWORD', randomBytes(18).toString('base64url'));
|
|
90
|
+
}
|
|
91
|
+
const placeholder = 'placeholder-updated-by-pipeline-after-first-apply';
|
|
92
|
+
await seedSecret(host, token, project.id, 'staging', 'DATABASE_URL', placeholder);
|
|
93
|
+
await seedSecret(host, token, project.id, 'prod', 'DATABASE_URL', placeholder);
|
|
94
|
+
return { projectId: project.id, createdProject };
|
|
95
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { CreateBucketCommand, HeadBucketCommand, PutBucketVersioningCommand, S3Client, } from '@aws-sdk/client-s3';
|
|
2
|
+
export class ScalewayError extends Error {
|
|
3
|
+
}
|
|
4
|
+
const API_BASE = 'https://api.scaleway.com';
|
|
5
|
+
/**
|
|
6
|
+
* Validate credentials with a harmless read call before creating anything.
|
|
7
|
+
* Also confirms the project belongs to the given organization.
|
|
8
|
+
*/
|
|
9
|
+
export async function validateScalewayCredentials(answers) {
|
|
10
|
+
const { secretKey, projectId, organizationId } = answers.scaleway;
|
|
11
|
+
const response = await fetch(`${API_BASE}/account/v3/projects/${projectId}`, {
|
|
12
|
+
headers: { 'X-Auth-Token': secretKey },
|
|
13
|
+
});
|
|
14
|
+
if (response.status === 401 || response.status === 403) {
|
|
15
|
+
throw new ScalewayError('Scaleway rejected the API key (401/403). Check SCW_ACCESS_KEY / SCW_SECRET_KEY and ' +
|
|
16
|
+
'make sure the key can read the project and manage Object Storage, Containers and Serverless SQL.');
|
|
17
|
+
}
|
|
18
|
+
if (response.status === 404) {
|
|
19
|
+
throw new ScalewayError(`Scaleway project ${projectId} not found with this API key.`);
|
|
20
|
+
}
|
|
21
|
+
if (!response.ok) {
|
|
22
|
+
throw new ScalewayError(`Scaleway API error while validating credentials: HTTP ${response.status}.`);
|
|
23
|
+
}
|
|
24
|
+
const project = (await response.json());
|
|
25
|
+
if (project.organization_id && project.organization_id !== organizationId) {
|
|
26
|
+
throw new ScalewayError(`Scaleway project ${projectId} belongs to organization ${project.organization_id}, ` +
|
|
27
|
+
`not ${organizationId}. Check --scw-organization-id.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function s3Client(answers) {
|
|
31
|
+
return new S3Client({
|
|
32
|
+
region: answers.region,
|
|
33
|
+
endpoint: `https://s3.${answers.region}.scw.cloud`,
|
|
34
|
+
credentials: {
|
|
35
|
+
accessKeyId: answers.scaleway.accessKey,
|
|
36
|
+
secretAccessKey: answers.scaleway.secretKey,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/** Create the Terraform state bucket if it does not exist yet (idempotent). */
|
|
41
|
+
export async function ensureStateBucket(answers) {
|
|
42
|
+
const client = s3Client(answers);
|
|
43
|
+
const bucket = answers.stateBucket;
|
|
44
|
+
try {
|
|
45
|
+
await client.send(new HeadBucketCommand({ Bucket: bucket }));
|
|
46
|
+
return { created: false }; // Already exists and we own it.
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
const status = error.$metadata?.httpStatusCode;
|
|
50
|
+
if (status === 403) {
|
|
51
|
+
throw new ScalewayError(`Bucket "${bucket}" already exists but is owned by another account. ` +
|
|
52
|
+
'Choose a different project name.');
|
|
53
|
+
}
|
|
54
|
+
if (status !== 404 && status !== 301) {
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
await client.send(new CreateBucketCommand({ Bucket: bucket }));
|
|
59
|
+
// Versioning protects the state history against accidental overwrites.
|
|
60
|
+
await client.send(new PutBucketVersioningCommand({
|
|
61
|
+
Bucket: bucket,
|
|
62
|
+
VersioningConfiguration: { Status: 'Enabled' },
|
|
63
|
+
}));
|
|
64
|
+
return { created: true };
|
|
65
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
export const REGIONS = ['fr-par', 'nl-ams', 'pl-waw'];
|
|
2
|
+
export const DEFAULT_INFISICAL_HOST = 'https://app.infisical.com';
|
|
3
|
+
export const DEFAULT_REGION = 'fr-par';
|
|
4
|
+
export const DEFAULT_SCALING = {
|
|
5
|
+
stagingMinScale: 0,
|
|
6
|
+
stagingMaxScale: 1,
|
|
7
|
+
prodMinScale: 0,
|
|
8
|
+
prodMaxScale: 2,
|
|
9
|
+
};
|
|
10
|
+
export class ConfigError extends Error {
|
|
11
|
+
}
|
|
12
|
+
const PROJECT_NAME_RE = /^[a-z](?:[a-z0-9-]{0,48}[a-z0-9])?$/;
|
|
13
|
+
export function validateProjectName(name) {
|
|
14
|
+
const trimmed = name.trim();
|
|
15
|
+
if (!PROJECT_NAME_RE.test(trimmed) || trimmed.includes('--')) {
|
|
16
|
+
throw new ConfigError(`Invalid project name "${trimmed}": use 1-50 lowercase letters, digits or single hyphens, ` +
|
|
17
|
+
'starting with a letter and not ending with a hyphen (DNS-safe).');
|
|
18
|
+
}
|
|
19
|
+
return trimmed;
|
|
20
|
+
}
|
|
21
|
+
export function validateRegion(region) {
|
|
22
|
+
const trimmed = region.trim();
|
|
23
|
+
if (!REGIONS.includes(trimmed)) {
|
|
24
|
+
throw new ConfigError(`Invalid region "${region}". Supported regions: ${REGIONS.join(', ')}.`);
|
|
25
|
+
}
|
|
26
|
+
return trimmed;
|
|
27
|
+
}
|
|
28
|
+
export function validateScale(value, label) {
|
|
29
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
30
|
+
if (!Number.isInteger(n) || n < 0 || n > 20) {
|
|
31
|
+
throw new ConfigError(`Invalid ${label}: expected an integer between 0 and 20.`);
|
|
32
|
+
}
|
|
33
|
+
return n;
|
|
34
|
+
}
|
|
35
|
+
export function validateUrl(value, label) {
|
|
36
|
+
let parsed;
|
|
37
|
+
try {
|
|
38
|
+
parsed = new URL(value.trim());
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
throw new ConfigError(`Invalid ${label}: "${value}" is not a valid URL.`);
|
|
42
|
+
}
|
|
43
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
44
|
+
throw new ConfigError(`Invalid ${label}: only http(s) URLs are supported.`);
|
|
45
|
+
}
|
|
46
|
+
return parsed.origin;
|
|
47
|
+
}
|
|
48
|
+
function requireString(value, label) {
|
|
49
|
+
const trimmed = value?.trim();
|
|
50
|
+
if (!trimmed) {
|
|
51
|
+
throw new ConfigError(`Missing required value: ${label}.`);
|
|
52
|
+
}
|
|
53
|
+
return trimmed;
|
|
54
|
+
}
|
|
55
|
+
export function stateBucketName(projectName) {
|
|
56
|
+
return `${projectName}-tfstate`;
|
|
57
|
+
}
|
|
58
|
+
/** Read partial answers from environment variables. */
|
|
59
|
+
export function fromEnv(env) {
|
|
60
|
+
return {
|
|
61
|
+
scaleway: {
|
|
62
|
+
accessKey: env.SCW_ACCESS_KEY,
|
|
63
|
+
secretKey: env.SCW_SECRET_KEY,
|
|
64
|
+
projectId: env.SCW_DEFAULT_PROJECT_ID ?? env.SCW_PROJECT_ID,
|
|
65
|
+
organizationId: env.SCW_DEFAULT_ORGANIZATION_ID ?? env.SCW_ORGANIZATION_ID,
|
|
66
|
+
},
|
|
67
|
+
infisical: {
|
|
68
|
+
host: env.INFISICAL_HOST,
|
|
69
|
+
clientId: env.INFISICAL_CLIENT_ID,
|
|
70
|
+
clientSecret: env.INFISICAL_CLIENT_SECRET,
|
|
71
|
+
},
|
|
72
|
+
github: {
|
|
73
|
+
token: env.GITHUB_TOKEN ?? env.GH_TOKEN,
|
|
74
|
+
},
|
|
75
|
+
scaling: {},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Merge partial answers; later sources win over earlier ones. */
|
|
79
|
+
export function mergeAnswers(...sources) {
|
|
80
|
+
const out = { scaleway: {}, infisical: {}, github: {}, scaling: {} };
|
|
81
|
+
for (const src of sources) {
|
|
82
|
+
for (const key of ['projectName', 'region', 'targetDir', 'basicAuthStaging']) {
|
|
83
|
+
const value = src[key];
|
|
84
|
+
if (value !== undefined) {
|
|
85
|
+
out[key] = value;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const group of ['scaleway', 'infisical', 'github', 'scaling']) {
|
|
89
|
+
for (const [k, v] of Object.entries(src[group] ?? {})) {
|
|
90
|
+
if (v !== undefined) {
|
|
91
|
+
out[group][k] = v;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
/** Validate a fully-collected set of answers and freeze it into a Config. */
|
|
99
|
+
export function finalizeAnswers(partial) {
|
|
100
|
+
const projectName = validateProjectName(requireString(partial.projectName, 'project name'));
|
|
101
|
+
const region = validateRegion(partial.region ?? DEFAULT_REGION);
|
|
102
|
+
return {
|
|
103
|
+
projectName,
|
|
104
|
+
region,
|
|
105
|
+
targetDir: partial.targetDir?.trim() || projectName,
|
|
106
|
+
stateBucket: stateBucketName(projectName),
|
|
107
|
+
scaleway: {
|
|
108
|
+
accessKey: requireString(partial.scaleway.accessKey, 'Scaleway access key'),
|
|
109
|
+
secretKey: requireString(partial.scaleway.secretKey, 'Scaleway secret key'),
|
|
110
|
+
projectId: requireString(partial.scaleway.projectId, 'Scaleway project ID'),
|
|
111
|
+
organizationId: requireString(partial.scaleway.organizationId, 'Scaleway organization ID'),
|
|
112
|
+
},
|
|
113
|
+
infisical: {
|
|
114
|
+
host: validateUrl(partial.infisical.host ?? DEFAULT_INFISICAL_HOST, 'Infisical host'),
|
|
115
|
+
clientId: requireString(partial.infisical.clientId, 'Infisical client ID'),
|
|
116
|
+
clientSecret: requireString(partial.infisical.clientSecret, 'Infisical client secret'),
|
|
117
|
+
projectName: partial.infisical.projectName?.trim() || projectName,
|
|
118
|
+
},
|
|
119
|
+
github: {
|
|
120
|
+
token: requireString(partial.github.token, 'GitHub token'),
|
|
121
|
+
repoName: validateProjectName(partial.github.repoName?.trim() || projectName),
|
|
122
|
+
repoPrivate: partial.github.repoPrivate ?? false,
|
|
123
|
+
},
|
|
124
|
+
basicAuthStaging: partial.basicAuthStaging ?? true,
|
|
125
|
+
scaling: {
|
|
126
|
+
stagingMinScale: validateScale(partial.scaling.stagingMinScale ?? DEFAULT_SCALING.stagingMinScale, 'staging min scale'),
|
|
127
|
+
stagingMaxScale: validateScale(partial.scaling.stagingMaxScale ?? DEFAULT_SCALING.stagingMaxScale, 'staging max scale'),
|
|
128
|
+
prodMinScale: validateScale(partial.scaling.prodMinScale ?? DEFAULT_SCALING.prodMinScale, 'prod min scale'),
|
|
129
|
+
prodMaxScale: validateScale(partial.scaling.prodMaxScale ?? DEFAULT_SCALING.prodMaxScale, 'prod max scale'),
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/** List which required values are still missing, for non-interactive runs. */
|
|
134
|
+
export function missingRequired(partial) {
|
|
135
|
+
const missing = [];
|
|
136
|
+
if (!partial.projectName?.trim())
|
|
137
|
+
missing.push('project name (--name)');
|
|
138
|
+
if (!partial.scaleway.accessKey?.trim())
|
|
139
|
+
missing.push('Scaleway access key (--scw-access-key or SCW_ACCESS_KEY)');
|
|
140
|
+
if (!partial.scaleway.secretKey?.trim())
|
|
141
|
+
missing.push('Scaleway secret key (--scw-secret-key or SCW_SECRET_KEY)');
|
|
142
|
+
if (!partial.scaleway.projectId?.trim())
|
|
143
|
+
missing.push('Scaleway project ID (--scw-project-id or SCW_DEFAULT_PROJECT_ID)');
|
|
144
|
+
if (!partial.scaleway.organizationId?.trim())
|
|
145
|
+
missing.push('Scaleway organization ID (--scw-organization-id or SCW_DEFAULT_ORGANIZATION_ID)');
|
|
146
|
+
if (!partial.infisical.clientId?.trim())
|
|
147
|
+
missing.push('Infisical client ID (--infisical-client-id or INFISICAL_CLIENT_ID)');
|
|
148
|
+
if (!partial.infisical.clientSecret?.trim())
|
|
149
|
+
missing.push('Infisical client secret (--infisical-client-secret or INFISICAL_CLIENT_SECRET)');
|
|
150
|
+
if (!partial.github.token?.trim())
|
|
151
|
+
missing.push('GitHub token (--github-token or GITHUB_TOKEN)');
|
|
152
|
+
return missing;
|
|
153
|
+
}
|
package/dist/generate.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { STATE_FILE } from './state.js';
|
|
6
|
+
export class GenerateError extends Error {
|
|
7
|
+
}
|
|
8
|
+
/** Files whose real name would confuse npm packaging are stored renamed. */
|
|
9
|
+
const RENAMES = {
|
|
10
|
+
_gitignore: '.gitignore',
|
|
11
|
+
};
|
|
12
|
+
export function templatesDir() {
|
|
13
|
+
return fileURLToPath(new URL('../templates', import.meta.url));
|
|
14
|
+
}
|
|
15
|
+
export function tokenMap(answers) {
|
|
16
|
+
return {
|
|
17
|
+
__PROJECT_NAME__: answers.projectName,
|
|
18
|
+
__REGION__: answers.region,
|
|
19
|
+
__TF_STATE_BUCKET__: answers.stateBucket,
|
|
20
|
+
__STAGING_MIN_SCALE__: String(answers.scaling.stagingMinScale),
|
|
21
|
+
__STAGING_MAX_SCALE__: String(answers.scaling.stagingMaxScale),
|
|
22
|
+
__PROD_MIN_SCALE__: String(answers.scaling.prodMinScale),
|
|
23
|
+
__PROD_MAX_SCALE__: String(answers.scaling.prodMaxScale),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function renderContent(content, tokens, file) {
|
|
27
|
+
let out = content;
|
|
28
|
+
for (const [token, value] of Object.entries(tokens)) {
|
|
29
|
+
out = out.replaceAll(token, value);
|
|
30
|
+
}
|
|
31
|
+
const leftover = out.match(/__[A-Z0-9_]+__/);
|
|
32
|
+
if (leftover) {
|
|
33
|
+
throw new GenerateError(`Template ${file} contains an unknown token: ${leftover[0]}`);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
function walk(dir, base = '') {
|
|
38
|
+
const files = [];
|
|
39
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
40
|
+
const rel = base ? `${base}/${entry.name}` : entry.name;
|
|
41
|
+
if (entry.isDirectory()) {
|
|
42
|
+
files.push(...walk(join(dir, entry.name), rel));
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
files.push(rel);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return files;
|
|
49
|
+
}
|
|
50
|
+
/** Render all templates into targetDir and create the initial git commit. */
|
|
51
|
+
export function generateProject(answers, options = {}) {
|
|
52
|
+
const source = templatesDir();
|
|
53
|
+
const tokens = tokenMap(answers);
|
|
54
|
+
const target = answers.targetDir;
|
|
55
|
+
if (existsSync(target) && readdirSync(target).some((f) => f !== STATE_FILE)) {
|
|
56
|
+
throw new GenerateError(`Directory "${target}" already exists and is not empty. ` +
|
|
57
|
+
'Pick a different name with --dir or remove it first.');
|
|
58
|
+
}
|
|
59
|
+
mkdirSync(target, { recursive: true });
|
|
60
|
+
const written = [];
|
|
61
|
+
for (const rel of walk(source)) {
|
|
62
|
+
const parts = rel.split('/');
|
|
63
|
+
const fileName = parts[parts.length - 1] ?? rel;
|
|
64
|
+
const destRel = [...parts.slice(0, -1), RENAMES[fileName] ?? fileName].join('/');
|
|
65
|
+
const destPath = join(target, destRel);
|
|
66
|
+
mkdirSync(join(target, ...parts.slice(0, -1)), { recursive: true });
|
|
67
|
+
const rendered = renderContent(readFileSync(join(source, rel), 'utf8'), tokens, rel);
|
|
68
|
+
writeFileSync(destPath, rendered);
|
|
69
|
+
if (destRel.endsWith('.sh')) {
|
|
70
|
+
chmodSync(destPath, 0o755);
|
|
71
|
+
}
|
|
72
|
+
written.push(destRel);
|
|
73
|
+
}
|
|
74
|
+
// backend.hcl (git-ignored) so local terraform runs work out of the box.
|
|
75
|
+
cpSync(join(target, 'backend.hcl.example'), join(target, 'backend.hcl'));
|
|
76
|
+
if (options.git !== false) {
|
|
77
|
+
gitInit(target);
|
|
78
|
+
}
|
|
79
|
+
return written;
|
|
80
|
+
}
|
|
81
|
+
function run(cwd, args, extraEnv = {}) {
|
|
82
|
+
const result = spawnSync('git', args, {
|
|
83
|
+
cwd,
|
|
84
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
85
|
+
env: { ...process.env, ...extraEnv },
|
|
86
|
+
});
|
|
87
|
+
if (result.status !== 0) {
|
|
88
|
+
throw new GenerateError(`git ${args[0]} failed: ${result.stderr?.toString().trim()}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function gitInit(target) {
|
|
92
|
+
if (!existsSync(join(target, '.git'))) {
|
|
93
|
+
run(target, ['init', '-b', 'main']);
|
|
94
|
+
}
|
|
95
|
+
run(target, ['add', '-A']);
|
|
96
|
+
const status = spawnSync('git', ['status', '--porcelain'], { cwd: target });
|
|
97
|
+
if (status.stdout.toString().trim() === '') {
|
|
98
|
+
return; // Nothing to commit (resume case).
|
|
99
|
+
}
|
|
100
|
+
// Fall back to a tool identity when the user has no git identity configured.
|
|
101
|
+
const hasIdentity = spawnSync('git', ['config', 'user.email'], { cwd: target }).status === 0;
|
|
102
|
+
const identity = hasIdentity ? [] : ['-c', 'user.name=keel', '-c', 'user.email=noreply@keel'];
|
|
103
|
+
run(target, [...identity, 'commit', '-m', 'Initial infrastructure from keel']);
|
|
104
|
+
}
|