@bespokeagentics/create-microdots-host 0.1.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 (60) hide show
  1. package/LICENSE +202 -0
  2. package/package.json +26 -0
  3. package/src/cli.ts +50 -0
  4. package/src/index.ts +215 -0
  5. package/templates/base/README.md +49 -0
  6. package/templates/base/_gitignore +7 -0
  7. package/templates/base/host/index.html +33 -0
  8. package/templates/base/host/package.json +10 -0
  9. package/templates/base/host/public/themes/neutral-dark.css +109 -0
  10. package/templates/base/host/public/themes/neutral.css +109 -0
  11. package/templates/base/host/src/main.ts +202 -0
  12. package/templates/base/host/src/topology.test.ts +42 -0
  13. package/templates/base/host/topology.json +84 -0
  14. package/templates/base/host/vite.base.ts +41 -0
  15. package/templates/base/microdots/pulse/package.json +20 -0
  16. package/templates/base/microdots/pulse/service/handlers.ts +36 -0
  17. package/templates/base/microdots/pulse/service/main.ts +30 -0
  18. package/templates/base/microdots/pulse/src/app.ts +275 -0
  19. package/templates/base/microdots/pulse/src/client.ts +14 -0
  20. package/templates/base/microdots/pulse/src/contract.ts +32 -0
  21. package/templates/base/microdots/pulse/src/element.ts +24 -0
  22. package/templates/base/microdots/pulse/src/entry.ts +1 -0
  23. package/templates/base/microdots/pulse/src/surface.ts +30 -0
  24. package/templates/base/microdots/pulse/src/tokens.css +30 -0
  25. package/templates/base/microdots/scope-picker/package.json +20 -0
  26. package/templates/base/microdots/scope-picker/service/handlers.ts +15 -0
  27. package/templates/base/microdots/scope-picker/service/main.ts +30 -0
  28. package/templates/base/microdots/scope-picker/src/app.ts +256 -0
  29. package/templates/base/microdots/scope-picker/src/client.ts +18 -0
  30. package/templates/base/microdots/scope-picker/src/contract.ts +27 -0
  31. package/templates/base/microdots/scope-picker/src/element.ts +21 -0
  32. package/templates/base/microdots/scope-picker/src/entry.ts +1 -0
  33. package/templates/base/microdots/scope-picker/src/surface.ts +23 -0
  34. package/templates/base/microdots/scope-picker/src/tokens.css +30 -0
  35. package/templates/base/scripts/build-output.test.ts +71 -0
  36. package/templates/base/scripts/build-output.ts +28 -0
  37. package/templates/base/scripts/build.ts +37 -0
  38. package/templates/base/scripts/check-tokens.ts +63 -0
  39. package/templates/base/scripts/dev.ts +78 -0
  40. package/templates/base/scripts/discover.test.ts +133 -0
  41. package/templates/base/scripts/discover.ts +137 -0
  42. package/templates/base/scripts/dot-vite.ts +28 -0
  43. package/templates/base/scripts/emit-manifests.ts +154 -0
  44. package/templates/base/tsconfig.json +18 -0
  45. package/templates/base/types.d.ts +9 -0
  46. package/templates/base/vitest.config.ts +8 -0
  47. package/templates/plain/host/src/styles.css +46 -0
  48. package/templates/plain/host/vite.config.template.ts +4 -0
  49. package/templates/plain/microdots/pulse/src/styles.css +17 -0
  50. package/templates/plain/microdots/pulse/vite.config.template.ts +4 -0
  51. package/templates/plain/microdots/scope-picker/src/styles.css +19 -0
  52. package/templates/plain/microdots/scope-picker/vite.config.template.ts +4 -0
  53. package/templates/plain/package.json +39 -0
  54. package/templates/tailwind/host/src/styles.css +28 -0
  55. package/templates/tailwind/host/vite.config.template.ts +5 -0
  56. package/templates/tailwind/microdots/pulse/src/styles.css +24 -0
  57. package/templates/tailwind/microdots/pulse/vite.config.template.ts +5 -0
  58. package/templates/tailwind/microdots/scope-picker/src/styles.css +25 -0
  59. package/templates/tailwind/microdots/scope-picker/vite.config.template.ts +5 -0
  60. package/templates/tailwind/package.json +41 -0
