@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/dist/adapter.js CHANGED
@@ -1,11 +1,282 @@
1
- import { nodeBuiltinExternals } from '@pikku/deploy';
1
+ import { nodeBuiltinExternals, SERVER_READY_MARKER } from '@pikku/deploy';
2
+ /**
3
+ * Directory the built frontend is copied to, both inside the unit and beside
4
+ * the shipped bundle. The node entry resolves it relative to itself at runtime,
5
+ * so the two have to agree.
6
+ */
7
+ export const STANDALONE_FRONTEND_DIR = 'frontend';
8
+ /**
9
+ * Module the bun entry imports its embedded assets from. It stays out of the
10
+ * esbuild bundle — esbuild rejects the `with { type: 'file' }` attribute the
11
+ * manifest is built on — and is resolved by `bun build --compile` instead.
12
+ */
13
+ export const STANDALONE_FRONTEND_MANIFEST = './frontend-assets.gen.js';
14
+ /**
15
+ * Lines every standalone entry ends with, whatever the runtime.
16
+ *
17
+ * The ready line is the handshake a parent process — `pikku dev --spawn`, or
18
+ * the desktop shell that runs this binary as a sidecar — blocks on. It carries
19
+ * `server.port` rather than the requested port because a shell passes `PORT=0`:
20
+ * picking a free port in the parent and handing it down races anything else
21
+ * that binds it in between, so the server binds first and reports back.
22
+ */
23
+ const sidecarHandshakeLines = () => [
24
+ ` watchParentProcess()`,
25
+ ` console.log(\`${SERVER_READY_MARKER} on http://\${hostname}:\${server.port}\`)`,
26
+ ];
27
+ /**
28
+ * The runtime helpers the entry imports. The database ones are left out of a
29
+ * build with no database, so the bundle carries no migrator it can never run.
30
+ */
31
+ const runtimeImport = (ctx) => {
32
+ const names = ['watchParentProcess', 'parseStandaloneCommand'];
33
+ if (ctx.db)
34
+ names.push('runStandaloneCommand', 'resolveMigrationsDir');
35
+ return `import { ${names.join(', ')} } from '@pikku/deploy-standalone/runtime'`;
36
+ };
37
+ /**
38
+ * Environment variable naming the directory the SQLite file lives in.
39
+ *
40
+ * The database has to outlive a release. A deploy that swaps the artifact
41
+ * directory would take the database with it if the file sat beside the bundle,
42
+ * so the path comes from the environment and points somewhere the operator
43
+ * keeps stable across releases, rather than being derived from the bundle's own
44
+ * location the way the frontend directory is.
45
+ */
46
+ const DATA_DIR_VAR = 'PIKKU_DATA_DIR';
47
+ /**
48
+ * Full override for the database file, for when it must match a path something
49
+ * else already decided — notably `pikku db migrate`, which has to open the same
50
+ * file this opens or the app runs against an unmigrated database.
51
+ */
52
+ const DATABASE_FILE_VAR = 'PIKKU_DATABASE_FILE';
53
+ const DEFAULT_DATABASE_FILENAME = 'pikku.db';
54
+ /**
55
+ * The dialect factory each runtime opens SQLite with. bun cannot use the node
56
+ * one — `bun:sqlite` is a different driver, and the node build reaches for
57
+ * `node:sqlite`, which a compiled bun binary does not carry.
58
+ */
59
+ const SQLITE_FACTORY = {
60
+ node: {
61
+ specifier: '@pikku/kysely-node-sqlite',
62
+ fn: 'createNodeSqliteKysely',
63
+ },
64
+ bun: { specifier: '@pikku/kysely-bun-sqlite', fn: 'createBunSqliteKysely' },
65
+ };
66
+ /**
67
+ * The environment variable a Postgres build reads its connection string from.
68
+ *
69
+ * The same name every other pikku host uses, so an artifact dropped onto a
70
+ * machine that already runs a pikku app needs no new variable.
71
+ */
72
+ const DATABASE_URL_VAR = 'DATABASE_URL';
73
+ /** Imports the coercion plugin, for an app that generated a map. */
74
+ const coercionImportLines = (coercionImportPath) => [
75
+ `import { createCoercionPlugin } from '@pikku/kysely'`,
76
+ `import { coercionMap as __pikkuCoercionMap } from '${coercionImportPath}'`,
77
+ ];
78
+ /** Imports a database-backed entry needs on top of the common set. */
79
+ const dbImportLines = (runtime, db) => [
80
+ ...(db.engine === 'sqlite'
81
+ ? [
82
+ `import { ${SQLITE_FACTORY[runtime].fn} } from '${SQLITE_FACTORY[runtime].specifier}'`,
83
+ `import { mkdirSync as __pikkuMkdirSync } from 'node:fs'`,
84
+ ]
85
+ : [`import { PikkuKysely } from '@pikku/kysely-postgres'`]),
86
+ ...(db.coercionImportPath ? coercionImportLines(db.coercionImportPath) : []),
87
+ ];
88
+ /**
89
+ * Opens the database before services are built, so `createSingletonServices`
90
+ * receives `kysely` exactly as a hosted runtime would hand it over.
91
+ *
92
+ * Creating the directory rather than requiring it is deliberate: the artifact
93
+ * is expected to start on a machine where nothing has run yet, and a missing
94
+ * parent directory is the difference between a first boot that works and one
95
+ * that needs a documented mkdir nobody reads.
96
+ */
97
+ const dbSetupLines = (runtime, db) => {
98
+ const plugins = db.coercionImportPath
99
+ ? `[createCoercionPlugin({ map: __pikkuCoercionMap })]`
100
+ : `[]`;
101
+ if (db.engine === 'sqlite') {
102
+ return [
103
+ ` const __pikkuDbFile = process.env.${DATABASE_FILE_VAR}`,
104
+ ` ? process.env.${DATABASE_FILE_VAR}`,
105
+ ` : __pikkuJoin(__pikkuRequireDataDir(), '${DEFAULT_DATABASE_FILENAME}')`,
106
+ ` __pikkuMkdirSync(__pikkuDirname(__pikkuDbFile), { recursive: true })`,
107
+ ` const kysely = ${SQLITE_FACTORY[runtime].fn}({`,
108
+ ` filename: __pikkuDbFile,`,
109
+ ` plugins: ${plugins},`,
110
+ ` })`,
111
+ ];
112
+ }
113
+ return [
114
+ ` const __pikkuDbUrl = process.env.${DATABASE_URL_VAR}`,
115
+ ` if (!__pikkuDbUrl) {`,
116
+ ` throw new Error(`,
117
+ ` 'This build connects to Postgres, so it needs ${DATABASE_URL_VAR} set to the database it should open.'`,
118
+ ` )`,
119
+ ` }`,
120
+ ` const __pikkuPg = new PikkuKysely(logger, __pikkuDbUrl)`,
121
+ ` await __pikkuPg.init()`,
122
+ ...(db.coercionImportPath
123
+ ? [
124
+ ` const kysely = __pikkuPg.kysely.withPlugin(`,
125
+ ` createCoercionPlugin({ map: __pikkuCoercionMap })`,
126
+ ` )`,
127
+ ]
128
+ : [` const kysely = __pikkuPg.kysely`]),
129
+ ];
130
+ };
131
+ /**
132
+ * Where the migrations sit relative to the running artifact.
133
+ *
134
+ * A node bundle reads them from its own directory. A compiled bun binary has no
135
+ * directory — `import.meta.url` points inside the embedded filesystem — so it
136
+ * resolves them beside the executable, which is where an operator unpacking an
137
+ * artifact puts them.
138
+ */
139
+ const bundleDirExpression = (runtime) => runtime === 'node'
140
+ ? `__pikkuDirname(__pikkuFileURLToPath(import.meta.url))`
141
+ : `__pikkuDirname(process.execPath)`;
142
+ /**
143
+ * The command line, parsed before anything is opened.
144
+ *
145
+ * `version` and `help` have to answer without a database, a config factory or a
146
+ * port, because the machine asking may be one where none of the three work yet
147
+ * — which is exactly when someone runs them.
148
+ */
149
+ const commandParseLines = (ctx) => [
150
+ `const __pikkuCommand = parseStandaloneCommand(process.argv.slice(2), {`,
151
+ ` version: '${(ctx.version ?? 'unknown').replace(/'/g, "\\'")}',`,
152
+ ` hasDb: ${Boolean(ctx.db)},`,
153
+ ...(ctx.db ? [` engine: '${ctx.db.engine}',`] : []),
154
+ `})`,
155
+ `if (__pikkuCommand.kind === 'exit') process.exit(__pikkuCommand.code)`,
156
+ ];
157
+ /**
158
+ * Runs a non-serve command against the database the app itself just opened, and
159
+ * stops before a port is bound.
160
+ *
161
+ * Reusing the app's own connection is the point: a command that resolved its
162
+ * own would be free to migrate a different database than the next `serve`
163
+ * reads, and the two would only disagree once in production.
164
+ */
165
+ const commandDispatchLines = (runtime, ctx) => {
166
+ if (!ctx.db) {
167
+ return [` if (__pikkuCommand.kind !== 'serve') process.exit(0)`, ``];
168
+ }
169
+ const dir = `__pikkuJoin(${bundleDirExpression(runtime)}, 'db', '${ctx.db.engine}')`;
170
+ const handle = ctx.db.engine === 'sqlite'
171
+ ? `databaseFile: __pikkuDbFile,`
172
+ : `sql: __pikkuPg.sql,`;
173
+ return [
174
+ ` const __pikkuDbCommandTarget = {`,
175
+ ` engine: '${ctx.db.engine}',`,
176
+ ` migrationsDir: resolveMigrationsDir(${dir}),`,
177
+ ` ${handle}`,
178
+ ` }`,
179
+ ` if ((await runStandaloneCommand(__pikkuCommand, __pikkuDbCommandTarget)) === 'done') {`,
180
+ ...(ctx.db.engine === 'postgres' ? [` await __pikkuPg.close()`] : []),
181
+ ` return`,
182
+ ` }`,
183
+ ``,
184
+ ];
185
+ };
186
+ /** Imports the app's own lifecycle module, when it declares one. */
187
+ const lifecycleImportLines = (lifecycle) => [
188
+ `import { ${lifecycle.variable} as __pikkuLifecycle } from '${lifecycle.importPath}'`,
189
+ ];
190
+ /**
191
+ * Runs the app's start hooks around the port opening, the same order and the
192
+ * same services `pikku dev` gives them.
193
+ *
194
+ * `beforeStart` runs after `init` so a hook can rely on everything the server
195
+ * resolved, and before `start` so work that must finish before the first
196
+ * request — a seeded admin account, a schema probe — is finished when one
197
+ * arrives.
198
+ */
199
+ const lifecycleStartLines = () => [
200
+ ` await __pikkuLifecycle?.beforeStart?.(singletonServices)`,
201
+ ];
202
+ const lifecycleAfterStartLines = () => [
203
+ ` await __pikkuLifecycle?.afterStart?.(singletonServices)`,
204
+ ];
205
+ /**
206
+ * Hands the stop hooks to the signal handler that owns the shutdown.
207
+ *
208
+ * The connection pool is closed in `afterStop`, once the app's own hook and the
209
+ * server have both finished with it — a pool closed any earlier takes the
210
+ * queries they are still allowed to make down with it. SQLite needs no
211
+ * counterpart: the process exiting releases the file.
212
+ */
213
+ const shutdownHooksArg = (ctx) => {
214
+ const before = [];
215
+ const after = [];
216
+ if (ctx.lifecycle) {
217
+ before.push(`await __pikkuLifecycle?.beforeStop?.(singletonServices)`);
218
+ after.push(`await __pikkuLifecycle?.afterStop?.(singletonServices)`);
219
+ }
220
+ if (ctx.db?.engine === 'postgres') {
221
+ after.push(`await __pikkuPg.close()`);
222
+ }
223
+ if (before.length === 0 && after.length === 0)
224
+ return '';
225
+ const hooks = [];
226
+ if (before.length > 0) {
227
+ hooks.push(`beforeStop: async () => { ${before.join('; ')} }`);
228
+ }
229
+ if (after.length > 0) {
230
+ hooks.push(`afterStop: async () => { ${after.join('; ')} }`);
231
+ }
232
+ return `{ ${hooks.join(', ')} }`;
233
+ };
234
+ /**
235
+ * Fails with the variable's name rather than whatever SQLite says about a path
236
+ * of `undefined/pikku.db`, which is the error an operator would otherwise have
237
+ * to work backwards from.
238
+ */
239
+ const dataDirHelperLines = () => [
240
+ `function __pikkuRequireDataDir() {`,
241
+ ` const dir = process.env.${DATA_DIR_VAR}`,
242
+ ` if (!dir) {`,
243
+ ` throw new Error(`,
244
+ ` '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.'`,
245
+ ` )`,
246
+ ` }`,
247
+ ` return dir`,
248
+ `}`,
249
+ ];
250
+ /**
251
+ * `rustc -vV`, or nothing when no toolchain is installed. The triple then falls
252
+ * back to the Node platform pair, which is right for every ordinary host — the
253
+ * cases rustc knows better about (musl, Rosetta) are the ones where a Rust
254
+ * toolchain is present anyway.
255
+ */
256
+ const rustcHostOutput = async () => {
257
+ try {
258
+ const { execFileSync } = await import('node:child_process');
259
+ return execFileSync('rustc', ['-vV'], { encoding: 'utf-8', stdio: 'pipe' });
260
+ }
261
+ catch {
262
+ return undefined;
263
+ }
264
+ };
2
265
  export class StandaloneProviderAdapter {
3
266
  name = 'standalone';
4
267
  deployDirName = 'standalone';
5
268
  singleUnit = true;
6
269
  runtime;
270
+ desktop;
271
+ projectDir;
272
+ desktopIdentifier;
273
+ desktopUrl;
7
274
  constructor(options = {}) {
8
275
  this.runtime = options.runtime ?? 'node';
276
+ this.desktop = options.desktop ?? Boolean(options.desktopUrl);
277
+ this.projectDir = options.projectDir;
278
+ this.desktopIdentifier = options.desktopIdentifier;
279
+ this.desktopUrl = options.desktopUrl;
9
280
  }
10
281
  generateEntrySource(ctx) {
11
282
  if (this.runtime === 'bun') {
@@ -24,6 +295,15 @@ export class StandaloneProviderAdapter {
24
295
  `import { PikkuNodeHTTPServer } from '@pikku/node-http-server'`,
25
296
  `import { DEFAULT_WS_MAX_PAYLOAD, pikkuWebsocketHandler } from '@pikku/ws'`,
26
297
  `import { WebSocketServer } from 'ws'`,
298
+ runtimeImport(ctx),
299
+ ...(ctx.frontend || ctx.db
300
+ ? [
301
+ `import { dirname as __pikkuDirname, join as __pikkuJoin } from 'node:path'`,
302
+ `import { fileURLToPath as __pikkuFileURLToPath } from 'node:url'`,
303
+ ]
304
+ : []),
305
+ ...(ctx.db ? dbImportLines('node', ctx.db) : []),
306
+ ...(ctx.lifecycle ? lifecycleImportLines(ctx.lifecycle) : []),
27
307
  ``,
28
308
  ctx.configImport,
29
309
  ctx.servicesImport,
@@ -35,6 +315,8 @@ export class StandaloneProviderAdapter {
35
315
  `const port = parseInt(process.env.PORT || '3000', 10)`,
36
316
  `const hostname = process.env.HOST || '0.0.0.0'`,
37
317
  ``,
318
+ ...commandParseLines(ctx),
319
+ ``,
38
320
  `async function main() {`,
39
321
  ` const config = await ${ctx.configVar}()`,
40
322
  ` const schedulerService = new InMemorySchedulerService()`,
@@ -44,8 +326,11 @@ export class StandaloneProviderAdapter {
44
326
  ` const eventHub = new LocalEventHubService()`,
45
327
  ` workflowService.wireQueueWorkers()`,
46
328
  ` wireAgentScorerQueueWorkers()`,
329
+ ...(ctx.db ? dbSetupLines('node', ctx.db) : []),
330
+ ...commandDispatchLines('node', ctx),
47
331
  ` const singletonServices = await ${ctx.servicesVar}(config, {`,
48
332
  ` logger,`,
333
+ ...(ctx.db ? [` kysely,`] : []),
49
334
  ` schedulerService,`,
50
335
  ` queueService,`,
51
336
  ` workflowService,`,
@@ -55,9 +340,21 @@ export class StandaloneProviderAdapter {
55
340
  ` })`,
56
341
  ` pikkuState(null, 'package', 'singletonServices', singletonServices)`,
57
342
  ``,
343
+ ...(ctx.frontend
344
+ ? [
345
+ // Resolved from the running bundle rather than baked in at build
346
+ // time, so the distributable stays movable.
347
+ ` const staticMounts = [{`,
348
+ ` urlPrefix: '${ctx.frontend.urlPrefix}',`,
349
+ ` directory: __pikkuJoin(__pikkuDirname(__pikkuFileURLToPath(import.meta.url)), '${STANDALONE_FRONTEND_DIR}'),`,
350
+ ` spaFallback: ${ctx.frontend.spaFallback},`,
351
+ ` }]`,
352
+ ``,
353
+ ]
354
+ : []),
58
355
  ` const wss = new WebSocketServer({ noServer: true, maxPayload: DEFAULT_WS_MAX_PAYLOAD })`,
59
356
  ` const server = new PikkuNodeHTTPServer(`,
60
- ` { ...config, port, hostname },`,
357
+ ` { ...config, port, hostname${ctx.frontend ? ', staticMounts' : ''} },`,
61
358
  ` logger,`,
62
359
  ` {`,
63
360
  ` ${ctx.mcpServerOption}configureServer: (httpServer) => {`,
@@ -68,8 +365,11 @@ export class StandaloneProviderAdapter {
68
365
  ` await server.init()`,
69
366
  ` await schedulerService.start()`,
70
367
  ` await triggerService.start()`,
71
- ` server.enableExitOnSignals()`,
368
+ ...(ctx.lifecycle ? lifecycleStartLines() : []),
369
+ ` server.enableExitOnSignals(${shutdownHooksArg(ctx)})`,
72
370
  ` await server.start()`,
371
+ ...(ctx.lifecycle ? lifecycleAfterStartLines() : []),
372
+ ...sidecarHandshakeLines(),
73
373
  `}`,
74
374
  ``,
75
375
  `main().catch((err) => {`,
@@ -77,16 +377,28 @@ export class StandaloneProviderAdapter {
77
377
  ` process.exit(1)`,
78
378
  `})`,
79
379
  ``,
380
+ ...(ctx.db?.engine === 'sqlite' ? [...dataDirHelperLines(), ``] : []),
80
381
  ].join('\n');
81
382
  }
82
383
  generateBunEntrySource(ctx) {
83
384
  return [
84
385
  `// Generated standalone entry (bun runtime) — all functions in one process`,
85
386
  `import { ConsoleLogger, InMemoryQueueService, InMemoryTriggerService, InMemoryWorkflowService } from '@pikku/core/services'`,
387
+ runtimeImport(ctx),
86
388
  `import { pikkuState } from '@pikku/core/state'`,
87
389
  `import { wireAgentScorerQueueWorkers } from '@pikku/core/agent-scorer'`,
88
390
  `import { InMemorySchedulerService } from '@pikku/schedule'`,
89
391
  `import { PikkuBunServer, BunEventHubService } from '@pikku/bun-server'`,
392
+ ...(ctx.frontend
393
+ ? [`import { frontendAssets } from '${STANDALONE_FRONTEND_MANIFEST}'`]
394
+ : []),
395
+ ...(ctx.db
396
+ ? [
397
+ `import { dirname as __pikkuDirname, join as __pikkuJoin } from 'node:path'`,
398
+ ]
399
+ : []),
400
+ ...(ctx.db ? dbImportLines('bun', ctx.db) : []),
401
+ ...(ctx.lifecycle ? lifecycleImportLines(ctx.lifecycle) : []),
90
402
  ``,
91
403
  ctx.configImport,
92
404
  ctx.servicesImport,
@@ -98,6 +410,8 @@ export class StandaloneProviderAdapter {
98
410
  `const port = parseInt(process.env.PORT || '3000', 10)`,
99
411
  `const hostname = process.env.HOST || '0.0.0.0'`,
100
412
  ``,
413
+ ...commandParseLines(ctx),
414
+ ``,
101
415
  `async function main() {`,
102
416
  ` const config = await ${ctx.configVar}()`,
103
417
  ` const schedulerService = new InMemorySchedulerService()`,
@@ -107,8 +421,11 @@ export class StandaloneProviderAdapter {
107
421
  ` const eventHub = new BunEventHubService()`,
108
422
  ` workflowService.wireQueueWorkers()`,
109
423
  ` wireAgentScorerQueueWorkers()`,
424
+ ...(ctx.db ? dbSetupLines('bun', ctx.db) : []),
425
+ ...commandDispatchLines('bun', ctx),
110
426
  ` const singletonServices = await ${ctx.servicesVar}(config, {`,
111
427
  ` logger,`,
428
+ ...(ctx.db ? [` kysely,`] : []),
112
429
  ` schedulerService,`,
113
430
  ` queueService,`,
114
431
  ` workflowService,`,
@@ -118,12 +435,28 @@ export class StandaloneProviderAdapter {
118
435
  ` })`,
119
436
  ` pikkuState(null, 'package', 'singletonServices', singletonServices)`,
120
437
  ``,
121
- ` const server = new PikkuBunServer({ ...config, port, hostname }, logger, { ${ctx.mcpServerOption}eventHub })`,
438
+ ...(ctx.frontend
439
+ ? [
440
+ // A compiled binary has no directory to read: every file was
441
+ // embedded, and the map is the only way back to it.
442
+ ` const staticMounts = [{`,
443
+ ` urlPrefix: '${ctx.frontend.urlPrefix}',`,
444
+ ` directory: '',`,
445
+ ` spaFallback: ${ctx.frontend.spaFallback},`,
446
+ ` assets: frontendAssets,`,
447
+ ` }]`,
448
+ ``,
449
+ ]
450
+ : []),
451
+ ` const server = new PikkuBunServer({ ...config, port, hostname${ctx.frontend ? ', staticMounts' : ''} }, logger, { ${ctx.mcpServerOption}eventHub })`,
122
452
  ` await server.init()`,
123
453
  ` await schedulerService.start()`,
124
454
  ` await triggerService.start()`,
125
- ` server.enableExitOnSignals()`,
455
+ ...(ctx.lifecycle ? lifecycleStartLines() : []),
456
+ ` server.enableExitOnSignals(${shutdownHooksArg(ctx)})`,
126
457
  ` await server.start()`,
458
+ ...(ctx.lifecycle ? lifecycleAfterStartLines() : []),
459
+ ...sidecarHandshakeLines(),
127
460
  `}`,
128
461
  ``,
129
462
  `main().catch((err) => {`,
@@ -131,6 +464,7 @@ export class StandaloneProviderAdapter {
131
464
  ` process.exit(1)`,
132
465
  `})`,
133
466
  ``,
467
+ ...(ctx.db?.engine === 'sqlite' ? [...dataDirHelperLines(), ``] : []),
134
468
  ].join('\n');
135
469
  }
136
470
  generateUnitConfigs() {
@@ -148,16 +482,58 @@ export class StandaloneProviderAdapter {
148
482
  // Bun-native builtins are provided by the runtime and resolved by
149
483
  // `bun build --compile` — leave them as imports rather than inlining.
150
484
  externals.push('bun', 'bun:*', 'bun:sqlite', 'bun:ffi');
485
+ externals.push(STANDALONE_FRONTEND_MANIFEST);
151
486
  }
152
487
  return externals;
153
488
  }
489
+ /**
490
+ * The SQLite driver this runtime cannot load.
491
+ *
492
+ * `loadSqliteRuntime` picks its driver by looking for `globalThis.Bun`, so a
493
+ * node process never runs the bun branch — but esbuild still follows the
494
+ * import, and `bun:sqlite` sits at the top of that module as a static import
495
+ * it cannot resolve. Left in, the bundle fails to build; marked external, it
496
+ * becomes a top-level import node fails to load. Stubbing removes the branch
497
+ * that was already dead.
498
+ */
499
+ getStubModules() {
500
+ if (this.runtime === 'bun')
501
+ return [];
502
+ return ['sqlite-runtime-bun'];
503
+ }
154
504
  getPlatform() {
155
505
  return 'node';
156
506
  }
157
507
  async deploy(options) {
158
508
  const { buildDir, logger } = options;
509
+ // Checked before anything expensive runs: a `--desktop` deploy that cannot
510
+ // produce a shell should say so now, not after a bun compile.
511
+ if (this.desktop) {
512
+ if (!this.desktopUrl && this.runtime !== 'bun') {
513
+ return {
514
+ success: false,
515
+ errors: [
516
+ {
517
+ step: 'desktop',
518
+ 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}').`,
519
+ },
520
+ ],
521
+ };
522
+ }
523
+ if (!this.projectDir) {
524
+ return {
525
+ success: false,
526
+ errors: [
527
+ {
528
+ step: 'desktop',
529
+ error: 'No project directory was supplied, so there is nowhere to write src-tauri/.',
530
+ },
531
+ ],
532
+ };
533
+ }
534
+ }
159
535
  const { join, dirname } = await import('node:path');
160
- const { readdir, writeFile, copyFile, mkdir } = await import('node:fs/promises');
536
+ const { cp, readdir, writeFile, copyFile, mkdir } = await import('node:fs/promises');
161
537
  const { existsSync } = await import('node:fs');
162
538
  // Find the unit dir with the bundle
163
539
  const entries = await readdir(buildDir);
@@ -179,6 +555,21 @@ export class StandaloneProviderAdapter {
179
555
  await copyFile(join(unitDir, 'bundle.js.map'), join(outDir, 'bundle.js.map'));
180
556
  }
181
557
  logger.info(`Bundle: ${join(outDir, 'bundle.js')}`);
558
+ // --- 2a. Frontend, when the build produced one ---
559
+ // Both runtimes need it here rather than only in the build directory: node
560
+ // resolves the mount relative to the shipped bundle, and `bun build
561
+ // --compile` follows the manifest import out of the copy it is given.
562
+ const frontendDir = join(unitDir, STANDALONE_FRONTEND_DIR);
563
+ if (existsSync(frontendDir)) {
564
+ await cp(frontendDir, join(outDir, STANDALONE_FRONTEND_DIR), {
565
+ recursive: true,
566
+ });
567
+ const manifestName = STANDALONE_FRONTEND_MANIFEST.replace('./', '');
568
+ if (existsSync(join(unitDir, manifestName))) {
569
+ await copyFile(join(unitDir, manifestName), join(outDir, manifestName));
570
+ }
571
+ logger.info(`Frontend: ${join(outDir, STANDALONE_FRONTEND_DIR)}`);
572
+ }
182
573
  // --- 2b. bun runtime: compile the bundle into a self-contained binary ---
183
574
  if (this.runtime === 'bun') {
184
575
  const { execFileSync } = await import('node:child_process');
@@ -206,6 +597,56 @@ export class StandaloneProviderAdapter {
206
597
  };
207
598
  }
208
599
  }
600
+ // --- 2c. desktop: wrap the server in a shell, or point one at a remote ---
601
+ let targetTriple;
602
+ if (this.desktop && this.projectDir) {
603
+ const { generateTauriShell, tauriBundleIdentifier } = await import('./tauri/generate.js');
604
+ const { hostTargetTriple } = await import('./tauri/target-triple.js');
605
+ const { renderTauriNextSteps } = await import('./tauri/next-steps.js');
606
+ try {
607
+ const rustcVersionVerbose = await rustcHostOutput();
608
+ targetTriple = hostTargetTriple({ rustcVersionVerbose });
609
+ const shell = await generateTauriShell({
610
+ projectDir: this.projectDir,
611
+ appName,
612
+ identifier: this.desktopIdentifier ?? tauriBundleIdentifier(appName),
613
+ targetTriple,
614
+ ...(this.desktopUrl
615
+ ? { remoteUrl: this.desktopUrl }
616
+ : { binaryPath: join(outDir, appName) }),
617
+ });
618
+ logger.info(`Desktop shell: ${shell.dir} (${shell.targetTriple})`);
619
+ if (shell.written.length) {
620
+ logger.info(` wrote ${shell.written.join(', ')}`);
621
+ }
622
+ if (shell.preserved.length) {
623
+ logger.info(` kept your edits, not regenerated: ${shell.preserved.join(', ')}`);
624
+ }
625
+ if (shell.sidecar) {
626
+ logger.info(` sidecar: binaries/${shell.sidecar.fileName}`);
627
+ }
628
+ else {
629
+ logger.info(` window opens: ${this.desktopUrl}`);
630
+ }
631
+ for (const line of renderTauriNextSteps({
632
+ shellDir: shell.dir,
633
+ hasRust: rustcVersionVerbose !== undefined,
634
+ })) {
635
+ logger.info(line);
636
+ }
637
+ }
638
+ catch (e) {
639
+ return {
640
+ success: false,
641
+ errors: [
642
+ {
643
+ step: 'desktop',
644
+ error: e instanceof Error ? e.message : String(e),
645
+ },
646
+ ],
647
+ };
648
+ }
649
+ }
209
650
  // --- 3. config/ — empty template with .env example ---
210
651
  const configDir = join(outDir, 'config');
211
652
  await mkdir(configDir, { recursive: true });
@@ -228,6 +669,7 @@ export class StandaloneProviderAdapter {
228
669
  workersDeployed: [appName],
229
670
  resourcesCreated: [],
230
671
  errors: [],
672
+ targetTriple,
231
673
  };
232
674
  }
233
675
  }
@@ -0,0 +1,75 @@
1
+ /** Where the migrations live, when the operator has moved them. */
2
+ export declare const MIGRATIONS_DIR_ENV = "PIKKU_MIGRATIONS_DIR";
3
+ export interface StandaloneSqliteDb {
4
+ engine: 'sqlite';
5
+ migrationsDir: string;
6
+ /** The file the app itself opens, so a migration cannot target another one. */
7
+ databaseFile: string;
8
+ }
9
+ /**
10
+ * The postgres.js tagged template the app is already connected through.
11
+ *
12
+ * Taken rather than constructed so a migration runs on the app's own pool: a
13
+ * second connection would need the credentials resolved twice and could reach a
14
+ * different database than the one the next `serve` opens.
15
+ */
16
+ export interface PostgresSql {
17
+ unsafe(query: string, parameters?: unknown[]): Promise<any> & {
18
+ simple(): Promise<any>;
19
+ };
20
+ begin<T>(handler: (sql: PostgresSql) => Promise<T>): Promise<T>;
21
+ }
22
+ export interface StandalonePostgresDb {
23
+ engine: 'postgres';
24
+ migrationsDir: string;
25
+ sql: PostgresSql;
26
+ }
27
+ export type StandaloneDb = StandaloneSqliteDb | StandalonePostgresDb;
28
+ export type StandaloneCommand = {
29
+ kind: 'serve';
30
+ } | {
31
+ kind: 'db';
32
+ action: 'migrate' | 'status';
33
+ } | {
34
+ kind: 'backup';
35
+ destination: string;
36
+ } | {
37
+ kind: 'exit';
38
+ code: number;
39
+ };
40
+ export interface ParseOptions {
41
+ version: string;
42
+ /** False for a build with no database, whose db commands cannot be answered. */
43
+ hasDb: boolean;
44
+ engine?: 'sqlite' | 'postgres';
45
+ write?: (line: string) => void;
46
+ }
47
+ export declare function parseStandaloneCommand(argv: string[], options: ParseOptions): StandaloneCommand;
48
+ /**
49
+ * The migrations directory, honouring an operator who keeps them elsewhere.
50
+ *
51
+ * `bundleDir` is where the build put them, which is the same `db/<engine>/`
52
+ * path Fabric's build container stages into an artifact — so an artifact from
53
+ * either producer answers `db migrate` without being told where to look.
54
+ */
55
+ export declare const resolveMigrationsDir: (bundleDir: string, env?: Record<string, string | undefined>) => string;
56
+ export interface CommandOutput {
57
+ write(line: string): void;
58
+ }
59
+ export declare function runDbCommand(action: 'migrate' | 'status', db: StandaloneDb, out?: CommandOutput): Promise<void>;
60
+ /**
61
+ * Copy the SQLite database somewhere else, while the app may be running.
62
+ *
63
+ * `VACUUM INTO` rather than copying the file: a plain copy taken while another
64
+ * process is mid-write captures a torn page and a write-ahead log it has no
65
+ * copy of, which restores as a corrupt database and only says so later.
66
+ */
67
+ export declare function runBackupCommand(destination: string, db: StandaloneSqliteDb, out?: CommandOutput): Promise<void>;
68
+ /**
69
+ * Run whatever the argv asked for, and say whether the caller should serve.
70
+ *
71
+ * The database is passed already open, because the entry has to open it the one
72
+ * way the app does — a command that resolved its own connection could migrate a
73
+ * different database than the next `serve` reads.
74
+ */
75
+ export declare function runStandaloneCommand(command: StandaloneCommand, db: StandaloneDb | undefined, out?: CommandOutput): Promise<'serve' | 'done'>;