@geekmidas/cli 1.8.0 → 1.9.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/{HostingerProvider-BiXdHjiq.cjs → HostingerProvider-5KYmwoK2.cjs} +1 -1
  3. package/dist/{HostingerProvider-BiXdHjiq.cjs.map → HostingerProvider-5KYmwoK2.cjs.map} +1 -1
  4. package/dist/{HostingerProvider-402UdK89.mjs → HostingerProvider-ANWchdiK.mjs} +1 -1
  5. package/dist/{HostingerProvider-402UdK89.mjs.map → HostingerProvider-ANWchdiK.mjs.map} +1 -1
  6. package/dist/{LocalStateProvider-CdspeSVL.cjs → LocalStateProvider-CLifRC0Y.cjs} +1 -1
  7. package/dist/{LocalStateProvider-CdspeSVL.cjs.map → LocalStateProvider-CLifRC0Y.cjs.map} +1 -1
  8. package/dist/{LocalStateProvider-BDm7ZqJo.mjs → LocalStateProvider-Dp0KkRcw.mjs} +1 -1
  9. package/dist/{LocalStateProvider-BDm7ZqJo.mjs.map → LocalStateProvider-Dp0KkRcw.mjs.map} +1 -1
  10. package/dist/{Route53Provider-DbBo7Uz5.mjs → Route53Provider-QoPgcXxn.mjs} +1 -1
  11. package/dist/{Route53Provider-DbBo7Uz5.mjs.map → Route53Provider-QoPgcXxn.mjs.map} +1 -1
  12. package/dist/{Route53Provider-kfJ77LmL.cjs → Route53Provider-owQQ4pn6.cjs} +1 -1
  13. package/dist/{Route53Provider-kfJ77LmL.cjs.map → Route53Provider-owQQ4pn6.cjs.map} +1 -1
  14. package/dist/{SSMStateProvider-DGrqYll0.cjs → SSMStateProvider-CT8tjl9o.cjs} +1 -1
  15. package/dist/{SSMStateProvider-DGrqYll0.cjs.map → SSMStateProvider-CT8tjl9o.cjs.map} +1 -1
  16. package/dist/{SSMStateProvider-DT0WV-E_.mjs → SSMStateProvider-CksOTB8M.mjs} +1 -1
  17. package/dist/{SSMStateProvider-DT0WV-E_.mjs.map → SSMStateProvider-CksOTB8M.mjs.map} +1 -1
  18. package/dist/{backup-provisioner-BIArpmTr.mjs → backup-provisioner-BEXoHTuC.mjs} +1 -1
  19. package/dist/{backup-provisioner-BIArpmTr.mjs.map → backup-provisioner-BEXoHTuC.mjs.map} +1 -1
  20. package/dist/{backup-provisioner-B5e-F6zX.cjs → backup-provisioner-C4noe75O.cjs} +1 -1
  21. package/dist/{backup-provisioner-B5e-F6zX.cjs.map → backup-provisioner-C4noe75O.cjs.map} +1 -1
  22. package/dist/{bundler-DgXsOSxc.mjs → bundler-DQYjKFPm.mjs} +1 -1
  23. package/dist/{bundler-DgXsOSxc.mjs.map → bundler-DQYjKFPm.mjs.map} +1 -1
  24. package/dist/{bundler-tHLLwYuU.cjs → bundler-NpfYPBUo.cjs} +1 -1
  25. package/dist/{bundler-tHLLwYuU.cjs.map → bundler-NpfYPBUo.cjs.map} +1 -1
  26. package/dist/config.d.mts +2 -2
  27. package/dist/fullstack-secrets-COWz084x.cjs +238 -0
  28. package/dist/fullstack-secrets-COWz084x.cjs.map +1 -0
  29. package/dist/fullstack-secrets-UZAFWuH4.mjs +202 -0
  30. package/dist/fullstack-secrets-UZAFWuH4.mjs.map +1 -0
  31. package/dist/{index-C-KxSGGK.d.mts → index-Bt2kX0-R.d.mts} +2 -2
  32. package/dist/{index-C-KxSGGK.d.mts.map → index-Bt2kX0-R.d.mts.map} +1 -1
  33. package/dist/index.cjs +1063 -730
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.mjs +1054 -721
  36. package/dist/index.mjs.map +1 -1
  37. package/dist/{openapi-react-query-DaTMSPD5.mjs → openapi-react-query-C4UdILaI.mjs} +1 -1
  38. package/dist/{openapi-react-query-DaTMSPD5.mjs.map → openapi-react-query-C4UdILaI.mjs.map} +1 -1
  39. package/dist/{openapi-react-query-BeXvk-wa.cjs → openapi-react-query-DYbBq-WJ.cjs} +1 -1
  40. package/dist/{openapi-react-query-BeXvk-wa.cjs.map → openapi-react-query-DYbBq-WJ.cjs.map} +1 -1
  41. package/dist/openapi-react-query.cjs +1 -1
  42. package/dist/openapi-react-query.mjs +1 -1
  43. package/dist/openapi.d.mts +1 -1
  44. package/dist/reconcile-7yarEvmK.cjs +36 -0
  45. package/dist/reconcile-7yarEvmK.cjs.map +1 -0
  46. package/dist/reconcile-D2WCDQue.mjs +36 -0
  47. package/dist/reconcile-D2WCDQue.mjs.map +1 -0
  48. package/dist/sync-6FoT41G3.mjs +3 -0
  49. package/dist/sync-CbeKrnQV.mjs +76 -0
  50. package/dist/sync-CbeKrnQV.mjs.map +1 -0
  51. package/dist/sync-DdkKaHqP.cjs +93 -0
  52. package/dist/sync-DdkKaHqP.cjs.map +1 -0
  53. package/dist/sync-RsnjXYwG.cjs +4 -0
  54. package/dist/{types-CZg5iUgD.d.mts → types-wXMIMOyK.d.mts} +1 -1
  55. package/dist/{types-CZg5iUgD.d.mts.map → types-wXMIMOyK.d.mts.map} +1 -1
  56. package/dist/workspace/index.d.mts +2 -2
  57. package/package.json +5 -5
  58. package/src/dev/__tests__/index.spec.ts +49 -0
  59. package/src/dev/index.ts +85 -64
  60. package/src/generators/SubscriberGenerator.ts +1 -0
  61. package/src/index.ts +171 -0
  62. package/src/init/index.ts +4 -23
  63. package/src/init/utils.ts +103 -2
  64. package/src/init/versions.ts +4 -4
  65. package/src/secrets/__tests__/reconcile.spec.ts +123 -0
  66. package/src/secrets/index.ts +20 -1
  67. package/src/secrets/reconcile.ts +53 -0
  68. package/src/secrets/sync.ts +136 -0
  69. package/src/setup/fullstack-secrets.ts +123 -0
  70. package/src/setup/index.ts +212 -0
  71. package/src/test/__tests__/web.spec.ts +1 -1
  72. package/src/upgrade/__tests__/index.spec.ts +354 -0
  73. package/src/upgrade/index.ts +253 -0
