@monimesl/dot-cli 0.1.0-alpha.2 → 0.1.0-alpha.23

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 (132) hide show
  1. package/README.md +91 -17
  2. package/dist/cli.js +853 -160
  3. package/dist/cli.js.map +1 -1
  4. package/dist/server-DMnWDdJ6.js +11308 -0
  5. package/dist/server-DMnWDdJ6.js.map +1 -0
  6. package/dist/templates/showcase/.env +3 -0
  7. package/dist/templates/showcase/dot.ts +60 -0
  8. package/dist/templates/showcase/pages/basics/bars/controller.ts +47 -0
  9. package/dist/templates/showcase/pages/basics/bars/state.ts +7 -0
  10. package/dist/templates/showcase/pages/basics/bars/view.dml +36 -0
  11. package/dist/templates/showcase/pages/basics/bindings/controller.ts +15 -0
  12. package/dist/templates/showcase/pages/basics/bindings/state.ts +14 -0
  13. package/dist/templates/showcase/pages/basics/bindings/view.dml +41 -0
  14. package/dist/templates/showcase/pages/basics/colors/controller.ts +112 -0
  15. package/dist/templates/showcase/pages/basics/colors/state.ts +17 -0
  16. package/dist/templates/showcase/pages/basics/colors/view.dml +142 -0
  17. package/dist/templates/showcase/pages/basics/concert/controller.ts +16 -0
  18. package/dist/templates/showcase/pages/basics/concert/state.ts +7 -0
  19. package/dist/templates/showcase/pages/basics/concert/view.dml +20 -0
  20. package/dist/templates/showcase/pages/basics/control-flow/controller.ts +18 -0
  21. package/dist/templates/showcase/pages/basics/control-flow/state.ts +16 -0
  22. package/dist/templates/showcase/pages/basics/control-flow/view.dml +55 -0
  23. package/dist/templates/showcase/pages/basics/events/controller.ts +43 -0
  24. package/dist/templates/showcase/pages/basics/events/state.ts +11 -0
  25. package/dist/templates/showcase/pages/basics/events/view.dml +51 -0
  26. package/dist/templates/showcase/pages/basics/hooks/controller.ts +43 -0
  27. package/dist/templates/showcase/pages/basics/hooks/state.ts +10 -0
  28. package/dist/templates/showcase/pages/basics/hooks/view.dml +37 -0
  29. package/dist/templates/showcase/pages/basics/navigation/controller.ts +41 -0
  30. package/dist/templates/showcase/pages/basics/navigation/state.ts +11 -0
  31. package/dist/templates/showcase/pages/basics/navigation/view.dml +48 -0
  32. package/dist/templates/showcase/pages/basics/pick-genre/controller.ts +18 -0
  33. package/dist/templates/showcase/pages/basics/pick-genre/state.ts +3 -0
  34. package/dist/templates/showcase/pages/basics/pick-genre/view.dml +10 -0
  35. package/dist/templates/showcase/pages/basics/share/concerts.ts +31 -0
  36. package/dist/templates/showcase/pages/basics/share/controller.ts +12 -0
  37. package/dist/templates/showcase/pages/basics/share/state.ts +5 -0
  38. package/dist/templates/showcase/pages/basics/share/view.dml +21 -0
  39. package/dist/templates/showcase/pages/basics/state/controller.ts +23 -0
  40. package/dist/templates/showcase/pages/basics/state/state.ts +10 -0
  41. package/dist/templates/showcase/pages/basics/state/view.dml +46 -0
  42. package/dist/templates/showcase/pages/components/carousel/controller.ts +41 -0
  43. package/dist/templates/showcase/pages/components/carousel/state.ts +77 -0
  44. package/dist/templates/showcase/pages/components/carousel/view.dml +42 -0
  45. package/dist/templates/showcase/pages/components/feedback/controller.ts +32 -0
  46. package/dist/templates/showcase/pages/components/feedback/state.ts +41 -0
  47. package/dist/templates/showcase/pages/components/feedback/view.dml +116 -0
  48. package/dist/templates/showcase/pages/components/forms/controller.ts +72 -0
  49. package/dist/templates/showcase/pages/components/forms/state.ts +37 -0
  50. package/dist/templates/showcase/pages/components/forms/view.dml +97 -0
  51. package/dist/templates/showcase/pages/components/icons/controller.ts +15 -0
  52. package/dist/templates/showcase/pages/components/icons/state.ts +75 -0
  53. package/dist/templates/showcase/pages/components/icons/view.dml +36 -0
  54. package/dist/templates/showcase/pages/components/images/controller.ts +44 -0
  55. package/dist/templates/showcase/pages/components/images/state.ts +10 -0
  56. package/dist/templates/showcase/pages/components/images/view.dml +83 -0
  57. package/dist/templates/showcase/pages/components/layout/controller.ts +61 -0
  58. package/dist/templates/showcase/pages/components/layout/state.ts +34 -0
  59. package/dist/templates/showcase/pages/components/layout/view.dml +143 -0
  60. package/dist/templates/showcase/pages/components/lists/controller.ts +18 -0
  61. package/dist/templates/showcase/pages/components/lists/state.ts +130 -0
  62. package/dist/templates/showcase/pages/components/lists/view.dml +88 -0
  63. package/dist/templates/showcase/pages/components/money/controller.ts +45 -0
  64. package/dist/templates/showcase/pages/components/money/state.ts +13 -0
  65. package/dist/templates/showcase/pages/components/money/view.dml +54 -0
  66. package/dist/templates/showcase/pages/components/reading/controller.ts +6 -0
  67. package/dist/templates/showcase/pages/components/reading/state.ts +23 -0
  68. package/dist/templates/showcase/pages/components/reading/view.dml +42 -0
  69. package/dist/templates/showcase/pages/components/sheets/controller.ts +36 -0
  70. package/dist/templates/showcase/pages/components/sheets/state.ts +26 -0
  71. package/dist/templates/showcase/pages/components/sheets/view.dml +38 -0
  72. package/dist/templates/showcase/pages/components/styles/controller.ts +6 -0
  73. package/dist/templates/showcase/pages/components/styles/state.ts +3 -0
  74. package/dist/templates/showcase/pages/components/styles/view.dml +101 -0
  75. package/dist/templates/showcase/pages/components/tabs/controller.ts +41 -0
  76. package/dist/templates/showcase/pages/components/tabs/state.ts +127 -0
  77. package/dist/templates/showcase/pages/components/tabs/view.dml +57 -0
  78. package/dist/templates/showcase/pages/components/text/controller.ts +10 -0
  79. package/dist/templates/showcase/pages/components/text/state.ts +3 -0
  80. package/dist/templates/showcase/pages/components/text/view.dml +95 -0
  81. package/dist/templates/showcase/pages/dash/account/controller.ts +23 -0
  82. package/dist/templates/showcase/pages/dash/account/state.ts +7 -0
  83. package/dist/templates/showcase/pages/dash/account/view.dml +23 -0
  84. package/dist/templates/showcase/pages/dash/cache/controller.ts +50 -0
  85. package/dist/templates/showcase/pages/dash/cache/state.ts +17 -0
  86. package/dist/templates/showcase/pages/dash/cache/view.dml +48 -0
  87. package/dist/templates/showcase/pages/dash/calls/controller.ts +64 -0
  88. package/dist/templates/showcase/pages/dash/calls/state.ts +9 -0
  89. package/dist/templates/showcase/pages/dash/calls/view.dml +67 -0
  90. package/dist/templates/showcase/pages/dash/contacts/controller.ts +30 -0
  91. package/dist/templates/showcase/pages/dash/contacts/state.ts +6 -0
  92. package/dist/templates/showcase/pages/dash/contacts/view.dml +33 -0
  93. package/dist/templates/showcase/pages/dash/crypto/controller.ts +34 -0
  94. package/dist/templates/showcase/pages/dash/crypto/state.ts +10 -0
  95. package/dist/templates/showcase/pages/dash/crypto/view.dml +48 -0
  96. package/dist/templates/showcase/pages/dash/files/controller.ts +71 -0
  97. package/dist/templates/showcase/pages/dash/files/state.ts +23 -0
  98. package/dist/templates/showcase/pages/dash/files/view.dml +78 -0
  99. package/dist/templates/showcase/pages/dash/media/controller.ts +65 -0
  100. package/dist/templates/showcase/pages/dash/media/state.ts +12 -0
  101. package/dist/templates/showcase/pages/dash/media/view.dml +53 -0
  102. package/dist/templates/showcase/pages/dash/network/controller.ts +215 -0
  103. package/dist/templates/showcase/pages/dash/network/state.ts +35 -0
  104. package/dist/templates/showcase/pages/dash/network/view.dml +108 -0
  105. package/dist/templates/showcase/pages/dash/secrets/controller.ts +44 -0
  106. package/dist/templates/showcase/pages/dash/secrets/state.ts +5 -0
  107. package/dist/templates/showcase/pages/dash/secrets/view.dml +26 -0
  108. package/dist/templates/showcase/pages/dash/system/controller.ts +89 -0
  109. package/dist/templates/showcase/pages/dash/system/state.ts +13 -0
  110. package/dist/templates/showcase/pages/dash/system/view.dml +97 -0
  111. package/dist/templates/showcase/pages/dash/ui/controller.ts +70 -0
  112. package/dist/templates/showcase/pages/dash/ui/state.ts +8 -0
  113. package/dist/templates/showcase/pages/dash/ui/view.dml +31 -0
  114. package/dist/templates/showcase/pages/index/controller.ts +10 -0
  115. package/dist/templates/showcase/pages/index/state.ts +237 -0
  116. package/dist/templates/showcase/pages/index/view.dml +23 -0
  117. package/dist/templates/showcase/statics/logo.png +0 -0
  118. package/dist/templates/starter/.env +3 -0
  119. package/dist/templates/starter/dot.ts +3 -0
  120. package/dist/templates/starter/pages/index/controller.ts +9 -0
  121. package/dist/templates/starter/pages/index/state.ts +4 -0
  122. package/dist/templates/starter/pages/index/view.dml +16 -0
  123. package/package.json +12 -7
  124. package/dist/template/.env +0 -3
  125. package/dist/template/dot.ts +0 -6
  126. package/dist/template/pages/help/tips/controller.ts +0 -30
  127. package/dist/template/pages/help/tips/state.ts +0 -11
  128. package/dist/template/pages/help/tips/view.dml +0 -15
  129. package/dist/template/pages/index/controller.ts +0 -67
  130. package/dist/template/pages/index/state.ts +0 -17
  131. package/dist/template/pages/index/view.dml +0 -66
  132. /package/dist/{template → templates/starter}/statics/logo.png +0 -0
