@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.
- package/LICENSE +202 -0
- package/package.json +26 -0
- package/src/cli.ts +50 -0
- package/src/index.ts +215 -0
- package/templates/base/README.md +49 -0
- package/templates/base/_gitignore +7 -0
- package/templates/base/host/index.html +33 -0
- package/templates/base/host/package.json +10 -0
- package/templates/base/host/public/themes/neutral-dark.css +109 -0
- package/templates/base/host/public/themes/neutral.css +109 -0
- package/templates/base/host/src/main.ts +202 -0
- package/templates/base/host/src/topology.test.ts +42 -0
- package/templates/base/host/topology.json +84 -0
- package/templates/base/host/vite.base.ts +41 -0
- package/templates/base/microdots/pulse/package.json +20 -0
- package/templates/base/microdots/pulse/service/handlers.ts +36 -0
- package/templates/base/microdots/pulse/service/main.ts +30 -0
- package/templates/base/microdots/pulse/src/app.ts +275 -0
- package/templates/base/microdots/pulse/src/client.ts +14 -0
- package/templates/base/microdots/pulse/src/contract.ts +32 -0
- package/templates/base/microdots/pulse/src/element.ts +24 -0
- package/templates/base/microdots/pulse/src/entry.ts +1 -0
- package/templates/base/microdots/pulse/src/surface.ts +30 -0
- package/templates/base/microdots/pulse/src/tokens.css +30 -0
- package/templates/base/microdots/scope-picker/package.json +20 -0
- package/templates/base/microdots/scope-picker/service/handlers.ts +15 -0
- package/templates/base/microdots/scope-picker/service/main.ts +30 -0
- package/templates/base/microdots/scope-picker/src/app.ts +256 -0
- package/templates/base/microdots/scope-picker/src/client.ts +18 -0
- package/templates/base/microdots/scope-picker/src/contract.ts +27 -0
- package/templates/base/microdots/scope-picker/src/element.ts +21 -0
- package/templates/base/microdots/scope-picker/src/entry.ts +1 -0
- package/templates/base/microdots/scope-picker/src/surface.ts +23 -0
- package/templates/base/microdots/scope-picker/src/tokens.css +30 -0
- package/templates/base/scripts/build-output.test.ts +71 -0
- package/templates/base/scripts/build-output.ts +28 -0
- package/templates/base/scripts/build.ts +37 -0
- package/templates/base/scripts/check-tokens.ts +63 -0
- package/templates/base/scripts/dev.ts +78 -0
- package/templates/base/scripts/discover.test.ts +133 -0
- package/templates/base/scripts/discover.ts +137 -0
- package/templates/base/scripts/dot-vite.ts +28 -0
- package/templates/base/scripts/emit-manifests.ts +154 -0
- package/templates/base/tsconfig.json +18 -0
- package/templates/base/types.d.ts +9 -0
- package/templates/base/vitest.config.ts +8 -0
- package/templates/plain/host/src/styles.css +46 -0
- package/templates/plain/host/vite.config.template.ts +4 -0
- package/templates/plain/microdots/pulse/src/styles.css +17 -0
- package/templates/plain/microdots/pulse/vite.config.template.ts +4 -0
- package/templates/plain/microdots/scope-picker/src/styles.css +19 -0
- package/templates/plain/microdots/scope-picker/vite.config.template.ts +4 -0
- package/templates/plain/package.json +39 -0
- package/templates/tailwind/host/src/styles.css +28 -0
- package/templates/tailwind/host/vite.config.template.ts +5 -0
- package/templates/tailwind/microdots/pulse/src/styles.css +24 -0
- package/templates/tailwind/microdots/pulse/vite.config.template.ts +5 -0
- package/templates/tailwind/microdots/scope-picker/src/styles.css +25 -0
- package/templates/tailwind/microdots/scope-picker/vite.config.template.ts +5 -0
- 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')
|