package/src/dev/index.ts CHANGED
@@ -1041,7 +1041,7 @@ export async function loadDevSecrets(
1041
1041
  }
1042
1042
 
1043
1043
  logger.warn(
1044
- '⚠️ Secrets enabled but no dev/development secrets found. Run "gkm secrets:init --stage dev"',
1044
+ '⚠️ Secrets enabled but no dev/development secrets found. Run "gkm setup" to initialize your development environment',
1045
1045
  );
1046
1046
  return {};
1047
1047
  }
@@ -1832,6 +1832,85 @@ class EntryRunner {
1832
1832
  }
1833
1833
  }
1834
1834
 
1835
+ /**
1836
+ * Generate the content of the dev server entry file (server.ts).
1837
+ * Uses dynamic import for createApp so Credentials are populated
1838
+ * before any app modules evaluate.
1839
+ * @internal Exported for testing
1840
+ */
1841
+ export function generateServerEntryContent(options: {
1842
+ secretsJsonPath?: string;
1843
+ runtime?: Runtime;
1844
+ enableOpenApi?: boolean;
1845
+ appImportPath?: string;
1846
+ }): string {
1847
+ const {
1848
+ secretsJsonPath,
1849
+ runtime = 'node',
1850
+ enableOpenApi = false,
1851
+ appImportPath = './app.js',
1852
+ } = options;
1853
+
1854
+ const credentialsInjection = secretsJsonPath
1855
+ ? `import { Credentials } from '@geekmidas/envkit/credentials';
1856
+ import { existsSync, readFileSync } from 'node:fs';
1857
+
1858
+ // Inject dev secrets into Credentials (must happen before app import)
1859
+ const secretsPath = '${secretsJsonPath}';
1860
+ if (existsSync(secretsPath)) {
1861
+ Object.assign(Credentials, JSON.parse(readFileSync(secretsPath, 'utf-8')));
1862
+ }
1863
+
1864
+ `
1865
+ : '';
1866
+
1867
+ const serveCode =
1868
+ runtime === 'bun'
1869
+ ? `Bun.serve({
1870
+ port,
1871
+ fetch: app.fetch,
1872
+ });`
1873
+ : `const { serve } = await import('@hono/node-server');
1874
+ const server = serve({
1875
+ fetch: app.fetch,
1876
+ port,
1877
+ });
1878
+ // Inject WebSocket support if available
1879
+ const injectWs = (app as any).__injectWebSocket;
1880
+ if (injectWs) {
1881
+ injectWs(server);
1882
+ console.log('🔌 Telescope real-time updates enabled');
1883
+ }`;
1884
+
1885
+ return `#!/usr/bin/env node
1886
+ /**
1887
+ * Development server entry point
1888
+ * This file is auto-generated by 'gkm dev'
1889
+ */
1890
+ ${credentialsInjection}
1891
+ const port = process.argv.includes('--port')
1892
+ ? Number.parseInt(process.argv[process.argv.indexOf('--port') + 1])
1893
+ : 3000;
1894
+
1895
+ // Dynamic import so Credentials are populated before env.ts evaluates
1896
+ const { createApp } = await import('${appImportPath}');
1897
+
1898
+ // createApp is async to support optional WebSocket setup
1899
+ const { app, start } = await createApp(undefined, ${enableOpenApi});
1900
+
1901
+ // Start the server
1902
+ start({
1903
+ port,
1904
+ serve: async (app, port) => {
1905
+ ${serveCode}
1906
+ },
1907
+ }).catch((error) => {
1908
+ console.error('Failed to start server:', error);
1909
+ process.exit(1);
1910
+ });
1911
+ `;
1912
+ }
1913
+
1835
1914
  class DevServer {
1836
1915
  private serverProcess: ChildProcess | null = null;
1837
1916
  private isRunning = false;
@@ -1997,72 +2076,14 @@ class DevServer {
1997
2076
 
1998
2077
  private async createServerEntry(): Promise<void> {
1999
2078
  const { writeFile: fsWriteFile } = await import('node:fs/promises');
2000
- const { relative, dirname } = await import('node:path');
2001
2079
 
2002
2080
  const serverPath = join(this.appRoot, '.gkm', this.provider, 'server.ts');
2003
2081
 
2004
- const relativeAppPath = relative(
2005
- dirname(serverPath),
2006
- join(dirname(serverPath), 'app.js'),
2007
- );
2008
-
2009
- // Generate credentials injection code if secrets are available
2010
- const credentialsInjection = this.secretsJsonPath
2011
- ? `import { Credentials } from '@geekmidas/envkit/credentials';
2012
- import { existsSync, readFileSync } from 'node:fs';
2013
-
2014
- // Inject dev secrets into Credentials (must happen before app import)
2015
- const secretsPath = '${this.secretsJsonPath}';
2016
- if (existsSync(secretsPath)) {
2017
- Object.assign(Credentials, JSON.parse(readFileSync(secretsPath, 'utf-8')));
2018
- }
2019
-
2020
- `
2021
- : '';
2022
-
2023
- const serveCode =
2024
- this.runtime === 'bun'
2025
- ? `Bun.serve({
2026
- port,
2027
- fetch: app.fetch,
2028
- });`
2029
- : `const { serve } = await import('@hono/node-server');
2030
- const server = serve({
2031
- fetch: app.fetch,
2032
- port,
2033
- });
2034
- // Inject WebSocket support if available
2035
- const injectWs = (app as any).__injectWebSocket;
2036
- if (injectWs) {
2037
- injectWs(server);
2038
- console.log('🔌 Telescope real-time updates enabled');
2039
- }`;
2040
-
2041
- const content = `#!/usr/bin/env node
2042
- /**
2043
- * Development server entry point
2044
- * This file is auto-generated by 'gkm dev'
2045
- */
2046
- ${credentialsInjection}import { createApp } from './${relativeAppPath.startsWith('.') ? relativeAppPath : `./${relativeAppPath}`}';
2047
-
2048
- const port = process.argv.includes('--port')
2049
- ? Number.parseInt(process.argv[process.argv.indexOf('--port') + 1])
2050
- : 3000;
2051
-
2052
- // createApp is async to support optional WebSocket setup
2053
- const { app, start } = await createApp(undefined, ${this.enableOpenApi});
2054
-
2055
- // Start the server
2056
- start({
2057
- port,
2058
- serve: async (app, port) => {
2059
- ${serveCode}
2060
- },
2061
- }).catch((error) => {
2062
- console.error('Failed to start server:', error);
2063
- process.exit(1);
2064
- });
2065
- `;
2082
+ const content = generateServerEntryContent({
2083
+ secretsJsonPath: this.secretsJsonPath,
2084
+ runtime: this.runtime,
2085
+ enableOpenApi: this.enableOpenApi,
2086
+ });
2066
2087
 
2067
2088
  await fsWriteFile(serverPath, content);
2068
2089
  }
@@ -164,6 +164,7 @@ export const handler = adapter.handler;
164
164
  * - sqs://region/account-id/queue-name (SQS queue)
165
165
  * - sns://region/account-id/topic-name (SNS topic)
166
166
  * - rabbitmq://host:port/queue-name (RabbitMQ)
167
+ * - pgboss://user:pass@host:port/database (pg-boss / PostgreSQL)
167
168
  * - basic://in-memory (In-memory for testing)
168
169
  */
169
170
  import type { EnvironmentParser } from '@geekmidas/envkit';
package/src/index.ts CHANGED
@@ -24,8 +24,10 @@ import {
24
24
  secretsSetCommand,
25
25
  secretsShowCommand,
26
26
  } from './secrets';
27
+ import { type SetupOptions, setupCommand } from './setup/index';
27
28
  import { type TestOptions, testCommand } from './test/index';
28
29
  import type { ComposeServiceName, LegacyProvider, MainProvider } from './types';
30
+ import { type UpgradeOptions, upgradeCommand } from './upgrade/index';
29
31
 
30
32
  const program = new Command();
31
33
 
@@ -61,6 +63,26 @@ program
61
63
  }
62
64
  });
