@octane-xplat/workshop 0.1.0 → 0.15.0

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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/MOBILE.md +143 -0
  3. package/README.md +305 -1
  4. package/dist/node/index.js +50 -0
  5. package/package.json +118 -5
  6. package/src/catalog.ts +58 -0
  7. package/src/contract.ts +257 -0
  8. package/src/controls.ts +159 -0
  9. package/src/discover.mjs +584 -0
  10. package/src/errors.ts +47 -0
  11. package/src/fixture.ts +158 -0
  12. package/src/index.ts +6 -0
  13. package/src/macos/controller.ts +170 -0
  14. package/src/macos/index.macos.ts +2 -0
  15. package/src/macos/shell.macos.mjs +673 -0
  16. package/src/macos/stage.macos.ts +182 -0
  17. package/src/macos-node.mjs +95 -0
  18. package/src/macos-vite.mjs +47 -0
  19. package/src/mobile/controller.ts +134 -0
  20. package/src/mobile/entry.mobile.ts +607 -0
  21. package/src/mobile/index.mobile.ts +3 -0
  22. package/src/mobile/mount.mobile.ts +60 -0
  23. package/src/mobile/tree.mobile.tsrx +17 -0
  24. package/src/mobile-node.mjs +126 -0
  25. package/src/mobile-regex.mjs +60 -0
  26. package/src/node.d.ts +65 -0
  27. package/src/node.mjs +536 -0
  28. package/src/session.ts +520 -0
  29. package/src/vite-mobile.d.ts +21 -0
  30. package/src/vite-mobile.mjs +101 -0
  31. package/src/vite.d.ts +11 -0
  32. package/src/vite.mjs +315 -0
  33. package/src/web/entry.web.ts +270 -0
  34. package/src/web/index.web.ts +7 -0
  35. package/src/web/mount.web.ts +86 -0
  36. package/src/web/shell.css +449 -0
  37. package/src/web/shell.web.tsrx +759 -0
  38. package/src/windows/preflight.mjs +113 -0
  39. package/types/generated/.tsrx-typegen-manifest.json +24 -0
  40. package/types/generated/catalog.d.ts +30 -0
  41. package/types/generated/contract.d.ts +145 -0
  42. package/types/generated/controls.d.ts +28 -0
  43. package/types/generated/errors.d.ts +13 -0
  44. package/types/generated/fixture.d.ts +53 -0
  45. package/types/generated/index.d.ts +6 -0
  46. package/types/generated/macos/controller.d.ts +52 -0
  47. package/types/generated/macos/index.macos.d.ts +2 -0
  48. package/types/generated/macos/stage.macos.d.ts +10 -0
  49. package/types/generated/mobile/controller.d.ts +31 -0
  50. package/types/generated/mobile/entry.mobile.d.ts +22 -0
  51. package/types/generated/mobile/index.mobile.d.ts +3 -0
  52. package/types/generated/mobile/mount.mobile.d.ts +4 -0
  53. package/types/generated/mobile/tree.mobile.d.ts +4 -0
  54. package/types/generated/session.d.ts +84 -0
  55. package/types/generated/web/entry.web.d.ts +53 -0
  56. package/types/generated/web/index.web.d.ts +7 -0
  57. package/types/generated/web/mount.web.d.ts +2 -0
  58. package/types/generated/web/shell.web.d.ts +4 -0
