@monimesl/dot-cli 0.1.0-alpha.3 → 0.1.0-alpha.5

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 (61) hide show
  1. package/README.md +63 -15
  2. package/dist/cli.js +331 -39
  3. package/dist/cli.js.map +1 -1
  4. package/dist/{server-z7M4FLjf.js → server-DFOUQnLA.js} +437 -60
  5. package/dist/server-DFOUQnLA.js.map +1 -0
  6. package/dist/{template → templates/showcase}/dot.ts +3 -0
  7. package/dist/templates/showcase/pages/carousel/controller.ts +10 -0
  8. package/dist/templates/showcase/pages/carousel/state.ts +40 -0
  9. package/dist/templates/showcase/pages/carousel/view.dml +37 -0
  10. package/dist/templates/showcase/pages/filters/controller.ts +28 -0
  11. package/dist/templates/showcase/pages/filters/state.ts +72 -0
  12. package/dist/templates/showcase/pages/filters/view.dml +43 -0
  13. package/dist/{template → templates/showcase}/pages/index/state.ts +19 -1
  14. package/dist/{template → templates/showcase}/pages/layout/controller.ts +3 -0
  15. package/dist/{template → templates/showcase}/pages/layout/state.ts +2 -0
  16. package/dist/{template → templates/showcase}/pages/layout/view.dml +17 -0
  17. package/dist/{template → templates/showcase}/pages/lists/view.dml +5 -5
  18. package/dist/{template → templates/showcase}/pages/navigation/controller.ts +22 -0
  19. package/dist/{template → templates/showcase}/pages/navigation/state.ts +4 -0
  20. package/dist/{template → templates/showcase}/pages/navigation/view.dml +10 -0
  21. package/dist/templates/showcase/pages/payments/controller.ts +42 -0
  22. package/dist/templates/showcase/pages/payments/state.ts +10 -0
  23. package/dist/templates/showcase/pages/payments/view.dml +35 -0
  24. package/dist/templates/starter/.env +3 -0
  25. package/dist/templates/starter/dot.ts +3 -0
  26. package/dist/templates/starter/pages/index/controller.ts +9 -0
  27. package/dist/templates/starter/pages/index/state.ts +4 -0
  28. package/dist/templates/starter/pages/index/view.dml +16 -0
  29. package/dist/templates/starter/statics/logo.png +0 -0
  30. package/package.json +8 -7
  31. package/dist/server-z7M4FLjf.js.map +0 -1
  32. /package/dist/{template → templates/showcase}/.env +0 -0
  33. /package/dist/{template → templates/showcase}/pages/device/controller.ts +0 -0
  34. /package/dist/{template → templates/showcase}/pages/device/state.ts +0 -0
  35. /package/dist/{template → templates/showcase}/pages/device/view.dml +0 -0
  36. /package/dist/{template → templates/showcase}/pages/feedback/controller.ts +0 -0
  37. /package/dist/{template → templates/showcase}/pages/feedback/state.ts +0 -0
  38. /package/dist/{template → templates/showcase}/pages/feedback/view.dml +0 -0
  39. /package/dist/{template → templates/showcase}/pages/files/controller.ts +0 -0
  40. /package/dist/{template → templates/showcase}/pages/files/state.ts +0 -0
  41. /package/dist/{template → templates/showcase}/pages/files/view.dml +0 -0
  42. /package/dist/{template → templates/showcase}/pages/forms/controller.ts +0 -0
  43. /package/dist/{template → templates/showcase}/pages/forms/state.ts +0 -0
  44. /package/dist/{template → templates/showcase}/pages/forms/view.dml +0 -0
  45. /package/dist/{template → templates/showcase}/pages/images/controller.ts +0 -0
  46. /package/dist/{template → templates/showcase}/pages/images/state.ts +0 -0
  47. /package/dist/{template → templates/showcase}/pages/images/view.dml +0 -0
  48. /package/dist/{template → templates/showcase}/pages/index/controller.ts +0 -0
  49. /package/dist/{template → templates/showcase}/pages/index/view.dml +0 -0
  50. /package/dist/{template → templates/showcase}/pages/lists/controller.ts +0 -0
  51. /package/dist/{template → templates/showcase}/pages/lists/state.ts +0 -0
  52. /package/dist/{template → templates/showcase}/pages/network/controller.ts +0 -0
  53. /package/dist/{template → templates/showcase}/pages/network/state.ts +0 -0
  54. /package/dist/{template → templates/showcase}/pages/network/view.dml +0 -0
  55. /package/dist/{template → templates/showcase}/pages/state/controller.ts +0 -0
  56. /package/dist/{template → templates/showcase}/pages/state/state.ts +0 -0
  57. /package/dist/{template → templates/showcase}/pages/state/view.dml +0 -0
  58. /package/dist/{template → templates/showcase}/pages/text/controller.ts +0 -0
  59. /package/dist/{template → templates/showcase}/pages/text/state.ts +0 -0
  60. /package/dist/{template → templates/showcase}/pages/text/view.dml +0 -0
  61. /package/dist/{template → templates/showcase}/statics/logo.png +0 -0
