@plitzi/cli 0.38.2 → 0.38.3

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
@@ -1186,6 +1186,10 @@ const StatCard = ({ label = 'Metric', value = 0, unit = '', series, className }:
1186
1186
  * It is what makes this an ELEMENT rather than a component that happens to be on the page: the element's id and
1187
1187
  * classes land on it, so the CSS authored on it in \`src/space.ts\` applies, the builder can select it, a test
1188
1188
  * can find it by name, and interactions fire on it. A plain tag renders the same pixels and none of that.
1189
+ *
1190
+ * Its \`style\` is inline, and inline outranks every class: what \`CARD\` sets — its display, its padding — the CSS
1191
+ * authored on the element can never change. Keep it to what the element cannot render without, and leave layout and
1192
+ * position (\`position\`, \`width\`, \`margin\`) to the space's classes.
1189
1193
  */
1190
1194
  return (
1191
1195
  <RootElement className={className} style={CARD}>
@@ -1420,6 +1424,8 @@ var managerPackageFields = (manager) => manager === "npm" ? { allowScripts: {
1420
1424
  fsevents: false
1421
1425
  } } : {};
1422
1426
  var SDK_VERSION = `^${createRequire(import.meta.url)("@plitzi/sdk-authoring/package.json").version}`;
1427
+ /** The same version, bare: this CLI's own — what `--version` prints and what wrote a project's machinery. */
1428
+ var CLI_VERSION = SDK_VERSION.slice(1);
1423
1429
  /**
1424
1430
  * What every generated project builds and checks itself with.
1425
1431
  *
@@ -1478,8 +1484,8 @@ var NODE_ENGINES = { node: ">=22.18" };
1478
1484
  *
1479
1485
  * `client` runs Vite, so a save is a hot module replacement — the page updates without reloading, and editing the
1480
1486
  * space is a live loop. `server` runs the page server under Node's `--watch`: there is no client bundle of this
1481
- * project's own to hot-replace (the SDK is served by the server from its own copy), so a save restarts the
1482
- * process and the next request renders the change. Both are one command; only one of them is HMR, and calling
1487
+ * project's own to hot-replace (the SDK is served by the server from its own copy), so a save to the server's code
1488
+ * restarts the process — and a save to a local space is re-authored in place and the open page reloads. Both are one command; only one of them is HMR, and calling
1483
1489
  * the other one HMR would be a promise the loop does not keep.
1484
1490
  */
1485
1491
  var scripts = ({ mode, source }) => ({
@@ -1489,9 +1495,11 @@ var scripts = ({ mode, source }) => ({
1489
1495
  * Watched by PATH, not wholesale.
1490
1496
  *
1491
1497
  * The server compiles the project's plugins into `.sdk-plugins/` and then IMPORTS what it built, so a
1492
- * bare `--watch` sees its own output land, restarts, compiles again, and never stops.
1498
+ * bare `--watch` sees its own output land, restarts, compiles again, and never stops. A local space is not
1499
+ * among them: `main.ts` re-authors it on save in a process of its own and the open pages load again
1500
+ * (`reloadPages`), so only the server's own code and the plugins restart it.
1493
1501
  */
1494
- "start:dev": "node --watch-path=./src src/main.ts",
1502
+ "start:dev": source === "local" ? "node --watch-path=./src/main.ts --watch-path=./src/plugins src/main.ts" : "node --watch-path=./src src/main.ts",
1495
1503
  /**
1496
1504
  * What production runs: the same entry compiled to JavaScript. Node strips types by loading a TypeScript
1497
1505
  * transformer into the process — ~10 MB a server keeps for its whole life to read one file — so a deployment
@@ -1586,7 +1594,7 @@ var YARN_IGNORES$1 = "\n.yarn/*\n!.yarn/patches\n!.yarn/plugins\n!.yarn/releases
1586
1594
  /** Where the page server writes the plugin bundles it builds. A build output, and rebuilt whenever it is missing. */
1587
1595
  var SERVER_IGNORES = ".sdk-plugins\n.plitzi\n";
1588
1596
  var gitignore$1 = ({ mode, packageManager }) => `node_modules\ndist\n.env\nvisual/.results\nvisual/screenshots\n${mode === "server" ? SERVER_IGNORES : ""}${packageManager === "yarn" ? YARN_IGNORES$1 : ""}`;
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.`;
1597
+ var startLine = ({ mode, packageManager, source }) => 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 a save to the server's code${source === "local" ? "; a save to the space reloads the open page" : ""}. 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.`;
1590
1598
  var spaceSection = (answers) => {
1591
1599
  if (answers.source === "cloud") return `## Where the space comes from
1592
1600
 
@@ -1688,6 +1696,7 @@ ${code(".claude/skills/plitzi-cli/SKILL.md")} first: ${code("plitzi add plugin")
1688
1696
  ## The rules that go wrong most
1689
1697
 
1690
1698
  - 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.
1699
+ - After the ${code("@plitzi/*")} packages move, ${code("npx plitzi upgrade")}: what this project's CLI files, scripts, skills and renamed names should now be — ${code("--write")} makes it, a file you changed comes as a diff.
1691
1700
  - ${zeroWarnings}
1692
1701
  - Chrome shared by pages is a layout; a look used twice is a class; a repeated block is a function or a ${code("map")}.
1693
1702
  - Ids are one namespace for the whole space: name what is referred to; a helper that runs more than once builds inside ${code("scope()")}.
@@ -1889,7 +1898,8 @@ writeFileSync(
1889
1898
  \`\${JSON.stringify({ name: SERVER_NAME, port: PORT, url: \`http://127.0.0.1:\${PORT}\` }, null, 2)}\\n\`
1890
1899
  );
1891
1900
  console.log(\`pages on http://127.0.0.1:\${PORT}/\`);`;
1892
- var localMain = () => `import { mkdirSync, readdirSync, writeFileSync } from 'node:fs';
1901
+ var localMain = () => `import { spawn } from 'node:child_process';
1902
+ import { mkdirSync, readdirSync, watch, writeFileSync } from 'node:fs';
1893
1903
  import path from 'node:path';
1894
1904
 
1895
1905
  import {
@@ -1925,6 +1935,18 @@ for (const warning of warnings) {
1925
1935
 
1926
1936
  ${PLUGINS}
1927
1937
 
1938
+ /**
1939
+ * While developing, the documents are served from the file \`npm run author\` writes, which the server reads again
1940
+ * whenever it changes — so a save is re-authored and shown without restarting anything (see \`watchSpace\` below). A
1941
+ * deployment keeps them in memory, as authored at boot.
1942
+ */
1943
+ const DEVELOPING = process.env.NODE_ENV !== 'production';
1944
+ const OFFLINE_DATA = path.join(PROJECT_ROOT, 'space/offline-data.json');
1945
+ if (DEVELOPING) {
1946
+ mkdirSync(path.dirname(OFFLINE_DATA), { recursive: true });
1947
+ writeFileSync(OFFLINE_DATA, \`\${JSON.stringify(offlineData, null, 2)}\\n\`);
1948
+ }
1949
+
1928
1950
  /**
1929
1951
  * This project's own server code: \`functions/\` — what \`plitzi functions pull\` writes and \`push\` sends — built
1930
1952
  * the way Plitzi builds a space's and run here, in this process. Nothing there, no functions; code that does not build
@@ -1944,9 +1966,11 @@ const SERVER_NAME = schema.definition.permanentUrl;
1944
1966
  const server = createServer({
1945
1967
  port: PORT,
1946
1968
  devMode: process.env.NODE_ENV !== 'production',
1969
+ // A save to the space re-authors it and the open pages load again (\`watchSpace\` below).
1970
+ devReload: DEVELOPING,
1947
1971
  health: { name: SERVER_NAME },
1948
1972
  adapters: createJsonAdapters({
1949
- offlineData,
1973
+ offlineData: DEVELOPING ? OFFLINE_DATA : offlineData,
1950
1974
  deployment: { spaceId: 1, environment: 'main', revision: 0, pluginNames }
1951
1975
  }),
1952
1976
  plugins,
@@ -1966,6 +1990,56 @@ ${LISTEN_SNIPPET}
1966
1990
  * queue for whichever server runs next. A second ^C exits at once.
1967
1991
  */
1968
1992
  closeOnSignals(server);
1993
+
1994
+ /**
1995
+ * A save to the space, while developing: re-authored by \`src/author.ts\` in a process of its own — the only way to
1996
+ * read every file of it again, which an import never does twice — and every open page loads again once it wrote the
1997
+ * new documents. What it refuses is printed and the page keeps the last space that authored. A change to this file or
1998
+ * to a plugin restarts the server instead (\`start:dev\` watches those).
1999
+ */
2000
+ const RESTARTS = ['main.ts', 'plugins'];
2001
+ const watchSpace = (): void => {
2002
+ let timer: ReturnType<typeof setTimeout> | undefined;
2003
+ let authoring = false;
2004
+ let again = false;
2005
+ const author = (): void => {
2006
+ if (authoring) {
2007
+ again = true;
2008
+
2009
+ return;
2010
+ }
2011
+
2012
+ authoring = true;
2013
+ const child = spawn(process.execPath, [path.join(PROJECT_ROOT, 'src/author.ts')], {
2014
+ cwd: PROJECT_ROOT,
2015
+ stdio: 'inherit'
2016
+ });
2017
+ child.on('close', code => {
2018
+ authoring = false;
2019
+ if (code === 0) {
2020
+ server.reloadPages();
2021
+ }
2022
+
2023
+ if (again) {
2024
+ again = false;
2025
+ author();
2026
+ }
2027
+ });
2028
+ };
2029
+
2030
+ watch(path.join(PROJECT_ROOT, 'src'), { recursive: true }, (_event, file) => {
2031
+ if (!file || RESTARTS.some(name => file === name || file.startsWith(\`\${name}\${path.sep}\`))) {
2032
+ return;
2033
+ }
2034
+
2035
+ clearTimeout(timer);
2036
+ timer = setTimeout(author, 100);
2037
+ });
2038
+ };
2039
+
2040
+ if (DEVELOPING) {
2041
+ watchSpace();
2042
+ }
1969
2043
  `;
1970
2044
  var cloudMain = (name) => `import { mkdirSync, readdirSync, writeFileSync } from 'node:fs';
1971
2045
  import path from 'node:path';
@@ -2116,7 +2190,7 @@ var filesUnder$1 = (root, folder = root) => readdirSync(folder, { withFileTypes:
2116
2190
  });
2117
2191
  /**
2118
2192
  * 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.
2193
+ * `root`, from the packages that project installed — what `plitzi upgrade skills` brings it up to.
2120
2194
  */
2121
2195
  var skillFiles = (names = SKILL_NAMES, root) => {
2122
2196
  const resolvers = resolversFor(root);
@@ -2160,7 +2234,7 @@ import { space } from './space.ts';
2160
2234
  // \`--json\` answers in one object, for a tool or an agent; otherwise one line on success, the problems on failure.
2161
2235
  const json = process.argv.includes('--json');
2162
2236
 
2163
- /** How many of the problems said have one fix \`plitzi fix\` can write in this source — and the line that says so. */
2237
+ /** How many of the warnings and suggestions said have one fix \`plitzi fix\` can write in this source — and the line that says so. */
2164
2238
  const fixableHint = (): string | undefined => {
2165
2239
  let count: number;
2166
2240
  try {
@@ -2172,18 +2246,21 @@ const fixableHint = (): string | undefined => {
2172
2246
  return count === 0 ? undefined : \`[fix] \${count} of these have one fix: npx plitzi fix shows it in your source, --write writes it\`;
2173
2247
  };
2174
2248
 
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;
2249
+ /** A file's text, or undefined when there is none to read. */
2250
+ const readOptional = (file: string): string | undefined => {
2181
2251
  try {
2182
- skill = /^version: (.+)$/m.exec(readFileSync('.claude/skills/plitzi-authoring/SKILL.md', 'utf-8'))?.[1]?.trim();
2252
+ return readFileSync(file, 'utf-8');
2183
2253
  } catch {
2184
2254
  return undefined;
2185
2255
  }
2256
+ };
2186
2257
 
2258
+ /**
2259
+ * What of the project is older than the SDK installed: the skill an agent reads, copied from the SDK at one version,
2260
+ * and the files the CLI wrote (\`.plitzi/scaffold.json\`) — a newer SDK has what neither taught nor wired. Each version
2261
+ * that differs — \`npx plitzi upgrade\` brings both up.
2262
+ */
2263
+ const outdated = (): { skill?: string; files?: string; sdk: string } | undefined => {
2187
2264
  const manifest: unknown = JSON.parse(
2188
2265
  readFileSync(createRequire(import.meta.url).resolve('@plitzi/sdk-authoring/package.json'), 'utf-8')
2189
2266
  );
@@ -2191,8 +2268,17 @@ const outdatedSkill = (): { skill: string; sdk: string } | undefined => {
2191
2268
  typeof manifest === 'object' && manifest !== null && 'version' in manifest && typeof manifest.version === 'string'
2192
2269
  ? manifest.version
2193
2270
  : undefined;
2271
+ const skill = /^version: (.+)$/m.exec(readOptional('.claude/skills/plitzi-authoring/SKILL.md') ?? '')?.[1]?.trim();
2272
+ const record: unknown = JSON.parse(readOptional('.plitzi/scaffold.json') ?? 'null');
2273
+ const files =
2274
+ typeof record === 'object' && record !== null && 'cli' in record && typeof record.cli === 'string'
2275
+ ? record.cli
2276
+ : undefined;
2277
+ if (!sdk || ((!skill || skill === sdk) && (!files || files === sdk))) {
2278
+ return undefined;
2279
+ }
2194
2280
 
2195
- return skill && sdk && skill !== sdk ? { skill, sdk } : undefined;
2281
+ return { ...(skill && skill !== sdk ? { skill } : {}), ...(files && files !== sdk ? { files } : {}), sdk };
2196
2282
  };
2197
2283
 
2198
2284
  try {
@@ -2200,30 +2286,35 @@ try {
2200
2286
  mkdirSync('space', { recursive: true });
2201
2287
  writeFileSync('space/offline-data.json', \`\${JSON.stringify({ schema, style }, null, 2)}\\n\`);
2202
2288
 
2203
- const outdated = outdatedSkill();
2289
+ const behind = outdated();
2204
2290
  if (json) {
2205
2291
  console.log(
2206
- JSON.stringify({ ok: true, pages: schema.pages.length, warnings, suggestions, ...(outdated ? { outdated } : {}) })
2292
+ JSON.stringify({ ok: true, pages: schema.pages.length, warnings, suggestions, ...(behind ? { outdated: behind } : {}) })
2207
2293
  );
2208
2294
  } else {
2209
2295
  for (const warning of warnings) {
2210
2296
  console.warn(\`[author] \${warning.code} · \${warning.message}\`);
2211
2297
  }
2212
2298
 
2213
- const hint = warnings.length > 0 ? fixableHint() : undefined;
2214
- if (hint) {
2215
- console.warn(hint);
2216
- }
2217
-
2218
2299
  // Not problems: a shorter way to the same page, the one that saves the most elements first.
2219
2300
  for (const suggestion of suggestions) {
2220
2301
  const at = suggestion.at ? \` · \${suggestion.at}\` : '';
2221
2302
  console.warn(\`[suggest] \${suggestion.code} · \${suggestion.message} (saves \${suggestion.saves})\${at}\`);
2222
2303
  }
2223
2304
 
2224
- if (outdated) {
2305
+ // After both: \`plitzi fix\` writes a warning's fix and a suggestion's alike, where it has one reading.
2306
+ const hint = warnings.length + suggestions.length > 0 ? fixableHint() : undefined;
2307
+ if (hint) {
2308
+ console.warn(hint);
2309
+ }
2310
+
2311
+ if (behind) {
2312
+ const older = [
2313
+ behind.skill ? \`the authoring skill is \${behind.skill}\` : '',
2314
+ behind.files ? \`its files are from \${behind.files}\` : ''
2315
+ ].filter(Boolean);
2225
2316
  console.warn(
2226
- \`[skills] the authoring skill is \${outdated.skill} and @plitzi/sdk-authoring is \${outdated.sdk}: npx @plitzi/cli skills update\`
2317
+ \`[upgrade] \${older.join(' and ')}, and @plitzi/sdk-authoring is \${behind.sdk}: npx plitzi upgrade shows what changes, --write makes it\`
2227
2318
  );
2228
2319
  }
2229
2320
 
@@ -3473,6 +3564,10 @@ const ${name} = ({ label = ${tsString(title)}, start = 0, step = 1, className }:
3473
3564
  * It is what makes this an ELEMENT rather than a component that happens to be on the page: the element's id and
3474
3565
  * classes land on it, so the CSS authored on it applies, the builder can select it, a test can find it by name, and
3475
3566
  * the events and actions above are this element's.
3567
+ *
3568
+ * Its \`style\` is inline, and inline outranks every class: what \`CARD\` sets — its display, its padding — the CSS
3569
+ * authored on the element can never change. Keep it to what the element cannot render without, and leave layout and
3570
+ * position (\`position\`, \`width\`, \`margin\`) to the space's classes.
3476
3571
  */
3477
3572
  return (
3478
3573
  <RootElement
@@ -3815,6 +3910,35 @@ var scaffold = (answers) => ({
3815
3910
  ...visualFiles$1(answers),
3816
3911
  ...skillFiles()
3817
3912
  });
3913
+ /**
3914
+ * The files of a project that are this CLI's machinery rather than the project's own: what `plitzi upgrade` keeps up
3915
+ * with the CLI. Named, not inferred, so a file the scaffold starts a project with — its space, pages, plugins, data,
3916
+ * README, `.env` — can never be taken for one: those are the project's from the moment they are written.
3917
+ *
3918
+ * `package.json` is not among them: it is merged, never replaced (`upgrade packages`). Nor are the skills, which come
3919
+ * from the packages installed (`upgrade skills`).
3920
+ */
3921
+ var MACHINERY = /* @__PURE__ */ new Set([
3922
+ "src/author.ts",
3923
+ "src/main.ts",
3924
+ "src/preflight.css",
3925
+ "index.html",
3926
+ "vite.config.ts",
3927
+ "tsconfig.json",
3928
+ "tsconfig.build.json",
3929
+ ".gitignore",
3930
+ "AGENTS.md",
3931
+ "CLAUDE.md",
3932
+ ".prettierrc",
3933
+ ".prettierignore",
3934
+ "eslint.config.mjs",
3935
+ "playwright.config.ts",
3936
+ "visual/home.spec.ts",
3937
+ "src/plugins/README.md",
3938
+ ".yarnrc.yml",
3939
+ "pnpm-workspace.yaml"
3940
+ ]);
3941
+ var machineryFiles = (answers) => Object.fromEntries(Object.entries(scaffold(answers)).filter(([file]) => MACHINERY.has(file)));
3818
3942
  //#endregion
3819
3943
  //#region src/commands/existingProject.ts
3820
3944
  var LOCKFILES = [
@@ -3874,7 +3998,7 @@ var nearestWorkspace = async (from) => {
3874
3998
  var lockfileManager = async (dirs) => {
3875
3999
  for (const dir of dirs) for (const [file, manager] of LOCKFILES) if (await exists(path.join(dir, file))) return manager;
3876
4000
  };
3877
- var readText = async (file) => {
4001
+ var readText$1 = async (file) => {
3878
4002
  try {
3879
4003
  return await fs.readFile(file, "utf-8");
3880
4004
  } catch {
@@ -3886,7 +4010,7 @@ var readText = async (file) => {
3886
4010
  * of its lists byte for byte, which is what says nobody has changed them since the CLI did.
3887
4011
  */
3888
4012
  var packageComponents = async (root) => {
3889
- const [elements, declarations] = await Promise.all([readText(path.join(root, "src/elements.ts")), readText(path.join(root, "src/declarations.ts"))]);
4013
+ const [elements, declarations] = await Promise.all([readText$1(path.join(root, "src/elements.ts")), readText$1(path.join(root, "src/declarations.ts"))]);
3890
4014
  if (elements === void 0 || declarations === void 0) return;
3891
4015
  const components = [...elements.matchAll(/^import (\w+) from '\.\/(\w+)';$/gm)].filter(([, name, folder]) => name === folder).map(([, name]) => name);
3892
4016
  return components.length > 0 && elementsRegistry(components) === elements && declarationsRegistry(components) === declarations ? components : void 0;
@@ -3896,7 +4020,7 @@ var packageComponents = async (root) => {
3896
4020
  * gives the file byte for byte, as with a plugin package's lists.
3897
4021
  */
3898
4022
  var projectDeclared = async (root) => {
3899
- const listed = await readText(path.join(root, "src/plugins/declarations.ts"));
4023
+ const listed = await readText$1(path.join(root, "src/plugins/declarations.ts"));
3900
4024
  if (listed === void 0) return;
3901
4025
  const components = [...listed.matchAll(/^import \w+ from '\.\/(\w+)\/declaration\.ts';$/gm)].map(([, folder]) => folder);
3902
4026
  return projectDeclarations(components) === listed ? components : void 0;
@@ -3918,7 +4042,7 @@ var plitziProject = async (root, packageJson) => {
3918
4042
  ...packageJson.dependencies,
3919
4043
  ...packageJson.devDependencies
3920
4044
  };
3921
- const main = await readText(path.join(root, "src/main.ts"));
4045
+ const main = await readText$1(path.join(root, "src/main.ts"));
3922
4046
  if (!("@plitzi/plitzi-sdk" in dependencies) || main === void 0 || !/src\/plugins|\.\/plugins\//.test(main)) return;
3923
4047
  const server = "@plitzi/sdk-server" in dependencies;
3924
4048
  return {
@@ -4266,30 +4390,55 @@ var checkAt = async (browser, origin, pathname, width, scheme, handles, asked) =
4266
4390
  page.on("response", (response) => {
4267
4391
  if (response.status() >= 400 && response.url().startsWith(origin)) failedRequests.push(`${String(response.status())} ${response.url().slice(origin.length)}`);
4268
4392
  });
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
- };
4393
+ if (!await page.goto(`${origin}${pathname}`, { waitUntil: "networkidle" }).catch(() => null)) {
4394
+ const issue = {
4395
+ code: "no-answer",
4396
+ message: `nothing answers at ${origin}${pathname}`,
4397
+ width
4398
+ };
4399
+ return {
4400
+ path: pathname,
4401
+ width,
4402
+ ok: false,
4403
+ checked: 0,
4404
+ problems: [issue.message],
4405
+ issues: [issue],
4406
+ hiddenAtWidth: [],
4407
+ consoleErrors,
4408
+ failedRequests,
4409
+ devTools: false
4410
+ };
4411
+ }
4279
4412
  const pageId = handles ? pageFor(handles, pathname) : void 0;
4280
4413
  const report = handles && pageId ? await inspectPage(page, handles, { page: pageId }) : await inspectDocument(page);
4281
4414
  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)
4415
+ const noPage = handles && !pageId ? [{
4416
+ code: "no-page",
4417
+ message: `no page of the space answers at ${pathname}`,
4418
+ width
4419
+ }] : [];
4420
+ const failedFlows = devTools.flows.filter((flow) => flow.status === "failed").map((flow) => ({
4421
+ code: "flow-failed",
4422
+ message: failedFlowText(flow),
4423
+ ...flow.on === void 0 ? {} : { elementId: flow.on },
4424
+ width
4425
+ }));
4426
+ const issues = [
4427
+ ...noPage,
4428
+ ...report.issues.map((issue) => ({
4429
+ ...issue,
4430
+ width
4431
+ })),
4432
+ ...failedFlows
4286
4433
  ];
4287
4434
  return {
4288
4435
  path: pathname,
4289
4436
  width,
4290
- ok: problems.length === 0 && consoleErrors.length === 0 && failedRequests.length === 0,
4437
+ ok: issues.length === 0 && consoleErrors.length === 0 && failedRequests.length === 0,
4291
4438
  checked: report.checked,
4292
- problems,
4439
+ problems: issues.map((issue) => issue.message),
4440
+ issues,
4441
+ hiddenAtWidth: report.hiddenAtWidth,
4293
4442
  consoleErrors,
4294
4443
  failedRequests,
4295
4444
  devTools: devTools.available,
@@ -4318,10 +4467,15 @@ var shapeOf = (value) => {
4318
4467
  const text = value === void 0 ? "undefined" : JSON.stringify(value);
4319
4468
  return text.length > 60 ? `${text.slice(0, 57)}…` : text;
4320
4469
  };
4321
- var reportText = (report, asked) => {
4470
+ var reportText$1 = (report, asked) => {
4322
4471
  const head = `${report.path} at ${String(report.width)} px`;
4323
4472
  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");
4473
+ const atWidth = report.hiddenAtWidth.length > 0 ? chalk.dim(` · hidden at this width by a breakpoint, on purpose: ${report.hiddenAtWidth.join(", ")}`) : void 0;
4474
+ if (report.ok) return [
4475
+ chalk.green(`✓ ${head} — ${report.checked > 0 ? `${String(report.checked)} elements on screen, ` : ""}nothing wrong`),
4476
+ ...atWidth ? [atWidth] : [],
4477
+ ...held
4478
+ ].join("\n");
4325
4479
  return [
4326
4480
  chalk.red(`✗ ${head}`),
4327
4481
  ...report.problems.map((problem) => ` - ${problem}`),
@@ -4358,7 +4512,7 @@ var check = async (route, options) => {
4358
4512
  };
4359
4513
  const reports = [];
4360
4514
  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"));
4515
+ console.log(options.json ? JSON.stringify(reports) : reports.map((report) => reportText$1(report, asked)).join("\n"));
4362
4516
  if (reports.some((report) => !report.ok)) process.exitCode = 1;
4363
4517
  } finally {
4364
4518
  await browser.close();
@@ -5192,6 +5346,43 @@ var recordOrigin = async (root, { api, exported, source, pinned, project }) => {
5192
5346
  });
5193
5347
  };
5194
5348
  //#endregion
5349
+ //#region src/commands/scaffoldRecord.ts
5350
+ /**
5351
+ * What the CLI last wrote of a project's machinery (`machineryFiles`): a digest of each file as it was written, never
5352
+ * the file — as `.plitzi/space.json` keeps of what a pull wrote. Enough for `plitzi upgrade` to tell a file nobody
5353
+ * touched since, which it may replace, from one somebody made their own, which it only shows.
5354
+ *
5355
+ * Kept where the CLI keeps its other state. A project without one — made before it existed, or a clone that did not
5356
+ * carry it — is not refused anything: every file that differs is then shown as the project's, for its author to take.
5357
+ */
5358
+ var SCAFFOLD_RECORD_FILE = path.join(".plitzi", "scaffold.json");
5359
+ var digestOf = (contents) => createHash("sha256").update(contents).digest("hex");
5360
+ var readScaffoldRecord = async (root) => {
5361
+ let parsed;
5362
+ try {
5363
+ parsed = JSON.parse(await fs.readFile(path.join(root, SCAFFOLD_RECORD_FILE), "utf-8"));
5364
+ } catch {
5365
+ return;
5366
+ }
5367
+ if (!e$1(parsed) || parsed.format !== 1 || typeof parsed.cli !== "string" || !e$1(parsed.files)) return;
5368
+ const files = Object.fromEntries(Object.entries(parsed.files).filter((entry) => typeof entry[1] === "string"));
5369
+ return {
5370
+ format: 1,
5371
+ cli: parsed.cli,
5372
+ files
5373
+ };
5374
+ };
5375
+ /** What the CLI wrote, file → its digest (`digestOf`), as `cli` wrote it. */
5376
+ var writeScaffoldRecord = async (root, cli, digests) => {
5377
+ const record = {
5378
+ format: 1,
5379
+ cli,
5380
+ files: Object.fromEntries(Object.entries(digests).sort(([a], [b]) => a.localeCompare(b)))
5381
+ };
5382
+ await fs.mkdir(path.join(root, ".plitzi"), { recursive: true });
5383
+ await fs.writeFile(path.join(root, SCAFFOLD_RECORD_FILE), `${JSON.stringify(record, null, 2)}\n`);
5384
+ };
5385
+ //#endregion
5195
5386
  //#region src/commands/create.ts
5196
5387
  var MODES = ["server", "client"];
5197
5388
  var SOURCES = ["local", "cloud"];
@@ -5349,6 +5540,7 @@ var create = async (directory, options) => {
5349
5540
  ".env": envFromSpace(exported, answers, randomBytes(32).toString("hex"))
5350
5541
  } : files);
5351
5542
  const missing = fromSpace ? await writeFromSpace(target, fromSpace) : [];
5543
+ await writeScaffoldRecord(target, CLI_VERSION, Object.fromEntries(Object.keys(machineryFiles(answers)).filter((file) => file in files).map((file) => [file, digestOf(files[file])])));
5352
5544
  const wantsInstall = options.install !== false;
5353
5545
  const installed = wantsInstall && await install(packageManager, target);
5354
5546
  /**
@@ -5815,6 +6007,7 @@ var verdict = (before, written, after) => {
5815
6007
  };
5816
6008
  //#endregion
5817
6009
  //#region src/fix/sourceEdits.ts
6010
+ var one = (change) => ({ changes: [change] });
5818
6011
  var SINGLE = "'";
5819
6012
  var quoted = (text) => `${SINGLE}${text.replace(/\\/g, "\\\\").replaceAll(SINGLE, `\\${SINGLE}`)}${SINGLE}`;
5820
6013
  var literalText = (value) => typeof value === "string" ? quoted(value) : String(value);
@@ -5910,27 +6103,27 @@ var editKey = (ts, sourceFile, object, nested, edit) => {
5910
6103
  const property = holder ? findProperty(ts, holder, edit.key) : void 0;
5911
6104
  if (edit.op === "set") {
5912
6105
  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)) };
6106
+ if (!holder || !property) return one(insertInto(sourceFile, inner ?? object, edit.key, literalText(edit.value)));
5914
6107
  if (!ts.isPropertyAssignment(property) || !isLiteral(ts, property.initializer)) return { unplaced: `\`${edit.key}\` is not written as a value there` };
5915
- return { change: {
6108
+ return one({
5916
6109
  start: property.initializer.getStart(sourceFile),
5917
6110
  end: property.initializer.getEnd(),
5918
6111
  text: literalText(edit.value)
5919
- } };
6112
+ });
5920
6113
  }
5921
6114
  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)) };
6115
+ if (edit.op === "remove") return one(removeFromList(sourceFile, holder.properties, holder.properties.indexOf(property)));
5923
6116
  if (edit.op === "rename" && edit.to !== void 0) {
5924
- if (ts.isShorthandPropertyAssignment(property)) return { change: {
6117
+ if (ts.isShorthandPropertyAssignment(property)) return one({
5925
6118
  start: property.getStart(sourceFile),
5926
6119
  end: property.getEnd(),
5927
6120
  text: `${keyText(edit.to)}: ${property.name.text}`
5928
- } };
5929
- if (ts.isPropertyAssignment(property)) return { change: {
6121
+ });
6122
+ if (ts.isPropertyAssignment(property)) return one({
5930
6123
  start: property.name.getStart(sourceFile),
5931
6124
  end: property.name.getEnd(),
5932
6125
  text: keyText(edit.to)
5933
- } };
6126
+ });
5934
6127
  }
5935
6128
  return { unplaced: `\`${edit.key}\` is not a property that edit applies to` };
5936
6129
  };
