@launchsecure/launch-kit 0.0.27 → 0.0.28

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.
Files changed (45) hide show
  1. package/dist/beacon/beacon.mjs +1003 -440
  2. package/dist/beacon/beacon.mjs.map +1 -1
  3. package/dist/beacon/beacon.umd.js +45 -24
  4. package/dist/beacon/beacon.umd.js.map +1 -1
  5. package/dist/beacon/types/capture/events.d.ts +20 -0
  6. package/dist/beacon/types/capture/events.d.ts.map +1 -0
  7. package/dist/beacon/types/element.d.ts +1 -0
  8. package/dist/beacon/types/element.d.ts.map +1 -1
  9. package/dist/beacon/types/index.d.ts +2 -1
  10. package/dist/beacon/types/index.d.ts.map +1 -1
  11. package/dist/beacon/types/monitor/dom.d.ts +13 -0
  12. package/dist/beacon/types/monitor/dom.d.ts.map +1 -0
  13. package/dist/beacon/types/monitor/index.d.ts +19 -0
  14. package/dist/beacon/types/monitor/index.d.ts.map +1 -0
  15. package/dist/beacon/types/monitor/network.d.ts +12 -0
  16. package/dist/beacon/types/monitor/network.d.ts.map +1 -0
  17. package/dist/beacon/types/monitor/transport.d.ts +27 -0
  18. package/dist/beacon/types/monitor/transport.d.ts.map +1 -0
  19. package/dist/beacon/types/monitor/types.d.ts +117 -0
  20. package/dist/beacon/types/monitor/types.d.ts.map +1 -0
  21. package/dist/beacon/types/types.d.ts +10 -0
  22. package/dist/beacon/types/types.d.ts.map +1 -1
  23. package/dist/beacon/types/ui/drawer.d.ts +3 -1
  24. package/dist/beacon/types/ui/drawer.d.ts.map +1 -1
  25. package/dist/beacon/types/ui/monitor-panel.d.ts +19 -0
  26. package/dist/beacon/types/ui/monitor-panel.d.ts.map +1 -0
  27. package/dist/server/beacon-monitor-entry.js +353 -0
  28. package/dist/server/cli.js +50 -2
  29. package/dist/server/council-entry.js +0 -0
  30. package/dist/server/course-entry.js +246 -0
  31. package/dist/server/fb-wizard.js +0 -0
  32. package/dist/server/init-entry.js +394 -64
  33. package/dist/server/orbit-entry.js +187 -24
  34. package/package.json +24 -23
  35. package/scaffolds/ls-marketplace/.claude-plugin/marketplace.json +15 -0
  36. package/scaffolds/ls-marketplace/plugins/ls/.claude-plugin/plugin.json +28 -0
  37. package/scaffolds/ls-marketplace/plugins/ls/commands/activate-beacon.md +216 -0
  38. package/scaffolds/ls-marketplace/plugins/ls/commands/beacon-array.md +92 -0
  39. package/scaffolds/ls-marketplace/plugins/ls/commands/beacon-clear.md +68 -0
  40. package/scaffolds/ls-marketplace/plugins/ls/commands/beacon-pulse.md +80 -0
  41. package/scaffolds/ls-marketplace/plugins/ls/commands/beacon-scan.md +62 -0
  42. package/scaffolds/ls-marketplace/plugins/ls/commands/show-mcp-status.md +109 -0
  43. package/scaffolds/ls-marketplace/plugins/ls/commands/standup.md +177 -0
  44. package/scaffolds/migrate-safety/scripts/migrate-with-backup.sh +0 -0
  45. package/scaffolds/recall-hook/scripts/ensure-recall.sh +69 -0
@@ -25,14 +25,78 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
25
25
 
26
26
  // src/server/init-entry.ts
27
27
  var import_node_child_process = require("node:child_process");
28
- var fs = __toESM(require("node:fs"));
28
+ var fs2 = __toESM(require("node:fs"));
29
29
  var import_node_http = require("node:http");
30
30
  var import_node_https = require("node:https");
31
- var path = __toESM(require("node:path"));
31
+ var path2 = __toESM(require("node:path"));
32
32
  var readline = __toESM(require("node:readline"));
33
33
  var import_node_url = require("node:url");
34
- var DEFAULT_SERVER_URL = "https://launchsecure-v2.vercel.app";
34
+
35
+ // src/server/cred-shape.ts
36
+ var fs = __toESM(require("node:fs"));
37
+ var path = __toESM(require("node:path"));
35
38
  var CONFIG_FILENAME = ".launch-secure.cred.config";
39
+ function inferCourseName(serverUrl) {
40
+ try {
41
+ const host = new URL(serverUrl).hostname.toLowerCase();
42
+ if (host === "localhost" || host === "127.0.0.1" || host.endsWith(".local")) return "local";
43
+ if (host.includes("staging")) return "staging";
44
+ if (host.endsWith(".vercel.app")) return "prod";
45
+ return host.split(".")[0] || "default";
46
+ } catch {
47
+ return "default";
48
+ }
49
+ }
50
+ function toNested(cred) {
51
+ if (cred.profiles && cred.active && cred.profiles[cred.active]) {
52
+ return { active: cred.active, profiles: cred.profiles };
53
+ }
54
+ if (!cred.pat || !cred.orgSlug || !cred.projectSlug || !cred.serverUrl) {
55
+ return null;
56
+ }
57
+ const name = inferCourseName(cred.serverUrl);
58
+ return {
59
+ active: name,
60
+ profiles: {
61
+ [name]: {
62
+ pat: cred.pat,
63
+ orgSlug: cred.orgSlug,
64
+ projectSlug: cred.projectSlug,
65
+ serverUrl: cred.serverUrl
66
+ }
67
+ }
68
+ };
69
+ }
70
+ function upsertProfile(existing, name, profile) {
71
+ const base = existing ? toNested(existing) ?? { active: name, profiles: {} } : { active: name, profiles: {} };
72
+ return {
73
+ active: name,
74
+ profiles: { ...base.profiles, [name]: profile }
75
+ };
76
+ }
77
+ function readCredFile(repoRoot) {
78
+ const p = path.join(repoRoot, CONFIG_FILENAME);
79
+ if (!fs.existsSync(p)) return null;
80
+ try {
81
+ return JSON.parse(fs.readFileSync(p, "utf-8"));
82
+ } catch (err) {
83
+ throw new Error(`could not parse ${CONFIG_FILENAME}: ${err instanceof Error ? err.message : String(err)}`);
84
+ }
85
+ }
86
+ function writeJsonAtomic(absPath, value, mode) {
87
+ const tmp = `${absPath}.tmp`;
88
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", "utf-8");
89
+ if (mode !== void 0) {
90
+ try {
91
+ fs.chmodSync(tmp, mode);
92
+ } catch {
93
+ }
94
+ }
95
+ fs.renameSync(tmp, absPath);
96
+ }
97
+
98
+ // src/server/init-entry.ts
99
+ var DEFAULT_SERVER_URL = "https://launchsecure-v2.vercel.app";
36
100
  var LEGACY_CONFIG_FILENAME = ".launch-secure.config";