package/README.md CHANGED
@@ -4,23 +4,71 @@ The `dotctl` command: build mini apps and run them in the simulator. Platform ta
4
4
  packaging, signing and publishing mini apps will live here too.
5
5
 
6
6
  ```sh
7
- dotctl create [dir] # scaffold a dot (asks for its name), with a sample home page
8
- dotctl build [root] # compile the dot into dist/ui and dist/logic, minified
9
- dotctl dev [root] # build, open it in the browser simulator, rebuild and reload on change
7
+ dotctl create [dir] # scaffold a dot (asks for its name and template)
8
+ dotctl create page <path> # add a page to the dot you are in
9
+ dotctl build [root] # compile the dot into dist/ui and dist/logic, minified
10
+ dotctl dev [root] # build, run it in the browser simulator, and reload it on each change
11
+ dotctl keys show # print this computer's developer key: its public key and key ID
12
+ dotctl lsp # the .dml language server, which editors start
10
13
  ```
11
14
 
12
- | Option | Applies to | What it does |
13
- | --------------------- | ---------- | ------------------------------------------------------- |
14
- | `--out-dir <dir>` | both | Output directory (default: `<root>/dist`) |
15
- | `--conditions <list>` | both | Extra export conditions, such as `@monimesl/dot-source` |
16
- | `--port <port>` | `dev` | Simulator port (default: `3680`, or the next free one) |
15
+ | Option | Applies to | What it does |
16
+ | --------------------- | ---------- | ------------------------------------------------------------------------ |
17
+ | `--out-dir <dir>` | both | Output directory (default: `<root>/dist`) |
18
+ | `--conditions <list>` | both | Extra export conditions, such as `@monimesl/dot-source` |
19
+ | `--port <port>` | `dev` | Simulator port (default: `3680`, or the next free one) |
20
+ | `--device` | `dev` | Also serve the dot to the Dot example apps on phones, through a QR code |
21
+ | `--name <name>` | `create` | The dot's name, instead of asking |
22
+ | `--template <name>` | `create` | `starter` (one page to build on, the default) or `showcase` (every page) |
23
+ | `--pm <name>` | `create` | The package manager to install with: npm, pnpm, yarn or bun |
17
24
 
18
25
  ## `dotctl create`
19
26
 
