@surph_ai/cli 0.0.27 → 0.0.29
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/cli.js +4 -1
- package/package.json +1 -1
- package/scaffold/board/README.md +385 -0
- package/scaffold/board/assets/css/site.css +224 -0
- package/scaffold/board/index.html +152 -0
- package/scaffold/board/package.json +15 -0
- package/scaffold/index.js +132 -2
- package/scaffold/project/package.json +9 -15
- package/toolserver/routes/index.js +12 -10
- package/utils/boardConfiguration.js +12 -4
- package/utils/userConfiguration.js +52 -25
package/cli.js
CHANGED
|
@@ -293,6 +293,10 @@ program.command('tool <name>')
|
|
|
293
293
|
.description('create new Surph tool')
|
|
294
294
|
.action(name => scaffold(name, 'tool'))
|
|
295
295
|
|
|
296
|
+
program.command('board <name>')
|
|
297
|
+
.description('create new Surph board')
|
|
298
|
+
.action(name => scaffold(name, 'board'))
|
|
299
|
+
|
|
296
300
|
program.command('manifest')
|
|
297
301
|
.description('update manifest for Surph tool')
|
|
298
302
|
.action(async (cmd, options) => {
|
|
@@ -537,7 +541,6 @@ program.command('publish')
|
|
|
537
541
|
}
|
|
538
542
|
})
|
|
539
543
|
|
|
540
|
-
|
|
541
544
|
program.command('deploy')
|
|
542
545
|
.description('Deploy to Surph')
|
|
543
546
|
.action(async (cmd, options) => {
|
package/package.json
CHANGED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
# Surph board
|
|
2
|
+
|
|
3
|
+
This project is a **Surph board**: a set of web pages that Surph shows in its **canvas**, the panel beside the Surph chat. Each page is a static HTML file. Surph loads it in an iframe and answers the page's requests for data. The page talks to Surph only through the Surph SDK (`surph-wave.js`), which works over `postMessage`.
|
|
4
|
+
|
|
5
|
+
**If you're a coding agent, read this whole file before writing a page.** Surph answers only the operations listed in [Operations the host answers](#operations-the-host-answers), with exactly the payloads shown. A page that calls anything else gets an error, and a field not listed here doesn't exist.
|
|
6
|
+
|
|
7
|
+
## How a page runs in Surph
|
|
8
|
+
|
|
9
|
+
- **Surph is the host; the page is the guest.** Surph frames the page and answers its `host.rpc(op, args)` calls with data it already has: the board's recent items, the chat's context, the items the chat looked up, the signed-in person's contexts. The page never calls Surph's servers itself, and it can't read Surph's cookies, session or DOM.
|
|
10
|
+
- **The chat decides which page to show.** Every page is registered with a `shows` line: plain facts about what it displays. The chat's model reads the board's pages and their `shows` lines and opens the page that fits what the person asked. A person can also pick a page from the canvas's Pages menu.
|
|
11
|
+
- **The chat can hand a page values.** A page can declare address parameters (for example `?ids=`). The chat's model sees them and opens the page at an address that carries them; the page reads them from `location.search`.
|
|
12
|
+
- **Pages update as the chat goes.** When the data behind an operation changes (the chat looked up new items, the person subscribed to a context), Surph calls the page's `onRefresh`, and the page asks again and repaints.
|
|
13
|
+
- **A board's pages replace Surph's own.** A chat on your board shows only your board's pages. One of them is the **start page** the canvas opens on.
|
|
14
|
+
- **Opened directly, a page still loads.** Outside Surph, `host.hosted` is `false` and every call rejects. The page must still render its empty layout without errors. That's what you see when you run this project locally.
|
|
15
|
+
|
|
16
|
+
## Project layout
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
index.html The board's home page (edit it; it starts as an intro to this setup)
|
|
20
|
+
<page>.html Each further page sits next to it (items.html, events.html, …)
|
|
21
|
+
assets/
|
|
22
|
+
css/site.css Styles shared by every page in the board
|
|
23
|
+
js/ Scripts shared by the board's pages (optional)
|
|
24
|
+
package.json `npm run dev` serves this folder at http://localhost:4180
|
|
25
|
+
README.md This file
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- **Pages sit at the top of the folder.** A page's registry entry names its file without a folder, so a page can't live in a subfolder.
|
|
29
|
+
- **Everything else a page loads, apart from the SDK, goes under `assets/`**, linked with relative paths (`assets/css/site.css`, `./items.html`).
|
|
30
|
+
|
|
31
|
+
### Shared styles: `assets/css/site.css`
|
|
32
|
+
|
|
33
|
+
Every page links it in `<head>`, before its own `<style>`:
|
|
34
|
+
|
|
35
|
+
```html
|
|
36
|
+
<link rel="stylesheet" href="assets/css/site.css?v=1">
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- It holds the colour variables (`--ink`, `--muted`, `--faint`, `--subtle`, `--accent`, `--rule`, `--tint`, …), the page shell (body, `h1`, `h2`, `p.empty`) and a few shared pieces (a context list `ul.contexts` of `.context` rows with an `.icon`, and an owner pill `button.owner`). Use the variables in page styles rather than repeating colours.
|
|
40
|
+
- A page's own `<style>` holds only what that page alone uses.
|
|
41
|
+
- **A change to `site.css` changes every page.** Check each page that uses what you changed.
|
|
42
|
+
- **Bump `?v=` in every page's link** whenever `site.css` changes, so browsers don't keep serving the old copy.
|
|
43
|
+
|
|
44
|
+
## Registering pages
|
|
45
|
+
|
|
46
|
+
Surph only shows pages that are registered with the board. **Today the board's registry is set up by Surph**: when you add, remove or rename a page, or change which operations it calls, your hand-off must include the registry entry for it.
|
|
47
|
+
|
|
48
|
+
A registry entry:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"name": "events",
|
|
53
|
+
"url": "https://your-host.example.com/board/events.html",
|
|
54
|
+
"shows": "Upcoming events from the board's contexts: each event's name, date, place, and a link to its source.",
|
|
55
|
+
"ops": ["board.get"]
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
| Field | Meaning |
|
|
60
|
+
| --- | --- |
|
|
61
|
+
| `name` | Short, stable label. The chat's model and saved conversations refer to the page by this name, and the canvas header shows it, so don't rename it casually. Unique within the board. |
|
|
62
|
+
| `url` | The page's full `https://` address, when you host the files yourself. |
|
|
63
|
+
| `file` | Instead of `url`, when Surph hosts the board's files: the HTML file's name only, with no folder (`events.html`). |
|
|
64
|
+
| `shows` | What the page displays, as plain facts. The chat's model reads this to judge when the page fits. Describe the content, not instructions: write "Every item in the board's newest 25, …", not "Use this when the user asks about…". |
|
|
65
|
+
| `start` | Optional. Marks a page the canvas opens on before the chat starts: `"always"`, `"context"` (a chat that has a context) or `"no-context"` (a chat without one). A board needs at least one. A start page may route on load to another page with [`page.open`](#pageopen) and `replace: true`. |
|
|
66
|
+
| `ops` | The operations the page calls. It must match the `host.rpc(…)` calls in the file. Surph finds pages by op; for example, the page that calls `items.get` is used to show the items a chat looked up. |
|
|
67
|
+
| `params` | Optional. Address parameters the page reads, as `{ "<name>": "<what it is>" }`. The chat's model sees them and can open the page with them. |
|
|
68
|
+
|
|
69
|
+
## The SDK
|
|
70
|
+
|
|
71
|
+
Every page loads the SDK from this exact URL. Don't copy, bundle, edit or replace it.
|
|
72
|
+
|
|
73
|
+
```html
|
|
74
|
+
<script src="https://surph.s3.us-east-1.amazonaws.com/admin/desktop/js/wave-sdk/v1/surph-wave.js"></script>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
It defines one global, `SurphWave`:
|
|
78
|
+
|
|
79
|
+
```js
|
|
80
|
+
const host = SurphWave.connect({
|
|
81
|
+
pageSlug: 'events', // The page's own name (any short string)
|
|
82
|
+
onRefresh: function () { paint(); } // Surph's data changed: ask again and repaint
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
host.ready(); // Call once, after connect
|
|
86
|
+
host.rpc(op, args, { timeout }); // Ask Surph for something; returns a Promise
|
|
87
|
+
host.hosted; // true inside the Surph canvas, false when opened directly
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- `rpc(op, args, extra)` sends `args` as an object (use `{}` when there are none). It resolves with the operation's result object.
|
|
91
|
+
- It rejects with:
|
|
92
|
+
- `Error('host timeout')`: no reply in time. The default wait is 15 seconds; pass `{ timeout: 800 }` on reads.
|
|
93
|
+
- `Error('not in a Surph host')`: the page isn't inside the canvas (`hosted` is false).
|
|
94
|
+
- `Error('<message>')`: Surph answered with an error, for example `Unknown op: …` for an operation it doesn't support.
|
|
95
|
+
- Only use `connect`, `ready`, `rpc`, `hosted` and the `onRefresh` option. The script has other options; they belong to other hosts and do nothing here.
|
|
96
|
+
|
|
97
|
+
## Call pattern
|
|
98
|
+
|
|
99
|
+
Every page that reads data uses this loop. Keep its rules:
|
|
100
|
+
- a **timeout** means Surph is still loading, so ask again shortly;
|
|
101
|
+
- any **other error** means stop;
|
|
102
|
+
- paint only after a successful reply.
|
|
103
|
+
|
|
104
|
+
```js
|
|
105
|
+
const host = SurphWave.connect({
|
|
106
|
+
pageSlug: 'events',
|
|
107
|
+
onRefresh: function () { paint(); }
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
let painting = false;
|
|
111
|
+
async function paint() {
|
|
112
|
+
if (painting) return;
|
|
113
|
+
painting = true;
|
|
114
|
+
let got;
|
|
115
|
+
try {
|
|
116
|
+
got = await host.rpc('board.get', {}, { timeout: 800 });
|
|
117
|
+
} catch (e) {
|
|
118
|
+
painting = false;
|
|
119
|
+
if (e && e.message === 'host timeout' && host.hosted) setTimeout(paint, 200);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
painting = false;
|
|
123
|
+
// got is the operation's result. Paint it here (see "Rules for pages").
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
host.ready();
|
|
127
|
+
paint();
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Surph holds a read until its data is ready rather than answering with an error, so a short timeout plus a retry is expected while it loads. Don't clear the page before a reply arrives: if a refresh fails, the page keeps what it last painted.
|
|
131
|
+
|
|
132
|
+
## Operations the host answers
|
|
133
|
+
|
|
134
|
+
These are the only operations Surph answers. Every field below is exactly what arrives; treat anything not listed as absent.
|
|
135
|
+
|
|
136
|
+
### `board.get`
|
|
137
|
+
|
|
138
|
+
No args. Resolves once the board's recent items have loaded.
|
|
139
|
+
|
|
140
|
+
```js
|
|
141
|
+
{
|
|
142
|
+
name: "University of Maryland",
|
|
143
|
+
items: [ /* Item, … */ ]
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
- `name`: the board's name; `""` when the chat isn't on a board.
|
|
148
|
+
- `items`: the 25 newest visible items across every context on the board, newest first (by `createdAt`, else `updatedAt`), each an [Item](#item). `[]` when there are none.
|
|
149
|
+
|
|
150
|
+
### `items.get`
|
|
151
|
+
|
|
152
|
+
The items the chat has looked up. Two forms:
|
|
153
|
+
|
|
154
|
+
- **No args (`{}`).** Resolves once the chat has looked items up; until then the call is held. The items are the chat's current list, which changes as the conversation goes on; when it does, Surph sends `onRefresh`. A lookup sets the list to what it returned; when the chat's reply finishes, the list becomes the items the reply cited, in citation order, if it cited any.
|
|
155
|
+
- **`{ ids: ['<id>', …] }`.** Resolves at once with exactly those items, in that order (at most 50); never held. Ids that aren't made of letters, digits, `_` or `-` are dropped, and an id with no visible item is left out.
|
|
156
|
+
|
|
157
|
+
```js
|
|
158
|
+
{
|
|
159
|
+
title: "",
|
|
160
|
+
description: "",
|
|
161
|
+
items: [ /* Item, … */ ]
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
- `title` and `description` are strings, currently always `""`. Hide them when they're empty.
|
|
166
|
+
- `items`: the chat's current items (or the requested `ids`), each an [Item](#item), in that list's order.
|
|
167
|
+
|
|
168
|
+
A page that takes `?ids=a,b,c` passes them on, and its registry entry declares the parameter so the chat's model can open it with a chosen set:
|
|
169
|
+
|
|
170
|
+
```js
|
|
171
|
+
const raw = new URLSearchParams(location.search).get('ids') || '';
|
|
172
|
+
const ids = raw.split(',').map(function (id) { return id.trim(); }).filter(Boolean);
|
|
173
|
+
got = await host.rpc('items.get', ids.length ? { ids: ids } : {}, { timeout: 800 });
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
```json
|
|
177
|
+
"params": { "ids": "Comma-separated item ids (the <id> of [[ref:<id>]]). The page shows exactly those items, in that order." }
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### `context.get`
|
|
181
|
+
|
|
182
|
+
The chat's context. No args. Resolves once the chat has one.
|
|
183
|
+
|
|
184
|
+
```js
|
|
185
|
+
{
|
|
186
|
+
slug: "university-of-maryland-ufiz9v9e",
|
|
187
|
+
name: "University of Maryland",
|
|
188
|
+
topics: [
|
|
189
|
+
{ name: "Events", description: "What's happening on campus." }
|
|
190
|
+
],
|
|
191
|
+
subscribed: false,
|
|
192
|
+
isCreator: false
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
- `topics` only includes topics that have a name. `description` is `""` when a topic has none.
|
|
197
|
+
- `subscribed`: `true` when the signed-in person subscribes to the context. When that changes, Surph sends `onRefresh`.
|
|
198
|
+
- `isCreator`: `true` when the signed-in person created the context. It's `false` until Surph has loaded the context's record; Surph sends `onRefresh` when it changes.
|
|
199
|
+
|
|
200
|
+
### `context.subscribe`
|
|
201
|
+
|
|
202
|
+
Subscribes the signed-in person to the chat's context. `{ subscribed: false }` unsubscribes.
|
|
203
|
+
|
|
204
|
+
```js
|
|
205
|
+
await host.rpc('context.subscribe', { subscribed: true });
|
|
206
|
+
// resolves { subscribed: true }
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
- Resolves with the new state once Surph has saved it.
|
|
210
|
+
- Rejects with `The chat has no context`, `Sign in to subscribe` (no signed-in person), or `Could not subscribe` / `Could not unsubscribe` (the save failed and nothing changed). Keep the old state on a rejection.
|
|
211
|
+
- Call it only from the person's own click, and disable the button while the call is out so a double click doesn't toggle twice.
|
|
212
|
+
|
|
213
|
+
### `contexts.get`
|
|
214
|
+
|
|
215
|
+
The contexts the signed-in person subscribes to. No args. Resolves at once; never held.
|
|
216
|
+
|
|
217
|
+
```js
|
|
218
|
+
{
|
|
219
|
+
contexts: [
|
|
220
|
+
{
|
|
221
|
+
slug: "recommendations-w1ey7i96",
|
|
222
|
+
name: "Recommendations",
|
|
223
|
+
description: "Tracks personal recommendations for products, food, …",
|
|
224
|
+
icon: "https://…/recommendations-icon.png",
|
|
225
|
+
creator: { username: "dev_kinney5521", avatar: "https://…" },
|
|
226
|
+
canEdit: true
|
|
227
|
+
}
|
|
228
|
+
]
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
- `contexts`: `[]` when the person has none or isn't signed in.
|
|
233
|
+
- `name`: the context's name, else its slug. `description`: `""` when there is none.
|
|
234
|
+
- `icon`: the full URL of the context's icon. Omitted when it has none.
|
|
235
|
+
- `creator`: the context's owner, `{ username, avatar? }`. `avatar` is omitted when there is none; `creator` is omitted when the owner isn't known. Avatars may come from another site, so set `referrerPolicy = 'no-referrer'` on the `<img>`.
|
|
236
|
+
- `canEdit`: `true` when the signed-in person created the context or collaborates on it. Show an edit control only when it's `true`.
|
|
237
|
+
- When the list changes, Surph sends `onRefresh`.
|
|
238
|
+
|
|
239
|
+
### `contexts.public`
|
|
240
|
+
|
|
241
|
+
Every public context on Surph (at most 100). No args. Surph fetches the list the first time a page asks and the call waits for it, so pass a longer timeout than 800ms (for example `{ timeout: 5000 }`).
|
|
242
|
+
|
|
243
|
+
```js
|
|
244
|
+
{ contexts: [ /* same shape as contexts.get */ ] }
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
- `contexts`: `[]` when there are none or the list couldn't be fetched; the next call fetches again.
|
|
248
|
+
|
|
249
|
+
### `profile.get`
|
|
250
|
+
|
|
251
|
+
On a person's profile in Surph: that profile and the contexts it created. No args. Held until a profile is open and its contexts have loaded.
|
|
252
|
+
|
|
253
|
+
```js
|
|
254
|
+
{
|
|
255
|
+
username: "dev_kinney5521",
|
|
256
|
+
contexts: [ /* same shape as contexts.get */ ]
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### `context.open`
|
|
261
|
+
|
|
262
|
+
Starts a chat with a context and moves the canvas to the page that calls `context.get`.
|
|
263
|
+
|
|
264
|
+
```js
|
|
265
|
+
await host.rpc('context.open', { slug: 'recommendations-w1ey7i96' });
|
|
266
|
+
// resolves { ok: true }
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
- `slug` is required: a `slug` from `contexts.get`, `contexts.public` or `profile.get`. A missing one rejects with `slug is required`.
|
|
270
|
+
- The calling page is replaced, so don't count on running code after the call.
|
|
271
|
+
|
|
272
|
+
### `context.edit`
|
|
273
|
+
|
|
274
|
+
Opens a context in Surph's context builder (the whole app moves there).
|
|
275
|
+
|
|
276
|
+
```js
|
|
277
|
+
await host.rpc('context.edit', { slug: 'recommendations-w1ey7i96' });
|
|
278
|
+
// resolves { ok: true }
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
- `slug` is required, from a context whose `canEdit` is `true`. Surph rejects any other with `You can't edit this context`.
|
|
282
|
+
|
|
283
|
+
### `profile.open`
|
|
284
|
+
|
|
285
|
+
Takes the person to someone's profile in Surph (the whole app moves there).
|
|
286
|
+
|
|
287
|
+
```js
|
|
288
|
+
await host.rpc('profile.open', { username: 'dev_kinney5521' });
|
|
289
|
+
// resolves { ok: true }
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
- `username` is required, for example a context's `creator.username`. A missing or malformed one rejects with `username is required`.
|
|
293
|
+
|
|
294
|
+
### `page.open`
|
|
295
|
+
|
|
296
|
+
Moves the canvas to another page of this board, by its registry `name`.
|
|
297
|
+
|
|
298
|
+
```js
|
|
299
|
+
await host.rpc('page.open', { name: 'events', replace: true });
|
|
300
|
+
// resolves { ok: true }
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
- `name` is required. A name the board doesn't have rejects with `Unknown page: <name>`; catch it and paint the calling page instead.
|
|
304
|
+
- The new page loads as if picked from the Pages menu: the header shows its name, the chat is told it's showing, and the conversation remembers it.
|
|
305
|
+
- `replace: true` puts the new page in the calling page's place, so Back skips the caller. Use it when a start page routes on load (otherwise Back returns to the start page, which routes again). Replacing a page with itself does nothing.
|
|
306
|
+
- The calling page is unloaded, so don't count on running code after the call.
|
|
307
|
+
|
|
308
|
+
### `chat.say`
|
|
309
|
+
|
|
310
|
+
Puts one short line into the chat, for a greeting or a short note. The page is what the canvas shows, so don't use this to repeat page content.
|
|
311
|
+
|
|
312
|
+
```js
|
|
313
|
+
await host.rpc('chat.say', { text: 'Welcome to the board.' });
|
|
314
|
+
// resolves { ok: true }
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
- `text` is required. Surph trims it and keeps the first 500 characters. An empty `text` rejects with `text is required`.
|
|
318
|
+
|
|
319
|
+
### Item
|
|
320
|
+
|
|
321
|
+
`board.get` and `items.get` return items in this one shape: a chosen set of public fields, not the stored record.
|
|
322
|
+
|
|
323
|
+
```js
|
|
324
|
+
{
|
|
325
|
+
id: "item_mvbf9necbickh1",
|
|
326
|
+
title: "Black Beans (30 oz, 6-Pack)",
|
|
327
|
+
preview: "My go-to pantry staple — great for tacos, salads, soups…",
|
|
328
|
+
content: "My go-to pantry staple — great for tacos, salads, soups, and batch cooking. …",
|
|
329
|
+
source: { type: "url", url: "https://www.amazon.com/dp/B00GXHTZCI" },
|
|
330
|
+
context: { slug: "recommendations-w1ey7i96", name: "Recommendations" },
|
|
331
|
+
topic: { id: "topic_mva2c2v8b698k4", name: "Food" },
|
|
332
|
+
addedBy: { username: "dev_kinney5521", avatar: "https://…" },
|
|
333
|
+
media: [{ type: "image", url: "https://…" }],
|
|
334
|
+
tags: ["pantry"],
|
|
335
|
+
createdAt: "2026-10-16T20:30:35.087Z",
|
|
336
|
+
updatedAt: "2026-10-19T15:24:42.587Z"
|
|
337
|
+
}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
| Field | |
|
|
341
|
+
| --- | --- |
|
|
342
|
+
| `id` | The item's id (a string). |
|
|
343
|
+
| `title` | The item's title, else its URL, else `"Untitled"`. |
|
|
344
|
+
| `preview` | A short summary. `""` when the item has none. |
|
|
345
|
+
| `content` | The item's full text (it may contain line breaks and Markdown). `""` when it has none. A list page shortens it itself. |
|
|
346
|
+
| `source` | `{ type }` (`"url"` or `"text"`), plus `url` when the item came from an address. Omitted when unknown. |
|
|
347
|
+
| `context` | `{ slug, name }` of the context the item belongs to. Omitted when unknown. |
|
|
348
|
+
| `topic` | `{ id, name }` of the item's topic. Omitted when it has none. |
|
|
349
|
+
| `addedBy` | `{ username, avatar? }` of the person who added it. Omitted when unknown. |
|
|
350
|
+
| `media` | `[{ type, url }]`. `[]` when there is none. |
|
|
351
|
+
| `tags` | Strings. `[]` when there are none. |
|
|
352
|
+
| `createdAt` | When it was added, ISO 8601. Omitted when unknown. |
|
|
353
|
+
| `updatedAt` | When it was last changed, ISO 8601. Omitted when unknown. |
|
|
354
|
+
|
|
355
|
+
A field marked "omitted" is missing from the object, not `null`. Every URL is `http` or `https`.
|
|
356
|
+
|
|
357
|
+
## Rules for pages
|
|
358
|
+
|
|
359
|
+
- **Write data with `textContent`, never `innerHTML`.** Every string from Surph comes from what people submitted. Build elements with `document.createElement` and set `textContent`. Set `href` and `src` only to values that start with `http://` or `https://`.
|
|
360
|
+
- **Data comes through the operations above.** Don't try to reach Surph's servers, cookies or session; a page can't, and it doesn't need to.
|
|
361
|
+
- **Change the app only through operations, and only when the person asked.** `context.subscribe`, `context.open`, `context.edit`, `profile.open`, `page.open` and `chat.say` act for the signed-in person. Call them from that person's own click, except a start page routing on load with `page.open`.
|
|
362
|
+
- **Work when not hosted.** Opened directly, `host.hosted` is false and every `rpc` rejects. The page renders its empty layout without errors and stops retrying, which the call pattern already does.
|
|
363
|
+
- **One `ready()` per page load**, called after `connect`.
|
|
364
|
+
- **Don't invent operations or fields.** If a page needs data the operations above don't provide, say so in your hand-off; a new operation needs a change in Surph first.
|
|
365
|
+
- **Keep the registry in step.** A page added, renamed or removed, or with changed `rpc` calls, needs its registry entry in your hand-off.
|
|
366
|
+
- **Plain, static files.** No build step is needed or expected. Scripts can live inline or under `assets/js/`; keep the SDK `<script>` tag as given.
|
|
367
|
+
|
|
368
|
+
## Developing locally
|
|
369
|
+
|
|
370
|
+
1. Install once: `npm install`.
|
|
371
|
+
2. Run `npm run dev`. It serves this folder at `http://localhost:4180` with caching off.
|
|
372
|
+
3. Open `http://localhost:4180/` to check a page loads without console errors. It shows its empty layout there (`hosted` is false); real data only appears inside the Surph canvas.
|
|
373
|
+
4. To see a page with real data, it has to be registered with a board in Surph (see [Registering pages](#registering-pages)).
|
|
374
|
+
5. In Surph, keep your browser's DevTools open with **Network → Disable cache** checked, or the canvas may keep showing an older copy of a page after you change it.
|
|
375
|
+
|
|
376
|
+
## Checklist before handing off a change
|
|
377
|
+
|
|
378
|
+
- [ ] Every `host.rpc(…)` call uses an operation from [Operations the host answers](#operations-the-host-answers) and reads only the fields listed for it and for [Item](#item).
|
|
379
|
+
- [ ] Reads use the call pattern: a timeout retries, other errors stop, nothing is cleared before a reply.
|
|
380
|
+
- [ ] Data from Surph is written with `textContent`; links and images use only `http(s)` values.
|
|
381
|
+
- [ ] Operations that act for the person run only from their click (a start page's `page.open` aside).
|
|
382
|
+
- [ ] The SDK `<script>` tag is the v1 URL above, unchanged.
|
|
383
|
+
- [ ] Every page links `assets/css/site.css?v=<n>`; if `site.css` changed, `<n>` is bumped in every page.
|
|
384
|
+
- [ ] Each page added, renamed or removed, or with changed `rpc` calls, comes with its registry entry: `name`, `url` or `file`, `shows` written as facts, and `ops` matching the file.
|
|
385
|
+
- [ ] Opened at `http://localhost:4180/`, every page loads without console errors.
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/* Shared styles for every page in this set. Linked from each page as
|
|
2
|
+
assets/css/site.css?v=<n> — bump <n> on every change (S3 caches it).
|
|
3
|
+
Each set (base, each board) has its own copy; they start the same and
|
|
4
|
+
may diverge.
|
|
5
|
+
A page's own <style> holds only what that page alone uses. */
|
|
6
|
+
|
|
7
|
+
:root {
|
|
8
|
+
--ink: #1e0a3c; /* text */
|
|
9
|
+
--muted: #4b5563; /* secondary text */
|
|
10
|
+
--faint: #6b7280; /* empty / status lines */
|
|
11
|
+
--subtle: #9ca3af; /* dates, link icons */
|
|
12
|
+
--accent: #6b5b8a; /* section headings, initials */
|
|
13
|
+
--rule: #ece7f3; /* row dividers */
|
|
14
|
+
--tint: #f6f3fa; /* row hover */
|
|
15
|
+
--tint-strong: #f3f0f8; /* icon placeholder */
|
|
16
|
+
--pill: #f1eff6; /* owner pill */
|
|
17
|
+
--pill-hover: #e4e0ec;
|
|
18
|
+
--owner: #1f7f78; /* owner pill text */
|
|
19
|
+
--owner-hover: #145f5a;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* Page shell */
|
|
23
|
+
|
|
24
|
+
html, body {
|
|
25
|
+
margin: 0;
|
|
26
|
+
background: #fff;
|
|
27
|
+
color: var(--ink);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
body {
|
|
31
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
32
|
+
padding: 28px 24px;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
h1 {
|
|
36
|
+
margin: 0;
|
|
37
|
+
font-size: 1.35rem;
|
|
38
|
+
font-weight: 650;
|
|
39
|
+
line-height: 1.3;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* A section heading (filled in once the section's data arrives). */
|
|
43
|
+
h2 {
|
|
44
|
+
margin: 1.75rem 0 0;
|
|
45
|
+
font-size: 0.8rem;
|
|
46
|
+
font-weight: 650;
|
|
47
|
+
letter-spacing: 0.02em;
|
|
48
|
+
color: var(--accent);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
h2:empty {
|
|
52
|
+
display: none;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* The line shown when a list has nothing in it. */
|
|
56
|
+
p.empty {
|
|
57
|
+
margin: 0.5rem 0 0;
|
|
58
|
+
font-size: 0.9rem;
|
|
59
|
+
color: var(--faint);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* Context lists (landing, explore, profile): <ul class="contexts">, each
|
|
63
|
+
<li> holding one .context row. --icon-size sets the icon square; a list
|
|
64
|
+
whose rows have no owner pill sets it smaller. */
|
|
65
|
+
|
|
66
|
+
ul.contexts {
|
|
67
|
+
--icon-size: 64px;
|
|
68
|
+
list-style: none;
|
|
69
|
+
margin: 0.5rem 0 0;
|
|
70
|
+
padding: 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
ul.contexts > li {
|
|
74
|
+
border-top: 1px solid var(--rule);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* The row: icon, then name / description / owner. A <button>, or a
|
|
78
|
+
div[role=button] when it holds the owner's button. Scoped to
|
|
79
|
+
ul.contexts: other pages use a .context class of their own. */
|
|
80
|
+
ul.contexts .context {
|
|
81
|
+
display: flex;
|
|
82
|
+
align-items: center;
|
|
83
|
+
gap: 12px;
|
|
84
|
+
width: 100%;
|
|
85
|
+
padding: 0.7rem 0.5rem;
|
|
86
|
+
margin: 0 -0.5rem;
|
|
87
|
+
box-sizing: content-box;
|
|
88
|
+
background: transparent;
|
|
89
|
+
border: none;
|
|
90
|
+
border-radius: 8px;
|
|
91
|
+
color: inherit;
|
|
92
|
+
font: inherit;
|
|
93
|
+
text-align: left;
|
|
94
|
+
cursor: pointer;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
ul.contexts .context:hover,
|
|
98
|
+
ul.contexts .context:focus-visible {
|
|
99
|
+
background: var(--tint);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* The icon square; the image fills it, else it shows the name's initial. */
|
|
103
|
+
ul.contexts .context > .icon {
|
|
104
|
+
position: relative;
|
|
105
|
+
flex-shrink: 0;
|
|
106
|
+
width: var(--icon-size);
|
|
107
|
+
height: var(--icon-size);
|
|
108
|
+
border-radius: 10px;
|
|
109
|
+
overflow: hidden;
|
|
110
|
+
background: var(--tint-strong);
|
|
111
|
+
display: flex;
|
|
112
|
+
align-items: center;
|
|
113
|
+
justify-content: center;
|
|
114
|
+
font-weight: 650;
|
|
115
|
+
color: var(--accent);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
ul.contexts .context > .icon img {
|
|
119
|
+
position: absolute;
|
|
120
|
+
inset: 0;
|
|
121
|
+
width: 100%;
|
|
122
|
+
height: 100%;
|
|
123
|
+
object-fit: cover;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
ul.contexts .context .text {
|
|
127
|
+
flex: 1;
|
|
128
|
+
min-width: 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* The line under the description: owner pill, then the settings (edit) button. */
|
|
132
|
+
ul.contexts .context .meta {
|
|
133
|
+
display: flex;
|
|
134
|
+
align-items: center;
|
|
135
|
+
gap: 0.25rem;
|
|
136
|
+
margin-top: 0.35rem;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
ul.contexts .context .meta button.owner {
|
|
140
|
+
margin-top: 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* Settings (gear): only on contexts this user created or collaborates on
|
|
144
|
+
(canEdit); opens the context builder (context.edit). */
|
|
145
|
+
ul.contexts .context button.edit {
|
|
146
|
+
flex-shrink: 0;
|
|
147
|
+
display: flex;
|
|
148
|
+
align-items: center;
|
|
149
|
+
justify-content: center;
|
|
150
|
+
width: 24px;
|
|
151
|
+
height: 24px;
|
|
152
|
+
padding: 0;
|
|
153
|
+
border: none;
|
|
154
|
+
border-radius: 999px;
|
|
155
|
+
background: transparent;
|
|
156
|
+
color: var(--subtle);
|
|
157
|
+
cursor: pointer;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
ul.contexts .context button.edit:hover,
|
|
161
|
+
ul.contexts .context button.edit:focus-visible {
|
|
162
|
+
background: var(--pill-hover);
|
|
163
|
+
color: var(--accent);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
ul.contexts .context button.edit svg {
|
|
167
|
+
width: 14px;
|
|
168
|
+
height: 14px;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
ul.contexts .context strong {
|
|
172
|
+
display: block;
|
|
173
|
+
font-size: 0.95rem;
|
|
174
|
+
font-weight: 650;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
ul.contexts .context span.description {
|
|
178
|
+
display: block;
|
|
179
|
+
margin-top: 0.15rem;
|
|
180
|
+
font-size: 0.85rem;
|
|
181
|
+
line-height: 1.4;
|
|
182
|
+
color: var(--muted);
|
|
183
|
+
white-space: nowrap;
|
|
184
|
+
overflow: hidden;
|
|
185
|
+
text-overflow: ellipsis;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* The context's owner: avatar + username, as in the chat's pills. A
|
|
189
|
+
<button> that opens their profile (profile.open). */
|
|
190
|
+
button.owner {
|
|
191
|
+
display: inline-flex;
|
|
192
|
+
align-items: center;
|
|
193
|
+
gap: 0.35rem;
|
|
194
|
+
margin-top: 0.35rem;
|
|
195
|
+
padding: 0.2rem 0.6rem 0.2rem 0.25rem;
|
|
196
|
+
border: none;
|
|
197
|
+
border-radius: 999px;
|
|
198
|
+
background: var(--pill);
|
|
199
|
+
color: var(--owner);
|
|
200
|
+
font-family: inherit;
|
|
201
|
+
font-size: 0.78rem;
|
|
202
|
+
font-weight: 500;
|
|
203
|
+
line-height: 1.2;
|
|
204
|
+
white-space: nowrap;
|
|
205
|
+
cursor: pointer;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
button.owner:hover,
|
|
209
|
+
button.owner:focus-visible {
|
|
210
|
+
background: var(--pill-hover);
|
|
211
|
+
color: var(--owner-hover);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
button.owner.no-avatar {
|
|
215
|
+
padding-left: 0.6rem;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
button.owner img {
|
|
219
|
+
width: 16px;
|
|
220
|
+
height: 16px;
|
|
221
|
+
border-radius: 50%;
|
|
222
|
+
object-fit: cover;
|
|
223
|
+
background: var(--pill-hover);
|
|
224
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>your surph board</title>
|
|
7
|
+
<link rel="stylesheet" href="assets/css/site.css?v=1">
|
|
8
|
+
<style>
|
|
9
|
+
p.lead {
|
|
10
|
+
margin: 0.5rem 0 0;
|
|
11
|
+
font-size: 0.95rem;
|
|
12
|
+
line-height: 1.5;
|
|
13
|
+
color: var(--muted);
|
|
14
|
+
}
|
|
15
|
+
ol.steps,
|
|
16
|
+
ul.facts {
|
|
17
|
+
margin: 0.6rem 0 0;
|
|
18
|
+
padding-left: 1.2rem;
|
|
19
|
+
font-size: 0.9rem;
|
|
20
|
+
line-height: 1.55;
|
|
21
|
+
color: var(--muted);
|
|
22
|
+
}
|
|
23
|
+
ol.steps li,
|
|
24
|
+
ul.facts li {
|
|
25
|
+
margin-top: 0.4rem;
|
|
26
|
+
}
|
|
27
|
+
ol.steps strong,
|
|
28
|
+
ul.facts strong {
|
|
29
|
+
color: var(--ink);
|
|
30
|
+
}
|
|
31
|
+
code {
|
|
32
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
33
|
+
font-size: 0.82rem;
|
|
34
|
+
padding: 0.05rem 0.3rem;
|
|
35
|
+
border-radius: 4px;
|
|
36
|
+
background: var(--tint);
|
|
37
|
+
color: var(--ink);
|
|
38
|
+
}
|
|
39
|
+
/* Where this page is running: inside Surph's canvas, or opened directly. */
|
|
40
|
+
.status {
|
|
41
|
+
display: flex;
|
|
42
|
+
align-items: flex-start;
|
|
43
|
+
gap: 10px;
|
|
44
|
+
margin-top: 0.75rem;
|
|
45
|
+
padding: 0.75rem 0.9rem;
|
|
46
|
+
border: 1px solid var(--rule);
|
|
47
|
+
border-radius: 10px;
|
|
48
|
+
font-size: 0.9rem;
|
|
49
|
+
line-height: 1.45;
|
|
50
|
+
color: var(--muted);
|
|
51
|
+
}
|
|
52
|
+
.status .dot {
|
|
53
|
+
flex-shrink: 0;
|
|
54
|
+
width: 10px;
|
|
55
|
+
height: 10px;
|
|
56
|
+
margin-top: 0.35rem;
|
|
57
|
+
border-radius: 50%;
|
|
58
|
+
background: var(--subtle);
|
|
59
|
+
}
|
|
60
|
+
.status.is-hosted .dot {
|
|
61
|
+
background: var(--owner);
|
|
62
|
+
}
|
|
63
|
+
.status strong {
|
|
64
|
+
display: block;
|
|
65
|
+
color: var(--ink);
|
|
66
|
+
}
|
|
67
|
+
</style>
|
|
68
|
+
</head>
|
|
69
|
+
<body>
|
|
70
|
+
<h1>your surph board</h1>
|
|
71
|
+
<p class="lead">This page is part of a Surph board. Surph shows it in the <strong>canvas</strong>, the panel beside its chat, and answers its requests for data. Edit <code>index.html</code> to make it your board's home page.</p>
|
|
72
|
+
|
|
73
|
+
<div class="status" id="status">
|
|
74
|
+
<span class="dot" aria-hidden="true"></span>
|
|
75
|
+
<div>
|
|
76
|
+
<strong id="status-title">Checking…</strong>
|
|
77
|
+
<span id="status-text"></span>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<h2>How a page works in Surph</h2>
|
|
82
|
+
<ul class="facts">
|
|
83
|
+
<li><strong>It's a static HTML file.</strong> No build step or framework. Surph loads it in an iframe; you can host it anywhere on <code>https</code>.</li>
|
|
84
|
+
<li><strong>It talks to Surph through the SDK.</strong> The page loads <code>surph-wave.js</code>, calls <code>SurphWave.connect()</code>, then asks the host for data with <code>host.rpc(op, args)</code>: the board's recent items, the chat's context, the items the chat looked up, and more. The page never calls Surph's servers itself.</li>
|
|
85
|
+
<li><strong>The chat decides when to show it.</strong> Each page is registered with a <code>shows</code> line, plain facts about what it displays. The chat's model reads those and opens the page that fits what the person asked. A page can also take values in its address (<code>?ids=…</code>) that the chat fills in.</li>
|
|
86
|
+
<li><strong>It updates as the chat goes.</strong> When the host's data changes, it calls the page's <code>onRefresh</code>, and the page asks again and repaints.</li>
|
|
87
|
+
<li><strong>Opened directly, it still loads.</strong> Outside Surph, <code>host.hosted</code> is false and every call rejects, so the page shows its empty layout. That's what you see here when you run it locally.</li>
|
|
88
|
+
</ul>
|
|
89
|
+
|
|
90
|
+
<h2>Next steps</h2>
|
|
91
|
+
<ol class="steps">
|
|
92
|
+
<li><strong>Read <code>README.md</code>.</strong> It lists every operation the host answers, the item shape, and the rules a page follows. Point your coding agent at it.</li>
|
|
93
|
+
<li><strong>Run it locally:</strong> <code>npm run dev</code> serves this folder at <code>http://localhost:4180</code> with caching off.</li>
|
|
94
|
+
<li><strong>Add pages</strong> next to this one (<code>items.html</code>, <code>events.html</code>…). Shared styles go in <code>assets/css/site.css</code>.</li>
|
|
95
|
+
<li><strong>Register them with your board.</strong> Each page needs a registry entry (its <code>name</code>, address, <code>shows</code> and the <code>ops</code> it calls) so Surph knows it exists. The README shows the format.</li>
|
|
96
|
+
</ol>
|
|
97
|
+
|
|
98
|
+
<script src="https://surph.s3.us-east-1.amazonaws.com/admin/desktop/js/wave-sdk/v1/surph-wave.js"></script>
|
|
99
|
+
<script>
|
|
100
|
+
const host = SurphWave.connect({
|
|
101
|
+
pageSlug: 'index',
|
|
102
|
+
onRefresh: function () { paint(); }
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const statusBox = document.getElementById('status');
|
|
106
|
+
const statusTitle = document.getElementById('status-title');
|
|
107
|
+
const statusText = document.getElementById('status-text');
|
|
108
|
+
|
|
109
|
+
function setStatus(hosted, title, text) {
|
|
110
|
+
statusBox.classList.toggle('is-hosted', hosted);
|
|
111
|
+
statusTitle.textContent = title;
|
|
112
|
+
statusText.textContent = text;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The call pattern every page uses: a timeout means the host is still
|
|
116
|
+
// loading (ask again shortly), any other error means stop, and the page
|
|
117
|
+
// paints only after a successful reply.
|
|
118
|
+
let painting = false;
|
|
119
|
+
async function paint() {
|
|
120
|
+
if (!host.hosted) {
|
|
121
|
+
setStatus(false, 'Opened directly, not inside Surph', 'Host calls reject here, so this page shows its layout only. Inside the Surph canvas, this box shows your board.');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (painting) return;
|
|
125
|
+
painting = true;
|
|
126
|
+
let got;
|
|
127
|
+
try {
|
|
128
|
+
got = await host.rpc('board.get', {}, { timeout: 800 });
|
|
129
|
+
} catch (e) {
|
|
130
|
+
painting = false;
|
|
131
|
+
if (e && e.message === 'host timeout') {
|
|
132
|
+
setStatus(true, 'Inside Surph', 'Loading the board…');
|
|
133
|
+
setTimeout(paint, 200);
|
|
134
|
+
} else {
|
|
135
|
+
setStatus(true, 'Inside Surph', 'The host answered: ' + ((e && e.message) || 'an error') + '.');
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
painting = false;
|
|
140
|
+
const name = got && typeof got.name === 'string' ? got.name : '';
|
|
141
|
+
const count = Array.isArray(got && got.items) ? got.items.length : 0;
|
|
142
|
+
setStatus(true, 'Inside Surph' + (name ? ': ' + name : ''),
|
|
143
|
+
name
|
|
144
|
+
? 'board.get answered with ' + count + ' recent item' + (count === 1 ? '' : 's') + ' from this board.'
|
|
145
|
+
: 'board.get answered: this chat isn’t on a board, so it has no board items.');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
host.ready();
|
|
149
|
+
paint();
|
|
150
|
+
</script>
|
|
151
|
+
</body>
|
|
152
|
+
</html>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "surph-board",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "http-server -p 4180 -c-1"
|
|
7
|
+
},
|
|
8
|
+
"keywords": [],
|
|
9
|
+
"author": "",
|
|
10
|
+
"license": "ISC",
|
|
11
|
+
"type": "commonjs",
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"http-server": "^14.1.1"
|
|
14
|
+
}
|
|
15
|
+
}
|
package/scaffold/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// const program = require('commander')
|
|
2
|
+
// const fs = require('fs')
|
|
3
|
+
// const dotenv = require('dotenv')
|
|
2
4
|
const shell = require('shelljs')
|
|
3
5
|
const path = require('path')
|
|
4
|
-
const fs = require('fs')
|
|
5
|
-
const dotenv = require('dotenv')
|
|
6
6
|
const colors = require('colors/safe')
|
|
7
7
|
const utils = require('../utils')
|
|
8
8
|
const { StepProgress } = require('../utils/progress')
|
|
@@ -22,6 +22,17 @@ const TOOL_STEPS = [
|
|
|
22
22
|
{ id: 'devserver', doing: 'Starting dev server', done: 'Dev server starting', weight: 8 }
|
|
23
23
|
]
|
|
24
24
|
|
|
25
|
+
const BOARD_STEPS = [
|
|
26
|
+
{ id: 'directory', doing: 'Creating project directory', done: 'Project directory created', weight: 1 },
|
|
27
|
+
{ id: 'package', doing: 'Writing package.json', done: 'package.json written', weight: 1 },
|
|
28
|
+
{ id: 'examples', doing: 'Copying example tools', done: 'Example tools copied', weight: 2 },
|
|
29
|
+
{ id: 'manifests', doing: 'Writing manifests', done: 'Manifests written', weight: 1 },
|
|
30
|
+
{ id: 'config', doing: 'Writing .env and tsconfig.json', done: '.env and tsconfig.json written', weight: 1 },
|
|
31
|
+
{ id: 'cheatsheet', doing: 'Fetching cheatsheet', done: 'Cheatsheet fetched', weight: 4 },
|
|
32
|
+
{ id: 'install', doing: 'Installing dependencies', done: 'Dependencies installed', weight: 82 },
|
|
33
|
+
{ id: 'devserver', doing: 'Starting dev server', done: 'Dev server starting', weight: 8 }
|
|
34
|
+
]
|
|
35
|
+
|
|
25
36
|
// Expected package count for install sub-progress; grows if the tree is larger.
|
|
26
37
|
// Used as clamp for install sub-progress. Never shown to the user.
|
|
27
38
|
const EXPECTED_PACKAGES = 324
|
|
@@ -241,6 +252,121 @@ const scaffoldTool = async (name) => {
|
|
|
241
252
|
shell.exec('npm run dev')
|
|
242
253
|
}
|
|
243
254
|
|
|
255
|
+
const scaffoldBoard = async (name) => {
|
|
256
|
+
const progress = new StepProgress(BOARD_STEPS)
|
|
257
|
+
console.log(progress.paint(colors.cyan, `\nCreating Surph board: ${name}\n`))
|
|
258
|
+
progress.start()
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
progress.begin('directory')
|
|
262
|
+
shell.mkdir('-p', name)
|
|
263
|
+
shell.cd(name)
|
|
264
|
+
await progress.done()
|
|
265
|
+
|
|
266
|
+
progress.begin('package')
|
|
267
|
+
const pkgJsonStr = utils.readFile(path.join(__dirname, 'board/package.json'))
|
|
268
|
+
const pkgJson = JSON.parse(pkgJsonStr)
|
|
269
|
+
pkgJson.name = name
|
|
270
|
+
utils.write('package.json', JSON.stringify(pkgJson, null, 2) + '\n')
|
|
271
|
+
|
|
272
|
+
shell.cp('-R', path.join(__dirname, `board/README.md`), 'README.md')
|
|
273
|
+
shell.cp('-R', path.join(__dirname, `board/index.html`), 'index.html')
|
|
274
|
+
|
|
275
|
+
shell.mkdir('-p', 'assets')
|
|
276
|
+
shell.mkdir('-p', 'assets/css')
|
|
277
|
+
shell.mkdir('-p', 'assets/js')
|
|
278
|
+
shell.cp('-R', path.join(__dirname, `board/assets/css/site.css`), 'assets/css/site.css')
|
|
279
|
+
|
|
280
|
+
// shell.cp('-R', path.join(__dirname, `board/assets/js/wave-sdk/v1/surph-wave.js`), 'assets/js/surph-wave.js')
|
|
281
|
+
|
|
282
|
+
await progress.done()
|
|
283
|
+
|
|
284
|
+
progress.begin('install')
|
|
285
|
+
await installDependencies(progress, process.cwd())
|
|
286
|
+
await progress.done()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
/*
|
|
290
|
+
progress.begin('examples')
|
|
291
|
+
shell.mkdir('-p', 'tools')
|
|
292
|
+
shell.cp('-R', path.join(__dirname, `tool/tools/index.ts`), 'tools/index.ts')
|
|
293
|
+
|
|
294
|
+
shell.mkdir('-p', 'examples')
|
|
295
|
+
shell.cp('-R', path.join(__dirname, `tool/examples/README.md`), 'examples/README.md')
|
|
296
|
+
|
|
297
|
+
shell.mkdir('-p', 'examples/population')
|
|
298
|
+
shell.cp('-R', path.join(__dirname, `tool/examples/population/index.ts`), 'examples/population/index.ts')
|
|
299
|
+
shell.cp('-R', path.join(__dirname, `tool/examples/population/manifest.json`), 'examples/population/manifest.json')
|
|
300
|
+
|
|
301
|
+
shell.mkdir('-p', 'examples/weather')
|
|
302
|
+
shell.cp('-R', path.join(__dirname, `tool/examples/weather/index.ts`), 'examples/weather/index.ts')
|
|
303
|
+
shell.cp('-R', path.join(__dirname, `tool/examples/weather/manifest.json`), 'examples/weather/manifest.json')
|
|
304
|
+
await progress.done()
|
|
305
|
+
|
|
306
|
+
progress.begin('manifests')
|
|
307
|
+
utils.write('manifest-example.json', JSON.stringify(manifestExample, null, 2) + '\n')
|
|
308
|
+
|
|
309
|
+
const slug = slugify(name, 8)
|
|
310
|
+
const manifest = {}
|
|
311
|
+
manifest[slug] = {
|
|
312
|
+
name,
|
|
313
|
+
slug,
|
|
314
|
+
type: 'custom',
|
|
315
|
+
description: '',
|
|
316
|
+
parameters: {
|
|
317
|
+
type: "object",
|
|
318
|
+
properties: {},
|
|
319
|
+
required: []
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
utils.write('manifest.json', JSON.stringify(manifest, null, 2) + '\n')
|
|
324
|
+
await progress.done()
|
|
325
|
+
|
|
326
|
+
progress.begin('config')
|
|
327
|
+
const envStr = utils.readFile(path.join(__dirname, 'tool/env.txt'))
|
|
328
|
+
utils.write('.env', envStr.replace('<SURPH_TOOL>', slug))
|
|
329
|
+
utils.write('tsconfig.json', JSON.stringify(tsConfig, null, 2) + '\n')
|
|
330
|
+
await progress.done()
|
|
331
|
+
|
|
332
|
+
progress.begin('cheatsheet')
|
|
333
|
+
try {
|
|
334
|
+
const cheatsheetResp = await fetch(CHEATSHEET_URL)
|
|
335
|
+
if (!cheatsheetResp.ok) {
|
|
336
|
+
throw new Error(`HTTP ${cheatsheetResp.status}`)
|
|
337
|
+
}
|
|
338
|
+
utils.write('CHEATSHEET.md', await cheatsheetResp.text())
|
|
339
|
+
} catch (error) {
|
|
340
|
+
// Cosmetic — a reference doc that didn't download is no reason to fail
|
|
341
|
+
// a scaffold that is otherwise complete.
|
|
342
|
+
progress.warn(`Could not fetch CHEATSHEET.md (${error.message}) - skipped.`)
|
|
343
|
+
}
|
|
344
|
+
await progress.done()
|
|
345
|
+
|
|
346
|
+
progress.begin('install')
|
|
347
|
+
await installDependencies(progress, process.cwd())
|
|
348
|
+
await progress.done()
|
|
349
|
+
|
|
350
|
+
progress.begin('devserver')
|
|
351
|
+
*/
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
await progress.complete()
|
|
355
|
+
} catch (error) {
|
|
356
|
+
progress.fail(error)
|
|
357
|
+
process.exitCode = 1
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
console.log('\n- - - - - Next Steps - - - - - ')
|
|
362
|
+
// console.log('Open codebase in a code editor and navigate to /tools/index.ts - this is where your tool code goes.')
|
|
363
|
+
// console.log('\nFor full documentation, visit https://docs.surph.ai\n')
|
|
364
|
+
// console.log('Starting the dev server - your browser will open at http://localhost:6767')
|
|
365
|
+
// console.log('To stop it, press Ctrl+C. To start it again, run: npm run dev\n')
|
|
366
|
+
|
|
367
|
+
// shell.exec('npm run dev')
|
|
368
|
+
}
|
|
369
|
+
|
|
244
370
|
module.exports = async (name, type = 'app') => {
|
|
245
371
|
/*
|
|
246
372
|
https://stackoverflow.com/questions/8683895/how-do-i-determine-the-current-operating-system-with-node-js
|
|
@@ -255,4 +381,8 @@ module.exports = async (name, type = 'app') => {
|
|
|
255
381
|
if (type === 'tool') {
|
|
256
382
|
await scaffoldTool(name)
|
|
257
383
|
}
|
|
384
|
+
|
|
385
|
+
if (type === 'board') {
|
|
386
|
+
await scaffoldBoard(name)
|
|
387
|
+
}
|
|
258
388
|
}
|
|
@@ -1,21 +1,15 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
2
|
+
"name": "surph-board",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "",
|
|
5
5
|
"scripts": {
|
|
6
|
-
"
|
|
7
|
-
"dev": "nodemon",
|
|
8
|
-
"test": "",
|
|
9
|
-
"build": ""
|
|
10
|
-
},
|
|
11
|
-
"dependencies": {
|
|
12
|
-
"@turbo360/turbo-sdk": "^0.0.8",
|
|
13
|
-
"cookie": "^0.5.0",
|
|
14
|
-
"express": "^4.18.1",
|
|
15
|
-
"mustache": "^4.2.0",
|
|
16
|
-
"saxa": "^0.0.26"
|
|
6
|
+
"dev": "http-server -p 4180 -c-1"
|
|
17
7
|
},
|
|
8
|
+
"keywords": [],
|
|
9
|
+
"author": "",
|
|
10
|
+
"license": "ISC",
|
|
11
|
+
"type": "commonjs",
|
|
18
12
|
"devDependencies": {
|
|
19
|
-
"
|
|
13
|
+
"http-server": "^14.1.1"
|
|
20
14
|
}
|
|
21
15
|
}
|
|
@@ -29,20 +29,22 @@ const readSurphSession = () => {
|
|
|
29
29
|
|
|
30
30
|
router.get('/', async (req, res, next) => {
|
|
31
31
|
try {
|
|
32
|
+
// res.json({data: 'test'})
|
|
33
|
+
// return
|
|
32
34
|
const toolSlugs = Object.keys(manifest)
|
|
33
35
|
|
|
34
36
|
// Mirror `userId` to `_id` (and `id`) so preloaded.user matches the
|
|
35
37
|
// production shape the web app consumes — useSettings.js reads
|
|
36
38
|
// `preloaded.user` and downstream code branches on `_id`.
|
|
37
39
|
const session = readSurphSession()
|
|
38
|
-
// const { user, contexts,
|
|
40
|
+
// const { user, contexts, waves, tools } = session?.userId? await userConfiguration({ id: session.userId }) : null
|
|
39
41
|
const userConfig = session?.userId? await userConfiguration({ id: session.userId }) : null
|
|
40
42
|
|
|
41
43
|
let title = 'Surph AI'
|
|
42
44
|
let ctx = null
|
|
43
45
|
let board = null
|
|
44
46
|
let tools = null
|
|
45
|
-
let
|
|
47
|
+
let waves = null
|
|
46
48
|
let contexts = null
|
|
47
49
|
|
|
48
50
|
if (req.surph_board) {
|
|
@@ -51,15 +53,15 @@ router.get('/', async (req, res, next) => {
|
|
|
51
53
|
title = req.surph_board.name
|
|
52
54
|
|
|
53
55
|
tools = req.surph_board.tools
|
|
54
|
-
|
|
56
|
+
waves = req.surph_board.waves
|
|
55
57
|
contexts = req.surph_board.contexts
|
|
56
58
|
} else if (userConfig) {
|
|
57
59
|
tools = userConfig.tools
|
|
58
|
-
|
|
60
|
+
waves = userConfig.waves
|
|
59
61
|
contexts = userConfig.contexts
|
|
60
62
|
} else { // no user logged in, set empty:
|
|
61
63
|
tools = []
|
|
62
|
-
|
|
64
|
+
waves = []
|
|
63
65
|
contexts = []
|
|
64
66
|
}
|
|
65
67
|
|
|
@@ -67,17 +69,17 @@ router.get('/', async (req, res, next) => {
|
|
|
67
69
|
const creator = userConfig ? { _id: user._id, username: user.username, avatar: user.avatar } : {}
|
|
68
70
|
const localTools = toolSlugs.map(slug => {
|
|
69
71
|
const tool = manifest[slug]
|
|
70
|
-
let
|
|
71
|
-
|
|
72
|
+
let isWaveTool = false
|
|
73
|
+
waves.forEach(t => {
|
|
72
74
|
if (!t.tools)
|
|
73
75
|
return
|
|
74
76
|
|
|
75
77
|
if (t.tools.indexOf(slug) > -1) {
|
|
76
|
-
|
|
78
|
+
isWaveTool = true
|
|
77
79
|
}
|
|
78
80
|
})
|
|
79
81
|
|
|
80
|
-
return
|
|
82
|
+
return isWaveTool ? { ...tool, creator, origin: 'wave' } : { ...tool, creator }
|
|
81
83
|
})
|
|
82
84
|
|
|
83
85
|
const context = {
|
|
@@ -89,7 +91,7 @@ router.get('/', async (req, res, next) => {
|
|
|
89
91
|
user,
|
|
90
92
|
config: { ...req.config, entry: 'web' },
|
|
91
93
|
contexts,
|
|
92
|
-
|
|
94
|
+
waves,
|
|
93
95
|
tools: localTools // sdk mode is for local tool development only, do not clutter with live tools.
|
|
94
96
|
// tools: [ ...tools, ...localTools ]
|
|
95
97
|
}
|
|
@@ -32,10 +32,18 @@ const boardConfiguration = async (boardSlug) => {
|
|
|
32
32
|
}
|
|
33
33
|
});
|
|
34
34
|
const { payload } = await response.json();
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
// console.log('PAYLOAD: ' + JSON.stringify(payload))
|
|
36
|
+
if (!payload)
|
|
37
|
+
return null;
|
|
38
|
+
if (!payload.board)
|
|
39
|
+
return null;
|
|
40
|
+
// Tides are retired: drop the board's legacy tide list (still on the
|
|
41
|
+
// Mongo record and in the query payload) so nothing downstream sees it.
|
|
42
|
+
const { tides: _tides, ...board } = payload.board;
|
|
43
|
+
const { tides: _boardTides, ...config } = payload;
|
|
44
|
+
const result = { ...config, board };
|
|
45
|
+
BOARD_CACHE[boardSlug] = result;
|
|
46
|
+
return result;
|
|
39
47
|
}
|
|
40
48
|
catch (error) {
|
|
41
49
|
console.error('Error fetching board: ', error.message);
|
|
@@ -1,29 +1,56 @@
|
|
|
1
1
|
const REST_API_URL = 'https://surph-mongo-http-19075ff8d93f.herokuapp.com'
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
if (!
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
'
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
3
|
+
// The user_configuration query also adds the tools attached to the user's
|
|
4
|
+
// waves, tagged `origin: 'wave'` (often a second copy of an installed tool).
|
|
5
|
+
// Keep only the ones the user created, untagged, and one entry per slug.
|
|
6
|
+
const withoutWaveTools = (tools, userId) => {
|
|
7
|
+
if (!Array.isArray(tools))
|
|
8
|
+
return tools;
|
|
9
|
+
const uid = String(userId);
|
|
10
|
+
const seen = new Set();
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const t of tools) {
|
|
13
|
+
if (!t)
|
|
14
|
+
continue;
|
|
15
|
+
if (t.origin === 'wave') {
|
|
16
|
+
const creatorId = t.creator?._id || t.creator?.id || null;
|
|
17
|
+
if (creatorId == null || String(creatorId) !== uid)
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const { origin: _origin, ...tool } = t;
|
|
21
|
+
const key = tool.slug || String(tool._id || '');
|
|
22
|
+
if (key && seen.has(key))
|
|
23
|
+
continue;
|
|
24
|
+
if (key)
|
|
25
|
+
seen.add(key);
|
|
26
|
+
out.push(tool);
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
};
|
|
30
|
+
// fetch user contexts, waves, tools, boards, etc.
|
|
31
|
+
const userConfiguration = async (user) => {
|
|
32
|
+
try {
|
|
33
|
+
const userId = user?._id || user?.id || null;
|
|
34
|
+
if (!userId)
|
|
35
|
+
return null;
|
|
36
|
+
const response = await fetch(`${REST_API_URL}/queries/user_configuration`, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
body: JSON.stringify({ user: userId }),
|
|
39
|
+
headers: {
|
|
40
|
+
'Accept': 'application/json',
|
|
41
|
+
'Content-type': 'application/json',
|
|
42
|
+
'x-surph-client': 'surph-webapp',
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
const { payload } = await response.json();
|
|
46
|
+
const { user: _user, contexts, waves, tools, boards, templates } = payload;
|
|
47
|
+
// Tides are retired: drop the user's legacy subscribed-tide list.
|
|
48
|
+
const { tides: _tides, ...userRecord } = _user || {};
|
|
49
|
+
return { user: _user ? userRecord : _user, contexts, waves, tools: withoutWaveTools(tools, userId), boards, templates };
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
28
55
|
|
|
29
56
|
module.exports = userConfiguration;
|