37
101
  var ONBOARD_SCRIPT_NAME = "onboard";
38
102
  var LAUNCH_KIT_PKG = "@launchsecure/launch-kit";
@@ -47,6 +111,38 @@ Wired in Claude Code (.mcp.json):
47
111
  Other tools (run on demand via npx):
48
112
  npx launch-pod radar \u2014 webhook listener (LS pings \u2192 terminal/UI)
49
113
  npx launch-pod \u2014 full pipeline UI (separate launch-pod login)
114
+ npx launch-beacon monitor \u2014 local HTTP receiver for the launch-kit-beacon
115
+ in-browser monitor. Paste the printed URL into
116
+ the beacon debug panel; events stream to
117
+ .launchsecure/beacon-<token>.ndjson for the
118
+ /ls:beacon-* commands below to read.
119
+
120
+ LS slash commands (run inside Claude Code in this project):
121
+ /ls:activate-beacon \u2014 wire the launch-kit-beacon in-app feedback
122
+ widget into this app (mounts the <launch-kit-
123
+ beacon> Web Component + scaffolds /api/feedback
124
+ forwarding to LaunchSecure Comm Hub)
125
+ /ls:standup \u2014 draft a daily standup from work since the last
126
+ push (chart-grouped themes, work-item linkage,
127
+ release detection) and post to LS Comm Hub as
128
+ a daily_update after you confirm
129
+ /ls:show-mcp-status \u2014 show recall watcher health + last snapshot.
130
+ Add 'full' for expanded report (PID, shadow
131
+ repo size, recent snaps)
132
+ /ls:beacon-scan \u2014 scan recent events from the active
133
+ launch-beacon monitor session. Pass a kind
134
+ (error/click/fetch/route/dialog/probe) and/or
135
+ a limit to filter.
136
+ /ls:beacon-pulse \u2014 most recent error + the N events that preceded
137
+ it. "What was happening just before it broke."
138
+ /ls:beacon-array \u2014 list monitor sessions in .launchsecure/ with
139
+ event counts, last activity, liveness glyph.
140
+ Add 'full' for a per-session expanded report.
141
+ /ls:beacon-clear \u2014 wipe the latest monitor session NDJSON (or
142
+ 'all'). Confirms before deleting.
143
+
144
+ Open this repo in Claude Code; on first open you'll be prompted to install
145
+ the "launchsecure" marketplace \u2014 accept to enable the commands above.
50
146
  `;
51
147
  var PACKAGE_MANAGERS = [
52
148
  { name: "pnpm", binary: "pnpm", lockfiles: ["pnpm-lock.yaml"], workspaceFiles: ["pnpm-workspace.yaml"], installArgs: ["install"] },
@@ -61,9 +157,13 @@ function parseArgs(argv) {
61
157
  projectSlug: null,
62
158
  serverUrl: DEFAULT_SERVER_URL,
63
159
  targetDir: null,
160
+ course: null,
64
161
  noInstall: false,
65
162
  noRecall: false,
66
163
  noMigrateSafety: false,
164
+ noLsMarketplace: false,
165
+ noRecallHook: false,
166
+ dryRun: false,
67
167
  help: false
68
168
  };
69
169
  for (const raw of argv) {
@@ -83,6 +183,18 @@ function parseArgs(argv) {
83
183
  args.noMigrateSafety = true;
84
184
  continue;
85
185
  }
186
+ if (raw === "--no-ls-marketplace") {
187
+ args.noLsMarketplace = true;
188
+ continue;
189
+ }
190
+ if (raw === "--no-recall-hook") {
191
+ args.noRecallHook = true;
192
+ continue;
193
+ }
194
+ if (raw === "--dry-run") {
195
+ args.dryRun = true;
196
+ continue;
197
+ }
86
198
  const eq = raw.indexOf("=");
87
199
  if (!raw.startsWith("--") || eq < 0) continue;
88
200
  const key = raw.slice(2, eq);
@@ -92,6 +204,7 @@ function parseArgs(argv) {
92
204
  else if (key === "project") args.projectSlug = val;
93
205
  else if (key === "url") args.serverUrl = val.replace(/\/+$/, "");
94
206
  else if (key === "dir") args.targetDir = val;
207
+ else if (key === "course") args.course = val;
95
208
  }
96
209
  return args;
97
210
  }
@@ -109,10 +222,30 @@ Required:
109
222
  Options:
110
223
  --url=<serverUrl> LaunchSecure base URL (default: ${DEFAULT_SERVER_URL}).
111
224
  --dir=<path> Target directory (default: ./<projectSlug>).
225
+ --course=<name> Name for the course (profile) being added to
226
+ .launch-secure.cred.config. When omitted, inferred
227
+ from --url: localhost\u2192"local", *staging*\u2192"staging",
228
+ *.vercel.app\u2192"prod", else hostname. The course
229
+ becomes active; re-run with a different --course
230
+ and --url to add another (e.g. local + staging).
231
+ Use \`launch-course set <name>\` to switch later.
112
232
  --no-install Skip dependency install step.
113
233
  --no-recall Skip launch-recall (shadow git backup) scaffold.
114
234
  --no-migrate-safety Skip migrate-safety scaffold (pg_dump-before-migrate
115
235
  wrapper + GitHub Action + runbook).
236
+ --no-ls-marketplace Skip the Claude Code "launchsecure" marketplace
237
+ scaffold (.claude/marketplace/ + .claude/settings.json
238
+ wiring \u2014 exposes /ls:activate-beacon and future
239
+ ls-namespaced slash commands).
240
+ --no-recall-hook Skip the SessionStart hook scaffold (Claude Code
241
+ hook + scripts/ensure-recall.sh that auto-restarts
242
+ the launch-recall watcher if it died between
243
+ sessions). The hook is the surfacing layer for
244
+ watcher-died-silently scenarios.
245
+ --dry-run Preview every file write, merge, clone, and install
246
+ command without making any changes. Useful before
247
+ re-running init against a customized project. The
248
+ project_info HTTP call still runs (it's read-only).
116
249
  --help Show this help.
117
250
 
118
251
  What it does:
@@ -132,6 +265,15 @@ What it does:
132
265
  8. Scaffolds launch-recall (shadow git backup). Skip with --no-recall.
133
266
  9. Scaffolds migrate-safety (pg_dump wrapper + GHA backup workflow +
134
267
  runbook + .backups/ gitignore line). Skip with --no-migrate-safety.
268
+ 10. Scaffolds the Claude Code "launchsecure" marketplace at
269
+ .claude/marketplace/ and wires .claude/settings.json so Claude Code
270
+ auto-discovers it and enables the "ls" plugin (exposes
271
+ /ls:activate-beacon for wiring the launch-kit-beacon in-app feedback
272
+ widget). Skip with --no-ls-marketplace.
273
+ 11. Scaffolds scripts/ensure-recall.sh and appends a SessionStart hook to
274
+ .claude/settings.json that respawns the launch-recall watcher if it
275
+ died between sessions. Idempotent (dedupes by hook command-match).
276
+ Skip with --no-recall-hook.
135
277
  `);