63
65
 
66
+ program
67
+ .command('setup')
68
+ .description('Setup development environment (secrets, Docker, database)')
69
+ .option('--stage <stage>', 'Stage name', 'development')
70
+ .option('--force', 'Regenerate secrets even if they exist')
71
+ .option('--skip-docker', 'Skip starting Docker services')
72
+ .option('-y, --yes', 'Skip prompts')
73
+ .action(async (options: SetupOptions) => {
74
+ try {
75
+ const globalOptions = program.opts();
76
+ if (globalOptions.cwd) {
77
+ process.chdir(globalOptions.cwd);
78
+ }
79
+ await setupCommand(options);
80
+ } catch (error) {
81
+ console.error(error instanceof Error ? error.message : 'Command failed');
82
+ process.exit(1);
83
+ }
84
+ });
85
+
64
86
  program
65
87
  .command('build')
66
88
  .description('Build handlers from endpoints, functions, and crons')
@@ -483,6 +505,138 @@ program
483
505
  }
484
506
  });
485
507
 
508
+ program
509
+ .command('secrets:push')
510
+ .description('Push secrets to remote provider (SSM)')
511
+ .requiredOption('--stage <stage>', 'Stage name')
512
+ .action(async (options: { stage: string }) => {
513
+ try {
514
+ const globalOptions = program.opts();
515
+ if (globalOptions.cwd) {
516
+ process.chdir(globalOptions.cwd);
517
+ }
518
+
519
+ const { loadWorkspaceConfig } = await import('./config');
520
+ const { pushSecrets } = await import('./secrets/sync');
521
+ const { reconcileMissingSecrets } = await import('./secrets/reconcile');
522
+ const { readStageSecrets, writeStageSecrets } = await import(
523
+ './secrets/storage'
524
+ );
525
+
526
+ const { workspace } = await loadWorkspaceConfig();
527
+
528
+ const secrets = await readStageSecrets(options.stage, workspace.root);
529
+ if (secrets) {
530
+ const result = reconcileMissingSecrets(secrets, workspace);
531
+ if (result) {
532
+ await writeStageSecrets(result.secrets, workspace.root);
533
+ console.log(
534
+ ` Reconciled ${result.addedKeys.length} missing secret(s):`,
535
+ );
536
+ for (const key of result.addedKeys) {
537
+ console.log(` + ${key}`);
538
+ }
539
+ }
540
+ }
541
+
542
+ await pushSecrets(options.stage, workspace);
543
+ console.log(`\n✓ Secrets pushed for stage "${options.stage}"`);
544
+ } catch (error) {
545
+ console.error(error instanceof Error ? error.message : 'Command failed');
546
+ process.exit(1);
547
+ }
548
+ });
549
+
550
+ program
551
+ .command('secrets:pull')
552
+ .description('Pull secrets from remote provider (SSM)')
553
+ .requiredOption('--stage <stage>', 'Stage name')
554
+ .action(async (options: { stage: string }) => {
555
+ try {
556
+ const globalOptions = program.opts();
557
+ if (globalOptions.cwd) {
558
+ process.chdir(globalOptions.cwd);
559
+ }
560
+
561
+ const { loadWorkspaceConfig } = await import('./config');
562
+ const { pullSecrets } = await import('./secrets/sync');
563
+ const { writeStageSecrets } = await import('./secrets/storage');
564
+ const { reconcileMissingSecrets } = await import('./secrets/reconcile');
565
+
566
+ const { workspace } = await loadWorkspaceConfig();
567
+ let secrets = await pullSecrets(options.stage, workspace);
568
+
569
+ if (!secrets) {
570
+ console.error(`No remote secrets found for stage "${options.stage}".`);
571
+ process.exit(1);
572
+ }
573
+
574
+ const result = reconcileMissingSecrets(secrets, workspace);
575
+ if (result) {
576
+ secrets = result.secrets;
577
+ console.log(
578
+ ` Reconciled ${result.addedKeys.length} missing secret(s):`,
579
+ );
580
+ for (const key of result.addedKeys) {
581
+ console.log(` + ${key}`);
582
+ }
583
+ }
584
+
585
+ await writeStageSecrets(secrets, workspace.root);
586
+ console.log(`\n✓ Secrets pulled for stage "${options.stage}"`);
587
+ } catch (error) {
588
+ console.error(error instanceof Error ? error.message : 'Command failed');
589
+ process.exit(1);
590
+ }
591
+ });
592
+
593
+ program
594
+ .command('secrets:reconcile')
595
+ .description('Backfill missing custom secrets from workspace config')
596
+ .option('--stage <stage>', 'Stage name', 'development')
597
+ .action(async (options: { stage: string }) => {
598
+ try {
599
+ const globalOptions = program.opts();
600
+ if (globalOptions.cwd) {
601
+ process.chdir(globalOptions.cwd);
602
+ }
603
+
604
+ const { loadWorkspaceConfig } = await import('./config');
605
+ const { reconcileMissingSecrets } = await import('./secrets/reconcile');
606
+ const { readStageSecrets, writeStageSecrets } = await import(
607
+ './secrets/storage'
608
+ );
609
+
610
+ const { workspace } = await loadWorkspaceConfig();
611
+ const secrets = await readStageSecrets(options.stage, workspace.root);
612
+
613
+ if (!secrets) {
614
+ console.error(
615
+ `No secrets found for stage "${options.stage}". Run "gkm secrets:init --stage ${options.stage}" first.`,
616
+ );
617
+ process.exit(1);
618
+ }
619
+
620
+ const result = reconcileMissingSecrets(secrets, workspace);
621
+
622
+ if (!result) {
623
+ console.log(`\n✓ Secrets for stage "${options.stage}" are up-to-date`);
624
+ return;
625
+ }
626
+
627
+ await writeStageSecrets(result.secrets, workspace.root);
628
+ console.log(
629
+ `\n✓ Reconciled ${result.addedKeys.length} missing secret(s) for stage "${options.stage}":`,
630
+ );
631
+ for (const key of result.addedKeys) {
632
+ console.log(` + ${key}`);
633
+ }
634
+ } catch (error) {
635
+ console.error(error instanceof Error ? error.message : 'Command failed');
636
+ process.exit(1);
637
+ }
638
+ });
639
+
486
640
  // Deploy command
