@pikku/deploy-standalone 0.12.12 → 0.12.17

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 (50) hide show
  1. package/CHANGELOG.md +177 -0
  2. package/dist/adapter.d.ts +45 -0
  3. package/dist/adapter.js +448 -6
  4. package/dist/runtime/cli.d.ts +75 -0
  5. package/dist/runtime/cli.js +193 -0
  6. package/dist/runtime/index.d.ts +11 -0
  7. package/dist/runtime/index.js +9 -0
  8. package/dist/runtime/parent-watch.d.ts +45 -0
  9. package/dist/runtime/parent-watch.js +87 -0
  10. package/dist/tauri/generate.d.ts +45 -0
  11. package/dist/tauri/generate.js +230 -0
  12. package/dist/tauri/icon.d.ts +1 -0
  13. package/dist/tauri/icon.js +54 -0
  14. package/dist/tauri/main-rs.d.ts +31 -0
  15. package/dist/tauri/main-rs.js +213 -0
  16. package/dist/tauri/next-steps.d.ts +15 -0
  17. package/dist/tauri/next-steps.js +16 -0
  18. package/dist/tauri/target-triple.d.ts +29 -0
  19. package/dist/tauri/target-triple.js +42 -0
  20. package/knowledge/decisions/a-pikku-server-serves-a-static-frontend.md +36 -0
  21. package/knowledge/decisions/a-remote-desktop-shell-bundles-nothing.md +38 -0
  22. package/knowledge/decisions/deploy-consumes-a-built-frontend.md +33 -0
  23. package/knowledge/decisions/desktop-builds-are-unsigned-and-never-update-themselves.md +34 -0
  24. package/knowledge/decisions/index.md +19 -0
  25. package/knowledge/decisions/standalone-assets-are-embedded-in-the-bun-binary.md +39 -0
  26. package/knowledge/decisions/the-desktop-shell-runs-the-server-as-a-sidecar.md +51 -0
  27. package/knowledge/decisions/the-sidecar-reports-its-port-the-shell-never-picks-one.md +44 -0
  28. package/knowledge/index.md +22 -0
  29. package/package.json +7 -4
  30. package/src/adapter.test.ts +725 -0
  31. package/src/adapter.ts +508 -6
  32. package/src/desktop-deploy.test.ts +167 -0
  33. package/src/runtime/cli.test.ts +222 -0
  34. package/src/runtime/cli.ts +311 -0
  35. package/src/runtime/index.ts +31 -0
  36. package/src/runtime/parent-watch.process.test.ts +112 -0
  37. package/src/runtime/parent-watch.test.ts +148 -0
  38. package/src/runtime/parent-watch.ts +115 -0
  39. package/src/sidecar-entry.test.ts +89 -0
  40. package/src/tauri/generate.test.ts +401 -0
  41. package/src/tauri/generate.ts +327 -0
  42. package/src/tauri/icon.test.ts +63 -0
  43. package/src/tauri/icon.ts +62 -0
  44. package/src/tauri/main-rs.rustfmt.test.ts +86 -0
  45. package/src/tauri/main-rs.ts +241 -0
  46. package/src/tauri/next-steps.test.ts +38 -0
  47. package/src/tauri/next-steps.ts +30 -0
  48. package/src/tauri/target-triple.test.ts +84 -0
  49. package/src/tauri/target-triple.ts +65 -0
  50. package/tsconfig.tsbuildinfo +1 -1
package/src/adapter.ts CHANGED
@@ -17,12 +17,329 @@
17
17
  * `bun build --compile`. No runtime needed on the target host.
18
18
  */
19
19
  import type { EntryGenerationContext, ProviderAdapter } from '@pikku/deploy'
20
- import { nodeBuiltinExternals } from '@pikku/deploy'
20
+ import { nodeBuiltinExternals, SERVER_READY_MARKER } from '@pikku/deploy'
21
21
 
22
22
  export type StandaloneRuntime = 'node' | 'bun'
23
23
 
