@lessonkit/cli 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { createRequire as createRequire2 } from "module";
4
+ import { createRequire as createRequire3 } from "module";
5
5
  import { Command } from "commander";
6
6
 
7
7
  // src/commands/init.ts
8
8
  import { slugifyId } from "@lessonkit/core";
9
- import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from "fs/promises";
9
+ import { cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
10
10
  import { existsSync } from "fs";
11
11
  import { randomUUID } from "crypto";
12
12
  import { basename, dirname, join, resolve } from "path";
@@ -138,6 +138,11 @@ function getTemplateDir() {
138
138
  }
139
139
  return candidates[0];
140
140
  }
141
+ async function isDirEmpty(dir) {
142
+ if (!existsSync(dir)) return true;
143
+ const entries = await readdir(dir);
144
+ return entries.length === 0;
145
+ }
141
146
  async function isDirEmptyOrDotfilesOnly(dir) {
142
147
  if (!existsSync(dir)) return true;
143
148
  const entries = await readdir(dir);
@@ -190,6 +195,51 @@ async function applyTemplateSubstitutions(projectDir, projectName, slug) {
190
195
  appSource = appSource.replace(/\{\{courseTitle\}\}/g, escapeJsxString(projectName));
191
196
  await writeFile(appPath, appSource, "utf8");
192
197
  }
198
+ async function backupConflictingFiles(stagingDir, projectDir) {
199
+ 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);
203
+ if (!existsSync(destPath)) continue;
204
+ const destStat = await stat(destPath);
205
+ if (destStat.isFile()) {
206
+ backups.set(entry.name, await readFile(destPath));
207
+ }
208
+ }
209
+ return backups;
210
+ }
211
+ async function rollbackPromotedFiles(projectDir, stagingDir, preExisting, backups) {
212
+ const failures = [];
213
+ let stagingEntries;
214
+ try {
215
+ stagingEntries = await readdir(stagingDir, { withFileTypes: true });
216
+ } catch {
217
+ return;
218
+ }
219
+ for (const entry of stagingEntries) {
220
+ if (preExisting.has(entry.name)) continue;
221
+ try {
222
+ await rm(join(projectDir, entry.name), { recursive: true, force: true });
223
+ } catch (err) {
224
+ failures.push(
225
+ `remove ${entry.name}: ${err instanceof Error ? err.message : String(err)}`
226
+ );
227
+ }
228
+ }
229
+ for (const [name, content] of backups) {
230
+ try {
231
+ await writeFile(join(projectDir, name), content);
232
+ } catch (err) {
233
+ failures.push(`restore ${name}: ${err instanceof Error ? err.message : String(err)}`);
234
+ }
235
+ }
236
+ if (failures.length > 0) {
237
+ throw new CliError(`Init rollback failed: ${failures.join("; ")}`, {
238
+ code: "RUNTIME",
239
+ exitCode: EXIT_INVALID_PROJECT
240
+ });
241
+ }
242
+ }
193
243
  async function promoteStagingToProjectDir(stagingDir, projectDir) {
194
244
  await mkdir(projectDir, { recursive: true });
195
245
  const entries = await readdir(stagingDir, { withFileTypes: true });
@@ -204,6 +254,16 @@ async function promoteStagingToProjectDir(stagingDir, projectDir) {
204
254
  }
205
255
  }
206
256
  }
257
+ var __testInitHelpers = {
258
+ getTemplateDir,
259
+ isDirEmpty,
260
+ isDirEmptyOrDotfilesOnly,
261
+ escapeJsxString,
262
+ copyTemplate,
263
+ promoteStagingToProjectDir,
264
+ rollbackPromotedFiles,
265
+ backupConflictingFiles
266
+ };
207
267
  async function runInit(opts, logger) {
208
268
  const cwd = process.cwd();
209
269
  const rawName = opts.name ?? (opts.here ? slugifyId(basename(process.cwd()) || "my-course") : void 0);
@@ -232,10 +292,13 @@ async function runInit(opts, logger) {
232
292
  );
233
293
  }
234
294
  if (opts.here && !await isDirEmptyOrDotfilesOnly(projectDir) && !opts.force) {
235
- throw new CliError(`Directory is not empty: ${projectDir}. Use --force to initialize anyway.`, {
236
- code: "INVALID_PROJECT",
237
- exitCode: EXIT_INVALID_PROJECT
238
- });
295
+ 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
+ );
239
302
  }
