@wyndlass/app-sdk 0.0.0 → 1.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/README.md +212 -1
- package/cli/app-package.mjs +111 -0
- package/cli/commands.mjs +133 -0
- package/cli/main.mjs +73 -0
- package/cli/package.mjs +119 -0
- package/cli/preview.mjs +142 -0
- package/cli/zip.mjs +63 -0
- package/dist/contract/app-contract.d.ts +221 -0
- package/dist/contract/app-contract.js +6 -0
- package/dist/contract/app-definition.d.ts +155 -0
- package/dist/contract/app-definition.js +3 -0
- package/dist/contract/data-answer.d.ts +40 -0
- package/dist/contract/data-answer.js +3 -0
- package/dist/contract/data-calendar.d.ts +57 -0
- package/dist/contract/data-calendar.js +3 -0
- package/dist/contract/data-news.d.ts +29 -0
- package/dist/contract/data-news.js +3 -0
- package/dist/contract/data-quotes.d.ts +75 -0
- package/dist/contract/data-quotes.js +3 -0
- package/dist/contract/data-series.d.ts +51 -0
- package/dist/contract/data-series.js +3 -0
- package/dist/contract/data-snapshot.d.ts +30 -0
- package/dist/contract/data-snapshot.js +3 -0
- package/dist/contract/data-weather.d.ts +421 -0
- package/dist/contract/data-weather.js +3 -0
- package/dist/contract/player-api-event.d.ts +253 -0
- package/dist/contract/player-api-event.js +3 -0
- package/dist/contract/player-api-status.d.ts +178 -0
- package/dist/contract/player-api-status.js +3 -0
- package/dist/easing.d.ts +16 -0
- package/dist/easing.js +71 -0
- package/dist/index.d.ts +94 -0
- package/dist/index.js +218 -0
- package/dist/preview/frame/frame.js +2 -0
- package/dist/preview/frame/index.html +12 -0
- package/dist/preview/index.html +40 -0
- package/dist/preview/page.js +7 -0
- package/dist/qr.d.ts +5 -0
- package/dist/qr.js +14 -0
- package/dist/react.d.ts +52 -0
- package/dist/react.js +149 -0
- package/package.json +83 -4
- package/protocol/app-definition.schema.json +435 -0
- package/protocol/app-packages.json +172 -0
- package/protocol/common.schema.json +197 -0
- package/protocol/samples/calendar.json +63 -0
- package/protocol/samples/events/app.command.json +14 -0
- package/protocol/samples/events/item.end.json +13 -0
- package/protocol/samples/events/item.start.json +18 -0
- package/protocol/samples/events/state.changed.json +11 -0
- package/protocol/samples/news.json +37 -0
- package/protocol/samples/player-status.json +58 -0
- package/protocol/samples/weather.json +59 -0
- package/skill/wyndlass-app/SKILL.md +90 -0
- package/skill/wyndlass-app/references/contract.md +91 -0
- package/skill/wyndlass-app/references/mcp.md +79 -0
- package/skill/wyndlass-app/references/templates.md +38 -0
- package/templates/react/AGENTS.md +17 -0
- package/templates/react/README.md +23 -0
- package/templates/react/app.json +14 -0
- package/templates/react/gitignore +2 -0
- package/templates/react/package.json +22 -0
- package/templates/react/src/app.css +7 -0
- package/templates/react/src/app.tsx +39 -0
- package/templates/react/src/env.d.ts +13 -0
- package/templates/react/tsconfig.json +13 -0
- package/templates/vanilla/AGENTS.md +17 -0
- package/templates/vanilla/README.md +23 -0
- package/templates/vanilla/app.json +14 -0
- package/templates/vanilla/gitignore +2 -0
- package/templates/vanilla/package.json +18 -0
- package/templates/vanilla/src/app.css +7 -0
- package/templates/vanilla/src/app.ts +59 -0
- package/templates/vanilla/src/env.d.ts +13 -0
- package/templates/vanilla/tsconfig.json +12 -0
package/README.md
CHANGED
|
@@ -1,3 +1,214 @@
|
|
|
1
1
|
# @wyndlass/app-sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Build your own apps for Wyndlass's screens. An app is a web component, written in TypeScript or React. You import it into your organization, a person reviews it, and layouts place it like any of Wyndlass's own apps.
|
|
4
|
+
|
|
5
|
+
This package has:
|
|
6
|
+
- the runtime Wyndlass's own apps are built on;
|
|
7
|
+
- a React adapter;
|
|
8
|
+
- two templates;
|
|
9
|
+
- the command `wyndlass-app`, which previews, checks and packs an app;
|
|
10
|
+
- a skill that builds, imports and schedules an app with your coding agent.
|
|
11
|
+
|
|
12
|
+
It needs Node 20 or later. MIT licensed, © Northwynd Labs LLC.
|
|
13
|
+
|
|
14
|
+
## Before you start
|
|
15
|
+
|
|
16
|
+
- **Imported apps must be on** where you import: an Owner or Admin turns them on in the CMS, under **Settings**, **Imported apps**.
|
|
17
|
+
- **To import an app,** you need a role that may add media at that place. A later version goes to the app where it already is, so it needs that role there.
|
|
18
|
+
- **Versions are reviewed:** a person who may approve media approves an app's first version, and any later version that asks for more than the one before it.
|
|
19
|
+
- **Screens play an approved version** only where their player can run imported apps. A screen whose player is too old leaves the app out, and plays the rest of the layout.
|
|
20
|
+
|
|
21
|
+
## Start an app
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npx @wyndlass/app-sdk create queue-board --template react --id com.example.queue-board
|
|
25
|
+
cd queue-board
|
|
26
|
+
npm install
|
|
27
|
+
npm run preview
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`create` makes a working app from a template:
|
|
31
|
+
- `react`: a React component, rendered in the app's shadow root;
|
|
32
|
+
- `vanilla`: TypeScript on `WyndlassApp`, with no framework.
|
|
33
|
+
|
|
34
|
+
Each template is a small app with two pages: a heading and the time, then the weather. It has params of four kinds, says `ready()`, and shows the weather the screen kept while it asks again. The project has a README, an `AGENTS.md` for coding agents, and the scripts `preview`, `check`, `pack` and `typecheck`.
|
|
35
|
+
|
|
36
|
+
The id is a reverse-domain name in your organization's own domain. Ids under `com.wyndlass.` are Wyndlass's, and are refused.
|
|
37
|
+
|
|
38
|
+
## With a coding agent
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
npx @wyndlass/app-sdk skill install
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
It adds the skill `wyndlass-app` to the project's `.claude/skills/`, where Claude Code finds it. With `--user`, it goes in `~/.claude/skills/` instead, for every project. Running it again replaces the copy with this version's.
|
|
45
|
+
|
|
46
|
+
Then ask your agent for the app you want. The skill:
|
|
47
|
+
1. asks what the app shows and to whom, the screens' orientation, the data it needs, and its name and id;
|
|
48
|
+
2. makes it from a template, and previews it with you in both orientations and in the CMS's frame;
|
|
49
|
+
3. checks and packs it;
|
|
50
|
+
4. imports, places and schedules it through the Wyndlass MCP, asking you before each.
|
|
51
|
+
|
|
52
|
+
The agent never approves a version: a person does, in the CMS.
|
|
53
|
+
|
|
54
|
+
## Preview it
|
|
55
|
+
|
|
56
|
+
`npm run preview` (`wyndlass-app preview`) runs the app as screens run it, at `http://127.0.0.1:5199/`, on your computer only. The page is the same host Wyndlass's players run, with the same contract, deadline and error handling. It draws the app again whenever a source or `app.json` changes, and says why when a source doesn't build.
|
|
57
|
+
|
|
58
|
+
Its controls:
|
|
59
|
+
- **The screen:** its size (1920 × 1080, 1080 × 1920, 3840 × 2160, or any), its name, place and fields, the time zone and the locale.
|
|
60
|
+
- **The app's params,** each shown while its `when` holds, as in the designer.
|
|
61
|
+
- **The clock:** play, pause, seek, and speeds from 0.25× to 4×.
|
|
62
|
+
- **Enter and exit,** with each preset and how long it lasts, and the app's pages.
|
|
63
|
+
- **Data:** each data source's answer, from samples you can edit in the page. `--data <dir>` reads `<source>.json` there instead (the answer's `data`, or a whole answer). The preview reaches no data service. A source the definition doesn't declare gets no answer, as on a screen.
|
|
64
|
+
- **Tables:** each file in `<dir>/tables/`, or one you add in the page. A file may be CSV (its header gives the columns), a JSON array of rows, or a snapshot. A `datatable` param chooses among them, and an app with `slots` is bound to the first.
|
|
65
|
+
- **The people at the screen,** for an app that declares the `impressions` sensor: someone arrives, looks, or leaves.
|
|
66
|
+
- **The player's controller,** for what `capabilities.player` allows. The page sends the app the player's events, each one editable. A command the app may give is answered "not applied", and one it may not is refused, as on a screen.
|
|
67
|
+
|
|
68
|
+
**What a screen would report** is shown beside the app: how long it took to say it was ready, or that it was late, and each failure with its reason.
|
|
69
|
+
|
|
70
|
+
**`npm run preview -- --frame`** runs the app in the frame the CMS gives every imported app: sandboxed, on an origin of its own, with no storage, cookies or parent page. An app that reaches for one of them works on screens but fails in the CMS's designer. Run it with `--frame` before you import.
|
|
71
|
+
|
|
72
|
+
## Check and pack it
|
|
73
|
+
|
|
74
|
+
- **`npm run check`** (`wyndlass-app check`) makes the checks Wyndlass makes when you import an app, and gives the same answer:
|
|
75
|
+
- the package's shape and limits;
|
|
76
|
+
- the definition against its schema and Wyndlass's rules;
|
|
77
|
+
- the module: ES2020, no imports, a default export.
|
|
78
|
+
|
|
79
|
+
It also warns, without refusing, of a module over 1 MiB and of a module that never calls `ready()`.
|
|
80
|
+
- **`npm run pack`** (`wyndlass-app pack`) writes `dist/<id>-<version>.zip`, the package you import, after the checks. It writes nothing for an app Wyndlass would refuse.
|
|
81
|
+
- The module is one minified ES2020 file. CSS a source imports becomes text for `css()`, and images and fonts are inlined.
|
|
82
|
+
- The same sources give the same bytes, so importing a version again changes nothing.
|
|
83
|
+
- The source map is written beside the zip, never in it.
|
|
84
|
+
|
|
85
|
+
## Import it
|
|
86
|
+
|
|
87
|
+
**In the CMS:** under **Content**, **Library**, choose **Import an app**, choose the place it goes, and choose the zip. Wyndlass checks it, then says whether it is approved or held for review.
|
|
88
|
+
|
|
89
|
+
**With the Wyndlass MCP**, from a coding agent, as the person who connected it. The MCP lists its import tools only while imported apps are on at the top of your organization.
|
|
90
|
+
1. `app.prepareImport` at the place, with the zip's name and size, answers an address and headers for an hour.
|
|
91
|
+
2. `npx wyndlass-app upload <address> dist/<id>-<version>.zip --header "<name>: <value>" …` PUTs the zip there. The command holds no token and signs in to nothing.
|
|
92
|
+
3. `app.import` with the same place and the upload's id imports it.
|
|
93
|
+
4. `app.list` shows when the version is checked, and whether it is approved, held, refused or rejected, with the reason.
|
|
94
|
+
|
|
95
|
+
**Reviews:**
|
|
96
|
+
- An app's first version is held until a person approves it in the CMS. So is a later version that asks for more than the newest approved one: control of the player, a new sensor, audio, or a new network host. Any other version is approved with its import.
|
|
97
|
+
- Any version is also held where the place's **Media review** holds everything new, or holds media made with AI and the Assistant or one of your organization's agents imported it.
|
|
98
|
+
- Agents never approve.
|
|
99
|
+
- A held version can be placed and previewed, and reaches no screen. Screens keep playing the newest approved version meanwhile.
|
|
100
|
+
- **A version never changes.** The same bytes again are answered with the version you have. Other bytes need a new `version` in `app.json`.
|
|
101
|
+
|
|
102
|
+
**Place it:** in the designer, your organization's apps are under **Apps**, **Your apps**. Place one as you place Wyndlass's.
|
|
103
|
+
|
|
104
|
+
## What an app is
|
|
105
|
+
|
|
106
|
+
- **One ES2020 module,** whose default export is its element's class. The class extends `HTMLElement`, usually through `WyndlassApp`.
|
|
107
|
+
- The host defines it under a tag of its own: your definition's `element`, a hyphen and a suffix.
|
|
108
|
+
- It hands the element its context once, before the element joins the page. Nothing is left on the element for another app to read.
|
|
109
|
+
- **A definition, `app.json`,** valid against `app-definition.schema.json` (in this package, as `@wyndlass/app-sdk/app-definition.schema.json`):
|
|
110
|
+
- `id`, `version`, `name` and `element`, which are required, and `description`;
|
|
111
|
+
- `params`: each of a kind the schema lists, such as `string`, `number`, `boolean`, `enum`, `color`, `font`, `media` or `datatable`, with its label, default, values, limits and help, and `when` it applies;
|
|
112
|
+
- `data`: the data sources it reads, from `weather`, `news`, `image` and `calendar`;
|
|
113
|
+
- `capabilities`: `player` (`read`, the default, or `control`), `sensors` (`impressions`), `audio`, and `network`, the hosts it reaches;
|
|
114
|
+
- `slots` and `animation`, for apps bound to a table's rows and for the enter and exit presets it takes.
|
|
115
|
+
- **A package** is a zip of exactly those two, `app.json` and `app.js`. `pack` makes it.
|
|
116
|
+
|
|
117
|
+
## The runtime
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { WyndlassApp, fit } from "@wyndlass/app-sdk";
|
|
121
|
+
|
|
122
|
+
export default class QueueBoard extends WyndlassApp {
|
|
123
|
+
#number!: HTMLElement;
|
|
124
|
+
|
|
125
|
+
protected mount() {
|
|
126
|
+
this.css(`:host { display: grid; place-items: center; color: white; } b { font-size: ${fit(0.5, 0.3)}; }`);
|
|
127
|
+
this.#number = document.createElement("b");
|
|
128
|
+
this.root.append(this.#number);
|
|
129
|
+
this.ready();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
protected draw() {
|
|
133
|
+
this.#number.textContent = String(this.prop("number", 7));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- **`WyndlassApp`:** extend it, and write `mount()` (build the shadow DOM, once) and `draw(epochMs)` (draw that moment of the layout's clock). It gives:
|
|
139
|
+
- `prop(name, default)`, `font()` (the font param as CSS), `now()`, `startedAt()`, `timeZone` and `locale`;
|
|
140
|
+
- `css(text)`: styles for the shadow root, as a constructable stylesheet;
|
|
141
|
+
- `ready()`: says the app may be shown, once, within 5 s of its start;
|
|
142
|
+
- `--w` and `--h`: the box's size, for text sized to it with `fit`;
|
|
143
|
+
- the enter and exit presets (fade, scale, slide-up, slide-left), staggered over `staggered()`'s items and drawn on the host's clock;
|
|
144
|
+
- `paging()`, for an app that turns pages.
|
|
145
|
+
- **What acts or reads for the app is a function taking it,** not a method of the element, so another app on the page can't use it:
|
|
146
|
+
- `data(this, source, params)`, the data service's answer;
|
|
147
|
+
- `kept(this, source, params)`, the answer the screen last kept, at once;
|
|
148
|
+
- `image(this, path)`, `snapshot(this, table)`, `controllerOf(this)` and `impressionsOf(this)`;
|
|
149
|
+
- `contextOf(this)`, for the rest.
|
|
150
|
+
- **Helpers:** `fit`, `typographyCss`, `color` and `escape`, and the presets' curves `easingOf`, `cubicBezier` and `staggerTimes`.
|
|
151
|
+
- **Types:**
|
|
152
|
+
- the contract's (`AppContext`, `DataAnswer`, `HookOptions` and the rest);
|
|
153
|
+
- the definition's (`AppDefinition`);
|
|
154
|
+
- each data source's answer, as a namespace (`weather`, `news`, `calendar`, `tables`).
|
|
155
|
+
- **`@wyndlass/app-sdk/qr`:** `qrModules(text)`, a QR code's modules, made without the network.
|
|
156
|
+
|
|
157
|
+
**Outside a host,** such as a unit test or a page of your own, an app still runs: the time is now, each prop is its default, `data` and `image` are refused as unavailable, and `ready()` does nothing.
|
|
158
|
+
|
|
159
|
+
## React (`@wyndlass/app-sdk/react`)
|
|
160
|
+
|
|
161
|
+
```tsx
|
|
162
|
+
import { useEffect } from "react";
|
|
163
|
+
import { reactApp, useApp, useData, useNow, usePaging } from "@wyndlass/app-sdk/react";
|
|
164
|
+
import css from "./app.css";
|
|
165
|
+
|
|
166
|
+
function Board() {
|
|
167
|
+
const app = useApp();
|
|
168
|
+
const now = useNow();
|
|
169
|
+
const page = usePaging(2, 8000);
|
|
170
|
+
const { answer, error } = useData("weather", { lat: 49.9, lon: -97.14 });
|
|
171
|
+
useEffect(() => { if (answer || error) app.ready(); }, [app, answer, error]);
|
|
172
|
+
// …
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export default reactApp(Board, { css });
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
- **`reactApp(Component, { css })`** makes the element's class, the module's default export. The component renders in the app's shadow root, with `css` as its styles. React is a peer dependency, and `pack` bundles it.
|
|
179
|
+
- **`useApp()`:** `prop(name, default)`, `ready()`, `timeZone`, `locale`, the screen's `place` and `screen`, and `preview`.
|
|
180
|
+
- **`useNow()`:** the layout's clock, drawn again at each of its frames.
|
|
181
|
+
- **`usePaging(count, ms)`:** the page showing now. It also tells the host the pages.
|
|
182
|
+
- **`useData(source, params)`:** at once the answer the screen kept, then the service's, asked again every 10 minutes. `error` is set once an ask fails with nothing to show.
|
|
183
|
+
- **`useSnapshot(table)`:** a data table's snapshot.
|
|
184
|
+
|
|
185
|
+
The hooks reach the app's context through the SDK's own module, never through React's tree, so nothing another app could use hangs off the shadow root.
|
|
186
|
+
|
|
187
|
+
## Limits
|
|
188
|
+
|
|
189
|
+
- The zip: at most 6 MiB, holding only `app.json` (at most 256 KiB) and `app.js` (at most 5 MiB).
|
|
190
|
+
- The module: ES2020, one file, importing nothing.
|
|
191
|
+
- Data: only the sources your definition declares, from `weather`, `news`, `image` and `calendar`.
|
|
192
|
+
|
|
193
|
+
## What screens do
|
|
194
|
+
|
|
195
|
+
- **They draw on the layout's clock.** The time comes from `now()` (or `useNow()`), never `Date.now()`, so a seek or a pause draws the right moment.
|
|
196
|
+
- **`ready()` within 5 s.** An app that isn't ready by then is shown as it is, and reported late.
|
|
197
|
+
- **They catch every hook.** An app that throws three times in a minute is stopped and its box emptied. The screen reports why (`load`, `shape`, `unsupported`, `error`, `late` or `overran`), with the name of what was thrown, never its message.
|
|
198
|
+
- **Each app paints only in its box.** What an app adds outside it is removed, and a dialog opens inside it. `alert`, `confirm`, `prompt`, `print` and `window.open` do nothing.
|
|
199
|
+
- **A version that hangs the page twice in an hour** is held back on that screen until the screen's content next changes.
|
|
200
|
+
- **Offline,** an app shows the answers the screen kept: `kept()` gives them at once.
|
|
201
|
+
- **In the CMS,** an app runs in a sandboxed frame with no storage, cookies or parent page.
|
|
202
|
+
|
|
203
|
+
So:
|
|
204
|
+
- don't use `localStorage` for anything that matters;
|
|
205
|
+
- keep secrets out of params and the module, since both can be read;
|
|
206
|
+
- declare the network hosts truthfully: a person approves them.
|
|
207
|
+
|
|
208
|
+
## Versions
|
|
209
|
+
|
|
210
|
+
This package follows semver from 1.0.0. Its 1.x versions build apps on Player API 2, and a change a 1.x app couldn't survive would be a 2.0. A version on npm never changes: a broken one is deprecated, never replaced.
|
|
211
|
+
|
|
212
|
+
## Licence
|
|
213
|
+
|
|
214
|
+
MIT, © Northwynd Labs LLC. See `LICENSE`.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Copied from protocol/scripts/lib/app-package.mjs by app-sdk/scripts/copy-protocol.mjs: edit the source, then run `pnpm --filter @wyndlass/app-sdk copy-protocol`.
|
|
2
|
+
// The reference reader of a customer's app package (WP-0100 decisions 2 and 5): one zip of exactly app.json (the
|
|
3
|
+
// definition) and app.js (one ES2020 module). It answers "valid" or the first check that fails, in the order core's
|
|
4
|
+
// media worker runs them (after its malware scan): the zip, then the definition, then the module. test-vectors/
|
|
5
|
+
// app-packages.json is checked against it, and core and WP-0101's `check` are held to the same vectors.
|
|
6
|
+
import { inflateRawSync } from "node:zlib";
|
|
7
|
+
import { parse } from "acorn";
|
|
8
|
+
|
|
9
|
+
export const LIMITS = { zip: 6 * 1024 * 1024, definition: 256 * 1024, module: 5 * 1024 * 1024 };
|
|
10
|
+
/** The data service's sources a customer's app may read (decision 7; Decision needed: Mike). */
|
|
11
|
+
export const DATA_SOURCES = ["weather", "news", "image", "calendar"];
|
|
12
|
+
const ENTRIES = ["app.json", "app.js"];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The package's verdict: "valid", or the code of the first check that fails. `validateDefinition` is the definition's
|
|
16
|
+
* JSON Schema (app-definition.schema.json), compiled.
|
|
17
|
+
*/
|
|
18
|
+
export function checkAppPackage(bytes, validateDefinition) {
|
|
19
|
+
const zip = readZip(bytes);
|
|
20
|
+
if (typeof zip === "string") return zip;
|
|
21
|
+
const definition = checkDefinition(zip["app.json"], validateDefinition);
|
|
22
|
+
if (definition !== "valid") return definition;
|
|
23
|
+
return checkModule(zip["app.js"]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The zip's two entries' bytes, or the code of what is wrong with it. */
|
|
27
|
+
export function readZip(bytes) {
|
|
28
|
+
const b = Buffer.from(bytes);
|
|
29
|
+
if (b.length > LIMITS.zip) return "too-large";
|
|
30
|
+
// The end of central directory record: 22 bytes, then at most a 65,535-byte comment.
|
|
31
|
+
let end = -1;
|
|
32
|
+
for (let i = b.length - 22; i >= Math.max(0, b.length - 22 - 0xffff); i--) if (b.readUInt32LE(i) === 0x06054b50) { end = i; break; }
|
|
33
|
+
if (end < 0) return "not-a-zip";
|
|
34
|
+
const count = b.readUInt16LE(end + 10), size = b.readUInt32LE(end + 12), at = b.readUInt32LE(end + 16);
|
|
35
|
+
if (count === 0xffff || at === 0xffffffff || at + size > end) return "not-a-zip";
|
|
36
|
+
const entries = [];
|
|
37
|
+
for (let i = 0, p = at; i < count; i++) {
|
|
38
|
+
if (p + 46 > end || b.readUInt32LE(p) !== 0x02014b50) return "not-a-zip";
|
|
39
|
+
const flags = b.readUInt16LE(p + 8), method = b.readUInt16LE(p + 10);
|
|
40
|
+
const compressed = b.readUInt32LE(p + 20), declared = b.readUInt32LE(p + 24);
|
|
41
|
+
const nameLength = b.readUInt16LE(p + 28), extra = b.readUInt16LE(p + 30), comment = b.readUInt16LE(p + 32), local = b.readUInt32LE(p + 42);
|
|
42
|
+
entries.push({ name: b.subarray(p + 46, p + 46 + nameLength).toString("utf8"), flags, method, compressed, declared, local });
|
|
43
|
+
p += 46 + nameLength + extra + comment;
|
|
44
|
+
}
|
|
45
|
+
for (const e of entries) if (e.flags & 1) return "encrypted";
|
|
46
|
+
for (const e of entries) if (e.name.endsWith("/")) return "folder";
|
|
47
|
+
for (const e of entries) if (/[/\\]/.test(e.name) || e.name === ".." || e.name === ".") return "path";
|
|
48
|
+
const names = entries.map(e => e.name);
|
|
49
|
+
if (names.length !== ENTRIES.length || !ENTRIES.every(n => names.includes(n))) return "entries";
|
|
50
|
+
for (const e of entries) if (e.declared > (e.name === "app.json" ? LIMITS.definition : LIMITS.module)) return "too-large";
|
|
51
|
+
const out = {};
|
|
52
|
+
for (const e of entries) {
|
|
53
|
+
if (e.local + 30 > b.length || b.readUInt32LE(e.local) !== 0x04034b50) return "not-a-zip";
|
|
54
|
+
const start = e.local + 30 + b.readUInt16LE(e.local + 26) + b.readUInt16LE(e.local + 28);
|
|
55
|
+
const data = b.subarray(start, start + e.compressed);
|
|
56
|
+
if (data.length !== e.compressed) return "not-a-zip";
|
|
57
|
+
let unpacked;
|
|
58
|
+
if (e.method === 0) unpacked = data;
|
|
59
|
+
else if (e.method === 8) {
|
|
60
|
+
// At most one byte more than it says: enough to tell that it says too little.
|
|
61
|
+
try { unpacked = inflateRawSync(data, { maxOutputLength: e.declared + 1 }); }
|
|
62
|
+
catch (x) { if (x.code === "ERR_BUFFER_TOO_LARGE" || x.code === "ERR_OUT_OF_RANGE") return "size-mismatch"; return "not-a-zip"; }
|
|
63
|
+
} else return "not-a-zip";
|
|
64
|
+
if (unpacked.length !== e.declared) return "size-mismatch";
|
|
65
|
+
out[e.name] = unpacked;
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The definition's verdict: UTF-8 JSON, its schema, then the rules a schema cannot say. */
|
|
71
|
+
export function checkDefinition(bytes, validateDefinition) {
|
|
72
|
+
let d;
|
|
73
|
+
try { d = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); } catch { return "definition-json"; }
|
|
74
|
+
if (!d || typeof d !== "object" || Array.isArray(d)) return "definition-json";
|
|
75
|
+
if (!validateDefinition(d)) return "definition-schema";
|
|
76
|
+
if (d.id.startsWith("com.wyndlass.")) return "definition-id";
|
|
77
|
+
const params = d.params ?? {};
|
|
78
|
+
for (const [name, p] of Object.entries(params))
|
|
79
|
+
for (const other of Object.keys(p.when ?? {})) if (other === name || !Object.hasOwn(params, other)) return "definition-when";
|
|
80
|
+
for (const p of Object.values(params)) {
|
|
81
|
+
if (p.type !== "url") continue;
|
|
82
|
+
const values = [...(p.values ?? []).map(v => (v && typeof v === "object" ? v.value : v)), ...(p.default === undefined ? [] : [p.default])];
|
|
83
|
+
for (const v of values) if (typeof v !== "string" || !URL.canParse(v) || new URL(v).protocol !== "https:") return "definition-url";
|
|
84
|
+
}
|
|
85
|
+
if (d.capabilities?.peers === true) return "definition-peers";
|
|
86
|
+
for (const source of d.data ?? []) if (!DATA_SOURCES.includes(source)) return "definition-data";
|
|
87
|
+
return "valid";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The module's verdict: an ES2020 module that imports nothing and has a default export. */
|
|
91
|
+
export function checkModule(bytes) {
|
|
92
|
+
let program;
|
|
93
|
+
try { program = parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes), { ecmaVersion: 2020, sourceType: "module" }); } catch { return "module-syntax"; }
|
|
94
|
+
let imports = false, defaultExport = false;
|
|
95
|
+
const visit = node => {
|
|
96
|
+
if (!node || typeof node.type !== "string") return;
|
|
97
|
+
if (node.type === "ImportDeclaration" || node.type === "ImportExpression" || node.type === "ExportAllDeclaration" || (node.type === "ExportNamedDeclaration" && node.source)) imports = true;
|
|
98
|
+
// export default …, or export { x as default }, as bundlers write it.
|
|
99
|
+
if (node.type === "ExportDefaultDeclaration") defaultExport = true;
|
|
100
|
+
if (node.type === "ExportNamedDeclaration" && !node.source && node.specifiers.some(s => (s.exported.name ?? s.exported.value) === "default")) defaultExport = true;
|
|
101
|
+
for (const [key, value] of Object.entries(node)) {
|
|
102
|
+
if (key === "type" || key === "start" || key === "end") continue;
|
|
103
|
+
if (Array.isArray(value)) value.forEach(visit);
|
|
104
|
+
else if (value && typeof value === "object") visit(value);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
visit(program);
|
|
108
|
+
if (imports) return "module-import";
|
|
109
|
+
if (!defaultExport) return "module-default";
|
|
110
|
+
return "valid";
|
|
111
|
+
}
|
package/cli/commands.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// The CLI's commands (WP-0101 decision 5): create, check, pack, upload and skill install. Each returns what it says, as
|
|
2
|
+
// lines, and throws CliError for a refusal. Nothing here signs in anywhere (decision 6): what touches Wyndlass goes
|
|
3
|
+
// through the person's MCP client or the CMS, and upload only PUTs to an address it is given.
|
|
4
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { CliError, MEANING, SDK_VERSION, packageOf, schemaErrors, verdict } from "./package.mjs";
|
|
9
|
+
|
|
10
|
+
const sdk = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
11
|
+
export const TEMPLATES = ["vanilla", "react"];
|
|
12
|
+
const ID = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9-]*)+$/;
|
|
13
|
+
|
|
14
|
+
/** A refusal's words: the code, what it means, and for a schema refusal, what the schema said. */
|
|
15
|
+
function refusal(code, definition) {
|
|
16
|
+
const lines = [`refused (${code}): ${MEANING[code] ?? code}`];
|
|
17
|
+
if (code === "definition-schema" && definition) for (const e of schemaErrors(definition)) lines.push(` ${e}`);
|
|
18
|
+
return lines;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Text files of a template get the app's values; the rest are copied as they are. */
|
|
22
|
+
const TEXT = /\.(json|md|ts|tsx|css|gitignore|txt)$|^\.gitignore$/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* create <dir>: a working app from a template, `vanilla` or `react` (decision 4). Its id must be one the import takes,
|
|
26
|
+
* never com.wyndlass.; its element is the id's last part, with "-app" when that has no hyphen. `sdk` is the version
|
|
27
|
+
* spec the project depends on (the SDK's own, ^x.y.z, unless a test gives a tarball).
|
|
28
|
+
*/
|
|
29
|
+
export function create(dir, { template = "vanilla", id, name, sdk: sdkSpec } = {}) {
|
|
30
|
+
if (!TEMPLATES.includes(template)) throw new CliError(`no template "${template}": ${TEMPLATES.join(" or ")}`);
|
|
31
|
+
const target = resolve(dir);
|
|
32
|
+
if (existsSync(target) && readdirSync(target).length) throw new CliError(`${dir} is not empty`);
|
|
33
|
+
const slug = basename(target).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^[^a-z]+|-+$/g, "") || "my-app";
|
|
34
|
+
const appId = id ?? `com.example.${slug}`;
|
|
35
|
+
if (!ID.test(appId)) throw new CliError(`"${appId}" is not an app id: a reverse-domain name in your organization's own domain, such as com.example.queue-board`);
|
|
36
|
+
if (appId.startsWith("com.wyndlass.")) throw new CliError("com.wyndlass. is Wyndlass's own: use your organization's own domain");
|
|
37
|
+
const last = appId.split(".").at(-1);
|
|
38
|
+
const element = last.includes("-") ? last : `${last}-app`;
|
|
39
|
+
const appName = name ?? last.split("-").map(w => w[0].toUpperCase() + w.slice(1)).join(" ");
|
|
40
|
+
const values = { __APP_ID__: appId, __APP_NAME__: appName, __ELEMENT__: element, __PACKAGE__: last, __SDK__: sdkSpec ?? `^${SDK_VERSION}` };
|
|
41
|
+
const from = join(sdk, "templates", template);
|
|
42
|
+
mkdirSync(target, { recursive: true });
|
|
43
|
+
cpSync(from, target, { recursive: true });
|
|
44
|
+
for (const file of walk(target)) {
|
|
45
|
+
if (!TEXT.test(basename(file))) continue;
|
|
46
|
+
const text = readFileSync(file, "utf8");
|
|
47
|
+
const filled = text.replace(/__(APP_ID|APP_NAME|ELEMENT|PACKAGE|SDK)__/g, k => values[k].replace(/"/g, "\\\""));
|
|
48
|
+
if (filled !== text) writeFileSync(file, filled);
|
|
49
|
+
}
|
|
50
|
+
// npm leaves a package's .gitignore out of what it publishes, so the template's is called gitignore.
|
|
51
|
+
if (existsSync(join(target, "gitignore"))) renameSync(join(target, "gitignore"), join(target, ".gitignore"));
|
|
52
|
+
return [`${appName} (${appId}), from the ${template} template, in ${relative(process.cwd(), target) || "."}.`,
|
|
53
|
+
"Next:", ` cd ${relative(process.cwd(), target) || "."}`, " npm install", " npm run check", " npm run pack"];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function walk(dir) {
|
|
57
|
+
return readdirSync(dir).flatMap(n => {
|
|
58
|
+
const p = join(dir, n);
|
|
59
|
+
return statSync(p).isDirectory() ? walk(p) : [p];
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* check [dir|zip]: the checks core makes (decision 5), on a zip as it is, or on a project as pack would build it.
|
|
65
|
+
* Throws with the first refusal; returns the verdict and any warnings.
|
|
66
|
+
*/
|
|
67
|
+
export async function check(target = ".") {
|
|
68
|
+
const path = resolve(target);
|
|
69
|
+
if (!existsSync(path)) throw new CliError(`${target} does not exist`);
|
|
70
|
+
if (statSync(path).isFile()) {
|
|
71
|
+
const code = verdict(readFileSync(path));
|
|
72
|
+
if (code !== "valid") throw new CliError(refusal(code, null).join("\n"));
|
|
73
|
+
return [`${basename(path)}: Wyndlass takes it.`];
|
|
74
|
+
}
|
|
75
|
+
const p = await packageOf(path);
|
|
76
|
+
if (p.code !== "valid") throw new CliError([`${p.id} ${p.version}:`, ...refusal(p.code, p.definition)].join("\n"));
|
|
77
|
+
return [`${p.id} ${p.version}: Wyndlass takes it.`, ...p.warnings.map(w => `warning: ${w}`)];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* pack [dir]: the package WP-0100 takes (decision 8), dist/<id>-<version>.zip, and its module's source map beside it,
|
|
82
|
+
* never inside. It checks first, and writes nothing for an app core would refuse.
|
|
83
|
+
*/
|
|
84
|
+
export async function pack(dir = ".") {
|
|
85
|
+
const path = resolve(dir);
|
|
86
|
+
const p = await packageOf(path);
|
|
87
|
+
if (p.code !== "valid") throw new CliError([`${p.id} ${p.version}: not packed.`, ...refusal(p.code, p.definition)].join("\n"));
|
|
88
|
+
const out = join(path, "dist");
|
|
89
|
+
mkdirSync(out, { recursive: true });
|
|
90
|
+
const file = join(out, `${p.id}-${p.version}.zip`);
|
|
91
|
+
writeFileSync(file, p.bytes);
|
|
92
|
+
writeFileSync(join(out, `${p.id}-${p.version}.js.map`), p.map);
|
|
93
|
+
return [`${relative(process.cwd(), file)}: ${p.bytes.length} bytes, ready to import.`, ...p.warnings.map(w => `warning: ${w}`)];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* upload <url> <zip>: PUTs the zip to the address app.prepareImport gave, with the headers it gave (`k: v` each). It
|
|
98
|
+
* holds no token: the address is signed, for an hour.
|
|
99
|
+
*/
|
|
100
|
+
export async function upload(url, file, headers = []) {
|
|
101
|
+
if (!url || !file) throw new CliError("upload needs the address app.prepareImport gave, and the zip");
|
|
102
|
+
let address;
|
|
103
|
+
try { address = new URL(url); } catch { throw new CliError(`${url} is not an address`); }
|
|
104
|
+
if (address.protocol !== "https:" && address.hostname !== "127.0.0.1" && address.hostname !== "localhost") throw new CliError(`${url} is not an https address`);
|
|
105
|
+
const body = readFileSync(resolve(file));
|
|
106
|
+
const sent = {};
|
|
107
|
+
for (const h of headers) {
|
|
108
|
+
const at = h.indexOf(":");
|
|
109
|
+
if (at <= 0) throw new CliError(`"${h}" is not a header: name: value`);
|
|
110
|
+
sent[h.slice(0, at).trim()] = h.slice(at + 1).trim();
|
|
111
|
+
}
|
|
112
|
+
const res = await fetch(address, { method: "PUT", headers: sent, body }).catch(e => { throw new CliError(`the upload did not reach ${address.host}: ${e.cause?.code ?? e.message}`); });
|
|
113
|
+
if (!res.ok) throw new CliError(`the upload was refused: HTTP ${res.status}${res.status === 403 ? " (the address may have expired: ask for another)" : ""}`);
|
|
114
|
+
return [`${basename(file)}: uploaded (${body.length} bytes). Import it with app.import.`];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The skill for the customer's own coding agent (decision 12), in the package beside the CLI it describes. */
|
|
118
|
+
export const SKILL = "wyndlass-app";
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* skill install [--user]: the skill in the project's .claude/skills/wyndlass-app/ (the folder it runs in), or with --user
|
|
122
|
+
* in ~/.claude/skills/wyndlass-app/, where Claude Code finds it. An earlier copy is replaced whole, so a newer SDK's skill
|
|
123
|
+
* leaves none of an older one's files behind.
|
|
124
|
+
*/
|
|
125
|
+
export function skillInstall({ user = false, cwd = process.cwd(), home = homedir() } = {}) {
|
|
126
|
+
const target = join(user ? home : resolve(cwd), ".claude", "skills", SKILL);
|
|
127
|
+
const earlier = existsSync(target);
|
|
128
|
+
rmSync(target, { recursive: true, force: true });
|
|
129
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
130
|
+
cpSync(join(sdk, "skill", SKILL), target, { recursive: true });
|
|
131
|
+
return [`${earlier ? "Replaced" : "Installed"} the skill ${SKILL} (@wyndlass/app-sdk ${SDK_VERSION}) in ${user ? target : relative(cwd, target)}.`,
|
|
132
|
+
"Claude Code finds it there: ask it to build an app for your Wyndlass screens."];
|
|
133
|
+
}
|
package/cli/main.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// wyndlass-app (WP-0101 decision 5): create, preview, check, pack and upload an app for Wyndlass's screens, and install
|
|
3
|
+
// the skill that does it with a coding agent (decision 12). It signs in to nothing (decision 6).
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { CliError, SDK_VERSION } from "./package.mjs";
|
|
6
|
+
import { check, create, pack, skillInstall, TEMPLATES, upload } from "./commands.mjs";
|
|
7
|
+
import { preview } from "./preview.mjs";
|
|
8
|
+
|
|
9
|
+
const HELP = `wyndlass-app ${SDK_VERSION}: build apps for Wyndlass's screens.
|
|
10
|
+
|
|
11
|
+
create <dir> [--template ${TEMPLATES.join("|")}] [--id com.example.app] [--name "App name"]
|
|
12
|
+
a working app to start from
|
|
13
|
+
preview [dir] [--port 5199] [--data <dir>] [--frame]
|
|
14
|
+
the app as screens run it, on 127.0.0.1, drawn again as you change it;
|
|
15
|
+
--frame runs it in the frame the CMS gives apps, on an origin of its own
|
|
16
|
+
check [dir|zip] the checks Wyndlass makes on an imported app, on a project or a package
|
|
17
|
+
pack [dir] dist/<id>-<version>.zip, the package to import, after check
|
|
18
|
+
upload <url> <zip> [--header "name: value" ...]
|
|
19
|
+
PUT the package to the address app.prepareImport gave, with its headers
|
|
20
|
+
skill install [--user]
|
|
21
|
+
the skill for a coding agent, in .claude/skills/wyndlass-app/ here, or with
|
|
22
|
+
--user in ~/.claude/skills/; it replaces an earlier copy
|
|
23
|
+
|
|
24
|
+
Importing, placing and scheduling go through the Wyndlass MCP (app.prepareImport, app.import) or the CMS.`;
|
|
25
|
+
|
|
26
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
27
|
+
try {
|
|
28
|
+
let lines;
|
|
29
|
+
if (command === "create") {
|
|
30
|
+
const { values, positionals } = parseArgs({ args: rest, allowPositionals: true,
|
|
31
|
+
options: { template: { type: "string" }, id: { type: "string" }, name: { type: "string" }, sdk: { type: "string" } } });
|
|
32
|
+
if (positionals.length !== 1) throw new CliError("create needs one folder: wyndlass-app create <dir>");
|
|
33
|
+
lines = create(positionals[0], values);
|
|
34
|
+
} else if (command === "preview") {
|
|
35
|
+
const { values, positionals } = parseArgs({ args: rest, allowPositionals: true,
|
|
36
|
+
options: { port: { type: "string" }, data: { type: "string" }, frame: { type: "boolean" } } });
|
|
37
|
+
const port = values.port === undefined ? 5199 : Number(values.port);
|
|
38
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new CliError(`"${values.port}" is not a port`);
|
|
39
|
+
const running = await preview(positionals[0] ?? ".", { port, data: values.data, frame: values.frame === true });
|
|
40
|
+
console.log(`The preview is at ${running.url} (on this computer only). Ctrl+C stops it.`);
|
|
41
|
+
if (running.frames) console.log(`The app runs in a frame on ${running.frames}, as in the CMS.`);
|
|
42
|
+
const stop = () => { void running.close().then(() => process.exit(0)); };
|
|
43
|
+
process.on("SIGINT", stop);
|
|
44
|
+
process.on("SIGTERM", stop);
|
|
45
|
+
lines = [];
|
|
46
|
+
} else if (command === "check") {
|
|
47
|
+
const { positionals } = parseArgs({ args: rest, allowPositionals: true });
|
|
48
|
+
lines = await check(positionals[0]);
|
|
49
|
+
} else if (command === "pack") {
|
|
50
|
+
const { positionals } = parseArgs({ args: rest, allowPositionals: true });
|
|
51
|
+
lines = await pack(positionals[0]);
|
|
52
|
+
} else if (command === "upload") {
|
|
53
|
+
const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { header: { type: "string", multiple: true } } });
|
|
54
|
+
lines = await upload(positionals[0], positionals[1], values.header ?? []);
|
|
55
|
+
} else if (command === "skill") {
|
|
56
|
+
const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { user: { type: "boolean" } } });
|
|
57
|
+
if (positionals.length !== 1 || positionals[0] !== "install") throw new CliError("skill has one command: wyndlass-app skill install [--user]");
|
|
58
|
+
lines = skillInstall({ user: values.user === true });
|
|
59
|
+
} else if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
60
|
+
lines = [HELP];
|
|
61
|
+
} else if (command === "--version" || command === "-v") {
|
|
62
|
+
lines = [SDK_VERSION];
|
|
63
|
+
} else {
|
|
64
|
+
throw new CliError(`no command "${command}"\n\n${HELP}`);
|
|
65
|
+
}
|
|
66
|
+
for (const line of lines) console.log(line);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
if (e instanceof CliError || e?.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION" || e?.code === "ERR_PARSE_ARGS_INVALID_OPTION_VALUE") {
|
|
69
|
+
console.error(`wyndlass-app: ${e.message}`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
throw e;
|
|
73
|
+
}
|