@pikku/deploy-standalone 0.12.12 → 0.12.13

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 (46) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/adapter.d.ts +34 -0
  3. package/dist/adapter.js +184 -4
  4. package/dist/runtime/index.d.ts +9 -0
  5. package/dist/runtime/index.js +8 -0
  6. package/dist/runtime/parent-watch.d.ts +45 -0
  7. package/dist/runtime/parent-watch.js +87 -0
  8. package/dist/tauri/generate.d.ts +45 -0
  9. package/dist/tauri/generate.js +230 -0
  10. package/dist/tauri/icon.d.ts +1 -0
  11. package/dist/tauri/icon.js +54 -0
  12. package/dist/tauri/main-rs.d.ts +31 -0
  13. package/dist/tauri/main-rs.js +213 -0
  14. package/dist/tauri/next-steps.d.ts +15 -0
  15. package/dist/tauri/next-steps.js +16 -0
  16. package/dist/tauri/target-triple.d.ts +29 -0
  17. package/dist/tauri/target-triple.js +42 -0
  18. package/knowledge/decisions/a-pikku-server-serves-a-static-frontend.md +36 -0
  19. package/knowledge/decisions/a-remote-desktop-shell-bundles-nothing.md +38 -0
  20. package/knowledge/decisions/deploy-consumes-a-built-frontend.md +33 -0
  21. package/knowledge/decisions/desktop-builds-are-unsigned-and-never-update-themselves.md +34 -0
  22. package/knowledge/decisions/index.md +19 -0
  23. package/knowledge/decisions/standalone-assets-are-embedded-in-the-bun-binary.md +39 -0
  24. package/knowledge/decisions/the-desktop-shell-runs-the-server-as-a-sidecar.md +51 -0
  25. package/knowledge/decisions/the-sidecar-reports-its-port-the-shell-never-picks-one.md +44 -0
  26. package/knowledge/index.md +22 -0
  27. package/package.json +6 -4
  28. package/src/adapter.test.ts +186 -0
  29. package/src/adapter.ts +210 -4
  30. package/src/desktop-deploy.test.ts +167 -0
  31. package/src/runtime/index.ts +13 -0
  32. package/src/runtime/parent-watch.process.test.ts +112 -0
  33. package/src/runtime/parent-watch.test.ts +148 -0
  34. package/src/runtime/parent-watch.ts +115 -0
  35. package/src/sidecar-entry.test.ts +89 -0
  36. package/src/tauri/generate.test.ts +401 -0
  37. package/src/tauri/generate.ts +327 -0
  38. package/src/tauri/icon.test.ts +63 -0
  39. package/src/tauri/icon.ts +62 -0
  40. package/src/tauri/main-rs.rustfmt.test.ts +86 -0
  41. package/src/tauri/main-rs.ts +241 -0
  42. package/src/tauri/next-steps.test.ts +38 -0
  43. package/src/tauri/next-steps.ts +30 -0
  44. package/src/tauri/target-triple.test.ts +84 -0
  45. package/src/tauri/target-triple.ts +65 -0
  46. package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # @pikku/deploy-standalone
2
2
 
3
+ ## 0.12.13
4
+
5
+ ### Patch Changes
6
+
7
+ - 80eb5c0: Generate a desktop shell from `pikku deploy apply --desktop`
8
+
9
+ `pikku deploy apply --provider standalone --runtime bun --desktop` now emits a
10
+ `src-tauri/` crate that ships the compiled binary as a sidecar and opens a
11
+ window on the server's own HTTP origin, so cookies, CORS and OAuth behave
12
+ exactly as they do in a browser. Regeneration is idempotent and leaves an
13
+ edited file alone rather than overwriting it.
14
+
15
+ `--desktop-url https://app.example.com` builds the other shape: a shell that
16
+ points at an already-deployed server. Nothing is bundled — no sidecar, no
17
+ binary, and so no bun runtime to compile one — and the window is declared in
18
+ `tauri.conf.json` rather than opened from Rust, because the origin is known up
19
+ front. The url can also live in `pikku.config.json` as `deploy.desktop.url`,
20
+ alongside `deploy.desktop.identifier`.
21
+
22
+ Supporting changes: `SERVER_READY_MARKER` moved to `@pikku/deploy` (the CLI
23
+ re-exports it from its old path), both HTTP runtimes expose the port they
24
+ actually bound so `--port 0` reports a real port, and the generated server
25
+ entry exits when its parent process goes away.
26
+
27
+ - 80eb5c0: feat: serve a built frontend from the pikku server's own origin
28
+
29
+ A new `frontend` key in `pikku.config.json` names a directory of built
30
+ frontend output. `pikku serve` mounts it, and `pikku deploy` ships it inside
31
+ the distributable — into a directory beside the bundle for the node runtime,
32
+ and embedded in the binary for a `bun build --compile` standalone. `pikku dev`
33
+ deliberately ignores it and says so, because the frontend's own dev server owns
34
+ that job.
35
+
36
+ Pikku reads the frontend's output and never builds it, so an unbuilt directory
37
+ fails with a message that says which build to run rather than booting a server
38
+ that answers every page with a 404.
39
+
40
+ - Updated dependencies [80eb5c0]
41
+ - Updated dependencies [80eb5c0]
42
+ - @pikku/deploy@0.12.2
43
+
3
44
  ## 0.12.12