24
+ /**
25
+ * Directory the built frontend is copied to, both inside the unit and beside
26
+ * the shipped bundle. The node entry resolves it relative to itself at runtime,
27
+ * so the two have to agree.
28
+ */
29
+ export const STANDALONE_FRONTEND_DIR = 'frontend'
30
+
31
+ /**
32
+ * Module the bun entry imports its embedded assets from. It stays out of the
33
+ * esbuild bundle — esbuild rejects the `with { type: 'file' }` attribute the
34
+ * manifest is built on — and is resolved by `bun build --compile` instead.
35
+ */
36
+ export const STANDALONE_FRONTEND_MANIFEST = './frontend-assets.gen.js'
37
+
38
+ /**
39
+ * Lines every standalone entry ends with, whatever the runtime.
40
+ *
41
+ * The ready line is the handshake a parent process — `pikku dev --spawn`, or
42
+ * the desktop shell that runs this binary as a sidecar — blocks on. It carries
43
+ * `server.port` rather than the requested port because a shell passes `PORT=0`:
44
+ * picking a free port in the parent and handing it down races anything else
45
+ * that binds it in between, so the server binds first and reports back.
46
+ */
47
+ const sidecarHandshakeLines = (): string[] => [
48
+ ` watchParentProcess()`,
49
+ ` console.log(\`${SERVER_READY_MARKER} on http://\${hostname}:\${server.port}\`)`,
50
+ ]
51
+
52
+ /**
53
+ * The runtime helpers the entry imports. The database ones are left out of a
54
+ * build with no database, so the bundle carries no migrator it can never run.
55
+ */
56
+ const runtimeImport = (ctx: EntryGenerationContext): string => {
57
+ const names = ['watchParentProcess', 'parseStandaloneCommand']
58
+ if (ctx.db) names.push('runStandaloneCommand', 'resolveMigrationsDir')
59
+ return `import { ${names.join(', ')} } from '@pikku/deploy-standalone/runtime'`
60
+ }
61
+
62
+ /**
63
+ * Environment variable naming the directory the SQLite file lives in.
64
+ *
65
+ * The database has to outlive a release. A deploy that swaps the artifact
66
+ * directory would take the database with it if the file sat beside the bundle,
67
+ * so the path comes from the environment and points somewhere the operator
68
+ * keeps stable across releases, rather than being derived from the bundle's own
69
+ * location the way the frontend directory is.
70
+ */
71
+ const DATA_DIR_VAR = 'PIKKU_DATA_DIR'
72
+
73
+ /**
74
+ * Full override for the database file, for when it must match a path something
75
+ * else already decided — notably `pikku db migrate`, which has to open the same
76
+ * file this opens or the app runs against an unmigrated database.
77
+ */
78
+ const DATABASE_FILE_VAR = 'PIKKU_DATABASE_FILE'
79
+
80
+ const DEFAULT_DATABASE_FILENAME = 'pikku.db'
81
+
82
+ /**
83
+ * The dialect factory each runtime opens SQLite with. bun cannot use the node
84
+ * one — `bun:sqlite` is a different driver, and the node build reaches for
85
+ * `node:sqlite`, which a compiled bun binary does not carry.
86
+ */
87
+ const SQLITE_FACTORY = {
88
+ node: {
89
+ specifier: '@pikku/kysely-node-sqlite',
90
+ fn: 'createNodeSqliteKysely',
91
+ },
92
+ bun: { specifier: '@pikku/kysely-bun-sqlite', fn: 'createBunSqliteKysely' },
93
+ } as const
94
+
95
+ /**
96
+ * The environment variable a Postgres build reads its connection string from.
97
+ *
98
+ * The same name every other pikku host uses, so an artifact dropped onto a
99
+ * machine that already runs a pikku app needs no new variable.
100
+ */
101
+ const DATABASE_URL_VAR = 'DATABASE_URL'
102
+
103
+ /** Imports the coercion plugin, for an app that generated a map. */
104
+ const coercionImportLines = (coercionImportPath: string): string[] => [
105
+ `import { createCoercionPlugin } from '@pikku/kysely'`,
106
+ `import { coercionMap as __pikkuCoercionMap } from '${coercionImportPath}'`,
107
+ ]
108
+
109
+ /** Imports a database-backed entry needs on top of the common set. */
110
+ const dbImportLines = (
111
+ runtime: 'node' | 'bun',
112
+ db: NonNullable<EntryGenerationContext['db']>
113
+ ): string[] => [
114
+ ...(db.engine === 'sqlite'
115
+ ? [
116
+ `import { ${SQLITE_FACTORY[runtime].fn} } from '${SQLITE_FACTORY[runtime].specifier}'`,
117
+ `import { mkdirSync as __pikkuMkdirSync } from 'node:fs'`,
118
+ ]
119
+ : [`import { PikkuKysely } from '@pikku/kysely-postgres'`]),
120
+ ...(db.coercionImportPath ? coercionImportLines(db.coercionImportPath) : []),
121
+ ]
122
+
123
+ /**
124
+ * Opens the database before services are built, so `createSingletonServices`
125
+ * receives `kysely` exactly as a hosted runtime would hand it over.
126
+ *
127
+ * Creating the directory rather than requiring it is deliberate: the artifact
128
+ * is expected to start on a machine where nothing has run yet, and a missing
129
+ * parent directory is the difference between a first boot that works and one
130
+ * that needs a documented mkdir nobody reads.
131
+ */
132
+ const dbSetupLines = (
133
+ runtime: 'node' | 'bun',
134
+ db: NonNullable<EntryGenerationContext['db']>
135
+ ): string[] => {
136
+ const plugins = db.coercionImportPath
137
+ ? `[createCoercionPlugin({ map: __pikkuCoercionMap })]`
138
+ : `[]`
139
+
140
+ if (db.engine === 'sqlite') {
141
+ return [
142
+ ` const __pikkuDbFile = process.env.${DATABASE_FILE_VAR}`,
143
+ ` ? process.env.${DATABASE_FILE_VAR}`,
144
+ ` : __pikkuJoin(__pikkuRequireDataDir(), '${DEFAULT_DATABASE_FILENAME}')`,
145
+ ` __pikkuMkdirSync(__pikkuDirname(__pikkuDbFile), { recursive: true })`,
146
+ ` const kysely = ${SQLITE_FACTORY[runtime].fn}({`,
147
+ ` filename: __pikkuDbFile,`,
148
+ ` plugins: ${plugins},`,
149
+ ` })`,
150
+ ]
151
+ }
152
+
153
+ return [
154
+ ` const __pikkuDbUrl = process.env.${DATABASE_URL_VAR}`,
155
+ ` if (!__pikkuDbUrl) {`,
156
+ ` throw new Error(`,
157
+ ` 'This build connects to Postgres, so it needs ${DATABASE_URL_VAR} set to the database it should open.'`,
158
+ ` )`,
159
+ ` }`,
160
+ ` const __pikkuPg = new PikkuKysely(logger, __pikkuDbUrl)`,
161
+ ` await __pikkuPg.init()`,
162
+ ...(db.coercionImportPath
163
+ ? [
164
+ ` const kysely = __pikkuPg.kysely.withPlugin(`,
165
+ ` createCoercionPlugin({ map: __pikkuCoercionMap })`,
166
+ ` )`,
167
+ ]
168
+ : [` const kysely = __pikkuPg.kysely`]),
169
+ ]
170
+ }
171
+
172
+ /**
173
+ * Where the migrations sit relative to the running artifact.
174
+ *
175
+ * A node bundle reads them from its own directory. A compiled bun binary has no
176
+ * directory — `import.meta.url` points inside the embedded filesystem — so it
177
+ * resolves them beside the executable, which is where an operator unpacking an
178
+ * artifact puts them.
179
+ */
180
+ const bundleDirExpression = (runtime: 'node' | 'bun'): string =>
181
+ runtime === 'node'
182
+ ? `__pikkuDirname(__pikkuFileURLToPath(import.meta.url))`
183
+ : `__pikkuDirname(process.execPath)`
184
+
185
+ /**
186
+ * The command line, parsed before anything is opened.
187
+ *
188
+ * `version` and `help` have to answer without a database, a config factory or a
189
+ * port, because the machine asking may be one where none of the three work yet
190
+ * — which is exactly when someone runs them.
191
+ */
192
+ const commandParseLines = (ctx: EntryGenerationContext): string[] => [
193
+ `const __pikkuCommand = parseStandaloneCommand(process.argv.slice(2), {`,
194
+ ` version: '${(ctx.version ?? 'unknown').replace(/'/g, "\\'")}',`,
195
+ ` hasDb: ${Boolean(ctx.db)},`,
196
+ ...(ctx.db ? [` engine: '${ctx.db.engine}',`] : []),
197
+ `})`,
198
+ `if (__pikkuCommand.kind === 'exit') process.exit(__pikkuCommand.code)`,
199
+ ]
200
+
201
+ /**
202
+ * Runs a non-serve command against the database the app itself just opened, and
203
+ * stops before a port is bound.
204
+ *
205
+ * Reusing the app's own connection is the point: a command that resolved its
206
+ * own would be free to migrate a different database than the next `serve`
207
+ * reads, and the two would only disagree once in production.
208
+ */
209
+ const commandDispatchLines = (
210
+ runtime: 'node' | 'bun',
211
+ ctx: EntryGenerationContext
212
+ ): string[] => {
213
+ if (!ctx.db) {
214
+ return [` if (__pikkuCommand.kind !== 'serve') process.exit(0)`, ``]
215
+ }
216
+
217
+ const dir = `__pikkuJoin(${bundleDirExpression(runtime)}, 'db', '${ctx.db.engine}')`
218
+ const handle =
219
+ ctx.db.engine === 'sqlite'
220
+ ? `databaseFile: __pikkuDbFile,`
221
+ : `sql: __pikkuPg.sql,`
222
+
223
+ return [
224
+ ` const __pikkuDbCommandTarget = {`,
225
+ ` engine: '${ctx.db.engine}',`,
226
+ ` migrationsDir: resolveMigrationsDir(${dir}),`,
227
+ ` ${handle}`,
228
+ ` }`,
229
+ ` if ((await runStandaloneCommand(__pikkuCommand, __pikkuDbCommandTarget)) === 'done') {`,
230
+ ...(ctx.db.engine === 'postgres' ? [` await __pikkuPg.close()`] : []),
231
+ ` return`,
232
+ ` }`,
233
+ ``,
234
+ ]
235
+ }
236
+
237
+ /** Imports the app's own lifecycle module, when it declares one. */
238
+ const lifecycleImportLines = (
239
+ lifecycle: NonNullable<EntryGenerationContext['lifecycle']>
240
+ ): string[] => [
241
+ `import { ${lifecycle.variable} as __pikkuLifecycle } from '${lifecycle.importPath}'`,
242
+ ]
243
+
244
+ /**
245
+ * Runs the app's start hooks around the port opening, the same order and the
246
+ * same services `pikku dev` gives them.
247
+ *
248
+ * `beforeStart` runs after `init` so a hook can rely on everything the server
249
+ * resolved, and before `start` so work that must finish before the first
250
+ * request — a seeded admin account, a schema probe — is finished when one
251
+ * arrives.
252
+ */
253
+ const lifecycleStartLines = (): string[] => [
254
+ ` await __pikkuLifecycle?.beforeStart?.(singletonServices)`,
255
+ ]
256
+
257
+ const lifecycleAfterStartLines = (): string[] => [
258
+ ` await __pikkuLifecycle?.afterStart?.(singletonServices)`,
259
+ ]
260
+
261
+ /**
262
+ * Hands the stop hooks to the signal handler that owns the shutdown.
263
+ *
264
+ * The connection pool is closed in `afterStop`, once the app's own hook and the
265
+ * server have both finished with it — a pool closed any earlier takes the
266
+ * queries they are still allowed to make down with it. SQLite needs no
267
+ * counterpart: the process exiting releases the file.
268
+ */
269
+ const shutdownHooksArg = (ctx: EntryGenerationContext): string => {
270
+ const before: string[] = []
271
+ const after: string[] = []
272
+
273
+ if (ctx.lifecycle) {
274
+ before.push(`await __pikkuLifecycle?.beforeStop?.(singletonServices)`)
275
+ after.push(`await __pikkuLifecycle?.afterStop?.(singletonServices)`)
276
+ }
277
+ if (ctx.db?.engine === 'postgres') {
278
+ after.push(`await __pikkuPg.close()`)
279
+ }
280
+
281
+ if (before.length === 0 && after.length === 0) return ''
282
+
283
+ const hooks: string[] = []
284
+ if (before.length > 0) {
285
+ hooks.push(`beforeStop: async () => { ${before.join('; ')} }`)
286
+ }
287
+ if (after.length > 0) {
288
+ hooks.push(`afterStop: async () => { ${after.join('; ')} }`)
289
+ }
290
+ return `{ ${hooks.join(', ')} }`
291
+ }
292
+
293
+ /**
294
+ * Fails with the variable's name rather than whatever SQLite says about a path
295
+ * of `undefined/pikku.db`, which is the error an operator would otherwise have
296
+ * to work backwards from.
297
+ */
298
+ const dataDirHelperLines = (): string[] => [
299
+ `function __pikkuRequireDataDir() {`,
300
+ ` const dir = process.env.${DATA_DIR_VAR}`,
301
+ ` if (!dir) {`,
302
+ ` throw new Error(`,
303
+ ` 'This build bundles a SQLite database, so it needs somewhere to keep it. Set ${DATA_DIR_VAR} to a writable directory that survives a release swap, or set ${DATABASE_FILE_VAR} to the database file itself.'`,
304
+ ` )`,
305
+ ` }`,
306
+ ` return dir`,
307
+ `}`,
308
+ ]
309
+
310
+ /**
311
+ * `rustc -vV`, or nothing when no toolchain is installed. The triple then falls
312
+ * back to the Node platform pair, which is right for every ordinary host — the
313
+ * cases rustc knows better about (musl, Rosetta) are the ones where a Rust
314
+ * toolchain is present anyway.
315
+ */
316
+ const rustcHostOutput = async (): Promise<string | undefined> => {
317
+ try {
318
+ const { execFileSync } = await import('node:child_process')
319
+ return execFileSync('rustc', ['-vV'], { encoding: 'utf-8', stdio: 'pipe' })
320
+ } catch {
321
+ return undefined
322
+ }
323
+ }
324
+
24
325
  export interface StandaloneProviderAdapterOptions {
25
326
  runtime?: StandaloneRuntime
327
+ /**
328
+ * Generate a desktop shell (Tauri) around the compiled binary. Requires the
329
+ * `bun` runtime — the shell ships the binary as a sidecar, and only that
330
+ * runtime produces one. A shell pointed at {@link desktopUrl} ships no binary
331
+ * and so has no such requirement.
332
+ */
333
+ desktop?: boolean
334
+ /** Project root. The shell crate is written to `<projectDir>/src-tauri`. */
335
+ projectDir?: string
336
+ /** Bundle identifier for the shell. Derived from the app name when absent. */
337
+ desktopIdentifier?: string
338
+ /**
339
+ * An already-deployed server for the shell to open, instead of bundling one.
340
+ * The window is a webview onto that origin and nothing else is shipped.
341
+ */
342
+ desktopUrl?: string
26
343
  }