487
641
  program
488
642
  .command('deploy')
@@ -798,4 +952,21 @@ program
798
952
  }
799
953
  });
800
954
 
955
+ program
956
+ .command('upgrade')
957
+ .description('Upgrade all @geekmidas packages to their latest versions')
958
+ .option('--dry-run', 'Show what would be upgraded without making changes')
959
+ .action(async (options: UpgradeOptions) => {
960
+ try {
961
+ const globalOptions = program.opts();
962
+ if (globalOptions.cwd) {
963
+ process.chdir(globalOptions.cwd);
964
+ }
965
+ await upgradeCommand(options);
966
+ } catch (error) {
967
+ console.error(error instanceof Error ? error.message : 'Command failed');
968
+ process.exit(1);
969
+ }
970
+ });
971
+
801
972
  program.parse();
package/src/init/index.ts CHANGED
@@ -5,6 +5,10 @@ import prompts from 'prompts';
5
5
  import { createStageSecrets } from '../secrets/generator.js';
6
6
  import { getKeyPath } from '../secrets/keystore.js';
7
7
  import { writeStageSecrets } from '../secrets/storage.js';
8
+ import {
9
+ generateDbPassword,
10
+ generateDbUrl,
11
+ } from '../setup/fullstack-secrets.js';
8
12
  import type { ComposeServiceName } from '../types.js';
