@lessonkit/cli 1.6.0 → 1.7.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/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` | Overwrite existing files in the target directory |
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) · [Ship to LMS checklist](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/ship-to-lms.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))
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 stagingEntries = await readdir(stagingDir, { withFileTypes: true });
201
- for (const entry of stagingEntries) {
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(entry.name, await readFile(destPath));
247
+ backups.set(relPath, await readFile(destPath));
207
248
  }
208
249
  }
209
250
  return backups;
210
251
  }
211
- async function rollbackPromotedFiles(projectDir, stagingDir, preExisting, backups) {
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 (preExisting.has(entry.name)) continue;
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 [name, content] of backups) {
295
+ for (const [relPath, content] of backups) {
230
296
  try {
231
- await writeFile(join(projectDir, name), content);
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 ${name}: ${err instanceof Error ? err.message : String(err)}`);
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 only when the directory is empty or contains dotfiles only (e.g. .git).`,
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 preExisting = new Set(await readdir(projectDir));
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(projectDir, stagingDir, preExisting, backups);
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 { isAbsolute } from "path";
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
- opts.out
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
- "Requires --here: allow init when the directory is empty or contains only dotfiles"
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 stagingEntries = await readdir(stagingDir, { withFileTypes: true });
199
- for (const entry of stagingEntries) {
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(entry.name, await readFile(destPath));
245
+ backups.set(relPath, await readFile(destPath));
205
246
  }
206
247
  }
207
248
  return backups;
208
249
  }
209
- async function rollbackPromotedFiles(projectDir, stagingDir, preExisting, backups) {
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 (preExisting.has(entry.name)) continue;
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 [name, content] of backups) {
293
+ for (const [relPath, content] of backups) {
228
294
  try {
229
- await writeFile(join(projectDir, name), content);
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 ${name}: ${err instanceof Error ? err.message : String(err)}`);
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 only when the directory is empty or contains dotfiles only (e.g. .git).`,
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 preExisting = new Set(await readdir(projectDir));
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(projectDir, stagingDir, preExisting, backups);
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 { isAbsolute } from "path";
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
- opts.out
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
- "Requires --here: allow init when the directory is empty or contains only dotfiles"
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.6.0",
3
+ "version": "1.7.0",
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.6.0",
49
- "@lessonkit/lxpack": "1.6.0",
50
- "@lessonkit/react": "1.6.0",
48
+ "@lessonkit/core": "1.7.0",
49
+ "@lessonkit/lxpack": "1.7.0",
50
+ "@lessonkit/react": "1.7.0",
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 the [production checklist](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/production-checklist.html) and [Ship to LMS checklist](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/ship-to-lms.html).
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) · [First LMS export](https://lessonkit.readthedocs.io/en/latest/guides/react-developers/first-lms-export.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)
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.6.0",
20
- "@lessonkit/react": "^1.6.0",
21
- "@lessonkit/themes": "^1.6.0",
22
- "@lessonkit/xapi": "^1.6.0",
19
+ "@lessonkit/core": "^1.7.0",
20
+ "@lessonkit/react": "^1.7.0",
21
+ "@lessonkit/themes": "^1.7.0",
22
+ "@lessonkit/xapi": "^1.7.0",
23
23
  "react": "^19.2.7",
24
24
  "react-dom": "^19.2.7"
25
25
  },
26
26
  "devDependencies": {
27
- "@lessonkit/cli": "^1.6.0",
28
- "@lessonkit/lxpack": "^1.6.0",
27
+ "@lessonkit/cli": "^1.7.0",
28
+ "@lessonkit/lxpack": "^1.7.0",
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