project-devkit 2.0.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.
@@ -0,0 +1,42 @@
1
+ import chalk from 'chalk';
2
+
3
+ function handleError(e) {
4
+ console.error(`Error: ${e.message}`);
5
+ process.exit(1);
6
+ }
7
+
8
+ /**
9
+ * Register docker plugin commands.
10
+ * @param {import('commander').Command} program
11
+ * @param {object} context
12
+ */
13
+ export function register(program, { config, utils, projectRoot, devkitRoot }) {
14
+ const dockerCmd = program
15
+ .command('docker')
16
+ .description(config.description || 'Docker environment management');
17
+
18
+ dockerCmd
19
+ .command('setup')
20
+ .description('Create required Docker networks (external networks from compose)')
21
+ .action(() => {
22
+ try {
23
+ const composeFile = config.compose_file || 'docker-compose.dev.yml';
24
+ const networks = utils.getComposeExternalNetworks(projectRoot, composeFile);
25
+ if (networks.length === 0) {
26
+ console.log('No external networks found in compose file.');
27
+ return;
28
+ }
29
+ const created = utils.ensureDockerNetworks(projectRoot, composeFile);
30
+ for (const net of networks) {
31
+ if (created.includes(net)) {
32
+ console.log(chalk.green(` Created network: ${net}`));
33
+ } else {
34
+ console.log(` Network exists: ${net}`);
35
+ }
36
+ }
37
+ console.log(chalk.green('Done!'));
38
+ } catch (e) {
39
+ handleError(e);
40
+ }
41
+ });
42
+ }
@@ -0,0 +1,40 @@
1
+ name: env
2
+ description: Manage .env files from examples + Infisical secrets
3
+
4
+ # Infisical configuration — override in .devkit.d/env.yml
5
+ infisical:
6
+ project_id: ~
7
+ site_url: ~
8
+ client_id_env: INFISICAL_UNIVERSAL_AUTH_CLIENT_ID
9
+ client_secret_env: INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET
10
+
11
+ # Subprojects — override in .devkit.d/env.yml
12
+ # Example:
13
+ # subprojects:
14
+ # myproject:
15
+ # path: .
16
+ # variants: [inner, outer]
17
+ # config_dir: config
18
+ # infisical_path: "/{project}/{variant}"
19
+ subprojects: {}
20
+
21
+ # Servers for deploy command — override in .devkit.d/env.yml
22
+ # Example:
23
+ # servers:
24
+ # test:
25
+ # ssh: user@host
26
+ # projects_path: /opt/projects
27
+ # confirm: false
28
+ # stage:
29
+ # ssh: user@host
30
+ # projects_path: /opt/projects
31
+ # confirm: true
32
+ servers: {}
33
+
34
+ # Containers for deploy — override in .devkit.d/env.yml
35
+ # Example:
36
+ # containers:
37
+ # myproject:
38
+ # inner: container-inner
39
+ # outer: container-outer
40
+ containers: {}
@@ -0,0 +1,249 @@
1
+ import { resolve } from 'path';
2
+ import { existsSync, readFileSync, copyFileSync, writeFileSync } from 'fs';
3
+ import { execSync } from 'child_process';
4
+ import chalk from 'chalk';
5
+
6
+ /**
7
+ * Get Infisical credentials from config + environment variables.
8
+ */
9
+ function getInfisicalCredentials(config) {
10
+ const infisical = config.infisical || {};
11
+ const clientIdEnv = infisical.client_id_env || 'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID';
12
+ const clientSecretEnv = infisical.client_secret_env || 'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET';
13
+
14
+ return {
15
+ siteUrl: infisical.site_url,
16
+ projectId: infisical.project_id,
17
+ clientId: process.env[clientIdEnv],
18
+ clientSecret: process.env[clientSecretEnv],
19
+ };
20
+ }
21
+
22
+ function hasInfisical(config) {
23
+ const creds = getInfisicalCredentials(config);
24
+ return !!(creds.siteUrl && creds.projectId && creds.clientId && creds.clientSecret);
25
+ }
26
+
27
+ /**
28
+ * Optionally validate .env against .env.schema if envschema is installed.
29
+ */
30
+ function validateWithEnvSchema(envFile, schemaFile) {
31
+ if (!existsSync(schemaFile)) return;
32
+
33
+ try {
34
+ execSync('command -v envschema', { stdio: 'pipe' });
35
+ } catch {
36
+ console.log(chalk.dim(' hint: install envschema-dsl for .env validation'));
37
+ return;
38
+ }
39
+
40
+ try {
41
+ execSync(`envschema validate --schema "${schemaFile}" --env "${envFile}"`, {
42
+ stdio: 'pipe', encoding: 'utf-8',
43
+ });
44
+ console.log(chalk.green(' .env.schema validation passed'));
45
+ } catch (e) {
46
+ console.log(chalk.yellow(` .env.schema validation warnings:\n${e.stdout || e.stderr || e.message}`));
47
+ }
48
+ }
49
+
50
+ // --- Commands ---
51
+
52
+ export async function status({ config, utils, projectRoot }) {
53
+ const subprojects = config.subprojects || {};
54
+
55
+ if (Object.keys(subprojects).length === 0) {
56
+ console.log(chalk.yellow('No subprojects configured. Add them to .devkit.d/env.yml'));
57
+ return;
58
+ }
59
+
60
+ for (const [name, cfg] of Object.entries(subprojects)) {
61
+ console.log(chalk.cyan.bold(`\n${name}`));
62
+ const basePath = resolve(projectRoot, cfg.path || '.');
63
+ console.log(` path: ${basePath}`);
64
+
65
+ if (!existsSync(basePath)) {
66
+ console.log(chalk.red(' directory not found'));
67
+ continue;
68
+ }
69
+
70
+ for (const variant of (cfg.variants || [])) {
71
+ const configDir = resolve(basePath, cfg.config_dir || 'config');
72
+ const exampleFile = resolve(configDir, `.env.${variant}.example`);
73
+ const envFile = resolve(configDir, `.env.${variant}`);
74
+
75
+ const parts = [];
76
+ parts.push(existsSync(exampleFile) ? chalk.green('example') : chalk.yellow('no example'));
77
+ parts.push(existsSync(envFile) ? chalk.green('.env') : chalk.red('no .env'));
78
+
79
+ console.log(` ${variant}: ${parts.join(' | ')}`);
80
+ }
81
+ }
82
+ }
83
+
84
+ export async function init({ config, utils, projectRoot, envName = 'dev', force = false }) {
85
+ const subprojects = config.subprojects || {};
86
+
87
+ if (Object.keys(subprojects).length === 0) {
88
+ console.log(chalk.yellow('No subprojects configured. Add them to .devkit.d/env.yml'));
89
+ return;
90
+ }
91
+
92
+ // Root .env (devkit secrets from Infisical path /)
93
+ if (hasInfisical(config)) {
94
+ const exampleFile = resolve(projectRoot, '.env.example');
95
+ const envFile = resolve(projectRoot, '.env');
96
+
97
+ console.log(chalk.cyan.bold('\ndevkit (root)'));
98
+
99
+ if (existsSync(exampleFile)) {
100
+ if (!existsSync(envFile) || force) {
101
+ copyFileSync(exampleFile, envFile);
102
+ const action = force && existsSync(envFile) ? 'overwritten' : 'created';
103
+ console.log(` .env ${action} from .env.example`);
104
+ } else {
105
+ console.log(` .env exists (skip)`);
106
+ }
107
+ }
108
+
109
+ if (existsSync(envFile)) {
110
+ try {
111
+ const creds = getInfisicalCredentials(config);
112
+ const secrets = await utils.fetchInfisicalSecrets({
113
+ ...creds, envSlug: envName, secretPath: '/',
114
+ });
115
+ if (secrets.length > 0) {
116
+ const secretsMap = {};
117
+ for (const s of secrets) secretsMap[s.secretKey] = s.secretValue;
118
+ const envContent = readFileSync(envFile, 'utf-8');
119
+ const { content, updated } = utils.mergeSecretsIntoEnv(envContent, secretsMap);
120
+ writeFileSync(envFile, content);
121
+ console.log(chalk.green(` +${updated} secrets from Infisical (/)`));
122
+ }
123
+ } catch (e) {
124
+ console.log(chalk.red(` Infisical error: ${e.message}`));
125
+ }
126
+ }
127
+ }
128
+
129
+ for (const [name, cfg] of Object.entries(subprojects)) {
130
+ const basePath = resolve(projectRoot, cfg.path || '.');
131
+ if (!existsSync(basePath)) {
132
+ console.log(chalk.red(` ${name}: directory not found (${basePath})`));
133
+ continue;
134
+ }
135
+
136
+ console.log(chalk.cyan.bold(`\n${name}`));
137
+
138
+ for (const variant of (cfg.variants || [])) {
139
+ const configDir = resolve(basePath, cfg.config_dir || 'config');
140
+ const exampleFile = resolve(configDir, `.env.${variant}.example`);
141
+ const envFile = resolve(configDir, `.env.${variant}`);
142
+
143
+ // Step 1: copy example → .env
144
+ if (existsSync(exampleFile)) {
145
+ if (!existsSync(envFile) || force) {
146
+ copyFileSync(exampleFile, envFile);
147
+ const action = force && existsSync(envFile) ? 'overwritten' : 'created';
148
+ console.log(` ${variant}: .env.${variant} ${action} from example`);
149
+ } else {
150
+ console.log(` ${variant}: .env.${variant} exists (skip)`);
151
+ }
152
+ } else {
153
+ console.log(chalk.yellow(` ${variant}: .env.${variant}.example not found`));
154
+ if (!existsSync(envFile)) continue;
155
+ }
156
+
157
+ // Step 2: pull secrets from Infisical
158
+ if (hasInfisical(config)) {
159
+ try {
160
+ const infisicalPath = (cfg.infisical_path || '/{project}/{variant}')
161
+ .replace('{project}', name)
162
+ .replace('{variant}', variant);
163
+ const creds = getInfisicalCredentials(config);
164
+ const secrets = await utils.fetchInfisicalSecrets({
165
+ ...creds, envSlug: envName, secretPath: infisicalPath,
166
+ });
167
+ if (secrets.length > 0) {
168
+ const secretsMap = {};
169
+ for (const s of secrets) secretsMap[s.secretKey] = s.secretValue;
170
+ if (secretsMap.DB_PASSWORD) {
171
+ secretsMap.POSTGRES_PASSWORD = secretsMap.DB_PASSWORD;
172
+ }
173
+ const envContent = readFileSync(envFile, 'utf-8');
174
+ const { content, updated } = utils.mergeSecretsIntoEnv(envContent, secretsMap);
175
+ writeFileSync(envFile, content);
176
+ console.log(chalk.green(` ${variant}: +${updated} secrets from Infisical (${infisicalPath})`));
177
+ } else {
178
+ console.log(` ${variant}: no secrets in Infisical (${infisicalPath})`);
179
+ }
180
+ } catch (e) {
181
+ console.log(chalk.red(` ${variant}: Infisical error: ${e.message}`));
182
+ }
183
+ }
184
+
185
+ // Step 3: validate with env-schema
186
+ const schemaFile = resolve(configDir, '.env.schema');
187
+ validateWithEnvSchema(envFile, schemaFile);
188
+ }
189
+ }
190
+
191
+ console.log(chalk.green('\nDone!'));
192
+ }
193
+
194
+ export async function pullSecrets({ config, utils, projectRoot, envName = 'dev' }) {
195
+ const subprojects = config.subprojects || {};
196
+
197
+ if (!hasInfisical(config)) {
198
+ console.log(chalk.yellow('Infisical not configured. Add credentials to .devkit.d/env.yml'));
199
+ return;
200
+ }
201
+
202
+ for (const [name, cfg] of Object.entries(subprojects)) {
203
+ const basePath = resolve(projectRoot, cfg.path || '.');
204
+ if (!existsSync(basePath)) continue;
205
+
206
+ console.log(chalk.cyan.bold(`\n${name}`));
207
+
208
+ for (const variant of (cfg.variants || [])) {
209
+ const configDir = resolve(basePath, cfg.config_dir || 'config');
210
+ const envFile = resolve(configDir, `.env.${variant}`);
211
+
212
+ if (!existsSync(envFile)) {
213
+ console.log(chalk.yellow(` ${variant}: .env.${variant} not found (run env init)`));
214
+ continue;
215
+ }
216
+
217
+ try {
218
+ const infisicalPath = (cfg.infisical_path || '/{project}/{variant}')
219
+ .replace('{project}', name)
220
+ .replace('{variant}', variant);
221
+ const creds = getInfisicalCredentials(config);
222
+ const secrets = await utils.fetchInfisicalSecrets({
223
+ ...creds, envSlug: envName, secretPath: infisicalPath,
224
+ });
225
+ if (secrets.length > 0) {
226
+ const secretsMap = {};
227
+ for (const s of secrets) secretsMap[s.secretKey] = s.secretValue;
228
+ if (secretsMap.DB_PASSWORD) {
229
+ secretsMap.POSTGRES_PASSWORD = secretsMap.DB_PASSWORD;
230
+ }
231
+ const envContent = readFileSync(envFile, 'utf-8');
232
+ const { content, updated } = utils.mergeSecretsIntoEnv(envContent, secretsMap);
233
+ writeFileSync(envFile, content);
234
+ console.log(chalk.green(` ${variant}: updated ${updated} secrets`));
235
+ } else {
236
+ console.log(` ${variant}: no secrets in ${infisicalPath}`);
237
+ }
238
+ } catch (e) {
239
+ console.log(chalk.red(` ${variant}: error: ${e.message}`));
240
+ }
241
+
242
+ // Validate
243
+ const schemaFile = resolve(configDir, '.env.schema');
244
+ validateWithEnvSchema(envFile, schemaFile);
245
+ }
246
+ }
247
+
248
+ console.log(chalk.green('\nDone!'));
249
+ }
@@ -0,0 +1,93 @@
1
+ import * as env from './env.mjs';
2
+ import * as infisical from './infisical.mjs';
3
+
4
+ function handleError(e) {
5
+ console.error(`Error: ${e.message}`);
6
+ process.exit(1);
7
+ }
8
+
9
+ /**
10
+ * Register env plugin commands.
11
+ * @param {import('commander').Command} program
12
+ * @param {object} context
13
+ */
14
+ export function register(program, { config, utils, projectRoot, devkitRoot }) {
15
+ const envCmd = program
16
+ .command('env')
17
+ .description(config.description || 'Manage .env files');
18
+
19
+ envCmd
20
+ .command('status')
21
+ .description('Show .env file status')
22
+ .action(() => env.status({ config, utils, projectRoot }).catch(handleError));
23
+
24
+ envCmd
25
+ .command('init')
26
+ .description('Initialize .env from examples + Infisical secrets')
27
+ .option('--env-name <env>', 'Infisical environment', 'dev')
28
+ .option('--force', 'Overwrite existing .env')
29
+ .action(opts => env.init({
30
+ config, utils, projectRoot,
31
+ envName: opts.envName, force: opts.force,
32
+ }).catch(handleError));
33
+
34
+ envCmd
35
+ .command('pull-secrets')
36
+ .description('Update only secrets in .env files')
37
+ .option('--env-name <env>', 'Infisical environment', 'dev')
38
+ .action(opts => env.pullSecrets({
39
+ config, utils, projectRoot,
40
+ envName: opts.envName,
41
+ }).catch(handleError));
42
+
43
+ // --- infisical subcommand ---
44
+
45
+ const infisicalCmd = envCmd
46
+ .command('infisical')
47
+ .description('Manage Infisical secrets');
48
+
49
+ infisicalCmd
50
+ .command('list')
51
+ .description('List secrets')
52
+ .option('--env <env>', 'Environment', 'dev')
53
+ .option('--path <path>', 'Secret path', '/')
54
+ .action(opts => infisical.list({
55
+ config, utils, env: opts.env, path: opts.path,
56
+ }).catch(handleError));
57
+
58
+ infisicalCmd
59
+ .command('get <name>')
60
+ .description('Get secret value')
61
+ .option('--env <env>', 'Environment', 'dev')
62
+ .option('--path <path>', 'Secret path', '/')
63
+ .action((name, opts) => infisical.get({
64
+ config, utils, name, env: opts.env, path: opts.path,
65
+ }).catch(handleError));
66
+
67
+ infisicalCmd
68
+ .command('set <keyValue>')
69
+ .description('Set secret (KEY=VALUE)')
70
+ .option('--env <env>', 'Environment', 'dev')
71
+ .option('--path <path>', 'Secret path', '/')
72
+ .action((kv, opts) => infisical.set({
73
+ config, utils, keyValue: kv, env: opts.env, path: opts.path,
74
+ }).catch(handleError));
75
+
76
+ infisicalCmd
77
+ .command('delete <name>')
78
+ .description('Delete secret')
79
+ .option('--env <env>', 'Environment', 'dev')
80
+ .option('--path <path>', 'Secret path', '/')
81
+ .action((name, opts) => infisical.del({
82
+ config, utils, name, env: opts.env, path: opts.path,
83
+ }).catch(handleError));
84
+
85
+ infisicalCmd
86
+ .command('deploy')
87
+ .description('Deploy secrets to server')
88
+ .requiredOption('--env <env>', 'Environment (test or stage)')
89
+ .option('--yes, -y', 'Skip confirmation')
90
+ .action(opts => infisical.deploy({
91
+ config, utils, env: opts.env, yes: opts.yes,
92
+ }).catch(handleError));
93
+ }
@@ -0,0 +1,152 @@
1
+ import chalk from 'chalk';
2
+ import { execSync } from 'child_process';
3
+
4
+ /**
5
+ * Get Infisical credentials from config + environment variables.
6
+ */
7
+ function getInfisicalCredentials(config) {
8
+ const infisical = config.infisical || {};
9
+ const clientIdEnv = infisical.client_id_env || 'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID';
10
+ const clientSecretEnv = infisical.client_secret_env || 'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET';
11
+
12
+ return {
13
+ siteUrl: infisical.site_url,
14
+ projectId: infisical.project_id,
15
+ clientId: process.env[clientIdEnv],
16
+ clientSecret: process.env[clientSecretEnv],
17
+ };
18
+ }
19
+
20
+ export async function list({ config, utils, env = 'dev', path = '/' }) {
21
+ const creds = getInfisicalCredentials(config);
22
+ const secrets = await utils.fetchInfisicalSecrets({
23
+ ...creds, envSlug: env, secretPath: path,
24
+ });
25
+ if (secrets.length === 0) { console.log('No secrets.'); return; }
26
+
27
+ for (const s of secrets) {
28
+ console.log(`${s.secretKey}=${s.secretValue}`);
29
+ }
30
+ }
31
+
32
+ export async function get({ config, utils, name, env = 'dev', path = '/' }) {
33
+ const creds = getInfisicalCredentials(config);
34
+ const client = await utils.getInfisicalClient(creds);
35
+ const secret = await client.getSecret({
36
+ projectId: creds.projectId,
37
+ environment: env,
38
+ path,
39
+ secretName: name,
40
+ });
41
+ console.log(secret.secretValue);
42
+ }
43
+
44
+ export async function set({ config, utils, keyValue, env = 'dev', path = '/' }) {
45
+ const idx = keyValue.indexOf('=');
46
+ if (idx === -1) throw new Error('Format: KEY=VALUE');
47
+
48
+ const key = keyValue.slice(0, idx);
49
+ const value = keyValue.slice(idx + 1);
50
+
51
+ const creds = getInfisicalCredentials(config);
52
+ const client = await utils.getInfisicalClient(creds);
53
+
54
+ try {
55
+ await client.updateSecret({
56
+ projectId: creds.projectId,
57
+ environment: env,
58
+ path,
59
+ secretName: key,
60
+ secretValue: value,
61
+ });
62
+ } catch {
63
+ await client.createSecret({
64
+ projectId: creds.projectId,
65
+ environment: env,
66
+ path,
67
+ secretName: key,
68
+ secretValue: value,
69
+ });
70
+ }
71
+
72
+ console.log(chalk.green(`\u2713 ${key} updated`));
73
+ }
74
+
75
+ export async function del({ config, utils, name, env = 'dev', path = '/' }) {
76
+ const creds = getInfisicalCredentials(config);
77
+ const client = await utils.getInfisicalClient(creds);
78
+ await client.deleteSecret({
79
+ projectId: creds.projectId,
80
+ environment: env,
81
+ path,
82
+ secretName: name,
83
+ });
84
+ console.log(chalk.green(`\u2713 ${name} deleted`));
85
+ }
86
+
87
+ export async function deploy({ config, utils, env, yes = false }) {
88
+ if (!env) throw new Error('Specify --env (test or stage)');
89
+
90
+ const servers = config.servers || {};
91
+ const server = servers[env];
92
+ if (!server) throw new Error(`No server for environment '${env}' (available: ${Object.keys(servers).join(', ')})`);
93
+
94
+ if (server.confirm && !yes) {
95
+ const readline = await import('readline');
96
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
97
+ const answer = await new Promise(resolve => {
98
+ rl.question(chalk.yellow(`Deploy to ${env} (${server.ssh}). Continue? (y/N) `), resolve);
99
+ });
100
+ rl.close();
101
+ if (answer.toLowerCase() !== 'y') { console.log('Cancelled'); return; }
102
+ }
103
+
104
+ console.log(chalk.cyan(`\nDeploying secrets to ${env} (${server.ssh})...\n`));
105
+
106
+ const containers = config.containers || {};
107
+ const creds = getInfisicalCredentials(config);
108
+
109
+ for (const [project, variants] of Object.entries(containers)) {
110
+ for (const [variant, container] of Object.entries(variants)) {
111
+ const secretPath = `/${project}/${variant}`;
112
+ const remotePath = `${server.projects_path}/${project}/config/.env.${variant}`;
113
+
114
+ process.stdout.write(` ${project}/${variant}: `);
115
+
116
+ try {
117
+ const secrets = await utils.fetchInfisicalSecrets({
118
+ ...creds, envSlug: env, secretPath,
119
+ });
120
+ if (secrets.length === 0) {
121
+ console.log(chalk.dim('no secrets — skip'));
122
+ continue;
123
+ }
124
+
125
+ const secretsMap = {};
126
+ for (const s of secrets) secretsMap[s.secretKey] = s.secretValue;
127
+
128
+ const currentEnv = utils.sshReadFile(server.ssh, remotePath);
129
+ if (!currentEnv) {
130
+ console.log(chalk.yellow('file not found — skip'));
131
+ continue;
132
+ }
133
+
134
+ const { content, updated } = utils.mergeSecretsIntoEnv(currentEnv, secretsMap);
135
+ utils.sshWriteFile(server.ssh, remotePath, content);
136
+
137
+ let restartOk = false;
138
+ try {
139
+ execSync(`ssh ${server.ssh} 'docker restart ${container}'`, { encoding: 'utf-8', timeout: 30000 });
140
+ restartOk = true;
141
+ } catch {}
142
+
143
+ const status = restartOk ? chalk.green(container) : chalk.red(`${container} not restarted`);
144
+ console.log(chalk.green(`${updated} secrets`) + chalk.dim(' \u2192 ') + status);
145
+ } catch (e) {
146
+ console.log(chalk.red(`error: ${e.message}`));
147
+ }
148
+ }
149
+ }
150
+
151
+ console.log(chalk.green('\n\u2713 Deploy complete'));
152
+ }