@monimesl/dot-cli 0.1.0-alpha.4 → 0.1.0-alpha.40

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