@@ -5946,18 +6139,18 @@ var editBinding = (ts, sourceFile, props, edit) => {
5946
6139
  ts.forEachChild(node, visit);
5947
6140
  };
5948
6141
  visit(list);
5949
- return found ? { change: {
6142
+ return found ? one({
5950
6143
  start: found.getStart(sourceFile),
5951
6144
  end: found.getEnd(),
5952
6145
  text: quoted(edit.to)
5953
- } } : { unplaced: `"${edit.key}" is not written as a transformer there` };
6146
+ }) : { unplaced: `"${edit.key}" is not written as a transformer there` };
5954
6147
  }
5955
6148
  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)) });
6149
+ const dropBind = () => one(removeFromList(sourceFile, props.properties, props.properties.indexOf(bind)));
5957
6150
  if (ts.isObjectLiteralExpression(list)) {
5958
6151
  const property = findProperty(ts, list, edit.key);
5959
6152
  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)) };
6153
+ return list.properties.length === 1 ? dropBind() : one(removeFromList(sourceFile, list.properties, list.properties.indexOf(property)));
5961
6154
  }
5962
6155
  if (ts.isArrayLiteralExpression(list)) {
5963
6156
  const index = list.elements.findIndex((entry) => {
@@ -5966,10 +6159,95 @@ var editBinding = (ts, sourceFile, props, edit) => {
5966
6159
  return to !== void 0 && ts.isPropertyAssignment(to) && ts.isStringLiteral(to.initializer) && to.initializer.text === edit.key;
5967
6160
  });
5968
6161
  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) };