package/dist/cli.js CHANGED
@@ -1,46 +1,116 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { spawn, spawnSync } from "node:child_process";
3
3
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
4
- import { createInterface } from "node:readline/promises";
5
- import { parseArgs } from "node:util";
6
- import { CompileError, build, installedRuntime, loadProject } from "@monimesl/dot-compiler";
4
+ import { parseArgs, styleText } from "node:util";
5
+ import { CompileError, DOT_FILE, DOT_JSON_FILE, PAGES_DIR, build, installedRuntime, isDotPackage, loadProject } from "@monimesl/dot-compiler";
7
6
  import { DEFAULT_PORT, startSimulator } from "@monimesl/dot-simulator";
8
7
  import { existsSync, readFileSync, readdirSync, watch } from "node:fs";
9
- import { cp, mkdir, writeFile } from "node:fs/promises";
8
+ import { cp, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
10
9
  import { DmlParseError } from "@monimesl/dot-dml-parser";
11
- import { ScriptParseError } from "@monimesl/dot-script-parser";
10
+ import { ScriptParseError, parseDotModule } from "@monimesl/dot-script-parser";
11
+ import { DEFAULT_DASH_API_VERSION, isPageId } from "@monimesl/dot-core";
12
+ import { renderUnicodeCompact } from "uqr";
13
+ import { createHash, createHmac, createPrivateKey, createPublicKey, randomBytes, sign, timingSafeEqual } from "node:crypto";
14
+ import { homedir, networkInterfaces } from "node:os";
15
+ import { createServer } from "node:http";
16
+ import { emitKeypressEvents } from "node:readline";
17
+ import { createInterface } from "node:readline/promises";
18
+ //#region src/report.ts
19
+ var CommandError = class extends Error {
20
+ name = "CommandError";
21
+ };
22
+ function describeError(error) {
23
+ if (error instanceof CommandError || error instanceof CompileError || error instanceof DmlParseError || error instanceof ScriptParseError) return error.message;
24
+ if (isArgumentError(error)) {
25
+ const [firstSentence] = error.message.split(". ");
26
+ return `${firstSentence}. See dotctl --help.`;
27
+ }
28
+ if (error instanceof Error) return error.stack ?? error.message;
29
+ return String(error);
30
+ }
31
+ function isArgumentError(error) {
32
+ if (!(error instanceof Error) || !("code" in error)) return false;
33
+ return typeof error.code === "string" && error.code.startsWith("ERR_PARSE_ARGS");
34
+ }
35
+ function printFiles(files) {
36
+ for (const file of files) console.log(` ${relative(process.cwd(), file)}`);
37
+ }
38
+ function printWarnings({ warnings }) {
39
+ for (const warning of warnings) console.warn(`warning: ${warning.message}`);
40
+ }
41
+ function printBuildTime(startedAt) {
42
+ console.log(`Built in ${Math.round(performance.now() - startedAt)}ms`);
43
+ }
44
+ function printSimulatorUrl(url) {
45
+ console.log(`\n Simulator running at: ${url}\n`);
46
+ }
47
+ //#endregion
48
+ //#region src/utils.ts
49
+ function findFolderWith(fileName, dir) {
50
+ let current = dir;
51
+ while (true) {
52
+ if (existsSync(join(current, fileName))) return current;
53
+ const parent = dirname(current);
54
+ if (parent === current) return;
55
+ current = parent;
56
+ }
57
+ }
58
+ function findDotRoot(dir) {
59
+ return findFolderWith(DOT_JSON_FILE, dir);
60
+ }
61
+ function resolveOutDir(root, outDir) {
62
+ return resolve(root, outDir ?? "dist");
63
+ }
64
+ function isOneOf(values, name) {
65
+ return values.includes(name);
66
+ }
67
+ //#endregion
12
68
  //#region src/create/template.ts
69
+ const TEMPLATES = ["starter", "showcase"];
70
+ const TEMPLATE_HINTS = {
71
+ starter: "one page to build on",
72
+ showcase: "a page for each feature, to learn from"
73
+ };
74
+ function isTemplate(name) {
75
+ return isOneOf(TEMPLATES, name);
76
+ }
77
+ const PAGE_FILES = {
78
+ "view.dml": "<View>\n <Text>My new page</Text>\n</View>\n",
79
+ "state.ts": "export default {};\n",
80
+ "controller.ts": `import state from './state';
81
+
82
+ export default definePage({
83
+ initialState: state,
84
+ });
85
+ `
86
+ };
13
87
  const TEMPLATE_FILES = [
14
88
  "dot.ts",
15
89
  "pages",
16
90
  "statics",
17
91
  ".env"
18
92
  ];
19
- const DOT_JSON_SCHEMA = "./node_modules/@monimesl/dot-runtime/dot.schema.json";
20
- const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
21
- function projectFiles(options) {
93
+ const DOT_JSON_SCHEMA = "https://unpkg.com/@monimesl/dot-runtime/dot.schema.json";
94
+ const UNREGISTERED_DOT_ID = "<monime-Internal-Id>";
95
+ function generatedFiles(options) {
22
96
  return {
23
97
  "package.json": packageJson(options),
24
98
  "tsconfig.json": tsconfig(options),
25
99
  ".gitignore": "node_modules/\ndist/\n# Types generated by dotctl\n.dot/\n",
26
- "dot.json": json({
27
- $schema: DOT_JSON_SCHEMA,
28
- id: `com.example.${idWord(options.packageName)}`,
29
- name: options.name,
30
- version: "0.1.0"
31
- }),
32
- "README.md": readme(options.name)
100
+ "dot.json": dotJson(options),
101
+ "README.md": readme(options)
33
102
  };
34
103
  }
35
104
  function packageJson({ packageName, dotVersion, workspace }) {
36
105
  const conditions = workspace ? " --conditions @monimesl/dot-source" : "";
37
- return json({
106
+ return formatJson({
38
107
  name: packageName,
39
108
  private: true,
40
109
  type: "module",
41
110
  scripts: {
42
111
  dev: `dotctl dev${conditions}`,
43
112
  build: `dotctl build${conditions}`,
113
+ preview: `dotctl preview${conditions}`,
44
114
  typecheck: "tsc --noEmit"
45
115
  },
46
116
  dependencies: { "@monimesl/dot-runtime": dotVersion },
@@ -63,7 +133,7 @@ function tsconfig({ workspace }) {
63
133
  verbatimModuleSyntax: true
64
134
  };
65
135
  if (workspace) compilerOptions.customConditions = ["@monimesl/dot-source"];
66
- return json({
136
+ return formatJson({
67
137
  compilerOptions,
68
138
  include: [
69
139
  "dot.ts",
@@ -73,7 +143,41 @@ function tsconfig({ workspace }) {
73
143
  ]
74
144
  });
75
145
  }
76
- function readme(name) {
146
+ function dotJson({ name, dotPackage, template }) {
147
+ const dot = {
148
+ id: UNREGISTERED_DOT_ID,
149
+ $schema: DOT_JSON_SCHEMA,
150
+ package: dotPackage,
151
+ name,
152
+ version: "0.1.0"
153
+ };
154
+ if (template === "showcase") {
155
+ dot.permissions = {
156
+ account: "Show your name on your concert ticket",
157
+ camera: "Take a photo for your concert ticket, or scan a ticket's QR code",
158
+ photoLibrary: "Choose a photo for your concert ticket",
159
+ files: "Attach your concert ticket",
160
+ contacts: "Choose a friend to send a ticket to"
161
+ };
162
+ dot.network = { hosts: [
163
+ "itunes.apple.com",
164
+ "*.mzstatic.com",
165
+ "httpbin.org"
166
+ ] };
167
+ }
168
+ dot.dash = { minApiVersion: DEFAULT_DASH_API_VERSION };
169
+ return formatJson(dot);
170
+ }
171
+ const TEMPLATE_READMES = {
172
+ starter: `It starts with one page, \`index\`. It shows a greeting from \`.env\` and a counter you
173
+ can tap. Change it, and add pages as the dot grows. To see how each feature works, make a showcase
174
+ dot with \`npx dotctl create my-tour --template showcase\`.`,
175
+ showcase: `It starts as a showcase. The \`index\` page links to a page for each feature, in three
176
+ folders: \`pages/basics/\` for how a page works, \`pages/components/\` for what a view can show,
177
+ and \`pages/dash/\` for the phone's features. Read a page to see how it works. Then delete the
178
+ pages you don't need, both from \`pages/\` and from \`dot.ts\`.`
179
+ };
180
+ function readme({ name, template }) {
77
181
  return `# ${name}
78
182
 
79
183
  A dot: a mini app that runs inside Dash.
@@ -84,42 +188,70 @@ npm run dev # run it in the simulator; it reloads on every change
84
188
  npm run build # build it for Dash
85
189
  \`\`\`
86
190
 
87
- Each page is a folder under \`pages/\` with three files: \`view.dml\` (what it shows),
88
- \`state.ts\` (what it starts with) and \`controller.ts\` (how it changes). List new pages in
89
- \`dot.ts\`; the first one is where the dot starts.
191
+ ${TEMPLATE_READMES[template]}
192
+
193
+ ## Pages
194
+
195
+ Each page is a folder under \`pages/\` with three files:
196
+
197
+ - \`view.dml\`: what the page shows. It has one root \`<View>\`, with everything else inside it.
198
+ - \`state.ts\`: the data the page starts with.
199
+ - \`controller.ts\`: how the data changes.
200
+
201
+ \`npx dotctl create page <path>\` adds a page, such as \`cart/checkout\`, with these three files. It
202
+ also lists the page in \`dot.ts\`. The dot opens on the first page in that list.
203
+
204
+ ## dot.json
205
+
206
+ \`dot.json\` describes the dot to Dash. Its \`package\` is a reverse domain name you own, such as
207
+ \`com.mysalone.thing\`. You can't change it after publishing. To publish, register the dot with
208
+ Monime first, and replace its \`id\`, \`${UNREGISTERED_DOT_ID}\`, with the ID Monime gives you.
209
+ \`network.hosts\` lists every host the dot sends requests to with \`dash.network\`. Requests to any
210
+ other host fail. Your editor can complete the other fields from the schema the file points to.
90
211
 
91
- \`dot.json\` describes the dot to Dash. Change its \`id\` from \`com.example.…\` to a reverse domain
92
- name you own before you publish: it can't change afterwards. Your editor completes the other fields
93
- from the schema it points to.
212
+ ## Which Dash opens it
94
213
 
95
- \`.env\` holds public configuration: variables named \`DOT_PUBLIC_…\`, which the service reads as
96
- \`env.DOT_PUBLIC_…\`. They are built into the dot, so anyone who has it can read them: never put a
97
- secret there. \`.env.development\` and \`.env.production\` override it for \`dev\` and \`build\`.
214
+ \`dash.minApiVersion\` in \`dot.json\` is the Dash API version the dot needs, such as \`1.0\`. Dash
215
+ opens the dot when it offers that version, or a newer minor of the same major. Each Dash call's docs
216
+ say which version it came in, after \`@since\`. To use a newer call and still open on older Dash,
217
+ keep \`minApiVersion\` and check \`this.dash.supports('contacts.pickPhone')\` before the call.
218
+ \`dotctl dev\` warns when the dot makes a newer call without checking.
98
219
 
99
- It starts as a counter with two pages. \`index\` counts taps with mutations. \`help/tips\` shows
100
- navigation: each page you open goes on top of the stack, Back returns to the one below, and the
101
- trail at the top shows where you are.
220
+ ## .env
221
+
222
+ \`.env\` holds public configuration. Code in the dot reads each variable from \`env\`, such as
223
+ \`env.GREETING\`. Every variable is built into the dot, so anyone who has the dot can read it.
224
+ Never put a secret there. \`.env.development\` and \`.env.production\` override it for \`dev\` and
225
+ \`build\`.
102
226
  `;
103
227
  }
104
- function idWord(packageName) {
105
- return packageName.slice(0, 63).replace(/-+$/, "");
228
+ function suggestedDotPackage(packageName) {
229
+ return `com.example.${packageName.slice(0, 63).replace(/-+$/, "")}`;
230
+ }
231
+ function formatJson(value) {
232
+ return `${JSON.stringify(value, null, 2)}\n`;
106
233
  }
107
234
  //#endregion
108
235
  //#region src/create/create.ts
109
- async function create({ name, dir }) {
236
+ const DOT_PACKAGE_RULE = "Use lowercase words joined by dots, such as com.mysalone.thing.";
237
+ async function create({ name, dir, dotPackage, template = "starter" }) {
110
238
  const root = resolve(dir);
111
- if (existsSync(root) && readdirSync(root).length > 0) throw new Error(`${dir} already exists and is not empty`);
239
+ if (existsSync(root) && readdirSync(root).length > 0) throw new CommandError(`${dir} already exists and is not empty. Choose another folder`);
112
240
  const packageName = toPackageName(name);
113
- if (!packageName) throw new Error(`"${name}" needs at least one letter or digit`);
241
+ if (!packageName) throw new CommandError(`The name "${name}" needs at least one letter or digit`);
242
+ dotPackage ??= suggestedDotPackage(packageName);
243
+ if (!isDotPackage(dotPackage)) throw new CommandError(`"${dotPackage}" isn't a package name. ${DOT_PACKAGE_RULE}`);
114
244
  const workspace = isInDotWorkspace(root);
115
- const template = templateDir();
245
+ const source = templateDir(template);
116
246
  await mkdir(root, { recursive: true });
117
- for (const path of TEMPLATE_FILES) await cp(join(template, path), join(root, path), { recursive: true });
118
- const generated = projectFiles({
247
+ for (const path of TEMPLATE_FILES) await cp(join(source, path), join(root, path), { recursive: true });
248
+ const generated = generatedFiles({
119
249
  name,
120
250
  packageName,
251
+ dotPackage,
121
252
  dotVersion: workspace ? "workspace:*" : `^${cliVersion()}`,
122
- workspace
253
+ workspace,
254
+ template
123
255
  });
124
256
  for (const [path, content] of Object.entries(generated)) await writeFile(join(root, path), content);
125
257
  return {
@@ -134,21 +266,16 @@ function toDisplayName(dir) {
134
266
  return basename(resolve(dir)).split(/[-_\s]+/).filter(Boolean).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
135
267
  }
136
268
  function isInDotWorkspace(dir) {
137
- let current = dirname(dir);
138
- while (true) {
139
- if (existsSync(join(current, "pnpm-workspace.yaml"))) {
140
- const runtimeManifest = join(current, "packages/runtime/package.json");
141
- if (!existsSync(runtimeManifest)) return false;
142
- return readPackageJson(runtimeManifest).name === "@monimesl/dot-runtime";
143
- }
144
- const parent = dirname(current);
145
- if (parent === current) return false;
146
- current = parent;
147
- }
269
+ const workspaceRoot = findFolderWith("pnpm-workspace.yaml", dirname(dir));
270
+ if (workspaceRoot === void 0) return false;
271
+ const runtimeManifest = join(workspaceRoot, "packages/runtime/package.json");
272
+ if (!existsSync(runtimeManifest)) return false;
273
+ return readPackageJson(runtimeManifest).name === "@monimesl/dot-runtime";
148
274
  }
149
- function templateDir() {
150
- const example = join(cliRoot(), "../../examples/counter");
151
- return existsSync(example) ? example : join(cliRoot(), "dist/template");
275
+ function templateDir(template) {
276
+ const example = join(cliRoot(), "../../examples", template);
277
+ if (existsSync(example)) return example;
278
+ return join(cliRoot(), "dist/templates", template);
152
279
  }
153
280
  function cliVersion() {
154
281
  return readPackageJson(join(cliRoot(), "package.json")).version;
@@ -160,6 +287,64 @@ function cliRoot() {
160
287
  return dirname(createRequire(import.meta.url).resolve("@monimesl/dot-cli/package.json"));
161
288
  }
162
289
  //#endregion
290
+ //#region src/create/page.ts
291
+ async function createPage(path, cwd) {
292
+ if (!isPageId(path)) throw new CommandError(`"${path}" is not a page path. Use letters, digits, "-" and "_", with "/" between folders, such as "cart/checkout"`);
293
+ const root = findDotRoot(resolve(cwd));
294
+ if (root === void 0) throw new CommandError("There is no dot.json in this folder or any folder above it. Run this command inside a dot");
295
+ const dir = join(root, PAGES_DIR, path);
296
+ if (existsSync(dir)) throw new CommandError(`pages/${path} already exists. Choose another path`);
297
+ await mkdir(dir, { recursive: true });
298
+ const files = [];
299
+ for (const [name, content] of Object.entries(PAGE_FILES)) {
300
+ const file = join(dir, name);
301
+ await writeFile(file, content);
302
+ files.push(file);
303
+ }
304
+ return {
305
+ dir,
306
+ files,
307
+ listing: await addToDotPages(join(root, DOT_FILE), path)
308
+ };
309
+ }
310
+ async function addToDotPages(dotFile, path) {
311
+ if (!existsSync(dotFile)) return "manual";
312
+ const source = await readFile(dotFile, "utf8");
313
+ let dot;
314
+ try {
315
+ dot = parseDotModule(source, dotFile);
316
+ } catch {
317
+ return "manual";
318
+ }
319
+ if (dot.pages.includes(path)) return "already";
320
+ const updated = insertPage(source, dot.lastPage, path);
321
+ if (!listsPage(updated, dotFile, path)) return "manual";
322
+ await writeFile(dotFile, updated);
323
+ return "added";
324
+ }
325
+ function insertPage(source, lastPage, path) {
326
+ const quote = source[lastPage.start] === "\"" ? "\"" : "'";
327
+ const page = `${quote}${path}${quote}`;
328
+ const before = source.slice(0, lastPage.end);
329
+ const after = source.slice(lastPage.end);
330
+ const lineStart = source.lastIndexOf("\n", lastPage.start - 1) + 1;
331
+ const indent = source.slice(lineStart, lastPage.start);
332
+ if (!/^\s*$/.test(indent)) return `${before}, ${page}${after}`;
333
+ const trailingComma = /^\s*,/.exec(after);
334
+ if (trailingComma) {
335
+ const end = lastPage.end + trailingComma[0].length;
336
+ return `${source.slice(0, end)}\n${indent}${page},${source.slice(end)}`;
337
+ }
338
+ return `${before},\n${indent}${page}${after}`;
339
+ }
340
+ function listsPage(source, dotFile, path) {
341
+ try {
342
+ return parseDotModule(source, dotFile).pages.includes(path);
343
+ } catch {
344
+ return false;
345
+ }
346
+ }
347
+ //#endregion
163
348
  //#region src/create/package-manager.ts
164
349
  const PACKAGE_MANAGERS = [
165
350
  "npm",
@@ -168,78 +353,337 @@ const PACKAGE_MANAGERS = [
168
353
  "bun"
169
354
  ];
170
355
  function isPackageManager(name) {
171
- return PACKAGE_MANAGERS.includes(name);
356
+ return isOneOf(PACKAGE_MANAGERS, name);
172
357
  }
173
358
  function launchedBy(userAgent = process.env.npm_config_user_agent ?? "") {
174
359
  return PACKAGE_MANAGERS.find((name) => userAgent.startsWith(`${name}/`)) ?? "npm";
175
360
  }
176
- function installedManagers(isInstalled = onPath) {
361
+ function installedManagers(isInstalled = isOnPath) {
177
362
  return PACKAGE_MANAGERS.filter(isInstalled);
178
363
  }
179
- function onPath(name) {
364
+ function isOnPath(name) {
180
365
  return spawnSync(name, ["--version"], {
181
366
  stdio: "ignore",
182
367
  shell: process.platform === "win32"
183
368
  }).status === 0;
184
369
  }
185
370
  //#endregion
186
- //#region src/report.ts
187
- function describeError(error) {
188
- if (error instanceof CompileError || error instanceof DmlParseError || error instanceof ScriptParseError) return error.message;
189
- if (error.code?.startsWith("ERR_PARSE_ARGS")) {
190
- const [firstSentence] = error.message.split(". ");
191
- return `${firstSentence}. See dotctl --help.`;
371
+ //#region src/device/developer-key.ts
372
+ function defaultDeveloperKeyPath() {
373
+ return process.env.DOT_DEVELOPER_KEY ?? join(homedir(), ".dot", "developer-key.pem");
374
+ }
375
+ function missingDeveloperKeyHint(path = defaultDeveloperKeyPath()) {
376
+ return ` There is no developer key at ${path}. Dash only opens signed links.\n Make one with OpenSSL 3. macOS's built-in openssl can't, so first run brew install openssl:
377
+
378
+ mkdir -p ${dirname(path)} && openssl genpkey -algorithm ed25519 -out ${path}\n\n Then run \`dotctl keys show\` and add the public key it prints to your Monime account.`;
379
+ }
380
+ async function loadDeveloperKey(path = defaultDeveloperKeyPath()) {
381
+ let pem;
382
+ try {
383
+ pem = await readFile(path, "utf8");
384
+ } catch (error) {
385
+ if (error.code === "ENOENT") return;
386
+ throw error;
192
387
  }
193
- return error instanceof Error ? error.stack ?? error.message : String(error);
388
+ const privateKey = createPrivateKey(pem);
389
+ if (privateKey.asymmetricKeyType !== "ed25519") throw new Error(`${path} is not an Ed25519 key. Make one with: openssl genpkey -algorithm ed25519 -out ${path}`);
390
+ return toDeveloperKey(privateKey);
194
391
  }
195
- function printWarnings({ warnings }) {
196
- for (const warning of warnings) console.warn(`warning: ${warning.message}`);
392
+ function toDeveloperKey(privateKey) {
393
+ const publicKeyJwk = createPublicKey(privateKey).export({ format: "jwk" });
394
+ const rawPublicKey = Buffer.from(publicKeyJwk.x, "base64url");
395
+ return {
396
+ publicKey: rawPublicKey.toString("base64"),
397
+ keyId: rawPublicKey.subarray(0, 8).toString("hex"),
398
+ sign: (bytes) => sign(null, bytes, privateKey).toString("base64")
399
+ };
197
400
  }
198
- function printBuildTime(startedAt) {
199
- console.log(`Built in ${Math.round(performance.now() - startedAt)}ms`);
401
+ //#endregion
402
+ //#region src/device/server.ts
403
+ const DEVICE_SCHEME = "dotdev";
404
+ const FILES_PATH = "/dev/files/";
405
+ const WAIT_MS = 25e3;
406
+ const MAX_LOG_BYTES = 1048576;
407
+ const LINK_LIFETIME_SECONDS = 43200;
408
+ const BUILD_MAC_HEADER = "dot-build-mac";
409
+ function signedLinkMessage(server, secret, expires, keyId) {
410
+ return [
411
+ "dotdev-connect",
412
+ server,
413
+ secret,
414
+ String(expires),
415
+ keyId
416
+ ].join("\n");
417
+ }
418
+ function deviceToken(secret) {
419
+ return hmacWithSecret(secret, "dotdev-token");
420
+ }
421
+ function buildMac(secret, body) {
422
+ return hmacWithSecret(secret, `dotdev-build\n${body}`);
423
+ }
424
+ async function startDeviceServer({ address = localNetworkAddress(), port = 0, onLog = () => {}, onConnect = () => {}, developerKey } = {}) {
425
+ if (address === void 0) throw new Error("This computer has no local network address, so devices can't reach it. Connect to a network and try again");
426
+ const deviceServer = new LocalDeviceServer(onLog, onConnect);
427
+ await deviceServer.listen(port, address, developerKey);
428
+ return deviceServer;
429
+ }
430
+ var LocalDeviceServer = class {
431
+ #onLog;
432
+ #onConnect;
433
+ #secret = randomBytes(16).toString("base64url");
434
+ #token = deviceToken(this.#secret);
435
+ #server;
436
+ #waiting = /* @__PURE__ */ new Set();
437
+ #connected = /* @__PURE__ */ new Set();
438
+ #link = "";
439
+ #signature = null;
440
+ #files = /* @__PURE__ */ new Map();
441
+ #generation = 0;
442
+ #hotSince = 0;
443
+ #error = null;
444
+ constructor(onLog, onConnect) {
445
+ this.#onLog = onLog;
446
+ this.#onConnect = onConnect;
447
+ this.#server = createServer((request, response) => {
448
+ this.#handle(request, response);
449
+ });
450
+ }
451
+ get link() {
452
+ return this.#link;
453
+ }
454
+ async listen(port, address, developerKey) {
455
+ await new Promise((resolve, reject) => {
456
+ this.#server.once("error", reject);
457
+ this.#server.listen(port, address, () => resolve());
458
+ });
459
+ const { port: listeningPort } = this.#server.address();
460
+ const origin = `http://${address}:${listeningPort}`;
461
+ if (developerKey) {
462
+ const expires = Math.floor(Date.now() / 1e3) + LINK_LIFETIME_SECONDS;
463
+ const keyId = developerKey.keyId;
464
+ const message = signedLinkMessage(origin, this.#secret, expires, keyId);
465
+ const sig = developerKey.sign(Buffer.from(message, "utf8"));
466
+ this.#signature = {
467
+ expires,
468
+ keyId,
469
+ sig
470
+ };
471
+ }
472
+ const server = encodeURIComponent(origin);
473
+ this.#link = `${DEVICE_SCHEME}://connect?server=${server}&secret=${this.#secret}`;
474
+ }
475
+ async publish(dir, { hot = false } = {}) {
476
+ this.#files = await readBuild(dir);
477
+ this.#error = null;
478
+ this.#announce();
479
+ if (!hot) this.#hotSince = this.#generation;
480
+ }
481
+ fail(message) {
482
+ this.#error = message;
483
+ this.#announce();
484
+ }
485
+ close() {
486
+ this.#wakeWaitingDevices();
487
+ return new Promise((resolve) => {
488
+ this.#server.close(() => resolve());
489
+ this.#server.closeAllConnections();
490
+ });
491
+ }
492
+ #announce() {
493
+ this.#generation += 1;
494
+ this.#wakeWaitingDevices();
495
+ }
496
+ #wakeWaitingDevices() {
497
+ for (const wake of this.#waiting) wake();
498
+ }
499
+ #handle(request, response) {
500
+ const from = request.socket.remoteAddress ?? "a device";
501
+ if (request.method === "GET" && request.url === "/dev/link") {
502
+ this.#sendSignature(response);
503
+ return;
504
+ }
505
+ if (!isAuthorized(request, this.#token)) {
506
+ response.writeHead(401).end();
507
+ return;
508
+ }
509
+ const url = new URL(request.url ?? "/", "http://device");
510
+ const path = url.pathname;
511
+ if (request.method === "GET" && path === "/dev/build") this.#sendBuild(response, from);
512
+ else if (request.method === "GET" && path.startsWith(FILES_PATH)) this.#sendFile(response, path, url.searchParams.get("generation"));
513
+ else if (request.method === "GET" && path === "/dev/changes") this.#waitForChange(response, Number(url.searchParams.get("after")));
514
+ else if (request.method === "POST" && path === "/dev/log") this.#receiveLog(request, response, from);
515
+ else response.writeHead(404).end();
516
+ }
517
+ #sendSignature(response) {
518
+ if (this.#signature) sendJson(response, this.#signature);
519
+ else response.writeHead(404).end();
520
+ }
521
+ #sendBuild(response, from) {
522
+ if (!this.#connected.has(from)) {
523
+ this.#connected.add(from);
524
+ this.#onConnect(from);
525
+ }
526
+ const hashes = {};
527
+ for (const [name, file] of this.#files) hashes[name] = file.hash;
528
+ const body = JSON.stringify({
529
+ generation: this.#generation,
530
+ hotSince: this.#hotSince,
531
+ files: hashes,
532
+ error: this.#error
533
+ });
534
+ response.writeHead(200, {
535
+ "content-type": "application/json",
536
+ [BUILD_MAC_HEADER]: buildMac(this.#secret, body)
537
+ }).end(body);
538
+ }
539
+ #sendFile(response, path, generation) {
540
+ if (generation !== String(this.#generation)) {
541
+ response.writeHead(409).end();
542
+ return;
543
+ }
544
+ const fileName = buildFileName(path);
545
+ if (fileName === void 0) {
546
+ response.writeHead(400).end();
547
+ return;
548
+ }
549
+ const file = this.#files.get(fileName);
550
+ if (!file) {
551
+ response.writeHead(404).end();
552
+ return;
553
+ }
554
+ response.writeHead(200, { "content-type": "application/octet-stream" }).end(file.bytes);
555
+ }
556
+ #waitForChange(response, after) {
557
+ if (this.#generation > after) {
558
+ sendJson(response, { generation: this.#generation });
559
+ return;
560
+ }
561
+ const stopWaiting = () => {
562
+ clearTimeout(timer);
563
+ this.#waiting.delete(wake);
564
+ };
565
+ const wake = () => {
566
+ stopWaiting();
567
+ sendJson(response, { generation: this.#generation });
568
+ };
569
+ const timer = setTimeout(() => {
570
+ stopWaiting();
571
+ response.writeHead(204).end();
572
+ }, WAIT_MS);
573
+ this.#waiting.add(wake);
574
+ response.on("close", stopWaiting);
575
+ }
576
+ #receiveLog(request, response, from) {
577
+ readLogText(request).then((text) => {
578
+ for (const line of text.split("\n")) this.#onLog(line, from);
579
+ response.writeHead(204).end();
580
+ }).catch(() => response.writeHead(413).end());
581
+ }
582
+ };
583
+ function hmacWithSecret(secret, text) {
584
+ const key = Buffer.from(secret, "base64url");
585
+ return createHmac("sha256", key).update(text, "utf8").digest("base64url");
586
+ }
587
+ function localNetworkAddress() {
588
+ for (const addresses of Object.values(networkInterfaces())) for (const candidate of addresses ?? []) if (candidate.family === "IPv4" && !candidate.internal) return candidate.address;
589
+ }
590
+ async function readBuild(dir) {
591
+ const files = /* @__PURE__ */ new Map();
592
+ const entries = await readdir(dir, {
593
+ recursive: true,
594
+ withFileTypes: true
595
+ });
596
+ for (const entry of entries) {
597
+ if (!entry.isFile() || entry.name.endsWith(".map") || entry.name.endsWith(".zip")) continue;
598
+ const path = join(entry.parentPath, entry.name);
599
+ const bytes = await readFile(path);
600
+ const hash = createHash("sha256").update(bytes).digest("base64url");
601
+ const name = relative(dir, path).split(sep).join("/");
602
+ files.set(name, {
603
+ bytes,
604
+ hash
605
+ });
606
+ }
607
+ return files;
608
+ }
609
+ function buildFileName(path) {
610
+ const encodedParts = path.slice(11).split("/");
611
+ try {
612
+ return encodedParts.map((part) => decodeURIComponent(part)).join("/");
613
+ } catch {
614
+ return;
615
+ }
616
+ }
617
+ function isAuthorized(request, token) {
618
+ const given = Buffer.from(request.headers.authorization ?? "");
619
+ const expected = Buffer.from(`Bearer ${token}`);
620
+ if (given.length !== expected.length) return false;
621
+ return timingSafeEqual(given, expected);
622
+ }
623
+ function sendJson(response, body) {
624
+ response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(body));
625
+ }
626
+ async function readLogText(request) {
627
+ const chunks = [];
628
+ let size = 0;
629
+ for await (const chunk of request) {
630
+ const bytes = chunk;
631
+ size += bytes.length;
632
+ if (size > MAX_LOG_BYTES) throw new Error(`A device sent more than ${MAX_LOG_BYTES} bytes of log at once`);
633
+ chunks.push(bytes);
634
+ }
635
+ return Buffer.concat(chunks).toString("utf8");
636
+ }
637
+ //#endregion
638
+ //#region src/simulator.ts
639
+ function startDotSimulator(project, outDir, port) {
640
+ return startSimulator({
641
+ dotDir: outDir,
642
+ reactHostDir: installedRuntime(project.root).react.dir,
643
+ name: project.dotJson.name,
644
+ port,
645
+ storageDir: join(project.root, ".dot", "files")
646
+ });
200
647
  }
201
648
  //#endregion
202
649
  //#region src/dev.ts
203
650
  const CHANGE_DEBOUNCE_MS = 50;
204
- async function dev({ port, ...options }) {
651
+ async function dev({ port, device, ...options }) {
205
652
  const project = await loadProject(options.root);
206
- const outDir = resolve(project.root, options.outDir ?? "dist");
653
+ const outDir = resolveOutDir(project.root, options.outDir);
207
654
  const buildOptions = {
208
655
  mode: "development",
209
656
  ...options,
210
657
  outDir
211
658
  };
212
- const buildApp = async () => {
213
- const started = performance.now();
214
- try {
215
- printWarnings(await build(buildOptions));
216
- printBuildTime(started);
217
- return null;
218
- } catch (error) {
219
- const message = describeError(error).replaceAll(project.root + sep, "");
220
- console.error(`error: ${message}`);
221
- return message;
222
- }
223
- };
224
- const firstError = await buildApp();
225
- const simulator = await startSimulator({
226
- dotDir: outDir,
227
- reactHostDir: installedRuntime(project.root).react.dir,
228
- name: project.dotJson.name,
229
- port
230
- });
659
+ const firstError = await buildDot(buildOptions, project.root);
660
+ const simulator = await startDotSimulator(project, outDir, port);
231
661
  if (firstError) simulator.error(firstError);
232
- console.log(`\n Simulator running at: ${simulator.url}\n`);
662
+ printSimulatorUrl(simulator.url);
663
+ let deviceServer;
664
+ if (device) deviceServer = await startDevices(outDir, firstError);
665
+ const changedFiles = /* @__PURE__ */ new Set();
666
+ const pagesDir = join(project.root, PAGES_DIR) + sep;
233
667
  const rebuilds = new RebuildQueue(async () => {
234
- const error = await buildApp();
235
- if (error) simulator.error(error);
668
+ const filesInThisBuild = [...changedFiles];
669
+ const error = await buildDot(buildOptions, project.root);
670
+ if (error) {
671
+ simulator.error(error);
672
+ deviceServer?.fail(error);
673
+ return;
674
+ }
675
+ for (const path of filesInThisBuild) changedFiles.delete(path);
676
+ const hot = filesInThisBuild.every((path) => path.startsWith(pagesDir));
677
+ if (hot) simulator.hotReload();
236
678
  else simulator.reload();
679
+ await deviceServer?.publish(outDir, { hot });
237
680
  });
238
681
  let debounceTimer;
239
682
  const watcher = watch(project.root, { recursive: true }, (_event, file) => {
240
683
  if (!file || isIgnored(file)) return;
241
684
  const path = resolve(project.root, file);
242
685
  if (path === outDir || path.startsWith(outDir + sep)) return;
686
+ changedFiles.add(path);
243
687
  clearTimeout(debounceTimer);
244
688
  debounceTimer = setTimeout(() => {
245
689
  console.log(`Changed ${relative(project.root, path)}`);
@@ -249,9 +693,41 @@ async function dev({ port, ...options }) {
249
693
  return async () => {
250
694
  clearTimeout(debounceTimer);
251
695
  watcher.close();
252
- await simulator.close();
696
+ await Promise.all([simulator.close(), deviceServer?.close()]);
253
697
  };
254
698
  }
699
+ async function buildDot(buildOptions, root) {
700
+ const startedAt = performance.now();
701
+ try {
702
+ printWarnings(await build(buildOptions));
703
+ printBuildTime(startedAt);
704
+ return null;
705
+ } catch (error) {
706
+ const message = describeError(error).replaceAll(root + sep, "");
707
+ console.error(`error: ${message}`);
708
+ return message;
709
+ }
710
+ }
711
+ async function startDevices(outDir, firstError) {
712
+ const developerKey = await loadDeveloperKey();
713
+ const deviceServer = await startDeviceServer({
714
+ developerKey,
715
+ onConnect: (from) => console.log(`A device connected from ${from}`),
716
+ onLog: (line) => console.log(`device ${line}`)
717
+ });
718
+ if (firstError) deviceServer.fail(firstError);
719
+ else await deviceServer.publish(outDir);
720
+ printDeviceLink(deviceServer.link);
721
+ if (developerKey) console.log(` Signed with developer key ${developerKey.keyId}\n`);
722
+ else console.log(`${missingDeveloperKeyHint()}\n`);
723
+ return deviceServer;
724
+ }
725
+ function printDeviceLink(link) {
726
+ console.log(" To open it in Dash, turn on Enable Dot Debugging in Developer Settings.\n Then tap Live Test on the Dots tab and scan this code.\n");
727
+ const qrCode = renderUnicodeCompact(link, { border: 1 });
728
+ console.log(qrCode.replace(/^/gm, " "));
729
+ console.log(`\n ${link}\n`);
730
+ }
255
731
  var RebuildQueue = class {
256
732
  #rebuild;
257
733
  #building = false;
@@ -284,18 +760,140 @@ function isIgnored(file) {
284
760
  });
285
761
  }
286
762
  //#endregion
763
+ //#region src/preview.ts
764
+ async function preview({ port, ...options }) {
765
+ const project = await loadProject(options.root);
766
+ const outDir = resolveOutDir(project.root, options.outDir);
767
+ const startedAt = performance.now();
768
+ printWarnings(await build({
769
+ ...options,
770
+ outDir,
771
+ mode: "production"
772
+ }));
773
+ printBuildTime(startedAt);
774
+ const simulator = await startDotSimulator(project, outDir, port);
775
+ printSimulatorUrl(simulator.url);
776
+ return simulator;
777
+ }
778
+ //#endregion
779
+ //#region src/prompts.ts
780
+ const TERMINAL = {
781
+ input: process.stdin,
782
+ output: process.stdout
783
+ };
784
+ const ERASE_BELOW = "\x1B[0J";
785
+ const HIDE_CURSOR = "\x1B[?25l";
786
+ const SHOW_CURSOR = "\x1B[?25h";
787
+ async function select(question, choices, fallback, { input, output } = TERMINAL) {
788
+ if (!input.isTTY) return fallback;
789
+ let selectedIndex = choices.findIndex((choice) => choice.value === fallback);
790
+ if (selectedIndex < 0) selectedIndex = 0;
791
+ let valueWidth = 0;
792
+ if (choices.some((choice) => choice.hint)) valueWidth = Math.max(...choices.map((choice) => choice.value.length));
793
+ const formatChoice = (choice, index) => {
794
+ const value = choice.value.padEnd(valueWidth);
795
+ const hint = choice.hint ? styleText("dim", ` ${choice.hint}`) : "";
796
+ const defaultMark = choice.value === fallback ? styleText("green", " (default)") : "";
797
+ if (index === selectedIndex) return `${styleText("cyan", "❯")} ${styleText(["cyan", "bold"], value)}${hint}${defaultMark}`;
798
+ return ` ${value}${hint}${defaultMark}`;
799
+ };
800
+ let drawnLines = 0;
801
+ const draw = (lines) => {
802
+ let moveUp = "";
803
+ if (drawnLines > 0) moveUp = cursorUp(drawnLines);
804
+ output.write(`${moveUp}${ERASE_BELOW}${lines.join("\n")}\n`);
805
+ drawnLines = lines.length;
806
+ };
807
+ const drawQuestionAndChoices = () => {
808
+ const keysHint = styleText("dim", "↑/↓ to move, Enter to choose");
809
+ draw([`${styleText("bold", question)} ${keysHint}`, ...choices.map(formatChoice)]);
810
+ };
811
+ emitKeypressEvents(input);
812
+ input.setRawMode(true);
813
+ input.resume();
814
+ output.write(HIDE_CURSOR);
815
+ drawQuestionAndChoices();
816
+ return new Promise((resolve) => {
817
+ const onKeypress = (_text, key) => {
818
+ if (key.ctrl && key.name === "c") {
819
+ output.write(`${SHOW_CURSOR}\n`);
820
+ process.exit(130);
821
+ }
822
+ if (key.name === "up" || key.name === "k") {
823
+ selectedIndex = (selectedIndex - 1 + choices.length) % choices.length;
824
+ drawQuestionAndChoices();
825
+ } else if (key.name === "down" || key.name === "j") {
826
+ selectedIndex = (selectedIndex + 1) % choices.length;
827
+ drawQuestionAndChoices();
828
+ } else if (key.name === "return" || key.name === "enter") {
829
+ const { value } = choices[selectedIndex];
830
+ input.off("keypress", onKeypress);
831
+ input.setRawMode(false);
832
+ input.pause();
833
+ draw([`${styleText("bold", question)} ${styleText("cyan", value)}`]);
834
+ output.write(SHOW_CURSOR);
835
+ resolve(value);
836
+ }
837
+ };
838
+ input.on("keypress", onKeypress);
839
+ });
840
+ }
841
+ async function confirm(question, fallback, terminal = TERMINAL) {
842
+ if (!terminal.input.isTTY) return false;
843
+ return await select(question, [{ value: "Yes" }, { value: "No" }], fallback ? "Yes" : "No", terminal) === "Yes";
844
+ }
845
+ async function ask(question, fallback, hint, { input, output } = TERMINAL) {
846
+ if (!input.isTTY) return fallback;
847
+ const hintText = hint ? styleText("dim", `, ${hint}`) : "";
848
+ const defaultMark = fallback ? styleText("dim", ` (default: ${fallback})`) : "";
849
+ const readline = createInterface({
850
+ input,
851
+ output
852
+ });
853
+ try {
854
+ const trimmedAnswer = (await readline.question(`${styleText("bold", question)}${hintText}${defaultMark}: `)).trim();
855
+ if (trimmedAnswer === "") return fallback;
856
+ return trimmedAnswer;
857
+ } catch (error) {
858
+ if (error.name === "AbortError") return;
859
+ throw error;
860
+ } finally {
861
+ readline.close();
862
+ }
863
+ }
864
+ function cursorUp(lines) {
865
+ return `\x1b[${lines}A`;
866
+ }
867
+ //#endregion
287
868
  //#region src/cli.ts
288
869
  const USAGE = `Usage:
289
- dotctl create [dir] [--name <name>] [--pm <npm|pnpm|yarn|bun>]
290
- Create a dot (asks for its name)
870
+ dotctl create [dir] [--name <name>] [--package <com.mysalone.thing>]
871
+ [--template <starter|showcase>] [--pm <npm|pnpm|yarn|bun>]
872
+ Create a dot. Asks for its name, its package name
873
+ (a reverse domain name you own) and its template:
874
+ starter is one page to build on, showcase has a
875
+ page for each feature
876
+ dotctl create page <path> Add a page to the dot you are in. Writes
877
+ pages/<path>/ with view.dml, state.ts and controller.ts
291
878
  dotctl build [root] [options] Build the minified bundles the host app runs
292
- dotctl dev [root] [options] Run in the browser simulator, rebuilding on change
879
+ dotctl dev [root] [options] Run the dot in the browser simulator and rebuild on
880
+ every change
881
+ dotctl preview [root] [options] Build as \`build\` does, then run that build in the
882
+ browser simulator, exactly as the host app gets it
883
+ dotctl keys show Show this computer's developer key: its public key
884
+ and key ID. The key signs the links that
885
+ \`dev --device\` shows, so Dash will open them
886
+ dotctl lsp [--stdio] Start the .dml language server for editors. It talks
887
+ over stdin and stdout
293
888
 
294
889
  Options:
295
890
  --out-dir <dir> Output directory (default: <root>/dist)
296
- --conditions <list> Extra export conditions, comma separated
297
- --port <port> Simulator port for \`dev\`; the next free one is used if taken
298
- (default: ${DEFAULT_PORT})
891
+ --conditions <list> Extra export conditions, separated by commas
892
+ --port <port> Simulator port for \`dev\` and \`preview\`. If it is taken, the
893
+ next free port is used (default: ${DEFAULT_PORT})
894
+ --device With \`dev\`, also serve the dot to the Dot example apps on phones
895
+ on your local network, through a link and QR code. The apps
896
+ reload after each build
299
897
  -h, --help Show this message`;
300
898
  async function main(argv) {
301
899
  const { values, positionals } = parseArgs({
@@ -305,120 +903,215 @@ async function main(argv) {
305
903
  "out-dir": { type: "string" },
306
904
  conditions: { type: "string" },
307
905
  port: { type: "string" },
906
+ device: { type: "boolean" },
308
907
  name: { type: "string" },
908
+ package: { type: "string" },
909
+ template: { type: "string" },
309
910
  pm: { type: "string" },
310
911
  help: {
311
912
  type: "boolean",
312
913
  short: "h"
313
- }
914
+ },
915
+ stdio: { type: "boolean" }
314
916
  }
315
917
  });
316
- const [command, target] = positionals;
317
- if (command === "create" && !values.help) return createApp(target, values.name, values.pm);
318
- if (values.help || command !== "build" && command !== "dev") {
918
+ const [command, target, pagePath] = positionals;
919
+ if (values.help) {
319
920
  console.log(USAGE);
320
- return values.help ? 0 : 1;
921
+ return 0;
321
922
  }
322
- const port = values.port === void 0 ? void 0 : Number(values.port);
323
- if (port !== void 0 && !Number.isInteger(port)) {
324
- console.error(`Invalid --port "${values.port}"`);
325
- return 1;
923
+ switch (command) {
924
+ case "lsp": return serveLanguageServer();
925
+ case "keys": return showKeys(target);
926
+ case "create":
927
+ if (target === "page") return addPage(pagePath);
928
+ return createDot(target, {
929
+ name: values.name,
930
+ dotPackage: values.package,
931
+ template: values.template,
932
+ packageManager: values.pm
933
+ });
934
+ case "build":
935
+ case "dev":
936
+ case "preview": break;
937
+ default:
938
+ console.log(USAGE);
939
+ return 1;
940
+ }
941
+ let port;
942
+ if (values.port !== void 0) {
943
+ port = Number(values.port);
944
+ if (!Number.isInteger(port)) {
945
+ console.error(`--port must be a whole number, not "${values.port}"`);
946
+ return 1;
947
+ }
326
948
  }
327
949
  const options = { root: target ?? "." };
328
950
  if (values["out-dir"] !== void 0) options.outDir = values["out-dir"];
329
951
  if (values.conditions !== void 0) options.conditions = values.conditions.split(",");
330
- if (command === "dev") {
331
- const stop = await dev({
332
- ...options,
333
- port
334
- });
335
- await new Promise((resolve) => process.once("SIGINT", resolve));
336
- await stop();
337
- return 0;
952
+ if (command === "dev") return runDev(options, port, values.device);
953
+ if (command === "preview") return runPreview(options, port, values.device);
954
+ return runBuild(options);
955
+ }
956
+ async function serveLanguageServer() {
957
+ const { serveStdio } = await import("./server-DMnWDdJ6.js");
958
+ serveStdio();
959
+ return new Promise(() => {});
960
+ }
961
+ async function runDev(options, port, device) {
962
+ const stop = await dev({
963
+ ...options,
964
+ port,
965
+ device
966
+ });
967
+ await waitForCtrlC();
968
+ await stop();
969
+ return 0;
970
+ }
971
+ async function runPreview(options, port, device) {
972
+ if (device) {
973
+ console.error("--device only works with dotctl dev");
974
+ return 1;
338
975
  }
339
- const started = performance.now();
976
+ const simulator = await preview({
977
+ ...options,
978
+ port
979
+ });
980
+ await waitForCtrlC();
981
+ await simulator.close();
982
+ return 0;
983
+ }
984
+ async function runBuild(options) {
985
+ const startedAt = performance.now();
340
986
  const result = await build(options);
341
- for (const file of result.files.filter((file) => !file.endsWith(".map"))) console.log(` ${relative(process.cwd(), file)}`);
987
+ printFiles(result.files.filter((file) => !file.endsWith(".map")));
342
988
  printWarnings(result);
343
- printBuildTime(started);
989
+ printBuildTime(startedAt);
344
990
  return 0;
345
991
  }
346
- async function createApp(dir, name, packageManagerFlag) {
992
+ function waitForCtrlC() {
993
+ return new Promise((resolve) => {
994
+ process.once("SIGINT", () => resolve());
995
+ });
996
+ }
997
+ async function showKeys(action) {
998
+ if (action !== "show") {
999
+ console.error("Usage: dotctl keys show");
1000
+ return 1;
1001
+ }
1002
+ const path = defaultDeveloperKeyPath();
1003
+ const key = await loadDeveloperKey(path);
1004
+ if (!key) {
1005
+ console.error(missingDeveloperKeyHint(path));
1006
+ return 1;
1007
+ }
1008
+ console.log(`\n Public key: ${key.publicKey}\n Key ID: ${key.keyId}\n`);
1009
+ return 0;
1010
+ }
1011
+ async function addPage(typedPath) {
1012
+ if (typedPath === void 0) {
1013
+ console.error("Give the page a path, such as: dotctl create page cart/checkout");
1014
+ return 1;
1015
+ }
1016
+ const path = trimSlashes(typedPath);
1017
+ const { files, listing } = await createPage(path, process.cwd());
1018
+ printFiles(files);
1019
+ if (listing === "added") console.log(`\nListed '${path}' in dot.ts.`);
1020
+ else if (listing === "manual") console.log(`\nAdd it to the pages in dot.ts so it gets built: defineDot({ pages: [..., '${path}'] })`);
1021
+ return 0;
1022
+ }
1023
+ function trimSlashes(path) {
1024
+ let trimmed = path;
1025
+ if (trimmed.startsWith("/")) trimmed = trimmed.substring(1);
1026
+ if (trimmed.endsWith("/")) trimmed = trimmed.substring(0, trimmed.length - 1);
1027
+ return trimmed;
1028
+ }
1029
+ async function createDot(dir, flags) {
1030
+ const templateFlag = flags.template;
1031
+ if (templateFlag !== void 0 && !isTemplate(templateFlag)) {
1032
+ console.error(`--template must be one of ${TEMPLATES.join(", ")}`);
1033
+ return 1;
1034
+ }
1035
+ const packageManagerFlag = flags.packageManager;
347
1036
  if (packageManagerFlag !== void 0 && !isPackageManager(packageManagerFlag)) {
348
1037
  console.error(`--pm must be one of ${PACKAGE_MANAGERS.join(", ")}`);
349
1038
  return 1;
350
1039
  }
351
- name ??= await ask("Dot name:", dir ? toDisplayName(dir) : "Dot");
1040
+ let name = flags.name;
1041
+ if (name === void 0) name = await ask("Dot name", dir ? toDisplayName(dir) : "Dot");
352
1042
  if (!name) {
353
- console.error("A dot needs a name: dotctl create [dir] --name \"My Dot\"");
1043
+ console.error("The dot needs a name. Pass one with: dotctl create [dir] --name \"My Dot\"");
1044
+ return 1;
1045
+ }
1046
+ const dotPackageFlag = flags.dotPackage;
1047
+ if (dotPackageFlag !== void 0 && !isDotPackage(dotPackageFlag)) {
1048
+ console.error(`--package "${dotPackageFlag}" isn't a package name. ${DOT_PACKAGE_RULE}`);
354
1049
  return 1;
355
1050
  }
1051
+ let dotPackage = dotPackageFlag;
1052
+ if (dotPackage === void 0) dotPackage = await askDotPackage(suggestedDotPackage(toPackageName(name)));
1053
+ if (!dotPackage) return 1;
1054
+ let template = templateFlag;
1055
+ if (template === void 0) template = await chooseTemplate();
356
1056
  const { root, workspace } = await create({
357
1057
  name,
358
- dir: dir ?? toPackageName(name)
1058
+ dir: dir ?? toPackageName(name),
1059
+ dotPackage,
1060
+ template
359
1061
  });
360
- const path = relative(process.cwd(), root) || ".";
1062
+ let path = relative(process.cwd(), root);
1063
+ if (path === "") path = ".";
361
1064
  console.log(`\nCreated ${name} in ${path}`);
362
1065
  if (workspace) console.log("It is inside the Dot repository, so it uses the workspace packages.");
363
- let packageManager = workspace ? "pnpm" : packageManagerFlag ?? launchedBy();
364
- if (await confirm("\nInstall and start it now?")) {
1066
+ let packageManager;
1067
+ if (workspace) packageManager = "pnpm";
1068
+ else packageManager = packageManagerFlag ?? launchedBy();
1069
+ console.log();
1070
+ if (await confirm("Install and start it now?", true)) {
365
1071
  if (!workspace && !packageManagerFlag) packageManager = await choosePackageManager(packageManager);
366
- if (await run(packageManager, ["install"], root) === 0) return run(packageManager, ["run", "dev"], root);
367
- console.error(`\n${packageManager} install failed.`);
1072
+ if (await runCommand(packageManager, ["install"], root) === 0) return runCommand(packageManager, ["run", "dev"], root);
1073
+ console.error(`\n${packageManager} install failed. Fix the problem above, then run the steps below.`);
368
1074
  }
369
1075
  console.log(`\nNext:\n\n cd ${path}\n ${packageManager} install\n ${packageManager} run dev\n`);
370
1076
  return 0;
371
1077
  }
372
- function run(command, args, cwd) {
373
- return new Promise((resolvePromise, reject) => {
1078
+ function runCommand(command, args, cwd) {
1079
+ return new Promise((resolve, reject) => {
374
1080
  const child = spawn(command, args, {
375
1081
  cwd,
376
1082
  stdio: "inherit",
377
1083
  shell: process.platform === "win32"
378
1084
  });
379
1085
  child.on("error", reject);
380
- child.on("exit", (code) => resolvePromise(code ?? 1));
1086
+ child.on("exit", (exitCode) => resolve(exitCode ?? 1));
381
1087
  });
382
1088
  }
383
- async function confirm(question) {
384
- if (!process.stdin.isTTY) return false;
385
- const answer = await readAnswer(`${question} (Y/n)`, "y");
386
- return answer !== void 0 && answer.toLowerCase().startsWith("y");
387
- }
388
- async function ask(question, fallback) {
389
- return readAnswer(`${question}${fallback ? ` (${fallback})` : ""}`, fallback);
390
- }
391
- async function readAnswer(prompt, fallback) {
392
- if (!process.stdin.isTTY) return fallback;
393
- const readline = createInterface({
394
- input: process.stdin,
395
- output: process.stdout
396
- });
397
- try {
398
- return (await readline.question(`${prompt} `)).trim() || fallback;
399
- } catch (error) {
400
- if (error.name === "AbortError") return;
401
- throw error;
402
- } finally {
403
- readline.close();
1089
+ async function askDotPackage(suggested) {
1090
+ while (true) {
1091
+ const answer = await ask("Package name", suggested, "a reverse domain name you own");
1092
+ if (answer === void 0 || isDotPackage(answer)) return answer;
1093
+ console.error(`"${answer}" isn't a package name. ${DOT_PACKAGE_RULE}`);
404
1094
  }
405
1095
  }
1096
+ function chooseTemplate() {
1097
+ console.log();
1098
+ return select("Template?", TEMPLATES.map((template) => ({
1099
+ value: template,
1100
+ hint: TEMPLATE_HINTS[template]
1101
+ })), "starter");
1102
+ }
406
1103
  async function choosePackageManager(suggested) {
407
1104
  const installed = installedManagers();
408
1105
  if (installed.length < 2) return installed[0] ?? suggested;
409
- const fallback = installed.includes(suggested) ? suggested : installed[0];
410
- while (true) {
411
- const answer = await ask(`Package manager? ${installed.join(" / ")}`, fallback);
412
- if (answer === void 0) return fallback;
413
- if (isPackageManager(answer) && installed.includes(answer)) return answer;
414
- console.log(`Choose one of ${installed.join(", ")}.`);
415
- }
1106
+ let fallback = installed[0];
1107
+ if (installed.includes(suggested)) fallback = suggested;
1108
+ return select("Package manager?", installed.map((manager) => ({ value: manager })), fallback);
416
1109
  }
417
- main(process.argv.slice(2)).then((code) => process.exit(code), (error) => {
1110
+ main(process.argv.slice(2)).then((exitCode) => process.exit(exitCode), (error) => {
418
1111
  console.error(`error: ${describeError(error)}`);
419
1112
  process.exit(1);
420
1113
  });
421
1114
  //#endregion
422
- export {};
1115
+ export { findDotRoot as t };
423
1116
 
424
1117
  //# sourceMappingURL=cli.js.map