@lernapps/tooling 0.0.1 → 0.1.0
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/LICENSE +22 -0
- package/README.md +67 -2
- package/archetypes/shared/a11y.ts +38 -0
- package/archetypes/shared/hooks/pre-commit +22 -0
- package/archetypes/shared/hooks/pre-push +4 -0
- package/archetypes/shared/i18n.ts +25 -0
- package/archetypes/shared/install-hooks.ts +63 -0
- package/archetypes/shared/playwright.ts +54 -0
- package/archetypes/shared/preset.ts +81 -0
- package/archetypes/shared/storage.ts +167 -0
- package/archetypes/shared/tsconfig.json +27 -0
- package/check/validation-report.v1.schema.json +116 -0
- package/dist/a11y.mjs +30 -0
- package/dist/check/rules/accessible.mjs +18 -0
- package/dist/check/rules/app-content-guide.mjs +69 -0
- package/dist/check/rules/dependency-licence.mjs +82 -0
- package/dist/check/rules/entry-fitness-matches.mjs +22 -0
- package/dist/check/rules/entry-links-resolve.mjs +44 -0
- package/dist/check/rules/explainer-readable-without-js.mjs +66 -0
- package/dist/check/rules/links-resolve.mjs +10 -0
- package/dist/check/rules/lockfile-matches.mjs +57 -0
- package/dist/check/rules/no-request-before-click.mjs +17 -0
- package/dist/check/rules/no-tracking.mjs +17 -0
- package/dist/check/rules/own-data-only.mjs +66 -0
- package/dist/check/rules/pages-reachable.mjs +25 -0
- package/dist/check/rules/usable-at-360px.mjs +19 -0
- package/dist/check-cv4I6CCH.mjs +28 -0
- package/dist/cli.mjs +3118 -0
- package/dist/i18n.mjs +11 -0
- package/dist/lint.mjs +528 -0
- package/dist/plan-DWKayEnY.mjs +75 -0
- package/dist/playwright.mjs +47 -0
- package/dist/preset.mjs +60 -0
- package/dist/skills/lernapps-app/SKILL.md +49 -0
- package/dist/skills/lernapps-cli/SKILL.md +156 -0
- package/dist/skills/lernapps-cli/references/platform-rules.md +258 -0
- package/dist/skills/lernapps-commit/SKILL.md +39 -0
- package/dist/skills/lernapps-content-growth/SKILL.md +33 -0
- package/dist/skills/lernapps-content-questions/SKILL.md +133 -0
- package/dist/skills/lernapps-content-questions/references/question-types.md +69 -0
- package/dist/skills/lernapps-content-research/SKILL.md +50 -0
- package/dist/skills/lernapps-content-research/references/sources.md +16 -0
- package/dist/skills/lernapps-exercise-patterns/SKILL.md +74 -0
- package/dist/skills/lernapps-exercise-patterns/references/biology-geography.md +13 -0
- package/dist/skills/lernapps-exercise-patterns/references/chemistry.md +16 -0
- package/dist/skills/lernapps-exercise-patterns/references/computer-science.md +13 -0
- package/dist/skills/lernapps-exercise-patterns/references/figures.md +10 -0
- package/dist/skills/lernapps-exercise-patterns/references/history.md +13 -0
- package/dist/skills/lernapps-exercise-patterns/references/mathematics.md +19 -0
- package/dist/skills/lernapps-exercise-patterns/references/page-structures.md +8 -0
- package/dist/skills/lernapps-exercise-patterns/references/physics.md +18 -0
- package/dist/skills/lernapps-exercise-patterns/references/success.md +28 -0
- package/dist/skills/lernapps-explainer/SKILL.md +33 -0
- package/dist/skills/lernapps-explainer/references/blocks.md +83 -0
- package/dist/skills/lernapps-explainer/references/components.md +66 -0
- package/dist/skills/lernapps-explainer/references/exercises.md +115 -0
- package/dist/skills/lernapps-explainer/references/rules.md +285 -0
- package/dist/skills/lernapps-explainer/references/runtime.md +79 -0
- package/dist/skills/lernapps-explainer/references/structure.md +24 -0
- package/dist/skills/lernapps-explainer-design/SKILL.md +57 -0
- package/dist/skills/lernapps-explainer-design/references/rules.md +75 -0
- package/dist/skills/lernapps-explainer-self-assessment/SKILL.md +93 -0
- package/dist/skills/lernapps-github/SKILL.md +102 -0
- package/dist/skills/lernapps-interactive/SKILL.md +141 -0
- package/dist/skills/lernapps-interactive/references/frame.md +30 -0
- package/dist/skills/lernapps-interactive-design/SKILL.md +58 -0
- package/dist/skills/lernapps-listing/SKILL.md +44 -0
- package/dist/skills/lernapps-listing/references/keeping-updated.md +33 -0
- package/dist/skills/lernapps-quiz/SKILL.md +100 -0
- package/dist/skills/lernapps-quiz-design/SKILL.md +48 -0
- package/dist/status-CXoQBgfU.mjs +76 -0
- package/dist/storage.mjs +113 -0
- package/generator/app/.agents/skills/app-content/SKILL.md +63 -0
- package/generator/app/.github/ISSUE_TEMPLATE/inhaltsfehler.yml +25 -0
- package/generator/app/.github/workflows/pages.yml +61 -0
- package/generator/app/LICENSE +21 -0
- package/generator/app/renovate.json +4 -0
- package/generator/architecture.ts +212 -0
- package/generator/create.ts +462 -0
- package/generator/init.ts +186 -0
- package/generator/update.ts +429 -0
- package/guidance/AGENTS.md +26 -0
- package/guidance/increment-template.md +194 -0
- package/guidance/plan-front-matter.v1.schema.json +36 -0
- package/guidance/plan-template.md +233 -0
- package/guidance/subagent-brief.md +25 -0
- package/guidance/workflow-rules.md +109 -0
- package/package.json +133 -4
- package/review/verdict.v1.schema.json +134 -0
- package/skills/archetypes/lernapps-explainer/SKILL.md +33 -0
- package/skills/archetypes/lernapps-explainer/references/blocks.md +83 -0
- package/skills/archetypes/lernapps-explainer/references/components.md +66 -0
- package/skills/archetypes/lernapps-explainer/references/exercises.md +115 -0
- package/skills/archetypes/lernapps-explainer/references/rules.md +285 -0
- package/skills/archetypes/lernapps-explainer/references/runtime.md +79 -0
- package/skills/archetypes/lernapps-explainer/references/structure.md +24 -0
- package/skills/archetypes/lernapps-explainer-design/SKILL.md +57 -0
- package/skills/archetypes/lernapps-explainer-design/references/rules.md +75 -0
- package/skills/archetypes/lernapps-explainer-self-assessment/SKILL.md +93 -0
- package/skills/archetypes/lernapps-interactive/SKILL.md +141 -0
- package/skills/archetypes/lernapps-interactive/references/frame.md +30 -0
- package/skills/archetypes/lernapps-interactive-design/SKILL.md +58 -0
- package/skills/archetypes/lernapps-quiz/SKILL.md +100 -0
- package/skills/archetypes/lernapps-quiz-design/SKILL.md +48 -0
- package/skills/content/lernapps-content-questions/SKILL.md +133 -0
- package/skills/content/lernapps-content-questions/references/question-types.md +69 -0
- package/skills/content/lernapps-content-research/SKILL.md +50 -0
- package/skills/content/lernapps-content-research/references/sources.md +16 -0
- package/skills/content/lernapps-exercise-patterns/SKILL.md +74 -0
- package/skills/content/lernapps-exercise-patterns/references/biology-geography.md +13 -0
- package/skills/content/lernapps-exercise-patterns/references/chemistry.md +16 -0
- package/skills/content/lernapps-exercise-patterns/references/computer-science.md +13 -0
- package/skills/content/lernapps-exercise-patterns/references/figures.md +10 -0
- package/skills/content/lernapps-exercise-patterns/references/history.md +13 -0
- package/skills/content/lernapps-exercise-patterns/references/mathematics.md +19 -0
- package/skills/content/lernapps-exercise-patterns/references/page-structures.md +8 -0
- package/skills/content/lernapps-exercise-patterns/references/physics.md +18 -0
- package/skills/content/lernapps-exercise-patterns/references/success.md +28 -0
- package/skills/process/lernapps-app/SKILL.md +49 -0
- package/skills/process/lernapps-commit/SKILL.md +39 -0
- package/skills/process/lernapps-content-growth/SKILL.md +33 -0
- package/skills/process/lernapps-github/SKILL.md +102 -0
- package/skills/process/lernapps-listing/SKILL.md +44 -0
- package/skills/process/lernapps-listing/references/keeping-updated.md +33 -0
- package/skills/tooling/lernapps-cli/SKILL.md +156 -0
- package/skills/tooling/lernapps-cli/references/platform-rules.md +258 -0
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,3118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { r as measure, t as FOREIGN_KEY } from "./check-cv4I6CCH.mjs";
|
|
3
|
+
import { n as linkStatus } from "./status-CXoQBgfU.mjs";
|
|
4
|
+
import { a as countFailedChecks, c as planFrontMatter, i as PLAN_NAME, l as plans, n as MIGRATED_PLAN, o as countPrePushFailure, r as PLANS, s as currentPlan, t as LEGACY_PLAN } from "./plan-DWKayEnY.mjs";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { appendFileSync, closeSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { homedir, tmpdir } from "node:os";
|
|
9
|
+
import { basename, delimiter, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
10
|
+
import { parseArgs } from "node:util";
|
|
11
|
+
import { parse, stringify } from "yaml";
|
|
12
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
|
+
import { createServer } from "node:http";
|
|
14
|
+
import { chromium } from "playwright";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
// check/run.ts
|
|
17
|
+
const RULE_PAGE = "https://lernapps.net/tooling/rules/#";
|
|
18
|
+
const finding = (rule, severity, problem) => ({
|
|
19
|
+
rule,
|
|
20
|
+
severity,
|
|
21
|
+
where: problem.where,
|
|
22
|
+
found: problem.found,
|
|
23
|
+
fix: problem.fix,
|
|
24
|
+
link: `${RULE_PAGE}${rule}`
|
|
25
|
+
});
|
|
26
|
+
/** The checks next to the CLI: check/rules/*.ts in the source, dist/check/rules/*.mjs in the package. */
|
|
27
|
+
async function loadChecks() {
|
|
28
|
+
const source = import.meta.url.endsWith(".ts");
|
|
29
|
+
const dir = fileURLToPath(new URL(source ? "./rules/" : "./check/rules/", import.meta.url));
|
|
30
|
+
const files = readdirSync(dir).filter((file) => file.endsWith(source ? ".ts" : ".mjs")).sort();
|
|
31
|
+
const checks = [];
|
|
32
|
+
for (const file of files) {
|
|
33
|
+
const check = (await import(pathToFileURL(join(dir, file)).href)).default;
|
|
34
|
+
if (typeof check?.id !== "string" || typeof check.run !== "function") continue;
|
|
35
|
+
checks.push({
|
|
36
|
+
id: check.id,
|
|
37
|
+
severity: check.severity ?? "error",
|
|
38
|
+
description: check.description ?? "",
|
|
39
|
+
run: check.run
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return checks;
|
|
43
|
+
}
|
|
44
|
+
/** Every check with its rule id, severity and what it checks, for the help of `lernapps check`. */
|
|
45
|
+
async function describeChecks() {
|
|
46
|
+
const checks = await loadChecks();
|
|
47
|
+
const width = Math.max(...checks.map((check) => check.id.length));
|
|
48
|
+
const lines = checks.map((check) => ` ${check.id.padEnd(width)} ${check.severity.padEnd(7)} ${check.description}`);
|
|
49
|
+
return `Checks (rule id, severity, what it checks; each rule at ${RULE_PAGE}<id>):\n${lines.join("\n")}\n`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Runs every check; a check that returns undefined found nothing to check and is left out of the rules. A problem
|
|
53
|
+
* has its check's severity, or the lower one it names.
|
|
54
|
+
*/
|
|
55
|
+
async function runChecks(context) {
|
|
56
|
+
const rules = [];
|
|
57
|
+
const findings = [];
|
|
58
|
+
for (const check of await loadChecks()) {
|
|
59
|
+
const problems = await check.run(context);
|
|
60
|
+
if (problems === void 0) continue;
|
|
61
|
+
rules.push(check.id);
|
|
62
|
+
findings.push(...problems.map((problem) => finding(check.id, problem.severity ?? check.severity, problem)));
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
rules,
|
|
66
|
+
findings
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const byPlace = (a, b) => a.rule.localeCompare(b.rule) || a.where.localeCompare(b.where) || a.found.localeCompare(b.found);
|
|
70
|
+
function toYaml(report) {
|
|
71
|
+
const sorted = {
|
|
72
|
+
...report,
|
|
73
|
+
rules: [...new Set(report.rules)].sort(),
|
|
74
|
+
findings: [...report.findings].sort(byPlace)
|
|
75
|
+
};
|
|
76
|
+
return stringify(sorted, { lineWidth: 0 });
|
|
77
|
+
}
|
|
78
|
+
const failed = (report) => report.findings.some((finding) => finding.severity === "error");
|
|
79
|
+
// check/visit.ts
|
|
80
|
+
const require = createRequire(import.meta.url);
|
|
81
|
+
const WIDTH = 360;
|
|
82
|
+
/** Where the page keeps what its scripts did with stored data (recordStorageAccess). */
|
|
83
|
+
const ACCESS_LOG = "__lernappsStorageAccess";
|
|
84
|
+
/**
|
|
85
|
+
* Runs in the page before its own scripts (an init script, serialized: it uses nothing from outside): puts the data
|
|
86
|
+
* of another app into the browser, and records every access by name to localStorage, sessionStorage, IndexedDB and
|
|
87
|
+
* the Cache API in window[log]. Listing the keys is not recorded; the app's own storage needs it to keep its quota.
|
|
88
|
+
*/
|
|
89
|
+
function recordStorageAccess({ foreign, log }) {
|
|
90
|
+
const accesses = [];
|
|
91
|
+
Object.defineProperty(window, log, { value: accesses });
|
|
92
|
+
const record = (area, action, name) => {
|
|
93
|
+
accesses.push({
|
|
94
|
+
area,
|
|
95
|
+
action,
|
|
96
|
+
name: String(name)
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
for (const area of ["localStorage", "sessionStorage"]) {
|
|
100
|
+
let target;
|
|
101
|
+
try {
|
|
102
|
+
target = window[area];
|
|
103
|
+
if (target.getItem(foreign) === null) target.setItem(foreign, "3");
|
|
104
|
+
} catch {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const methods = {
|
|
108
|
+
getItem: (key) => (record(area, "read", key), target.getItem(key)),
|
|
109
|
+
setItem: (key, value) => (record(area, "write", key), target.setItem(key, value)),
|
|
110
|
+
removeItem: (key) => (record(area, "remove", key), target.removeItem(key)),
|
|
111
|
+
clear: () => (record(area, "clear", ""), target.clear()),
|
|
112
|
+
key: (index) => target.key(index)
|
|
113
|
+
};
|
|
114
|
+
const own = (property) => typeof property === "string" && !(property in Storage.prototype);
|
|
115
|
+
const proxy = new Proxy(target, {
|
|
116
|
+
get(_, property) {
|
|
117
|
+
if (property === "length") return target.length;
|
|
118
|
+
if (typeof property === "string" && Object.hasOwn(methods, property)) return methods[property];
|
|
119
|
+
if (!own(property)) return Reflect.get(target, property);
|
|
120
|
+
record(area, "read", property);
|
|
121
|
+
return target.getItem(property) ?? void 0;
|
|
122
|
+
},
|
|
123
|
+
set(_, property, value) {
|
|
124
|
+
if (!own(property)) return Reflect.set(target, property, value);
|
|
125
|
+
record(area, "write", property);
|
|
126
|
+
target.setItem(property, String(value));
|
|
127
|
+
return true;
|
|
128
|
+
},
|
|
129
|
+
deleteProperty(_, property) {
|
|
130
|
+
if (!own(property)) return Reflect.deleteProperty(target, property);
|
|
131
|
+
record(area, "remove", property);
|
|
132
|
+
target.removeItem(property);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
Object.defineProperty(window, area, {
|
|
137
|
+
get: () => proxy,
|
|
138
|
+
configurable: true
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/** Calls `before` with the arguments of every call of `method` of `target`, before it runs. */
|
|
142
|
+
const wrap = (target, method, before) => {
|
|
143
|
+
const original = Reflect.get(target, method);
|
|
144
|
+
Reflect.set(target, method, function(...args) {
|
|
145
|
+
before(args);
|
|
146
|
+
return Reflect.apply(original, this, args);
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
wrap(IDBFactory.prototype, "open", ([name]) => record("IndexedDB", "open", name));
|
|
150
|
+
wrap(IDBFactory.prototype, "deleteDatabase", ([name]) => record("IndexedDB", "delete", name));
|
|
151
|
+
if (typeof CacheStorage === "undefined") return;
|
|
152
|
+
wrap(CacheStorage.prototype, "open", ([name]) => record("Cache", "open", name));
|
|
153
|
+
wrap(CacheStorage.prototype, "delete", ([name]) => record("Cache", "delete", name));
|
|
154
|
+
wrap(CacheStorage.prototype, "has", ([name]) => record("Cache", "read", name));
|
|
155
|
+
wrap(CacheStorage.prototype, "match", ([, options]) => {
|
|
156
|
+
const name = options?.cacheName;
|
|
157
|
+
if (name === void 0) record("Cache", "match", "");
|
|
158
|
+
else record("Cache", "read", name);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
const WCAG_21_AA = [
|
|
162
|
+
"wcag2a",
|
|
163
|
+
"wcag2aa",
|
|
164
|
+
"wcag21a",
|
|
165
|
+
"wcag21aa"
|
|
166
|
+
];
|
|
167
|
+
/** A browser cannot start here; the message says how to make it run. */
|
|
168
|
+
var BrowserError = class extends Error {};
|
|
169
|
+
const TYPES = {
|
|
170
|
+
".html": "text/html; charset=utf-8",
|
|
171
|
+
".css": "text/css; charset=utf-8",
|
|
172
|
+
".js": "text/javascript; charset=utf-8",
|
|
173
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
174
|
+
".json": "application/json",
|
|
175
|
+
".svg": "image/svg+xml",
|
|
176
|
+
".png": "image/png",
|
|
177
|
+
".jpg": "image/jpeg",
|
|
178
|
+
".jpeg": "image/jpeg",
|
|
179
|
+
".webp": "image/webp",
|
|
180
|
+
".gif": "image/gif",
|
|
181
|
+
".ico": "image/x-icon",
|
|
182
|
+
".woff2": "font/woff2",
|
|
183
|
+
".woff": "font/woff",
|
|
184
|
+
".txt": "text/plain; charset=utf-8",
|
|
185
|
+
".webmanifest": "application/manifest+json"
|
|
186
|
+
};
|
|
187
|
+
/** Serves a bundle at http://127.0.0.1:<port><path>, as a static host does; nothing outside the path. */
|
|
188
|
+
async function serve(bundle, path) {
|
|
189
|
+
const root = resolve(bundle);
|
|
190
|
+
const server = createServer((request, response) => {
|
|
191
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
192
|
+
const pathname = decodeURIComponent(url.pathname);
|
|
193
|
+
const file = pathname.startsWith(path) ? join(root, pathname.slice(path.length)) : "";
|
|
194
|
+
if (file === "" || file !== root && !file.startsWith(root + sep) || !existsSync(file)) response.writeHead(404, { "content-type": "text/plain" }).end("not found");
|
|
195
|
+
else if (statSync(file).isDirectory()) {
|
|
196
|
+
if (!pathname.endsWith("/")) response.writeHead(301, { location: `${pathname}/${url.search}` }).end();
|
|
197
|
+
else if (existsSync(join(file, "index.html"))) response.writeHead(200, { "content-type": TYPES[".html"] }).end(readFileSync(join(file, "index.html")));
|
|
198
|
+
else response.writeHead(404, { "content-type": "text/plain" }).end("not found");
|
|
199
|
+
} else {
|
|
200
|
+
const type = TYPES[extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
201
|
+
response.writeHead(200, { "content-type": type }).end(readFileSync(file));
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
await new Promise((done) => server.listen(0, "127.0.0.1", done));
|
|
205
|
+
return {
|
|
206
|
+
server,
|
|
207
|
+
base: `http://127.0.0.1:${server.address().port}${path}`
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* The pages of a bundle: its .html files, sorted. Left out, as no pages: the 404 page at the root, which a static host
|
|
212
|
+
* serves at any address that does not exist, so its links work only from there (a link that leads to it makes it a
|
|
213
|
+
* page like any other); and a file without <!doctype html>, which is no document (a fragment, a host's verification
|
|
214
|
+
* file). Not navigable: a page the app marks with <meta name="robots" content="noindex">, reached another way on
|
|
215
|
+
* purpose; it is checked like any page, but no link needs to reach it.
|
|
216
|
+
*/
|
|
217
|
+
function bundlePages(bundle) {
|
|
218
|
+
const root = resolve(bundle);
|
|
219
|
+
const files = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
220
|
+
const path = join(dir, entry.name);
|
|
221
|
+
if (entry.isDirectory()) return files(path);
|
|
222
|
+
return entry.isFile() && entry.name.toLowerCase().endsWith(".html") ? [path] : [];
|
|
223
|
+
});
|
|
224
|
+
const doctype = /^\uFEFF?\s*<!doctype html\b/i;
|
|
225
|
+
const noindex = /<meta\b(?=[^>]*\bname\s*=\s*["']?robots\b)(?=[^>]*\bcontent\s*=\s*["'][^"']*\bnoindex\b)[^>]*>/i;
|
|
226
|
+
return files(root).map((file) => ({
|
|
227
|
+
name: relative(root, file).split(sep).join("/"),
|
|
228
|
+
html: readFileSync(file, "utf8")
|
|
229
|
+
})).filter(({ name, html }) => name !== "404.html" && doctype.test(html)).map(({ name, html }) => ({
|
|
230
|
+
name,
|
|
231
|
+
navigable: !noindex.test(html)
|
|
232
|
+
})).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Where Playwright's own Chromium is missing: another Chromium installed in the browser folder, where the environment
|
|
236
|
+
* asks for no downloads (PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD, as a sandbox with a preinstalled browser sets it), the
|
|
237
|
+
* newest first; else a download of Playwright's own. CI sets no such variable, so it downloads (and caches) as before.
|
|
238
|
+
*/
|
|
239
|
+
function chooseBrowsers(env, installed) {
|
|
240
|
+
const skip = (env["PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD"] ?? "").toLowerCase();
|
|
241
|
+
return [...skip !== "" && skip !== "0" && skip !== "false" ? installed.map((executablePath) => ({
|
|
242
|
+
use: "installed",
|
|
243
|
+
executablePath
|
|
244
|
+
})) : [], { use: "download" }];
|
|
245
|
+
}
|
|
246
|
+
/** Playwright's browser folder: PLAYWRIGHT_BROWSERS_PATH, else its cache; undefined where it is node_modules ("0"). */
|
|
247
|
+
function browsersFolder(env) {
|
|
248
|
+
const path = env["PLAYWRIGHT_BROWSERS_PATH"];
|
|
249
|
+
if (path === "0") return void 0;
|
|
250
|
+
if (path !== void 0 && path !== "") return path;
|
|
251
|
+
if (process.platform === "win32") return join(env["LOCALAPPDATA"] ?? homedir(), "ms-playwright");
|
|
252
|
+
if (process.platform === "darwin") return join(homedir(), "Library", "Caches", "ms-playwright");
|
|
253
|
+
return join(env["XDG_CACHE_HOME"] ?? join(homedir(), ".cache"), "ms-playwright");
|
|
254
|
+
}
|
|
255
|
+
/** The Chromium executables of any version in a browser folder, newest first; the headless shell before the full one. */
|
|
256
|
+
function installedChromiums(folder) {
|
|
257
|
+
if (folder === void 0 || !existsSync(folder)) return [];
|
|
258
|
+
const names = [
|
|
259
|
+
"chrome-headless-shell",
|
|
260
|
+
"headless_shell",
|
|
261
|
+
"chrome",
|
|
262
|
+
"chrome.exe",
|
|
263
|
+
"chrome-headless-shell.exe"
|
|
264
|
+
];
|
|
265
|
+
return readdirSync(folder).flatMap((name) => {
|
|
266
|
+
const match = /^chromium(_headless_shell)?-(\d+)$/.exec(name);
|
|
267
|
+
if (match === null) return [];
|
|
268
|
+
const rank = Number(match[2]) * 2 + (match[1] === void 0 ? 0 : 1);
|
|
269
|
+
return readdirSync(join(folder, name), { withFileTypes: true }).filter((entry) => entry.isDirectory()).flatMap((entry) => names.map((file) => join(folder, name, entry.name, file))).filter((file) => existsSync(file)).map((file) => ({
|
|
270
|
+
file,
|
|
271
|
+
rank
|
|
272
|
+
}));
|
|
273
|
+
}).sort((a, b) => b.rank - a.rank).map(({ file }) => file);
|
|
274
|
+
}
|
|
275
|
+
/** The browser that started last in this run: Playwright's own (undefined path) or another installed Chromium. */
|
|
276
|
+
let started;
|
|
277
|
+
/**
|
|
278
|
+
* Starts Chromium: Playwright's own build; where that is missing, another one that is installed (chooseBrowsers), or
|
|
279
|
+
* Playwright's own, installed once (Playwright caches it). Each says so in one line.
|
|
280
|
+
*/
|
|
281
|
+
async function launch() {
|
|
282
|
+
const missing = (error) => /Executable doesn't exist|playwright install/i.test(String(error));
|
|
283
|
+
try {
|
|
284
|
+
const browser = await chromium.launch(started?.executablePath ? { executablePath: started.executablePath } : {});
|
|
285
|
+
started ??= {};
|
|
286
|
+
return browser;
|
|
287
|
+
} catch (error) {
|
|
288
|
+
if (!missing(error)) throw cannotRun(error);
|
|
289
|
+
}
|
|
290
|
+
for (const choice of chooseBrowsers(process.env, installedChromiums(browsersFolder(process.env)))) if (choice.use === "installed") try {
|
|
291
|
+
const browser = await chromium.launch({ executablePath: choice.executablePath });
|
|
292
|
+
process.stderr.write(`lernapps check: Playwright's Chromium is not installed, using the installed ${choice.executablePath}\n`);
|
|
293
|
+
started = { executablePath: choice.executablePath };
|
|
294
|
+
return browser;
|
|
295
|
+
} catch {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const cli = join(dirname(require.resolve("playwright/package.json")), "cli.js");
|
|
299
|
+
process.stderr.write("lernapps check: no usable Chromium installed, downloading Playwright's (once; it is cached)\n");
|
|
300
|
+
if (spawnSync(process.execPath, [
|
|
301
|
+
cli,
|
|
302
|
+
"install",
|
|
303
|
+
"chromium"
|
|
304
|
+
], { stdio: [
|
|
305
|
+
"ignore",
|
|
306
|
+
2,
|
|
307
|
+
2
|
|
308
|
+
] }).status !== 0) throw cannotRun(/* @__PURE__ */ new Error("npx playwright install chromium failed"));
|
|
309
|
+
try {
|
|
310
|
+
const browser = await chromium.launch();
|
|
311
|
+
started = {};
|
|
312
|
+
return browser;
|
|
313
|
+
} catch (error) {
|
|
314
|
+
throw cannotRun(error);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* The environment of the end-to-end tests, once a browser has started in this run (else it starts one): the
|
|
319
|
+
* executable of an installed Chromium in LERNAPPS_CHROMIUM, which the Playwright preset launches
|
|
320
|
+
* (archetypes/shared/playwright.ts); nothing for Playwright's own.
|
|
321
|
+
*/
|
|
322
|
+
async function browserForTests() {
|
|
323
|
+
if (started === void 0) await (await launch()).close();
|
|
324
|
+
return started?.executablePath ? { LERNAPPS_CHROMIUM: started.executablePath } : {};
|
|
325
|
+
}
|
|
326
|
+
function cannotRun(error) {
|
|
327
|
+
return new BrowserError(`no browser can run here: ${((error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "").trim()}`);
|
|
328
|
+
}
|
|
329
|
+
/** The status of every link, fetched once per URL (a rate-limited one with a short, bounded backoff). */
|
|
330
|
+
var Links = class {
|
|
331
|
+
known = /* @__PURE__ */ new Map();
|
|
332
|
+
get(url) {
|
|
333
|
+
let result = this.known.get(url);
|
|
334
|
+
if (result === void 0) {
|
|
335
|
+
result = linkStatus(url);
|
|
336
|
+
this.known.set(url, result);
|
|
337
|
+
}
|
|
338
|
+
return result;
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
const withoutHash = (url) => url.replace(/#.*$/, "");
|
|
342
|
+
/**
|
|
343
|
+
* The page a link leads to: without query and fragment. A static host serves the same file for any query, so a link
|
|
344
|
+
* that only changes it (a quiz's "start again" with a random seed) is the same page, and the report stays the same.
|
|
345
|
+
*/
|
|
346
|
+
const pageOf = (url) => url.replace(/[?#].*$/, "");
|
|
347
|
+
/**
|
|
348
|
+
* For the tooling's own tests only, not for users: LERNAPPS_TEST_URL_PAGE_LIMIT lowers the limit, so a test reaches it
|
|
349
|
+
* with a few pages (test/check-pages.e2e.test.ts). Not in the help; any other value keeps URL_PAGE_LIMIT.
|
|
350
|
+
*/
|
|
351
|
+
function urlPageLimit(env = process.env) {
|
|
352
|
+
const value = Number(env["LERNAPPS_TEST_URL_PAGE_LIMIT"]);
|
|
353
|
+
return Number.isInteger(value) && value > 0 ? value : 100;
|
|
354
|
+
}
|
|
355
|
+
/** Pages visited at the same time, each in its own browser context. */
|
|
356
|
+
const PARALLEL = 4;
|
|
357
|
+
/**
|
|
358
|
+
* Visits the start page and every page of the app reachable from it by links below `base`, breadth first, in a fixed
|
|
359
|
+
* order, a few at a time; the result does not depend on how long a page takes. `name` turns a page's URL into what
|
|
360
|
+
* the messages call it. For a bundle, `pages` are its pages (bundlePages): those the links do not reach, nor a page
|
|
361
|
+
* embeds (an iframe), are visited afterwards, and unreachable unless they are not navigable. Without `pages` (an app
|
|
362
|
+
* on a URL), it stops after `limit` pages and says so.
|
|
363
|
+
*/
|
|
364
|
+
async function visitApp(browser, base, name, options = {}) {
|
|
365
|
+
const { start = base, pages, limit = Infinity } = options;
|
|
366
|
+
const axe = readFileSync(require.resolve("axe-core/axe.min.js"), "utf8");
|
|
367
|
+
const links = new Links();
|
|
368
|
+
const queue = [withoutHash(start)];
|
|
369
|
+
const seen = /* @__PURE__ */ new Set([pageOf(start)]);
|
|
370
|
+
const visits = [];
|
|
371
|
+
let withoutJs;
|
|
372
|
+
const noJs = () => withoutJs ??= newContext(browser, new URL(base).origin, [], false);
|
|
373
|
+
const visitAll = async (urls) => {
|
|
374
|
+
return (await Promise.all(urls.map((url) => visitPage(browser, url, base, axe, links, noJs)))).map((visit, index) => ({
|
|
375
|
+
...visit,
|
|
376
|
+
page: name(urls[index] ?? "")
|
|
377
|
+
}));
|
|
378
|
+
};
|
|
379
|
+
try {
|
|
380
|
+
while (queue.length > 0 && visits.length < limit) for (const visit of await visitAll(queue.splice(0, Math.min(PARALLEL, limit - visits.length)))) {
|
|
381
|
+
const { anchors, ...rest } = visit;
|
|
382
|
+
visits.push(rest);
|
|
383
|
+
for (const link of anchors) {
|
|
384
|
+
const next = pageOf(link);
|
|
385
|
+
if (seen.has(next) || !next.startsWith(base)) continue;
|
|
386
|
+
seen.add(next);
|
|
387
|
+
const { status, html } = await links.get(next);
|
|
388
|
+
if (status > 0 && status < 400 && html) queue.push(next);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const reached = new Set(visits.flatMap((visit) => [visit.page, ...visit.links.map((link) => name(pageOf(link.url)))]));
|
|
392
|
+
const rest = (pages ?? []).filter((page) => !reached.has(page.name));
|
|
393
|
+
for (let index = 0; index < rest.length; index += PARALLEL) {
|
|
394
|
+
const urls = rest.slice(index, index + PARALLEL).map((page) => base + encodeURI(page.name));
|
|
395
|
+
visits.push(...(await visitAll(urls)).map(({ anchors: _anchors, ...visit }) => visit));
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
pages: visits.sort((a, b) => a.page.localeCompare(b.page)),
|
|
399
|
+
unreachable: rest.filter((page) => page.navigable).map((page) => page.name),
|
|
400
|
+
...queue.length > 0 ? { stopped: {
|
|
401
|
+
limit,
|
|
402
|
+
more: queue.length
|
|
403
|
+
} } : {}
|
|
404
|
+
};
|
|
405
|
+
} finally {
|
|
406
|
+
if (withoutJs !== void 0) await (await withoutJs).close();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async function newContext(browser, origin, requests, javaScriptEnabled = true) {
|
|
410
|
+
const context = await browser.newContext({
|
|
411
|
+
viewport: {
|
|
412
|
+
width: WIDTH,
|
|
413
|
+
height: 800
|
|
414
|
+
},
|
|
415
|
+
javaScriptEnabled
|
|
416
|
+
});
|
|
417
|
+
if (javaScriptEnabled) await context.addInitScript(recordStorageAccess, {
|
|
418
|
+
foreign: FOREIGN_KEY,
|
|
419
|
+
log: ACCESS_LOG
|
|
420
|
+
});
|
|
421
|
+
await context.route("**/*", async (route) => {
|
|
422
|
+
const request = route.request();
|
|
423
|
+
requests.push({
|
|
424
|
+
url: request.url(),
|
|
425
|
+
method: request.method()
|
|
426
|
+
});
|
|
427
|
+
if (new URL(request.url()).origin === origin) await route.continue();
|
|
428
|
+
else await route.abort("blockedbyclient");
|
|
429
|
+
});
|
|
430
|
+
return context;
|
|
431
|
+
}
|
|
432
|
+
async function settle(context, url) {
|
|
433
|
+
const page = await context.newPage();
|
|
434
|
+
await page.goto(url, { waitUntil: "load" });
|
|
435
|
+
await page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => void 0);
|
|
436
|
+
return page;
|
|
437
|
+
}
|
|
438
|
+
async function visitPage(browser, url, base, axe, links, noJs) {
|
|
439
|
+
const origin = new URL(base).origin;
|
|
440
|
+
const requests = [];
|
|
441
|
+
const context = await newContext(browser, origin, requests);
|
|
442
|
+
try {
|
|
443
|
+
const page = await settle(context, url);
|
|
444
|
+
const withoutJavaScript = await page.locator(EXPLAINER_MARKER).count() > 0 ? noJs().then((noJsContext) => readWithoutJavaScript(noJsContext, url)) : void 0;
|
|
445
|
+
withoutJavaScript?.catch(() => void 0);
|
|
446
|
+
const marker = page.locator(ARCHETYPE_MARKER).first();
|
|
447
|
+
const archetype = await marker.count() > 0 ? await marker.getAttribute("content") ?? void 0 : void 0;
|
|
448
|
+
const loaded = [...requests];
|
|
449
|
+
const cookies = (await context.cookies()).map((cookie) => cookie.name).sort();
|
|
450
|
+
const { storage, access } = await page.evaluate(async ({ foreign, log }) => {
|
|
451
|
+
const databases = typeof indexedDB.databases === "function" ? await indexedDB.databases() : [];
|
|
452
|
+
const cacheNames = typeof caches === "undefined" ? [] : await caches.keys();
|
|
453
|
+
const own = (key) => key !== foreign;
|
|
454
|
+
const accesses = (window[log] ?? []).map((entry) => JSON.stringify([
|
|
455
|
+
entry.area,
|
|
456
|
+
entry.action,
|
|
457
|
+
entry.name
|
|
458
|
+
]));
|
|
459
|
+
return {
|
|
460
|
+
storage: [
|
|
461
|
+
...Object.keys(localStorage).filter(own).map((key) => `localStorage ${key}`),
|
|
462
|
+
...Object.keys(sessionStorage).filter(own).map((key) => `sessionStorage ${key}`),
|
|
463
|
+
...databases.map((database) => `IndexedDB ${database.name ?? ""}`),
|
|
464
|
+
...cacheNames.map((name) => `Cache ${name}`)
|
|
465
|
+
].sort(),
|
|
466
|
+
access: [...new Set(accesses)].sort((a, b) => a.localeCompare(b))
|
|
467
|
+
};
|
|
468
|
+
}, {
|
|
469
|
+
foreign: FOREIGN_KEY,
|
|
470
|
+
log: ACCESS_LOG
|
|
471
|
+
});
|
|
472
|
+
const overflow = await page.evaluate((width) => {
|
|
473
|
+
const selector = (element) => {
|
|
474
|
+
const id = element.id === "" ? "" : `#${element.id}`;
|
|
475
|
+
const classes = [...element.classList].map((name) => `.${name}`).join("");
|
|
476
|
+
return `${element.tagName.toLowerCase()}${id}${classes}`;
|
|
477
|
+
};
|
|
478
|
+
const wide = (element) => element.getBoundingClientRect().right > width + 1;
|
|
479
|
+
const elements = [...document.body.querySelectorAll("*")].filter((element) => wide(element) && !(element.parentElement && wide(element.parentElement))).slice(0, 5).map(selector);
|
|
480
|
+
return {
|
|
481
|
+
width: document.documentElement.scrollWidth,
|
|
482
|
+
elements
|
|
483
|
+
};
|
|
484
|
+
}, WIDTH);
|
|
485
|
+
const anchors = await page.evaluate(() => [...document.querySelectorAll("a[href]")].map((element) => element.href));
|
|
486
|
+
const resources = await page.evaluate(() => [...document.querySelectorAll("[src], link[href]")].map((element) => element.src || element.href).filter((value) => typeof value === "string" && value !== ""));
|
|
487
|
+
await page.addScriptTag({ content: axe });
|
|
488
|
+
const violations = await page.evaluate(async (tags) => {
|
|
489
|
+
return (await window.axe.run(document, {
|
|
490
|
+
runOnly: {
|
|
491
|
+
type: "tag",
|
|
492
|
+
values: tags
|
|
493
|
+
},
|
|
494
|
+
resultTypes: ["violations"]
|
|
495
|
+
})).violations.map((violation) => ({
|
|
496
|
+
id: violation.id,
|
|
497
|
+
help: violation.help,
|
|
498
|
+
helpUrl: violation.helpUrl,
|
|
499
|
+
targets: violation.nodes.map((node) => node.target.map(String).join(" "))
|
|
500
|
+
}));
|
|
501
|
+
}, [...WCAG_21_AA]);
|
|
502
|
+
const own = [...new Set([...anchors, ...resources].map(withoutHash))].filter((link) => link.startsWith(base)).sort();
|
|
503
|
+
const statuses = await Promise.all(own.map(async (link) => ({
|
|
504
|
+
url: link,
|
|
505
|
+
status: (await links.get(link)).status
|
|
506
|
+
})));
|
|
507
|
+
return {
|
|
508
|
+
requests: loaded,
|
|
509
|
+
cookies,
|
|
510
|
+
storage,
|
|
511
|
+
access: access.map((entry) => {
|
|
512
|
+
const [area, action, name] = JSON.parse(entry);
|
|
513
|
+
return {
|
|
514
|
+
area,
|
|
515
|
+
action,
|
|
516
|
+
name
|
|
517
|
+
};
|
|
518
|
+
}),
|
|
519
|
+
overflow,
|
|
520
|
+
axe: violations.sort((a, b) => a.id.localeCompare(b.id)),
|
|
521
|
+
links: statuses,
|
|
522
|
+
...archetype ? { archetype } : {},
|
|
523
|
+
...withoutJavaScript ? { withoutJavaScript: await withoutJavaScript } : {},
|
|
524
|
+
anchors
|
|
525
|
+
};
|
|
526
|
+
} finally {
|
|
527
|
+
await context.close();
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
/** The marker of a page the explainer runtime renders; only such a page is visited again without JavaScript. */
|
|
531
|
+
const EXPLAINER_MARKER = "meta[name=\"lernapps-archetype\"][content=\"explainer\"]";
|
|
532
|
+
/** The marker of the archetype whose runtime renders a page; its settings are the page's to read (own-data-only). */
|
|
533
|
+
const ARCHETYPE_MARKER = "meta[name=\"lernapps-archetype\"]";
|
|
534
|
+
/**
|
|
535
|
+
* The parts of an explainer page that only JavaScript shows, hidden without it: a component's island, a generated
|
|
536
|
+
* exercise's live task, a question's controls and its reveal after "Prüfen". What is inside is not read.
|
|
537
|
+
*/
|
|
538
|
+
const ONLY_WITH_JAVASCRIPT = [
|
|
539
|
+
".explainer-island",
|
|
540
|
+
".explainer-live",
|
|
541
|
+
".question-reveal",
|
|
542
|
+
".question-actions"
|
|
543
|
+
];
|
|
544
|
+
/**
|
|
545
|
+
* What an explainer page shows with JavaScript off (in `context`, made with JavaScript disabled): its h1, headings,
|
|
546
|
+
* formulas and figures, each exercise's "Lösung" and each component's nojs text, with every <details> opened, as a
|
|
547
|
+
* learner opens a "Lösung". Visible means rendered, not hidden by an attribute or a style.
|
|
548
|
+
*/
|
|
549
|
+
async function readWithoutJavaScript(context, url) {
|
|
550
|
+
const page = await context.newPage();
|
|
551
|
+
try {
|
|
552
|
+
await page.goto(url, { waitUntil: "load" });
|
|
553
|
+
return await page.evaluate((skip) => {
|
|
554
|
+
const fold = (text) => (text ?? "").replace(/\s+/g, " ").trim();
|
|
555
|
+
for (const details of document.querySelectorAll("details")) details.open = true;
|
|
556
|
+
const read = (selector, root = document) => [...root.querySelectorAll(selector)].filter((element) => element.closest(skip) === null);
|
|
557
|
+
const visible = (element) => element.checkVisibility({
|
|
558
|
+
opacityProperty: true,
|
|
559
|
+
visibilityProperty: true
|
|
560
|
+
});
|
|
561
|
+
const shown = (element) => visible(element) ? fold(element instanceof HTMLElement ? element.innerText : element.textContent) : "";
|
|
562
|
+
const section = (element) => {
|
|
563
|
+
const heading = document.evaluate("preceding::*[self::h2 or self::h3 or self::h4][@id][1]", element, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
|
564
|
+
return heading instanceof Element ? ` in #${heading.id}` : "";
|
|
565
|
+
};
|
|
566
|
+
const labelledBy = (element) => (element.getAttribute("aria-labelledby") ?? "").split(/\s+/).map((id) => fold(id === "" ? "" : document.getElementById(id)?.textContent)).join(" ").trim();
|
|
567
|
+
const own = (element) => element.localName === "img" ? element.getAttribute("alt") : element.localName === "figure" ? element.querySelector(":scope > figcaption")?.textContent : element.querySelector(":scope > title")?.textContent;
|
|
568
|
+
const accessibleName = (element) => fold(element.getAttribute("aria-label")) || labelledBy(element) || fold(own(element)) || fold(element.getAttribute("title"));
|
|
569
|
+
const headings = read("h1, h2, h3, h4, h5, h6").map((element) => ({
|
|
570
|
+
kind: "heading",
|
|
571
|
+
where: `${element.localName}${element.id === "" ? ` "${fold(element.textContent).slice(0, 40)}"` : `#${element.id}`}`,
|
|
572
|
+
visible: visible(element),
|
|
573
|
+
text: shown(element)
|
|
574
|
+
}));
|
|
575
|
+
const formulas = read("math").map((element, index) => ({
|
|
576
|
+
kind: "formula",
|
|
577
|
+
where: `formula ${index + 1}${section(element)}`,
|
|
578
|
+
visible: visible(element),
|
|
579
|
+
text: shown(element)
|
|
580
|
+
}));
|
|
581
|
+
const figures = read("figure, img, [role=\"img\"]").map((element, index) => ({
|
|
582
|
+
kind: "figure",
|
|
583
|
+
where: `figure ${index + 1} (${element.localName})${section(element)}`,
|
|
584
|
+
visible: visible(element),
|
|
585
|
+
text: accessibleName(element)
|
|
586
|
+
}));
|
|
587
|
+
return {
|
|
588
|
+
h1: headings.find((heading) => heading.where.startsWith("h1") && heading.text !== "")?.text ?? "",
|
|
589
|
+
parts: [
|
|
590
|
+
...headings,
|
|
591
|
+
...formulas,
|
|
592
|
+
...figures
|
|
593
|
+
],
|
|
594
|
+
exercises: read(".explainer-exercise").map((element, index) => ({
|
|
595
|
+
where: `exercise ${element.id === "" ? String(index + 1) : `#${element.id}`}`,
|
|
596
|
+
solutions: read("details.explainer-solution", element).map((details) => visible(details) ? fold([...details.childNodes].filter((node) => !(node instanceof Element && node.localName === "summary")).map((node) => node instanceof HTMLElement ? node.innerText : node.textContent).join(" ")) : "")
|
|
597
|
+
})),
|
|
598
|
+
components: read(".explainer-component").map((element) => {
|
|
599
|
+
const nojs = element.querySelector(":scope > .explainer-nojs");
|
|
600
|
+
return {
|
|
601
|
+
where: `component ${element.getAttribute("data-component") ?? ""}${section(element)}`,
|
|
602
|
+
...nojs === null ? {} : { nojs: shown(nojs) }
|
|
603
|
+
};
|
|
604
|
+
})
|
|
605
|
+
};
|
|
606
|
+
}, ONLY_WITH_JAVASCRIPT.join(", "));
|
|
607
|
+
} finally {
|
|
608
|
+
await page.close();
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// src/usage.ts
|
|
612
|
+
/** A command was called wrongly: the CLI prints the message and the usage, and exits 2. */
|
|
613
|
+
var UsageError = class extends Error {};
|
|
614
|
+
const USAGE = `Usage: lernapps <command> [options]
|
|
615
|
+
|
|
616
|
+
Commands:
|
|
617
|
+
changes [--staged | --pushed | --ci] what a change can affect: none, docs or all
|
|
618
|
+
check [--pre-commit] [--pre-push] [<dir|url>] run the checks of a repo, a built app or an app on a URL
|
|
619
|
+
create --archetype <name> [<dir>] write a new app from the archetype's template
|
|
620
|
+
hooks install the app's git hooks (the app's prepare script)
|
|
621
|
+
init <short name> [--addon <package>] start an app: plan template and skills from the registry
|
|
622
|
+
screenshots [--out <dir>] [<dir>] an image of every page and every figure of a built explainer or quiz
|
|
623
|
+
skills --archetype <name> add the skills of the archetype the plan chose
|
|
624
|
+
update move the app to the runtime and tooling this tooling depends on
|
|
625
|
+
|
|
626
|
+
Options:
|
|
627
|
+
-h, --help print this help
|
|
628
|
+
-v, --version print the version
|
|
629
|
+
|
|
630
|
+
Run "lernapps <command> --help" for the options of a command. Exit codes: 0 on success, 1 when a check fails or
|
|
631
|
+
the app cannot be created, 2 on a usage error.
|
|
632
|
+
|
|
633
|
+
The package @lernapps/tooling comes from the npm registry. Before an app exists, run a command with
|
|
634
|
+
npx --yes @lernapps/tooling <command> ...
|
|
635
|
+
In an app, it is a development dependency: npx lernapps <command> ...
|
|
636
|
+
`;
|
|
637
|
+
/** The options of `lernapps check`, as node:util parseArgs takes them. */
|
|
638
|
+
const CHECK_OPTIONS = {
|
|
639
|
+
"pre-commit": {
|
|
640
|
+
type: "boolean",
|
|
641
|
+
default: false
|
|
642
|
+
},
|
|
643
|
+
"pre-push": {
|
|
644
|
+
type: "boolean",
|
|
645
|
+
default: false
|
|
646
|
+
},
|
|
647
|
+
entry: { type: "string" },
|
|
648
|
+
report: { type: "string" },
|
|
649
|
+
help: {
|
|
650
|
+
type: "boolean",
|
|
651
|
+
short: "h",
|
|
652
|
+
default: false
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
const CHECK_USAGE = `Usage: lernapps check [--pre-commit] [--pre-push] [<dir|url>] [--entry <file>] [--report <file>]
|
|
656
|
+
|
|
657
|
+
Runs every check that has something to check. Passing, it prints one line; otherwise the validation report in
|
|
658
|
+
YAML (schema: https://lernapps.net/tooling/schemas/validation-report.v1.schema.json). Every finding names its
|
|
659
|
+
rule id and severity, where, what was found and how to fix it, and links its rule.
|
|
660
|
+
|
|
661
|
+
Exit codes: 0 when no rule of severity error fails, 1 when one does, 2 on a usage error.
|
|
662
|
+
|
|
663
|
+
In a repo (no <dir|url>):
|
|
664
|
+
--pre-commit the fast part: format, lint, type check (the pre-commit hook); where the project has
|
|
665
|
+
Vue components (.vue files), also their type check with vue-tsc --noEmit; in an app with
|
|
666
|
+
an architecture (docs/arc42/ from lernapps create, an interactive app), when the commit
|
|
667
|
+
stages a file of it, .arc42ignore or src/, arc42 diff --staged (also when the commit is only
|
|
668
|
+
docs): a block and its prose not changed together fail, and new code no block claims that
|
|
669
|
+
.arc42ignore does not list; a hint to review an element under whose path code changed is a
|
|
670
|
+
warning. Before the first commit arc42 validate --strict
|
|
671
|
+
--pre-push the heavy part: in an app with an architecture arc42 validate --strict, unit tests, build (vp build for an app with index.html, else vp pack),
|
|
672
|
+
checks of the built app in dist/, the Playwright tests when playwright.config.* exists,
|
|
673
|
+
dependency licences, the lock file (the pre-push hook); a failure increments prePushFailures in the front
|
|
674
|
+
matter of the current plan (the highest number in .vibe/plans/, else .vibe/plan.md). A
|
|
675
|
+
failing generic test of the archetype's runtime (@lernapps/app-templates) is named as a test
|
|
676
|
+
of the template, with where to report it
|
|
677
|
+
Without a flag, or with both, it runs both parts (CI): the hooks together run exactly what CI runs.
|
|
678
|
+
Each hook part first asks lernapps changes what the change can affect (the staged files before a commit, the
|
|
679
|
+
pushed commits before a push): a change of only the plan (.vibe/**) checks nothing; a change of only docs
|
|
680
|
+
(docs/**/*.md) and the plan checks nothing in an app, whose bundle has no docs, and before a push builds and
|
|
681
|
+
checks the site in a repo with one (npm run build, then npm run check:site in the tooling, npm run check in a
|
|
682
|
+
site). Then it prints one line and writes no report. Anything else, or changed files it cannot know, runs the
|
|
683
|
+
part. CI decides the same in the app check action (lernapps changes --help).
|
|
684
|
+
Every run in a repo also writes the full report to node_modules/.cache/lernapps/validation-report.yaml,
|
|
685
|
+
passing or not; the app check action keeps it. A failed run counts each rule with an error in failedChecks
|
|
686
|
+
of the current plan's front matter, when it has the key.
|
|
687
|
+
|
|
688
|
+
On a built app, without its repo:
|
|
689
|
+
<dir> a bundle (a directory with index.html), served on 127.0.0.1
|
|
690
|
+
<url> an app on the web
|
|
691
|
+
|
|
692
|
+
The checks of a built app open its pages in Chromium, as a learner would, without a click; a link that only
|
|
693
|
+
changes the query string is the same page. Requests to other hosts are blocked and recorded.
|
|
694
|
+
On a bundle every page: first those reachable by links from the start page, then every other .html file,
|
|
695
|
+
which is a finding of pages-reachable (unreachable from the start page). Not pages: 404.html at
|
|
696
|
+
the root (the host's page for missing addresses) and a file without <!doctype html>. A page with
|
|
697
|
+
<meta name="robots" content="noindex"> is checked, but no link needs to reach it.
|
|
698
|
+
On a URL the pages reachable by links, at most 100: a check of a deployed app stays within minutes and
|
|
699
|
+
asks its host a bounded number of times. When it stops there, a hint of pages-reachable says how
|
|
700
|
+
many pages it checked and that further pages were not visited; check the bundle for all of them.
|
|
701
|
+
Explainer a page the explainer runtime marks as its own, <meta name="lernapps-archetype" content="explainer">,
|
|
702
|
+
is opened once more with JavaScript off, for explainer-readable-without-js: its h1, every heading,
|
|
703
|
+
formula (<math>) and figure visible, a figure with its accessible name, every exercise's "Lösung"
|
|
704
|
+
and every component's nojs text not empty. Pages without the marker get no second visit.
|
|
705
|
+
A page takes about a second; four are visited at a time, about 0.3 s per page: 120 pages in about 40 s.
|
|
706
|
+
Chromium is downloaded on the first run and cached; where no browser can run, the report says so.
|
|
707
|
+
A link whose server answers 429 (rate limited) is asked again after a short wait, at most a few seconds; still
|
|
708
|
+
limited, it is a finding of severity hint that names the link: not checked, nothing failed.
|
|
709
|
+
|
|
710
|
+
Options:
|
|
711
|
+
--entry <file> check a catalog entry (YAML) against the app: its URL and topic links resolve, a topic's
|
|
712
|
+
#<section> (or #<section>/<sub>) is an element id of its page as the server sends it, the
|
|
713
|
+
fitness values match; without <dir|url> the app at the entry's url is checked
|
|
714
|
+
--report <file> also write the full report to <file>, passing or not
|
|
715
|
+
-h, --help print this help
|
|
716
|
+
|
|
717
|
+
`;
|
|
718
|
+
/** The options of `lernapps changes`, as node:util parseArgs takes them. */
|
|
719
|
+
const CHANGES_OPTIONS = {
|
|
720
|
+
staged: {
|
|
721
|
+
type: "boolean",
|
|
722
|
+
default: false
|
|
723
|
+
},
|
|
724
|
+
pushed: {
|
|
725
|
+
type: "boolean",
|
|
726
|
+
default: false
|
|
727
|
+
},
|
|
728
|
+
ci: {
|
|
729
|
+
type: "boolean",
|
|
730
|
+
default: false
|
|
731
|
+
},
|
|
732
|
+
base: { type: "string" },
|
|
733
|
+
head: { type: "string" },
|
|
734
|
+
help: {
|
|
735
|
+
type: "boolean",
|
|
736
|
+
short: "h",
|
|
737
|
+
default: false
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
const CHANGES_USAGE = `Usage: lernapps changes [--staged | --pushed | --ci | --base <rev> [--head <rev>] | <path>...]
|
|
741
|
+
|
|
742
|
+
Prints the change scope of a change: what it can affect, and so what the hooks and CI run for it.
|
|
743
|
+
none every changed file is a plan: .vibe/**. Nothing runs
|
|
744
|
+
docs every changed file is docs, docs/**/*.md, or the plan. Only the site's checks and deployment run; an
|
|
745
|
+
app has no site (its bundle holds no docs), so nothing runs
|
|
746
|
+
all anything else, also when the changed files are not known, or none are. Everything runs
|
|
747
|
+
|
|
748
|
+
"Docs" are only Markdown files below docs/ (docs/**/*.md). README.md, AGENTS.md and every other path are not
|
|
749
|
+
docs. The lists of none and docs are allowlists: every other path is all, so a new folder never causes a skip.
|
|
750
|
+
|
|
751
|
+
Where the changed files come from (one source):
|
|
752
|
+
--staged the staged files (the pre-commit hook)
|
|
753
|
+
--pushed the pushed commits, from git's lines on standard input (the pre-push hook): each ref against
|
|
754
|
+
the remote's commit; a new branch against its merge base with origin/main; no lines (run by
|
|
755
|
+
hand) or an unknown commit is all
|
|
756
|
+
--ci the GitHub Actions event: a pull request against the merge base with its base, a push to a
|
|
757
|
+
branch before..after; a new branch, workflow_dispatch and any other event is all. A shallow
|
|
758
|
+
clone is deepened once when a commit is missing. Also writes the step outputs scope, kind, code
|
|
759
|
+
(true when the code's build and tests run: scope all) and site (true when the site's checks and
|
|
760
|
+
deployment run) to GITHUB_OUTPUT, and a notice when something is skipped
|
|
761
|
+
--base <rev> what --head (default HEAD) adds to its merge base with <rev>
|
|
762
|
+
<path>... these paths, relative to the repo root
|
|
763
|
+
Without a source the changed files are not known: all.
|
|
764
|
+
|
|
765
|
+
The kind of repo decides what docs runs: app (index.html at the root), tooling (the package @lernapps/tooling),
|
|
766
|
+
site (any other: a site or the catalog). A repo may name its kind and declare exceptions in package.json, each
|
|
767
|
+
key optional, each list of path patterns relative to the repo root (** any folders, * any characters but /, a
|
|
768
|
+
pattern ending in / a folder):
|
|
769
|
+
"lernapps": { "changes": { "kind": "site", "none": ["notes/**"], "docs": ["handbook/**/*.md"],
|
|
770
|
+
"all": ["docs/generated/**"] } }
|
|
771
|
+
kind app, tooling or site, instead of the detected one
|
|
772
|
+
none more patterns that are plan: nothing runs for them
|
|
773
|
+
docs more patterns that are docs
|
|
774
|
+
all patterns that are all, also where they match none or docs
|
|
775
|
+
Any other key, or a malformed value, is an error.
|
|
776
|
+
|
|
777
|
+
Prints the scope (none, docs or all) on standard output and why on standard error.
|
|
778
|
+
Exit codes: 0 when the scope is printed, 1 when package.json lernapps.changes is malformed, 2 on a usage error.
|
|
779
|
+
|
|
780
|
+
Options:
|
|
781
|
+
-h, --help print this help
|
|
782
|
+
`;
|
|
783
|
+
/** The options of `lernapps hooks`, as node:util parseArgs takes them. */
|
|
784
|
+
const HOOKS_OPTIONS = { help: {
|
|
785
|
+
type: "boolean",
|
|
786
|
+
short: "h",
|
|
787
|
+
default: false
|
|
788
|
+
} };
|
|
789
|
+
const HOOKS_USAGE = `Usage: lernapps hooks
|
|
790
|
+
|
|
791
|
+
Installs the git hooks of @lernapps/tooling (the fast part of lernapps check before a commit, the heavy part
|
|
792
|
+
before a push) into the repository of the app in the current folder. Every app runs it as its prepare script, so
|
|
793
|
+
npm install installs the hooks.
|
|
794
|
+
|
|
795
|
+
It installs them only where the current folder is the root of its git repository. In a folder inside another
|
|
796
|
+
repository, such as a template folder of lernapps/app-templates, it changes nothing and says so: that
|
|
797
|
+
repository keeps its own hooks. Outside a repository it says so too: git init, then npm install again.
|
|
798
|
+
|
|
799
|
+
Exit codes: 0 when the hooks are installed or deliberately not, 1 when they cannot be, 2 on a usage error.
|
|
800
|
+
|
|
801
|
+
Options:
|
|
802
|
+
-h, --help print this help
|
|
803
|
+
`;
|
|
804
|
+
/** The options of `lernapps screenshots`. */
|
|
805
|
+
const SCREENSHOTS_OPTIONS = {
|
|
806
|
+
out: {
|
|
807
|
+
type: "string",
|
|
808
|
+
default: "screenshots"
|
|
809
|
+
},
|
|
810
|
+
width: {
|
|
811
|
+
type: "string",
|
|
812
|
+
default: "390"
|
|
813
|
+
},
|
|
814
|
+
desktop: {
|
|
815
|
+
type: "boolean",
|
|
816
|
+
default: false
|
|
817
|
+
},
|
|
818
|
+
help: {
|
|
819
|
+
type: "boolean",
|
|
820
|
+
short: "h",
|
|
821
|
+
default: false
|
|
822
|
+
}
|
|
823
|
+
};
|
|
824
|
+
const SCREENSHOTS_USAGE = `Usage: lernapps screenshots [--out <dir>] [--width <px>] [--desktop] [<dir>]
|
|
825
|
+
|
|
826
|
+
Writes a full-page image of every page of a built explainer or quiz, and an image of every figure on it, so that a
|
|
827
|
+
creator sees what a learner sees before the app goes out. A page image is scaled down to be shown whole; check the
|
|
828
|
+
details of a figure, such as where a line of a drawing points, in its own image. It works where an assistant can only
|
|
829
|
+
send images: the images are PNG files, and an index.html shows them all in page order with each page's title and path
|
|
830
|
+
(one file, images relative, no network).
|
|
831
|
+
|
|
832
|
+
The built app is <dir>, default dist/ (build it first). Its pages are those lernapps check visits; the start page
|
|
833
|
+
comes first. Each is opened in Chromium, as a learner first sees it: nothing answered, no hover, animations off,
|
|
834
|
+
fonts and images loaded; requests to other hosts are blocked. A figure is a block figure or labelled-figure, or any
|
|
835
|
+
other figure with an image or a drawing. It is cut out with its caption, at twice its size on screen, from the page
|
|
836
|
+
as the build writes it, without JavaScript, so that the figures of every question of a quiz are there too; a figure
|
|
837
|
+
that takes no space, such as one in a closed details element, is left out. Another archetype, such as an interactive
|
|
838
|
+
app, is not supported. Prints the folder and the number of pages and figures.
|
|
839
|
+
|
|
840
|
+
Exit codes: 0 when the images are written, 1 when they cannot be (an archetype not supported, no browser can run),
|
|
841
|
+
2 on a usage error, such as no build in <dir>. Then no folder is written or emptied.
|
|
842
|
+
|
|
843
|
+
Output: one PNG per page, named by its order and path (01-index.png, 02-plan-selbsteinschaetzung.png), one PNG per
|
|
844
|
+
figure, named by its page and its place on the page (02-plan-selbsteinschaetzung-figure-1.png), and the index.html,
|
|
845
|
+
which shows the figures with their page, in the folder --out. The folder is emptied first. It is not part of the app: do not commit it.
|
|
846
|
+
|
|
847
|
+
Options:
|
|
848
|
+
--out <dir> the folder of the images (default screenshots)
|
|
849
|
+
--width <px> the width of the images in pixels (default 390, a phone)
|
|
850
|
+
--desktop also images 1280 px wide; every image is then named with its width (01-index-390.png,
|
|
851
|
+
01-index-1280.png, 01-index-figure-1-1280.png)
|
|
852
|
+
-h, --help print this help
|
|
853
|
+
`;
|
|
854
|
+
// archetypes/shared/install-hooks.ts
|
|
855
|
+
/** The hooks, as the app's folder sees them once @lernapps/tooling is installed. */
|
|
856
|
+
const HOOKS_DIR = "node_modules/@lernapps/tooling/archetypes/shared/hooks";
|
|
857
|
+
/** Where git runs them from: a folder npm leaves alone when it replaces the package. */
|
|
858
|
+
const GIT_HOOKS_DIR = "node_modules/.cache/lernapps/hooks";
|
|
859
|
+
const HOOKS = ["pre-commit", "pre-push"];
|
|
860
|
+
function installHooks(args) {
|
|
861
|
+
const { values } = parseArgs({
|
|
862
|
+
args,
|
|
863
|
+
options: HOOKS_OPTIONS,
|
|
864
|
+
strict: true,
|
|
865
|
+
allowPositionals: false
|
|
866
|
+
});
|
|
867
|
+
if (values.help) {
|
|
868
|
+
process.stdout.write(HOOKS_USAGE);
|
|
869
|
+
return 0;
|
|
870
|
+
}
|
|
871
|
+
const say = (message) => process.stdout.write(`lernapps hooks: ${message}\n`);
|
|
872
|
+
const prefix = spawnSync("git", ["rev-parse", "--show-prefix"], { encoding: "utf8" });
|
|
873
|
+
if (prefix.error || prefix.status !== 0) {
|
|
874
|
+
say("not in a git repository, no hooks installed (git init, then npm install again)");
|
|
875
|
+
return 0;
|
|
876
|
+
}
|
|
877
|
+
const inside = prefix.stdout.trim();
|
|
878
|
+
if (inside !== "") {
|
|
879
|
+
say(`this folder is ${inside} inside another git repository; its hooks stay as they are`);
|
|
880
|
+
return 0;
|
|
881
|
+
}
|
|
882
|
+
mkdirSync(GIT_HOOKS_DIR, { recursive: true });
|
|
883
|
+
for (const hook of HOOKS) writeFileSync(join(GIT_HOOKS_DIR, hook), `# Written by lernapps hooks: runs the ${hook} hook of the installed @lernapps/tooling.\n. ./${HOOKS_DIR}/${hook}\n`);
|
|
884
|
+
if (spawnSync("git", [
|
|
885
|
+
"config",
|
|
886
|
+
"--local",
|
|
887
|
+
"core.hooksPath"
|
|
888
|
+
], { encoding: "utf8" }).stdout.trim() === `node_modules/@lernapps/tooling/archetypes/shared/hooks/_`) spawnSync("git", [
|
|
889
|
+
"config",
|
|
890
|
+
"--local",
|
|
891
|
+
"core.hooksPath",
|
|
892
|
+
`${GIT_HOOKS_DIR}/_`
|
|
893
|
+
]);
|
|
894
|
+
const env = {
|
|
895
|
+
...process.env,
|
|
896
|
+
PATH: [join(process.cwd(), "node_modules", ".bin"), process.env["PATH"] ?? ""].join(delimiter)
|
|
897
|
+
};
|
|
898
|
+
const result = spawnSync("vp", [
|
|
899
|
+
"config",
|
|
900
|
+
"--no-agent",
|
|
901
|
+
"--hooks-dir",
|
|
902
|
+
GIT_HOOKS_DIR
|
|
903
|
+
], {
|
|
904
|
+
stdio: "inherit",
|
|
905
|
+
env
|
|
906
|
+
});
|
|
907
|
+
if (result.error) {
|
|
908
|
+
process.stderr.write(`lernapps hooks: cannot run vp: ${result.error.message}. Install the dependencies (npm ci)\n`);
|
|
909
|
+
return 1;
|
|
910
|
+
}
|
|
911
|
+
return result.status ?? 1;
|
|
912
|
+
}
|
|
913
|
+
// changes/changes.ts
|
|
914
|
+
const KINDS = [
|
|
915
|
+
"app",
|
|
916
|
+
"tooling",
|
|
917
|
+
"site"
|
|
918
|
+
];
|
|
919
|
+
/**
|
|
920
|
+
* The default map per kind of repo. The allowlists are the same for every kind; the kind decides what `docs` runs:
|
|
921
|
+
* nothing in an app (docs are not part of its bundle), the site's checks and deployment in the tooling and in a site
|
|
922
|
+
* or catalog repo.
|
|
923
|
+
*/
|
|
924
|
+
const DEFAULTS = {
|
|
925
|
+
app: {
|
|
926
|
+
kind: "app",
|
|
927
|
+
none: [".vibe/**"],
|
|
928
|
+
docs: ["docs/**/*.md"],
|
|
929
|
+
all: []
|
|
930
|
+
},
|
|
931
|
+
tooling: {
|
|
932
|
+
kind: "tooling",
|
|
933
|
+
none: [".vibe/**"],
|
|
934
|
+
docs: ["docs/**/*.md"],
|
|
935
|
+
all: []
|
|
936
|
+
},
|
|
937
|
+
site: {
|
|
938
|
+
kind: "site",
|
|
939
|
+
none: [".vibe/**"],
|
|
940
|
+
docs: ["docs/**/*.md"],
|
|
941
|
+
all: []
|
|
942
|
+
}
|
|
943
|
+
};
|
|
944
|
+
/** The npm script that checks the built site of a kind of repo, after `npm run build`; an app has no site. */
|
|
945
|
+
const SITE_CHECK = {
|
|
946
|
+
app: void 0,
|
|
947
|
+
tooling: "check:site",
|
|
948
|
+
site: "check"
|
|
949
|
+
};
|
|
950
|
+
/** The repo's configuration in package.json is wrong; the message says what to fix. */
|
|
951
|
+
var ChangesConfigError = class extends Error {};
|
|
952
|
+
/**
|
|
953
|
+
* A glob as a regular expression: `**` any number of folders, `*` any characters but "/", `?` one of them. A pattern
|
|
954
|
+
* that ends with "/" is a folder and matches everything below it.
|
|
955
|
+
*/
|
|
956
|
+
function globToRegExp(pattern) {
|
|
957
|
+
const glob = pattern.endsWith("/") ? `${pattern}**` : pattern;
|
|
958
|
+
let source = "";
|
|
959
|
+
for (let i = 0; i < glob.length; i++) {
|
|
960
|
+
const char = glob[i] ?? "";
|
|
961
|
+
if (char === "*" && glob[i + 1] === "*") {
|
|
962
|
+
i++;
|
|
963
|
+
if (glob[i + 1] === "/") {
|
|
964
|
+
i++;
|
|
965
|
+
source += "(?:.*/)?";
|
|
966
|
+
} else source += ".*";
|
|
967
|
+
} else if (char === "*") source += "[^/]*";
|
|
968
|
+
else if (char === "?") source += "[^/]";
|
|
969
|
+
else source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
970
|
+
}
|
|
971
|
+
return new RegExp(`^${source}$`);
|
|
972
|
+
}
|
|
973
|
+
const compiled = /* @__PURE__ */ new Map();
|
|
974
|
+
/** Whether a path (relative to the repo root, with "/") matches one of the patterns. */
|
|
975
|
+
function matchesAny(path, patterns) {
|
|
976
|
+
return patterns.some((pattern) => {
|
|
977
|
+
let regexp = compiled.get(pattern);
|
|
978
|
+
if (regexp === void 0) {
|
|
979
|
+
regexp = globToRegExp(pattern);
|
|
980
|
+
compiled.set(pattern, regexp);
|
|
981
|
+
}
|
|
982
|
+
return regexp.test(path);
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* The scope of the changed files. Undefined (not known) or empty is `all`: a change whose files are not known could
|
|
987
|
+
* affect anything, and nothing staged means a run by hand.
|
|
988
|
+
*/
|
|
989
|
+
function classify(files, rules) {
|
|
990
|
+
if (files === void 0) return {
|
|
991
|
+
scope: "all",
|
|
992
|
+
reason: "the changed files are not known"
|
|
993
|
+
};
|
|
994
|
+
if (files.length === 0) return {
|
|
995
|
+
scope: "all",
|
|
996
|
+
reason: "no changed files are known"
|
|
997
|
+
};
|
|
998
|
+
let docs = 0;
|
|
999
|
+
for (const file of files) {
|
|
1000
|
+
if (matchesAny(file, rules.all)) return {
|
|
1001
|
+
scope: "all",
|
|
1002
|
+
reason: `${file} is an exception of this repo (lernapps.changes.all)`
|
|
1003
|
+
};
|
|
1004
|
+
if (matchesAny(file, rules.none)) continue;
|
|
1005
|
+
if (!matchesAny(file, rules.docs)) return {
|
|
1006
|
+
scope: "all",
|
|
1007
|
+
reason: `${file} is neither plan nor docs`
|
|
1008
|
+
};
|
|
1009
|
+
docs++;
|
|
1010
|
+
}
|
|
1011
|
+
const count = (n) => `${n} changed file${n === 1 ? "" : "s"}`;
|
|
1012
|
+
return docs === 0 ? {
|
|
1013
|
+
scope: "none",
|
|
1014
|
+
reason: `${count(files.length)}, only the plan (${rules.none.join(", ")})`
|
|
1015
|
+
} : {
|
|
1016
|
+
scope: "docs",
|
|
1017
|
+
reason: `${count(files.length)}, only docs (${rules.docs.join(", ")}) and the plan`
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* The scope of a push: as classify, except that a push which changes no file (an empty start commit) is `none`. The
|
|
1022
|
+
* pushed commits are known, so there is nothing to check.
|
|
1023
|
+
*/
|
|
1024
|
+
function classifyPush(files, rules) {
|
|
1025
|
+
return files?.length === 0 ? {
|
|
1026
|
+
scope: "none",
|
|
1027
|
+
reason: "the push changes no file"
|
|
1028
|
+
} : classify(files, rules);
|
|
1029
|
+
}
|
|
1030
|
+
/** What runs for a scope in a kind of repo: the code (build, tests), the site's checks and deployment. */
|
|
1031
|
+
function runs(scope, kind) {
|
|
1032
|
+
return {
|
|
1033
|
+
code: scope === "all",
|
|
1034
|
+
site: scope === "all" || scope === "docs" && SITE_CHECK[kind] !== void 0
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
const CONFIG_KEYS = [
|
|
1038
|
+
"kind",
|
|
1039
|
+
"none",
|
|
1040
|
+
"docs",
|
|
1041
|
+
"all"
|
|
1042
|
+
];
|
|
1043
|
+
function patterns(value, key) {
|
|
1044
|
+
const where = `package.json lernapps.changes.${key}`;
|
|
1045
|
+
if (!Array.isArray(value)) throw new ChangesConfigError(`${where}: a list of path patterns, not ${JSON.stringify(value)}`);
|
|
1046
|
+
return value.map((item) => {
|
|
1047
|
+
if (typeof item !== "string" || item === "") throw new ChangesConfigError(`${where}: every pattern is a non-empty string, not ${JSON.stringify(item)}`);
|
|
1048
|
+
if (item.startsWith("/") || item.startsWith("!") || item.split("/").includes("..") || item.includes("\\")) throw new ChangesConfigError(`${where}: "${item}" must be relative to the repo root, with "/", without "..", and not negated with "!"`);
|
|
1049
|
+
return item;
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
/** The kind of the repo in `dir` when its package.json names none: the tooling, an app (index.html), else a site. */
|
|
1053
|
+
function detectKind(dir, manifest) {
|
|
1054
|
+
if (manifest["name"] === "@lernapps/tooling") return "tooling";
|
|
1055
|
+
if (existsSync(join(dir, "index.html"))) return "app";
|
|
1056
|
+
return "site";
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* The rules of the repo in `dir`: the default map of its kind, with the exceptions its package.json declares in
|
|
1060
|
+
* `lernapps.changes` ({ kind?, none?, docs?, all? }: `none` and `docs` add patterns to the allowlists, `all` makes
|
|
1061
|
+
* paths `all` regardless). Throws ChangesConfigError when that configuration is malformed.
|
|
1062
|
+
*/
|
|
1063
|
+
function loadRules(dir) {
|
|
1064
|
+
const file = join(dir, "package.json");
|
|
1065
|
+
let manifest = {};
|
|
1066
|
+
if (existsSync(file)) try {
|
|
1067
|
+
const value = JSON.parse(readFileSync(file, "utf8"));
|
|
1068
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) manifest = value;
|
|
1069
|
+
} catch (error) {
|
|
1070
|
+
throw new ChangesConfigError(`package.json: ${error instanceof Error ? error.message : String(error)}`);
|
|
1071
|
+
}
|
|
1072
|
+
const lernapps = manifest["lernapps"];
|
|
1073
|
+
let config = {};
|
|
1074
|
+
if (lernapps !== void 0) {
|
|
1075
|
+
if (typeof lernapps !== "object" || lernapps === null || Array.isArray(lernapps)) throw new ChangesConfigError("package.json lernapps: an object");
|
|
1076
|
+
const changes = lernapps["changes"];
|
|
1077
|
+
if (changes !== void 0) {
|
|
1078
|
+
if (typeof changes !== "object" || changes === null || Array.isArray(changes)) throw new ChangesConfigError("package.json lernapps.changes: an object with kind, none, docs, all");
|
|
1079
|
+
config = changes;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
const unknown = Object.keys(config).filter((key) => !CONFIG_KEYS.includes(key));
|
|
1083
|
+
if (unknown.length > 0) throw new ChangesConfigError(`package.json lernapps.changes: unknown ${unknown.length === 1 ? "key" : "keys"} ${unknown.join(", ")} (known: ${CONFIG_KEYS.join(", ")})`);
|
|
1084
|
+
let kind = detectKind(dir, manifest);
|
|
1085
|
+
if (config["kind"] !== void 0) {
|
|
1086
|
+
if (!KINDS.includes(config["kind"])) throw new ChangesConfigError(`package.json lernapps.changes.kind: one of ${KINDS.join(", ")}, not ${JSON.stringify(config["kind"])}`);
|
|
1087
|
+
kind = config["kind"];
|
|
1088
|
+
}
|
|
1089
|
+
const defaults = DEFAULTS[kind];
|
|
1090
|
+
const extra = (key) => config[key] === void 0 ? [] : patterns(config[key], key);
|
|
1091
|
+
return {
|
|
1092
|
+
kind,
|
|
1093
|
+
none: [...defaults.none, ...extra("none")],
|
|
1094
|
+
docs: [...defaults.docs, ...extra("docs")],
|
|
1095
|
+
all: [...defaults.all, ...extra("all")]
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
const ZERO = /^0+$/;
|
|
1099
|
+
const git$1 = (cwd, ...args) => spawnSync("git", args, {
|
|
1100
|
+
cwd,
|
|
1101
|
+
encoding: "utf8",
|
|
1102
|
+
maxBuffer: 67108864
|
|
1103
|
+
});
|
|
1104
|
+
/** The paths of `git diff --name-only -z`; renames as a deletion and an addition, so both paths count. */
|
|
1105
|
+
function diffNames(cwd, ...args) {
|
|
1106
|
+
const diff = git$1(cwd, "diff", "--name-only", "--no-renames", "-z", ...args);
|
|
1107
|
+
if (diff.error || diff.status !== 0) return void 0;
|
|
1108
|
+
return diff.stdout.split("\0").filter((path) => path !== "");
|
|
1109
|
+
}
|
|
1110
|
+
const isCommit = (cwd, rev) => git$1(cwd, "cat-file", "-e", `${rev}^{commit}`).status === 0;
|
|
1111
|
+
/**
|
|
1112
|
+
* What a new branch is compared with: its merge base with origin/main; when the remote has no main yet (the first push
|
|
1113
|
+
* of a new repo), the empty tree, so the branch adds everything it holds (nothing, for an empty start commit).
|
|
1114
|
+
*/
|
|
1115
|
+
function newBranchBase(cwd, local) {
|
|
1116
|
+
if (git$1(cwd, "rev-parse", "--verify", "--quiet", "origin/main").status !== 0) {
|
|
1117
|
+
const empty = spawnSync("git", [
|
|
1118
|
+
"hash-object",
|
|
1119
|
+
"-t",
|
|
1120
|
+
"tree",
|
|
1121
|
+
"--stdin"
|
|
1122
|
+
], {
|
|
1123
|
+
cwd,
|
|
1124
|
+
encoding: "utf8",
|
|
1125
|
+
input: ""
|
|
1126
|
+
});
|
|
1127
|
+
return {
|
|
1128
|
+
base: empty.status === 0 ? empty.stdout.trim() : "",
|
|
1129
|
+
tree: true
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
return {
|
|
1133
|
+
base: git$1(cwd, "merge-base", local, "origin/main").stdout.trim(),
|
|
1134
|
+
tree: false
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
/** The staged files of the next commit (the pre-commit hook); undefined outside a repository. */
|
|
1138
|
+
function stagedFiles(cwd = ".") {
|
|
1139
|
+
return diffNames(cwd, "--cached");
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* The files a push changes (the pre-push hook), from git's lines "<local ref> <local sha> <remote ref> <remote sha>":
|
|
1143
|
+
* what each pushed ref adds to the remote's commit; for a new branch, what it adds to its merge base with
|
|
1144
|
+
* origin/main, or to the empty tree when the remote has no main yet. Undefined when that cannot be known: no lines
|
|
1145
|
+
* (run by hand), an unknown commit.
|
|
1146
|
+
*/
|
|
1147
|
+
function pushedFiles(lines, cwd = ".") {
|
|
1148
|
+
const refs = lines.split("\n").map((line) => line.trim().split(/\s+/)).filter((fields) => fields.length === 4);
|
|
1149
|
+
if (refs.length === 0) return void 0;
|
|
1150
|
+
const paths = /* @__PURE__ */ new Set();
|
|
1151
|
+
for (const [, local = "", , remote = ""] of refs) {
|
|
1152
|
+
if (ZERO.test(local)) continue;
|
|
1153
|
+
const { base, tree } = ZERO.test(remote) ? newBranchBase(cwd, local) : {
|
|
1154
|
+
base: remote,
|
|
1155
|
+
tree: false
|
|
1156
|
+
};
|
|
1157
|
+
if (base === "" || !tree && !isCommit(cwd, base)) return void 0;
|
|
1158
|
+
const names = diffNames(cwd, base, local);
|
|
1159
|
+
if (names === void 0) return void 0;
|
|
1160
|
+
for (const path of names) paths.add(path);
|
|
1161
|
+
}
|
|
1162
|
+
return [...paths];
|
|
1163
|
+
}
|
|
1164
|
+
/** Fetches the whole history once, when the clone is shallow (as actions/checkout makes it by default). */
|
|
1165
|
+
function unshallow(cwd) {
|
|
1166
|
+
if (git$1(cwd, "rev-parse", "--is-shallow-repository").stdout.trim() !== "true") return false;
|
|
1167
|
+
return git$1(cwd, "fetch", "--quiet", "--no-tags", "--unshallow", "origin").status === 0;
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* The files between two commits: with `mergeBase`, what `head` adds to its merge base with `base` (a pull request);
|
|
1171
|
+
* else the difference from `base` to `head` (a push: before..after). A shallow clone is deepened once when a commit
|
|
1172
|
+
* or the merge base is missing. Undefined when the commits cannot be found.
|
|
1173
|
+
*/
|
|
1174
|
+
function rangeFiles(base, head, mergeBase, cwd = ".") {
|
|
1175
|
+
const attempt = () => {
|
|
1176
|
+
if (!isCommit(cwd, base) || !isCommit(cwd, head)) return void 0;
|
|
1177
|
+
const from = mergeBase ? git$1(cwd, "merge-base", base, head).stdout.trim() : base;
|
|
1178
|
+
return from === "" ? void 0 : diffNames(cwd, from, head);
|
|
1179
|
+
};
|
|
1180
|
+
return attempt() ?? (unshallow(cwd) ? attempt() : void 0);
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* The files of the GitHub Actions event (GITHUB_EVENT_NAME, GITHUB_EVENT_PATH): a pull request, what it adds to the
|
|
1184
|
+
* merge base with its base; a push, before..after. A new branch (before is zeros), workflow_dispatch and every other
|
|
1185
|
+
* event: undefined, so everything runs.
|
|
1186
|
+
*/
|
|
1187
|
+
function eventFiles(env = process.env, cwd = ".") {
|
|
1188
|
+
const name = env["GITHUB_EVENT_NAME"];
|
|
1189
|
+
const path = env["GITHUB_EVENT_PATH"];
|
|
1190
|
+
if (name === void 0 || path === void 0 || !existsSync(path)) return void 0;
|
|
1191
|
+
let event;
|
|
1192
|
+
try {
|
|
1193
|
+
event = JSON.parse(readFileSync(path, "utf8"));
|
|
1194
|
+
} catch {
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
const sha = (value) => typeof value === "string" && /^[0-9a-f]{40}$/.test(value) && !ZERO.test(value) ? value : void 0;
|
|
1198
|
+
if (name === "pull_request" || name === "pull_request_target") {
|
|
1199
|
+
const base = sha(event.pull_request?.base?.sha);
|
|
1200
|
+
const head = sha(event.pull_request?.head?.sha);
|
|
1201
|
+
return base && head ? rangeFiles(base, head, true, cwd) : void 0;
|
|
1202
|
+
}
|
|
1203
|
+
if (name === "push") {
|
|
1204
|
+
const before = sha(event.before);
|
|
1205
|
+
const after = sha(event.after);
|
|
1206
|
+
return before && after ? rangeFiles(before, after, false, cwd) : void 0;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
/** Standard input, as git gives it to the pre-push hook; empty when it is a terminal or closed. */
|
|
1210
|
+
function readStdin() {
|
|
1211
|
+
try {
|
|
1212
|
+
return process.stdin.isTTY ? "" : readFileSync(0, "utf8");
|
|
1213
|
+
} catch {
|
|
1214
|
+
return "";
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
/** `lernapps changes`: prints the scope; with --ci also the step outputs and a notice. */
|
|
1218
|
+
function changes(args) {
|
|
1219
|
+
const { values, positionals } = parseArgs({
|
|
1220
|
+
args,
|
|
1221
|
+
options: CHANGES_OPTIONS,
|
|
1222
|
+
strict: true,
|
|
1223
|
+
allowPositionals: true
|
|
1224
|
+
});
|
|
1225
|
+
if (values.help) {
|
|
1226
|
+
process.stdout.write(CHANGES_USAGE);
|
|
1227
|
+
return 0;
|
|
1228
|
+
}
|
|
1229
|
+
if ([
|
|
1230
|
+
values.staged,
|
|
1231
|
+
values.pushed,
|
|
1232
|
+
values.ci,
|
|
1233
|
+
values.base !== void 0,
|
|
1234
|
+
positionals.length > 0
|
|
1235
|
+
].filter(Boolean).length > 1) throw new UsageError("changes takes one source: --staged, --pushed, --ci, --base <rev> or <path>...");
|
|
1236
|
+
if (values.head !== void 0 && values.base === void 0) throw new UsageError("--head needs --base <rev>");
|
|
1237
|
+
let rules;
|
|
1238
|
+
try {
|
|
1239
|
+
rules = loadRules(".");
|
|
1240
|
+
} catch (error) {
|
|
1241
|
+
if (!(error instanceof ChangesConfigError)) throw error;
|
|
1242
|
+
process.stderr.write(`lernapps changes: ${error.message}\n`);
|
|
1243
|
+
return 1;
|
|
1244
|
+
}
|
|
1245
|
+
const { scope, reason } = classify(values.staged ? stagedFiles() : values.pushed ? pushedFiles(readStdin()) : values.ci ? eventFiles() : values.base !== void 0 ? rangeFiles(values.base, values.head ?? "HEAD", true) : positionals.length > 0 ? positionals : void 0, rules);
|
|
1246
|
+
const run = runs(scope, rules.kind);
|
|
1247
|
+
process.stdout.write(`${scope}\n`);
|
|
1248
|
+
process.stderr.write(`lernapps changes: ${scope} in a repo of kind ${rules.kind}: ${reason}\n`);
|
|
1249
|
+
if (values.ci) {
|
|
1250
|
+
const output = process.env["GITHUB_OUTPUT"];
|
|
1251
|
+
const lines = [
|
|
1252
|
+
`scope=${scope}`,
|
|
1253
|
+
`kind=${rules.kind}`,
|
|
1254
|
+
`code=${run.code}`,
|
|
1255
|
+
`site=${run.site}`
|
|
1256
|
+
];
|
|
1257
|
+
if (output !== void 0 && output !== "") appendFileSync(output, `${lines.join("\n")}\n`);
|
|
1258
|
+
if (scope !== "all") {
|
|
1259
|
+
const what = run.site ? "only the site's checks and deployment run" : "nothing to build, check or publish";
|
|
1260
|
+
process.stdout.write(`::notice title=lernapps changes::change scope ${scope}: ${what} (${reason})\n`);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
return 0;
|
|
1264
|
+
}
|
|
1265
|
+
// generator/architecture.ts
|
|
1266
|
+
/** Where an app keeps its architecture, and where app-templates keeps the generic part. */
|
|
1267
|
+
const ARCHITECTURE = join("docs", "arc42");
|
|
1268
|
+
/** The archetypes whose apps get the architecture and its checks. */
|
|
1269
|
+
const ARCHITECTURE_ARCHETYPES = ["interactive"];
|
|
1270
|
+
/** The mark of a reference file, which `lernapps update` replaces outside its app sections. */
|
|
1271
|
+
const REFERENCE = "lernapps update replaces this file";
|
|
1272
|
+
/** A marker of an app section: `<!-- app: begin <name> -->` or `<!-- app: end <name> -->`, the name optional. */
|
|
1273
|
+
const APP_MARKER = /^<!-- app: (begin|end)(?: ([a-z0-9-]+))? -->$/;
|
|
1274
|
+
/** The app's architecture cannot be replaced; the message says why and what to do. */
|
|
1275
|
+
var ArchitectureError = class extends Error {};
|
|
1276
|
+
const TOP = "<!-- at the top of the generic chapter -->";
|
|
1277
|
+
const END = "<!-- end -->";
|
|
1278
|
+
const RUNTIME_PATH = "installed package @lernapps/app-templates (arc42 cannot resolve untracked paths, docToolchain/arc42-language#128)";
|
|
1279
|
+
const chapterFiles = (dir) => existsSync(dir) ? readdirSync(dir).filter((name) => /^\d{2}-.*\.arc42\.md$/.test(name)).sort() : [];
|
|
1280
|
+
/** The lines of a file, without the trailing newline. */
|
|
1281
|
+
const linesOf = (file) => readFileSync(file, "utf8").replace(/\n$/, "").split("\n");
|
|
1282
|
+
/**
|
|
1283
|
+
* The paths of the blocks and interfaces, for the app: `path: runtime/...` becomes an ignore directive (H014, the
|
|
1284
|
+
* missing path) before the element; `path: <archetype>/...` loses the prefix.
|
|
1285
|
+
*/
|
|
1286
|
+
function rewritePaths(lines, archetype) {
|
|
1287
|
+
const out = [];
|
|
1288
|
+
let element;
|
|
1289
|
+
for (const line of lines) {
|
|
1290
|
+
if (element === void 0) {
|
|
1291
|
+
if (line === ":::building-block" || line === ":::interface") element = [line];
|
|
1292
|
+
else out.push(line);
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
if (line !== ":::") {
|
|
1296
|
+
element.push(line);
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
const path = element.find((text) => text.startsWith("path: "))?.slice(6);
|
|
1300
|
+
if (path !== void 0 && (path === "runtime" || path.startsWith("runtime/"))) {
|
|
1301
|
+
if (out.at(-1) === ":::" && out.at(-2)?.startsWith(":::ignore H020 ")) out.splice(-2, 2);
|
|
1302
|
+
out.push(`:::ignore H014 ${RUNTIME_PATH}`, ":::", ...element.filter((text) => !text.startsWith("path: ")));
|
|
1303
|
+
} else if (path?.startsWith(`${archetype}/`)) out.push(...element.map((text) => text === `path: ${path}` ? `path: ${path.slice(archetype.length + 1)}` : text));
|
|
1304
|
+
else out.push(...element);
|
|
1305
|
+
out.push(line);
|
|
1306
|
+
element = void 0;
|
|
1307
|
+
}
|
|
1308
|
+
if (element !== void 0) out.push(...element);
|
|
1309
|
+
return out;
|
|
1310
|
+
}
|
|
1311
|
+
/** The combined chapters of the generic part and `archetype`'s extension in a checkout of app-templates, by name. */
|
|
1312
|
+
function combine(templates, archetype, commit) {
|
|
1313
|
+
const generic = join(templates, ARCHITECTURE);
|
|
1314
|
+
const extension = join(templates, archetype, ARCHITECTURE);
|
|
1315
|
+
const chapters = /* @__PURE__ */ new Map();
|
|
1316
|
+
for (const name of chapterFiles(generic)) {
|
|
1317
|
+
const lines = [];
|
|
1318
|
+
const top = [];
|
|
1319
|
+
const add = (file, own) => {
|
|
1320
|
+
let atTop = false;
|
|
1321
|
+
linesOf(file).forEach((text, index) => {
|
|
1322
|
+
if (own && index === 0 && text.startsWith("# ")) return;
|
|
1323
|
+
if (own && text === TOP) atTop = true;
|
|
1324
|
+
if (atTop) {
|
|
1325
|
+
if (text === END) atTop = false;
|
|
1326
|
+
else if (text !== TOP) top.push(text);
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
lines.push(text);
|
|
1330
|
+
});
|
|
1331
|
+
};
|
|
1332
|
+
add(join(generic, name), false);
|
|
1333
|
+
for (const own of chapterFiles(extension).filter((candidate) => candidate.slice(0, 3) === name.slice(0, 3))) {
|
|
1334
|
+
lines.push("");
|
|
1335
|
+
add(join(extension, own), true);
|
|
1336
|
+
}
|
|
1337
|
+
const mark = `<!-- From @lernapps/app-templates ${commit.slice(0, 7)} (the generic part and the ${archetype} extension): ${REFERENCE} except the app sections, between <!-- app: begin … --> and <!-- app: end … -->, which hold the app's own architecture. -->`;
|
|
1338
|
+
lines.splice(1, 0, "", mark, ...top.length > 0 ? ["", ...top] : []);
|
|
1339
|
+
chapters.set(name, `${rewritePaths(lines, archetype).join("\n")}\n`);
|
|
1340
|
+
}
|
|
1341
|
+
return chapters;
|
|
1342
|
+
}
|
|
1343
|
+
/** The reference files of the app's architecture: those that carry REFERENCE. */
|
|
1344
|
+
function referenceFiles(dir) {
|
|
1345
|
+
const folder = join(dir, ARCHITECTURE);
|
|
1346
|
+
if (!existsSync(folder)) return [];
|
|
1347
|
+
return readdirSync(folder).filter((name) => name.endsWith(".arc42.md")).filter((name) => linesOf(join(folder, name)).slice(0, 4).join("\n").includes(REFERENCE)).sort();
|
|
1348
|
+
}
|
|
1349
|
+
/** Whether the app has the architecture from lernapps create or update, which its checks then validate. */
|
|
1350
|
+
const hasArchitecture = (dir = ".") => referenceFiles(dir).length > 0;
|
|
1351
|
+
/** The archetype whose extension the app's reference holds, as its mark names it; undefined without a reference. */
|
|
1352
|
+
function architectureArchetype(dir = ".") {
|
|
1353
|
+
const [first] = referenceFiles(dir);
|
|
1354
|
+
if (first === void 0) return void 0;
|
|
1355
|
+
return /the generic part and the ([a-z-]+) extension/.exec(readFileSync(join(dir, ARCHITECTURE, first), "utf8"))?.[1];
|
|
1356
|
+
}
|
|
1357
|
+
/** The app sections of a file's lines, by name ("" for the unnamed one): the lines between their markers. */
|
|
1358
|
+
function appSections(lines) {
|
|
1359
|
+
const sections = /* @__PURE__ */ new Map();
|
|
1360
|
+
let open;
|
|
1361
|
+
for (const line of lines) {
|
|
1362
|
+
const marker = APP_MARKER.exec(line);
|
|
1363
|
+
if (marker?.[1] === "begin") {
|
|
1364
|
+
open = marker[2] ?? "";
|
|
1365
|
+
sections.set(open, []);
|
|
1366
|
+
} else if (marker?.[1] === "end") open = void 0;
|
|
1367
|
+
else if (open !== void 0) sections.get(open)?.push(line);
|
|
1368
|
+
}
|
|
1369
|
+
return sections;
|
|
1370
|
+
}
|
|
1371
|
+
/** The lines with each app section's content replaced by that of `sections`, where it has one. */
|
|
1372
|
+
function fillSections(lines, sections) {
|
|
1373
|
+
const out = [];
|
|
1374
|
+
let open;
|
|
1375
|
+
for (const line of lines) {
|
|
1376
|
+
const marker = APP_MARKER.exec(line);
|
|
1377
|
+
if (marker?.[1] === "begin") {
|
|
1378
|
+
open = marker[2] ?? "";
|
|
1379
|
+
out.push(line, ...sections.get(open) ?? []);
|
|
1380
|
+
} else if (marker?.[1] === "end") {
|
|
1381
|
+
open = void 0;
|
|
1382
|
+
out.push(line);
|
|
1383
|
+
} else if (open === void 0) out.push(line);
|
|
1384
|
+
}
|
|
1385
|
+
return out;
|
|
1386
|
+
}
|
|
1387
|
+
const sectionName = (file, name) => `${file}${name === "" ? " (at its end)" : ` (${name})`}`;
|
|
1388
|
+
/**
|
|
1389
|
+
* Replaces the app's reference files with the combination of a checkout of app-templates, keeping the content of each
|
|
1390
|
+
* app section. It writes nothing when an app section with content has no place in the new reference (it would be
|
|
1391
|
+
* lost) and says so (ArchitectureError). What it did, as lines of the output.
|
|
1392
|
+
*/
|
|
1393
|
+
function writeArchitecture(dir, templates, archetype, commit) {
|
|
1394
|
+
const folder = join(dir, ARCHITECTURE);
|
|
1395
|
+
const before = new Map(referenceFiles(dir).map((name) => [name, readFileSync(join(folder, name), "utf8")]));
|
|
1396
|
+
const kept = new Map([...before].map(([name, text]) => [name, appSections(text.replace(/\n$/, "").split("\n"))]));
|
|
1397
|
+
const chapters = new Map([...combine(templates, archetype, commit)].map(([name, text]) => {
|
|
1398
|
+
const lines = text.replace(/\n$/, "").split("\n");
|
|
1399
|
+
return [name, {
|
|
1400
|
+
lines,
|
|
1401
|
+
sections: appSections(lines)
|
|
1402
|
+
}];
|
|
1403
|
+
}));
|
|
1404
|
+
const lost = [...kept].flatMap(([name, sections]) => [...sections].filter(([section, lines]) => lines.some((line) => line.trim() !== "") && !chapters.get(name)?.sections.has(section)).map(([section]) => sectionName(name, section)));
|
|
1405
|
+
if (lost.length > 0) throw new ArchitectureError(`the reference of app-templates ${commit.slice(0, 7)} has no place for the app sections ${lost.join(", ")}: nothing was replaced. Move their content into an app section the new reference has (compare with ${archetype}/${ARCHITECTURE}/ of app-templates at that commit), then run it again`);
|
|
1406
|
+
for (const name of before.keys()) rmSync(join(folder, name));
|
|
1407
|
+
mkdirSync(folder, { recursive: true });
|
|
1408
|
+
let changed = 0;
|
|
1409
|
+
for (const [name, { lines }] of chapters) {
|
|
1410
|
+
const text = `${fillSections(lines, kept.get(name) ?? /* @__PURE__ */ new Map()).join("\n")}\n`;
|
|
1411
|
+
writeFileSync(join(folder, name), text);
|
|
1412
|
+
if (before.get(name) !== text) changed++;
|
|
1413
|
+
}
|
|
1414
|
+
const removed = [...before.keys()].filter((name) => !chapters.has(name)).length;
|
|
1415
|
+
return changed + removed === 0 ? [] : [`architecture: ${ARCHITECTURE}/ has the reference of app-templates ${commit.slice(0, 7)} (${chapters.size} chapters, the generic part and the ${archetype} extension), with the app's own sections as they were`];
|
|
1416
|
+
}
|
|
1417
|
+
// generator/create.ts
|
|
1418
|
+
/** The options of `lernapps create`, as node:util parseArgs takes them. */
|
|
1419
|
+
const CREATE_OPTIONS = {
|
|
1420
|
+
archetype: { type: "string" },
|
|
1421
|
+
plan: { type: "string" },
|
|
1422
|
+
templates: { type: "string" },
|
|
1423
|
+
tooling: { type: "string" },
|
|
1424
|
+
help: {
|
|
1425
|
+
type: "boolean",
|
|
1426
|
+
short: "h",
|
|
1427
|
+
default: false
|
|
1428
|
+
}
|
|
1429
|
+
};
|
|
1430
|
+
const CREATE_USAGE = `Usage: lernapps create --archetype <name> [<dir>] [--plan <file>] [--templates <repo>] [--tooling <spec>]
|
|
1431
|
+
|
|
1432
|
+
Writes a new app from the archetype's template into <dir> (default: the current folder), which must be empty
|
|
1433
|
+
except for .git, the first version's plan .vibe/plans/01-<short name>.md and the skills that npx skills add
|
|
1434
|
+
lernapps init synced (.agents/skills/, .claude/skills/, skills-lock.json, and the package.json, package-lock.json and
|
|
1435
|
+
node_modules init wrote). The template is the folder <name>/ of the installed package @lernapps/app-templates, the
|
|
1436
|
+
version @lernapps/tooling depends on; the app depends on the runtime of the same version.
|
|
1437
|
+
|
|
1438
|
+
It needs the plan of the first version, confirmed by the creator: started from the plan template as
|
|
1439
|
+
.vibe/plans/01-<short name>.md, the only plan there, worked through Explore and Plan, now in the phase code
|
|
1440
|
+
(phase: code in its front matter), with the same archetype as --archetype. A plan .vibe/plan.md of an earlier
|
|
1441
|
+
tooling is moved to .vibe/plans/01-first-version.md. Without a plan, it writes nothing and says what to do.
|
|
1442
|
+
|
|
1443
|
+
Run it in the folder lernapps init prepared, from the tooling init installed:
|
|
1444
|
+
npx lernapps create --archetype <name> [<dir>]
|
|
1445
|
+
It needs git, and the npm registry for the npm install that follows. The app then depends on the version of
|
|
1446
|
+
@lernapps/tooling that created it, and its workflows use the actions of that version's tag.
|
|
1447
|
+
|
|
1448
|
+
It writes, besides the template: package.json named after the folder (its prepare script, lernapps hooks,
|
|
1449
|
+
installs the git hooks; for an explainer and an interactive app also vue-tsc and typescript, with which lernapps
|
|
1450
|
+
check type-checks its Vue components; for an interactive app also @doctc/arc42, which checks its architecture),
|
|
1451
|
+
for an interactive app its architecture docs/arc42/ (the generic part and the interactive extension of app-templates,
|
|
1452
|
+
which lernapps update replaces except the marked app sections, where the app's own part goes),
|
|
1453
|
+
AGENTS.md, the plan in .vibe/plans/ (the one already there is kept), the content guide
|
|
1454
|
+
.agents/skills/app-content/SKILL.md (a skeleton the assistant fills in; linked as .claude/skills/app-content for
|
|
1455
|
+
Claude Code where the system allows symbolic links), the workflow
|
|
1456
|
+
.github/workflows/pages.yml (the app check action on every pull request and push to main; on main, then the app
|
|
1457
|
+
publish action to the repo's GitHub Pages), an issue form for content errors, LICENSE (MIT), renovate.json (the
|
|
1458
|
+
Renovate preset of lernapps). The installed skills and skills-lock.json are committed with the app, like the
|
|
1459
|
+
content guide; lernapps update replaces the lernapps-* skills.
|
|
1460
|
+
|
|
1461
|
+
Then: git init (if <dir> is not a repository yet), npm install (installs the git hooks), and the first commit.
|
|
1462
|
+
|
|
1463
|
+
Exit codes: 0 when the app is written, 1 when it cannot be (the message says why), 2 on a usage error.
|
|
1464
|
+
|
|
1465
|
+
Options:
|
|
1466
|
+
--archetype <name> the archetype chosen in the plan: {archetypes}
|
|
1467
|
+
--plan <file> the confirmed plan to carry over to .vibe/plans/01-<short name>.md, the short name from
|
|
1468
|
+
its file name <NN>-<short name>.md, else 01-plan.md (default: the one in <dir>)
|
|
1469
|
+
--templates <dir> the package @lernapps/app-templates to copy from: its folder, or a clone of its repository
|
|
1470
|
+
(default: the environment variable LERNAPPS_TEMPLATES, else the installed package)
|
|
1471
|
+
--tooling <spec> the npm spec of @lernapps/tooling the app depends on (default: ^ the version of this
|
|
1472
|
+
@lernapps/tooling)
|
|
1473
|
+
-h, --help print this help
|
|
1474
|
+
`;
|
|
1475
|
+
/** The generator cannot write the app; the message says why and what to do. */
|
|
1476
|
+
var CreateError = class extends Error {};
|
|
1477
|
+
const packageRoot = fileURLToPath(new URL("..", import.meta.url));
|
|
1478
|
+
const fromPackage = (...path) => join(packageRoot, ...path);
|
|
1479
|
+
const readJson = (file) => JSON.parse(readFileSync(file, "utf8"));
|
|
1480
|
+
const writeJson = (file, value) => writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
1481
|
+
/** The runtime package, which also carries the templates. */
|
|
1482
|
+
const RUNTIME = "@lernapps/app-templates";
|
|
1483
|
+
/**
|
|
1484
|
+
* The package @lernapps/app-templates: the folder named (a local copy or clone), else the one installed next to this
|
|
1485
|
+
* package, the version it depends on.
|
|
1486
|
+
*/
|
|
1487
|
+
function templatesPackage(dir) {
|
|
1488
|
+
let root;
|
|
1489
|
+
if (dir !== void 0) root = resolve(dir);
|
|
1490
|
+
else try {
|
|
1491
|
+
root = dirname(createRequire(import.meta.url).resolve(`${RUNTIME}/package.json`));
|
|
1492
|
+
} catch {
|
|
1493
|
+
throw new CreateError(`the package ${RUNTIME} is not installed: it is a dependency of @lernapps/tooling, so run npm install, or name a copy with --templates <dir> or LERNAPPS_TEMPLATES`);
|
|
1494
|
+
}
|
|
1495
|
+
if (!existsSync(join(root, "package.json"))) throw new CreateError(`${root} is no package ${RUNTIME}: it has no package.json`);
|
|
1496
|
+
const manifest = readJson(join(root, "package.json"));
|
|
1497
|
+
return {
|
|
1498
|
+
root,
|
|
1499
|
+
version: manifest.version ?? "0.0.0",
|
|
1500
|
+
archetypes: [...manifest.lernapps?.archetypes ?? []].sort()
|
|
1501
|
+
};
|
|
1502
|
+
}
|
|
1503
|
+
/** The version of this package. */
|
|
1504
|
+
const ownVersion = () => readJson(fromPackage("package.json")).version ?? "0.0.0";
|
|
1505
|
+
/** The lock file of `npx skills add`, next to the skills it installed. */
|
|
1506
|
+
const SKILLS_LOCK = "skills-lock.json";
|
|
1507
|
+
/** The skills that `npx skills add` installed into the folder: the names in its lock file. */
|
|
1508
|
+
function installedSkills(dir) {
|
|
1509
|
+
try {
|
|
1510
|
+
const lock = JSON.parse(readFileSync(join(dir, SKILLS_LOCK), "utf8"));
|
|
1511
|
+
return new Set(Object.keys(lock.skills ?? {}));
|
|
1512
|
+
} catch {
|
|
1513
|
+
return /* @__PURE__ */ new Set();
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1517
|
+
* What is in the folder besides .git, the plans (src/plan.ts) and the skills as `npx skills add lernapps/tooling`
|
|
1518
|
+
* installs them: each skill of its lock file in .agents/skills/<skill>/ and, for Claude Code, .claude/skills/<skill>
|
|
1519
|
+
* (a link to it, or a copy with --copy).
|
|
1520
|
+
*/
|
|
1521
|
+
function contents(dir) {
|
|
1522
|
+
if (!existsSync(dir)) return [];
|
|
1523
|
+
if (!statSync(dir).isDirectory()) throw new CreateError(`${dir} is a file, not a folder`);
|
|
1524
|
+
const skills = installedSkills(dir);
|
|
1525
|
+
const within = (folder, keep) => readdirSync(join(dir, folder)).filter((name) => !keep(name)).map((name) => `${folder}/${name}`);
|
|
1526
|
+
return readdirSync(dir).flatMap((name) => {
|
|
1527
|
+
if (name === ".git" || name === "skills-lock.json" && skills.size > 0) return [];
|
|
1528
|
+
if (name === "node_modules" || name === "package-lock.json") return [];
|
|
1529
|
+
if (name === "package.json" && readJson(join(dir, name)).lernapps?.init === true) return [];
|
|
1530
|
+
if (name === ".vibe") return within(name, (file) => file === "plan.md" || file === "plans").concat(existsSync(join(dir, ".vibe/plans")) ? within(PLANS, (file) => PLAN_NAME.test(file)) : []);
|
|
1531
|
+
if ((name === ".agents" || name === ".claude") && skills.size > 0) return within(name, (folder) => folder === "skills").concat(existsSync(join(dir, name, "skills")) ? within(`${name}/skills`, (skill) => skills.has(skill)) : []);
|
|
1532
|
+
return [name];
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
function git(args, cwd) {
|
|
1536
|
+
const result = spawnSync("git", args, {
|
|
1537
|
+
cwd,
|
|
1538
|
+
encoding: "utf8"
|
|
1539
|
+
});
|
|
1540
|
+
if (result.error) throw new CreateError(`git cannot run: ${result.error.message}. Install git, then create again`);
|
|
1541
|
+
return {
|
|
1542
|
+
ok: result.status === 0,
|
|
1543
|
+
out: `${result.stdout}${result.stderr}`.trim()
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
/** A package name from the folder's name: lower case, letters, digits and hyphens. */
|
|
1547
|
+
const packageName = (dir) => basename(dir).toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "app";
|
|
1548
|
+
/** This package, as an app depends on it. */
|
|
1549
|
+
const TOOLING = "@lernapps/tooling";
|
|
1550
|
+
/**
|
|
1551
|
+
* What the apps of an archetype need beyond its template, as development dependencies: an explainer's Vue components
|
|
1552
|
+
* (src/components/<Name>.vue) are type-checked by vue-tsc, which `lernapps check` runs once the app has a .vue file.
|
|
1553
|
+
* The same versions as in app-templates; Renovate moves them in the app.
|
|
1554
|
+
*/
|
|
1555
|
+
const ARCHETYPE_DEV_DEPENDENCIES = {
|
|
1556
|
+
explainer: {
|
|
1557
|
+
typescript: "6.0.3",
|
|
1558
|
+
"vue-tsc": "3.3.11"
|
|
1559
|
+
},
|
|
1560
|
+
interactive: {
|
|
1561
|
+
"@doctc/arc42": "0.38.0",
|
|
1562
|
+
typescript: "6.0.3",
|
|
1563
|
+
"vue-tsc": "3.3.11"
|
|
1564
|
+
}
|
|
1565
|
+
};
|
|
1566
|
+
const START_THE_PLAN = "start the plan of the first version from the plan template with `npx --yes @lernapps/tooling init <short name>` in an empty folder, which writes it complete and unchanged as .vibe/plans/01-<short name>.md, and work through Explore and Plan with the creator first";
|
|
1567
|
+
/**
|
|
1568
|
+
* The first version's plan: the one given (carried over to .vibe/plans/01-<short name>.md), else the only one in
|
|
1569
|
+
* the folder's .vibe/plans/, numbered 01, else a .vibe/plan.md of an earlier tooling (moved to
|
|
1570
|
+
* .vibe/plans/01-first-version.md, as lernapps update moves it).
|
|
1571
|
+
*/
|
|
1572
|
+
function firstPlan(dir, given) {
|
|
1573
|
+
const found = plans(dir).map((plan) => join(dir, plan));
|
|
1574
|
+
const legacy = existsSync(join(dir, ".vibe/plan.md")) ? [join(dir, LEGACY_PLAN)] : [];
|
|
1575
|
+
if (given !== void 0) {
|
|
1576
|
+
if (!existsSync(given)) throw new CreateError(`there is no plan ${given}: ${START_THE_PLAN}`);
|
|
1577
|
+
const others = [...found, ...legacy].filter((plan) => resolve(plan) !== resolve(given));
|
|
1578
|
+
if (others.length > 0) throw new CreateError(`${others.join(", ")} is already there besides the plan ${given} (--plan): a new app has one plan, the first version's; remove the one that is not confirmed`);
|
|
1579
|
+
const name = /^\d{2}-([^/\\]+\.md)$/.exec(basename(given))?.[1] ?? "plan.md";
|
|
1580
|
+
return {
|
|
1581
|
+
file: given,
|
|
1582
|
+
target: join(dir, PLANS, `01-${name}`)
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
1585
|
+
if (found.length + legacy.length > 1) throw new CreateError(`there is more than one plan (${[...found, ...legacy].join(", ")}): a new app has one plan, the first version's, .vibe/plans/01-<short name>.md; keep the one the creator confirmed, or name it with --plan <file>`);
|
|
1586
|
+
const [plan] = found;
|
|
1587
|
+
if (plan !== void 0) {
|
|
1588
|
+
if (!basename(plan).startsWith("01-")) throw new CreateError(`the plan ${plan} is not numbered 01: the first version's plan is .vibe/plans/01-<short name>.md, from the plan template; rename it`);
|
|
1589
|
+
return {
|
|
1590
|
+
file: plan,
|
|
1591
|
+
target: plan
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
const [old] = legacy;
|
|
1595
|
+
if (old !== void 0) return {
|
|
1596
|
+
file: old,
|
|
1597
|
+
target: join(dir, MIGRATED_PLAN)
|
|
1598
|
+
};
|
|
1599
|
+
throw new CreateError(`there is no plan in ${join(dir, PLANS)}: ${START_THE_PLAN}`);
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1602
|
+
* The plan the app is created from, which the creator has confirmed: it is in the phase code (or later), for this
|
|
1603
|
+
* archetype. The front matter is read as the hooks read it (src/plan.ts).
|
|
1604
|
+
*/
|
|
1605
|
+
function confirmedPlan(dir, archetype, given) {
|
|
1606
|
+
const found = firstPlan(dir, given);
|
|
1607
|
+
const plan = found.file;
|
|
1608
|
+
const values = planFrontMatter(plan);
|
|
1609
|
+
const phase = values["phase"];
|
|
1610
|
+
if (phase === "explore" || phase === "plan") throw new CreateError(`the plan ${plan} is in the phase ${phase}: the creator must confirm the plan first. Work through Explore and Plan with the creator; the Plan phase ends with phase: code, then create the app`);
|
|
1611
|
+
if (phase !== "code" && phase !== "commit") throw new CreateError(`the plan ${plan} has no phase code in its front matter: ${START_THE_PLAN}`);
|
|
1612
|
+
if (values["archetype"] !== archetype) throw new CreateError(`the plan ${plan} chose the archetype ${String(values["archetype"])}, not ${archetype} (--archetype): create the app with the archetype of the plan, or change the plan with the creator first`);
|
|
1613
|
+
return found;
|
|
1614
|
+
}
|
|
1615
|
+
/** The plan at its place in .vibe/plans/: the one in the folder stays, a legacy one moves, one given is copied. */
|
|
1616
|
+
function writePlan(dir, plan) {
|
|
1617
|
+
if (resolve(plan.file) === resolve(plan.target)) return;
|
|
1618
|
+
mkdirSync(dirname(plan.target), { recursive: true });
|
|
1619
|
+
if (resolve(plan.file) === resolve(join(dir, ".vibe/plan.md"))) renameSync(plan.file, plan.target);
|
|
1620
|
+
else writeFileSync(plan.target, readFileSync(plan.file));
|
|
1621
|
+
}
|
|
1622
|
+
/**
|
|
1623
|
+
* The lines an earlier generator appended to .gitignore, to ignore the skills `npx skills add lernapps/tooling`
|
|
1624
|
+
* installed. Apps now commit them (every checkout has the skills of the tooling the app is on); `lernapps update`
|
|
1625
|
+
* removes these lines from an app that has them.
|
|
1626
|
+
*/
|
|
1627
|
+
const IGNORED_SKILLS_LINES = [
|
|
1628
|
+
"# the skills npx skills add lernapps/tooling installed: each checkout installs them; the content guide is the app's",
|
|
1629
|
+
`/${SKILLS_LOCK}`,
|
|
1630
|
+
"/.agents/skills/*",
|
|
1631
|
+
"!/.agents/skills/app-content/",
|
|
1632
|
+
"/.claude/skills/*",
|
|
1633
|
+
"!/.claude/skills/app-content"
|
|
1634
|
+
];
|
|
1635
|
+
/** The files every app has (generator/app/), with their placeholders filled. */
|
|
1636
|
+
function writeAppFiles(dir, values) {
|
|
1637
|
+
const source = fromPackage("generator", "app");
|
|
1638
|
+
const walk = (folder) => readdirSync(folder, { withFileTypes: true }).flatMap((entry) => entry.isDirectory() ? walk(join(folder, entry.name)) : [join(folder, entry.name)]);
|
|
1639
|
+
for (const file of walk(source)) {
|
|
1640
|
+
const target = join(dir, relative(source, file));
|
|
1641
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
1642
|
+
const text = readFileSync(file, "utf8").replace(/\{\{(\w+)\}\}/g, (match, key) => values[key] ?? match);
|
|
1643
|
+
writeFileSync(target, text);
|
|
1644
|
+
}
|
|
1645
|
+
writeFileSync(join(dir, "AGENTS.md"), readFileSync(fromPackage("guidance", "AGENTS.md")));
|
|
1646
|
+
}
|
|
1647
|
+
/** The app's content guide, an app-local skill: written as a skeleton, filled in by the assistant. */
|
|
1648
|
+
const CONTENT_GUIDE = ".agents/skills/app-content/SKILL.md";
|
|
1649
|
+
/** Where Claude Code looks for the skills of a project: a relative symbolic link to the guide's folder. */
|
|
1650
|
+
const CONTENT_GUIDE_LINK = ".claude/skills/app-content";
|
|
1651
|
+
/**
|
|
1652
|
+
* Adds the content guide's skeleton and its link where they are missing; what it did, one line per file. The link is
|
|
1653
|
+
* relative, so it survives a move, a commit and a clone (git keeps it as a link). Where the system allows no symbolic
|
|
1654
|
+
* link (Windows without Developer Mode) it is left out: AGENTS.md names the guide's path, which every harness reads.
|
|
1655
|
+
*/
|
|
1656
|
+
function addContentGuide(dir) {
|
|
1657
|
+
const done = [];
|
|
1658
|
+
const guide = join(dir, CONTENT_GUIDE);
|
|
1659
|
+
if (!existsSync(guide)) {
|
|
1660
|
+
mkdirSync(dirname(guide), { recursive: true });
|
|
1661
|
+
writeFileSync(guide, readFileSync(fromPackage("generator", "app", CONTENT_GUIDE)));
|
|
1662
|
+
done.push(`added ${CONTENT_GUIDE}; fill it in from the plan, as its comments say`);
|
|
1663
|
+
}
|
|
1664
|
+
const link = join(dir, CONTENT_GUIDE_LINK);
|
|
1665
|
+
if (!existsSync(link)) try {
|
|
1666
|
+
rmSync(link, { force: true });
|
|
1667
|
+
mkdirSync(dirname(link), { recursive: true });
|
|
1668
|
+
symlinkSync("../../.agents/skills/app-content", link, "dir");
|
|
1669
|
+
done.push(`linked ${CONTENT_GUIDE_LINK} to it for Claude Code`);
|
|
1670
|
+
} catch (error) {
|
|
1671
|
+
done.push(`could not link ${CONTENT_GUIDE_LINK} (${error instanceof Error ? error.message : String(error)}); AGENTS.md names ${CONTENT_GUIDE}, which is enough`);
|
|
1672
|
+
}
|
|
1673
|
+
return done;
|
|
1674
|
+
}
|
|
1675
|
+
function create(args) {
|
|
1676
|
+
const { values, positionals } = parseArgs({
|
|
1677
|
+
args,
|
|
1678
|
+
options: CREATE_OPTIONS,
|
|
1679
|
+
strict: true,
|
|
1680
|
+
allowPositionals: true
|
|
1681
|
+
});
|
|
1682
|
+
const templates = templatesPackage(values.templates ?? process.env["LERNAPPS_TEMPLATES"]);
|
|
1683
|
+
const known = templates.archetypes;
|
|
1684
|
+
if (values.help) {
|
|
1685
|
+
process.stdout.write(CREATE_USAGE.replace("{archetypes}", known.join(", ")));
|
|
1686
|
+
return 0;
|
|
1687
|
+
}
|
|
1688
|
+
const archetype = values.archetype;
|
|
1689
|
+
if (archetype === void 0) throw new UsageError(`create needs --archetype <name> (${known.join(", ")})`);
|
|
1690
|
+
if (!known.includes(archetype)) throw new UsageError(`unknown archetype: ${archetype} (known: ${known.join(", ")})`);
|
|
1691
|
+
if (positionals.length > 1) throw new UsageError(`one <dir> at most, not ${positionals.join(" ")}`);
|
|
1692
|
+
const dir = resolve(positionals[0] ?? ".");
|
|
1693
|
+
const found = contents(dir);
|
|
1694
|
+
if (found.length > 0) throw new CreateError(`${dir} is not empty (${found.slice(0, 5).join(", ")}${found.length > 5 ? ", ..." : ""}): create the app in an empty folder; only .git, the first version's plan .vibe/plans/01-<short name>.md and the skills that npx skills add lernapps/tooling installed may be there`);
|
|
1695
|
+
const plan = confirmedPlan(dir, archetype, values.plan);
|
|
1696
|
+
const architecture = ARCHITECTURE_ARCHETYPES.includes(archetype);
|
|
1697
|
+
if (!existsSync(join(templates.root, archetype, "package.json"))) throw new CreateError(`${templates.root} has no template ${archetype}/ (version ${templates.version})`);
|
|
1698
|
+
const initialised = existsSync(join(dir, "package.json")) ? readJson(join(dir, "package.json")) : void 0;
|
|
1699
|
+
const initialLock = existsSync(join(dir, "package-lock.json")) ? readFileSync(join(dir, "package-lock.json"), "utf8") : void 0;
|
|
1700
|
+
mkdirSync(dir, { recursive: true });
|
|
1701
|
+
cpSync(join(templates.root, archetype), dir, { recursive: true });
|
|
1702
|
+
rmSync(join(dir, "package-lock.json"), { force: true });
|
|
1703
|
+
if (initialLock !== void 0) writeFileSync(join(dir, "package-lock.json"), initialLock);
|
|
1704
|
+
if (existsSync(join(dir, "_gitignore"))) renameSync(join(dir, "_gitignore"), join(dir, ".gitignore"));
|
|
1705
|
+
rmSync(join(dir, "README.md"), { force: true });
|
|
1706
|
+
rmSync(join(dir, ARCHITECTURE), {
|
|
1707
|
+
recursive: true,
|
|
1708
|
+
force: true
|
|
1709
|
+
});
|
|
1710
|
+
if (existsSync(join(dir, "docs")) && readdirSync(join(dir, "docs")).length === 0) rmSync(join(dir, "docs"), { recursive: true });
|
|
1711
|
+
if (architecture) writeArchitecture(dir, templates.root, archetype, templates.version);
|
|
1712
|
+
else rmSync(join(dir, ".arc42ignore"), { force: true });
|
|
1713
|
+
const app = readJson(join(dir, "package.json"));
|
|
1714
|
+
const name = packageName(dir);
|
|
1715
|
+
app.name = name;
|
|
1716
|
+
app.scripts = {
|
|
1717
|
+
...app.scripts,
|
|
1718
|
+
prepare: "lernapps hooks"
|
|
1719
|
+
};
|
|
1720
|
+
if (app.devDependencies?.["@lernapps/app-templates"] !== void 0) {
|
|
1721
|
+
app.devDependencies = {
|
|
1722
|
+
...app.devDependencies,
|
|
1723
|
+
[RUNTIME]: `^${templates.version}`
|
|
1724
|
+
};
|
|
1725
|
+
rmSync(join(dir, ".npmrc"), { force: true });
|
|
1726
|
+
}
|
|
1727
|
+
const tooling = values.tooling ?? `^${ownVersion()}`;
|
|
1728
|
+
app.devDependencies = {
|
|
1729
|
+
...app.devDependencies,
|
|
1730
|
+
...ARCHETYPE_DEV_DEPENDENCIES[archetype],
|
|
1731
|
+
...initialised?.devDependencies,
|
|
1732
|
+
[TOOLING]: tooling
|
|
1733
|
+
};
|
|
1734
|
+
app.devDependencies = Object.fromEntries(Object.entries(app.devDependencies).sort(([a], [b]) => a.localeCompare(b)));
|
|
1735
|
+
writeJson(join(dir, "package.json"), app);
|
|
1736
|
+
const holder = git(["config", "user.name"], existsSync(dir) ? dir : process.cwd());
|
|
1737
|
+
const moved = resolve(plan.file) === resolve(join(dir, LEGACY_PLAN));
|
|
1738
|
+
writePlan(dir, plan);
|
|
1739
|
+
writeAppFiles(dir, {
|
|
1740
|
+
tooling: `v${ownVersion()}`,
|
|
1741
|
+
year: String((/* @__PURE__ */ new Date()).getFullYear()),
|
|
1742
|
+
holder: holder.ok && holder.out !== "" ? holder.out : "the creator of this app"
|
|
1743
|
+
});
|
|
1744
|
+
const guide = addContentGuide(dir).filter((line) => line.startsWith("could not"));
|
|
1745
|
+
process.stdout.write([
|
|
1746
|
+
`lernapps create: the ${archetype} app ${name} in ${dir} (app-templates ${templates.version})`,
|
|
1747
|
+
...guide.map((line) => `Content guide: ${line}`),
|
|
1748
|
+
...moved ? [`Plan: moved ${LEGACY_PLAN} to ${relative(dir, plan.target)}`] : [],
|
|
1749
|
+
`Next: ${existsSync(join(dir, ".git")) ? "" : "git init, "}npm install, then follow ${relative(dir, plan.target)}`,
|
|
1750
|
+
""
|
|
1751
|
+
].join("\n"));
|
|
1752
|
+
return 0;
|
|
1753
|
+
}
|
|
1754
|
+
// generator/init.ts
|
|
1755
|
+
/** The options of `lernapps init`, as node:util parseArgs takes them. */
|
|
1756
|
+
const INIT_OPTIONS = {
|
|
1757
|
+
addon: { type: "string" },
|
|
1758
|
+
help: {
|
|
1759
|
+
type: "boolean",
|
|
1760
|
+
short: "h",
|
|
1761
|
+
default: false
|
|
1762
|
+
}
|
|
1763
|
+
};
|
|
1764
|
+
const INIT_USAGE = `Usage: lernapps init <short name> [--addon <package>]
|
|
1765
|
+
|
|
1766
|
+
Starts an app in the current folder, which must be empty except for .git. It needs git, Node and the npm registry; nothing
|
|
1767
|
+
else. In order:
|
|
1768
|
+
package.json names the folder, depends on @lernapps/tooling (this version) and the add-on
|
|
1769
|
+
npm install installs them
|
|
1770
|
+
.vibe/plans/01-<short name>.md
|
|
1771
|
+
the plan template of the add-on, else of this tooling, copied complete and unchanged
|
|
1772
|
+
.agents/skills/, ... the skills of the installed packages, for every agent \`skills\` finds, except those of
|
|
1773
|
+
the archetypes: after the plan chose one, run \`lernapps skills --archetype <name>\`
|
|
1774
|
+
|
|
1775
|
+
Then follow the plan: it says what to do, and when to wait for the creator.
|
|
1776
|
+
|
|
1777
|
+
Options:
|
|
1778
|
+
--addon <package> a package that extends the tooling, e.g. @lernapps/practice: its plan template and its skills
|
|
1779
|
+
-h, --help print this help
|
|
1780
|
+
`;
|
|
1781
|
+
/** The options of `lernapps skills`. */
|
|
1782
|
+
const SKILLS_OPTIONS = {
|
|
1783
|
+
archetype: { type: "string" },
|
|
1784
|
+
help: {
|
|
1785
|
+
type: "boolean",
|
|
1786
|
+
short: "h",
|
|
1787
|
+
default: false
|
|
1788
|
+
}
|
|
1789
|
+
};
|
|
1790
|
+
const SKILLS_USAGE = `Usage: lernapps skills --archetype <name>
|
|
1791
|
+
|
|
1792
|
+
Adds the skills of the archetype the plan chose to the agent directories (.agents/skills/ and those of the agents
|
|
1793
|
+
\`skills\` finds), next to those lernapps init synced. The skills of the other archetypes stay out.
|
|
1794
|
+
|
|
1795
|
+
Options:
|
|
1796
|
+
--archetype <name> the archetype of the plan
|
|
1797
|
+
-h, --help print this help
|
|
1798
|
+
`;
|
|
1799
|
+
/** The version of the package `skills` that does the sync, as this tooling depends on it. */
|
|
1800
|
+
const SKILLS_BIN = () => {
|
|
1801
|
+
const manifest = createRequire(import.meta.url).resolve("skills/package.json");
|
|
1802
|
+
const bin = readJson(manifest)["bin"];
|
|
1803
|
+
return join(dirname(manifest), bin?.["skills"] ?? "bin/cli.mjs");
|
|
1804
|
+
};
|
|
1805
|
+
/** The names of the skills of the archetypes, with the archetype each belongs to (lernapps-<archetype>[-…]). */
|
|
1806
|
+
function archetypeSkills() {
|
|
1807
|
+
const dir = fromPackage("skills", "archetypes");
|
|
1808
|
+
if (!existsSync(dir)) return [];
|
|
1809
|
+
return readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => ({
|
|
1810
|
+
name: entry.name,
|
|
1811
|
+
archetype: /^lernapps-([a-z]+)(?:-|$)/.exec(entry.name)?.[1] ?? entry.name
|
|
1812
|
+
}));
|
|
1813
|
+
}
|
|
1814
|
+
/** The script of a command that is run; its failure is an error of `fail`. */
|
|
1815
|
+
function run(command, args, fail, cwd = ".") {
|
|
1816
|
+
const result = spawnSync(command, args, {
|
|
1817
|
+
cwd,
|
|
1818
|
+
stdio: [
|
|
1819
|
+
"ignore",
|
|
1820
|
+
2,
|
|
1821
|
+
2
|
|
1822
|
+
]
|
|
1823
|
+
});
|
|
1824
|
+
if (result.error) throw fail(`${command} cannot run: ${result.error.message}`);
|
|
1825
|
+
if (result.status !== 0) throw fail(`${command} ${args.join(" ")} failed (exit ${result.status}); its output is above`);
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* `skills experimental_sync` in `dir`, without the skills of the archetypes in `exclude`. The skills are copied, not
|
|
1829
|
+
* linked into node_modules: the app commits them, and a fresh clone has them before its first npm install.
|
|
1830
|
+
*/
|
|
1831
|
+
function syncSkills(dir, exclude) {
|
|
1832
|
+
const filters = exclude.flatMap((name) => [`${TOOLING}#${name}`]);
|
|
1833
|
+
run(process.execPath, [
|
|
1834
|
+
SKILLS_BIN(),
|
|
1835
|
+
"experimental_sync",
|
|
1836
|
+
"-y",
|
|
1837
|
+
"--no-remote",
|
|
1838
|
+
"--copy",
|
|
1839
|
+
...filters.length > 0 ? ["--exclude", ...filters] : []
|
|
1840
|
+
], (message) => new CreateError(message), dir);
|
|
1841
|
+
}
|
|
1842
|
+
/** A package name from the folder's name: lower case, letters, digits and hyphens. */
|
|
1843
|
+
const slug = (dir) => basename(dir).toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "app";
|
|
1844
|
+
function init(args) {
|
|
1845
|
+
const { values, positionals } = parseArgs({
|
|
1846
|
+
args,
|
|
1847
|
+
options: INIT_OPTIONS,
|
|
1848
|
+
strict: true,
|
|
1849
|
+
allowPositionals: true
|
|
1850
|
+
});
|
|
1851
|
+
if (values.help) {
|
|
1852
|
+
process.stdout.write(INIT_USAGE);
|
|
1853
|
+
return 0;
|
|
1854
|
+
}
|
|
1855
|
+
if (positionals.length !== 1 || !/^[a-z0-9][a-z0-9-]*$/.test(positionals[0] ?? "")) throw new UsageError("init needs one <short name> for the plan: lower case letters, digits and hyphens");
|
|
1856
|
+
const name = positionals[0] ?? "plan";
|
|
1857
|
+
const dir = resolve(".");
|
|
1858
|
+
const present = readdirSync(dir).filter((entry) => entry !== ".git");
|
|
1859
|
+
if (present.length > 0) throw new CreateError(`${dir} is not empty (${present.slice(0, 5).join(", ")}${present.length > 5 ? ", ..." : ""}): start in a new, empty folder`);
|
|
1860
|
+
const addon = values.addon;
|
|
1861
|
+
writeJson(join(dir, "package.json"), {
|
|
1862
|
+
name: slug(dir),
|
|
1863
|
+
version: "0.0.0",
|
|
1864
|
+
private: true,
|
|
1865
|
+
lernapps: { init: true }
|
|
1866
|
+
});
|
|
1867
|
+
const fail = (message) => new CreateError(message);
|
|
1868
|
+
run("npm", [
|
|
1869
|
+
"install",
|
|
1870
|
+
"--save-dev",
|
|
1871
|
+
"--no-audit",
|
|
1872
|
+
"--no-fund",
|
|
1873
|
+
`${TOOLING}@${ownVersion()}`,
|
|
1874
|
+
...addon ? [addon] : []
|
|
1875
|
+
], fail);
|
|
1876
|
+
const source = (packageName, ...path) => join(dir, "node_modules", packageName, ...path);
|
|
1877
|
+
let template = source(TOOLING, "guidance", "plan-template.md");
|
|
1878
|
+
if (addon !== void 0) {
|
|
1879
|
+
const relative = readJson(source(addon, "package.json")).lernapps?.planTemplate;
|
|
1880
|
+
if (relative !== void 0) template = source(addon, relative);
|
|
1881
|
+
}
|
|
1882
|
+
const target = join(dir, PLANS, `01-${name}.md`);
|
|
1883
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
1884
|
+
writeFileSync(target, readFileSync(template));
|
|
1885
|
+
syncSkills(dir, archetypeSkills().map((skill) => skill.name));
|
|
1886
|
+
process.stdout.write(`lernapps init: ${relative$1(dir, target)} from ${relativeTemplate(template, dir)}; the skills of the packages are installed\nNext: read ${relative$1(dir, target)} completely and follow it\n`);
|
|
1887
|
+
return 0;
|
|
1888
|
+
}
|
|
1889
|
+
const relative$1 = (from, to) => to.startsWith(from) ? to.slice(from.length + 1) : to;
|
|
1890
|
+
const relativeTemplate = (template, dir) => relative$1(join(dir, "node_modules"), template);
|
|
1891
|
+
function skills(args) {
|
|
1892
|
+
const { values, positionals } = parseArgs({
|
|
1893
|
+
args,
|
|
1894
|
+
options: SKILLS_OPTIONS,
|
|
1895
|
+
strict: true,
|
|
1896
|
+
allowPositionals: true
|
|
1897
|
+
});
|
|
1898
|
+
if (values.help) {
|
|
1899
|
+
process.stdout.write(SKILLS_USAGE);
|
|
1900
|
+
return 0;
|
|
1901
|
+
}
|
|
1902
|
+
if (positionals.length > 0) throw new UsageError(`skills takes no arguments, not ${positionals.join(" ")}`);
|
|
1903
|
+
const known = [...new Set(archetypeSkills().map((skill) => skill.archetype))].sort();
|
|
1904
|
+
if (values.archetype === void 0) throw new UsageError(`skills needs --archetype <name> (${known.join(", ")})`);
|
|
1905
|
+
if (!known.includes(values.archetype)) throw new UsageError(`unknown archetype: ${values.archetype} (known: ${known.join(", ")})`);
|
|
1906
|
+
const others = archetypeSkills().filter((skill) => skill.archetype !== values.archetype).map((skill) => skill.name);
|
|
1907
|
+
syncSkills(resolve("."), others);
|
|
1908
|
+
process.stdout.write(`lernapps skills: the skills of the archetype ${values.archetype} are installed\n`);
|
|
1909
|
+
return 0;
|
|
1910
|
+
}
|
|
1911
|
+
// generator/update.ts
|
|
1912
|
+
/** The options of `lernapps update`, as node:util parseArgs takes them. */
|
|
1913
|
+
const UPDATE_OPTIONS = { help: {
|
|
1914
|
+
type: "boolean",
|
|
1915
|
+
short: "h",
|
|
1916
|
+
default: false
|
|
1917
|
+
} };
|
|
1918
|
+
const LATEST = "npx --yes @lernapps/tooling@latest update";
|
|
1919
|
+
const UPDATE_USAGE = `Usage: lernapps update
|
|
1920
|
+
|
|
1921
|
+
Moves the app in the current folder to the versions of this @lernapps/tooling, then runs npm install. npm update
|
|
1922
|
+
moves the app within its version ranges; this moves the ranges, and what npm does not know:
|
|
1923
|
+
@lernapps/app-templates to the version this tooling depends on: the runtime its tests ran with. A newer version
|
|
1924
|
+
(e.g. from Renovate) is kept. A git commit, as apps once pinned, becomes a version
|
|
1925
|
+
@lernapps/tooling to the version of this tooling (unchanged when run from the app)
|
|
1926
|
+
.github/workflows/ the actions of lernapps/tooling at the tag of that version
|
|
1927
|
+
.agents/skills/app-content/SKILL.md
|
|
1928
|
+
the content guide's skeleton, where the app has none yet (and its link for Claude Code)
|
|
1929
|
+
.vibe/plan.md the plan of an app made before the plans had a folder: moved to
|
|
1930
|
+
.vibe/plans/01-first-version.md, the first of its plans (one per piece of work)
|
|
1931
|
+
.agents/skills/lernapps-*, .claude/skills/lernapps-*, skills-lock.json
|
|
1932
|
+
replaced with the skills of this tooling, as npx skills add lays them out: those of
|
|
1933
|
+
every phase, and of the archetypes only those of the current plan's archetype; every
|
|
1934
|
+
other lernapps-* skill is removed. The app's own skills (app-content, any other) stay. The lines of an earlier generator that had git ignore them are removed
|
|
1935
|
+
from .gitignore: the app commits its skills
|
|
1936
|
+
docs/arc42/ of an interactive app: replaced with the generic part and the interactive extension of
|
|
1937
|
+
app-templates at the version of the app's runtime, except the app sections (between
|
|
1938
|
+
<!-- app: begin … --> and <!-- app: end … -->), which stay as they are
|
|
1939
|
+
|
|
1940
|
+
Run it from the latest tooling, in the app's folder, e.g. to pick up a fixed runtime:
|
|
1941
|
+
${LATEST}
|
|
1942
|
+
Run as the app's own tooling (npx lernapps update), it knows only the versions the app already has.
|
|
1943
|
+
Then run npx lernapps check, and commit package.json, package-lock.json, .github/workflows/ and what else it names.
|
|
1944
|
+
|
|
1945
|
+
Exit codes: 0 when the app is updated (or was up to date), 1 when it cannot be or only partly (the message says
|
|
1946
|
+
what was not moved and why), 2 on a usage error.
|
|
1947
|
+
|
|
1948
|
+
Options:
|
|
1949
|
+
-h, --help print this help
|
|
1950
|
+
`;
|
|
1951
|
+
/** The app cannot be updated; the message says why and what to do. */
|
|
1952
|
+
var UpdateError = class extends Error {};
|
|
1953
|
+
/** The version a dependency range names at its lowest: `^1.2.3`, `~1.2.3`, `1.2.3`; undefined for a git spec or a tag. */
|
|
1954
|
+
function lowest(range) {
|
|
1955
|
+
const match = /^[\^~>=\s]*(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(range);
|
|
1956
|
+
return match ? [
|
|
1957
|
+
Number(match[1]),
|
|
1958
|
+
Number(match[2]),
|
|
1959
|
+
Number(match[3])
|
|
1960
|
+
] : void 0;
|
|
1961
|
+
}
|
|
1962
|
+
/** Whether `a` is older than `b`. */
|
|
1963
|
+
const older = (a, b) => a[0] !== b[0] ? a[0] < b[0] : a[1] !== b[1] ? a[1] < b[1] : a[2] < b[2];
|
|
1964
|
+
/** The next range of a dependency at `version`, and what the output says about it. */
|
|
1965
|
+
function nextRange(name, current, version) {
|
|
1966
|
+
const target = `^${version}`;
|
|
1967
|
+
if (current !== void 0 && /^(file:|link:|\.{0,2}\/)/.test(current)) return {
|
|
1968
|
+
spec: current,
|
|
1969
|
+
line: `${name}: kept ${current}, a local copy`
|
|
1970
|
+
};
|
|
1971
|
+
const now = current === void 0 ? void 0 : lowest(current);
|
|
1972
|
+
const next = lowest(version);
|
|
1973
|
+
if (current === target) return {
|
|
1974
|
+
spec: current,
|
|
1975
|
+
line: `${name}: unchanged (${current})`
|
|
1976
|
+
};
|
|
1977
|
+
if (now !== void 0 && next !== void 0 && !older(now, next)) return {
|
|
1978
|
+
spec: current ?? target,
|
|
1979
|
+
line: `${name}: kept ${current}, not moved to ${target}: it is not older`
|
|
1980
|
+
};
|
|
1981
|
+
return {
|
|
1982
|
+
spec: target,
|
|
1983
|
+
line: `${name}: ${current ?? "none"} → ${target}`
|
|
1984
|
+
};
|
|
1985
|
+
}
|
|
1986
|
+
/** The workflows' `uses: lernapps/tooling/actions/<name>@<ref>`, set to `ref` (a commit or a tag); the files it changed. */
|
|
1987
|
+
function moveActions(ref) {
|
|
1988
|
+
const changed = [];
|
|
1989
|
+
for (const file of workflows()) {
|
|
1990
|
+
const text = readFileSync(file, "utf8");
|
|
1991
|
+
const next = text.replace(/(lernapps\/tooling\/actions\/[\w-]+@)(?:[0-9a-f]{40}|v\d+\.\d+\.\d+)\b/g, `$1${ref}`);
|
|
1992
|
+
if (next !== text) {
|
|
1993
|
+
writeFileSync(file, next);
|
|
1994
|
+
changed.push(file);
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
return changed;
|
|
1998
|
+
}
|
|
1999
|
+
/**
|
|
2000
|
+
* Moves the plan of an app made before the plans had a folder, .vibe/plan.md, to .vibe/plans/01-first-version.md,
|
|
2001
|
+
* where there is no plan 01 yet; what it did, as lines of the output. A move only: git sees the rename when the
|
|
2002
|
+
* change is committed.
|
|
2003
|
+
*/
|
|
2004
|
+
function migratePlan() {
|
|
2005
|
+
if (!existsSync(".vibe/plan.md")) return [];
|
|
2006
|
+
if (plans().some((plan) => /\/01-[^/]+$/.test(plan))) return [`plan: kept ${LEGACY_PLAN}: .vibe/plans/ already has a plan 01. Compare the two and keep one as the plan 01`];
|
|
2007
|
+
mkdirSync(dirname(MIGRATED_PLAN), { recursive: true });
|
|
2008
|
+
renameSync(LEGACY_PLAN, MIGRATED_PLAN);
|
|
2009
|
+
return [`plan: moved ${LEGACY_PLAN} to ${MIGRATED_PLAN}, unchanged: each piece of work has its own plan in .vibe/plans/`];
|
|
2010
|
+
}
|
|
2011
|
+
/**
|
|
2012
|
+
* The skills of this tooling, as it ships them (skills/<group>/<name>/), and as the app keeps them (flat, by name).
|
|
2013
|
+
* Every app has those of the groups process, tooling and content; of the group archetypes only those of its own
|
|
2014
|
+
* archetype (lernapps-<archetype> and lernapps-<archetype>-<part>, e.g. lernapps-explainer-design), which the plan
|
|
2015
|
+
* installs in the phase that needs it.
|
|
2016
|
+
*/
|
|
2017
|
+
const SKILL_PREFIX = "lernapps-";
|
|
2018
|
+
const TOOLING_SOURCE = "lernapps/tooling";
|
|
2019
|
+
const ARCHETYPE_GROUP = "archetypes";
|
|
2020
|
+
const SKILLS = join(".agents", "skills");
|
|
2021
|
+
const SKILL_LINKS = join(".claude", "skills");
|
|
2022
|
+
/** The hash `npx skills` keeps in skills-lock.json (computedHash): every file of the folder, path and content. */
|
|
2023
|
+
function skillHash(dir) {
|
|
2024
|
+
const files = [];
|
|
2025
|
+
const walk = (folder) => {
|
|
2026
|
+
for (const entry of readdirSync(folder, { withFileTypes: true })) if (entry.isDirectory() && entry.name !== ".git" && entry.name !== "node_modules") walk(join(folder, entry.name));
|
|
2027
|
+
else if (entry.isFile()) files.push(relative(dir, join(folder, entry.name)).split("\\").join("/"));
|
|
2028
|
+
};
|
|
2029
|
+
walk(dir);
|
|
2030
|
+
const hash = createHash("sha256");
|
|
2031
|
+
for (const file of files.sort((a, b) => a.localeCompare(b))) {
|
|
2032
|
+
hash.update(file);
|
|
2033
|
+
hash.update(readFileSync(join(dir, file)));
|
|
2034
|
+
}
|
|
2035
|
+
return hash.digest("hex");
|
|
2036
|
+
}
|
|
2037
|
+
/** The lernapps-* skills below `source`: each folder with a SKILL.md, its group and its path relative to `source`. */
|
|
2038
|
+
function shippedSkills(source) {
|
|
2039
|
+
const found = [];
|
|
2040
|
+
for (const group of readdirSync(source, { withFileTypes: true })) {
|
|
2041
|
+
if (!group.isDirectory()) continue;
|
|
2042
|
+
for (const skill of readdirSync(join(source, group.name), { withFileTypes: true })) if (skill.isDirectory() && skill.name.startsWith(SKILL_PREFIX) && existsSync(join(source, group.name, skill.name, "SKILL.md"))) found.push({
|
|
2043
|
+
name: skill.name,
|
|
2044
|
+
group: group.name,
|
|
2045
|
+
path: `${group.name}/${skill.name}`
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
return found;
|
|
2049
|
+
}
|
|
2050
|
+
/**
|
|
2051
|
+
* Replaces the app's lernapps-* skills with those of this tooling, as `npx skills add lernapps/tooling` lays them out:
|
|
2052
|
+
* the folder in .agents/skills/, a relative link to it in .claude/skills/ (a copy where the system allows no link), and
|
|
2053
|
+
* its entry in skills-lock.json with the hash of the new files. Of the archetypes' skills only those of the archetype
|
|
2054
|
+
* the current plan names; without one, those the app has. Removes every other lernapps-* skill, unless its entry in
|
|
2055
|
+
* skills-lock.json names another source than this tooling (lernapps-practice-* come from lernapps/practice).
|
|
2056
|
+
* Never touches another skill. What it did, as lines of the output.
|
|
2057
|
+
*/
|
|
2058
|
+
function replaceSkills(dir = ".", source = fromPackage("skills")) {
|
|
2059
|
+
const skillsDir = join(dir, SKILLS);
|
|
2060
|
+
const linksDir = join(dir, SKILL_LINKS);
|
|
2061
|
+
const lockFile = join(dir, SKILLS_LOCK);
|
|
2062
|
+
let lock = {
|
|
2063
|
+
version: 1,
|
|
2064
|
+
skills: {}
|
|
2065
|
+
};
|
|
2066
|
+
try {
|
|
2067
|
+
lock = JSON.parse(readFileSync(lockFile, "utf8"));
|
|
2068
|
+
} catch {}
|
|
2069
|
+
const foreign = (name) => {
|
|
2070
|
+
const source = lock.skills?.[name]?.["source"];
|
|
2071
|
+
return source !== void 0 && source !== TOOLING_SOURCE;
|
|
2072
|
+
};
|
|
2073
|
+
const present = existsSync(skillsDir) ? readdirSync(skillsDir).filter((name) => name.startsWith(SKILL_PREFIX) && !foreign(name)) : [];
|
|
2074
|
+
const plan = currentPlan(dir);
|
|
2075
|
+
const archetype = plan === void 0 ? void 0 : planFrontMatter(join(dir, plan))["archetype"];
|
|
2076
|
+
const own = (name) => typeof archetype === "string" ? name === `${SKILL_PREFIX}${archetype}` || name.startsWith(`${SKILL_PREFIX}${archetype}-`) : present.includes(name);
|
|
2077
|
+
const shipped = shippedSkills(source).filter(({ name, group }) => group !== ARCHETYPE_GROUP || own(name));
|
|
2078
|
+
const folder = new Map(shipped.map(({ name, path }) => [name, path]));
|
|
2079
|
+
const ours = [...folder.keys()].sort();
|
|
2080
|
+
const entries = { ...lock.skills };
|
|
2081
|
+
const added = [];
|
|
2082
|
+
const changed = [];
|
|
2083
|
+
const removed = present.filter((name) => !ours.includes(name));
|
|
2084
|
+
for (const name of removed) {
|
|
2085
|
+
rmSync(join(skillsDir, name), {
|
|
2086
|
+
recursive: true,
|
|
2087
|
+
force: true
|
|
2088
|
+
});
|
|
2089
|
+
rmSync(join(linksDir, name), {
|
|
2090
|
+
recursive: true,
|
|
2091
|
+
force: true
|
|
2092
|
+
});
|
|
2093
|
+
delete entries[name];
|
|
2094
|
+
}
|
|
2095
|
+
for (const name of ours) {
|
|
2096
|
+
const target = join(skillsDir, name);
|
|
2097
|
+
const before = existsSync(target) ? skillHash(target) : void 0;
|
|
2098
|
+
rmSync(target, {
|
|
2099
|
+
recursive: true,
|
|
2100
|
+
force: true
|
|
2101
|
+
});
|
|
2102
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
2103
|
+
cpSync(join(source, folder.get(name) ?? name), target, { recursive: true });
|
|
2104
|
+
const hash = skillHash(target);
|
|
2105
|
+
if (before === void 0) added.push(name);
|
|
2106
|
+
else if (before !== hash) changed.push(name);
|
|
2107
|
+
const link = join(linksDir, name);
|
|
2108
|
+
const to = `../../.agents/skills/${name}`;
|
|
2109
|
+
let linked = false;
|
|
2110
|
+
try {
|
|
2111
|
+
linked = lstatSync(link).isSymbolicLink() && readlinkSync(link) === to;
|
|
2112
|
+
} catch {}
|
|
2113
|
+
if (!linked) {
|
|
2114
|
+
rmSync(link, {
|
|
2115
|
+
recursive: true,
|
|
2116
|
+
force: true
|
|
2117
|
+
});
|
|
2118
|
+
mkdirSync(linksDir, { recursive: true });
|
|
2119
|
+
try {
|
|
2120
|
+
symlinkSync(to, link, "dir");
|
|
2121
|
+
} catch {
|
|
2122
|
+
cpSync(target, link, { recursive: true });
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
entries[name] = {
|
|
2126
|
+
source: TOOLING_SOURCE,
|
|
2127
|
+
sourceType: "github",
|
|
2128
|
+
...entries[name],
|
|
2129
|
+
skillPath: `skills/${folder.get(name) ?? name}/SKILL.md`,
|
|
2130
|
+
computedHash: hash
|
|
2131
|
+
};
|
|
2132
|
+
}
|
|
2133
|
+
const sorted = Object.fromEntries(Object.keys(entries).sort().map((name) => [name, entries[name]]));
|
|
2134
|
+
const next = `${JSON.stringify({
|
|
2135
|
+
...lock,
|
|
2136
|
+
version: lock.version ?? 1,
|
|
2137
|
+
skills: sorted
|
|
2138
|
+
}, null, 2)}\n`;
|
|
2139
|
+
const lockChanged = !existsSync(lockFile) || readFileSync(lockFile, "utf8") !== next;
|
|
2140
|
+
if (lockChanged) writeFileSync(lockFile, next);
|
|
2141
|
+
const lines = [];
|
|
2142
|
+
const list = (names) => names.join(", ");
|
|
2143
|
+
if (added.length + changed.length + removed.length === 0) lines.push(lockChanged ? `skills: the ${SKILL_PREFIX}* skills are those of this tooling; their entries in ${SKILLS_LOCK} updated` : `skills: the ${SKILL_PREFIX}* skills are those of this tooling already (${ours.length})`);
|
|
2144
|
+
else {
|
|
2145
|
+
const parts = [
|
|
2146
|
+
...changed.length > 0 ? [`replaced ${list(changed)}`] : [],
|
|
2147
|
+
...added.length > 0 ? [`added ${list(added)}`] : [],
|
|
2148
|
+
...removed.length > 0 ? [`removed ${list(removed)}: not of this tooling, or of another archetype`] : []
|
|
2149
|
+
];
|
|
2150
|
+
lines.push(`skills: ${parts.join("; ")}: the ${SKILL_PREFIX}* skills of this tooling, in ${SKILLS}/ and ${SKILL_LINKS}/`);
|
|
2151
|
+
}
|
|
2152
|
+
return lines;
|
|
2153
|
+
}
|
|
2154
|
+
/**
|
|
2155
|
+
* Removes the lines an earlier generator appended to .gitignore so that git ignored the installed skills: the app
|
|
2156
|
+
* commits them now. What it did, as lines of the output.
|
|
2157
|
+
*/
|
|
2158
|
+
function unignoreSkills(dir = ".") {
|
|
2159
|
+
const ignore = join(dir, ".gitignore");
|
|
2160
|
+
if (!existsSync(ignore)) return [];
|
|
2161
|
+
const lines = readFileSync(ignore, "utf8").split("\n");
|
|
2162
|
+
const kept = lines.filter((line) => !IGNORED_SKILLS_LINES.includes(line));
|
|
2163
|
+
if (kept.length === lines.length) return [];
|
|
2164
|
+
writeFileSync(ignore, `${kept.join("\n").replace(/\n+$/, "")}\n`);
|
|
2165
|
+
return ["skills: removed the lines from .gitignore that had git ignore them: the app commits its skills"];
|
|
2166
|
+
}
|
|
2167
|
+
/**
|
|
2168
|
+
* Replaces the reference files of an interactive app's architecture with the generic part and the extension of
|
|
2169
|
+
* the installed app-templates; the app's own files stay. What it did, as lines of the output, and whether it failed.
|
|
2170
|
+
*/
|
|
2171
|
+
function updateArchitecture() {
|
|
2172
|
+
const archetype = architectureArchetype();
|
|
2173
|
+
if (archetype === void 0) return {
|
|
2174
|
+
lines: [],
|
|
2175
|
+
failed: false
|
|
2176
|
+
};
|
|
2177
|
+
try {
|
|
2178
|
+
const templates = templatesPackage();
|
|
2179
|
+
return {
|
|
2180
|
+
lines: writeArchitecture(".", templates.root, archetype, templates.version),
|
|
2181
|
+
failed: false
|
|
2182
|
+
};
|
|
2183
|
+
} catch (error) {
|
|
2184
|
+
if (!(error instanceof ArchitectureError) && !(error instanceof CreateError)) throw error;
|
|
2185
|
+
return {
|
|
2186
|
+
lines: [`architecture: not replaced: ${error.message}`],
|
|
2187
|
+
failed: true
|
|
2188
|
+
};
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
/** The app's workflow files. */
|
|
2192
|
+
function workflows() {
|
|
2193
|
+
const dir = join(".github", "workflows");
|
|
2194
|
+
if (!existsSync(dir)) return [];
|
|
2195
|
+
return readdirSync(dir).filter((file) => /\.ya?ml$/.test(file)).map((name) => join(dir, name));
|
|
2196
|
+
}
|
|
2197
|
+
function update(args) {
|
|
2198
|
+
const { values, positionals } = parseArgs({
|
|
2199
|
+
args,
|
|
2200
|
+
options: UPDATE_OPTIONS,
|
|
2201
|
+
strict: true,
|
|
2202
|
+
allowPositionals: true
|
|
2203
|
+
});
|
|
2204
|
+
if (values.help) {
|
|
2205
|
+
process.stdout.write(UPDATE_USAGE);
|
|
2206
|
+
return 0;
|
|
2207
|
+
}
|
|
2208
|
+
if (positionals.length > 0) throw new UsageError(`update takes no arguments, not ${positionals.join(" ")}`);
|
|
2209
|
+
if (!existsSync("package.json")) throw new UpdateError("no package.json here: run it in the app's folder");
|
|
2210
|
+
const devDependencies = readJson("package.json").devDependencies ?? {};
|
|
2211
|
+
const current = devDependencies[TOOLING];
|
|
2212
|
+
if (current === void 0) throw new UpdateError(`this is not an app from lernapps create: package.json has no devDependency ${TOOLING}`);
|
|
2213
|
+
const templates = templatesPackage();
|
|
2214
|
+
const lines = [];
|
|
2215
|
+
const specs = [];
|
|
2216
|
+
const notMoved = [];
|
|
2217
|
+
const runtime = devDependencies[RUNTIME];
|
|
2218
|
+
if (runtime !== void 0) {
|
|
2219
|
+
const next = nextRange(RUNTIME, runtime, templates.version);
|
|
2220
|
+
if (next.spec !== runtime) specs.push(`${RUNTIME}@${next.spec}`);
|
|
2221
|
+
lines.push(next.line);
|
|
2222
|
+
}
|
|
2223
|
+
const version = ownVersion();
|
|
2224
|
+
const nextTooling = nextRange(TOOLING, current, version);
|
|
2225
|
+
if (nextTooling.spec !== current) specs.push(`${TOOLING}@${nextTooling.spec}`);
|
|
2226
|
+
lines.push(nextTooling.line);
|
|
2227
|
+
if (nextTooling.spec === current && resolve(fromPackage()) === resolve("node_modules", "@lernapps/tooling")) lines.push(`this is the app's own tooling, which knows only its own version; for the latest run: ${LATEST}`);
|
|
2228
|
+
if (specs.length > 0) {
|
|
2229
|
+
const install = spawnSync("npm", [
|
|
2230
|
+
"install",
|
|
2231
|
+
"--no-audit",
|
|
2232
|
+
"--no-fund",
|
|
2233
|
+
"--save-dev",
|
|
2234
|
+
...specs
|
|
2235
|
+
], { stdio: [
|
|
2236
|
+
"ignore",
|
|
2237
|
+
2,
|
|
2238
|
+
2
|
|
2239
|
+
] });
|
|
2240
|
+
if (install.error || install.status !== 0) throw new UpdateError(`npm install ${specs.join(" ")} failed${install.error ? `: ${install.error.message}` : ` (exit ${install.status})`}; its output is above. Fix the cause, then run lernapps update again`);
|
|
2241
|
+
if (installHooks([]) !== 0) throw new UpdateError("lernapps hooks failed (see above); run npx lernapps hooks again");
|
|
2242
|
+
}
|
|
2243
|
+
const changed = moveActions(`v${version}`);
|
|
2244
|
+
if (changed.length > 0) lines.push(`the actions of lernapps/tooling in ${changed.join(", ")}: v${version}`);
|
|
2245
|
+
const guide = addContentGuide(".");
|
|
2246
|
+
lines.push(...guide.map((line) => `content guide: ${line}`));
|
|
2247
|
+
const plan = migratePlan();
|
|
2248
|
+
lines.push(...plan);
|
|
2249
|
+
const skills = [...replaceSkills(), ...unignoreSkills()];
|
|
2250
|
+
lines.push(...skills);
|
|
2251
|
+
const architecture = updateArchitecture();
|
|
2252
|
+
lines.push(...architecture.lines);
|
|
2253
|
+
if (architecture.failed) notMoved.push("the architecture");
|
|
2254
|
+
process.stdout.write(`${lines.map((line) => `lernapps update: ${line}`).join("\n")}\n`);
|
|
2255
|
+
if (notMoved.length > 0) throw new UpdateError(`partly updated: ${notMoved.join(" and ")} not moved (see above). Run it from the latest tooling, installed by npx: ${LATEST}`);
|
|
2256
|
+
const files = [
|
|
2257
|
+
"package.json",
|
|
2258
|
+
"package-lock.json",
|
|
2259
|
+
".github/workflows/"
|
|
2260
|
+
];
|
|
2261
|
+
const skillsMoved = skills.some((line) => !line.includes("those of this tooling already"));
|
|
2262
|
+
if (guide.length > 0 || skillsMoved) files.push(".agents/", ".claude/");
|
|
2263
|
+
if (skillsMoved) files.push(SKILLS_LOCK);
|
|
2264
|
+
if (skills.some((line) => line.includes(".gitignore"))) files.push(".gitignore");
|
|
2265
|
+
if (architecture.lines.length > 0) files.push(`${ARCHITECTURE}/`);
|
|
2266
|
+
if (plan.some((line) => line.startsWith("plan: moved"))) files.push(".vibe/ (git add -A .vibe, to keep the move)");
|
|
2267
|
+
process.stdout.write(`Next: npx lernapps check, then commit ${files.slice(0, -1).join(", ")} and ${files.at(-1) ?? ""}\n`);
|
|
2268
|
+
return 0;
|
|
2269
|
+
}
|
|
2270
|
+
// src/screenshots.ts
|
|
2271
|
+
/** The archetypes whose pages are shown to a creator before a submission. */
|
|
2272
|
+
const SUPPORTED = ["explainer", "quiz"];
|
|
2273
|
+
const DESKTOP = 1280;
|
|
2274
|
+
const HEIGHT = 800;
|
|
2275
|
+
/** A figure is shot at twice its size on screen, as a phone with a sharp display shows it. */
|
|
2276
|
+
const FIGURE_SCALE = 2;
|
|
2277
|
+
/** A figure: the blocks figure and labelled-figure (an image or a drawing), and any other figure with a picture. */
|
|
2278
|
+
const FIGURES = "figure:has(img, svg)";
|
|
2279
|
+
/** The command cannot be done for the app it was given (not a usage error): printed as it is, exit code 1. */
|
|
2280
|
+
var ScreenshotsError = class extends Error {};
|
|
2281
|
+
/** No browser can run here: the finding of `lernapps check` for it, about the bundle it was to photograph. */
|
|
2282
|
+
var NoBrowser = class extends Error {
|
|
2283
|
+
error;
|
|
2284
|
+
bundle;
|
|
2285
|
+
constructor(error, bundle) {
|
|
2286
|
+
super(error.message);
|
|
2287
|
+
this.error = error;
|
|
2288
|
+
this.bundle = bundle;
|
|
2289
|
+
}
|
|
2290
|
+
};
|
|
2291
|
+
/**
|
|
2292
|
+
* The archetype of a built app, from its start page: the explainer and interactive runtimes mark their pages with
|
|
2293
|
+
* <meta name="lernapps-archetype">; a quiz has no marker but is the one page with <main class="quiz">.
|
|
2294
|
+
*/
|
|
2295
|
+
function archetypeOf(startPage) {
|
|
2296
|
+
const marker = /<meta\b(?=[^>]*\bname\s*=\s*["']lernapps-archetype["'])[^>]*\bcontent\s*=\s*["']([^"']*)["']/i.exec(startPage);
|
|
2297
|
+
if (marker !== null) return marker[1];
|
|
2298
|
+
return /<main\b[^>]*\bclass\s*=\s*["'][^"']*\bquiz\b/i.test(startPage) ? "quiz" : void 0;
|
|
2299
|
+
}
|
|
2300
|
+
/**
|
|
2301
|
+
* The file name of a page's image: its place in the order and its path (`02-plan-selbsteinschaetzung`); of a figure on
|
|
2302
|
+
* the page, also its place on the page (`02-plan-selbsteinschaetzung-figure-1`).
|
|
2303
|
+
*/
|
|
2304
|
+
function imageName(order, page, width, figure) {
|
|
2305
|
+
const path = page.replace(/(^|\/)index\.html$/i, "").replace(/\.html$/i, "");
|
|
2306
|
+
const slug = (path === "" ? "index" : path).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "page";
|
|
2307
|
+
return `${`${String(order).padStart(2, "0")}-${slug}${figure === void 0 ? "" : `-figure-${figure}`}`}${width === void 0 ? "" : `-${width}`}.png`;
|
|
2308
|
+
}
|
|
2309
|
+
const escape = (text) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
2310
|
+
/** The index.html of the folder: every image in page order with the page's title and path; one file, no network. */
|
|
2311
|
+
function indexPage(shots) {
|
|
2312
|
+
return `<!doctype html>
|
|
2313
|
+
<html lang="en">
|
|
2314
|
+
<head>
|
|
2315
|
+
<meta charset="utf-8">
|
|
2316
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2317
|
+
<title>Screenshots</title>
|
|
2318
|
+
<style>
|
|
2319
|
+
body { font-family: system-ui, sans-serif; margin: 1rem; background: #f4f4f4; color: #111; }
|
|
2320
|
+
section { margin: 0 0 3rem; }
|
|
2321
|
+
figure { display: inline-block; margin: 0 1rem 1rem 0; vertical-align: top; }
|
|
2322
|
+
img { max-width: 100%; height: auto; border: 1px solid #888; background: #fff; }
|
|
2323
|
+
</style>
|
|
2324
|
+
</head>
|
|
2325
|
+
<body>
|
|
2326
|
+
<main>\n <h1>Screenshots</h1>\n${shots.map(({ page, title, images, figures }) => ` <section>\n <h2>${escape(title)}</h2>\n <p><code>${escape(page)}</code></p>\n` + images.map(({ file, width }) => ` <figure><img src="${escape(file)}" width="${width}" alt="${escape(`${title} (${page}), ${width} px wide`)}"><figcaption>${width} px</figcaption></figure>\n`).join("") + (figures.length === 0 ? "" : ` <h3>Figures</h3>\n`) + figures.map(({ file, width, figure }) => ` <figure><a href="${escape(file)}"><img src="${escape(file)}" alt="${escape(`Figure ${figure} of ${title} (${page}), ${width} px wide page`)}"></a><figcaption>Figure ${figure}, ${width} px</figcaption></figure>\n`).join("") + ` </section>\n`).join("")} </main>\n</body>\n</html>\n`;
|
|
2327
|
+
}
|
|
2328
|
+
/** Whether `out` may be emptied: not the working folder, one of its parents, the bundle, or a folder that holds it. */
|
|
2329
|
+
function checkOut(out, bundle) {
|
|
2330
|
+
const inside = (outer, inner) => inner === outer || inner.startsWith(outer + sep);
|
|
2331
|
+
const cwd = resolve(".");
|
|
2332
|
+
if (inside(out, cwd) || inside(out, bundle) || inside(bundle, out) || dirname(out) === out) throw new UsageError(`--out ${relative(cwd, out) || "."}: this folder is emptied first, and it holds the working folder or the built app; choose another`);
|
|
2333
|
+
}
|
|
2334
|
+
/**
|
|
2335
|
+
* Opens a page in a new context, loaded, fonts and images there, no animation, nothing answered or hovered, and gives it
|
|
2336
|
+
* to `use`.
|
|
2337
|
+
*/
|
|
2338
|
+
async function opened(browser, base, page, options, use) {
|
|
2339
|
+
const context = await browser.newContext({
|
|
2340
|
+
viewport: {
|
|
2341
|
+
width: options.width,
|
|
2342
|
+
height: HEIGHT
|
|
2343
|
+
},
|
|
2344
|
+
deviceScaleFactor: options.scale ?? 1,
|
|
2345
|
+
javaScriptEnabled: options.javaScript ?? true,
|
|
2346
|
+
reducedMotion: "reduce",
|
|
2347
|
+
serviceWorkers: "block"
|
|
2348
|
+
});
|
|
2349
|
+
try {
|
|
2350
|
+
const origin = new URL(base).origin;
|
|
2351
|
+
await context.route("**/*", (route) => new URL(route.request().url()).origin === origin ? route.continue() : route.abort("blockedbyclient"));
|
|
2352
|
+
const tab = await context.newPage();
|
|
2353
|
+
await tab.goto(new URL(page.replace(/(^|\/)index\.html$/i, "$1"), base).href, { waitUntil: "load" });
|
|
2354
|
+
await tab.evaluate(() => {
|
|
2355
|
+
for (const image of document.images) image.loading = "eager";
|
|
2356
|
+
});
|
|
2357
|
+
await tab.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => void 0);
|
|
2358
|
+
await tab.evaluate(() => Promise.all([document.fonts.ready, ...[...document.images].map((image) => image.decode().catch(() => void 0))]));
|
|
2359
|
+
return await use(tab);
|
|
2360
|
+
} finally {
|
|
2361
|
+
await context.close();
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
/** A page as a learner first sees it, whole: returns its title. */
|
|
2365
|
+
async function shoot(browser, base, page, width, file) {
|
|
2366
|
+
return opened(browser, base, page, { width }, async (tab) => {
|
|
2367
|
+
await tab.screenshot({
|
|
2368
|
+
path: file,
|
|
2369
|
+
fullPage: true,
|
|
2370
|
+
animations: "disabled"
|
|
2371
|
+
});
|
|
2372
|
+
return (await tab.title()).trim();
|
|
2373
|
+
});
|
|
2374
|
+
}
|
|
2375
|
+
/**
|
|
2376
|
+
* Every figure of a page, each with its caption, at twice its size: `file` names the image of the figure with its place
|
|
2377
|
+
* on the page. The page is opened without JavaScript, as the build writes it, so that the figures are there that a
|
|
2378
|
+
* script shows only later, such as those of a quiz's questions. A figure that takes no space, such as one in a closed
|
|
2379
|
+
* details element, is left out. Returns the number of figures shot.
|
|
2380
|
+
*/
|
|
2381
|
+
async function shootFigures(browser, base, page, width, file) {
|
|
2382
|
+
return opened(browser, base, page, {
|
|
2383
|
+
width,
|
|
2384
|
+
scale: FIGURE_SCALE,
|
|
2385
|
+
javaScript: false
|
|
2386
|
+
}, async (tab) => {
|
|
2387
|
+
let count = 0;
|
|
2388
|
+
for (const figure of await tab.locator(FIGURES).all()) {
|
|
2389
|
+
const box = await figure.boundingBox();
|
|
2390
|
+
if (box === null || box.width === 0 || box.height === 0) continue;
|
|
2391
|
+
count += 1;
|
|
2392
|
+
await figure.screenshot({
|
|
2393
|
+
path: file(count),
|
|
2394
|
+
animations: "disabled",
|
|
2395
|
+
scale: "device"
|
|
2396
|
+
});
|
|
2397
|
+
}
|
|
2398
|
+
return count;
|
|
2399
|
+
});
|
|
2400
|
+
}
|
|
2401
|
+
/**
|
|
2402
|
+
* Writes the images and the index.html. Everything that can fail before is checked before the folder is touched, so a
|
|
2403
|
+
* failed run writes nothing. Returns the line to print.
|
|
2404
|
+
*/
|
|
2405
|
+
async function screenshots(args) {
|
|
2406
|
+
const { values, positionals } = parseArgs({
|
|
2407
|
+
args,
|
|
2408
|
+
options: SCREENSHOTS_OPTIONS,
|
|
2409
|
+
strict: true,
|
|
2410
|
+
allowPositionals: true
|
|
2411
|
+
});
|
|
2412
|
+
if (positionals.length > 1) throw new UsageError(`one <dir> at most, not ${positionals.join(" ")}`);
|
|
2413
|
+
if (!/^\d+$/.test(values.width) || Number(values.width) < 200 || Number(values.width) > 4e3) throw new UsageError(`--width ${values.width}: a number of pixels from 200 to 4000`);
|
|
2414
|
+
const widths = [.../* @__PURE__ */ new Set([Number(values.width), ...values.desktop ? [DESKTOP] : []])];
|
|
2415
|
+
const bundle = resolve(positionals[0] ?? "dist");
|
|
2416
|
+
if (!existsSync(bundle) || !statSync(bundle).isDirectory() || !existsSync(join(bundle, "index.html"))) throw new UsageError(`${positionals[0] ?? "dist"}: not a directory with index.html (build the app first)`);
|
|
2417
|
+
const archetype = archetypeOf(readFileSync(join(bundle, "index.html"), "utf8"));
|
|
2418
|
+
if (archetype === void 0 || !SUPPORTED.includes(archetype)) throw new ScreenshotsError(`screenshots support explainer and quiz, not ${archetype === void 0 ? "this app" : `the ${archetype} archetype`}`);
|
|
2419
|
+
const out = resolve(values.out);
|
|
2420
|
+
checkOut(out, bundle);
|
|
2421
|
+
const pages = bundlePages(bundle).map(({ name }) => name);
|
|
2422
|
+
pages.sort((a, b) => Number(b === "index.html") - Number(a === "index.html"));
|
|
2423
|
+
const browser = await launch().catch((error) => {
|
|
2424
|
+
throw error instanceof BrowserError ? new NoBrowser(error, relative(resolve("."), bundle) || ".") : error;
|
|
2425
|
+
});
|
|
2426
|
+
const { server, base } = await serve(bundle, "/");
|
|
2427
|
+
let figureCount = 0;
|
|
2428
|
+
try {
|
|
2429
|
+
rmSync(out, {
|
|
2430
|
+
recursive: true,
|
|
2431
|
+
force: true
|
|
2432
|
+
});
|
|
2433
|
+
mkdirSync(out, { recursive: true });
|
|
2434
|
+
const shots = [];
|
|
2435
|
+
for (const [index, page] of pages.entries()) {
|
|
2436
|
+
const images = [];
|
|
2437
|
+
const figures = [];
|
|
2438
|
+
let title = "";
|
|
2439
|
+
for (const width of widths) {
|
|
2440
|
+
const named = widths.length > 1 ? width : void 0;
|
|
2441
|
+
const file = imageName(index + 1, page, named);
|
|
2442
|
+
title = await shoot(browser, base, page, width, join(out, file));
|
|
2443
|
+
images.push({
|
|
2444
|
+
file,
|
|
2445
|
+
width
|
|
2446
|
+
});
|
|
2447
|
+
const count = await shootFigures(browser, base, page, width, (figure) => join(out, imageName(index + 1, page, named, figure)));
|
|
2448
|
+
for (let figure = 1; figure <= count; figure += 1) figures.push({
|
|
2449
|
+
file: imageName(index + 1, page, named, figure),
|
|
2450
|
+
width,
|
|
2451
|
+
figure
|
|
2452
|
+
});
|
|
2453
|
+
}
|
|
2454
|
+
figureCount += figures.filter(({ width }) => width === widths[0]).length;
|
|
2455
|
+
shots.push({
|
|
2456
|
+
page,
|
|
2457
|
+
title: title || page,
|
|
2458
|
+
images,
|
|
2459
|
+
figures
|
|
2460
|
+
});
|
|
2461
|
+
}
|
|
2462
|
+
writeFileSync(join(out, "index.html"), indexPage(shots));
|
|
2463
|
+
} finally {
|
|
2464
|
+
server.close();
|
|
2465
|
+
await browser.close();
|
|
2466
|
+
}
|
|
2467
|
+
const count = readdirSync(out).filter((file) => file.endsWith(".png")).length;
|
|
2468
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
2469
|
+
return `lernapps screenshots: ${plural(pages.length, "page")}, ${plural(figureCount, "figure")}, ${plural(count, "image")} in ${relative(resolve("."), out) || "."}/ (open index.html; check every figure in its own image)\n`;
|
|
2470
|
+
}
|
|
2471
|
+
// src/cli.ts
|
|
2472
|
+
/** In a repo, every run also writes its full report here, passing or not: the hooks and CI alike. */
|
|
2473
|
+
const REPORT = "node_modules/.cache/lernapps/validation-report.yaml";
|
|
2474
|
+
const CHECKS_PASS = "checks-pass";
|
|
2475
|
+
/** A built app or a URL that could not be checked at all: a platform rule, unlike the toolchain's checks-pass. */
|
|
2476
|
+
const APP_CHECKABLE = "app-checkable";
|
|
2477
|
+
/** The Playwright tests' results, as its JSON reporter writes them, to tell the runtime's tests from the app's. */
|
|
2478
|
+
const E2E_RESULTS = "node_modules/.cache/lernapps/playwright-results.json";
|
|
2479
|
+
const RUNTIME_ISSUES = "https://github.com/lernapps/app-templates/issues";
|
|
2480
|
+
function version() {
|
|
2481
|
+
const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
2482
|
+
if (typeof manifest === "object" && manifest !== null && "version" in manifest) return String(manifest.version);
|
|
2483
|
+
return "unknown";
|
|
2484
|
+
}
|
|
2485
|
+
/** The toolchain of the project being checked: its node_modules/.bin first. */
|
|
2486
|
+
const projectEnv = () => ({
|
|
2487
|
+
...process.env,
|
|
2488
|
+
PATH: [join(process.cwd(), "node_modules", ".bin"), process.env["PATH"] ?? ""].join(delimiter)
|
|
2489
|
+
});
|
|
2490
|
+
/** Folders that hold no sources of the project: what tools install or write. */
|
|
2491
|
+
const NOT_SOURCES = /* @__PURE__ */ new Set([
|
|
2492
|
+
"node_modules",
|
|
2493
|
+
"dist",
|
|
2494
|
+
"_site",
|
|
2495
|
+
"playwright-report",
|
|
2496
|
+
"test-results"
|
|
2497
|
+
]);
|
|
2498
|
+
/** Whether the project has a Vue single-file component (.vue) among its sources. */
|
|
2499
|
+
function hasVueFiles(dir) {
|
|
2500
|
+
return readdirSync(dir, { withFileTypes: true }).some((entry) => {
|
|
2501
|
+
if (entry.isDirectory()) return !entry.name.startsWith(".") && !NOT_SOURCES.has(entry.name) && hasVueFiles(join(dir, entry.name));
|
|
2502
|
+
return entry.name.endsWith(".vue");
|
|
2503
|
+
});
|
|
2504
|
+
}
|
|
2505
|
+
/**
|
|
2506
|
+
* An arc42 step on the working tree, as the rest of the check reads it (the build, the tests): a file not added to git
|
|
2507
|
+
* yet counts too, so a freshly created app passes before anything is added. arc42 resolves paths only from
|
|
2508
|
+
* `git ls-files` (docToolchain/arc42-language#128). In a repository the step therefore runs on a copy of its index
|
|
2509
|
+
* with every untracked, not ignored file below the app added as intent to add: no content goes into git and the
|
|
2510
|
+
* repository's own index stays as it is. Outside a repository arc42 lists the files below its root, which must then
|
|
2511
|
+
* be the app's folder (by default it would be the architecture's).
|
|
2512
|
+
*/
|
|
2513
|
+
function ofWorkingTree(step) {
|
|
2514
|
+
const where = step.where ?? [step.command, ...step.args].join(" ");
|
|
2515
|
+
const gitPath = spawnSync("git", [
|
|
2516
|
+
"rev-parse",
|
|
2517
|
+
"--git-path",
|
|
2518
|
+
"index"
|
|
2519
|
+
], { encoding: "utf8" });
|
|
2520
|
+
if (gitPath.status !== 0) return {
|
|
2521
|
+
step: {
|
|
2522
|
+
...step,
|
|
2523
|
+
where,
|
|
2524
|
+
args: [
|
|
2525
|
+
"--root",
|
|
2526
|
+
".",
|
|
2527
|
+
...step.args
|
|
2528
|
+
]
|
|
2529
|
+
},
|
|
2530
|
+
done: () => {}
|
|
2531
|
+
};
|
|
2532
|
+
const temp = mkdtempSync(join(tmpdir(), "lernapps-index-"));
|
|
2533
|
+
const index = join(temp, "index");
|
|
2534
|
+
const own = resolve(gitPath.stdout.trim());
|
|
2535
|
+
if (existsSync(own)) copyFileSync(own, index);
|
|
2536
|
+
const env = {
|
|
2537
|
+
...step.env,
|
|
2538
|
+
GIT_INDEX_FILE: index
|
|
2539
|
+
};
|
|
2540
|
+
spawnSync("git", [
|
|
2541
|
+
"add",
|
|
2542
|
+
"--all",
|
|
2543
|
+
"--intent-to-add",
|
|
2544
|
+
"--",
|
|
2545
|
+
"."
|
|
2546
|
+
], { env: {
|
|
2547
|
+
...process.env,
|
|
2548
|
+
...env
|
|
2549
|
+
} });
|
|
2550
|
+
return {
|
|
2551
|
+
step: {
|
|
2552
|
+
...step,
|
|
2553
|
+
where,
|
|
2554
|
+
env
|
|
2555
|
+
},
|
|
2556
|
+
done: () => rmSync(temp, {
|
|
2557
|
+
recursive: true,
|
|
2558
|
+
force: true
|
|
2559
|
+
})
|
|
2560
|
+
};
|
|
2561
|
+
}
|
|
2562
|
+
/** Runs a step; its output is shown only when it fails. */
|
|
2563
|
+
function runStep(planned) {
|
|
2564
|
+
const { step, done } = planned.workingTree ? ofWorkingTree(planned) : {
|
|
2565
|
+
step: planned,
|
|
2566
|
+
done: () => {}
|
|
2567
|
+
};
|
|
2568
|
+
let result;
|
|
2569
|
+
try {
|
|
2570
|
+
result = spawnSync(step.command, step.args, {
|
|
2571
|
+
env: {
|
|
2572
|
+
...projectEnv(),
|
|
2573
|
+
...step.env
|
|
2574
|
+
},
|
|
2575
|
+
encoding: "utf8",
|
|
2576
|
+
maxBuffer: 268435456
|
|
2577
|
+
});
|
|
2578
|
+
} finally {
|
|
2579
|
+
done();
|
|
2580
|
+
}
|
|
2581
|
+
const where = step.where ?? [step.command, ...step.args].join(" ");
|
|
2582
|
+
if (result.error) return finding(CHECKS_PASS, "error", {
|
|
2583
|
+
where,
|
|
2584
|
+
found: `${step.name}: cannot run ${step.command}: ${result.error.message}`,
|
|
2585
|
+
fix: step.command === "vue-tsc" ? "The app has Vue components (.vue), which vue-tsc type-checks: add it with npm install --save-dev vue-tsc typescript, then check again" : step.command === "arc42" ? `The app has an architecture (${ARCHITECTURE}/), which the arc42 CLI checks: add it with npm install --save-dev @doctc/arc42@0.38.0, then check again` : `Install the project's dependencies (npm ci), then check again`
|
|
2586
|
+
});
|
|
2587
|
+
if (result.status === 0) return void 0;
|
|
2588
|
+
process.stderr.write(`${result.stdout}${result.stderr}`);
|
|
2589
|
+
return finding(CHECKS_PASS, "error", {
|
|
2590
|
+
where,
|
|
2591
|
+
found: `${step.name} failed (its output is above)`,
|
|
2592
|
+
fix: step.command === "vp" && step.args[0] === "check" ? "Run vp check --fix, which fixes what it can of format and lint; fix the rest from the output, then check again" : step.command === "arc42" ? `Keep the architecture current with the code: fix each finding of ${where} in the app's own part, between the markers <!-- app: begin … --> and <!-- app: end … --> of ${ARCHITECTURE}/, or in the code; never edit outside those markers, which lernapps update replaces.${step.workingTree ? "" : " Before a commit arc42 reads what is staged: git add the new files that belong to the commit."} Then check again` : `Fix the cause from the output of ${where}, then check again`
|
|
2593
|
+
});
|
|
2594
|
+
}
|
|
2595
|
+
/** No browser could start: in a repo a step of the toolchain's checks, on a built app or a URL the app could not be checked. */
|
|
2596
|
+
const browserFinding = (error, rule = CHECKS_PASS) => finding(rule, "error", {
|
|
2597
|
+
where: "browser",
|
|
2598
|
+
found: error.message,
|
|
2599
|
+
fix: rule === CHECKS_PASS ? "Install Chromium and its system libraries: npx playwright install --with-deps chromium. Where no browser can run, push and let CI check" : "Install Chromium and its system libraries: npx playwright install --with-deps chromium, then check again. If no browser can start even then, open an issue at https://github.com/lernapps/tooling/issues with this message"
|
|
2600
|
+
});
|
|
2601
|
+
/**
|
|
2602
|
+
* The failing tests that are defined in the runtime (a file below node_modules/@lernapps/app-templates/), by their
|
|
2603
|
+
* titles: the suites' titles below the file, then the test's. Playwright names a spec's file relative to the test
|
|
2604
|
+
* folder, e.g. ../node_modules/@lernapps/app-templates/dist/quiz-e2e.mjs for the quiz's e2e/quiz.e2e.ts, which only
|
|
2605
|
+
* calls quizTests(). Empty when the results cannot be read.
|
|
2606
|
+
*/
|
|
2607
|
+
function failingRuntimeTests() {
|
|
2608
|
+
let results;
|
|
2609
|
+
try {
|
|
2610
|
+
results = JSON.parse(readFileSync(E2E_RESULTS, "utf8"));
|
|
2611
|
+
} catch {
|
|
2612
|
+
return [];
|
|
2613
|
+
}
|
|
2614
|
+
const found = /* @__PURE__ */ new Set();
|
|
2615
|
+
const walk = (suite, titles) => {
|
|
2616
|
+
for (const spec of suite.specs ?? []) if (!spec.ok && spec.file.split(/[\\/]/).join("/").includes(`node_modules/@lernapps/app-templates/`)) found.add([...titles, spec.title].join(" › "));
|
|
2617
|
+
for (const child of suite.suites ?? []) walk(child, child.title ? [...titles, child.title] : titles);
|
|
2618
|
+
};
|
|
2619
|
+
for (const file of results.suites ?? []) walk(file, []);
|
|
2620
|
+
return [...found].sort();
|
|
2621
|
+
}
|
|
2622
|
+
/**
|
|
2623
|
+
* Runs the Playwright tests in the browser of this run; a failing test of the runtime is named as a test of the
|
|
2624
|
+
* template, with what to do.
|
|
2625
|
+
*/
|
|
2626
|
+
function runEndToEndTests(browser) {
|
|
2627
|
+
rmSync(E2E_RESULTS, { force: true });
|
|
2628
|
+
mkdirSync(dirname(E2E_RESULTS), { recursive: true });
|
|
2629
|
+
const failure = runStep({
|
|
2630
|
+
name: "end-to-end tests",
|
|
2631
|
+
command: "playwright",
|
|
2632
|
+
args: ["test", "--reporter=line,json"],
|
|
2633
|
+
where: "playwright test",
|
|
2634
|
+
env: {
|
|
2635
|
+
...browser,
|
|
2636
|
+
PLAYWRIGHT_JSON_OUTPUT_FILE: resolve(E2E_RESULTS)
|
|
2637
|
+
}
|
|
2638
|
+
});
|
|
2639
|
+
if (failure === void 0) return void 0;
|
|
2640
|
+
const runtime = failingRuntimeTests();
|
|
2641
|
+
if (runtime.length === 0) return failure;
|
|
2642
|
+
const shown = runtime.slice(0, 5).map((title) => `"${title}"`);
|
|
2643
|
+
const more = runtime.length > shown.length ? ` and ${runtime.length - shown.length} more` : "";
|
|
2644
|
+
return {
|
|
2645
|
+
...failure,
|
|
2646
|
+
found: `end-to-end tests failed (their output is above): ${runtime.length === 1 ? "a generic test" : "generic tests"} of the template's runtime ${RUNTIME}, not of this app: ${shown.join(", ")}${more}`,
|
|
2647
|
+
fix: `If the content validates (the build passed), this is a bug in the template: tell the creator and open an issue at ${RUNTIME_ISSUES} with this validation report (or ask the creator to). Never change the tests or the configuration to get around it. Once it is fixed, lernapps update moves the app to the fixed runtime: npx --yes github:lernapps/tooling#main update`
|
|
2648
|
+
};
|
|
2649
|
+
}
|
|
2650
|
+
/** Serves a bundle (or opens a URL), visits its pages and measures its fitness. */
|
|
2651
|
+
async function visit(target) {
|
|
2652
|
+
const browser = await launch();
|
|
2653
|
+
try {
|
|
2654
|
+
if ("url" in target) {
|
|
2655
|
+
const base = new URL("./", target.url).href;
|
|
2656
|
+
const { pages, stopped } = await visitApp(browser, base, (url) => url, {
|
|
2657
|
+
start: target.url,
|
|
2658
|
+
limit: urlPageLimit()
|
|
2659
|
+
});
|
|
2660
|
+
return {
|
|
2661
|
+
base,
|
|
2662
|
+
pages,
|
|
2663
|
+
fitness: measure(pages, base),
|
|
2664
|
+
...stopped ? { stopped } : {}
|
|
2665
|
+
};
|
|
2666
|
+
}
|
|
2667
|
+
const { server, base } = await serve(target.bundle, "/");
|
|
2668
|
+
try {
|
|
2669
|
+
const name = (url) => {
|
|
2670
|
+
const path = decodeURIComponent(url.slice(base.length));
|
|
2671
|
+
if (path === "" || path.endsWith("/")) return `${path}index.html`;
|
|
2672
|
+
const file = join(target.bundle, path);
|
|
2673
|
+
return existsSync(file) && statSync(file).isDirectory() ? `${path}/index.html` : path;
|
|
2674
|
+
};
|
|
2675
|
+
const { pages, unreachable } = await visitApp(browser, base, name, { pages: bundlePages(target.bundle) });
|
|
2676
|
+
return {
|
|
2677
|
+
base,
|
|
2678
|
+
pages,
|
|
2679
|
+
fitness: measure(pages, base),
|
|
2680
|
+
bundle: target.bundle,
|
|
2681
|
+
unreachable
|
|
2682
|
+
};
|
|
2683
|
+
} finally {
|
|
2684
|
+
server.close();
|
|
2685
|
+
}
|
|
2686
|
+
} finally {
|
|
2687
|
+
await browser.close();
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
function commitOrDirectory() {
|
|
2691
|
+
const git = spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" });
|
|
2692
|
+
const commit = git.status === 0 ? git.stdout.trim() : "";
|
|
2693
|
+
return /^[0-9a-f]{40}$/.test(commit) ? { commit } : { directory: process.cwd() };
|
|
2694
|
+
}
|
|
2695
|
+
function readEntry(file) {
|
|
2696
|
+
let value;
|
|
2697
|
+
try {
|
|
2698
|
+
value = parse(readFileSync(file, "utf8"));
|
|
2699
|
+
} catch (error) {
|
|
2700
|
+
throw new UsageError(`cannot read the entry ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2701
|
+
}
|
|
2702
|
+
if (typeof value !== "object" || value === null || typeof value.url !== "string") throw new UsageError(`the entry ${file} has no url`);
|
|
2703
|
+
return {
|
|
2704
|
+
file,
|
|
2705
|
+
value
|
|
2706
|
+
};
|
|
2707
|
+
}
|
|
2708
|
+
/** What the arc42 CLI reads in an app with an architecture: a staged change of one of these is checked before a commit. */
|
|
2709
|
+
const ARCHITECTURE_INPUTS = [
|
|
2710
|
+
`${ARCHITECTURE}/`,
|
|
2711
|
+
".arc42ignore",
|
|
2712
|
+
"src/"
|
|
2713
|
+
];
|
|
2714
|
+
/**
|
|
2715
|
+
* The architecture's step before a commit of an app with one (generator/architecture.ts), when the commit stages a
|
|
2716
|
+
* file of the architecture or of the sources: `arc42 diff --staged` (a fraction of a second), whose findings
|
|
2717
|
+
* architectureDiffFindings judges; before the first commit, which has nothing to compare with,
|
|
2718
|
+
* `arc42 validate --strict`.
|
|
2719
|
+
*/
|
|
2720
|
+
function architectureBeforeCommit() {
|
|
2721
|
+
if (!hasArchitecture()) return void 0;
|
|
2722
|
+
const staged = stagedFiles();
|
|
2723
|
+
if (staged !== void 0 && !staged.some((file) => ARCHITECTURE_INPUTS.some((input) => file.startsWith(input)))) return;
|
|
2724
|
+
return spawnSync("git", [
|
|
2725
|
+
"rev-parse",
|
|
2726
|
+
"--verify",
|
|
2727
|
+
"--quiet",
|
|
2728
|
+
"HEAD"
|
|
2729
|
+
], { encoding: "utf8" }).status === 0 ? {
|
|
2730
|
+
name: "architecture",
|
|
2731
|
+
command: "arc42",
|
|
2732
|
+
args: [
|
|
2733
|
+
"--dir",
|
|
2734
|
+
ARCHITECTURE,
|
|
2735
|
+
"diff",
|
|
2736
|
+
"--staged",
|
|
2737
|
+
"--format",
|
|
2738
|
+
"json"
|
|
2739
|
+
],
|
|
2740
|
+
where: `arc42 --dir ${ARCHITECTURE} diff --staged`,
|
|
2741
|
+
architectureDiff: true
|
|
2742
|
+
} : {
|
|
2743
|
+
name: "architecture",
|
|
2744
|
+
command: "arc42",
|
|
2745
|
+
args: [
|
|
2746
|
+
"--dir",
|
|
2747
|
+
ARCHITECTURE,
|
|
2748
|
+
"validate",
|
|
2749
|
+
"--strict"
|
|
2750
|
+
]
|
|
2751
|
+
};
|
|
2752
|
+
}
|
|
2753
|
+
/**
|
|
2754
|
+
* The paths of .arc42ignore, as arc42 reads them for its coverage hint H021 in `validate`: each line that is neither
|
|
2755
|
+
* blank nor a comment, trimmed, matched exactly. `arc42 diff` does not read the file (its coverage hint is not H021),
|
|
2756
|
+
* so the wrapper applies it to the diff's coverage hints: what validate accepts, the commit accepts too.
|
|
2757
|
+
*/
|
|
2758
|
+
function arc42Ignored() {
|
|
2759
|
+
if (!existsSync(".arc42ignore")) return /* @__PURE__ */ new Set();
|
|
2760
|
+
return new Set(readFileSync(".arc42ignore", "utf8").split(/\r?\n/).map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("#")));
|
|
2761
|
+
}
|
|
2762
|
+
/**
|
|
2763
|
+
* What a commit's `arc42 diff --staged` found, judged for the commit: it fails on a block changed without its prose
|
|
2764
|
+
* (or the other way round) and on new code that no block claims and .arc42ignore does not list, as
|
|
2765
|
+
* `arc42 validate --strict` before a push would. A hint "review architecture element" (code changed under an
|
|
2766
|
+
* interface's path) asks a person to look at that element; it is shown as a warning and does not fail the commit,
|
|
2767
|
+
* since nothing in it is wrong yet. ARC42_CONSISTENT (arc42's acceptance, the HEAD commit) accepts only the
|
|
2768
|
+
* consistency findings, which can be intended, and they stay in the report as a warning; new code no block claims has
|
|
2769
|
+
* a fix that is always possible (a block, or .arc42ignore for configuration), so the acceptance does not pass it.
|
|
2770
|
+
*/
|
|
2771
|
+
function architectureDiffFindings(where, stdout, stderr) {
|
|
2772
|
+
let diff;
|
|
2773
|
+
try {
|
|
2774
|
+
diff = JSON.parse(stdout);
|
|
2775
|
+
} catch {
|
|
2776
|
+
process.stderr.write(`${stdout}${stderr}`);
|
|
2777
|
+
return [finding(CHECKS_PASS, "error", {
|
|
2778
|
+
where,
|
|
2779
|
+
found: "architecture: arc42 diff failed (its output is above)",
|
|
2780
|
+
fix: `Fix the cause from the output of ${where}, then check again`
|
|
2781
|
+
})];
|
|
2782
|
+
}
|
|
2783
|
+
const ignored = arc42Ignored();
|
|
2784
|
+
const all = diff.findings ?? [];
|
|
2785
|
+
const consistency = all.filter((f) => f.kind !== "implementation-path" && f.kind !== "new-building-block-hint");
|
|
2786
|
+
const uncovered = all.filter((f) => f.kind === "new-building-block-hint" && !ignored.has(f.file));
|
|
2787
|
+
const review = all.filter((f) => f.kind === "implementation-path");
|
|
2788
|
+
const line = (f) => f.kind === "new-building-block-hint" ? `${f.file} is new code that no building block claims` : `${f.file}${f.line > 0 ? `:${f.line}` : ""} ${f.message}`;
|
|
2789
|
+
const findings = [];
|
|
2790
|
+
const failing = diff.accepted === true ? [] : consistency;
|
|
2791
|
+
if (failing.length < consistency.length) findings.push(finding(CHECKS_PASS, "warning", {
|
|
2792
|
+
where,
|
|
2793
|
+
found: `architecture: accepted with ARC42_CONSISTENT, not fixed: ${consistency.map(line).join("; ")}`,
|
|
2794
|
+
fix: "Accept a finding only after reading it, with a note in the plan why it is intended; ARC42_CONSISTENT set without a finding shown is a bypass like --no-verify"
|
|
2795
|
+
}));
|
|
2796
|
+
const blocking = [...failing, ...uncovered];
|
|
2797
|
+
if (blocking.length > 0) {
|
|
2798
|
+
const fixes = [...uncovered.length > 0 ? [`For new code no block claims, add a building block with its path to the app's section of ${ARCHITECTURE}/05-building-blocks.arc42.md (and place it on a node in chapter 7); only a path that is configuration rather than architecture goes into .arc42ignore. ARC42_CONSISTENT does not accept new code.`] : [], ...failing.length > 0 ? ["For a block changed without its prose, or prose naming a fact of a block without the block, change both together in the same commit. Only if you read the finding and judge the change intended may you accept it with ARC42_CONSISTENT (the base commit arc42 diff --staged names) and a note in the plan why."] : []];
|
|
2799
|
+
const untracked = diff.untracked ?? [];
|
|
2800
|
+
findings.push(finding(CHECKS_PASS, "error", {
|
|
2801
|
+
where,
|
|
2802
|
+
found: `architecture: ${blocking.map(line).join("; ")}`,
|
|
2803
|
+
fix: `Keep the architecture current with the code, in the app's own part between the markers <!-- app: begin … --> and <!-- app: end … --> of ${ARCHITECTURE}/ (never outside them, which lernapps update replaces). ${fixes.join(" ")} Before a commit arc42 reads what is staged: git add the files that belong to the commit${untracked.length > 0 ? ` (not compared until added: ${untracked.join(", ")})` : ""}. Then commit again`
|
|
2804
|
+
}));
|
|
2805
|
+
}
|
|
2806
|
+
if (review.length > 0) findings.push(finding(CHECKS_PASS, "warning", {
|
|
2807
|
+
where,
|
|
2808
|
+
found: `architecture: code changed under an interface; review whether its element still says what the code does: ${review.map((f) => `${f.file} (${f.elementId ?? "?"})`).join(", ")}`,
|
|
2809
|
+
fix: `Read each element in ${ARCHITECTURE}/; if the change alters what it describes, update the element in the same commit. This does not fail the commit, and nothing is to be accepted`
|
|
2810
|
+
}));
|
|
2811
|
+
return findings;
|
|
2812
|
+
}
|
|
2813
|
+
/** Runs a step and returns its findings: a diff's as architectureDiffFindings judges them, else runStep's one. */
|
|
2814
|
+
function stepFindings(step) {
|
|
2815
|
+
if (!step.architectureDiff) {
|
|
2816
|
+
const failure = runStep(step);
|
|
2817
|
+
return failure ? [failure] : [];
|
|
2818
|
+
}
|
|
2819
|
+
const where = step.where ?? [step.command, ...step.args].join(" ");
|
|
2820
|
+
const folder = mkdtempSync(join(tmpdir(), "lernapps-arc42-"));
|
|
2821
|
+
const file = join(folder, "diff.json");
|
|
2822
|
+
const out = openSync(file, "w");
|
|
2823
|
+
try {
|
|
2824
|
+
const result = spawnSync(step.command, step.args, {
|
|
2825
|
+
env: {
|
|
2826
|
+
...projectEnv(),
|
|
2827
|
+
...step.env
|
|
2828
|
+
},
|
|
2829
|
+
encoding: "utf8",
|
|
2830
|
+
stdio: [
|
|
2831
|
+
"ignore",
|
|
2832
|
+
out,
|
|
2833
|
+
"pipe"
|
|
2834
|
+
],
|
|
2835
|
+
maxBuffer: 268435456
|
|
2836
|
+
});
|
|
2837
|
+
if (result.error) return [runStep(step)].filter((f) => f !== void 0);
|
|
2838
|
+
return architectureDiffFindings(where, readFileSync(file, "utf8"), result.stderr);
|
|
2839
|
+
} finally {
|
|
2840
|
+
closeSync(out);
|
|
2841
|
+
rmSync(folder, {
|
|
2842
|
+
recursive: true,
|
|
2843
|
+
force: true
|
|
2844
|
+
});
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
/** The checks of the toolchain in a repo, then the checks of what it built. */
|
|
2848
|
+
async function checkProject(fast, heavy, context) {
|
|
2849
|
+
const findings = [];
|
|
2850
|
+
const steps = [];
|
|
2851
|
+
if (fast && !heavy) {
|
|
2852
|
+
const architecture = architectureBeforeCommit();
|
|
2853
|
+
if (architecture) steps.push(architecture);
|
|
2854
|
+
}
|
|
2855
|
+
if (heavy && hasArchitecture()) steps.push({
|
|
2856
|
+
name: "architecture",
|
|
2857
|
+
command: "arc42",
|
|
2858
|
+
args: [
|
|
2859
|
+
"--dir",
|
|
2860
|
+
ARCHITECTURE,
|
|
2861
|
+
"validate",
|
|
2862
|
+
"--strict"
|
|
2863
|
+
],
|
|
2864
|
+
workingTree: true
|
|
2865
|
+
});
|
|
2866
|
+
if (fast) {
|
|
2867
|
+
steps.push({
|
|
2868
|
+
name: "format, lint, type check",
|
|
2869
|
+
command: "vp",
|
|
2870
|
+
args: ["check"]
|
|
2871
|
+
});
|
|
2872
|
+
if (hasVueFiles(".")) steps.push({
|
|
2873
|
+
name: "type check of the Vue components",
|
|
2874
|
+
command: "vue-tsc",
|
|
2875
|
+
args: ["--noEmit"]
|
|
2876
|
+
});
|
|
2877
|
+
}
|
|
2878
|
+
if (heavy) {
|
|
2879
|
+
steps.push({
|
|
2880
|
+
name: "unit tests",
|
|
2881
|
+
command: "vp",
|
|
2882
|
+
args: ["test", "--passWithNoTests"]
|
|
2883
|
+
});
|
|
2884
|
+
steps.push({
|
|
2885
|
+
name: "build",
|
|
2886
|
+
command: "vp",
|
|
2887
|
+
args: existsSync("index.html") ? ["build"] : ["pack"]
|
|
2888
|
+
});
|
|
2889
|
+
}
|
|
2890
|
+
let built = true;
|
|
2891
|
+
for (const step of steps) {
|
|
2892
|
+
const found = stepFindings(step);
|
|
2893
|
+
findings.push(...found);
|
|
2894
|
+
if (found.length > 0 && step.name === "build") built = false;
|
|
2895
|
+
}
|
|
2896
|
+
const rules = steps.length > 0 ? [CHECKS_PASS] : [];
|
|
2897
|
+
if (heavy && built) {
|
|
2898
|
+
context.project = process.cwd();
|
|
2899
|
+
if (existsSync(join("dist", "index.html"))) try {
|
|
2900
|
+
context.app = await visit({ bundle: "dist" });
|
|
2901
|
+
} catch (error) {
|
|
2902
|
+
if (!(error instanceof BrowserError)) throw error;
|
|
2903
|
+
findings.push(browserFinding(error));
|
|
2904
|
+
}
|
|
2905
|
+
const checks = await runChecks(context);
|
|
2906
|
+
rules.push(...checks.rules);
|
|
2907
|
+
findings.push(...checks.findings);
|
|
2908
|
+
if ([
|
|
2909
|
+
"ts",
|
|
2910
|
+
"js",
|
|
2911
|
+
"mjs",
|
|
2912
|
+
"cjs"
|
|
2913
|
+
].map((ext) => `playwright.config.${ext}`).find((file) => existsSync(file)) !== void 0) try {
|
|
2914
|
+
const failure = runEndToEndTests(await browserForTests());
|
|
2915
|
+
if (failure) findings.push(failure);
|
|
2916
|
+
} catch (error) {
|
|
2917
|
+
if (!(error instanceof BrowserError)) throw error;
|
|
2918
|
+
findings.push(browserFinding(error));
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
return {
|
|
2922
|
+
checked: commitOrDirectory(),
|
|
2923
|
+
parts: [...fast ? ["pre-commit"] : [], ...heavy ? ["pre-push"] : []],
|
|
2924
|
+
rules,
|
|
2925
|
+
findings,
|
|
2926
|
+
suppressions: [],
|
|
2927
|
+
...context.app ? { fitness: context.app.fitness } : {}
|
|
2928
|
+
};
|
|
2929
|
+
}
|
|
2930
|
+
/**
|
|
2931
|
+
* Before a hook part, the change scope (changes/changes.ts) of the staged files (pre-commit) or the pushed commits
|
|
2932
|
+
* (pre-push): the line to print when the part has nothing to check, the site's check when the push changes only docs
|
|
2933
|
+
* in a repo with a site, else undefined and the part runs.
|
|
2934
|
+
*/
|
|
2935
|
+
function hookScope(part) {
|
|
2936
|
+
const rules = loadRules(".");
|
|
2937
|
+
const { scope, reason } = part === "pre-commit" ? classify(stagedFiles(), rules) : classifyPush(pushedFiles(readStdin()), rules);
|
|
2938
|
+
if (scope === "all") return void 0;
|
|
2939
|
+
const site = SITE_CHECK[rules.kind];
|
|
2940
|
+
if (scope === "docs" && part === "pre-push" && site !== void 0) return { site };
|
|
2941
|
+
return { skip: `lernapps check --${part}: nothing to check, the change is ${scope === "none" ? "the plan only" : rules.kind === "app" ? "docs, which an app does not bundle" : "docs"} (${scope}: ${reason})` };
|
|
2942
|
+
}
|
|
2943
|
+
/** The check of a repo's site before a push that changes only docs: build it, then its check script. */
|
|
2944
|
+
function checkSite(script) {
|
|
2945
|
+
const findings = [];
|
|
2946
|
+
for (const step of [{
|
|
2947
|
+
name: "site build",
|
|
2948
|
+
command: "npm",
|
|
2949
|
+
args: [
|
|
2950
|
+
"run",
|
|
2951
|
+
"--silent",
|
|
2952
|
+
"build"
|
|
2953
|
+
]
|
|
2954
|
+
}, {
|
|
2955
|
+
name: "site check",
|
|
2956
|
+
command: "npm",
|
|
2957
|
+
args: [
|
|
2958
|
+
"run",
|
|
2959
|
+
"--silent",
|
|
2960
|
+
script
|
|
2961
|
+
]
|
|
2962
|
+
}]) {
|
|
2963
|
+
const failure = runStep(step);
|
|
2964
|
+
if (failure) {
|
|
2965
|
+
findings.push(failure);
|
|
2966
|
+
break;
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
return {
|
|
2970
|
+
checked: commitOrDirectory(),
|
|
2971
|
+
parts: ["pre-push"],
|
|
2972
|
+
rules: [CHECKS_PASS],
|
|
2973
|
+
findings,
|
|
2974
|
+
suppressions: []
|
|
2975
|
+
};
|
|
2976
|
+
}
|
|
2977
|
+
/** The checks of a built app alone: a bundle directory or a URL. */
|
|
2978
|
+
async function checkApp(target, context) {
|
|
2979
|
+
const isUrl = /^https?:\/\//.test(target);
|
|
2980
|
+
if (!isUrl && !(existsSync(target) && statSync(target).isDirectory() && existsSync(join(target, "index.html")))) throw new UsageError(`${target}: not a URL, nor a directory with index.html (build the app first)`);
|
|
2981
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
2982
|
+
const findings = [];
|
|
2983
|
+
try {
|
|
2984
|
+
context.app = await visit(isUrl ? { url: target } : { bundle: target });
|
|
2985
|
+
} catch (error) {
|
|
2986
|
+
if (!(error instanceof BrowserError)) throw error;
|
|
2987
|
+
findings.push(browserFinding(error, APP_CHECKABLE));
|
|
2988
|
+
}
|
|
2989
|
+
const checks = await runChecks(context);
|
|
2990
|
+
return {
|
|
2991
|
+
checked: isUrl ? {
|
|
2992
|
+
url: target,
|
|
2993
|
+
at
|
|
2994
|
+
} : { bundle: target },
|
|
2995
|
+
rules: [...checks.rules, ...findings.length > 0 ? [APP_CHECKABLE] : []],
|
|
2996
|
+
findings: [...findings, ...checks.findings],
|
|
2997
|
+
suppressions: [],
|
|
2998
|
+
...context.app ? { fitness: context.app.fitness } : {}
|
|
2999
|
+
};
|
|
3000
|
+
}
|
|
3001
|
+
async function check(args) {
|
|
3002
|
+
const { values, positionals } = parseArgs({
|
|
3003
|
+
args,
|
|
3004
|
+
options: CHECK_OPTIONS,
|
|
3005
|
+
strict: true,
|
|
3006
|
+
allowPositionals: true
|
|
3007
|
+
});
|
|
3008
|
+
if (values.help) {
|
|
3009
|
+
process.stdout.write(`${CHECK_USAGE}${await describeChecks()}`);
|
|
3010
|
+
return 0;
|
|
3011
|
+
}
|
|
3012
|
+
if (positionals.length > 1) throw new UsageError(`one <dir|url> at most, not ${positionals.join(" ")}`);
|
|
3013
|
+
const context = {};
|
|
3014
|
+
if (values.entry !== void 0) context.entry = readEntry(values.entry);
|
|
3015
|
+
const target = positionals[0] ?? context.entry?.value.url;
|
|
3016
|
+
let report;
|
|
3017
|
+
if (target !== void 0) {
|
|
3018
|
+
if (values["pre-commit"] || values["pre-push"]) throw new UsageError("--pre-commit and --pre-push check a repo; a built app is checked without them");
|
|
3019
|
+
report = await checkApp(target, context);
|
|
3020
|
+
} else {
|
|
3021
|
+
const all = values["pre-commit"] === values["pre-push"];
|
|
3022
|
+
const scope = all ? void 0 : hookScope(values["pre-commit"] ? "pre-commit" : "pre-push");
|
|
3023
|
+
const architecture = scope !== void 0 && "skip" in scope && values["pre-commit"] ? architectureBeforeCommit() : void 0;
|
|
3024
|
+
if (architecture !== void 0) report = {
|
|
3025
|
+
checked: commitOrDirectory(),
|
|
3026
|
+
parts: ["pre-commit"],
|
|
3027
|
+
rules: [CHECKS_PASS],
|
|
3028
|
+
findings: stepFindings(architecture),
|
|
3029
|
+
suppressions: []
|
|
3030
|
+
};
|
|
3031
|
+
else if (scope !== void 0 && "skip" in scope) {
|
|
3032
|
+
rmSync(REPORT, { force: true });
|
|
3033
|
+
process.stdout.write(`${scope.skip}\n`);
|
|
3034
|
+
return 0;
|
|
3035
|
+
} else {
|
|
3036
|
+
report = scope !== void 0 ? checkSite(scope.site) : await checkProject(all || values["pre-commit"], all || values["pre-push"], context);
|
|
3037
|
+
if (values["pre-push"] && failed(report)) countPrePushFailure();
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
if (target === void 0 && failed(report)) countFailedChecks(report.findings.filter((found) => found.severity === "error").map((found) => found.rule));
|
|
3041
|
+
const yaml = toYaml(report);
|
|
3042
|
+
if (target === void 0) {
|
|
3043
|
+
mkdirSync(dirname(REPORT), { recursive: true });
|
|
3044
|
+
writeFileSync(REPORT, yaml);
|
|
3045
|
+
}
|
|
3046
|
+
if (values.report !== void 0) writeFileSync(values.report, yaml);
|
|
3047
|
+
if (report.findings.length > 0) process.stdout.write(yaml);
|
|
3048
|
+
else {
|
|
3049
|
+
const what = "commit" in report.checked ? `commit ${report.checked.commit.slice(0, 7)}` : "bundle" in report.checked ? relative(process.cwd(), resolve(report.checked.bundle)) || "." : "url" in report.checked ? report.checked.url : report.checked.directory;
|
|
3050
|
+
process.stdout.write(`lernapps check: ok, ${report.rules.length} rule${report.rules.length === 1 ? "" : "s"}, no findings (${what})\n`);
|
|
3051
|
+
}
|
|
3052
|
+
return failed(report) ? 1 : 0;
|
|
3053
|
+
}
|
|
3054
|
+
async function screenshotsCommand(args) {
|
|
3055
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
3056
|
+
process.stdout.write(SCREENSHOTS_USAGE);
|
|
3057
|
+
return 0;
|
|
3058
|
+
}
|
|
3059
|
+
try {
|
|
3060
|
+
process.stdout.write(await screenshots(args));
|
|
3061
|
+
return 0;
|
|
3062
|
+
} catch (error) {
|
|
3063
|
+
if (error instanceof ScreenshotsError) {
|
|
3064
|
+
process.stderr.write(`lernapps screenshots: ${error.message}\n`);
|
|
3065
|
+
return 1;
|
|
3066
|
+
}
|
|
3067
|
+
if (!(error instanceof NoBrowser)) throw error;
|
|
3068
|
+
process.stdout.write(toYaml({
|
|
3069
|
+
checked: { bundle: error.bundle },
|
|
3070
|
+
rules: [APP_CHECKABLE],
|
|
3071
|
+
findings: [browserFinding(error.error, APP_CHECKABLE)],
|
|
3072
|
+
suppressions: []
|
|
3073
|
+
}));
|
|
3074
|
+
return 1;
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
async function main(argv) {
|
|
3078
|
+
const [command, ...rest] = argv;
|
|
3079
|
+
switch (command) {
|
|
3080
|
+
case "changes": return changes(rest);
|
|
3081
|
+
case "check": return check(rest);
|
|
3082
|
+
case "create": return create(rest);
|
|
3083
|
+
case "hooks": return installHooks(rest);
|
|
3084
|
+
case "init": return init(rest);
|
|
3085
|
+
case "screenshots": return screenshotsCommand(rest);
|
|
3086
|
+
case "skills": return skills(rest);
|
|
3087
|
+
case "update": return update(rest);
|
|
3088
|
+
case "-h":
|
|
3089
|
+
case "--help":
|
|
3090
|
+
process.stdout.write(USAGE);
|
|
3091
|
+
return 0;
|
|
3092
|
+
case "-v":
|
|
3093
|
+
case "--version":
|
|
3094
|
+
process.stdout.write(`${version()}\n`);
|
|
3095
|
+
return 0;
|
|
3096
|
+
case void 0: throw new UsageError("no command given");
|
|
3097
|
+
default: throw new UsageError(`unknown command: ${command}`);
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
try {
|
|
3101
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
3102
|
+
} catch (error) {
|
|
3103
|
+
const parseError = error instanceof TypeError && "code" in error && String(error.code).startsWith("ERR_PARSE_ARGS");
|
|
3104
|
+
if (error instanceof UsageError || parseError) {
|
|
3105
|
+
process.stderr.write(`lernapps: ${error.message}\n\n${USAGE}`);
|
|
3106
|
+
process.exitCode = 2;
|
|
3107
|
+
} else if (error instanceof ChangesConfigError) {
|
|
3108
|
+
process.stderr.write(`lernapps: ${error.message}\n`);
|
|
3109
|
+
process.exitCode = 1;
|
|
3110
|
+
} else if (error instanceof CreateError) {
|
|
3111
|
+
process.stderr.write(`lernapps create: ${error.message}\n`);
|
|
3112
|
+
process.exitCode = 1;
|
|
3113
|
+
} else if (error instanceof UpdateError) {
|
|
3114
|
+
process.stderr.write(`lernapps update: ${error.message}\n`);
|
|
3115
|
+
process.exitCode = 1;
|
|
3116
|
+
} else throw error;
|
|
3117
|
+
}
|
|
3118
|
+
export {};
|