6162
+ return list.elements.length === 1 ? dropBind() : one(removeFromList(sourceFile, list.elements, index));
5970
6163
  }
5971
6164
  return { unplaced: "`bind` is not written as a literal there" };
5972
6165
  };
6166
+ /** The factories a child of the moved kind is written with: what a move may leave imported and unused. */
6167
+ var WORD_FACTORIES = ["text", "fontAwesome"];
6168
+ /**
6169
+ * `children: [text(words), fontAwesome({ icon })]` written as the element's own `content: words, icon` — the words
6170
+ * kept as written, a literal or an expression (`entry.label`). Only children written exactly so are moved: a `text`
6171
+ * handed options (a class, an id) or an icon with more than its class is something the element's own attributes would
6172
+ * not say, and is left for the author.
6173
+ */
6174
+ var moveChildrenToContent = (ts, sourceFile, props, edit) => {
6175
+ const children = findProperty(ts, props, "children");
6176
+ if (!children || !ts.isPropertyAssignment(children) || !ts.isArrayLiteralExpression(children.initializer)) return { unplaced: "`children` is not written as a list there" };
6177
+ let words;
6178
+ let icon;
6179
+ for (const child of children.initializer.elements) {
6180
+ const call = ts.isCallExpression(child) && ts.isIdentifier(child.expression) && child.arguments.length === 1 ? {
6181
+ factory: child.expression.text,
6182
+ argument: child.arguments[0]
6183
+ } : void 0;
6184
+ if (call?.factory === "text" && !words) {
6185
+ words = call.argument;
6186
+ continue;
6187
+ }
6188
+ const iconProperty = call?.factory === "fontAwesome" && ts.isObjectLiteralExpression(call.argument) && call.argument.properties.length === 1 ? findProperty(ts, call.argument, "icon") : void 0;
6189
+ if (iconProperty && ts.isPropertyAssignment(iconProperty) && !icon) {
6190
+ icon = iconProperty.initializer;
6191
+ continue;
6192
+ }
6193
+ return { unplaced: "a child carries more than its words or its icon there" };
6194
+ }
6195
+ if (Boolean(icon) !== (edit.icon !== void 0) || words === void 0 && edit.value !== "") return { unplaced: "the children written there are not the ones it holds" };
6196
+ if (words && ts.isStringLiteral(words) && words.text !== edit.value) return { unplaced: "the words written there are not the ones it shows" };
6197
+ if (icon && findProperty(ts, props, "icon")) return { unplaced: "it is written with an `icon` of its own already" };
6198
+ const content = findProperty(ts, props, "content");
6199
+ if (content && words && !(ts.isPropertyAssignment(content) && ts.isStringLiteral(content.initializer) && content.initializer.text === "")) return { unplaced: "it is written with words of its own beside its children" };
6200
+ const written = [
6201
+ words && `content: ${words.getText(sourceFile)}`,
6202
+ icon && `icon: ${icon.getText(sourceFile)}`,
6203
+ edit.iconPlacement && `iconPlacement: ${quoted(edit.iconPlacement)}`
6204
+ ].filter((part) => Boolean(part));
6205
+ return {
6206
+ changes: [...content && words ? [removeFromList(sourceFile, props.properties, props.properties.indexOf(content))] : [], {
6207
+ start: children.getStart(sourceFile),
6208
+ end: children.getEnd(),
6209
+ text: written.join(", ")
6210
+ }],
6211
+ orphans: WORD_FACTORIES
6212
+ };
6213
+ };
6214
+ /**
6215
+ * The named imports among `names` that nothing in the file uses any longer — what a move left behind, taken out with
6216
+ * the import that held only them.
6217
+ */
6218
+ var pruneImports = (ts, fileName, text, names) => {
6219
+ const sourceFile = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
6220
+ const used = /* @__PURE__ */ new Set();
6221
+ const visit = (node) => {
6222
+ if (ts.isIdentifier(node) && !ts.isImportSpecifier(node.parent)) used.add(node.text);
6223
+ ts.forEachChild(node, visit);
6224
+ };
6225
+ visit(sourceFile);
6226
+ const changes = [];
6227
+ for (const statement of sourceFile.statements) {
6228
+ const clause = ts.isImportDeclaration(statement) ? statement.importClause : void 0;
6229
+ const bindings = clause?.namedBindings;
6230
+ if (!clause || !bindings || !ts.isNamedImports(bindings)) continue;
6231
+ const elements = bindings.elements;
6232
+ const unused = elements.filter((element) => names.includes(element.name.text) && !used.has(element.name.text));
6233
+ if (unused.length === 0) continue;
6234
+ if (unused.length === elements.length && !clause.name) {
6235
+ changes.push({
6236
+ start: statement.getFullStart(),
6237
+ end: statement.getEnd(),
6238
+ text: ""
6239
+ });
6240
+ continue;
6241
+ }
6242
+ const kept = elements.filter((element) => !unused.includes(element));
6243
+ changes.push({
6244
+ start: bindings.getStart(sourceFile),
6245
+ end: bindings.getEnd(),
6246
+ text: `{ ${kept.map((element) => element.getText(sourceFile)).join(", ")} }`
6247
+ });
6248
+ }
6249
+ return applyChanges(text, changes) ?? text;
6250
+ };
5973
6251
  /**
5974
6252
  * What an edit planned on a spec changes in the source that wrote it: the call at its position, the props it was
5975
6253
  * handed, and in them only what is written as a literal. Anything else — a value computed, props spread from a
@@ -5981,6 +6259,7 @@ var sourceEdit = (ts, sourceFile, position, edit) => {
5981
6259
  const props = propsOf(ts, call);
5982
6260
  if (!props) return { unplaced: "the call that wrote it was handed no props to edit" };
5983
6261
  if (edit.on === "binding") return editBinding(ts, sourceFile, props, edit);
6262
+ if (edit.on === "children") return moveChildrenToContent(ts, sourceFile, props, edit);
5984
6263
  if (edit.on === "step") {
5985
6264
  const step = edit.step ? stepIn(ts, props, edit.step.flow, edit.step.index) : void 0;
5986
6265
  return step ? editKey(ts, sourceFile, step, "params", edit) : { unplaced: "the step is not written in that call" };
@@ -6047,7 +6326,8 @@ var editSources = async (root, fixes) => {
6047
6326
  else placed.push({
6048
6327
  fix,
6049
6328
  file,
6050
- change: outcome.change
6329
+ changes: outcome.changes,
6330
+ orphans: outcome.orphans ?? []
6051
6331
  });
6052
6332
  }
6053
6333
  const format = await projectFormatter(root);
@@ -6055,7 +6335,7 @@ var editSources = async (root, fixes) => {
6055
6335
  const written = [];
6056
6336
  for (const [file, { text }] of sources) {
6057
6337
  const mine = placed.filter((entry) => entry.file === file);
6058
- const changed = applyChanges(text, mine.map((entry) => entry.change));
6338
+ const changed = applyChanges(text, mine.flatMap((entry) => entry.changes));
6059
6339
  if (changed === void 0) {
6060
6340
  left.push(...mine.map((entry) => ({
6061
6341
  fix: entry.fix,
@@ -6064,10 +6344,12 @@ var editSources = async (root, fixes) => {
6064
6344
  continue;
6065
6345
  }
6066
6346
  if (mine.length > 0) {
6347
+ const orphans = [...new Set(mine.flatMap((entry) => entry.orphans))];
6348
+ const pruned = ts && orphans.length > 0 ? pruneImports(ts, file, changed, orphans) : changed;
6067
6349
  files.push({
6068
6350
  file,
6069
6351
  before: text,
6070
- after: await formatLikeBefore(format, path.relative(root, file), text, changed)
6352
+ after: await formatLikeBefore(format, path.relative(root, file), text, pruned)
6071
6353
  });
6072
6354
  written.push(...mine.map((entry) => entry.fix));
6073
6355
  }
@@ -7380,10 +7662,23 @@ var pictureOf = async (browser, url, view) => {
7380
7662
  if (view.waitFor) {
7381
7663
  if (!await page.waitForSelector(selectorOf(view.waitFor), { timeout: 1e4 }).catch(() => null)) return { problem: `${view.waitFor} did not appear on ${url} within 10 s.` };
7382
7664
  }
7665
+ for (const target of [view.scrollTo, view.clip]) if (target && !await page.waitForSelector(selectorOf(target), { timeout: 1e4 }).catch(() => null)) return { problem: `${target} is not on ${url}.` };
7666
+ if (view.scrollTo) await page.locator(selectorOf(view.scrollTo)).first().scrollIntoViewIfNeeded();
7383
7667
  return {
7384
7668
  page,
7385
- png: await page.screenshot({ fullPage: true })
7669
+ png: await capture(page, framing(view))
7670
+ };
7671
+ };
7672
+ var framing = (view) => {
7673
+ if (view.clip) return {
7674
+ kind: "element",
7675
+ selector: selectorOf(view.clip)
7386
7676
  };
7677
+ return view.viewport || view.scrollTo ? { kind: "viewport" } : { kind: "page" };
7678
+ };
7679
+ var capture = (page, how) => {
7680
+ if (how.kind === "element") return page.locator(how.selector).first().screenshot();
7681
+ return page.screenshot({ fullPage: how.kind === "page" });
7387
7682
  };
7388
7683
  var regionsText = (regions) => regions.map((region) => `${region.name} ${String(region.changed)}%`).join(", ");
7389
7684
  /** Compared frame to frame: the most each region changed between two that follow each other. */