136
278
  }
137
279
  async function prompt(question) {
@@ -151,6 +293,10 @@ function info(msg) {
151
293
  function ok(msg) {
152
294
  console.log(`[launch-kit] \u2713 ${msg}`);
153
295
  }
296
+ var DRY_RUN = false;
297
+ function dryNote(msg) {
298
+ console.log(`[launch-kit] (dry-run) ${msg}`);
299
+ }
154
300
  function which(bin) {
155
301
  const res = (0, import_node_child_process.spawnSync)(process.platform === "win32" ? "where" : "which", [bin], { encoding: "utf-8" });
156
302
  if (res.status !== 0) return null;
@@ -265,11 +411,11 @@ function normalizeRepoUrl(url) {
265
411
  }
266
412
  }
267
413
  function isGitRepo(dir) {
268
- return fs.existsSync(path.join(dir, ".git"));
414
+ return fs2.existsSync(path2.join(dir, ".git"));
269
415
  }
270
416
  function dirIsEmpty(dir) {
271
- if (!fs.existsSync(dir)) return true;
272
- return fs.readdirSync(dir).length === 0;
417
+ if (!fs2.existsSync(dir)) return true;
418
+ return fs2.readdirSync(dir).length === 0;
273
419
  }
274
420
  function cloneRepo(repoUrl, targetDir, hasGh) {
275
421
  const isGithub = /github\.com/i.test(repoUrl);
@@ -284,6 +430,10 @@ function cloneRepo(repoUrl, targetDir, hasGh) {
284
430
  args = ["clone", repoUrl, targetDir];
285
431
  info(`cloning via git: ${repoUrl} \u2192 ${targetDir}`);
286
432
  }
433
+ if (DRY_RUN) {
434
+ dryNote(`would run: ${cmd} ${args.join(" ")}`);
435
+ return;
436
+ }
287
437
  const res = (0, import_node_child_process.spawnSync)(cmd, args, { stdio: "inherit" });
288
438
  if (res.status !== 0) {
289
439
  fail(
@@ -292,43 +442,56 @@ function cloneRepo(repoUrl, targetDir, hasGh) {
292
442
  }
293
443
  ok(`cloned to ${targetDir}`);
294
444
  }
295
- function writeConfigFile(targetDir, cfg) {
445
+ function writeConfigFile(targetDir, cfg, courseName) {
296
446
  migrateLegacyCredFile(targetDir);
297
- const p = path.join(targetDir, CONFIG_FILENAME);
298
- const existed = fs.existsSync(p);
299
- fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
300
- try {
301
- fs.chmodSync(p, 384);
302
- } catch {
447
+ const p = path2.join(targetDir, CONFIG_FILENAME);
448
+ const existing = readCredFile(targetDir);
449
+ const isNew = existing === null;
450
+ const isUpdate = !isNew && Boolean(existing?.profiles?.[courseName]);
451
+ if (DRY_RUN) {
452
+ const verb = isNew ? "write" : isUpdate ? "update course" : "add course";
453
+ dryNote(`would ${verb} "${courseName}" in ${CONFIG_FILENAME} (org=${cfg.orgSlug}, project=${cfg.projectSlug}, url=${cfg.serverUrl})`);
454
+ return;
303
455
  }
304
- ok(`${existed ? "updated" : "wrote"} ${CONFIG_FILENAME}`);
456
+ const nested = upsertProfile(existing, courseName, cfg);
457
+ writeJsonAtomic(p, nested, 384);
458
+ const action = isNew ? "wrote" : isUpdate ? `updated course "${courseName}" in` : `added course "${courseName}" to`;
459
+ ok(`${action} ${CONFIG_FILENAME} (active: ${courseName})`);
305
460
  }
306
461
  function migrateLegacyCredFile(targetDir) {
307
- const legacy = path.join(targetDir, LEGACY_CONFIG_FILENAME);
308
- const dest = path.join(targetDir, CONFIG_FILENAME);
309
- if (!fs.existsSync(legacy) || fs.existsSync(dest)) return;
462
+ const legacy = path2.join(targetDir, LEGACY_CONFIG_FILENAME);
463
+ const dest = path2.join(targetDir, CONFIG_FILENAME);
464
+ if (!fs2.existsSync(legacy) || fs2.existsSync(dest)) return;
310
465
  let parsed;
311
466
  try {
312
- parsed = JSON.parse(fs.readFileSync(legacy, "utf-8"));
467
+ parsed = JSON.parse(fs2.readFileSync(legacy, "utf-8"));
313
468
  } catch {
314
469
  return;
315
470
  }
316
471
  const pat = parsed?.pat;
317
472
  if (typeof pat !== "string" || !pat.startsWith("ls_pat_")) return;
318
- fs.renameSync(legacy, dest);
473
+ if (DRY_RUN) {
474
+ dryNote(`would migrate legacy ${LEGACY_CONFIG_FILENAME} \u2192 ${CONFIG_FILENAME} and strip the legacy gitignore line`);
475
+ return;
476
+ }
477
+ fs2.renameSync(legacy, dest);
319
478
  removeGitignoreLine(targetDir, LEGACY_CONFIG_FILENAME);
320
479
  ok(`migrated legacy ${LEGACY_CONFIG_FILENAME} \u2192 ${CONFIG_FILENAME} (the old name is now reserved for file-backed-config)`);
321
480
  }
322
481
  function removeGitignoreLine(targetDir, line) {
323
- const p = path.join(targetDir, ".gitignore");
324
- if (!fs.existsSync(p)) return;
325
- const before = fs.readFileSync(p, "utf-8");
482
+ const p = path2.join(targetDir, ".gitignore");
483
+ if (!fs2.existsSync(p)) return;
484
+ const before = fs2.readFileSync(p, "utf-8");
326
485
  const after = before.split(/\r?\n/).filter((l) => l.trim() !== line).join("\n");
327
486
  if (after === before) return;
328
- fs.writeFileSync(p, after, "utf-8");
487
+ if (DRY_RUN) {
488
+ dryNote(`would remove "${line}" from .gitignore`);
489
+ return;
490
+ }
491
+ fs2.writeFileSync(p, after, "utf-8");
329
492
  ok(`removed ${line} from .gitignore (now reserved for file-backed-config)`);
330
493
  }
331
- var LAUNCH_SECURE_HEADERS_HELPER = `node -e 'const j=JSON.parse(require("fs").readFileSync(".launch-secure.cred.config","utf-8"));process.stdout.write(JSON.stringify({Authorization:"Bearer "+j.pat,"X-Org-Slug":j.orgSlug,"X-Project-Slug":j.projectSlug}))'`;
494
+ var LAUNCH_SECURE_HEADERS_HELPER = `node -e 'const j=JSON.parse(require("fs").readFileSync(".launch-secure.cred.config","utf-8"));const c=j.profiles&&j.active?j.profiles[j.active]:j;process.stdout.write(JSON.stringify({Authorization:"Bearer "+c.pat,"X-Org-Slug":c.orgSlug,"X-Project-Slug":c.projectSlug}))'`;
332
495
  function buildLaunchKitMcpEntries(cfg) {
333
496
  return {
334
497
  "launch-secure": {
@@ -356,30 +519,39 @@ function buildLaunchKitMcpEntries(cfg) {
356
519
  };
357
520
  }
358
521
  function mergeMcpFile(targetDir, launchKitEntries) {
359
- const p = path.join(targetDir, ".mcp.json");
360
- const hadExisting = fs.existsSync(p);
522
+ const p = path2.join(targetDir, ".mcp.json");
523
+ const hadExisting = fs2.existsSync(p);
361
524
  let existing = {};
362
525
  if (hadExisting) {
363
526
  try {
364
- existing = JSON.parse(fs.readFileSync(p, "utf-8"));
527
+ existing = JSON.parse(fs2.readFileSync(p, "utf-8"));
365
528
  } catch (err) {
366
529
  fail(`Could not parse existing .mcp.json: ${err instanceof Error ? err.message : String(err)}`);
367
530
  }
368
531
  }
369
532
  const existingServerCount = Object.keys(existing.mcpServers ?? {}).length;
370
533
  const merged = { ...existing, mcpServers: { ...existing.mcpServers ?? {} } };
534
+ const overwrites = [];
535
+ const additions = [];
371
536
  for (const [name, entry] of Object.entries(launchKitEntries)) {
537
+ if (merged.mcpServers[name]) overwrites.push(name);
538
+ else additions.push(name);
372
539
  merged.mcpServers[name] = entry;
373
540
  }
374
- fs.writeFileSync(p, JSON.stringify(merged, null, 2) + "\n", "utf-8");
541
+ if (DRY_RUN) {
542
+ const action2 = hadExisting && existingServerCount > 0 ? "would merge into" : "would write";
543
+ dryNote(`${action2} .mcp.json \u2014 overwriting [${overwrites.join(", ") || "none"}], adding [${additions.join(", ") || "none"}]`);
544
+ return;
545
+ }
546
+ fs2.writeFileSync(p, JSON.stringify(merged, null, 2) + "\n", "utf-8");
375
547
  const action = hadExisting && existingServerCount > 0 ? "merged into" : "wrote";
376
548
  ok(`${action} .mcp.json (${Object.keys(launchKitEntries).length} launch-kit entries)`);
377
549
  }
378
550
  function detectPackageManager(repoDir) {
379
- const pkgPath = path.join(repoDir, "package.json");
380
- if (!fs.existsSync(pkgPath)) return null;
551
+ const pkgPath = path2.join(repoDir, "package.json");
552
+ if (!fs2.existsSync(pkgPath)) return null;
381
553
  try {
382
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
554
+ const pkg = JSON.parse(fs2.readFileSync(pkgPath, "utf-8"));
383
555
  if (typeof pkg.packageManager === "string") {
384
556
  const name = pkg.packageManager.split("@")[0];
385
557
  const match = PACKAGE_MANAGERS.find((p) => p.name === name);
@@ -388,7 +560,7 @@ function detectPackageManager(repoDir) {
388
560
  }
389
561
  } catch {
390
562
  }
391
- const matches = PACKAGE_MANAGERS.map((pm) => ({ pm, lockfile: pm.lockfiles.find((lf) => fs.existsSync(path.join(repoDir, lf))) ?? null })).filter((m) => m.lockfile !== null);
563
+ const matches = PACKAGE_MANAGERS.map((pm) => ({ pm, lockfile: pm.lockfiles.find((lf) => fs2.existsSync(path2.join(repoDir, lf))) ?? null })).filter((m) => m.lockfile !== null);
392
564
  if (matches.length === 1) {
393
565
  return { pm: matches[0].pm, source: `lockfile ${matches[0].lockfile}` };
394
566
  }
@@ -397,8 +569,8 @@ function detectPackageManager(repoDir) {
397
569
  return { pm: matches[0].pm, source: `lockfile ${matches[0].lockfile} (multiple present)` };
398
570
  }
399
571
  for (const pm of PACKAGE_MANAGERS) {
400
- if (pm.workspaceFiles?.some((wf) => fs.existsSync(path.join(repoDir, wf)))) {
401
- return { pm, source: `workspace file (${pm.workspaceFiles.find((wf) => fs.existsSync(path.join(repoDir, wf)))})` };
572
+ if (pm.workspaceFiles?.some((wf) => fs2.existsSync(path2.join(repoDir, wf)))) {
573
+ return { pm, source: `workspace file (${pm.workspaceFiles.find((wf) => fs2.existsSync(path2.join(repoDir, wf)))})` };
402
574
  }
403
575
  }
404
576
  const npm = PACKAGE_MANAGERS.find((p) => p.name === "npm");
@@ -408,23 +580,27 @@ function runInstall(repoDir, detected) {
408
580
  const { pm } = detected;
409
581
  if (!which(pm.binary)) {
410
582
  fail(
411
- `${pm.name} not found on PATH. Configs and clone are intact. Install ${pm.name} (try \`corepack enable\` if you have Node \u226516), then run: cd ${path.basename(repoDir)} && ${pm.binary} ${pm.installArgs.join(" ")}`
583
+ `${pm.name} not found on PATH. Configs and clone are intact. Install ${pm.name} (try \`corepack enable\` if you have Node \u226516), then run: cd ${path2.basename(repoDir)} && ${pm.binary} ${pm.installArgs.join(" ")}`
412
584
  );
413
585
  }
414
586
  info(`running ${pm.binary} ${pm.installArgs.join(" ")} \u2026`);
587
+ if (DRY_RUN) {
588
+ dryNote(`would run: ${pm.binary} ${pm.installArgs.join(" ")} (cwd: ${repoDir})`);
589
+ return;
590
+ }
415
591
  const res = (0, import_node_child_process.spawnSync)(pm.binary, pm.installArgs, { cwd: repoDir, stdio: "inherit" });
416
592
  if (res.status !== 0) {
417
593
  fail(
418
- `${pm.name} install failed (exit ${res.status}). Configs and clone are intact \u2014 fix the underlying error and retry: cd ${path.basename(repoDir)} && ${pm.binary} ${pm.installArgs.join(" ")}`
594
+ `${pm.name} install failed (exit ${res.status}). Configs and clone are intact \u2014 fix the underlying error and retry: cd ${path2.basename(repoDir)} && ${pm.binary} ${pm.installArgs.join(" ")}`
419
595
  );
420
596
  }
421
597
  ok(`${pm.name} install complete`);
422
598
  }
423
599
  function hasOnboardScript(repoDir) {
424
- const pkgPath = path.join(repoDir, "package.json");
425
- if (!fs.existsSync(pkgPath)) return false;
600
+ const pkgPath = path2.join(repoDir, "package.json");
601
+ if (!fs2.existsSync(pkgPath)) return false;
426
602
  try {
427
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
603
+ const pkg = JSON.parse(fs2.readFileSync(pkgPath, "utf-8"));
428
604
  return typeof pkg.scripts?.[ONBOARD_SCRIPT_NAME] === "string";
429
605
  } catch {
430
606
  return false;
@@ -432,74 +608,115 @@ function hasOnboardScript(repoDir) {
432
608
  }
433
609
  function runRecallInit(repoDir) {
434
610
  info(`scaffolding launch-recall (shadow git backup) \u2026`);
435
- const recallEntry = path.resolve(__dirname, "recall-entry.js");
436
- const useSibling = fs.existsSync(recallEntry);
611
+ const recallEntry = path2.resolve(__dirname, "recall-entry.js");
612
+ const useSibling = fs2.existsSync(recallEntry);
437
613
  const cmd = useSibling ? process.execPath : "npx";
438
614
  const args = useSibling ? [recallEntry, "init"] : ["-y", "-p", LAUNCH_KIT_PKG, "launch-recall", "init"];
615
+ if (DRY_RUN) {
616
+ dryNote(`would run launch-recall init: ${cmd} ${args.join(" ")} (cwd: ${repoDir})`);
617
+ return;
618
+ }
439
619
  const res = (0, import_node_child_process.spawnSync)(cmd, args, { cwd: repoDir, stdio: "inherit" });
440
620
  if (res.status !== 0) {
441
- info(`\u26A0 launch-recall init failed (exit ${res.status}). Main onboarding is complete \u2014 you can retry later: cd ${path.basename(repoDir)} && npx -y -p ${LAUNCH_KIT_PKG} launch-recall init`);
621
+ info(`\u26A0 launch-recall init failed (exit ${res.status}). Main onboarding is complete \u2014 you can retry later: cd ${path2.basename(repoDir)} && npx -y -p ${LAUNCH_KIT_PKG} launch-recall init`);
442
622
  return;
443
623
  }
444
624
  ok(`launch-recall ready (shadow git initialized)`);
445
625
  }
446
626
  function runOnboard(repoDir, pm) {
447
627
  info(`running ${pm.binary} run ${ONBOARD_SCRIPT_NAME} \u2026`);
628
+ if (DRY_RUN) {
629
+ dryNote(`would run: ${pm.binary} run ${ONBOARD_SCRIPT_NAME} (cwd: ${repoDir})`);
630
+ return;
631
+ }
448
632
  const res = (0, import_node_child_process.spawnSync)(pm.binary, ["run", ONBOARD_SCRIPT_NAME], { cwd: repoDir, stdio: "inherit" });
449
633
  if (res.status !== 0) {
450
634
  fail(
451
- `${pm.name} run ${ONBOARD_SCRIPT_NAME} failed (exit ${res.status}). Install completed but the onboard script errored. Fix and retry: cd ${path.basename(repoDir)} && ${pm.binary} run ${ONBOARD_SCRIPT_NAME}`
635
+ `${pm.name} run ${ONBOARD_SCRIPT_NAME} failed (exit ${res.status}). Install completed but the onboard script errored. Fix and retry: cd ${path2.basename(repoDir)} && ${pm.binary} run ${ONBOARD_SCRIPT_NAME}`
452
636
  );
453
637
  }
454
638
  ok(`${ONBOARD_SCRIPT_NAME} script complete`);
455
639
  }
456
640
  function ensureGitignoreLine(targetDir, line) {
457
- const p = path.join(targetDir, ".gitignore");
458
- let content = fs.existsSync(p) ? fs.readFileSync(p, "utf-8") : "";
641
+ const p = path2.join(targetDir, ".gitignore");
642
+ let content = fs2.existsSync(p) ? fs2.readFileSync(p, "utf-8") : "";
459
643
  const lines = content.split(/\r?\n/);
460
644
  if (lines.some((l) => l.trim() === line)) return;
461
645
  if (content.length && !content.endsWith("\n")) content += "\n";
462
646
  content += `${line}
463
647
  `;
464
- fs.writeFileSync(p, content, "utf-8");
648
+ if (DRY_RUN) {
649
+ dryNote(`would append "${line}" to .gitignore`);
650
+ return;
651
+ }
652
+ fs2.writeFileSync(p, content, "utf-8");
465
653
  ok(`appended ${line} to .gitignore`);
466
654
  }
467
655
  function copyScaffoldIfMissing(srcPath, destPath, label) {
468
- if (!fs.existsSync(srcPath)) return "missing-src";
469
- if (fs.existsSync(destPath)) {
656
+ if (!fs2.existsSync(srcPath)) return "missing-src";
657
+ if (fs2.existsSync(destPath)) {
470
658
  info(`${label} already present \u2014 leaving alone`);
471
659
  return "existed";
472
660
  }
473
- fs.mkdirSync(path.dirname(destPath), { recursive: true });
474
- fs.copyFileSync(srcPath, destPath);
661
+ if (DRY_RUN) {
662
+ dryNote(`would write ${label}`);
663
+ return "wrote";
664
+ }
665
+ fs2.mkdirSync(path2.dirname(destPath), { recursive: true });
666
+ fs2.copyFileSync(srcPath, destPath);
475
667
  try {
476
- const srcMode = fs.statSync(srcPath).mode;
477
- fs.chmodSync(destPath, srcMode);
668
+ const srcMode = fs2.statSync(srcPath).mode;
669
+ fs2.chmodSync(destPath, srcMode);
478
670
  } catch {
479
671
  }
480
672
  ok(`wrote ${label}`);
481
673
  return "wrote";
482
674
  }
675
+ function copyScaffoldDirAlways(srcDir, destDir, labelPrefix) {
676
+ if (!fs2.existsSync(srcDir)) return;
677
+ for (const entry of fs2.readdirSync(srcDir, { withFileTypes: true })) {
678
+ const srcPath = path2.join(srcDir, entry.name);
679
+ const destPath = path2.join(destDir, entry.name);
680
+ const label = labelPrefix ? `${labelPrefix}/${entry.name}` : entry.name;
681
+ if (entry.isDirectory()) {
682
+ copyScaffoldDirAlways(srcPath, destPath, label);
683
+ } else if (entry.isFile()) {
684
+ const existed = fs2.existsSync(destPath);
685
+ if (DRY_RUN) {
686
+ dryNote(`would ${existed ? "refresh" : "write"} ${label}`);
687
+ continue;
688
+ }
689
+ fs2.mkdirSync(path2.dirname(destPath), { recursive: true });
690
+ fs2.copyFileSync(srcPath, destPath);
691
+ try {
692
+ const srcMode = fs2.statSync(srcPath).mode;
693
+ fs2.chmodSync(destPath, srcMode);
694
+ } catch {
695
+ }
696
+ ok(`${existed ? "refreshed" : "wrote"} ${label}`);
697
+ }
698
+ }
699
+ }
483
700
  function scaffoldMigrateSafety(targetDir) {
484
- const scaffoldsRoot = path.resolve(__dirname, "..", "..", "scaffolds", "migrate-safety");
485
- if (!fs.existsSync(scaffoldsRoot)) {
701
+ const scaffoldsRoot = path2.resolve(__dirname, "..", "..", "scaffolds", "migrate-safety");
702
+ if (!fs2.existsSync(scaffoldsRoot)) {
486
703
  info(`\u26A0 migrate-safety scaffolds not found at ${scaffoldsRoot} \u2014 skipping (this is a packaging bug; main onboarding is unaffected)`);
487
704
  return;
488
705
  }
489
706
  const files = [
490
707
  {
491
- src: path.join(scaffoldsRoot, ".github", "workflows", "backup-on-migration.yml"),
492
- dest: path.join(targetDir, ".github", "workflows", "backup-on-migration.yml"),
708
+ src: path2.join(scaffoldsRoot, ".github", "workflows", "backup-on-migration.yml"),
709
+ dest: path2.join(targetDir, ".github", "workflows", "backup-on-migration.yml"),
493
710
  label: ".github/workflows/backup-on-migration.yml"
494
711
  },
495
712
  {
496
- src: path.join(scaffoldsRoot, "scripts", "migrate-with-backup.sh"),
497
- dest: path.join(targetDir, "scripts", "migrate-with-backup.sh"),
713
+ src: path2.join(scaffoldsRoot, "scripts", "migrate-with-backup.sh"),
714
+ dest: path2.join(targetDir, "scripts", "migrate-with-backup.sh"),
498
715
  label: "scripts/migrate-with-backup.sh"
499
716
  },
500
717
  {
501
- src: path.join(scaffoldsRoot, "docs", "migrations-runbook.md"),
502
- dest: path.join(targetDir, "docs", "migrations-runbook.md"),
718
+ src: path2.join(scaffoldsRoot, "docs", "migrations-runbook.md"),
719
+ dest: path2.join(targetDir, "docs", "migrations-runbook.md"),
503
720
  label: "docs/migrations-runbook.md"
504
721
  }
505
722
  ];
@@ -508,6 +725,101 @@ function scaffoldMigrateSafety(targetDir) {
508
725
  ensureGitignoreLine(targetDir, ".backups/");
509
726
  ok("migrate-safety ready \u2014 see docs/migrations-runbook.md for db:migrate wiring + PROD_DATABASE_URL secret setup");
510
727
  }
728
+ var MARKETPLACE_ID = "launchsecure";
729
+ var LS_PLUGIN_ID = "ls";
730
+ function scaffoldLsMarketplace(targetDir) {
731
+ const scaffoldsRoot = path2.resolve(__dirname, "..", "..", "scaffolds", "ls-marketplace");
732
+ if (!fs2.existsSync(scaffoldsRoot)) {
733
+ info(`\u26A0 ls-marketplace scaffolds not found at ${scaffoldsRoot} \u2014 skipping (this is a packaging bug; main onboarding is unaffected)`);
734
+ return;
735
+ }
736
+ const marketplaceRoot = path2.join(targetDir, ".claude", "marketplace");
737
+ info("scaffolding ls marketplace (Claude Code /ls: namespace \u2014 refreshes every /ls:* command found in the scaffold) \u2026");
738
+ copyScaffoldDirAlways(scaffoldsRoot, marketplaceRoot, ".claude/marketplace");
739
+ wireLsSettings(targetDir);
740
+ ok(`ls marketplace ready \u2014 open this repo in Claude Code, approve the "${MARKETPLACE_ID}" marketplace prompt, then try /ls:activate-beacon, /ls:standup, or /ls:show-mcp-status`);
741
+ }
742
+ function wireLsSettings(targetDir) {
743
+ const p = path2.join(targetDir, ".claude", "settings.json");
744
+ const hadExisting = fs2.existsSync(p);
745
+ let existing = {};
746
+ if (hadExisting) {
747
+ try {
748
+ existing = JSON.parse(fs2.readFileSync(p, "utf-8"));
749
+ } catch (err) {
750
+ fail(`Could not parse existing .claude/settings.json: ${err instanceof Error ? err.message : String(err)}`);
751
+ }
752
+ }
753
+ const merged = { ...existing };
754
+ merged.extraKnownMarketplaces = {
755
+ ...existing.extraKnownMarketplaces ?? {},
756
+ [MARKETPLACE_ID]: {
757
+ source: { source: "directory", path: "./.claude/marketplace" }
758
+ }
759
+ };
760
+ merged.enabledPlugins = {
761
+ ...existing.enabledPlugins ?? {},
762
+ [`${LS_PLUGIN_ID}@${MARKETPLACE_ID}`]: true
763
+ };
764
+ if (DRY_RUN) {
765
+ dryNote(`would ${hadExisting ? "merge into" : "write"} .claude/settings.json (set extraKnownMarketplaces.${MARKETPLACE_ID} + enabledPlugins.${LS_PLUGIN_ID}@${MARKETPLACE_ID}; preserves every other key)`);
766
+ return;
767
+ }
768
+ fs2.mkdirSync(path2.dirname(p), { recursive: true });
769
+ fs2.writeFileSync(p, JSON.stringify(merged, null, 2) + "\n", "utf-8");
770
+ ok(`${hadExisting ? "merged into" : "wrote"} .claude/settings.json (extraKnownMarketplaces.${MARKETPLACE_ID} + enabledPlugins.${LS_PLUGIN_ID}@${MARKETPLACE_ID})`);
771
+ }
772
+ var RECALL_HOOK_SIGNATURE = "ensure-recall.sh";
773
+ var RECALL_HOOK_COMMAND = 'bash "${CLAUDE_PROJECT_DIR:-$PWD}/scripts/ensure-recall.sh"';
774
+ function scaffoldRecallHook(targetDir) {
775
+ const scaffoldsRoot = path2.resolve(__dirname, "..", "..", "scaffolds", "recall-hook");
776
+ if (!fs2.existsSync(scaffoldsRoot)) {
777
+ info(`\u26A0 recall-hook scaffolds not found at ${scaffoldsRoot} \u2014 skipping (this is a packaging bug; main onboarding is unaffected)`);
778
+ return;
779
+ }
780
+ info("scaffolding recall-hook (SessionStart watcher-respawn hook + ensure-recall.sh) \u2026");
781
+ copyScaffoldDirAlways(scaffoldsRoot, targetDir, "");
782
+ wireRecallHook(targetDir);
783
+ ok("recall-hook ready \u2014 opens with Claude Code will respawn the launch-recall watcher if it died between sessions");
784
+ }
785
+ function wireRecallHook(targetDir) {
786
+ const p = path2.join(targetDir, ".claude", "settings.json");
787
+ const hadExisting = fs2.existsSync(p);
788
+ let existing = {};
789
+ if (hadExisting) {
790
+ try {
791
+ existing = JSON.parse(fs2.readFileSync(p, "utf-8"));
792
+ } catch (err) {
793
+ fail(`Could not parse existing .claude/settings.json: ${err instanceof Error ? err.message : String(err)}`);
794
+ }
795
+ }
796
+ const hooks = existing.hooks ?? {};
797
+ const sessionStart = hooks.SessionStart ?? [];
798
+ const alreadyWired = sessionStart.some(
799
+ (group) => (group.hooks ?? []).some((h) => typeof h?.command === "string" && h.command.includes(RECALL_HOOK_SIGNATURE))
800
+ );
801
+ if (alreadyWired) {
802
+ info(".claude/settings.json SessionStart hook already references ensure-recall.sh \u2014 leaving alone");
803
+ return;
804
+ }
805
+ const newGroup = {
806
+ hooks: [{ type: "command", command: RECALL_HOOK_COMMAND }]
807
+ };
808
+ const merged = {
809
+ ...existing,
810
+ hooks: {
811
+ ...hooks,
812
+ SessionStart: [...sessionStart, newGroup]
813
+ }
814
+ };
815
+ if (DRY_RUN) {
816
+ dryNote(`would append SessionStart hook to .claude/settings.json (bash scripts/ensure-recall.sh; preserves every other key + existing hooks)`);
817
+ return;
818
+ }
819
+ fs2.mkdirSync(path2.dirname(p), { recursive: true });
820
+ fs2.writeFileSync(p, JSON.stringify(merged, null, 2) + "\n", "utf-8");
821
+ ok(`appended SessionStart hook to .claude/settings.json (bash scripts/ensure-recall.sh)`);
822
+ }
511
823
  async function main() {
512
824
  const args = parseArgs(process.argv.slice(2));
513
825
  if (args.help) {
@@ -518,6 +830,13 @@ async function main() {
518
830
  if (subcommand && subcommand !== "init" && !subcommand.startsWith("--")) {
519
831
  fail(`Unknown subcommand "${subcommand}". Only "init" is supported. Run with --help for usage.`);
520
832
  }
833
+ DRY_RUN = args.dryRun;
834
+ if (DRY_RUN) {
835
+ info("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
836
+ info("DRY RUN \u2014 no files will be written, no commands will run.");
837
+ info("Lines tagged (dry-run) show what would happen.");
838
+ info("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
839
+ }
521
840
  if (!args.token) {
522
841
  const t = await prompt("LaunchSecure PAT (ls_pat_\u2026): ");
523
842
  args.token = t || null;
@@ -542,10 +861,10 @@ async function main() {
542
861
  }
543
862
  const repoUrl = resolved.repositoryUrl;
544
863
  const cwd = process.cwd();
545
- const targetDir = path.resolve(args.targetDir ?? path.join(cwd, resolved.projectSlug));
864
+ const targetDir = path2.resolve(args.targetDir ?? path2.join(cwd, resolved.projectSlug));
546
865
  const normalizedRemote = normalizeRepoUrl(repoUrl);
547
866
  let skipClone = false;
548
- if (fs.existsSync(targetDir)) {
867
+ if (fs2.existsSync(targetDir)) {
549
868
  if (isGitRepo(targetDir)) {
550
869
  const existingRemote = gitRemoteUrl(targetDir);
551
870
  if (existingRemote && normalizeRepoUrl(existingRemote) === normalizedRemote) {
@@ -565,7 +884,8 @@ async function main() {
565
884
  projectSlug: resolved.projectSlug,
566
885
  serverUrl: args.serverUrl
567
886
  };
568
- writeConfigFile(targetDir, cfg);
887
+ const courseName = args.course ?? inferCourseName(cfg.serverUrl);
888
+ writeConfigFile(targetDir, cfg, courseName);
569
889
  mergeMcpFile(targetDir, buildLaunchKitMcpEntries(cfg));
570
890
  ensureGitignoreLine(targetDir, CONFIG_FILENAME);
571
891
  let installSkippedReason = null;
@@ -582,8 +902,18 @@ async function main() {
582
902
  const hasOnboard = hasOnboardScript(targetDir);
583
903
  if (!args.noRecall) runRecallInit(targetDir);
584
904
  if (!args.noMigrateSafety) scaffoldMigrateSafety(targetDir);
585
- const relTarget = path.relative(cwd, targetDir) || ".";
905
+ if (!args.noLsMarketplace) scaffoldLsMarketplace(targetDir);
906
+ if (!args.noRecallHook) scaffoldRecallHook(targetDir);
907
+ const relTarget = path2.relative(cwd, targetDir) || ".";
586
908
  console.log("");
909
+ if (DRY_RUN) {
910
+ info("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
911
+ info(`DRY RUN COMPLETE \u2014 no files were modified, no commands ran.`);
912
+ info(`Target: ${targetDir}`);
913
+ info(`Re-run without --dry-run to apply the changes shown above.`);
914
+ info("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
915
+ return;
916
+ }
587
917
  ok(`done \u2014 ${resolved.projectName} is ready at ${targetDir}`);
588
918
  if (installSkippedReason) {
589
919
  const installLine = detected ? ` ${detected.pm.binary} ${detected.pm.installArgs.join(" ")}` : ` npm install # or your package manager of choice`;