27
344
 
28
345
  export class StandaloneProviderAdapter implements ProviderAdapter {
@@ -30,9 +347,17 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
30
347
  readonly deployDirName = 'standalone'
31
348
  readonly singleUnit = true
32
349
  readonly runtime: StandaloneRuntime
350
+ readonly desktop: boolean
351
+ readonly projectDir?: string
352
+ readonly desktopIdentifier?: string
353
+ readonly desktopUrl?: string
33
354
 
34
355
  constructor(options: StandaloneProviderAdapterOptions = {}) {
35
356
  this.runtime = options.runtime ?? 'node'
357
+ this.desktop = options.desktop ?? Boolean(options.desktopUrl)
358
+ this.projectDir = options.projectDir
359
+ this.desktopIdentifier = options.desktopIdentifier
360
+ this.desktopUrl = options.desktopUrl
36
361
  }
37
362
 
38
363
  generateEntrySource(ctx: EntryGenerationContext): string {
@@ -53,6 +378,15 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
53
378
  `import { PikkuNodeHTTPServer } from '@pikku/node-http-server'`,
54
379
  `import { DEFAULT_WS_MAX_PAYLOAD, pikkuWebsocketHandler } from '@pikku/ws'`,
55
380
  `import { WebSocketServer } from 'ws'`,
381
+ runtimeImport(ctx),
382
+ ...(ctx.frontend || ctx.db
383
+ ? [
384
+ `import { dirname as __pikkuDirname, join as __pikkuJoin } from 'node:path'`,
385
+ `import { fileURLToPath as __pikkuFileURLToPath } from 'node:url'`,
386
+ ]
387
+ : []),
388
+ ...(ctx.db ? dbImportLines('node', ctx.db) : []),
389
+ ...(ctx.lifecycle ? lifecycleImportLines(ctx.lifecycle) : []),
56
390
  ``,
57
391
  ctx.configImport,
58
392
  ctx.servicesImport,
@@ -64,6 +398,8 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
64
398
  `const port = parseInt(process.env.PORT || '3000', 10)`,
65
399
  `const hostname = process.env.HOST || '0.0.0.0'`,
66
400
  ``,
401
+ ...commandParseLines(ctx),
402
+ ``,
67
403
  `async function main() {`,
68
404
  ` const config = await ${ctx.configVar}()`,
69
405
  ` const schedulerService = new InMemorySchedulerService()`,
@@ -73,8 +409,11 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
73
409
  ` const eventHub = new LocalEventHubService()`,
74
410
  ` workflowService.wireQueueWorkers()`,
75
411
  ` wireAgentScorerQueueWorkers()`,
412
+ ...(ctx.db ? dbSetupLines('node', ctx.db) : []),
413
+ ...commandDispatchLines('node', ctx),
76
414
  ` const singletonServices = await ${ctx.servicesVar}(config, {`,
77
415
  ` logger,`,
416
+ ...(ctx.db ? [` kysely,`] : []),
78
417
  ` schedulerService,`,
79
418
  ` queueService,`,
80
419
  ` workflowService,`,
@@ -84,9 +423,21 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
84
423
  ` })`,
85
424
  ` pikkuState(null, 'package', 'singletonServices', singletonServices)`,
86
425
  ``,
426
+ ...(ctx.frontend
427
+ ? [
428
+ // Resolved from the running bundle rather than baked in at build
429
+ // time, so the distributable stays movable.
430
+ ` const staticMounts = [{`,
431
+ ` urlPrefix: '${ctx.frontend.urlPrefix}',`,
432
+ ` directory: __pikkuJoin(__pikkuDirname(__pikkuFileURLToPath(import.meta.url)), '${STANDALONE_FRONTEND_DIR}'),`,
433
+ ` spaFallback: ${ctx.frontend.spaFallback},`,
434
+ ` }]`,
435
+ ``,
436
+ ]
437
+ : []),
87
438
  ` const wss = new WebSocketServer({ noServer: true, maxPayload: DEFAULT_WS_MAX_PAYLOAD })`,
88
439
  ` const server = new PikkuNodeHTTPServer(`,
89
- ` { ...config, port, hostname },`,
440
+ ` { ...config, port, hostname${ctx.frontend ? ', staticMounts' : ''} },`,
90
441
  ` logger,`,
91
442
  ` {`,
92
443
  ` ${ctx.mcpServerOption}configureServer: (httpServer) => {`,
@@ -97,8 +448,11 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
97
448
  ` await server.init()`,
98
449
  ` await schedulerService.start()`,
99
450
  ` await triggerService.start()`,
100
- ` server.enableExitOnSignals()`,
451
+ ...(ctx.lifecycle ? lifecycleStartLines() : []),
452
+ ` server.enableExitOnSignals(${shutdownHooksArg(ctx)})`,
101
453
  ` await server.start()`,
454
+ ...(ctx.lifecycle ? lifecycleAfterStartLines() : []),
455
+ ...sidecarHandshakeLines(),
102
456
  `}`,
103
457
  ``,
104
458
  `main().catch((err) => {`,
@@ -106,6 +460,7 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
106
460
  ` process.exit(1)`,
107
461
  `})`,
108
462
  ``,
463
+ ...(ctx.db?.engine === 'sqlite' ? [...dataDirHelperLines(), ``] : []),
109
464
  ].join('\n')
110
465
  }
