@plitzi/cli 0.37.9 → 0.37.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,21 +1,236 @@
1
1
  #!/usr/bin/env node
2
- import { Option, program } from "commander";
2
+ import { InvalidArgumentError, Option, program } from "commander";
3
+ import fs, { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
4
+ import { builtinModules, createRequire } from "node:module";
5
+ import path, { dirname, join, relative, sep } from "node:path";
6
+ import { pathToFileURL } from "node:url";
3
7
  import chalk from "chalk";
4
- import fs from "node:fs/promises";
8
+ import { execFile, spawn, spawnSync } from "node:child_process";
5
9
  import os from "node:os";
6
- import path, { dirname, join, relative, sep } from "node:path";
7
- import { spawn, spawnSync } from "node:child_process";
8
10
  import { createHash, randomBytes } from "node:crypto";
9
11
  import { createServer } from "node:http";
10
12
  import tls from "node:tls";
11
13
  import readline from "node:readline/promises";
12
- import { builtinModules, createRequire } from "node:module";
13
14
  import { readFileSync, readdirSync, watch } from "node:fs";
14
- import { actionSpecFromEntry, actionToSource, blankSpaceSource, elementTypeNames } from "@plitzi/sdk-authoring";
15
- import { pathToFileURL } from "node:url";
15
+ import { EXPLAIN_KINDS, actionSpecFromEntry, actionToSource, authorSpace, blankSpaceSource, catalogTemplateFiles, comparePictures, darkScheme, elementTriggers, elementTypeNames, emptySpaceSource, explain, explainKindOf, explainList, explanationText, failedFlowText, importProbe, importedFiles, inspectDocument, inspectPage, pageRegions, planFixes, readDevTools, refusalOf } from "@plitzi/sdk-authoring";
16
+ import { promisify } from "node:util";
17
+ import { lookup } from "node:dns/promises";
16
18
  import { build } from "esbuild";
17
19
  import { unzipSync, zipSync } from "fflate";
18
20
  import { gzipSync } from "node:zlib";
21
+ //#region ../../packages/sdk-shared/dist/helpers/isRecord.mjs
22
+ var e$1 = (e) => typeof e == "object" && !!e && !Array.isArray(e);
23
+ //#endregion
24
+ //#region src/browser/index.ts
25
+ /**
26
+ * A browser on the project's own Playwright, and the project's own server to point it at — what `plitzi check`,
27
+ * `plitzi shot` and `plitzi import` look at a page with (`import` at a site of the person's, not the project's server).
28
+ *
29
+ * Playwright is the project's: `create` installs it for the visual tests, and a second copy in the CLI would be a
30
+ * second set of browsers to download. It is described here by the little that is used of it, and checked to be that
31
+ * when it loads, so the CLI depends on no driver.
32
+ */
33
+ var SCHEMES = ["light", "dark"];
34
+ var isChromium = (value) => e$1(value) && typeof value.launch === "function";
35
+ /** A property of an object or of a function, which a module's exports can be either of. */
36
+ var propertyOf = (value, key) => (typeof value === "object" || typeof value === "function") && value !== null ? Reflect.get(value, key) : void 0;
37
+ /** The project's `@playwright/test`'s Chromium, launched — or why there is none. */
38
+ var launchBrowser = async (root) => {
39
+ let loaded;
40
+ try {
41
+ const resolved = createRequire(path.join(root, "package.json")).resolve("@playwright/test");
42
+ loaded = await import(pathToFileURL(resolved).href);
43
+ } catch {
44
+ return { problem: "This project has no @playwright/test installed: npm install -D @playwright/test" };
45
+ }
46
+ const chromium = [propertyOf(loaded, "chromium"), propertyOf(propertyOf(loaded, "default"), "chromium")].find(isChromium);
47
+ if (!isChromium(chromium)) return { problem: "The @playwright/test installed here has no Chromium to launch." };
48
+ try {
49
+ return await chromium.launch();
50
+ } catch (error) {
51
+ return { problem: `Chromium did not start (${error instanceof Error ? error.message.split("\n")[0] : String(error)}): npx playwright install chromium` };
52
+ }
53
+ };
54
+ /** What `npm start` wrote down when it took a port: the port, and the name its `/health` answers with. */
55
+ var recorded = async (root) => {
56
+ try {
57
+ const value = JSON.parse(await readFile(path.join(root, ".plitzi/dev-server.json"), "utf8"));
58
+ if (!e$1(value)) return {};
59
+ return {
60
+ ...typeof value.port === "number" ? { port: value.port } : {},
61
+ ...typeof value.name === "string" ? { name: value.name } : {}
62
+ };
63
+ } catch {
64
+ return {};
65
+ }
66
+ };
67
+ /**
68
+ * Where the project's server answers: `PORT` when set, the port `npm start` took otherwise, else the mode's default —
69
+ * and, when `npm start` named itself, that what answers there is this project. Something else on the port answers too,
70
+ * with a page and a `200`, and a picture of the wrong server looks like one of the right one.
71
+ */
72
+ var projectOrigin = async (root, project) => {
73
+ const server = await recorded(root);
74
+ const port = Number(process.env.PORT ?? server.port ?? (project?.mode === "client" ? 5173 : 8080));
75
+ const origin = `http://127.0.0.1:${String(port)}`;
76
+ if (!server.name) return { origin };
77
+ const health = await fetch(`${origin}/health`).then((response) => response.json()).catch(() => null);
78
+ const answered = e$1(health) && typeof health.Server === "string" ? health.Server : "";
79
+ if (answered !== server.name) return { problem: answered ? `Port ${String(port)} answers as "${answered}", not this project ("${server.name}"). Start it: npm start` : `Nothing of this project answers on port ${String(port)}. Start it: npm start` };
80
+ return { origin };
81
+ };
82
+ /** A picture as a data URL, which is how a page in the browser is handed one to read. */
83
+ var dataUrl = (png) => `data:image/png;base64,${Buffer.from(png).toString("base64")}`;
84
+ /** A data URL's picture back as bytes, to write to a file. */
85
+ var fromDataUrl = (url) => Buffer.from(url.slice(url.indexOf(",") + 1), "base64");
86
+ //#endregion
87
+ //#region src/commands/terminal.ts
88
+ /**
89
+ * What every command shares with the person running it: whether anybody is there, how a choice is put to them, and
90
+ * what happens when nobody is.
91
+ */
92
+ /** Why a command stopped, to stderr, and the exit code that says it did. */
93
+ var fail = (problem) => {
94
+ console.error(chalk.red(problem));
95
+ process.exitCode = 1;
96
+ };
97
+ /** Somebody who can answer a prompt. An agent's shell, CI and a pipe are nobody, whatever they were asked to do. */
98
+ var atTerminal = () => process.stdin.isTTY && process.stdout.isTTY;
99
+ /**
100
+ * Nobody to ask, so the questions go to whoever ran this — in practice an agent — to put to the person.
101
+ *
102
+ * It offers no way around them. It used to end with "or with --yes to take the defaults", and an agent keen to finish
103
+ * took that exit every time: the person was never asked, which is the whole failure this stop exists to prevent.
104
+ */
105
+ var refuseWithoutTerminal = (what, questions, stopped = `plitzi create stopped before writing anything: these choices shape the whole ${what}`) => {
106
+ console.error(chalk.red(`\n${stopped}, they belong to the person it is for, and nobody is at this terminal to make them.`));
107
+ console.error(chalk.bold("\nIf you are an AI agent: ask the user each question below, show them the options, and wait for their answers. Do not choose for them and do not fall back to a default.\n"));
108
+ questions.forEach(({ flag, choices, question }, index) => {
109
+ console.error(` ${index + 1}. ${question}`);
110
+ console.error(` ${flag} ${choices.join(" | ")}`);
111
+ });
112
+ const flags = questions.map(({ flag }) => `${flag} <their answer>`).join(" ");
113
+ console.error(`\nThen run the same command again with their answers: ${flags}`);
114
+ process.exitCode = 1;
115
+ };
116
+ /** One choice at the terminal: Enter takes the default, anything off the list is refused rather than guessed at. */
117
+ var ask = async (rl, question) => {
118
+ const reply = (await rl.question(`\n${question.question}\n${question.flag} (${question.choices.join("/")}) [${question.fallback}] > `)).trim();
119
+ const chosen = question.choices.find((choice) => choice === (reply || question.fallback));
120
+ if (!chosen) console.error(chalk.red(`"${reply}" is not one of ${question.choices.join(", ")}.`));
121
+ return chosen;
122
+ };
123
+ /**
124
+ * A free answer at the terminal, with the likely one offered. `check` answers what is wrong with a reply, if anything:
125
+ * a refused reply is said and asked again, since a name or a path is typed rather than picked and a typo is the
126
+ * usual reason for one.
127
+ */
128
+ var askText = async (rl, question, fallback, check = () => void 0) => {
129
+ for (;;) {
130
+ const offered = fallback ? `[${fallback}]` : "(optional)";
131
+ const reply = (await rl.question(`\n${question}\n${offered} > `)).trim() || fallback;
132
+ const problem = check(reply);
133
+ if (!problem) return reply;
134
+ console.error(chalk.red(problem));
135
+ }
136
+ };
137
+ /**
138
+ * One of a numbered list, or something else typed out. Enter takes the first entry. `other` is the question for the
139
+ * typed answer, offered as the last number.
140
+ */
141
+ var askChoice = async (rl, question, options, other) => {
142
+ const menu = [...options.map((option) => option.label), other.label].map((label, index) => ` ${index + 1}. ${label}`).join("\n");
143
+ for (;;) {
144
+ const reply = (await rl.question(`\n${question}\n${menu}\n[1] > `)).trim() || "1";
145
+ const index = Number(reply) - 1;
146
+ if (Number.isInteger(index) && index >= 0 && index < options.length) return options[index].value;
147
+ if (index === options.length) return askText(rl, other.question, other.fallback, other.check);
148
+ console.error(chalk.red(`"${reply}" is not a number from 1 to ${options.length + 1}.`));
149
+ }
150
+ };
151
+ /** One of a numbered list and nothing else. Enter takes the first entry. */
152
+ var askPick = async (rl, question, options) => {
153
+ const menu = options.map(({ label }, index) => ` ${index + 1}. ${label}`).join("\n");
154
+ for (;;) {
155
+ const reply = (await rl.question(`\n${question}\n${menu}\n[1] > `)).trim() || "1";
156
+ const index = Number(reply) - 1;
157
+ if (Number.isInteger(index) && index >= 0 && index < options.length) return options[index].value;
158
+ console.error(chalk.red(`"${reply}" is not a number from 1 to ${options.length}.`));
159
+ }
160
+ };
161
+ /** A directory that does not exist yet is as empty as one can be, which is the answer this question wants. */
162
+ var isEmpty = async (target) => {
163
+ try {
164
+ return (await fs.readdir(target)).length === 0;
165
+ } catch {
166
+ return true;
167
+ }
168
+ };
169
+ var isDirectory$1 = async (candidate) => {
170
+ try {
171
+ return (await fs.stat(candidate)).isDirectory();
172
+ } catch {
173
+ return false;
174
+ }
175
+ };
176
+ /** The closest directory that already exists: the project's own does not until its files are written. */
177
+ var nearestExisting = async (target) => {
178
+ let dir = target;
179
+ while (!await isDirectory$1(dir) && path.dirname(dir) !== dir) dir = path.dirname(dir);
180
+ return dir;
181
+ };
182
+ /** Every file, by the path under `target` it is written to. */
183
+ var writeFiles = async (target, files) => {
184
+ await Promise.all(Object.entries(files).map(async ([file, contents]) => {
185
+ const destination = path.join(target, file);
186
+ await fs.mkdir(path.dirname(destination), { recursive: true });
187
+ return fs.writeFile(destination, contents);
188
+ }));
189
+ };
190
+ /**
191
+ * What a first install is most likely refused over, and what to do about it.
192
+ *
193
+ * The scaffold already exempts what it ships, but not the rest of the tree: a third-party dependency published in
194
+ * the last day is held back by the same age gates, and says so in a code that means nothing to somebody who has
195
+ * not touched the project yet.
196
+ */
197
+ var INSTALL_HINTS = {
198
+ npm: "A peer-dependency conflict (ERESOLVE) is a bug in this scaffold: please report it. `npm install --legacy-peer-deps` gets past it meanwhile.",
199
+ yarn: "YN0016 (\"quarantined\") means a dependency was published less than a day ago. Wait, or add it to `npmPreapprovedPackages` in .yarnrc.yml.",
200
+ pnpm: "A dependency published too recently is added to `minimumReleaseAgeExclude` in pnpm-workspace.yaml; a skipped build script is approved with `pnpm approve-builds`."
201
+ };
202
+ var install = (manager, cwd) => new Promise((resolve) => {
203
+ const child = spawn(manager, ["install"], {
204
+ cwd,
205
+ stdio: "inherit",
206
+ shell: process.platform === "win32"
207
+ });
208
+ child.on("error", () => resolve(false));
209
+ child.on("close", (code) => resolve(code === 0));
210
+ });
211
+ /**
212
+ * One of the project's own scripts, run quietly: what it prints is the project's business, and whether it worked is
213
+ * all this needs. `run` is understood by all three managers alike.
214
+ */
215
+ var runScript = (manager, script, cwd) => new Promise((resolve) => {
216
+ const child = spawn(manager, ["run", script], {
217
+ cwd,
218
+ stdio: "ignore",
219
+ shell: process.platform === "win32"
220
+ });
221
+ child.on("error", () => resolve(false));
222
+ child.on("close", (code) => resolve(code === 0));
223
+ });
224
+ /**
225
+ * How to get to `target` from here, in whichever form is shorter.
226
+ *
227
+ * A relative path out of a temp directory is six `../` and unreadable; an absolute one to a sibling folder is noise.
228
+ * These lines are meant to be pasted, so they are printed the way somebody would have typed them.
229
+ */
230
+ var cdPrefix = (target) => {
231
+ const relative = path.relative(process.cwd(), target);
232
+ return target === process.cwd() ? "" : `cd ${relative.length < target.length ? relative : target} && `;
233
+ };
19
234
  /** `$XDG_CONFIG_HOME/plitzi`, `%APPDATA%\plitzi` on Windows, `~/.config/plitzi` otherwise. */
20
235
  var configDir = () => {
21
236
  if (process.env.XDG_CONFIG_HOME) return path.join(process.env.XDG_CONFIG_HOME, "plitzi");
@@ -395,7 +610,7 @@ var spaceOfGrant = async (api, grant) => {
395
610
  * A new connection, made in the browser, REPLACING whatever connection there was — another space, another account,
396
611
  * another platform. The old grant is revoked rather than left alive in nobody's hands.
397
612
  */
398
- var connect$2 = async (api, { scope, open }) => {
613
+ var connect = async (api, { scope, open }) => {
399
614
  const signedIn = await signInThroughBrowser(api, {
400
615
  scope,
401
616
  open
@@ -518,24 +733,20 @@ var authorizedRequest = async (connection, pathname, init = {}) => {
518
733
  };
519
734
  //#endregion
520
735
  //#region src/commands/account.ts
521
- /** The address to open, printed as well: over SSH, or with no browser to start, it is opened by hand. */
736
+ /**
737
+ * The address to open, printed as well: over SSH, or with no browser to start, it is opened by hand. To stderr, like
738
+ * every prompt on the way to a command's answer, so `--json` output stays only the answer.
739
+ */
522
740
  var openInBrowser = (url) => {
523
- console.log(`\nContinue in your browser. If it did not open, go to:\n ${chalk.cyan(url)}`);
741
+ console.error(`\nContinue in your browser. If it did not open, go to:\n ${chalk.cyan(url)}`);
524
742
  openBrowser(url);
525
743
  };
526
744
  /** The platform this command talks to, or undefined — said — when `--api` or `PLITZI_API_URL` is not an address. */
527
745
  var apiFor = async (options) => {
528
746
  const api = resolveApi(options.api, await readConnection());
529
- if (!api) {
530
- console.error(chalk.red(`"${options.api ?? process.env.PLITZI_API_URL ?? ""}" is not an address.`));
531
- process.exitCode = 1;
532
- }
747
+ if (!api) fail(`"${options.api ?? process.env.PLITZI_API_URL ?? ""}" is not an address.`);
533
748
  return api;
534
749
  };
535
- var fail = (error) => {
536
- console.error(chalk.red(error));
537
- process.exitCode = 1;
538
- };
539
750
  /** Who the connection signs in as, asked of the platform — which also proves the session still works. */
540
751
  var whoIs = async (connection) => {
541
752
  const answered = await authorizedRequest(connection, "/auth/session");
@@ -564,7 +775,7 @@ var login = async (options) => {
564
775
  }
565
776
  }
566
777
  const previous = await readConnection();
567
- const connected = await connect$2(api, { open: openInBrowser });
778
+ const connected = await connect(api, { open: openInBrowser });
568
779
  if (!connected.ok) {
569
780
  fail(connected.error);
570
781
  return;
@@ -595,6 +806,19 @@ var whoami = async (options) => {
595
806
  fail(who.error);
596
807
  return;
597
808
  }
809
+ if (options.json) {
810
+ const { space: chosen } = current.value;
811
+ console.log(JSON.stringify({
812
+ api,
813
+ user: who.value,
814
+ space: chosen ? {
815
+ id: chosen.id,
816
+ name: chosen.name,
817
+ permanentUrl: chosen.permanentUrl
818
+ } : null
819
+ }));
820
+ return;
821
+ }
598
822
  console.log(`\nSigned in to ${api} as ${chalk.bold(who.value)}.`);
599
823
  console.log(`${spaceLine(current.value)}\n`);
600
824
  };
@@ -603,7 +827,7 @@ var whoami = async (options) => {
603
827
  * works in one space at a time, and the space it worked in before is no longer reachable from it.
604
828
  */
605
829
  var chooseSpace = async (api) => {
606
- const connected = await connect$2(api, {
830
+ const connected = await connect(api, {
607
831
  scope: "space",
608
832
  open: openInBrowser
609
833
  });
@@ -635,7 +859,7 @@ var connectionWithSpace = async (api, doing = "to work in") => {
635
859
  return;
636
860
  }
637
861
  if (current.value?.space) return current.value;
638
- console.log(current.value ? `\nChoose the space ${doing}.` : `\nSign in, and choose the space ${doing}.`);
862
+ console.error(current.value ? `\nChoose the space ${doing}.` : `\nSign in, and choose the space ${doing}.`);
639
863
  const chosen = await chooseSpace(api);
640
864
  if (!chosen.ok) {
641
865
  fail(chosen.error);
@@ -651,14 +875,19 @@ var connectionWithSpace = async (api, doing = "to work in") => {
651
875
  var signedIn = async (api, doing) => {
652
876
  const current = await currentConnection(api);
653
877
  if (current.ok && current.value) return current.value;
654
- console.log(`\nSign in ${doing}.`);
655
- const connected = await connect$2(api, { open: openInBrowser });
878
+ console.error(`\nSign in ${doing}.`);
879
+ const connected = await connect(api, { open: openInBrowser });
656
880
  if (!connected.ok) {
657
881
  fail(connected.error);
658
882
  return;
659
883
  }
660
884
  return connected.value;
661
885
  };
886
+ /** The platform the options name, then the connection to its chosen space: how a command that works in it starts. */
887
+ var connectToSpace = async (options, doing) => {
888
+ const api = await apiFor(options);
889
+ return api ? connectionWithSpace(api, doing) : void 0;
890
+ };
662
891
  //#endregion
663
892
  //#region src/scaffold/client.ts
664
893
  /**
@@ -1054,11 +1283,10 @@ to author — and \`YourThing/index.ts\` with an \`export default\`.
1054
1283
 
1055
1284
  ## Three things that bite
1056
1285
 
1057
- **Render \`RootElement\`, not a \`div\`.** It is what makes a plugin an *element*: the id and classes the space
1286
+ **Render \`RootElement\`, not a \`div\`.** It is what makes a plugin an _element_: the id and classes the space
1058
1287
  gave it land on what you render, so the CSS authored on the element applies, the builder can select it, and a test
1059
1288
  can find it by name. A plain tag renders the same pixels and none of that.
1060
1289
 
1061
-
1062
1290
  **Do not render anything that differs between the server and the first client render** — a clock, a random number,
1063
1291
  anything read out of \`window\`. React answers a hydration mismatch by discarding the whole tree, so it does not
1064
1292
  break the plugin, it blanks the page. Put live values in an effect.
@@ -1086,8 +1314,10 @@ ${list.length <= 120 ? list : `export const declarations: PluginDeclarationData[
1086
1314
  `;
1087
1315
  };
1088
1316
  var pluginFiles = (answers) => ({
1089
- "src/plugins/StatCard/StatCard.tsx": component$1(),
1090
- "src/plugins/StatCard/index.ts": barrel(),
1317
+ ...(answers.template ?? "welcome") === "welcome" ? {
1318
+ "src/plugins/StatCard/StatCard.tsx": component$1(),
1319
+ "src/plugins/StatCard/index.ts": barrel()
1320
+ } : {},
1091
1321
  "src/plugins/README.md": readme$2(answers),
1092
1322
  ...answers.source === "local" ? { "src/plugins/declarations.ts": projectDeclarations([]) } : {}
1093
1323
  });
@@ -1232,6 +1462,7 @@ var dependencies = ({ mode, source }) => ({
1232
1462
  /** A server-mode project runs no bundler of its own: the page server builds the plugins, and Node runs the rest. */
1233
1463
  var devDependencies = ({ mode }) => ({
1234
1464
  ...SHARED_DEV_DEPENDENCIES,
1465
+ "@plitzi/cli": SDK_VERSION,
1235
1466
  ...mode === "server" ? {} : { vite: VITE_VERSION }
1236
1467
  });
1237
1468
  /**
@@ -1274,11 +1505,12 @@ var scripts = ({ mode, source }) => ({
1274
1505
  preview: "vite preview"
1275
1506
  },
1276
1507
  ...source === "local" ? { author: "node src/author.ts" } : {},
1277
- typecheck: "tsc -p tsconfig.json --noEmit",
1508
+ typecheck: "tsc -p tsconfig.json --noEmit --pretty false",
1278
1509
  lint: "eslint .",
1279
1510
  format: "prettier --write .",
1280
1511
  visual: "playwright test",
1281
- shot: "node scripts/shot.ts"
1512
+ shot: "plitzi shot",
1513
+ check: "plitzi check"
1282
1514
  });
1283
1515
  /**
1284
1516
  * `extra` is what a project made from a space needs besides (`plitzi create --from`): the packages its plugins and
@@ -1341,7 +1573,6 @@ var tsconfig$1 = ({ mode }) => `${JSON.stringify({
1341
1573
  },
1342
1574
  include: [
1343
1575
  "src",
1344
- "scripts",
1345
1576
  "visual",
1346
1577
  "playwright.config.ts",
1347
1578
  ...mode === "client" ? ["vite.config.ts"] : []
@@ -1353,9 +1584,9 @@ var tsconfig$1 = ({ mode }) => `${JSON.stringify({
1353
1584
  */
1354
1585
  var YARN_IGNORES$1 = "\n.yarn/*\n!.yarn/patches\n!.yarn/plugins\n!.yarn/releases\n!.yarn/versions\n";
1355
1586
  /** Where the page server writes the plugin bundles it builds. A build output, and rebuilt whenever it is missing. */
1356
- var SERVER_IGNORES = ".sdk-plugins\n";
1587
+ var SERVER_IGNORES = ".sdk-plugins\n.plitzi\n";
1357
1588
  var gitignore$1 = ({ mode, packageManager }) => `node_modules\ndist\n.env\nvisual/.results\nvisual/screenshots\n${mode === "server" ? SERVER_IGNORES : ""}${packageManager === "yarn" ? YARN_IGNORES$1 : ""}`;
1358
- var startLine = ({ mode, packageManager }) => mode === "server" ? `\`${runCommand(packageManager, "start")}\` serves pages on http://127.0.0.1:8080. \`${runCommand(packageManager, "start:dev")}\` restarts on save. In production, \`${runCommand(packageManager, "build")}\` once and run \`${runCommand(packageManager, "start:prod")}\` with \`NODE_ENV=production\`: the compiled server, with no TypeScript in the process.` : `\`${runCommand(packageManager, "start")}\` runs Vite on http://127.0.0.1:5173, with hot module replacement.`;
1589
+ var startLine = ({ mode, packageManager }) => mode === "server" ? `\`${runCommand(packageManager, "start")}\` serves pages on http://127.0.0.1:8080 — or, when something else holds 8080, the next free port, which it prints and writes to \`.plitzi/dev-server.json\` (set \`PORT\` to choose one). \`${runCommand(packageManager, "start:dev")}\` restarts on save. In production, \`${runCommand(packageManager, "build")}\` once and run \`${runCommand(packageManager, "start:prod")}\` with \`NODE_ENV=production\`: the compiled server, with no TypeScript in the process.` : `\`${runCommand(packageManager, "start")}\` runs Vite on http://127.0.0.1:5173, with hot module replacement.`;
1359
1590
  var spaceSection = (answers) => {
1360
1591
  if (answers.source === "cloud") return `## Where the space comes from
1361
1592
 
@@ -1413,10 +1644,14 @@ var agentsFile = (answers) => {
1413
1644
  const commands = [
1414
1645
  `| ${code(installCommand(answers.packageManager))} | install |`,
1415
1646
  `| ${run("start")} | serve it |`,
1416
- ...local ? [`| ${run("author")} | author the space and print its warnings |`] : [],
1647
+ ...local ? [`| ${run("author")} | author the space and print its warnings and suggestions |`] : [],
1417
1648
  `| ${run("visual")} | open the page in a browser and check it rendered |`
1418
1649
  ];
1419
1650
  const zeroWarnings = local ? `Zero warnings from ${run("author")}.` : "Zero warnings from authoring.";
1651
+ const port = answers.mode === "server" ? `${run("start")} serves on 8080, or on the next free port when something else holds it — printed, and written to ${code(".plitzi/dev-server.json")}, where ${code("check")}, ${code("shot")} and ${code("visual")} read it. ${code("PORT")} chooses one.` : `${run("start")} runs Vite on 5173.`;
1652
+ const pictures = answers.mode === "server" ? `- **Pictures from other sites** are resized by this server once ${code("src/main.ts")} names their hosts — ${code("images: { domains }")} in ${code("createServer")}, a list of hosts like ${code("images.example.com")} — and ${code("sharp")} is installed: an ${code("image")} then offers a ${code("srcset")} (give it ${code("sizes")}, and ${code("width")}/${code("height")} so nothing jumps).\n` : "";
1653
+ const serverData = answers.mode === "server" ? `; one whose ${code("runtime")} is ${code("server")} is read by the server, and the page arrives with it` : "";
1654
+ const generated = [...local ? [`- ${code("space/offline-data.json")} — what ${run("author")} writes out; the space is ${code("src/space.ts")}.`] : [], ...answers.mode === "server" ? [`- ${code(".sdk-plugins/")} and ${code(".plitzi/")} — built and written by the server.`] : []];
1420
1655
  return `# ${answers.name} — notes for agents
1421
1656
 
1422
1657
  A Plitzi space, rendered ${answers.mode === "server" ? "by the server in this project (SSR + RSC)" : "in the browser by a Vite app"}.
@@ -1428,6 +1663,21 @@ ${where}
1428
1663
  | --- | --- |
1429
1664
  ${commands.join("\n")}
1430
1665
 
1666
+ ## This project
1667
+
1668
+ - **Port.** ${port}
1669
+ - **Data with no backend** goes in ${code("public/data/*.json")}, served as it is and read by an ${code("apiContainer")} whose ${code("query")} is ${code("/data/products.json")}${serverData}.
1670
+ ${pictures}- **Check a page in text first:** ${code(`${runCommand(answers.packageManager, "check")} -- / --width 1440,390`)} says whether every element is on screen, nothing overflows and the console is clean — a picture only when it says something is wrong: ${code(`${runCommand(answers.packageManager, "shot")} -- / --width 390`)} (add ${code("--scheme dark")}; ${code("--frames 4")} to see what moves; ${code("--compare <url>")} against another site, by section). ${run("visual")} runs the checks as tests.
1671
+ - **What the page holds, in text:** ${code(`${runCommand(answers.packageManager, "check")} -- /products --state --element <id>`)} adds its state, every source by name and one element (what it reads, its own state, whether it is on screen); every check already lists the flows that failed. Read it instead of guessing from classes in the DOM.
1672
+
1673
+ ## Do not read
1674
+
1675
+ ${[
1676
+ ...generated,
1677
+ `- A large ${code("public/data/*.json")} — ${code("npx plitzi data describe public/data/<file>.json")} prints its fields, their types and one row.`,
1678
+ `- The bundles in ${code("node_modules/@plitzi/*/dist/*.js")}. ${code("npx plitzi explain <name>")} says what an element, a step or a problem's code is; the ${code(".d.ts")} beside them documents the rest — search it, never read it whole.`
1679
+ ].join("\n")}
1680
+
1431
1681
  ## Before anything else
1432
1682
 
1433
1683
  Read ${code(".claude/skills/plitzi-authoring/SKILL.md")} — how a space is written, and the references it links to for
@@ -1437,10 +1687,12 @@ ${code(".claude/skills/plitzi-cli/SKILL.md")} first: ${code("plitzi add plugin")
1437
1687
 
1438
1688
  ## The rules that go wrong most
1439
1689
 
1440
- - Never write schema/style JSON by hand; author it. A refusal names the fix — fix the declaration.
1690
+ - Never write schema/style JSON by hand; author it. A refusal names the fix — fix the declaration; ${code("npx plitzi fix --write")} writes the ones with a single reading.
1441
1691
  - ${zeroWarnings}
1442
1692
  - Chrome shared by pages is a layout; a look used twice is a class; a repeated block is a function or a ${code("map")}.
1443
- - Ids are one namespace for the whole space: name what is referred to, prefix ids made by a helper.
1693
+ - Ids are one namespace for the whole space: name what is referred to; a helper that runs more than once builds inside ${code("scope()")}.
1694
+ - A file per part — the tokens, the layout, each component, each page — short enough to read whole; ${code("src/space.ts")} assembles them. ${code("npx plitzi create <dir> --template catalog")} is a complete example of the shape.
1695
+ - Rebuilding a page the user owns: ${code("npx plitzi import <url>")} writes its tokens, outline and lists as a start — then split it into parts and write the content. It reads only a site whose domain the user verified on one of their spaces.
1444
1696
  - Elements are visible by default. One the logic REVEALS starts hidden (${code("visible")}, or ${code("visible: false")} plus a computed binding) so nothing flashes while loading; one a flag HIDES stays shown while the flag is unset.
1445
1697
  - Inside a template a source is spelled in full (${code("apiContainer_stats")}); an attribute only resolves ${code("{{ name|filter }}")}.
1446
1698
  - Colours are tokens with light and dark values; times carry an explicit zone and say it.
@@ -1512,7 +1764,16 @@ var PROJECT_OUTPUTS = [
1512
1764
  "visual/.results",
1513
1765
  "visual/screenshots"
1514
1766
  ];
1515
- var prettierignore = (outputs) => `${["node_modules", ...outputs.filter((output) => output !== ".sdk-plugins")].join("\n")}\n`;
1767
+ /**
1768
+ * What is the agents' and not the project's: the skills copied in, whose recipes are TypeScript no program of the
1769
+ * project includes — linted with types, they would stop the lint before its first file.
1770
+ */
1771
+ var AGENT_FILES = ".claude";
1772
+ var prettierignore = (outputs) => `${[
1773
+ "node_modules",
1774
+ AGENT_FILES,
1775
+ ...outputs.filter((output) => output !== ".sdk-plugins")
1776
+ ].join("\n")}\n`;
1516
1777
  /**
1517
1778
  * Flat config, and only what earns its place.
1518
1779
  *
@@ -1528,7 +1789,7 @@ import tsEslint from 'typescript-eslint';
1528
1789
  import eslintConfigPrettier from 'eslint-config-prettier';
1529
1790
 
1530
1791
  export default tsEslint.config(
1531
- { ignores: [${outputs.map((output) => `'${output}'`).join(", ")}] },
1792
+ { ignores: [${[AGENT_FILES, ...outputs].map((output) => `'${output}'`).join(", ")}] },
1532
1793
  js.configs.recommended,
1533
1794
  {
1534
1795
  /**
@@ -1608,19 +1869,44 @@ const plugins = Object.fromEntries(
1608
1869
  * no error anywhere, which is a long afternoon.
1609
1870
  */
1610
1871
  const pluginNames = Object.keys(plugins);`;
1611
- var localMain = () => `import { readdirSync } from 'node:fs';
1872
+ /**
1873
+ * Which port the server takes. `PORT` set: that one, and a clear error if something else has it. Not set, while
1874
+ * developing: 8080, or the next free one — so a second project, or anything else on 8080, does not stop this one.
1875
+ */
1876
+ var PORT_SNIPPET = `// Loopback unless told otherwise: a container publishes a port only from an address it listens on (\`HOST=0.0.0.0\`).
1877
+ const HOST = process.env.HOST ?? '127.0.0.1';
1878
+ // \`PORT\` set: that port. Not set, while developing: 8080 or the next free one, written down below for the scripts.
1879
+ const PORT = process.env.PORT
1880
+ ? Number(process.env.PORT)
1881
+ : process.env.NODE_ENV === 'production'
1882
+ ? 8080
1883
+ : await freePort(8080, HOST);`;
1884
+ /** Where the server says it is: the port it took, for `npm run shot` and the visual tests to find. */
1885
+ var LISTEN_SNIPPET = `server.listen(PORT, HOST);
1886
+ mkdirSync(path.join(PROJECT_ROOT, '.plitzi'), { recursive: true });
1887
+ writeFileSync(
1888
+ path.join(PROJECT_ROOT, '.plitzi/dev-server.json'),
1889
+ \`\${JSON.stringify({ name: SERVER_NAME, port: PORT, url: \`http://127.0.0.1:\${PORT}\` }, null, 2)}\\n\`
1890
+ );
1891
+ console.log(\`pages on http://127.0.0.1:\${PORT}/\`);`;
1892
+ var localMain = () => `import { mkdirSync, readdirSync, writeFileSync } from 'node:fs';
1612
1893
  import path from 'node:path';
1613
1894
 
1614
- import { closeOnSignals, consoleLogger, createJsonAdapters, createServer, loadFunctions } from '@plitzi/sdk-server';
1895
+ import {
1896
+ closeOnSignals,
1897
+ consoleLogger,
1898
+ createJsonAdapters,
1899
+ createServer,
1900
+ freePort,
1901
+ loadFunctions
1902
+ } from '@plitzi/sdk-server';
1615
1903
 
1616
1904
  import { authorSpace } from '@plitzi/sdk-authoring';
1617
1905
 
1618
1906
  import { declarations } from './plugins/declarations.ts';
1619
1907
  import { space } from './space.ts';
1620
1908
 
1621
- const PORT = Number(process.env.PORT ?? 8080);
1622
- // Loopback unless told otherwise: a container publishes a port only from an address it listens on (\`HOST=0.0.0.0\`).
1623
- const HOST = process.env.HOST ?? '127.0.0.1';
1909
+ ${PORT_SNIPPET}
1624
1910
 
1625
1911
  /**
1626
1912
  * The space, held in this project.
@@ -1653,20 +1939,26 @@ const functions = await loadFunctions(new URL('../functions/', import.meta.url))
1653
1939
  * page server makes. A real deployment swaps this for adapters onto its own database, or for
1654
1940
  * \`createCloudAdapters\` to read the live space out of Plitzi — the server never learns the difference.
1655
1941
  */
1942
+ // What \`/health\` answers with, and \`.plitzi/dev-server.json\` records: how a tool knows it reached THIS project.
1943
+ const SERVER_NAME = schema.definition.permanentUrl;
1656
1944
  const server = createServer({
1657
1945
  port: PORT,
1658
1946
  devMode: process.env.NODE_ENV !== 'production',
1947
+ health: { name: SERVER_NAME },
1659
1948
  adapters: createJsonAdapters({
1660
1949
  offlineData,
1661
1950
  deployment: { spaceId: 1, environment: 'main', revision: 0, pluginNames }
1662
1951
  }),
1663
1952
  plugins,
1953
+ // \`public/\` served as it is: the data an apiContainer reads (\`/data/home.json\`), images, a favicon.
1954
+ publicDir: path.join(PROJECT_ROOT, 'public'),
1664
1955
  functions: { native: functions },
1956
+ // What went wrong and nothing else: \`npm start -- --verbose\` adds a line for every request.
1957
+ logLevel: process.argv.includes('--verbose') ? 'info' : 'warn',
1665
1958
  logger: consoleLogger
1666
1959
  });
1667
1960
 
1668
- server.listen(PORT, HOST);
1669
- console.log(\`pages on http://127.0.0.1:\${PORT}/\`);
1961
+ ${LISTEN_SNIPPET}
1670
1962
 
1671
1963
  /**
1672
1964
  * A deploy, a restart or ^C closes the server instead of dropping it: requests in flight are answered, and once the
@@ -1675,14 +1967,19 @@ console.log(\`pages on http://127.0.0.1:\${PORT}/\`);
1675
1967
  */
1676
1968
  closeOnSignals(server);
1677
1969
  `;
1678
- var cloudMain = () => `import { readdirSync } from 'node:fs';
1970
+ var cloudMain = (name) => `import { mkdirSync, readdirSync, writeFileSync } from 'node:fs';
1679
1971
  import path from 'node:path';
1680
1972
 
1681
- import { closeOnSignals, consoleLogger, createCloudAdapters, createServer, loadFunctions } from '@plitzi/sdk-server';
1973
+ import {
1974
+ closeOnSignals,
1975
+ consoleLogger,
1976
+ createCloudAdapters,
1977
+ createServer,
1978
+ freePort,
1979
+ loadFunctions
1980
+ } from '@plitzi/sdk-server';
1682
1981
 
1683
- const PORT = Number(process.env.PORT ?? 8080);
1684
- // Loopback unless told otherwise: a container publishes a port only from an address it listens on (\`HOST=0.0.0.0\`).
1685
- const HOST = process.env.HOST ?? '127.0.0.1';
1982
+ ${PORT_SNIPPET}
1686
1983
 
1687
1984
  ${PLUGINS}
1688
1985
 
@@ -1718,9 +2015,12 @@ const functions = await loadFunctions(new URL('../functions/', import.meta.url))
1718
2015
  * on every request; a published environment with no \`revision\` serves the latest and releases itself; with a
1719
2016
  * \`revision\` it serves exactly that version, for a deployment that rolls forward on its own schedule.
1720
2017
  */
2018
+ // What \`/health\` answers with, and \`.plitzi/dev-server.json\` records: how a tool knows it reached THIS project.
2019
+ const SERVER_NAME = ${JSON.stringify(name)};
1721
2020
  const server = createServer({
1722
2021
  port: PORT,
1723
2022
  devMode: process.env.NODE_ENV !== 'production',
2023
+ health: { name: SERVER_NAME },
1724
2024
  adapters: createCloudAdapters({
1725
2025
  webKey: HOST_KEY,
1726
2026
  ...(process.env.PLITZI_SERVER_URL ? { serverUrl: process.env.PLITZI_SERVER_URL } : {}),
@@ -1729,12 +2029,15 @@ const server = createServer({
1729
2029
  deployment: { pluginNames }
1730
2030
  }),
1731
2031
  plugins,
2032
+ // \`public/\` served as it is: the data an apiContainer reads (\`/data/home.json\`), images, a favicon.
2033
+ publicDir: path.join(PROJECT_ROOT, 'public'),
1732
2034
  functions: { native: functions },
2035
+ // What went wrong and nothing else: \`npm start -- --verbose\` adds a line for every request.
2036
+ logLevel: process.argv.includes('--verbose') ? 'info' : 'warn',
1733
2037
  logger: consoleLogger
1734
2038
  });
1735
2039
 
1736
- server.listen(PORT, HOST);
1737
- console.log(\`pages on http://127.0.0.1:\${PORT}/\`);
2040
+ ${LISTEN_SNIPPET}
1738
2041
 
1739
2042
  /**
1740
2043
  * A deploy, a restart or ^C closes the server instead of dropping it: requests in flight are answered, and once the
@@ -1743,7 +2046,10 @@ console.log(\`pages on http://127.0.0.1:\${PORT}/\`);
1743
2046
  */
1744
2047
  closeOnSignals(server);
1745
2048
  `;
1746
- var serverFiles = (answers) => ({ "src/main.ts": answers.source === "cloud" ? cloudMain() : localMain() });
2049
+ var serverFiles = (answers) => ({
2050
+ "src/main.ts": answers.source === "cloud" ? cloudMain(answers.name) : localMain(),
2051
+ "public/data/.gitkeep": ""
2052
+ });
1747
2053
  //#endregion
1748
2054
  //#region src/scaffold/skills.ts
1749
2055
  /**
@@ -1759,19 +2065,43 @@ var serverFiles = (answers) => ({ "src/main.ts": answers.source === "cloud" ? cl
1759
2065
  * other one Plitzi ships, and it is deliberately left out: it drives the `plitzi_render` MCP tool, so in a project with
1760
2066
  * no MCP connection it would be an instruction to use something that is not there.
1761
2067
  */
1762
- var require = createRequire(import.meta.url);
2068
+ var cliRequire = createRequire(import.meta.url);
2069
+ var SKILL_NAMES = ["plitzi-authoring", "plitzi-cli"];
2070
+ /** The package each skill is published in, and copied from. */
1763
2071
  var SKILLS = {
1764
2072
  "plitzi-authoring": "@plitzi/sdk-authoring",
1765
2073
  "plitzi-cli": "@plitzi/cli"
1766
2074
  };
1767
2075
  /**
2076
+ * How a package is found: the project's own install first — the SDK it renders with is the one its skill has to match —
2077
+ * then this CLI's. `@plitzi/cli` is usually run with npx and installed nowhere in the project, so it falls through.
2078
+ */
2079
+ var resolversFor = (root) => root ? [createRequire(join(root, "package.json")), cliRequire] : [cliRequire];
2080
+ /**
1768
2081
  * Where a skill file sits, resolved through the package's own export map rather than guessed from a path.
1769
2082
  *
1770
2083
  * `<package>/skills/*` is an export of the packages that ship one, which is what makes this work under Yarn PnP —
1771
2084
  * there is no `node_modules` directory to walk there, and a file the package does not export is unreachable
1772
2085
  * however present it is on disk.
1773
2086
  */
1774
- var skillPath = (packageName, skill) => require.resolve(`${packageName}/skills/${skill}/SKILL.md`);
2087
+ var resolveFrom = (resolvers, request) => {
2088
+ for (const resolver of resolvers) try {
2089
+ return resolver.resolve(request);
2090
+ } catch {
2091
+ continue;
2092
+ }
2093
+ };
2094
+ /** The version of the package a skill came from, written into its frontmatter — `version: 0.38.0`. */
2095
+ var versionOf = (resolvers, packageName) => {
2096
+ const manifest = resolveFrom(resolvers, `${packageName}/package.json`);
2097
+ if (!manifest) return;
2098
+ const parsed = JSON.parse(readFileSync(manifest, "utf-8"));
2099
+ return e$1(parsed) && typeof parsed.version === "string" ? parsed.version : void 0;
2100
+ };
2101
+ /** The frontmatter's `version:`, said once: after the skill's `name:`, replacing any already there. */
2102
+ var stampVersion = (skill, version) => skill.replace(/^version: .*\n/m, "").replace(/^(name: .*)$/m, `$1\nversion: ${version}`);
2103
+ /** The version a copied `SKILL.md` says it came from. */
2104
+ var skillVersion = (skill) => /^version: (.+)$/m.exec(skill)?.[1]?.trim();
1775
2105
  /**
1776
2106
  * Every file under a skill's folder, by its path inside it.
1777
2107
  *
@@ -1779,19 +2109,23 @@ var skillPath = (packageName, skill) => require.resolve(`${packageName}/skills/$
1779
2109
  * sit beside it and are opened when the task needs them. Copying \`SKILL.md\` alone left every one of those links
1780
2110
  * pointing at nothing.
1781
2111
  */
1782
- var filesUnder = (root, folder = root) => readdirSync(folder, { withFileTypes: true }).flatMap((entry) => {
2112
+ var filesUnder$1 = (root, folder = root) => readdirSync(folder, { withFileTypes: true }).flatMap((entry) => {
1783
2113
  const path = join(folder, entry.name);
1784
- if (entry.isDirectory()) return filesUnder(root, path);
2114
+ if (entry.isDirectory()) return filesUnder$1(root, path);
1785
2115
  return entry.isFile() ? [[relative(root, path).split(sep).join("/"), readFileSync(path, "utf-8")]] : [];
1786
2116
  });
1787
- /** The skills a project carries: both for a project that renders a space, the CLI's alone for a plugin package. */
1788
- var skillFiles = (names = ["plitzi-authoring", "plitzi-cli"]) => {
2117
+ /**
2118
+ * The skills a project carries: both for a project that renders a space, the CLI's alone for a plugin package. With
2119
+ * `root`, from the packages that project installed — what `plitzi skills update` brings it up to.
2120
+ */
2121
+ var skillFiles = (names = SKILL_NAMES, root) => {
2122
+ const resolvers = resolversFor(root);
1789
2123
  const files = {};
1790
- for (const name of names) try {
1791
- const root = dirname(skillPath(SKILLS[name], name));
1792
- for (const [path, content] of filesUnder(root)) files[`.claude/skills/${name}/${path}`] = content;
1793
- } catch {
1794
- continue;
2124
+ for (const name of names) {
2125
+ const entry = resolveFrom(resolvers, `${SKILLS[name]}/skills/${name}/SKILL.md`);
2126
+ if (!entry) continue;
2127
+ const version = versionOf(resolvers, SKILLS[name]);
2128
+ for (const [path, content] of filesUnder$1(dirname(entry))) files[`.claude/skills/${name}/${path}`] = path === "SKILL.md" && version ? stampVersion(content, version) : content;
1795
2129
  }
1796
2130
  return files;
1797
2131
  };
@@ -1815,23 +2149,105 @@ var skillFiles = (names = ["plitzi-authoring", "plitzi-cli"]) => {
1815
2149
  * the space has to go somewhere else: imported into Plitzi, handed to another server, or checked into a
1816
2150
  * repository with no TypeScript in it.
1817
2151
  */
1818
- var authorScript = () => `import { mkdirSync, writeFileSync } from 'node:fs';
2152
+ var authorScript = () => `import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2153
+ import { createRequire } from 'node:module';
1819
2154
 
1820
- import { authorSpace } from '@plitzi/sdk-authoring';
2155
+ import { authorSpace, planFixes, refusalOf, SpaceRefusedError } from '@plitzi/sdk-authoring';
1821
2156
 
1822
2157
  import { declarations } from './plugins/declarations.ts';
1823
2158
  import { space } from './space.ts';
1824
2159
 
1825
- const { schema, style, warnings } = authorSpace(space, { plugins: declarations });
2160
+ // \`--json\` answers in one object, for a tool or an agent; otherwise one line on success, the problems on failure.
2161
+ const json = process.argv.includes('--json');
1826
2162
 
1827
- mkdirSync('space', { recursive: true });
1828
- writeFileSync('space/offline-data.json', \`\${JSON.stringify({ schema, style }, null, 2)}\\n\`);
2163
+ /** How many of the problems said have one fix \`plitzi fix\` can write in this source — and the line that says so. */
2164
+ const fixableHint = (): string | undefined => {
2165
+ let count: number;
2166
+ try {
2167
+ count = planFixes(space, { plugins: declarations }).fixes.length;
2168
+ } catch {
2169
+ return undefined;
2170
+ }
1829
2171
 
1830
- for (const warning of warnings) {
1831
- console.warn(\`[author] \${warning.message}\`);
1832
- }
2172
+ return count === 0 ? undefined : \`[fix] \${count} of these have one fix: npx plitzi fix shows it in your source, --write writes it\`;
2173
+ };
1833
2174
 
1834
- console.log('space/offline-data.json');
2175
+ /**
2176
+ * The skill an agent reads was copied from the SDK at one version: a newer SDK has what the older skill never taught.
2177
+ * Both versions, when they differ — \`npx @plitzi/cli skills update\` brings the skill up to the SDK.
2178
+ */
2179
+ const outdatedSkill = (): { skill: string; sdk: string } | undefined => {
2180
+ let skill: string | undefined;
2181
+ try {
2182
+ skill = /^version: (.+)$/m.exec(readFileSync('.claude/skills/plitzi-authoring/SKILL.md', 'utf-8'))?.[1]?.trim();
2183
+ } catch {
2184
+ return undefined;
2185
+ }
2186
+
2187
+ const manifest: unknown = JSON.parse(
2188
+ readFileSync(createRequire(import.meta.url).resolve('@plitzi/sdk-authoring/package.json'), 'utf-8')
2189
+ );
2190
+ const sdk =
2191
+ typeof manifest === 'object' && manifest !== null && 'version' in manifest && typeof manifest.version === 'string'
2192
+ ? manifest.version
2193
+ : undefined;
2194
+
2195
+ return skill && sdk && skill !== sdk ? { skill, sdk } : undefined;
2196
+ };
2197
+
2198
+ try {
2199
+ const { schema, style, warnings, suggestions } = authorSpace(space, { plugins: declarations });
2200
+ mkdirSync('space', { recursive: true });
2201
+ writeFileSync('space/offline-data.json', \`\${JSON.stringify({ schema, style }, null, 2)}\\n\`);
2202
+
2203
+ const outdated = outdatedSkill();
2204
+ if (json) {
2205
+ console.log(
2206
+ JSON.stringify({ ok: true, pages: schema.pages.length, warnings, suggestions, ...(outdated ? { outdated } : {}) })
2207
+ );
2208
+ } else {
2209
+ for (const warning of warnings) {
2210
+ console.warn(\`[author] \${warning.code} · \${warning.message}\`);
2211
+ }
2212
+
2213
+ const hint = warnings.length > 0 ? fixableHint() : undefined;
2214
+ if (hint) {
2215
+ console.warn(hint);
2216
+ }
2217
+
2218
+ // Not problems: a shorter way to the same page, the one that saves the most elements first.
2219
+ for (const suggestion of suggestions) {
2220
+ const at = suggestion.at ? \` · \${suggestion.at}\` : '';
2221
+ console.warn(\`[suggest] \${suggestion.code} · \${suggestion.message} (saves \${suggestion.saves})\${at}\`);
2222
+ }
2223
+
2224
+ if (outdated) {
2225
+ console.warn(
2226
+ \`[skills] the authoring skill is \${outdated.skill} and @plitzi/sdk-authoring is \${outdated.sdk}: npx @plitzi/cli skills update\`
2227
+ );
2228
+ }
2229
+
2230
+ console.log(
2231
+ \`ok · \${schema.pages.length} pages · \${warnings.length} warnings · \${suggestions.length} suggestions · space/offline-data.json\`
2232
+ );
2233
+ }
2234
+ } catch (error) {
2235
+ // The message is the whole report — every problem, where it was written and what to change. The stack would only
2236
+ // point inside @plitzi/sdk-authoring.
2237
+ const refusals = error instanceof SpaceRefusedError ? error.refusals : undefined;
2238
+ const message = error instanceof Error ? error.message : String(error);
2239
+ if (json) {
2240
+ console.log(JSON.stringify({ ok: false, refusals: refusals ?? [{ place: '', ...refusalOf(error) }] }));
2241
+ } else {
2242
+ console.error(message);
2243
+ const hint = fixableHint();
2244
+ if (hint) {
2245
+ console.error(hint);
2246
+ }
2247
+ }
2248
+
2249
+ process.exitCode = 1;
2250
+ }
1835
2251
  `;
1836
2252
  /** The numbers the example shows: in a data file where one is served, on the element where none is. */
1837
2253
  var STATS = {
@@ -1879,13 +2295,25 @@ var pluginHost = ({ mode }) => mode === "client" ? {
1879
2295
  ...STATS
1880
2296
  }
1881
2297
  };
1882
- var spaceFiles = (answers) => answers.source === "cloud" ? {} : {
1883
- "src/space.ts": blankSpaceSource({
1884
- name: answers.name,
1885
- plugin: pluginHost(answers)
1886
- }),
1887
- "src/author.ts": authorScript(),
1888
- ...answers.mode === "client" ? { "public/data/stats.json": `${JSON.stringify(STATS, null, 2)}\n` } : {}
2298
+ var spaceFiles = (answers) => {
2299
+ if (answers.source === "cloud") return {};
2300
+ if (answers.template === "catalog") return {
2301
+ ...catalogTemplateFiles({ name: answers.name }),
2302
+ "src/author.ts": authorScript()
2303
+ };
2304
+ if (answers.template === "blank") return {
2305
+ "src/space.ts": emptySpaceSource({ name: answers.name }),
2306
+ "src/author.ts": authorScript(),
2307
+ "public/data/.gitkeep": ""
2308
+ };
2309
+ return {
2310
+ "src/space.ts": blankSpaceSource({
2311
+ name: answers.name,
2312
+ plugin: pluginHost(answers)
2313
+ }),
2314
+ "src/author.ts": authorScript(),
2315
+ ...answers.mode === "client" ? { "public/data/stats.json": `${JSON.stringify(STATS, null, 2)}\n` } : {}
2316
+ };
1889
2317
  };
1890
2318
  //#endregion
1891
2319
  //#region src/scaffold/visual.ts
@@ -1896,9 +2324,30 @@ var spaceFiles = (answers) => answers.source === "cloud" ? {} : {
1896
2324
  * a space renders through a stack — schema, style, plugins, hydration — where a mistake shows up as a blank page
1897
2325
  * rather than as an exception, and nobody writes the first test for a project that already looks fine.
1898
2326
  */
1899
- var playwrightConfig$1 = ({ mode, packageManager }) => `import { defineConfig } from '@playwright/test';
1900
-
1901
- const PORT = ${mode === "server" ? "8080" : "5173"};
2327
+ var RECORDED = `/** What \`npm start\` wrote down when it took a port: the port, and the name its \`/health\` answers with. */
2328
+ const recorded = (): { port?: number; name?: string } => {
2329
+ try {
2330
+ const value: unknown = JSON.parse(readFileSync('.plitzi/dev-server.json', 'utf8'));
2331
+ if (typeof value !== 'object' || value === null) {
2332
+ return {};
2333
+ }
2334
+
2335
+ return {
2336
+ ...('port' in value && typeof value.port === 'number' ? { port: value.port } : {}),
2337
+ ...('name' in value && typeof value.name === 'string' ? { name: value.name } : {})
2338
+ };
2339
+ } catch {
2340
+ return {};
2341
+ }
2342
+ };`;
2343
+ var playwrightConfig$1 = ({ mode, packageManager }) => `import { readFileSync } from 'node:fs';
2344
+
2345
+ import { defineConfig } from '@playwright/test';
2346
+
2347
+ ${RECORDED}
2348
+
2349
+ // \`PORT\` when set; otherwise the port \`npm start\` took; otherwise the default.
2350
+ const PORT = Number(process.env.PORT ?? recorded().port ?? ${mode === "server" ? "8080" : "5173"});
1902
2351
 
1903
2352
  export default defineConfig({
1904
2353
  testDir: './visual',
@@ -1909,6 +2358,7 @@ export default defineConfig({
1909
2358
  webServer: {
1910
2359
  command: '${runCommand(packageManager, "start")}',
1911
2360
  url: \`http://127.0.0.1:\${PORT}\`,
2361
+ env: { PORT: String(PORT) },
1912
2362
  reuseExistingServer: true,
1913
2363
  timeout: 120_000
1914
2364
  }
@@ -1974,53 +2424,8 @@ test('renders the space without errors', async ({ page }) => {
1974
2424
  expect(errors).toEqual([]);
1975
2425
  });
1976
2426
  `;
1977
- var shotScript = ({ mode }) => `import { mkdir } from 'node:fs/promises';
1978
- import { dirname } from 'node:path';
1979
-
1980
- import { chromium } from '@playwright/test';
1981
-
1982
- /**
1983
- * A screenshot of one page, to look at a change without writing a test for it.
1984
- *
1985
- * npm run shot -- /about --width 390 --scheme dark
1986
- * npm run shot -- / --out shots/home.png --height 900
1987
- *
1988
- * The whole page, not one screen of it. The dev server has to be running (npm start).
1989
- */
1990
- const PORT = ${mode === "server" ? "8080" : "5173"};
1991
-
1992
- const args = process.argv.slice(2);
1993
- const option = (name: string, fallback: string): string => {
1994
- const index = args.indexOf('--' + name);
1995
-
1996
- return index === -1 ? fallback : (args[index + 1] ?? fallback);
1997
- };
1998
-
1999
- const path = args.find(arg => arg.startsWith('/')) ?? '/';
2000
- const width = Number(option('width', '1280'));
2001
- const height = Number(option('height', '800'));
2002
- const scheme = option('scheme', 'light') === 'dark' ? 'dark' : 'light';
2003
- const name = path === '/' ? 'home' : path.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '');
2004
- const out = option('out', 'visual/.shots/' + name + '-' + width + '-' + scheme + '.png');
2005
-
2006
- const browser = await chromium.launch();
2007
- try {
2008
- const page = await browser.newPage({ viewport: { width, height }, colorScheme: scheme });
2009
- const response = await page.goto('http://127.0.0.1:' + PORT + path, { waitUntil: 'networkidle' }).catch(() => null);
2010
- if (!response) {
2011
- throw new Error('Nothing answers on port ' + PORT + '. Start the project first: npm start');
2012
- }
2013
-
2014
- await mkdir(dirname(out), { recursive: true });
2015
- await page.screenshot({ path: out, fullPage: true });
2016
- console.log(out);
2017
- } finally {
2018
- await browser.close();
2019
- }
2020
- `;
2021
2427
  var visualFiles$1 = (answers) => ({
2022
2428
  "playwright.config.ts": playwrightConfig$1(answers),
2023
- "scripts/shot.ts": shotScript(answers),
2024
2429
  "visual/home.spec.ts": answers.source === "local" ? authoredSpec() : documentSpec()
2025
2430
  });
2026
2431
  //#endregion
@@ -2251,7 +2656,7 @@ var packageJson = ({ packageName, base }, { packageManager, owner }) => `${JSON.
2251
2656
  },
2252
2657
  scripts: {
2253
2658
  start: "vite",
2254
- typecheck: "tsc -p tsconfig.json --noEmit",
2659
+ typecheck: "tsc -p tsconfig.json --noEmit --pretty false",
2255
2660
  lint: "eslint .",
2256
2661
  format: "prettier --write .",
2257
2662
  visual: "playwright test"
@@ -2435,6 +2840,497 @@ var previewFiles = ([main, ...others]) => ({
2435
2840
  })
2436
2841
  });
2437
2842
  //#endregion
2843
+ //#region ../../packages/sdk-shared/dist/helpers/scroll.mjs
2844
+ var e = ["smooth", "auto"];
2845
+ var t = [
2846
+ "start",
2847
+ "center",
2848
+ "end",
2849
+ "nearest"
2850
+ ];
2851
+ //#endregion
2852
+ //#region ../../packages/sdk-shared/dist/authoring/elementCallbacks.mjs
2853
+ var r$2 = {
2854
+ type: "select",
2855
+ description: "How it moves: \"smooth\" glides there, \"auto\" jumps.",
2856
+ default: "smooth",
2857
+ options: [...e]
2858
+ };
2859
+ var a$1 = {
2860
+ setState: {
2861
+ title: "Update Element (set attribute / state)",
2862
+ type: "callback",
2863
+ strictParams: !0,
2864
+ params: {
2865
+ category: {
2866
+ type: "select",
2867
+ description: "What to change on the element: \"attribute\" sets one of its props (the common case — e.g. content, disabled); \"state\" sets element state (visibility or a style selector). REQUIRED — always set it.",
2868
+ default: "attribute",
2869
+ options: ["attribute", "state"],
2870
+ required: !0
2871
+ },
2872
+ key: {
2873
+ type: "text",
2874
+ description: "The field to set. When category=\"attribute\", an attribute/prop key of THIS element (e.g. \"content\", \"disabled\"). When category=\"state\", \"visibility\" or \"styleSelectors.<selector>\". REQUIRED.",
2875
+ required: !0
2876
+ },
2877
+ value: {
2878
+ type: "scalar",
2879
+ description: "The value to set — its type follows the target attribute: a real boolean (true/false) for a boolean attribute, a number for a numeric one, otherwise a string (the strings \"true\"/\"false\" also coerce). There is NO separate `type` param (that belongs to the global state setState, not this one). REQUIRED.",
2880
+ when: (e) => !!e.category,
2881
+ required: !0
2882
+ },
2883
+ revertOnFinish: {
2884
+ type: "boolean",
2885
+ description: "When true, this change is UNDONE automatically when the whole flow finishes. Use it for a TEMPORARY change (a \"loading…\" label, disabling a button while it works) instead of adding manual restore steps at the end of the flow.",
2886
+ default: !1,
2887
+ label: "Revert changes after interaction"
2888
+ }
2889
+ }
2890
+ },
2891
+ toggleState: {
2892
+ title: "Toggle Element (flip attribute / state)",
2893
+ type: "callback",
2894
+ strictParams: !0,
2895
+ params: {
2896
+ category: {
2897
+ type: "select",
2898
+ description: "What to flip on the element: \"attribute\" flips one of its props (e.g. disabled); \"state\" flips element state (\"visibility\", or \"styleSelectors.<selector>\" for an expanded/collapsed look). REQUIRED.",
2899
+ default: "state",
2900
+ options: ["attribute", "state"],
2901
+ required: !0
2902
+ },
2903
+ key: {
2904
+ type: "text",
2905
+ description: "The field to flip. When category=\"attribute\", an attribute/prop key of THIS element. When category=\"state\", \"visibility\" or \"styleSelectors.<selector>\". Anything not already true counts as false, so a field that has never been set flips ON first. REQUIRED.",
2906
+ required: !0
2907
+ },
2908
+ revertOnFinish: {
2909
+ type: "boolean",
2910
+ description: "When true, the flip is UNDONE automatically when the whole flow finishes — a change that lasts only as long as the flow does.",
2911
+ default: !1,
2912
+ label: "Revert changes after interaction"
2913
+ }
2914
+ }
2915
+ },
2916
+ scrollBy: {
2917
+ title: "Scroll By",
2918
+ type: "callback",
2919
+ strictParams: !0,
2920
+ params: {
2921
+ x: {
2922
+ type: "text",
2923
+ description: "How far across: pixels (\"240\") or a share of what the box shows (\"80%\"); negative goes back.",
2924
+ default: ""
2925
+ },
2926
+ y: {
2927
+ type: "text",
2928
+ description: "How far down, the same way; negative goes up.",
2929
+ default: ""
2930
+ },
2931
+ behavior: r$2
2932
+ }
2933
+ },
2934
+ scrollTo: {
2935
+ title: "Scroll To",
2936
+ type: "callback",
2937
+ strictParams: !0,
2938
+ params: {
2939
+ x: {
2940
+ type: "text",
2941
+ description: "Where across: \"start\", \"end\", pixels from the start, or a share of the way (\"50%\"). Empty stays.",
2942
+ default: ""
2943
+ },
2944
+ y: {
2945
+ type: "text",
2946
+ description: "Where down, the same way. Empty stays.",
2947
+ default: ""
2948
+ },
2949
+ behavior: r$2
2950
+ }
2951
+ },
2952
+ scrollIntoView: {
2953
+ title: "Scroll Into View",
2954
+ type: "callback",
2955
+ strictParams: !0,
2956
+ params: {
2957
+ block: {
2958
+ type: "select",
2959
+ description: "Where the element lands, top to bottom.",
2960
+ default: "start",
2961
+ options: [...t]
2962
+ },
2963
+ inline: {
2964
+ type: "select",
2965
+ description: "Where it lands, side to side.",
2966
+ default: "nearest",
2967
+ options: [...t]
2968
+ },
2969
+ behavior: r$2
2970
+ }
2971
+ }
2972
+ };
2973
+ //#endregion
2974
+ //#region src/scaffold/pluginPackage/shape.ts
2975
+ /**
2976
+ * An element written in its final shape, from what `plitzi add plugin` was told: its props with their types and
2977
+ * defaults, the events it fires and what each hands a flow, the actions it answers to, and whether it draws anything.
2978
+ *
2979
+ * The counter `add plugin` writes without them shows the three ways an element talks to a space, and is rewritten on
2980
+ * day one; told the shape, it writes that shape — four files with nothing to delete.
2981
+ */
2982
+ var PROP_TYPES = [
2983
+ "string",
2984
+ "number",
2985
+ "boolean"
2986
+ ];
2987
+ var IDENTIFIER = /^[a-z][A-Za-z0-9]*$/;
2988
+ var EVENT = /^on[A-Z][A-Za-z0-9]*$/;
2989
+ /** What the runtime hands every element already, so a prop of that name would never be the author's. */
2990
+ var RESERVED_PROPS = [
2991
+ "className",
2992
+ "children",
2993
+ "ref",
2994
+ "key",
2995
+ "id",
2996
+ "style"
2997
+ ];
2998
+ var parseProp = (flag) => {
2999
+ const match = /^([^:=]+):([^=]+)(?:=(.*))?$/.exec(flag);
3000
+ if (!match) return `--prop ${flag}: write it name:type or name:type=default — interval:number=5000, paused:boolean, label:string=Hi.`;
3001
+ const [, name, type] = match;
3002
+ const given = match.at(3);
3003
+ if (!IDENTIFIER.test(name) || RESERVED_PROPS.includes(name)) return `--prop ${flag}: "${name}" is not a prop name of its own — camelCase, starting with a lowercase letter, and none of ${RESERVED_PROPS.join(", ")}.`;
3004
+ const propType = PROP_TYPES.find((candidate) => candidate === type);
3005
+ if (!propType) return `--prop ${flag}: the type is ${PROP_TYPES.join(", ")}, not "${type}".`;
3006
+ if (propType === "number") {
3007
+ const value = given === void 0 ? 0 : Number(given);
3008
+ return Number.isFinite(value) && given !== "" ? {
3009
+ name,
3010
+ type: propType,
3011
+ value
3012
+ } : `--prop ${flag}: "${given ?? ""}" is not a number.`;
3013
+ }
3014
+ if (propType === "boolean") {
3015
+ if (given !== void 0 && given !== "true" && given !== "false") return `--prop ${flag}: a boolean's default is true or false, not "${given}".`;
3016
+ return {
3017
+ name,
3018
+ type: propType,
3019
+ value: given === "true"
3020
+ };
3021
+ }
3022
+ return {
3023
+ name,
3024
+ type: propType,
3025
+ value: given ?? ""
3026
+ };
3027
+ };
3028
+ var parseTrigger = (flag) => {
3029
+ const [name, ...rest] = flag.split(":");
3030
+ const fields = rest.join(":").split(",").map((field) => field.trim()).filter(Boolean);
3031
+ if (!EVENT.test(name)) return `--trigger ${flag}: an event is named on + what happened, in camelCase — onTick, onPick.`;
3032
+ if (Object.hasOwn(elementTriggers, "custom") && elementTriggers.custom.includes(name)) return `--trigger ${flag}: every element already fires "${name}"; name the event after what this one does.`;
3033
+ const wrong = fields.find((field) => !IDENTIFIER.test(field));
3034
+ return wrong ? `--trigger ${flag}: "${wrong}" is not a field name — camelCase, starting with a lowercase letter.` : {
3035
+ name,
3036
+ fields
3037
+ };
3038
+ };
3039
+ var parseCallback = (name) => {
3040
+ if (!IDENTIFIER.test(name)) return { problem: `--callback ${name}: an action is named in camelCase, starting with a lowercase letter — reset, play.` };
3041
+ return Object.hasOwn(a$1, name) ? { problem: `--callback ${name}: every element already answers to "${name}"; name the action after what this one does.` } : name;
3042
+ };
3043
+ var repeated = (names) => names.find((name, index) => names.indexOf(name) !== index);
3044
+ /**
3045
+ * The shape the flags describe — `undefined` when none was given, which keeps the example — or the first thing wrong
3046
+ * with them, said so it can be fixed in one go.
3047
+ */
3048
+ var shapeFromFlags = (flags) => {
3049
+ if (!([
3050
+ flags.prop,
3051
+ flags.trigger,
3052
+ flags.callback
3053
+ ].some((list) => list && list.length > 0) || flags.headless)) return {};
3054
+ const props = [];
3055
+ for (const flag of flags.prop ?? []) {
3056
+ const prop = parseProp(flag);
3057
+ if (typeof prop === "string") return { problem: prop };
3058
+ props.push(prop);
3059
+ }
3060
+ const triggers = [];
3061
+ for (const flag of flags.trigger ?? []) {
3062
+ const trigger = parseTrigger(flag);
3063
+ if (typeof trigger === "string") return { problem: trigger };
3064
+ triggers.push(trigger);
3065
+ }
3066
+ const callbacks = [];
3067
+ for (const flag of flags.callback ?? []) {
3068
+ const callback = parseCallback(flag);
3069
+ if (typeof callback !== "string") return { problem: callback.problem };
3070
+ callbacks.push(callback);
3071
+ }
3072
+ const twice = repeated([
3073
+ ...props.map((prop) => prop.name),
3074
+ ...triggers.map((trigger) => trigger.name),
3075
+ ...callbacks
3076
+ ]);
3077
+ if (twice) return { problem: `"${twice}" is named twice: a prop, an event and an action each need a name of their own.` };
3078
+ return { shape: {
3079
+ props,
3080
+ triggers,
3081
+ callbacks,
3082
+ headless: flags.headless === true
3083
+ } };
3084
+ };
3085
+ /** `onTick` → `On Tick`, `pausedOnHover` → `Paused On Hover`: what the builder shows for a name. */
3086
+ var titleOf = (name) => {
3087
+ const words = name.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
3088
+ return `${words.charAt(0).toUpperCase()}${words.slice(1)}`;
3089
+ };
3090
+ var literal = (value) => typeof value === "string" ? tsString(value) : String(value);
3091
+ var shapedDeclaration = ({ component: name, type }, { title, description, owner }, shape) => `import type { ${name}Props } from './${name}';
3092
+ import type { PluginDeclaration } from '@plitzi/plitzi-sdk';
3093
+
3094
+ /** What this element can be authored with — its component's own props, minus what the runtime supplies. */
3095
+ export type ${name}Attributes = Omit<${name}Props, 'className'>;
3096
+
3097
+ /**
3098
+ * Static declaration for ${name}: its type, the events it fires and the actions it answers to, and the element the
3099
+ * builder adds when somebody drops it on a page. Data only, no React — the build reads it to write the manifest.
3100
+ */
3101
+ const declaration = {
3102
+ /** What a space names it by: the \`renderType\` of the \`custom\` element that hosts it. Renaming it orphans every one. */
3103
+ type: '${type}',
3104
+ /** The events it fires; \`preview\` names what a flow started by one reads — shown in the builder, never sent. */
3105
+ triggers: {${shape.triggers.map((trigger) => `
3106
+ ${trigger.name}: {
3107
+ action: '${trigger.name}',
3108
+ title: ${tsString(titleOf(trigger.name))},
3109
+ type: 'trigger',
3110
+ params: {},
3111
+ preview: { ${trigger.fields.map((field) => `${field}: ''`).join(", ")} }
3112
+ }`).join(",")}${shape.triggers.length > 0 ? "\n " : ""}},
3113
+ /** The actions it answers to, static half: the component adds the function that does each. */
3114
+ callbacks: {${shape.callbacks.map((callback) => `
3115
+ ${callback}: { action: '${callback}', title: ${tsString(titleOf(callback))}, type: 'callback', params: {} }`).join(",")}${shape.callbacks.length > 0 ? "\n " : ""}},
3116
+ content: {
3117
+ /** The starting attributes — the component's defaults, written where the builder can show them. */
3118
+ attributes: { ${shape.props.map((prop) => `${prop.name}: ${literal(prop.value)}`).join(", ")} },
3119
+ definition: {
3120
+ label: ${tsString(title)},
3121
+ type: '${type}',
3122
+ /** What it is for: the builder shows it, and an agent connected over MCP reads it to choose the element. */
3123
+ description: ${tsString(description)},
3124
+ items: [],
3125
+ bindings: {},
3126
+ styleSelectors: { base: '' },
3127
+ initialState: { visibility: true }
3128
+ },
3129
+ builder: {
3130
+ canDelete: true,
3131
+ canSelect: true,
3132
+ canDragDrop: true,
3133
+ canMove: true,
3134
+ canSnippet: true,
3135
+ itemsAllowed: [],
3136
+ itemsNotAllowed: []
3137
+ },
3138
+ market: {
3139
+ category: ${tsString(title)},
3140
+ owner: ${tsString(owner)},
3141
+ license: 'MIT',
3142
+ website: '',
3143
+ backgroundColor: '#4422ee',
3144
+ icon: ''
3145
+ },
3146
+ defaultStyle: {
3147
+ name: ${tsString(title)},
3148
+ displayMode: 'desktop',
3149
+ style: { base: { default: {} } },
3150
+ /** The attributes a data source may be pointed at — what the builder offers when somebody connects data to it. */
3151
+ bindingsAllowed: {
3152
+ attributes: [${shape.props.map((prop) => `{ path: '${prop.name}', label: ${tsString(titleOf(prop.name))} }`).join(", ")}],
3153
+ initialState: []
3154
+ }
3155
+ },
3156
+ settings: {}
3157
+ }
3158
+ } satisfies PluginDeclaration<${name}Attributes>;
3159
+
3160
+ export default declaration;
3161
+ `;
3162
+ var eventsSource = (name, triggers) => triggers.length === 0 ? "" : `
3163
+ /** What a flow started by each event reads — \`{{ <step>.${triggers[0].fields[0] ?? "field"} }}\`. */
3164
+ export type ${name}Events = {
3165
+ ${triggers.map((trigger) => ` ${trigger.name}: ${trigger.fields.length > 0 ? `{ ${trigger.fields.map((field) => `${field}: unknown`).join("; ")} }` : "Record<string, never>"};`).join("\n")}
3166
+ };
3167
+
3168
+ /**
3169
+ * Fires one of the element's events: \`fire('${triggers[0].name}', { ${triggers[0].fields.map((field) => `${field}: …`).join(", ")} })\`. A flow on that event
3170
+ * runs, and reads what it was handed.
3171
+ */
3172
+ export const use${name}Events = () => {
3173
+ const { id } = useElement();
3174
+ const {
3175
+ contexts: { InteractionsContext }
3176
+ } = usePlitziServiceContext();
3177
+ const { interactionsManager } = use(InteractionsContext);
3178
+
3179
+ return useCallback(
3180
+ <Event extends keyof ${name}Events>(event: Event, payload: ${name}Events[Event]) => {
3181
+ void interactionsManager.interactionTrigger(id, event, payload);
3182
+ },
3183
+ [interactionsManager, id]
3184
+ );
3185
+ };
3186
+ `;
3187
+ var callbacksSource = (callbacks) => callbacks.length === 0 ? "" : `
3188
+ /** The declared actions, with what only a mounted element has: the function that does each. */
3189
+ const interactionCallbacks = useMemo<Record<string, InteractionCallback>>(
3190
+ () => ({${callbacks.map((callback) => `
3191
+ ${callback}: {
3192
+ ...declaration.callbacks.${callback},
3193
+ callback: () => {
3194
+ // What \`${callback}\` does when a flow calls it.
3195
+ }
3196
+ }`).join(",")}
3197
+ }),
3198
+ []
3199
+ );
3200
+ `;
3201
+ var shapedComponent = ({ component: name, title }, shape) => {
3202
+ const { props, triggers, callbacks, headless } = shape;
3203
+ const reactImports = [...triggers.length > 0 ? ["use", "useCallback"] : [], ...callbacks.length > 0 ? ["useMemo"] : []];
3204
+ const sdkImports = [
3205
+ "RootElement",
3206
+ ...triggers.length > 0 ? ["useElement"] : [],
3207
+ ...triggers.length > 0 || headless ? ["usePlitziServiceContext"] : []
3208
+ ];
3209
+ const imports = [
3210
+ ...reactImports.length > 0 ? [`import { ${reactImports.join(", ")} } from 'react';`, ""] : [],
3211
+ `import { ${sdkImports.join(", ")} } from '@plitzi/plitzi-sdk';`,
3212
+ "",
3213
+ "import declaration from './declaration';",
3214
+ "",
3215
+ ...callbacks.length > 0 ? ["import type { InteractionCallback } from '@plitzi/plitzi-sdk';"] : [],
3216
+ "import type { CSSProperties } from 'react';"
3217
+ ];
3218
+ const params = [...props.map((prop) => `${prop.name} = ${literal(prop.value)}`), "className"].join(", ");
3219
+ const rootProps = [
3220
+ "className={className}",
3221
+ "style={style}",
3222
+ ...triggers.length > 0 ? ["interactionTriggers={declaration.triggers}"] : [],
3223
+ ...callbacks.length > 0 ? ["interactionCallbacks={interactionCallbacks}"] : []
3224
+ ];
3225
+ const shown = (line) => headless ? `{!previewMode && ${line}}` : line;
3226
+ const body = [shown(`<span style={LABEL}>${title}</span>`), ...props.map((prop) => shown(`<span>${titleOf(prop.name)}: {String(${prop.name})}</span>`))].map((line) => ` ${line}`).join("\n");
3227
+ const look = headless ? `
3228
+ const {
3229
+ settings: { previewMode }
3230
+ } = usePlitziServiceContext();
3231
+ const style = previewMode ? HIDDEN : BADGE;
3232
+ ` : `
3233
+ const style = CARD;
3234
+ `;
3235
+ const styles = headless ? `const HIDDEN: CSSProperties = { display: 'none' };
3236
+
3237
+ /** In the builder: small, and in the colours of whatever page it is on. */
3238
+ const BADGE: CSSProperties = {
3239
+ display: 'inline-flex',
3240
+ gap: '8px',
3241
+ padding: '4px 10px',
3242
+ borderRadius: '999px',
3243
+ fontSize: '12px',
3244
+ border: '1px dashed color-mix(in srgb, currentColor 35%, transparent)'
3245
+ };` : `/** Colours borrowed rather than chosen: a plugin does not know the palette of the page it is dropped on. */
3246
+ const CARD: CSSProperties = {
3247
+ display: 'inline-flex',
3248
+ flexDirection: 'column',
3249
+ gap: '4px',
3250
+ padding: '12px 16px',
3251
+ borderRadius: '12px',
3252
+ border: '1px solid color-mix(in srgb, currentColor 15%, transparent)'
3253
+ };`;
3254
+ return `${imports.join("\n")}
3255
+
3256
+ /**
3257
+ * The props ARE the element's attributes: whatever a space writes on the element arrives here by the same name, and so
3258
+ * does whatever a binding writes later. Everything is optional with a default — an attribute nobody has authored yet,
3259
+ * or a binding whose source has not answered, is \`undefined\`.
3260
+ */
3261
+ export interface ${name}Props {
3262
+ ${props.map((prop) => ` ${prop.name}?: ${prop.type};\n`).join("")} /** Supplied by the runtime, not authored: the classes the element's own style rules are written against. */
3263
+ className?: string;
3264
+ }
3265
+ ${eventsSource(name, triggers)}
3266
+ /**
3267
+ * ${headless ? "Nothing to see on a page: it is there for what it does. In the builder it shows as a badge with its attributes, so it can be selected; on a page it renders hidden — still an element, so its events and actions are its own." : "What it draws — for now its attributes, to be replaced by what it is. Deterministic on its first render (the same markup on a server and in the browser); anything live goes in an effect."}
3268
+ */
3269
+ const ${name} = ({ ${params} }: ${name}Props) => {${look}${callbacksSource(callbacks)}
3270
+ return (
3271
+ <RootElement
3272
+ ${rootProps.map((prop) => ` ${prop}`).join("\n")}
3273
+ >
3274
+ ${body}
3275
+ </RootElement>
3276
+ );
3277
+ };
3278
+
3279
+ ${styles}
3280
+
3281
+ const LABEL: CSSProperties = { fontSize: '12px', opacity: 0.65 };
3282
+
3283
+ export default ${name};
3284
+ `;
3285
+ };
3286
+ var settingsControl = (prop) => {
3287
+ const label = titleOf(prop.name);
3288
+ if (prop.type === "boolean") return ` <label style={FIELD}>
3289
+ <input type="checkbox" checked={${prop.name}} onChange={event => onUpdate?.('${prop.name}', event.target.checked)} />
3290
+ ${label}
3291
+ </label>`;
3292
+ return ` <label style={FIELD}>
3293
+ ${label}
3294
+ <input style={INPUT} ${prop.type === "number" ? `type="number" value={${prop.name}} onChange={event => onUpdate?.('${prop.name}', Number(event.target.value))}` : `value={${prop.name}} onChange={event => onUpdate?.('${prop.name}', event.target.value)}`} />
3295
+ </label>`;
3296
+ };
3297
+ var shapedSettings = ({ component: name }, shape) => {
3298
+ const { props } = shape;
3299
+ const typed = props.some((prop) => prop.type !== "boolean");
3300
+ return `import type { ${name}Attributes } from './declaration';
3301
+ import type { CSSProperties } from 'react';
3302
+
3303
+ /**
3304
+ * The builder's panel for the element: one control per attribute, writing back through \`onUpdate\` the attributes the
3305
+ * component reads. Only the builder loads it.
3306
+ */
3307
+ export type SettingsProps = ${name}Attributes & {
3308
+ onUpdate?: (attribute: string, value: string | number | boolean) => void;
3309
+ };
3310
+
3311
+ ${props.length === 0 ? "const Settings = () => <div style={PANEL}>It has no attributes to set.</div>;" : `const Settings = ({ ${[...props.map((prop) => `${prop.name} = ${literal(prop.value)}`), "onUpdate"].join(", ")} }: SettingsProps) => (
3312
+ <div style={PANEL}>
3313
+ ${props.map(settingsControl).join("\n")}
3314
+ </div>
3315
+ );`}
3316
+
3317
+ const PANEL: CSSProperties = { display: 'flex', flexDirection: 'column', gap: '12px', padding: '8px 0' };
3318
+ ${props.length > 0 ? `
3319
+ const FIELD: CSSProperties = { display: 'flex', flexDirection: 'column', gap: '4px', fontSize: '12px' };
3320
+ ` : ""}${typed ? `
3321
+ const INPUT: CSSProperties = {
3322
+ font: 'inherit',
3323
+ color: 'inherit',
3324
+ padding: '6px 8px',
3325
+ borderRadius: '6px',
3326
+ border: '1px solid color-mix(in srgb, currentColor 25%, transparent)',
3327
+ background: 'transparent'
3328
+ };
3329
+ ` : ""}
3330
+ export default Settings;
3331
+ `;
3332
+ };
3333
+ //#endregion
2438
3334
  //#region src/scaffold/pluginPackage/source.ts
2439
3335
  var declarationFile = ({ component: name, type }, { title, description, owner }) => `import type { ${name}Props } from './${name}';
2440
3336
  import type { PluginDeclaration } from '@plitzi/plitzi-sdk';
@@ -2476,7 +3372,7 @@ const declaration = {
2476
3372
  canSelect: true,
2477
3373
  canDragDrop: true,
2478
3374
  canMove: true,
2479
- canTemplate: true,
3375
+ canSnippet: true,
2480
3376
  itemsAllowed: [],
2481
3377
  itemsNotAllowed: []
2482
3378
  },
@@ -2758,10 +3654,10 @@ export { elements };
2758
3654
  export default main;
2759
3655
  `;
2760
3656
  /** The element alone: its folder's files, by their path inside it. */
2761
- var elementFiles = (names, text) => ({
2762
- [`${names.component}.tsx`]: component(names),
2763
- "declaration.ts": declarationFile(names, text),
2764
- "Settings.tsx": settings(names),
3657
+ var elementFiles = (names, text, shape) => ({
3658
+ [`${names.component}.tsx`]: shape ? shapedComponent(names, shape) : component(names),
3659
+ "declaration.ts": shape ? shapedDeclaration(names, text, shape) : declarationFile(names, text),
3660
+ "Settings.tsx": shape ? shapedSettings(names, shape) : settings(names),
2765
3661
  "index.ts": elementIndex(names)
2766
3662
  });
2767
3663
  /** Every element of a plugin package in its own folder, the two lists that gather them, and the entry. */
@@ -2889,10 +3785,17 @@ var scaffoldPlugin = (answers) => {
2889
3785
  * One element, for a folder of a project that already exists: paths relative to the element's own folder. `title` is
2890
3786
  * the label it renders until an attribute says otherwise.
2891
3787
  */
2892
- var scaffoldElement = (name, text) => elementFiles({
3788
+ var scaffoldElement = (name, text, shape) => elementFiles({
2893
3789
  ...pluginNames(name),
2894
3790
  title: text.title
2895
- }, text);
3791
+ }, text, shape);
3792
+ //#endregion
3793
+ //#region src/scaffold/types.ts
3794
+ var CREATE_TEMPLATES = [
3795
+ "welcome",
3796
+ "blank",
3797
+ "catalog"
3798
+ ];
2896
3799
  //#endregion
2897
3800
  //#region src/scaffold/index.ts
2898
3801
  /**
@@ -3043,144 +3946,49 @@ var findProject = async (from) => {
3043
3946
  };
3044
3947
  /** Whether `dir` is one of the workspace's packages — a folder its globs cover — so an install there installs it. */
3045
3948
  var coveredByWorkspace = (project, dir) => project.workspaceRoot !== void 0 && project.workspaceFolders.some((folder) => path.dirname(dir) === path.join(project.workspaceRoot ?? "", folder));
3046
- //#endregion
3047
- //#region src/commands/terminal.ts
3048
- /** Somebody who can answer a prompt. An agent's shell, CI and a pipe are nobody, whatever they were asked to do. */
3049
- var atTerminal = () => process.stdin.isTTY && process.stdout.isTTY;
3050
- /**
3051
- * Nobody to ask, so the questions go to whoever ran this — in practice an agent — to put to the person.
3052
- *
3053
- * It offers no way around them. It used to end with "or with --yes to take the defaults", and an agent keen to finish
3054
- * took that exit every time: the person was never asked, which is the whole failure this stop exists to prevent.
3055
- */
3056
- var refuseWithoutTerminal = (what, questions, stopped = `plitzi create stopped before writing anything: these choices shape the whole ${what}`) => {
3057
- console.error(chalk.red(`\n${stopped}, they belong to the person it is for, and nobody is at this terminal to make them.`));
3058
- console.error(chalk.bold("\nIf you are an AI agent: ask the user each question below, show them the options, and wait for their answers. Do not choose for them and do not fall back to a default.\n"));
3059
- questions.forEach(({ flag, choices, question }, index) => {
3060
- console.error(` ${index + 1}. ${question}`);
3061
- console.error(` ${flag} ${choices.join(" | ")}`);
3062
- });
3063
- const flags = questions.map(({ flag }) => `${flag} <their answer>`).join(" ");
3064
- console.error(`\nThen run the same command again with their answers: ${flags}`);
3065
- process.exitCode = 1;
3066
- };
3067
- /** One choice at the terminal: Enter takes the default, anything off the list is refused rather than guessed at. */
3068
- var ask = async (rl, question) => {
3069
- const reply = (await rl.question(`\n${question.question}\n${question.flag} (${question.choices.join("/")}) [${question.fallback}] > `)).trim();
3070
- const chosen = question.choices.find((choice) => choice === (reply || question.fallback));
3071
- if (!chosen) console.error(chalk.red(`"${reply}" is not one of ${question.choices.join(", ")}.`));
3072
- return chosen;
3073
- };
3074
3949
  /**
3075
- * A free answer at the terminal, with the likely one offered. `check` answers what is wrong with a reply, if anything:
3076
- * a refused reply is said and asked again, since a name or a path is typed rather than picked and a typo is the
3077
- * usual reason for one.
3950
+ * The project the command runs in, or `undefined` — said — when there is none: every command that works on a project
3951
+ * refuses outside one rather than writing into whatever folder it was run from. `what` ends the sentence: "whose page
3952
+ * to check".
3078
3953
  */
3079
- var askText = async (rl, question, fallback, check = () => void 0) => {
3080
- for (;;) {
3081
- const offered = fallback ? `[${fallback}]` : "(optional)";
3082
- const reply = (await rl.question(`\n${question}\n${offered} > `)).trim() || fallback;
3083
- const problem = check(reply);
3084
- if (!problem) return reply;
3085
- console.error(chalk.red(problem));
3086
- }
3954
+ var projectHere = async (what) => {
3955
+ const project = await findProject(process.cwd());
3956
+ if (!project) fail(`There is no package.json here or above: run this in the project ${what}.`);
3957
+ return project;
3087
3958
  };
3088
- /**
3089
- * One of a numbered list, or something else typed out. Enter takes the first entry. `other` is the question for the
3090
- * typed answer, offered as the last number.
3091
- */
3092
- var askChoice = async (rl, question, options, other) => {
3093
- const menu = [...options.map((option) => option.label), other.label].map((label, index) => ` ${index + 1}. ${label}`).join("\n");
3094
- for (;;) {
3095
- const reply = (await rl.question(`\n${question}\n${menu}\n[1] > `)).trim() || "1";
3096
- const index = Number(reply) - 1;
3097
- if (Number.isInteger(index) && index >= 0 && index < options.length) return options[index].value;
3098
- if (index === options.length) return askText(rl, other.question, other.fallback, other.check);
3099
- console.error(chalk.red(`"${reply}" is not a number from 1 to ${options.length + 1}.`));
3100
- }
3101
- };
3102
- /** One of a numbered list and nothing else. Enter takes the first entry. */
3103
- var askPick = async (rl, question, options) => {
3104
- const menu = options.map(({ label }, index) => ` ${index + 1}. ${label}`).join("\n");
3105
- for (;;) {
3106
- const reply = (await rl.question(`\n${question}\n${menu}\n[1] > `)).trim() || "1";
3107
- const index = Number(reply) - 1;
3108
- if (Number.isInteger(index) && index >= 0 && index < options.length) return options[index].value;
3109
- console.error(chalk.red(`"${reply}" is not a number from 1 to ${options.length}.`));
3110
- }
3111
- };
3112
- /** A directory that does not exist yet is as empty as one can be, which is the answer this question wants. */
3113
- var isEmpty = async (target) => {
3114
- try {
3115
- return (await fs.readdir(target)).length === 0;
3116
- } catch {
3117
- return true;
3118
- }
3119
- };
3120
- var isDirectory$1 = async (candidate) => {
3959
+ //#endregion
3960
+ //#region src/commands/projectFormatter.ts
3961
+ var isPrettier = (value) => e$1(value) && [
3962
+ "format",
3963
+ "resolveConfig",
3964
+ "getFileInfo"
3965
+ ].every((name) => typeof value[name] === "function");
3966
+ var unformatted = (_file, text) => Promise.resolve(text);
3967
+ /** Loads the project's Prettier once. A project without one formats nothing. */
3968
+ var projectFormatter = async (root) => {
3969
+ let loaded;
3121
3970
  try {
3122
- return (await fs.stat(candidate)).isDirectory();
3971
+ const resolved = createRequire(path.join(root, "package.json")).resolve("prettier");
3972
+ loaded = await import(pathToFileURL(resolved).href);
3123
3973
  } catch {
3124
- return false;
3974
+ return unformatted;
3125
3975
  }
3126
- };
3127
- /** The closest directory that already exists: the project's own does not until its files are written. */
3128
- var nearestExisting = async (target) => {
3129
- let dir = target;
3130
- while (!await isDirectory$1(dir) && path.dirname(dir) !== dir) dir = path.dirname(dir);
3131
- return dir;
3132
- };
3133
- /** Every file, by the path under `target` it is written to. */
3134
- var writeFiles = async (target, files) => {
3135
- await Promise.all(Object.entries(files).map(async ([file, contents]) => {
3136
- const destination = path.join(target, file);
3137
- await fs.mkdir(path.dirname(destination), { recursive: true });
3138
- return fs.writeFile(destination, contents);
3139
- }));
3140
- };
3141
- /**
3142
- * What a first install is most likely refused over, and what to do about it.
3143
- *
3144
- * The scaffold already exempts what it ships, but not the rest of the tree: a third-party dependency published in
3145
- * the last day is held back by the same age gates, and says so in a code that means nothing to somebody who has
3146
- * not touched the project yet.
3147
- */
3148
- var INSTALL_HINTS = {
3149
- npm: "A peer-dependency conflict (ERESOLVE) is a bug in this scaffold: please report it. `npm install --legacy-peer-deps` gets past it meanwhile.",
3150
- yarn: "YN0016 (\"quarantined\") means a dependency was published less than a day ago. Wait, or add it to `npmPreapprovedPackages` in .yarnrc.yml.",
3151
- pnpm: "A dependency published too recently is added to `minimumReleaseAgeExclude` in pnpm-workspace.yaml; a skipped build script is approved with `pnpm approve-builds`."
3152
- };
3153
- var install = (manager, cwd) => new Promise((resolve) => {
3154
- const child = spawn(manager, ["install"], {
3155
- cwd,
3156
- stdio: "inherit",
3157
- shell: process.platform === "win32"
3158
- });
3159
- child.on("error", () => resolve(false));
3160
- child.on("close", (code) => resolve(code === 0));
3161
- });
3162
- /**
3163
- * One of the project's own scripts, run quietly: what it prints is the project's business, and whether it worked is
3164
- * all this needs. `run` is understood by all three managers alike.
3165
- */
3166
- var runScript = (manager, script, cwd) => new Promise((resolve) => {
3167
- const child = spawn(manager, ["run", script], {
3168
- cwd,
3169
- stdio: "ignore",
3170
- shell: process.platform === "win32"
3171
- });
3172
- child.on("error", () => resolve(false));
3173
- child.on("close", (code) => resolve(code === 0));
3174
- });
3175
- /**
3176
- * How to get to `target` from here, in whichever form is shorter.
3177
- *
3178
- * A relative path out of a temp directory is six `../` and unreadable; an absolute one to a sibling folder is noise.
3179
- * These lines are meant to be pasted, so they are printed the way somebody would have typed them.
3180
- */
3181
- var cdPrefix = (target) => {
3182
- const relative = path.relative(process.cwd(), target);
3183
- return target === process.cwd() ? "" : `cd ${relative.length < target.length ? relative : target} && `;
3976
+ const prettier = isPrettier(loaded) ? loaded : e$1(loaded) ? loaded.default : void 0;
3977
+ if (!isPrettier(prettier)) return unformatted;
3978
+ const api = prettier;
3979
+ const ignorePath = [".prettierignore", ".gitignore"].map((file) => path.join(root, file));
3980
+ return async (file, text) => {
3981
+ const filepath = path.join(root, file);
3982
+ const info = await api.getFileInfo(filepath, {
3983
+ ignorePath,
3984
+ resolveConfig: true
3985
+ });
3986
+ if (info.ignored || !info.inferredParser) return text;
3987
+ return api.format(text, {
3988
+ ...await api.resolveConfig(filepath),
3989
+ filepath
3990
+ });
3991
+ };
3184
3992
  };
3185
3993
  //#endregion
3186
3994
  //#region src/commands/addPlugin.ts
@@ -3295,7 +4103,7 @@ src/plugins/declarations.ts is missing or not the list the CLI wrote, so it is y
3295
4103
  /** A project the CLI did not write: how to register, and the one dependency the elements import. */
3296
4104
  var registerElsewhere = async (root, added) => {
3297
4105
  console.log(`\nRegister ${added.length > 1 ? "them" : "it"} where the project renders its space:\n\n${chalk.dim(registration(added))}`);
3298
- const { dependencies = {}, devDependencies = {} } = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf-8"));
4106
+ const { dependencies = {}, devDependencies = {} } = await readPackageJson(root) ?? {};
3299
4107
  if (!("@plitzi/plitzi-sdk" in {
3300
4108
  ...dependencies,
3301
4109
  ...devDependencies
@@ -3304,19 +4112,21 @@ var registerElsewhere = async (root, added) => {
3304
4112
  var addPlugin = async (namesGiven, options) => {
3305
4113
  const project = await findProject(process.cwd());
3306
4114
  if (!project) {
3307
- console.error(chalk.red("plitzi add plugin adds an element to a project, and there is no package.json here or above. For a plugin of its own, run plitzi create <folder> --plugin."));
3308
- process.exitCode = 1;
4115
+ fail("plitzi add plugin adds an element to a project, and there is no package.json here or above. For a plugin of its own, run plitzi create <folder> --plugin.");
3309
4116
  return;
3310
4117
  }
3311
4118
  if (namesGiven.length > 1 && (options.title !== void 0 || options.description !== void 0)) {
3312
- console.error(chalk.red("--title and --description describe one element: add several without them, or one at a time."));
3313
- process.exitCode = 1;
4119
+ fail("--title and --description describe one element: add several without them, or one at a time.");
4120
+ return;
4121
+ }
4122
+ const { shape, problem: shapeProblem } = shapeFromFlags(options);
4123
+ if (shapeProblem ?? (shape && namesGiven.length > 1)) {
4124
+ fail(shapeProblem ?? "--prop, --trigger, --callback and --headless shape one element: add them one at a time.");
3314
4125
  return;
3315
4126
  }
3316
4127
  const givenProblem = namesGiven.length > 0 ? namesProblem(namesGiven) : void 0;
3317
4128
  if (givenProblem) {
3318
- console.error(chalk.red(givenProblem));
3319
- process.exitCode = 1;
4129
+ fail(givenProblem);
3320
4130
  return;
3321
4131
  }
3322
4132
  const folderKnown = options.dir !== void 0 || project.plitzi !== void 0;
@@ -3351,21 +4161,23 @@ var addPlugin = async (namesGiven, options) => {
3351
4161
  };
3352
4162
  });
3353
4163
  for (const { target } of planned) if (!options.force && !await isEmpty(target)) {
3354
- console.error(chalk.red(`${target} is not empty. Pass --force to write into it anyway.`));
3355
- process.exitCode = 1;
4164
+ fail(`${target} is not empty. Pass --force to write into it anyway.`);
3356
4165
  return;
3357
4166
  }
4167
+ const format = await projectFormatter(project.root);
3358
4168
  const single = planned.length === 1;
3359
4169
  const answer = async (question, given, fallback) => given ?? (rl ? askText(rl, question, fallback) : fallback);
3360
4170
  const added = [];
3361
4171
  for (const { name, names: elementNames, target } of planned) {
3362
4172
  const called = single ? "it" : name;
3363
4173
  const title = await answer(`What does the builder call ${called}?`, single ? options.title : void 0, elementNames.title);
3364
- await writeFiles(target, scaffoldElement(name, {
4174
+ const files = scaffoldElement(name, {
3365
4175
  title,
3366
4176
  description: await answer(`What is ${called} for, in a sentence? The builder shows it, and an agent reads it to choose the element.`, single ? options.description : void 0, ""),
3367
4177
  owner: ""
3368
- }));
4178
+ }, shape);
4179
+ const formatted = await Promise.all(Object.entries(files).map(async ([file, text]) => [file, await format(path.relative(project.root, path.join(target, file)), text)]));
4180
+ await writeFiles(target, Object.fromEntries(formatted));
3369
4181
  added.push({
3370
4182
  names: {
3371
4183
  ...elementNames,
@@ -3393,6 +4205,166 @@ This project lists its plugins in src/main.ts (projects created since find them
3393
4205
  }
3394
4206
  };
3395
4207
  //#endregion
4208
+ //#region src/commands/projectSpace.ts
4209
+ /**
4210
+ * What the module exports as `space`, taken as a declaration when it has the shape of one. `authorSpace` checks the
4211
+ * rest of it, field by field, and says what is wrong — so this only has to tell a space from anything else.
4212
+ */
4213
+ var isSpaceSpec = (value) => e$1(value) && typeof value.name === "string" && typeof value.permanentUrl === "string" && Array.isArray(value.pages);
4214
+ var isDeclarations = (value) => Array.isArray(value) && value.every((entry) => e$1(entry) && typeof entry.type === "string");
4215
+ var importProject = async (file) => import(pathToFileURL(file).href);
4216
+ /**
4217
+ * The project's own declaration, loaded as its `author` script loads it — `src/space.ts`, and the plugins in
4218
+ * `src/plugins/declarations.ts` — for a command that authors it in this process.
4219
+ */
4220
+ var loadProjectSpace = async (root) => {
4221
+ const module = await importProject(path.join(root, "src/space.ts"));
4222
+ const space = e$1(module) ? module.space : void 0;
4223
+ if (!isSpaceSpec(space)) return { problem: "src/space.ts exports no `space`." };
4224
+ const registry = await importProject(path.join(root, "src/plugins/declarations.ts")).catch(() => void 0);
4225
+ const declarations = e$1(registry) ? registry.declarations : void 0;
4226
+ return {
4227
+ space,
4228
+ plugins: isDeclarations(declarations) ? declarations : []
4229
+ };
4230
+ };
4231
+ //#endregion
4232
+ //#region src/commands/check.ts
4233
+ /** The space the project declares, authored — the handles say what each page owes. Only for one written here. */
4234
+ var projectHandles = async (root) => {
4235
+ const project = await loadProjectSpace(root);
4236
+ if ("problem" in project) return { problem: `${project.problem} There is nothing to check the page against.` };
4237
+ try {
4238
+ return authorSpace(project.space, { plugins: project.plugins }).handles;
4239
+ } catch (error) {
4240
+ return { problem: `The space does not author — npm run author says why: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}` };
4241
+ }
4242
+ };
4243
+ /** The page a path is, `:slug` and `{{slug}}` matching any segment. */
4244
+ var pageFor = (handles, pathname) => {
4245
+ const wanted = pathname.replace(/\/$/, "").split("/");
4246
+ return Object.values(handles.pages).find((page) => {
4247
+ const segments = page.path.replace(/\/$/, "").split("/");
4248
+ return segments.length === wanted.length && segments.every((segment, index) => segment.startsWith(":") || /^\{\{.*\}\}$/.test(segment) || segment === wanted[index]);
4249
+ })?.id;
4250
+ };
4251
+ var checkAt = async (browser, origin, pathname, width, scheme, handles, asked) => {
4252
+ const page = await browser.newPage({
4253
+ viewport: {
4254
+ width,
4255
+ height: 900
4256
+ },
4257
+ colorScheme: scheme,
4258
+ reducedMotion: "no-preference"
4259
+ });
4260
+ const consoleErrors = [];
4261
+ const failedRequests = [];
4262
+ page.on("pageerror", (error) => consoleErrors.push(error.message));
4263
+ page.on("console", (message) => {
4264
+ if (message.type() === "error") consoleErrors.push(message.text());
4265
+ });
4266
+ page.on("response", (response) => {
4267
+ if (response.status() >= 400 && response.url().startsWith(origin)) failedRequests.push(`${String(response.status())} ${response.url().slice(origin.length)}`);
4268
+ });
4269
+ if (!await page.goto(`${origin}${pathname}`, { waitUntil: "networkidle" }).catch(() => null)) return {
4270
+ path: pathname,
4271
+ width,
4272
+ ok: false,
4273
+ checked: 0,
4274
+ problems: [`nothing answers at ${origin}${pathname}`],
4275
+ consoleErrors,
4276
+ failedRequests,
4277
+ devTools: false
4278
+ };
4279
+ const pageId = handles ? pageFor(handles, pathname) : void 0;
4280
+ const report = handles && pageId ? await inspectPage(page, handles, { page: pageId }) : await inspectDocument(page);
4281
+ const devTools = await readDevTools(page, asked);
4282
+ const problems = [
4283
+ ...handles && !pageId ? [`no page of the space answers at ${pathname}`] : [],
4284
+ ...report.problems,
4285
+ ...devTools.flows.filter((flow) => flow.status === "failed").map(failedFlowText)
4286
+ ];
4287
+ return {
4288
+ path: pathname,
4289
+ width,
4290
+ ok: problems.length === 0 && consoleErrors.length === 0 && failedRequests.length === 0,
4291
+ checked: report.checked,
4292
+ problems,
4293
+ consoleErrors,
4294
+ failedRequests,
4295
+ devTools: devTools.available,
4296
+ ...devTools.state === void 0 ? {} : { state: devTools.state },
4297
+ ...devTools.sources === void 0 ? {} : { sources: devTools.sources },
4298
+ ...devTools.element === void 0 ? {} : { element: devTools.element }
4299
+ };
4300
+ };
4301
+ /** What the page holds, when it was asked for: one compact line each, the shape of a source rather than its rows. */
4302
+ var heldText = (report, asked) => {
4303
+ if (!asked.state && asked.element === void 0) return [];
4304
+ if (!report.devTools) return [" · the page has no dev tools on (debug mode), so its state and elements cannot be read"];
4305
+ return [
4306
+ ...report.state === void 0 ? [] : [` · state ${JSON.stringify(report.state)}`],
4307
+ ...Object.entries(report.sources ?? {}).map(([name, value]) => ` · source ${name}: ${shapeOf(value)}`),
4308
+ ...asked.element === void 0 ? [] : [` · element ${asked.element}: ${JSON.stringify(report.element ?? null)}`]
4309
+ ];
4310
+ };
4311
+ /** A value's shape in a few words: a list's length, an object's keys, a short value as it is. */
4312
+ var shapeOf = (value) => {
4313
+ if (Array.isArray(value)) return `a list of ${String(value.length)}`;
4314
+ if (e$1(value)) {
4315
+ const keys = Object.keys(value);
4316
+ return `{ ${keys.slice(0, 8).join(", ")}${keys.length > 8 ? ", …" : ""} }`;
4317
+ }
4318
+ const text = value === void 0 ? "undefined" : JSON.stringify(value);
4319
+ return text.length > 60 ? `${text.slice(0, 57)}…` : text;
4320
+ };
4321
+ var reportText = (report, asked) => {
4322
+ const head = `${report.path} at ${String(report.width)} px`;
4323
+ const held = heldText(report, asked);
4324
+ if (report.ok) return [chalk.green(`✓ ${head} — ${report.checked > 0 ? `${String(report.checked)} elements on screen, ` : ""}nothing wrong`), ...held].join("\n");
4325
+ return [
4326
+ chalk.red(`✗ ${head}`),
4327
+ ...report.problems.map((problem) => ` - ${problem}`),
4328
+ ...report.consoleErrors.map((error) => ` - console: ${error}`),
4329
+ ...report.failedRequests.map((request) => ` - request refused: ${request}`),
4330
+ ...held
4331
+ ].join("\n");
4332
+ };
4333
+ var check = async (route, options) => {
4334
+ const project = await projectHere("whose page to check");
4335
+ if (!project) return;
4336
+ const plitzi = project.plitzi?.kind === "project" ? project.plitzi : void 0;
4337
+ const where = await projectOrigin(project.root, plitzi);
4338
+ if ("problem" in where) {
4339
+ fail(where.problem);
4340
+ return;
4341
+ }
4342
+ const handles = plitzi?.source === "local" ? await projectHandles(project.root) : void 0;
4343
+ if (handles && "problem" in handles) {
4344
+ fail(handles.problem);
4345
+ return;
4346
+ }
4347
+ const browser = await launchBrowser(project.root);
4348
+ if ("problem" in browser) {
4349
+ fail(browser.problem);
4350
+ return;
4351
+ }
4352
+ try {
4353
+ const widths = options.width ?? [1440, 390];
4354
+ const scheme = options.scheme ?? "light";
4355
+ const asked = {
4356
+ state: Boolean(options.state),
4357
+ ...options.element ? { element: options.element } : {}
4358
+ };
4359
+ const reports = [];
4360
+ for (const width of widths) reports.push(await checkAt(browser, where.origin, route ?? "/", width, scheme, handles, asked));
4361
+ console.log(options.json ? JSON.stringify(reports) : reports.map((report) => reportText(report, asked)).join("\n"));
4362
+ if (reports.some((report) => !report.ok)) process.exitCode = 1;
4363
+ } finally {
4364
+ await browser.close();
4365
+ }
4366
+ };
4367
+ //#endregion
3396
4368
  //#region ../../packages/sdk-shared/dist/actions/functions.mjs
3397
4369
  var r$1 = (e) => /^[A-Za-z0-9_][A-Za-z0-9_./-]*\.(ts|js|mjs|json)$/.test(e) && !e.split("/").some((e) => e === ".." || e === "." || e === "");
3398
4370
  var i$1 = async (e, t) => {
@@ -3414,9 +4386,8 @@ var i$1 = async (e, t) => {
3414
4386
  //#region src/commands/functions.ts
3415
4387
  var FUNCTIONS_DIR = "functions";
3416
4388
  var STATE_FILE = path.join(".plitzi", "functions.json");
3417
- var isRecord$6 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3418
- /** The project's root: where `functions/` and `.plitzi/` go. */
3419
- var rootOf = async () => (await findProject(process.cwd()))?.root ?? process.cwd();
4389
+ /** The project's root: where `functions/` and `.plitzi/` go. Undefined — said — outside a project. */
4390
+ var rootOf = async () => (await projectHere("whose functions these are"))?.root;
3420
4391
  /** Every source file under `functions/`, by its path there — read by the one rule of what a source is. */
3421
4392
  var readLocal = (root) => i$1(path.join(root, FUNCTIONS_DIR), {
3422
4393
  list: async (dir) => (await fs.readdir(dir, { withFileTypes: true })).map((entry) => ({
@@ -3428,7 +4399,7 @@ var readLocal = (root) => i$1(path.join(root, FUNCTIONS_DIR), {
3428
4399
  var readState = async (root) => {
3429
4400
  try {
3430
4401
  const value = JSON.parse(await fs.readFile(path.join(root, STATE_FILE), "utf8"));
3431
- if (!isRecord$6(value) || typeof value.space !== "number" || typeof value.version !== "string" || !isRecord$6(value.files)) return;
4402
+ if (!e$1(value) || typeof value.space !== "number" || typeof value.version !== "string" || !e$1(value.files)) return;
3432
4403
  const files = Object.fromEntries(Object.entries(value.files).flatMap(([name, text]) => typeof text === "string" ? [[name, text]] : []));
3433
4404
  return {
3434
4405
  space: value.space,
@@ -3446,10 +4417,6 @@ var writeFunctionsState = async (root, state) => {
3446
4417
  /** The files that differ between two copies — added, removed or changed — sorted. */
3447
4418
  var changedFiles = (a, b) => [.../* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)])].filter((file) => a[file] !== b[file]).sort();
3448
4419
  var taskList = (draft) => draft.manifest?.tasks.length ? draft.manifest.tasks.map((task) => `${task.namespace}.${task.action}`).join(", ") : "no tasks";
3449
- var connect$1 = async (options, doing) => {
3450
- const api = await apiFor(options);
3451
- return api ? connectionWithSpace(api, doing) : void 0;
3452
- };
3453
4420
  var readDraft = async (connection, spaceId) => {
3454
4421
  const answered = await authorizedRequest(connection, `/spaces/${spaceId}/functions`);
3455
4422
  if (!answered.ok) {
@@ -3464,9 +4431,9 @@ var readDraft = async (connection, spaceId) => {
3464
4431
  return reply.data;
3465
4432
  };
3466
4433
  var pullFunctions = async (options) => {
3467
- const connection = await connect$1(options, "to pull from");
3468
- if (!connection?.space) return;
3469
4434
  const root = await rootOf();
4435
+ const connection = root && await connectToSpace(options, "to pull from");
4436
+ if (!root || !connection || !connection.space) return;
3470
4437
  const [local, state] = await Promise.all([readLocal(root), readState(root)]);
3471
4438
  const unpushed = state ? changedFiles(local, state.files) : Object.keys(local);
3472
4439
  if (!options.force && (state?.space ?? connection.space.id) !== connection.space.id) {
@@ -3491,7 +4458,7 @@ var pullFunctions = async (options) => {
3491
4458
  files: draft.files
3492
4459
  });
3493
4460
  const count = Object.keys(draft.files).length;
3494
- console.log(count ? `Pulled ${count} file${count === 1 ? "" : "s"} of ${chalk.bold(connection.space.name)}’s functions into functions/ — ${taskList(draft)}.` : `${chalk.bold(connection.space.name)} has no functions yet. Write functions/index.ts and plitzi functions push.`);
4461
+ console.log(count ? chalk.green(`Pulled ${count} file${count === 1 ? "" : "s"} of ${connection.space.name}’s functions into functions/ — ${taskList(draft)}.`) : `${chalk.bold(connection.space.name)} has no functions yet. Write functions/index.ts and plitzi functions push.`);
3495
4462
  };
3496
4463
  var printProblems = (problems) => {
3497
4464
  console.error(chalk.red("The functions were not saved:"));
@@ -3502,9 +4469,9 @@ var printProblems = (problems) => {
3502
4469
  process.exitCode = 1;
3503
4470
  };
3504
4471
  var pushFunctions = async (options) => {
3505
- const connection = await connect$1(options, "to push to");
3506
- if (!connection?.space) return;
3507
4472
  const root = await rootOf();
4473
+ const connection = root && await connectToSpace(options, "to push to");
4474
+ if (!root || !connection || !connection.space) return;
3508
4475
  const [local, state] = await Promise.all([readLocal(root), readState(root)]);
3509
4476
  if (!Object.hasOwn(local, "index.ts")) {
3510
4477
  fail("There is no functions/index.ts here: it is where a space’s functions start. Pull them, or write it.");
@@ -3554,7 +4521,7 @@ var pushFunctions = async (options) => {
3554
4521
  version: reply.data.version,
3555
4522
  files: local
3556
4523
  });
3557
- console.log(`Pushed to ${chalk.bold(connection.space.name)}’s draft — ${taskList({ manifest: reply.data.manifest ?? null })}. ` + chalk.dim("The live site runs them once the space is published."));
4524
+ console.log(chalk.green(`Pushed to ${connection.space.name}’s draft — ${taskList({ manifest: reply.data.manifest ?? null })}.`) + chalk.dim(" The live site runs them once the space is published."));
3558
4525
  };
3559
4526
  /** What one run of a task did: its logs, then its value — or why it failed, as a failure of the command. */
3560
4527
  var printRun = (task, report) => {
@@ -3572,14 +4539,14 @@ var printRun = (task, report) => {
3572
4539
  var paramsOf = (given) => {
3573
4540
  try {
3574
4541
  const params = given ? JSON.parse(given) : {};
3575
- if (isRecord$6(params)) return params;
4542
+ if (e$1(params)) return params;
3576
4543
  } catch {}
3577
4544
  fail("--params is a JSON object of the task’s params, by name: --params '{\"name\":\"Ada\"}'");
3578
4545
  };
3579
4546
  var tryFunction = async (task, options) => {
3580
4547
  const params = paramsOf(options.params);
3581
4548
  if (!params) return;
3582
- const connection = await connect$1(options, "to try it in");
4549
+ const connection = await connectToSpace(options, "to try it in");
3583
4550
  if (!connection?.space) return;
3584
4551
  const answered = await authorizedRequest(connection, `/spaces/${connection.space.id}/functions/try`, {
3585
4552
  method: "POST",
@@ -3600,7 +4567,7 @@ var tryFunction = async (task, options) => {
3600
4567
  }
3601
4568
  printRun(task, reply.data);
3602
4569
  };
3603
- var isLocalRunner = (value) => isRecord$6(value) && typeof value.createLocalFunctions === "function";
4570
+ var isLocalRunner = (value) => e$1(value) && typeof value.createLocalFunctions === "function";
3604
4571
  /**
3605
4572
  * This project's own runner: `dev` runs the functions with the `@plitzi/sdk-server` the project installed, so the build,
3606
4573
  * the checks, the isolates and the limits are the platform's own, at the version the project is on.
@@ -3618,7 +4585,7 @@ var localCredentials = () => {
3618
4585
  const raw = process.env.PLITZI_FUNCTIONS_CREDENTIALS;
3619
4586
  try {
3620
4587
  const value = raw ? JSON.parse(raw) : {};
3621
- return isRecord$6(value) ? Object.fromEntries(Object.entries(value).flatMap(([id, keys]) => isRecord$6(keys) ? [[id, Object.fromEntries(Object.entries(keys).filter((pair) => typeof pair[1] === "string"))]] : [])) : {};
4588
+ return e$1(value) ? Object.fromEntries(Object.entries(value).flatMap(([id, keys]) => e$1(keys) ? [[id, Object.fromEntries(Object.entries(keys).filter((pair) => typeof pair[1] === "string"))]] : [])) : {};
3622
4589
  } catch {
3623
4590
  return {};
3624
4591
  }
@@ -3649,9 +4616,9 @@ var devFunction = async (task, options) => {
3649
4616
  return;
3650
4617
  }
3651
4618
  const params = paramsOf(options.params);
3652
- const root = await rootOf();
3653
- const runner = params ? await projectRunner(root) : void 0;
3654
- if (!params || !runner) return;
4619
+ const root = params && await rootOf();
4620
+ const runner = root ? await projectRunner(root) : void 0;
4621
+ if (!root || !runner) return;
3655
4622
  const local = runner.createLocalFunctions({ credentials: localCredentials() });
3656
4623
  const run = async () => {
3657
4624
  const loaded = await local.load(await readLocal(root));
@@ -3679,41 +4646,6 @@ var devFunction = async (task, options) => {
3679
4646
  });
3680
4647
  };
3681
4648
  //#endregion
3682
- //#region src/commands/projectFormatter.ts
3683
- var isRecord$5 = (value) => typeof value === "object" && value !== null;
3684
- var isPrettier = (value) => isRecord$5(value) && [
3685
- "format",
3686
- "resolveConfig",
3687
- "getFileInfo"
3688
- ].every((name) => typeof value[name] === "function");
3689
- var unformatted = (_file, text) => Promise.resolve(text);
3690
- /** Loads the project's Prettier once. A project without one formats nothing. */
3691
- var projectFormatter = async (root) => {
3692
- let loaded;
3693
- try {
3694
- const resolved = createRequire(path.join(root, "package.json")).resolve("prettier");
3695
- loaded = await import(pathToFileURL(resolved).href);
3696
- } catch {
3697
- return unformatted;
3698
- }
3699
- const prettier = isPrettier(loaded) ? loaded : isRecord$5(loaded) ? loaded.default : void 0;
3700
- if (!isPrettier(prettier)) return unformatted;
3701
- const api = prettier;
3702
- const ignorePath = [".prettierignore", ".gitignore"].map((file) => path.join(root, file));
3703
- return async (file, text) => {
3704
- const filepath = path.join(root, file);
3705
- const info = await api.getFileInfo(filepath, {
3706
- ignorePath,
3707
- resolveConfig: true
3708
- });
3709
- if (info.ignored || !info.inferredParser) return text;
3710
- return api.format(text, {
3711
- ...await api.resolveConfig(filepath),
3712
- filepath
3713
- });
3714
- };
3715
- };
3716
- //#endregion
3717
4649
  //#region src/scaffold/fromSpace.ts
3718
4650
  var TEXT = /\.((m|c)?(t|j)sx?|json|css|scss|sass|less|md|txt|html|svg|ya?ml|xml|csv|graphql)$/i;
3719
4651
  /** Whether a file of the project is text — written, compared and formatted as such — rather than bytes. */
@@ -4102,8 +5034,7 @@ var projectFromSpace = (exported, spaceSource) => {
4102
5034
  * copy of the whole project beside the project would be the second source of it nobody keeps in step.
4103
5035
  */
4104
5036
  var ORIGIN_FILE = path.join(".plitzi", "space.json");
4105
- var isRecord$4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4106
- var isStrings = (value) => isRecord$4(value) && Object.values(value).every((entry) => typeof entry === "string");
5037
+ var isStrings = (value) => e$1(value) && Object.values(value).every((entry) => typeof entry === "string");
4107
5038
  var digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
4108
5039
  /**
4109
5040
  * A file of the project as a pull compares it: the digest of what the project's formatter makes of it — so a file only
@@ -4127,7 +5058,7 @@ var digestsOf = async (root, file, format) => {
4127
5058
  var readOrigin = async (root) => {
4128
5059
  try {
4129
5060
  const value = JSON.parse(await fs.readFile(path.join(root, ORIGIN_FILE), "utf-8"));
4130
- if (!isRecord$4(value) || value.format !== 1 || typeof value.api !== "string" || !isRecord$4(value.space) || typeof value.space.id !== "number" || typeof value.space.name !== "string" || typeof value.space.permanentUrl !== "string" || value.source !== "local" && value.source !== "cloud" || !isRecord$4(value.version) || typeof value.version.environment !== "string" || value.version.revision !== void 0 && typeof value.version.revision !== "number" || !isStrings(value.files) || !isStrings(value.downloads) || !isStrings(value.dependencies)) return;
5061
+ if (!e$1(value) || value.format !== 1 || typeof value.api !== "string" || !e$1(value.space) || typeof value.space.id !== "number" || typeof value.space.name !== "string" || typeof value.space.permanentUrl !== "string" || value.source !== "local" && value.source !== "cloud" || !e$1(value.version) || typeof value.version.environment !== "string" || value.version.revision !== void 0 && typeof value.version.revision !== "number" || !isStrings(value.files) || !isStrings(value.downloads) || !isStrings(value.dependencies)) return;
4131
5062
  return {
4132
5063
  format: 1,
4133
5064
  api: value.api,
@@ -4171,9 +5102,8 @@ var digestsOnDisk = async (root, files, format) => {
4171
5102
  * `plitzi create --from <space>`: the space as Plitzi keeps it (docs/en/projects-from-spaces.md), asked for as the person — who must be able
4172
5103
  * to change it — and the files of it the project serves itself, fetched once.
4173
5104
  */
4174
- var isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4175
5105
  /** What the platform answered is an export: of the format this CLI writes, with every part it reads. */
4176
- var isExport = (value) => isRecord$3(value) && value.format === 1 && isRecord$3(value.space) && isRecord$3(value.version) && isRecord$3(value.source) && isRecord$3(value.builtOnly) && isRecord$3(value.report) && isRecord$3(value.functions) && Array.isArray(value.actions) && Array.isArray(value.assets) && Array.isArray(value.visitorRoles);
5106
+ var isExport = (value) => e$1(value) && value.format === 1 && e$1(value.space) && e$1(value.version) && e$1(value.source) && e$1(value.builtOnly) && e$1(value.report) && e$1(value.functions) && Array.isArray(value.actions) && Array.isArray(value.assets) && Array.isArray(value.visitorRoles);
4177
5107
  /** How a version is said: `the draft`, `production (latest)`, `production r3`. */
4178
5108
  var versionLabel = ({ environment, revision }) => {
4179
5109
  if (environment === "main") return "the draft";
@@ -4194,19 +5124,16 @@ var fetchExport = async (api, from, { source, version, name = from }) => {
4194
5124
  });
4195
5125
  const answered = await authorizedRequest(connection, `/spaces/${encodeURIComponent(from)}/export?${query.toString()}`);
4196
5126
  if (!answered.ok) {
4197
- console.error(chalk.red(answered.error));
4198
- process.exitCode = 1;
5127
+ fail(answered.error);
4199
5128
  return;
4200
5129
  }
4201
5130
  const { status, data } = answered.value.reply;
4202
5131
  if (status === 200 && isExport(data)) return data;
4203
- const said = isRecord$3(data) && typeof data.error === "string" ? data.error : void 0;
4204
- const why = {
5132
+ const said = e$1(data) && typeof data.error === "string" ? data.error : void 0;
5133
+ fail({
4205
5134
  403: `You may not take ${from} out: it takes being able to change it — its owner, an administrator or a writer.`,
4206
5135
  404: said ?? `There is no space ${from} you can reach.`
4207
- };
4208
- console.error(chalk.red(why[status] ?? said ?? `The platform would not hand ${from} over (${String(status)}).`));
4209
- process.exitCode = 1;
5136
+ }[status] ?? said ?? `The platform would not hand ${from} over (${String(status)}).`);
4210
5137
  };
4211
5138
  /** A file of the space's CDN, fetched. */
4212
5139
  var download = async (url) => {
@@ -4352,20 +5279,17 @@ var create = async (directory, options) => {
4352
5279
  const name = path.basename(target);
4353
5280
  const wasEmpty = await isEmpty(target);
4354
5281
  if (!options.force && !wasEmpty) {
4355
- console.error(chalk.red(`${target} is not empty. Pass --force to write into it anyway.`));
4356
- process.exitCode = 1;
5282
+ fail(`${target} is not empty. Pass --force to write into it anyway.`);
4357
5283
  return;
4358
5284
  }
4359
5285
  if (options.from && options.mode === "client") {
4360
- console.error(chalk.red("A project made from a space runs its own server: leave out --mode, or pass --mode server."));
4361
- process.exitCode = 1;
5286
+ fail("A project made from a space runs its own server: leave out --mode, or pass --mode server.");
4362
5287
  return;
4363
5288
  }
4364
5289
  const environment = options.environment ?? "main";
4365
5290
  const revision = options.revision === void 0 ? void 0 : Number(options.revision);
4366
5291
  if (revision !== void 0 && (!Number.isInteger(revision) || revision < 1 || environment === "main")) {
4367
- console.error(chalk.red(environment === "main" ? "The draft (main) has no revisions: name a published environment with --environment, then --revision." : `--revision takes a revision number from 1, not "${options.revision ?? ""}".`));
4368
- process.exitCode = 1;
5292
+ fail(environment === "main" ? "The draft (main) has no revisions: name a published environment with --environment, then --revision." : `--revision takes a revision number from 1, not "${options.revision ?? ""}".`);
4369
5293
  return;
4370
5294
  }
4371
5295
  const decisions = await resolveDecisions(options.from ? {
@@ -4374,9 +5298,13 @@ var create = async (directory, options) => {
4374
5298
  } : options);
4375
5299
  if (!decisions) return;
4376
5300
  const { packageManager, mode, source } = decisions;
5301
+ const template = CREATE_TEMPLATES.find((candidate) => candidate === (options.template ?? "welcome"));
5302
+ if (!template || options.template !== void 0 && (source === "cloud" || options.from)) {
5303
+ fail(template ? "--template is what a space written in the project starts as: it goes with --source local, without --from." : `--template is ${CREATE_TEMPLATES.join(" or ")}, not "${options.template ?? ""}".`);
5304
+ return;
5305
+ }
4377
5306
  if (revision !== void 0 && !options.from && source === "local") {
4378
- console.error(chalk.red("--revision pins a published version: it goes with --from, or with --source cloud."));
4379
- process.exitCode = 1;
5307
+ fail("--revision pins a published version: it goes with --from, or with --source cloud.");
4380
5308
  return;
4381
5309
  }
4382
5310
  if (source === "cloud" && !options.key && !atTerminal()) {
@@ -4389,14 +5317,14 @@ var create = async (directory, options) => {
4389
5317
  }
4390
5318
  const key = source === "cloud" ? options.key ?? await askForKey(mode) : "";
4391
5319
  if (source === "cloud" && !key) {
4392
- console.error(chalk.red("\nNo key. Mint one under Credentials in the builder, then run this again."));
4393
- process.exitCode = 1;
5320
+ fail("\nNo key. Mint one under Credentials in the builder, then run this again.");
4394
5321
  return;
4395
5322
  }
4396
5323
  const answers = {
4397
5324
  name,
4398
5325
  mode,
4399
5326
  source,
5327
+ ...template === "welcome" ? {} : { template },
4400
5328
  key,
4401
5329
  environment,
4402
5330
  ...revision ? { revision } : {},
@@ -4431,9 +5359,8 @@ var create = async (directory, options) => {
4431
5359
  * `node_modules`.
4432
5360
  */
4433
5361
  if (wantsInstall && !installed) {
4434
- console.error(chalk.red(`\n\`${installCommand(packageManager)}\` failed. The project is written; the reason is in the output above.`));
5362
+ fail(`\n\`${installCommand(packageManager)}\` failed. The project is written; the reason is in the output above.`);
4435
5363
  console.error(chalk.dim(INSTALL_HINTS[packageManager]));
4436
- process.exitCode = 1;
4437
5364
  }
4438
5365
  /**
4439
5366
  * Formatted by the project's own Prettier, once, so the first commit is already in its style — the scaffold writes
@@ -4483,173 +5410,967 @@ var QUESTIONS = {
4483
5410
  choices: ["<folder>"],
4484
5411
  question: "Which folder should the plugin be written to?"
4485
5412
  }
4486
- };
4487
- var splitNames = (list) => list.split(",").map((name) => name.trim()).filter(Boolean);
4488
- /**
4489
- * What is wrong with the other elements asked for, if anything: a name that makes no element, or two elements — the
4490
- * package's own among them — that would share a type, which a space could not tell apart.
4491
- */
4492
- var elementListProblem = (main, others) => {
4493
- for (const name of others) {
4494
- const problem = pluginNameProblem(name);
4495
- if (problem) return problem;
5413
+ };
5414
+ var splitNames = (list) => list.split(",").map((name) => name.trim()).filter(Boolean);
5415
+ /**
5416
+ * What is wrong with the other elements asked for, if anything: a name that makes no element, or two elements — the
5417
+ * package's own among them — that would share a type, which a space could not tell apart.
5418
+ */
5419
+ var elementListProblem = (main, others) => {
5420
+ for (const name of others) {
5421
+ const problem = pluginNameProblem(name);
5422
+ if (problem) return problem;
5423
+ }
5424
+ const types = [pluginNames(main).type, ...others.map((name) => pluginNames(name).type)];
5425
+ const repeated = types.find((type, index) => types.indexOf(type) !== index);
5426
+ return repeated ? `Two elements would both be "${repeated}": give each a name of its own.` : void 0;
5427
+ };
5428
+ /** The folders a repository keeps its packages in, as places for this one — or `./<name>` outside of any. */
5429
+ var folderOptions = (project, base) => {
5430
+ if (!project) return [];
5431
+ const root = project.workspaceRoot ?? project.root;
5432
+ return [.../* @__PURE__ */ new Set([...project.workspaceFolders, "plugins"])].map((folder) => {
5433
+ const value = path.join(root, folder, base);
5434
+ return {
5435
+ label: path.relative(process.cwd(), value) || ".",
5436
+ value
5437
+ };
5438
+ });
5439
+ };
5440
+ var askDirectory = async (rl, project, base) => {
5441
+ const typed = {
5442
+ question: "Which folder? (relative to here)",
5443
+ fallback: `./${base}`
5444
+ };
5445
+ if (!project) return path.resolve(await askText(rl, "Which folder should the plugin be written to?", typed.fallback));
5446
+ const chosen = await askChoice(rl, `Which folder of ${path.relative(process.cwd(), project.workspaceRoot ?? project.root) || "this project"} should the plugin go in?`, folderOptions(project, base), {
5447
+ label: "Somewhere else",
5448
+ ...typed
5449
+ });
5450
+ return path.resolve(chosen);
5451
+ };
5452
+ /**
5453
+ * The manager: the one the repository already installs with, when it is written into one — asking would only offer a
5454
+ * second lockfile — and otherwise asked, as a project's is.
5455
+ */
5456
+ var resolveManager = async (rl, options, project) => {
5457
+ const given = PACKAGE_MANAGERS.find((manager) => manager === options.packageManager);
5458
+ if (given ?? project?.packageManager) return given ?? project?.packageManager;
5459
+ const detected = detectPackageManager();
5460
+ if (!rl || options.yes) return options.yes ? detected : void 0;
5461
+ return ask(rl, {
5462
+ flag: "--package-manager",
5463
+ choices: PACKAGE_MANAGERS,
5464
+ question: `Which package manager will you work in? (this was run through ${detected})`,
5465
+ fallback: detected
5466
+ });
5467
+ };
5468
+ var createPlugin = async (directory, options) => {
5469
+ const nameGiven = options.name ?? (directory ? path.basename(path.resolve(directory)) : void 0);
5470
+ const othersGiven = options.elements === void 0 ? void 0 : splitNames(options.elements);
5471
+ const nameProblem = nameGiven === void 0 ? void 0 : pluginNameProblem(nameGiven) ?? elementListProblem(pluginNames(nameGiven).base, othersGiven ?? []);
5472
+ if (nameProblem) {
5473
+ fail(nameProblem);
5474
+ return;
5475
+ }
5476
+ const here = await findProject(process.cwd());
5477
+ const terminal = atTerminal();
5478
+ if (!terminal && (nameGiven === void 0 || directory === void 0)) {
5479
+ refuseWithoutTerminal("plugin", [...nameGiven === void 0 ? [QUESTIONS.name] : [], ...directory === void 0 ? [QUESTIONS.directory] : []]);
5480
+ return;
5481
+ }
5482
+ const rl = terminal ? readline.createInterface({
5483
+ input: process.stdin,
5484
+ output: process.stdout
5485
+ }) : void 0;
5486
+ try {
5487
+ /**
5488
+ * Everything the plugin is called and says about itself, asked before a file is written — the package name, what
5489
+ * the builder's catalogue shows, what an agent reads to choose it, who publishes it. Each has a default, taken
5490
+ * whole with Enter; only the name and the folder are ever refused for want of an answer.
5491
+ */
5492
+ const name = nameGiven ?? (rl ? await askText(rl, QUESTIONS.name.question, DEFAULT_NAME, pluginNameProblem) : DEFAULT_NAME);
5493
+ const names = pluginNames(name);
5494
+ const answer = async (question, given, fallback) => given ?? (rl && !options.yes ? askText(rl, question, fallback) : fallback);
5495
+ const title = await answer("What does the builder call the element?", options.title, names.title);
5496
+ const description = await answer("What is it for, in a sentence? The builder shows it, and an agent reads it to choose the element.", options.description, "");
5497
+ const owner = await answer("Who publishes it?", options.owner, "");
5498
+ const others = othersGiven ?? (rl && !options.yes ? splitNames(await askText(rl, "Any other elements in this package? Their names, separated by commas.", "", (reply) => elementListProblem(names.base, splitNames(reply)))) : []);
5499
+ const otherElements = [];
5500
+ for (const other of others) {
5501
+ const otherNames = pluginNames(other);
5502
+ otherElements.push({
5503
+ name: other,
5504
+ title: await answer(`What does the builder call ${other}?`, void 0, otherNames.title),
5505
+ description: await answer(`What is ${other} for, in a sentence?`, void 0, "")
5506
+ });
5507
+ }
5508
+ const target = directory ? path.resolve(directory) : rl ? await askDirectory(rl, here, names.base) : void 0;
5509
+ if (!target) return;
5510
+ const wasEmpty = await isEmpty(target);
5511
+ if (!options.force && !wasEmpty) {
5512
+ fail(`${target} is not empty. Pass --force to write into it anyway.`);
5513
+ return;
5514
+ }
5515
+ const project = await findProject(path.dirname(target));
5516
+ const packageManager = await resolveManager(rl, options, project);
5517
+ if (!packageManager) {
5518
+ if (!rl) refuseWithoutTerminal("plugin", [{
5519
+ flag: "--package-manager",
5520
+ choices: PACKAGE_MANAGERS,
5521
+ question: "Which package manager will you work in?"
5522
+ }]);
5523
+ process.exitCode = 1;
5524
+ return;
5525
+ }
5526
+ await writeFiles(target, scaffoldPlugin({
5527
+ packageName: name,
5528
+ elements: [{
5529
+ name: names.base,
5530
+ title,
5531
+ description
5532
+ }, ...otherElements],
5533
+ owner,
5534
+ packageManager,
5535
+ managerVersion: detectManagerVersion(packageManager, await nearestExisting(target)),
5536
+ inProject: project !== void 0
5537
+ }));
5538
+ /**
5539
+ * Installed where it will be installed from. Inside a workspace that covers the folder, installing there installs
5540
+ * the workspace; inside a repository that does not list it, Yarn refuses to install a package it does not know —
5541
+ * so that is said, with the fix, rather than failed at.
5542
+ */
5543
+ const unlisted = project?.workspaceRoot !== void 0 && !coveredByWorkspace(project, target);
5544
+ const wantsInstall = options.install !== false && !unlisted;
5545
+ const installed = wantsInstall && await install(packageManager, target);
5546
+ if (wantsInstall && !installed) {
5547
+ fail(`\n\`${installCommand(packageManager)}\` failed. The plugin is written; the reason is in the output above.`);
5548
+ console.error(chalk.dim(INSTALL_HINTS[packageManager]));
5549
+ }
5550
+ if (installed && wasEmpty) await runScript(packageManager, "format", target);
5551
+ const where = cdPrefix(target);
5552
+ console.log(chalk.green(`\n${name} — the ${names.type} element, as a package any space can load.`));
5553
+ if (unlisted) console.log(chalk.yellow(`\n${path.relative(process.cwd(), target)} is not one of the workspace's packages: add it to \`workspaces\`, then install.`));
5554
+ console.log("");
5555
+ if (!installed) console.log(` ${where}${installCommand(packageManager)}`);
5556
+ console.log(` ${where}${runCommand(packageManager, "start")} # the plugin inside a space`);
5557
+ console.log(` ${where}${runCommand(packageManager, "zip")} # what the builder takes, under Resources`);
5558
+ console.log("");
5559
+ } finally {
5560
+ rl?.close();
5561
+ }
5562
+ };
5563
+ //#endregion
5564
+ //#region src/commands/data.ts
5565
+ var emptyShape = () => ({ primitives: /* @__PURE__ */ new Set() });
5566
+ var isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5567
+ var merge = (shape, value) => {
5568
+ if (value === null) {
5569
+ shape.primitives.add("null");
5570
+ return;
5571
+ }
5572
+ if (Array.isArray(value)) {
5573
+ shape.array ??= {
5574
+ occurrences: 0,
5575
+ lengths: /* @__PURE__ */ new Set(),
5576
+ item: emptyShape()
5577
+ };
5578
+ shape.array.occurrences += 1;
5579
+ shape.array.lengths.add(value.length);
5580
+ for (const item of value) merge(shape.array.item, item);
5581
+ return;
5582
+ }
5583
+ if (isPlainObject(value)) {
5584
+ shape.object ??= {
5585
+ count: 0,
5586
+ fields: /* @__PURE__ */ new Map()
5587
+ };
5588
+ shape.object.count += 1;
5589
+ for (const [key, field] of Object.entries(value)) {
5590
+ const entry = shape.object.fields.get(key) ?? {
5591
+ count: 0,
5592
+ shape: emptyShape()
5593
+ };
5594
+ entry.count += 1;
5595
+ merge(entry.shape, field);
5596
+ shape.object.fields.set(key, entry);
5597
+ }
5598
+ return;
5599
+ }
5600
+ shape.primitives.add(typeof value);
5601
+ };
5602
+ /** Past this many fields an object is summarised: a map keyed by ids is data, not a shape. */
5603
+ var MAX_FIELDS = 40;
5604
+ var PAD = " ";
5605
+ var KEY = /^[A-Za-z_$][\w$]*$/;
5606
+ var render = (shape, depth) => {
5607
+ const parts = [...shape.primitives].sort();
5608
+ if (shape.object) parts.push(renderObject(shape.object, depth));
5609
+ if (shape.array) parts.push(renderArray(shape.array, depth));
5610
+ return parts.length > 0 ? parts.join(" | ") : "never";
5611
+ };
5612
+ var renderArray = ({ occurrences, lengths, item }, depth) => {
5613
+ const inner = render(item, depth);
5614
+ if (occurrences === 1) {
5615
+ const [length] = lengths;
5616
+ return length === 0 ? "Array(0)" : `Array(${String(length)}) of ${inner}`;
5617
+ }
5618
+ return inner.includes(" | ") ? `(${inner})[]` : `${inner}[]`;
5619
+ };
5620
+ var renderObject = ({ count, fields }, depth) => {
5621
+ if (fields.size === 0) return "{}";
5622
+ const indent = PAD.repeat(depth + 1);
5623
+ const entries = [...fields].slice(0, MAX_FIELDS).map(([key, field]) => {
5624
+ const name = KEY.test(key) ? key : JSON.stringify(key);
5625
+ const optional = field.count < count;
5626
+ const seen = optional && count > 1 ? ` (in ${String(field.count)} of ${String(count)})` : "";
5627
+ return `${indent}${name}${optional ? "?" : ""}: ${render(field.shape, depth + 1)}${seen}`;
5628
+ });
5629
+ const more = fields.size > MAX_FIELDS ? [`${indent}… ${String(fields.size - MAX_FIELDS)} more keys`] : [];
5630
+ return [
5631
+ "{",
5632
+ ...entries,
5633
+ ...more,
5634
+ `${PAD.repeat(depth)}}`
5635
+ ].join("\n");
5636
+ };
5637
+ /** The longest list of objects in the document, and where it is — the rows a page most likely renders. */
5638
+ var rowsOf = (value) => {
5639
+ const candidates = [];
5640
+ const visit = (node, at) => {
5641
+ if (Array.isArray(node)) {
5642
+ if (node.some(isPlainObject)) candidates.push({
5643
+ path: at,
5644
+ rows: node
5645
+ });
5646
+ node.forEach((item, index) => visit(item, `${at}[${String(index)}]`));
5647
+ return;
5648
+ }
5649
+ if (isPlainObject(node)) for (const [key, field] of Object.entries(node)) visit(field, at ? `${at}.${key}` : key);
5650
+ };
5651
+ visit(value, "");
5652
+ return candidates.sort((a, b) => b.rows.length - a.rows.length)[0];
5653
+ };
5654
+ /** One row, shortened: long text cut, long lists cut to their first items. */
5655
+ var shorten = (value) => {
5656
+ if (typeof value === "string") return value.length > 80 ? `${value.slice(0, 77)}…` : value;
5657
+ if (Array.isArray(value)) return value.length > 3 ? [...value.slice(0, 3).map(shorten), `… ${String(value.length - 3)} more`] : value.map(shorten);
5658
+ if (isPlainObject(value)) return Object.fromEntries(Object.entries(value).map(([key, field]) => [key, shorten(field)]));
5659
+ return value;
5660
+ };
5661
+ var describeData = (value) => {
5662
+ const shape = emptyShape();
5663
+ merge(shape, value);
5664
+ const rows = rowsOf(value);
5665
+ const first = rows?.rows.find(isPlainObject);
5666
+ return {
5667
+ shape: render(shape, 0),
5668
+ ...rows && first ? { example: {
5669
+ path: rows.path || "(the document)",
5670
+ row: shorten(first)
5671
+ } } : {}
5672
+ };
5673
+ };
5674
+ var dataDescribe = async (file, options) => {
5675
+ let value;
5676
+ try {
5677
+ value = JSON.parse(await fs.readFile(file, "utf-8"));
5678
+ } catch (error) {
5679
+ fail(`${file} is not a JSON file this can read: ${error instanceof Error ? error.message : String(error)}`);
5680
+ return;
5681
+ }
5682
+ const description = describeData(value);
5683
+ if (options.json) {
5684
+ console.log(JSON.stringify(description));
5685
+ return;
5686
+ }
5687
+ console.log(description.shape);
5688
+ if (description.example) {
5689
+ console.log(`\n${chalk.dim(`One row of ${description.example.path}:`)}`);
5690
+ console.log(JSON.stringify(description.example.row, null, 2));
5691
+ }
5692
+ };
5693
+ //#endregion
5694
+ //#region src/commands/explain.ts
5695
+ var kinds = Object.values(EXPLAIN_KINDS).join(", ");
5696
+ var explainCommand = (name, options) => {
5697
+ if (options.list !== void 0) {
5698
+ const kind = explainKindOf(options.list);
5699
+ if (!kind) {
5700
+ fail(`--list takes ${kinds}, not "${options.list}".`);
5701
+ return;
5702
+ }
5703
+ const entries = explainList(kind);
5704
+ console.log(options.json ? JSON.stringify(entries) : entries.map((entry) => `${entry.name.padEnd(32)} ${chalk.dim(entry.summary)}`).join("\n"));
5705
+ return;
5706
+ }
5707
+ if (!name) {
5708
+ fail(`Name what to explain — plitzi explain container — or list a kind: --list ${kinds}.`);
5709
+ return;
5710
+ }
5711
+ const explanations = explain(name);
5712
+ if (explanations.length === 0) {
5713
+ fail(`"${name}" is no element, step, trigger, code or transformer. See what there is: --list ${kinds}.`);
5714
+ return;
5715
+ }
5716
+ console.log(options.json ? JSON.stringify(explanations) : explanations.map(explanationText).join("\n\n"));
5717
+ };
5718
+ //#endregion
5719
+ //#region src/fix/diff.ts
5720
+ /** A line diff of a file, as `diff -u` prints it: the changed lines and two of context around each run of them. */
5721
+ var unifiedDiff = (file, before, after, context = 2) => {
5722
+ const a = before.split("\n");
5723
+ const b = after.split("\n");
5724
+ const common = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
5725
+ for (let i = a.length - 1; i >= 0; i -= 1) for (let j = b.length - 1; j >= 0; j -= 1) common[i][j] = a[i] === b[j] ? common[i + 1][j + 1] + 1 : Math.max(common[i + 1][j], common[i][j + 1]);
5726
+ const lines = [];
5727
+ let i = 0;
5728
+ let j = 0;
5729
+ while (i < a.length || j < b.length) if (i < a.length && j < b.length && a[i] === b[j]) {
5730
+ lines.push({
5731
+ kind: " ",
5732
+ text: a[i],
5733
+ at: i + 1
5734
+ });
5735
+ i += 1;
5736
+ j += 1;
5737
+ } else if (i < a.length && (j === b.length || common[i + 1][j] >= common[i][j + 1])) {
5738
+ lines.push({
5739
+ kind: "-",
5740
+ text: a[i],
5741
+ at: i + 1
5742
+ });
5743
+ i += 1;
5744
+ } else {
5745
+ lines.push({
5746
+ kind: "+",
5747
+ text: b[j],
5748
+ at: i + 1
5749
+ });
5750
+ j += 1;
5751
+ }
5752
+ const keep = lines.map((_line, index) => lines.slice(Math.max(0, index - context), index + context + 1).some((near) => near.kind !== " "));
5753
+ const hunks = [];
5754
+ let open = false;
5755
+ lines.forEach((line, index) => {
5756
+ if (!keep[index]) {
5757
+ open = false;
5758
+ return;
5759
+ }
5760
+ if (!open) {
5761
+ hunks.push(`@@ line ${String(line.at)} @@`);
5762
+ open = true;
5763
+ }
5764
+ hunks.push(`${line.kind}${line.text}`);
5765
+ });
5766
+ return hunks.length === 0 ? "" : [
5767
+ `--- ${file}`,
5768
+ `+++ ${file}`,
5769
+ ...hunks
5770
+ ].join("\n");
5771
+ };
5772
+ //#endregion
5773
+ //#region src/fix/format.ts
5774
+ /**
5775
+ * The edited text formatted as the project formats it — when the file was formatted that way before the edit. One the
5776
+ * project does not keep formatted is written as it is, so the change is the edit and nothing else.
5777
+ */
5778
+ var formatLikeBefore = async (format, file, before, after) => {
5779
+ try {
5780
+ return await format(file, before) === before ? await format(file, after) : after;
5781
+ } catch {
5782
+ return after;
5783
+ }
5784
+ };
5785
+ //#endregion
5786
+ //#region src/fix/plan.ts
5787
+ /** A fix by what it is, to tell whether it is still to be made after the source was edited. */
5788
+ var fixKey = (fix) => `${fix.code} ${fix.elementId ?? ""} ${fix.message}`;
5789
+ /** A problem by its code and its element: the message may change with a fix, the problem is the same problem. */
5790
+ var problemKey = (problem) => `${problem.code} ${problem.elementId ?? ""}`;
5791
+ var projectPlan = ({ space, plugins }) => {
5792
+ try {
5793
+ return planFixes(space, { plugins });
5794
+ } catch (error) {
5795
+ return { problem: `the space is not written as far as its documents — ${refusalOf(error).message.split("\n")[0]}` };
5796
+ }
5797
+ };
5798
+ /**
5799
+ * Why the fixes written are not to be kept, by element: a fix still there to make, a problem that was not there
5800
+ * before. An element named is one whose fixes go back to the author; a reason with no element blames them all.
5801
+ */
5802
+ var verdict = (before, written, after) => {
5803
+ if ("problem" in after) return {
5804
+ reasons: [`the space no longer authors: ${after.problem}`],
5805
+ elements: /* @__PURE__ */ new Set([null])
5806
+ };
5807
+ const still = new Set(after.fixes.map(fixKey));
5808
+ const known = new Set(before.problems.map(problemKey));
5809
+ const stuck = written.filter((fix) => still.has(fixKey(fix)));
5810
+ const added = after.problems.filter((problem) => !known.has(problemKey(problem)));
5811
+ return {
5812
+ reasons: [...stuck.map((fix) => `still to fix: [${fix.code}] ${fix.message}`), ...added.map((problem) => `new problem: [${problem.code}] ${problem.message}`)],
5813
+ elements: /* @__PURE__ */ new Set([...stuck.map((fix) => fix.elementId), ...added.map((problem) => problem.elementId)])
5814
+ };
5815
+ };
5816
+ //#endregion
5817
+ //#region src/fix/sourceEdits.ts
5818
+ var SINGLE = "'";
5819
+ var quoted = (text) => `${SINGLE}${text.replace(/\\/g, "\\\\").replaceAll(SINGLE, `\\${SINGLE}`)}${SINGLE}`;
5820
+ var literalText = (value) => typeof value === "string" ? quoted(value) : String(value);
5821
+ var keyText = (key) => /^[A-Za-z_$][\w$]*$/.test(key) ? key : quoted(key);
5822
+ /** A property's name as written: `mode`, `'aria-label'`. */
5823
+ var nameOf = (ts, property) => {
5824
+ const name = property.name;
5825
+ if (!name) return;
5826
+ return ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name) ? name.text : void 0;
5827
+ };
5828
+ var findProperty = (ts, object, key) => object.properties.find((property) => nameOf(ts, property) === key);
5829
+ /** The object literal a property holds, when it holds one: `attributes: { … }`, `params: { … }`. */
5830
+ var objectAt = (ts, object, key) => {
5831
+ const property = findProperty(ts, object, key);
5832
+ return property && ts.isPropertyAssignment(property) && ts.isObjectLiteralExpression(property.initializer) ? property.initializer : void 0;
5833
+ };
5834
+ /** One item of a comma list taken out, with the comma that went with it. */
5835
+ var removeFromList = (sourceFile, items, index) => {
5836
+ const item = items[index];
5837
+ if (index < items.length - 1) return {
5838
+ start: item.getStart(sourceFile),
5839
+ end: items[index + 1].getStart(sourceFile),
5840
+ text: ""
5841
+ };
5842
+ if (index > 0) return {
5843
+ start: items[index - 1].getEnd(),
5844
+ end: item.getEnd(),
5845
+ text: ""
5846
+ };
5847
+ return {
5848
+ start: item.getStart(sourceFile),
5849
+ end: item.getEnd(),
5850
+ text: ""
5851
+ };
5852
+ };
5853
+ /** The call written at the factory's name: `container(` at that line and column, from 1. */
5854
+ var callAt = (ts, sourceFile, line, column) => {
5855
+ let found;
5856
+ const visit = (node) => {
5857
+ if (found) return;
5858
+ if (ts.isCallExpression(node)) {
5859
+ const callee = ts.isPropertyAccessExpression(node.expression) ? node.expression.name : node.expression;
5860
+ const at = sourceFile.getLineAndCharacterOfPosition(callee.getStart(sourceFile));
5861
+ if (at.line + 1 === line && at.character + 1 === column) {
5862
+ found = node;
5863
+ return;
5864
+ }
5865
+ }
5866
+ ts.forEachChild(node, visit);
5867
+ };
5868
+ visit(sourceFile);
5869
+ return found;
5870
+ };
5871
+ /** The props a factory call was handed: its last object literal — `text('Hi', { … })`, `container({ … })`. */
5872
+ var propsOf = (ts, call) => call.arguments.filter(ts.isObjectLiteralExpression).at(-1);
5873
+ /**
5874
+ * The object a step was written with: itself when it is a literal, a builder's own props (`setState({ … })`), or —
5875
+ * through a wrapper (`named('x', …)`, `when(rule, …)`) — the step it wraps, which is its last argument.
5876
+ */
5877
+ var stepObject = (ts, expression) => {
5878
+ if (ts.isObjectLiteralExpression(expression)) return expression;
5879
+ if (!ts.isCallExpression(expression)) return;
5880
+ const last = expression.arguments.at(-1);
5881
+ if (last && ts.isCallExpression(last)) return stepObject(ts, last);
5882
+ return expression.arguments.filter(ts.isObjectLiteralExpression).at(-1);
5883
+ };
5884
+ var stepIn = (ts, props, flow, index) => {
5885
+ const flows = findProperty(ts, props, "flows");
5886
+ if (!flows || !ts.isPropertyAssignment(flows) || !ts.isArrayLiteralExpression(flows.initializer)) return;
5887
+ const steps = flows.initializer.elements.at(flow);
5888
+ const step = steps && ts.isArrayLiteralExpression(steps) ? steps.elements.at(index) : void 0;
5889
+ return step ? stepObject(ts, step) : void 0;
5890
+ };
5891
+ var isLiteral = (ts, node) => ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) || ts.isNumericLiteral(node) || node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword || node.kind === ts.SyntaxKind.NullKeyword;
5892
+ /** `key: value` added to an object literal, after what it already holds. */
5893
+ var insertInto = (sourceFile, object, key, value) => {
5894
+ const last = object.properties.at(-1);
5895
+ if (!last) return {
5896
+ start: object.getStart(sourceFile),
5897
+ end: object.getEnd(),
5898
+ text: `{ ${keyText(key)}: ${value} }`
5899
+ };
5900
+ return {
5901
+ start: last.getEnd(),
5902
+ end: last.getEnd(),
5903
+ text: `, ${keyText(key)}: ${value}`
5904
+ };
5905
+ };
5906
+ /** An edit to one key of `object` — or of what it nests the key in (`attributes`, `params`). */
5907
+ var editKey = (ts, sourceFile, object, nested, edit) => {
5908
+ const inner = nested === void 0 ? void 0 : objectAt(ts, object, nested);
5909
+ const holder = findProperty(ts, object, edit.key) ? object : inner && findProperty(ts, inner, edit.key) ? inner : void 0;
5910
+ const property = holder ? findProperty(ts, holder, edit.key) : void 0;
5911
+ if (edit.op === "set") {
5912
+ if (edit.value === void 0) return { unplaced: "there is no value to write" };
5913
+ if (!holder || !property) return { change: insertInto(sourceFile, inner ?? object, edit.key, literalText(edit.value)) };
5914
+ if (!ts.isPropertyAssignment(property) || !isLiteral(ts, property.initializer)) return { unplaced: `\`${edit.key}\` is not written as a value there` };
5915
+ return { change: {
5916
+ start: property.initializer.getStart(sourceFile),
5917
+ end: property.initializer.getEnd(),
5918
+ text: literalText(edit.value)
5919
+ } };
5920
+ }
5921
+ if (!holder || !property) return { unplaced: `\`${edit.key}\` is not written in that call` };
5922
+ if (edit.op === "remove") return { change: removeFromList(sourceFile, holder.properties, holder.properties.indexOf(property)) };
5923
+ if (edit.op === "rename" && edit.to !== void 0) {
5924
+ if (ts.isShorthandPropertyAssignment(property)) return { change: {
5925
+ start: property.getStart(sourceFile),
5926
+ end: property.getEnd(),
5927
+ text: `${keyText(edit.to)}: ${property.name.text}`
5928
+ } };
5929
+ if (ts.isPropertyAssignment(property)) return { change: {
5930
+ start: property.name.getStart(sourceFile),
5931
+ end: property.name.getEnd(),
5932
+ text: keyText(edit.to)
5933
+ } };
5934
+ }
5935
+ return { unplaced: `\`${edit.key}\` is not a property that edit applies to` };
5936
+ };
5937
+ /** A binding by its target: a key of `bind: { … }`, or the entry of `bind: [ … ]` whose `to` it is. */
5938
+ var editBinding = (ts, sourceFile, props, edit) => {
5939
+ const bind = findProperty(ts, props, "bind");
5940
+ if (!bind || !ts.isPropertyAssignment(bind)) return { unplaced: "the binding is not written in that call" };
5941
+ const list = bind.initializer;
5942
+ if (edit.op === "replace" && edit.to !== void 0) {
5943
+ let found;
5944
+ const visit = (node) => {
5945
+ if (!found && ts.isStringLiteral(node) && node.text === edit.key && ts.isPropertyAssignment(node.parent) && nameOf(ts, node.parent) === "action") found = node;
5946
+ ts.forEachChild(node, visit);
5947
+ };
5948
+ visit(list);
5949
+ return found ? { change: {
5950
+ start: found.getStart(sourceFile),
5951
+ end: found.getEnd(),
5952
+ text: quoted(edit.to)
5953
+ } } : { unplaced: `"${edit.key}" is not written as a transformer there` };
5954
+ }
5955
+ if (edit.op !== "remove") return { unplaced: "that change to a binding has no one way to be written" };
5956
+ const dropBind = () => ({ change: removeFromList(sourceFile, props.properties, props.properties.indexOf(bind)) });
5957
+ if (ts.isObjectLiteralExpression(list)) {
5958
+ const property = findProperty(ts, list, edit.key);
5959
+ if (!property) return { unplaced: `the binding onto "${edit.key}" is not written there` };
5960
+ return list.properties.length === 1 ? dropBind() : { change: removeFromList(sourceFile, list.properties, list.properties.indexOf(property)) };
5961
+ }
5962
+ if (ts.isArrayLiteralExpression(list)) {
5963
+ const index = list.elements.findIndex((entry) => {
5964
+ if (!ts.isObjectLiteralExpression(entry)) return false;
5965
+ const to = findProperty(ts, entry, "to");
5966
+ return to !== void 0 && ts.isPropertyAssignment(to) && ts.isStringLiteral(to.initializer) && to.initializer.text === edit.key;
5967
+ });
5968
+ if (index === -1) return { unplaced: `the binding onto "${edit.key}" is not written there` };
5969
+ return list.elements.length === 1 ? dropBind() : { change: removeFromList(sourceFile, list.elements, index) };
5970
+ }
5971
+ return { unplaced: "`bind` is not written as a literal there" };
5972
+ };
5973
+ /**
5974
+ * What an edit planned on a spec changes in the source that wrote it: the call at its position, the props it was
5975
+ * handed, and in them only what is written as a literal. Anything else — a value computed, props spread from a
5976
+ * variable, a flow built elsewhere — is left as it was and said, for the author.
5977
+ */
5978
+ var sourceEdit = (ts, sourceFile, position, edit) => {
5979
+ const call = callAt(ts, sourceFile, position.line, position.column);
5980
+ if (!call) return { unplaced: "the call that wrote it is not where it was" };
5981
+ const props = propsOf(ts, call);
5982
+ if (!props) return { unplaced: "the call that wrote it was handed no props to edit" };
5983
+ if (edit.on === "binding") return editBinding(ts, sourceFile, props, edit);
5984
+ if (edit.on === "step") {
5985
+ const step = edit.step ? stepIn(ts, props, edit.step.flow, edit.step.index) : void 0;
5986
+ return step ? editKey(ts, sourceFile, step, "params", edit) : { unplaced: "the step is not written in that call" };
5987
+ }
5988
+ return editKey(ts, sourceFile, props, edit.on === "attribute" ? "attributes" : void 0, edit);
5989
+ };
5990
+ /** The text with every change made — from the end back, so each span is where it was found. Overlaps are refused. */
5991
+ var applyChanges = (text, changes) => {
5992
+ const ordered = [...changes].toSorted((a, b) => b.start - a.start);
5993
+ for (let index = 1; index < ordered.length; index += 1) if (ordered[index].end > ordered[index - 1].start) return;
5994
+ return ordered.reduce((result, change) => result.slice(0, change.start) + change.text + result.slice(change.end), text);
5995
+ };
5996
+ //#endregion
5997
+ //#region src/projectTypeScript.ts
5998
+ /**
5999
+ * The TypeScript a project (or a plugin package) installs, resolved from its own folder rather than the CLI's: its
6000
+ * version is the one its source is written for. Absent when it installs none.
6001
+ */
6002
+ var loadTypeScript = (root) => {
6003
+ try {
6004
+ return createRequire(path.join(root, "package.json"))("typescript");
6005
+ } catch {
6006
+ return;
6007
+ }
6008
+ };
6009
+ //#endregion
6010
+ //#region src/commands/fix.ts
6011
+ var run = promisify(execFile);
6012
+ var editSources = async (root, fixes) => {
6013
+ const ts = loadTypeScript(root);
6014
+ const placed = [];
6015
+ const left = [];
6016
+ const sources = /* @__PURE__ */ new Map();
6017
+ for (const fix of fixes) {
6018
+ if (!fix.edit || !fix.position) {
6019
+ left.push({
6020
+ fix,
6021
+ why: fix.position ? "it has no one way to be written" : "no call of the project wrote it"
6022
+ });
6023
+ continue;
6024
+ }
6025
+ if (!ts) {
6026
+ left.push({
6027
+ fix,
6028
+ why: "the project has no TypeScript to read its source with"
6029
+ });
6030
+ continue;
6031
+ }
6032
+ const file = path.resolve(process.cwd(), fix.position.file);
6033
+ let source = sources.get(file);
6034
+ if (!source) {
6035
+ const text = await fs.readFile(file, "utf-8");
6036
+ source = {
6037
+ text,
6038
+ sourceFile: ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
6039
+ };
6040
+ sources.set(file, source);
6041
+ }
6042
+ const outcome = sourceEdit(ts, source.sourceFile, fix.position, fix.edit);
6043
+ if ("unplaced" in outcome) left.push({
6044
+ fix,
6045
+ why: outcome.unplaced
6046
+ });
6047
+ else placed.push({
6048
+ fix,
6049
+ file,
6050
+ change: outcome.change
6051
+ });
6052
+ }
6053
+ const format = await projectFormatter(root);
6054
+ const files = [];
6055
+ const written = [];
6056
+ for (const [file, { text }] of sources) {
6057
+ const mine = placed.filter((entry) => entry.file === file);
6058
+ const changed = applyChanges(text, mine.map((entry) => entry.change));
6059
+ if (changed === void 0) {
6060
+ left.push(...mine.map((entry) => ({
6061
+ fix: entry.fix,
6062
+ why: "it overlaps another fix in the same place"
6063
+ })));
6064
+ continue;
6065
+ }
6066
+ if (mine.length > 0) {
6067
+ files.push({
6068
+ file,
6069
+ before: text,
6070
+ after: await formatLikeBefore(format, path.relative(root, file), text, changed)
6071
+ });
6072
+ written.push(...mine.map((entry) => entry.fix));
6073
+ }
6074
+ }
6075
+ return {
6076
+ files,
6077
+ written,
6078
+ left
6079
+ };
6080
+ };
6081
+ /** A plan as `plitzi fix --json` prints it. */
6082
+ var planFromJson = (stdout) => {
6083
+ const parsed = JSON.parse(stdout);
6084
+ if (!e$1(parsed)) return { problem: "the space did not answer as a plan" };
6085
+ if (typeof parsed.problem === "string") return { problem: parsed.problem };
6086
+ const entries = (value) => Array.isArray(value) ? value.filter(e$1) : [];
6087
+ const elementOf = (entry) => typeof entry.elementId === "string" ? entry.elementId : null;
6088
+ return {
6089
+ fixes: entries(parsed.fixes).map((entry) => ({
6090
+ code: String(entry.code),
6091
+ elementId: elementOf(entry),
6092
+ message: String(entry.message)
6093
+ })),
6094
+ problems: entries(parsed.problems).map((entry) => ({
6095
+ code: String(entry.code),
6096
+ elementId: elementOf(entry),
6097
+ message: String(entry.message)
6098
+ }))
6099
+ };
6100
+ };
6101
+ /** The plan, made in a process of its own: the edited files are imported fresh, never from this one's module cache. */
6102
+ var planAfresh = async () => {
6103
+ try {
6104
+ const { stdout } = await run(process.execPath, [
6105
+ process.argv[1],
6106
+ "fix",
6107
+ "--json"
6108
+ ], {
6109
+ cwd: process.cwd(),
6110
+ maxBuffer: 16777216
6111
+ });
6112
+ return planFromJson(stdout);
6113
+ } catch (error) {
6114
+ const printed = e$1(error) && typeof error.stdout === "string" ? error.stdout.trim() : "";
6115
+ if (printed.startsWith("{")) return planFromJson(printed);
6116
+ return { problem: error instanceof Error ? error.message.split("\n").slice(0, 3).join(" ") : String(error) };
6117
+ }
6118
+ };
6119
+ var leftText = (left) => left.length === 0 ? [] : [chalk.yellow(`${String(left.length)} left for you:`), ...left.map(({ fix, why }) => ` - ${fix.at ? `${fix.at} ` : ""}[${fix.code}] ${fix.message} (${why})`)];
6120
+ var fix = async (options) => {
6121
+ const project = await findProject(process.cwd());
6122
+ if (!project || project.plitzi?.kind !== "project" || project.plitzi.source !== "local") {
6123
+ fail("Run this in a project whose space is written in it (`src/space.ts`): one kept on Plitzi is fixed in the builder.");
6124
+ return;
6125
+ }
6126
+ const loaded = await loadProjectSpace(project.root);
6127
+ if ("problem" in loaded) {
6128
+ fail(loaded.problem);
6129
+ return;
6130
+ }
6131
+ const plan = projectPlan(loaded);
6132
+ if (options.json) {
6133
+ console.log(JSON.stringify(plan));
6134
+ if ("problem" in plan) process.exitCode = 1;
6135
+ return;
6136
+ }
6137
+ if ("problem" in plan) {
6138
+ fail(`Nothing to plan: ${plan.problem}. \`npm run author\` says what to change first.`);
6139
+ return;
6140
+ }
6141
+ if (plan.fixes.length === 0) {
6142
+ console.log(chalk.green("Nothing to fix."));
6143
+ return;
6144
+ }
6145
+ const edited = await editSources(project.root, plan.fixes);
6146
+ const diffs = edited.files.map(({ file, before, after }) => unifiedDiff(path.relative(process.cwd(), file), before, after));
6147
+ if (!options.write) {
6148
+ console.log([
6149
+ ...diffs,
6150
+ ...edited.written.length > 0 ? [chalk.green(`${String(edited.written.length)} to write: plitzi fix --write`)] : [],
6151
+ ...leftText(edited.left)
6152
+ ].join("\n"));
6153
+ return;
6154
+ }
6155
+ let attempt = edited;
6156
+ const refused = [];
6157
+ for (;;) {
6158
+ await Promise.all(attempt.files.map(({ file, after }) => fs.writeFile(file, after)));
6159
+ const { reasons, elements } = verdict(plan, attempt.written, await planAfresh());
6160
+ if (reasons.length === 0) break;
6161
+ await Promise.all(attempt.files.map(({ file, before }) => fs.writeFile(file, before)));
6162
+ const blamed = attempt.written.filter((entry) => elements.has(null) || elements.has(entry.elementId));
6163
+ const why = `writing it would leave the space with ${reasons.join("; ")}`;
6164
+ refused.push(...blamed.map((entry) => ({
6165
+ fix: entry,
6166
+ why
6167
+ })));
6168
+ const rest = attempt.written.filter((entry) => !blamed.includes(entry));
6169
+ if (blamed.length === 0 || rest.length === 0) {
6170
+ fail(["Nothing was written — the source is as it was:", ...reasons.map((reason) => ` - ${reason}`)].join("\n"));
6171
+ console.log(leftText([...edited.left, ...refused]).join("\n"));
6172
+ return;
6173
+ }
6174
+ attempt = await editSources(project.root, rest);
4496
6175
  }
4497
- const types = [pluginNames(main).type, ...others.map((name) => pluginNames(name).type)];
4498
- const repeated = types.find((type, index) => types.indexOf(type) !== index);
4499
- return repeated ? `Two elements would both be "${repeated}": give each a name of its own.` : void 0;
6176
+ console.log([
6177
+ ...attempt.files.map(({ file, before, after }) => unifiedDiff(path.relative(process.cwd(), file), before, after)),
6178
+ chalk.green(`Wrote ${String(attempt.written.length)} ${attempt.written.length === 1 ? "fix" : "fixes"} in ${String(attempt.files.length)} ${attempt.files.length === 1 ? "file" : "files"}; the space authors with them gone. Typecheck the project before calling it done.`),
6179
+ ...leftText([...edited.left, ...refused])
6180
+ ].join("\n"));
4500
6181
  };
4501
- /** The folders a repository keeps its packages in, as places for this one — or `./<name>` outside of any. */
4502
- var folderOptions = (project, base) => {
4503
- if (!project) return [];
4504
- const root = project.workspaceRoot ?? project.root;
4505
- return [.../* @__PURE__ */ new Set([...project.workspaceFolders, "plugins"])].map((folder) => {
4506
- const value = path.join(root, folder, base);
4507
- return {
4508
- label: path.relative(process.cwd(), value) || ".",
4509
- value
4510
- };
4511
- });
6182
+ //#endregion
6183
+ //#region src/commands/siteOwnership.ts
6184
+ var LOOPBACK = /^(127\.|::1$|::ffff:127\.)/;
6185
+ /** Served from this machine: `localhost`, or a name that resolves only to loopback addresses (a `/etc/hosts` entry). */
6186
+ var isThisMachine = async (host) => {
6187
+ const name = host.replace(/^\[|\]$/g, "").toLowerCase();
6188
+ if (name === "localhost" || name.endsWith(".localhost")) return true;
6189
+ try {
6190
+ const addresses = await lookup(name, { all: true });
6191
+ return addresses.length > 0 && addresses.every(({ address }) => LOOPBACK.test(address));
6192
+ } catch {
6193
+ return false;
6194
+ }
4512
6195
  };
4513
- var askDirectory = async (rl, project, base) => {
4514
- const typed = {
4515
- question: "Which folder? (relative to here)",
4516
- fallback: `./${base}`
6196
+ var NOT_YOURS = (host) => `${host} is not a verified domain of any space of yours, so it is not imported. If the site is yours: add its domain to one of your spaces under Domains in the dashboard, publish the \`_plitzi\` TXT record it shows, verify it, and import again.`;
6197
+ var siteOwnership = async (url, options) => {
6198
+ if (await isThisMachine(url.hostname)) return {
6199
+ ok: true,
6200
+ said: "served from this machine"
6201
+ };
6202
+ const api = await apiFor(options);
6203
+ const connection = api && await signedIn(api, `to show ${url.hostname} is yours`);
6204
+ if (!connection) return { ok: false };
6205
+ const answered = await authorizedRequest(connection, `/account/domains/covering?host=${encodeURIComponent(url.hostname)}`);
6206
+ if (!answered.ok) return {
6207
+ ok: false,
6208
+ problem: answered.error
6209
+ };
6210
+ const { status, data } = answered.value.reply;
6211
+ const space = e$1(data) && e$1(data.space) && typeof data.space.name === "string" ? data.space.name : "";
6212
+ if (status === 200 && e$1(data) && typeof data.domain === "string") return {
6213
+ ok: true,
6214
+ said: `${data.domain}, verified for ${space || "one of your spaces"}`
6215
+ };
6216
+ if (status === 404) return {
6217
+ ok: false,
6218
+ problem: NOT_YOURS(url.hostname)
6219
+ };
6220
+ return {
6221
+ ok: false,
6222
+ problem: `The platform could not say whether ${url.hostname} is yours (${String(status)}).`
4517
6223
  };
4518
- if (!project) return path.resolve(await askText(rl, "Which folder should the plugin be written to?", typed.fallback));
4519
- const chosen = await askChoice(rl, `Which folder of ${path.relative(process.cwd(), project.workspaceRoot ?? project.root) || "this project"} should the plugin go in?`, folderOptions(project, base), {
4520
- label: "Somewhere else",
4521
- ...typed
4522
- });
4523
- return path.resolve(chosen);
4524
6224
  };
4525
- /**
4526
- * The manager: the one the repository already installs with, when it is written into one — asking would only offer a
4527
- * second lockfile — and otherwise asked, as a project's is.
4528
- */
4529
- var resolveManager = async (rl, options, project) => {
4530
- const given = PACKAGE_MANAGERS.find((manager) => manager === options.packageManager);
4531
- if (given ?? project?.packageManager) return given ?? project?.packageManager;
4532
- const detected = detectPackageManager();
4533
- if (!rl || options.yes) return options.yes ? detected : void 0;
4534
- return ask(rl, {
4535
- flag: "--package-manager",
4536
- choices: PACKAGE_MANAGERS,
4537
- question: `Which package manager will you work in? (this was run through ${detected})`,
4538
- fallback: detected
6225
+ //#endregion
6226
+ //#region src/commands/importPage.ts
6227
+ var OUTLINE_DEPTH = 4;
6228
+ /** Scrolls to the end and back, so pictures and sections that load as they come into view are there to measure. */
6229
+ var revealLazyContent = async (page) => {
6230
+ await page.evaluate(async () => {
6231
+ const step = window.innerHeight;
6232
+ for (let y = 0; y < Math.min(document.body.scrollHeight, 3e4); y += step) {
6233
+ window.scrollTo(0, y);
6234
+ await new Promise((resolve) => setTimeout(resolve, 60));
6235
+ }
6236
+ window.scrollTo(0, 0);
6237
+ }, void 0);
6238
+ await page.waitForTimeout(300);
6239
+ };
6240
+ var measure = async (browser, url, width, scheme, read = []) => {
6241
+ const page = await browser.newPage({
6242
+ viewport: {
6243
+ width,
6244
+ height: 900
6245
+ },
6246
+ colorScheme: scheme,
6247
+ reducedMotion: "reduce"
4539
6248
  });
6249
+ const answered = await page.goto(url, { waitUntil: "networkidle" }).catch((error) => error);
6250
+ if (answered instanceof Error) return { problem: `${url} did not load at ${String(width)} px: ${answered.message.split("\n")[0] ?? ""}` };
6251
+ await revealLazyContent(page);
6252
+ return {
6253
+ probe: await page.evaluate(importProbe, {
6254
+ depth: OUTLINE_DEPTH,
6255
+ read
6256
+ }),
6257
+ screen: await page.screenshot({ fullPage: true })
6258
+ };
4540
6259
  };
4541
- var createPlugin = async (directory, options) => {
4542
- const nameGiven = options.name ?? (directory ? path.basename(path.resolve(directory)) : void 0);
4543
- const othersGiven = options.elements === void 0 ? void 0 : splitNames(options.elements);
4544
- const nameProblem = nameGiven === void 0 ? void 0 : pluginNameProblem(nameGiven) ?? elementListProblem(pluginNames(nameGiven).base, othersGiven ?? []);
4545
- if (nameProblem) {
4546
- console.error(chalk.red(nameProblem));
4547
- process.exitCode = 1;
6260
+ /** Every file under a folder, by its path inside it — none when there is no such folder. */
6261
+ var filesUnder = async (folder) => {
6262
+ return (await readdir(folder, {
6263
+ recursive: true,
6264
+ withFileTypes: true
6265
+ }).catch(() => [])).filter((entry) => entry.isFile()).map((entry) => path.relative(folder, path.join(entry.parentPath, entry.name)));
6266
+ };
6267
+ var count = (amount, one, many = `${one}s`) => `${String(amount)} ${amount === 1 ? one : many}`;
6268
+ var summaryText = (summary, out, stale) => [
6269
+ chalk.green(`Imported ${summary.url} into ${out}`),
6270
+ ` tokens ${count(summary.colours, "colour")} (${summary.dark ? "light and dark" : "no dark scheme: dark repeats light"}), ${count(summary.shadows, "shadow")}, ${count(summary.radii, "radius", "radii")}${summary.fonts.length > 0 ? `, fonts ${summary.fonts.join(", ")}` : ""}`,
6271
+ ` outline ${count(summary.blocks, "block")} at ${summary.widths.join(", ")} px`,
6272
+ ...summary.lists.length > 0 ? [` lists ${summary.lists.map((list) => `${list.file} (${count(list.rows, "row")})`).join(", ")}`] : [],
6273
+ ` pictures ${String(summary.pictures)} in assets.json`,
6274
+ ...stale.length > 0 ? [chalk.yellow(` earlier ${stale.join(", ")} ${stale.length === 1 ? "was" : "were"} there before and not written this time: delete what is stale`)] : [],
6275
+ ` next ${path.join(out, "IMPORT.md")} says what was not carried over`
6276
+ ].join("\n");
6277
+ var importPage = async (address, options) => {
6278
+ let url;
6279
+ try {
6280
+ url = new URL(address);
6281
+ } catch {
6282
+ fail(`${address} is not a URL: give the whole address, https://example.com/page.`);
4548
6283
  return;
4549
6284
  }
4550
- const here = await findProject(process.cwd());
4551
- const terminal = atTerminal();
4552
- if (!terminal && (nameGiven === void 0 || directory === void 0)) {
4553
- refuseWithoutTerminal("plugin", [...nameGiven === void 0 ? [QUESTIONS.name] : [], ...directory === void 0 ? [QUESTIONS.directory] : []]);
6285
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
6286
+ fail(`${address} is not a web page: only http and https addresses are imported.`);
6287
+ return;
6288
+ }
6289
+ const project = await projectHere("to import into");
6290
+ if (!project) return;
6291
+ const out = options.out ?? "src/imported";
6292
+ const target = path.resolve(project.root, out);
6293
+ if (path.relative(project.root, target).startsWith("..")) {
6294
+ fail(`${out} is outside the project: the import is written inside it.`);
6295
+ return;
6296
+ }
6297
+ const existing = await filesUnder(target);
6298
+ if (existing.length > 0 && !options.force) {
6299
+ fail(`${out} already has files: choose another --out, or --force to write over them.`);
6300
+ return;
6301
+ }
6302
+ const widths = options.width ?? [
6303
+ 1440,
6304
+ 768,
6305
+ 390
6306
+ ];
6307
+ const ownership = await siteOwnership(url, options);
6308
+ if (!ownership.ok) {
6309
+ if (ownership.problem) fail(ownership.problem);
6310
+ return;
6311
+ }
6312
+ if (!options.json) console.log(chalk.dim(`${url.hostname} is yours: ${ownership.said}.`));
6313
+ const browser = await launchBrowser(project.root);
6314
+ if ("problem" in browser) {
6315
+ fail(browser.problem);
4554
6316
  return;
4555
6317
  }
4556
- const rl = terminal ? readline.createInterface({
4557
- input: process.stdin,
4558
- output: process.stdout
4559
- }) : void 0;
4560
6318
  try {
4561
- /**
4562
- * Everything the plugin is called and says about itself, asked before a file is written — the package name, what
4563
- * the builder's catalogue shows, what an agent reads to choose it, who publishes it. Each has a default, taken
4564
- * whole with Enter; only the name and the folder are ever refused for want of an answer.
4565
- */
4566
- const name = nameGiven ?? (rl ? await askText(rl, QUESTIONS.name.question, DEFAULT_NAME, pluginNameProblem) : DEFAULT_NAME);
4567
- const names = pluginNames(name);
4568
- const answer = async (question, given, fallback) => given ?? (rl && !options.yes ? askText(rl, question, fallback) : fallback);
4569
- const title = await answer("What does the builder call the element?", options.title, names.title);
4570
- const description = await answer("What is it for, in a sentence? The builder shows it, and an agent reads it to choose the element.", options.description, "");
4571
- const owner = await answer("Who publishes it?", options.owner, "");
4572
- const others = othersGiven ?? (rl && !options.yes ? splitNames(await askText(rl, "Any other elements in this package? Their names, separated by commas.", "", (reply) => elementListProblem(names.base, splitNames(reply)))) : []);
4573
- const otherElements = [];
4574
- for (const other of others) {
4575
- const otherNames = pluginNames(other);
4576
- otherElements.push({
4577
- name: other,
4578
- title: await answer(`What does the builder call ${other}?`, void 0, otherNames.title),
4579
- description: await answer(`What is ${other} for, in a sentence?`, void 0, "")
4580
- });
4581
- }
4582
- const target = directory ? path.resolve(directory) : rl ? await askDirectory(rl, here, names.base) : void 0;
4583
- if (!target) return;
4584
- const wasEmpty = await isEmpty(target);
4585
- if (!options.force && !wasEmpty) {
4586
- console.error(chalk.red(`${target} is not empty. Pass --force to write into it anyway.`));
4587
- process.exitCode = 1;
4588
- return;
6319
+ const measured = [];
6320
+ for (const width of widths) {
6321
+ const result = await measure(browser, url.href, width, "light");
6322
+ if ("problem" in result) {
6323
+ fail(result.problem);
6324
+ return;
6325
+ }
6326
+ measured.push(result);
4589
6327
  }
4590
- const project = await findProject(path.dirname(target));
4591
- const packageManager = await resolveManager(rl, options, project);
4592
- if (!packageManager) {
4593
- if (!rl) refuseWithoutTerminal("plugin", [{
4594
- flag: "--package-manager",
4595
- choices: PACKAGE_MANAGERS,
4596
- question: "Which package manager will you work in?"
4597
- }]);
4598
- process.exitCode = 1;
4599
- return;
6328
+ const widest = measured.reduce((wide, next) => next.probe.width > wide.probe.width ? next : wide).probe;
6329
+ const darkResult = await measure(browser, url.href, widest.width, "dark", Object.values(widest.colors.samples));
6330
+ const dark = "probe" in darkResult ? darkScheme(widest, darkResult.probe) : void 0;
6331
+ const { files, summary } = importedFiles({
6332
+ probes: measured.map(({ probe }) => probe),
6333
+ ...dark ? { dark } : {}
6334
+ });
6335
+ const format = await projectFormatter(project.root);
6336
+ await mkdir(path.join(target, "screens"), { recursive: true });
6337
+ for (const file of files) {
6338
+ const destination = path.join(target, file.path);
6339
+ await mkdir(path.dirname(destination), { recursive: true });
6340
+ const relative = path.relative(project.root, destination);
6341
+ await writeFile(destination, file.path.endsWith(".ts") ? await format(relative, file.content) : file.content);
4600
6342
  }
4601
- await writeFiles(target, scaffoldPlugin({
4602
- packageName: name,
4603
- elements: [{
4604
- name: names.base,
4605
- title,
4606
- description
4607
- }, ...otherElements],
4608
- owner,
4609
- packageManager,
4610
- managerVersion: detectManagerVersion(packageManager, await nearestExisting(target)),
4611
- inProject: project !== void 0
6343
+ const screens = measured.map(({ probe, screen }) => ({
6344
+ path: `screens/${String(probe.width)}.png`,
6345
+ screen
4612
6346
  }));
4613
- /**
4614
- * Installed where it will be installed from. Inside a workspace that covers the folder, installing there installs
4615
- * the workspace; inside a repository that does not list it, Yarn refuses to install a package it does not know —
4616
- * so that is said, with the fix, rather than failed at.
4617
- */
4618
- const unlisted = project?.workspaceRoot !== void 0 && !coveredByWorkspace(project, target);
4619
- const wantsInstall = options.install !== false && !unlisted;
4620
- const installed = wantsInstall && await install(packageManager, target);
4621
- if (wantsInstall && !installed) {
4622
- console.error(chalk.red(`\n\`${installCommand(packageManager)}\` failed. The plugin is written; the reason is in the output above.`));
4623
- console.error(chalk.dim(INSTALL_HINTS[packageManager]));
4624
- process.exitCode = 1;
4625
- }
4626
- if (installed && wasEmpty) await runScript(packageManager, "format", target);
4627
- const where = cdPrefix(target);
4628
- console.log(chalk.green(`\n${name} — the ${names.type} element, as a package any space can load.`));
4629
- if (unlisted) console.log(chalk.yellow(`\n${path.relative(process.cwd(), target)} is not one of the workspace's packages: add it to \`workspaces\`, then install.`));
4630
- console.log("");
4631
- if (!installed) console.log(` ${where}${installCommand(packageManager)}`);
4632
- console.log(` ${where}${runCommand(packageManager, "start")} # the plugin inside a space`);
4633
- console.log(` ${where}${runCommand(packageManager, "zip")} # what the builder takes, under Resources`);
4634
- console.log("");
6347
+ for (const { path: file, screen } of screens) await writeFile(path.join(target, file), screen);
6348
+ const written = /* @__PURE__ */ new Set([...files.map((file) => file.path), ...screens.map((screen) => screen.path)]);
6349
+ const stale = existing.filter((file) => !written.has(file.split(path.sep).join("/")));
6350
+ console.log(options.json ? JSON.stringify({
6351
+ ...summary,
6352
+ out,
6353
+ files: [...written],
6354
+ stale
6355
+ }) : summaryText(summary, out, stale));
4635
6356
  } finally {
4636
- rl?.close();
6357
+ await browser.close();
4637
6358
  }
4638
6359
  };
4639
6360
  //#endregion
4640
6361
  //#region ../../packages/sdk-shared/dist/source/snapshot.mjs
4641
- var t = {
6362
+ var n = {
4642
6363
  files: 2e3,
4643
6364
  fileBytes: 4194304,
4644
6365
  totalBytes: 25165824
4645
6366
  };
4646
- var n = ["plugin", "runtime"];
4647
- var r = [
6367
+ var r = ["plugin", "runtime"];
6368
+ var i = [
4648
6369
  /(^|\/)\.env(\.|$)/,
4649
6370
  /\.(pem|key|p12|pfx)$/i,
4650
6371
  /(^|\/)id_(rsa|dsa|ecdsa|ed25519)$/
4651
6372
  ];
4652
- var i = [
6373
+ var a = [
4653
6374
  {
4654
6375
  what: "a private key",
4655
6376
  pattern: /-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----/
@@ -4671,55 +6392,54 @@ var i = [
4671
6392
  pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/
4672
6393
  }
4673
6394
  ];
4674
- var a = (e) => {
6395
+ var o = (e) => {
4675
6396
  let t = e.split("/");
4676
6397
  if (!e || e.startsWith("/") || /^[a-zA-Z]:/.test(e) || e.includes("\\")) return `"${e}" is not a path relative to the project, with forward slashes`;
4677
6398
  if (t.some((e) => e === "" || e === "." || e === "..")) return `"${e}" climbs out of the project, or has an empty part`;
4678
6399
  if (t.some((e) => e === "node_modules" || e === ".git")) return `"${e}" is in node_modules or .git: a package is a dependency, not a file of the project`;
4679
- if (r.some((t) => t.test(e))) return `"${e}" is a file of credentials: they stay where the project runs, never in what Plitzi keeps`;
4680
- };
4681
- var o = (e) => i.find(({ pattern: t }) => t.test(e))?.what;
4682
- var s = (e) => typeof e == "string" && n.some((t) => t === e);
4683
- var c = (e) => typeof e == "string" && /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(e);
4684
- var l = (e) => Array.isArray(e) && e.length > 0 && e.every((e) => typeof e == "string");
4685
- var u = (e) => typeof e == "object" && !!e && !Array.isArray(e);
4686
- var d = (e) => u(e) && Object.values(e).every((e) => typeof e == "string");
6400
+ if (i.some((t) => t.test(e))) return `"${e}" is a file of credentials: they stay where the project runs, never in what Plitzi keeps`;
6401
+ };
6402
+ var s = (e) => a.find(({ pattern: t }) => t.test(e))?.what;
6403
+ var c = (e) => typeof e == "string" && r.some((t) => t === e);
6404
+ var l = (e) => typeof e == "string" && /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(e);
6405
+ var u = (e) => Array.isArray(e) && e.length > 0 && e.every((e) => typeof e == "string");
6406
+ var d = (t) => e$1(t) && Object.values(t).every((e) => typeof e == "string");
4687
6407
  var f = (e) => Math.floor(e.length * 3 / 4) - (e.endsWith("==") ? 2 : +!!e.endsWith("="));
4688
- var p = (e, r) => {
4689
- if (!u(e) || e.format !== 1) return {
6408
+ var p = (t, i) => {
6409
+ if (!e$1(t) || t.format !== 1) return {
4690
6410
  ok: !1,
4691
6411
  problems: ["Not a source snapshot of format 1"]
4692
6412
  };
4693
- let { kind: i, name: p, entries: m, files: h, dependencies: g } = e, _ = [];
4694
- if (s(i) || _.push(`Its kind is one of ${n.join(", ")}`), c(p) || _.push("Its name is a plugin type or \"runtime\": letters, digits, dashes and underscores"), !d(h) || !d(g)) return {
6413
+ let { kind: a, name: p, entries: m, files: h, dependencies: g } = t, _ = [];
6414
+ if (c(a) || _.push(`Its kind is one of ${r.join(", ")}`), l(p) || _.push("Its name is a plugin type or \"runtime\": letters, digits, dashes and underscores"), !d(h) || !d(g)) return {
4695
6415
  ok: !1,
4696
6416
  problems: [..._, "Its files and dependencies are maps of text"]
4697
6417
  };
4698
- l(m) ? m.filter((e) => !Object.hasOwn(h, e)).forEach((e) => _.push(`Its entry "${e}" is not among its files`)) : _.push("It names at least one entry");
6418
+ u(m) ? m.filter((e) => !Object.hasOwn(h, e)).forEach((e) => _.push(`Its entry "${e}" is not among its files`)) : _.push("It names at least one entry");
4699
6419
  let v = Object.keys(h);
4700
- v.length > t.files && _.push(`It holds ${String(v.length)} files; a snapshot holds at most ${String(t.files)}`);
6420
+ v.length > n.files && _.push(`It holds ${String(v.length)} files; a snapshot holds at most ${String(n.files)}`);
4701
6421
  let y = 0;
4702
6422
  return v.forEach((e) => {
4703
- let n = a(e);
4704
- if (n) {
4705
- _.push(n);
6423
+ let t = o(e);
6424
+ if (t) {
6425
+ _.push(t);
4706
6426
  return;
4707
6427
  }
4708
- let i = f(h[e]);
4709
- if (y += i, i > t.fileBytes) {
4710
- _.push(`"${e}" is ${(i / 1024 / 1024).toFixed(1)} MB; a file of source is at most 4 MB`);
6428
+ let r = f(h[e]);
6429
+ if (y += r, r > n.fileBytes) {
6430
+ _.push(`"${e}" is ${(r / 1024 / 1024).toFixed(1)} MB; a file of source is at most 4 MB`);
4711
6431
  return;
4712
6432
  }
4713
- let s = o(r(h[e]));
4714
- s && _.push(`"${e}" holds ${s}: take it out of the code and give it to the project as a variable`);
4715
- }), y > t.totalBytes && _.push(`It holds ${(y / 1024 / 1024).toFixed(1)} MB of files; a snapshot holds at most 24 MB`), _.length > 0 || !s(i) || !c(p) || !l(m) ? {
6433
+ let a = s(i(h[e]));
6434
+ a && _.push(`"${e}" holds ${a}: take it out of the code and give it to the project as a variable`);
6435
+ }), y > n.totalBytes && _.push(`It holds ${(y / 1024 / 1024).toFixed(1)} MB of files; a snapshot holds at most 24 MB`), _.length > 0 || !c(a) || !l(p) || !u(m) ? {
4716
6436
  ok: !1,
4717
6437
  problems: _
4718
6438
  } : {
4719
6439
  ok: !0,
4720
6440
  snapshot: {
4721
6441
  format: 1,
4722
- kind: i,
6442
+ kind: a,
4723
6443
  name: p,
4724
6444
  entries: m,
4725
6445
  files: h,
@@ -4746,13 +6466,6 @@ var EXTENSIONS = [
4746
6466
  ".scss"
4747
6467
  ];
4748
6468
  var BUILTINS = new Set(builtinModules);
4749
- var loadTypeScript$1 = (root) => {
4750
- try {
4751
- return createRequire(path.join(root, "package.json"))("typescript");
4752
- } catch {
4753
- return;
4754
- }
4755
- };
4756
6469
  var isFile = async (file) => {
4757
6470
  try {
4758
6471
  return (await fs.stat(file)).isFile();
@@ -4803,7 +6516,7 @@ var dependencyOf = async (root, name) => {
4803
6516
  * reaches holds a credential.
4804
6517
  */
4805
6518
  var packSource = async ({ root, kind, name, entries }) => {
4806
- const ts = loadTypeScript$1(root);
6519
+ const ts = loadTypeScript(root);
4807
6520
  if (!ts) throw new Error("Packing the source reads its imports with the project’s own TypeScript:\n npm install -D typescript");
4808
6521
  const problems = [];
4809
6522
  const files = {};
@@ -4859,13 +6572,6 @@ var packSource = async ({ root, kind, name, entries }) => {
4859
6572
  };
4860
6573
  //#endregion
4861
6574
  //#region src/pack/typeDeclarations.ts
4862
- var loadTypeScript = (root) => {
4863
- try {
4864
- return createRequire(path.join(root, "package.json"))("typescript");
4865
- } catch {
4866
- return;
4867
- }
4868
- };
4869
6575
  var emitTypeDeclarations = (root, outDir) => {
4870
6576
  const ts = loadTypeScript(root);
4871
6577
  if (!ts) return "no-typescript";
@@ -4929,21 +6635,20 @@ var INLINED = [
4929
6635
  ];
4930
6636
  /** A plugin that cannot be packed, with what to change — never a stack trace out of esbuild. */
4931
6637
  var PackError = class extends Error {};
4932
- var isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4933
6638
  /** The declaration, or what is missing from it — named, so the fix is one edit away. */
4934
6639
  var checkDeclaration = (value, where) => {
4935
6640
  const missing = (field) => new PackError(`${where}: the declaration has no ${field}. Write it the way \`plitzi add plugin\` does.`);
4936
- if (!isRecord$2(value) || typeof value.type !== "string" || !value.type) throw missing("`type`");
6641
+ if (!e$1(value) || typeof value.type !== "string" || !value.type) throw missing("`type`");
4937
6642
  const { content } = value;
4938
- if (!isRecord$2(content)) throw missing("`content`");
6643
+ if (!e$1(content)) throw missing("`content`");
4939
6644
  for (const field of [
4940
6645
  "attributes",
4941
6646
  "definition",
4942
6647
  "builder",
4943
6648
  "market",
4944
6649
  "defaultStyle"
4945
- ]) if (!isRecord$2(content[field])) throw missing(`\`content.${field}\``);
4946
- if (!isRecord$2(content.definition) || typeof content.definition.label !== "string") throw missing("`content.definition.label`");
6650
+ ]) if (!e$1(content[field])) throw missing(`\`content.${field}\``);
6651
+ if (!e$1(content.definition) || typeof content.definition.label !== "string") throw missing("`content.definition.label`");
4947
6652
  return value;
4948
6653
  };
4949
6654
  var toImportPath = (file) => file.split(path.sep).join("/");
@@ -5125,10 +6830,6 @@ var elementFolders = async (project) => {
5125
6830
  const packable = await Promise.all(folders.map(hasElement));
5126
6831
  return folders.filter((_folder, index) => packable[index]);
5127
6832
  };
5128
- var readVersion = async (root) => {
5129
- const { version } = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf-8"));
5130
- return version ?? "0.0.0";
5131
- };
5132
6833
  /** The `-kebab-` spelling of a type, for the files it names: `seatPicker` writes `seat-picker.mjs`. */
5133
6834
  var fileNameOf = (folder) => path.basename(folder).replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
5134
6835
  /** Which folders to pack, when none were named: asked for at a terminal, from the ones the project has. */
@@ -5154,8 +6855,7 @@ var chosenFolders = async (project, foldersGiven) => {
5154
6855
  if (foldersGiven.length > 0) return foldersGiven.map((folder) => path.resolve(folder));
5155
6856
  const candidates = await elementFolders(project);
5156
6857
  if (candidates.length === 0) {
5157
- console.error(chalk.red("No element to pack: name its folder (plitzi pack plugin src/components/SeatPicker), or add one with plitzi add plugin."));
5158
- process.exitCode = 1;
6858
+ fail("No element to pack: name its folder (plitzi pack plugin src/components/SeatPicker), or add one with plitzi add plugin.");
5159
6859
  return;
5160
6860
  }
5161
6861
  if (!atTerminal()) {
@@ -5171,16 +6871,15 @@ var chosenFolders = async (project, foldersGiven) => {
5171
6871
  var packPluginCommand = async (foldersGiven, options) => {
5172
6872
  const project = await findProject(process.cwd());
5173
6873
  if (!project) {
5174
- console.error(chalk.red("plitzi pack plugin packs a plugin of a project, and there is no package.json here or above."));
5175
- process.exitCode = 1;
6874
+ fail("plitzi pack plugin packs a plugin of a project, and there is no package.json here or above.");
5176
6875
  return;
5177
6876
  }
5178
6877
  const inPackage = project.plitzi?.kind === "plugin" && foldersGiven.length === 0;
5179
6878
  const folders = inPackage ? [] : await chosenFolders(project, foldersGiven);
5180
6879
  if (!folders) return;
5181
- const packageName = JSON.parse(await fs.readFile(path.join(project.root, "package.json"), "utf-8"));
5182
- const base = inPackage ? pluginNames(packageName.name ?? path.basename(project.root)).base : fileNameOf(folders[0]);
5183
- const version = options.pluginVersion ?? await readVersion(project.root);
6880
+ const manifest = await readPackageJson(project.root);
6881
+ const base = inPackage ? pluginNames(manifest?.name ?? path.basename(project.root)).base : fileNameOf(folders[0]);
6882
+ const version = options.pluginVersion ?? manifest?.version ?? "0.0.0";
5184
6883
  const source = inPackage ? {
5185
6884
  kind: "package",
5186
6885
  entry: path.join(project.root, "src/index.ts"),
@@ -5218,8 +6917,7 @@ var packPluginCommand = async (foldersGiven, options) => {
5218
6917
  console.log("");
5219
6918
  } catch (error) {
5220
6919
  if (!(error instanceof PackError)) throw error;
5221
- console.error(chalk.red(error.message));
5222
- process.exitCode = 1;
6920
+ fail(error.message);
5223
6921
  }
5224
6922
  };
5225
6923
  //#endregion
@@ -5231,8 +6929,7 @@ var packPluginCommand = async (foldersGiven, options) => {
5231
6929
  var packSourceCommand = async (entries, options) => {
5232
6930
  const root = options.root ? path.resolve(options.root) : (await findProject(process.cwd()))?.root;
5233
6931
  if (!root) {
5234
- console.error(chalk.red("plitzi pack source packs a project’s source, and there is no package.json here or above."));
5235
- process.exitCode = 1;
6932
+ fail("plitzi pack source packs a project’s source, and there is no package.json here or above.");
5236
6933
  return;
5237
6934
  }
5238
6935
  try {
@@ -5247,8 +6944,7 @@ var packSourceCommand = async (entries, options) => {
5247
6944
  await fs.writeFile(out, bytes);
5248
6945
  console.log(chalk.green(`${options.name}: ${String(Object.keys(snapshot.files).length)} files`) + chalk.dim(` — ${path.relative(process.cwd(), out)} (${(bytes.byteLength / 1024).toFixed(0)} KB)`));
5249
6946
  } catch (error) {
5250
- console.error(chalk.red(error instanceof Error ? error.message : String(error)));
5251
- process.exitCode = 1;
6947
+ fail(error instanceof Error ? error.message : String(error));
5252
6948
  }
5253
6949
  };
5254
6950
  //#endregion
@@ -5297,13 +6993,12 @@ var verdictOf = ({ given, was, here }) => {
5297
6993
  if (here === was) return "write";
5298
6994
  return given === was ? "keep" : "conflict";
5299
6995
  };
5300
- var isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5301
6996
  /**
5302
6997
  * The packages the space's code asks for, added to the project's `package.json`: a package it does not list is added,
5303
6998
  * one still at the range the space last asked for follows the space, and one the project changed stays as it is.
5304
6999
  */
5305
7000
  var mergeDependencies = (manifest, asked, before) => {
5306
- const current = isRecord$1(manifest.dependencies) ? manifest.dependencies : {};
7001
+ const current = e$1(manifest.dependencies) ? manifest.dependencies : {};
5307
7002
  const dependencies = { ...current };
5308
7003
  const changed = [];
5309
7004
  const kept = [];
@@ -5400,7 +7095,7 @@ var pull = async (options) => {
5400
7095
  await Promise.all(removed.map((file) => fs.rm(path.join(root, file), { force: true })));
5401
7096
  const packageFile = path.join(root, "package.json");
5402
7097
  const manifest = JSON.parse(await fs.readFile(packageFile, "utf-8"));
5403
- const dependencies = mergeDependencies(isRecord$1(manifest) ? manifest : {}, next.dependencies, origin.dependencies);
7098
+ const dependencies = mergeDependencies(e$1(manifest) ? manifest : {}, next.dependencies, origin.dependencies);
5404
7099
  if (dependencies.changed.length > 0) await fs.writeFile(packageFile, `${JSON.stringify(dependencies.manifest, null, 2)}\n`);
5405
7100
  const files = Object.fromEntries([...[...given].map(([file, bytes]) => [file, digest(bytes)]), ...[...untouched].flatMap((file) => origin.files[file] ? [[file, origin.files[file]]] : [])]);
5406
7101
  await writeOrigin(root, {
@@ -5469,8 +7164,7 @@ var keepSource = async (connection, spaceId, bytes) => {
5469
7164
  //#endregion
5470
7165
  //#region src/commands/runtime.ts
5471
7166
  var DEFAULT_ENTRY = path.join("src", "runtime.ts");
5472
- var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5473
- var isPacker = (value) => isRecord(value) && typeof value.packRuntime === "function";
7167
+ var isPacker = (value) => e$1(value) && typeof value.packRuntime === "function";
5474
7168
  /** This project's own packer — read, since it is the project's. */
5475
7169
  var projectPacker = async (root) => {
5476
7170
  try {
@@ -5480,15 +7174,11 @@ var projectPacker = async (root) => {
5480
7174
  } catch {}
5481
7175
  fail("plitzi runtime packs it with this project’s own @plitzi/sdk-server:\n npm install @plitzi/sdk-server");
5482
7176
  };
5483
- var connect = async (options, doing) => {
5484
- const api = await apiFor(options);
5485
- return api ? connectionWithSpace(api, doing) : void 0;
5486
- };
5487
7177
  /** Packs the project's runtime module and keeps it as the space's draft runtime — which a publish takes live. */
5488
7178
  var pushRuntime = async (options) => {
5489
- const connection = await connect(options, "to push to");
5490
- if (!connection?.space) return;
5491
- const root = (await findProject(process.cwd()))?.root ?? process.cwd();
7179
+ const root = (await projectHere("whose runtime to push"))?.root;
7180
+ const connection = root && await connectToSpace(options, "to push to");
7181
+ if (!root || !connection || !connection.space) return;
5492
7182
  const entry = path.resolve(root, options.entry ?? DEFAULT_ENTRY);
5493
7183
  try {
5494
7184
  await fs.access(entry);
@@ -5519,7 +7209,7 @@ var pushRuntime = async (options) => {
5519
7209
  fail(reply.data.error ?? `The runtime was not kept (${String(reply.status)}).`);
5520
7210
  return;
5521
7211
  }
5522
- console.log(`${chalk.green("✓")} ${connection.space.name}’s draft runtime is ${reply.data.digest.slice(0, 12)} (${(bytes.byteLength / 1024).toFixed(0)} KB). It starts in a moment; publish the space to take it live.`);
7212
+ console.log(chalk.green(`${connection.space.name}’s draft runtime is ${reply.data.digest.slice(0, 12)} (${(bytes.byteLength / 1024).toFixed(0)} KB).`) + chalk.dim(" It starts in a moment; publish the space to take it live."));
5523
7213
  try {
5524
7214
  const source = await packSource({
5525
7215
  root,
@@ -5552,10 +7242,20 @@ var readRuntime = async (connection, spaceId) => {
5552
7242
  };
5553
7243
  /** How each environment's runtime is, and the names of its variables. */
5554
7244
  var runtimeStatus = async (options) => {
5555
- const connection = await connect(options, "to read");
7245
+ const connection = await connectToSpace(options, "to read");
5556
7246
  if (!connection?.space) return;
5557
7247
  const runtime = await readRuntime(connection, connection.space.id);
5558
7248
  if (!runtime) return;
7249
+ if (options.json) {
7250
+ console.log(JSON.stringify({
7251
+ space: {
7252
+ id: connection.space.id,
7253
+ name: connection.space.name
7254
+ },
7255
+ ...runtime
7256
+ }));
7257
+ return;
7258
+ }
5559
7259
  if (!runtime.environments.length) console.log(`${connection.space.name} has no runtime. Push one: plitzi runtime push.`);
5560
7260
  const spendOf = (name) => {
5561
7261
  const size = runtime.sizes.find((option) => option.name === name);
@@ -5578,7 +7278,7 @@ var runtimeStatus = async (options) => {
5578
7278
  };
5579
7279
  /** Starts an environment's runtime again, or stops it — kept stopped until started. */
5580
7280
  var powerRuntime = async (power, options) => {
5581
- const connection = await connect(options, "to configure");
7281
+ const connection = await connectToSpace(options, "to configure");
5582
7282
  if (!connection?.space) return;
5583
7283
  const environment = options.environment ?? "main";
5584
7284
  const answered = await authorizedRequest(connection, `/spaces/${String(connection.space.id)}/runtime/${power}`, {
@@ -5595,11 +7295,11 @@ var powerRuntime = async (power, options) => {
5595
7295
  fail(reply.data.error ?? `The runtime was not ${power === "start" ? "started" : "stopped"} (${String(reply.status)}).`);
5596
7296
  return;
5597
7297
  }
5598
- console.log(`${chalk.green("✓")} ${environment} ${power === "start" ? "starts in a moment" : "stops, and stays stopped until started"}.`);
7298
+ console.log(chalk.green(`${environment} ${power === "start" ? "starts in a moment" : "stops, and stays stopped until started"}.`));
5599
7299
  };
5600
7300
  /** Chooses the size an environment's runtime runs at — one the space's plan includes — and it starts again at it. */
5601
7301
  var setRuntimeSize = async (size, options) => {
5602
- const connection = await connect(options, "to configure");
7302
+ const connection = await connectToSpace(options, "to configure");
5603
7303
  if (!connection?.space) return;
5604
7304
  const environment = options.environment ?? "main";
5605
7305
  const answered = await authorizedRequest(connection, `/spaces/${String(connection.space.id)}/runtime/size`, {
@@ -5619,7 +7319,7 @@ var setRuntimeSize = async (size, options) => {
5619
7319
  fail(reply.data.error ?? `The size was not changed (${String(reply.status)}).`);
5620
7320
  return;
5621
7321
  }
5622
- console.log(`${chalk.green("✓")} ${environment} runs at ${size} — the runtime starts again at it.`);
7322
+ console.log(chalk.green(`${environment} runs at ${size} — the runtime starts again at it.`));
5623
7323
  };
5624
7324
  /** Everything sent on standard input: a value piped in rather than typed where the shell history keeps it. */
5625
7325
  var readStdin = async () => {
@@ -5632,7 +7332,7 @@ var readStdin = async () => {
5632
7332
  * input (`printf %s "$URL" | plitzi runtime vars set REDIS_URL`), which keeps a secret out of the shell's history.
5633
7333
  */
5634
7334
  var setRuntimeVariable = async (name, value, options) => {
5635
- const connection = await connect(options, "to configure");
7335
+ const connection = await connectToSpace(options, "to configure");
5636
7336
  if (!connection?.space) return;
5637
7337
  const given = value ?? await readStdin();
5638
7338
  const answered = await authorizedRequest(connection, `/spaces/${String(connection.space.id)}/runtime/variables/${encodeURIComponent(name)}`, {
@@ -5649,17 +7349,190 @@ var setRuntimeVariable = async (name, value, options) => {
5649
7349
  fail(reply.data.error ?? `${name} was not set (${String(reply.status)}).`);
5650
7350
  return;
5651
7351
  }
5652
- console.log(`${chalk.green("✓")} ${name} set — the runtime starts again with it.`);
7352
+ console.log(chalk.green(`${name} set — the runtime starts again with it.`));
5653
7353
  };
5654
7354
  var unsetRuntimeVariable = async (name, options) => {
5655
- const connection = await connect(options, "to configure");
7355
+ const connection = await connectToSpace(options, "to configure");
5656
7356
  if (!connection?.space) return;
5657
7357
  const answered = await authorizedRequest(connection, `/spaces/${String(connection.space.id)}/runtime/variables/${encodeURIComponent(name)}`, { method: "DELETE" });
5658
7358
  if (!answered.ok) {
5659
7359
  fail(answered.error);
5660
7360
  return;
5661
7361
  }
5662
- console.log(`${chalk.green("✓")} ${name} removed — the runtime starts again without it.`);
7362
+ console.log(chalk.green(`${name} removed — the runtime starts again without it.`));
7363
+ };
7364
+ //#endregion
7365
+ //#region src/commands/shot.ts
7366
+ /** A region moves when more than this share of it changed between two frames: below is anti-aliasing and carets. */
7367
+ var MOVING_PERCENT = .5;
7368
+ /** A selector, or an element's name — what `data-plitzi-el` carries. */
7369
+ var selectorOf = (target) => /^[#.[]/.test(target) ? target : `[data-plitzi-el="${target}"]`;
7370
+ var pictureOf = async (browser, url, view) => {
7371
+ const page = await browser.newPage({
7372
+ viewport: {
7373
+ width: view.width,
7374
+ height: view.height
7375
+ },
7376
+ colorScheme: view.scheme,
7377
+ reducedMotion: view.reducedMotion ? "reduce" : "no-preference"
7378
+ });
7379
+ if (!await page.goto(url, { waitUntil: "networkidle" }).catch(() => null)) return { problem: `Nothing answers at ${url}.` };
7380
+ if (view.waitFor) {
7381
+ if (!await page.waitForSelector(selectorOf(view.waitFor), { timeout: 1e4 }).catch(() => null)) return { problem: `${view.waitFor} did not appear on ${url} within 10 s.` };
7382
+ }
7383
+ return {
7384
+ page,
7385
+ png: await page.screenshot({ fullPage: true })
7386
+ };
7387
+ };
7388
+ var regionsText = (regions) => regions.map((region) => `${region.name} ${String(region.changed)}%`).join(", ");
7389
+ /** Compared frame to frame: the most each region changed between two that follow each other. */
7390
+ var framesMoved = (diffs) => {
7391
+ const most = /* @__PURE__ */ new Map();
7392
+ for (const diff of diffs) for (const region of diff.regions) most.set(region.name, Math.max(most.get(region.name) ?? 0, region.changed));
7393
+ return [...most].map(([name, changed]) => ({
7394
+ name,
7395
+ changed
7396
+ }));
7397
+ };
7398
+ var shot = async (route, options) => {
7399
+ const project = await projectHere("whose page to picture");
7400
+ if (!project) return;
7401
+ const where = await projectOrigin(project.root, project.plitzi?.kind === "project" ? project.plitzi : void 0);
7402
+ if ("problem" in where) {
7403
+ fail(where.problem);
7404
+ return;
7405
+ }
7406
+ const browser = await launchBrowser(project.root);
7407
+ if ("problem" in browser) {
7408
+ fail(browser.problem);
7409
+ return;
7410
+ }
7411
+ try {
7412
+ const pathname = route ?? "/";
7413
+ const view = {
7414
+ width: options.width ?? 1280,
7415
+ height: options.height ?? 800,
7416
+ scheme: options.scheme ?? "light",
7417
+ reducedMotion: options.reducedMotion === true,
7418
+ ...options.waitFor ? { waitFor: options.waitFor } : {}
7419
+ };
7420
+ const name = pathname === "/" ? "home" : pathname.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "");
7421
+ const out = path.resolve(project.root, options.out ?? `visual/.shots/${name}-${String(view.width)}-${view.scheme}.png`);
7422
+ const base = out.replace(/\.png$/, "");
7423
+ const taken = await pictureOf(browser, `${where.origin}${pathname}`, view);
7424
+ if ("problem" in taken) {
7425
+ fail(`${taken.problem} Start the project first: npm start`);
7426
+ return;
7427
+ }
7428
+ await mkdir(path.dirname(out), { recursive: true });
7429
+ await writeFile(out, taken.png);
7430
+ const regions = await taken.page.evaluate(pageRegions, void 0);
7431
+ const report = {
7432
+ path: pathname,
7433
+ width: view.width,
7434
+ scheme: view.scheme,
7435
+ out
7436
+ };
7437
+ if (options.compare) {
7438
+ const other = new URL(options.compare);
7439
+ const target = other.pathname === "/" ? new URL(pathname, other).href : other.href;
7440
+ const theirs = await pictureOf(browser, target, view);
7441
+ if ("problem" in theirs) {
7442
+ fail(theirs.problem);
7443
+ return;
7444
+ }
7445
+ const diff = await comparePictures(taken.page, dataUrl(taken.png), dataUrl(theirs.png), regions);
7446
+ await writeFile(`${base}-compare.png`, fromDataUrl(diff.sideBySide));
7447
+ await writeFile(`${base}-diff.png`, fromDataUrl(diff.diff));
7448
+ report.compare = {
7449
+ url: target,
7450
+ changed: diff.changed,
7451
+ regions: diff.regions,
7452
+ sideBySide: `${base}-compare.png`,
7453
+ diff: `${base}-diff.png`
7454
+ };
7455
+ }
7456
+ const frames = options.frames ?? 0;
7457
+ if (frames > 1) {
7458
+ const every = options.every ?? 500;
7459
+ const pictures = [taken.png];
7460
+ for (let frame = 1; frame < frames; frame += 1) {
7461
+ await taken.page.waitForTimeout(every);
7462
+ pictures.push(await taken.page.screenshot({ fullPage: true }));
7463
+ }
7464
+ const diffs = [];
7465
+ for (let frame = 1; frame < pictures.length; frame += 1) diffs.push(await comparePictures(taken.page, dataUrl(pictures[frame - 1]), dataUrl(pictures[frame]), regions));
7466
+ const moved = framesMoved(diffs);
7467
+ report.frames = {
7468
+ count: frames,
7469
+ every,
7470
+ moving: moved.filter((region) => region.changed > MOVING_PERCENT),
7471
+ still: moved.filter((region) => region.changed <= MOVING_PERCENT).map((region) => region.name)
7472
+ };
7473
+ }
7474
+ if (options.json) {
7475
+ console.log(JSON.stringify(report));
7476
+ return;
7477
+ }
7478
+ console.log(out);
7479
+ if (report.compare) {
7480
+ console.log(`${chalk.bold(`${String(report.compare.changed)}% differs`)} from ${report.compare.url}${report.compare.regions.length > 0 ? ` — ${regionsText(report.compare.regions)}` : ""}`);
7481
+ console.log(chalk.dim(`${report.compare.sideBySide}\n${report.compare.diff}`));
7482
+ }
7483
+ if (report.frames) console.log(report.frames.moving.length > 0 ? `moving over ${String(report.frames.count)} frames: ${regionsText(report.frames.moving)}` : `nothing moved over ${String(report.frames.count)} frames, ${String(report.frames.every)} ms apart`);
7484
+ } finally {
7485
+ await browser.close();
7486
+ }
7487
+ };
7488
+ //#endregion
7489
+ //#region src/commands/skills.ts
7490
+ /**
7491
+ * `plitzi skills update`: the Plitzi skills in `.claude/skills/` brought up to the packages the project has installed.
7492
+ *
7493
+ * A skill is copied into a project when `create` writes it and then stays as it was, while `@plitzi/sdk-authoring` moves
7494
+ * on — and an agent reading the older one is taught a surface the SDK no longer has, or misses what it gained. Only the
7495
+ * `plitzi-*` skills are touched, each replaced whole (a reference it no longer has goes with it); any other skill in the
7496
+ * folder is the project's.
7497
+ */
7498
+ var readSkill = async (file) => {
7499
+ try {
7500
+ return await fs.readFile(file, "utf-8");
7501
+ } catch {
7502
+ return;
7503
+ }
7504
+ };
7505
+ var skillsUpdate = async () => {
7506
+ const project = await projectHere("whose skills to update");
7507
+ if (!project) return;
7508
+ const folder = path.join(project.root, ".claude/skills");
7509
+ const present = [];
7510
+ for (const name of SKILL_NAMES) {
7511
+ const skill = await readSkill(path.join(folder, name, "SKILL.md"));
7512
+ if (skill !== void 0) present.push({
7513
+ name,
7514
+ was: skillVersion(skill)
7515
+ });
7516
+ }
7517
+ if (present.length === 0) {
7518
+ fail(`No Plitzi skill in ${path.relative(process.cwd(), folder) || ".claude/skills"}: \`plitzi create\` writes them into a project it makes.`);
7519
+ return;
7520
+ }
7521
+ const files = skillFiles(present.map(({ name }) => name), project.root);
7522
+ for (const { name, was } of present) {
7523
+ const own = Object.fromEntries(Object.entries(files).filter(([file]) => file.startsWith(`.claude/skills/${name}/`)));
7524
+ if (Object.keys(own).length === 0) {
7525
+ console.log(chalk.yellow(`${name}: its package is not installed here, so it was left as it was.`));
7526
+ continue;
7527
+ }
7528
+ await fs.rm(path.join(folder, name), {
7529
+ recursive: true,
7530
+ force: true
7531
+ });
7532
+ await writeFiles(project.root, own);
7533
+ const now = skillVersion(own[`.claude/skills/${name}/SKILL.md`] ?? "");
7534
+ console.log(chalk.green(`${name}: ${was ?? "unversioned"} → ${now ?? "unversioned"}`));
7535
+ }
5663
7536
  };
5664
7537
  //#endregion
5665
7538
  //#region src/commands/uploadPlugin.ts
@@ -5843,6 +7716,29 @@ var uploadPluginCommand = async (zipGiven, options) => {
5843
7716
  console.log("");
5844
7717
  };
5845
7718
  //#endregion
7719
+ //#region src/options.ts
7720
+ /**
7721
+ * What a flag's value has to be, read once where the command line is declared. A value that is not one is refused
7722
+ * there, saying what it takes — never turned into a default the person did not ask for, which is how a typo used to
7723
+ * check a page at no width at all and say nothing.
7724
+ */
7725
+ var MIN_WIDTH = 320;
7726
+ var MAX_WIDTH = 3840;
7727
+ /** A viewport width: a whole number of pixels, from a small phone to a 4K screen. */
7728
+ var width = (value) => {
7729
+ const parsed = Number(value.trim());
7730
+ if (!Number.isInteger(parsed) || parsed < MIN_WIDTH || parsed > MAX_WIDTH) throw new InvalidArgumentError(`A width is a whole number of pixels from ${String(MIN_WIDTH)} to ${String(MAX_WIDTH)}: 1440 or 390.`);
7731
+ return parsed;
7732
+ };
7733
+ /** Widths separated by commas, each once, in the order given. */
7734
+ var widths = (value) => [...new Set(value.split(",").map(width))];
7735
+ /** A whole number from 1: a height in pixels, a count, milliseconds. */
7736
+ var positiveInteger = (value) => {
7737
+ const parsed = Number(value.trim());
7738
+ if (!Number.isInteger(parsed) || parsed < 1) throw new InvalidArgumentError("It takes a whole number from 1.");
7739
+ return parsed;
7740
+ };
7741
+ //#endregion
5846
7742
  //#region src/index.ts
5847
7743
  /**
5848
7744
  * The command line for Plitzi.
@@ -5852,28 +7748,45 @@ var uploadPluginCommand = async (zipGiven, options) => {
5852
7748
  * could tell whether any of it worked. The same holds for an element of one's own, which is the second thing it does.
5853
7749
  */
5854
7750
  program.name("plitzi").description("Plitzi command line");
7751
+ /** A flag given more than once, as the list of every value. */
7752
+ var collect = (value, previous) => [...previous, value];
5855
7753
  /** The flags that shape a PROJECT: given with `--plugin`, they are a mistake worth saying rather than ignoring. */
5856
7754
  var PROJECT_ONLY = [
5857
7755
  "mode",
5858
7756
  "source",
5859
- "key"
7757
+ "key",
7758
+ "template"
5860
7759
  ];
7760
+ /** The widths a page is looked at, as one flag every command that opens a page shares. */
7761
+ var widthsOption = (description, fallback) => new Option("--width <px,px>", description).argParser(widths).default(fallback, fallback.join(","));
7762
+ var schemeOption = () => new Option("--scheme <scheme>", "The colour scheme").choices(SCHEMES).default("light");
5861
7763
  var API_OPTION = ["--api <url>", "The platform’s API. Defaults to PLITZI_API_URL, else the one signed in to, else https://api.plitzi.com."];
5862
- program.command("create").argument("[directory]", "Where to write it. Defaults to the current directory, or asked for with --plugin.").description("Scaffold a project that renders a Plitzi space, or with --plugin a plugin package").addOption(new Option("-m, --mode <mode>", "server (SSR + RSC on a Node tier) or client (browser only)").choices(["server", "client"])).addOption(new Option("-s, --source <source>", "local (the space travels in the project) or cloud (read it from Plitzi)").choices(["local", "cloud"])).option("-k, --key <key>", "Cloud only: the space key (asked for when omitted)").option("-e, --environment <environment>", "Which version: main (the draft), or a published environment — what --from takes out, and what a cloud project serves", "main").option("--revision <n>", "A published environment’s revision, pinned rather than its latest (with --from or --source cloud)").addOption(new Option("-p, --package-manager <manager>", "The package manager the project is written for. Asked for when left out.").choices([...PACKAGE_MANAGERS])).option("--from <space>", "Make it from a space on Plitzi (its id or permanent URL): its pages, actions, functions, runtime, plugins and files, served by the project alone. Signs in as you; the space must be one you can change.").option(...API_OPTION).option("--plugin", "A plugin package instead: one element any space can load, with a preview and a build").option("--name <name>", "Plugin only: its package name (plitzi-plugin-seat-picker). Asked for when left out.").option("--title <title>", "Plugin only: what the builder calls the element").option("--description <description>", "Plugin only: what the element is for, in a sentence").option("--owner <owner>", "Plugin only: who publishes it").option("--elements <names>", "Plugin only: other elements the package holds, by name, separated by commas").option("--no-install", "Write the files without installing dependencies").option("-f, --force", "Write into a directory that is not empty").option("-y, --yes", "At a terminal: take the defaults for any choice not passed. Without one, every choice must be passed").action((directory, options) => {
7764
+ program.command("create").argument("[directory]", "Where to write it. Defaults to the current directory, or asked for with --plugin.").description("Scaffold a project that renders a Plitzi space, or with --plugin a plugin package").addOption(new Option("-m, --mode <mode>", "server (SSR + RSC on a Node tier) or client (browser only)").choices(["server", "client"])).addOption(new Option("-s, --source <source>", "local (the space travels in the project) or cloud (read it from Plitzi)").choices(["local", "cloud"])).option("-k, --key <key>", "Cloud only: the space key (asked for when omitted)").option("-e, --environment <environment>", "Which version: main (the draft), or a published environment — what --from takes out, and what a cloud project serves", "main").option("--revision <n>", "A published environment’s revision, pinned rather than its latest (with --from or --source cloud)").addOption(new Option("-p, --package-manager <manager>", "The package manager the project is written for. Asked for when left out.").choices([...PACKAGE_MANAGERS])).option("--from <space>", "Make it from a space on Plitzi (its id or permanent URL): its pages, actions, functions, runtime, plugins and files, served by the project alone. Signs in as you; the space must be one you can change.").option(...API_OPTION).option("--plugin", "A plugin package instead: one element any space can load, with a preview and a build").option("--name <name>", "Plugin only: its package name (plitzi-plugin-seat-picker). Asked for when left out.").option("--title <title>", "Plugin only: what the builder calls the element").option("--description <description>", "Plugin only: what the element is for, in a sentence").option("--owner <owner>", "Plugin only: who publishes it").option("--elements <names>", "Plugin only: other elements the package holds, by name, separated by commas").addOption(new Option("-t, --template <template>", "What a space written here starts as: welcome (a tour, the default), blank (tokens, a layout, one empty page) or catalog (a shop: layout, card component, data, filtered list, a page per product)").choices([...CREATE_TEMPLATES])).option("--no-install", "Write the files without installing dependencies").option("-f, --force", "Write into a directory that is not empty").option("-y, --yes", "At a terminal: take the defaults for any choice not passed. Without one, every choice must be passed").action((directory, options) => {
5863
7765
  if (!options.plugin) return create(directory, options);
5864
7766
  const stray = PROJECT_ONLY.filter((flag) => options[flag] !== void 0);
5865
7767
  if (stray.length > 0) program.error(`--${stray.join(", --")} shape a project, not a plugin: leave them out with --plugin.`);
5866
7768
  return createPlugin(directory, options);
5867
7769
  });
5868
7770
  program.command("pull").description("Bring a project made with create --from up to date with its space: what changed there is written, what changed here is kept").option("-f, --force", "Where a file changed both here and on the space, take the space’s copy").option("-e, --environment <environment>", "Follow another version from now on: main (the draft), or a published environment").option("--revision <n>", "Pin a published revision of it, or latest to follow its newest again").option(...API_OPTION).action((options) => pull(options));
5869
- program.command("add").description("Add something to the project you are in").command("plugin").argument("[names...]", "What each element is called: seat-picker. Asked for when left out.").description("Add elements of your own to this project: each one its component, declaration and builder panel").option("-d, --dir <folder>", "The folder that holds the project’s components. Asked for, unless the project says.").option("--title <title>", "One element only: what the builder calls it").option("--description <description>", "One element only: what it is for, in a sentence").option("-f, --force", "Write into a folder that is not empty").action((names, options) => addPlugin(names, options));
7771
+ program.command("add").description("Add something to the project you are in").command("plugin").argument("[names...]", "What each element is called: seat-picker. Asked for when left out.").description("Add elements of your own to this project: each one its component, declaration and builder panel").option("-d, --dir <folder>", "The folder that holds the project’s components. Asked for, unless the project says.").option("--title <title>", "One element only: what the builder calls it").option("--description <description>", "One element only: what it is for, in a sentence").option("--prop <name:type=default>", "One element only: an attribute it takes — interval:number=5000, paused:boolean, label:string=Hi. Repeat it", collect, []).option("--trigger <name:fields>", "An event it fires and what a flow reads — onTick:count. Repeat it", collect, []).option("--callback <name>", "An action a flow can call on it — reset. Repeat it", collect, []).option("--headless", "Nothing to see: hidden on a page, a badge in the builder (a clock, a listener)").option("-f, --force", "Write into a folder that is not empty").action((names, options) => addPlugin(names, options));
5870
7772
  var pack = program.command("pack").description("Build something of this project into what the platform takes");
5871
7773
  pack.command("plugin").argument("[folders...]", "Element folders to pack, the main one first. In a plugin package, left out: all of them.").description("Build a plugin: one ES module, its plugin-manifest.json, and the zip the builder takes under Resources").option("-o, --out <folder>", "Where the build goes. Emptied first; it must be inside the project.").option("--no-zip", "Build without the zip").option("--plugin-version <version>", "The version the manifest carries. Defaults to the one in package.json.").option("--source-root <folder>", "The project its source is kept relative to, when the elements are a project of their own inside this one").action((folders, options) => packPluginCommand(folders, options));
5872
7774
  pack.command("source").argument("<entries...>", "The files the build starts from: a runtime module, or each element’s index").description("Write the source snapshot plitzi upload and plitzi runtime push keep beside what they send").addOption(new Option("--kind <kind>", "What it is the source of").choices(["plugin", "runtime"]).makeOptionMandatory()).requiredOption("--name <name>", "The plugin’s type, or runtime").option("--root <folder>", "The project the paths are relative to. Defaults to the nearest package.json’s folder.").requiredOption("-o, --out <file>", "Where the gzipped snapshot goes").action((entries, options) => packSourceCommand(entries, options));
5873
7775
  program.command("login").description("Sign in, in your browser. The session is kept and renewed until plitzi logout").option(...API_OPTION).action((options) => login(options));
5874
7776
  program.command("logout").description("Sign out: the session is revoked on the platform and forgotten here").action(() => logout());
5875
- program.command("whoami").description("Who the CLI is signed in as, and the space it works in").option(...API_OPTION).action((options) => whoami(options));
7777
+ program.command("whoami").description("Who the CLI is signed in as, and the space it works in").option("--json", "One object: { api, user, space }, for a tool or an agent").option(...API_OPTION).action((options) => whoami(options));
5876
7778
  program.command("space").description("Choose the space to work in, in your browser. One at a time: it replaces the one chosen before").option(...API_OPTION).action((options) => space(options));
7779
+ program.command("check").argument("[path]", "The page: /about. The home page when left out").description("Whether a page of the running project is whole, in text: every element on screen, no broken image, no console error, no failed flow").addOption(widthsOption("The widths to check it at, separated by commas", [1440, 390])).addOption(schemeOption()).option("--state", "Also what the page holds: its state, and every source by name").option("--element <id>", "Also one element: what it reads, its own state, whether it is on screen").option("--json", "One object per width, for a tool or an agent").action((path, options) => check(path, options));
7780
+ program.command("fix").description("What authoring would fix in the space, as edits to your source: shown as a diff; with --write written, formatted and checked").option("--write", "Write the edits — kept only when the space authors with each fix gone and no problem added").option("--json", "The plan as one object, for a tool or an agent").action((options) => fix(options));
7781
+ program.command("import").argument("<url>", "The page to start from: https://example.com/pricing").description("A page of a site you verified, as a place to start writing from: its tokens, the outline of its blocks per breakpoint, its lists as JSON — never its words").option("-o, --out <dir>", "Where it is written, inside the project", "src/imported").addOption(widthsOption("The widths it is measured at: the widest is desktop, then tablet and mobile", [
7782
+ 1440,
7783
+ 768,
7784
+ 390
7785
+ ])).option("-f, --force", "Write over what --out already holds").option("--json", "One object, for a tool or an agent").option(...API_OPTION).action((url, options) => importPage(url, options));
7786
+ program.command("shot").argument("[path]", "The page: /about. The home page when left out").description("A picture of one page of the running project — and, asked, how it differs from another or what moves").addOption(new Option("--width <px>", "The viewport width").argParser(width).default(1280)).addOption(new Option("--height <px>", "The viewport height (the picture is the whole page)").argParser(positiveInteger).default(800)).addOption(schemeOption()).option("-o, --out <file>", "Where the picture goes. visual/.shots/<page>-<width>-<scheme>.png by default").option("--compare <url>", "Another site: the same page there, side by side, and how much differs by section").addOption(new Option("--frames <n>", "Pictures taken one after another: which sections move (a marquee, an autoplay)").argParser(positiveInteger)).addOption(new Option("--every <ms>", "How far apart --frames are taken").argParser(positiveInteger).default(500)).option("--wait-for <element>", "An element to wait for first: its name (data-plitzi-el) or a CSS selector").option("--reduced-motion", "As a visitor who asked for less motion").option("--json", "One object, for a tool or an agent").action((path, options) => shot(path, options));
7787
+ program.command("explain").argument("[name]", "An element, a step, a trigger, a problem’s code or a transformer: container, navigate, class-and-css").description("What a name means when authoring a space — what it takes, fires, reads and how it is written").option("--list <kind>", "Every name of a kind: elements, steps, triggers, codes, transformers").option("--json", "One object, for a tool or an agent").action((name, options) => explainCommand(name, options));
7788
+ program.command("skills").description("The Plitzi skills an agent reads in .claude/skills/").command("update").description("Bring them up to the Plitzi packages this project has installed, each replaced whole").action(() => skillsUpdate());
7789
+ program.command("data").description("The JSON a project serves (public/data), without reading it whole").command("describe").argument("<file>", "A JSON file: public/data/products.json").description("Its shape — every field, its type, whether every row has it — and one row of its longest list").option("--json", "One object: { shape, example }").action((file, options) => dataDescribe(file, options));
5877
7790
  program.command("upload").description("Put something of this project on the space you work in").command("plugin").argument("[zip]", "The zip plitzi pack plugin built. Left out: the one packed in this project.").description("Upload a packed plugin to a CDN of the space you work in, and install it there").option("--cdn <identifier>", "Which of the space’s CDNs the bucket is in.").option("--bucket <identifier>", "Which public bucket the plugin goes in. Asked for when there are several.").option(...API_OPTION).action((zip, options) => uploadPluginCommand(zip, options));
5878
7791
  var functions = program.command("functions").description("The space’s own server code: functions/ in this project is a working copy of it");
5879
7792
  functions.command("pull").description("Write the space’s functions into functions/ — refused when that would overwrite what is not pushed").option("-f, --force", "Overwrite what is not pushed, or a copy of another space").option(...API_OPTION).action((options) => pullFunctions(options));
@@ -5882,11 +7795,11 @@ functions.command("try").argument("<task>", "The task, <namespace>.<action>").de
5882
7795
  functions.command("dev").argument("<task>", "The task, <namespace>.<action>").description("Run one task from functions/ on this machine, as the platform runs it — nothing reaches the space").option("--params <json>", "Its params, as a JSON object").option("-w, --watch", "Run it again every time a file of functions/ is saved").action((task, options) => devFunction(task, options));
5883
7796
  var runtime = program.command("runtime").description("The space’s runtime: its own server code, run as a process of its own beside the platform");
5884
7797
  runtime.command("push").description("Pack this project’s runtime module and keep it as the space’s draft runtime").option("--entry <path>", "The module whose default export is defineRuntime(…)", "src/runtime.ts").option(...API_OPTION).action((options) => pushRuntime(options));
5885
- runtime.command("status").description("How each environment’s runtime is, and the names of its variables").option(...API_OPTION).action((options) => runtimeStatus(options));
5886
- for (const power of ["start", "stop"]) runtime.command(power).description(power === "start" ? "Start an environment’s runtime again — one stopped for being unused, or by hand" : "Stop an environment’s runtime, and keep it stopped until it is started").option("--environment <name>", "The environment: main (the draft) or a published one", "main").option(...API_OPTION).action((options) => powerRuntime(power, options));
5887
- runtime.command("size").description("Choose the size an environment’s runtime runs at, among those the space’s plan includes").argument("<size>", "small, medium or large — plitzi runtime status says which the plan includes").option("--environment <name>", "The environment: main (the draft) or a published one", "main").option(...API_OPTION).action((size, options) => setRuntimeSize(size, options));
7798
+ runtime.command("status").description("How each environment’s runtime is, and the names of its variables").option("--json", "One object: { space, environments, variables, sizes }, for a tool or an agent").option(...API_OPTION).action((options) => runtimeStatus(options));
7799
+ for (const power of ["start", "stop"]) runtime.command(power).description(power === "start" ? "Start an environment’s runtime again — one stopped for being unused, or by hand" : "Stop an environment’s runtime, and keep it stopped until it is started").option("-e, --environment <name>", "The environment: main (the draft) or a published one", "main").option(...API_OPTION).action((options) => powerRuntime(power, options));
7800
+ runtime.command("size").description("Choose the size an environment’s runtime runs at, among those the space’s plan includes").argument("<size>", "small, medium or large — plitzi runtime status says which the plan includes").option("-e, --environment <name>", "The environment: main (the draft) or a published one", "main").option(...API_OPTION).action((size, options) => setRuntimeSize(size, options));
5888
7801
  var vars = runtime.command("vars").description("What the runtime starts with — written, never read back");
5889
- vars.command("set").argument("<name>", "The variable, in capitals: REDIS_URL").argument("[value]", "Its value — read from standard input when left out, which keeps it out of the shell history").option(...API_OPTION).action((name, value, options) => setRuntimeVariable(name, value, options));
5890
- vars.command("unset").argument("<name>", "The variable").option(...API_OPTION).action((name, options) => unsetRuntimeVariable(name, options));
7802
+ vars.command("set").description("Give the runtime a variable, or a new value for one it has").argument("<name>", "The variable, in capitals: REDIS_URL").argument("[value]", "Its value — read from standard input when left out, which keeps it out of the shell history").option(...API_OPTION).action((name, value, options) => setRuntimeVariable(name, value, options));
7803
+ vars.command("unset").description("Take a variable away from the runtime").argument("<name>", "The variable").option(...API_OPTION).action((name, options) => unsetRuntimeVariable(name, options));
5891
7804
  program.parse(process.argv);
5892
7805
  //#endregion