@lernapps/tooling 0.0.0-stage → 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 +135 -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
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// The architecture of an interactive app (docs/arc42/, in the arc42 DSL of @doctc/arc42): `lernapps create` and
|
|
2
|
+
// `lernapps update` copy the generic part of lernapps/app-templates (docs/arc42/) with the archetype's extension
|
|
3
|
+
// (<archetype>/docs/arc42/) into the app, as a reference that `update` replaces, like the lernapps-* skills. They are
|
|
4
|
+
// combined as scripts/arc42.ts of app-templates combines them: a chapter's extension file is appended to the generic
|
|
5
|
+
// file of the same chapter (without its title), and the lines between `<!-- at the top of the generic chapter -->` and
|
|
6
|
+
// `<!-- end -->` go to the top of the combined chapter. The paths are rewritten for the app: a runtime block's
|
|
7
|
+
// (`runtime/...`) is in the untracked node_modules, which arc42 cannot resolve, so it becomes an ignore directive; a
|
|
8
|
+
// template block's loses the template's prefix (`interactive/src/core` → `src/core`). Every reference file carries
|
|
9
|
+
// REFERENCE in its second line. The app's own part is written into the app sections the extension marks
|
|
10
|
+
// (`<!-- app: begin core -->` … `<!-- app: end core -->`, a name per section of a chapter, none at a chapter's end),
|
|
11
|
+
// because arc42 wants a part in its parent's section and in a diagram of the same file: `update` replaces everything
|
|
12
|
+
// outside them and keeps each section verbatim. Only the archetypes in ARCHITECTURE_ARCHETYPES get an architecture.
|
|
13
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
/** Where an app keeps its architecture, and where app-templates keeps the generic part. */
|
|
17
|
+
export const ARCHITECTURE = join("docs", "arc42");
|
|
18
|
+
/** The archetypes whose apps get the architecture and its checks. */
|
|
19
|
+
export const ARCHITECTURE_ARCHETYPES: readonly string[] = ["interactive"];
|
|
20
|
+
/** The mark of a reference file, which `lernapps update` replaces outside its app sections. */
|
|
21
|
+
export const REFERENCE = "lernapps update replaces this file";
|
|
22
|
+
/** A marker of an app section: `<!-- app: begin <name> -->` or `<!-- app: end <name> -->`, the name optional. */
|
|
23
|
+
const APP_MARKER = /^<!-- app: (begin|end)(?: ([a-z0-9-]+))? -->$/;
|
|
24
|
+
|
|
25
|
+
/** The app's architecture cannot be replaced; the message says why and what to do. */
|
|
26
|
+
export class ArchitectureError extends Error {}
|
|
27
|
+
|
|
28
|
+
const TOP = "<!-- at the top of the generic chapter -->";
|
|
29
|
+
const END = "<!-- end -->";
|
|
30
|
+
const RUNTIME_PATH =
|
|
31
|
+
"installed package @lernapps/app-templates (arc42 cannot resolve untracked paths, docToolchain/arc42-language#128)";
|
|
32
|
+
|
|
33
|
+
const chapterFiles = (dir: string) =>
|
|
34
|
+
existsSync(dir)
|
|
35
|
+
? readdirSync(dir)
|
|
36
|
+
.filter((name) => /^\d{2}-.*\.arc42\.md$/.test(name))
|
|
37
|
+
.sort()
|
|
38
|
+
: [];
|
|
39
|
+
|
|
40
|
+
/** The lines of a file, without the trailing newline. */
|
|
41
|
+
const linesOf = (file: string) => readFileSync(file, "utf8").replace(/\n$/, "").split("\n");
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The paths of the blocks and interfaces, for the app: `path: runtime/...` becomes an ignore directive (H014, the
|
|
45
|
+
* missing path) before the element; `path: <archetype>/...` loses the prefix.
|
|
46
|
+
*/
|
|
47
|
+
export function rewritePaths(lines: readonly string[], archetype: string): string[] {
|
|
48
|
+
const out: string[] = [];
|
|
49
|
+
let element: string[] | undefined;
|
|
50
|
+
for (const line of lines) {
|
|
51
|
+
if (element === undefined) {
|
|
52
|
+
if (line === ":::building-block" || line === ":::interface") element = [line];
|
|
53
|
+
else out.push(line);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (line !== ":::") {
|
|
57
|
+
element.push(line);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const path = element.find((text) => text.startsWith("path: "))?.slice("path: ".length);
|
|
61
|
+
if (path !== undefined && (path === "runtime" || path.startsWith("runtime/"))) {
|
|
62
|
+
// an element without a path shares none: its directive for a path two elements share (H020) would be stale
|
|
63
|
+
if (out.at(-1) === ":::" && out.at(-2)?.startsWith(":::ignore H020 ")) out.splice(-2, 2);
|
|
64
|
+
out.push(`:::ignore H014 ${RUNTIME_PATH}`, ":::", ...element.filter((text) => !text.startsWith("path: ")));
|
|
65
|
+
} else if (path?.startsWith(`${archetype}/`)) {
|
|
66
|
+
out.push(
|
|
67
|
+
...element.map((text) => (text === `path: ${path}` ? `path: ${path.slice(archetype.length + 1)}` : text)),
|
|
68
|
+
);
|
|
69
|
+
} else {
|
|
70
|
+
out.push(...element);
|
|
71
|
+
}
|
|
72
|
+
out.push(line);
|
|
73
|
+
element = undefined;
|
|
74
|
+
}
|
|
75
|
+
if (element !== undefined) out.push(...element);
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The combined chapters of the generic part and `archetype`'s extension in a checkout of app-templates, by name. */
|
|
80
|
+
export function combine(templates: string, archetype: string, commit: string): Map<string, string> {
|
|
81
|
+
const generic = join(templates, ARCHITECTURE);
|
|
82
|
+
const extension = join(templates, archetype, ARCHITECTURE);
|
|
83
|
+
const chapters = new Map<string, string>();
|
|
84
|
+
for (const name of chapterFiles(generic)) {
|
|
85
|
+
const lines: string[] = [];
|
|
86
|
+
const top: string[] = [];
|
|
87
|
+
const add = (file: string, own: boolean) => {
|
|
88
|
+
let atTop = false;
|
|
89
|
+
linesOf(file).forEach((text, index) => {
|
|
90
|
+
if (own && index === 0 && text.startsWith("# ")) return;
|
|
91
|
+
if (own && text === TOP) atTop = true;
|
|
92
|
+
if (atTop) {
|
|
93
|
+
if (text === END) atTop = false;
|
|
94
|
+
else if (text !== TOP) top.push(text);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
lines.push(text);
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
add(join(generic, name), false);
|
|
101
|
+
for (const own of chapterFiles(extension).filter((candidate) => candidate.slice(0, 3) === name.slice(0, 3))) {
|
|
102
|
+
lines.push("");
|
|
103
|
+
add(join(extension, own), true);
|
|
104
|
+
}
|
|
105
|
+
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. -->`;
|
|
106
|
+
lines.splice(1, 0, "", mark, ...(top.length > 0 ? ["", ...top] : []));
|
|
107
|
+
chapters.set(name, `${rewritePaths(lines, archetype).join("\n")}\n`);
|
|
108
|
+
}
|
|
109
|
+
return chapters;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The reference files of the app's architecture: those that carry REFERENCE. */
|
|
113
|
+
export function referenceFiles(dir: string): string[] {
|
|
114
|
+
const folder = join(dir, ARCHITECTURE);
|
|
115
|
+
if (!existsSync(folder)) return [];
|
|
116
|
+
return readdirSync(folder)
|
|
117
|
+
.filter((name) => name.endsWith(".arc42.md"))
|
|
118
|
+
.filter((name) => linesOf(join(folder, name)).slice(0, 4).join("\n").includes(REFERENCE))
|
|
119
|
+
.sort();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Whether the app has the architecture from lernapps create or update, which its checks then validate. */
|
|
123
|
+
export const hasArchitecture = (dir = "."): boolean => referenceFiles(dir).length > 0;
|
|
124
|
+
|
|
125
|
+
/** The archetype whose extension the app's reference holds, as its mark names it; undefined without a reference. */
|
|
126
|
+
export function architectureArchetype(dir = "."): string | undefined {
|
|
127
|
+
const [first] = referenceFiles(dir);
|
|
128
|
+
if (first === undefined) return undefined;
|
|
129
|
+
return /the generic part and the ([a-z-]+) extension/.exec(readFileSync(join(dir, ARCHITECTURE, first), "utf8"))?.[1];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The app sections of a file's lines, by name ("" for the unnamed one): the lines between their markers. */
|
|
133
|
+
export function appSections(lines: readonly string[]): Map<string, string[]> {
|
|
134
|
+
const sections = new Map<string, string[]>();
|
|
135
|
+
let open: string | undefined;
|
|
136
|
+
for (const line of lines) {
|
|
137
|
+
const marker = APP_MARKER.exec(line);
|
|
138
|
+
if (marker?.[1] === "begin") {
|
|
139
|
+
open = marker[2] ?? "";
|
|
140
|
+
sections.set(open, []);
|
|
141
|
+
} else if (marker?.[1] === "end") {
|
|
142
|
+
open = undefined;
|
|
143
|
+
} else if (open !== undefined) {
|
|
144
|
+
sections.get(open)?.push(line);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return sections;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The lines with each app section's content replaced by that of `sections`, where it has one. */
|
|
151
|
+
function fillSections(lines: readonly string[], sections: ReadonlyMap<string, readonly string[]>): string[] {
|
|
152
|
+
const out: string[] = [];
|
|
153
|
+
let open: string | undefined;
|
|
154
|
+
for (const line of lines) {
|
|
155
|
+
const marker = APP_MARKER.exec(line);
|
|
156
|
+
if (marker?.[1] === "begin") {
|
|
157
|
+
open = marker[2] ?? "";
|
|
158
|
+
out.push(line, ...(sections.get(open) ?? []));
|
|
159
|
+
} else if (marker?.[1] === "end") {
|
|
160
|
+
open = undefined;
|
|
161
|
+
out.push(line);
|
|
162
|
+
} else if (open === undefined) {
|
|
163
|
+
out.push(line);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const sectionName = (file: string, name: string) => `${file}${name === "" ? " (at its end)" : ` (${name})`}`;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Replaces the app's reference files with the combination of a checkout of app-templates, keeping the content of each
|
|
173
|
+
* app section. It writes nothing when an app section with content has no place in the new reference (it would be
|
|
174
|
+
* lost) and says so (ArchitectureError). What it did, as lines of the output.
|
|
175
|
+
*/
|
|
176
|
+
export function writeArchitecture(dir: string, templates: string, archetype: string, commit: string): string[] {
|
|
177
|
+
const folder = join(dir, ARCHITECTURE);
|
|
178
|
+
const before = new Map(referenceFiles(dir).map((name) => [name, readFileSync(join(folder, name), "utf8")]));
|
|
179
|
+
const kept = new Map([...before].map(([name, text]) => [name, appSections(text.replace(/\n$/, "").split("\n"))]));
|
|
180
|
+
const chapters = new Map(
|
|
181
|
+
[...combine(templates, archetype, commit)].map(([name, text]) => {
|
|
182
|
+
const lines = text.replace(/\n$/, "").split("\n");
|
|
183
|
+
return [name, { lines, sections: appSections(lines) }] as const;
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
const lost = [...kept].flatMap(([name, sections]) =>
|
|
187
|
+
[...sections]
|
|
188
|
+
.filter(
|
|
189
|
+
([section, lines]) => lines.some((line) => line.trim() !== "") && !chapters.get(name)?.sections.has(section),
|
|
190
|
+
)
|
|
191
|
+
.map(([section]) => sectionName(name, section)),
|
|
192
|
+
);
|
|
193
|
+
if (lost.length > 0) {
|
|
194
|
+
throw new ArchitectureError(
|
|
195
|
+
`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`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
for (const name of before.keys()) rmSync(join(folder, name));
|
|
199
|
+
mkdirSync(folder, { recursive: true });
|
|
200
|
+
let changed = 0;
|
|
201
|
+
for (const [name, { lines }] of chapters) {
|
|
202
|
+
const text = `${fillSections(lines, kept.get(name) ?? new Map()).join("\n")}\n`;
|
|
203
|
+
writeFileSync(join(folder, name), text);
|
|
204
|
+
if (before.get(name) !== text) changed++;
|
|
205
|
+
}
|
|
206
|
+
const removed = [...before.keys()].filter((name) => !chapters.has(name)).length;
|
|
207
|
+
return changed + removed === 0
|
|
208
|
+
? []
|
|
209
|
+
: [
|
|
210
|
+
`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`,
|
|
211
|
+
];
|
|
212
|
+
}
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
// The generator, `lernapps create --archetype <name> [<dir>]`: writes a new app into an empty folder, once the creator
|
|
2
|
+
// has confirmed the first version's plan (.vibe/plans/01-<short name>.md, in the phase code, for this archetype). It
|
|
3
|
+
// copies the archetype's folder out of the installed package @lernapps/app-templates (a dependency of this package, at
|
|
4
|
+
// an exact version), and makes the app depend on that same version of the runtime, so template and runtime always
|
|
5
|
+
// match. Then it writes what every app has: AGENTS.md, the
|
|
6
|
+
// plan, the content guide (an app-local skill), the workflows, LICENSE, an issue form for content errors and the
|
|
7
|
+
// Renovate configuration (generator/app/); an interactive app also its architecture (generator/architecture.ts). The
|
|
8
|
+
// skills `lernapps init` synced into the folder stay, and are committed with the app; `lernapps update` replaces them
|
|
9
|
+
// with those of the tooling it moves the app to.
|
|
10
|
+
// To move to a new app-templates: set the exact version of @lernapps/app-templates in package.json; the generator's
|
|
11
|
+
// tests then run on that version.
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import {
|
|
14
|
+
cpSync,
|
|
15
|
+
existsSync,
|
|
16
|
+
mkdirSync,
|
|
17
|
+
readFileSync,
|
|
18
|
+
readdirSync,
|
|
19
|
+
renameSync,
|
|
20
|
+
rmSync,
|
|
21
|
+
statSync,
|
|
22
|
+
symlinkSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
} from "node:fs";
|
|
25
|
+
import { createRequire } from "node:module";
|
|
26
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
import { parseArgs, type ParseArgsOptionsConfig } from "node:util";
|
|
29
|
+
import { ARCHITECTURE, ARCHITECTURE_ARCHETYPES, writeArchitecture } from "./architecture.ts";
|
|
30
|
+
import { LEGACY_PLAN, MIGRATED_PLAN, PLAN_NAME, PLANS, planFrontMatter, plans } from "../src/plan.ts";
|
|
31
|
+
import { UsageError } from "../src/usage.ts";
|
|
32
|
+
|
|
33
|
+
/** The options of `lernapps create`, as node:util parseArgs takes them. */
|
|
34
|
+
export const CREATE_OPTIONS = {
|
|
35
|
+
archetype: { type: "string" },
|
|
36
|
+
plan: { type: "string" },
|
|
37
|
+
templates: { type: "string" },
|
|
38
|
+
tooling: { type: "string" },
|
|
39
|
+
help: { type: "boolean", short: "h", default: false },
|
|
40
|
+
} as const satisfies ParseArgsOptionsConfig;
|
|
41
|
+
|
|
42
|
+
export const CREATE_USAGE = `Usage: lernapps create --archetype <name> [<dir>] [--plan <file>] [--templates <repo>] [--tooling <spec>]
|
|
43
|
+
|
|
44
|
+
Writes a new app from the archetype's template into <dir> (default: the current folder), which must be empty
|
|
45
|
+
except for .git, the first version's plan .vibe/plans/01-<short name>.md and the skills that npx skills add
|
|
46
|
+
lernapps init synced (.agents/skills/, .claude/skills/, skills-lock.json, and the package.json, package-lock.json and
|
|
47
|
+
node_modules init wrote). The template is the folder <name>/ of the installed package @lernapps/app-templates, the
|
|
48
|
+
version @lernapps/tooling depends on; the app depends on the runtime of the same version.
|
|
49
|
+
|
|
50
|
+
It needs the plan of the first version, confirmed by the creator: started from the plan template as
|
|
51
|
+
.vibe/plans/01-<short name>.md, the only plan there, worked through Explore and Plan, now in the phase code
|
|
52
|
+
(phase: code in its front matter), with the same archetype as --archetype. A plan .vibe/plan.md of an earlier
|
|
53
|
+
tooling is moved to .vibe/plans/01-first-version.md. Without a plan, it writes nothing and says what to do.
|
|
54
|
+
|
|
55
|
+
Run it in the folder lernapps init prepared, from the tooling init installed:
|
|
56
|
+
npx lernapps create --archetype <name> [<dir>]
|
|
57
|
+
It needs git, and the npm registry for the npm install that follows. The app then depends on the version of
|
|
58
|
+
@lernapps/tooling that created it, and its workflows use the actions of that version's tag.
|
|
59
|
+
|
|
60
|
+
It writes, besides the template: package.json named after the folder (its prepare script, lernapps hooks,
|
|
61
|
+
installs the git hooks; for an explainer and an interactive app also vue-tsc and typescript, with which lernapps
|
|
62
|
+
check type-checks its Vue components; for an interactive app also @doctc/arc42, which checks its architecture),
|
|
63
|
+
for an interactive app its architecture docs/arc42/ (the generic part and the interactive extension of app-templates,
|
|
64
|
+
which lernapps update replaces except the marked app sections, where the app's own part goes),
|
|
65
|
+
AGENTS.md, the plan in .vibe/plans/ (the one already there is kept), the content guide
|
|
66
|
+
.agents/skills/app-content/SKILL.md (a skeleton the assistant fills in; linked as .claude/skills/app-content for
|
|
67
|
+
Claude Code where the system allows symbolic links), the workflow
|
|
68
|
+
.github/workflows/pages.yml (the app check action on every pull request and push to main; on main, then the app
|
|
69
|
+
publish action to the repo's GitHub Pages), an issue form for content errors, LICENSE (MIT), renovate.json (the
|
|
70
|
+
Renovate preset of lernapps). The installed skills and skills-lock.json are committed with the app, like the
|
|
71
|
+
content guide; lernapps update replaces the lernapps-* skills.
|
|
72
|
+
|
|
73
|
+
Then: git init (if <dir> is not a repository yet), npm install (installs the git hooks), and the first commit.
|
|
74
|
+
|
|
75
|
+
Exit codes: 0 when the app is written, 1 when it cannot be (the message says why), 2 on a usage error.
|
|
76
|
+
|
|
77
|
+
Options:
|
|
78
|
+
--archetype <name> the archetype chosen in the plan: ${"{archetypes}"}
|
|
79
|
+
--plan <file> the confirmed plan to carry over to .vibe/plans/01-<short name>.md, the short name from
|
|
80
|
+
its file name <NN>-<short name>.md, else 01-plan.md (default: the one in <dir>)
|
|
81
|
+
--templates <dir> the package @lernapps/app-templates to copy from: its folder, or a clone of its repository
|
|
82
|
+
(default: the environment variable LERNAPPS_TEMPLATES, else the installed package)
|
|
83
|
+
--tooling <spec> the npm spec of @lernapps/tooling the app depends on (default: ^ the version of this
|
|
84
|
+
@lernapps/tooling)
|
|
85
|
+
-h, --help print this help
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
/** The generator cannot write the app; the message says why and what to do. */
|
|
89
|
+
export class CreateError extends Error {}
|
|
90
|
+
|
|
91
|
+
const packageRoot = fileURLToPath(new URL("..", import.meta.url));
|
|
92
|
+
export const fromPackage = (...path: string[]) => join(packageRoot, ...path);
|
|
93
|
+
|
|
94
|
+
export interface Manifest {
|
|
95
|
+
name?: string;
|
|
96
|
+
version?: string;
|
|
97
|
+
devDependencies?: Record<string, string>;
|
|
98
|
+
scripts?: Record<string, string>;
|
|
99
|
+
lernapps?: { archetypes?: string[]; init?: boolean };
|
|
100
|
+
[key: string]: unknown;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const readJson = (file: string): Manifest => JSON.parse(readFileSync(file, "utf8")) as Manifest;
|
|
104
|
+
export const writeJson = (file: string, value: unknown) => writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
105
|
+
|
|
106
|
+
/** The package @lernapps/app-templates: where its templates are, its version, and the archetypes it has. */
|
|
107
|
+
export interface Templates {
|
|
108
|
+
root: string;
|
|
109
|
+
version: string;
|
|
110
|
+
archetypes: string[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The runtime package, which also carries the templates. */
|
|
114
|
+
export const RUNTIME = "@lernapps/app-templates";
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The package @lernapps/app-templates: the folder named (a local copy or clone), else the one installed next to this
|
|
118
|
+
* package, the version it depends on.
|
|
119
|
+
*/
|
|
120
|
+
export function templatesPackage(dir?: string): Templates {
|
|
121
|
+
let root: string;
|
|
122
|
+
if (dir !== undefined) root = resolve(dir);
|
|
123
|
+
else {
|
|
124
|
+
try {
|
|
125
|
+
root = dirname(createRequire(import.meta.url).resolve(`${RUNTIME}/package.json`));
|
|
126
|
+
} catch {
|
|
127
|
+
throw new CreateError(
|
|
128
|
+
`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`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!existsSync(join(root, "package.json"))) {
|
|
133
|
+
throw new CreateError(`${root} is no package ${RUNTIME}: it has no package.json`);
|
|
134
|
+
}
|
|
135
|
+
const manifest = readJson(join(root, "package.json"));
|
|
136
|
+
return { root, version: manifest.version ?? "0.0.0", archetypes: [...(manifest.lernapps?.archetypes ?? [])].sort() };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The archetypes of the installed templates. */
|
|
140
|
+
export const archetypes = (): string[] => templatesPackage().archetypes;
|
|
141
|
+
|
|
142
|
+
/** The version of this package. */
|
|
143
|
+
export const ownVersion = (): string => readJson(fromPackage("package.json")).version ?? "0.0.0";
|
|
144
|
+
|
|
145
|
+
/** The lock file of `npx skills add`, next to the skills it installed. */
|
|
146
|
+
export const SKILLS_LOCK = "skills-lock.json";
|
|
147
|
+
|
|
148
|
+
/** The skills that `npx skills add` installed into the folder: the names in its lock file. */
|
|
149
|
+
function installedSkills(dir: string): Set<string> {
|
|
150
|
+
try {
|
|
151
|
+
const lock = JSON.parse(readFileSync(join(dir, SKILLS_LOCK), "utf8")) as { skills?: Record<string, unknown> };
|
|
152
|
+
return new Set(Object.keys(lock.skills ?? {}));
|
|
153
|
+
} catch {
|
|
154
|
+
return new Set();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* What is in the folder besides .git, the plans (src/plan.ts) and the skills as `npx skills add lernapps/tooling`
|
|
160
|
+
* installs them: each skill of its lock file in .agents/skills/<skill>/ and, for Claude Code, .claude/skills/<skill>
|
|
161
|
+
* (a link to it, or a copy with --copy).
|
|
162
|
+
*/
|
|
163
|
+
function contents(dir: string): string[] {
|
|
164
|
+
if (!existsSync(dir)) return [];
|
|
165
|
+
if (!statSync(dir).isDirectory()) throw new CreateError(`${dir} is a file, not a folder`);
|
|
166
|
+
const skills = installedSkills(dir);
|
|
167
|
+
const within = (folder: string, keep: (name: string) => boolean): string[] =>
|
|
168
|
+
readdirSync(join(dir, folder))
|
|
169
|
+
.filter((name) => !keep(name))
|
|
170
|
+
.map((name) => `${folder}/${name}`);
|
|
171
|
+
return readdirSync(dir).flatMap((name) => {
|
|
172
|
+
if (name === ".git" || (name === SKILLS_LOCK && skills.size > 0)) return [];
|
|
173
|
+
// what `lernapps init` wrote: the installed packages, their lock, and a package.json it marked
|
|
174
|
+
if (name === "node_modules" || name === "package-lock.json") return [];
|
|
175
|
+
if (name === "package.json" && readJson(join(dir, name)).lernapps?.init === true) return [];
|
|
176
|
+
if (name === ".vibe") {
|
|
177
|
+
return within(name, (file) => file === "plan.md" || file === "plans").concat(
|
|
178
|
+
existsSync(join(dir, PLANS)) ? within(PLANS, (file) => PLAN_NAME.test(file)) : [],
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
if ((name === ".agents" || name === ".claude") && skills.size > 0) {
|
|
182
|
+
return within(name, (folder) => folder === "skills").concat(
|
|
183
|
+
existsSync(join(dir, name, "skills")) ? within(`${name}/skills`, (skill) => skills.has(skill)) : [],
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return [name];
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function git(args: readonly string[], cwd: string): { ok: boolean; out: string } {
|
|
191
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
192
|
+
if (result.error) throw new CreateError(`git cannot run: ${result.error.message}. Install git, then create again`);
|
|
193
|
+
return { ok: result.status === 0, out: `${result.stdout}${result.stderr}`.trim() };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A package name from the folder's name: lower case, letters, digits and hyphens. */
|
|
197
|
+
const packageName = (dir: string) =>
|
|
198
|
+
basename(dir)
|
|
199
|
+
.toLowerCase()
|
|
200
|
+
.normalize("NFKD")
|
|
201
|
+
.replace(/[̀-ͯ]/g, "")
|
|
202
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
203
|
+
.replace(/^-+|-+$/g, "") || "app";
|
|
204
|
+
|
|
205
|
+
/** This package, as an app depends on it. */
|
|
206
|
+
export const TOOLING = "@lernapps/tooling";
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* What the apps of an archetype need beyond its template, as development dependencies: an explainer's Vue components
|
|
210
|
+
* (src/components/<Name>.vue) are type-checked by vue-tsc, which `lernapps check` runs once the app has a .vue file.
|
|
211
|
+
* The same versions as in app-templates; Renovate moves them in the app.
|
|
212
|
+
*/
|
|
213
|
+
export const ARCHETYPE_DEV_DEPENDENCIES: Readonly<Record<string, Readonly<Record<string, string>>>> = {
|
|
214
|
+
explainer: { typescript: "6.0.3", "vue-tsc": "3.3.11" },
|
|
215
|
+
// an interactive app's UI is Vue components from the start; its architecture (docs/arc42/) is validated by the
|
|
216
|
+
// arc42 CLI, the version this repo validates its own with
|
|
217
|
+
interactive: { "@doctc/arc42": "0.38.0", typescript: "6.0.3", "vue-tsc": "3.3.11" },
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const START_THE_PLAN =
|
|
221
|
+
"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";
|
|
222
|
+
|
|
223
|
+
/** The plan the app is created from, and where it goes in the app. */
|
|
224
|
+
interface Plan {
|
|
225
|
+
file: string;
|
|
226
|
+
target: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The first version's plan: the one given (carried over to .vibe/plans/01-<short name>.md), else the only one in
|
|
231
|
+
* the folder's .vibe/plans/, numbered 01, else a .vibe/plan.md of an earlier tooling (moved to
|
|
232
|
+
* .vibe/plans/01-first-version.md, as lernapps update moves it).
|
|
233
|
+
*/
|
|
234
|
+
function firstPlan(dir: string, given: string | undefined): Plan {
|
|
235
|
+
const found = plans(dir).map((plan) => join(dir, plan));
|
|
236
|
+
const legacy = existsSync(join(dir, LEGACY_PLAN)) ? [join(dir, LEGACY_PLAN)] : [];
|
|
237
|
+
if (given !== undefined) {
|
|
238
|
+
if (!existsSync(given)) throw new CreateError(`there is no plan ${given}: ${START_THE_PLAN}`);
|
|
239
|
+
const others = [...found, ...legacy].filter((plan) => resolve(plan) !== resolve(given));
|
|
240
|
+
if (others.length > 0) {
|
|
241
|
+
throw new CreateError(
|
|
242
|
+
`${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`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
const name = /^\d{2}-([^/\\]+\.md)$/.exec(basename(given))?.[1] ?? "plan.md";
|
|
246
|
+
return { file: given, target: join(dir, PLANS, `01-${name}`) };
|
|
247
|
+
}
|
|
248
|
+
if (found.length + legacy.length > 1) {
|
|
249
|
+
throw new CreateError(
|
|
250
|
+
`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>`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
const [plan] = found;
|
|
254
|
+
if (plan !== undefined) {
|
|
255
|
+
if (!basename(plan).startsWith("01-")) {
|
|
256
|
+
throw new CreateError(
|
|
257
|
+
`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`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return { file: plan, target: plan };
|
|
261
|
+
}
|
|
262
|
+
const [old] = legacy;
|
|
263
|
+
if (old !== undefined) return { file: old, target: join(dir, MIGRATED_PLAN) };
|
|
264
|
+
throw new CreateError(`there is no plan in ${join(dir, PLANS)}: ${START_THE_PLAN}`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The plan the app is created from, which the creator has confirmed: it is in the phase code (or later), for this
|
|
269
|
+
* archetype. The front matter is read as the hooks read it (src/plan.ts).
|
|
270
|
+
*/
|
|
271
|
+
function confirmedPlan(dir: string, archetype: string, given: string | undefined): Plan {
|
|
272
|
+
const found = firstPlan(dir, given);
|
|
273
|
+
const plan = found.file;
|
|
274
|
+
const values = planFrontMatter(plan);
|
|
275
|
+
const phase = values["phase"];
|
|
276
|
+
if (phase === "explore" || phase === "plan") {
|
|
277
|
+
throw new CreateError(
|
|
278
|
+
`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`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
if (phase !== "code" && phase !== "commit") {
|
|
282
|
+
throw new CreateError(`the plan ${plan} has no phase code in its front matter: ${START_THE_PLAN}`);
|
|
283
|
+
}
|
|
284
|
+
if (values["archetype"] !== archetype) {
|
|
285
|
+
throw new CreateError(
|
|
286
|
+
`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`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
return found;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The plan at its place in .vibe/plans/: the one in the folder stays, a legacy one moves, one given is copied. */
|
|
293
|
+
function writePlan(dir: string, plan: Plan): void {
|
|
294
|
+
if (resolve(plan.file) === resolve(plan.target)) return;
|
|
295
|
+
mkdirSync(dirname(plan.target), { recursive: true });
|
|
296
|
+
if (resolve(plan.file) === resolve(join(dir, LEGACY_PLAN))) renameSync(plan.file, plan.target);
|
|
297
|
+
else writeFileSync(plan.target, readFileSync(plan.file));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The lines an earlier generator appended to .gitignore, to ignore the skills `npx skills add lernapps/tooling`
|
|
302
|
+
* installed. Apps now commit them (every checkout has the skills of the tooling the app is on); `lernapps update`
|
|
303
|
+
* removes these lines from an app that has them.
|
|
304
|
+
*/
|
|
305
|
+
export const IGNORED_SKILLS_LINES: readonly string[] = [
|
|
306
|
+
"# the skills npx skills add lernapps/tooling installed: each checkout installs them; the content guide is the app's",
|
|
307
|
+
`/${SKILLS_LOCK}`,
|
|
308
|
+
"/.agents/skills/*",
|
|
309
|
+
"!/.agents/skills/app-content/",
|
|
310
|
+
"/.claude/skills/*",
|
|
311
|
+
"!/.claude/skills/app-content",
|
|
312
|
+
];
|
|
313
|
+
|
|
314
|
+
/** The files every app has (generator/app/), with their placeholders filled. */
|
|
315
|
+
function writeAppFiles(dir: string, values: Record<string, string>): void {
|
|
316
|
+
const source = fromPackage("generator", "app");
|
|
317
|
+
const walk = (folder: string): string[] =>
|
|
318
|
+
readdirSync(folder, { withFileTypes: true }).flatMap((entry) =>
|
|
319
|
+
entry.isDirectory() ? walk(join(folder, entry.name)) : [join(folder, entry.name)],
|
|
320
|
+
);
|
|
321
|
+
for (const file of walk(source)) {
|
|
322
|
+
const target = join(dir, relative(source, file));
|
|
323
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
324
|
+
const text = readFileSync(file, "utf8").replace(/\{\{(\w+)\}\}/g, (match, key: string) => values[key] ?? match);
|
|
325
|
+
writeFileSync(target, text);
|
|
326
|
+
}
|
|
327
|
+
writeFileSync(join(dir, "AGENTS.md"), readFileSync(fromPackage("guidance", "AGENTS.md")));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** The app's content guide, an app-local skill: written as a skeleton, filled in by the assistant. */
|
|
331
|
+
export const CONTENT_GUIDE = ".agents/skills/app-content/SKILL.md";
|
|
332
|
+
/** Where Claude Code looks for the skills of a project: a relative symbolic link to the guide's folder. */
|
|
333
|
+
const CONTENT_GUIDE_LINK = ".claude/skills/app-content";
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Adds the content guide's skeleton and its link where they are missing; what it did, one line per file. The link is
|
|
337
|
+
* relative, so it survives a move, a commit and a clone (git keeps it as a link). Where the system allows no symbolic
|
|
338
|
+
* link (Windows without Developer Mode) it is left out: AGENTS.md names the guide's path, which every harness reads.
|
|
339
|
+
*/
|
|
340
|
+
export function addContentGuide(dir: string): string[] {
|
|
341
|
+
const done: string[] = [];
|
|
342
|
+
const guide = join(dir, CONTENT_GUIDE);
|
|
343
|
+
if (!existsSync(guide)) {
|
|
344
|
+
mkdirSync(dirname(guide), { recursive: true });
|
|
345
|
+
writeFileSync(guide, readFileSync(fromPackage("generator", "app", CONTENT_GUIDE)));
|
|
346
|
+
done.push(`added ${CONTENT_GUIDE}; fill it in from the plan, as its comments say`);
|
|
347
|
+
}
|
|
348
|
+
const link = join(dir, CONTENT_GUIDE_LINK);
|
|
349
|
+
if (!existsSync(link)) {
|
|
350
|
+
try {
|
|
351
|
+
rmSync(link, { force: true }); // a dangling link
|
|
352
|
+
mkdirSync(dirname(link), { recursive: true });
|
|
353
|
+
symlinkSync("../../.agents/skills/app-content", link, "dir");
|
|
354
|
+
done.push(`linked ${CONTENT_GUIDE_LINK} to it for Claude Code`);
|
|
355
|
+
} catch (error) {
|
|
356
|
+
done.push(
|
|
357
|
+
`could not link ${CONTENT_GUIDE_LINK} (${error instanceof Error ? error.message : String(error)}); AGENTS.md names ${CONTENT_GUIDE}, which is enough`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return done;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function create(args: string[]): number {
|
|
365
|
+
const { values, positionals } = parseArgs({
|
|
366
|
+
args,
|
|
367
|
+
options: CREATE_OPTIONS,
|
|
368
|
+
strict: true,
|
|
369
|
+
allowPositionals: true,
|
|
370
|
+
});
|
|
371
|
+
const templates = templatesPackage(values.templates ?? process.env["LERNAPPS_TEMPLATES"]);
|
|
372
|
+
const known = templates.archetypes;
|
|
373
|
+
if (values.help) {
|
|
374
|
+
process.stdout.write(CREATE_USAGE.replace("{archetypes}", known.join(", ")));
|
|
375
|
+
return 0;
|
|
376
|
+
}
|
|
377
|
+
const archetype = values.archetype;
|
|
378
|
+
if (archetype === undefined) throw new UsageError(`create needs --archetype <name> (${known.join(", ")})`);
|
|
379
|
+
if (!known.includes(archetype)) {
|
|
380
|
+
throw new UsageError(`unknown archetype: ${archetype} (known: ${known.join(", ")})`);
|
|
381
|
+
}
|
|
382
|
+
if (positionals.length > 1) throw new UsageError(`one <dir> at most, not ${positionals.join(" ")}`);
|
|
383
|
+
const dir = resolve(positionals[0] ?? ".");
|
|
384
|
+
const found = contents(dir);
|
|
385
|
+
if (found.length > 0) {
|
|
386
|
+
throw new CreateError(
|
|
387
|
+
`${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`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
const plan = confirmedPlan(dir, archetype, values.plan);
|
|
391
|
+
|
|
392
|
+
const architecture = ARCHITECTURE_ARCHETYPES.includes(archetype);
|
|
393
|
+
if (!existsSync(join(templates.root, archetype, "package.json"))) {
|
|
394
|
+
throw new CreateError(`${templates.root} has no template ${archetype}/ (version ${templates.version})`);
|
|
395
|
+
}
|
|
396
|
+
// what `lernapps init` wrote: its dependencies (the tooling, skills, an add-on) stay, and so does its lock
|
|
397
|
+
const initialised = existsSync(join(dir, "package.json")) ? readJson(join(dir, "package.json")) : undefined;
|
|
398
|
+
const initialLock = existsSync(join(dir, "package-lock.json"))
|
|
399
|
+
? readFileSync(join(dir, "package-lock.json"), "utf8")
|
|
400
|
+
: undefined;
|
|
401
|
+
mkdirSync(dir, { recursive: true });
|
|
402
|
+
cpSync(join(templates.root, archetype), dir, { recursive: true });
|
|
403
|
+
rmSync(join(dir, "package-lock.json"), { force: true }); // the template's lock, of another name and tooling
|
|
404
|
+
if (initialLock !== undefined) writeFileSync(join(dir, "package-lock.json"), initialLock);
|
|
405
|
+
// npm never ships a .gitignore: the package carries it as _gitignore (app-templates, scripts/pack-dotfiles.ts)
|
|
406
|
+
if (existsSync(join(dir, "_gitignore"))) renameSync(join(dir, "_gitignore"), join(dir, ".gitignore"));
|
|
407
|
+
// the template's README says what the archetype can do, for choosing one; it is no part of the app
|
|
408
|
+
rmSync(join(dir, "README.md"), { force: true });
|
|
409
|
+
// the template folder's architecture is the extension of app-templates' own: an interactive app gets the
|
|
410
|
+
// combination as its reference, the other archetypes none
|
|
411
|
+
rmSync(join(dir, ARCHITECTURE), { recursive: true, force: true });
|
|
412
|
+
if (existsSync(join(dir, "docs")) && readdirSync(join(dir, "docs")).length === 0) {
|
|
413
|
+
rmSync(join(dir, "docs"), { recursive: true });
|
|
414
|
+
}
|
|
415
|
+
if (architecture) writeArchitecture(dir, templates.root, archetype, templates.version);
|
|
416
|
+
else rmSync(join(dir, ".arc42ignore"), { force: true });
|
|
417
|
+
|
|
418
|
+
const app = readJson(join(dir, "package.json"));
|
|
419
|
+
const name = packageName(dir);
|
|
420
|
+
app.name = name;
|
|
421
|
+
// npm install installs the hooks of this package, in the app's own repository only (lernapps hooks); the
|
|
422
|
+
// template needs no prepare script of its own, which would install them in app-templates
|
|
423
|
+
app.scripts = { ...app.scripts, prepare: "lernapps hooks" };
|
|
424
|
+
// the template uses the runtime of its own checkout (file:.., installed as a copy through .npmrc); the app the
|
|
425
|
+
// version of the runtime it was copied from
|
|
426
|
+
if (app.devDependencies?.[RUNTIME] !== undefined) {
|
|
427
|
+
app.devDependencies = { ...app.devDependencies, [RUNTIME]: `^${templates.version}` };
|
|
428
|
+
rmSync(join(dir, ".npmrc"), { force: true });
|
|
429
|
+
}
|
|
430
|
+
// the tooling the app depends on: the one named, else this version (init may have installed it already)
|
|
431
|
+
const tooling = values.tooling ?? `^${ownVersion()}`;
|
|
432
|
+
app.devDependencies = {
|
|
433
|
+
...app.devDependencies,
|
|
434
|
+
...ARCHETYPE_DEV_DEPENDENCIES[archetype],
|
|
435
|
+
...initialised?.devDependencies,
|
|
436
|
+
[TOOLING]: tooling,
|
|
437
|
+
};
|
|
438
|
+
// sorted by name, as npm and the formatter keep them
|
|
439
|
+
app.devDependencies = Object.fromEntries(Object.entries(app.devDependencies).sort(([a], [b]) => a.localeCompare(b)));
|
|
440
|
+
writeJson(join(dir, "package.json"), app);
|
|
441
|
+
|
|
442
|
+
const holder = git(["config", "user.name"], existsSync(dir) ? dir : process.cwd());
|
|
443
|
+
const moved = resolve(plan.file) === resolve(join(dir, LEGACY_PLAN));
|
|
444
|
+
writePlan(dir, plan);
|
|
445
|
+
writeAppFiles(dir, {
|
|
446
|
+
tooling: `v${ownVersion()}`,
|
|
447
|
+
year: String(new Date().getFullYear()),
|
|
448
|
+
holder: holder.ok && holder.out !== "" ? holder.out : "the creator of this app",
|
|
449
|
+
});
|
|
450
|
+
const guide = addContentGuide(dir).filter((line) => line.startsWith("could not"));
|
|
451
|
+
|
|
452
|
+
process.stdout.write(
|
|
453
|
+
[
|
|
454
|
+
`lernapps create: the ${archetype} app ${name} in ${dir} (app-templates ${templates.version})`,
|
|
455
|
+
...guide.map((line) => `Content guide: ${line}`),
|
|
456
|
+
...(moved ? [`Plan: moved ${LEGACY_PLAN} to ${relative(dir, plan.target)}`] : []),
|
|
457
|
+
`Next: ${existsSync(join(dir, ".git")) ? "" : "git init, "}npm install, then follow ${relative(dir, plan.target)}`,
|
|
458
|
+
"",
|
|
459
|
+
].join("\n"),
|
|
460
|
+
);
|
|
461
|
+
return 0;
|
|
462
|
+
}
|