111
466
 
@@ -113,10 +468,21 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
113
468
  return [
114
469
  `// Generated standalone entry (bun runtime) — all functions in one process`,
115
470
  `import { ConsoleLogger, InMemoryQueueService, InMemoryTriggerService, InMemoryWorkflowService } from '@pikku/core/services'`,
471
+ runtimeImport(ctx),
116
472
  `import { pikkuState } from '@pikku/core/state'`,
117
473
  `import { wireAgentScorerQueueWorkers } from '@pikku/core/agent-scorer'`,
118
474
  `import { InMemorySchedulerService } from '@pikku/schedule'`,
119
475
  `import { PikkuBunServer, BunEventHubService } from '@pikku/bun-server'`,
476
+ ...(ctx.frontend
477
+ ? [`import { frontendAssets } from '${STANDALONE_FRONTEND_MANIFEST}'`]
478
+ : []),
479
+ ...(ctx.db
480
+ ? [
481
+ `import { dirname as __pikkuDirname, join as __pikkuJoin } from 'node:path'`,
482
+ ]
483
+ : []),
484
+ ...(ctx.db ? dbImportLines('bun', ctx.db) : []),
485
+ ...(ctx.lifecycle ? lifecycleImportLines(ctx.lifecycle) : []),
120
486
  ``,
121
487
  ctx.configImport,
122
488
  ctx.servicesImport,
@@ -128,6 +494,8 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
128
494
  `const port = parseInt(process.env.PORT || '3000', 10)`,
129
495
  `const hostname = process.env.HOST || '0.0.0.0'`,
130
496
  ``,
497
+ ...commandParseLines(ctx),
498
+ ``,
131
499
  `async function main() {`,
132
500
  ` const config = await ${ctx.configVar}()`,
133
501
  ` const schedulerService = new InMemorySchedulerService()`,
@@ -137,8 +505,11 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
137
505
  ` const eventHub = new BunEventHubService()`,
138
506
  ` workflowService.wireQueueWorkers()`,
139
507
  ` wireAgentScorerQueueWorkers()`,
508
+ ...(ctx.db ? dbSetupLines('bun', ctx.db) : []),
509
+ ...commandDispatchLines('bun', ctx),
140
510
  ` const singletonServices = await ${ctx.servicesVar}(config, {`,
141
511
  ` logger,`,
512
+ ...(ctx.db ? [` kysely,`] : []),
142
513
  ` schedulerService,`,
143
514
  ` queueService,`,
144
515
  ` workflowService,`,
@@ -148,12 +519,28 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
148
519
  ` })`,
149
520
  ` pikkuState(null, 'package', 'singletonServices', singletonServices)`,
150
521
  ``,
151
- ` const server = new PikkuBunServer({ ...config, port, hostname }, logger, { ${ctx.mcpServerOption}eventHub })`,
522
+ ...(ctx.frontend
523
+ ? [
524
+ // A compiled binary has no directory to read: every file was
525
+ // embedded, and the map is the only way back to it.
526
+ ` const staticMounts = [{`,
527
+ ` urlPrefix: '${ctx.frontend.urlPrefix}',`,
528
+ ` directory: '',`,
529
+ ` spaFallback: ${ctx.frontend.spaFallback},`,
530
+ ` assets: frontendAssets,`,
531
+ ` }]`,
532
+ ``,
533
+ ]
534
+ : []),
535
+ ` const server = new PikkuBunServer({ ...config, port, hostname${ctx.frontend ? ', staticMounts' : ''} }, logger, { ${ctx.mcpServerOption}eventHub })`,
152
536
  ` await server.init()`,
153
537
  ` await schedulerService.start()`,
154
538
  ` await triggerService.start()`,
155
- ` server.enableExitOnSignals()`,
539
+ ...(ctx.lifecycle ? lifecycleStartLines() : []),
540
+ ` server.enableExitOnSignals(${shutdownHooksArg(ctx)})`,
156
541
  ` await server.start()`,
542
+ ...(ctx.lifecycle ? lifecycleAfterStartLines() : []),
543
+ ...sidecarHandshakeLines(),
157
544
  `}`,
158
545
  ``,
159
546
  `main().catch((err) => {`,
@@ -161,6 +548,7 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
161
548
  ` process.exit(1)`,
162
549
  `})`,
163
550
  ``,
551
+ ...(ctx.db?.engine === 'sqlite' ? [...dataDirHelperLines(), ``] : []),
164
552
  ].join('\n')