@@ -0,0 +1,256 @@
1
+ import { Duration, Effect, Schema as S, Stream } from 'effect'
2
+ import { Command, Port, Subscription } from 'foldkit'
3
+ import type { Html, HtmlBuilder } from 'foldkit/html'
4
+ import { m } from 'foldkit/message'
5
+ import { makeElement } from 'foldkit/runtime'
6
+ import { ts } from 'foldkit/schema'
7
+
8
+ import { ScopePickerClient, scopePickerClientLive } from './client.ts'
9
+ import { ScopeList, ScopeOption, ScopeSelected } from './contract.ts'
10
+
11
+ export const POLL_INTERVAL_MS = 10_000
12
+
13
+ const Loading = ts('Loading')
14
+ const Ready = ts('Ready', { value: ScopeList, stale: S.Boolean })
15
+ const Failed = ts('Failed', { error: S.String, last: S.UndefinedOr(ScopeList) })
16
+ const Unauthorized = ts('Unauthorized')
17
+ const RemoteScopes = S.Union([Loading, Ready, Failed, Unauthorized])
18
+ type RemoteScopes = typeof RemoteScopes.Type
19
+
20
+ const Model = S.Struct({
21
+ scopes: RemoteScopes,
22
+ selectedId: S.String,
23
+ apiUrl: S.String,
24
+ pollIntervalMs: S.Number,
25
+ polls: S.Number,
26
+ })
27
+ type Model = typeof Model.Type
28
+
29
+ const PollTicked = m('PollTicked')
30
+ const ScopesReceived = m('ScopesReceived', { scopes: ScopeList })
31
+ const ScopesFailed = m('ScopesFailed', { error: S.String })
32
+ const ScopeChosen = m('ScopeChosen', { scope: ScopeOption })
33
+ const ScopePublished = m('ScopePublished')
34
+ const RefreshRequested = m('RefreshRequested')
35
+ const Message = S.Union([
36
+ PollTicked,
37
+ ScopesReceived,
38
+ ScopesFailed,
39
+ ScopeChosen,
40
+ ScopePublished,
41
+ RefreshRequested,
42
+ ])
43
+ type Message = typeof Message.Type
44
+
45
+ export const ports = {
46
+ inbound: {},
47
+ outbound: { scopeSelected: Port.outbound(ScopeSelected) },
48
+ }
49
+
50
+ const FetchScopes = Command.define('FetchScopes', {
51
+ args: {},
52
+ messages: [ScopesReceived, ScopesFailed],
53
+ execute: () =>
54
+ Effect.gen(function* () {
55
+ const client = yield* ScopePickerClient
56
+ const scopes = yield* client.ListScopes()
57
+ return ScopesReceived({ scopes })
58
+ }).pipe(
59
+ Effect.catch(error =>
60
+ Effect.succeed(
61
+ ScopesFailed({
62
+ error:
63
+ error._tag === 'ScopesUnavailable'
64
+ ? error.reason
65
+ : `could not reach the service (${error._tag})`,
66
+ }),
67
+ ),
68
+ ),
69
+ ),
70
+ })
71
+
72
+ const PublishScope = Command.define('PublishScope', {
73
+ args: { scope: ScopeOption },
74
+ messages: [ScopePublished],
75
+ execute: ({ scope }) =>
76
+ Port.emit(ports.outbound.scopeSelected, scope).pipe(
77
+ Effect.as(ScopePublished()),
78
+ ),
79
+ })
80
+
81
+ const refreshing = (remote: RemoteScopes): RemoteScopes =>
82
+ remote._tag === 'Ready' ? Ready({ value: remote.value, stale: true }) : remote
83
+
84
+ const lastGood = (remote: RemoteScopes): ScopeList | undefined =>
85
+ remote._tag === 'Ready'
86
+ ? remote.value
87
+ : remote._tag === 'Failed'
88
+ ? remote.last
89
+ : undefined
90
+
91
+ type UpdateResult = readonly [
92
+ Model,
93
+ ReadonlyArray<Command.Command<Message, never, ScopePickerClient>>,
94
+ ]
95
+
96
+ const update = (model: Model, message: Message): UpdateResult => {
97
+ switch (message._tag) {
98
+ case 'PollTicked':
99
+ case 'RefreshRequested':
100
+ return [{ ...model, scopes: refreshing(model.scopes) }, [FetchScopes({})]]
101
+ case 'ScopesReceived':
102
+ return [
103
+ {
104
+ ...model,
105
+ scopes: Ready({ value: message.scopes, stale: false }),
106
+ polls: model.polls + 1,
107
+ },
108
+ [],
109
+ ]
110
+ case 'ScopesFailed':
111
+ return [
112
+ {
113
+ ...model,
114
+ scopes: Failed({
115
+ error: message.error,
116
+ last: lastGood(model.scopes),
117
+ }),
118
+ },
119
+ [],
120
+ ]
121
+ case 'ScopeChosen':
122
+ return [
123
+ { ...model, selectedId: message.scope.id },
124
+ [PublishScope({ scope: message.scope })],
125
+ ]
126
+ case 'ScopePublished':
127
+ return [model, []]
128
+ }
129
+ }
130
+
131
+ const subscriptions = Subscription.make<Model, Message, ScopePickerClient>()(
132
+ entry => ({
133
+ poll: entry(
134
+ { pollIntervalMs: S.Number },
135
+ {
136
+ modelToDependencies: model => ({
137
+ pollIntervalMs: model.pollIntervalMs,
138
+ }),
139
+ dependenciesToStream: ({ pollIntervalMs }) =>
140
+ Stream.map(Stream.tick(Duration.millis(pollIntervalMs)), () =>
141
+ PollTicked(),
142
+ ),
143
+ },
144
+ ),
145
+ }),
146
+ )
147
+
148
+ const scopeButtons = (
149
+ h: HtmlBuilder<Message>,
150
+ model: Model,
151
+ scopes: ScopeList,
152
+ ): ReadonlyArray<Html> =>
153
+ scopes.items.map(scope =>
154
+ h.button(
155
+ [
156
+ h.Class(
157
+ scope.id === model.selectedId
158
+ ? 'scope-picker__choice scope-picker__choice--selected'
159
+ : 'scope-picker__choice',
160
+ ),
161
+ h.OnClick(ScopeChosen({ scope })),
162
+ ],
163
+ [scope.label],
164
+ ),
165
+ )
166
+
167
+ const body = (h: HtmlBuilder<Message>, model: Model): ReadonlyArray<Html> => {
168
+ const remote = model.scopes
169
+ switch (remote._tag) {
170
+ case 'Loading':
171
+ return [h.p([h.Class('microdot-note')], ['Loading scopes…'])]
172
+ case 'Ready':
173
+ return [
174
+ h.p(
175
+ [h.Class('microdot-state')],
176
+ [remote.stale ? 'Refreshing' : 'Ready'],
177
+ ),
178
+ h.div(
179
+ [h.Class('scope-picker__choices')],
180
+ scopeButtons(h, model, remote.value),
181
+ ),
182
+ ]
183
+ case 'Failed':
184
+ return [
185
+ h.p([h.Class('microdot-state microdot-state--error')], ['Failed']),
186
+ h.p([h.Class('microdot-note')], [remote.error]),
187
+ ...(remote.last === undefined
188
+ ? []
189
+ : [
190
+ h.div(
191
+ [h.Class('scope-picker__choices')],
192
+ scopeButtons(h, model, remote.last),
193
+ ),
194
+ ]),
195
+ ]
196
+ case 'Unauthorized':
197
+ return [h.p([h.Class('microdot-state')], ['Unauthorized'])]
198
+ }
199
+ }
200
+
201
+ const view = (model: Model, h: HtmlBuilder<Message>): Html =>
202
+ h.article(
203
+ [h.Class('starter-card scope-picker')],
204
+ [
205
+ h.header(
206
+ [],
207
+ [
208
+ h.p([h.Class('microdot-overline')], ['Independent service · 3201']),
209
+ h.h2([], ['Scope picker']),
210
+ h.p(
211
+ [h.Class('microdot-note')],
212
+ [
213
+ 'Emits an event. The host topology—not this dot—decides who receives it.',
214
+ ],
215
+ ),
216
+ ],
217
+ ),
218
+ h.div([h.Class('starter-card__body')], body(h, model)),
219
+ h.footer(
220
+ [],
221
+ [
222
+ h.span([], [`successful polls ${model.polls}`]),
223
+ h.button(
224
+ [h.Class('microdot-action'), h.OnClick(RefreshRequested())],
225
+ ['Refresh'],
226
+ ),
227
+ ],
228
+ ),
229
+ ],
230
+ )
231
+
232
+ export const makeScopePickerProgram = (
233
+ container: HTMLElement,
234
+ attributes: Readonly<Record<string, string>>,
235
+ ) => {
236
+ const apiUrl = attributes['api-url'] ?? ''
237
+ return makeElement<Model, Message, ScopePickerClient, never, typeof ports>({
238
+ Model,
239
+ container,
240
+ ports,
241
+ resources: scopePickerClientLive(apiUrl),
242
+ init: () => [
243
+ {
244
+ scopes: Loading(),
245
+ selectedId: 'general',
246
+ apiUrl,
247
+ pollIntervalMs: POLL_INTERVAL_MS,
248
+ polls: 0,
249
+ },
250
+ [FetchScopes({})],
251
+ ],
252
+ update,
253
+ view,
254
+ subscriptions,
255
+ })
256
+ }
@@ -0,0 +1,18 @@
1
+ import { Context } from 'effect'
2
+ import type { Layer } from 'effect'
3
+ import type { RpcClient, RpcClientError } from 'effect/unstable/rpc'
4
+ import { makeRpcClientLive } from '@bespokeagentics/microdots-runtime'
5
+
6
+ import { ScopePickerRpcs } from './contract.ts'
7
+
8
+ export class ScopePickerClient extends Context.Service<
9
+ ScopePickerClient,
10
+ RpcClient.RpcClient<ScopePickerRpcs, RpcClientError.RpcClientError>
11
+ >()('ScopePickerClient') {}
12
+
13
+ export const scopePickerClientLive: (
14
+ baseUrl: string,
15
+ ) => Layer.Layer<ScopePickerClient> = makeRpcClientLive(
16
+ ScopePickerClient,
17
+ ScopePickerRpcs,
18
+ )
@@ -0,0 +1,27 @@
1
+ import { Schema as S } from 'effect'
2
+ import { Rpc, RpcGroup } from 'effect/unstable/rpc'
3
+
4
+ export const ScopeOption = S.Struct({ id: S.String, label: S.String })
5
+ export type ScopeOption = typeof ScopeOption.Type
6
+
7
+ export const ScopeList = S.Struct({
8
+ items: S.Array(ScopeOption),
9
+ observedAt: S.String,
10
+ })
11
+ export type ScopeList = typeof ScopeList.Type
12
+
13
+ export const ScopeSelected = ScopeOption
14
+ export type ScopeSelected = typeof ScopeSelected.Type
15
+
16
+ export class ScopesUnavailable extends S.TaggedError<ScopesUnavailable>()(
17
+ 'ScopesUnavailable',
18
+ { reason: S.String },
19
+ ) {}
20
+
21
+ export const ListScopes = Rpc.make('ListScopes', {
22
+ success: ScopeList,
23
+ error: ScopesUnavailable,
24
+ })
25
+
26
+ export const ScopePickerRpcs = RpcGroup.make(ListScopes)
27
+ export type ScopePickerRpcs = RpcGroup.Rpcs<typeof ScopePickerRpcs>
@@ -0,0 +1,21 @@
1
+ import { type Disposable, defineMicroDot } from '@bespokeagentics/microdots-element'
2
+ import { Runtime } from 'foldkit'
3
+
4
+ import { makeScopePickerProgram } from './app.ts'
5
+ import styles from './styles.css?inline'
6
+ import { scopePickerSurface } from './surface.ts'
7
+
8
+ type Handle = Runtime.EmbedHandle<
9
+ ReturnType<typeof makeScopePickerProgram>['ports']
10
+ > & Disposable
11
+
12
+ defineMicroDot<Handle, typeof scopePickerSurface>({
13
+ surface: scopePickerSurface,
14
+ styles,
15
+ embed: (container, attributes) =>
16
+ Runtime.embed(makeScopePickerProgram(container, attributes)),
17
+ receive: {},
18
+ publish: {
19
+ 'scope-selected': handle => handle.ports.scopeSelected,
20
+ },
21
+ })
@@ -0,0 +1 @@
1
+ import './element.ts'
@@ -0,0 +1,23 @@
1
+ import { makeMicroDotSurface } from '@bespokeagentics/microdots-element'
2
+
3
+ import { ScopeSelected } from './contract.ts'
4
+
5
+ export const scopePickerSurface = makeMicroDotSurface({
6
+ tag: 'starter-scope-picker',
7
+ attributes: {
8
+ 'api-url': {
9
+ type: 'string',
10
+ required: true,
11
+ ownership: 'environment',
12
+ description: "Base URL of the scope picker's own service.",
13
+ },
14
+ },
15
+ events: {
16
+ 'scope-selected': {
17
+ payload: ScopeSelected,
18
+ description: "The scope the host should broker to interested MicroDots.",
19
+ },
20
+ },
21
+ })
22
+
23
+ export const surfaces = [scopePickerSurface]
@@ -0,0 +1,30 @@
1
+ :where([data-microdot]) {
2
+ --font-body: Inter, ui-sans-serif, system-ui, sans-serif;
3
+ --font-display: Inter, ui-sans-serif, system-ui, sans-serif;
4
+ --font-mono: 'SFMono-Regular', Consolas, monospace;
5
+ --fw-medium: 500;
6
+ --fs-caption: 0.75rem;
7
+ --fs-body-s: 0.82rem;
8
+ --fs-body-m: 0.92rem;
9
+ --fs-button-sm: 0.78rem;
10
+ --fs-display-m: 1.5rem;
11
+ --radius-sm: 0.35rem;
12
+ --radius-md: 0.55rem;
13
+ --radius-lg: 0.8rem;
14
+ --radius-full: 9999px;
15
+ --text-primary: #18202b;
16
+ --text-secondary: #465466;
17
+ --text-muted: #69778a;
18
+ --text-heading: #111827;
19
+ --text-on-brand: #ffffff;
20
+ --surface-default: #ffffff;
21
+ --surface-raised: #f8fafc;
22
+ --border-default: #d5dce6;
23
+ --border-subtle: #e7ebf0;
24
+ --accent-fill: #5264c7;
25
+ --accent-fill-hover: #4355b9;
26
+ --int-hover: rgb(82 100 199 / 0.12);
27
+ --int-active: rgb(82 100 199 / 0.17);
28
+ --status-error-fg: #a12b2b;
29
+ --shadow-card: 0 10px 28px rgb(17 24 39 / 0.08);
30
+ }
@@ -0,0 +1,71 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ readdirSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from 'node:fs'
9
+ import { tmpdir } from 'node:os'
10
+ import { resolve } from 'node:path'
11
+ import { afterEach, describe, expect, test } from 'vitest'
12
+
13
+ import type { MicroDotPackage } from './discover.ts'
14
+ import { cleanGeneratedBuildOutputs } from './build-output.ts'
15
+
16
+ const roots: string[] = []
17
+
18
+ const makeRoot = (): string => {
19
+ const root = mkdtempSync(resolve(tmpdir(), 'microdots-build-output-'))
20
+ roots.push(root)
21
+ return root
22
+ }
23
+
24
+ const dotAt = (root: string, name: string): MicroDotPackage => ({
25
+ name,
26
+ directory: resolve(root, 'microdots', name),
27
+ tags: [`${name}-view`],
28
+ port: 3991,
29
+ bundle: `${name}.js`,
30
+ })
31
+
32
+ afterEach(() => {
33
+ roots.splice(0).forEach(root => {
34
+ rmSync(root, { recursive: true, force: true })
35
+ })
36
+ })
37
+
38
+ describe('aggregate build output cleanup', () => {
39
+ test('removes stale shared bundles and discovered-dot artifacts', () => {
40
+ const root = makeRoot()
41
+ const dot = dotAt(root, 'renamed')
42
+ const bundleRoot = resolve(root, 'host/public/microdots')
43
+ const dotDist = resolve(dot.directory, 'dist')
44
+ mkdirSync(bundleRoot, { recursive: true })
45
+ mkdirSync(dotDist, { recursive: true })
46
+ writeFileSync(resolve(bundleRoot, 'old-name.js'), 'stale')
47
+ writeFileSync(resolve(dotDist, 'old-name.manifest.json'), 'stale')
48
+
49
+ cleanGeneratedBuildOutputs(root, [dot])
50
+
51
+ expect(readdirSync(bundleRoot)).toEqual([])
52
+ expect(existsSync(dotDist)).toBe(false)
53
+ })
54
+
55
+ test('refuses a dot outside the exact repository microdots directory', () => {
56
+ const root = makeRoot()
57
+ const outside = resolve(root, 'outside')
58
+ const dist = resolve(outside, 'dist')
59
+ mkdirSync(dist, { recursive: true })
60
+ writeFileSync(resolve(dist, 'keep.txt'), 'mine')
61
+ const dot: MicroDotPackage = {
62
+ ...dotAt(root, 'escaped'),
63
+ directory: outside,
64
+ }
65
+
66
+ expect(() => cleanGeneratedBuildOutputs(root, [dot])).toThrow(
67
+ 'refusing to clean dist outside',
68
+ )
69
+ expect(existsSync(resolve(dist, 'keep.txt'))).toBe(true)
70
+ })
71
+ })
@@ -0,0 +1,28 @@
1
+ import { mkdirSync, rmSync } from 'node:fs'
2
+ import { dirname, resolve } from 'node:path'
3
+
4
+ import type { MicroDotPackage } from './discover.ts'
5
+
6
+ export const cleanGeneratedBuildOutputs = (
7
+ root: string,
8
+ dots: ReadonlyArray<MicroDotPackage>,
9
+ ): void => {
10
+ const repositoryRoot = resolve(root)
11
+ const microdotsRoot = resolve(repositoryRoot, 'microdots')
12
+ const bundleRoot = resolve(repositoryRoot, 'host/public/microdots')
13
+
14
+ for (const dot of dots) {
15
+ const directory = resolve(dot.directory)
16
+ if (dirname(directory) !== microdotsRoot) {
17
+ throw new Error(
18
+ `[build:${dot.name}] refusing to clean dist outside ${microdotsRoot}: ${directory}`,
19
+ )
20
+ }
21
+ }
22
+
23
+ rmSync(bundleRoot, { recursive: true, force: true })
24
+ mkdirSync(bundleRoot, { recursive: true })
25
+ dots.forEach(dot => {
26
+ rmSync(resolve(dot.directory, 'dist'), { recursive: true, force: true })
27
+ })
28
+ }
@@ -0,0 +1,37 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+
5
+ import { cleanGeneratedBuildOutputs } from './build-output.ts'
6
+ import { listMicroDots, repoRoot } from './discover.ts'
7
+
8
+ const run = (label: string, cwd: string, command: ReadonlyArray<string>): void => {
9
+ console.log(`[build:${label}] ${command.join(' ')}`)
10
+ const [executable, ...args] = command
11
+ if (executable === undefined) throw new Error(`${label} has no build command`)
12
+ const result = spawnSync(executable, args, {
13
+ cwd,
14
+ stdio: 'inherit',
15
+ })
16
+ if (result.status !== 0) {
17
+ throw new Error(`${label} build failed with exit ${String(result.status)}`)
18
+ }
19
+ }
20
+
21
+ const dots = listMicroDots()
22
+ cleanGeneratedBuildOutputs(repoRoot, dots)
23
+ dots.forEach(dot => {
24
+ run(dot.name, dot.directory, ['bunx', 'vite', 'build'])
25
+ })
26
+ // The Vite plugin is intentionally warn-only so a transient manifest failure
27
+ // cannot kill a long-running watcher. A one-shot release build needs the
28
+ // opposite contract: re-run every emitter strictly and fail the aggregate.
29
+ run('manifests', repoRoot, ['bun', 'scripts/emit-manifests.ts'])
30
+ dots.forEach(dot => {
31
+ const manifest = resolve(dot.directory, 'dist', `${dot.name}.manifest.json`)
32
+ if (!existsSync(manifest)) {
33
+ throw new Error(`[build:${dot.name}] manifest is missing: ${manifest}`)
34
+ }
35
+ })
36
+ run('host', resolve(repoRoot, 'host'), ['bunx', 'vite', 'build'])
37
+ console.log('[build] complete')
@@ -0,0 +1,63 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+
4
+ import { repoRoot } from './discover.ts'
5
+
6
+ const withoutComments = (css: string): string =>
7
+ css.replace(/\/\*[\s\S]*?\*\//g, '')
8
+
9
+ const cssFiles = (directory: string): ReadonlyArray<string> =>
10
+ existsSync(directory)
11
+ ? readdirSync(directory)
12
+ .filter(name => name !== 'node_modules')
13
+ .flatMap(name => {
14
+ const path = resolve(directory, name)
15
+ return statSync(path).isDirectory()
16
+ ? cssFiles(path)
17
+ : path.endsWith('.css')
18
+ ? [path]
19
+ : []
20
+ })
21
+ : []
22
+
23
+ const referencesIn = (paths: ReadonlyArray<string>): ReadonlySet<string> =>
24
+ new Set(
25
+ paths.flatMap(path =>
26
+ [...withoutComments(readFileSync(path, 'utf8')).matchAll(/var\((--[\w-]+)/g)].map(
27
+ match => match[1] ?? '',
28
+ ),
29
+ ),
30
+ )
31
+
32
+ const declarationsIn = (path: string): ReadonlySet<string> =>
33
+ new Set(
34
+ [...withoutComments(readFileSync(path, 'utf8')).matchAll(/(--[\w-]+)\s*:/g)].map(
35
+ match => match[1] ?? '',
36
+ ),
37
+ )
38
+
39
+ const themePackage = resolve(
40
+ repoRoot,
41
+ 'node_modules/@bespokeagentics/microdots-theme/src/theme.css',
42
+ )
43
+ const consumers = [
44
+ ...cssFiles(resolve(repoRoot, 'host/src')),
45
+ ...cssFiles(resolve(repoRoot, 'microdots')),
46
+ ...(existsSync(themePackage) ? [themePackage] : []),
47
+ ]
48
+ const required = referencesIn(consumers)
49
+ const themes = cssFiles(resolve(repoRoot, 'host/public/themes'))
50
+
51
+ if (themes.length < 2) {
52
+ throw new Error('[tokens] expected at least two theme sets')
53
+ }
54
+
55
+ themes.forEach(path => {
56
+ const declared = declarationsIn(path)
57
+ const missing = [...required].filter(name => !declared.has(name)).sort()
58
+ if (missing.length > 0) {
59
+ throw new Error(`[tokens] ${path} is missing ${missing.join(', ')}`)
60
+ }
61
+ })
62
+
63
+ console.log(`[tokens] ${themes.length} sets satisfy ${required.size} referenced tokens`)
@@ -0,0 +1,78 @@
1
+ import { spawn, spawnSync } from 'node:child_process'
2
+ import { resolve } from 'node:path'
3
+
4
+ import { listMicroDots, repoRoot } from './discover.ts'
5
+
6
+ const children: Array<ReturnType<typeof spawn>> = []
7
+ let stopping = false
8
+
9
+ const start = (
10
+ label: string,
11
+ cwd: string,
12
+ command: ReadonlyArray<string>,
13
+ ): void => {
14
+ const [executable, ...args] = command
15
+ if (executable === undefined) throw new Error(`${label} has no command`)
16
+ const child = spawn(executable, args, {
17
+ cwd,
18
+ stdio: ['ignore', 'pipe', 'pipe'],
19
+ })
20
+ children.push(child)
21
+ const pipe = (stream: NodeJS.ReadableStream | null): void => {
22
+ stream?.on('data', chunk => {
23
+ String(chunk)
24
+ .split('\n')
25
+ .filter(line => line.trim() !== '')
26
+ .forEach(line => console.log(`[${label}] ${line}`))
27
+ })
28
+ }
29
+ pipe(child.stdout)
30
+ pipe(child.stderr)
31
+ child.on('exit', code => {
32
+ if (!stopping) console.log(`[${label}] exited with ${String(code)}`)
33
+ })
34
+ }
35
+
36
+ const stop = (): void => {
37
+ if (stopping) return
38
+ stopping = true
39
+ children.forEach(child => child.kill('SIGTERM'))
40
+ }
41
+
42
+ process.on('SIGINT', stop)
43
+ process.on('SIGTERM', stop)
44
+
45
+ const dots = listMicroDots()
46
+
47
+ // A fresh scaffold has no bundles for the host to import. Build once before the
48
+ // concurrent watchers start so the first browser load cannot race them and turn
49
+ // a healthy MicroDot into a sticky load-failure placeholder.
50
+ dots.forEach(dot => {
51
+ console.log(`[dev] preparing ${dot.name}`)
52
+ const prepared = spawnSync('bunx', ['vite', 'build'], {
53
+ cwd: dot.directory,
54
+ stdio: 'inherit',
55
+ })
56
+ if (prepared.status !== 0) {
57
+ throw new Error(
58
+ `[dev] ${dot.name} initial build failed with exit ${String(prepared.status)}`,
59
+ )
60
+ }
61
+ })
62
+
63
+ dots.forEach(dot => {
64
+ start(`${dot.name}:service`, dot.directory, [
65
+ 'bun',
66
+ '--watch',
67
+ 'service/main.ts',
68
+ ])
69
+ start(`${dot.name}:bundle`, dot.directory, [
70
+ 'bunx',
71
+ 'vite',
72
+ 'build',
73
+ '--watch',
74
+ ])
75
+ })
76
+ start('host', resolve(repoRoot, 'host'), ['bunx', 'vite', 'dev'])
77
+
78
+ console.log('[dev] http://localhost:5190 — Ctrl-C stops every owned process')