@lessonkit/cli 1.6.0 → 1.7.1
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/README.md +2 -2
- package/dist/bin.js +121 -29
- package/dist/index.js +121 -29
- package/package.json +6 -6
- package/template/vite-react/README.md +2 -2
- package/template/vite-react/package.json +6 -6
- package/template/vite-react/src/courseConfig.test.ts +5 -0
- package/template/vite-react/src/courseConfig.ts +3 -0
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ lessonkit package --target scorm12 # LMS artifact
|
|
|
30
30
|
| Flag | Purpose |
|
|
31
31
|
| --- | --- |
|
|
32
32
|
| `--here` | Scaffold in the current directory |
|
|
33
|
-
| `--force` |
|
|
33
|
+
| `--force` | With `--here`: scaffold in a non-empty directory; conflicting template files (e.g. `.gitignore`, `README.md`) are backed up to `.lessonkit-init-backup/` before overwrite. Non-conflicting files are kept. |
|
|
34
34
|
| `--skip-install` | Skip `npm install` after copying the template |
|
|
35
35
|
|
|
36
36
|
### Package targets
|
|
@@ -57,7 +57,7 @@ Subprocess timeout defaults to **30 minutes** (`LESSONKIT_CMD_TIMEOUT_MS`).
|
|
|
57
57
|
|
|
58
58
|
## Docs
|
|
59
59
|
|
|
60
|
-
[5-minute guide](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/getting-started-in-5-minutes.html) · [CLI reference](https://lessonkit.readthedocs.io/en/latest/reference/cli.html) · [
|
|
60
|
+
[5-minute guide](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/getting-started-in-5-minutes.html) · [CLI reference](https://lessonkit.readthedocs.io/en/latest/reference/cli.html) · [LMS Go-Live](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/lms-go-live.html) · [Packaging guide](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/packaging-and-cli.html) · [Published template](https://github.com/eddiethedean/lessonkit/tree/main/packages/cli/template/vite-react) (monorepo source: [`templates/vite-react`](https://github.com/eddiethedean/lessonkit/tree/main/templates/vite-react))
|
|
61
61
|
|
|
62
62
|
## License
|
|
63
63
|
|
package/dist/bin.js
CHANGED
|
@@ -127,6 +127,7 @@ async function runNpmInstall(cwd) {
|
|
|
127
127
|
// src/commands/init.ts
|
|
128
128
|
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", ".lxpack", ".git", "coverage", ".nyc_output"]);
|
|
129
129
|
var SKIP_FILES = /* @__PURE__ */ new Set([".DS_Store"]);
|
|
130
|
+
var INIT_BACKUP_DIR = ".lessonkit-init-backup";
|
|
130
131
|
function getTemplateDir() {
|
|
131
132
|
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
132
133
|
const candidates = [
|
|
@@ -195,29 +196,94 @@ async function applyTemplateSubstitutions(projectDir, projectName, slug) {
|
|
|
195
196
|
appSource = appSource.replace(/\{\{courseTitle\}\}/g, escapeJsxString(projectName));
|
|
196
197
|
await writeFile(appPath, appSource, "utf8");
|
|
197
198
|
}
|
|
199
|
+
function toPosixRelativePath(relativePath) {
|
|
200
|
+
return relativePath.replace(/\\/g, "/");
|
|
201
|
+
}
|
|
202
|
+
async function listStagingFiles(stagingDir, relativeDir = "") {
|
|
203
|
+
const dir = relativeDir ? join(stagingDir, relativeDir) : stagingDir;
|
|
204
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
205
|
+
const files = [];
|
|
206
|
+
for (const entry of entries) {
|
|
207
|
+
if (SKIP_DIRS.has(entry.name) || SKIP_FILES.has(entry.name)) continue;
|
|
208
|
+
const rel = relativeDir ? join(relativeDir, entry.name) : entry.name;
|
|
209
|
+
const relPosix = toPosixRelativePath(rel);
|
|
210
|
+
if (entry.isDirectory()) {
|
|
211
|
+
files.push(...await listStagingFiles(stagingDir, rel));
|
|
212
|
+
} else if (entry.isFile()) {
|
|
213
|
+
files.push(relPosix);
|
|
214
|
+
} else {
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return files;
|
|
218
|
+
}
|
|
219
|
+
async function listProjectFiles(projectDir, relativeDir = "") {
|
|
220
|
+
const files = /* @__PURE__ */ new Set();
|
|
221
|
+
const dir = relativeDir ? join(projectDir, relativeDir) : projectDir;
|
|
222
|
+
if (!existsSync(dir)) return files;
|
|
223
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
224
|
+
for (const entry of entries) {
|
|
225
|
+
if (relativeDir === "" && entry.name === INIT_BACKUP_DIR) continue;
|
|
226
|
+
if (relativeDir === "" && entry.name.startsWith(".lessonkit-init-")) continue;
|
|
227
|
+
if (SKIP_DIRS.has(entry.name) || SKIP_FILES.has(entry.name)) continue;
|
|
228
|
+
const rel = relativeDir ? join(relativeDir, entry.name) : entry.name;
|
|
229
|
+
const relPosix = toPosixRelativePath(rel);
|
|
230
|
+
if (entry.isDirectory()) {
|
|
231
|
+
for (const nested of await listProjectFiles(projectDir, rel)) {
|
|
232
|
+
files.add(nested);
|
|
233
|
+
}
|
|
234
|
+
} else if (entry.isFile()) {
|
|
235
|
+
files.add(relPosix);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return files;
|
|
239
|
+
}
|
|
198
240
|
async function backupConflictingFiles(stagingDir, projectDir) {
|
|
199
241
|
const backups = /* @__PURE__ */ new Map();
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
const destPath = join(projectDir, entry.name);
|
|
242
|
+
for (const relPath of await listStagingFiles(stagingDir)) {
|
|
243
|
+
const destPath = join(projectDir, relPath);
|
|
203
244
|
if (!existsSync(destPath)) continue;
|
|
204
245
|
const destStat = await stat(destPath);
|
|
205
246
|
if (destStat.isFile()) {
|
|
206
|
-
backups.set(
|
|
247
|
+
backups.set(relPath, await readFile(destPath));
|
|
207
248
|
}
|
|
208
249
|
}
|
|
209
250
|
return backups;
|
|
210
251
|
}
|
|
211
|
-
async function
|
|
252
|
+
async function writeInitBackupDir(projectDir, backups) {
|
|
253
|
+
const backupDir = join(projectDir, INIT_BACKUP_DIR);
|
|
254
|
+
await mkdir(backupDir, { recursive: true });
|
|
255
|
+
for (const [relPath, content] of backups) {
|
|
256
|
+
const outPath = join(backupDir, relPath);
|
|
257
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
258
|
+
await writeFile(outPath, content);
|
|
259
|
+
}
|
|
260
|
+
return backupDir;
|
|
261
|
+
}
|
|
262
|
+
async function rollbackPromotedFiles(projectDir, stagingDir, preExistingRoots, preExistingFiles, backups) {
|
|
212
263
|
const failures = [];
|
|
264
|
+
const stagingFiles = await listStagingFiles(stagingDir);
|
|
265
|
+
for (const relPath of stagingFiles) {
|
|
266
|
+
if (backups.has(relPath) || preExistingFiles.has(relPath)) continue;
|
|
267
|
+
try {
|
|
268
|
+
await rm(join(projectDir, relPath), { force: true });
|
|
269
|
+
} catch (err) {
|
|
270
|
+
failures.push(`remove ${relPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
213
273
|
let stagingEntries;
|
|
214
274
|
try {
|
|
215
275
|
stagingEntries = await readdir(stagingDir, { withFileTypes: true });
|
|
216
276
|
} catch {
|
|
277
|
+
if (failures.length > 0) {
|
|
278
|
+
throw new CliError(`Init rollback failed: ${failures.join("; ")}`, {
|
|
279
|
+
code: "RUNTIME",
|
|
280
|
+
exitCode: EXIT_INVALID_PROJECT
|
|
281
|
+
});
|
|
282
|
+
}
|
|
217
283
|
return;
|
|
218
284
|
}
|
|
219
285
|
for (const entry of stagingEntries) {
|
|
220
|
-
if (
|
|
286
|
+
if (preExistingRoots.has(entry.name)) continue;
|
|
221
287
|
try {
|
|
222
288
|
await rm(join(projectDir, entry.name), { recursive: true, force: true });
|
|
223
289
|
} catch (err) {
|
|
@@ -226,11 +292,13 @@ async function rollbackPromotedFiles(projectDir, stagingDir, preExisting, backup
|
|
|
226
292
|
);
|
|
227
293
|
}
|
|
228
294
|
}
|
|
229
|
-
for (const [
|
|
295
|
+
for (const [relPath, content] of backups) {
|
|
230
296
|
try {
|
|
231
|
-
|
|
297
|
+
const destPath = join(projectDir, relPath);
|
|
298
|
+
await mkdir(dirname(destPath), { recursive: true });
|
|
299
|
+
await writeFile(destPath, content);
|
|
232
300
|
} catch (err) {
|
|
233
|
-
failures.push(`restore ${
|
|
301
|
+
failures.push(`restore ${relPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
234
302
|
}
|
|
235
303
|
}
|
|
236
304
|
if (failures.length > 0) {
|
|
@@ -240,12 +308,16 @@ async function rollbackPromotedFiles(projectDir, stagingDir, preExisting, backup
|
|
|
240
308
|
});
|
|
241
309
|
}
|
|
242
310
|
}
|
|
311
|
+
var PROMOTE_REPLACE_ENTRIES = /* @__PURE__ */ new Set(["node_modules", "package-lock.json"]);
|
|
243
312
|
async function promoteStagingToProjectDir(stagingDir, projectDir) {
|
|
244
313
|
await mkdir(projectDir, { recursive: true });
|
|
245
314
|
const entries = await readdir(stagingDir, { withFileTypes: true });
|
|
246
315
|
for (const entry of entries) {
|
|
247
316
|
const srcPath = join(stagingDir, entry.name);
|
|
248
317
|
const destPath = join(projectDir, entry.name);
|
|
318
|
+
if (PROMOTE_REPLACE_ENTRIES.has(entry.name) && existsSync(destPath)) {
|
|
319
|
+
await rm(destPath, { recursive: true, force: true });
|
|
320
|
+
}
|
|
249
321
|
if (entry.isDirectory()) {
|
|
250
322
|
await cp(srcPath, destPath, { recursive: true });
|
|
251
323
|
} else if (entry.isFile()) {
|
|
@@ -262,7 +334,11 @@ var __testInitHelpers = {
|
|
|
262
334
|
copyTemplate,
|
|
263
335
|
promoteStagingToProjectDir,
|
|
264
336
|
rollbackPromotedFiles,
|
|
265
|
-
backupConflictingFiles
|
|
337
|
+
backupConflictingFiles,
|
|
338
|
+
writeInitBackupDir,
|
|
339
|
+
listStagingFiles,
|
|
340
|
+
listProjectFiles,
|
|
341
|
+
INIT_BACKUP_DIR
|
|
266
342
|
};
|
|
267
343
|
async function runInit(opts, logger) {
|
|
268
344
|
const cwd = process.cwd();
|
|
@@ -293,16 +369,7 @@ async function runInit(opts, logger) {
|
|
|
293
369
|
}
|
|
294
370
|
if (opts.here && !await isDirEmptyOrDotfilesOnly(projectDir) && !opts.force) {
|
|
295
371
|
throw new CliError(
|
|
296
|
-
`Directory is not empty: ${projectDir}. Use --here --force
|
|
297
|
-
{
|
|
298
|
-
code: "INVALID_PROJECT",
|
|
299
|
-
exitCode: EXIT_INVALID_PROJECT
|
|
300
|
-
}
|
|
301
|
-
);
|
|
302
|
-
}
|
|
303
|
-
if (opts.here && opts.force && !await isDirEmptyOrDotfilesOnly(projectDir)) {
|
|
304
|
-
throw new CliError(
|
|
305
|
-
`Directory is not empty: ${projectDir}. --force only initializes when the directory is empty or contains dotfiles only (e.g. .git).`,
|
|
372
|
+
`Directory is not empty: ${projectDir}. Use --here --force to scaffold anyway (conflicting files are backed up under ${INIT_BACKUP_DIR}/).`,
|
|
306
373
|
{
|
|
307
374
|
code: "INVALID_PROJECT",
|
|
308
375
|
exitCode: EXIT_INVALID_PROJECT
|
|
@@ -325,13 +392,38 @@ async function runInit(opts, logger) {
|
|
|
325
392
|
await runNpmInstall(stagingDir);
|
|
326
393
|
}
|
|
327
394
|
if (opts.here) {
|
|
328
|
-
const
|
|
395
|
+
const preExistingRoots = new Set(await readdir(projectDir));
|
|
396
|
+
const preExistingFiles = await listProjectFiles(projectDir);
|
|
329
397
|
const backups = await backupConflictingFiles(stagingDir, projectDir);
|
|
398
|
+
const conflicts = [...backups.keys()].sort();
|
|
399
|
+
if (conflicts.length > 0 && !opts.force) {
|
|
400
|
+
throw new CliError(
|
|
401
|
+
`Would overwrite existing file(s): ${conflicts.join(", ")}. Re-run with --force to back them up under ${INIT_BACKUP_DIR}/ and continue.`,
|
|
402
|
+
{
|
|
403
|
+
code: "INVALID_PROJECT",
|
|
404
|
+
exitCode: EXIT_INVALID_PROJECT
|
|
405
|
+
}
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
if (conflicts.length > 0 && opts.force && !opts.json) {
|
|
409
|
+
const backupDir = await writeInitBackupDir(projectDir, backups);
|
|
410
|
+
logger.log(
|
|
411
|
+
`Backed up ${conflicts.length} conflicting file(s) to ${backupDir}: ${conflicts.join(", ")}`
|
|
412
|
+
);
|
|
413
|
+
} else if (conflicts.length > 0 && opts.force) {
|
|
414
|
+
await writeInitBackupDir(projectDir, backups);
|
|
415
|
+
}
|
|
330
416
|
try {
|
|
331
417
|
await __testInitHelpers.promoteStagingToProjectDir(stagingDir, projectDir);
|
|
332
418
|
} catch (promoteErr) {
|
|
333
419
|
try {
|
|
334
|
-
await rollbackPromotedFiles(
|
|
420
|
+
await rollbackPromotedFiles(
|
|
421
|
+
projectDir,
|
|
422
|
+
stagingDir,
|
|
423
|
+
preExistingRoots,
|
|
424
|
+
preExistingFiles,
|
|
425
|
+
backups
|
|
426
|
+
);
|
|
335
427
|
} catch (rollbackErr) {
|
|
336
428
|
const promoteMessage = promoteErr instanceof Error ? promoteErr.message : String(promoteErr);
|
|
337
429
|
const rollbackMessage = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
|
|
@@ -632,13 +724,13 @@ async function runBuild(opts) {
|
|
|
632
724
|
{ code: "INVALID_PROJECT", exitCode: EXIT_INVALID_PROJECT }
|
|
633
725
|
);
|
|
634
726
|
}
|
|
727
|
+
await assertSpaDistContentsSafe({ main: distDir }, project.root);
|
|
635
728
|
return { ok: true, command: "build", projectRoot: project.root };
|
|
636
729
|
}
|
|
637
730
|
|
|
638
731
|
// src/commands/package.ts
|
|
639
732
|
import { existsSync as existsSync4 } from "fs";
|
|
640
|
-
import {
|
|
641
|
-
import { packageLessonkitCourse } from "@lessonkit/lxpack";
|
|
733
|
+
import { assertSpaDistContentsSafe as assertSpaDistContentsSafe2, packageLessonkitCourse } from "@lessonkit/lxpack";
|
|
642
734
|
async function runPackage(opts) {
|
|
643
735
|
let target;
|
|
644
736
|
try {
|
|
@@ -674,6 +766,7 @@ async function runPackage(opts) {
|
|
|
674
766
|
exitCode: EXIT_INVALID_PROJECT
|
|
675
767
|
});
|
|
676
768
|
}
|
|
769
|
+
await assertSpaDistContentsSafe2({ main: distDir }, project.root);
|
|
677
770
|
return { ok: true, command: "package", target, projectRoot: project.root, distDir };
|
|
678
771
|
}
|
|
679
772
|
assertNode18ForLxpack();
|
|
@@ -687,20 +780,19 @@ async function runPackage(opts) {
|
|
|
687
780
|
});
|
|
688
781
|
}
|
|
689
782
|
const outDir = resolveLxpackOutDir(project);
|
|
783
|
+
const trimmedOut = opts.out?.trim();
|
|
690
784
|
const { output: resolvedOutput, dir, outputBaseDir } = resolvePackageOutput(
|
|
691
785
|
project,
|
|
692
786
|
target,
|
|
693
|
-
|
|
787
|
+
trimmedOut
|
|
694
788
|
);
|
|
695
|
-
const trimmedOut = opts.out?.trim();
|
|
696
|
-
const output = trimmedOut && !isAbsolute(trimmedOut) ? trimmedOut : resolvedOutput;
|
|
697
789
|
const result = await packageLessonkitCourse({
|
|
698
790
|
descriptor: project.course,
|
|
699
791
|
outDir,
|
|
700
792
|
spaDistDir: distDir,
|
|
701
793
|
projectRoot: project.root,
|
|
702
794
|
target,
|
|
703
|
-
output,
|
|
795
|
+
output: trimmedOut ? resolvedOutput : void 0,
|
|
704
796
|
dir,
|
|
705
797
|
outputBaseDir,
|
|
706
798
|
strictParity: opts.strictParity,
|
|
@@ -879,7 +971,7 @@ function createProgram(baseLogger = console) {
|
|
|
879
971
|
program.name("lessonkit").description("LessonKit CLI").version(version);
|
|
880
972
|
program.command("init").description("Initialize a LessonKit project from the Vite + React template").argument("[name]", "Project directory name").option("--here", "Initialize in the current directory").option("--skip-install", "Skip npm install").option(
|
|
881
973
|
"--force",
|
|
882
|
-
"
|
|
974
|
+
"With --here: scaffold in a non-empty directory; back up conflicting template paths under .lessonkit-init-backup/ before overwrite"
|
|
883
975
|
).option("--json", "Emit structured JSON result").action(async (name, opts) => {
|
|
884
976
|
const logger = createLogger({ json: opts.json });
|
|
885
977
|
await handleCommand(
|
package/dist/index.js
CHANGED
|
@@ -125,6 +125,7 @@ async function runNpmInstall(cwd) {
|
|
|
125
125
|
// src/commands/init.ts
|
|
126
126
|
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", ".lxpack", ".git", "coverage", ".nyc_output"]);
|
|
127
127
|
var SKIP_FILES = /* @__PURE__ */ new Set([".DS_Store"]);
|
|
128
|
+
var INIT_BACKUP_DIR = ".lessonkit-init-backup";
|
|
128
129
|
function getTemplateDir() {
|
|
129
130
|
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
130
131
|
const candidates = [
|
|
@@ -193,29 +194,94 @@ async function applyTemplateSubstitutions(projectDir, projectName, slug) {
|
|
|
193
194
|
appSource = appSource.replace(/\{\{courseTitle\}\}/g, escapeJsxString(projectName));
|
|
194
195
|
await writeFile(appPath, appSource, "utf8");
|
|
195
196
|
}
|
|
197
|
+
function toPosixRelativePath(relativePath) {
|
|
198
|
+
return relativePath.replace(/\\/g, "/");
|
|
199
|
+
}
|
|
200
|
+
async function listStagingFiles(stagingDir, relativeDir = "") {
|
|
201
|
+
const dir = relativeDir ? join(stagingDir, relativeDir) : stagingDir;
|
|
202
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
203
|
+
const files = [];
|
|
204
|
+
for (const entry of entries) {
|
|
205
|
+
if (SKIP_DIRS.has(entry.name) || SKIP_FILES.has(entry.name)) continue;
|
|
206
|
+
const rel = relativeDir ? join(relativeDir, entry.name) : entry.name;
|
|
207
|
+
const relPosix = toPosixRelativePath(rel);
|
|
208
|
+
if (entry.isDirectory()) {
|
|
209
|
+
files.push(...await listStagingFiles(stagingDir, rel));
|
|
210
|
+
} else if (entry.isFile()) {
|
|
211
|
+
files.push(relPosix);
|
|
212
|
+
} else {
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return files;
|
|
216
|
+
}
|
|
217
|
+
async function listProjectFiles(projectDir, relativeDir = "") {
|
|
218
|
+
const files = /* @__PURE__ */ new Set();
|
|
219
|
+
const dir = relativeDir ? join(projectDir, relativeDir) : projectDir;
|
|
220
|
+
if (!existsSync(dir)) return files;
|
|
221
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
if (relativeDir === "" && entry.name === INIT_BACKUP_DIR) continue;
|
|
224
|
+
if (relativeDir === "" && entry.name.startsWith(".lessonkit-init-")) continue;
|
|
225
|
+
if (SKIP_DIRS.has(entry.name) || SKIP_FILES.has(entry.name)) continue;
|
|
226
|
+
const rel = relativeDir ? join(relativeDir, entry.name) : entry.name;
|
|
227
|
+
const relPosix = toPosixRelativePath(rel);
|
|
228
|
+
if (entry.isDirectory()) {
|
|
229
|
+
for (const nested of await listProjectFiles(projectDir, rel)) {
|
|
230
|
+
files.add(nested);
|
|
231
|
+
}
|
|
232
|
+
} else if (entry.isFile()) {
|
|
233
|
+
files.add(relPosix);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return files;
|
|
237
|
+
}
|
|
196
238
|
async function backupConflictingFiles(stagingDir, projectDir) {
|
|
197
239
|
const backups = /* @__PURE__ */ new Map();
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
const destPath = join(projectDir, entry.name);
|
|
240
|
+
for (const relPath of await listStagingFiles(stagingDir)) {
|
|
241
|
+
const destPath = join(projectDir, relPath);
|
|
201
242
|
if (!existsSync(destPath)) continue;
|
|
202
243
|
const destStat = await stat(destPath);
|
|
203
244
|
if (destStat.isFile()) {
|
|
204
|
-
backups.set(
|
|
245
|
+
backups.set(relPath, await readFile(destPath));
|
|
205
246
|
}
|
|
206
247
|
}
|
|
207
248
|
return backups;
|
|
208
249
|
}
|
|
209
|
-
async function
|
|
250
|
+
async function writeInitBackupDir(projectDir, backups) {
|
|
251
|
+
const backupDir = join(projectDir, INIT_BACKUP_DIR);
|
|
252
|
+
await mkdir(backupDir, { recursive: true });
|
|
253
|
+
for (const [relPath, content] of backups) {
|
|
254
|
+
const outPath = join(backupDir, relPath);
|
|
255
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
256
|
+
await writeFile(outPath, content);
|
|
257
|
+
}
|
|
258
|
+
return backupDir;
|
|
259
|
+
}
|
|
260
|
+
async function rollbackPromotedFiles(projectDir, stagingDir, preExistingRoots, preExistingFiles, backups) {
|
|
210
261
|
const failures = [];
|
|
262
|
+
const stagingFiles = await listStagingFiles(stagingDir);
|
|
263
|
+
for (const relPath of stagingFiles) {
|
|
264
|
+
if (backups.has(relPath) || preExistingFiles.has(relPath)) continue;
|
|
265
|
+
try {
|
|
266
|
+
await rm(join(projectDir, relPath), { force: true });
|
|
267
|
+
} catch (err) {
|
|
268
|
+
failures.push(`remove ${relPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
211
271
|
let stagingEntries;
|
|
212
272
|
try {
|
|
213
273
|
stagingEntries = await readdir(stagingDir, { withFileTypes: true });
|
|
214
274
|
} catch {
|
|
275
|
+
if (failures.length > 0) {
|
|
276
|
+
throw new CliError(`Init rollback failed: ${failures.join("; ")}`, {
|
|
277
|
+
code: "RUNTIME",
|
|
278
|
+
exitCode: EXIT_INVALID_PROJECT
|
|
279
|
+
});
|
|
280
|
+
}
|
|
215
281
|
return;
|
|
216
282
|
}
|
|
217
283
|
for (const entry of stagingEntries) {
|
|
218
|
-
if (
|
|
284
|
+
if (preExistingRoots.has(entry.name)) continue;
|
|
219
285
|
try {
|
|
220
286
|
await rm(join(projectDir, entry.name), { recursive: true, force: true });
|
|
221
287
|
} catch (err) {
|
|
@@ -224,11 +290,13 @@ async function rollbackPromotedFiles(projectDir, stagingDir, preExisting, backup
|
|
|
224
290
|
);
|
|
225
291
|
}
|
|
226
292
|
}
|
|
227
|
-
for (const [
|
|
293
|
+
for (const [relPath, content] of backups) {
|
|
228
294
|
try {
|
|
229
|
-
|
|
295
|
+
const destPath = join(projectDir, relPath);
|
|
296
|
+
await mkdir(dirname(destPath), { recursive: true });
|
|
297
|
+
await writeFile(destPath, content);
|
|
230
298
|
} catch (err) {
|
|
231
|
-
failures.push(`restore ${
|
|
299
|
+
failures.push(`restore ${relPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
232
300
|
}
|
|
233
301
|
}
|
|
234
302
|
if (failures.length > 0) {
|
|
@@ -238,12 +306,16 @@ async function rollbackPromotedFiles(projectDir, stagingDir, preExisting, backup
|
|
|
238
306
|
});
|
|
239
307
|
}
|
|
240
308
|
}
|
|
309
|
+
var PROMOTE_REPLACE_ENTRIES = /* @__PURE__ */ new Set(["node_modules", "package-lock.json"]);
|
|
241
310
|
async function promoteStagingToProjectDir(stagingDir, projectDir) {
|
|
242
311
|
await mkdir(projectDir, { recursive: true });
|
|
243
312
|
const entries = await readdir(stagingDir, { withFileTypes: true });
|
|
244
313
|
for (const entry of entries) {
|
|
245
314
|
const srcPath = join(stagingDir, entry.name);
|
|
246
315
|
const destPath = join(projectDir, entry.name);
|
|
316
|
+
if (PROMOTE_REPLACE_ENTRIES.has(entry.name) && existsSync(destPath)) {
|
|
317
|
+
await rm(destPath, { recursive: true, force: true });
|
|
318
|
+
}
|
|
247
319
|
if (entry.isDirectory()) {
|
|
248
320
|
await cp(srcPath, destPath, { recursive: true });
|
|
249
321
|
} else if (entry.isFile()) {
|
|
@@ -260,7 +332,11 @@ var __testInitHelpers = {
|
|
|
260
332
|
copyTemplate,
|
|
261
333
|
promoteStagingToProjectDir,
|
|
262
334
|
rollbackPromotedFiles,
|
|
263
|
-
backupConflictingFiles
|
|
335
|
+
backupConflictingFiles,
|
|
336
|
+
writeInitBackupDir,
|
|
337
|
+
listStagingFiles,
|
|
338
|
+
listProjectFiles,
|
|
339
|
+
INIT_BACKUP_DIR
|
|
264
340
|
};
|
|
265
341
|
async function runInit(opts, logger) {
|
|
266
342
|
const cwd = process.cwd();
|
|
@@ -291,16 +367,7 @@ async function runInit(opts, logger) {
|
|
|
291
367
|
}
|
|
292
368
|
if (opts.here && !await isDirEmptyOrDotfilesOnly(projectDir) && !opts.force) {
|
|
293
369
|
throw new CliError(
|
|
294
|
-
`Directory is not empty: ${projectDir}. Use --here --force
|
|
295
|
-
{
|
|
296
|
-
code: "INVALID_PROJECT",
|
|
297
|
-
exitCode: EXIT_INVALID_PROJECT
|
|
298
|
-
}
|
|
299
|
-
);
|
|
300
|
-
}
|
|
301
|
-
if (opts.here && opts.force && !await isDirEmptyOrDotfilesOnly(projectDir)) {
|
|
302
|
-
throw new CliError(
|
|
303
|
-
`Directory is not empty: ${projectDir}. --force only initializes when the directory is empty or contains dotfiles only (e.g. .git).`,
|
|
370
|
+
`Directory is not empty: ${projectDir}. Use --here --force to scaffold anyway (conflicting files are backed up under ${INIT_BACKUP_DIR}/).`,
|
|
304
371
|
{
|
|
305
372
|
code: "INVALID_PROJECT",
|
|
306
373
|
exitCode: EXIT_INVALID_PROJECT
|
|
@@ -323,13 +390,38 @@ async function runInit(opts, logger) {
|
|
|
323
390
|
await runNpmInstall(stagingDir);
|
|
324
391
|
}
|
|
325
392
|
if (opts.here) {
|
|
326
|
-
const
|
|
393
|
+
const preExistingRoots = new Set(await readdir(projectDir));
|
|
394
|
+
const preExistingFiles = await listProjectFiles(projectDir);
|
|
327
395
|
const backups = await backupConflictingFiles(stagingDir, projectDir);
|
|
396
|
+
const conflicts = [...backups.keys()].sort();
|
|
397
|
+
if (conflicts.length > 0 && !opts.force) {
|
|
398
|
+
throw new CliError(
|
|
399
|
+
`Would overwrite existing file(s): ${conflicts.join(", ")}. Re-run with --force to back them up under ${INIT_BACKUP_DIR}/ and continue.`,
|
|
400
|
+
{
|
|
401
|
+
code: "INVALID_PROJECT",
|
|
402
|
+
exitCode: EXIT_INVALID_PROJECT
|
|
403
|
+
}
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
if (conflicts.length > 0 && opts.force && !opts.json) {
|
|
407
|
+
const backupDir = await writeInitBackupDir(projectDir, backups);
|
|
408
|
+
logger.log(
|
|
409
|
+
`Backed up ${conflicts.length} conflicting file(s) to ${backupDir}: ${conflicts.join(", ")}`
|
|
410
|
+
);
|
|
411
|
+
} else if (conflicts.length > 0 && opts.force) {
|
|
412
|
+
await writeInitBackupDir(projectDir, backups);
|
|
413
|
+
}
|
|
328
414
|
try {
|
|
329
415
|
await __testInitHelpers.promoteStagingToProjectDir(stagingDir, projectDir);
|
|
330
416
|
} catch (promoteErr) {
|
|
331
417
|
try {
|
|
332
|
-
await rollbackPromotedFiles(
|
|
418
|
+
await rollbackPromotedFiles(
|
|
419
|
+
projectDir,
|
|
420
|
+
stagingDir,
|
|
421
|
+
preExistingRoots,
|
|
422
|
+
preExistingFiles,
|
|
423
|
+
backups
|
|
424
|
+
);
|
|
333
425
|
} catch (rollbackErr) {
|
|
334
426
|
const promoteMessage = promoteErr instanceof Error ? promoteErr.message : String(promoteErr);
|
|
335
427
|
const rollbackMessage = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
|
|
@@ -630,13 +722,13 @@ async function runBuild(opts) {
|
|
|
630
722
|
{ code: "INVALID_PROJECT", exitCode: EXIT_INVALID_PROJECT }
|
|
631
723
|
);
|
|
632
724
|
}
|
|
725
|
+
await assertSpaDistContentsSafe({ main: distDir }, project.root);
|
|
633
726
|
return { ok: true, command: "build", projectRoot: project.root };
|
|
634
727
|
}
|
|
635
728
|
|
|
636
729
|
// src/commands/package.ts
|
|
637
730
|
import { existsSync as existsSync4 } from "fs";
|
|
638
|
-
import {
|
|
639
|
-
import { packageLessonkitCourse } from "@lessonkit/lxpack";
|
|
731
|
+
import { assertSpaDistContentsSafe as assertSpaDistContentsSafe2, packageLessonkitCourse } from "@lessonkit/lxpack";
|
|
640
732
|
async function runPackage(opts) {
|
|
641
733
|
let target;
|
|
642
734
|
try {
|
|
@@ -672,6 +764,7 @@ async function runPackage(opts) {
|
|
|
672
764
|
exitCode: EXIT_INVALID_PROJECT
|
|
673
765
|
});
|
|
674
766
|
}
|
|
767
|
+
await assertSpaDistContentsSafe2({ main: distDir }, project.root);
|
|
675
768
|
return { ok: true, command: "package", target, projectRoot: project.root, distDir };
|
|
676
769
|
}
|
|
677
770
|
assertNode18ForLxpack();
|
|
@@ -685,20 +778,19 @@ async function runPackage(opts) {
|
|
|
685
778
|
});
|
|
686
779
|
}
|
|
687
780
|
const outDir = resolveLxpackOutDir(project);
|
|
781
|
+
const trimmedOut = opts.out?.trim();
|
|
688
782
|
const { output: resolvedOutput, dir, outputBaseDir } = resolvePackageOutput(
|
|
689
783
|
project,
|
|
690
784
|
target,
|
|
691
|
-
|
|
785
|
+
trimmedOut
|
|
692
786
|
);
|
|
693
|
-
const trimmedOut = opts.out?.trim();
|
|
694
|
-
const output = trimmedOut && !isAbsolute(trimmedOut) ? trimmedOut : resolvedOutput;
|
|
695
787
|
const result = await packageLessonkitCourse({
|
|
696
788
|
descriptor: project.course,
|
|
697
789
|
outDir,
|
|
698
790
|
spaDistDir: distDir,
|
|
699
791
|
projectRoot: project.root,
|
|
700
792
|
target,
|
|
701
|
-
output,
|
|
793
|
+
output: trimmedOut ? resolvedOutput : void 0,
|
|
702
794
|
dir,
|
|
703
795
|
outputBaseDir,
|
|
704
796
|
strictParity: opts.strictParity,
|
|
@@ -877,7 +969,7 @@ function createProgram(baseLogger = console) {
|
|
|
877
969
|
program.name("lessonkit").description("LessonKit CLI").version(version);
|
|
878
970
|
program.command("init").description("Initialize a LessonKit project from the Vite + React template").argument("[name]", "Project directory name").option("--here", "Initialize in the current directory").option("--skip-install", "Skip npm install").option(
|
|
879
971
|
"--force",
|
|
880
|
-
"
|
|
972
|
+
"With --here: scaffold in a non-empty directory; back up conflicting template paths under .lessonkit-init-backup/ before overwrite"
|
|
881
973
|
).option("--json", "Emit structured JSON result").action(async (name, opts) => {
|
|
882
974
|
const logger = createLogger({ json: opts.json });
|
|
883
975
|
await handleCommand(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lessonkit/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "LessonKit CLI — init, dev, build, and package learning experiences.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
],
|
|
37
37
|
"scripts": {
|
|
38
38
|
"copy-template": "node scripts/copy-template.mjs",
|
|
39
|
-
"build": "npm run copy-template && tsup src/index.ts src/bin.ts --format esm --splitting=false --platform node --target node18 --dts",
|
|
40
|
-
"dev": "npm run copy-template && tsup src/index.ts src/bin.ts --format esm --watch --splitting=false --platform node --target node18 --dts",
|
|
39
|
+
"build": "npm run copy-template && tsup src/index.ts src/bin.ts --format esm --splitting=false --platform node --target node18 --dts --tsconfig tsconfig.build.json",
|
|
40
|
+
"dev": "npm run copy-template && tsup src/index.ts src/bin.ts --format esm --watch --splitting=false --platform node --target node18 --dts --tsconfig tsconfig.build.json",
|
|
41
41
|
"prepublishOnly": "npm run build",
|
|
42
42
|
"typecheck": "tsc -p tsconfig.json",
|
|
43
43
|
"test": "vitest run --passWithNoTests",
|
|
@@ -45,9 +45,9 @@
|
|
|
45
45
|
"lint": "eslint --max-warnings 0 \"src/**/*.{ts,tsx}\" \"test/**/*.{ts,tsx}\""
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@lessonkit/core": "1.
|
|
49
|
-
"@lessonkit/lxpack": "1.
|
|
50
|
-
"@lessonkit/react": "1.
|
|
48
|
+
"@lessonkit/core": "1.7.1",
|
|
49
|
+
"@lessonkit/lxpack": "1.7.1",
|
|
50
|
+
"@lessonkit/react": "1.7.1",
|
|
51
51
|
"commander": "^15.0.0"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|
|
@@ -45,8 +45,8 @@ After `npm run package:scorm12`, the CLI prints the resolved ZIP path. Default:
|
|
|
45
45
|
|
|
46
46
|
## Production
|
|
47
47
|
|
|
48
|
-
Copy `.env.example` to `.env` and set your LRS/analytics proxy URLs before `npm run build`. See
|
|
48
|
+
Copy `.env.example` to `.env` and set your LRS/analytics proxy URLs before `npm run build`. See [LMS Go-Live](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/lms-go-live.html) and the [backend proxy cookbook](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/backend-proxy-cookbook.html).
|
|
49
49
|
|
|
50
50
|
## Docs
|
|
51
51
|
|
|
52
|
-
[5-minute guide](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/getting-started-in-5-minutes.html) · [
|
|
52
|
+
[5-minute guide](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/getting-started-in-5-minutes.html) · [LMS Go-Live](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/lms-go-live.html) · [CLI reference](https://lessonkit.readthedocs.io/en/latest/reference/cli.html) · [Packaging guide](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/packaging-and-cli.html)
|
|
@@ -16,16 +16,16 @@
|
|
|
16
16
|
"test:coverage": "vitest run --coverage --passWithNoTests=false"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@lessonkit/core": "^1.
|
|
20
|
-
"@lessonkit/react": "^1.
|
|
21
|
-
"@lessonkit/themes": "^1.
|
|
22
|
-
"@lessonkit/xapi": "^1.
|
|
19
|
+
"@lessonkit/core": "^1.7.1",
|
|
20
|
+
"@lessonkit/react": "^1.7.1",
|
|
21
|
+
"@lessonkit/themes": "^1.7.1",
|
|
22
|
+
"@lessonkit/xapi": "^1.7.1",
|
|
23
23
|
"react": "^19.2.7",
|
|
24
24
|
"react-dom": "^19.2.7"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
|
-
"@lessonkit/cli": "^1.
|
|
28
|
-
"@lessonkit/lxpack": "^1.
|
|
27
|
+
"@lessonkit/cli": "^1.7.1",
|
|
28
|
+
"@lessonkit/lxpack": "^1.7.1",
|
|
29
29
|
"@testing-library/react": "^16.3.0",
|
|
30
30
|
"@testing-library/dom": "^10.4.1",
|
|
31
31
|
"@types/react": "^19.2.17",
|
|
@@ -46,6 +46,11 @@ describe("createCourseConfig", () => {
|
|
|
46
46
|
config.observability?.onXapiTransportError?.(new Error("transport"));
|
|
47
47
|
config.observability?.onXapiMappingError?.(new Error("mapping"));
|
|
48
48
|
config.observability?.onLxpackBridgeError?.(new Error("bridge"));
|
|
49
|
+
config.observability?.onInvalidSessionId?.({
|
|
50
|
+
invalidId: "bad:id",
|
|
51
|
+
fallbackId: "tab-1",
|
|
52
|
+
source: "provided",
|
|
53
|
+
});
|
|
49
54
|
|
|
50
55
|
expect(warn).toHaveBeenCalled();
|
|
51
56
|
warn.mockRestore();
|
|
@@ -34,6 +34,9 @@ function createObservability(): NonNullable<LessonkitConfig["observability"]> {
|
|
|
34
34
|
onLxpackBridgeError: (err) => report("lxpack-bridge-error", { err }),
|
|
35
35
|
onXapiTransportError: (err) => report("xapi-transport", { err }),
|
|
36
36
|
onXapiMappingError: (err) => report("xapi-mapping", { err }),
|
|
37
|
+
onXapiDeadLetterPersistError: (err, ctx) =>
|
|
38
|
+
report("xapi-dead-letter-persist", { err, statementId: ctx.statement.id }),
|
|
39
|
+
onInvalidSessionId: (ctx) => report("invalid-session-id", ctx),
|
|
37
40
|
};
|
|
38
41
|
}
|
|
39
42
|
|