20
- Asks for the dot's name (`Dot` by default, or pass `--name`) and creates a self-contained project
21
- in `dir` (by default, the name in kebab case) with one page, `home`: a short welcome, localized for
22
- Sierra Leone, to a dot running inside Dash. The dot depends on `@monimesl/dot-runtime` and `@monimesl/dot-cli`; inside
23
- the Dot repository it uses the workspace packages instead. It then offers to install the
24
- dependencies and start the simulator inside the new dot, asking which of the installed package
25
- managers to use (npm, pnpm, yarn or bun; the one that launched `dotctl`, or npm, by default).
26
- `--pm <name>` answers that up front.
27
+ Asks for the dot's name (`Dot` by default) and template, and creates a self-contained project in
28
+ `dir` (by default, the name in kebab case): `starter` is one page to build on, and `showcase` a page
29
+ for each feature. The dot depends on `@monimesl/dot-runtime` and `@monimesl/dot-cli`; inside the Dot
30
+ repository it uses the workspace packages instead. It then offers to install the dependencies and
31
+ start the simulator inside the new dot, asking which of the installed package managers to use (npm,
32
+ pnpm, yarn or bun; the one that launched `dotctl`, or npm, by default).
33
+
34
+ ## `dotctl dev`
35
+
36
+ Builds the dot, opens it in the browser simulator, and builds it again on each save:
37
+
38
+ - A change to its pages (`pages/**`) runs in place: each open page keeps its state, and runs the new
39
+ code. A page whose initial state changed starts over from the new one.
40
+ - Any other change, such as to `dot.ts`, `dot.json`, `.env` or `statics/`, starts the dot again, on
41
+ the pages it had open.
42
+ - A failed build shows its error, and the dot keeps its last good build.
43
+
44
+ With `--device`, it also serves each build on the local network to the Dot example apps. It prints a
45
+ QR code to scan with one, which then follows the dot in the same way: see
46
+ [the example apps](../hosts/native/README.md#the-example-apps).
47
+
48
+ ## Developer keys
49
+
50
+ Dash opens only links that one of the signed-in developer's keys signed. `dotctl dev --device`
51
+ signs with the Ed25519 key in `~/.dot/developer-key.pem` (or the file `DOT_DEVELOPER_KEY` names),
52
+ which dotctl reads but doesn't make. Make one with OpenSSL 3 (macOS's own `openssl` can't; `brew
53
+ install openssl`):
54
+
55
+ ```sh
56
+ mkdir -p ~/.dot && openssl genpkey -algorithm ed25519 -out ~/.dot/developer-key.pem
57
+ ```
58
+
59
+ `dotctl keys show` prints its public key, in base64, and its key ID: the lowercase hex of the public
60
+ key's first 8 bytes. Add the public key to your Monime account,
61
+ once for each computer you work on.
62
+
63
+ Each link carries a random `secret`, which never goes over the network. `dotctl dev --device` sends
64
+ each build's listing, with the SHA-256 of each of its files, with an HMAC-SHA256 of it keyed with
65
+ the secret, in a `dot-build-mac` header. A device takes only listings with the right MAC, and each
66
+ file only as its listing hashes it, so no one else on the network can swap a build.
67
+
68
+ With a key, it also signs the link: the server, the token, the secret, when the link expires (12
69
+ hours on) and the key ID, a line each, as `expires`, `keyId` and `sig` (a base64 signature). Before
70
+ opening it, Dash sends Monime the key ID, what the signature signs (in base64) and the signature.
71
+ Monime checks that the key is on the signed-in account, signed it, and that it hasn't expired. Dash
72
+ itself keeps no keys.
73
+
74
+ Without a key the link is unsigned, which the Dot example apps still accept.
package/dist/cli.js CHANGED
@@ -3,13 +3,17 @@ import { spawn, spawnSync } from "node:child_process";
3
3
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { createInterface } from "node:readline/promises";
5
5
  import { parseArgs } from "node:util";
6
- import { CompileError, build, installedRuntime, loadProject } from "@monimesl/dot-compiler";
6
+ import { CompileError, PAGES_DIR, build, installedRuntime, loadProject } from "@monimesl/dot-compiler";
7
7
  import { DEFAULT_PORT, startSimulator } from "@monimesl/dot-simulator";
8
8
  import { existsSync, readFileSync, readdirSync, watch } from "node:fs";
9
- import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
9
+ import { cp, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
10
10
  import { DmlParseError } from "@monimesl/dot-dml-parser";
11
11
  import { ScriptParseError, parseDotModule } from "@monimesl/dot-script-parser";
12
12
  import { isPageId } from "@monimesl/dot-core";
13
+ import { renderUnicodeCompact } from "uqr";
14
+ import { createHash, createHmac, createPrivateKey, createPublicKey, randomBytes, sign, timingSafeEqual } from "node:crypto";
15
+ import { homedir, networkInterfaces } from "node:os";
16
+ import { createServer } from "node:http";
13
17
  //#region src/report.ts
14
18
  /** A mistake in how a command was used, which its message alone explains. */
15
19
  var CommandError = class extends Error {
@@ -31,6 +35,10 @@ function printBuildTime(startedAt) {
31
35
  }
32
36
  //#endregion
33
37
  //#region src/create/template.ts
38
+ const TEMPLATES = ["starter", "showcase"];
39
+ function isTemplate(name) {
40
+ return TEMPLATES.includes(name);
41
+ }
34
42
  const PAGE_FILES = {
35
43
  "view.dml": "<View>\n <Text>My new page</Text>\n</View>\n",
36
44
  "state.ts": "export default {};\n",
@@ -54,18 +62,8 @@ function projectFiles(options) {
54
62
  "package.json": packageJson(options),
55
63
  "tsconfig.json": tsconfig(options),
56
64
  ".gitignore": "node_modules/\ndist/\n# Types generated by dotctl\n.dot/\n",
57
- "dot.json": json({
58
- $schema: DOT_JSON_SCHEMA,
59
- id: `com.example.${idWord(options.packageName)}`,
60
- name: options.name,
61
- version: "0.1.0",
62
- network: { hosts: [
63
- "api.github.com",
64
- "avatars.githubusercontent.com",
65
- "httpbin.org"
66
- ] }
67
- }),
68
- "README.md": readme(options.name)
65
+ "dot.json": dotJson(options),
66
+ "README.md": readme(options)
69
67
  };
70
68
  }
71
69
  function packageJson({ packageName, dotVersion, workspace }) {
@@ -109,7 +107,29 @@ function tsconfig({ workspace }) {
109
107
  ]
110
108
  });
111
109
  }
112
- function readme(name) {
110
+ function dotJson({ name, packageName, template }) {
111
+ const dot = {
112
+ $schema: DOT_JSON_SCHEMA,
113
+ id: `com.example.${idWord(packageName)}`,
114
+ name,
115
+ version: "0.1.0"
116
+ };
117
+ if (template === "showcase") dot.network = { hosts: [
118
+ "api.github.com",
119
+ "avatars.githubusercontent.com",
120
+ "httpbin.org"
121
+ ] };
122
+ return json(dot);
123
+ }
124
+ const TEMPLATE_READMES = {
125
+ starter: `It starts with one page, \`index\`: a greeting from \`.env\` and a count to tap. Change it,
126
+ and add pages as the dot grows. \`npx dotctl create my-tour --template showcase\` makes a dot with a
127
+ page for each feature, to read how each is done.`,
128
+ showcase: `It starts as a showcase: \`index\` lists a page for each feature, from layout, text, images and
129
+ inputs to state, navigation, and Dash's alerts, network and files. Read a page to see how it's
130
+ done, then delete the ones you don't need, from \`pages/\` and from \`dot.ts\`.`
131
+ };
132
+ function readme({ name, template }) {
113
133
  return `# ${name}
114
134
 
115
135
  A dot: a mini app that runs inside Dash.
@@ -135,9 +155,7 @@ the other fields from the schema it points to.
135
155
  there is built into the dot, so anyone who has it can read them: never put a secret there.
136
156
  \`.env.development\` and \`.env.production\` override it for \`dev\` and \`build\`.
137
157
 
138
- It starts as a showcase: \`index\` lists a page for each feature, from layout, text, images and
139
- inputs to state, navigation, and Dash's alerts, network and files. Read a page to see how it's
140
- done, then delete the ones you don't need, from \`pages/\` and from \`dot.ts\`.
158
+ ${TEMPLATE_READMES[template]}
141
159
  `;
142
160
  }