9
13
  import { generateAuthAppFiles } from './generators/auth.js';
10
14
  import { generateConfigFiles } from './generators/config.js';
@@ -60,29 +64,6 @@ export interface InitOptions {
60
64
  pm?: PackageManager;
61
65
  }
62
66
 
63
- /**
64
- * Generate a secure random password for database users
65
- */
66
- function generateDbPassword(): string {
67
- return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
68
- }
69
-
70
- /**
71
- * Generate database URL for an app
72
- * All apps connect to the same database, but use different users/schemas
73
- */
74
- function generateDbUrl(
75
- appName: string,
76
- password: string,
77
- projectName: string,
78
- host = 'localhost',
79
- port = 5432,
80
- ): string {
81
- const userName = appName.replace(/-/g, '_');
82
- const dbName = `${projectName.replace(/-/g, '_')}_dev`;
83
- return `postgresql://${userName}:${password}@${host}:${port}/${dbName}`;
84
- }
85
-
86
67
  /**
87
68
  * Main init command - scaffolds a new project
88
69
  */
package/src/init/utils.ts CHANGED
@@ -1,5 +1,7 @@
1
- import { existsSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, join, parse } from 'node:path';
3
+ import fg from 'fast-glob';
4
+ import { parse as parseYaml } from 'yaml';
3
5
 
