@lernapps/tooling 0.3.1 → 0.5.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/dist/check/rules/competencies-on-map.mjs +85 -0
- package/dist/check/rules/one-competency-per-page.mjs +18 -0
- package/dist/check/rules/prerequisites-linked.mjs +38 -0
- package/dist/cli.mjs +519 -56
- package/dist/explainer-map-DW4XCcY0.mjs +124 -0
- package/dist/skills/lernapps-app/SKILL.md +2 -1
- package/dist/skills/lernapps-cli/SKILL.md +13 -4
- package/dist/skills/lernapps-content-questions/SKILL.md +2 -0
- package/dist/skills/lernapps-content-questions/references/question-types.md +2 -1
- package/dist/skills/lernapps-content-research/SKILL.md +41 -5
- package/dist/skills/lernapps-exercise-patterns/references/mathematics.md +25 -1
- package/dist/skills/lernapps-exercise-patterns/references/physics.md +1 -0
- package/dist/skills/lernapps-explainer/SKILL.md +10 -4
- package/dist/skills/lernapps-explainer/references/blocks.md +7 -3
- package/dist/skills/lernapps-explainer/references/exercises.md +6 -0
- package/dist/skills/lernapps-explainer/references/rules.md +29 -0
- package/dist/skills/lernapps-explainer/references/structure.md +62 -0
- package/dist/skills/lernapps-explainer-design/SKILL.md +46 -2
- package/dist/skills/lernapps-explainer-design/references/rules.md +1 -0
- package/dist/skills/lernapps-explainer-self-assessment/SKILL.md +1 -0
- package/dist/skills/lernapps-listing/SKILL.md +9 -0
- package/generator/create.ts +39 -10
- package/generator/init.ts +89 -19
- package/generator/update.ts +53 -8
- package/guidance/increment-template.md +13 -5
- package/guidance/plan-template.md +25 -11
- package/guidance/workflow-rules.md +3 -1
- package/package.json +3 -3
- package/skills/archetypes/lernapps-explainer/SKILL.md +10 -4
- package/skills/archetypes/lernapps-explainer/references/blocks.md +7 -3
- package/skills/archetypes/lernapps-explainer/references/exercises.md +6 -0
- package/skills/archetypes/lernapps-explainer/references/rules.md +29 -0
- package/skills/archetypes/lernapps-explainer/references/structure.md +62 -0
- package/skills/archetypes/lernapps-explainer-design/SKILL.md +46 -2
- package/skills/archetypes/lernapps-explainer-design/references/rules.md +1 -0
- package/skills/archetypes/lernapps-explainer-self-assessment/SKILL.md +1 -0
- package/skills/content/lernapps-content-questions/SKILL.md +2 -0
- package/skills/content/lernapps-content-questions/references/question-types.md +2 -1
- package/skills/content/lernapps-content-research/SKILL.md +41 -5
- package/skills/content/lernapps-exercise-patterns/references/mathematics.md +25 -1
- package/skills/content/lernapps-exercise-patterns/references/physics.md +1 -0
- package/skills/process/lernapps-app/SKILL.md +2 -1
- package/skills/process/lernapps-listing/SKILL.md +9 -0
- package/skills/tooling/lernapps-cli/SKILL.md +13 -4
package/dist/cli.mjs
CHANGED
|
@@ -5,14 +5,219 @@ import { a as countFailedChecks, c as planFrontMatter, i as PLAN_NAME, l as plan
|
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
6
|
import { spawnSync } from "node:child_process";
|
|
7
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";
|
|
8
|
+
import { homedir, hostname, tmpdir } from "node:os";
|
|
9
9
|
import { basename, delimiter, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
10
10
|
import { parseArgs } from "node:util";
|
|
11
11
|
import { parse, stringify } from "yaml";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
12
13
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
14
|
import { createServer } from "node:http";
|
|
14
15
|
import { chromium } from "playwright";
|
|
15
|
-
|
|
16
|
+
/** Every package the lock file installs below node_modules, by its path: the version it asks for and whether it is optional. */
|
|
17
|
+
function lockedPackages(dir) {
|
|
18
|
+
const file = join(dir, "package-lock.json");
|
|
19
|
+
if (!existsSync(file)) return void 0;
|
|
20
|
+
const lock = JSON.parse(readFileSync(file, "utf8"));
|
|
21
|
+
return new Map(Object.entries(lock.packages ?? {}).filter(([path, entry]) => path.startsWith("node_modules/") && entry.link !== true));
|
|
22
|
+
}
|
|
23
|
+
/** The version of the package installed at `path` (below `dir`), or undefined when none is installed there. */
|
|
24
|
+
function installedVersion(dir, path) {
|
|
25
|
+
try {
|
|
26
|
+
const manifest = JSON.parse(readFileSync(join(dir, path, "package.json"), "utf8"));
|
|
27
|
+
return typeof manifest.version === "string" ? manifest.version : "";
|
|
28
|
+
} catch {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The installed version of every package the lock file names, as one line per package ("path version", "path -" when
|
|
34
|
+
* none is installed), sorted: what node_modules holds, for the key of a cached result. Undefined without a lock file.
|
|
35
|
+
*/
|
|
36
|
+
function installedPackages(dir) {
|
|
37
|
+
const locked = lockedPackages(dir);
|
|
38
|
+
if (locked === void 0) return void 0;
|
|
39
|
+
return [...locked.keys()].sort().map((path) => `${path} ${installedVersion(dir, path) ?? "-"}`).join("\n");
|
|
40
|
+
}
|
|
41
|
+
/** How many mismatches a finding names before it says how many more there are. */
|
|
42
|
+
const SHOWN = 5;
|
|
43
|
+
/**
|
|
44
|
+
* Whether node_modules holds what package-lock.json asks for: every package at its version. An optional package that
|
|
45
|
+
* is not installed (one for another platform) is no mismatch. Empty without a lock file or when everything matches;
|
|
46
|
+
* else one problem that names the first mismatches.
|
|
47
|
+
*/
|
|
48
|
+
function installedProblems(dir) {
|
|
49
|
+
const locked = lockedPackages(dir);
|
|
50
|
+
if (locked === void 0 || locked.size === 0) return [];
|
|
51
|
+
const mismatches = [];
|
|
52
|
+
for (const [path, entry] of [...locked].sort(([a], [b]) => a.localeCompare(b))) {
|
|
53
|
+
const installed = installedVersion(dir, path);
|
|
54
|
+
if (installed === void 0 && entry.optional === true) continue;
|
|
55
|
+
if (installed !== void 0 && (entry.version === void 0 || installed === entry.version)) continue;
|
|
56
|
+
const name = path.slice(path.lastIndexOf("node_modules/") + 13);
|
|
57
|
+
mismatches.push(installed === void 0 ? `${name} is not installed (the lock file asks for ${entry.version ?? "it"})` : `${name} ${installed} is installed, the lock file asks for ${entry.version}`);
|
|
58
|
+
}
|
|
59
|
+
if (mismatches.length === 0) return [];
|
|
60
|
+
const more = mismatches.length > SHOWN ? ` and ${mismatches.length - SHOWN} more` : "";
|
|
61
|
+
return [{
|
|
62
|
+
where: "node_modules",
|
|
63
|
+
found: `node_modules does not hold what package-lock.json asks for: ${mismatches.slice(0, SHOWN).join("; ")}${more}. CI installs with npm ci, so the check would test other packages than CI`,
|
|
64
|
+
fix: "Run npm ci, which installs exactly what package-lock.json asks for, then check again"
|
|
65
|
+
}];
|
|
66
|
+
}
|
|
67
|
+
/** A warning when this Node has another major version than CI, or undefined. */
|
|
68
|
+
function nodeMajorProblem(version = process.versions.node) {
|
|
69
|
+
if (Number(version.split(".")[0]) === 26) return void 0;
|
|
70
|
+
return {
|
|
71
|
+
where: "node",
|
|
72
|
+
found: `Node ${version} runs this check, CI runs Node 26: what passes here may fail in CI`,
|
|
73
|
+
fix: `Use Node 26 (for example nvm install 26); the check does not fail for this`,
|
|
74
|
+
severity: "warning"
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const git$3 = (cwd, ...args) => {
|
|
78
|
+
const result = spawnSync("git", args, {
|
|
79
|
+
cwd,
|
|
80
|
+
encoding: "utf8"
|
|
81
|
+
});
|
|
82
|
+
return result.status === 0 ? result.stdout : void 0;
|
|
83
|
+
};
|
|
84
|
+
/** The folder of the cache and the lock: <git common dir>/lernapps, or undefined outside a repository. */
|
|
85
|
+
function stateDir(cwd = ".") {
|
|
86
|
+
const common = git$3(cwd, "rev-parse", "--git-common-dir")?.trim();
|
|
87
|
+
return common === void 0 || common === "" ? void 0 : join(resolve(cwd, common), "lernapps");
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The key of the state in `cwd` for a check of `parts`, or the reason it has none: not a repository, no commit, a
|
|
91
|
+
* working tree with changes git sees (untracked files included), or a run in CI.
|
|
92
|
+
*/
|
|
93
|
+
function cacheKey(cwd, parts, toolingVersion, env = process.env) {
|
|
94
|
+
if (env["CI"] !== void 0 && env["CI"] !== "" && env["CI"] !== "false") return { none: "CI checks every time" };
|
|
95
|
+
const top = git$3(cwd, "rev-parse", "--show-toplevel")?.trim();
|
|
96
|
+
if (top === void 0) return { none: "not a git repository" };
|
|
97
|
+
const tree = git$3(cwd, "rev-parse", "--verify", "--quiet", "HEAD^{tree}")?.trim();
|
|
98
|
+
if (tree === void 0 || tree === "") return { none: "no commit yet" };
|
|
99
|
+
const status = git$3(top, "status", "--porcelain", "--untracked-files=all");
|
|
100
|
+
if (status === void 0) return { none: "git status failed" };
|
|
101
|
+
if (status.trim() !== "") return { none: "the working tree has changes that are not committed" };
|
|
102
|
+
const variables = Object.keys(env).filter((name) => name.startsWith("LERNAPPS_")).sort().map((name) => `${name}=${env[name]}`);
|
|
103
|
+
const hash = createHash("sha256");
|
|
104
|
+
for (const part of [
|
|
105
|
+
`tree ${tree}`,
|
|
106
|
+
`folder ${relative(top, resolve(cwd)).split("\\").join("/")}`,
|
|
107
|
+
`parts ${[...parts].sort().join(",")}`,
|
|
108
|
+
`tooling ${toolingVersion}`,
|
|
109
|
+
`node ${process.versions.node}`,
|
|
110
|
+
...variables,
|
|
111
|
+
`installed\n${installedPackages(cwd) ?? ""}`
|
|
112
|
+
]) {
|
|
113
|
+
hash.update(part);
|
|
114
|
+
hash.update("\0");
|
|
115
|
+
}
|
|
116
|
+
return { key: hash.digest("hex") };
|
|
117
|
+
}
|
|
118
|
+
const checkedFile = (dir, key) => join(dir, "checked", `${key}.yaml`);
|
|
119
|
+
/** The report of a passing check of this state, and when it was made; undefined when there is none. */
|
|
120
|
+
function cachedReport(dir, key) {
|
|
121
|
+
const file = checkedFile(dir, key);
|
|
122
|
+
try {
|
|
123
|
+
return {
|
|
124
|
+
report: readFileSync(file, "utf8"),
|
|
125
|
+
at: statSync(file).mtime
|
|
126
|
+
};
|
|
127
|
+
} catch {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Keeps the report of a passing check of this state; the cache keeps the newest KEEP states. */
|
|
132
|
+
function storeReport(dir, key, report) {
|
|
133
|
+
const folder = join(dir, "checked");
|
|
134
|
+
mkdirSync(folder, { recursive: true });
|
|
135
|
+
writeFileSync(checkedFile(dir, key), report);
|
|
136
|
+
const files = readdirSync(folder).filter((name) => name.endsWith(".yaml")).map((name) => ({
|
|
137
|
+
file: join(folder, name),
|
|
138
|
+
at: statSync(join(folder, name)).mtimeMs
|
|
139
|
+
})).sort((a, b) => b.at - a.at);
|
|
140
|
+
for (const { file } of files.slice(20)) rmSync(file, { force: true });
|
|
141
|
+
}
|
|
142
|
+
/** Whether a process with this id runs on this machine. */
|
|
143
|
+
function alive(pid) {
|
|
144
|
+
try {
|
|
145
|
+
process.kill(pid, 0);
|
|
146
|
+
return true;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return error.code === "EPERM";
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function readOwner(lock) {
|
|
152
|
+
try {
|
|
153
|
+
return JSON.parse(readFileSync(join(lock, "owner.json"), "utf8"));
|
|
154
|
+
} catch {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
159
|
+
/**
|
|
160
|
+
* Takes the lock of checks in this clone (a folder, made atomically with mkdir), waiting while another check holds
|
|
161
|
+
* it; `waiting` is called once, with its owner, when it has to wait. A lock whose process no longer runs on this
|
|
162
|
+
* machine is taken over. Returns the function that releases it; the lock is also released when the process exits or
|
|
163
|
+
* is interrupted.
|
|
164
|
+
*/
|
|
165
|
+
async function acquireLock(dir, waiting, poll = 1e3) {
|
|
166
|
+
mkdirSync(dir, { recursive: true });
|
|
167
|
+
const lock = join(dir, "check.lock");
|
|
168
|
+
let told = false;
|
|
169
|
+
for (;;) {
|
|
170
|
+
try {
|
|
171
|
+
mkdirSync(lock);
|
|
172
|
+
break;
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (error.code !== "EEXIST") throw error;
|
|
175
|
+
}
|
|
176
|
+
const owner = readOwner(lock);
|
|
177
|
+
if (owner !== void 0 && owner.host === hostname() && !alive(owner.pid)) {
|
|
178
|
+
rmSync(lock, {
|
|
179
|
+
recursive: true,
|
|
180
|
+
force: true
|
|
181
|
+
});
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const made = statSync(lock, { throwIfNoEntry: false })?.mtimeMs;
|
|
185
|
+
if (owner === void 0 && made !== void 0 && Date.now() - made > 10 * poll) {
|
|
186
|
+
rmSync(lock, {
|
|
187
|
+
recursive: true,
|
|
188
|
+
force: true
|
|
189
|
+
});
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (!told) {
|
|
193
|
+
waiting(owner);
|
|
194
|
+
told = true;
|
|
195
|
+
}
|
|
196
|
+
await sleep(poll);
|
|
197
|
+
}
|
|
198
|
+
writeFileSync(join(lock, "owner.json"), JSON.stringify({
|
|
199
|
+
pid: process.pid,
|
|
200
|
+
host: hostname(),
|
|
201
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
202
|
+
}));
|
|
203
|
+
const release = () => {
|
|
204
|
+
process.off("exit", release);
|
|
205
|
+
process.off("SIGINT", interrupted);
|
|
206
|
+
process.off("SIGTERM", interrupted);
|
|
207
|
+
if (readOwner(lock)?.pid === process.pid) rmSync(lock, {
|
|
208
|
+
recursive: true,
|
|
209
|
+
force: true
|
|
210
|
+
});
|
|
211
|
+
};
|
|
212
|
+
const interrupted = (signal) => {
|
|
213
|
+
release();
|
|
214
|
+
process.exit(signal === "SIGINT" ? 130 : 143);
|
|
215
|
+
};
|
|
216
|
+
process.on("exit", release);
|
|
217
|
+
process.on("SIGINT", interrupted);
|
|
218
|
+
process.on("SIGTERM", interrupted);
|
|
219
|
+
return release;
|
|
220
|
+
}
|
|
16
221
|
// check/run.ts
|
|
17
222
|
const RULE_PAGE = "https://lernapps.net/tooling/rules/#";
|
|
18
223
|
const finding = (rule, severity, problem) => ({
|
|
@@ -644,6 +849,10 @@ const CHECK_OPTIONS = {
|
|
|
644
849
|
type: "boolean",
|
|
645
850
|
default: false
|
|
646
851
|
},
|
|
852
|
+
"no-cache": {
|
|
853
|
+
type: "boolean",
|
|
854
|
+
default: false
|
|
855
|
+
},
|
|
647
856
|
entry: { type: "string" },
|
|
648
857
|
report: { type: "string" },
|
|
649
858
|
help: {
|
|
@@ -652,7 +861,7 @@ const CHECK_OPTIONS = {
|
|
|
652
861
|
default: false
|
|
653
862
|
}
|
|
654
863
|
};
|
|
655
|
-
const CHECK_USAGE = `Usage: lernapps check [--pre-commit] [--pre-push] [<dir|url>] [--entry <file>] [--report <file>]
|
|
864
|
+
const CHECK_USAGE = `Usage: lernapps check [--pre-commit] [--pre-push] [--no-cache] [<dir|url>] [--entry <file>] [--report <file>]
|
|
656
865
|
|
|
657
866
|
Runs every check that has something to check. Passing, it prints one line; otherwise the validation report in
|
|
658
867
|
YAML (schema: https://lernapps.net/tooling/schemas/validation-report.v1.schema.json). Every finding names its
|
|
@@ -669,7 +878,9 @@ In a repo (no <dir|url>):
|
|
|
669
878
|
.arc42ignore does not list; a hint to review an element under whose path code changed is a
|
|
670
879
|
warning. Before the first commit arc42 validate --strict
|
|
671
880
|
--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/,
|
|
881
|
+
checks of the built app in dist/, of an explainer in format 2 its strand, page
|
|
882
|
+
competencies, recap pages and prerequisites of other strands against the learning map
|
|
883
|
+
(https://lernapps.net/apps/map/), the Playwright tests when playwright.config.* exists,
|
|
673
884
|
dependency licences, the lock file (the pre-push hook); a failure increments prePushFailures in the front
|
|
674
885
|
matter of the current plan (the highest number in .vibe/plans/, else .vibe/plan.md). A
|
|
675
886
|
failing generic test of the archetype's runtime (@lernapps/app-templates) is named as a test
|
|
@@ -681,8 +892,20 @@ In a repo (no <dir|url>):
|
|
|
681
892
|
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
893
|
site). Then it prints one line and writes no report. Anything else, or changed files it cannot know, runs the
|
|
683
894
|
part. CI decides the same in the app check action (lernapps changes --help).
|
|
895
|
+
Every run in a repo first compares node_modules with package-lock.json: a package installed at another
|
|
896
|
+
version, or missing, fails the run before anything else (run npm ci), since CI installs with npm ci.
|
|
897
|
+
The heavy part (--pre-push, or both) then:
|
|
898
|
+
- reuses a passing result of the same state: the tree of HEAD, the folder, the parts, the versions of
|
|
899
|
+
the tooling and of Node, the packages installed and every LERNAPPS_* variable, with a working tree
|
|
900
|
+
that has no changes git sees (untracked files included). It prints that line with "already checked".
|
|
901
|
+
Kept in <git common dir>/lernapps/, which the worktrees of a clone share; never in CI (CI set)
|
|
902
|
+
- waits for another check of the heavy part in this clone (and its worktrees), saying so in one line
|
|
903
|
+
on standard error; a lock whose process no longer runs is taken over
|
|
904
|
+
- warns in one line on standard error when Node has another major version than CI (no finding:
|
|
905
|
+
the report is the same for the same state wherever it is checked)
|
|
684
906
|
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
|
|
907
|
+
passing or not; the app check action keeps it. A run that checked writes how long each step took to
|
|
908
|
+
node_modules/.cache/lernapps/timings.json; the passing line ends with the whole time. A failed run counts each rule with an error in failedChecks
|
|
686
909
|
of the current plan's front matter, when it has the key.
|
|
687
910
|
|
|
688
911
|
On a built app, without its repo:
|
|
@@ -712,6 +935,7 @@ Options:
|
|
|
712
935
|
#<section> (or #<section>/<sub>) is an element id of its page as the server sends it, the
|
|
713
936
|
fitness values match; without <dir|url> the app at the entry's url is checked
|
|
714
937
|
--report <file> also write the full report to <file>, passing or not
|
|
938
|
+
--no-cache check again even when this state passed before (in a repo)
|
|
715
939
|
-h, --help print this help
|
|
716
940
|
|
|
717
941
|
`;
|
|
@@ -829,8 +1053,9 @@ details of a figure, such as where a line of a drawing points, in its own image.
|
|
|
829
1053
|
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
1054
|
(one file, images relative, no network).
|
|
831
1055
|
|
|
832
|
-
The built app is <dir
|
|
833
|
-
|
|
1056
|
+
The built app is <dir> (build it first); default dist/ (npx vp build, as lernapps check builds) or _site/ (npm run
|
|
1057
|
+
build), whichever has an index.html, the newer when both have one. Its pages are those lernapps check visits; the
|
|
1058
|
+
start page comes first. Each is opened in Chromium, as a learner first sees it: nothing answered, no hover, animations off,
|
|
834
1059
|
fonts and images loaded; requests to other hosts are blocked. A figure is a block figure or labelled-figure, or any
|
|
835
1060
|
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
1061
|
as the build writes it, without JavaScript, so that the figures of every question of a quiz are there too; a figure
|
|
@@ -1096,24 +1321,24 @@ function loadRules(dir) {
|
|
|
1096
1321
|
};
|
|
1097
1322
|
}
|
|
1098
1323
|
const ZERO = /^0+$/;
|
|
1099
|
-
const git$
|
|
1324
|
+
const git$2 = (cwd, ...args) => spawnSync("git", args, {
|
|
1100
1325
|
cwd,
|
|
1101
1326
|
encoding: "utf8",
|
|
1102
1327
|
maxBuffer: 67108864
|
|
1103
1328
|
});
|
|
1104
1329
|
/** The paths of `git diff --name-only -z`; renames as a deletion and an addition, so both paths count. */
|
|
1105
1330
|
function diffNames(cwd, ...args) {
|
|
1106
|
-
const diff = git$
|
|
1331
|
+
const diff = git$2(cwd, "diff", "--name-only", "--no-renames", "-z", ...args);
|
|
1107
1332
|
if (diff.error || diff.status !== 0) return void 0;
|
|
1108
1333
|
return diff.stdout.split("\0").filter((path) => path !== "");
|
|
1109
1334
|
}
|
|
1110
|
-
const isCommit = (cwd, rev) => git$
|
|
1335
|
+
const isCommit = (cwd, rev) => git$2(cwd, "cat-file", "-e", `${rev}^{commit}`).status === 0;
|
|
1111
1336
|
/**
|
|
1112
1337
|
* What a new branch is compared with: its merge base with origin/main; when the remote has no main yet (the first push
|
|
1113
1338
|
* of a new repo), the empty tree, so the branch adds everything it holds (nothing, for an empty start commit).
|
|
1114
1339
|
*/
|
|
1115
1340
|
function newBranchBase(cwd, local) {
|
|
1116
|
-
if (git$
|
|
1341
|
+
if (git$2(cwd, "rev-parse", "--verify", "--quiet", "origin/main").status !== 0) {
|
|
1117
1342
|
const empty = spawnSync("git", [
|
|
1118
1343
|
"hash-object",
|
|
1119
1344
|
"-t",
|
|
@@ -1130,7 +1355,7 @@ function newBranchBase(cwd, local) {
|
|
|
1130
1355
|
};
|
|
1131
1356
|
}
|
|
1132
1357
|
return {
|
|
1133
|
-
base: git$
|
|
1358
|
+
base: git$2(cwd, "merge-base", local, "origin/main").stdout.trim(),
|
|
1134
1359
|
tree: false
|
|
1135
1360
|
};
|
|
1136
1361
|
}
|
|
@@ -1163,8 +1388,8 @@ function pushedFiles(lines, cwd = ".") {
|
|
|
1163
1388
|
}
|
|
1164
1389
|
/** Fetches the whole history once, when the clone is shallow (as actions/checkout makes it by default). */
|
|
1165
1390
|
function unshallow(cwd) {
|
|
1166
|
-
if (git$
|
|
1167
|
-
return git$
|
|
1391
|
+
if (git$2(cwd, "rev-parse", "--is-shallow-repository").stdout.trim() !== "true") return false;
|
|
1392
|
+
return git$2(cwd, "fetch", "--quiet", "--no-tags", "--unshallow", "origin").status === 0;
|
|
1168
1393
|
}
|
|
1169
1394
|
/**
|
|
1170
1395
|
* The files between two commits: with `mergeBase`, what `head` adds to its merge base with `base` (a pull request);
|
|
@@ -1174,7 +1399,7 @@ function unshallow(cwd) {
|
|
|
1174
1399
|
function rangeFiles(base, head, mergeBase, cwd = ".") {
|
|
1175
1400
|
const attempt = () => {
|
|
1176
1401
|
if (!isCommit(cwd, base) || !isCommit(cwd, head)) return void 0;
|
|
1177
|
-
const from = mergeBase ? git$
|
|
1402
|
+
const from = mergeBase ? git$2(cwd, "merge-base", base, head).stdout.trim() : base;
|
|
1178
1403
|
return from === "" ? void 0 : diffNames(cwd, from, head);
|
|
1179
1404
|
};
|
|
1180
1405
|
return attempt() ?? (unshallow(cwd) ? attempt() : void 0);
|
|
@@ -1436,7 +1661,8 @@ node_modules init wrote). From the installed package @lernapps/app-templates, th
|
|
|
1436
1661
|
on, it writes the archetype's scaffold: the files of the template folder <name>/ that the package's lernapps.scaffold
|
|
1437
1662
|
lists (the entry, the configuration, the end-to-end tests of the content) and a placeholder as start content
|
|
1438
1663
|
(scaffold/<name>/), which the planned content replaces. The template folder itself is the example app: it stays in
|
|
1439
|
-
node_modules/@lernapps/app-templates/<name>/, to look up. The app depends on the runtime of the same version
|
|
1664
|
+
node_modules/@lernapps/app-templates/<name>/, to look up. The app depends on the runtime of the same version (or
|
|
1665
|
+
the same commit, while @lernapps/tooling depends on an unreleased one).
|
|
1440
1666
|
|
|
1441
1667
|
It needs the plan of the first version, confirmed by the creator: started from the plan template as
|
|
1442
1668
|
.vibe/plans/01-<short name>.md, the only plan there, worked through Explore and Plan, now in the phase code
|
|
@@ -1461,7 +1687,10 @@ publish action to the repo's GitHub Pages), an issue form for content errors, LI
|
|
|
1461
1687
|
Renovate preset of lernapps). The installed skills and skills-lock.json are committed with the app, like the
|
|
1462
1688
|
content guide; lernapps update replaces the lernapps-* skills.
|
|
1463
1689
|
|
|
1464
|
-
|
|
1690
|
+
It installs nothing and commits nothing. Its last lines say what to do next, in this order: git init
|
|
1691
|
+
--initial-branch=work (only if <dir> is not a repository yet; lernapps init made one), npm install (installs the
|
|
1692
|
+
runtime and the git hooks), git add -A and git commit (the first commit, on work; the hooks check it), then follow
|
|
1693
|
+
the plan.
|
|
1465
1694
|
|
|
1466
1695
|
Exit codes: 0 when the app is written, 1 when it cannot be (the message says why), 2 on a usage error.
|
|
1467
1696
|
|
|
@@ -1471,8 +1700,8 @@ Options:
|
|
|
1471
1700
|
its file name <NN>-<short name>.md, else 01-plan.md (default: the one in <dir>)
|
|
1472
1701
|
--templates <dir> the package @lernapps/app-templates to copy from: its folder, or a clone of its repository
|
|
1473
1702
|
(default: the environment variable LERNAPPS_TEMPLATES, else the installed package)
|
|
1474
|
-
--tooling <spec> the npm spec of @lernapps/tooling the app depends on (default:
|
|
1475
|
-
@lernapps/tooling)
|
|
1703
|
+
--tooling <spec> the npm spec of @lernapps/tooling the app depends on (default: the one lernapps init
|
|
1704
|
+
installed, else ^ the version of this @lernapps/tooling)
|
|
1476
1705
|
-h, --help print this help
|
|
1477
1706
|
`;
|
|
1478
1707
|
/** The generator cannot write the app; the message says why and what to do. */
|
|
@@ -1504,6 +1733,17 @@ function templatesPackage(dir) {
|
|
|
1504
1733
|
scaffold: manifest.lernapps?.scaffold ?? {}
|
|
1505
1734
|
};
|
|
1506
1735
|
}
|
|
1736
|
+
/** The version of a package built from a commit, not released: its package.json in the repository says 0.0.0. */
|
|
1737
|
+
const UNRELEASED = "0.0.0";
|
|
1738
|
+
/**
|
|
1739
|
+
* The spec of the runtime an app depends on, for templates of `version`: ^ that version; for templates built from a
|
|
1740
|
+
* commit (version 0.0.0, which the registry does not have) the spec this tooling depends on, the same commit
|
|
1741
|
+
* (github:lernapps/app-templates#<commit>), so that template and runtime match.
|
|
1742
|
+
*/
|
|
1743
|
+
function runtimeSpec(version) {
|
|
1744
|
+
if (version !== UNRELEASED) return `^${version}`;
|
|
1745
|
+
return readJson(fromPackage("package.json")).dependencies?.["@lernapps/app-templates"] ?? `^${version}`;
|
|
1746
|
+
}
|
|
1507
1747
|
/** The version of this package. */
|
|
1508
1748
|
const ownVersion = () => readJson(fromPackage("package.json")).version ?? "0.0.0";
|
|
1509
1749
|
/** The lock file of `npx skills add`, next to the skills it installed. */
|
|
@@ -1536,7 +1776,7 @@ function contents(dir) {
|
|
|
1536
1776
|
return [name];
|
|
1537
1777
|
});
|
|
1538
1778
|
}
|
|
1539
|
-
function git(args, cwd) {
|
|
1779
|
+
function git$1(args, cwd) {
|
|
1540
1780
|
const result = spawnSync("git", args, {
|
|
1541
1781
|
cwd,
|
|
1542
1782
|
encoding: "utf8"
|
|
@@ -1570,6 +1810,8 @@ function writeScaffold(templates, archetype, dir) {
|
|
|
1570
1810
|
if (!existsSync(start)) throw new CreateError(`${templates.root} has no start content scaffold/${archetype}/`);
|
|
1571
1811
|
cpSync(start, dir, { recursive: true });
|
|
1572
1812
|
}
|
|
1813
|
+
/** The branch the plan works on (rule 8 of the plan template). */
|
|
1814
|
+
const WORK_BRANCH = "work";
|
|
1573
1815
|
/** This package, as an app depends on it. */
|
|
1574
1816
|
const TOOLING = "@lernapps/tooling";
|
|
1575
1817
|
/**
|
|
@@ -1735,7 +1977,7 @@ function create(args) {
|
|
|
1735
1977
|
};
|
|
1736
1978
|
if (app.devDependencies?.["@lernapps/app-templates"] !== void 0) app.devDependencies = {
|
|
1737
1979
|
...app.devDependencies,
|
|
1738
|
-
[RUNTIME]:
|
|
1980
|
+
[RUNTIME]: runtimeSpec(templates.version)
|
|
1739
1981
|
};
|
|
1740
1982
|
const overrides = app["overrides"];
|
|
1741
1983
|
if (typeof overrides === "object" && overrides !== null && "@lernapps/app-templates" in overrides) {
|
|
@@ -1743,7 +1985,7 @@ function create(args) {
|
|
|
1743
1985
|
if (Object.keys(kept).length > 0) app["overrides"] = kept;
|
|
1744
1986
|
else delete app["overrides"];
|
|
1745
1987
|
}
|
|
1746
|
-
const tooling = values.tooling ?? `^${ownVersion()}`;
|
|
1988
|
+
const tooling = values.tooling ?? initialised?.devDependencies?.["@lernapps/tooling"] ?? `^${ownVersion()}`;
|
|
1747
1989
|
app.devDependencies = {
|
|
1748
1990
|
...app.devDependencies,
|
|
1749
1991
|
...ARCHETYPE_DEV_DEPENDENCIES[archetype],
|
|
@@ -1752,7 +1994,7 @@ function create(args) {
|
|
|
1752
1994
|
};
|
|
1753
1995
|
app.devDependencies = Object.fromEntries(Object.entries(app.devDependencies).sort(([a], [b]) => a.localeCompare(b)));
|
|
1754
1996
|
writeJson(join(dir, "package.json"), app);
|
|
1755
|
-
const holder = git(["config", "user.name"], existsSync(dir) ? dir : process.cwd());
|
|
1997
|
+
const holder = git$1(["config", "user.name"], existsSync(dir) ? dir : process.cwd());
|
|
1756
1998
|
const moved = resolve(plan.file) === resolve(join(dir, LEGACY_PLAN));
|
|
1757
1999
|
writePlan(dir, plan);
|
|
1758
2000
|
writeAppFiles(dir, {
|
|
@@ -1765,7 +2007,13 @@ function create(args) {
|
|
|
1765
2007
|
`lernapps create: the ${archetype} app ${name} in ${dir} (app-templates ${templates.version})`,
|
|
1766
2008
|
...guide.map((line) => `Content guide: ${line}`),
|
|
1767
2009
|
...moved ? [`Plan: moved ${LEGACY_PLAN} to ${relative(dir, plan.target)}`] : [],
|
|
1768
|
-
|
|
2010
|
+
"Next, in this order (create installs and commits nothing):",
|
|
2011
|
+
...[
|
|
2012
|
+
...existsSync(join(dir, ".git")) ? [] : [`git init --initial-branch=${WORK_BRANCH}`],
|
|
2013
|
+
"npm install (the runtime and the git hooks)",
|
|
2014
|
+
"git add -A, then git commit (the first commit; the hooks check it)",
|
|
2015
|
+
`follow ${relative(dir, plan.target)}`
|
|
2016
|
+
].map((step, index) => ` ${index + 1}. ${step}`),
|
|
1769
2017
|
""
|
|
1770
2018
|
].join("\n"));
|
|
1771
2019
|
return 0;
|
|
@@ -1774,17 +2022,19 @@ function create(args) {
|
|
|
1774
2022
|
/** The options of `lernapps init`, as node:util parseArgs takes them. */
|
|
1775
2023
|
const INIT_OPTIONS = {
|
|
1776
2024
|
addon: { type: "string" },
|
|
2025
|
+
tooling: { type: "string" },
|
|
1777
2026
|
help: {
|
|
1778
2027
|
type: "boolean",
|
|
1779
2028
|
short: "h",
|
|
1780
2029
|
default: false
|
|
1781
2030
|
}
|
|
1782
2031
|
};
|
|
1783
|
-
const INIT_USAGE = `Usage: lernapps init <short name> [--addon <package>]
|
|
2032
|
+
const INIT_USAGE = `Usage: lernapps init <short name> [--addon <package>] [--tooling <spec>]
|
|
1784
2033
|
|
|
1785
2034
|
Starts an app in the current folder, which must be empty except for .git. It needs git, Node and the npm registry; nothing
|
|
1786
2035
|
else. In order:
|
|
1787
|
-
git repository on the branch work, which the plan names
|
|
2036
|
+
git repository on the branch work, which the plan names; no commit. A repository without commits
|
|
2037
|
+
switches to work; one with commits stays, and init warns: git switch -c work
|
|
1788
2038
|
package.json names the folder, depends on @lernapps/tooling (this version) and the add-on
|
|
1789
2039
|
npm install installs them
|
|
1790
2040
|
.vibe/plans/01-<short name>.md
|
|
@@ -1796,6 +2046,12 @@ Then follow the plan: it says what to do, and when to wait for the creator.
|
|
|
1796
2046
|
|
|
1797
2047
|
Options:
|
|
1798
2048
|
--addon <package> a package that extends the tooling, e.g. @lernapps/practice: its plan template and its skills
|
|
2049
|
+
--tooling <spec> for maintainers: the npm spec of @lernapps/tooling to install instead of this version, e.g.
|
|
2050
|
+
file:/path/to/lernapps-tooling-0.0.0.tgz or github:lernapps/tooling#<commit>; give the same
|
|
2051
|
+
to lernapps create. Without it, a tooling of version 0.0.0 (a local build, not on the
|
|
2052
|
+
registry) is packed with npm pack into a temporary folder and installed from that tarball
|
|
2053
|
+
when its folder is a git checkout (file:<tarball>), with its dependencies, as from the
|
|
2054
|
+
registry
|
|
1799
2055
|
-h, --help print this help
|
|
1800
2056
|
`;
|
|
1801
2057
|
/** The options of `lernapps skills`. */
|
|
@@ -1822,25 +2078,57 @@ const SKILLS_BIN = () => {
|
|
|
1822
2078
|
const bin = readJson(manifest)["bin"];
|
|
1823
2079
|
return join(dirname(manifest), bin?.["skills"] ?? "bin/cli.mjs");
|
|
1824
2080
|
};
|
|
1825
|
-
/**
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
* Makes the folder a git repository on the branch `work`, which the plan names, unless it is one already: `create` and
|
|
1829
|
-
* the hooks need it, and the plan's first rule is to work there. Returns what it did, for the output; no commit.
|
|
1830
|
-
*/
|
|
1831
|
-
function ensureRepository(dir) {
|
|
1832
|
-
if (existsSync(join(dir, ".git"))) return void 0;
|
|
1833
|
-
const result = spawnSync("git", [
|
|
1834
|
-
"init",
|
|
1835
|
-
"--quiet",
|
|
1836
|
-
`--initial-branch=${WORK_BRANCH}`
|
|
1837
|
-
], {
|
|
2081
|
+
/** A git command in `dir`; a git that cannot run is an error of init. */
|
|
2082
|
+
function git(dir, args) {
|
|
2083
|
+
const result = spawnSync("git", args, {
|
|
1838
2084
|
cwd: dir,
|
|
1839
2085
|
encoding: "utf8"
|
|
1840
2086
|
});
|
|
1841
2087
|
if (result.error) throw new CreateError(`git cannot run: ${result.error.message}. Install git, then run init again`);
|
|
1842
|
-
|
|
1843
|
-
|
|
2088
|
+
return {
|
|
2089
|
+
ok: result.status === 0,
|
|
2090
|
+
out: `${result.stdout}${result.stderr}`.trim()
|
|
2091
|
+
};
|
|
2092
|
+
}
|
|
2093
|
+
/**
|
|
2094
|
+
* Puts the folder on the branch `work`, which the plan names: `create` and the hooks need a repository, and the plan's
|
|
2095
|
+
* first rule is to work there. No repository yet → `git init` on `work`. A repository without commits (a fresh
|
|
2096
|
+
* `git init` on master or main, a clone of an empty GitHub repo) → its unborn branch becomes `work`: nothing is lost,
|
|
2097
|
+
* since there is nothing on it. A repository with commits on another branch is left as it is, with a warning that
|
|
2098
|
+
* names the command. No commit.
|
|
2099
|
+
*/
|
|
2100
|
+
function ensureRepository(dir) {
|
|
2101
|
+
if (!existsSync(join(dir, ".git"))) {
|
|
2102
|
+
const result = git(dir, [
|
|
2103
|
+
"init",
|
|
2104
|
+
"--quiet",
|
|
2105
|
+
`--initial-branch=${WORK_BRANCH}`
|
|
2106
|
+
]);
|
|
2107
|
+
if (!result.ok) throw new CreateError(`git init failed: ${result.out}`);
|
|
2108
|
+
return { done: `a git repository on the branch ${WORK_BRANCH}` };
|
|
2109
|
+
}
|
|
2110
|
+
const branch = git(dir, [
|
|
2111
|
+
"symbolic-ref",
|
|
2112
|
+
"--quiet",
|
|
2113
|
+
"--short",
|
|
2114
|
+
"HEAD"
|
|
2115
|
+
]);
|
|
2116
|
+
if (branch.ok && branch.out === "work") return {};
|
|
2117
|
+
if (!git(dir, [
|
|
2118
|
+
"rev-parse",
|
|
2119
|
+
"--verify",
|
|
2120
|
+
"--quiet",
|
|
2121
|
+
"HEAD"
|
|
2122
|
+
]).ok) {
|
|
2123
|
+
const result = git(dir, [
|
|
2124
|
+
"symbolic-ref",
|
|
2125
|
+
"HEAD",
|
|
2126
|
+
`refs/heads/${WORK_BRANCH}`
|
|
2127
|
+
]);
|
|
2128
|
+
if (!result.ok) throw new CreateError(`git could not switch to the branch ${WORK_BRANCH}: ${result.out}`);
|
|
2129
|
+
return { done: `the repository had no commit yet: now on the branch ${WORK_BRANCH}` };
|
|
2130
|
+
}
|
|
2131
|
+
return { warning: `the repository has commits and is ${branch.ok ? `on the branch ${branch.out}` : "on no branch (detached HEAD)"}, not on ${WORK_BRANCH}, which the plan works on. Run next: git switch -c ${WORK_BRANCH}` };
|
|
1844
2132
|
}
|
|
1845
2133
|
/** The names of the skills of the archetypes, with the archetype each belongs to (lernapps-<archetype>[-…]). */
|
|
1846
2134
|
function archetypeSkills() {
|
|
@@ -1879,6 +2167,37 @@ function syncSkills(dir, exclude) {
|
|
|
1879
2167
|
...filters.length > 0 ? ["--exclude", ...filters] : []
|
|
1880
2168
|
], (message) => new CreateError(message), dir);
|
|
1881
2169
|
}
|
|
2170
|
+
/** The version of a build that is not on the registry: package.json of the repository says 0.0.0. */
|
|
2171
|
+
const LOCAL_VERSION = "0.0.0";
|
|
2172
|
+
/**
|
|
2173
|
+
* The tarball of the package in `root`, packed by npm pack (with its prepare script, so the build is current) into a
|
|
2174
|
+
* new temporary folder.
|
|
2175
|
+
*/
|
|
2176
|
+
function packBuild(root) {
|
|
2177
|
+
const out = mkdtempSync(join(tmpdir(), "lernapps-tooling-"));
|
|
2178
|
+
run("npm", [
|
|
2179
|
+
"pack",
|
|
2180
|
+
"--silent",
|
|
2181
|
+
"--pack-destination",
|
|
2182
|
+
out
|
|
2183
|
+
], (message) => new CreateError(message), root);
|
|
2184
|
+
const tarball = readdirSync(out).find((name) => name.endsWith(".tgz"));
|
|
2185
|
+
if (tarball === void 0) throw new CreateError(`npm pack in ${root} wrote no tarball into ${out}`);
|
|
2186
|
+
return join(out, tarball);
|
|
2187
|
+
}
|
|
2188
|
+
/**
|
|
2189
|
+
* The spec of @lernapps/tooling that init installs: the one given, else this version. A local build (version 0.0.0,
|
|
2190
|
+
* which the registry does not have) is packed when its folder is a git checkout and installed from the tarball, else
|
|
2191
|
+
* init says to name one with --tooling. Not the folder itself (file:<folder>): npm links it, so the app's
|
|
2192
|
+
* node_modules lacks the tooling's dependencies (Playwright for the end-to-end tests, @lernapps/app-templates with
|
|
2193
|
+
* the templates and their READMEs); from a tarball they are installed into the app, as from the registry.
|
|
2194
|
+
*/
|
|
2195
|
+
function toolingSpec(given, version = ownVersion(), root = fromPackage(), pack = packBuild) {
|
|
2196
|
+
if (given !== void 0 && given !== "") return given;
|
|
2197
|
+
if (version !== LOCAL_VERSION) return version;
|
|
2198
|
+
if (existsSync(join(root, ".git"))) return `file:${pack(resolve(root))}`;
|
|
2199
|
+
throw new CreateError(`this @lernapps/tooling is a local build (version ${LOCAL_VERSION}), which the npm registry does not have: name the tooling to install with --tooling <spec>, e.g. file:/path/to/lernapps-tooling-${LOCAL_VERSION}.tgz (npm pack) or github:lernapps/tooling#<commit>, and give the same to lernapps create`);
|
|
2200
|
+
}
|
|
1882
2201
|
function init(args) {
|
|
1883
2202
|
const { values, positionals } = parseArgs({
|
|
1884
2203
|
args,
|
|
@@ -1896,6 +2215,7 @@ function init(args) {
|
|
|
1896
2215
|
const present = readdirSync(dir).filter((entry) => entry !== ".git");
|
|
1897
2216
|
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`);
|
|
1898
2217
|
const addon = values.addon;
|
|
2218
|
+
const tooling = toolingSpec(values.tooling);
|
|
1899
2219
|
const repository = ensureRepository(dir);
|
|
1900
2220
|
writeJson(join(dir, "package.json"), {
|
|
1901
2221
|
name,
|
|
@@ -1909,7 +2229,7 @@ function init(args) {
|
|
|
1909
2229
|
"--save-dev",
|
|
1910
2230
|
"--no-audit",
|
|
1911
2231
|
"--no-fund",
|
|
1912
|
-
`${TOOLING}@${
|
|
2232
|
+
`${TOOLING}@${tooling}`,
|
|
1913
2233
|
...addon ? [addon] : []
|
|
1914
2234
|
], fail);
|
|
1915
2235
|
const source = (packageName, ...path) => join(dir, "node_modules", packageName, ...path);
|
|
@@ -1922,7 +2242,8 @@ function init(args) {
|
|
|
1922
2242
|
mkdirSync(dirname(target), { recursive: true });
|
|
1923
2243
|
writeFileSync(target, readFileSync(template));
|
|
1924
2244
|
syncSkills(dir, archetypeSkills().map((skill) => skill.name));
|
|
1925
|
-
process.
|
|
2245
|
+
if (repository.warning !== void 0) process.stderr.write(`lernapps init: warning: ${repository.warning}\n`);
|
|
2246
|
+
process.stdout.write(`lernapps init: ${relative$1(dir, target)} from ${relativeTemplate(template, dir)}; the skills of the packages are installed${repository.done ? `; ${repository.done}` : ""}\nNext: ${repository.warning ? `git switch -c ${WORK_BRANCH}, then ` : ""}read ${relative$1(dir, target)} completely and follow it\n`);
|
|
1926
2247
|
return 0;
|
|
1927
2248
|
}
|
|
1928
2249
|
const relative$1 = (from, to) => to.startsWith(from) ? to.slice(from.length + 1) : to;
|
|
@@ -1960,13 +2281,16 @@ const UPDATE_USAGE = `Usage: lernapps update
|
|
|
1960
2281
|
Moves the app in the current folder to the versions of this @lernapps/tooling, then runs npm install. npm update
|
|
1961
2282
|
moves the app within its version ranges; this moves the ranges, and what npm does not know:
|
|
1962
2283
|
@lernapps/app-templates to the version this tooling depends on: the runtime its tests ran with. A newer version
|
|
1963
|
-
(e.g. from Renovate) is kept. A git commit, as apps once pinned, becomes a version
|
|
2284
|
+
(e.g. from Renovate) is kept. A git commit, as apps once pinned, becomes a version; while
|
|
2285
|
+
this tooling depends on a commit (an unreleased runtime), the app moves to that commit
|
|
1964
2286
|
@lernapps/tooling to the version of this tooling (unchanged when run from the app)
|
|
1965
2287
|
.github/workflows/ the actions of lernapps/tooling at the tag of that version
|
|
1966
2288
|
.agents/skills/app-content/SKILL.md
|
|
1967
2289
|
the content guide's skeleton, where the app has none yet (and its link for Claude Code)
|
|
1968
2290
|
.vibe/plan.md the plan of an app made before the plans had a folder: moved to
|
|
1969
2291
|
.vibe/plans/01-first-version.md, the first of its plans (one per piece of work)
|
|
2292
|
+
src/app.yaml of an explainer in format 1, when the runtime knows format 2: adds format: 2 and
|
|
2293
|
+
strand: none (the app is not on the learning map; its pages need no competencies)
|
|
1970
2294
|
.agents/skills/lernapps-*, .claude/skills/lernapps-*, skills-lock.json
|
|
1971
2295
|
replaced with the skills of this tooling, as npx skills add lays them out: those of
|
|
1972
2296
|
every phase, and of the archetypes only those of the current plan's archetype; every
|
|
@@ -2000,9 +2324,11 @@ function lowest(range) {
|
|
|
2000
2324
|
}
|
|
2001
2325
|
/** Whether `a` is older than `b`. */
|
|
2002
2326
|
const older = (a, b) => a[0] !== b[0] ? a[0] < b[0] : a[1] !== b[1] ? a[1] < b[1] : a[2] < b[2];
|
|
2003
|
-
/**
|
|
2004
|
-
|
|
2005
|
-
|
|
2327
|
+
/**
|
|
2328
|
+
* The next range of a dependency at `version` (target: ^ that version, or the spec given, e.g. a git commit of an
|
|
2329
|
+
* unreleased runtime), and what the output says about it.
|
|
2330
|
+
*/
|
|
2331
|
+
function nextRange(name, current, version, target = `^${version}`) {
|
|
2006
2332
|
if (current !== void 0 && /^(file:|link:|\.{0,2}\/)/.test(current)) return {
|
|
2007
2333
|
spec: current,
|
|
2008
2334
|
line: `${name}: kept ${current}, a local copy`
|
|
@@ -2013,7 +2339,7 @@ function nextRange(name, current, version) {
|
|
|
2013
2339
|
spec: current,
|
|
2014
2340
|
line: `${name}: unchanged (${current})`
|
|
2015
2341
|
};
|
|
2016
|
-
if (now !== void 0 && next !== void 0 && !older(now, next)) return {
|
|
2342
|
+
if (lowest(target) !== void 0 && now !== void 0 && next !== void 0 && !older(now, next)) return {
|
|
2017
2343
|
spec: current ?? target,
|
|
2018
2344
|
line: `${name}: kept ${current}, not moved to ${target}: it is not older`
|
|
2019
2345
|
};
|
|
@@ -2047,6 +2373,30 @@ function migratePlan() {
|
|
|
2047
2373
|
renameSync(LEGACY_PLAN, MIGRATED_PLAN);
|
|
2048
2374
|
return [`plan: moved ${LEGACY_PLAN} to ${MIGRATED_PLAN}, unchanged: each piece of work has its own plan in .vibe/plans/`];
|
|
2049
2375
|
}
|
|
2376
|
+
/** The explainer's app file, and the schema file that tells the runtime knows format 2 (learning map). */
|
|
2377
|
+
const EXPLAINER_APP = join("src", "app.yaml");
|
|
2378
|
+
const EXPLAINER_V2_SCHEMA = join("runtime", "explainer", "explainer.v2.schema.json");
|
|
2379
|
+
/**
|
|
2380
|
+
* Moves an explainer from format 1 to format 2 (the learning map): adds `format: 2` and `strand: none` to
|
|
2381
|
+
* src/app.yaml when the app has neither, and points its schema comment to explainer.v2.schema.json. `strand: none`
|
|
2382
|
+
* keeps the app off the map, so its pages need no competencies; the creator may name a strand later (skill
|
|
2383
|
+
* lernapps-content-research). Only where the runtime of this tooling has the v2 schema: an older runtime does not
|
|
2384
|
+
* know the fields. An app that is no explainer, or already has a `format` or a `strand`, stays as it is. What it did,
|
|
2385
|
+
* as lines of the output.
|
|
2386
|
+
*/
|
|
2387
|
+
function migrateExplainer(templatesRoot) {
|
|
2388
|
+
if (!existsSync(EXPLAINER_APP) || !existsSync(join(templatesRoot, EXPLAINER_V2_SCHEMA))) return [];
|
|
2389
|
+
const text = readFileSync(EXPLAINER_APP, "utf8");
|
|
2390
|
+
const top = (key) => new RegExp(`^${key}:`, "m").test(text);
|
|
2391
|
+
if (!top("chapters") || top("format") || top("strand")) return [];
|
|
2392
|
+
const schema = /^(# yaml-language-server: \$schema=.*)explainer\.v1\.schema\.json$/m;
|
|
2393
|
+
const fields = "format: 2\nstrand: none\n";
|
|
2394
|
+
let next;
|
|
2395
|
+
if (schema.test(text)) next = text.replace(schema, (_all, head) => `${head}explainer.v2.schema.json\n${fields.trimEnd()}`);
|
|
2396
|
+
else next = fields + text;
|
|
2397
|
+
writeFileSync(EXPLAINER_APP, next);
|
|
2398
|
+
return [`explainer: ${EXPLAINER_APP} moved to format 2: added format: 2 and strand: none (not on the learning map; pages need no competencies). To join the map, pick a strand of https://lernapps.net/apps/map/ and give every page competencies`];
|
|
2399
|
+
}
|
|
2050
2400
|
/**
|
|
2051
2401
|
* The skills of this tooling, as it ships them (skills/<group>/<name>/), and as the app keeps them (flat, by name).
|
|
2052
2402
|
* Every app has those of the groups process, tooling and content; of the group archetypes only those of its own
|
|
@@ -2255,7 +2605,7 @@ function update(args) {
|
|
|
2255
2605
|
const notMoved = [];
|
|
2256
2606
|
const runtime = devDependencies[RUNTIME];
|
|
2257
2607
|
if (runtime !== void 0) {
|
|
2258
|
-
const next = nextRange(RUNTIME, runtime, templates.version);
|
|
2608
|
+
const next = nextRange(RUNTIME, runtime, templates.version, runtimeSpec(templates.version));
|
|
2259
2609
|
if (next.spec !== runtime) specs.push(`${RUNTIME}@${next.spec}`);
|
|
2260
2610
|
lines.push(next.line);
|
|
2261
2611
|
}
|
|
@@ -2285,6 +2635,8 @@ function update(args) {
|
|
|
2285
2635
|
lines.push(...guide.map((line) => `content guide: ${line}`));
|
|
2286
2636
|
const plan = migratePlan();
|
|
2287
2637
|
lines.push(...plan);
|
|
2638
|
+
const explainer = migrateExplainer(templates.root);
|
|
2639
|
+
lines.push(...explainer);
|
|
2288
2640
|
const skills = [...replaceSkills(), ...unignoreSkills()];
|
|
2289
2641
|
lines.push(...skills);
|
|
2290
2642
|
const architecture = updateArchitecture();
|
|
@@ -2302,6 +2654,7 @@ function update(args) {
|
|
|
2302
2654
|
if (skillsMoved) files.push(SKILLS_LOCK);
|
|
2303
2655
|
if (skills.some((line) => line.includes(".gitignore"))) files.push(".gitignore");
|
|
2304
2656
|
if (architecture.lines.length > 0) files.push(`${ARCHITECTURE}/`);
|
|
2657
|
+
if (explainer.length > 0) files.push(EXPLAINER_APP);
|
|
2305
2658
|
if (plan.some((line) => line.startsWith("plan: moved"))) files.push(".vibe/ (git add -A .vibe, to keep the move)");
|
|
2306
2659
|
process.stdout.write(`Next: npx --no -- lernapps check, then commit ${files.slice(0, -1).join(", ")} and ${files.at(-1) ?? ""}\n`);
|
|
2307
2660
|
return 0;
|
|
@@ -2437,6 +2790,13 @@ async function shootFigures(browser, base, page, width, file) {
|
|
|
2437
2790
|
return count;
|
|
2438
2791
|
});
|
|
2439
2792
|
}
|
|
2793
|
+
/** Where an app's build is: dist/ (npx vp build, as lernapps check builds it) or _site/ (npm run build of the templates). */
|
|
2794
|
+
const BUILDS = ["dist", "_site"];
|
|
2795
|
+
const isBuild = (dir) => existsSync(dir) && statSync(dir).isDirectory() && existsSync(join(dir, "index.html"));
|
|
2796
|
+
/** The build to take without <dir>: of dist/ and _site/ the one with an index.html, the newer of both when both have one. */
|
|
2797
|
+
function defaultBundle() {
|
|
2798
|
+
return BUILDS.filter((dir) => isBuild(dir)).sort((a, b) => statSync(join(b, "index.html")).mtimeMs - statSync(join(a, "index.html")).mtimeMs)[0] ?? "dist";
|
|
2799
|
+
}
|
|
2440
2800
|
/**
|
|
2441
2801
|
* Writes the images and the index.html. Everything that can fail before is checked before the folder is touched, so a
|
|
2442
2802
|
* failed run writes nothing. Returns the line to print.
|
|
@@ -2451,8 +2811,9 @@ async function screenshots(args) {
|
|
|
2451
2811
|
if (positionals.length > 1) throw new UsageError(`one <dir> at most, not ${positionals.join(" ")}`);
|
|
2452
2812
|
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`);
|
|
2453
2813
|
const widths = [.../* @__PURE__ */ new Set([Number(values.width), ...values.desktop ? [DESKTOP] : []])];
|
|
2454
|
-
const
|
|
2455
|
-
|
|
2814
|
+
const named = positionals[0] ?? defaultBundle();
|
|
2815
|
+
const bundle = resolve(named);
|
|
2816
|
+
if (!isBuild(bundle)) throw new UsageError(positionals[0] === void 0 ? `neither ${BUILDS.map((dir) => `${dir}/`).join(" nor ")} has an index.html (build the app first: npx vp build writes dist/, npm run build _site/)` : `${named}: not a directory with index.html (build the app first)`);
|
|
2456
2817
|
const archetype = archetypeOf(readFileSync(join(bundle, "index.html"), "utf8"));
|
|
2457
2818
|
if (archetype === void 0 || !SUPPORTED.includes(archetype)) throw new ScreenshotsError(`screenshots support explainer and quiz, not ${archetype === void 0 ? "this app" : `the ${archetype} archetype`}`);
|
|
2458
2819
|
const out = resolve(values.out);
|
|
@@ -2514,6 +2875,34 @@ const REPORT = "node_modules/.cache/lernapps/validation-report.yaml";
|
|
|
2514
2875
|
const CHECKS_PASS = "checks-pass";
|
|
2515
2876
|
/** A built app or a URL that could not be checked at all: a platform rule, unlike the toolchain's checks-pass. */
|
|
2516
2877
|
const APP_CHECKABLE = "app-checkable";
|
|
2878
|
+
/** The duration of every step of the last check in a repo that ran: not in the report, which is deterministic. */
|
|
2879
|
+
const TIMINGS = "node_modules/.cache/lernapps/timings.json";
|
|
2880
|
+
/** The steps of this run and how long each took, in seconds, for TIMINGS. */
|
|
2881
|
+
const timings = [];
|
|
2882
|
+
/** Runs `run` and records how long it took as the step `name`. */
|
|
2883
|
+
function timed(name, run) {
|
|
2884
|
+
const start = performance.now();
|
|
2885
|
+
try {
|
|
2886
|
+
return run();
|
|
2887
|
+
} finally {
|
|
2888
|
+
timings.push({
|
|
2889
|
+
step: name,
|
|
2890
|
+
seconds: Math.round(performance.now() - start) / 1e3
|
|
2891
|
+
});
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
/** Like timed, for a step that returns a promise. */
|
|
2895
|
+
async function timedAsync(name, run) {
|
|
2896
|
+
const start = performance.now();
|
|
2897
|
+
try {
|
|
2898
|
+
return await run();
|
|
2899
|
+
} finally {
|
|
2900
|
+
timings.push({
|
|
2901
|
+
step: name,
|
|
2902
|
+
seconds: Math.round(performance.now() - start) / 1e3
|
|
2903
|
+
});
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2517
2906
|
/** The Playwright tests' results, as its JSON reporter writes them, to tell the runtime's tests from the app's. */
|
|
2518
2907
|
const E2E_RESULTS = "node_modules/.cache/lernapps/playwright-results.json";
|
|
2519
2908
|
const RUNTIME_ISSUES = "https://github.com/lernapps/app-templates/issues";
|
|
@@ -2852,6 +3241,9 @@ function architectureDiffFindings(where, stdout, stderr) {
|
|
|
2852
3241
|
}
|
|
2853
3242
|
/** Runs a step and returns its findings: a diff's as architectureDiffFindings judges them, else runStep's one. */
|
|
2854
3243
|
function stepFindings(step) {
|
|
3244
|
+
return timed(step.name, () => untimedStepFindings(step));
|
|
3245
|
+
}
|
|
3246
|
+
function untimedStepFindings(step) {
|
|
2855
3247
|
if (!step.architectureDiff) {
|
|
2856
3248
|
const failure = runStep(step);
|
|
2857
3249
|
return failure ? [failure] : [];
|
|
@@ -2937,12 +3329,12 @@ async function checkProject(fast, heavy, context) {
|
|
|
2937
3329
|
if (heavy && built) {
|
|
2938
3330
|
context.project = process.cwd();
|
|
2939
3331
|
if (existsSync(join("dist", "index.html"))) try {
|
|
2940
|
-
context.app = await visit({ bundle: "dist" });
|
|
3332
|
+
context.app = await timedAsync("visit of the built app", () => visit({ bundle: "dist" }));
|
|
2941
3333
|
} catch (error) {
|
|
2942
3334
|
if (!(error instanceof BrowserError)) throw error;
|
|
2943
3335
|
findings.push(browserFinding(error));
|
|
2944
3336
|
}
|
|
2945
|
-
const checks = await runChecks(context);
|
|
3337
|
+
const checks = await timedAsync("checks of the built app", () => runChecks(context));
|
|
2946
3338
|
rules.push(...checks.rules);
|
|
2947
3339
|
findings.push(...checks.findings);
|
|
2948
3340
|
if ([
|
|
@@ -2951,7 +3343,8 @@ async function checkProject(fast, heavy, context) {
|
|
|
2951
3343
|
"mjs",
|
|
2952
3344
|
"cjs"
|
|
2953
3345
|
].map((ext) => `playwright.config.${ext}`).find((file) => existsSync(file)) !== void 0) try {
|
|
2954
|
-
const
|
|
3346
|
+
const browser = await browserForTests();
|
|
3347
|
+
const failure = timed("end-to-end tests", () => runEndToEndTests(browser));
|
|
2955
3348
|
if (failure) findings.push(failure);
|
|
2956
3349
|
} catch (error) {
|
|
2957
3350
|
if (!(error instanceof BrowserError)) throw error;
|
|
@@ -3014,6 +3407,62 @@ function checkSite(script) {
|
|
|
3014
3407
|
suppressions: []
|
|
3015
3408
|
};
|
|
3016
3409
|
}
|
|
3410
|
+
/**
|
|
3411
|
+
* The check of a repo: first whether node_modules holds what package-lock.json asks for (else nothing else runs, its
|
|
3412
|
+
* result would not be CI's). The heavy part warns on standard error when Node has another major version than CI,
|
|
3413
|
+
* then reuses a passing report of the same state (check/cache.ts) or waits for another check in this clone.
|
|
3414
|
+
*/
|
|
3415
|
+
async function checkRepo(fast, heavy, context, noCache) {
|
|
3416
|
+
const parts = [...fast ? ["pre-commit"] : [], ...heavy ? ["pre-push"] : []];
|
|
3417
|
+
const installed = timed("installed packages", () => installedProblems("."));
|
|
3418
|
+
if (installed.length > 0) return { report: {
|
|
3419
|
+
checked: commitOrDirectory(),
|
|
3420
|
+
parts,
|
|
3421
|
+
rules: [CHECKS_PASS],
|
|
3422
|
+
findings: installed.map((problem) => finding(CHECKS_PASS, "error", problem)),
|
|
3423
|
+
suppressions: []
|
|
3424
|
+
} };
|
|
3425
|
+
if (!heavy) return { report: await checkProject(fast, heavy, context) };
|
|
3426
|
+
const node = nodeMajorProblem();
|
|
3427
|
+
if (node !== void 0) process.stderr.write(`lernapps check: warning: ${node.found}. ${node.fix}\n`);
|
|
3428
|
+
const dir = stateDir();
|
|
3429
|
+
const keyOf = () => {
|
|
3430
|
+
if (noCache || dir === void 0) return void 0;
|
|
3431
|
+
const key = cacheKey(".", parts, version());
|
|
3432
|
+
return "key" in key ? key.key : void 0;
|
|
3433
|
+
};
|
|
3434
|
+
const cached = (key) => {
|
|
3435
|
+
if (key === void 0 || dir === void 0) return void 0;
|
|
3436
|
+
const hit = cachedReport(dir, key);
|
|
3437
|
+
if (hit === void 0) return void 0;
|
|
3438
|
+
return {
|
|
3439
|
+
report: {
|
|
3440
|
+
...parse(hit.report),
|
|
3441
|
+
checked: commitOrDirectory()
|
|
3442
|
+
},
|
|
3443
|
+
cachedAt: hit.at
|
|
3444
|
+
};
|
|
3445
|
+
};
|
|
3446
|
+
const before = keyOf();
|
|
3447
|
+
const hit = cached(before);
|
|
3448
|
+
if (hit !== void 0) return hit;
|
|
3449
|
+
let waited = false;
|
|
3450
|
+
const release = dir === void 0 ? () => {} : await acquireLock(dir, (owner) => {
|
|
3451
|
+
waited = true;
|
|
3452
|
+
process.stderr.write(`lernapps check: waiting for another lernapps check in this clone${owner === void 0 ? "" : ` (process ${owner.pid}, since ${owner.at})`}\n`);
|
|
3453
|
+
});
|
|
3454
|
+
try {
|
|
3455
|
+
if (waited) {
|
|
3456
|
+
const after = cached(keyOf());
|
|
3457
|
+
if (after !== void 0) return after;
|
|
3458
|
+
}
|
|
3459
|
+
const report = await checkProject(fast, heavy, context);
|
|
3460
|
+
if (!failed(report) && before !== void 0 && dir !== void 0 && keyOf() === before) storeReport(dir, before, toYaml(report));
|
|
3461
|
+
return { report };
|
|
3462
|
+
} finally {
|
|
3463
|
+
release();
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3017
3466
|
/** The checks of a built app alone: a bundle directory or a URL. */
|
|
3018
3467
|
async function checkApp(target, context) {
|
|
3019
3468
|
const isUrl = /^https?:\/\//.test(target);
|
|
@@ -3054,6 +3503,9 @@ async function check(args) {
|
|
|
3054
3503
|
if (values.entry !== void 0) context.entry = readEntry(values.entry);
|
|
3055
3504
|
const target = positionals[0] ?? context.entry?.value.url;
|
|
3056
3505
|
let report;
|
|
3506
|
+
/** When the report comes from the cache: when that state was checked. */
|
|
3507
|
+
let cachedAt;
|
|
3508
|
+
const start = performance.now();
|
|
3057
3509
|
if (target !== void 0) {
|
|
3058
3510
|
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");
|
|
3059
3511
|
report = await checkApp(target, context);
|
|
@@ -3073,21 +3525,32 @@ async function check(args) {
|
|
|
3073
3525
|
process.stdout.write(`${scope.skip}\n`);
|
|
3074
3526
|
return 0;
|
|
3075
3527
|
} else {
|
|
3076
|
-
|
|
3528
|
+
if (scope !== void 0) report = checkSite(scope.site);
|
|
3529
|
+
else {
|
|
3530
|
+
const result = await checkRepo(all || values["pre-commit"], all || values["pre-push"], context, values["no-cache"]);
|
|
3531
|
+
report = result.report;
|
|
3532
|
+
cachedAt = result.cachedAt;
|
|
3533
|
+
}
|
|
3077
3534
|
if (values["pre-push"] && failed(report)) countPrePushFailure();
|
|
3078
3535
|
}
|
|
3079
3536
|
}
|
|
3080
3537
|
if (target === void 0 && failed(report)) countFailedChecks(report.findings.filter((found) => found.severity === "error").map((found) => found.rule));
|
|
3081
3538
|
const yaml = toYaml(report);
|
|
3539
|
+
const seconds = Math.round((performance.now() - start) / 100) / 10;
|
|
3082
3540
|
if (target === void 0) {
|
|
3083
3541
|
mkdirSync(dirname(REPORT), { recursive: true });
|
|
3084
3542
|
writeFileSync(REPORT, yaml);
|
|
3543
|
+
if (cachedAt === void 0 && timings.length > 0) writeFileSync(TIMINGS, `${JSON.stringify({
|
|
3544
|
+
seconds,
|
|
3545
|
+
steps: timings
|
|
3546
|
+
}, null, 2)}\n`);
|
|
3085
3547
|
}
|
|
3086
3548
|
if (values.report !== void 0) writeFileSync(values.report, yaml);
|
|
3087
3549
|
if (report.findings.length > 0) process.stdout.write(yaml);
|
|
3088
3550
|
else {
|
|
3089
3551
|
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;
|
|
3090
|
-
|
|
3552
|
+
const how = cachedAt !== void 0 ? `, already checked at ${cachedAt.toTimeString().slice(0, 5)} (--no-cache checks again)` : ` in ${seconds} s`;
|
|
3553
|
+
process.stdout.write(`lernapps check: ok, ${report.rules.length} rule${report.rules.length === 1 ? "" : "s"}, no findings (${what})${how}\n`);
|
|
3091
3554
|
}
|
|
3092
3555
|
return failed(report) ? 1 : 0;
|
|
3093
3556
|
}
|