@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/src/adapter.ts CHANGED
@@ -17,12 +17,73 @@
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
+ const SIDECAR_RUNTIME_IMPORT = `import { watchParentProcess } from '@pikku/deploy-standalone/runtime'`
53
+
54
+ /**
55
+ * `rustc -vV`, or nothing when no toolchain is installed. The triple then falls
56
+ * back to the Node platform pair, which is right for every ordinary host — the
57
+ * cases rustc knows better about (musl, Rosetta) are the ones where a Rust
58
+ * toolchain is present anyway.
59
+ */
60
+ const rustcHostOutput = async (): Promise<string | undefined> => {
61
+ try {
62
+ const { execFileSync } = await import('node:child_process')
63
+ return execFileSync('rustc', ['-vV'], { encoding: 'utf-8', stdio: 'pipe' })
64
+ } catch {
65
+ return undefined
66
+ }
67
+ }
68
+
24
69
  export interface StandaloneProviderAdapterOptions {
25
70
  runtime?: StandaloneRuntime
71
+ /**
72
+ * Generate a desktop shell (Tauri) around the compiled binary. Requires the
73
+ * `bun` runtime — the shell ships the binary as a sidecar, and only that
74
+ * runtime produces one. A shell pointed at {@link desktopUrl} ships no binary
75
+ * and so has no such requirement.
76
+ */
77
+ desktop?: boolean
78
+ /** Project root. The shell crate is written to `<projectDir>/src-tauri`. */
79
+ projectDir?: string
80
+ /** Bundle identifier for the shell. Derived from the app name when absent. */
81
+ desktopIdentifier?: string
82
+ /**
83
+ * An already-deployed server for the shell to open, instead of bundling one.
84
+ * The window is a webview onto that origin and nothing else is shipped.
85
+ */
86
+ desktopUrl?: string
26
87
  }
27
88
 
