reladraw 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.claude/skills/reladraw/SKILL.md +7 -5
  2. package/.claude/skills/reladraw/reference/syntax.md +44 -5
  3. package/README.md +2 -2
  4. package/SYNTAX.md +44 -5
  5. package/dist/cli.js +15 -1
  6. package/dist/examples.d.ts +26 -0
  7. package/dist/examples.js +56 -0
  8. package/dist/page-data.d.ts +130 -0
  9. package/dist/page-data.js +226 -0
  10. package/dist/page.d.ts +46 -2
  11. package/dist/page.js +1239 -241
  12. package/dist/serve.d.ts +18 -0
  13. package/dist/serve.js +544 -0
  14. package/examples/arch.reladraw +95 -0
  15. package/examples/around.reladraw +101 -0
  16. package/examples/borders.reladraw +28 -0
  17. package/examples/bundles.reladraw +49 -0
  18. package/examples/circles.reladraw +37 -0
  19. package/examples/coincident.reladraw +66 -0
  20. package/examples/contents.reladraw +52 -0
  21. package/examples/corridors.reladraw +48 -0
  22. package/examples/deck.reladraw +26 -0
  23. package/examples/flowchart.reladraw +23 -0
  24. package/examples/frames.reladraw +79 -0
  25. package/examples/gap.reladraw +21 -0
  26. package/examples/gaps.reladraw +25 -0
  27. package/examples/icons.reladraw +35 -0
  28. package/examples/lanes.reladraw +54 -0
  29. package/examples/lines.reladraw +73 -0
  30. package/examples/linetexts.reladraw +48 -0
  31. package/examples/marks.reladraw +39 -0
  32. package/examples/names.reladraw +23 -0
  33. package/examples/outlines.reladraw +56 -0
  34. package/examples/overhang.reladraw +24 -0
  35. package/examples/overlays.reladraw +75 -0
  36. package/examples/parts.reladraw +31 -0
  37. package/examples/passes.reladraw +56 -0
  38. package/examples/points.reladraw +28 -0
  39. package/examples/regions.reladraw +14 -0
  40. package/examples/routing.reladraw +88 -0
  41. package/examples/shapes.reladraw +38 -0
  42. package/examples/snug.reladraw +8 -0
  43. package/examples/text.reladraw +69 -0
  44. package/examples/themes.reladraw +16 -0
  45. package/examples/urls.reladraw +35 -0
  46. package/examples/venn.reladraw +15 -0
  47. package/package.json +2 -1
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `reladraw serve`: a page for one diagram, served on this machine and tied to
3
+ * its file. The page redraws whenever the file changes on disk, whoever
4
+ * changed it, so a person and an agent can both work on the one file.
5
+ *
6
+ * One server serves a folder, the one it was started in. Asked for a second
7
+ * file in that folder, `serve` finds the server already running and opens a
8
+ * page on it rather than starting another. It listens only on this machine,
9
+ * and serves only `.reladraw` files inside the folder, and the library the
10
+ * page draws with.
11
+ */
12
+ /** The port tried first, and how many after it are tried before giving up. */
13
+ export declare const FIRST_PORT = 4747;
14
+ export declare function serve(args: string[]): Promise<number>;
15
+ /** `reladraw note`: run from the folder the server serves, as serve is. */
16
+ export declare function noteCommand(args: string[]): Promise<number>;
17
+ /** Opens an address in the default browser, as the skill's pages are opened. */
18
+ export declare function openInBrowser(address: string): void;
package/dist/serve.js ADDED
@@ -0,0 +1,544 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { watch } from 'node:fs';
3
+ import { readFile, realpath, stat, writeFile } from 'node:fs/promises';
4
+ import { createServer, request } from 'node:http';
5
+ import { homedir } from 'node:os';
6
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve as resolvePath, sep } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { OFFERED, START } from './examples.js';
9
+ import { SETTINGS_FILE } from './format-settings.js';
10
+ import { drawPage, playgroundPage } from './page.js';
11
+ /**
12
+ * `reladraw serve`: a page for one diagram, served on this machine and tied to
13
+ * its file. The page redraws whenever the file changes on disk, whoever
14
+ * changed it, so a person and an agent can both work on the one file.
15
+ *
16
+ * One server serves a folder, the one it was started in. Asked for a second
17
+ * file in that folder, `serve` finds the server already running and opens a
18
+ * page on it rather than starting another. It listens only on this machine,
19
+ * and serves only `.reladraw` files inside the folder, and the library the
20
+ * page draws with.
21
+ */
22
+ /** The port tried first, and how many after it are tried before giving up. */
23
+ export const FIRST_PORT = 4747;
24
+ const PORTS = 20;
25
+ const HELP = `reladraw serve [<file.reladraw>] [--no-open]
26
+
27
+ Serves a page for the diagram on this machine and opens it in the browser.
28
+ The page redraws whenever the file changes on disk, and its Save writes the
29
+ file. With no file, the page is a playground: the examples, and a Save that
30
+ names a file in the folder, after which the page follows that file. One
31
+ server serves the folder it was started in: serving another file in that
32
+ folder opens a page on the server already running. Ctrl-C stops it.
33
+
34
+ --no-open print the page's address instead of opening it.
35
+ `;
36
+ /** Where the library lives: the compiled files beside this one. */
37
+ const LIB = dirname(fileURLToPath(import.meta.url));
38
+ /** The examples a page with no file offers, which the package carries beside the library. */
39
+ async function examples() {
40
+ const offered = [START];
41
+ for (const [file, name, about] of OFFERED) {
42
+ try {
43
+ offered.push({ name, about, source: await readFile(join(LIB, '..', 'examples', `${file}.reladraw`), 'utf8') });
44
+ }
45
+ catch {
46
+ // A copy of the package without its examples still has a playground.
47
+ }
48
+ }
49
+ return offered;
50
+ }
51
+ /** A folder as the page names it: from the home folder, as ~/proj, when it is inside it. */
52
+ function shown(folder) {
53
+ const home = homedir();
54
+ const rel = relative(home, folder);
55
+ return rel === '' ? '~' : !rel.startsWith('..') && !isAbsolute(rel) ? `~/${rel.split(sep).join('/')}` : folder;
56
+ }
57
+ /** How many versions a file keeps. */
58
+ const KEPT = 20;
59
+ function record(entry, text, who, at = Date.now()) {
60
+ entry.text = text;
61
+ entry.at = at;
62
+ entry.changed = who !== 'opened';
63
+ entry.versions.push({ text, at, who });
64
+ if (entry.versions.length > KEPT)
65
+ entry.versions.splice(0, entry.versions.length - KEPT);
66
+ }
67
+ function newEntry() {
68
+ return { text: null, at: Date.now(), changed: false, listeners: new Set(), watcher: null, timer: null, versions: [] };
69
+ }
70
+ /** The entry for a file, its text read if it hasn't been yet. */
71
+ async function entryFor(root, followed, path) {
72
+ let entry = followed.get(path);
73
+ if (!entry) {
74
+ entry = newEntry();
75
+ followed.set(path, entry);
76
+ }
77
+ if (entry.text === null)
78
+ record(entry, await readFile(join(root, path), 'utf8'), 'opened');
79
+ return entry;
80
+ }
81
+ function broadcast(entry) {
82
+ for (const listener of entry.listeners)
83
+ send(listener, entry);
84
+ }
85
+ async function body(req) {
86
+ let text = '';
87
+ req.setEncoding('utf8');
88
+ for await (const chunk of req) {
89
+ text += chunk;
90
+ if (text.length > 5_000_000)
91
+ throw new Error('too large');
92
+ }
93
+ return text;
94
+ }
95
+ export async function serve(args) {
96
+ let file;
97
+ let open = true;
98
+ for (const arg of args) {
99
+ if (arg === '-h' || arg === '--help') {
100
+ process.stdout.write(HELP);
101
+ return 0;
102
+ }
103
+ else if (arg === '--no-open') {
104
+ open = false;
105
+ }
106
+ else if (arg.startsWith('-')) {
107
+ process.stderr.write(`reladraw: serve doesn't take ${arg}; it takes a file and --no-open\n`);
108
+ return 1;
109
+ }
110
+ else if (file !== undefined) {
111
+ process.stderr.write(`reladraw: serve takes one file; serve each in turn, and they share one server\n`);
112
+ return 1;
113
+ }
114
+ else {
115
+ file = arg;
116
+ }
117
+ }
118
+ const root = await realpath(process.cwd());
119
+ let path = '';
120
+ if (file !== undefined) {
121
+ const inside = await insideRoot(root, file);
122
+ if (typeof inside !== 'string') {
123
+ process.stderr.write(`reladraw: ${inside.problem}\n`);
124
+ return 1;
125
+ }
126
+ path = inside;
127
+ }
128
+ // A server already serving this folder is used as it is.
129
+ for (let port = FIRST_PORT; port < FIRST_PORT + PORTS; port++) {
130
+ const there = await whoIsOn(port);
131
+ if (there === null)
132
+ continue;
133
+ if (there.root === root) {
134
+ const address = pageAddress(port, path);
135
+ process.stdout.write(`reladraw is already serving ${root} at http://127.0.0.1:${port}/\n${address}\n`);
136
+ if (open)
137
+ openInBrowser(address);
138
+ return 0;
139
+ }
140
+ }
141
+ const port = await listen(root);
142
+ if (port === null) {
143
+ process.stderr.write(`reladraw: no free port from ${FIRST_PORT} to ${FIRST_PORT + PORTS - 1}\n`);
144
+ return 1;
145
+ }
146
+ const address = pageAddress(port, path);
147
+ process.stdout.write(`reladraw is serving ${root} at http://127.0.0.1:${port}/ — Ctrl-C stops it\n${address}\n`);
148
+ if (open)
149
+ openInBrowser(address);
150
+ // Runs until stopped.
151
+ return new Promise(() => { });
152
+ }
153
+ function pageAddress(port, path) {
154
+ return `http://127.0.0.1:${port}/${path.split('/').map(encodeURIComponent).join('/')}`;
155
+ }
156
+ /**
157
+ * The file's path inside the folder, with forward slashes, or what is wrong
158
+ * with it: outside the folder, not a .reladraw file, or not there.
159
+ */
160
+ async function insideRoot(root, file) {
161
+ if (extname(file) !== '.reladraw')
162
+ return { problem: `${file} isn't a .reladraw file`, status: 404 };
163
+ const full = isAbsolute(file) ? file : resolvePath(root, file);
164
+ let real;
165
+ try {
166
+ real = await realpath(full);
167
+ if (!(await stat(real)).isFile())
168
+ return { problem: `${file} isn't a file`, status: 404 };
169
+ }
170
+ catch {
171
+ return { problem: `there is no ${file}`, status: 404 };
172
+ }
173
+ const rel = relative(root, real);
174
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
175
+ return {
176
+ problem: `${file} is outside ${root}, the folder serve was started in; start it in a folder that holds the file`,
177
+ status: 403,
178
+ };
179
+ }
180
+ return rel.split(sep).join('/');
181
+ }
182
+ /** Which folder a reladraw server on this port serves, or null if none is there. */
183
+ function whoIsOn(port) {
184
+ return new Promise((done) => {
185
+ const asking = request({ host: '127.0.0.1', port, path: '/.reladraw', timeout: 800 }, (response) => {
186
+ let body = '';
187
+ response.setEncoding('utf8');
188
+ response.on('data', (chunk) => (body += chunk));
189
+ response.on('end', () => {
190
+ try {
191
+ const told = JSON.parse(body);
192
+ done(told.reladraw && typeof told.root === 'string' ? { root: told.root } : null);
193
+ }
194
+ catch {
195
+ done(null);
196
+ }
197
+ });
198
+ });
199
+ asking.on('error', () => done(null));
200
+ asking.on('timeout', () => {
201
+ asking.destroy();
202
+ done(null);
203
+ });
204
+ asking.end();
205
+ });
206
+ }
207
+ function listen(root) {
208
+ const followed = new Map();
209
+ const server = createServer((req, res) => {
210
+ handle(root, followed, req, res).catch((error) => {
211
+ if (!res.headersSent)
212
+ res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
213
+ res.end(String(error instanceof Error ? error.message : error));
214
+ });
215
+ });
216
+ return new Promise((done) => {
217
+ const attempt = (port) => {
218
+ if (port >= FIRST_PORT + PORTS)
219
+ return done(null);
220
+ server.once('error', () => attempt(port + 1));
221
+ server.listen(port, '127.0.0.1', () => {
222
+ server.removeAllListeners('error');
223
+ done(port);
224
+ });
225
+ };
226
+ attempt(FIRST_PORT);
227
+ });
228
+ }
229
+ const TYPES = {
230
+ '.js': 'text/javascript; charset=utf-8',
231
+ '.html': 'text/html; charset=utf-8',
232
+ };
233
+ async function handle(root, followed, req, res) {
234
+ // Only pages from this server may use it: a page on some other site could
235
+ // otherwise reach it through a name that resolves to this machine.
236
+ const host = req.headers.host ?? '';
237
+ if (!/^(127\.0\.0\.1|localhost)(:\d+)?$/.test(host)) {
238
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
239
+ res.end('reladraw serves only this machine\n');
240
+ return;
241
+ }
242
+ const url = new URL(req.url ?? '/', `http://${host}`);
243
+ const path = decodeURIComponent(url.pathname);
244
+ if (path === '/.reladraw') {
245
+ res.writeHead(200, { 'content-type': 'application/json' });
246
+ res.end(JSON.stringify({ reladraw: true, root }));
247
+ return;
248
+ }
249
+ if (path.startsWith('/lib/')) {
250
+ const name = path.slice('/lib/'.length);
251
+ if (!/^[a-z0-9-]+\.js$/.test(name))
252
+ return notFound(res, path);
253
+ try {
254
+ const code = await readFile(join(LIB, name));
255
+ res.writeHead(200, { 'content-type': TYPES['.js'], 'cache-control': 'no-store' });
256
+ res.end(code);
257
+ }
258
+ catch {
259
+ notFound(res, path);
260
+ }
261
+ return;
262
+ }
263
+ if (path === '/events') {
264
+ const inside = await insideRoot(root, url.searchParams.get('file') ?? '');
265
+ if (typeof inside !== 'string')
266
+ return refuse(res, inside);
267
+ return follow(root, followed, inside, res);
268
+ }
269
+ // What changes a file comes only from a page this server served: a page on
270
+ // another site sends its own origin, and is refused.
271
+ if (req.method === 'POST' && req.headers.origin !== undefined && req.headers.origin !== `http://${host}`) {
272
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
273
+ res.end('reladraw: only its own pages may change a file\n');
274
+ return;
275
+ }
276
+ if (path === '/save' && req.method === 'POST')
277
+ return save(root, followed, url, req, res);
278
+ if (path === '/create' && req.method === 'POST')
279
+ return create(root, followed, url, req, res);
280
+ if (path === '/note' && req.method === 'POST')
281
+ return note(root, followed, url, req, res);
282
+ if (path === '/format-settings') {
283
+ const inside = await insideRoot(root, url.searchParams.get('file') ?? '');
284
+ if (typeof inside !== 'string')
285
+ return refuse(res, inside);
286
+ res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
287
+ res.end(await nearestSettings(dirname(join(root, inside))));
288
+ return;
289
+ }
290
+ if (path === '/') {
291
+ res.writeHead(200, { 'content-type': TYPES['.html'], 'cache-control': 'no-store' });
292
+ res.end(playgroundPage({ examples: await examples(), folder: shown(root) }));
293
+ return;
294
+ }
295
+ const inside = await insideRoot(root, path.slice(1));
296
+ if (typeof inside !== 'string')
297
+ return refuse(res, inside);
298
+ const text = await readFile(join(root, inside), 'utf8');
299
+ let html;
300
+ try {
301
+ html = drawPage(text, basename(inside), {}, new Date(), { kind: 'file', file: inside }).html;
302
+ }
303
+ catch {
304
+ // A file that doesn't draw yet still gets its page, drawn empty. The
305
+ // file's text arrives with the page's first event, fails there too, and
306
+ // the page shows the error, then draws as soon as the file does.
307
+ html = drawPage('', basename(inside), {}, new Date(), { kind: 'file', file: inside }).html;
308
+ }
309
+ res.writeHead(200, { 'content-type': TYPES['.html'], 'cache-control': 'no-store' });
310
+ res.end(html);
311
+ }
312
+ function notFound(res, path) {
313
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
314
+ res.end(`reladraw: nothing at ${path}\n`);
315
+ }
316
+ function refuse(res, why) {
317
+ res.writeHead(why.status, { 'content-type': 'text/plain; charset=utf-8' });
318
+ res.end(`reladraw: ${why.problem}\n`);
319
+ }
320
+ /**
321
+ * Sends the file's text now and again each time it changes, as server-sent
322
+ * events. The folder is watched rather than the file, because an editor that
323
+ * saves by writing a new file and renaming it over the old one leaves a watch
324
+ * on the old file watching nothing.
325
+ */
326
+ async function follow(root, followed, path, res) {
327
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
328
+ const one = await entryFor(root, followed, path);
329
+ one.listeners.add(res);
330
+ send(res, one);
331
+ const keepAlive = setInterval(() => res.write(': still here\n\n'), 25_000);
332
+ res.on('close', () => {
333
+ clearInterval(keepAlive);
334
+ one.listeners.delete(res);
335
+ });
336
+ if (!one.watcher) {
337
+ const full = join(root, path);
338
+ const name = basename(full);
339
+ one.watcher = watch(dirname(full), (_event, changed) => {
340
+ if (changed !== null && changed.toString() !== name)
341
+ return;
342
+ // Editors write in bursts; read once they settle.
343
+ if (one.timer)
344
+ clearTimeout(one.timer);
345
+ one.timer = setTimeout(() => {
346
+ one.timer = null;
347
+ readFile(full, 'utf8').then((text) => {
348
+ if (text === one.text)
349
+ return;
350
+ record(one, text, 'disk');
351
+ broadcast(one);
352
+ }, () => {
353
+ // Gone for the moment, as between an editor's delete and its
354
+ // rename; the next change brings it back.
355
+ });
356
+ }, 60);
357
+ });
358
+ }
359
+ }
360
+ /**
361
+ * The file's text, when it was read or changed and by whom, and its versions,
362
+ * newest last, each with its text, so the page can offer any of them back.
363
+ */
364
+ function send(res, entry) {
365
+ const last = entry.versions[entry.versions.length - 1];
366
+ const message = {
367
+ text: entry.text,
368
+ at: entry.at,
369
+ changed: entry.changed,
370
+ who: last?.who ?? 'opened',
371
+ note: last?.note ?? null,
372
+ versions: entry.versions,
373
+ };
374
+ res.write(`event: source\ndata: ${JSON.stringify(message)}\n\n`);
375
+ }
376
+ function reply(res, status, value) {
377
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
378
+ res.end(JSON.stringify(value));
379
+ }
380
+ /**
381
+ * A save from the page. It carries the text the page last had from the file;
382
+ * if the file has changed since, the save is refused with the file as it is
383
+ * now, unless it says to replace it anyway, which the page asks the person
384
+ * first. Nothing else on the page writes the file.
385
+ */
386
+ async function save(root, followed, url, req, res) {
387
+ const inside = await insideRoot(root, url.searchParams.get('file') ?? '');
388
+ if (typeof inside !== 'string')
389
+ return refuse(res, inside);
390
+ const asked = JSON.parse(await body(req));
391
+ if (typeof asked.text !== 'string' || typeof asked.base !== 'string')
392
+ return reply(res, 400, { error: 'text and base are wanted' });
393
+ const entry = await entryFor(root, followed, inside);
394
+ const full = join(root, inside);
395
+ // Read now rather than trust the watcher, which may not have caught up.
396
+ const now = await readFile(full, 'utf8');
397
+ if (now !== entry.text)
398
+ record(entry, now, 'disk');
399
+ if (now !== asked.base && !asked.replace) {
400
+ const last = entry.versions[entry.versions.length - 1];
401
+ return reply(res, 409, { text: now, at: last.at, who: last.who, note: last.note ?? null });
402
+ }
403
+ await writeFile(full, asked.text, 'utf8');
404
+ record(entry, asked.text, 'you');
405
+ reply(res, 200, { at: entry.at });
406
+ broadcast(entry);
407
+ }
408
+ /**
409
+ * The first Save of a page with no file: a new file at a path inside the
410
+ * folder, typed on the page. Its folder must already be there. A file already
411
+ * at that path is replaced only when the page says to, which it asks the
412
+ * person first. From then on the page saves through `save`, as a page opened
413
+ * with the file does.
414
+ */
415
+ async function create(root, followed, url, req, res) {
416
+ const asked = JSON.parse(await body(req));
417
+ if (typeof asked.text !== 'string')
418
+ return reply(res, 400, { error: 'text is wanted' });
419
+ const typed = (url.searchParams.get('file') ?? '').trim();
420
+ if (extname(typed) !== '.reladraw')
421
+ return reply(res, 400, { error: `${typed || 'The path'} isn't a .reladraw file` });
422
+ if (isAbsolute(typed) || typed.split(/[\\/]/).includes('..')) {
423
+ return reply(res, 400, { error: `Give a path inside ${shown(root)}, such as diagram.reladraw or docs/flow.reladraw` });
424
+ }
425
+ const full = resolvePath(root, typed);
426
+ let folder;
427
+ try {
428
+ folder = await realpath(dirname(full));
429
+ }
430
+ catch {
431
+ return reply(res, 400, { error: `There is no folder ${relative(root, dirname(full)).split(sep).join('/')} in ${shown(root)}` });
432
+ }
433
+ const within = relative(root, folder);
434
+ if (within.startsWith('..') || isAbsolute(within))
435
+ return reply(res, 403, { error: `That folder is outside ${shown(root)}` });
436
+ const there = await stat(full).then((found) => found, () => null);
437
+ if (there !== null) {
438
+ const inside = await insideRoot(root, full);
439
+ if (typeof inside !== 'string')
440
+ return reply(res, inside.status, { error: inside.problem });
441
+ if (!asked.replace)
442
+ return reply(res, 409, { exists: true, file: inside });
443
+ }
444
+ await writeFile(full, asked.text, 'utf8');
445
+ const inside = await insideRoot(root, full);
446
+ if (typeof inside !== 'string')
447
+ return reply(res, inside.status, { error: inside.problem });
448
+ const entry = followed.get(inside) ?? newEntry();
449
+ followed.set(inside, entry);
450
+ record(entry, asked.text, 'you');
451
+ reply(res, 200, { file: inside, at: entry.at });
452
+ broadcast(entry);
453
+ }
454
+ /**
455
+ * `reladraw note <file> "<what changed>"`: an agent describes the change it
456
+ * just made. The note goes on the file's latest version, read now so a change
457
+ * the watcher hasn't reached yet is the one described, and marks it the agent's.
458
+ */
459
+ async function note(root, followed, url, req, res) {
460
+ const inside = await insideRoot(root, url.searchParams.get('file') ?? '');
461
+ if (typeof inside !== 'string')
462
+ return refuse(res, inside);
463
+ const asked = JSON.parse(await body(req));
464
+ if (typeof asked.note !== 'string' || asked.note.trim() === '')
465
+ return reply(res, 400, { error: 'a note is wanted' });
466
+ const entry = await entryFor(root, followed, inside);
467
+ const now = await readFile(join(root, inside), 'utf8');
468
+ if (now !== entry.text)
469
+ record(entry, now, 'disk');
470
+ const last = entry.versions[entry.versions.length - 1];
471
+ last.who = 'agent';
472
+ last.note = asked.note.trim().slice(0, 300);
473
+ reply(res, 200, { at: last.at });
474
+ broadcast(entry);
475
+ }
476
+ /** The text of the nearest .reladraw-format from this folder up, or nothing. */
477
+ async function nearestSettings(folder) {
478
+ for (let at = folder;; at = dirname(at)) {
479
+ try {
480
+ return await readFile(join(at, SETTINGS_FILE), 'utf8');
481
+ }
482
+ catch {
483
+ if (dirname(at) === at)
484
+ return '';
485
+ }
486
+ }
487
+ }
488
+ const NOTE_HELP = `reladraw note <file.reladraw> "<what changed>"
489
+
490
+ Describes the change just made to the file, for the page reladraw serve shows
491
+ it on: the page's Versions menu lists it as the agent's, with this note. With
492
+ no server running for the file's folder, nothing is noted.
493
+ `;
494
+ /** `reladraw note`: run from the folder the server serves, as serve is. */
495
+ export async function noteCommand(args) {
496
+ if (args.includes('-h') || args.includes('--help')) {
497
+ process.stdout.write(NOTE_HELP);
498
+ return 0;
499
+ }
500
+ const [file, text, ...rest] = args;
501
+ if (file === undefined || text === undefined || rest.length > 0 || text.trim() === '') {
502
+ process.stderr.write('reladraw: note takes a file and one quoted note: reladraw note diagram.reladraw "moved the cache under the API"\n');
503
+ return 1;
504
+ }
505
+ const root = await realpath(process.cwd());
506
+ const inside = await insideRoot(root, file);
507
+ if (typeof inside !== 'string') {
508
+ process.stderr.write(`reladraw: ${inside.problem}\n`);
509
+ return 1;
510
+ }
511
+ for (let port = FIRST_PORT; port < FIRST_PORT + PORTS; port++) {
512
+ const there = await whoIsOn(port);
513
+ if (there?.root !== root)
514
+ continue;
515
+ const answer = await fetch(`http://127.0.0.1:${port}/note?file=${encodeURIComponent(inside)}`, {
516
+ method: 'POST',
517
+ body: JSON.stringify({ note: text }),
518
+ });
519
+ if (answer.ok)
520
+ return 0;
521
+ process.stderr.write(`reladraw: the server didn't take the note: ${await answer.text()}\n`);
522
+ return 1;
523
+ }
524
+ // Not an error: an agent told to leave a note shouldn't be stopped by there
525
+ // being no page to show it on.
526
+ process.stdout.write(`reladraw: no server is serving ${root}, so nothing was noted\n`);
527
+ return 0;
528
+ }
529
+ /** Opens an address in the default browser, as the skill's pages are opened. */
530
+ export function openInBrowser(address) {
531
+ const [command, args] = process.platform === 'darwin'
532
+ ? ['open', [address]]
533
+ : process.platform === 'win32'
534
+ ? ['cmd', ['/c', 'start', '""', address]]
535
+ : ['xdg-open', [address]];
536
+ try {
537
+ spawn(command, args, { stdio: 'ignore', detached: true }).on('error', () => {
538
+ process.stderr.write(`reladraw: couldn't open a browser; the page is at ${address}\n`);
539
+ }).unref();
540
+ }
541
+ catch {
542
+ process.stderr.write(`reladraw: couldn't open a browser; the page is at ${address}\n`);
543
+ }
544
+ }
@@ -0,0 +1,95 @@
1
+ // Transcribed from a draw.io diagram.
2
+
3
+ // --- Styles -----------------------------------------------------------------
4
+ style synced fill: theme-primary-subtle border: theme-primary line: theme-primary
5
+ style dim text: (color: theme-muted)
6
+ style backup border: #d2904e line: #d2904e
7
+ style dump fill: theme-secondary-subtle border: theme-border shape: document
8
+
9
+ // The draw.io original draws every line as a curve.
10
+ default edge line: (path: curved)
11
+
12
+
13
+ // --- The hub and its four machines ------------------------------------------
14
+ // The cluster is wrapped in an invisible container so things outside it can be
15
+ // placed against the whole cluster rather than against the hub alone. Each
16
+ // machine's title and picture are its heading, in the top-left corner, and its
17
+ // folder sits under them.
18
+
19
+ node cluster "" shape: none
20
+
21
+ node cluster.hub "Cloud sync" badge: package style: synced
22
+
23
+ node cluster.desktop1 "Desktop 1 / Linux" (inside top, align: left) above-left of cluster.hub (gap: tight) badge: desktop
24
+ node cluster.desktop1.files "shared folder / [dim]synced[/dim]" style: synced
25
+
26
+ node cluster.desktop2 "Desktop 2 / Windows" (inside top, align: left) above-right of cluster.hub (gap: tight) badge: desktop
27
+ node cluster.desktop2.files "shared folder / [dim]synced[/dim]" style: synced
28
+
29
+ node cluster.laptop1 "Laptop 1 / macOS" (inside top, align: left) below-left of cluster.hub (gap: tight) badge: laptop
30
+ node cluster.laptop1.files "shared folder / [dim]synced[/dim]" style: synced
31
+
32
+ node cluster.laptop2 "Laptop 2 / macOS" (inside top, align: left) below-right of cluster.hub (gap: tight) badge: laptop
33
+ node cluster.laptop2.files "shared folder / [dim]synced[/dim]" style: synced
34
+
35
+ edge cluster.desktop1.files <-> cluster.hub style: synced from: right to: top
36
+ edge cluster.desktop2.files <-> cluster.hub style: synced from: left to: top
37
+ edge cluster.laptop1.files <-> cluster.hub style: synced from: right to: bottom
38
+ edge cluster.laptop2.files <-> cluster.hub style: synced from: left to: bottom
39
+
40
+
41
+ // --- The database dumps -----------------------------------------------------
42
+ // The dumps sit a wide gap left of the machines, level with the Docker cubes,
43
+ // and the server a gap left of the dumps.
44
+
45
+ node dumps "" left of cluster (gap: wide) even with server.docker shape: none layout: column
46
+ node dumps.db1 "db dump / (app 1)" style: dump
47
+ node dumps.db2 "db dump / (app 2)" style: dump
48
+
49
+ node dumps_note "Written by nightly cron job." (wrap: 30) below dumps (gap: tight) shape: none
50
+
51
+
52
+ // --- The server -------------------------------------------------------------
53
+
54
+ node server "Home Server" (inside top, align: left) left of dumps even with cluster badge: desktop
55
+ node server.mirror "shared folder / mirror" style: backup width: server.docker
56
+ node server.deploy "deploy dir" below server.mirror width: server.docker
57
+
58
+ // The cubes are nodes rather than ornament: two of them are where the dump
59
+ // arrows start. The lower pair is centered under the upper three by naming two
60
+ // targets and nothing else.
61
+ node server.docker "Docker" (inside bottom) below server.deploy (gap: wide)
62
+ node server.docker.a icon: cube
63
+ node server.docker.b right of server.docker.a (gap: tight) icon: cube
64
+ node server.docker.db1 right of server.docker.b (gap: tight) icon: cube
65
+ node server.docker.c below server.docker.a and server.docker.b (gap: tight) icon: cube
66
+ node server.docker.db2 below server.docker.b and server.docker.db1 (gap: tight) icon: cube
67
+
68
+ edge server.deploy -> server.docker "compose up"
69
+
70
+ edge server.docker.db1 -> dumps.db1 from: right to: left
71
+ edge server.docker.db2 -> dumps.db2 from: right to: left
72
+
73
+ // The rclone lines travel together through the channel between the two
74
+ // left-hand machines, so the dumps arrive through the middle of the cluster
75
+ // rather than across one. Each rises from its dump into the bundle and runs
76
+ // level beside the other into the hub. Their texts sit one above the other,
77
+ // where a way straight down from Desktop 1 meets each.
78
+ guide region channel between cluster.desktop1 and cluster.laptop1
79
+ bundle rclone via: through channel horizontal order: one, two
80
+ edge one dumps.db1 -> cluster.hub "rclone" (straight down from cluster.desktop1) from: right to: left via: rclone
81
+ edge two dumps.db2 -> cluster.hub "rclone" (straight down from cluster.desktop1) from: right to: left via: rclone
82
+
83
+ edge cluster.desktop1.files -> server.mirror "backup script" style: backup from: left to: right
84
+ edge cluster.desktop1.files -> server.deploy "deploy script" from: left to: right
85
+
86
+
87
+ // --- The backup drive -------------------------------------------------------
88
+
89
+ node drive "External HD" (inside top, align: left) left of server top even with server badge: disk layout: column (widths: match)
90
+ node drive.mirror "shared folder / backup" style: backup
91
+ node drive.archive "archive"
92
+
93
+ edge server.mirror -> drive.mirror style: backup from: left to: right
94
+
95
+ node drive_note "Swapped with a second drive weekly." (wrap: 22) left of drive.mirror (gap: tight) shape: none