@geekmidas/cli 1.7.0 → 1.9.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/{HostingerProvider-BiXdHjiq.cjs → HostingerProvider-CEsQbmpY.cjs} +1 -1
  3. package/dist/{HostingerProvider-BiXdHjiq.cjs.map → HostingerProvider-CEsQbmpY.cjs.map} +1 -1
  4. package/dist/{HostingerProvider-402UdK89.mjs → HostingerProvider-DkahM5AP.mjs} +1 -1
  5. package/dist/{HostingerProvider-402UdK89.mjs.map → HostingerProvider-DkahM5AP.mjs.map} +1 -1
  6. package/dist/{LocalStateProvider-BDm7ZqJo.mjs → LocalStateProvider-DXIwWb7k.mjs} +1 -1
  7. package/dist/{LocalStateProvider-BDm7ZqJo.mjs.map → LocalStateProvider-DXIwWb7k.mjs.map} +1 -1
  8. package/dist/{LocalStateProvider-CdspeSVL.cjs → LocalStateProvider-Roi202l7.cjs} +1 -1
  9. package/dist/{LocalStateProvider-CdspeSVL.cjs.map → LocalStateProvider-Roi202l7.cjs.map} +1 -1
  10. package/dist/{Route53Provider-kfJ77LmL.cjs → Route53Provider-BqXeHzuc.cjs} +1 -1
  11. package/dist/{Route53Provider-kfJ77LmL.cjs.map → Route53Provider-BqXeHzuc.cjs.map} +1 -1
  12. package/dist/{Route53Provider-DbBo7Uz5.mjs → Route53Provider-Ckq_n5Be.mjs} +1 -1
  13. package/dist/{Route53Provider-DbBo7Uz5.mjs.map → Route53Provider-Ckq_n5Be.mjs.map} +1 -1
  14. package/dist/{SSMStateProvider-DGrqYll0.cjs → SSMStateProvider-BReQA5re.cjs} +1 -1
  15. package/dist/{SSMStateProvider-DGrqYll0.cjs.map → SSMStateProvider-BReQA5re.cjs.map} +1 -1
  16. package/dist/{SSMStateProvider-DT0WV-E_.mjs → SSMStateProvider-wddd0_-d.mjs} +1 -1
  17. package/dist/{SSMStateProvider-DT0WV-E_.mjs.map → SSMStateProvider-wddd0_-d.mjs.map} +1 -1
  18. package/dist/{backup-provisioner-BIArpmTr.mjs → backup-provisioner-BAExdDtc.mjs} +1 -1
  19. package/dist/{backup-provisioner-BIArpmTr.mjs.map → backup-provisioner-BAExdDtc.mjs.map} +1 -1
  20. package/dist/{backup-provisioner-B5e-F6zX.cjs → backup-provisioner-C8VK63I-.cjs} +1 -1
  21. package/dist/{backup-provisioner-B5e-F6zX.cjs.map → backup-provisioner-C8VK63I-.cjs.map} +1 -1
  22. package/dist/{bundler-DgXsOSxc.mjs → bundler-BxHyDhdt.mjs} +1 -1
  23. package/dist/{bundler-DgXsOSxc.mjs.map → bundler-BxHyDhdt.mjs.map} +1 -1
  24. package/dist/{bundler-tHLLwYuU.cjs → bundler-CuMIfXw5.cjs} +1 -1
  25. package/dist/{bundler-tHLLwYuU.cjs.map → bundler-CuMIfXw5.cjs.map} +1 -1
  26. package/dist/config.d.mts +2 -2
  27. package/dist/{index-C-KxSGGK.d.mts → index-BVNXOydm.d.mts} +2 -2
  28. package/dist/{index-C-KxSGGK.d.mts.map → index-BVNXOydm.d.mts.map} +1 -1
  29. package/dist/index.cjs +1019 -551
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.mjs +1017 -549
  32. package/dist/index.mjs.map +1 -1
  33. package/dist/openapi.d.mts +1 -1
  34. package/dist/sync-BOS0jKLn.cjs +93 -0
  35. package/dist/sync-BOS0jKLn.cjs.map +1 -0
  36. package/dist/sync-BnqNNc6O.mjs +3 -0
  37. package/dist/sync-BxFB34zW.cjs +4 -0
  38. package/dist/sync-CHfhmXF3.mjs +76 -0
  39. package/dist/sync-CHfhmXF3.mjs.map +1 -0
  40. package/dist/{types-CZg5iUgD.d.mts → types-eTlj5f2M.d.mts} +1 -1
  41. package/dist/{types-CZg5iUgD.d.mts.map → types-eTlj5f2M.d.mts.map} +1 -1
  42. package/dist/workspace/index.d.mts +2 -2
  43. package/package.json +4 -4
  44. package/src/dev/index.ts +1 -1
  45. package/src/generators/SubscriberGenerator.ts +1 -0
  46. package/src/index.ts +93 -0
  47. package/src/init/index.ts +4 -23
  48. package/src/init/utils.ts +103 -2
  49. package/src/init/versions.ts +1 -1
  50. package/src/secrets/index.ts +20 -1
  51. package/src/secrets/sync.ts +136 -0
  52. package/src/setup/fullstack-secrets.ts +121 -0
  53. package/src/setup/index.ts +212 -0
  54. package/src/test/__tests__/web.spec.ts +1 -1
  55. package/src/upgrade/__tests__/index.spec.ts +354 -0
  56. package/src/upgrade/index.ts +253 -0
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Secrets Sync via AWS SSM Parameter Store
3
+ *
4
+ * Stores and retrieves encrypted StageSecrets as SecureString parameters.
5
+ * Reuses the SSM infrastructure from the state provider.
6
+ *
7
+ * Parameter naming: /gkm/{workspaceName}/{stage}/secrets
8
+ */
9
+
10
+ import {
11
+ GetParameterCommand,
12
+ ParameterNotFound,
13
+ PutParameterCommand,
14
+ SSMClient,
15
+ type SSMClientConfig,
16
+ } from '@aws-sdk/client-ssm';
17
+ import type { SSMStateConfig } from '../deploy/StateProvider.js';
18
+ import type { NormalizedWorkspace } from '../workspace/types.js';
19
+ import type { StageSecrets } from './types.js';
20
+
21
+ /**
22
+ * Get the SSM parameter name for secrets.
23
+ */
24
+ function getSecretsParameterName(workspaceName: string, stage: string): string {
25
+ return `/gkm/${workspaceName}/${stage}/secrets`;
26
+ }
27
+
28
+ /**
29
+ * Create an SSM client from workspace state config.
30
+ */
31
+ function createSSMClient(config: SSMStateConfig): SSMClient {
32
+ const clientConfig: SSMClientConfig = {
33
+ region: config.region,
34
+ };
35
+
36
+ if (config.profile) {
37
+ const { fromIni } = require('@aws-sdk/credential-providers');
38
+ clientConfig.credentials = fromIni({ profile: config.profile });
39
+ }
40
+
41
+ return new SSMClient(clientConfig);
42
+ }
43
+
44
+ /**
45
+ * Push secrets to SSM Parameter Store.
46
+ *
47
+ * Stores the full StageSecrets object as a SecureString parameter.
48
+ */
49
+ export async function pushSecrets(
50
+ stage: string,
51
+ workspace: NormalizedWorkspace,
52
+ ): Promise<void> {
53
+ const config = workspace.state;
54
+ if (!config || config.provider !== 'ssm') {
55
+ throw new Error(
56
+ 'SSM state provider not configured. Add state: { provider: "ssm", region: "..." } to gkm.config.ts.',
57
+ );
58
+ }
59
+
60
+ if (!workspace.name) {
61
+ throw new Error(
62
+ 'Workspace name is required for SSM secrets sync. Set "name" in gkm.config.ts.',
63
+ );
64
+ }
65
+
66
+ const client = createSSMClient(config as SSMStateConfig);
67
+ const parameterName = getSecretsParameterName(workspace.name, stage);
68
+
69
+ const { readStageSecrets } = await import('./storage.js');
70
+ const secrets = await readStageSecrets(stage, workspace.root);
71
+
72
+ if (!secrets) {
73
+ throw new Error(
74
+ `No secrets found for stage "${stage}". Run "gkm secrets:init --stage ${stage}" first.`,
75
+ );
76
+ }
77
+
78
+ await client.send(
79
+ new PutParameterCommand({
80
+ Name: parameterName,
81
+ Value: JSON.stringify(secrets),
82
+ Type: 'SecureString',
83
+ Overwrite: true,
84
+ Description: `GKM secrets for ${workspace.name}/${stage}`,
85
+ }),
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Pull secrets from SSM Parameter Store.
91
+ *
92
+ * @returns StageSecrets or null if no secrets are stored remotely
93
+ */
94
+ export async function pullSecrets(
95
+ stage: string,
96
+ workspace: NormalizedWorkspace,
97
+ ): Promise<StageSecrets | null> {
98
+ const config = workspace.state;
99
+ if (!config || config.provider !== 'ssm') {
100
+ return null;
101
+ }
102
+
103
+ if (!workspace.name) {
104
+ return null;
105
+ }
106
+
107
+ const client = createSSMClient(config as SSMStateConfig);
108
+ const parameterName = getSecretsParameterName(workspace.name, stage);
109
+
110
+ try {
111
+ const response = await client.send(
112
+ new GetParameterCommand({
113
+ Name: parameterName,
114
+ WithDecryption: true,
115
+ }),
116
+ );
117
+
118
+ if (!response.Parameter?.Value) {
119
+ return null;
120
+ }
121
+
122
+ return JSON.parse(response.Parameter.Value) as StageSecrets;
123
+ } catch (error) {
124
+ if (error instanceof ParameterNotFound) {
125
+ return null;
126
+ }
127
+ throw error;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Check if SSM is configured for the workspace.
133
+ */
134
+ export function isSSMConfigured(workspace: NormalizedWorkspace): boolean {
135
+ return !!workspace.state && workspace.state.provider === 'ssm';
136
+ }
@@ -0,0 +1,121 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { generateSecurePassword } from '../secrets/generator.js';
4
+ import type { StageSecrets } from '../secrets/types.js';
5
+ import type { NormalizedWorkspace } from '../workspace/types.js';
6
+
7
+ /**
8
+ * Generate a secure random password for database users.
9
+ * Uses a combination of timestamp and random bytes for uniqueness.
10
+ */
11
+ export function generateDbPassword(): string {
12
+ return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
13
+ }
14
+
15
+ /**
16
+ * Generate database URL for an app.
17
+ * All apps connect to the same database, but use different users/schemas.
18
+ */
19
+ export function generateDbUrl(
20
+ appName: string,
21
+ password: string,
22
+ projectName: string,
23
+ host = 'localhost',
24
+ port = 5432,
25
+ ): string {
26
+ const userName = appName.replace(/-/g, '_');
27
+ const dbName = `${projectName.replace(/-/g, '_')}_dev`;
28
+ return `postgresql://${userName}:${password}@${host}:${port}/${dbName}`;
29
+ }
30
+
31
+ /**
32
+ * Generate fullstack-aware custom secrets for a workspace.
33
+ *
34
+ * Generates:
35
+ * - Common secrets: NODE_ENV, PORT, LOG_LEVEL, JWT_SECRET
36
+ * - Per-app database passwords and URLs for backend apps with db service
37
+ * - Better-auth secrets for apps using the better-auth framework
38
+ */
39
+ export function generateFullstackCustomSecrets(
40
+ workspace: NormalizedWorkspace,
41
+ ): Record<string, string> {
42
+ const hasDb = !!workspace.services.db;
43
+ const customs: Record<string, string> = {
44
+ NODE_ENV: 'development',
45
+ PORT: '3000',
46
+ LOG_LEVEL: 'debug',
47
+ JWT_SECRET: `dev-${Date.now()}-${Math.random().toString(36).slice(2)}`,
48
+ };
49
+
50
+ if (!hasDb) {
51
+ return customs;
52
+ }
53
+
54
+ // Collect all frontend ports for trusted origins
55
+ const frontendPorts: number[] = [];
56
+
57
+ for (const [appName, appConfig] of Object.entries(workspace.apps)) {
58
+ if (appConfig.type === 'frontend') {
59
+ frontendPorts.push(appConfig.port);
60
+ continue;
61
+ }
62
+
63
+ // Backend apps with database: generate per-app DB passwords and URLs
64
+ const password = generateDbPassword();
65
+ const upperName = appName.toUpperCase();
66
+
67
+ customs[`${upperName}_DATABASE_URL`] = generateDbUrl(
68
+ appName,
69
+ password,
70
+ workspace.name,
71
+ );
72
+ customs[`${upperName}_DB_PASSWORD`] = password;
73
+
74
+ // Better-auth framework secrets
75
+ if (appConfig.framework === 'better-auth') {
76
+ customs.AUTH_PORT = String(appConfig.port);
77
+ customs.AUTH_URL = `http://localhost:${appConfig.port}`;
78
+ customs.BETTER_AUTH_SECRET = `better-auth-${Date.now()}-${generateSecurePassword(16)}`;
79
+ customs.BETTER_AUTH_URL = `http://localhost:${appConfig.port}`;
80
+ }
81
+ }
82
+
83
+ // Generate trusted origins for better-auth (all app ports)
84
+ if (customs.BETTER_AUTH_SECRET) {
85
+ const allPorts = Object.values(workspace.apps).map((a) => a.port);
86
+ customs.BETTER_AUTH_TRUSTED_ORIGINS = allPorts
87
+ .map((p) => `http://localhost:${p}`)
88
+ .join(',');
89
+ }
90
+
91
+ return customs;
92
+ }
93
+
94
+ /**
95
+ * Extract *_DB_PASSWORD keys from secrets and write docker/.env.
96
+ *
97
+ * The docker/.env file contains database passwords that the PostgreSQL
98
+ * init script reads to create per-app database users.
99
+ */
100
+ export async function writeDockerEnvFromSecrets(
101
+ secrets: StageSecrets,
102
+ workspaceRoot: string,
103
+ ): Promise<void> {
104
+ const dbPasswordEntries = Object.entries(secrets.custom).filter(([key]) =>
105
+ key.endsWith('_DB_PASSWORD'),
106
+ );
107
+
108
+ if (dbPasswordEntries.length === 0) {
109
+ return;
110
+ }
111
+
112
+ const envContent = `# Auto-generated docker environment file
113
+ # Contains database passwords for docker-compose postgres init
114
+ # This file is gitignored - do not commit to version control
115
+ ${dbPasswordEntries.map(([key, value]) => `${key}=${value}`).join('\n')}
116
+ `;
117
+
118
+ const envPath = join(workspaceRoot, 'docker', '.env');
119
+ await mkdir(dirname(envPath), { recursive: true });
120
+ await writeFile(envPath, envContent);
121
+ }
@@ -0,0 +1,212 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import prompts from 'prompts';
4
+ import { loadWorkspaceConfig } from '../config.js';
5
+ import { startWorkspaceServices } from '../dev/index.js';
6
+ import { createStageSecrets } from '../secrets/generator.js';
7
+ import {
8
+ readStageSecrets,
9
+ secretsExist,
10
+ writeStageSecrets,
11
+ } from '../secrets/storage.js';
12
+ import { isSSMConfigured, pullSecrets, pushSecrets } from '../secrets/sync.js';
13
+ import type { ComposeServiceName } from '../types.js';
14
+ import type { LoadedConfig, NormalizedWorkspace } from '../workspace/types.js';
15
+ import {
16
+ generateFullstackCustomSecrets,
17
+ writeDockerEnvFromSecrets,
18
+ } from './fullstack-secrets.js';
19
+
20
+ const logger = console;
21
+
22
+ export interface SetupOptions {
23
+ stage?: string;
24
+ force?: boolean;
25
+ skipDocker?: boolean;
26
+ yes?: boolean;
27
+ }
28
+
29
+ /**
30
+ * Setup development environment.
31
+ *
32
+ * Orchestrates:
33
+ * 1. Load workspace config
34
+ * 2. Resolve secrets (local → SSM → generate fresh)
35
+ * 3. Write docker/.env from secrets
36
+ * 4. Start Docker services
37
+ */
38
+ export async function setupCommand(options: SetupOptions = {}): Promise<void> {
39
+ const stage = options.stage ?? 'development';
40
+
41
+ logger.log('\n🔧 Setting up development environment...\n');
42
+
43
+ // 1. Load workspace config
44
+ let loadedConfig: LoadedConfig;
45
+ try {
46
+ loadedConfig = await loadWorkspaceConfig();
47
+ } catch {
48
+ logger.error(
49
+ '❌ No gkm.config.ts found. Run this command from a workspace root.',
50
+ );
51
+ process.exit(1);
52
+ }
53
+
54
+ const { workspace } = loadedConfig;
55
+ const isMultiApp = Object.keys(workspace.apps).length > 1;
56
+
57
+ logger.log(`📦 Workspace: ${workspace.name}`);
58
+ logger.log(`📱 Apps: ${Object.keys(workspace.apps).join(', ')}`);
59
+ logger.log(`🔑 Stage: ${stage}\n`);
60
+
61
+ // 2. Resolve secrets
62
+ const secrets = await resolveSecrets(stage, workspace, options);
63
+
64
+ if (!secrets) {
65
+ logger.error('❌ Failed to resolve secrets. Exiting.');
66
+ process.exit(1);
67
+ }
68
+
69
+ // 3. Write docker/.env from secrets (always regenerated as derived file)
70
+ if (isMultiApp && workspace.services.db) {
71
+ await writeDockerEnvFromSecrets(secrets, workspace.root);
72
+ logger.log('📄 Generated docker/.env with database passwords');
73
+ }
74
+
75
+ // 4. Start Docker services
76
+ if (!options.skipDocker) {
77
+ const composeFile = join(workspace.root, 'docker-compose.yml');
78
+ if (existsSync(composeFile)) {
79
+ logger.log('');
80
+ await startWorkspaceServices(workspace);
81
+ } else {
82
+ logger.log('⚠️ No docker-compose.yml found. Skipping Docker services.');
83
+ }
84
+ }
85
+
86
+ // Print summary
87
+ printSummary(workspace, stage);
88
+ }
89
+
90
+ /**
91
+ * Resolve secrets with priority:
92
+ * 1. Local secrets exist → use them (preserves manual additions)
93
+ * 2. SSM configured and has secrets → pull and use
94
+ * 3. Neither → generate fresh secrets
95
+ *
96
+ * --force skips checks 1 and 2 and always regenerates.
97
+ */
98
+ async function resolveSecrets(
99
+ stage: string,
100
+ workspace: NormalizedWorkspace,
101
+ options: SetupOptions,
102
+ ) {
103
+ // Force regeneration
104
+ if (options.force) {
105
+ logger.log('🔐 Generating fresh secrets (--force)...');
106
+ return generateFreshSecrets(stage, workspace, options);
107
+ }
108
+
109
+ // Check local secrets first
110
+ if (secretsExist(stage, workspace.root)) {
111
+ logger.log('🔐 Using existing local secrets');
112
+ const secrets = await readStageSecrets(stage, workspace.root);
113
+ if (secrets) {
114
+ return secrets;
115
+ }
116
+ }
117
+
118
+ // Try SSM pull if configured
119
+ if (isSSMConfigured(workspace)) {
120
+ logger.log('☁️ Checking for remote secrets in SSM...');
121
+ try {
122
+ const remoteSecrets = await pullSecrets(stage, workspace);
123
+ if (remoteSecrets) {
124
+ logger.log('✅ Pulled secrets from SSM');
125
+ await writeStageSecrets(remoteSecrets, workspace.root);
126
+ return remoteSecrets;
127
+ }
128
+ logger.log(' No remote secrets found');
129
+ } catch (error) {
130
+ logger.warn(`⚠️ Failed to pull from SSM: ${(error as Error).message}`);
131
+ }
132
+ }
133
+
134
+ // Generate fresh secrets
135
+ logger.log('🔐 Generating fresh development secrets...');
136
+ return generateFreshSecrets(stage, workspace, options);
137
+ }
138
+
139
+ /**
140
+ * Generate fresh secrets for the workspace.
141
+ */
142
+ async function generateFreshSecrets(
143
+ stage: string,
144
+ workspace: NormalizedWorkspace,
145
+ options: SetupOptions,
146
+ ) {
147
+ // Determine services from workspace config
148
+ const serviceNames: ComposeServiceName[] = [];
149
+ if (workspace.services.db) serviceNames.push('postgres');
150
+ if (workspace.services.cache) serviceNames.push('redis');
151
+
152
+ // Create base secrets with service credentials
153
+ const secrets = createStageSecrets(stage, serviceNames);
154
+
155
+ // Generate fullstack-aware custom secrets
156
+ const isMultiApp = Object.keys(workspace.apps).length > 1;
157
+ if (isMultiApp) {
158
+ const customSecrets = generateFullstackCustomSecrets(workspace);
159
+ secrets.custom = customSecrets;
160
+ } else {
161
+ secrets.custom = {
162
+ NODE_ENV: 'development',
163
+ PORT: '3000',
164
+ LOG_LEVEL: 'debug',
165
+ JWT_SECRET: `dev-${Date.now()}-${Math.random().toString(36).slice(2)}`,
166
+ };
167
+ }
168
+
169
+ // Write secrets
170
+ await writeStageSecrets(secrets, workspace.root);
171
+ logger.log(` Secrets written to .gkm/secrets/${stage}.json`);
172
+
173
+ // Offer to push to SSM if configured
174
+ if (isSSMConfigured(workspace) && !options.yes) {
175
+ const { shouldPush } = await prompts({
176
+ type: 'confirm',
177
+ name: 'shouldPush',
178
+ message: 'Push secrets to SSM for team sharing?',
179
+ initial: true,
180
+ });
181
+
182
+ if (shouldPush) {
183
+ try {
184
+ await pushSecrets(stage, workspace);
185
+ logger.log('☁️ Secrets pushed to SSM');
186
+ } catch (error) {
187
+ logger.warn(`⚠️ Failed to push to SSM: ${(error as Error).message}`);
188
+ }
189
+ }
190
+ }
191
+
192
+ return secrets;
193
+ }
194
+
195
+ /**
196
+ * Print setup summary with next steps.
197
+ */
198
+ function printSummary(workspace: NormalizedWorkspace, stage: string): void {
199
+ logger.log(`\n${'─'.repeat(50)}`);
200
+ logger.log('\n✅ Development environment ready!\n');
201
+
202
+ logger.log('📋 Apps:');
203
+ for (const [name, app] of Object.entries(workspace.apps)) {
204
+ const icon = app.type === 'frontend' ? '🌐' : '🔧';
205
+ logger.log(` ${icon} ${name} → http://localhost:${app.port}`);
206
+ }
207
+
208
+ logger.log('\n🚀 Next steps:');
209
+ logger.log(' gkm dev # Start all apps');
210
+ logger.log(` gkm secrets:show --stage ${stage} # View secrets`);
211
+ logger.log('');
212
+ }
@@ -80,7 +80,7 @@ describe('web (frontend) app context', () => {
80
80
 
81
81
  it('should not have DATABASE_URL (frontend does not access database)', () => {
82
82
  // Frontend gets secrets mapped without an app-specific DATABASE_URL
83
- const secrets = mapSecretsForApp(createFullstackSecrets(), 'web');
83
+ const _secrets = mapSecretsForApp(createFullstackSecrets(), 'web');
84
84
 
85
85
  // No WEB_DATABASE_URL exists, so DATABASE_URL stays as default (api's)
86
86
  // In practice, frontend apps don't use DATABASE_URL at all