Skip to content

Commit 49d7073

Browse files
committed
feat(desktop): mildstack-cli integration
1 parent 4980b0b commit 49d7073

15 files changed

Lines changed: 870 additions & 110 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,6 @@ AGENTS.md
1616

1717
# go binary
1818
mildstack
19+
20+
*.env*
21+
!.env.example

apps/desktop/.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
2+
# The path or command to the mildstack binary
3+
MAIN_VITE_MILDSTACK_EXECUTABLE=

apps/desktop/src/main/dynamodb-ipc.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { ipcMain } from 'electron'
21
import { getActiveInstancePort } from './instance-state'
2+
import { registerValidatedHandler } from './ipc-middleware'
33
import {
44
DynamoDBClient,
55
ListTablesCommand,
@@ -79,12 +79,12 @@ type DynamoDBClientCacheEntry = {
7979
const clientCache = new Map<string, DynamoDBClientCacheEntry>()
8080

8181
export function registerDynamoDBIpcHandlers(): void {
82-
ipcMain.handle('dynamodb:listTables', async (_event, args: { region?: string }) => {
82+
registerValidatedHandler('dynamodb:listTables', async (_event, args: { region?: string }) => {
8383
const response = await getClient(args.region).send(new ListTablesCommand({}))
8484
return response.TableNames ?? []
8585
})
8686

87-
ipcMain.handle('dynamodb:describeTable', async (_event, args: DescribeTableArgs) => {
87+
registerValidatedHandler('dynamodb:describeTable', async (_event, args: DescribeTableArgs) => {
8888
const response = await getClient(args.region).send(
8989
new DescribeTableCommand({ TableName: args.tableName })
9090
)
@@ -136,7 +136,7 @@ export function registerDynamoDBIpcHandlers(): void {
136136
}
137137
})
138138

139-
ipcMain.handle('dynamodb:createTable', async (_event, args: CreateTableArgs) => {
139+
registerValidatedHandler('dynamodb:createTable', async (_event, args: CreateTableArgs) => {
140140
await getClient(args.region).send(
141141
new CreateTableCommand({
142142
TableName: args.tableName,
@@ -148,14 +148,14 @@ export function registerDynamoDBIpcHandlers(): void {
148148
return null
149149
})
150150

151-
ipcMain.handle('dynamodb:deleteTable', async (_event, args: { tableName: string; region?: string }) => {
151+
registerValidatedHandler('dynamodb:deleteTable', async (_event, args: { tableName: string; region?: string }) => {
152152
await getClient(args.region).send(
153153
new DeleteTableCommand({ TableName: args.tableName })
154154
)
155155
return null
156156
})
157157

158-
ipcMain.handle('dynamodb:scan', async (_event, args: ScanArgs) => {
158+
registerValidatedHandler('dynamodb:scan', async (_event, args: ScanArgs) => {
159159
const params: ScanCommandInput = {
160160
TableName: args.tableName,
161161
ExclusiveStartKey: args.exclusiveStartKey,
@@ -180,7 +180,7 @@ export function registerDynamoDBIpcHandlers(): void {
180180
}
181181
})
182182

183-
ipcMain.handle('dynamodb:query', async (_event, args: QueryArgs) => {
183+
registerValidatedHandler('dynamodb:query', async (_event, args: QueryArgs) => {
184184
const params: QueryCommandInput = {
185185
TableName: args.tableName,
186186
KeyConditionExpression: args.keyConditionExpression,
@@ -210,7 +210,7 @@ export function registerDynamoDBIpcHandlers(): void {
210210
}
211211
})
212212

213-
ipcMain.handle('dynamodb:putItem', async (_event, args: PutItemArgs) => {
213+
registerValidatedHandler('dynamodb:putItem', async (_event, args: PutItemArgs) => {
214214
await getClient(args.region).send(
215215
new PutItemCommand({
216216
TableName: args.tableName,
@@ -220,7 +220,7 @@ export function registerDynamoDBIpcHandlers(): void {
220220
return null
221221
})
222222

223-
ipcMain.handle('dynamodb:deleteItem', async (_event, args: DeleteItemArgs) => {
223+
registerValidatedHandler('dynamodb:deleteItem', async (_event, args: DeleteItemArgs) => {
224224
await getClient(args.region).send(
225225
new DeleteItemCommand({
226226
TableName: args.tableName,
@@ -230,7 +230,7 @@ export function registerDynamoDBIpcHandlers(): void {
230230
return null
231231
})
232232

233-
ipcMain.handle('dynamodb:getItem', async (_event, args: GetItemArgs) => {
233+
registerValidatedHandler('dynamodb:getItem', async (_event, args: GetItemArgs) => {
234234
const response = await getClient(args.region).send(
235235
new GetItemCommand({
236236
TableName: args.tableName,

apps/desktop/src/main/env.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/// <reference types="vite/client" />
2+
3+
interface ImportMetaEnv {
4+
readonly MAIN_VITE_MILDSTACK_EXECUTABLE: string
5+
}
6+
7+
interface ImportMeta {
8+
readonly env: ImportMetaEnv
9+
}

apps/desktop/src/main/index.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { autoUpdater } from 'electron-updater'
55
import icon from '../../build/icon.png?asset'
66
import { registerS3IpcHandlers } from './s3-ipc'
77
import { registerDynamoDBIpcHandlers } from './dynamodb-ipc'
8-
import { setActiveInstancePort } from './instance-state'
8+
import { registerMildStackIpcHandlers } from './mildstack-ipc'
99

1010
// Set app name for macOS Dock and Menu Bar as early as possible
1111
if (process.platform === 'darwin') {
@@ -82,10 +82,7 @@ app.whenReady().then(() => {
8282
ipcMain.on('ping', () => console.log('pong'))
8383
registerS3IpcHandlers()
8484
registerDynamoDBIpcHandlers()
85-
ipcMain.handle('instance:setSelected', (_event, port: number) => {
86-
setActiveInstancePort(port)
87-
return true
88-
})
85+
registerMildStackIpcHandlers()
8986

9087
createWindow()
9188
checkForUpdates()
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { ipcMain } from 'electron'
2+
import { getActiveInstancePort } from './instance-state'
3+
import { exec } from 'node:child_process'
4+
import { promisify } from 'node:util'
5+
6+
const userShell = process.env.SHELL || '/bin/zsh'
7+
const execAsync = (cmd: string) => promisify(exec)(cmd, { shell: userShell })
8+
const mildStackExecutable = import.meta.env.MAIN_VITE_MILDSTACK_EXECUTABLE || 'mildstack'
9+
10+
/**
11+
* Validates that the currently selected instance is running before
12+
* allowing S3/DynamoDB commands to execute. This is called as a
13+
* pre-check within IPC handlers.
14+
*
15+
* Returns true if the instance is running, throws otherwise.
16+
*/
17+
export async function assertInstanceRunning(): Promise<void> {
18+
const port = getActiveInstancePort()
19+
try {
20+
const { stdout } = await execAsync(`${mildStackExecutable} instances --json`)
21+
const response = JSON.parse(stdout)
22+
const instance = response.instances?.find((i: { port: number }) => i.port === port)
23+
24+
if (!instance) {
25+
throw new Error(`No MildStack instance found on port ${port}. Please start an instance first.`)
26+
}
27+
if (instance.status !== 'running') {
28+
throw new Error(
29+
`MildStack instance on port ${port} is not running (status: ${instance.status}). Please start it first.`
30+
)
31+
}
32+
} catch (err) {
33+
if (err instanceof Error && err.message.includes('MildStack instance')) {
34+
throw err
35+
}
36+
throw new Error(`Unable to verify MildStack instance on port ${port}. Is the CLI installed?`)
37+
}
38+
}
39+
40+
/**
41+
* Wraps an ipcMain.handle callback with instance validation.
42+
* Before the handler executes, it checks that the selected instance is running.
43+
*/
44+
export function withInstanceValidation<T extends unknown[], R>(
45+
handler: (event: Electron.IpcMainInvokeEvent, ...args: T) => Promise<R>
46+
): (event: Electron.IpcMainInvokeEvent, ...args: T) => Promise<R> {
47+
return async (event, ...args) => {
48+
await assertInstanceRunning()
49+
return handler(event, ...args)
50+
}
51+
}
52+
53+
/**
54+
* Registers an IPC handler with instance validation middleware.
55+
* Usage: registerValidatedHandler('channel', async (event, args) => { ... })
56+
*/
57+
export function registerValidatedHandler<T extends unknown[], R>(
58+
channel: string,
59+
handler: (event: Electron.IpcMainInvokeEvent, ...args: T) => Promise<R>
60+
): void {
61+
ipcMain.handle(channel, withInstanceValidation(handler))
62+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { ipcMain } from "electron"
2+
import { getActiveInstancePort, setActiveInstancePort } from "./instance-state"
3+
import { exec } from 'node:child_process'
4+
import { promisify } from 'node:util'
5+
6+
const userShell = process.env.SHELL || '/bin/zsh'
7+
const execAsync = (cmd: string) => promisify(exec)(cmd, { shell: userShell })
8+
9+
const mildStackExecutable = import.meta.env.MAIN_VITE_MILDSTACK_EXECUTABLE || 'mildstack'
10+
11+
export type MildStackInstanceStatus = 'running' | 'not_started' | 'errored'
12+
13+
export interface MildStackInstance {
14+
instanceId: string
15+
port: number
16+
pid?: number
17+
status: MildStackInstanceStatus
18+
error?: string
19+
}
20+
21+
export interface MildStackInstancesResponse {
22+
state: string
23+
services: Array<{
24+
name: string
25+
version: string
26+
tags: string[]
27+
}>
28+
instances: MildStackInstance[]
29+
ports: number[] | null
30+
}
31+
32+
/**
33+
* Parses CLI error output. The CLI logs the error message on the first line
34+
* and exits with status 1. execAsync rejects on non-zero exit, so we catch
35+
* and extract.
36+
*/
37+
function parseCliError(err: unknown): string {
38+
if (err && typeof err === 'object' && 'stderr' in err) {
39+
const stderr = (err as { stderr: string }).stderr?.trim()
40+
if (stderr) {
41+
// First line is usually "Error: <message>"
42+
const firstLine = stderr.split('\n')[0]
43+
return firstLine.replace(/^Error:\s*/, '')
44+
}
45+
}
46+
if (err && typeof err === 'object' && 'message' in err) {
47+
return (err as Error).message
48+
}
49+
return 'Unknown CLI error'
50+
}
51+
52+
export function registerMildStackIpcHandlers(): void {
53+
ipcMain.handle('instance:port', async () => {
54+
return getActiveInstancePort()
55+
})
56+
57+
ipcMain.handle('instance:setSelected', (_event, port: number) => {
58+
setActiveInstancePort(port)
59+
return true
60+
})
61+
62+
ipcMain.handle('mildstack:instances', async (_event): Promise<MildStackInstancesResponse> => {
63+
try {
64+
const { stdout } = await execAsync(`${mildStackExecutable} instances --json`)
65+
return JSON.parse(stdout)
66+
} catch (err) {
67+
// If no instances exist or CLI is not available, return empty state
68+
console.error('[MildStack IPC] instances error:', err)
69+
return {
70+
state: 'not_started',
71+
services: [],
72+
instances: [],
73+
ports: null
74+
}
75+
}
76+
})
77+
78+
ipcMain.handle('mildstack:serve', async (_event, port: number): Promise<{ success: boolean; error?: string }> => {
79+
try {
80+
// --d flag to detach (run in background)
81+
await execAsync(`${mildStackExecutable} serve ${port} --d`)
82+
return { success: true }
83+
} catch (err) {
84+
const error = parseCliError(err)
85+
console.error('[MildStack IPC] serve error:', error)
86+
return { success: false, error }
87+
}
88+
})
89+
90+
ipcMain.handle('mildstack:stop', async (_event, args: { port?: number; all?: boolean }): Promise<{ success: boolean; error?: string }> => {
91+
try {
92+
let cmd = `${mildStackExecutable} stop`
93+
if (args.all) {
94+
cmd += ' --all'
95+
} else if (args.port) {
96+
cmd += ` ${args.port}`
97+
}
98+
cmd += ' --json'
99+
await execAsync(cmd)
100+
return { success: true }
101+
} catch (err) {
102+
const error = parseCliError(err)
103+
console.error('[MildStack IPC] stop error:', error)
104+
return { success: false, error }
105+
}
106+
})
107+
108+
ipcMain.handle('mildstack:delete', async (_event, args: { port?: number; all?: boolean }): Promise<{ success: boolean; error?: string }> => {
109+
try {
110+
let cmd = `${mildStackExecutable} delete`
111+
if (args.all) {
112+
cmd += ' --all'
113+
} else if (args.port) {
114+
cmd += ` ${args.port}`
115+
}
116+
cmd += ' --json'
117+
await execAsync(cmd)
118+
return { success: true }
119+
} catch (err) {
120+
const error = parseCliError(err)
121+
console.error('[MildStack IPC] delete error:', error)
122+
return { success: false, error }
123+
}
124+
})
125+
126+
// Validation handler — checks if the selected instance is running
127+
ipcMain.handle('mildstack:validateInstance', async (_event): Promise<{ valid: boolean; error?: string }> => {
128+
const port = getActiveInstancePort()
129+
try {
130+
const { stdout } = await execAsync(`${mildStackExecutable} instances --json`)
131+
const response: MildStackInstancesResponse = JSON.parse(stdout)
132+
const instance = response.instances.find(i => i.port === port)
133+
134+
if (!instance) {
135+
return { valid: false, error: `No instance found on port ${port}` }
136+
}
137+
if (instance.status !== 'running') {
138+
return { valid: false, error: `Instance on port ${port} is not running (status: ${instance.status})` }
139+
}
140+
return { valid: true }
141+
} catch {
142+
return { valid: false, error: `Unable to verify instance on port ${port}` }
143+
}
144+
})
145+
}

0 commit comments

Comments
 (0)