143
161
  function idWord(packageName) {
@@ -145,20 +163,21 @@ function idWord(packageName) {
145
163
  }
146
164
  //#endregion
147
165
  //#region src/create/create.ts
148
- async function create({ name, dir }) {
166
+ async function create({ name, dir, template = "starter" }) {
149
167
  const root = resolve(dir);
150
168
  if (existsSync(root) && readdirSync(root).length > 0) throw new CommandError(`${dir} already exists and is not empty`);
151
169
  const packageName = toPackageName(name);
152
170
  if (!packageName) throw new CommandError(`"${name}" needs at least one letter or digit`);
153
171
  const workspace = isInDotWorkspace(root);
154
- const template = templateDir();
172
+ const source = templateDir(template);
155
173
  await mkdir(root, { recursive: true });
156
- for (const path of TEMPLATE_FILES) await cp(join(template, path), join(root, path), { recursive: true });
174
+ for (const path of TEMPLATE_FILES) await cp(join(source, path), join(root, path), { recursive: true });
157
175
  const generated = projectFiles({
158
176
  name,
159
177
  packageName,
160
178
  dotVersion: workspace ? "workspace:*" : `^${cliVersion()}`,
161
- workspace
179
+ workspace,
180
+ template
162
181
  });
163
182
  for (const [path, content] of Object.entries(generated)) await writeFile(join(root, path), content);
164
183
  return {
@@ -185,9 +204,9 @@ function isInDotWorkspace(dir) {
185
204
  current = parent;
186
205
  }
187
206
  }
188
- function templateDir() {
189
- const example = join(cliRoot(), "../../examples/showcase");
190
- return existsSync(example) ? example : join(cliRoot(), "dist/template");
207
+ function templateDir(template) {
208
+ const example = join(cliRoot(), "../../examples", template);
209
+ return existsSync(example) ? example : join(cliRoot(), "dist/templates", template);
191
210
  }
192
211
  function cliVersion() {
193
212
  return readPackageJson(join(cliRoot(), "package.json")).version;
@@ -290,9 +309,209 @@ function onPath(name) {
290
309
  }).status === 0;
291
310
  }
292
311
  //#endregion
312
+ //#region src/device/developer-key.ts
313
+ function defaultDeveloperKeyPath() {
314
+ return process.env.DOT_DEVELOPER_KEY ?? join(homedir(), ".dot", "developer-key.pem");
315
+ }
316
+ function missingDeveloperKeyHint(path = defaultDeveloperKeyPath()) {
317
+ return ` This computer has no developer key in ${path}, and Dash opens only signed links.\n Make one with OpenSSL 3 (macOS's own openssl can't; brew install openssl):
318
+
319
+ mkdir -p ${dirname(path)} && openssl genpkey -algorithm ed25519 -out ${path}\n\n Then add the public key \`dotctl keys show\` prints to your Monime account.`;
320
+ }
321
+ async function loadDeveloperKey(path = defaultDeveloperKeyPath()) {
322
+ let pem;
323
+ try {
324
+ pem = await readFile(path, "utf8");
325
+ } catch (error) {
326
+ if (error.code === "ENOENT") return;
327
+ throw error;
328
+ }
329
+ const privateKey = createPrivateKey(pem);
330
+ if (privateKey.asymmetricKeyType !== "ed25519") throw new Error(`${path} is not an Ed25519 key`);
331
+ return toDeveloperKey(privateKey);
332
+ }
333
+ function toDeveloperKey(privateKey) {
334
+ const raw = Buffer.from(createPublicKey(privateKey).export({ format: "jwk" }).x, "base64url");
335
+ return {
336
+ publicKey: raw.toString("base64"),
337
+ keyId: raw.subarray(0, 8).toString("hex"),
338
+ sign: (bytes) => sign(null, bytes, privateKey).toString("base64")
339
+ };
340
+ }
341
+ //#endregion
342
+ //#region src/device/server.ts
343
+ const DEVICE_SCHEME = "dotdev";
344
+ const WAIT_MS = 25e3;
345
+ const MAX_LOG_BYTES = 1048576;
346
+ const LINK_LIFETIME_SECONDS = 43200;
347
+ const BUILD_MAC_HEADER = "dot-build-mac";
348
+ function signedLinkMessage(server, token, secret, expires, keyId) {
349
+ return [
350
+ "dotdev-connect",
351
+ server,
352
+ token,
353
+ secret,
354
+ String(expires),
355
+ keyId
356
+ ].join("\n");
357
+ }
358
+ function buildMac(secret, body) {
359
+ return createHmac("sha256", Buffer.from(secret, "base64url")).update(`dotdev-build\n${body}`, "utf8").digest("base64url");
360
+ }
361
+ async function startDeviceServer({ address = localNetworkAddress(), port = 0, onLog = () => {}, onConnect = () => {}, developerKey } = {}) {
362
+ if (address === void 0) throw new Error("This computer has no local network address for devices to reach it on");
363
+ const token = randomBytes(18).toString("base64url");
364
+ const secret = randomBytes(32).toString("base64url");
365
+ let build = { files: /* @__PURE__ */ new Map() };
366
+ let generation = 0;
367
+ let hotSince = 0;
368
+ let error = null;
369
+ const waiting = /* @__PURE__ */ new Set();
370
+ const connected = /* @__PURE__ */ new Set();
371
+ const announce = () => {
372
+ generation += 1;
373
+ for (const wake of waiting) wake();
374
+ };
375
+ const server = createServer((request, response) => {
376
+ const from = request.socket.remoteAddress ?? "a device";
377
+ if (!isAuthorized(request, token)) {
378
+ response.writeHead(401).end();
379
+ return;
380
+ }
381
+ const url = new URL(request.url ?? "/", "http://device");
382
+ const path = url.pathname;
383
+ if (request.method === "GET" && path === "/dev/build") {
384
+ if (!connected.has(from)) {
385
+ connected.add(from);
386
+ onConnect(from);
387
+ }
388
+ const files = Object.fromEntries([...build.files].map(([name, file]) => [name, file.hash]));
389
+ const body = JSON.stringify({
390
+ generation,
391
+ hotSince,
392
+ files,
393
+ error
394
+ });
395
+ response.writeHead(200, {
396
+ "content-type": "application/json",
397
+ [BUILD_MAC_HEADER]: buildMac(secret, body)
398
+ }).end(body);
399
+ } else if (request.method === "GET" && path.startsWith("/dev/files/")) {
400
+ if (url.searchParams.get("generation") !== String(generation)) {
401
+ response.writeHead(409).end();
402
+ return;
403
+ }
404
+ const name = path.slice(11).split("/").map(decodeURIComponent).join("/");
405
+ const file = build.files.get(name);
406
+ if (!file) {
407
+ response.writeHead(404).end();
408
+ return;
409
+ }
410
+ response.writeHead(200, { "content-type": "application/octet-stream" }).end(file.bytes);
411
+ } else if (request.method === "GET" && path === "/dev/changes") waitForChange(response, Number(url.searchParams.get("after")));
412
+ else if (request.method === "POST" && path === "/dev/log") readText(request).then((text) => {
413
+ for (const line of text.split("\n")) onLog(line, from);
414
+ response.writeHead(204).end();
415
+ }).catch(() => response.writeHead(413).end());
416
+ else response.writeHead(404).end();
417
+ });
418
+ const waitForChange = (response, after) => {
419
+ if (generation > after) {
420
+ sendJson(response, { generation });
421
+ return;
422
+ }
423
+ const wake = () => {
424
+ clearTimeout(timer);
425
+ waiting.delete(wake);
426
+ sendJson(response, { generation });
427
+ };
428
+ const timer = setTimeout(() => {
429
+ waiting.delete(wake);
430
+ response.writeHead(204).end();
431
+ }, WAIT_MS);
432
+ waiting.add(wake);
433
+ response.on("close", () => {
434
+ clearTimeout(timer);
435
+ waiting.delete(wake);
436
+ });
437
+ };
438
+ await new Promise((resolve, reject) => {
439
+ server.once("error", reject);
440
+ server.listen(port, address, () => resolve());
441
+ });
442
+ const { port: listening } = server.address();
443
+ const origin = `http://${address}:${listening}`;
444
+ let link = `${DEVICE_SCHEME}://connect?server=${encodeURIComponent(origin)}&token=${token}&secret=${secret}`;
445
+ if (developerKey) {
446
+ const expires = Math.floor(Date.now() / 1e3) + LINK_LIFETIME_SECONDS;
447
+ const keyId = developerKey.keyId;
448
+ const signature = developerKey.sign(Buffer.from(signedLinkMessage(origin, token, secret, expires, keyId), "utf8"));
449
+ link += `&expires=${expires}&keyId=${keyId}&sig=${encodeURIComponent(signature)}`;
450
+ }
451
+ return {
452
+ link,
453
+ async publish(dir, { hot = false } = {}) {
454
+ build = await readBuild(dir);
455
+ error = null;
456
+ announce();
457
+ if (!hot) hotSince = generation;
458
+ },
459
+ fail(message) {
460
+ error = message;
461
+ announce();
462
+ },
463
+ close() {
464
+ for (const wake of waiting) wake();
465
+ return new Promise((resolve) => {
466
+ server.close(() => resolve());
467
+ server.closeAllConnections();
468
+ });
469
+ }
470
+ };
471
+ }
472
+ function localNetworkAddress() {
473
+ for (const addresses of Object.values(networkInterfaces())) for (const candidate of addresses ?? []) if (candidate.family === "IPv4" && !candidate.internal) return candidate.address;
474
+ }
475
+ async function readBuild(dir) {
476
+ const files = /* @__PURE__ */ new Map();
477
+ const entries = await readdir(dir, {
478
+ recursive: true,
479
+ withFileTypes: true
480
+ });
481
+ for (const entry of entries) {
482
+ if (!entry.isFile() || entry.name.endsWith(".map") || entry.name.endsWith(".zip")) continue;
483
+ const path = join(entry.parentPath, entry.name);
484
+ const bytes = await readFile(path);
485
+ const hash = createHash("sha256").update(bytes).digest("base64url");
486
+ files.set(relative(dir, path).split(sep).join("/"), {
487
+ bytes,
488
+ hash
489
+ });
490
+ }
491
+ return { files };
492
+ }
493
+ function isAuthorized(request, token) {
494
+ const given = Buffer.from(request.headers.authorization ?? "");
495
+ const expected = Buffer.from(`Bearer ${token}`);
496
+ return given.length === expected.length && timingSafeEqual(given, expected);
497
+ }
498
+ function sendJson(response, body) {
499
+ response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(body));
500
+ }
501
+ async function readText(request) {
502
+ const chunks = [];
503
+ let size = 0;
504
+ for await (const chunk of request) {
505
+ size += chunk.length;
506
+ if (size > MAX_LOG_BYTES) throw new Error("Too much log at once");
507
+ chunks.push(chunk);
508
+ }
509
+ return Buffer.concat(chunks).toString("utf8");
510
+ }
511
+ //#endregion
293
512
  //#region src/dev.ts
294
513
  const CHANGE_DEBOUNCE_MS = 50;
295
- async function dev({ port, ...options }) {
514
+ async function dev({ port, device, ...options }) {
296
515
  const project = await loadProject(options.root);
297
516
  const outDir = resolve(project.root, options.outDir ?? "dist");
298
517
  const buildOptions = {
@@ -322,16 +541,42 @@ async function dev({ port, ...options }) {
322
541
  });
323
542
  if (firstError) simulator.error(firstError);
324
543
  console.log(`\n Simulator running at: ${simulator.url}\n`);
544
+ let devices;
545
+ if (device) {
546
+ const developerKey = await loadDeveloperKey();
547
+ devices = await startDeviceServer({
548
+ developerKey,
549
+ onConnect: (from) => console.log(`A device connected from ${from}`),
550
+ onLog: (line) => console.log(`device ${line}`)
551
+ });
552
+ if (firstError) devices.fail(firstError);
553
+ else await devices.publish(outDir);
554
+ printDeviceLink(devices.link);
555
+ if (developerKey) console.log(` Signed with developer key ${developerKey.keyId}\n`);
556
+ else console.log(`${missingDeveloperKeyHint()}\n`);
557
+ }
558
+ const changed = /* @__PURE__ */ new Set();
559
+ const pagesDir = join(project.root, PAGES_DIR) + sep;
325
560
  const rebuilds = new RebuildQueue(async () => {
561
+ const building = [...changed];
326
562
  const error = await buildApp();
327
- if (error) simulator.error(error);
563
+ if (error) {
564
+ simulator.error(error);
565
+ devices?.fail(error);
566
+ return;
567
+ }
568
+ for (const path of building) changed.delete(path);
569
+ const hot = building.every((path) => path.startsWith(pagesDir));
570
+ if (hot) simulator.hotReload();
328
571
  else simulator.reload();
572
+ await devices?.publish(outDir, { hot });
329
573
  });
330
574
  let debounceTimer;
331
575
  const watcher = watch(project.root, { recursive: true }, (_event, file) => {
332
576
  if (!file || isIgnored(file)) return;
333
577
  const path = resolve(project.root, file);
334
578
  if (path === outDir || path.startsWith(outDir + sep)) return;
579
+ changed.add(path);
335
580
  clearTimeout(debounceTimer);
336
581
  debounceTimer = setTimeout(() => {
337
582
  console.log(`Changed ${relative(project.root, path)}`);
@@ -341,9 +586,16 @@ async function dev({ port, ...options }) {
341
586
  return async () => {
342
587
  clearTimeout(debounceTimer);
343
588
  watcher.close();
344
- await simulator.close();
589
+ await Promise.all([simulator.close(), devices?.close()]);
345
590
  };
346
591
  }
592
+ function printDeviceLink(link) {
593
+ console.log(" Scan this in Dash or a Dot example app on a phone:\n");
594
+ console.log(renderUnicodeCompact(link, { border: 2 }).replace(/^/gm, " "));
595
+ console.log(`\n Or open the link on a device, an emulator or a simulator:\n\n ${link}\n`);
596
+ console.log(` Android emulator: adb shell am start -a android.intent.action.VIEW -d '${link}'`);
597
+ console.log(` iOS Simulator: xcrun simctl openurl booted '${link}'\n`);
598
+ }
347
599
  var RebuildQueue = class {
348
600
  #rebuild;
349
601
  #building = false;
@@ -378,12 +630,18 @@ function isIgnored(file) {
378
630
  //#endregion
379
631
  //#region src/cli.ts
380
632
  const USAGE = `Usage:
381
- dotctl create [dir] [--name <name>] [--pm <npm|pnpm|yarn|bun>]
382
- Create a dot (asks for its name)
633
+ dotctl create [dir] [--name <name>] [--template <starter|showcase>]
634
+ [--pm <npm|pnpm|yarn|bun>]
635
+ Create a dot (asks for its name and template):
636
+ starter is one page to build on, showcase a page
637
+ for each feature
383
638
  dotctl create page <path> Add a page to the dot you are in: pages/<path>/ with
384
639
  view.dml, state.ts and controller.ts
385
640
  dotctl build [root] [options] Build the minified bundles the host app runs
386
641
  dotctl dev [root] [options] Run in the browser simulator, rebuilding on change
642
+ dotctl keys show Show the public key and key ID of this computer's
643
+ developer key, which signs the links \`dev --device\`
644
+ shows, for Dash to open
387
645
  dotctl lsp [--stdio] Serve .dml files to an editor over the Language Server
388
646
  Protocol, on stdin and stdout
389
647
 
@@ -392,6 +650,8 @@ Options:
392
650
  --conditions <list> Extra export conditions, comma separated
393
651
  --port <port> Simulator port for \`dev\`; the next free one is used if taken
394
652
  (default: ${DEFAULT_PORT})
653
+ --device With \`dev\`, also serve the dot to the Dot example apps on phones on
654
+ the local network, through a link and QR code; they reload on each build
395
655
  -h, --help Show this message`;
396
656
  async function main(argv) {
397
657
  const { values, positionals } = parseArgs({
@@ -401,7 +661,9 @@ async function main(argv) {
401
661
  "out-dir": { type: "string" },
402
662
  conditions: { type: "string" },
403
663
  port: { type: "string" },
664
+ device: { type: "boolean" },
404
665
  name: { type: "string" },
666
+ template: { type: "string" },
405
667
  pm: { type: "string" },
406
668
  help: {
407
669
  type: "boolean",
@@ -412,12 +674,13 @@ async function main(argv) {
412
674
  });
413
675
  const [command, target, pagePath] = positionals;
414
676
  if (command === "lsp" && !values.help) {
415
- const { serveStdio } = await import("./server-z7M4FLjf.js");
677
+ const { serveStdio } = await import("./server-DFOUQnLA.js");
416
678
  serveStdio();
417
679
  return new Promise(() => {});
418
680
  }
681
+ if (command === "keys" && !values.help) return keys(target);
419
682
  if (command === "create" && target === "page" && !values.help) return addPage(pagePath);
420
- if (command === "create" && !values.help) return createApp(target, values.name, values.pm);
683
+ if (command === "create" && !values.help) return createApp(target, values.name, values.template, values.pm);
421
684
  if (values.help || command !== "build" && command !== "dev") {
422
685
  console.log(USAGE);
423
686
  return values.help ? 0 : 1;
@@ -433,7 +696,8 @@ async function main(argv) {
433
696
  if (command === "dev") {
434
697
  const stop = await dev({
435
698
  ...options,
436
- port
699
+ port,
700
+ device: values.device
437
701
  });
438
702
  await new Promise((resolve) => process.once("SIGINT", resolve));
439
703
  await stop();
@@ -446,6 +710,20 @@ async function main(argv) {
446
710
  printBuildTime(started);
447
711
  return 0;
448
712
  }
713
+ async function keys(action) {
714
+ if (action !== "show") {
715
+ console.error("dotctl keys show");
716
+ return 1;
717
+ }
718
+ const path = defaultDeveloperKeyPath();
719
+ const key = await loadDeveloperKey(path);
720
+ if (!key) {
721
+ console.error(missingDeveloperKeyHint(path));
722
+ return 1;
723
+ }
724
+ console.log(`\n Public key: ${key.publicKey}\n Key ID: ${key.keyId}\n`);
725
+ return 0;
726
+ }
449
727
  async function addPage(path) {
450
728
  if (path === void 0) {
451
729
  console.error("Which page? dotctl create page <path>, such as dotctl create page cart/checkout");
@@ -459,7 +737,11 @@ async function addPage(path) {
459
737
  else if (listing === "manual") console.log(`\nList it in dot.ts to build it: defineDot({ pages: [..., '${path}'] })`);
460
738
  return 0;
461
739
  }
462
- async function createApp(dir, name, packageManagerFlag) {
740
+ async function createApp(dir, name, templateFlag, packageManagerFlag) {
741
+ if (templateFlag !== void 0 && !isTemplate(templateFlag)) {
742
+ console.error(`--template must be one of ${TEMPLATES.join(", ")}`);
743
+ return 1;
744
+ }
463
745
  if (packageManagerFlag !== void 0 && !isPackageManager(packageManagerFlag)) {
464
746
  console.error(`--pm must be one of ${PACKAGE_MANAGERS.join(", ")}`);
465
747
  return 1;
@@ -469,9 +751,11 @@ async function createApp(dir, name, packageManagerFlag) {
469
751
  console.error("A dot needs a name: dotctl create [dir] --name \"My Dot\"");
470
752
  return 1;
471
753
  }
754
+ const template = templateFlag ?? await chooseTemplate();
472
755
  const { root, workspace } = await create({
473
756
  name,
474
- dir: dir ?? toPackageName(name)
757
+ dir: dir ?? toPackageName(name),
758
+ template
475
759
  });
476
760
  const path = relative(process.cwd(), root) || ".";
477
761
  console.log(`\nCreated ${name} in ${path}`);
@@ -519,15 +803,23 @@ async function readAnswer(prompt, fallback) {
519
803
  readline.close();
520
804
  }
521
805
  }
806
+ async function chooseTemplate() {
807
+ if (!process.stdin.isTTY) return "starter";
808
+ console.log("\nTemplates:\n starter one page to build on\n showcase a page for each feature, to read");
809
+ return chooseOne("Template?", TEMPLATES, "starter");
810
+ }
522
811
  async function choosePackageManager(suggested) {
523
812
  const installed = installedManagers();
524
813
  if (installed.length < 2) return installed[0] ?? suggested;
525
- const fallback = installed.includes(suggested) ? suggested : installed[0];
814
+ return chooseOne("Package manager?", installed, installed.includes(suggested) ? suggested : installed[0]);
815
+ }
816
+ async function chooseOne(question, options, fallback) {
526
817
  while (true) {
527
- const answer = await ask(`Package manager? ${installed.join(" / ")}`, fallback);
818
+ const answer = await ask(`${question} ${options.join(" / ")}`, fallback);
528
819
  if (answer === void 0) return fallback;
529
- if (isPackageManager(answer) && installed.includes(answer)) return answer;
530
- console.log(`Choose one of ${installed.join(", ")}.`);
820
+ const chosen = options.find((option) => option === answer);
821
+ if (chosen) return chosen;
822
+ console.log(`Choose one of ${options.join(", ")}.`);
531
823
  }
532
824
  }
533
825
  main(process.argv.slice(2)).then((code) => process.exit(code), (error) => {