165
553
  }
166
554
 
@@ -182,10 +570,26 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
182
570
  // Bun-native builtins are provided by the runtime and resolved by
183
571
  // `bun build --compile` — leave them as imports rather than inlining.
184
572
  externals.push('bun', 'bun:*', 'bun:sqlite', 'bun:ffi')
573
+ externals.push(STANDALONE_FRONTEND_MANIFEST)
185
574
  }
186
575
  return externals
187
576
  }
188
577
 
578
+ /**
579
+ * The SQLite driver this runtime cannot load.
580
+ *
581
+ * `loadSqliteRuntime` picks its driver by looking for `globalThis.Bun`, so a
582
+ * node process never runs the bun branch — but esbuild still follows the
583
+ * import, and `bun:sqlite` sits at the top of that module as a static import
584
+ * it cannot resolve. Left in, the bundle fails to build; marked external, it
585
+ * becomes a top-level import node fails to load. Stubbing removes the branch
586
+ * that was already dead.
587
+ */
588
+ getStubModules(): string[] {
589
+ if (this.runtime === 'bun') return []
590
+ return ['sqlite-runtime-bun']
591
+ }
592
+
189
593
  getPlatform(): 'node' {
190
594
  return 'node'
191
595
  }
@@ -196,8 +600,37 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
196
600
  onProgress?: (step: string, detail: string) => void