package/src/node.mjs ADDED
@@ -0,0 +1,536 @@
1
+ /**
2
+ * Node backend for `xplat workshop …` — the CLI resolves this module from
3
+ * the consumer's installed @octane-xplat/workshop and delegates.
4
+ */
5
+ import { spawn, spawnSync } from 'node:child_process'
6
+ import { createRequire } from 'node:module'
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
8
+
9
+ import { dirname, join, relative, resolve } from 'node:path'
10
+ import { pathToFileURL } from 'node:url'
11
+ import { realpathSync } from 'node:fs'
12
+ import { discoverStories } from './discover.mjs'
13
+ import { windowsPreflight, windowsLaunchBlocker } from './windows/preflight.mjs'
14
+ import { xplatWorkshop } from './vite.mjs'
15
+ import { launchMobile, mobilePrerequisites } from './mobile-node.mjs'
16
+ import { xplatBoundary, xplatDevBridge, xplatNodeEnvDefine } from '@octane-xplat/cli/vite'
17
+
18
+ const CONFIG_NAMES = [
19
+ 'workshop.config.ts',
20
+ 'workshop.config.mts',
21
+ 'workshop.config.mjs',
22
+ 'workshop.config.js',
23
+ ]
24
+
25
+ const importFrom = (cwd, specifier) => {
26
+ const req = createRequire(join(cwd, 'package.json'))
27
+ return import(pathToFileURL(realpathSync(req.resolve(specifier))).href)
28
+ }
29
+
30
+ export class WorkshopUsageError extends Error {
31
+ name = 'WorkshopUsageError'
32
+ }
33
+
34
+ function consumerRoot(cwd) {
35
+ const dir = resolve(cwd)
36
+ if (!existsSync(join(dir, 'package.json'))) {
37
+ throw new WorkshopUsageError(`no package.json at ${dir} — run inside a consumer app`)
38
+ }
39
+
40
+ return dir
41
+ }
42
+
43
+ export function findConfigFile(root) {
44
+ for (const name of CONFIG_NAMES) {
45
+ const file = join(root, name)
46
+ if (existsSync(file)) {
47
+ return file
48
+ }
49
+ }
50
+
51
+ return null
52
+ }
53
+
54
+ export async function loadWorkshopConfig(root) {
55
+ const file = findConfigFile(root)
56
+ if (!file) {
57
+ return { config: {}, file: null }
58
+ }
59
+
60
+ const { loadConfigFromFile } = await importFrom(root, 'vite')
61
+ const result = await loadConfigFromFile({ command: 'serve', mode: 'development' }, file)
62
+
63
+ return { config: result.config?.default ?? result.config ?? {}, file }
64
+ }
65
+
66
+ // ---------- list ----------
67
+
68
+ export async function workshopList({ cwd, target = 'web', json = false } = {}) {
69
+ const root = consumerRoot(cwd)
70
+ const { config } = await loadWorkshopConfig(root)
71
+ const { entries, diagnostics } = discoverStories(root, {
72
+ include: config.stories?.include,
73
+ exclude: config.stories?.exclude,
74
+ target,
75
+ })
76
+
77
+ if (json) {
78
+ console.log(JSON.stringify({ target, entries, diagnostics }, null, 2))
79
+ return { entries, diagnostics }
80
+ }
81
+
82
+ console.log(`workshop stories (target: ${target})`)
83
+ if (!entries.length) {
84
+ console.log(' (none)')
85
+ }
86
+
87
+ for (const entry of entries) {
88
+ const mark = entry.applicable ? ' ' : '!'
89
+ const reason = entry.applicable ? '' : ` — ${entry.reason}`
90
+ const caps = entry.capabilities.length ? ` [${entry.capabilities.join(', ')}]` : ''
91
+ console.log(`${mark} ${entry.id.padEnd(32)} ${entry.title}${caps}${reason}`)
92
+ }
93
+
94
+ for (const diagnostic of diagnostics) {
95
+ console.error(`! ${relative(root, diagnostic.file)}: ${diagnostic.message}`)
96
+ }
97
+
98
+ if (diagnostics.length) {
99
+ throw new WorkshopUsageError(`${diagnostics.length} discovery diagnostic(s)`)
100
+ }
101
+
102
+ return { entries, diagnostics }
103
+ }
104
+
105
+ // ---------- doctor ----------
106
+
107
+ const resolveFrom = (cwd, specifier) => {
108
+ try {
109
+ const req = createRequire(join(cwd, 'package.json'))
110
+ return req.resolve(specifier)
111
+ } catch {
112
+ return null
113
+ }
114
+ }
115
+
116
+ export async function workshopDoctor({ cwd, json = false } = {}) {
117
+ const root = consumerRoot(cwd)
118
+ const report = { root, targets: {} }
119
+
120
+ const webRequirements = {
121
+ vite: Boolean(resolveFrom(root, 'vite')),
122
+ '@octanejs/vite-plugin': Boolean(resolveFrom(root, '@octanejs/vite-plugin')),
123
+ octane: Boolean(resolveFrom(root, 'octane')),
124
+ '@octane-xplat/ui': Boolean(resolveFrom(root, '@octane-xplat/ui')),
125
+ '@octane-xplat/workshop': Boolean(resolveFrom(root, '@octane-xplat/workshop')),
126
+ }
127
+
128
+ const configFile = findConfigFile(root)
129
+ report.targets.web = {
130
+ backend: 'octane-dom (vite)',
131
+ requirements: { ...webRequirements, 'workshop.config': Boolean(configFile) },
132
+ status: Object.values(webRequirements).every(Boolean) ? 'available' : 'missing-prerequisites',
133
+ }
134
+
135
+ for (const target of ['ios', 'android']) {
136
+ report.targets[target] = mobilePrerequisites(root, target)
137
+ }
138
+
139
+ const macosRequirements = {
140
+ 'darwin-arm64 host': process.platform === 'darwin' && process.arch === 'arm64',
141
+ '@octane-xplat/macos-renderer': Boolean(resolveFrom(root, '@octane-xplat/macos-renderer')),
142
+ '@nativescript/macos-node-api': Boolean(resolveFrom(root, '@nativescript/macos-node-api')),
143
+ '@octane-xplat/cli': Boolean(resolveFrom(root, '@octane-xplat/cli/macos')),
144
+ octane: Boolean(resolveFrom(root, 'octane')),
145
+ '@octanejs/vite-plugin': Boolean(resolveFrom(root, '@octanejs/vite-plugin')),
146
+ vite: Boolean(resolveFrom(root, 'vite')),
147
+ 'workshop.config': Boolean(configFile),
148
+ }
149
+
150
+ report.targets.macos = {
151
+ backend: 'appkit/jsc renderer',
152
+ requirements: macosRequirements,
153
+ status: Object.values(macosRequirements).every(Boolean)
154
+ ? 'available'
155
+ : macosRequirements['darwin-arm64 host']
156
+ ? 'missing-prerequisites'
157
+ : 'unsupported-host',
158
+ note: "the AppKit workshop needs the app's own macOS renderer and node-api versions",
159
+ }
160
+
161
+ report.targets.windows = windowsPreflight(root)
162
+
163
+ report.targets.linux = linuxDoctor(root, webRequirements, configFile)
164
+
165
+ if (json) {
166
+ console.log(JSON.stringify(report, null, 2))
167
+ return report
168
+ }
169
+
170
+ console.log(`workshop doctor — ${root}`)
171
+ for (const [target, info] of Object.entries(report.targets)) {
172
+ console.log(` ${target.padEnd(8)} ${info.status.padEnd(22)} ${info.backend}`)
173
+ for (const [req, ok] of Object.entries(info.requirements)) {
174
+ if (!ok) {
175
+ console.log(` missing: ${req}`)
176
+ }
177
+ }
178
+
179
+ if (info.note) {
180
+ console.log(` ${info.note}`)
181
+ }
182
+ }
183
+
184
+ return report
185
+ }
186
+
187
+ // ---------- init ----------
188
+
189
+ const CONFIG_TEMPLATE = `import { defineWorkshopConfig } from '@octane-xplat/workshop'
190
+
191
+ export default defineWorkshopConfig({
192
+ // Story modules are discovered statically — id/title/targets must be
193
+ // literal values. Platform leaves like counter.stories.ios.tsrx stay out
194
+ // of incompatible bundles.
195
+ stories: {
196
+ include: [
197
+ 'src/**/*.stories.{ts,tsx,tsrx}',
198
+ 'src/**/*.stories.{web,mobile,ios,android,macos,windows,linux}.{ts,tsx,tsrx}',
199
+ ],
200
+ },
201
+ // Optional: modules exporting a providers array applied around every story.
202
+ // providerModules: ['./src/demo-providers.tsrx'],
203
+ })
204
+ `
205
+
206
+ const STORY_TEMPLATE = `import { Pressable, Text } from '@octane-xplat/ui'
207
+ import { useStore, type Store } from '@octane-xplat/ui/data'
208
+ import { defineStory } from '@octane-xplat/workshop'
209
+
210
+ type Props = { step: number }
211
+ type State = { count: number }
212
+ type Fixture = { account: string }
213
+
214
+ function WorkshopCounter(p: Props & { state: Store<State>; fixture: Fixture }) {
215
+ const { count } = useStore(p.state)
216
+ return (
217
+ <Pressable
218
+ accessibilityLabel="Increment"
219
+ onPress={() => p.state.update((s) => ({ count: s.count + p.step }))}
220
+ >
221
+ <Text>{p.fixture.account}: {count}</Text>
222
+ </Pressable>
223
+ )
224
+ }
225
+
226
+ export const Basic = defineStory<Props, State, Fixture>({
227
+ id: 'workshop/counter-basic',
228
+ title: 'Workshop / Counter',
229
+ targets: 'all',
230
+ component: WorkshopCounter,
231
+ args: { step: 1 },
232
+ controls: { step: { type: 'number', min: 1, max: 10 } },
233
+ state: () => ({ count: 0 }),
234
+ fixture: () => ({ account: 'Demo' }),
235
+ })
236
+ `
237
+
238
+ export async function workshopInit({ cwd, scripts = true } = {}) {
239
+ const root = consumerRoot(cwd)
240
+ const created = []
241
+ const skipped = []
242
+
243
+ const writeIfAbsent = (file, contents) => {
244
+ if (existsSync(file)) {
245
+ const existing = readFileSync(file, 'utf8')
246
+ skipped.push(
247
+ existing === contents
248
+ ? `${relative(root, file)} already up to date`
249
+ : `${relative(root, file)} exists with different content — left untouched; reconcile it manually`,
250
+ )
251
+
252
+ return false
253
+ }
254
+
255
+ mkdirSync(dirname(file), { recursive: true })
256
+ writeFileSync(file, contents)
257
+ created.push(relative(root, file))
258
+ return true
259
+ }
260
+
261
+ writeIfAbsent(join(root, 'workshop.config.ts'), CONFIG_TEMPLATE)
262
+ writeIfAbsent(join(root, 'src', 'workshop', 'counter.stories.tsrx'), STORY_TEMPLATE)
263
+
264
+ if (scripts) {
265
+ const manifestPath = join(root, 'package.json')
266
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
267
+ manifest.scripts = manifest.scripts ?? {}
268
+ if (!manifest.scripts.workshop) {
269
+ manifest.scripts.workshop = 'xplat workshop dev --target web'
270
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
271
+ created.push('package.json script "workshop"')
272
+ } else {
273
+ skipped.push('package.json script "workshop" already exists')
274
+ }
275
+ }
276
+
277
+ const hasWorkshop = Boolean(resolveFrom(root, '@octane-xplat/workshop'))
278
+ for (const item of created) {
279
+ console.log(` created ${item}`)
280
+ }
281
+
282
+ for (const item of skipped) {
283
+ console.log(` skipped ${item}`)
284
+ }
285
+
286
+ if (!hasWorkshop) {
287
+ console.log(' next: pnpm add -D @octane-xplat/workshop')
288
+ }
289
+
290
+ console.log(' run: pnpm exec xplat workshop dev --target web')
291
+ return { created, skipped }
292
+ }
293
+
294
+ // ---------- linux host checks ----------
295
+
296
+ const gjsProbe = (args) => {
297
+ try {
298
+ return spawnSync('gjs', args, { encoding: 'utf8', timeout: 10_000 }).status === 0
299
+ } catch {
300
+ return false
301
+ }
302
+ }
303
+
304
+ const GJS_TYPELIB_PROBE =
305
+ 'imports.gi.versions.Gtk="4.0";imports.gi.versions.WebKit="6.0";imports.gi.versions.Adw="1";' +
306
+ 'void imports.gi.Gtk;void imports.gi.WebKit;void imports.gi.Adw'
307
+
308
+ /** Linux host prerequisites as a missing-item list (empty when satisfied). */
309
+ export function linuxMissingPrerequisites() {
310
+ const missing = []
311
+ if (!gjsProbe(['--version'])) {
312
+ missing.push('gjs')
313
+ } else if (!gjsProbe(['-c', GJS_TYPELIB_PROBE])) {
314
+ missing.push('GTK4/WebKitGTK/Adwaita typelibs (gir1.2-webkit-6.0, gir1.2-adw-1)')
315
+ }
316
+
317
+ return missing
318
+ }
319
+
320
+ /** The published `@octane-xplat/ui` map needs a `linux` condition or packed
321
+ * consumers silently fall back to the web build, losing openWindow and
322
+ * colorScheme host overrides. */
323
+ function uiHasLinuxExport(root) {
324
+ const manifest = resolveFrom(root, '@octane-xplat/ui/package.json')
325
+ if (!manifest) {
326
+ return false
327
+ }
328
+
329
+ try {
330
+ return Boolean(JSON.parse(readFileSync(manifest, 'utf8')).exports?.['.']?.linux)
331
+ } catch {
332
+ return false
333
+ }
334
+ }
335
+
336
+ function linuxDoctor(root, sharedRequirements, configFile) {
337
+ const backend = 'gtk4/webkitgtk dom host (gjs)'
338
+ if (process.platform !== 'linux') {
339
+ return {
340
+ backend,
341
+ requirements: {},
342
+ status: 'remote-host-required',
343
+ note: "Linux runs on the qualified GTK/WebKitGTK runtime — run `xplat workshop dev --target linux` in that host's checkout",
344
+ }
345
+ }
346
+
347
+ const requirements = {
348
+ ...sharedRequirements,
349
+ 'workshop.config': Boolean(configFile),
350
+ gjs: gjsProbe(['--version']),
351
+ 'GTK4/WebKitGTK/Adwaita typelibs': gjsProbe(['-c', GJS_TYPELIB_PROBE]),
352
+ '@octane-xplat/ui linux export': uiHasLinuxExport(root),
353
+ }
354
+
355
+ return {
356
+ backend,
357
+ requirements,
358
+ status: Object.values(requirements).every(Boolean) ? 'available' : 'missing-prerequisites',
359
+ }
360
+ }
361
+
362
+ // ---------- dev ----------
363
+
364
+ /**
365
+ * Shared dev-server seam for every DOM target — the caller owns process
366
+ * lifecycle (signal handlers, host subprocess). Exported so qualification
367
+ * harnesses can boot the exact production wiring.
368
+ */
369
+ export async function startWorkshopServer({ root, config, target, story, port } = {}) {
370
+ const [{ createServer, searchForWorkspaceRoot }, { octane }] = await Promise.all([
371
+ importFrom(root, 'vite'),
372
+ importFrom(root, '@octanejs/vite-plugin'),
373
+ ])
374
+
375
+ const assetRoots = Object.values(config.assets ?? {}).map((dir) => resolve(root, dir))
376
+ const server = await createServer({
377
+ root,
378
+ configFile: false,
379
+ mode: 'development',
380
+ logLevel: 'info',
381
+ plugins: [
382
+ // The workshop's 'pre' hotUpdate must run before the octane plugin's
383
+ // own 'pre' hook, so the story catalog answers edits before octane
384
+ // can force a full page reload.
385
+ ...xplatWorkshop({ cwd: root, config, target, story }),
386
+ ...octane(),
387
+ xplatBoundary(target),
388
+ xplatDevBridge(),
389
+ ],
390
+ define: xplatNodeEnvDefine('development'),
391
+ server: {
392
+ ...(port ? { port, strictPort: true } : {}),
393
+ fs: {
394
+ allow: [...new Set([searchForWorkspaceRoot(root), root, ...assetRoots].filter(Boolean))],
395
+ },
396
+ },
397
+ })
398
+
399
+ await server.listen()
400
+ const address = server.httpServer?.address()
401
+ const resolvedPort = typeof address === 'object' && address ? address.port : port
402
+ const url = `http://localhost:${resolvedPort}/`
403
+ console.log(`WORKSHOP_READY ${url}`)
404
+ server.printUrls()
405
+ return { server, url }
406
+ }
407
+
408
+ export async function workshopDev({ cwd, target = 'web', story, port, device } = {}) {
409
+ const root = consumerRoot(cwd)
410
+ if (target === 'macos') {
411
+ const { config } = await loadWorkshopConfig(root)
412
+ const { workshopMacOSDev } = await import('./macos-node.mjs')
413
+ return workshopMacOSDev({ root, config, story })
414
+ }
415
+
416
+ if (target === 'ios' || target === 'android') {
417
+ if (port !== undefined) {
418
+ throw new WorkshopUsageError(
419
+ 'Mobile workshop uses the consumer native Vite server port; configure it in the native Vite config',
420
+ )
421
+ }
422
+
423
+ const prerequisites = mobilePrerequisites(root, target)
424
+ if (prerequisites.status !== 'unqualified-backend') {
425
+ throw new WorkshopUsageError(
426
+ `Mobile workshop ${target}: ${prerequisites.status}. Run xplat workshop doctor and follow @octane-xplat/workshop/MOBILE.md setup.`,
427
+ )
428
+ }
429
+
430
+ const { config } = await loadWorkshopConfig(root)
431
+ try {
432
+ return await launchMobile({ root, target, device, story, config })
433
+ } catch (error) {
434
+ throw new WorkshopUsageError(error.message)
435
+ }
436
+ }
437
+
438
+ if (target === 'windows') {
439
+ throw new WorkshopUsageError(windowsLaunchBlocker(windowsPreflight(root)))
440
+ }
441
+
442
+ if (target !== 'web' && target !== 'linux') {
443
+ throw new WorkshopUsageError(
444
+ `xplat workshop dev --target ${target}: this workshop preview provides web, linux, macos, ios and android launch paths — ` +
445
+ `run \`xplat workshop doctor\` for per-target status`,
446
+ )
447
+ }
448
+
449
+ const gjsHost = target === 'linux' ? checkLinuxHost(root) : null
450
+ const { config } = await loadWorkshopConfig(root)
451
+ const { server, url } = await startWorkshopServer({ root, config, target, story, port })
452
+
453
+ if (target === 'linux') {
454
+ return runLinuxHost({ server, url, gjsHost })
455
+ }
456
+
457
+ const shutdown = async () => {
458
+ await server.close()
459
+ process.exit(0)
460
+ }
461
+
462
+ process.on('SIGINT', shutdown)
463
+ process.on('SIGTERM', shutdown)
464
+ return server
465
+ }
466
+
467
+ /** Gate the Linux launch before the dev server binds: the GTK host runs on
468
+ * Linux only, needs gjs + the WebKitGTK/Adwaita typelibs, and launches the
469
+ * canonical host shipped in @octane-xplat/cli. Returns the host script path. */
470
+ function checkLinuxHost(root) {
471
+ if (process.platform !== 'linux') {
472
+ throw new WorkshopUsageError(
473
+ 'xplat workshop dev --target linux: the GTK4/WebKitGTK host runs on Linux only — ' +
474
+ "run this command in the Linux host's own checkout. `xplat workshop doctor` reports per-target status",
475
+ )
476
+ }
477
+
478
+ const missing = linuxMissingPrerequisites()
479
+ if (missing.length) {
480
+ throw new WorkshopUsageError(
481
+ `xplat workshop dev --target linux: missing host prerequisites: ${missing.join(', ')} — ` +
482
+ 'on Debian/Ubuntu install: gjs libgtk-4-1 libadwaita-1-0 libwebkitgtk-6.0-4 gir1.2-webkit-6.0 gir1.2-adw-1',
483
+ )
484
+ }
485
+
486
+ const cliManifest = resolveFrom(root, '@octane-xplat/cli/package.json')
487
+ const gjsHost = cliManifest
488
+ ? join(dirname(cliManifest), 'src', 'linux', 'host', 'gjs-host.js')
489
+ : null
490
+
491
+ if (!gjsHost || !existsSync(gjsHost)) {
492
+ throw new WorkshopUsageError(
493
+ 'xplat workshop dev --target linux: cannot find the GJS host in @octane-xplat/cli ' +
494
+ '(expected cli/src/linux/host/gjs-host.js) — update @octane-xplat/cli',
495
+ )
496
+ }
497
+
498
+ return gjsHost
499
+ }
500
+
501
+ /** Launch the canonical GJS/WebKitGTK host at the dev server and own both
502
+ * halves' lifecycle: closing the host window stops the server, a dying
503
+ * server kills the host, and signals shut both down. */
504
+ async function runLinuxHost({ server, url, gjsHost }) {
505
+ // A distinct application id keeps workshop media-permission/config state
506
+ // out of the app under development's directories.
507
+ const host = spawn('gjs', [gjsHost, url], {
508
+ stdio: 'inherit',
509
+ env: { ...process.env, XPLAT_PROBE_APP_ID: 'org.octane.xplat.workshop' },
510
+ })
511
+
512
+ let exiting = false
513
+ const shutdown = async (code = 0) => {
514
+ if (exiting) {
515
+ return
516
+ }
517
+
518
+ exiting = true
519
+ if (host.exitCode === null && !host.killed) {
520
+ host.kill()
521
+ }
522
+
523
+ await server.close().catch(() => {})
524
+ process.exit(code)
525
+ }
526
+
527
+ host.on('exit', (code) => void shutdown(code ?? 0))
528
+ host.on('error', async (error) => {
529
+ console.error(`xplat workshop: failed to launch the Linux host — ${error.message}`)
530
+ await shutdown(1)
531
+ })
532
+
533
+ process.on('SIGINT', () => void shutdown(0))
534
+ process.on('SIGTERM', () => void shutdown(0))
535
+ return server
536
+ }