@geekmidas/cli 1.0.2 → 1.1.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 (49) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/{SSMStateProvider-C4wp4AZe.mjs → SSMStateProvider-BjCi_58g.mjs} +16 -7
  3. package/dist/SSMStateProvider-BjCi_58g.mjs.map +1 -0
  4. package/dist/{SSMStateProvider-BxAPU99a.cjs → SSMStateProvider-D79o_JjM.cjs} +16 -7
  5. package/dist/SSMStateProvider-D79o_JjM.cjs.map +1 -0
  6. package/dist/{config-BGeJsW1r.cjs → config-CKfif10N.cjs} +2 -2
  7. package/dist/{config-BGeJsW1r.cjs.map → config-CKfif10N.cjs.map} +1 -1
  8. package/dist/{config-C6awcFBx.mjs → config-ClfjsfwH.mjs} +2 -2
  9. package/dist/{config-C6awcFBx.mjs.map → config-ClfjsfwH.mjs.map} +1 -1
  10. package/dist/config.cjs +2 -2
  11. package/dist/config.d.cts +1 -1
  12. package/dist/config.d.mts +1 -1
  13. package/dist/config.mjs +2 -2
  14. package/dist/{index-KFEbMIRa.d.mts → index-CHQs8G3q.d.mts} +6 -1
  15. package/dist/index-CHQs8G3q.d.mts.map +1 -0
  16. package/dist/{index-B5rGIc4g.d.cts → index-afBljZKY.d.cts} +6 -1
  17. package/dist/index-afBljZKY.d.cts.map +1 -0
  18. package/dist/index.cjs +6 -6
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.mjs +6 -6
  21. package/dist/index.mjs.map +1 -1
  22. package/dist/{openapi-BMFmLnX6.mjs → openapi-C6sa0L8b.mjs} +2 -2
  23. package/dist/{openapi-BMFmLnX6.mjs.map → openapi-C6sa0L8b.mjs.map} +1 -1
  24. package/dist/{openapi-D1KXv2Ml.cjs → openapi-D3p6s8UA.cjs} +2 -2
  25. package/dist/{openapi-D1KXv2Ml.cjs.map → openapi-D3p6s8UA.cjs.map} +1 -1
  26. package/dist/openapi.cjs +3 -3
  27. package/dist/openapi.mjs +3 -3
  28. package/dist/workspace/index.cjs +1 -1
  29. package/dist/workspace/index.d.cts +1 -1
  30. package/dist/workspace/index.d.mts +1 -1
  31. package/dist/workspace/index.mjs +1 -1
  32. package/dist/{workspace-BFRUOOrh.cjs → workspace-CjT323qw.cjs} +3 -2
  33. package/dist/{workspace-BFRUOOrh.cjs.map → workspace-CjT323qw.cjs.map} +1 -1
  34. package/dist/{workspace-DAxG3_H2.mjs → workspace-CmITpum4.mjs} +3 -2
  35. package/dist/{workspace-DAxG3_H2.mjs.map → workspace-CmITpum4.mjs.map} +1 -1
  36. package/package.json +4 -4
  37. package/src/deploy/SSMStateProvider.ts +20 -7
  38. package/src/deploy/StateProvider.ts +1 -1
  39. package/src/deploy/__tests__/SSMStateProvider.spec.ts +15 -8
  40. package/src/deploy/__tests__/state-e2e.spec.ts +385 -0
  41. package/src/init/__tests__/init.spec.ts +10 -1
  42. package/src/secrets/__tests__/storage.spec.ts +6 -2
  43. package/src/workspace/__tests__/index.spec.ts +61 -0
  44. package/src/workspace/index.ts +1 -0
  45. package/src/workspace/types.ts +7 -0
  46. package/dist/SSMStateProvider-BxAPU99a.cjs.map +0 -1
  47. package/dist/SSMStateProvider-C4wp4AZe.mjs.map +0 -1
  48. package/dist/index-B5rGIc4g.d.cts.map +0 -1
  49. package/dist/index-KFEbMIRa.d.mts.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekmidas/cli",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "CLI tools for building Lambda handlers, server applications, and generating OpenAPI specs",
5
5
  "private": false,
6
6
  "type": "module",
@@ -55,9 +55,9 @@
55
55
  "tsx": "~4.20.3",
56
56
  "@geekmidas/constructs": "~1.0.0",
57
57
  "@geekmidas/envkit": "~1.0.0",
58
- "@geekmidas/errors": "~1.0.0",
59
58
  "@geekmidas/logger": "~1.0.0",
60
- "@geekmidas/schema": "~1.0.0"
59
+ "@geekmidas/schema": "~1.0.0",
60
+ "@geekmidas/errors": "~1.0.0"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/lodash.kebabcase": "^4.1.9",
@@ -67,7 +67,7 @@
67
67
  "typescript": "^5.8.2",
68
68
  "vitest": "^3.2.4",
69
69
  "zod": "~4.1.13",