197
601
  }) {
198
602
  const { buildDir, logger } = options
603
+
604
+ // Checked before anything expensive runs: a `--desktop` deploy that cannot
605
+ // produce a shell should say so now, not after a bun compile.
606
+ if (this.desktop) {
607
+ if (!this.desktopUrl && this.runtime !== 'bun') {
608
+ return {
609
+ success: false,
610
+ errors: [
611
+ {
612
+ step: 'desktop',
613
+ error: `A desktop shell ships the server as a sidecar binary, which only the bun runtime produces. Re-run with --runtime bun (got '${this.runtime}').`,
614
+ },
615
+ ],
616
+ }
617
+ }
618
+ if (!this.projectDir) {
619
+ return {
620
+ success: false,
621
+ errors: [
622
+ {
623
+ step: 'desktop',
624
+ error:
625
+ 'No project directory was supplied, so there is nowhere to write src-tauri/.',
626
+ },
627
+ ],
628
+ }
629
+ }
630
+ }
631
+
199
632
  const { join, dirname } = await import('node:path')
200
- const { readdir, writeFile, copyFile, mkdir } =
633
+ const { cp, readdir, writeFile, copyFile, mkdir } =
201
634
  await import('node:fs/promises')
202
635
  const { existsSync } = await import('node:fs')
203
636
 
@@ -230,6 +663,22 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
230
663
  }
