@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.
- package/CHANGELOG.md +41 -0
- package/dist/adapter.d.ts +34 -0
- package/dist/adapter.js +184 -4
- package/dist/runtime/index.d.ts +9 -0
- package/dist/runtime/index.js +8 -0
- package/dist/runtime/parent-watch.d.ts +45 -0
- package/dist/runtime/parent-watch.js +87 -0
- package/dist/tauri/generate.d.ts +45 -0
- package/dist/tauri/generate.js +230 -0
- package/dist/tauri/icon.d.ts +1 -0
- package/dist/tauri/icon.js +54 -0
- package/dist/tauri/main-rs.d.ts +31 -0
- package/dist/tauri/main-rs.js +213 -0
- package/dist/tauri/next-steps.d.ts +15 -0
- package/dist/tauri/next-steps.js +16 -0
- package/dist/tauri/target-triple.d.ts +29 -0
- package/dist/tauri/target-triple.js +42 -0
- package/knowledge/decisions/a-pikku-server-serves-a-static-frontend.md +36 -0
- package/knowledge/decisions/a-remote-desktop-shell-bundles-nothing.md +38 -0
- package/knowledge/decisions/deploy-consumes-a-built-frontend.md +33 -0
- package/knowledge/decisions/desktop-builds-are-unsigned-and-never-update-themselves.md +34 -0
- package/knowledge/decisions/index.md +19 -0
- package/knowledge/decisions/standalone-assets-are-embedded-in-the-bun-binary.md +39 -0
- package/knowledge/decisions/the-desktop-shell-runs-the-server-as-a-sidecar.md +51 -0
- package/knowledge/decisions/the-sidecar-reports-its-port-the-shell-never-picks-one.md +44 -0
- package/knowledge/index.md +22 -0
- package/package.json +6 -4
- package/src/adapter.test.ts +186 -0
- package/src/adapter.ts +210 -4
- package/src/desktop-deploy.test.ts +167 -0
- package/src/runtime/index.ts +13 -0
- package/src/runtime/parent-watch.process.test.ts +112 -0
- package/src/runtime/parent-watch.test.ts +148 -0
- package/src/runtime/parent-watch.ts +115 -0
- package/src/sidecar-entry.test.ts +89 -0
- package/src/tauri/generate.test.ts +401 -0
- package/src/tauri/generate.ts +327 -0
- package/src/tauri/icon.test.ts +63 -0
- package/src/tauri/icon.ts +62 -0
- package/src/tauri/main-rs.rustfmt.test.ts +86 -0
- package/src/tauri/main-rs.ts +241 -0
- package/src/tauri/next-steps.test.ts +38 -0
- package/src/tauri/next-steps.ts +30 -0
- package/src/tauri/target-triple.test.ts +84 -0
- package/src/tauri/target-triple.ts +65 -0
- 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
|
-
|
|
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
|
+
})
|