28
89
  export class StandaloneProviderAdapter implements ProviderAdapter {
@@ -30,9 +91,17 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
30
91
  readonly deployDirName = 'standalone'
31
92
  readonly singleUnit = true
32
93
  readonly runtime: StandaloneRuntime
94
+ readonly desktop: boolean
95
+ readonly projectDir?: string
96
+ readonly desktopIdentifier?: string
97
+ readonly desktopUrl?: string
33
98
 
34
99
  constructor(options: StandaloneProviderAdapterOptions = {}) {
35
100
  this.runtime = options.runtime ?? 'node'
101
+ this.desktop = options.desktop ?? Boolean(options.desktopUrl)
102
+ this.projectDir = options.projectDir
103
+ this.desktopIdentifier = options.desktopIdentifier
104
+ this.desktopUrl = options.desktopUrl
36
105
  }
37
106
 
38
107
  generateEntrySource(ctx: EntryGenerationContext): string {
@@ -53,6 +122,13 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
53
122
  `import { PikkuNodeHTTPServer } from '@pikku/node-http-server'`,
54
123
  `import { DEFAULT_WS_MAX_PAYLOAD, pikkuWebsocketHandler } from '@pikku/ws'`,
55
124
  `import { WebSocketServer } from 'ws'`,
125
+ SIDECAR_RUNTIME_IMPORT,
126
+ ...(ctx.frontend
127
+ ? [
128
+ `import { dirname as __pikkuDirname, join as __pikkuJoin } from 'node:path'`,
129
+ `import { fileURLToPath as __pikkuFileURLToPath } from 'node:url'`,
130
+ ]
131
+ : []),
56
132
  ``,
57
133
  ctx.configImport,
58
134
  ctx.servicesImport,
@@ -84,9 +160,21 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
84
160
  ` })`,
85
161
  ` pikkuState(null, 'package', 'singletonServices', singletonServices)`,
86
162
  ``,
163
+ ...(ctx.frontend
164
+ ? [
165
+ // Resolved from the running bundle rather than baked in at build
166
+ // time, so the distributable stays movable.
167
+ ` const staticMounts = [{`,
168
+ ` urlPrefix: '${ctx.frontend.urlPrefix}',`,
169
+ ` directory: __pikkuJoin(__pikkuDirname(__pikkuFileURLToPath(import.meta.url)), '${STANDALONE_FRONTEND_DIR}'),`,
170
+ ` spaFallback: ${ctx.frontend.spaFallback},`,
171
+ ` }]`,
172
+ ``,
173
+ ]
174
+ : []),
87
175
  ` const wss = new WebSocketServer({ noServer: true, maxPayload: DEFAULT_WS_MAX_PAYLOAD })`,
88
176
  ` const server = new PikkuNodeHTTPServer(`,
89
- ` { ...config, port, hostname },`,
177
+ ` { ...config, port, hostname${ctx.frontend ? ', staticMounts' : ''} },`,
90
178
  ` logger,`,
91
179
  ` {`,
92
180
  ` ${ctx.mcpServerOption}configureServer: (httpServer) => {`,
@@ -99,6 +187,7 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
99
187
  ` await triggerService.start()`,
100
188
  ` server.enableExitOnSignals()`,
101
189
  ` await server.start()`,
190
+ ...sidecarHandshakeLines(),
102
191
  `}`,
103
192
  ``,
104
193
  `main().catch((err) => {`,
@@ -113,10 +202,14 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
113
202
  return [
114
203
  `// Generated standalone entry (bun runtime) — all functions in one process`,
115
204
  `import { ConsoleLogger, InMemoryQueueService, InMemoryTriggerService, InMemoryWorkflowService } from '@pikku/core/services'`,
205
+ SIDECAR_RUNTIME_IMPORT,
116
206
  `import { pikkuState } from '@pikku/core/state'`,
117
207
  `import { wireAgentScorerQueueWorkers } from '@pikku/core/agent-scorer'`,
118
208
  `import { InMemorySchedulerService } from '@pikku/schedule'`,
119
209
  `import { PikkuBunServer, BunEventHubService } from '@pikku/bun-server'`,
210
+ ...(ctx.frontend
211
+ ? [`import { frontendAssets } from '${STANDALONE_FRONTEND_MANIFEST}'`]
212
+ : []),
120
213
  ``,
121
214
  ctx.configImport,
122
215
  ctx.servicesImport,
@@ -148,12 +241,26 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
148
241
  ` })`,
149
242
  ` pikkuState(null, 'package', 'singletonServices', singletonServices)`,
150
243
  ``,
151
- ` const server = new PikkuBunServer({ ...config, port, hostname }, logger, { ${ctx.mcpServerOption}eventHub })`,
244
+ ...(ctx.frontend
245
+ ? [
246
+ // A compiled binary has no directory to read: every file was
247
+ // embedded, and the map is the only way back to it.
248
+ ` const staticMounts = [{`,
249
+ ` urlPrefix: '${ctx.frontend.urlPrefix}',`,
250
+ ` directory: '',`,
251
+ ` spaFallback: ${ctx.frontend.spaFallback},`,
252
+ ` assets: frontendAssets,`,
253
+ ` }]`,
254
+ ``,
255
+ ]
256
+ : []),
257
+ ` const server = new PikkuBunServer({ ...config, port, hostname${ctx.frontend ? ', staticMounts' : ''} }, logger, { ${ctx.mcpServerOption}eventHub })`,
152
258
  ` await server.init()`,
153
259
  ` await schedulerService.start()`,
154
260
  ` await triggerService.start()`,
155
261
  ` server.enableExitOnSignals()`,
156
262
  ` await server.start()`,
263
+ ...sidecarHandshakeLines(),
157
264
  `}`,
158
265
  ``,
159
266
  `main().catch((err) => {`,
@@ -182,6 +289,7 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
182
289
  // Bun-native builtins are provided by the runtime and resolved by
183
290
  // `bun build --compile` — leave them as imports rather than inlining.
184
291
  externals.push('bun', 'bun:*', 'bun:sqlite', 'bun:ffi')
292
+ externals.push(STANDALONE_FRONTEND_MANIFEST)
185
293
  }
186
294
  return externals
187
295
  }
@@ -196,8 +304,37 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
196
304
  onProgress?: (step: string, detail: string) => void
197
305
  }) {
198
306
  const { buildDir, logger } = options
307
+
308
+ // Checked before anything expensive runs: a `--desktop` deploy that cannot
309
+ // produce a shell should say so now, not after a bun compile.
310
+ if (this.desktop) {
311
+ if (!this.desktopUrl && this.runtime !== 'bun') {
312
+ return {
313
+ success: false,
314
+ errors: [
315
+ {
316
+ step: 'desktop',
317
+ 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}').`,
318
+ },
319
+ ],
320
+ }
321
+ }
322
+ if (!this.projectDir) {
323
+ return {
324
+ success: false,
325
+ errors: [
326
+ {
327
+ step: 'desktop',
328
+ error:
329
+ 'No project directory was supplied, so there is nowhere to write src-tauri/.',
330
+ },
331
+ ],
332
+ }
333
+ }
334
+ }
335
+
199
336
  const { join, dirname } = await import('node:path')