@@ -7408,6 +7703,11 @@ var shot = async (route, options) => {
7408
7703
  fail(browser.problem);
7409
7704
  return;
7410
7705
  }
7706
+ if (options.compare && (options.clip || options.scrollTo || options.viewport)) {
7707
+ await browser.close();
7708
+ fail("`--compare` sets the whole page beside the other site, section by section: leave out --clip, --scroll-to and --viewport.");
7709
+ return;
7710
+ }
7411
7711
  try {
7412
7712
  const pathname = route ?? "/";
7413
7713
  const view = {
@@ -7415,8 +7715,12 @@ var shot = async (route, options) => {
7415
7715
  height: options.height ?? 800,
7416
7716
  scheme: options.scheme ?? "light",
7417
7717
  reducedMotion: options.reducedMotion === true,
7418
- ...options.waitFor ? { waitFor: options.waitFor } : {}
7718
+ ...options.waitFor ? { waitFor: options.waitFor } : {},
7719
+ ...options.clip ? { clip: options.clip } : {},
7720
+ ...options.scrollTo ? { scrollTo: options.scrollTo } : {},
7721
+ ...options.viewport ? { viewport: true } : {}
7419
7722
  };
7723
+ const how = framing(view);
7420
7724
  const name = pathname === "/" ? "home" : pathname.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "");
7421
7725
  const out = path.resolve(project.root, options.out ?? `visual/.shots/${name}-${String(view.width)}-${view.scheme}.png`);
7422
7726
  const base = out.replace(/\.png$/, "");
@@ -7427,7 +7731,7 @@ var shot = async (route, options) => {
7427
7731
  }
7428
7732
  await mkdir(path.dirname(out), { recursive: true });
7429
7733
  await writeFile(out, taken.png);
7430
- const regions = await taken.page.evaluate(pageRegions, void 0);
7734
+ const regions = how.kind === "page" ? await taken.page.evaluate(pageRegions, void 0) : [];
7431
7735
  const report = {
7432
7736
  path: pathname,
7433
7737
  width: view.width,
@@ -7459,11 +7763,15 @@ var shot = async (route, options) => {
7459
7763
  const pictures = [taken.png];
7460
7764
  for (let frame = 1; frame < frames; frame += 1) {
7461
7765
  await taken.page.waitForTimeout(every);
7462
- pictures.push(await taken.page.screenshot({ fullPage: true }));
7766
+ pictures.push(await capture(taken.page, how));
7463
7767
  }
7464
7768
  const diffs = [];
7465
7769
  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);
7770
+ const whole = how.kind === "element" ? options.clip ?? "element" : "viewport";
7771
+ const moved = how.kind === "page" ? framesMoved(diffs) : [{
7772
+ name: whole,
7773
+ changed: Math.max(0, ...diffs.map((diff) => diff.changed))
7774
+ }];
7467
7775
  report.frames = {
7468
7776
  count: frames,
7469
7777
  every,
@@ -7486,52 +7794,347 @@ var shot = async (route, options) => {
7486
7794
  }
7487
7795
  };
7488
7796
  //#endregion
7489
- //#region src/commands/skills.ts
7797
+ //#region src/commands/upgrade.ts
7490
7798
  /**
7491
- * `plitzi skills update`: the Plitzi skills in `.claude/skills/` brought up to the packages the project has installed.
7799
+ * `plitzi upgrade`: a project brought up to the CLI it now has — what `create` would write today, for what is the
7800
+ * CLI's to write. Every part on its own, or all of them:
7801
+ *
7802
+ * - `files`: the machinery (`machineryFiles`) — `author.ts`, `main.ts`, the Playwright and lint configs, AGENTS.md. One
7803
+ * nobody changed since the CLI wrote it is replaced; one the project made its own is shown as a diff and left, unless
7804
+ * `--take` names it.
7805
+ * - `packages`: `package.json` merged — the scripts and dependencies it lacks added, `@plitzi/*` raised to this
7806
+ * version. A script or a dependency the project has is never changed; then the install.
7807
+ * - `skills`: `.claude/skills/plitzi-*` from the packages installed, each replaced whole.
7808
+ * - `renames`: a name a version renamed, still written in the source (`canTemplate`), at its file and line — and with
7809
+ * `--write`, renamed.
7810
+ *
7811
+ * Shown by default; `--write` makes it. What the space is — its pages, plugins, data — is the project's, never named.
7492
7812
  *
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.
7813
+ * plitzi upgrade # everything, as it would be
7814
+ * plitzi upgrade skills --write # only the skills
7815
+ * plitzi upgrade --write --take src/author.ts
7497
7816
  */
7498
- var readSkill = async (file) => {
7817
+ var UPGRADE_PARTS = [
7818
+ "files",
7819
+ "packages",
7820
+ "skills",
7821
+ "renames"
7822
+ ];
7823
+ var RENAMES = [{
7824
+ name: "canTemplate",
7825
+ to: "canSnippet",
7826
+ since: "0.38.0",
7827
+ what: "a plugin’s builder config key"
7828
+ }, ...[
7829
+ "authorTemplate",
7830
+ "validateTemplate",
7831
+ "TemplateSpec",
7832
+ "AuthoredTemplate"
7833
+ ].map((name) => ({
7834
+ name,
7835
+ to: name.replace("Template", "Snippet"),
7836
+ since: "0.38.0",
7837
+ what: "what the builder saves from a subtree is a snippet",
7838
+ from: "@plitzi/sdk-authoring"
7839
+ }))];
7840
+ /** The project's own source: TypeScript and JavaScript under `src/` and `visual/`, never what was installed or built. */
7841
+ var sourceFiles = async (root) => {
7842
+ const found = [];
7843
+ const walk = async (dir) => {
7844
+ let entries;
7845
+ try {
7846
+ entries = await fs.readdir(path.join(root, dir), { withFileTypes: true });
7847
+ } catch {
7848
+ return;
7849
+ }
7850
+ for (const entry of entries) {
7851
+ const relative = path.join(dir, entry.name);
7852
+ if (entry.isDirectory()) await walk(relative);
7853
+ else if (/\.(ts|tsx|mts|js|jsx|mjs)$/.test(entry.name)) found.push(relative);
7854
+ }
7855
+ };
7856
+ await walk("src");
7857
+ await walk("visual");
7858
+ return found;
7859
+ };
7860
+ var wordOf = (name) => new RegExp(`\\b${name}\\b`, "g");
7861
+ var importsFrom = (text, name, from) => new RegExp(`import\\s+(type\\s+)?\\{[^}]*\\b${name}\\b[^}]*\\}\\s*from\\s*['"]${from}['"]`).test(text);
7862
+ var renamesIn = (file, text) => RENAMES.filter((rename) => !rename.from || importsFrom(text, rename.name, rename.from)).flatMap((rename) => text.split("\n").flatMap((line, index) => wordOf(rename.name).test(line) ? [{
7863
+ file,
7864
+ line: index + 1,
7865
+ name: rename.name,
7866
+ to: rename.to,
7867
+ since: rename.since,
7868
+ what: rename.what
7869
+ }] : []));
7870
+ /** The lowest version a range allows, as numbers: `^0.38.2` → [0, 38, 2]. */
7871
+ var floorOf = (range) => {
7872
+ const match = /(\d+)\.(\d+)\.(\d+)/.exec(range);
7873
+ return match ? match.slice(1).map(Number) : void 0;
7874
+ };
7875
+ var isBelow = (range, than) => {
7876
+ const [a, b] = [floorOf(range), floorOf(than)];
7877
+ if (!a || !b) return false;
7878
+ const at = a.findIndex((part, index) => part !== b[index]);
7879
+ return at !== -1 && a[at] < b[at];
7880
+ };
7881
+ var stringsOf = (value) => e$1(value) ? Object.fromEntries(Object.entries(value).filter((entry) => typeof entry[1] === "string")) : {};
7882
+ var planPackages = (text, answers) => {
7883
+ const parsed = JSON.parse(text);
7884
+ const ours = JSON.parse(packageJson$1(answers));
7885
+ const project = e$1(parsed) ? { ...parsed } : {};
7886
+ const scaffold = e$1(ours) ? ours : {};
7887
+ const plan = {
7888
+ added: [],
7889
+ raised: [],
7890
+ scripts: [],
7891
+ ownScripts: []
7892
+ };
7893
+ const scripts = stringsOf(project.scripts);
7894
+ for (const [name, command] of Object.entries(stringsOf(scaffold.scripts))) if (!(name in scripts)) {
7895
+ plan.scripts.push({
7896
+ name,
7897
+ command
7898
+ });
7899
+ scripts[name] = command;
7900
+ } else if (scripts[name] !== command) plan.ownScripts.push({
7901
+ name,
7902
+ yours: scripts[name],
7903
+ ours: command
7904
+ });
7905
+ const sections = {
7906
+ dependencies: stringsOf(project.dependencies),
7907
+ devDependencies: stringsOf(project.devDependencies)
7908
+ };
7909
+ for (const section of ["dependencies", "devDependencies"]) for (const [name, range] of Object.entries(stringsOf(scaffold[section]))) {
7910
+ const where = name in sections.dependencies ? "dependencies" : name in sections.devDependencies ? "devDependencies" : void 0;
7911
+ if (!where) {
7912
+ plan.added.push({
7913
+ section,
7914
+ name,
7915
+ range
7916
+ });
7917
+ sections[section][name] = range;
7918
+ } else if (name.startsWith("@plitzi/") && isBelow(sections[where][name], range)) {
7919
+ plan.raised.push({
7920
+ section: where,
7921
+ name,
7922
+ from: sections[where][name],
7923
+ to: range
7924
+ });
7925
+ sections[where][name] = range;
7926
+ }
7927
+ }
7928
+ if (plan.added.length + plan.raised.length + plan.scripts.length === 0) return plan;
7929
+ const sorted = (entries) => Object.fromEntries(Object.entries(entries).sort(([a], [b]) => a.localeCompare(b)));
7930
+ const indent = /^\{\n([ \t]+)"/.exec(text)?.[1] ?? " ";
7931
+ const merged = {
7932
+ ...project,
7933
+ scripts,
7934
+ dependencies: sorted(sections.dependencies),
7935
+ devDependencies: sorted(sections.devDependencies)
7936
+ };
7937
+ return {
7938
+ ...plan,
7939
+ after: `${JSON.stringify(merged, null, indent)}\n`
7940
+ };
7941
+ };
7942
+ var readText = async (file) => {
7499
7943
  try {
7500
7944
  return await fs.readFile(file, "utf-8");
7501
7945
  } catch {
7502
7946
  return;
7503
7947
  }
7504
7948
  };
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 = [];
7949
+ var planFiles = async (root, files, recorded, take) => Promise.all(Object.entries(files).map(async ([file, ours]) => {
7950
+ const yours = await readText(path.join(root, file));
7951
+ if (yours === void 0) return {
7952
+ file,
7953
+ status: "added",
7954
+ ours
7955
+ };
7956
+ if (yours === ours) return {
7957
+ file,
7958
+ status: "current",
7959
+ ours,
7960
+ yours
7961
+ };
7962
+ if (recorded[file] === digestOf(yours)) return {
7963
+ file,
7964
+ status: "updated",
7965
+ ours,
7966
+ yours
7967
+ };
7968
+ return {
7969
+ file,
7970
+ status: take.includes("all") || take.includes(file) ? "taken" : "yours",
7971
+ ours,
7972
+ yours
7973
+ };
7974
+ }));
7975
+ var WRITES = /* @__PURE__ */ new Set([
7976
+ "added",
7977
+ "updated",
7978
+ "taken"
7979
+ ]);
7980
+ var planSkills = async (root) => {
7981
+ const files = skillFiles(SKILL_NAMES, root);
7982
+ const plans = [];
7510
7983
  for (const name of SKILL_NAMES) {
7511
- const skill = await readSkill(path.join(folder, name, "SKILL.md"));
7512
- if (skill !== void 0) present.push({
7984
+ const skill = `.claude/skills/${name}/SKILL.md`;
7985
+ if (!(skill in files)) continue;
7986
+ const now = files[skill];
7987
+ const was = await readText(path.join(root, skill));
7988
+ if (was !== now) plans.push({
7513
7989
  name,
7514
- was: skillVersion(skill)
7990
+ ...was === void 0 ? {} : { was: skillVersion(was) ?? "unversioned" },
7991
+ now: skillVersion(now) ?? "unversioned"
7515
7992
  });
7516
7993
  }
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), {
7994
+ return {
7995
+ plans,
7996
+ files
7997
+ };
7998
+ };
7999
+ var writeSkills = async (root, plans, files) => {
8000
+ for (const { name } of plans) {
8001
+ await fs.rm(path.join(root, ".claude/skills", name), {
7529
8002
  recursive: true,
7530
8003
  force: true
7531
8004
  });
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"}`));
8005
+ await writeFiles(root, Object.fromEntries(Object.entries(files).filter(([file]) => file.startsWith(`.claude/skills/${name}/`))));
8006
+ }
8007
+ };
8008
+ /** What a project was made with, as far as its machinery cares: none of it reads the key or the environment. */
8009
+ var answersFor = async (root, plitzi, manager) => {
8010
+ const name = (await readPackageJson(root))?.name ?? path.basename(root);
8011
+ const managerVersion = detectManagerVersion(manager, root);
8012
+ return {
8013
+ name,
8014
+ mode: plitzi.mode,
8015
+ source: plitzi.source,
8016
+ key: "",
8017
+ environment: "main",
8018
+ packageManager: manager,
8019
+ ...managerVersion === void 0 ? {} : { managerVersion }
8020
+ };
8021
+ };
8022
+ var statusLine = {
8023
+ current: (file) => chalk.dim(` = ${file}`),
8024
+ added: (file) => chalk.green(` + ${file}`),
8025
+ updated: (file) => chalk.green(` ~ ${file}`),
8026
+ taken: (file) => chalk.yellow(` ~ ${file} (yours, taken)`),
8027
+ yours: (file) => chalk.yellow(` ! ${file} — yours: the CLI's version below; --take ${file} to replace it`)
8028
+ };
8029
+ var reportText = (report) => {
8030
+ const lines = [chalk.bold(`plitzi upgrade → ${report.version}${report.write ? "" : " (shown; --write makes it)"}`)];
8031
+ if (report.files) {
8032
+ const changed = report.files.filter((file) => file.status !== "current");
8033
+ lines.push(changed.length === 0 ? chalk.green("files: up to date") : "files:");
8034
+ for (const file of changed) {
8035
+ lines.push(statusLine[file.status](file.file));
8036
+ if (file.diff) lines.push(chalk.dim(file.diff));
8037
+ }
8038
+ }
8039
+ if (report.packages) {
8040
+ const { added, raised, scripts, ownScripts, install: installed } = report.packages;
8041
+ const none = added.length + raised.length + scripts.length === 0;
8042
+ lines.push(none ? chalk.green("packages: up to date") : "packages:");
8043
+ lines.push(...raised.map((entry) => chalk.green(` ~ ${entry.name} ${entry.from} → ${entry.to}`)), ...added.map((entry) => chalk.green(` + ${entry.name} ${entry.range} (${entry.section})`)), ...scripts.map((entry) => chalk.green(` + script ${entry.name}: ${entry.command}`)), ...ownScripts.map((entry) => chalk.dim(` = script ${entry.name} is yours ("${entry.yours}"; the CLI writes "${entry.ours}")`)));
8044
+ if (installed === "needed") lines.push(chalk.yellow(" then install, for the new versions to be the ones that run"));
8045
+ else if (installed === "failed") lines.push(chalk.red(" the install failed: the reason is in the output above"));
8046
+ }
8047
+ if (report.skills) {
8048
+ lines.push(report.skills.length === 0 ? chalk.green("skills: up to date") : "skills:");
8049
+ lines.push(...report.skills.map((skill) => chalk.green(` ~ ${skill.name}: ${skill.was ?? "none"} → ${skill.now ?? "?"}`)));
8050
+ }
8051
+ if (report.renames) {
8052
+ lines.push(report.renames.length === 0 ? chalk.green("renames: none written") : "renames:");
8053
+ lines.push(...report.renames.map((found) => chalk.yellow(` ${report.write ? "~" : "!"} ${found.file}:${String(found.line)} ${found.name} → ${found.to} (${found.since}: ${found.what})`)));
8054
+ }
8055
+ return lines.join("\n");
8056
+ };
8057
+ var upgrade = async (parts, options) => {
8058
+ const unknown = parts.filter((part) => !UPGRADE_PARTS.includes(part));
8059
+ if (unknown.length > 0) {
8060
+ fail(`Not a part of a project to upgrade: ${unknown.join(", ")}. The parts are ${UPGRADE_PARTS.join(", ")}.`);
8061
+ return;
8062
+ }
8063
+ const project = await projectHere("to upgrade");
8064
+ if (!project) return;
8065
+ const wanted = new Set(parts.length === 0 ? UPGRADE_PARTS : parts);
8066
+ const { root } = project;
8067
+ const manager = project.packageManager ?? "npm";
8068
+ const write = Boolean(options.write);
8069
+ const report = {
8070
+ version: CLI_VERSION,
8071
+ write
8072
+ };
8073
+ const plitzi = project.plitzi?.kind === "project" ? project.plitzi : void 0;
8074
+ if (!plitzi && (wanted.has("files") || wanted.has("packages"))) {
8075
+ fail("Its files and package.json are those of a project `plitzi create` wrote, and this is not one: `plitzi upgrade skills renames` brings up the rest.");
8076
+ return;
8077
+ }
8078
+ const answers = plitzi ? await answersFor(root, plitzi, manager) : void 0;
8079
+ if (answers && wanted.has("files")) {
8080
+ const ours = machineryFiles(answers);
8081
+ const recorded = (await readScaffoldRecord(root))?.files ?? {};
8082
+ const plans = await planFiles(root, ours, recorded, options.take ?? []);
8083
+ if (write) {
8084
+ const written = plans.filter((plan) => WRITES.has(plan.status));
8085
+ await writeFiles(root, Object.fromEntries(written.map((plan) => [plan.file, plan.ours])));
8086
+ await writeScaffoldRecord(root, CLI_VERSION, Object.fromEntries(plans.flatMap((plan) => {
8087
+ if (WRITES.has(plan.status) || plan.status === "current") return [[plan.file, digestOf(plan.ours)]];
8088
+ return plan.file in recorded ? [[plan.file, recorded[plan.file]]] : [];
8089
+ })));
8090
+ }
8091
+ report.files = plans.map((plan) => ({
8092
+ file: plan.file,
8093
+ status: plan.status,
8094
+ ...plan.status === "yours" && plan.yours !== void 0 ? { diff: unifiedDiff(plan.file, plan.yours, plan.ours) } : {}
8095
+ }));
8096
+ }
8097
+ if (answers && wanted.has("packages")) {
8098
+ const text = await readText(path.join(root, "package.json"));
8099
+ const plan = text === void 0 ? void 0 : planPackages(text, answers);
8100
+ if (plan) {
8101
+ const { after, ...rest } = plan;
8102
+ let installed;
8103
+ if (after !== void 0) {
8104
+ installed = "needed";
8105
+ if (write) {
8106
+ await fs.writeFile(path.join(root, "package.json"), after);
8107
+ if (options.install !== false && !options.json) installed = await install(manager, root) ? "done" : "failed";
8108
+ }
8109
+ }
8110
+ report.packages = {
8111
+ ...rest,
8112
+ ...installed === void 0 ? {} : { install: installed }
8113
+ };
8114
+ }
8115
+ }
8116
+ if (wanted.has("skills")) {
8117
+ const { plans, files } = await planSkills(root);
8118
+ if (write) await writeSkills(root, plans, files);
8119
+ report.skills = plans;
8120
+ }
8121
+ if (wanted.has("renames")) {
8122
+ const found = [];
8123
+ for (const file of await sourceFiles(root)) {
8124
+ const text = await readText(path.join(root, file));
8125
+ const inFile = text === void 0 ? [] : renamesIn(file, text);
8126
+ if (write && text !== void 0 && inFile.length > 0) {
8127
+ const renamed = [...new Set(inFile.map((entry) => entry.name))].reduce((result, name) => result.replace(wordOf(name), RENAMES.find((rename) => rename.name === name)?.to ?? name), text);
8128
+ await fs.writeFile(path.join(root, file), renamed);
8129
+ }
8130
+ found.push(...inFile);
8131
+ }
8132
+ report.renames = found;
8133
+ }
8134
+ console.log(options.json ? JSON.stringify(report) : reportText(report));
8135
+ if (report.packages?.install === "failed") {
8136
+ process.exitCode = 1;
8137
+ console.error(chalk.dim(`\`${installCommand(manager)}\` failed.`));
7535
8138
  }
7536
8139
  };
7537
8140
  //#endregion
@@ -7747,7 +8350,7 @@ var positiveInteger = (value) => {
7747
8350
  * documented, which was the problem — a person had to read four things and write three files correctly before they
7748
8351
  * could tell whether any of it worked. The same holds for an element of one's own, which is the second thing it does.
7749
8352
  */
7750
- program.name("plitzi").description("Plitzi command line");
8353
+ program.name("plitzi").description("Plitzi command line").version(CLI_VERSION);
7751
8354
  /** A flag given more than once, as the list of every value. */
7752
8355
  var collect = (value, previous) => [...previous, value];
7753
8356
  /** The flags that shape a PROJECT: given with `--plugin`, they are a mistake worth saying rather than ignoring. */
@@ -7783,9 +8386,10 @@ program.command("import").argument("<url>", "The page to start from: https://exa
7783
8386
  768,
7784
8387
  390
7785
8388
  ])).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));
8389
+ 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 unless --viewport, --scroll-to or --clip)").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("--clip <element>", "Only this element — its name or a CSS selector — scrolled to wherever it is").option("--scroll-to <element>", "The page scrolled until this element is in view; the picture is the screen there").option("--viewport", "Only what fits the viewport, not the whole page").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
8390
  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());
8391
+ program.command("upgrade").alias("update").argument("[parts...]", `Only these: ${UPGRADE_PARTS.join(", ")} — every one when none is named`).description("Bring the project up to this CLI: its machinery files, package.json, the skills and renamed names — shown; with --write made").option("--write", "Make the changes: files nobody changed replaced, package.json merged and installed, names renamed").option("--take <files...>", "Replace these files of your own too (`all` for every one), after reading their diff").option("--no-install", "Write package.json and leave the install to you").option("--json", "One object, for a tool or an agent").action((parts, options) => upgrade(parts, options));
8392
+ 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 (`upgrade skills --write`)").action(() => upgrade(["skills"], { write: true }));
7789
8393
  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));
7790
8394
  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));
7791
8395
  var functions = program.command("functions").description("The space’s own server code: functions/ in this project is a working copy of it");