@webhands/core 0.1.0 → 0.2.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 +661 -0
- package/README.md +112 -0
- package/dist/cookies-export.d.ts +5 -5
- package/dist/cookies-export.d.ts.map +1 -1
- package/dist/cookies-export.js +4 -4
- package/dist/hand-host.d.ts +217 -0
- package/dist/hand-host.d.ts.map +1 -0
- package/dist/hand-host.js +351 -0
- package/dist/hand-host.js.map +1 -0
- package/dist/hand-loading.d.ts +128 -0
- package/dist/hand-loading.d.ts.map +1 -0
- package/dist/hand-loading.js +143 -0
- package/dist/hand-loading.js.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/playwright-attach-transport.d.ts +9 -0
- package/dist/playwright-attach-transport.d.ts.map +1 -1
- package/dist/playwright-attach-transport.js +53 -91
- package/dist/playwright-attach-transport.js.map +1 -1
- package/dist/playwright-launch-transport.d.ts +7 -62
- package/dist/playwright-launch-transport.d.ts.map +1 -1
- package/dist/playwright-launch-transport.js +51 -204
- package/dist/playwright-launch-transport.js.map +1 -1
- package/dist/remote-session.d.ts +12 -2
- package/dist/remote-session.d.ts.map +1 -1
- package/dist/remote-session.js +37 -6
- package/dist/remote-session.js.map +1 -1
- package/dist/seam.d.ts +13 -5
- package/dist/seam.d.ts.map +1 -1
- package/dist/session-rpc.d.ts +76 -12
- package/dist/session-rpc.d.ts.map +1 -1
- package/dist/session-rpc.js +76 -8
- package/dist/session-rpc.js.map +1 -1
- package/dist/stub-transport.d.ts +2 -2
- package/dist/stub-transport.d.ts.map +1 -1
- package/dist/stub-transport.js +11 -0
- package/dist/stub-transport.js.map +1 -1
- package/package.json +21 -2
- package/src/cookies-export.ts +5 -5
- package/src/hand-host.ts +511 -0
- package/src/hand-loading.ts +254 -0
- package/src/index.ts +18 -1
- package/src/playwright-attach-transport.ts +65 -119
- package/src/playwright-launch-transport.ts +63 -244
- package/src/remote-session.ts +43 -5
- package/src/seam.ts +13 -5
- package/src/session-rpc.ts +121 -11
- package/src/stub-transport.ts +15 -3
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { errors as pwErrors } from 'playwright';
|
|
2
|
+
/**
|
|
3
|
+
* Compose a set of hands over one live {@link HandContext} into a single
|
|
4
|
+
* {@link WebHandsPage}. This is the host primitive both Playwright transports call to
|
|
5
|
+
* build their session's verb surface — the SINGLE shared composition (no
|
|
6
|
+
* duplicated page-object literal).
|
|
7
|
+
*
|
|
8
|
+
* Composition is EAGER (exactly as the page object literal was built before):
|
|
9
|
+
* each hand is invoked once at session-open time and its verbs are merged into
|
|
10
|
+
* one page object. There is no lazy registration and no ordering effect on the
|
|
11
|
+
* verbs themselves (the eight built-in verbs have disjoint names). The returned
|
|
12
|
+
* {@link WebHandsPage} is validated to carry every verb the seam requires, so a missing
|
|
13
|
+
* built-in verb is a build-time/open-time failure here rather than an `undefined
|
|
14
|
+
* is not a function` at the call site.
|
|
15
|
+
*/
|
|
16
|
+
export function composePage(ctx, hands) {
|
|
17
|
+
const verbs = {};
|
|
18
|
+
const disposers = [];
|
|
19
|
+
for (const hand of hands) {
|
|
20
|
+
const contribution = hand(ctx);
|
|
21
|
+
Object.assign(verbs, contribution.verbs);
|
|
22
|
+
if (contribution.dispose !== undefined) {
|
|
23
|
+
disposers.push(contribution.dispose);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const page = assertCompletePage(verbs);
|
|
27
|
+
return {
|
|
28
|
+
page,
|
|
29
|
+
async dispose() {
|
|
30
|
+
// LIFO teardown; await every disposer even if one rejects so a single
|
|
31
|
+
// failing hand cannot strand the others' cleanup.
|
|
32
|
+
const failures = [];
|
|
33
|
+
for (let i = disposers.length - 1; i >= 0; i--) {
|
|
34
|
+
try {
|
|
35
|
+
await disposers[i]();
|
|
36
|
+
}
|
|
37
|
+
catch (cause) {
|
|
38
|
+
failures.push(cause);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (failures.length > 0) {
|
|
42
|
+
throw failures[0];
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** The seam's full verb set; used to validate a composed page is complete. */
|
|
48
|
+
const REQUIRED_VERBS = [
|
|
49
|
+
'navigate',
|
|
50
|
+
'snapshot',
|
|
51
|
+
'click',
|
|
52
|
+
'type',
|
|
53
|
+
'eval',
|
|
54
|
+
'wait',
|
|
55
|
+
'cookies',
|
|
56
|
+
'setCookies',
|
|
57
|
+
];
|
|
58
|
+
/**
|
|
59
|
+
* Assert the composed verbs cover the whole seam {@link WebHandsPage}, then return it
|
|
60
|
+
* as a `WebHandsPage`. A gap here means a built-in hand was dropped from the
|
|
61
|
+
* composition — surfacing it at open time is far cheaper than a runtime
|
|
62
|
+
* `undefined is not a function`.
|
|
63
|
+
*/
|
|
64
|
+
function assertCompletePage(verbs) {
|
|
65
|
+
const missing = REQUIRED_VERBS.filter((name) => typeof verbs[name] !== 'function');
|
|
66
|
+
if (missing.length > 0) {
|
|
67
|
+
throw new Error(`hand-host: composed page is missing verb(s): ${missing.join(', ')}`);
|
|
68
|
+
}
|
|
69
|
+
return verbs;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* How long a normal, actionability-checked `click` may wait before we treat the
|
|
73
|
+
* element as un-clickable and fall back to a dispatched click. Short on purpose:
|
|
74
|
+
* a hidden custom input never becomes actionable, so the regular click would
|
|
75
|
+
* otherwise burn Playwright's full default timeout (30s) before the escape path
|
|
76
|
+
* runs. The visible-element happy path clicks immediately and never hits this;
|
|
77
|
+
* this bound is the latency cost paid ONLY on the hidden/non-actionable path,
|
|
78
|
+
* and is long enough to tolerate a slow-but-eventually-actionable element
|
|
79
|
+
* (animations, late layout) before deciding to dispatch.
|
|
80
|
+
*/
|
|
81
|
+
const NORMAL_CLICK_TIMEOUT_MS = 1_000;
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Built-in hands: webhands' OWN eight verbs, each a hand over the host.
|
|
84
|
+
//
|
|
85
|
+
// Grouped into cohesive capability modules (navigation, snapshot, interaction,
|
|
86
|
+
// eval, wait, cookies) to demonstrate that a hand can contribute several verbs
|
|
87
|
+
// + in-process logic (it is NOT one-verb-per-hand). The verb BODIES are moved
|
|
88
|
+
// verbatim from the two transports' page-object literals, so behavior is
|
|
89
|
+
// preserved byte-for-byte (the existing verb suite is the proof).
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
/** The `navigate` verb: go to a URL and let it settle on the `load` event. */
|
|
92
|
+
export const navigationHand = ({ pwPage, ensureOpen }) => ({
|
|
93
|
+
verbs: {
|
|
94
|
+
async navigate(url) {
|
|
95
|
+
ensureOpen();
|
|
96
|
+
// "Settled" for `goto` = the `load` event: the document and its
|
|
97
|
+
// subresources have loaded (PRD story 6, "navigate ... and wait for it
|
|
98
|
+
// to settle"). We deliberately do NOT wait for `networkidle`:
|
|
99
|
+
// Playwright discourages it, and it hangs forever on pages with
|
|
100
|
+
// long-poll / streaming / analytics beacons (exactly the logged-in apps
|
|
101
|
+
// this tool targets). Content rendered AFTER load (XHR-injected prices,
|
|
102
|
+
// hydrated lists) is the job of the explicit `wait` verb (story 10), not
|
|
103
|
+
// of `goto`.
|
|
104
|
+
await pwPage.goto(url, { waitUntil: 'load' });
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
/** The `snapshot` verb: the token-cheap a11y view, or `--full` raw DOM. */
|
|
109
|
+
export const snapshotHand = ({ pwPage, ensureOpen }) => ({
|
|
110
|
+
verbs: {
|
|
111
|
+
async snapshot(options) {
|
|
112
|
+
ensureOpen();
|
|
113
|
+
const url = pwPage.url();
|
|
114
|
+
if (options?.full === true) {
|
|
115
|
+
// `--full`: the raw DOM. `documentElement.outerHTML` is the serialized
|
|
116
|
+
// live DOM (post-script render), which is what an agent that wants the
|
|
117
|
+
// real HTML expects — not the original network response.
|
|
118
|
+
const content = await pwPage.evaluate(() => document.documentElement.outerHTML);
|
|
119
|
+
return { url, view: 'full', content };
|
|
120
|
+
}
|
|
121
|
+
// Default: the token-cheap accessibility tree + visible text with stable
|
|
122
|
+
// `[ref=...]` element refs. Playwright's `ariaSnapshot({mode: 'ai'})`
|
|
123
|
+
// emits exactly that — a YAML aria tree (roles + accessible names +
|
|
124
|
+
// text) where each node carries a stable `[ref=eN]` reference, assigned
|
|
125
|
+
// deterministically by traversal order so re-snapshotting an unchanged
|
|
126
|
+
// page yields the same refs. The string crosses the seam as opaque,
|
|
127
|
+
// transport-neutral text (no Playwright type leaks, ADR-0003).
|
|
128
|
+
const content = await pwPage.ariaSnapshot({ mode: 'ai' });
|
|
129
|
+
return { url, view: 'accessibility', content };
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
/** The `click` + `type` verbs: page interaction by raw locator (ADR-0004). */
|
|
134
|
+
export const interactionHand = ({ pwPage, ensureOpen }) => ({
|
|
135
|
+
verbs: {
|
|
136
|
+
async click(t) {
|
|
137
|
+
ensureOpen();
|
|
138
|
+
await clickLocator(pwPage, t);
|
|
139
|
+
},
|
|
140
|
+
async type(t, text) {
|
|
141
|
+
ensureOpen();
|
|
142
|
+
await resolveLocator(pwPage, t).fill(text);
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
/** The `eval` escape hatch: run a JS EXPRESSION in the page, return by value. */
|
|
147
|
+
export const evalHand = ({ pwPage, ensureOpen }) => ({
|
|
148
|
+
verbs: {
|
|
149
|
+
async eval(expression) {
|
|
150
|
+
ensureOpen();
|
|
151
|
+
// The `eval` escape hatch (PRD story 9): run the raw JS EXPRESSION in the
|
|
152
|
+
// page and return its serializable result. Playwright's `evaluate`
|
|
153
|
+
// already IS the seam's serialization contract (see {@link WebHandsPage.eval}):
|
|
154
|
+
// it passes a string as an expression, awaits a returned Promise, and
|
|
155
|
+
// structurally clones the result out of the page by VALUE. That clone is
|
|
156
|
+
// richer than JSON: it preserves NaN/Infinity/BigInt and circular
|
|
157
|
+
// structures (back-refs become a `[Circular]` marker), yields `undefined`
|
|
158
|
+
// for functions/symbols, and returns an opaque preview string for a live
|
|
159
|
+
// host object (a DOM node never crosses the process boundary). A page-side
|
|
160
|
+
// throw rejects. We pass it straight through rather than re-encode it:
|
|
161
|
+
// wrapping the value in a transport-specific envelope would invent a
|
|
162
|
+
// dialect the seam deliberately avoids. The thrown error is a plain
|
|
163
|
+
// `Error`, so no Playwright/CDP type leaks across the seam (ADR-0003).
|
|
164
|
+
return pwPage.evaluate(expression);
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
/** The `wait` verb: pace actions by a condition (timeout/locator/navigation). */
|
|
169
|
+
export const waitHand = ({ pwPage, ensureOpen }) => ({
|
|
170
|
+
verbs: {
|
|
171
|
+
async wait(condition) {
|
|
172
|
+
ensureOpen();
|
|
173
|
+
await waitFor(pwPage, condition);
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
/**
|
|
178
|
+
* The `cookies` + `setCookies` verbs. These prove the {@link HandContext} needs
|
|
179
|
+
* the `context`: cookies are a context-level, not page-level, concern, so this
|
|
180
|
+
* hand reaches `ctx.context`, not `ctx.pwPage`.
|
|
181
|
+
*/
|
|
182
|
+
export const cookiesHand = ({ context, ensureOpen }) => ({
|
|
183
|
+
verbs: {
|
|
184
|
+
async cookies() {
|
|
185
|
+
ensureOpen();
|
|
186
|
+
const raw = await context.cookies();
|
|
187
|
+
return raw.map(toSeamCookie);
|
|
188
|
+
},
|
|
189
|
+
async setCookies(cookies) {
|
|
190
|
+
ensureOpen();
|
|
191
|
+
await context.addCookies(cookies.map(fromSeamCookie));
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
/**
|
|
196
|
+
* webhands' eight built-in verbs as built-in hands, in composition order. Both
|
|
197
|
+
* Playwright transports compose THIS exact set, so the verb surface is
|
|
198
|
+
* identical across launch and attach (the only legitimate difference is the
|
|
199
|
+
* per-transport SESSION LIFECYCLE, which is not a hand's concern).
|
|
200
|
+
*/
|
|
201
|
+
export const BUILT_IN_HANDS = [
|
|
202
|
+
navigationHand,
|
|
203
|
+
snapshotHand,
|
|
204
|
+
interactionHand,
|
|
205
|
+
evalHand,
|
|
206
|
+
waitHand,
|
|
207
|
+
cookiesHand,
|
|
208
|
+
];
|
|
209
|
+
/**
|
|
210
|
+
* Compose webhands' built-in hands over a live context into the seam's
|
|
211
|
+
* {@link WebHandsPage}. The convenience both transports call: `composePage(ctx,
|
|
212
|
+
* BUILT_IN_HANDS)`. The built-in hands set up no in-process resources, so the
|
|
213
|
+
* returned `dispose` is a no-op today; it exists so a transport can sequence
|
|
214
|
+
* hand-teardown before its own browser/context teardown once third-party hands
|
|
215
|
+
* (which may hold resources) are added in Phase 2.
|
|
216
|
+
*/
|
|
217
|
+
export function composeBuiltInPage(ctx) {
|
|
218
|
+
return composePage(ctx, BUILT_IN_HANDS);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Compose webhands' built-in hands together with any explicitly-loaded
|
|
222
|
+
* third-party hands (Phase 2) over a live context. The third-party hands are
|
|
223
|
+
* composed AFTER the built-ins through the EXACT same {@link composePage} the
|
|
224
|
+
* built-ins use, so a loaded hand plugs into the same host: its verbs merge into
|
|
225
|
+
* the same seam {@link WebHandsPage} and its `dispose` is sequenced LIFO with the rest.
|
|
226
|
+
* A third-party hand may add NEW verbs (the common case) and, because later
|
|
227
|
+
* contributions win the merge, may also override a built-in verb — that is the
|
|
228
|
+
* operator's choice, made by the trust act of naming the hand (ADR-0007).
|
|
229
|
+
*/
|
|
230
|
+
export function composeWithHands(ctx, extraHands) {
|
|
231
|
+
return composePage(ctx, [...BUILT_IN_HANDS, ...extraHands]);
|
|
232
|
+
}
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// Shared verb building blocks (moved here with the verb bodies they back).
|
|
235
|
+
// Re-exported from the launch transport for its existing public-API consumers.
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
/**
|
|
238
|
+
* Run the `wait` verb's three forms (PRD story 10) against a Playwright page.
|
|
239
|
+
*
|
|
240
|
+
* - `timeout` — pace by a fixed delay (`waitForTimeout`), so an agent can act
|
|
241
|
+
* like a human and let XHR-rendered content land.
|
|
242
|
+
* - `locator` — block until the addressed element appears (`Locator.waitFor()`),
|
|
243
|
+
* the form for content rendered AFTER `goto` settled on `load`.
|
|
244
|
+
* - `navigation` — block until the NEXT navigation settles to `load`. We use
|
|
245
|
+
* `waitForNavigation()` even though Playwright marks it `@deprecated` ("racy,
|
|
246
|
+
* use waitForURL"): that deprecation targets in-process TEST code that can arm
|
|
247
|
+
* the wait BEFORE the action and pass a target URL. Neither holds here. Across
|
|
248
|
+
* this seam verbs are DISCRETE sequential calls (`click` then `wait`), so we
|
|
249
|
+
* CANNOT arm before the trigger; and the realistic trigger is an async,
|
|
250
|
+
* JS-driven transition (a redirect / SPA route change that fires AFTER the
|
|
251
|
+
* agent's action, the "let XHR-rendered content load" case of story 10), so
|
|
252
|
+
* "wait for the NEXT navigation" is exactly right — whereas `waitForLoadState`
|
|
253
|
+
* would see the already-loaded current page and return before the pending
|
|
254
|
+
* transition. `waitForURL` is unusable because the verb has no target URL by
|
|
255
|
+
* design (the agent waits for "a navigation", not a known address). (See the
|
|
256
|
+
* task's ## Decisions note.)
|
|
257
|
+
*
|
|
258
|
+
* Shared by both Playwright transports (via the `wait` built-in hand) so the
|
|
259
|
+
* verb behaviour stays identical (no parallel second implementation).
|
|
260
|
+
*/
|
|
261
|
+
export async function waitFor(page, condition) {
|
|
262
|
+
switch (condition.kind) {
|
|
263
|
+
case 'timeout':
|
|
264
|
+
await page.waitForTimeout(condition.ms);
|
|
265
|
+
return;
|
|
266
|
+
case 'locator':
|
|
267
|
+
await resolveLocator(page, condition.target).waitFor();
|
|
268
|
+
return;
|
|
269
|
+
case 'navigation':
|
|
270
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
271
|
+
await page.waitForNavigation();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Resolve a raw Playwright locator EXPRESSION (ADR-0004) against the page. The
|
|
277
|
+
* verb surface passes locator expressions like `getByRole('button', …)`; we
|
|
278
|
+
* evaluate them in a small sandbox where `page`/`p` is the page, so the full
|
|
279
|
+
* Playwright locator grammar is available without leaking the type across the
|
|
280
|
+
* seam.
|
|
281
|
+
*
|
|
282
|
+
* One resolution path for both transports (via the built-in interaction/wait
|
|
283
|
+
* hands), so there is no parallel addressing scheme.
|
|
284
|
+
*/
|
|
285
|
+
export function resolveLocator(page, expression) {
|
|
286
|
+
// eslint-disable-next-line no-new-func
|
|
287
|
+
const factory = new Function('page', 'p', `return (${expression});`);
|
|
288
|
+
return factory(page, page);
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Run the `click` verb against a Playwright page (PRD story 8), shared by both
|
|
292
|
+
* Playwright transports (via the built-in interaction hand) so the verb behaves
|
|
293
|
+
* identically (mirrors {@link waitFor}; no parallel second implementation).
|
|
294
|
+
*
|
|
295
|
+
* First try a normal `Locator.click()`, which AUTO-WAITS for the element to be
|
|
296
|
+
* visible and actionable — the right behaviour for a real button. A hidden
|
|
297
|
+
* custom input (the case the prd calls out) NEVER becomes actionable, so that
|
|
298
|
+
* click times out; on a Playwright `TimeoutError` we fall back to
|
|
299
|
+
* `dispatchEvent('click')`, which fires a click WITHOUT the actionability
|
|
300
|
+
* checks. The fallback is deliberately the documented Playwright escape (a
|
|
301
|
+
* sibling to the `eval` hatch, ADR-0004), not a reimplemented click: we keep
|
|
302
|
+
* the locator a raw resolved expression and only change HOW the resolved
|
|
303
|
+
* locator is clicked.
|
|
304
|
+
*
|
|
305
|
+
* Only a timeout triggers the fallback. The fallback `dispatchEvent` is itself
|
|
306
|
+
* bounded by the same short timeout, so a locator that resolves NO element (a
|
|
307
|
+
* bad locator) surfaces its timeout quickly instead of hanging the dispatch on
|
|
308
|
+
* Playwright's 30s default — the dispatch escape is for elements that EXIST but
|
|
309
|
+
* are not actionable (hidden custom inputs), not for absent ones.
|
|
310
|
+
*/
|
|
311
|
+
export async function clickLocator(page, expression) {
|
|
312
|
+
const target = resolveLocator(page, expression);
|
|
313
|
+
try {
|
|
314
|
+
await target.click({ timeout: NORMAL_CLICK_TIMEOUT_MS });
|
|
315
|
+
}
|
|
316
|
+
catch (cause) {
|
|
317
|
+
if (!(cause instanceof pwErrors.TimeoutError)) {
|
|
318
|
+
throw cause;
|
|
319
|
+
}
|
|
320
|
+
// The element never became actionable (e.g. a hidden custom input). Fire
|
|
321
|
+
// the click without actionability checks, the prd's explicit escape path.
|
|
322
|
+
await target.dispatchEvent('click', { timeout: NORMAL_CLICK_TIMEOUT_MS });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/** Map a Playwright cookie to the transport-neutral seam {@link Cookie}. */
|
|
326
|
+
function toSeamCookie(c) {
|
|
327
|
+
return {
|
|
328
|
+
name: c.name,
|
|
329
|
+
value: c.value,
|
|
330
|
+
domain: c.domain,
|
|
331
|
+
path: c.path,
|
|
332
|
+
expires: c.expires,
|
|
333
|
+
httpOnly: c.httpOnly,
|
|
334
|
+
secure: c.secure,
|
|
335
|
+
sameSite: c.sameSite,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
/** Map a seam {@link Cookie} to a Playwright cookie shape. */
|
|
339
|
+
function fromSeamCookie(c) {
|
|
340
|
+
return {
|
|
341
|
+
name: c.name,
|
|
342
|
+
value: c.value,
|
|
343
|
+
domain: c.domain,
|
|
344
|
+
path: c.path,
|
|
345
|
+
expires: c.expires,
|
|
346
|
+
httpOnly: c.httpOnly,
|
|
347
|
+
secure: c.secure,
|
|
348
|
+
sameSite: c.sameSite,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
//# sourceMappingURL=hand-host.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hand-host.js","sourceRoot":"","sources":["../src/hand-host.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,MAAM,IAAI,QAAQ,EAAiC,MAAM,YAAY,CAAC;AAgH9E;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,WAAW,CAC1B,GAAgB,EAChB,KAAsB;IAEtB,MAAM,KAAK,GAA0B,EAAE,CAAC;IACxC,MAAM,SAAS,GAAoD,EAAE,CAAC;IAEtE,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QAC1B,MAAM,YAAY,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC;QAC/B,MAAM,CAAC,MAAM,CAAC,KAAK,EAAE,YAAY,CAAC,KAAK,CAAC,CAAC;QACzC,IAAI,YAAY,CAAC,OAAO,KAAK,SAAS,EAAE,CAAC;YACxC,SAAS,CAAC,IAAI,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC;QACtC,CAAC;IACF,CAAC;IAED,MAAM,IAAI,GAAG,kBAAkB,CAAC,KAAK,CAAC,CAAC;IAEvC,OAAO;QACN,IAAI;QACJ,KAAK,CAAC,OAAO;YACZ,sEAAsE;YACtE,kDAAkD;YAClD,MAAM,QAAQ,GAAc,EAAE,CAAC;YAC/B,KAAK,IAAI,CAAC,GAAG,SAAS,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;gBAChD,IAAI,CAAC;oBACJ,MAAM,SAAS,CAAC,CAAC,CAAE,EAAE,CAAC;gBACvB,CAAC;gBAAC,OAAO,KAAK,EAAE,CAAC;oBAChB,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;gBACtB,CAAC;YACF,CAAC;YACD,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;gBACzB,MAAM,QAAQ,CAAC,CAAC,CAAC,CAAC;YACnB,CAAC;QACF,CAAC;KACD,CAAC;AACH,CAAC;AAED,8EAA8E;AAC9E,MAAM,cAAc,GAAG;IACtB,UAAU;IACV,UAAU;IACV,OAAO;IACP,MAAM;IACN,MAAM;IACN,MAAM;IACN,SAAS;IACT,YAAY;CACyC,CAAC;AAEvD;;;;;GAKG;AACH,SAAS,kBAAkB,CAAC,KAA4B;IACvD,MAAM,OAAO,GAAG,cAAc,CAAC,MAAM,CACpC,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,KAAK,CAAC,IAAI,CAAC,KAAK,UAAU,CAC3C,CAAC;IACF,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACxB,MAAM,IAAI,KAAK,CACd,gDAAgD,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CACpE,CAAC;IACH,CAAC;IACD,OAAO,KAAqB,CAAC;AAC9B,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,uBAAuB,GAAG,KAAK,CAAC;AAEtC,8EAA8E;AAC9E,wEAAwE;AACxE,EAAE;AACF,+EAA+E;AAC/E,+EAA+E;AAC/E,8EAA8E;AAC9E,yEAAyE;AACzE,kEAAkE;AAClE,8EAA8E;AAE9E,8EAA8E;AAC9E,MAAM,CAAC,MAAM,cAAc,GAAS,CAAC,EAAC,MAAM,EAAE,UAAU,EAAC,EAAE,EAAE,CAAC,CAAC;IAC9D,KAAK,EAAE;QACN,KAAK,CAAC,QAAQ,CAAC,GAAW;YACzB,UAAU,EAAE,CAAC;YACb,gEAAgE;YAChE,uEAAuE;YACvE,8DAA8D;YAC9D,gEAAgE;YAChE,wEAAwE;YACxE,wEAAwE;YACxE,yEAAyE;YACzE,aAAa;YACb,MAAM,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,EAAC,SAAS,EAAE,MAAM,EAAC,CAAC,CAAC;QAC7C,CAAC;KACD;CACD,CAAC,CAAC;AAEH,2EAA2E;AAC3E,MAAM,CAAC,MAAM,YAAY,GAAS,CAAC,EAAC,MAAM,EAAE,UAAU,EAAC,EAAE,EAAE,CAAC,CAAC;IAC5D,KAAK,EAAE;QACN,KAAK,CAAC,QAAQ,CAAC,OAAyB;YACvC,UAAU,EAAE,CAAC;YACb,MAAM,GAAG,GAAG,MAAM,CAAC,GAAG,EAAE,CAAC;YACzB,IAAI,OAAO,EAAE,IAAI,KAAK,IAAI,EAAE,CAAC;gBAC5B,uEAAuE;gBACvE,uEAAuE;gBACvE,yDAAyD;gBACzD,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,QAAQ,CACpC,GAAG,EAAE,CAAC,QAAQ,CAAC,eAAe,CAAC,SAAS,CACxC,CAAC;gBACF,OAAO,EAAC,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAC,CAAC;YACrC,CAAC;YACD,yEAAyE;YACzE,sEAAsE;YACtE,oEAAoE;YACpE,wEAAwE;YACxE,uEAAuE;YACvE,oEAAoE;YACpE,+DAA+D;YAC/D,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,YAAY,CAAC,EAAC,IAAI,EAAE,IAAI,EAAC,CAAC,CAAC;YACxD,OAAO,EAAC,GAAG,EAAE,IAAI,EAAE,eAAe,EAAE,OAAO,EAAC,CAAC;QAC9C,CAAC;KACD;CACD,CAAC,CAAC;AAEH,8EAA8E;AAC9E,MAAM,CAAC,MAAM,eAAe,GAAS,CAAC,EAAC,MAAM,EAAE,UAAU,EAAC,EAAE,EAAE,CAAC,CAAC;IAC/D,KAAK,EAAE;QACN,KAAK,CAAC,KAAK,CAAC,CAAC;YACZ,UAAU,EAAE,CAAC;YACb,MAAM,YAAY,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;QAC/B,CAAC;QACD,KAAK,CAAC,IAAI,CAAC,CAAC,EAAE,IAAI;YACjB,UAAU,EAAE,CAAC;YACb,MAAM,cAAc,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC5C,CAAC;KACD;CACD,CAAC,CAAC;AAEH,iFAAiF;AACjF,MAAM,CAAC,MAAM,QAAQ,GAAS,CAAC,EAAC,MAAM,EAAE,UAAU,EAAC,EAAE,EAAE,CAAC,CAAC;IACxD,KAAK,EAAE;QACN,KAAK,CAAC,IAAI,CAAC,UAAkB;YAC5B,UAAU,EAAE,CAAC;YACb,0EAA0E;YAC1E,mEAAmE;YACnE,gFAAgF;YAChF,sEAAsE;YACtE,yEAAyE;YACzE,kEAAkE;YAClE,0EAA0E;YAC1E,yEAAyE;YACzE,2EAA2E;YAC3E,uEAAuE;YACvE,qEAAqE;YACrE,oEAAoE;YACpE,uEAAuE;YACvE,OAAO,MAAM,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC;QACpC,CAAC;KACD;CACD,CAAC,CAAC;AAEH,iFAAiF;AACjF,MAAM,CAAC,MAAM,QAAQ,GAAS,CAAC,EAAC,MAAM,EAAE,UAAU,EAAC,EAAE,EAAE,CAAC,CAAC;IACxD,KAAK,EAAE;QACN,KAAK,CAAC,IAAI,CAAC,SAAwB;YAClC,UAAU,EAAE,CAAC;YACb,MAAM,OAAO,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;QAClC,CAAC;KACD;CACD,CAAC,CAAC;AAEH;;;;GAIG;AACH,MAAM,CAAC,MAAM,WAAW,GAAS,CAAC,EAAC,OAAO,EAAE,UAAU,EAAC,EAAE,EAAE,CAAC,CAAC;IAC5D,KAAK,EAAE;QACN,KAAK,CAAC,OAAO;YACZ,UAAU,EAAE,CAAC;YACb,MAAM,GAAG,GAAG,MAAM,OAAO,CAAC,OAAO,EAAE,CAAC;YACpC,OAAO,GAAG,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC;QAC9B,CAAC;QACD,KAAK,CAAC,UAAU,CAAC,OAAO;YACvB,UAAU,EAAE,CAAC;YACb,MAAM,OAAO,CAAC,UAAU,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC,CAAC;QACvD,CAAC;KACD;CACD,CAAC,CAAC;AAEH;;;;;GAKG;AACH,MAAM,CAAC,MAAM,cAAc,GAAoB;IAC9C,cAAc;IACd,YAAY;IACZ,eAAe;IACf,QAAQ;IACR,QAAQ;IACR,WAAW;CACX,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,UAAU,kBAAkB,CAAC,GAAgB;IAClD,OAAO,WAAW,CAAC,GAAG,EAAE,cAAc,CAAC,CAAC;AACzC,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,gBAAgB,CAC/B,GAAgB,EAChB,UAA2B;IAE3B,OAAO,WAAW,CAAC,GAAG,EAAE,CAAC,GAAG,cAAc,EAAE,GAAG,UAAU,CAAC,CAAC,CAAC;AAC7D,CAAC;AAED,8EAA8E;AAC9E,2EAA2E;AAC3E,+EAA+E;AAC/E,8EAA8E;AAE9E;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,CAAC,KAAK,UAAU,OAAO,CAC5B,IAAU,EACV,SAAwB;IAExB,QAAQ,SAAS,CAAC,IAAI,EAAE,CAAC;QACxB,KAAK,SAAS;YACb,MAAM,IAAI,CAAC,cAAc,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC;YACxC,OAAO;QACR,KAAK,SAAS;YACb,MAAM,cAAc,CAAC,IAAI,EAAE,SAAS,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,CAAC;YACvD,OAAO;QACR,KAAK,YAAY;YAChB,4DAA4D;YAC5D,MAAM,IAAI,CAAC,iBAAiB,EAAE,CAAC;YAC/B,OAAO;IACT,CAAC;AACF,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,cAAc,CAAC,IAAU,EAAE,UAAkB;IAC5D,uCAAuC;IACvC,MAAM,OAAO,GAAG,IAAI,QAAQ,CAAC,MAAM,EAAE,GAAG,EAAE,WAAW,UAAU,IAAI,CAGnC,CAAC;IACjC,OAAO,OAAO,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;AAC5B,CAAC;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CACjC,IAAU,EACV,UAAkB;IAElB,MAAM,MAAM,GAAG,cAAc,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC;IAChD,IAAI,CAAC;QACJ,MAAM,MAAM,CAAC,KAAK,CAAC,EAAC,OAAO,EAAE,uBAAuB,EAAC,CAAC,CAAC;IACxD,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,IAAI,CAAC,CAAC,KAAK,YAAY,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC;YAC/C,MAAM,KAAK,CAAC;QACb,CAAC;QACD,yEAAyE;QACzE,0EAA0E;QAC1E,MAAM,MAAM,CAAC,aAAa,CAAC,OAAO,EAAE,EAAC,OAAO,EAAE,uBAAuB,EAAC,CAAC,CAAC;IACzE,CAAC;AACF,CAAC;AAED,4EAA4E;AAC5E,SAAS,YAAY,CAAC,CASrB;IACA,OAAO;QACN,IAAI,EAAE,CAAC,CAAC,IAAI;QACZ,KAAK,EAAE,CAAC,CAAC,KAAK;QACd,MAAM,EAAE,CAAC,CAAC,MAAM;QAChB,IAAI,EAAE,CAAC,CAAC,IAAI;QACZ,OAAO,EAAE,CAAC,CAAC,OAAO;QAClB,QAAQ,EAAE,CAAC,CAAC,QAAQ;QACpB,MAAM,EAAE,CAAC,CAAC,MAAM;QAChB,QAAQ,EAAE,CAAC,CAAC,QAAQ;KACpB,CAAC;AACH,CAAC;AAED,8DAA8D;AAC9D,SAAS,cAAc,CAAC,CAAS;IAChC,OAAO;QACN,IAAI,EAAE,CAAC,CAAC,IAAI;QACZ,KAAK,EAAE,CAAC,CAAC,KAAK;QACd,MAAM,EAAE,CAAC,CAAC,MAAM;QAChB,IAAI,EAAE,CAAC,CAAC,IAAI;QACZ,OAAO,EAAE,CAAC,CAAC,OAAO;QAClB,QAAQ,EAAE,CAAC,CAAC,QAAQ;QACpB,MAAM,EAAE,CAAC,CAAC,MAAM;QAChB,QAAQ,EAAE,CAAC,CAAC,QAAQ;KACpB,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { Hand } from './hand-host.js';
|
|
2
|
+
/**
|
|
3
|
+
* Explicit, declarative third-party-hand loading (Phase 2 of the "hands" prd,
|
|
4
|
+
* `work/prds/tasked/hands-pluggable-page-capabilities.md`; ADR-0007).
|
|
5
|
+
*
|
|
6
|
+
* A third-party **hand** is in-process Node code the host will hand the live
|
|
7
|
+
* Playwright page (see {@link Hand}). Because that is arbitrary Node code in the
|
|
8
|
+
* webhands process — a strictly LARGER surface than `eval` (which is sandboxed
|
|
9
|
+
* to the page's JS world) — loading a hand is a TRUST act: the right mental
|
|
10
|
+
* model is npm supply-chain trust, "loading a hand == trusting an in-process npm
|
|
11
|
+
* dependency" (ADR-0007). This module makes that trust act EXPLICIT and
|
|
12
|
+
* DECLARATIVE, modeled on pi's `packages[]`:
|
|
13
|
+
*
|
|
14
|
+
* - A hand loads ONLY because it is NAMED in config ({@link HandsConfig}), each
|
|
15
|
+
* entry carrying a PINNED entry point. NAMING a hand in config IS the trust
|
|
16
|
+
* act.
|
|
17
|
+
* - There is NO auto-discovery, NO `node_modules` scan, NO convention-inferred
|
|
18
|
+
* entry file. An installed-but-not-named hand never loads.
|
|
19
|
+
* - INSTALL is SEPARATE from LOAD/trust: `npm install <hand>` alone never
|
|
20
|
+
* auto-loads it; the operator installs the dependency themselves (a managed
|
|
21
|
+
* installer is explicitly OUT of scope) and then names it here to load it.
|
|
22
|
+
*
|
|
23
|
+
* The trust boundary stays LOCAL-only: hands widen the in-process surface, not
|
|
24
|
+
* the remote one (no new network listener). This module never installs, never
|
|
25
|
+
* scans a directory, and never reads anything beyond the entries the config
|
|
26
|
+
* explicitly names.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* One explicitly-named third-party hand. NAMING an entry here is the trust act
|
|
30
|
+
* (ADR-0007); webhands will load EXACTLY this entry and nothing it was not told
|
|
31
|
+
* about.
|
|
32
|
+
*/
|
|
33
|
+
export interface HandEntry {
|
|
34
|
+
/**
|
|
35
|
+
* The operator-chosen identifier for this hand. Used in error messages and
|
|
36
|
+
* to make the config self-documenting; it has no install side effect (naming
|
|
37
|
+
* is the trust act, not an install instruction).
|
|
38
|
+
*/
|
|
39
|
+
readonly name: string;
|
|
40
|
+
/**
|
|
41
|
+
* Descriptive provenance, e.g. `npm:@scope/hand` or `git:https://…`. Mirrors
|
|
42
|
+
* pi's named-source shape. It is RECORDED, not acted on: webhands does NOT
|
|
43
|
+
* install from it (install is separate from load/trust — the operator
|
|
44
|
+
* installs the dependency themselves). Optional.
|
|
45
|
+
*/
|
|
46
|
+
readonly source?: string;
|
|
47
|
+
/**
|
|
48
|
+
* The PINNED entry point: the exact module file webhands will `import()`. No
|
|
49
|
+
* convention-inferred entry, no `package.json` `main` lookup, no directory
|
|
50
|
+
* scan — the operator pins the file. A relative path is resolved against
|
|
51
|
+
* {@link LoadHandsOptions.baseDir} (the config's own directory); an absolute
|
|
52
|
+
* path is used as-is.
|
|
53
|
+
*/
|
|
54
|
+
readonly entry: string;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The webhands hand config: an EXPLICIT named list of third-party hands. Modeled
|
|
58
|
+
* on pi's `settings.json` `packages[]` (a named list of sources, each with a
|
|
59
|
+
* pinned entry). An empty/absent list means no third-party hands load.
|
|
60
|
+
*/
|
|
61
|
+
export interface HandsConfig {
|
|
62
|
+
readonly hands: readonly HandEntry[];
|
|
63
|
+
}
|
|
64
|
+
/** The filename webhands reads the hand config from, under the home root. */
|
|
65
|
+
export declare const HANDS_CONFIG_FILENAME = "hands.json";
|
|
66
|
+
/** A loaded hand paired with the config entry that named it (for diagnostics). */
|
|
67
|
+
export interface LoadedHand {
|
|
68
|
+
readonly entry: HandEntry;
|
|
69
|
+
readonly hand: Hand;
|
|
70
|
+
}
|
|
71
|
+
/** Options controlling how config entries resolve to modules. */
|
|
72
|
+
export interface LoadHandsOptions {
|
|
73
|
+
/**
|
|
74
|
+
* The base directory a relative {@link HandEntry.entry} resolves against.
|
|
75
|
+
* Defaults to the current working directory. In production this is the
|
|
76
|
+
* config's own directory; in tests it points at a scratch dir so the real
|
|
77
|
+
* config/loading paths are never touched.
|
|
78
|
+
*/
|
|
79
|
+
readonly baseDir?: string;
|
|
80
|
+
/**
|
|
81
|
+
* The importer used to load a pinned entry. Defaults to a dynamic `import()`
|
|
82
|
+
* of the resolved file URL. Injectable so tests can load a fixture hand
|
|
83
|
+
* without a real on-disk module.
|
|
84
|
+
*/
|
|
85
|
+
readonly importModule?: (specifier: string) => Promise<unknown>;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Error raised when a NAMED hand cannot be loaded: its pinned entry is missing,
|
|
89
|
+
* fails to import, or does not export a {@link Hand}. A named hand that fails to
|
|
90
|
+
* resolve is a hard error (not a silent skip) so a typo or a broken/half-removed
|
|
91
|
+
* dependency surfaces loudly rather than silently dropping a capability the
|
|
92
|
+
* operator explicitly trusted.
|
|
93
|
+
*/
|
|
94
|
+
export declare class HandLoadError extends Error {
|
|
95
|
+
readonly entry: HandEntry;
|
|
96
|
+
constructor(entry: HandEntry, detail: string, options?: {
|
|
97
|
+
cause?: unknown;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Read the hand config from `<homeRoot>/hands.json`. A missing file yields an
|
|
102
|
+
* EMPTY config (no third-party hands) — the default, install-separate-from-load
|
|
103
|
+
* posture: webhands loads nothing it was not explicitly told to. A present file
|
|
104
|
+
* that is malformed is a hard error (so a broken config is not silently treated
|
|
105
|
+
* as "no hands").
|
|
106
|
+
*/
|
|
107
|
+
export declare function readHandsConfig(homeRoot: string): Promise<HandsConfig>;
|
|
108
|
+
/**
|
|
109
|
+
* Validate a parsed config object into a {@link HandsConfig}. Enforces the
|
|
110
|
+
* explicit-named-list + pinned-entry shape: every entry MUST carry a non-empty
|
|
111
|
+
* `name` and a non-empty `entry` (the pinned module). A missing/blank pin is
|
|
112
|
+
* rejected rather than guessed (no convention-inferred entry).
|
|
113
|
+
*/
|
|
114
|
+
export declare function normalizeConfig(parsed: unknown, whence?: string): HandsConfig;
|
|
115
|
+
/**
|
|
116
|
+
* Load every hand NAMED in `config`, in declaration order. Each entry's pinned
|
|
117
|
+
* {@link HandEntry.entry} is resolved (relative ⇒ against
|
|
118
|
+
* {@link LoadHandsOptions.baseDir}) and imported; the module must export a
|
|
119
|
+
* {@link Hand} as its DEFAULT export or as a named `hand` export. A failure to
|
|
120
|
+
* resolve/import/validate a named hand throws {@link HandLoadError} (named hands
|
|
121
|
+
* fail loud, never silently skip).
|
|
122
|
+
*
|
|
123
|
+
* Loading nothing for an empty list is the whole point of the model: only the
|
|
124
|
+
* entries the operator explicitly named load, so an installed-but-not-named hand
|
|
125
|
+
* is never reached here.
|
|
126
|
+
*/
|
|
127
|
+
export declare function loadHands(config: HandsConfig, options?: LoadHandsOptions): Promise<LoadedHand[]>;
|
|
128
|
+
//# sourceMappingURL=hand-loading.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hand-loading.d.ts","sourceRoot":"","sources":["../src/hand-loading.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAC,IAAI,EAAC,MAAM,gBAAgB,CAAC;AAEzC;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACzB;;;;OAIG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;OAMG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACvB;AAED;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC3B,QAAQ,CAAC,KAAK,EAAE,SAAS,SAAS,EAAE,CAAC;CACrC;AAED,6EAA6E;AAC7E,eAAO,MAAM,qBAAqB,eAAe,CAAC;AAElD,kFAAkF;AAClF,MAAM,WAAW,UAAU;IAC1B,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;CACpB;AAED,iEAAiE;AACjE,MAAM,WAAW,gBAAgB;IAChC;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;OAIG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CAChE;AAED;;;;;;GAMG;AACH,qBAAa,aAAc,SAAQ,KAAK;IACvC,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC;gBACd,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAC;CAQzE;AAED;;;;;;GAMG;AACH,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAkB5E;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAC9B,MAAM,EAAE,OAAO,EACf,MAAM,SAAgB,GACpB,WAAW,CAab;AAuBD;;;;;;;;;;;GAWG;AACH,wBAAsB,SAAS,CAC9B,MAAM,EAAE,WAAW,EACnB,OAAO,GAAE,gBAAqB,GAC5B,OAAO,CAAC,UAAU,EAAE,CAAC,CA2BvB"}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
/** The filename webhands reads the hand config from, under the home root. */
|
|
5
|
+
export const HANDS_CONFIG_FILENAME = 'hands.json';
|
|
6
|
+
/**
|
|
7
|
+
* Error raised when a NAMED hand cannot be loaded: its pinned entry is missing,
|
|
8
|
+
* fails to import, or does not export a {@link Hand}. A named hand that fails to
|
|
9
|
+
* resolve is a hard error (not a silent skip) so a typo or a broken/half-removed
|
|
10
|
+
* dependency surfaces loudly rather than silently dropping a capability the
|
|
11
|
+
* operator explicitly trusted.
|
|
12
|
+
*/
|
|
13
|
+
export class HandLoadError extends Error {
|
|
14
|
+
entry;
|
|
15
|
+
constructor(entry, detail, options) {
|
|
16
|
+
super(`failed to load hand '${entry.name}' (entry '${entry.entry}'): ${detail}`, options);
|
|
17
|
+
this.name = 'HandLoadError';
|
|
18
|
+
this.entry = entry;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Read the hand config from `<homeRoot>/hands.json`. A missing file yields an
|
|
23
|
+
* EMPTY config (no third-party hands) — the default, install-separate-from-load
|
|
24
|
+
* posture: webhands loads nothing it was not explicitly told to. A present file
|
|
25
|
+
* that is malformed is a hard error (so a broken config is not silently treated
|
|
26
|
+
* as "no hands").
|
|
27
|
+
*/
|
|
28
|
+
export async function readHandsConfig(homeRoot) {
|
|
29
|
+
const path = resolve(homeRoot, HANDS_CONFIG_FILENAME);
|
|
30
|
+
let raw;
|
|
31
|
+
try {
|
|
32
|
+
raw = await readFile(path, 'utf8');
|
|
33
|
+
}
|
|
34
|
+
catch (cause) {
|
|
35
|
+
if (cause?.code === 'ENOENT') {
|
|
36
|
+
return { hands: [] };
|
|
37
|
+
}
|
|
38
|
+
throw cause;
|
|
39
|
+
}
|
|
40
|
+
let parsed;
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(raw);
|
|
43
|
+
}
|
|
44
|
+
catch (cause) {
|
|
45
|
+
throw new Error(`invalid hand config at ${path}: not valid JSON`, { cause });
|
|
46
|
+
}
|
|
47
|
+
return normalizeConfig(parsed, path);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Validate a parsed config object into a {@link HandsConfig}. Enforces the
|
|
51
|
+
* explicit-named-list + pinned-entry shape: every entry MUST carry a non-empty
|
|
52
|
+
* `name` and a non-empty `entry` (the pinned module). A missing/blank pin is
|
|
53
|
+
* rejected rather than guessed (no convention-inferred entry).
|
|
54
|
+
*/
|
|
55
|
+
export function normalizeConfig(parsed, whence = 'hand config') {
|
|
56
|
+
if (parsed === null || typeof parsed !== 'object') {
|
|
57
|
+
throw new Error(`invalid ${whence}: expected an object`);
|
|
58
|
+
}
|
|
59
|
+
const handsValue = parsed.hands;
|
|
60
|
+
if (handsValue === undefined) {
|
|
61
|
+
return { hands: [] };
|
|
62
|
+
}
|
|
63
|
+
if (!Array.isArray(handsValue)) {
|
|
64
|
+
throw new Error(`invalid ${whence}: 'hands' must be an array`);
|
|
65
|
+
}
|
|
66
|
+
const hands = handsValue.map((value, i) => normalizeEntry(value, i, whence));
|
|
67
|
+
return { hands };
|
|
68
|
+
}
|
|
69
|
+
function normalizeEntry(value, i, whence) {
|
|
70
|
+
if (value === null || typeof value !== 'object') {
|
|
71
|
+
throw new Error(`invalid ${whence}: hands[${i}] must be an object`);
|
|
72
|
+
}
|
|
73
|
+
const { name, entry, source } = value;
|
|
74
|
+
if (typeof name !== 'string' || name === '') {
|
|
75
|
+
throw new Error(`invalid ${whence}: hands[${i}].name must be a non-empty string`);
|
|
76
|
+
}
|
|
77
|
+
if (typeof entry !== 'string' || entry === '') {
|
|
78
|
+
throw new Error(`invalid ${whence}: hands[${i}].entry (the pinned entry point) must be a non-empty string`);
|
|
79
|
+
}
|
|
80
|
+
if (source !== undefined && typeof source !== 'string') {
|
|
81
|
+
throw new Error(`invalid ${whence}: hands[${i}].source must be a string`);
|
|
82
|
+
}
|
|
83
|
+
return source === undefined ? { name, entry } : { name, entry, source };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Load every hand NAMED in `config`, in declaration order. Each entry's pinned
|
|
87
|
+
* {@link HandEntry.entry} is resolved (relative ⇒ against
|
|
88
|
+
* {@link LoadHandsOptions.baseDir}) and imported; the module must export a
|
|
89
|
+
* {@link Hand} as its DEFAULT export or as a named `hand` export. A failure to
|
|
90
|
+
* resolve/import/validate a named hand throws {@link HandLoadError} (named hands
|
|
91
|
+
* fail loud, never silently skip).
|
|
92
|
+
*
|
|
93
|
+
* Loading nothing for an empty list is the whole point of the model: only the
|
|
94
|
+
* entries the operator explicitly named load, so an installed-but-not-named hand
|
|
95
|
+
* is never reached here.
|
|
96
|
+
*/
|
|
97
|
+
export async function loadHands(config, options = {}) {
|
|
98
|
+
const baseDir = options.baseDir ?? process.cwd();
|
|
99
|
+
const importModule = options.importModule ??
|
|
100
|
+
((specifier) => import(specifier));
|
|
101
|
+
const loaded = [];
|
|
102
|
+
for (const entry of config.hands) {
|
|
103
|
+
const specifier = resolveEntrySpecifier(entry.entry, baseDir);
|
|
104
|
+
let mod;
|
|
105
|
+
try {
|
|
106
|
+
mod = await importModule(specifier);
|
|
107
|
+
}
|
|
108
|
+
catch (cause) {
|
|
109
|
+
throw new HandLoadError(entry, 'could not import the pinned entry', {
|
|
110
|
+
cause,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const hand = extractHand(mod);
|
|
114
|
+
if (hand === undefined) {
|
|
115
|
+
throw new HandLoadError(entry, 'the module does not export a Hand (expected a default export or a named `hand` export that is a function)');
|
|
116
|
+
}
|
|
117
|
+
loaded.push({ entry, hand });
|
|
118
|
+
}
|
|
119
|
+
return loaded;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Resolve a pinned entry to an import specifier. A relative/absolute filesystem
|
|
123
|
+
* path is resolved against `baseDir` and converted to a `file://` URL (so a
|
|
124
|
+
* Windows path or a path with spaces imports correctly); anything else is passed
|
|
125
|
+
* through verbatim (the operator may pin a bare package specifier they have
|
|
126
|
+
* installed themselves — webhands does not install it).
|
|
127
|
+
*/
|
|
128
|
+
function resolveEntrySpecifier(entry, baseDir) {
|
|
129
|
+
if (isAbsolute(entry) || entry.startsWith('.')) {
|
|
130
|
+
return pathToFileURL(resolve(baseDir, entry)).href;
|
|
131
|
+
}
|
|
132
|
+
return entry;
|
|
133
|
+
}
|
|
134
|
+
/** Pull a {@link Hand} out of an imported module (default or named `hand`). */
|
|
135
|
+
function extractHand(mod) {
|
|
136
|
+
if (mod === null || typeof mod !== 'object') {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
const record = mod;
|
|
140
|
+
const candidate = record.default ?? record.hand;
|
|
141
|
+
return typeof candidate === 'function' ? candidate : undefined;
|
|
142
|
+
}
|
|
143
|
+
//# sourceMappingURL=hand-loading.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hand-loading.js","sourceRoot":"","sources":["../src/hand-loading.ts"],"names":[],"mappings":"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"}
|