4
45
 
5
46
  ### Patch Changes
package/dist/adapter.d.ts CHANGED
@@ -18,14 +18,46 @@
18
18
  */
19
19
  import type { EntryGenerationContext, ProviderAdapter } from '@pikku/deploy';
20
20
  export type StandaloneRuntime = 'node' | 'bun';
21
+ /**
22
+ * Directory the built frontend is copied to, both inside the unit and beside
23
+ * the shipped bundle. The node entry resolves it relative to itself at runtime,
24
+ * so the two have to agree.
25
+ */
26
+ export declare const STANDALONE_FRONTEND_DIR = "frontend";
27
+ /**
28
+ * Module the bun entry imports its embedded assets from. It stays out of the
29
+ * esbuild bundle — esbuild rejects the `with { type: 'file' }` attribute the
30
+ * manifest is built on — and is resolved by `bun build --compile` instead.
31
+ */
32
+ export declare const STANDALONE_FRONTEND_MANIFEST = "./frontend-assets.gen.js";
21
33
  export interface StandaloneProviderAdapterOptions {
22
34
  runtime?: StandaloneRuntime;
35
+ /**
36
+ * Generate a desktop shell (Tauri) around the compiled binary. Requires the
37
+ * `bun` runtime — the shell ships the binary as a sidecar, and only that
38
+ * runtime produces one. A shell pointed at {@link desktopUrl} ships no binary
39
+ * and so has no such requirement.
40
+ */
41
+ desktop?: boolean;
42
+ /** Project root. The shell crate is written to `<projectDir>/src-tauri`. */
43
+ projectDir?: string;
44
+ /** Bundle identifier for the shell. Derived from the app name when absent. */
45
+ desktopIdentifier?: string;
46
+ /**
47
+ * An already-deployed server for the shell to open, instead of bundling one.
48
+ * The window is a webview onto that origin and nothing else is shipped.
49
+ */
50
+ desktopUrl?: string;
23
51
  }
24
52
  export declare class StandaloneProviderAdapter implements ProviderAdapter {
25
53
  readonly name = "standalone";
26
54
  readonly deployDirName = "standalone";
27
55
  readonly singleUnit = true;
28
56
  readonly runtime: StandaloneRuntime;
57
+ readonly desktop: boolean;
58
+ readonly projectDir?: string;
59
+ readonly desktopIdentifier?: string;
60
+ readonly desktopUrl?: string;
29
61
  constructor(options?: StandaloneProviderAdapterOptions);
30
62
  generateEntrySource(ctx: EntryGenerationContext): string;
31
63
  private generateNodeEntrySource;
@@ -50,10 +82,12 @@ export declare class StandaloneProviderAdapter implements ProviderAdapter {
50
82
  }[];
51
83
  workersDeployed?: undefined;
52
84
  resourcesCreated?: undefined;
85
+ targetTriple?: undefined;
53
86
  } | {
54
87
  success: boolean;
55
88
  workersDeployed: string[];
56
89
  resourcesCreated: never[];
57
90
  errors: never[];
91
+ targetTriple: string | undefined;
58
92
  }>;
59
93
  }