200
- const { readdir, writeFile, copyFile, mkdir } =
337
+ const { cp, readdir, writeFile, copyFile, mkdir } =
201
338
  await import('node:fs/promises')
202
339
  const { existsSync } = await import('node:fs')
203
340
 
@@ -230,6 +367,22 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
230
367
  }
231
368
  logger.info(`Bundle: ${join(outDir, 'bundle.js')}`)
232
369
 
370
+ // --- 2a. Frontend, when the build produced one ---
371
+ // Both runtimes need it here rather than only in the build directory: node
372
+ // resolves the mount relative to the shipped bundle, and `bun build
373
+ // --compile` follows the manifest import out of the copy it is given.
374
+ const frontendDir = join(unitDir, STANDALONE_FRONTEND_DIR)
375
+ if (existsSync(frontendDir)) {
376
+ await cp(frontendDir, join(outDir, STANDALONE_FRONTEND_DIR), {
377
+ recursive: true,
378
+ })
379
+ const manifestName = STANDALONE_FRONTEND_MANIFEST.replace('./', '')
380
+ if (existsSync(join(unitDir, manifestName))) {
381
+ await copyFile(join(unitDir, manifestName), join(outDir, manifestName))
382
+ }
383
+ logger.info(`Frontend: ${join(outDir, STANDALONE_FRONTEND_DIR)}`)
384
+ }
385
+
233
386
  // --- 2b. bun runtime: compile the bundle into a self-contained binary ---
234
387
  if (this.runtime === 'bun') {
235
388
  const { execFileSync } = await import('node:child_process')
@@ -261,6 +414,58 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
261
414
  }
262
415
  }
263
416
 
417
+ // --- 2c. desktop: wrap the server in a shell, or point one at a remote ---
418
+ let targetTriple: string | undefined
419
+ if (this.desktop && this.projectDir) {
420
+ const { generateTauriShell, tauriBundleIdentifier } =
421
+ await import('./tauri/generate.js')
422
+ const { hostTargetTriple } = await import('./tauri/target-triple.js')
423
+ const { renderTauriNextSteps } = await import('./tauri/next-steps.js')
424
+ try {
425
+ const rustcVersionVerbose = await rustcHostOutput()
426
+ targetTriple = hostTargetTriple({ rustcVersionVerbose })
427
+ const shell = await generateTauriShell({
428
+ projectDir: this.projectDir,
429
+ appName,
430
+ identifier: this.desktopIdentifier ?? tauriBundleIdentifier(appName),
431
+ targetTriple,
432
+ ...(this.desktopUrl
433
+ ? { remoteUrl: this.desktopUrl }
434
+ : { binaryPath: join(outDir, appName) }),
435
+ })
436
+ logger.info(`Desktop shell: ${shell.dir} (${shell.targetTriple})`)
437
+ if (shell.written.length) {
438
+ logger.info(` wrote ${shell.written.join(', ')}`)
439
+ }
440
+ if (shell.preserved.length) {
441
+ logger.info(
442
+ ` kept your edits, not regenerated: ${shell.preserved.join(', ')}`
443
+ )
444
+ }
445
+ if (shell.sidecar) {
446
+ logger.info(` sidecar: binaries/${shell.sidecar.fileName}`)
447
+ } else {
448
+ logger.info(` window opens: ${this.desktopUrl}`)
449
+ }
450
+ for (const line of renderTauriNextSteps({
451
+ shellDir: shell.dir,
452
+ hasRust: rustcVersionVerbose !== undefined,
453
+ })) {
454
+ logger.info(line)
455
+ }
456
+ } catch (e: unknown) {
457
+ return {
458
+ success: false,
459
+ errors: [
460
+ {
461
+ step: 'desktop',
462
+ error: e instanceof Error ? e.message : String(e),
463
+ },
464
+ ],
465
+ }
466
+ }
467
+ }
468
+
264
469
  // --- 3. config/ — empty template with .env example ---