231
664
  logger.info(`Bundle: ${join(outDir, 'bundle.js')}`)
232
665
 
666
+ // --- 2a. Frontend, when the build produced one ---
667
+ // Both runtimes need it here rather than only in the build directory: node
668
+ // resolves the mount relative to the shipped bundle, and `bun build
669
+ // --compile` follows the manifest import out of the copy it is given.
670
+ const frontendDir = join(unitDir, STANDALONE_FRONTEND_DIR)
671
+ if (existsSync(frontendDir)) {
672
+ await cp(frontendDir, join(outDir, STANDALONE_FRONTEND_DIR), {
673
+ recursive: true,
674
+ })
675
+ const manifestName = STANDALONE_FRONTEND_MANIFEST.replace('./', '')
676
+ if (existsSync(join(unitDir, manifestName))) {
677
+ await copyFile(join(unitDir, manifestName), join(outDir, manifestName))
678
+ }
679
+ logger.info(`Frontend: ${join(outDir, STANDALONE_FRONTEND_DIR)}`)
680
+ }
681
+
233
682
  // --- 2b. bun runtime: compile the bundle into a self-contained binary ---
234
683
  if (this.runtime === 'bun') {
235
684
  const { execFileSync } = await import('node:child_process')
@@ -261,6 +710,58 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
261
710
  }