package/dist/adapter.js CHANGED
@@ -1,11 +1,60 @@
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
+ const SIDECAR_RUNTIME_IMPORT = `import { watchParentProcess } from '@pikku/deploy-standalone/runtime'`;
28
+ /**
29
+ * `rustc -vV`, or nothing when no toolchain is installed. The triple then falls
30
+ * back to the Node platform pair, which is right for every ordinary host — the
31
+ * cases rustc knows better about (musl, Rosetta) are the ones where a Rust
32
+ * toolchain is present anyway.
33
+ */
34
+ const rustcHostOutput = async () => {
35
+ try {
36
+ const { execFileSync } = await import('node:child_process');
37
+ return execFileSync('rustc', ['-vV'], { encoding: 'utf-8', stdio: 'pipe' });
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ };
2
43
  export class StandaloneProviderAdapter {
3
44
  name = 'standalone';
4
45
  deployDirName = 'standalone';
5
46
  singleUnit = true;
6
47
  runtime;
48
+ desktop;
49
+ projectDir;
50
+ desktopIdentifier;
51
+ desktopUrl;
7
52
  constructor(options = {}) {
8
53
  this.runtime = options.runtime ?? 'node';
54
+ this.desktop = options.desktop ?? Boolean(options.desktopUrl);
55
+ this.projectDir = options.projectDir;
56
+ this.desktopIdentifier = options.desktopIdentifier;
57
+ this.desktopUrl = options.desktopUrl;
9
58
  }
10
59
  generateEntrySource(ctx) {
11
60
  if (this.runtime === 'bun') {
@@ -24,6 +73,13 @@ export class StandaloneProviderAdapter {
24
73
  `import { PikkuNodeHTTPServer } from '@pikku/node-http-server'`,
25
74
  `import { DEFAULT_WS_MAX_PAYLOAD, pikkuWebsocketHandler } from '@pikku/ws'`,
26
75
  `import { WebSocketServer } from 'ws'`,
76
+ SIDECAR_RUNTIME_IMPORT,
77
+ ...(ctx.frontend
78
+ ? [
79
+ `import { dirname as __pikkuDirname, join as __pikkuJoin } from 'node:path'`,
80
+ `import { fileURLToPath as __pikkuFileURLToPath } from 'node:url'`,
81
+ ]
82
+ : []),
27
83
  ``,
28
84
  ctx.configImport,
29
85
  ctx.servicesImport,
@@ -55,9 +111,21 @@ export class StandaloneProviderAdapter {
55
111
  ` })`,
56
112
  ` pikkuState(null, 'package', 'singletonServices', singletonServices)`,
57
113
  ``,
114
+ ...(ctx.frontend
115
+ ? [
116
+ // Resolved from the running bundle rather than baked in at build
117
+ // time, so the distributable stays movable.
118
+ ` const staticMounts = [{`,
119
+ ` urlPrefix: '${ctx.frontend.urlPrefix}',`,
120
+ ` directory: __pikkuJoin(__pikkuDirname(__pikkuFileURLToPath(import.meta.url)), '${STANDALONE_FRONTEND_DIR}'),`,
121
+ ` spaFallback: ${ctx.frontend.spaFallback},`,
122
+ ` }]`,
123
+ ``,
124
+ ]
125
+ : []),
58
126
  ` const wss = new WebSocketServer({ noServer: true, maxPayload: DEFAULT_WS_MAX_PAYLOAD })`,
59
127
  ` const server = new PikkuNodeHTTPServer(`,
60
- ` { ...config, port, hostname },`,
128
+ ` { ...config, port, hostname${ctx.frontend ? ', staticMounts' : ''} },`,
61
129
  ` logger,`,
62
130
  ` {`,
63
131
  ` ${ctx.mcpServerOption}configureServer: (httpServer) => {`,
@@ -70,6 +138,7 @@ export class StandaloneProviderAdapter {
70
138
  ` await triggerService.start()`,
71
139
  ` server.enableExitOnSignals()`,
72
140
  ` await server.start()`,
141
+ ...sidecarHandshakeLines(),
73
142
  `}`,
74
143
  ``,
75
144
  `main().catch((err) => {`,
@@ -83,10 +152,14 @@ export class StandaloneProviderAdapter {
83
152
  return [
84
153
  `// Generated standalone entry (bun runtime) — all functions in one process`,
85
154
  `import { ConsoleLogger, InMemoryQueueService, InMemoryTriggerService, InMemoryWorkflowService } from '@pikku/core/services'`,
155
+ SIDECAR_RUNTIME_IMPORT,
86
156
  `import { pikkuState } from '@pikku/core/state'`,
87
157
  `import { wireAgentScorerQueueWorkers } from '@pikku/core/agent-scorer'`,
88
158
  `import { InMemorySchedulerService } from '@pikku/schedule'`,
89
159
  `import { PikkuBunServer, BunEventHubService } from '@pikku/bun-server'`,
160
+ ...(ctx.frontend
161
+ ? [`import { frontendAssets } from '${STANDALONE_FRONTEND_MANIFEST}'`]
162
+ : []),
90
163
  ``,
91
164
  ctx.configImport,
92
165
  ctx.servicesImport,
@@ -118,12 +191,26 @@ export class StandaloneProviderAdapter {
118
191
  ` })`,
119
192
  ` pikkuState(null, 'package', 'singletonServices', singletonServices)`,
120
193
  ``,
121
- ` const server = new PikkuBunServer({ ...config, port, hostname }, logger, { ${ctx.mcpServerOption}eventHub })`,
194
+ ...(ctx.frontend
195
+ ? [
196
+ // A compiled binary has no directory to read: every file was
197
+ // embedded, and the map is the only way back to it.
198
+ ` const staticMounts = [{`,
199
+ ` urlPrefix: '${ctx.frontend.urlPrefix}',`,
200
+ ` directory: '',`,
201
+ ` spaFallback: ${ctx.frontend.spaFallback},`,
202
+ ` assets: frontendAssets,`,
203
+ ` }]`,
204
+ ``,
205
+ ]
206
+ : []),
207
+ ` const server = new PikkuBunServer({ ...config, port, hostname${ctx.frontend ? ', staticMounts' : ''} }, logger, { ${ctx.mcpServerOption}eventHub })`,
122
208
  ` await server.init()`,
123
209
  ` await schedulerService.start()`,
124
210
  ` await triggerService.start()`,
125
211
  ` server.enableExitOnSignals()`,
126
212
  ` await server.start()`,
213
+ ...sidecarHandshakeLines(),
127
214
  `}`,
128
215
  ``,
129
216
  `main().catch((err) => {`,
@@ -148,6 +235,7 @@ export class StandaloneProviderAdapter {
148
235
  // Bun-native builtins are provided by the runtime and resolved by
149
236
  // `bun build --compile` — leave them as imports rather than inlining.
150
237
  externals.push('bun', 'bun:*', 'bun:sqlite', 'bun:ffi');
238
+ externals.push(STANDALONE_FRONTEND_MANIFEST);
151
239
  }
152
240
  return externals;
153
241
  }
@@ -156,8 +244,34 @@ export class StandaloneProviderAdapter {
156
244
  }
157
245
  async deploy(options) {
158
246
  const { buildDir, logger } = options;
247
+ // Checked before anything expensive runs: a `--desktop` deploy that cannot
248
+ // produce a shell should say so now, not after a bun compile.
249
+ if (this.desktop) {
250
+ if (!this.desktopUrl && this.runtime !== 'bun') {
251
+ return {
252
+ success: false,
253
+ errors: [
254
+ {
255
+ step: 'desktop',
256
+ 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}').`,
257
+ },
258
+ ],
259
+ };
260
+ }
261
+ if (!this.projectDir) {
262
+ return {
263
+ success: false,
264
+ errors: [
265
+ {
266
+ step: 'desktop',
267
+ error: 'No project directory was supplied, so there is nowhere to write src-tauri/.',
268
+ },
269
+ ],
270
+ };
271
+ }
272
+ }
159
273
  const { join, dirname } = await import('node:path');
160
- const { readdir, writeFile, copyFile, mkdir } = await import('node:fs/promises');
274
+ const { cp, readdir, writeFile, copyFile, mkdir } = await import('node:fs/promises');
161
275
  const { existsSync } = await import('node:fs');
162
276
  // Find the unit dir with the bundle
163
277
  const entries = await readdir(buildDir);
@@ -179,6 +293,21 @@ export class StandaloneProviderAdapter {
179
293
  await copyFile(join(unitDir, 'bundle.js.map'), join(outDir, 'bundle.js.map'));
180
294
  }
181
295
  logger.info(`Bundle: ${join(outDir, 'bundle.js')}`);
296
+ // --- 2a. Frontend, when the build produced one ---
297
+ // Both runtimes need it here rather than only in the build directory: node
298
+ // resolves the mount relative to the shipped bundle, and `bun build
299
+ // --compile` follows the manifest import out of the copy it is given.
300
+ const frontendDir = join(unitDir, STANDALONE_FRONTEND_DIR);
301
+ if (existsSync(frontendDir)) {
302
+ await cp(frontendDir, join(outDir, STANDALONE_FRONTEND_DIR), {
303
+ recursive: true,
304
+ });
305
+ const manifestName = STANDALONE_FRONTEND_MANIFEST.replace('./', '');
306
+ if (existsSync(join(unitDir, manifestName))) {
307
+ await copyFile(join(unitDir, manifestName), join(outDir, manifestName));
308
+ }
309
+ logger.info(`Frontend: ${join(outDir, STANDALONE_FRONTEND_DIR)}`);
310
+ }
182
311
  // --- 2b. bun runtime: compile the bundle into a self-contained binary ---
183
312
  if (this.runtime === 'bun') {
184
313
  const { execFileSync } = await import('node:child_process');
@@ -206,6 +335,56 @@ export class StandaloneProviderAdapter {
206
335
  };
207
336
  }
208
337
  }
338
+ // --- 2c. desktop: wrap the server in a shell, or point one at a remote ---
339
+ let targetTriple;
340
+ if (this.desktop && this.projectDir) {
341
+ const { generateTauriShell, tauriBundleIdentifier } = await import('./tauri/generate.js');
342
+ const { hostTargetTriple } = await import('./tauri/target-triple.js');
343
+ const { renderTauriNextSteps } = await import('./tauri/next-steps.js');
344
+ try {
345
+ const rustcVersionVerbose = await rustcHostOutput();
346
+ targetTriple = hostTargetTriple({ rustcVersionVerbose });
347
+ const shell = await generateTauriShell({
348
+ projectDir: this.projectDir,
349
+ appName,
350
+ identifier: this.desktopIdentifier ?? tauriBundleIdentifier(appName),
351
+ targetTriple,
352
+ ...(this.desktopUrl
353
+ ? { remoteUrl: this.desktopUrl }
354
+ : { binaryPath: join(outDir, appName) }),
355
+ });
356
+ logger.info(`Desktop shell: ${shell.dir} (${shell.targetTriple})`);
357
+ if (shell.written.length) {
358
+ logger.info(` wrote ${shell.written.join(', ')}`);
359
+ }
360
+ if (shell.preserved.length) {
361
+ logger.info(` kept your edits, not regenerated: ${shell.preserved.join(', ')}`);
362
+ }
363
+ if (shell.sidecar) {
364
+ logger.info(` sidecar: binaries/${shell.sidecar.fileName}`);
365
+ }
366
+ else {
367
+ logger.info(` window opens: ${this.desktopUrl}`);
368
+ }
369
+ for (const line of renderTauriNextSteps({
370
+ shellDir: shell.dir,
371
+ hasRust: rustcVersionVerbose !== undefined,
372
+ })) {
373
+ logger.info(line);
374
+ }
375
+ }
376
+ catch (e) {
377
+ return {
378
+ success: false,
379
+ errors: [
380
+ {
381
+ step: 'desktop',
382
+ error: e instanceof Error ? e.message : String(e),
383
+ },
384
+ ],
385
+ };
386
+ }
387
+ }
209
388
  // --- 3. config/ — empty template with .env example ---
210
389
  const configDir = join(outDir, 'config');
211
390
  await mkdir(configDir, { recursive: true });
@@ -228,6 +407,7 @@ export class StandaloneProviderAdapter {
228
407
  workersDeployed: [appName],
229
408
  resourcesCreated: [],
230
409
  errors: [],
410
+ targetTriple,
231
411
  };
232
412
  }
233
413
  }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * `@pikku/deploy-standalone/runtime` — the sliver of this package that runs
3
+ * inside the shipped artifact rather than on the build machine.
4
+ *
5
+ * A generated standalone entry imports from here, so the code is unit-tested
6
+ * in TypeScript instead of being a string the adapter emits and nobody runs.
7
+ */
8
+ export { DATA_DIR_ENV, PARENT_PID_ENV, watchParentProcess, } from './parent-watch.js';
9
+ export type { ParentWatch, ParentWatchOptions } from './parent-watch.js';
@@ -0,0 +1,8 @@
1
+ /**
2
+ * `@pikku/deploy-standalone/runtime` — the sliver of this package that runs
3
+ * inside the shipped artifact rather than on the build machine.
4
+ *
5
+ * A generated standalone entry imports from here, so the code is unit-tested
6
+ * in TypeScript instead of being a string the adapter emits and nobody runs.
7
+ */
8
+ export { DATA_DIR_ENV, PARENT_PID_ENV, watchParentProcess, } from './parent-watch.js';
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Environment variable a desktop shell uses to tell its sidecar which process
3
+ * it must not outlive.
4
+ */
5
+ export declare const PARENT_PID_ENV = "PIKKU_PARENT_PID";
6
+ /**
7
+ * Environment variable a desktop shell uses to tell its sidecar where the
8
+ * SQLite file, uploaded content and runtime state belong. The shell resolves
9
+ * it from the platform's own app-data location, because a binary launched by
10
+ * double-click has no meaningful working directory.
11
+ */
12
+ export declare const DATA_DIR_ENV = "PIKKU_DATA_DIR";
13
+ export type ParentWatchOptions = {
14
+ /** Where the parent pid is read from. Defaults to `process.env`. */
15
+ env?: Record<string, string | undefined>;
16
+ /** Probe for whether a pid is still running. Defaults to signal 0. */
17
+ isAlive?: (pid: number) => boolean;
18
+ /** Run when the parent is found to be gone. Defaults to exiting cleanly. */
19
+ onOrphaned?: () => void;
20
+ intervalMs?: number;
21
+ };
22
+ export type ParentWatch = {
23
+ /** False when no usable parent pid was supplied — the watch is inert. */
24
+ readonly watching: boolean;
25
+ readonly parentPid: number | undefined;
26
+ /** True only if the poll timer would hold the event loop open. */
27
+ readonly holdsProcessOpen: boolean;
28
+ /** Probe immediately rather than waiting for the next interval. */
29
+ checkNow(): void;
30
+ stop(): void;
31
+ };
32
+ /**
33
+ * Exit when the process that spawned us does.
34
+ *
35
+ * Tauri kills its sidecar on a clean exit, but a hard crash of the shell never
36
+ * runs that path. An orphaned pikku server keeps the SQLite file open, and the
37
+ * next launch — which single-instance only guards against a second *shell* —
38
+ * would be a second writer against the same database. Polling the parent is the only portable answer: neither
39
+ * `process.on('disconnect')` (no IPC channel here) nor a closed stdin is
40
+ * reliable across the platforms a desktop build targets.
41
+ *
42
+ * With no parent pid in the environment the watch is inert, so a server run
43
+ * from a terminal or a container behaves exactly as it did before.
44
+ */
45
+ export declare const watchParentProcess: (options?: ParentWatchOptions) => ParentWatch;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Environment variable a desktop shell uses to tell its sidecar which process
3
+ * it must not outlive.
4
+ */
5
+ export const PARENT_PID_ENV = 'PIKKU_PARENT_PID';
6
+ /**
7
+ * Environment variable a desktop shell uses to tell its sidecar where the
8
+ * SQLite file, uploaded content and runtime state belong. The shell resolves
9
+ * it from the platform's own app-data location, because a binary launched by
10
+ * double-click has no meaningful working directory.
11
+ */
12
+ export const DATA_DIR_ENV = 'PIKKU_DATA_DIR';
13
+ /**
14
+ * A pid is alive if signalling it succeeds. `EPERM` also means alive — the
15
+ * process exists but belongs to another user — and only `ESRCH` means gone.
16
+ */
17
+ const defaultIsAlive = (pid) => {
18
+ try {
19
+ process.kill(pid, 0);
20
+ return true;
21
+ }
22
+ catch (err) {
23
+ return err.code === 'EPERM';
24
+ }
25
+ };
26
+ const parsePid = (raw) => {
27
+ if (!raw)
28
+ return undefined;
29
+ if (!/^\d+$/.test(raw))
30
+ return undefined;
31
+ const pid = Number(raw);
32
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
33
+ };
34
+ /**
35
+ * Exit when the process that spawned us does.
36
+ *
37
+ * Tauri kills its sidecar on a clean exit, but a hard crash of the shell never
38
+ * runs that path. An orphaned pikku server keeps the SQLite file open, and the
39
+ * next launch — which single-instance only guards against a second *shell* —
40
+ * would be a second writer against the same database. Polling the parent is the only portable answer: neither
41
+ * `process.on('disconnect')` (no IPC channel here) nor a closed stdin is
42
+ * reliable across the platforms a desktop build targets.
43
+ *
44
+ * With no parent pid in the environment the watch is inert, so a server run
45
+ * from a terminal or a container behaves exactly as it did before.
46
+ */
47
+ export const watchParentProcess = (options = {}) => {
48
+ const env = options.env ?? process.env;
49
+ const isAlive = options.isAlive ?? defaultIsAlive;
50
+ const onOrphaned = options.onOrphaned ?? (() => process.exit(0));
51
+ const intervalMs = options.intervalMs ?? 1_000;
52
+ const parentPid = parsePid(env[PARENT_PID_ENV]);
53
+ let timer;
54
+ let fired = false;
55
+ const stop = () => {
56
+ if (timer) {
57
+ clearInterval(timer);
58
+ timer = undefined;
59
+ }
60
+ };
61
+ const checkNow = () => {
62
+ if (parentPid === undefined || fired)
63
+ return;
64
+ if (isAlive(parentPid))
65
+ return;
66
+ fired = true;
67
+ stop();
68
+ onOrphaned();
69
+ };
70
+ if (parentPid !== undefined) {
71
+ timer = setInterval(checkNow, intervalMs);
72
+ // The watch is a guard, not a reason to stay running: a server that has
73
+ // finished its work must still be allowed to exit.
74
+ timer.unref?.();
75
+ }
76
+ return {
77
+ get watching() {
78
+ return timer !== undefined;
79
+ },
80
+ parentPid,
81
+ get holdsProcessOpen() {
82
+ return timer?.hasRef?.() ?? false;
83
+ },
84
+ checkNow,
85
+ stop,
86
+ };
87
+ };
@@ -0,0 +1,45 @@
1
+ /** Directory the shell crate is generated into, relative to the project root. */
2
+ export declare const TAURI_SHELL_DIR = "src-tauri";
3
+ /**
4
+ * A reverse-DNS identifier for the bundle.
5
+ *
6
+ * A scoped package already names its org, so `@acme/shop` becomes
7
+ * `com.acme.shop`. An unscoped name has no org to borrow, and `com.shop.app`
8
+ * is not an option — macOS rejects an identifier ending in `.app`.
9
+ */
10
+ export declare const tauriBundleIdentifier: (packageName: string) => string;
11
+ export type GenerateTauriShellOptions = {
12
+ /** Project root. The crate is written to `<projectDir>/src-tauri`. */
13
+ projectDir: string;
14
+ /** Product name, and the `externalBin` base name of the sidecar. */
15
+ appName: string;
16
+ /** Reverse-DNS bundle identifier. See {@link tauriBundleIdentifier}. */
17
+ identifier: string;
18
+ version?: string;
19
+ windowTitle?: string;
20
+ width?: number;
21
+ height?: number;
22
+ /** The compiled pikku binary to install as the sidecar. */
23
+ binaryPath?: string;
24
+ /** Defaults to the host triple, via `rustc -vV` when available. */
25
+ targetTriple?: string;
26
+ /**
27
+ * An already-running server to open the window against, instead of shipping
28
+ * one. The shell then bundles nothing: no sidecar, no binary, no supervision.
29
+ */
30
+ remoteUrl?: string;
31
+ };
32
+ export type GenerateTauriShellResult = {
33
+ /** Absolute path of the generated crate. */
34
+ dir: string;
35
+ /** Files written this run, relative to `dir`. */
36
+ written: string[];
37
+ /** Files left alone because the user has edited them, relative to `dir`. */
38
+ preserved: string[];
39
+ targetTriple: string;
40
+ sidecar?: {
41
+ fileName: string;
42
+ path: string;
43
+ };
44
+ };
45
+ export declare const generateTauriShell: (options: GenerateTauriShellOptions) => Promise<GenerateTauriShellResult>;