265
470
  const configDir = join(outDir, 'config')
266
471
  await mkdir(configDir, { recursive: true })
@@ -292,6 +497,7 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
292
497
  workersDeployed: [appName],
293
498
  resourcesCreated: [],
294
499
  errors: [],
500
+ targetTriple,
295
501
  }
296
502
  }
297
503
  }
@@ -0,0 +1,167 @@
1
+ import { after, describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
4
+ import { rmSync } from 'node:fs'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+
8
+ import { StandaloneProviderAdapter } from './adapter.js'
9
+ import { sidecarFileName } from './tauri/target-triple.js'
10
+
11
+ const tempDirs: string[] = []
12
+
13
+ after(() => {
14
+ for (const dir of tempDirs) {
15
+ rmSync(dir, { recursive: true, force: true })
16
+ }
17
+ })
18
+
19
+ const silentLogger = { info: () => {}, error: () => {} }
20
+
21
+ const builtUnit = async () => {
22
+ const buildDir = await mkdtemp(join(tmpdir(), 'pikku-desktop-deploy-'))
23
+ tempDirs.push(buildDir)
24
+ const unitDir = join(buildDir, 'shop')
25
+ await mkdir(unitDir, { recursive: true })
26
+ await writeFile(join(unitDir, 'bundle.js'), 'console.log("bundle")\n')
27
+
28
+ const projectDir = await mkdtemp(join(tmpdir(), 'pikku-desktop-project-'))
29
+ tempDirs.push(projectDir)
30
+ return { buildDir, projectDir, outDir: join(buildDir, 'shop-dist') }
31
+ }
32
+
33
+ describe('deploying a standalone unit as a desktop shell', () => {
34
+ test('refuses without the bun runtime, since there is no binary to ship', async () => {
35
+ const { buildDir, projectDir } = await builtUnit()
36
+
37
+ const result = await new StandaloneProviderAdapter({
38
+ runtime: 'node',
39
+ desktop: true,
40
+ projectDir,
41
+ }).deploy({ buildDir, logger: silentLogger })
42
+
43
+ assert.equal(result.success, false)
44
+ assert.match(result.errors[0]!.error, /bun/)
45
+ })
46
+
47
+ test('refuses when it was not told where the project lives', async () => {
48
+ const { buildDir } = await builtUnit()
49
+
50
+ const result = await new StandaloneProviderAdapter({
51
+ runtime: 'bun',
52
+ desktop: true,
53
+ }).deploy({ buildDir, logger: silentLogger })
54
+
55
+ assert.equal(result.success, false)
56
+ assert.match(result.errors[0]!.error, /project/i)
57
+ })
58
+
59
+ test('generates the shell around the compiled binary', async () => {
60
+ const { buildDir, projectDir, outDir } = await builtUnit()
61
+
62
+ const result = await new StandaloneProviderAdapter({
63
+ runtime: 'bun',
64
+ desktop: true,
65
+ projectDir,
66
+ }).deploy({ buildDir, logger: silentLogger })
67
+
68
+ assert.deepEqual(result.errors, [])
69
+ assert.equal(result.success, true)
70
+
71
+ const shellDir = join(projectDir, 'src-tauri')
72
+ const conf = JSON.parse(
73
+ await readFile(join(shellDir, 'tauri.conf.json'), 'utf-8')
74
+ )
75
+ assert.equal(conf.productName, 'shop')
76
+ assert.equal(conf.identifier, 'com.shop.desktop')
77
+ assert.deepEqual(conf.bundle.externalBin, ['binaries/shop'])
78
+
79
+ // The sidecar must be the binary the compile step actually produced, under
80
+ // the triple-suffixed name externalBin resolves.
81
+ const compiled = await readFile(join(outDir, 'shop'))
82
+ const shipped = await readFile(
83
+ join(shellDir, 'binaries', sidecarFileName('shop', result.targetTriple!))
84
+ )
85
+ assert.ok(compiled.equals(shipped))
86
+ assert.notEqual(
87
+ (
88
+ await stat(
89
+ join(
90
+ shellDir,
91
+ 'binaries',
92
+ sidecarFileName('shop', result.targetTriple!)
93
+ )
94
+ )
95
+ ).mode & 0o111,
96
+ 0
97
+ )
98
+ })
99
+
100
+ test('takes an explicit bundle identifier over the derived one', async () => {
101
+ const { buildDir, projectDir } = await builtUnit()
102
+
103
+ await new StandaloneProviderAdapter({
104
+ runtime: 'bun',
105
+ desktop: true,
106
+ projectDir,
107
+ desktopIdentifier: 'com.acme.pos',
108
+ }).deploy({ buildDir, logger: silentLogger })
109
+
110
+ const conf = JSON.parse(
111
+ await readFile(join(projectDir, 'src-tauri', 'tauri.conf.json'), 'utf-8')
112
+ )
113
+ assert.equal(conf.identifier, 'com.acme.pos')
114
+ })
115
+
116
+ test('points a remote shell at the url, with no runtime requirement', async () => {
117
+ const { buildDir, projectDir } = await builtUnit()
118
+
119
+ // Nothing is bundled, so there is no binary to compile and no reason to
120
+ // insist on bun — the node runtime has to be allowed through here.
121
+ const result = await new StandaloneProviderAdapter({
122
+ runtime: 'node',
123
+ desktop: true,
124
+ desktopUrl: 'https://shop.example.com',
125
+ projectDir,
126
+ }).deploy({ buildDir, logger: silentLogger })
127
+
128
+ assert.deepEqual(result.errors, [])
129
+ assert.equal(result.success, true)
130
+
131
+ const shellDir = join(projectDir, 'src-tauri')
132
+ const conf = JSON.parse(
133
+ await readFile(join(shellDir, 'tauri.conf.json'), 'utf-8')
134
+ )
135
+ assert.equal(conf.bundle.externalBin, undefined)
136
+ assert.equal(conf.app.windows[0].url, 'https://shop.example.com')
137
+ await assert.rejects(
138
+ () => stat(join(shellDir, 'binaries')),
139
+ 'a remote shell ships no sidecar'
140
+ )
141
+ })
142
+
143
+ test('refuses a url that is not http(s)', async () => {
144
+ const { buildDir, projectDir } = await builtUnit()
145
+
146
+ const result = await new StandaloneProviderAdapter({
147
+ runtime: 'node',
148
+ desktop: true,
149
+ desktopUrl: 'file:///etc/passwd',
150
+ projectDir,
151
+ }).deploy({ buildDir, logger: silentLogger })
152
+
153
+ assert.equal(result.success, false)
154
+ assert.match(result.errors[0]!.error, /http/i)
155
+ })
156
+
157
+ test('a plain standalone deploy generates no shell at all', async () => {
158
+ const { buildDir, projectDir } = await builtUnit()
159
+
160
+ await new StandaloneProviderAdapter({
161
+ runtime: 'bun',
162
+ projectDir,
163
+ }).deploy({ buildDir, logger: silentLogger })
164
+
165
+ await assert.rejects(() => stat(join(projectDir, 'src-tauri')))
166
+ })
167
+ })
@@ -0,0 +1,13 @@
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 {
9
+ DATA_DIR_ENV,
10
+ PARENT_PID_ENV,
11
+ watchParentProcess,
12
+ } from './parent-watch.js'
13
+ export type { ParentWatch, ParentWatchOptions } from './parent-watch.js'
@@ -0,0 +1,112 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { spawn } from 'node:child_process'
4
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
5
+ import { tmpdir } from 'node:os'
6
+ import { join } from 'node:path'
7
+ import { fileURLToPath, pathToFileURL } from 'node:url'
8
+
9
+ import { PARENT_PID_ENV } from './parent-watch.js'
10
+
11
+ const parentWatchUrl = pathToFileURL(
12
+ fileURLToPath(new URL('./parent-watch.ts', import.meta.url))
13
+ ).href
14
+
15
+ /**
16
+ * A stand-in for the compiled pikku binary: it installs the watch with its real
17
+ * defaults — including `process.exit` — and then holds the event loop open the
18
+ * way a listening server would.
19
+ */
20
+ const SIDECAR = `
21
+ import { watchParentProcess } from ${JSON.stringify(parentWatchUrl)}
22
+ watchParentProcess({ intervalMs: 25 })
23
+ setInterval(() => {}, 1000)
24
+ console.log('sidecar-up')
25
+ `
26
+
27
+ const spawnNode = (args: string[], env?: NodeJS.ProcessEnv) =>
28
+ spawn(process.execPath, args, {
29
+ env: { ...process.env, ...env },
30
+ stdio: ['ignore', 'pipe', 'pipe'],
31
+ })
32
+
33
+ const waitForExit = (child: ReturnType<typeof spawn>, timeoutMs: number) =>
34
+ new Promise<number | null>((resolve, reject) => {
35
+ const timer = setTimeout(
36
+ () => reject(new Error('the sidecar was still running')),
37
+ timeoutMs
38
+ )
39
+ child.once('exit', (code) => {
40
+ clearTimeout(timer)
41
+ resolve(code)
42
+ })
43
+ })
44
+
45
+ const waitForStdout = (child: ReturnType<typeof spawn>, needle: string) =>
46
+ new Promise<void>((resolve, reject) => {
47
+ let seen = ''
48
+ const timer = setTimeout(
49
+ () => reject(new Error(`never printed ${needle}: ${seen}`)),
50
+ 20_000
51
+ )
52
+ child.stdout?.on('data', (chunk) => {
53
+ seen += String(chunk)
54
+ if (seen.includes(needle)) {
55
+ clearTimeout(timer)
56
+ resolve()
57
+ }
58
+ })
59
+ child.stderr?.on('data', (chunk) => (seen += String(chunk)))
60
+ })
61
+
62
+ describe('a sidecar whose shell dies without cleaning up', () => {
63
+ it('exits on its own once the watched process is gone', async () => {
64
+ const dir = await mkdtemp(join(tmpdir(), 'pikku-parent-watch-'))
65
+ const script = join(dir, 'sidecar.mjs')
66
+ await writeFile(script, SIDECAR, 'utf-8')
67
+
68
+ // Stands in for the desktop shell. Killed with SIGKILL, so nothing it
69
+ // might have done on the way out can be what stops the sidecar.
70
+ const shell = spawnNode(['-e', 'setInterval(() => {}, 1000)'])
71
+ const sidecar = spawnNode(['--import', 'tsx', script], {
72
+ [PARENT_PID_ENV]: String(shell.pid),
73
+ })
74
+
75
+ try {
76
+ await waitForStdout(sidecar, 'sidecar-up')
77
+ assert.equal(sidecar.exitCode, null, 'the sidecar must start out running')
78
+
79
+ shell.kill('SIGKILL')
80
+
81
+ const code = await waitForExit(sidecar, 20_000)
82
+ assert.equal(code, 0, 'an orphaned sidecar must exit cleanly')
83
+ } finally {
84
+ shell.kill('SIGKILL')
85
+ sidecar.kill('SIGKILL')
86
+ await rm(dir, { recursive: true, force: true })
87
+ }
88
+ })
89
+
90
+ it('keeps running when no shell pid was handed down', async () => {
91
+ const dir = await mkdtemp(join(tmpdir(), 'pikku-parent-watch-'))
92
+ const script = join(dir, 'sidecar.mjs')
93
+ await writeFile(script, SIDECAR, 'utf-8')
94
+
95
+ const sidecar = spawnNode(['--import', 'tsx', script], {
96
+ [PARENT_PID_ENV]: undefined,
97
+ })
98
+
99
+ try {
100
+ await waitForStdout(sidecar, 'sidecar-up')
101
+ await new Promise((resolve) => setTimeout(resolve, 300))
102
+ assert.equal(
103
+ sidecar.exitCode,
104
+ null,
105
+ 'a server run from a terminal must not exit'
106
+ )
107
+ } finally {
108
+ sidecar.kill('SIGKILL')
109
+ await rm(dir, { recursive: true, force: true })
110
+ }
111
+ })
112
+ })