70
- "@geekmidas/testkit": "1.0.0"
70
+ "@geekmidas/testkit": "1.0.1"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "@geekmidas/telescope": "~1.0.0"
@@ -12,6 +12,7 @@ import {
12
12
  ParameterNotFound,
13
13
  PutParameterCommand,
14
14
  SSMClient,
15
+ type SSMClientConfig,
15
16
  } from '@aws-sdk/client-ssm';
16
17
  import type { AwsRegion, StateProvider } from './StateProvider';
17
18
  import type { DokployStageState } from './state';
@@ -19,8 +20,12 @@ import type { DokployStageState } from './state';
19
20
  export interface SSMStateProviderOptions {
20
21
  /** Workspace name (used in parameter path) */
21
22
  workspaceName: string;
22
- /** AWS region (required) */
23
- region: AwsRegion;
23
+ /** AWS region */
24
+ region?: AwsRegion;
25
+ /** AWS credentials (optional - uses default credential chain if not provided) */
26
+ credentials?: SSMClientConfig['credentials'];
27
+ /** Custom endpoint (for LocalStack or other S3-compatible services) */
28
+ endpoint?: string;
24
29
  }
25
30
 
26
31
  /**
@@ -30,14 +35,22 @@ export interface SSMStateProviderOptions {
30
35
  * Parameter path: /gkm/{workspaceName}/{stage}/state
31
36
  */