240
303
  if (opts.here && opts.force && !await isDirEmptyOrDotfilesOnly(projectDir)) {
241
304
  throw new CliError(
@@ -262,7 +325,23 @@ async function runInit(opts, logger) {
262
325
  await runNpmInstall(stagingDir);
263
326
  }
264
327
  if (opts.here) {
265
- await promoteStagingToProjectDir(stagingDir, projectDir);
328
+ const preExisting = new Set(await readdir(projectDir));
329
+ const backups = await backupConflictingFiles(stagingDir, projectDir);
330
+ try {
331
+ await __testInitHelpers.promoteStagingToProjectDir(stagingDir, projectDir);
332
+ } catch (promoteErr) {
333
+ try {
334
+ await rollbackPromotedFiles(projectDir, stagingDir, preExisting, backups);
335
+ } catch (rollbackErr) {
336
+ const promoteMessage = promoteErr instanceof Error ? promoteErr.message : String(promoteErr);
337
+ const rollbackMessage = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
338
+ throw new CliError(`${promoteMessage}; ${rollbackMessage}`, {
339
+ code: "RUNTIME",
340
+ exitCode: EXIT_INVALID_PROJECT
341
+ });
342
+ }
343
+ throw promoteErr;
344
+ }
266
345
  await rm(stagingDir, { recursive: true, force: true });
267
346
  } else {
268
347
  await rename(stagingDir, projectDir);
@@ -283,7 +362,9 @@ async function runInit(opts, logger) {
283
362
 
284
363
  // src/commands/dev.ts
285
364
  import { existsSync as existsSync3 } from "fs";
286
- import { join as join3 } from "path";
365
+ import { mkdir as mkdir2 } from "fs/promises";
366
+ import { dirname as dirname3, join as join3 } from "path";
367
+ import { assertSpaDistContentsSafe } from "@lessonkit/lxpack";
287
368
 
288
369
  // src/lib/project.ts
289
370
  import { readFileSync, existsSync as existsSync2 } from "fs";
@@ -293,10 +374,13 @@ import { dirname as dirname2, join as join2, parse, resolve as resolve2 } from "
293
374
  import { parseLessonkitManifest } from "@lessonkit/lxpack";
294
375
  var LESSONKIT_JSON = "lessonkit.json";
295
376
  var PACKAGE_JSON = "package.json";
377
+ function isSchemaVersionOne(value) {
378
+ return value === 1 || value === "1";
379
+ }
296
380
  function isProjectManifest(configPath) {
297
381
  try {
298
382
  const raw = JSON.parse(readFileSync(configPath, "utf8"));
299
- return raw.schemaVersion === 1 && typeof raw.name === "string" && raw.course !== null && typeof raw.course === "object" && !Array.isArray(raw.course);
383
+ return isSchemaVersionOne(raw.schemaVersion) && typeof raw.name === "string" && raw.course !== null && typeof raw.course === "object" && !Array.isArray(raw.course);
300
384
  } catch {
301
385
  return false;
302
386
  }
@@ -469,7 +553,12 @@ function resolvePackageOutput(project, target, override) {
469
553
  if (override) {
470
554
  try {
471
555
  const resolved = resolveSafePackageOutputOverride(project.root, override);
472
- return { output: resolved, dir: target === "standalone", outputBaseDir };
556
+ const isZipOutput = override.trim().toLowerCase().endsWith(".zip");
557
+ return {
558
+ output: resolved,
559
+ dir: target === "standalone" && !isZipOutput,
560
+ outputBaseDir
561
+ };
473
562
  } catch (err) {
474
563
  const message = err instanceof Error ? err.message : String(err);
475
564
  throw new CliError(message, { code: "INVALID_PROJECT", exitCode: EXIT_INVALID_PROJECT });
@@ -515,7 +604,8 @@ async function runDev(opts) {
515
604
  const pkg = await readPackageJson(project.root);
516
605
  assertViteProject(pkg, project.root);
517
606
  const viteJs = resolveViteJs(project.root);
518
- await runCommand(process.execPath, [viteJs, ...opts.viteArgs ?? []], {
607
+ const devArgs = stripOutDirFromViteArgs(opts.viteArgs ?? []);
608
+ await runCommand(process.execPath, [viteJs, ...devArgs], {
519
609
  cwd: project.root,
520
610
  timeoutMs: 0
521
611
  });
@@ -526,11 +616,15 @@ async function runBuild(opts) {
526
616
  const pkg = await readPackageJson(project.root);
527
617
  assertViteProject(pkg, project.root);
528
618
  const viteJs = resolveViteJs(project.root);
619
+ const distDir = resolveDistDir(project);
620
+ await mkdir2(dirname3(distDir), { recursive: true });
621
+ if (existsSync3(distDir)) {
622
+ await assertSpaDistContentsSafe({ main: distDir }, project.root);
623
+ }
529
624
  const buildArgs = resolveViteBuildArgv(project, opts.viteArgs);
530
625
  await runCommand(process.execPath, [viteJs, ...buildArgs], {
531
626
  cwd: project.root
532
627
  });
533
- const distDir = resolveDistDir(project);
534
628
  const indexHtml = join3(distDir, "index.html");
535
629
  if (!existsSync3(indexHtml)) {
536
630
  throw new CliError(
@@ -609,7 +703,8 @@ async function runPackage(opts) {
609
703
  output,
610
704
  dir,
611
705
  outputBaseDir,
612
- strictParity: opts.strictParity
706
+ strictParity: opts.strictParity,
707
+ strictBuild: opts.strict
613
708
  });
614
709
  if (!result.ok) {
615
710
  throw new CliError("Packaging failed.", {
@@ -641,6 +736,112 @@ async function runPackage(opts) {
641
736
  };
642
737
  }
643
738
 
739
+ // src/commands/export.ts
740
+ import { existsSync as existsSync5 } from "fs";
741
+ import { relative, resolve as resolve4 } from "path";
742
+ import { exportLkcourse, resolveSafePackageOutputOverride as resolveSafePackageOutputOverride2 } from "@lessonkit/lxpack";
743
+ function resolveExportOutput(projectRoot, override, defaultName) {
744
+ if (override) {
745
+ try {
746
+ return resolveSafePackageOutputOverride2(projectRoot, override);
747
+ } catch (err) {
748
+ const message = err instanceof Error ? err.message : String(err);
749
+ throw new CliError(message, { code: "INVALID_PROJECT", exitCode: EXIT_INVALID_PROJECT });
750
+ }
751
+ }
752
+ return resolve4(projectRoot, `${defaultName ?? "course"}.lkcourse`);
753
+ }
754
+ async function runExport(opts) {
755
+ const project = await loadProject(opts.cwd ?? process.cwd());
756
+ const distDir = resolve4(project.root, project.paths.spaDistDir);
757
+ if (opts.noBuild && !existsSync5(distDir)) {
758
+ throw new CliError(
759
+ `dist directory not found at ${distDir}. Run lessonkit build before export with --no-build.`,
760
+ {
761
+ code: "INVALID_PROJECT",
762
+ exitCode: EXIT_INVALID_PROJECT
763
+ }
764
+ );
765
+ }
766
+ if (!opts.noBuild) {
767
+ await runBuild({ cwd: project.root, json: opts.json });
768
+ }
769
+ if (!existsSync5(distDir)) {
770
+ throw new CliError(`dist directory not found at ${distDir}. Run lessonkit build first.`, {
771
+ code: "INVALID_PROJECT",
772
+ exitCode: EXIT_INVALID_PROJECT
773
+ });
774
+ }
775
+ const resolvedOut = resolveExportOutput(project.root, opts.out, project.name);
776
+ const outRelative = relative(project.root, resolvedOut).replace(/\\/g, "/");
777
+ const result = await exportLkcourse({
778
+ projectRoot: project.root,
779
+ manifest: project,
780
+ outPath: outRelative,
781
+ includeBlockTree: Boolean(opts.withBlockTree)
782
+ });
783
+ if (!result.ok) {
784
+ throw new CliError(
785
+ result.issues.map((i) => `${i.path}: ${i.message}`).join("; "),
786
+ { code: "EXPORT_FAILED", exitCode: EXIT_PACKAGING }
787
+ );
788
+ }
789
+ return {
790
+ ok: true,
791
+ command: "export",
792
+ projectRoot: project.root,
793
+ archivePath: result.archivePath,
794
+ fileCount: result.fileCount,
795
+ includeBlockTree: result.includeBlockTree
796
+ };
797
+ }
798
+
799
+ // src/commands/blocks.ts
800
+ import { readFileSync as readFileSync2 } from "fs";
801
+ import { createRequire as createRequire2 } from "module";
802
+ function loadBlockCatalog() {
803
+ const require3 = createRequire2(import.meta.url);
804
+ const catalogPath = require3.resolve("@lessonkit/react/block-catalog.v3.json");
805
+ return JSON.parse(readFileSync2(catalogPath, "utf8"));
806
+ }
807
+ function filterEntries(entries, opts) {
808
+ return entries.filter((entry) => {
809
+ if (opts.category && entry.category !== opts.category) return false;
810
+ if (opts.tier && entry.tier !== opts.tier) return false;
811
+ return true;
812
+ });
813
+ }
814
+ async function runBlocksList(opts) {
815
+ const catalog = loadBlockCatalog();
816
+ const entries = filterEntries(catalog.entries, opts);
817
+ if (!opts.json) {
818
+ const lines = [
819
+ "type category h5pMachineName",
820
+ ...entries.map(
821
+ (entry) => [
822
+ entry.type,
823
+ entry.category ?? "\u2014",
824
+ entry.h5pMachineName ?? "\u2014"
825
+ ].join(" ")
826
+ )
827
+ ];
828
+ return {
829
+ ok: true,
830
+ command: "blocks list",
831
+ schemaVersion: catalog.schemaVersion,
832
+ count: entries.length,
833
+ text: lines.join("\n")
834
+ };
835
+ }
836
+ return {
837
+ ok: true,
838
+ command: "blocks list",
839
+ schemaVersion: catalog.schemaVersion,
840
+ count: entries.length,
841
+ entries
842
+ };
843
+ }
844
+
644
845
  // src/lib/logger.ts
645
846
  function createLogger(opts) {
646
847
  if (opts?.json) {
@@ -655,7 +856,7 @@ function createLogger(opts) {
655
856
  }
656
857
 
657
858
  // src/index.ts
658
- var require2 = createRequire2(import.meta.url);
859
+ var require2 = createRequire3(import.meta.url);
659
860
  var { version } = require2("../package.json");
660
861
  async function handleCommand(fn, logger, json) {
661
862
  try {
@@ -712,7 +913,7 @@ function createProgram(baseLogger = console) {
712
913
  );
713
914
  }
714
915
  );
715
- program.command("package").description("Build or package for web / LMS delivery").requiredOption("--target <target>", `Export target (${PACKAGE_TARGETS.join(", ")})`).option("--cwd <dir>", "Project root directory").option("--no-build", "Skip implicit Vite build for LMS targets").option("--out <path>", "Override output artifact path").option("--strict-parity", "Treat React ID parity warnings as packaging errors").option("--json", "Emit structured JSON result").action(async (opts) => {
916
+ program.command("package").description("Build or package for web / LMS delivery").requiredOption("--target <target>", `Export target (${PACKAGE_TARGETS.join(", ")})`).option("--cwd <dir>", "Project root directory").option("--no-build", "Skip implicit Vite build for LMS targets").option("--out <path>", "Override output artifact path").option("--strict-parity", "Treat React ID parity warnings as packaging errors").option("--strict", "Treat Vite build warnings as packaging failures").option("--json", "Emit structured JSON result").action(async (opts) => {
716
917
  const logger = createLogger({ json: opts.json });
717
918
  await handleCommand(
718
919
  async () => {
@@ -722,7 +923,8 @@ function createProgram(baseLogger = console) {
722
923
  noBuild: opts.build === false,
723
924
  out: opts.out,
724
925
  json: opts.json,
725
- strictParity: opts.strictParity
926
+ strictParity: opts.strictParity,
927
+ strict: opts.strict
726
928
  });
727
929
  if (!opts.json && result.ok && result.command === "package") {
728
930
  if (result.target === "react-vite") {
@@ -740,6 +942,46 @@ function createProgram(baseLogger = console) {
740
942
  Boolean(opts.json)
741
943
  );
742
944
  });
945
+ addCwdAndJson(
946
+ program.command("export").description("Export a portable .lkcourse archive (manifest + interchange + dist)").option("--out <path>", "Output .lkcourse path (relative to project root)").option("--no-build", "Skip implicit Vite build").option("--with-block-tree", "Include optional block-tree.json from src scan")
947
+ ).action(async (opts) => {
948
+ const logger = createLogger({ json: opts.json });
949
+ await handleCommand(
950
+ async () => {
951
+ const result = await runExport({
952
+ cwd: opts.cwd,
953
+ out: opts.out,
954
+ noBuild: opts.build === false,
955
+ withBlockTree: opts.withBlockTree,
956
+ json: opts.json
957
+ });
958
+ if (!opts.json && result.ok && result.command === "export") {
959
+ logger.log(`Exported .lkcourse \u2192 ${result.archivePath} (${result.fileCount} files)`);
960
+ }
961
+ return result;
962
+ },
963
+ logger,
964
+ Boolean(opts.json)
965
+ );
966
+ });
967
+ program.command("blocks").description("Block registry commands").command("list").description("List runtime blocks from block-catalog.v3.json").option("--json", "Emit structured JSON result").option("--category <category>", "Filter by category (container, assessment, content, compound)").option("--tier <tier>", "Filter by tier (A, B, C, D, E)").action(async (opts) => {
968
+ const logger = createLogger({ json: opts.json });
969
+ await handleCommand(
970
+ async () => {
971
+ const result = await runBlocksList({
972
+ json: opts.json,
973
+ category: opts.category,
974
+ tier: opts.tier
975
+ });
976
+ if (!opts.json && result.ok && "text" in result && typeof result.text === "string") {
977
+ logger.log(result.text);
978
+ }
979
+ return result;
980
+ },
981
+ logger,
982
+ Boolean(opts.json)
983
+ );
984
+ });
743
985
  program.command("publish").description("[maintainers] Not implemented \u2014 use Changesets (see RELEASING.md)").action(() => {
744
986
  baseLogger.log(
745
987
  "lessonkit publish is not implemented. Monorepo releases use Changesets: npm run changeset && npm run version-packages && npm run release. See RELEASING.md."
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  // src/index.ts
2
- import { createRequire as createRequire2 } from "module";
2
+ import { createRequire as createRequire3 } from "module";
3
3
  import { Command } from "commander";
4
4
 
5
5
  // src/commands/init.ts
6
6
  import { slugifyId } from "@lessonkit/core";
7
- import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from "fs/promises";
7
+ import { cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
8
8
  import { existsSync } from "fs";
9
9
  import { randomUUID } from "crypto";
10
10
  import { basename, dirname, join, resolve } from "path";
@@ -136,6 +136,11 @@ function getTemplateDir() {
136
136
  }
137
137
  return candidates[0];
138
138
  }
139
+ async function isDirEmpty(dir) {
140
+ if (!existsSync(dir)) return true;
141
+ const entries = await readdir(dir);
142
+ return entries.length === 0;
143
+ }
139
144
  async function isDirEmptyOrDotfilesOnly(dir) {
140
145
  if (!existsSync(dir)) return true;
141
146
  const entries = await readdir(dir);
@@ -188,6 +193,51 @@ async function applyTemplateSubstitutions(projectDir, projectName, slug) {
188
193
  appSource = appSource.replace(/\{\{courseTitle\}\}/g, escapeJsxString(projectName));
189
194
  await writeFile(appPath, appSource, "utf8");
190
195
  }
196
+ async function backupConflictingFiles(stagingDir, projectDir) {
197
+ 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);
201
+ if (!existsSync(destPath)) continue;
202
+ const destStat = await stat(destPath);
203
+ if (destStat.isFile()) {
204
+ backups.set(entry.name, await readFile(destPath));
205
+ }
206
+ }
207
+ return backups;
208
+ }
209
+ async function rollbackPromotedFiles(projectDir, stagingDir, preExisting, backups) {
210
+ const failures = [];
211
+ let stagingEntries;
212
+ try {
213
+ stagingEntries = await readdir(stagingDir, { withFileTypes: true });
214
+ } catch {
215
+ return;
216
+ }
217
+ for (const entry of stagingEntries) {
218
+ if (preExisting.has(entry.name)) continue;
219
+ try {
220
+ await rm(join(projectDir, entry.name), { recursive: true, force: true });
221
+ } catch (err) {
222
+ failures.push(
223
+ `remove ${entry.name}: ${err instanceof Error ? err.message : String(err)}`
224
+ );
225
+ }
226
+ }
227
+ for (const [name, content] of backups) {
228
+ try {
229
+ await writeFile(join(projectDir, name), content);
230
+ } catch (err) {
231
+ failures.push(`restore ${name}: ${err instanceof Error ? err.message : String(err)}`);
232
+ }
233
+ }
234
+ if (failures.length > 0) {
235
+ throw new CliError(`Init rollback failed: ${failures.join("; ")}`, {
236
+ code: "RUNTIME",
237
+ exitCode: EXIT_INVALID_PROJECT
238
+ });
239
+ }
240
+ }
191
241
  async function promoteStagingToProjectDir(stagingDir, projectDir) {
192
242
  await mkdir(projectDir, { recursive: true });
193
243
  const entries = await readdir(stagingDir, { withFileTypes: true });
@@ -202,6 +252,16 @@ async function promoteStagingToProjectDir(stagingDir, projectDir) {
202
252
  }
203
253
  }
204
254
  }
255
+ var __testInitHelpers = {
256
+ getTemplateDir,
257
+ isDirEmpty,
258
+ isDirEmptyOrDotfilesOnly,
259
+ escapeJsxString,
260
+ copyTemplate,
261
+ promoteStagingToProjectDir,
262
+ rollbackPromotedFiles,
263
+ backupConflictingFiles
264
+ };
205
265
  async function runInit(opts, logger) {
206
266
  const cwd = process.cwd();
207
267
  const rawName = opts.name ?? (opts.here ? slugifyId(basename(process.cwd()) || "my-course") : void 0);
@@ -230,10 +290,13 @@ async function runInit(opts, logger) {
230
290
  );
231
291
  }
232
292
  if (opts.here && !await isDirEmptyOrDotfilesOnly(projectDir) && !opts.force) {
233
- throw new CliError(`Directory is not empty: ${projectDir}. Use --force to initialize anyway.`, {
234
- code: "INVALID_PROJECT",
235
- exitCode: EXIT_INVALID_PROJECT
236
- });
293
+ 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
+ );
237
300
  }
238
301
  if (opts.here && opts.force && !await isDirEmptyOrDotfilesOnly(projectDir)) {
239
302
  throw new CliError(
@@ -260,7 +323,23 @@ async function runInit(opts, logger) {
260
323
  await runNpmInstall(stagingDir);
261
324
  }
262
325
  if (opts.here) {
263
- await promoteStagingToProjectDir(stagingDir, projectDir);
326
+ const preExisting = new Set(await readdir(projectDir));
327
+ const backups = await backupConflictingFiles(stagingDir, projectDir);
328
+ try {
329
+ await __testInitHelpers.promoteStagingToProjectDir(stagingDir, projectDir);
330
+ } catch (promoteErr) {
331
+ try {
332
+ await rollbackPromotedFiles(projectDir, stagingDir, preExisting, backups);
333
+ } catch (rollbackErr) {
334
+ const promoteMessage = promoteErr instanceof Error ? promoteErr.message : String(promoteErr);
335
+ const rollbackMessage = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
336
+ throw new CliError(`${promoteMessage}; ${rollbackMessage}`, {
337
+ code: "RUNTIME",
338
+ exitCode: EXIT_INVALID_PROJECT
339
+ });
340
+ }
341
+ throw promoteErr;
342
+ }
264
343
  await rm(stagingDir, { recursive: true, force: true });
265
344
  } else {
266
345
  await rename(stagingDir, projectDir);
@@ -281,7 +360,9 @@ async function runInit(opts, logger) {
281
360
 
282
361
  // src/commands/dev.ts
283
362
  import { existsSync as existsSync3 } from "fs";
284
- import { join as join3 } from "path";
363
+ import { mkdir as mkdir2 } from "fs/promises";
364
+ import { dirname as dirname3, join as join3 } from "path";
365
+ import { assertSpaDistContentsSafe } from "@lessonkit/lxpack";
285
366
 
286
367
  // src/lib/project.ts
287
368
  import { readFileSync, existsSync as existsSync2 } from "fs";
@@ -291,10 +372,13 @@ import { dirname as dirname2, join as join2, parse, resolve as resolve2 } from "
291
372
  import { parseLessonkitManifest } from "@lessonkit/lxpack";
292
373
  var LESSONKIT_JSON = "lessonkit.json";
293
374
  var PACKAGE_JSON = "package.json";
375
+ function isSchemaVersionOne(value) {
376
+ return value === 1 || value === "1";
377
+ }
294
378
  function isProjectManifest(configPath) {
295
379
  try {
296
380
  const raw = JSON.parse(readFileSync(configPath, "utf8"));
297
- return raw.schemaVersion === 1 && typeof raw.name === "string" && raw.course !== null && typeof raw.course === "object" && !Array.isArray(raw.course);
381
+ return isSchemaVersionOne(raw.schemaVersion) && typeof raw.name === "string" && raw.course !== null && typeof raw.course === "object" && !Array.isArray(raw.course);
298
382
  } catch {
299
383
  return false;
300
384
  }
@@ -467,7 +551,12 @@ function resolvePackageOutput(project, target, override) {
467
551
  if (override) {
468
552
  try {
469
553
  const resolved = resolveSafePackageOutputOverride(project.root, override);
470
- return { output: resolved, dir: target === "standalone", outputBaseDir };
554
+ const isZipOutput = override.trim().toLowerCase().endsWith(".zip");
555
+ return {
556
+ output: resolved,
557
+ dir: target === "standalone" && !isZipOutput,
558
+ outputBaseDir
559
+ };
471
560
  } catch (err) {
472
561
  const message = err instanceof Error ? err.message : String(err);
473
562
  throw new CliError(message, { code: "INVALID_PROJECT", exitCode: EXIT_INVALID_PROJECT });
@@ -513,7 +602,8 @@ async function runDev(opts) {
513
602
  const pkg = await readPackageJson(project.root);
514
603
  assertViteProject(pkg, project.root);
515
604
  const viteJs = resolveViteJs(project.root);
516
- await runCommand(process.execPath, [viteJs, ...opts.viteArgs ?? []], {
605
+ const devArgs = stripOutDirFromViteArgs(opts.viteArgs ?? []);
606
+ await runCommand(process.execPath, [viteJs, ...devArgs], {
517
607
  cwd: project.root,
518
608
  timeoutMs: 0
519
609
  });
@@ -524,11 +614,15 @@ async function runBuild(opts) {
524
614
  const pkg = await readPackageJson(project.root);
525
615
  assertViteProject(pkg, project.root);
526
616
  const viteJs = resolveViteJs(project.root);
617
+ const distDir = resolveDistDir(project);
618
+ await mkdir2(dirname3(distDir), { recursive: true });
619
+ if (existsSync3(distDir)) {
620
+ await assertSpaDistContentsSafe({ main: distDir }, project.root);
621
+ }
527
622
  const buildArgs = resolveViteBuildArgv(project, opts.viteArgs);
528
623
  await runCommand(process.execPath, [viteJs, ...buildArgs], {
529
624
  cwd: project.root
530
625
  });
531
- const distDir = resolveDistDir(project);
532
626
  const indexHtml = join3(distDir, "index.html");
533
627
  if (!existsSync3(indexHtml)) {
534
628
  throw new CliError(
@@ -607,7 +701,8 @@ async function runPackage(opts) {
607
701
  output,
608
702
  dir,
609
703
  outputBaseDir,
610
- strictParity: opts.strictParity
704
+ strictParity: opts.strictParity,
705
+ strictBuild: opts.strict
611
706
  });
612
707
  if (!result.ok) {
613
708
  throw new CliError("Packaging failed.", {
@@ -639,6 +734,112 @@ async function runPackage(opts) {
639
734
  };
640
735
  }
641
736
 
737
+ // src/commands/export.ts
738
+ import { existsSync as existsSync5 } from "fs";
739
+ import { relative, resolve as resolve4 } from "path";
740
+ import { exportLkcourse, resolveSafePackageOutputOverride as resolveSafePackageOutputOverride2 } from "@lessonkit/lxpack";
741
+ function resolveExportOutput(projectRoot, override, defaultName) {
742
+ if (override) {
743
+ try {
744
+ return resolveSafePackageOutputOverride2(projectRoot, override);
745
+ } catch (err) {
746
+ const message = err instanceof Error ? err.message : String(err);
747
+ throw new CliError(message, { code: "INVALID_PROJECT", exitCode: EXIT_INVALID_PROJECT });
748
+ }
749
+ }
750
+ return resolve4(projectRoot, `${defaultName ?? "course"}.lkcourse`);
751
+ }
752
+ async function runExport(opts) {
753
+ const project = await loadProject(opts.cwd ?? process.cwd());
754
+ const distDir = resolve4(project.root, project.paths.spaDistDir);
755
+ if (opts.noBuild && !existsSync5(distDir)) {
756
+ throw new CliError(
757
+ `dist directory not found at ${distDir}. Run lessonkit build before export with --no-build.`,
758
+ {
759
+ code: "INVALID_PROJECT",
760
+ exitCode: EXIT_INVALID_PROJECT
761
+ }
762
+ );
763
+ }
764
+ if (!opts.noBuild) {
765
+ await runBuild({ cwd: project.root, json: opts.json });
766
+ }
767
+ if (!existsSync5(distDir)) {
768
+ throw new CliError(`dist directory not found at ${distDir}. Run lessonkit build first.`, {
769
+ code: "INVALID_PROJECT",
770
+ exitCode: EXIT_INVALID_PROJECT
771
+ });
772
+ }
773
+ const resolvedOut = resolveExportOutput(project.root, opts.out, project.name);
774
+ const outRelative = relative(project.root, resolvedOut).replace(/\\/g, "/");
775
+ const result = await exportLkcourse({
776
+ projectRoot: project.root,
777
+ manifest: project,
778
+ outPath: outRelative,
779
+ includeBlockTree: Boolean(opts.withBlockTree)
780
+ });
781
+ if (!result.ok) {
782
+ throw new CliError(
783
+ result.issues.map((i) => `${i.path}: ${i.message}`).join("; "),
784
+ { code: "EXPORT_FAILED", exitCode: EXIT_PACKAGING }
785
+ );
786
+ }
787
+ return {
788
+ ok: true,
789
+ command: "export",
790
+ projectRoot: project.root,
791
+ archivePath: result.archivePath,
792
+ fileCount: result.fileCount,
793
+ includeBlockTree: result.includeBlockTree
794
+ };
795
+ }
796
+
797
+ // src/commands/blocks.ts
798
+ import { readFileSync as readFileSync2 } from "fs";
799
+ import { createRequire as createRequire2 } from "module";
800
+ function loadBlockCatalog() {
801
+ const require3 = createRequire2(import.meta.url);
802
+ const catalogPath = require3.resolve("@lessonkit/react/block-catalog.v3.json");
803
+ return JSON.parse(readFileSync2(catalogPath, "utf8"));
804
+ }
805
+ function filterEntries(entries, opts) {
806
+ return entries.filter((entry) => {
807
+ if (opts.category && entry.category !== opts.category) return false;
808
+ if (opts.tier && entry.tier !== opts.tier) return false;
809
+ return true;
810
+ });
811
+ }
812
+ async function runBlocksList(opts) {
813
+ const catalog = loadBlockCatalog();
814
+ const entries = filterEntries(catalog.entries, opts);
815
+ if (!opts.json) {
816
+ const lines = [
817
+ "type category h5pMachineName",
818
+ ...entries.map(
819
+ (entry) => [
820
+ entry.type,
821
+ entry.category ?? "\u2014",
822
+ entry.h5pMachineName ?? "\u2014"
823
+ ].join(" ")
824
+ )
825
+ ];
826
+ return {
827
+ ok: true,
828
+ command: "blocks list",
829
+ schemaVersion: catalog.schemaVersion,
830
+ count: entries.length,
831
+ text: lines.join("\n")
832
+ };
833
+ }
834
+ return {
835
+ ok: true,
836
+ command: "blocks list",
837
+ schemaVersion: catalog.schemaVersion,
838
+ count: entries.length,
839
+ entries
840
+ };
841
+ }
842
+
642
843
  // src/lib/logger.ts
643
844
  function createLogger(opts) {
644
845
  if (opts?.json) {
@@ -653,7 +854,7 @@ function createLogger(opts) {
653
854
  }
654
855
 
655
856
  // src/index.ts
656
- var require2 = createRequire2(import.meta.url);
857
+ var require2 = createRequire3(import.meta.url);
657
858
  var { version } = require2("../package.json");
658
859
  async function handleCommand(fn, logger, json) {
659
860
  try {
@@ -710,7 +911,7 @@ function createProgram(baseLogger = console) {
710
911
  );
711
912
  }
712
913
  );
713
- program.command("package").description("Build or package for web / LMS delivery").requiredOption("--target <target>", `Export target (${PACKAGE_TARGETS.join(", ")})`).option("--cwd <dir>", "Project root directory").option("--no-build", "Skip implicit Vite build for LMS targets").option("--out <path>", "Override output artifact path").option("--strict-parity", "Treat React ID parity warnings as packaging errors").option("--json", "Emit structured JSON result").action(async (opts) => {
914
+ program.command("package").description("Build or package for web / LMS delivery").requiredOption("--target <target>", `Export target (${PACKAGE_TARGETS.join(", ")})`).option("--cwd <dir>", "Project root directory").option("--no-build", "Skip implicit Vite build for LMS targets").option("--out <path>", "Override output artifact path").option("--strict-parity", "Treat React ID parity warnings as packaging errors").option("--strict", "Treat Vite build warnings as packaging failures").option("--json", "Emit structured JSON result").action(async (opts) => {
714
915
  const logger = createLogger({ json: opts.json });
715
916
  await handleCommand(
716
917
  async () => {
@@ -720,7 +921,8 @@ function createProgram(baseLogger = console) {
720
921
  noBuild: opts.build === false,
721
922
  out: opts.out,
722
923
  json: opts.json,
723
- strictParity: opts.strictParity
924
+ strictParity: opts.strictParity,
925
+ strict: opts.strict
724
926
  });
725
927
  if (!opts.json && result.ok && result.command === "package") {
726
928
  if (result.target === "react-vite") {
@@ -738,6 +940,46 @@ function createProgram(baseLogger = console) {
738
940
  Boolean(opts.json)
739
941
  );
740
942
  });
943
+ addCwdAndJson(
944
+ program.command("export").description("Export a portable .lkcourse archive (manifest + interchange + dist)").option("--out <path>", "Output .lkcourse path (relative to project root)").option("--no-build", "Skip implicit Vite build").option("--with-block-tree", "Include optional block-tree.json from src scan")
945
+ ).action(async (opts) => {
946
+ const logger = createLogger({ json: opts.json });
947
+ await handleCommand(
948
+ async () => {
949
+ const result = await runExport({
950
+ cwd: opts.cwd,
951
+ out: opts.out,
952
+ noBuild: opts.build === false,
953
+ withBlockTree: opts.withBlockTree,
954
+ json: opts.json
955
+ });
956
+ if (!opts.json && result.ok && result.command === "export") {
957
+ logger.log(`Exported .lkcourse \u2192 ${result.archivePath} (${result.fileCount} files)`);
958
+ }
959
+ return result;
960
+ },
961
+ logger,
962
+ Boolean(opts.json)
963
+ );
964
+ });
965
+ program.command("blocks").description("Block registry commands").command("list").description("List runtime blocks from block-catalog.v3.json").option("--json", "Emit structured JSON result").option("--category <category>", "Filter by category (container, assessment, content, compound)").option("--tier <tier>", "Filter by tier (A, B, C, D, E)").action(async (opts) => {
966
+ const logger = createLogger({ json: opts.json });
967
+ await handleCommand(
968
+ async () => {
969
+ const result = await runBlocksList({
970
+ json: opts.json,
971
+ category: opts.category,
972
+ tier: opts.tier
973
+ });
974
+ if (!opts.json && result.ok && "text" in result && typeof result.text === "string") {
975
+ logger.log(result.text);
976
+ }
977
+ return result;
978
+ },
979
+ logger,
980
+ Boolean(opts.json)
981
+ );
982
+ });
741
983
  program.command("publish").description("[maintainers] Not implemented \u2014 use Changesets (see RELEASING.md)").action(() => {
742
984
  baseLogger.log(
743
985
  "lessonkit publish is not implemented. Monorepo releases use Changesets: npm run changeset && npm run version-packages && npm run release. See RELEASING.md."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lessonkit/cli",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "private": false,
5
5
  "description": "LessonKit CLI — init, dev, build, and package learning experiences.",
6
6
  "license": "Apache-2.0",
@@ -45,8 +45,9 @@
45
45
  "lint": "eslint --max-warnings 0 \"src/**/*.{ts,tsx}\" \"test/**/*.{ts,tsx}\""
46
46
  },
47
47
  "dependencies": {
48
- "@lessonkit/core": "1.5.0",
49
- "@lessonkit/lxpack": "1.5.0",
48
+ "@lessonkit/core": "1.6.0",
49
+ "@lessonkit/lxpack": "1.6.0",
50
+ "@lessonkit/react": "1.6.0",
50
51
  "commander": "^15.0.0"
51
52
  },
52
53
  "engines": {
@@ -16,16 +16,16 @@
16
16
  "test:coverage": "vitest run --coverage --passWithNoTests=false"
17
17
  },
18
18
  "dependencies": {
19
- "@lessonkit/core": "^1.5.0",
20
- "@lessonkit/react": "^1.5.0",
21
- "@lessonkit/themes": "^1.5.0",
22
- "@lessonkit/xapi": "^1.5.0",
19
+ "@lessonkit/core": "^1.6.0",
20
+ "@lessonkit/react": "^1.6.0",
21
+ "@lessonkit/themes": "^1.6.0",
22
+ "@lessonkit/xapi": "^1.6.0",
23
23
  "react": "^19.2.7",
24
24
  "react-dom": "^19.2.7"
25
25
  },
26
26
  "devDependencies": {
27
- "@lessonkit/cli": "^1.5.0",
28
- "@lessonkit/lxpack": "^1.5.0",
27
+ "@lessonkit/cli": "^1.6.0",
28
+ "@lessonkit/lxpack": "^1.6.0",
29
29
  "@testing-library/react": "^16.3.0",
30
30
  "@testing-library/dom": "^10.4.1",
31
31
  "@types/react": "^19.2.17",