@octane-xplat/workshop 0.0.0-nightly.g14544fe

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