4
6
  export type PackageManager = 'pnpm' | 'npm' | 'yarn' | 'bun';
5
7
 
@@ -94,3 +96,102 @@ export function getRunCommand(
94
96
  return `npm run ${script}`;
95
97
  }
96
98
  }
99
+
100
+ const lockfileByPm: Record<PackageManager, string> = {
101
+ pnpm: 'pnpm-lock.yaml',
102
+ yarn: 'yarn.lock',
103
+ npm: 'package-lock.json',
104
+ bun: 'bun.lockb',
105
+ };
106
+
107
+ /**
108
+ * Find the workspace/project root by walking up from cwd.
109
+ * Checks for PM-specific workspace config, package.json#workspaces,
110
+ * and lockfiles.
111
+ */
112
+ export function findWorkspaceRoot(cwd: string, pm: PackageManager): string {
113
+ let dir = cwd;
114
+ const root = parse(dir).root;
115
+ const lockfile = lockfileByPm[pm];
116
+
117
+ while (dir !== root) {
118
+ if (pm === 'pnpm' && existsSync(join(dir, 'pnpm-workspace.yaml'))) {
119
+ return dir;
120
+ }
121
+
122
+ const pkgJsonPath = join(dir, 'package.json');
123
+ if (existsSync(pkgJsonPath)) {
124
+ try {
125
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
126
+ if (pkg.workspaces) return dir;
127
+ } catch {
128
+ // ignore malformed package.json
129
+ }
130
+ }
131
+
132
+ if (existsSync(join(dir, lockfile))) {
133
+ return dir;
134
+ }
135
+
136
+ dir = dirname(dir);
137
+ }
138
+
139
+ return cwd;
140
+ }
141
+
142
+ /**
143
+ * Get workspace package glob patterns from pnpm-workspace.yaml
144
+ * or package.json#workspaces.
145
+ */
146
+ export function getWorkspaceGlobs(root: string): string[] {
147
+ const pnpmWorkspacePath = join(root, 'pnpm-workspace.yaml');
148
+ if (existsSync(pnpmWorkspacePath)) {
149
+ const content = readFileSync(pnpmWorkspacePath, 'utf-8');
150
+ const parsed = parseYaml(content);
151
+ return parsed?.packages ?? [];
152
+ }
153
+
154
+ const rootPkgJsonPath = join(root, 'package.json');
155
+ if (existsSync(rootPkgJsonPath)) {
156
+ const pkg = JSON.parse(readFileSync(rootPkgJsonPath, 'utf-8'));
157
+ if (Array.isArray(pkg.workspaces)) {
158
+ return pkg.workspaces;
159
+ }
160
+ if (pkg.workspaces?.packages) {
161
+ return pkg.workspaces.packages;
162
+ }
163
+ }
164
+
165
+ return [];
166
+ }
167
+
168
+ /**
169
+ * Find all package.json files across a workspace.
170
+ * Returns the root package.json plus all workspace member package.json paths.
171
+ */
172
+ export function findWorkspacePackages(
173
+ cwd: string,
174
+ pm: PackageManager,
175
+ ): string[] {
176
+ const workspaceRoot = findWorkspaceRoot(cwd, pm);
177
+ const results: string[] = [];
178
+
179
+ const rootPkgJson = join(workspaceRoot, 'package.json');
180
+ if (existsSync(rootPkgJson)) {
181
+ results.push(rootPkgJson);
182
+ }
183
+
184
+ const globs = getWorkspaceGlobs(workspaceRoot);
185
+
186
+ for (const glob of globs) {
187
+ const pattern = `${glob}/package.json`;
188
+ const matches = fg.sync(pattern, {
189
+ cwd: workspaceRoot,
190
+ absolute: true,
191
+ ignore: ['**/node_modules/**'],
192
+ });
193
+ results.push(...matches);
194
+ }
195
+
196
+ return [...new Set(results)];
197
+ }
@@ -30,14 +30,14 @@ export const GEEKMIDAS_VERSIONS = {
30
30
  '@geekmidas/audit': '~1.0.0',
31
31
  '@geekmidas/auth': '~1.0.0',
32
32
  '@geekmidas/cache': '~1.0.0',
33
- '@geekmidas/client': '~2.0.0',
33
+ '@geekmidas/client': '~3.0.0',
34
34
  '@geekmidas/cloud': '~1.0.0',
35
- '@geekmidas/constructs': '~1.1.1',
35
+ '@geekmidas/constructs': '~2.0.0',
36
36
  '@geekmidas/db': '~1.0.0',
37
37
  '@geekmidas/emailkit': '~1.0.0',
38
38
  '@geekmidas/envkit': '~1.0.3',
39
39
  '@geekmidas/errors': '~1.0.0',
40
- '@geekmidas/events': '~1.0.0',
40
+ '@geekmidas/events': '~1.1.0',
41
41
  '@geekmidas/logger': '~1.0.0',
42
42
  '@geekmidas/rate-limit': '~1.0.0',
43
43
  '@geekmidas/schema': '~1.0.0',
@@ -45,7 +45,7 @@ export const GEEKMIDAS_VERSIONS = {
45
45
  '@geekmidas/storage': '~1.0.0',
46
46
  '@geekmidas/studio': '~1.0.0',
47
47
  '@geekmidas/telescope': '~1.0.0',
48
- '@geekmidas/testkit': '~1.0.1',
48
+ '@geekmidas/testkit': '~1.0.2',
49
49
  '@geekmidas/cli': CLI_VERSION,
50
50
  } as const;
51
51
 
@@ -0,0 +1,123 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { generateFullstackCustomSecrets } from '../../setup/fullstack-secrets';
3
+ import type { NormalizedWorkspace } from '../../workspace/types';
4
+ import { reconcileMissingSecrets } from '../reconcile';
5
+ import type { StageSecrets } from '../types';
6
+
7
+ function createMultiAppWorkspace(
8
+ overrides?: Partial<NormalizedWorkspace>,
9
+ ): NormalizedWorkspace {
10
+ return {
11
+ name: 'test-project',
12
+ root: '/tmp/test',
13
+ apps: {
14
+ api: {
15
+ type: 'backend',
16
+ path: 'apps/api',
17
+ port: 3000,
18
+ dependencies: [],
19
+ resolvedDeployTarget: 'dokploy',
20
+ },
21
+ web: {
22
+ type: 'frontend',
23
+ path: 'apps/web',
24
+ port: 3001,
25
+ dependencies: ['api'],
26
+ resolvedDeployTarget: 'dokploy',
27
+ framework: 'nextjs',
28
+ },
29
+ },
30
+ services: { db: true },
31
+ deploy: {},
32
+ shared: {},
33
+ secrets: {},
34
+ ...overrides,
35
+ } as NormalizedWorkspace;
36
+ }
37
+
38
+ function createSecrets(custom: Record<string, string> = {}): StageSecrets {
39
+ return {
40
+ stage: 'development',
41
+ createdAt: '2024-01-01T00:00:00.000Z',
42
+ updatedAt: '2024-01-01T00:00:00.000Z',
43
+ services: {},
44
+ urls: {},
45
+ custom,
46
+ };
47
+ }
48
+
49
+ describe('reconcileMissingSecrets', () => {
50
+ it('should return null for single-app workspace', () => {
51
+ const workspace = createMultiAppWorkspace({
52
+ apps: {
53
+ api: {
54
+ type: 'backend',
55
+ path: 'apps/api',
56
+ port: 3000,
57
+ dependencies: [],
58
+ resolvedDeployTarget: 'dokploy',
59
+ },
60
+ },
61
+ }) as NormalizedWorkspace;
62
+
63
+ const result = reconcileMissingSecrets(createSecrets(), workspace);
64
+ expect(result).toBeNull();
65
+ });
66
+
67
+ it('should return null when no keys are missing', () => {
68
+ const workspace = createMultiAppWorkspace();
69
+ const expected = generateFullstackCustomSecrets(workspace);
70
+
71
+ const result = reconcileMissingSecrets(createSecrets(expected), workspace);
72
+ expect(result).toBeNull();
73
+ });
74
+
75
+ it('should backfill missing keys', () => {
76
+ const workspace = createMultiAppWorkspace();
77
+ const secrets = createSecrets();
78
+
79
+ const result = reconcileMissingSecrets(secrets, workspace);
80
+
81
+ expect(result).not.toBeNull();
82
+ expect(result!.addedKeys.length).toBeGreaterThan(0);
83
+ for (const key of result!.addedKeys) {
84
+ expect(result!.secrets.custom[key]).toBeDefined();
85
+ }
86
+ });
87
+
88
+ it('should never overwrite existing keys', () => {
89
+ const workspace = createMultiAppWorkspace();
90
+ const existingValue = 'my-custom-jwt-secret';
91
+ const secrets = createSecrets({
92
+ JWT_SECRET: existingValue,
93
+ });
94
+
95
+ const result = reconcileMissingSecrets(secrets, workspace);
96
+
97
+ expect(result).not.toBeNull();
98
+ expect(result!.secrets.custom.JWT_SECRET).toBe(existingValue);
99
+ expect(result!.addedKeys).not.toContain('JWT_SECRET');
100
+ });
101
+
102
+ it('should update updatedAt timestamp', () => {
103
+ const workspace = createMultiAppWorkspace();
104
+ const secrets = createSecrets();
105
+ const originalUpdatedAt = secrets.updatedAt;
106
+
107
+ const result = reconcileMissingSecrets(secrets, workspace);
108
+
109
+ expect(result).not.toBeNull();
110
+ expect(result!.secrets.updatedAt).not.toBe(originalUpdatedAt);
111
+ });
112
+
113
+ it('should backfill frontend URL keys', () => {
114
+ const workspace = createMultiAppWorkspace();
115
+ const secrets = createSecrets();
116
+
117
+ const result = reconcileMissingSecrets(secrets, workspace);
118
+
119
+ expect(result).not.toBeNull();
120
+ expect(result!.secrets.custom.WEB_URL).toBe('http://localhost:3001');
121
+ expect(result!.addedKeys).toContain('WEB_URL');
122
+ });
123
+ });