@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.
- package/README.md +63 -15
- package/dist/cli.js +331 -39
- package/dist/cli.js.map +1 -1
- package/dist/{server-z7M4FLjf.js → server-DFOUQnLA.js} +437 -60
- package/dist/server-DFOUQnLA.js.map +1 -0
- package/dist/{template → templates/showcase}/dot.ts +3 -0
- package/dist/templates/showcase/pages/carousel/controller.ts +10 -0
- package/dist/templates/showcase/pages/carousel/state.ts +40 -0
- package/dist/templates/showcase/pages/carousel/view.dml +37 -0
- package/dist/templates/showcase/pages/filters/controller.ts +28 -0
- package/dist/templates/showcase/pages/filters/state.ts +72 -0
- package/dist/templates/showcase/pages/filters/view.dml +43 -0
- package/dist/{template → templates/showcase}/pages/index/state.ts +19 -1
- package/dist/{template → templates/showcase}/pages/layout/controller.ts +3 -0
- package/dist/{template → templates/showcase}/pages/layout/state.ts +2 -0
- package/dist/{template → templates/showcase}/pages/layout/view.dml +17 -0
- package/dist/{template → templates/showcase}/pages/lists/view.dml +5 -5
- package/dist/{template → templates/showcase}/pages/navigation/controller.ts +22 -0
- package/dist/{template → templates/showcase}/pages/navigation/state.ts +4 -0
- package/dist/{template → templates/showcase}/pages/navigation/view.dml +10 -0
- package/dist/templates/showcase/pages/payments/controller.ts +42 -0
- package/dist/templates/showcase/pages/payments/state.ts +10 -0
- package/dist/templates/showcase/pages/payments/view.dml +35 -0
- package/dist/templates/starter/.env +3 -0
- package/dist/templates/starter/dot.ts +3 -0
- package/dist/templates/starter/pages/index/controller.ts +9 -0
- package/dist/templates/starter/pages/index/state.ts +4 -0
- package/dist/templates/starter/pages/index/view.dml +16 -0
- package/dist/templates/starter/statics/logo.png +0 -0
- package/package.json +8 -7
- package/dist/server-z7M4FLjf.js.map +0 -1
- /package/dist/{template → templates/showcase}/.env +0 -0
- /package/dist/{template → templates/showcase}/pages/device/controller.ts +0 -0
- /package/dist/{template → templates/showcase}/pages/device/state.ts +0 -0
- /package/dist/{template → templates/showcase}/pages/device/view.dml +0 -0
- /package/dist/{template → templates/showcase}/pages/feedback/controller.ts +0 -0
- /package/dist/{template → templates/showcase}/pages/feedback/state.ts +0 -0
- /package/dist/{template → templates/showcase}/pages/feedback/view.dml +0 -0
- /package/dist/{template → templates/showcase}/pages/files/controller.ts +0 -0
- /package/dist/{template → templates/showcase}/pages/files/state.ts +0 -0
- /package/dist/{template → templates/showcase}/pages/files/view.dml +0 -0
- /package/dist/{template → templates/showcase}/pages/forms/controller.ts +0 -0
- /package/dist/{template → templates/showcase}/pages/forms/state.ts +0 -0
- /package/dist/{template → templates/showcase}/pages/forms/view.dml +0 -0
- /package/dist/{template → templates/showcase}/pages/images/controller.ts +0 -0
- /package/dist/{template → templates/showcase}/pages/images/state.ts +0 -0
- /package/dist/{template → templates/showcase}/pages/images/view.dml +0 -0
- /package/dist/{template → templates/showcase}/pages/index/controller.ts +0 -0
- /package/dist/{template → templates/showcase}/pages/index/view.dml +0 -0
- /package/dist/{template → templates/showcase}/pages/lists/controller.ts +0 -0
- /package/dist/{template → templates/showcase}/pages/lists/state.ts +0 -0
- /package/dist/{template → templates/showcase}/pages/network/controller.ts +0 -0
- /package/dist/{template → templates/showcase}/pages/network/state.ts +0 -0
- /package/dist/{template → templates/showcase}/pages/network/view.dml +0 -0
- /package/dist/{template → templates/showcase}/pages/state/controller.ts +0 -0
- /package/dist/{template → templates/showcase}/pages/state/state.ts +0 -0
- /package/dist/{template → templates/showcase}/pages/state/view.dml +0 -0
- /package/dist/{template → templates/showcase}/pages/text/controller.ts +0 -0
- /package/dist/{template → templates/showcase}/pages/text/state.ts +0 -0
- /package/dist/{template → templates/showcase}/pages/text/view.dml +0 -0
- /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]
|
|
8
|
-
dotctl
|
|
9
|
-
dotctl
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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":
|
|
58
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
172
|
+
const source = templateDir(template);
|
|
155
173
|
await mkdir(root, { recursive: true });
|
|
156
|
-
for (const path of TEMPLATE_FILES) await cp(join(
|
|
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
|
|
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)
|
|
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>] [--
|
|
382
|
-
|
|
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-
|
|
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
|
-
|
|
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(
|
|
818
|
+
const answer = await ask(`${question} ${options.join(" / ")}`, fallback);
|
|
528
819
|
if (answer === void 0) return fallback;
|
|
529
|
-
|
|
530
|
-
|
|
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) => {
|