262
711
  }
263
712
 
713
+ // --- 2c. desktop: wrap the server in a shell, or point one at a remote ---
714
+ let targetTriple: string | undefined
715
+ if (this.desktop && this.projectDir) {
716
+ const { generateTauriShell, tauriBundleIdentifier } =
717
+ await import('./tauri/generate.js')
718
+ const { hostTargetTriple } = await import('./tauri/target-triple.js')
719
+ const { renderTauriNextSteps } = await import('./tauri/next-steps.js')
720
+ try {
721
+ const rustcVersionVerbose = await rustcHostOutput()
722
+ targetTriple = hostTargetTriple({ rustcVersionVerbose })
723
+ const shell = await generateTauriShell({
724
+ projectDir: this.projectDir,
725
+ appName,
726
+ identifier: this.desktopIdentifier ?? tauriBundleIdentifier(appName),
727
+ targetTriple,
728
+ ...(this.desktopUrl
729
+ ? { remoteUrl: this.desktopUrl }
730
+ : { binaryPath: join(outDir, appName) }),
731
+ })
732
+ logger.info(`Desktop shell: ${shell.dir} (${shell.targetTriple})`)
733
+ if (shell.written.length) {
734
+ logger.info(` wrote ${shell.written.join(', ')}`)
735
+ }
736
+ if (shell.preserved.length) {
737
+ logger.info(
738
+ ` kept your edits, not regenerated: ${shell.preserved.join(', ')}`
739
+ )
740
+ }
741
+ if (shell.sidecar) {
742
+ logger.info(` sidecar: binaries/${shell.sidecar.fileName}`)
743
+ } else {
744
+ logger.info(` window opens: ${this.desktopUrl}`)
745
+ }
746
+ for (const line of renderTauriNextSteps({
747
+ shellDir: shell.dir,
748
+ hasRust: rustcVersionVerbose !== undefined,
749
+ })) {
750
+ logger.info(line)
751
+ }
752
+ } catch (e: unknown) {
753
+ return {
754
+ success: false,
755
+ errors: [
756
+ {
757
+ step: 'desktop',
758
+ error: e instanceof Error ? e.message : String(e),
759
+ },
760
+ ],
761
+ }
762
+ }
763
+ }
764
+
264
765
  // --- 3. config/ — empty template with .env example ---
265
766
  const configDir = join(outDir, 'config')
266
767
  await mkdir(configDir, { recursive: true })
@@ -292,6 +793,7 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
292
793
  workersDeployed: [appName],
293
794
  resourcesCreated: [],
294
795
  errors: [],
796
+ targetTriple,
295
797
  }
296
798
  }
297
799
  }