@geekmidas/cli 0.39.0 → 0.41.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/dist/{bundler-CyHg1v_T.cjs → bundler-BB-kETMd.cjs} +20 -49
- package/dist/bundler-BB-kETMd.cjs.map +1 -0
- package/dist/{bundler-DQIuE3Kn.mjs → bundler-DGry2vaR.mjs} +22 -51
- package/dist/bundler-DGry2vaR.mjs.map +1 -0
- package/dist/{config-BC5n1a2D.mjs → config-C0b0jdmU.mjs} +2 -2
- package/dist/{config-BC5n1a2D.mjs.map → config-C0b0jdmU.mjs.map} +1 -1
- package/dist/{config-BAE9LFC1.cjs → config-xVZsRjN7.cjs} +2 -2
- package/dist/{config-BAE9LFC1.cjs.map → config-xVZsRjN7.cjs.map} +1 -1
- package/dist/config.cjs +2 -2
- package/dist/config.d.cts +1 -1
- package/dist/config.d.mts +2 -2
- package/dist/config.mjs +2 -2
- package/dist/dokploy-api-Bdmk5ImW.cjs +3 -0
- package/dist/{dokploy-api-C5czOZoc.cjs → dokploy-api-BdxOMH_V.cjs} +43 -1
- package/dist/{dokploy-api-C5czOZoc.cjs.map → dokploy-api-BdxOMH_V.cjs.map} +1 -1
- package/dist/{dokploy-api-B9qR2Yn1.mjs → dokploy-api-DWsqNjwP.mjs} +43 -1
- package/dist/{dokploy-api-B9qR2Yn1.mjs.map → dokploy-api-DWsqNjwP.mjs.map} +1 -1
- package/dist/dokploy-api-tZSZaHd9.mjs +3 -0
- package/dist/{encryption-JtMsiGNp.mjs → encryption-BC4MAODn.mjs} +1 -1
- package/dist/{encryption-JtMsiGNp.mjs.map → encryption-BC4MAODn.mjs.map} +1 -1
- package/dist/encryption-Biq0EZ4m.cjs +4 -0
- package/dist/encryption-CQXBZGkt.mjs +3 -0
- package/dist/{encryption-BAz0xQ1Q.cjs → encryption-DaCB_NmS.cjs} +13 -3
- package/dist/{encryption-BAz0xQ1Q.cjs.map → encryption-DaCB_NmS.cjs.map} +1 -1
- package/dist/{index-C7TkoYmt.d.mts → index-CXa3odEw.d.mts} +68 -7
- package/dist/index-CXa3odEw.d.mts.map +1 -0
- package/dist/{index-CpchsC9w.d.cts → index-E8Nu2Rxl.d.cts} +67 -6
- package/dist/index-E8Nu2Rxl.d.cts.map +1 -0
- package/dist/index.cjs +698 -127
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +677 -106
- package/dist/index.mjs.map +1 -1
- package/dist/{openapi-CjYeF-Tg.mjs → openapi-D3pA6FfZ.mjs} +2 -2
- package/dist/{openapi-CjYeF-Tg.mjs.map → openapi-D3pA6FfZ.mjs.map} +1 -1
- package/dist/{openapi-a-e3Y8WA.cjs → openapi-DhcCtKzM.cjs} +2 -2
- package/dist/{openapi-a-e3Y8WA.cjs.map → openapi-DhcCtKzM.cjs.map} +1 -1
- package/dist/{openapi-react-query-DvNpdDpM.cjs → openapi-react-query-C_MxpBgF.cjs} +1 -1
- package/dist/{openapi-react-query-DvNpdDpM.cjs.map → openapi-react-query-C_MxpBgF.cjs.map} +1 -1
- package/dist/{openapi-react-query-5rSortLH.mjs → openapi-react-query-ZoP9DPbY.mjs} +1 -1
- package/dist/{openapi-react-query-5rSortLH.mjs.map → openapi-react-query-ZoP9DPbY.mjs.map} +1 -1
- package/dist/openapi-react-query.cjs +1 -1
- package/dist/openapi-react-query.mjs +1 -1
- package/dist/openapi.cjs +3 -3
- package/dist/openapi.d.mts +1 -1
- package/dist/openapi.mjs +3 -3
- package/dist/{types-K2uQJ-FO.d.mts → types-BtGL-8QS.d.mts} +1 -1
- package/dist/{types-K2uQJ-FO.d.mts.map → types-BtGL-8QS.d.mts.map} +1 -1
- package/dist/workspace/index.cjs +1 -1
- package/dist/workspace/index.d.cts +2 -2
- package/dist/workspace/index.d.mts +3 -3
- package/dist/workspace/index.mjs +1 -1
- package/dist/{workspace-My0A4IRO.cjs → workspace-BDAhr6Kb.cjs} +33 -4
- package/dist/{workspace-My0A4IRO.cjs.map → workspace-BDAhr6Kb.cjs.map} +1 -1
- package/dist/{workspace-DFJ3sWfY.mjs → workspace-D_6ZCaR_.mjs} +33 -4
- package/dist/{workspace-DFJ3sWfY.mjs.map → workspace-D_6ZCaR_.mjs.map} +1 -1
- package/package.json +5 -5
- package/src/build/bundler.ts +27 -79
- package/src/deploy/__tests__/domain.spec.ts +231 -0
- package/src/deploy/__tests__/secrets.spec.ts +300 -0
- package/src/deploy/__tests__/sniffer.spec.ts +221 -0
- package/src/deploy/docker.ts +40 -11
- package/src/deploy/dokploy-api.ts +99 -0
- package/src/deploy/domain.ts +125 -0
- package/src/deploy/index.ts +366 -148
- package/src/deploy/secrets.ts +182 -0
- package/src/deploy/sniffer.ts +180 -0
- package/src/dev/index.ts +11 -0
- package/src/docker/index.ts +24 -5
- package/src/docker/templates.ts +187 -1
- package/src/init/templates/api.ts +4 -4
- package/src/init/versions.ts +2 -2
- package/src/workspace/index.ts +2 -0
- package/src/workspace/schema.ts +32 -6
- package/src/workspace/types.ts +64 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/bundler-CyHg1v_T.cjs.map +0 -1
- package/dist/bundler-DQIuE3Kn.mjs.map +0 -1
- package/dist/dokploy-api-B0w17y4_.mjs +0 -3
- package/dist/dokploy-api-BnGeUqN4.cjs +0 -3
- package/dist/index-C7TkoYmt.d.mts.map +0 -1
- package/dist/index-CpchsC9w.d.cts.map +0 -1
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { encryptSecrets } from '../secrets/encryption.js';
|
|
2
|
+
import { toEmbeddableSecrets } from '../secrets/storage.js';
|
|
3
|
+
import type { EmbeddableSecrets, EncryptedPayload, StageSecrets } from '../secrets/types.js';
|
|
4
|
+
import type { SniffedEnvironment } from './sniffer.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Result of filtering secrets for an app.
|
|
8
|
+
*/
|
|
9
|
+
export interface FilteredAppSecrets {
|
|
10
|
+
appName: string;
|
|
11
|
+
/** Secrets filtered to only include what the app needs */
|
|
12
|
+
secrets: EmbeddableSecrets;
|
|
13
|
+
/** List of required env vars that were found in secrets */
|
|
14
|
+
found: string[];
|
|
15
|
+
/** List of required env vars that were NOT found in secrets */
|
|
16
|
+
missing: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Filter secrets to only include the env vars that an app requires.
|
|
21
|
+
*
|
|
22
|
+
* @param stageSecrets - All secrets for the stage
|
|
23
|
+
* @param sniffedEnv - The sniffed environment requirements for the app
|
|
24
|
+
* @returns Filtered secrets with found/missing tracking
|
|
25
|
+
*/
|
|
26
|
+
export function filterSecretsForApp(
|
|
27
|
+
stageSecrets: StageSecrets,
|
|
28
|
+
sniffedEnv: SniffedEnvironment,
|
|
29
|
+
): FilteredAppSecrets {
|
|
30
|
+
// Convert stage secrets to flat embeddable format
|
|
31
|
+
const allSecrets = toEmbeddableSecrets(stageSecrets);
|
|
32
|
+
const filtered: EmbeddableSecrets = {};
|
|
33
|
+
const found: string[] = [];
|
|
34
|
+
const missing: string[] = [];
|
|
35
|
+
|
|
36
|
+
// Filter to only required env vars
|
|
37
|
+
for (const key of sniffedEnv.requiredEnvVars) {
|
|
38
|
+
if (key in allSecrets) {
|
|
39
|
+
filtered[key] = allSecrets[key]!;
|
|
40
|
+
found.push(key);
|
|
41
|
+
} else {
|
|
42
|
+
missing.push(key);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
appName: sniffedEnv.appName,
|
|
48
|
+
secrets: filtered,
|
|
49
|
+
found: found.sort(),
|
|
50
|
+
missing: missing.sort(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Result of encrypting secrets for an app.
|
|
56
|
+
*/
|
|
57
|
+
export interface EncryptedAppSecrets {
|
|
58
|
+
appName: string;
|
|
59
|
+
/** Encrypted payload with credentials and IV */
|
|
60
|
+
payload: EncryptedPayload;
|
|
61
|
+
/** Master key for runtime decryption (hex encoded) */
|
|
62
|
+
masterKey: string;
|
|
63
|
+
/** Number of secrets encrypted */
|
|
64
|
+
secretCount: number;
|
|
65
|
+
/** List of required env vars that were NOT found in secrets */
|
|
66
|
+
missingSecrets: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Encrypt filtered secrets for an app.
|
|
71
|
+
* Generates an ephemeral master key that should be injected into Dokploy.
|
|
72
|
+
*
|
|
73
|
+
* @param filteredSecrets - The filtered secrets for the app
|
|
74
|
+
* @returns Encrypted payload with master key
|
|
75
|
+
*/
|
|
76
|
+
export function encryptSecretsForApp(
|
|
77
|
+
filteredSecrets: FilteredAppSecrets,
|
|
78
|
+
): EncryptedAppSecrets {
|
|
79
|
+
const payload = encryptSecrets(filteredSecrets.secrets);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
appName: filteredSecrets.appName,
|
|
83
|
+
payload,
|
|
84
|
+
masterKey: payload.masterKey,
|
|
85
|
+
secretCount: Object.keys(filteredSecrets.secrets).length,
|
|
86
|
+
missingSecrets: filteredSecrets.missing,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Filter and encrypt secrets for an app in one step.
|
|
92
|
+
*
|
|
93
|
+
* @param stageSecrets - All secrets for the stage
|
|
94
|
+
* @param sniffedEnv - The sniffed environment requirements for the app
|
|
95
|
+
* @returns Encrypted secrets with master key
|
|
96
|
+
*/
|
|
97
|
+
export function prepareSecretsForApp(
|
|
98
|
+
stageSecrets: StageSecrets,
|
|
99
|
+
sniffedEnv: SniffedEnvironment,
|
|
100
|
+
): EncryptedAppSecrets {
|
|
101
|
+
const filtered = filterSecretsForApp(stageSecrets, sniffedEnv);
|
|
102
|
+
return encryptSecretsForApp(filtered);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Prepare secrets for multiple apps.
|
|
107
|
+
*
|
|
108
|
+
* @param stageSecrets - All secrets for the stage
|
|
109
|
+
* @param sniffedApps - Map of app name to sniffed environment
|
|
110
|
+
* @returns Map of app name to encrypted secrets
|
|
111
|
+
*/
|
|
112
|
+
export function prepareSecretsForAllApps(
|
|
113
|
+
stageSecrets: StageSecrets,
|
|
114
|
+
sniffedApps: Map<string, SniffedEnvironment>,
|
|
115
|
+
): Map<string, EncryptedAppSecrets> {
|
|
116
|
+
const results = new Map<string, EncryptedAppSecrets>();
|
|
117
|
+
|
|
118
|
+
for (const [appName, sniffedEnv] of sniffedApps) {
|
|
119
|
+
// Only prepare secrets for apps that have required env vars
|
|
120
|
+
if (sniffedEnv.requiredEnvVars.length > 0) {
|
|
121
|
+
const encrypted = prepareSecretsForApp(stageSecrets, sniffedEnv);
|
|
122
|
+
results.set(appName, encrypted);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return results;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Report on secrets preparation status for all apps.
|
|
131
|
+
*/
|
|
132
|
+
export interface SecretsReport {
|
|
133
|
+
/** Total number of apps processed */
|
|
134
|
+
totalApps: number;
|
|
135
|
+
/** Apps with encrypted secrets */
|
|
136
|
+
appsWithSecrets: string[];
|
|
137
|
+
/** Apps without secrets (frontends or no env requirements) */
|
|
138
|
+
appsWithoutSecrets: string[];
|
|
139
|
+
/** Apps with missing secrets (warnings) */
|
|
140
|
+
appsWithMissingSecrets: Array<{
|
|
141
|
+
appName: string;
|
|
142
|
+
missing: string[];
|
|
143
|
+
}>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Generate a report on secrets preparation.
|
|
148
|
+
*/
|
|
149
|
+
export function generateSecretsReport(
|
|
150
|
+
encryptedApps: Map<string, EncryptedAppSecrets>,
|
|
151
|
+
sniffedApps: Map<string, SniffedEnvironment>,
|
|
152
|
+
): SecretsReport {
|
|
153
|
+
const appsWithSecrets: string[] = [];
|
|
154
|
+
const appsWithoutSecrets: string[] = [];
|
|
155
|
+
const appsWithMissingSecrets: Array<{ appName: string; missing: string[] }> = [];
|
|
156
|
+
|
|
157
|
+
for (const [appName, sniffedEnv] of sniffedApps) {
|
|
158
|
+
if (sniffedEnv.requiredEnvVars.length === 0) {
|
|
159
|
+
appsWithoutSecrets.push(appName);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const encrypted = encryptedApps.get(appName);
|
|
164
|
+
if (encrypted) {
|
|
165
|
+
appsWithSecrets.push(appName);
|
|
166
|
+
|
|
167
|
+
if (encrypted.missingSecrets.length > 0) {
|
|
168
|
+
appsWithMissingSecrets.push({
|
|
169
|
+
appName,
|
|
170
|
+
missing: encrypted.missingSecrets,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
totalApps: sniffedApps.size,
|
|
178
|
+
appsWithSecrets: appsWithSecrets.sort(),
|
|
179
|
+
appsWithoutSecrets: appsWithoutSecrets.sort(),
|
|
180
|
+
appsWithMissingSecrets,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import type { SniffResult } from '@geekmidas/envkit/sniffer';
|
|
4
|
+
import type { NormalizedAppConfig } from '../workspace/types.js';
|
|
5
|
+
|
|
6
|
+
// Re-export SniffResult for consumers
|
|
7
|
+
export type { SniffResult } from '@geekmidas/envkit/sniffer';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Result of sniffing an app's environment requirements.
|
|
11
|
+
*/
|
|
12
|
+
export interface SniffedEnvironment {
|
|
13
|
+
appName: string;
|
|
14
|
+
requiredEnvVars: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Options for sniffing an app's environment.
|
|
19
|
+
*/
|
|
20
|
+
export interface SniffAppOptions {
|
|
21
|
+
/** Whether to log warnings for errors encountered during sniffing. Defaults to true. */
|
|
22
|
+
logWarnings?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get required environment variables for an app.
|
|
27
|
+
*
|
|
28
|
+
* Detection strategy:
|
|
29
|
+
* - Frontend apps: Returns empty (no server secrets)
|
|
30
|
+
* - Apps with `requiredEnv`: Uses explicit list from config
|
|
31
|
+
* - Apps with `envParser`: Runs SnifferEnvironmentParser to detect usage
|
|
32
|
+
* - Apps with neither: Returns empty
|
|
33
|
+
*
|
|
34
|
+
* This function handles "fire and forget" async operations gracefully,
|
|
35
|
+
* capturing errors and unhandled rejections without failing the build.
|
|
36
|
+
*
|
|
37
|
+
* @param app - The normalized app configuration
|
|
38
|
+
* @param appName - The name of the app
|
|
39
|
+
* @param workspacePath - Absolute path to the workspace root
|
|
40
|
+
* @param options - Optional configuration for sniffing behavior
|
|
41
|
+
* @returns The sniffed environment with required variables
|
|
42
|
+
*/
|
|
43
|
+
export async function sniffAppEnvironment(
|
|
44
|
+
app: NormalizedAppConfig,
|
|
45
|
+
appName: string,
|
|
46
|
+
workspacePath: string,
|
|
47
|
+
options: SniffAppOptions = {},
|
|
48
|
+
): Promise<SniffedEnvironment> {
|
|
49
|
+
const { logWarnings = true } = options;
|
|
50
|
+
|
|
51
|
+
// Frontend apps don't have server-side secrets
|
|
52
|
+
if (app.type === 'frontend') {
|
|
53
|
+
return { appName, requiredEnvVars: [] };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Entry-based apps with explicit env list
|
|
57
|
+
if (app.requiredEnv && app.requiredEnv.length > 0) {
|
|
58
|
+
return { appName, requiredEnvVars: [...app.requiredEnv] };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Apps with envParser - run sniffer to detect env var usage
|
|
62
|
+
if (app.envParser) {
|
|
63
|
+
const result = await sniffEnvParser(app.envParser, app.path, workspacePath);
|
|
64
|
+
|
|
65
|
+
// Log any issues for debugging
|
|
66
|
+
if (logWarnings) {
|
|
67
|
+
if (result.error) {
|
|
68
|
+
console.warn(
|
|
69
|
+
`[sniffer] ${appName}: envParser threw error during sniffing (env vars still captured): ${result.error.message}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (result.unhandledRejections.length > 0) {
|
|
73
|
+
console.warn(
|
|
74
|
+
`[sniffer] ${appName}: Fire-and-forget rejections during sniffing (suppressed): ${result.unhandledRejections.map((e) => e.message).join(', ')}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { appName, requiredEnvVars: result.envVars };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// No env detection method available
|
|
83
|
+
return { appName, requiredEnvVars: [] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Run the SnifferEnvironmentParser on an envParser module to detect
|
|
88
|
+
* which environment variables it accesses.
|
|
89
|
+
*
|
|
90
|
+
* This function handles "fire and forget" async operations by using
|
|
91
|
+
* the shared sniffWithFireAndForget utility from @geekmidas/envkit.
|
|
92
|
+
*
|
|
93
|
+
* @param envParserPath - The envParser config (e.g., './src/config/env#envParser')
|
|
94
|
+
* @param appPath - The app's path relative to workspace
|
|
95
|
+
* @param workspacePath - Absolute path to workspace root
|
|
96
|
+
* @returns SniffResult with env vars and any errors encountered
|
|
97
|
+
*/
|
|
98
|
+
async function sniffEnvParser(
|
|
99
|
+
envParserPath: string,
|
|
100
|
+
appPath: string,
|
|
101
|
+
workspacePath: string,
|
|
102
|
+
): Promise<SniffResult> {
|
|
103
|
+
// Parse the envParser path: './src/config/env#envParser' or './src/config/env'
|
|
104
|
+
const [modulePath, exportName = 'default'] = envParserPath.split('#');
|
|
105
|
+
if (!modulePath) {
|
|
106
|
+
return { envVars: [], unhandledRejections: [] };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Resolve the full path to the module
|
|
110
|
+
const fullPath = resolve(workspacePath, appPath, modulePath);
|
|
111
|
+
|
|
112
|
+
// Dynamically import the sniffer utilities
|
|
113
|
+
let SnifferEnvironmentParser: any;
|
|
114
|
+
let sniffWithFireAndForget: any;
|
|
115
|
+
try {
|
|
116
|
+
const envkitModule = await import('@geekmidas/envkit/sniffer');
|
|
117
|
+
SnifferEnvironmentParser = envkitModule.SnifferEnvironmentParser;
|
|
118
|
+
sniffWithFireAndForget = envkitModule.sniffWithFireAndForget;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
121
|
+
console.warn(`[sniffer] Failed to import SnifferEnvironmentParser: ${message}`);
|
|
122
|
+
return { envVars: [], unhandledRejections: [] };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const sniffer = new SnifferEnvironmentParser();
|
|
126
|
+
|
|
127
|
+
return sniffWithFireAndForget(sniffer, async () => {
|
|
128
|
+
// Import the envParser module
|
|
129
|
+
const moduleUrl = pathToFileURL(fullPath).href;
|
|
130
|
+
const module = await import(moduleUrl);
|
|
131
|
+
|
|
132
|
+
// Get the envParser function
|
|
133
|
+
const envParser = module[exportName];
|
|
134
|
+
if (typeof envParser !== 'function') {
|
|
135
|
+
console.warn(
|
|
136
|
+
`[sniffer] Export "${exportName}" from "${modulePath}" is not a function`,
|
|
137
|
+
);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The envParser function typically creates and configures an EnvironmentParser.
|
|
142
|
+
// We pass our sniffer which implements the same interface.
|
|
143
|
+
const result = envParser(sniffer);
|
|
144
|
+
|
|
145
|
+
// If the result is a ConfigParser, call parse() to trigger env var access
|
|
146
|
+
if (result && typeof result.parse === 'function') {
|
|
147
|
+
try {
|
|
148
|
+
result.parse();
|
|
149
|
+
} catch {
|
|
150
|
+
// Parsing may fail due to mock values, that's expected
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Sniff environment requirements for multiple apps.
|
|
158
|
+
*
|
|
159
|
+
* @param apps - Map of app name to app config
|
|
160
|
+
* @param workspacePath - Absolute path to workspace root
|
|
161
|
+
* @param options - Optional configuration for sniffing behavior
|
|
162
|
+
* @returns Map of app name to sniffed environment
|
|
163
|
+
*/
|
|
164
|
+
export async function sniffAllApps(
|
|
165
|
+
apps: Record<string, NormalizedAppConfig>,
|
|
166
|
+
workspacePath: string,
|
|
167
|
+
options: SniffAppOptions = {},
|
|
168
|
+
): Promise<Map<string, SniffedEnvironment>> {
|
|
169
|
+
const results = new Map<string, SniffedEnvironment>();
|
|
170
|
+
|
|
171
|
+
for (const [appName, app] of Object.entries(apps)) {
|
|
172
|
+
const sniffed = await sniffAppEnvironment(app, appName, workspacePath, options);
|
|
173
|
+
results.set(appName, sniffed);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return results;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Export for testing
|
|
180
|
+
export { sniffEnvParser as _sniffEnvParser };
|
package/src/dev/index.ts
CHANGED
|
@@ -341,6 +341,17 @@ export async function devCommand(options: DevOptions): Promise<void> {
|
|
|
341
341
|
workspaceAppName = appConfig.appName;
|
|
342
342
|
workspaceAppPort = appConfig.app.port;
|
|
343
343
|
logger.log(`📦 Running app: ${appConfig.appName} on port ${workspaceAppPort}`);
|
|
344
|
+
|
|
345
|
+
// Check if app has an entry point (non-gkm app like better-auth)
|
|
346
|
+
if (appConfig.app.entry) {
|
|
347
|
+
logger.log(`📄 Using entry point: ${appConfig.app.entry}`);
|
|
348
|
+
return entryDevCommand({
|
|
349
|
+
...options,
|
|
350
|
+
entry: appConfig.app.entry,
|
|
351
|
+
port: workspaceAppPort,
|
|
352
|
+
portExplicit: true,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
344
355
|
} catch {
|
|
345
356
|
// Not in a workspace or app not found in workspace - fall back to regular loading
|
|
346
357
|
const loadedConfig = await loadWorkspaceConfig();
|
package/src/docker/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process';
|
|
2
|
-
import { copyFileSync, existsSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { copyFileSync, existsSync, readFileSync, unlinkSync } from 'node:fs';
|
|
3
3
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { basename, join } from 'node:path';
|
|
5
5
|
import { loadConfig, loadWorkspaceConfig } from '../config';
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
generateBackendDockerfile,
|
|
16
16
|
generateDockerEntrypoint,
|
|
17
17
|
generateDockerignore,
|
|
18
|
+
generateEntryDockerfile,
|
|
18
19
|
generateMultiStageDockerfile,
|
|
19
20
|
generateNextjsDockerfile,
|
|
20
21
|
generateSlimDockerfile,
|
|
@@ -360,8 +361,12 @@ export interface WorkspaceDockerResult {
|
|
|
360
361
|
*/
|
|
361
362
|
function getAppPackageName(appPath: string): string | undefined {
|
|
362
363
|
try {
|
|
363
|
-
|
|
364
|
-
|
|
364
|
+
const pkgPath = join(appPath, 'package.json');
|
|
365
|
+
if (!existsSync(pkgPath)) {
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
const content = readFileSync(pkgPath, 'utf-8');
|
|
369
|
+
const pkg = JSON.parse(content);
|
|
365
370
|
return pkg.name;
|
|
366
371
|
} catch {
|
|
367
372
|
return undefined;
|
|
@@ -400,7 +405,9 @@ export async function workspaceDockerCommand(
|
|
|
400
405
|
// Determine image name
|
|
401
406
|
const imageName = appName;
|
|
402
407
|
|
|
403
|
-
|
|
408
|
+
const hasEntry = !!app.entry;
|
|
409
|
+
const buildType = hasEntry ? 'entry' : app.type;
|
|
410
|
+
logger.log(`\n 📄 Generating Dockerfile for ${appName} (${buildType})`);
|
|
404
411
|
|
|
405
412
|
let dockerfile: string;
|
|
406
413
|
|
|
@@ -414,8 +421,20 @@ export async function workspaceDockerCommand(
|
|
|
414
421
|
turboPackage,
|
|
415
422
|
packageManager,
|
|
416
423
|
});
|
|
424
|
+
} else if (app.entry) {
|
|
425
|
+
// Backend with custom entry point - use tsdown bundling
|
|
426
|
+
dockerfile = generateEntryDockerfile({
|
|
427
|
+
imageName,
|
|
428
|
+
baseImage: 'node:22-alpine',
|
|
429
|
+
port: app.port,
|
|
430
|
+
appPath,
|
|
431
|
+
entry: app.entry,
|
|
432
|
+
turboPackage,
|
|
433
|
+
packageManager,
|
|
434
|
+
healthCheckPath: '/health',
|
|
435
|
+
});
|
|
417
436
|
} else {
|
|
418
|
-
//
|
|
437
|
+
// Backend with gkm routes - use gkm build
|
|
419
438
|
dockerfile = generateBackendDockerfile({
|
|
420
439
|
imageName,
|
|
421
440
|
baseImage: 'node:22-alpine',
|
package/src/docker/templates.ts
CHANGED
|
@@ -25,6 +25,12 @@ export interface FrontendDockerfileOptions {
|
|
|
25
25
|
turboPackage: string;
|
|
26
26
|
/** Detected package manager */
|
|
27
27
|
packageManager: PackageManager;
|
|
28
|
+
/**
|
|
29
|
+
* Public URL build args to include in the Dockerfile.
|
|
30
|
+
* These will be declared as ARG and converted to ENV for Next.js build.
|
|
31
|
+
* Example: ['NEXT_PUBLIC_API_URL', 'NEXT_PUBLIC_AUTH_URL']
|
|
32
|
+
*/
|
|
33
|
+
publicUrlArgs?: string[];
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
export interface MultiStageDockerfileOptions extends DockerTemplateOptions {
|
|
@@ -568,7 +574,14 @@ export function resolveDockerConfig(
|
|
|
568
574
|
export function generateNextjsDockerfile(
|
|
569
575
|
options: FrontendDockerfileOptions,
|
|
570
576
|
): string {
|
|
571
|
-
const {
|
|
577
|
+
const {
|
|
578
|
+
baseImage,
|
|
579
|
+
port,
|
|
580
|
+
appPath,
|
|
581
|
+
turboPackage,
|
|
582
|
+
packageManager,
|
|
583
|
+
publicUrlArgs = ['NEXT_PUBLIC_API_URL', 'NEXT_PUBLIC_AUTH_URL'],
|
|
584
|
+
} = options;
|
|
572
585
|
|
|
573
586
|
const pm = getPmConfig(packageManager);
|
|
574
587
|
const installPm = pm.install ? `RUN ${pm.install}` : '';
|
|
@@ -580,6 +593,14 @@ export function generateNextjsDockerfile(
|
|
|
580
593
|
// Use pnpm dlx for pnpm (avoids global bin dir issues in Docker)
|
|
581
594
|
const turboCmd = packageManager === 'pnpm' ? 'pnpm dlx turbo' : 'npx turbo';
|
|
582
595
|
|
|
596
|
+
// Generate ARG and ENV declarations for public URLs
|
|
597
|
+
const publicUrlArgDeclarations = publicUrlArgs
|
|
598
|
+
.map((arg) => `ARG ${arg}=""`)
|
|
599
|
+
.join('\n');
|
|
600
|
+
const publicUrlEnvDeclarations = publicUrlArgs
|
|
601
|
+
.map((arg) => `ENV ${arg}=$${arg}`)
|
|
602
|
+
.join('\n');
|
|
603
|
+
|
|
583
604
|
return `# syntax=docker/dockerfile:1
|
|
584
605
|
# Next.js standalone Dockerfile with turbo prune optimization
|
|
585
606
|
|
|
@@ -615,9 +636,23 @@ FROM deps AS builder
|
|
|
615
636
|
|
|
616
637
|
WORKDIR /app
|
|
617
638
|
|
|
639
|
+
# Build-time args for public API URLs (populated by gkm deploy)
|
|
640
|
+
# These get baked into the Next.js build as public environment variables
|
|
641
|
+
${publicUrlArgDeclarations}
|
|
642
|
+
|
|
643
|
+
# Convert ARGs to ENVs for Next.js build
|
|
644
|
+
${publicUrlEnvDeclarations}
|
|
645
|
+
|
|
618
646
|
# Copy pruned source
|
|
619
647
|
COPY --from=pruner /app/out/full/ ./
|
|
620
648
|
|
|
649
|
+
# Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
|
|
650
|
+
# Using wildcard to make it optional for single-app projects
|
|
651
|
+
COPY --from=pruner /app/tsconfig.* ./
|
|
652
|
+
|
|
653
|
+
# Ensure public directory exists (may be empty for scaffolded projects)
|
|
654
|
+
RUN mkdir -p ${appPath}/public
|
|
655
|
+
|
|
621
656
|
# Set Next.js to produce standalone output
|
|
622
657
|
ENV NEXT_TELEMETRY_DISABLED=1
|
|
623
658
|
|
|
@@ -717,9 +752,25 @@ FROM deps AS builder
|
|
|
717
752
|
|
|
718
753
|
WORKDIR /app
|
|
719
754
|
|
|
755
|
+
# Build-time args for encrypted secrets
|
|
756
|
+
ARG GKM_ENCRYPTED_CREDENTIALS=""
|
|
757
|
+
ARG GKM_CREDENTIALS_IV=""
|
|
758
|
+
|
|
720
759
|
# Copy pruned source
|
|
721
760
|
COPY --from=pruner /app/out/full/ ./
|
|
722
761
|
|
|
762
|
+
# Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
|
|
763
|
+
# Using wildcard to make it optional for single-app projects
|
|
764
|
+
COPY --from=pruner /app/gkm.config.* ./
|
|
765
|
+
COPY --from=pruner /app/tsconfig.* ./
|
|
766
|
+
|
|
767
|
+
# Write encrypted credentials for gkm build to embed
|
|
768
|
+
RUN if [ -n "$GKM_ENCRYPTED_CREDENTIALS" ]; then \
|
|
769
|
+
mkdir -p ${appPath}/.gkm && \
|
|
770
|
+
echo "$GKM_ENCRYPTED_CREDENTIALS" > ${appPath}/.gkm/credentials.enc && \
|
|
771
|
+
echo "$GKM_CREDENTIALS_IV" > ${appPath}/.gkm/credentials.iv; \
|
|
772
|
+
fi
|
|
773
|
+
|
|
723
774
|
# Build production server using gkm
|
|
724
775
|
RUN cd ${appPath} && ./node_modules/.bin/gkm build --provider server --production
|
|
725
776
|
|
|
@@ -750,3 +801,138 @@ ENTRYPOINT ["/sbin/tini", "--"]
|
|
|
750
801
|
CMD ["node", "server.mjs"]
|
|
751
802
|
`;
|
|
752
803
|
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Options for entry-based Dockerfile generation.
|
|
807
|
+
*/
|
|
808
|
+
export interface EntryDockerfileOptions {
|
|
809
|
+
imageName: string;
|
|
810
|
+
baseImage: string;
|
|
811
|
+
port: number;
|
|
812
|
+
/** App path relative to workspace root */
|
|
813
|
+
appPath: string;
|
|
814
|
+
/** Entry file path relative to app path (e.g., './src/index.ts') */
|
|
815
|
+
entry: string;
|
|
816
|
+
/** Package name for turbo prune */
|
|
817
|
+
turboPackage: string;
|
|
818
|
+
/** Detected package manager */
|
|
819
|
+
packageManager: PackageManager;
|
|
820
|
+
/** Health check path (default: '/health') */
|
|
821
|
+
healthCheckPath?: string;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Generate a Dockerfile for apps with a custom entry point.
|
|
826
|
+
* Uses tsdown to bundle the entry point into dist/index.mjs.
|
|
827
|
+
* This is used for apps that don't use gkm routes (e.g., Better Auth servers).
|
|
828
|
+
* @internal Exported for testing
|
|
829
|
+
*/
|
|
830
|
+
export function generateEntryDockerfile(options: EntryDockerfileOptions): string {
|
|
831
|
+
const {
|
|
832
|
+
baseImage,
|
|
833
|
+
port,
|
|
834
|
+
appPath,
|
|
835
|
+
entry,
|
|
836
|
+
turboPackage,
|
|
837
|
+
packageManager,
|
|
838
|
+
healthCheckPath = '/health',
|
|
839
|
+
} = options;
|
|
840
|
+
|
|
841
|
+
const pm = getPmConfig(packageManager);
|
|
842
|
+
const installPm = pm.install ? `RUN ${pm.install}` : '';
|
|
843
|
+
const turboInstallCmd = getTurboInstallCmd(packageManager);
|
|
844
|
+
const turboCmd = packageManager === 'pnpm' ? 'pnpm dlx turbo' : 'npx turbo';
|
|
845
|
+
|
|
846
|
+
return `# syntax=docker/dockerfile:1
|
|
847
|
+
# Entry-based Dockerfile with turbo prune + tsdown bundling
|
|
848
|
+
|
|
849
|
+
# Stage 1: Prune monorepo
|
|
850
|
+
FROM ${baseImage} AS pruner
|
|
851
|
+
|
|
852
|
+
WORKDIR /app
|
|
853
|
+
|
|
854
|
+
${installPm}
|
|
855
|
+
|
|
856
|
+
COPY . .
|
|
857
|
+
|
|
858
|
+
# Prune to only include necessary packages
|
|
859
|
+
RUN ${turboCmd} prune ${turboPackage} --docker
|
|
860
|
+
|
|
861
|
+
# Stage 2: Install dependencies
|
|
862
|
+
FROM ${baseImage} AS deps
|
|
863
|
+
|
|
864
|
+
WORKDIR /app
|
|
865
|
+
|
|
866
|
+
${installPm}
|
|
867
|
+
|
|
868
|
+
# Copy pruned lockfile and package.jsons
|
|
869
|
+
COPY --from=pruner /app/out/${pm.lockfile} ./
|
|
870
|
+
COPY --from=pruner /app/out/json/ ./
|
|
871
|
+
|
|
872
|
+
# Install dependencies
|
|
873
|
+
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
874
|
+
${turboInstallCmd}
|
|
875
|
+
|
|
876
|
+
# Stage 3: Build with tsdown
|
|
877
|
+
FROM deps AS builder
|
|
878
|
+
|
|
879
|
+
WORKDIR /app
|
|
880
|
+
|
|
881
|
+
# Build-time args for encrypted secrets
|
|
882
|
+
ARG GKM_ENCRYPTED_CREDENTIALS=""
|
|
883
|
+
ARG GKM_CREDENTIALS_IV=""
|
|
884
|
+
|
|
885
|
+
# Copy pruned source
|
|
886
|
+
COPY --from=pruner /app/out/full/ ./
|
|
887
|
+
|
|
888
|
+
# Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
|
|
889
|
+
# Using wildcard to make it optional for single-app projects
|
|
890
|
+
COPY --from=pruner /app/tsconfig.* ./
|
|
891
|
+
|
|
892
|
+
# Write encrypted credentials for tsdown to embed via define
|
|
893
|
+
RUN if [ -n "$GKM_ENCRYPTED_CREDENTIALS" ]; then \
|
|
894
|
+
mkdir -p ${appPath}/.gkm && \
|
|
895
|
+
echo "$GKM_ENCRYPTED_CREDENTIALS" > ${appPath}/.gkm/credentials.enc && \
|
|
896
|
+
echo "$GKM_CREDENTIALS_IV" > ${appPath}/.gkm/credentials.iv; \
|
|
897
|
+
fi
|
|
898
|
+
|
|
899
|
+
# Bundle entry point with tsdown (outputs to dist/index.mjs)
|
|
900
|
+
# Use define to embed credentials if present
|
|
901
|
+
RUN cd ${appPath} && \
|
|
902
|
+
if [ -f .gkm/credentials.enc ]; then \
|
|
903
|
+
CREDS=$(cat .gkm/credentials.enc) && \
|
|
904
|
+
IV=$(cat .gkm/credentials.iv) && \
|
|
905
|
+
npx tsdown ${entry} --outDir dist --format esm \
|
|
906
|
+
--define __GKM_ENCRYPTED_CREDENTIALS__="'\\"$CREDS\\"'" \
|
|
907
|
+
--define __GKM_CREDENTIALS_IV__="'\\"$IV\\"'"; \
|
|
908
|
+
else \
|
|
909
|
+
npx tsdown ${entry} --outDir dist --format esm; \
|
|
910
|
+
fi
|
|
911
|
+
|
|
912
|
+
# Stage 4: Production
|
|
913
|
+
FROM ${baseImage} AS runner
|
|
914
|
+
|
|
915
|
+
WORKDIR /app
|
|
916
|
+
|
|
917
|
+
RUN apk add --no-cache tini
|
|
918
|
+
|
|
919
|
+
RUN addgroup --system --gid 1001 nodejs && \\
|
|
920
|
+
adduser --system --uid 1001 app
|
|
921
|
+
|
|
922
|
+
# Copy bundled output only (no node_modules needed - fully bundled)
|
|
923
|
+
COPY --from=builder --chown=app:nodejs /app/${appPath}/dist/index.mjs ./
|
|
924
|
+
|
|
925
|
+
ENV NODE_ENV=production
|
|
926
|
+
ENV PORT=${port}
|
|
927
|
+
|
|
928
|
+
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\
|
|
929
|
+
CMD wget -q --spider http://localhost:${port}${healthCheckPath} || exit 1
|
|
930
|
+
|
|
931
|
+
USER app
|
|
932
|
+
|
|
933
|
+
EXPOSE ${port}
|
|
934
|
+
|
|
935
|
+
ENTRYPOINT ["/sbin/tini", "--"]
|
|
936
|
+
CMD ["node", "index.mjs"]
|
|
937
|
+
`;
|
|
938
|
+
}
|
|
@@ -131,8 +131,8 @@ export const listUsersEndpoint = e
|
|
|
131
131
|
.output(ListUsersResponseSchema)
|
|
132
132
|
.handle(async () => ({
|
|
133
133
|
users: [
|
|
134
|
-
{ id: '
|
|
135
|
-
{ id: '
|
|
134
|
+
{ id: '550e8400-e29b-41d4-a716-446655440001', name: 'Alice' },
|
|
135
|
+
{ id: '550e8400-e29b-41d4-a716-446655440002', name: 'Bob' },
|
|
136
136
|
],
|
|
137
137
|
}));
|
|
138
138
|
`
|
|
@@ -161,12 +161,12 @@ export const listUsersEndpoint = e
|
|
|
161
161
|
path: getRoutePath('users/get.ts'),
|
|
162
162
|
content: modelsImport
|
|
163
163
|
? `import { e } from '@geekmidas/constructs/endpoints';
|
|
164
|
-
import {
|
|
164
|
+
import { IdSchema } from '${modelsImport}/common';
|
|
165
165
|
import { UserResponseSchema } from '${modelsImport}/user';
|
|
166
166
|
|
|
167
167
|
export const getUserEndpoint = e
|
|
168
168
|
.get('/users/:id')
|
|
169
|
-
.params(
|
|
169
|
+
.params({ id: IdSchema })
|
|
170
170
|
.output(UserResponseSchema)
|
|
171
171
|
.handle(async ({ params }) => ({
|
|
172
172
|
id: params.id,
|