32
37
  export class SSMStateProvider implements StateProvider {
33
- private readonly client: SSMClient;
34
- private readonly workspaceName: string;
38
+ constructor(
39
+ readonly workspaceName: string,
40
+ private readonly client: SSMClient,
41
+ ) {}
35
42
 
36
- constructor(options: SSMStateProviderOptions) {
37
- this.workspaceName = options.workspaceName;
38
- this.client = new SSMClient({
43
+ /**
44
+ * Create an SSMStateProvider with a new SSMClient.
45
+ */
46
+ static create(options: SSMStateProviderOptions): SSMStateProvider {
47
+ const client = new SSMClient({
39
48
  region: options.region,
49
+ credentials: options.credentials,
50
+ endpoint: options.endpoint,
40
51
  });
52
+
53
+ return new SSMStateProvider(options.workspaceName, client);
41
54
  }
42
55
 
43
56
  /**
@@ -158,7 +158,7 @@ export async function createStateProvider(
158
158
  const { CachedStateProvider } = await import('./CachedStateProvider');
159
159
 
160
160
  const local = new LocalStateProvider(workspaceRoot);
161
- const ssm = new SSMStateProvider({
161
+ const ssm = SSMStateProvider.create({
162
162
  workspaceName,
163
163
  region: (config as SSMStateConfig).region,
164
164
  });
@@ -44,14 +44,8 @@ describe('SSMStateProvider', () => {
44
44
  },
45
45
  });
46
46
 
47
- provider = new SSMStateProvider({
48
- workspaceName,
49
- region: 'us-east-1',
50
- });
51
-
52
- // Override the client's endpoint for localstack
53
- // @ts-expect-error - accessing private property for testing
54
- provider.client = client;
47
+ // Create provider with injected client
48
+ provider = new SSMStateProvider(workspaceName, client);
55
49
  });
56
50
 
57
51
  afterEach(async () => {
@@ -70,6 +64,19 @@ describe('SSMStateProvider', () => {
70
64
  client.destroy();
71
65
  });
72
66
 
67
+ describe('static create', () => {
68
+ it('should create provider with options', () => {
69
+ const provider = SSMStateProvider.create({
70
+ workspaceName: 'my-workspace',
71
+ region: 'us-west-2',
72
+ endpoint: LOCALSTACK_ENDPOINT,
73
+ });
74
+
75
+ expect(provider).toBeInstanceOf(SSMStateProvider);
76
+ expect(provider.workspaceName).toBe('my-workspace');
77
+ });
78
+ });
79
+
73
80
  describe('read', () => {
74
81
  it('should return null when parameter does not exist', async () => {
75
82
  const state = await provider.read('nonexistent-stage');
@@ -0,0 +1,385 @@
1
+ /**
2
+ * End-to-End State Provider Tests
3
+ *
4
+ * Tests the full flow from workspace config to state storage.
5
+ * - Local: verifies state is written to .gkm/deploy-{stage}.json
6
+ * - SSM: verifies state is written to AWS SSM Parameter Store (via LocalStack)
7
+ *
8
+ * SSM tests require LocalStack: docker compose up -d localstack
9
+ */
10
+
11
+ import { mkdir, readFile, rm } from 'node:fs/promises';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import {
15
+ DeleteParameterCommand,
16
+ GetParameterCommand,
17
+ PutParameterCommand,
18
+ SSMClient,
19
+ } from '@aws-sdk/client-ssm';
20
+ import {
21
+ afterAll,
22
+ afterEach,
23
+ beforeAll,
24
+ beforeEach,
25
+ describe,
26
+ expect,
27
+ it,
28
+ } from 'vitest';
29
+ import { normalizeWorkspace } from '../../workspace/index';
30
+ import type { WorkspaceConfig } from '../../workspace/types';
31
+ import { CachedStateProvider } from '../CachedStateProvider';
32
+ import { LocalStateProvider } from '../LocalStateProvider';
33
+ import { SSMStateProvider } from '../SSMStateProvider';
34
+ import { createStateProvider } from '../StateProvider';
35
+ import type { DokployStageState } from '../state';
36
+
37
+ describe('State Provider E2E', () => {
38
+ const testStage = 'e2e-test';
39
+
40
+ const createTestState = (
41
+ overrides?: Partial<DokployStageState>,
42
+ ): DokployStageState => ({
43
+ provider: 'dokploy',
44
+ stage: testStage,
45
+ environmentId: 'env_e2e_123',
46
+ applications: { api: 'app_e2e_123', web: 'app_e2e_456' },
47
+ services: { postgresId: 'pg_e2e_123', redisId: 'redis_e2e_123' },
48
+ lastDeployedAt: '2024-01-01T00:00:00.000Z',
49
+ ...overrides,
50
+ });
51
+
52
+ describe('Local State Provider', () => {
53
+ let testDir: string;
54
+
55
+ beforeEach(async () => {
56
+ testDir = join(tmpdir(), `gkm-e2e-local-${Date.now()}`);
57
+ await mkdir(testDir, { recursive: true });
58
+ });
59
+
60
+ afterEach(async () => {
61
+ await rm(testDir, { recursive: true, force: true });
62
+ });
63
+
64
+ it('should write state to filesystem when config has state.provider = local', async () => {
65
+ // 1. Create workspace config with local state provider
66
+ const config: WorkspaceConfig = {
67
+ name: 'e2e-local-test',
68
+ apps: {
69
+ api: {
70
+ type: 'backend',
71
+ path: 'apps/api',
72
+ port: 3000,
73
+ routes: './src/**/*.ts',
74
+ },
75
+ },
76
+ state: { provider: 'local' },
77
+ };
78
+
79
+ // 2. Normalize the workspace (simulates loadWorkspaceConfig)
80
+ const workspace = normalizeWorkspace(config, testDir);
81
+
82
+ // 3. Verify state config is passed through
83
+ expect(workspace.state).toEqual({ provider: 'local' });
84
+
85
+ // 4. Create state provider from workspace config
86
+ const provider = await createStateProvider({
87
+ config: workspace.state,
88
+ workspaceRoot: workspace.root,
89
+ workspaceName: workspace.name,
90
+ });
91
+
92
+ // 5. Write state
93
+ const state = createTestState();
94
+ await provider.write(testStage, state);
95
+
96
+ // 6. Verify state was written to filesystem directly
97
+ const stateFilePath = join(testDir, '.gkm', `deploy-${testStage}.json`);
98
+ const fileContent = await readFile(stateFilePath, 'utf-8');
99
+ const storedState = JSON.parse(fileContent);
100
+
101
+ expect(storedState.provider).toBe('dokploy');
102
+ expect(storedState.stage).toBe(testStage);
103
+ expect(storedState.environmentId).toBe('env_e2e_123');
104
+ expect(storedState.applications).toEqual({
105
+ api: 'app_e2e_123',
106
+ web: 'app_e2e_456',
107
+ });
108
+ expect(storedState.services).toEqual({
109
+ postgresId: 'pg_e2e_123',
110
+ redisId: 'redis_e2e_123',
111
+ });
112
+ expect(storedState.lastDeployedAt).toBeDefined();
113
+ });
114
+
115
+ it('should write state to filesystem when state config is undefined (default)', async () => {
116
+ // 1. Create workspace config WITHOUT state (should default to local)
117
+ const config: WorkspaceConfig = {
118
+ name: 'e2e-default-test',
119
+ apps: {
120
+ api: {
121
+ type: 'backend',
122
+ path: 'apps/api',
123
+ port: 3000,
124
+ routes: './src/**/*.ts',
125
+ },
126
+ },
127
+ // No state config - should default to local
128
+ };
129
+
130
+ // 2. Normalize the workspace
131
+ const workspace = normalizeWorkspace(config, testDir);
132
+
133
+ // 3. Verify state config is undefined
134
+ expect(workspace.state).toBeUndefined();
135
+
136
+ // 4. Create state provider (should create LocalStateProvider)
137
+ const provider = await createStateProvider({
138
+ config: workspace.state,
139
+ workspaceRoot: workspace.root,
140
+ workspaceName: workspace.name,
141
+ });
142
+
143
+ // 5. Write state
144
+ const state = createTestState();
145
+ await provider.write(testStage, state);
146
+
147
+ // 6. Verify state was written to filesystem
148
+ const stateFilePath = join(testDir, '.gkm', `deploy-${testStage}.json`);
149
+ const fileContent = await readFile(stateFilePath, 'utf-8');
150
+ const storedState = JSON.parse(fileContent);
151
+
152
+ expect(storedState.environmentId).toBe('env_e2e_123');
153
+ expect(storedState.applications.api).toBe('app_e2e_123');
154
+ });
155
+
156
+ it('should read state back correctly through provider', async () => {
157
+ const config: WorkspaceConfig = {
158
+ name: 'e2e-read-test',
159
+ apps: {
160
+ api: {
161
+ type: 'backend',
162
+ path: 'apps/api',
163
+ port: 3000,
164
+ routes: './src/**/*.ts',
165
+ },
166
+ },
167
+ state: { provider: 'local' },
168
+ };
169
+
170
+ const workspace = normalizeWorkspace(config, testDir);
171
+ const provider = await createStateProvider({
172
+ config: workspace.state,
173
+ workspaceRoot: workspace.root,
174
+ workspaceName: workspace.name,
175
+ });
176
+
177
+ // Write state
178
+ const originalState = createTestState();
179
+ await provider.write(testStage, originalState);
180
+
181
+ // Read state back
182
+ const readState = await provider.read(testStage);
183
+
184
+ expect(readState).not.toBeNull();
185
+ expect(readState!.environmentId).toBe('env_e2e_123');
186
+ expect(readState!.applications).toEqual({
187
+ api: 'app_e2e_123',
188
+ web: 'app_e2e_456',
189
+ });
190
+ });
191
+ });
192
+
193
+ describe('SSM State Provider', () => {
194
+ const LOCALSTACK_ENDPOINT = 'http://localhost:4566';
195
+ const workspaceName = 'e2e-ssm-test';
196
+ let ssmClient: SSMClient;
197
+ let testDir: string;
198
+
199
+ beforeAll(() => {
200
+ process.env.AWS_ACCESS_KEY_ID = 'test';
201
+ process.env.AWS_SECRET_ACCESS_KEY = 'test';
202
+ });
203
+
204
+ beforeEach(async () => {
205
+ testDir = join(tmpdir(), `gkm-e2e-ssm-${Date.now()}`);
206
+ await mkdir(testDir, { recursive: true });
207
+
208
+ ssmClient = new SSMClient({
209
+ region: 'us-east-1',
210
+ endpoint: LOCALSTACK_ENDPOINT,
211
+ credentials: {
212
+ accessKeyId: 'test',
213
+ secretAccessKey: 'test',
214
+ },
215
+ });
216
+ });
217
+
218
+ afterEach(async () => {
219
+ // Clean up SSM parameter
220
+ try {
221
+ await ssmClient.send(
222
+ new DeleteParameterCommand({
223
+ Name: `/gkm/${workspaceName}/${testStage}/state`,
224
+ }),
225
+ );
226
+ } catch {
227
+ // Ignore if parameter doesn't exist
228
+ }
229
+
230
+ // Clean up test directory
231
+ await rm(testDir, { recursive: true, force: true });
232
+ });
233
+
234
+ afterAll(() => {
235
+ ssmClient.destroy();
236
+ });
237
+
238
+ it('should write state to SSM when config has state.provider = ssm', async () => {
239
+ // 1. Create workspace config with SSM state provider
240
+ const config: WorkspaceConfig = {
241
+ name: workspaceName,
242
+ apps: {
243
+ api: {
244
+ type: 'backend',
245
+ path: 'apps/api',
246
+ port: 3000,
247
+ routes: './src/**/*.ts',
248
+ },
249
+ },
250
+ state: { provider: 'ssm', region: 'us-east-1' },
251
+ };
252
+
253
+ // 2. Normalize the workspace
254
+ const workspace = normalizeWorkspace(config, testDir);
255
+
256
+ // 3. Verify state config is passed through
257
+ expect(workspace.state).toEqual({ provider: 'ssm', region: 'us-east-1' });
258
+
259
+ // 4. Create providers with injected SSM client (for LocalStack)
260
+ const local = new LocalStateProvider(workspace.root);
261
+ const ssm = new SSMStateProvider(workspace.name, ssmClient);
262
+ const provider = new CachedStateProvider(ssm, local);
263
+
264
+ // 5. Write state
265
+ const state = createTestState();
266
+ await provider.write(testStage, state);
267
+
268
+ // 6. Query SSM directly to verify state was written
269
+ const response = await ssmClient.send(
270
+ new GetParameterCommand({
271
+ Name: `/gkm/${workspaceName}/${testStage}/state`,
272
+ WithDecryption: true,
273
+ }),
274
+ );
275
+
276
+ expect(response.Parameter).toBeDefined();
277
+ expect(response.Parameter!.Value).toBeDefined();
278
+
279
+ const storedState = JSON.parse(response.Parameter!.Value!);
280
+ expect(storedState.provider).toBe('dokploy');
281
+ expect(storedState.stage).toBe(testStage);
282
+ expect(storedState.environmentId).toBe('env_e2e_123');
283
+ expect(storedState.applications).toEqual({
284
+ api: 'app_e2e_123',
285
+ web: 'app_e2e_456',
286
+ });
287
+ expect(storedState.services).toEqual({
288
+ postgresId: 'pg_e2e_123',
289
+ redisId: 'redis_e2e_123',
290
+ });
291
+ });
292
+
293
+ it('should read state from SSM correctly', async () => {
294
+ // 1. Pre-populate SSM with state
295
+ const preExistingState: DokployStageState = {
296
+ provider: 'dokploy',
297
+ stage: testStage,
298
+ environmentId: 'env_pre_existing',
299
+ applications: { api: 'app_pre_123' },
300
+ services: { postgresId: 'pg_pre_123' },
301
+ lastDeployedAt: '2024-06-01T00:00:00.000Z',
302
+ };
303
+
304
+ await ssmClient.send(
305
+ new PutParameterCommand({
306
+ Name: `/gkm/${workspaceName}/${testStage}/state`,
307
+ Value: JSON.stringify(preExistingState),
308
+ Type: 'SecureString',
309
+ Overwrite: true,
310
+ }),
311
+ );
312
+
313
+ // 2. Create workspace config with SSM state provider
314
+ const config: WorkspaceConfig = {
315
+ name: workspaceName,
316
+ apps: {
317
+ api: {
318
+ type: 'backend',
319
+ path: 'apps/api',
320
+ port: 3000,
321
+ routes: './src/**/*.ts',
322
+ },
323
+ },
324
+ state: { provider: 'ssm', region: 'us-east-1' },
325
+ };
326
+
327
+ const workspace = normalizeWorkspace(config, testDir);
328
+
329
+ // 3. Create providers with injected SSM client
330
+ const local = new LocalStateProvider(workspace.root);
331
+ const ssm = new SSMStateProvider(workspace.name, ssmClient);
332
+ const provider = new CachedStateProvider(ssm, local);
333
+
334
+ // 4. Read state through provider
335
+ const readState = await provider.read(testStage);
336
+
337
+ expect(readState).not.toBeNull();
338
+ expect(readState!.environmentId).toBe('env_pre_existing');
339
+ expect(readState!.applications.api).toBe('app_pre_123');
340
+ });
341
+
342
+ it('should use CachedStateProvider that syncs local and remote', async () => {
343
+ const config: WorkspaceConfig = {
344
+ name: workspaceName,
345
+ apps: {
346
+ api: {
347
+ type: 'backend',
348
+ path: 'apps/api',
349
+ port: 3000,
350
+ routes: './src/**/*.ts',
351
+ },
352
+ },
353
+ state: { provider: 'ssm', region: 'us-east-1' },
354
+ };
355
+
356
+ const workspace = normalizeWorkspace(config, testDir);
357
+
358
+ // Create providers with injected SSM client
359
+ const local = new LocalStateProvider(workspace.root);
360
+ const ssm = new SSMStateProvider(workspace.name, ssmClient);
361
+ const provider = new CachedStateProvider(ssm, local);
362
+
363
+ // Write state
364
+ const state = createTestState();
365
+ await provider.write(testStage, state);
366
+
367
+ // Verify both local and SSM have the state
368
+ // Check local file
369
+ const localFilePath = join(testDir, '.gkm', `deploy-${testStage}.json`);
370
+ const localContent = await readFile(localFilePath, 'utf-8');
371
+ const localState = JSON.parse(localContent);
372
+ expect(localState.environmentId).toBe('env_e2e_123');
373
+
374
+ // Check SSM directly
375
+ const ssmResponse = await ssmClient.send(
376
+ new GetParameterCommand({
377
+ Name: `/gkm/${workspaceName}/${testStage}/state`,
378
+ WithDecryption: true,
379
+ }),
380
+ );
381
+ const ssmState = JSON.parse(ssmResponse.Parameter!.Value!);
382
+ expect(ssmState.environmentId).toBe('env_e2e_123');
383
+ });
384
+ });
385
+ });
@@ -1,10 +1,13 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { mkdir, readFile, rm } from 'node:fs/promises';
3
- import { tmpdir } from 'node:os';
3
+ import { homedir, tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
6
  import { initCommand } from '../index.js';
7
7
 
8
+ // Project names used in tests - keystore directories are created at ~/.gkm/{name}
9
+ const TEST_PROJECT_NAMES = ['my-api', 'my-monorepo', 'my-fullstack'];
10
+
8
11
  describe('initCommand', () => {
9
12
  let tempDir: string;
10
13
  let originalCwd: string;
@@ -19,6 +22,12 @@ describe('initCommand', () => {
19
22
  afterEach(async () => {
20
23
  process.chdir(originalCwd);
21
24
  await rm(tempDir, { recursive: true, force: true });
25
+
26
+ // Clean up keystore directories created at ~/.gkm/{project-name}
27
+ const gkmDir = join(homedir(), '.gkm');
28
+ for (const name of TEST_PROJECT_NAMES) {
29
+ await rm(join(gkmDir, name), { recursive: true, force: true });
30
+ }
22
31
  });
23
32
 
24
33
  describe('non-monorepo', () => {
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { mkdir, rm } from 'node:fs/promises';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ import { basename, join } from 'node:path';
5
5
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
6
  import {
7
7
  getSecretsDir,
@@ -56,6 +56,10 @@ describe('file operations', () => {
56
56
  if (existsSync(tempDir)) {
57
57
  await rm(tempDir, { recursive: true });
58
58
  }
59
+
60
+ // Clean up keystore directory created at ~/.gkm/{tempDir-basename}
61
+ const keystoreDir = join(homedir(), '.gkm', basename(tempDir));
62
+ await rm(keystoreDir, { recursive: true, force: true });
59
63
  });
60
64
 
61
65
  describe('writeStageSecrets / readStageSecrets', () => {
@@ -211,6 +211,67 @@ describe('normalizeWorkspace', () => {
211
211
 
212
212
  expect(result.apps.api.resolvedDeployTarget).toBe('dokploy');
213
213
  });
214
+
215
+ it('should pass through state config when specified', () => {
216
+ const config: WorkspaceConfig = {
217
+ apps: {
218
+ api: {
219
+ type: 'backend',
220
+ path: 'apps/api',
221
+ port: 3000,
222
+ routes: './src/**/*.ts',
223
+ },
224
+ },
225
+ state: {
226
+ provider: 'ssm',
227
+ region: 'us-east-1',
228
+ },
229
+ };
230
+
231
+ const result = normalizeWorkspace(config, '/project');
232
+
233
+ expect(result.state).toEqual({
234
+ provider: 'ssm',
235
+ region: 'us-east-1',
236
+ });
237
+ });
238
+
239
+ it('should leave state undefined when not specified', () => {
240
+ const config: WorkspaceConfig = {
241
+ apps: {
242
+ api: {
243
+ type: 'backend',
244
+ path: 'apps/api',
245
+ port: 3000,
246
+ routes: './src/**/*.ts',
247
+ },
248
+ },
249
+ };
250
+
251
+ const result = normalizeWorkspace(config, '/project');
252
+
253
+ expect(result.state).toBeUndefined();
254
+ });
255
+
256
+ it('should pass through local state config', () => {
257
+ const config: WorkspaceConfig = {
258
+ apps: {
259
+ api: {
260
+ type: 'backend',
261
+ path: 'apps/api',
262
+ port: 3000,
263
+ routes: './src/**/*.ts',
264
+ },
265
+ },
266
+ state: {
267
+ provider: 'local',
268
+ },
269
+ };
270
+
271
+ const result = normalizeWorkspace(config, '/project');
272
+
273
+ expect(result.state).toEqual({ provider: 'local' });
274
+ });
214
275
  });
215
276
 
216
277
  describe('wrapSingleAppAsWorkspace', () => {
@@ -176,6 +176,7 @@ export function normalizeWorkspace(
176
176
  deploy: config.deploy ?? { default: 'dokploy' },
177
177
  shared: config.shared ?? { packages: ['packages/*'] },
178
178
  secrets: config.secrets ?? {},
179
+ state: config.state,
179
180
  };
180
181
  }
181
182
 
@@ -744,6 +744,8 @@ export type WorkspaceInput<TApps extends AppsRecord> = {
744
744
  services?: ServicesConfig;
745
745
  /** Encrypted secrets configuration */
746
746
  secrets?: SecretsConfig;
747
+ /** State provider configuration (local filesystem by default, or SSM for team collaboration) */
748
+ state?: StateConfig;
747
749
  };
748
750
 
749
751
  /**
@@ -765,6 +767,7 @@ export type InferredWorkspaceConfig<TApps extends AppsRecord> = {
765
767
  deploy?: DeployConfig;
766
768
  services?: ServicesConfig;
767
769
  secrets?: SecretsConfig;
770
+ state?: StateConfig;
768
771
  };
769
772
 
770
773
  // Legacy types for backwards compatibility
@@ -776,6 +779,7 @@ export type RawWorkspaceInput = {
776
779
  deploy?: DeployConfig;
777
780
  services?: ServicesConfig;
778
781
  secrets?: SecretsConfig;
782
+ state?: StateConfig;
779
783
  };
780
784
 
781
785
  /** @deprecated Use WorkspaceInput */
@@ -876,6 +880,9 @@ export interface WorkspaceConfig {
876
880
 
877
881
  /** Encrypted secrets configuration */
878
882
  secrets?: SecretsConfig;
883
+
884
+ /** State provider configuration (local filesystem by default, or SSM for team collaboration) */
885
+ state?: StateConfig;
879
886
  }
880
887
 
881
888
  /**
@@ -1 +0,0 @@
1
- {"version":3,"file":"SSMStateProvider-BxAPU99a.cjs","names":["options: SSMStateProviderOptions","SSMClient","stage: string","GetParameterCommand","ParameterNotFound","state: DokployStageState","PutParameterCommand"],"sources":["../src/deploy/SSMStateProvider.ts"],"sourcesContent":["/**\n * AWS SSM Parameter Store State Provider\n *\n * Stores deployment state as SecureString parameters in AWS SSM.\n * Uses AWS-managed KMS key for encryption (free tier).\n *\n * Parameter naming: /gkm/{workspaceName}/{stage}/state\n */\n\nimport {\n\tGetParameterCommand,\n\tParameterNotFound,\n\tPutParameterCommand,\n\tSSMClient,\n} from '@aws-sdk/client-ssm';\nimport type { AwsRegion, StateProvider } from './StateProvider';\nimport type { DokployStageState } from './state';\n\nexport interface SSMStateProviderOptions {\n\t/** Workspace name (used in parameter path) */\n\tworkspaceName: string;\n\t/** AWS region (required) */\n\tregion: AwsRegion;\n}\n\n/**\n * AWS SSM Parameter Store state provider.\n *\n * Stores state as encrypted SecureString parameters.\n * Parameter path: /gkm/{workspaceName}/{stage}/state\n */\nexport class SSMStateProvider implements StateProvider {\n\tprivate readonly client: SSMClient;\n\tprivate readonly workspaceName: string;\n\n\tconstructor(options: SSMStateProviderOptions) {\n\t\tthis.workspaceName = options.workspaceName;\n\t\tthis.client = new SSMClient({\n\t\t\tregion: options.region,\n\t\t});\n\t}\n\n\t/**\n\t * Get the SSM parameter name for a stage.\n\t */\n\tprivate getParameterName(stage: string): string {\n\t\treturn `/gkm/${this.workspaceName}/${stage}/state`;\n\t}\n\n\tasync read(stage: string): Promise<DokployStageState | null> {\n\t\tconst parameterName = this.getParameterName(stage);\n\n\t\ttry {\n\t\t\tconst response = await this.client.send(\n\t\t\t\tnew GetParameterCommand({\n\t\t\t\t\tName: parameterName,\n\t\t\t\t\tWithDecryption: true,\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\tif (!response.Parameter?.Value) {\n\t\t\t\treturn null;\n\t\t\t}\n\n\t\t\treturn JSON.parse(response.Parameter.Value) as DokployStageState;\n\t\t} catch (error) {\n\t\t\t// Parameter doesn't exist - return null (new deployment)\n\t\t\tif (error instanceof ParameterNotFound) {\n\t\t\t\treturn null;\n\t\t\t}\n\n\t\t\t// Re-throw other errors (permission denied, network, etc.)\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\tasync write(stage: string, state: DokployStageState): Promise<void> {\n\t\tconst parameterName = this.getParameterName(stage);\n\n\t\t// Update last deployed timestamp\n\t\tstate.lastDeployedAt = new Date().toISOString();\n\n\t\tawait this.client.send(\n\t\t\tnew PutParameterCommand({\n\t\t\t\tName: parameterName,\n\t\t\t\tValue: JSON.stringify(state),\n\t\t\t\tType: 'SecureString',\n\t\t\t\tOverwrite: true,\n\t\t\t\tDescription: `GKM deployment state for ${this.workspaceName}/${stage}`,\n\t\t\t}),\n\t\t);\n\t}\n}\n"],"mappings":";;;;;;;;;;AA+BA,IAAa,mBAAb,MAAuD;CACtD,AAAiB;CACjB,AAAiB;CAEjB,YAAYA,SAAkC;AAC7C,OAAK,gBAAgB,QAAQ;AAC7B,OAAK,SAAS,IAAIC,+BAAU,EAC3B,QAAQ,QAAQ,OAChB;CACD;;;;CAKD,AAAQ,iBAAiBC,OAAuB;AAC/C,UAAQ,OAAO,KAAK,cAAc,GAAG,MAAM;CAC3C;CAED,MAAM,KAAKA,OAAkD;EAC5D,MAAM,gBAAgB,KAAK,iBAAiB,MAAM;AAElD,MAAI;GACH,MAAM,WAAW,MAAM,KAAK,OAAO,KAClC,IAAIC,yCAAoB;IACvB,MAAM;IACN,gBAAgB;GAChB,GACD;AAED,QAAK,SAAS,WAAW,MACxB,QAAO;AAGR,UAAO,KAAK,MAAM,SAAS,UAAU,MAAM;EAC3C,SAAQ,OAAO;AAEf,OAAI,iBAAiBC,uCACpB,QAAO;AAIR,SAAM;EACN;CACD;CAED,MAAM,MAAMF,OAAeG,OAAyC;EACnE,MAAM,gBAAgB,KAAK,iBAAiB,MAAM;AAGlD,QAAM,iBAAiB,qBAAI,QAAO,aAAa;AAE/C,QAAM,KAAK,OAAO,KACjB,IAAIC,yCAAoB;GACvB,MAAM;GACN,OAAO,KAAK,UAAU,MAAM;GAC5B,MAAM;GACN,WAAW;GACX,cAAc,2BAA2B,KAAK,cAAc,GAAG,MAAM;EACrE,GACD;CACD;AACD"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"SSMStateProvider-C4wp4AZe.mjs","names":["options: SSMStateProviderOptions","stage: string","state: DokployStageState"],"sources":["../src/deploy/SSMStateProvider.ts"],"sourcesContent":["/**\n * AWS SSM Parameter Store State Provider\n *\n * Stores deployment state as SecureString parameters in AWS SSM.\n * Uses AWS-managed KMS key for encryption (free tier).\n *\n * Parameter naming: /gkm/{workspaceName}/{stage}/state\n */\n\nimport {\n\tGetParameterCommand,\n\tParameterNotFound,\n\tPutParameterCommand,\n\tSSMClient,\n} from '@aws-sdk/client-ssm';\nimport type { AwsRegion, StateProvider } from './StateProvider';\nimport type { DokployStageState } from './state';\n\nexport interface SSMStateProviderOptions {\n\t/** Workspace name (used in parameter path) */\n\tworkspaceName: string;\n\t/** AWS region (required) */\n\tregion: AwsRegion;\n}\n\n/**\n * AWS SSM Parameter Store state provider.\n *\n * Stores state as encrypted SecureString parameters.\n * Parameter path: /gkm/{workspaceName}/{stage}/state\n */\nexport class SSMStateProvider implements StateProvider {\n\tprivate readonly client: SSMClient;\n\tprivate readonly workspaceName: string;\n\n\tconstructor(options: SSMStateProviderOptions) {\n\t\tthis.workspaceName = options.workspaceName;\n\t\tthis.client = new SSMClient({\n\t\t\tregion: options.region,\n\t\t});\n\t}\n\n\t/**\n\t * Get the SSM parameter name for a stage.\n\t */\n\tprivate getParameterName(stage: string): string {\n\t\treturn `/gkm/${this.workspaceName}/${stage}/state`;\n\t}\n\n\tasync read(stage: string): Promise<DokployStageState | null> {\n\t\tconst parameterName = this.getParameterName(stage);\n\n\t\ttry {\n\t\t\tconst response = await this.client.send(\n\t\t\t\tnew GetParameterCommand({\n\t\t\t\t\tName: parameterName,\n\t\t\t\t\tWithDecryption: true,\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\tif (!response.Parameter?.Value) {\n\t\t\t\treturn null;\n\t\t\t}\n\n\t\t\treturn JSON.parse(response.Parameter.Value) as DokployStageState;\n\t\t} catch (error) {\n\t\t\t// Parameter doesn't exist - return null (new deployment)\n\t\t\tif (error instanceof ParameterNotFound) {\n\t\t\t\treturn null;\n\t\t\t}\n\n\t\t\t// Re-throw other errors (permission denied, network, etc.)\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\tasync write(stage: string, state: DokployStageState): Promise<void> {\n\t\tconst parameterName = this.getParameterName(stage);\n\n\t\t// Update last deployed timestamp\n\t\tstate.lastDeployedAt = new Date().toISOString();\n\n\t\tawait this.client.send(\n\t\t\tnew PutParameterCommand({\n\t\t\t\tName: parameterName,\n\t\t\t\tValue: JSON.stringify(state),\n\t\t\t\tType: 'SecureString',\n\t\t\t\tOverwrite: true,\n\t\t\t\tDescription: `GKM deployment state for ${this.workspaceName}/${stage}`,\n\t\t\t}),\n\t\t);\n\t}\n}\n"],"mappings":";;;;;;;;;AA+BA,IAAa,mBAAb,MAAuD;CACtD,AAAiB;CACjB,AAAiB;CAEjB,YAAYA,SAAkC;AAC7C,OAAK,gBAAgB,QAAQ;AAC7B,OAAK,SAAS,IAAI,UAAU,EAC3B,QAAQ,QAAQ,OAChB;CACD;;;;CAKD,AAAQ,iBAAiBC,OAAuB;AAC/C,UAAQ,OAAO,KAAK,cAAc,GAAG,MAAM;CAC3C;CAED,MAAM,KAAKA,OAAkD;EAC5D,MAAM,gBAAgB,KAAK,iBAAiB,MAAM;AAElD,MAAI;GACH,MAAM,WAAW,MAAM,KAAK,OAAO,KAClC,IAAI,oBAAoB;IACvB,MAAM;IACN,gBAAgB;GAChB,GACD;AAED,QAAK,SAAS,WAAW,MACxB,QAAO;AAGR,UAAO,KAAK,MAAM,SAAS,UAAU,MAAM;EAC3C,SAAQ,OAAO;AAEf,OAAI,iBAAiB,kBACpB,QAAO;AAIR,SAAM;EACN;CACD;CAED,MAAM,MAAMA,OAAeC,OAAyC;EACnE,MAAM,gBAAgB,KAAK,iBAAiB,MAAM;AAGlD,QAAM,iBAAiB,qBAAI,QAAO,aAAa;AAE/C,QAAM,KAAK,OAAO,KACjB,IAAI,oBAAoB;GACvB,MAAM;GACN,OAAO,KAAK,UAAU,MAAM;GAC5B,MAAM;GACN,WAAW;GACX,cAAc,2BAA2B,KAAK,cAAc,GAAG,MAAM;EACrE,GACD;CACD;AACD"}