@launchsecure/launch-kit 0.0.33 → 0.0.35

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.
@@ -468,8 +468,281 @@ var init_mcp = __esm({
468
468
  }
469
469
  });
470
470
 
471
+ // src/server/launch-kit-services.ts
472
+ function defaultServices() {
473
+ return [expandShorthand("radar")];
474
+ }
475
+ function expandShorthand(name) {
476
+ if (name === "preview") {
477
+ const raw = process.env.PREVIEW_PORT;
478
+ const port = raw && Number.isFinite(Number.parseInt(raw, 10)) ? Number.parseInt(raw, 10) : 3e3;
479
+ return { name: "preview", port, bin: "", args: [], skipSpawn: true };
480
+ }
481
+ const def = SHORTHANDS[name];
482
+ if (!def) {
483
+ throw new Error(
484
+ `[launch-kit-services] unknown shorthand "${name}" \u2014 known: ${[...Object.keys(SHORTHANDS), "preview"].join(", ")}. Use an object entry { name, port, bin, args } for custom services.`
485
+ );
486
+ }
487
+ return { name, port: def.port, bin: def.bin, args: [...def.args] };
488
+ }
489
+ function coerceEntry(raw, index) {
490
+ if (typeof raw === "string") {
491
+ return expandShorthand(raw);
492
+ }
493
+ if (typeof raw !== "object" || raw === null) {
494
+ throw new Error(`[launch-kit-services] entry #${index} must be a string shorthand or an object`);
495
+ }
496
+ const r = raw;
497
+ if (typeof r.name !== "string" || typeof r.port !== "number" || typeof r.bin !== "string") {
498
+ throw new Error(`[launch-kit-services] entry #${index}: { name:string, port:number, bin:string } required`);
499
+ }
500
+ if (r.args !== void 0 && (!Array.isArray(r.args) || r.args.some((a) => typeof a !== "string"))) {
501
+ throw new Error(`[launch-kit-services] entry #${index}: args must be a string[]`);
502
+ }
503
+ return {
504
+ name: r.name,
505
+ port: r.port,
506
+ bin: r.bin,
507
+ args: r.args ?? []
508
+ };
509
+ }
510
+ function validate(services) {
511
+ if (services.length === 0) {
512
+ throw new Error(`[launch-kit-services] resolved an empty service list`);
513
+ }
514
+ const seenNames = /* @__PURE__ */ new Set();
515
+ const seenPorts = /* @__PURE__ */ new Set();
516
+ for (const s of services) {
517
+ if (!DNS_NAME_RE.test(s.name)) {
518
+ throw new Error(`[launch-kit-services] service name "${s.name}" is not DNS-safe (lowercase letters/digits/hyphens, \u226463 chars, no leading/trailing hyphen)`);
519
+ }
520
+ if (seenNames.has(s.name)) {
521
+ throw new Error(`[launch-kit-services] duplicate service name "${s.name}"`);
522
+ }
523
+ seenNames.add(s.name);
524
+ if (!Number.isInteger(s.port) || s.port < 1 || s.port > 65535) {
525
+ throw new Error(`[launch-kit-services] service "${s.name}" has invalid port ${s.port}`);
526
+ }
527
+ if (seenPorts.has(s.port)) {
528
+ throw new Error(`[launch-kit-services] duplicate port ${s.port} (services must each listen on a unique port)`);
529
+ }
530
+ seenPorts.add(s.port);
531
+ }
532
+ return services;
533
+ }
534
+ function resolveServices(opts = {}) {
535
+ const env = opts.env ?? process.env;
536
+ const cwd = opts.cwd ?? process.cwd();
537
+ const rawEnv = env.LAUNCHKIT_SERVICES?.trim();
538
+ if (rawEnv) {
539
+ let parsed;
540
+ try {
541
+ parsed = JSON.parse(rawEnv);
542
+ } catch (err) {
543
+ throw new Error(`[launch-kit-services] LAUNCHKIT_SERVICES is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
544
+ }
545
+ if (!Array.isArray(parsed)) {
546
+ throw new Error(`[launch-kit-services] LAUNCHKIT_SERVICES must be a JSON array`);
547
+ }
548
+ return validate(parsed.map(coerceEntry));
549
+ }
550
+ const filePath = (0, import_node_path.join)(cwd, ".launchpod", "services.json");
551
+ if ((0, import_node_fs.existsSync)(filePath)) {
552
+ let parsed;
553
+ try {
554
+ parsed = JSON.parse((0, import_node_fs.readFileSync)(filePath, "utf8"));
555
+ } catch (err) {
556
+ throw new Error(`[launch-kit-services] ${filePath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
557
+ }
558
+ if (!Array.isArray(parsed)) {
559
+ throw new Error(`[launch-kit-services] ${filePath} must be a JSON array`);
560
+ }
561
+ return validate(parsed.map(coerceEntry));
562
+ }
563
+ return validate(defaultServices());
564
+ }
565
+ var import_node_fs, import_node_path, SHORTHANDS, DNS_NAME_RE, SHORTHAND_NAMES;
566
+ var init_launch_kit_services = __esm({
567
+ "src/server/launch-kit-services.ts"() {
568
+ "use strict";
569
+ import_node_fs = require("node:fs");
570
+ import_node_path = require("node:path");
571
+ SHORTHANDS = {
572
+ radar: { port: 3517, bin: "launch-radar", args: [] },
573
+ sequencer: { port: 3517, bin: "launch-sequencer", args: [] },
574
+ chart: { port: 52819, bin: "launch-chart", args: ["serve"] },
575
+ deck: { port: 52829, bin: "launch-deck", args: ["serve"] },
576
+ council: { port: 52839, bin: "launch-council", args: ["serve"] },
577
+ // Claude web terminal — exposes a viewable/drivable `claude` session at
578
+ // `bot.<baseDomain>`. NOTE: no auth gate yet (tracked as a separate
579
+ // high-priority rover-security work item); ships behind a plain link first.
580
+ bot: { port: 52849, bin: "launch-bot", args: ["serve"] }
581
+ };
582
+ DNS_NAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
583
+ SHORTHAND_NAMES = [...Object.keys(SHORTHANDS), "preview"];
584
+ }
585
+ });
586
+
587
+ // src/server/cf-ingress.ts
588
+ async function cf(opts) {
589
+ const res = await fetch(`${CF_API_BASE}${opts.path}`, {
590
+ method: opts.method,
591
+ headers: {
592
+ Authorization: `Bearer ${opts.apiToken}`,
593
+ "Content-Type": "application/json",
594
+ Accept: "application/json",
595
+ "User-Agent": "launch-kit/cf-ingress"
596
+ },
597
+ body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0,
598
+ signal: AbortSignal.timeout(15e3)
599
+ });
600
+ const text = await res.text();
601
+ let parsed;
602
+ try {
603
+ parsed = text ? JSON.parse(text) : { success: false };
604
+ } catch {
605
+ throw new Error(`[cf] ${opts.method} ${opts.path} \u2192 ${res.status}, non-JSON body: ${text.slice(0, 200)}`);
606
+ }
607
+ return parsed;
608
+ }
609
+ function isNotFound(env) {
610
+ return !env.success && (env.errors ?? []).some((e) => e.code === 7003 || e.code === 1001 || e.code === 81044);
611
+ }
612
+ function loadState(path6) {
613
+ if (!(0, import_node_fs2.existsSync)(path6)) return null;
614
+ try {
615
+ const parsed = JSON.parse((0, import_node_fs2.readFileSync)(path6, "utf8"));
616
+ if (typeof parsed?.tunnelId === "string" && typeof parsed?.accountId === "string") {
617
+ return parsed;
618
+ }
619
+ return null;
620
+ } catch {
621
+ return null;
622
+ }
623
+ }
624
+ function saveState(path6, state) {
625
+ const dir = (0, import_node_path2.dirname)(path6);
626
+ if (!(0, import_node_fs2.existsSync)(dir)) (0, import_node_fs2.mkdirSync)(dir, { recursive: true });
627
+ (0, import_node_fs2.writeFileSync)(path6, JSON.stringify(state, null, 2));
628
+ }
629
+ async function ensureTunnel(input, knownTunnelId) {
630
+ if (knownTunnelId) {
631
+ const got = await cf({
632
+ apiToken: input.apiToken,
633
+ method: "GET",
634
+ path: `/accounts/${input.accountId}/cfd_tunnel/${knownTunnelId}`
635
+ });
636
+ if (got.success && got.result && !got.result.deleted_at) {
637
+ return knownTunnelId;
638
+ }
639
+ if (!isNotFound(got) && !got.success) {
640
+ throw new Error(`[cf] tunnel GET failed: ${JSON.stringify(got.errors)}`);
641
+ }
642
+ }
643
+ const created = await cf({
644
+ apiToken: input.apiToken,
645
+ method: "POST",
646
+ path: `/accounts/${input.accountId}/cfd_tunnel`,
647
+ body: { name: input.tunnelName, config_src: "cloudflare" }
648
+ });
649
+ if (!created.success || !created.result) {
650
+ throw new Error(`[cf] tunnel create failed: ${JSON.stringify(created.errors)}`);
651
+ }
652
+ return created.result.id;
653
+ }
654
+ async function fetchConnectorToken(input, tunnelId) {
655
+ const res = await cf({
656
+ apiToken: input.apiToken,
657
+ method: "GET",
658
+ path: `/accounts/${input.accountId}/cfd_tunnel/${tunnelId}/token`
659
+ });
660
+ if (!res.success || typeof res.result !== "string") {
661
+ throw new Error(`[cf] connector-token fetch failed: ${JSON.stringify(res.errors)}`);
662
+ }
663
+ return res.result;
664
+ }
665
+ async function setIngressConfig(input, tunnelId) {
666
+ const ingress = input.services.map((s) => ({
667
+ hostname: `${s.name}.${input.zone.name}`,
668
+ service: `http://localhost:${s.port}`
669
+ }));
670
+ ingress.push({ service: "http_status:404" });
671
+ const res = await cf({
672
+ apiToken: input.apiToken,
673
+ method: "PUT",
674
+ path: `/accounts/${input.accountId}/cfd_tunnel/${tunnelId}/configurations`,
675
+ body: { config: { ingress } }
676
+ });
677
+ if (!res.success) {
678
+ throw new Error(`[cf] ingress config PUT failed: ${JSON.stringify(res.errors)}`);
679
+ }
680
+ }
681
+ async function ensureDnsRecord(input, tunnelId, service) {
682
+ const fqdn = `${service.name}.${input.zone.name}`;
683
+ const target = `${tunnelId}.cfargotunnel.com`;
684
+ const existing = await cf({
685
+ apiToken: input.apiToken,
686
+ method: "GET",
687
+ path: `/zones/${input.zone.id}/dns_records?name=${encodeURIComponent(fqdn)}&type=CNAME`
688
+ });
689
+ if (existing.success && Array.isArray(existing.result) && existing.result.length > 0) {
690
+ const rec = existing.result[0];
691
+ if (rec.content === target) return;
692
+ const upd = await cf({
693
+ apiToken: input.apiToken,
694
+ method: "PUT",
695
+ path: `/zones/${input.zone.id}/dns_records/${rec.id}`,
696
+ body: { type: "CNAME", name: fqdn, content: target, proxied: true, ttl: 1 }
697
+ });
698
+ if (!upd.success) {
699
+ throw new Error(`[cf] DNS record update for ${fqdn} failed: ${JSON.stringify(upd.errors)}`);
700
+ }
701
+ return;
702
+ }
703
+ const created = await cf({
704
+ apiToken: input.apiToken,
705
+ method: "POST",
706
+ path: `/zones/${input.zone.id}/dns_records`,
707
+ body: { type: "CNAME", name: fqdn, content: target, proxied: true, ttl: 1 }
708
+ });
709
+ if (created.success) return;
710
+ if ((created.errors ?? []).some((e) => e.code === CF_ERR_DNS_RECORD_EXISTS)) return;
711
+ throw new Error(`[cf] DNS record create for ${fqdn} failed: ${JSON.stringify(created.errors)}`);
712
+ }
713
+ async function provisionIngress(input) {
714
+ const prior = loadState(input.stateFile);
715
+ const tunnelId = await ensureTunnel(input, prior?.tunnelId ?? null);
716
+ saveState(input.stateFile, {
717
+ tunnelId,
718
+ accountId: input.accountId,
719
+ tunnelName: input.tunnelName,
720
+ zoneId: input.zone.id
721
+ });
722
+ const connectorToken = await fetchConnectorToken(input, tunnelId);
723
+ await setIngressConfig(input, tunnelId);
724
+ await Promise.all(input.services.map((s) => ensureDnsRecord(input, tunnelId, s)));
725
+ const hostnames = {};
726
+ for (const s of input.services) hostnames[s.name] = `${s.name}.${input.zone.name}`;
727
+ return { tunnelId, connectorToken, hostnames };
728
+ }
729
+ var import_node_fs2, import_node_path2, CF_API_BASE, CF_ERR_DNS_RECORD_EXISTS;
730
+ var init_cf_ingress = __esm({
731
+ "src/server/cf-ingress.ts"() {
732
+ "use strict";
733
+ import_node_fs2 = require("node:fs");
734
+ import_node_path2 = require("node:path");
735
+ CF_API_BASE = "https://api.cloudflare.com/client/v4";
736
+ CF_ERR_DNS_RECORD_EXISTS = 81053;
737
+ }
738
+ });
739
+
471
740
  // src/server/radar-docker-init-entry.ts
472
741
  var radar_docker_init_entry_exports = {};
742
+ __export(radar_docker_init_entry_exports, {
743
+ maybeProvisionIngress: () => maybeProvisionIngress,
744
+ spawnServiceGroup: () => spawnServiceGroup
745
+ });
473
746
  function fail(message) {
474
747
  console.error(message);
475
748
  process.exit(1);
@@ -485,39 +758,39 @@ function run2(cmd, args, stdio = "inherit") {
485
758
  }
486
759
  async function setupFromCloud() {
487
760
  const pat = requireEnv("LS_PAT");
761
+ const orgSlug = requireEnv("LS_ORG_SLUG");
762
+ const projectSlug = requireEnv("LS_PROJECT_SLUG");
488
763
  const serverUrl = process.env.LS_SERVER_URL ?? "https://launchsecure-v2.vercel.app";
489
- const orgSlug = process.env.LS_ORG_SLUG;
490
- const projectSlug = process.env.LS_PROJECT_SLUG;
491
764
  const mcp = new ProjectMcpClient({ serverUrl, pat, orgSlug, projectSlug });
492
765
  let bundle;
493
766
  try {
494
767
  bundle = await mcp.call("radar_bootstrap_get", {});
495
768
  } catch (err) {
496
- fail(`[entrypoint] radar_bootstrap_get failed (${err instanceof Error ? err.message : String(err)}) \u2014 check LS_PAT has mcp:radar:bootstrap scope and is scoped to the right org/project.`);
769
+ fail(`[entrypoint] radar_bootstrap_get failed (${err instanceof Error ? err.message : String(err)}) \u2014 check LS_PAT has mcp:radar:bootstrap scope and LS_ORG_SLUG/LS_PROJECT_SLUG point at a project the user has access to.`);
497
770
  }
498
- if (!process.env.LS_ORG_SLUG) process.env.LS_ORG_SLUG = bundle.orgSlug;
499
- if (!process.env.LS_PROJECT_SLUG) process.env.LS_PROJECT_SLUG = bundle.projectSlug;
500
771
  if (!process.env.GIT_USER_NAME) process.env.GIT_USER_NAME = bundle.gitName;
501
772
  if (!process.env.GIT_USER_EMAIL) process.env.GIT_USER_EMAIL = bundle.gitEmail;
502
773
  if (!process.env.GH_TOKEN && bundle.githubToken) process.env.GH_TOKEN = bundle.githubToken;
503
774
  if (!process.env.GH_TOKEN) {
504
775
  fail(`[entrypoint] no GH_TOKEN available \u2014 user has not connected GitHub (githubTokenStatus=${bundle.githubTokenStatus}). Connect GitHub in LS or pre-set GH_TOKEN in the container env.`);
505
776
  }
506
- console.log(`[entrypoint] bundle from cloud: org=${process.env.LS_ORG_SLUG} project=${process.env.LS_PROJECT_SLUG} git=${process.env.GIT_USER_NAME} <${process.env.GIT_USER_EMAIL}> github=${bundle.githubTokenStatus.toLowerCase()}`);
777
+ const cfNote = bundle.cloudflareToken ? "cloudflare=connected" : "cloudflare=none";
778
+ console.log(`[entrypoint] bundle from cloud: org=${orgSlug} project=${projectSlug} git=${process.env.GIT_USER_NAME} <${process.env.GIT_USER_EMAIL}> github=${bundle.githubTokenStatus.toLowerCase()} ${cfNote}`);
779
+ return bundle;
507
780
  }
508
781
  function setupClaudeCredentials() {
509
782
  const home = process.env.HOME ?? "/home/launchpod";
510
- const claudeDir = (0, import_node_path.join)(home, ".claude");
511
- (0, import_node_fs.mkdirSync)(claudeDir, { recursive: true });
783
+ const claudeDir = (0, import_node_path3.join)(home, ".claude");
784
+ (0, import_node_fs3.mkdirSync)(claudeDir, { recursive: true });
512
785
  const decoded = Buffer.from(requireEnv("CLAUDE_CREDENTIALS_B64"), "base64").toString("utf8");
513
- const credsPath = (0, import_node_path.join)(claudeDir, ".credentials.json");
514
- (0, import_node_fs.writeFileSync)(credsPath, decoded);
515
- (0, import_node_fs.chmodSync)(credsPath, 384);
516
- const configPath = (0, import_node_path.join)(home, ".claude.json");
786
+ const credsPath = (0, import_node_path3.join)(claudeDir, ".credentials.json");
787
+ (0, import_node_fs3.writeFileSync)(credsPath, decoded);
788
+ (0, import_node_fs3.chmodSync)(credsPath, 384);
789
+ const configPath = (0, import_node_path3.join)(home, ".claude.json");
517
790
  let cfg = {};
518
- if ((0, import_node_fs.existsSync)(configPath)) {
791
+ if ((0, import_node_fs3.existsSync)(configPath)) {
519
792
  try {
520
- cfg = JSON.parse((0, import_node_fs.readFileSync)(configPath, "utf8"));
793
+ cfg = JSON.parse((0, import_node_fs3.readFileSync)(configPath, "utf8"));
521
794
  } catch {
522
795
  cfg = {};
523
796
  }
@@ -526,8 +799,25 @@ function setupClaudeCredentials() {
526
799
  cfg.lastOnboardingVersion = cfg.lastOnboardingVersion ?? "2.1.159";
527
800
  cfg.numStartups = (cfg.numStartups ?? 0) + 1;
528
801
  cfg.installMethod = cfg.installMethod ?? "global";
529
- (0, import_node_fs.writeFileSync)(configPath, JSON.stringify(cfg, null, 2));
530
- (0, import_node_fs.chmodSync)(configPath, 384);
802
+ const PREAPPROVED_MCPS = [
803
+ "launch-secure",
804
+ "launch-chart",
805
+ "launch-deck",
806
+ "launch-orbit",
807
+ "launch-recall",
808
+ "launch-beacon",
809
+ "launch-sequencer"
810
+ ];
811
+ const projects = cfg.projects ?? {};
812
+ const wsKey = "/workspace";
813
+ const wsProject = projects[wsKey] ?? {};
814
+ const existingEnabled = Array.isArray(wsProject.enabledMcpjsonServers) ? wsProject.enabledMcpjsonServers : [];
815
+ const mergedEnabled = Array.from(/* @__PURE__ */ new Set([...existingEnabled, ...PREAPPROVED_MCPS]));
816
+ wsProject.enabledMcpjsonServers = mergedEnabled;
817
+ projects[wsKey] = wsProject;
818
+ cfg.projects = projects;
819
+ (0, import_node_fs3.writeFileSync)(configPath, JSON.stringify(cfg, null, 2));
820
+ (0, import_node_fs3.chmodSync)(configPath, 384);
531
821
  }
532
822
  function setupGitAndGh() {
533
823
  const name = process.env.GIT_USER_NAME ?? "Radar Bot";
@@ -535,9 +825,30 @@ function setupGitAndGh() {
535
825
  const status = run2("launch-kit", ["setup-git", `--identity=${name} <${email}>`]);
536
826
  if (status !== 0) fail(`[entrypoint] launch-kit setup-git failed (status ${status})`);
537
827
  }
828
+ function detectAndSetPreviewPort() {
829
+ if (process.env.PREVIEW_PORT) return;
830
+ try {
831
+ const pkgPath = "/workspace/package.json";
832
+ if (!(0, import_node_fs3.existsSync)(pkgPath)) return;
833
+ const pkg = JSON.parse((0, import_node_fs3.readFileSync)(pkgPath, "utf-8"));
834
+ const scripts = pkg.scripts ?? {};
835
+ const portRe = /(?:--port[= ]|-p\s+|\bPORT=)(\d{2,5})\b/;
836
+ for (const name of ["dev", "start", "serve"]) {
837
+ const script = scripts[name];
838
+ if (typeof script !== "string") continue;
839
+ const m = script.match(portRe);
840
+ if (m) {
841
+ process.env.PREVIEW_PORT = m[1];
842
+ console.log(`[entrypoint] preview port detected from package.json scripts.${name}: ${m[1]}`);
843
+ return;
844
+ }
845
+ }
846
+ } catch {
847
+ }
848
+ }
538
849
  function initWorkspaceIfEmpty() {
539
850
  process.chdir("/workspace");
540
- if ((0, import_node_fs.existsSync)(".git")) {
851
+ if ((0, import_node_fs3.existsSync)(".git")) {
541
852
  console.log("[entrypoint] /workspace already initialized \u2014 skipping init");
542
853
  return;
543
854
  }
@@ -552,47 +863,183 @@ function initWorkspaceIfEmpty() {
552
863
  ]);
553
864
  if (status !== 0) fail(`[entrypoint] launch-kit init failed (status ${status})`);
554
865
  }
555
- function execLaunchPodRadar() {
556
- console.log("[entrypoint] starting launch-pod radar");
557
- const child = (0, import_node_child_process2.spawn)("launch-pod", ["radar"], { stdio: "inherit" });
558
- const forward = (sig) => () => {
559
- try {
560
- child.kill(sig);
561
- } catch {
866
+ async function maybeProvisionIngress(bundle, services, projectSlug) {
867
+ const token = bundle.cloudflareToken ?? null;
868
+ const accountId = bundle.cloudflareAccountId ?? null;
869
+ const zones = bundle.cloudflareZones ?? [];
870
+ if (!token && !accountId && zones.length === 0) return null;
871
+ if (!token || !accountId) {
872
+ fail(`[entrypoint] cloudflare integration is partial \u2014 token=${token ? "set" : "missing"} accountId=${accountId ? "set" : "missing"}. Re-connect the Cloudflare provider in LS.`);
873
+ }
874
+ const baseDomain = process.env.LAUNCHKIT_CF_BASE_DOMAIN?.trim();
875
+ let chosen = null;
876
+ if (baseDomain) {
877
+ chosen = zones.find((z) => z.name === baseDomain) ?? null;
878
+ if (!chosen) {
879
+ fail(`[entrypoint] LAUNCHKIT_CF_BASE_DOMAIN="${baseDomain}" is not among the connected CF token's zones (${zones.map((z) => z.name).join(", ") || "none"}). Either change the env or grant Zone:Read on that zone in the CF token.`);
562
880
  }
563
- };
564
- process.on("SIGTERM", forward("SIGTERM"));
565
- process.on("SIGINT", forward("SIGINT"));
566
- process.on("SIGHUP", forward("SIGHUP"));
567
- child.on("exit", (code, signal) => {
568
- if (signal) process.kill(process.pid, signal);
569
- else process.exit(code ?? 0);
881
+ } else if (zones.length === 1) {
882
+ chosen = { id: zones[0].id, name: zones[0].name };
883
+ } else {
884
+ fail(`[entrypoint] cloudflare token covers ${zones.length} zones (${zones.map((z) => z.name).join(", ")}) \u2014 set LAUNCHKIT_CF_BASE_DOMAIN to pick one.`);
885
+ }
886
+ const stateFile = "/workspace/.launchpod/launch-kit-tunnel.json";
887
+ console.log(`[entrypoint] provisioning CF named tunnel \u2014 name=launch-kit-${projectSlug} zone=${chosen.name} services=${services.map((s) => s.name).join(",")}`);
888
+ const result = await provisionIngress({
889
+ apiToken: token,
890
+ accountId,
891
+ zone: chosen,
892
+ tunnelName: `launch-kit-${projectSlug}`,
893
+ services: services.map((s) => ({ name: s.name, port: s.port })),
894
+ stateFile
570
895
  });
896
+ for (const [name, fqdn] of Object.entries(result.hostnames)) {
897
+ console.log(`[entrypoint] ${name} \u2192 https://${fqdn}`);
898
+ }
899
+ return result;
900
+ }
901
+ function spawnServiceGroup(services) {
902
+ const children = [];
903
+ let shuttingDown = false;
904
+ const killAll = (signal = "SIGTERM") => {
905
+ if (shuttingDown) return;
906
+ shuttingDown = true;
907
+ for (const c2 of children) {
908
+ try {
909
+ c2.proc.kill(signal);
910
+ } catch {
911
+ }
912
+ }
913
+ };
914
+ const prefixStream = (name, stream, sink) => {
915
+ let buf = "";
916
+ stream.setEncoding("utf8");
917
+ stream.on("data", (chunk) => {
918
+ buf += chunk;
919
+ const lines = buf.split("\n");
920
+ buf = lines.pop() ?? "";
921
+ for (const line of lines) sink.write(`[${name}] ${line}
922
+ `);
923
+ });
924
+ stream.on("end", () => {
925
+ if (buf) sink.write(`[${name}] ${buf}
926
+ `);
927
+ });
928
+ };
929
+ const signalHandlers = [];
930
+ const installSignals = () => {
931
+ for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) {
932
+ const fn = () => {
933
+ console.log(`[entrypoint] received ${sig} \u2014 forwarding to ${children.length} child process(es)`);
934
+ killAll(sig);
935
+ };
936
+ process.on(sig, fn);
937
+ signalHandlers.push({ sig, fn });
938
+ }
939
+ };
940
+ const removeSignals = () => {
941
+ for (const h of signalHandlers) process.off(h.sig, h.fn);
942
+ signalHandlers.length = 0;
943
+ };
944
+ return new Promise((resolve3, reject) => {
945
+ let exitedCount = 0;
946
+ let firstFailure = null;
947
+ for (const spec of services) {
948
+ if (spec.skipSpawn) {
949
+ console.log(`[entrypoint] ${spec.name} \u2192 ingress-only on port ${spec.port} (no spawn; user starts dev server here)`);
950
+ continue;
951
+ }
952
+ const args = [...spec.args, "--port", String(spec.port)];
953
+ console.log(`[entrypoint] starting ${spec.name}: ${spec.bin} ${args.join(" ")}`);
954
+ const proc = (0, import_node_child_process2.spawn)(spec.bin, args, { stdio: ["ignore", "pipe", "pipe"] });
955
+ children.push({ spec, proc });
956
+ if (proc.stdout) prefixStream(spec.name, proc.stdout, process.stdout);
957
+ if (proc.stderr) prefixStream(spec.name, proc.stderr, process.stderr);
958
+ proc.on("exit", (code, signal) => {
959
+ exitedCount += 1;
960
+ const label = `[${spec.name}] exited code=${code ?? "?"} signal=${signal ?? "-"}`;
961
+ if (!shuttingDown && code !== 0) {
962
+ console.error(`[entrypoint] ${label} \u2014 bringing the group down`);
963
+ if (!firstFailure) firstFailure = { name: spec.name, code, signal };
964
+ killAll();
965
+ } else {
966
+ console.log(`[entrypoint] ${label}`);
967
+ }
968
+ if (exitedCount === children.length) {
969
+ if (firstFailure) reject(new Error(`service "${firstFailure.name}" exited code=${firstFailure.code ?? "?"}`));
970
+ else resolve3();
971
+ }
972
+ });
973
+ proc.on("error", (err) => {
974
+ console.error(`[entrypoint] [${spec.name}] spawn error: ${err.message}`);
975
+ if (!firstFailure) firstFailure = { name: spec.name, code: null, signal: null };
976
+ killAll();
977
+ });
978
+ }
979
+ installSignals();
980
+ }).finally(removeSignals);
571
981
  }
572
982
  async function main() {
573
983
  for (const k of REQUIRED_ENV) requireEnv(k);
574
- await setupFromCloud();
984
+ const bundle = await setupFromCloud();
575
985
  setupClaudeCredentials();
576
986
  setupGitAndGh();
577
987
  initWorkspaceIfEmpty();
578
- execLaunchPodRadar();
988
+ detectAndSetPreviewPort();
989
+ let services;
990
+ try {
991
+ services = resolveServices();
992
+ } catch (err) {
993
+ fail(`[entrypoint] ${err instanceof Error ? err.message : String(err)}`);
994
+ }
995
+ console.log(`[entrypoint] services: ${services.map((s) => `${s.name}@${s.port}`).join(", ")}`);
996
+ const ingress = await maybeProvisionIngress(bundle, services, requireEnv("LS_PROJECT_SLUG"));
997
+ if (ingress) {
998
+ process.env.RADAR_CF_TUNNEL_TOKEN = ingress.connectorToken;
999
+ const radarFqdn = ingress.hostnames.radar;
1000
+ if (radarFqdn) process.env.RADAR_CF_TUNNEL_HOSTNAME = radarFqdn;
1001
+ else if (services.some((s) => s.name === "radar")) {
1002
+ fail(`[entrypoint] internal: ingress provisioned but no hostname for radar`);
1003
+ }
1004
+ } else if (services.length > 1) {
1005
+ const first = services[0];
1006
+ console.warn(
1007
+ `[entrypoint] \u26A0 quick mode \u2014 only the first service "${first.name}" (port ${first.port}) will be exposed via the ephemeral *.trycloudflare.com URL. Other service(s) [${services.slice(1).map((s) => s.name).join(", ")}] will run on localhost inside the container only. Connect a Cloudflare provider in LS and set LAUNCHKIT_CF_BASE_DOMAIN to expose all services with stable subdomains.`
1008
+ );
1009
+ if (first.name !== "radar") {
1010
+ console.warn(`[entrypoint] \u26A0 first service is "${first.name}", not "radar" \u2014 quick tunneling is owned by the radar agent today, so NO external URL will be available.`);
1011
+ }
1012
+ }
1013
+ try {
1014
+ await spawnServiceGroup(services);
1015
+ process.exit(0);
1016
+ } catch (err) {
1017
+ console.error(`[entrypoint] ${err instanceof Error ? err.message : String(err)}`);
1018
+ process.exit(1);
1019
+ }
579
1020
  }
580
- var import_node_child_process2, import_node_fs, import_node_path, REQUIRED_ENV;
1021
+ var import_node_child_process2, import_node_fs3, import_node_path3, REQUIRED_ENV;
581
1022
  var init_radar_docker_init_entry = __esm({
582
1023
  "src/server/radar-docker-init-entry.ts"() {
583
1024
  "use strict";
584
1025
  import_node_child_process2 = require("node:child_process");
585
- import_node_fs = require("node:fs");
586
- import_node_path = require("node:path");
1026
+ import_node_fs3 = require("node:fs");
1027
+ import_node_path3 = require("node:path");
587
1028
  init_mcp();
1029
+ init_launch_kit_services();
1030
+ init_cf_ingress();
588
1031
  REQUIRED_ENV = [
589
1032
  "CLAUDE_CREDENTIALS_B64",
590
- "LS_PAT"
1033
+ "LS_PAT",
1034
+ "LS_ORG_SLUG",
1035
+ "LS_PROJECT_SLUG"
591
1036
  ];
592
- main().catch((err) => {
593
- console.error(`[entrypoint] fatal: ${err instanceof Error ? err.message : String(err)}`);
594
- process.exit(1);
595
- });
1037
+ if (!process.env.VITEST) {
1038
+ main().catch((err) => {
1039
+ console.error(`[entrypoint] fatal: ${err instanceof Error ? err.message : String(err)}`);
1040
+ process.exit(1);
1041
+ });
1042
+ }
596
1043
  }
597
1044
  });
598
1045
 
@@ -612,10 +1059,28 @@ var import_node_child_process = require("node:child_process");
612
1059
  function run(cmd, args, stdio = "inherit") {
613
1060
  return (0, import_node_child_process.spawnSync)(cmd, args, { stdio }).status ?? 1;
614
1061
  }
615
- function configureGitForBot(identity) {
616
- if (process.env.GH_TOKEN) {
1062
+ function hasGh() {
1063
+ return (0, import_node_child_process.spawnSync)("gh", ["--version"], { stdio: "ignore" }).status === 0;
1064
+ }
1065
+ function wireGitHubAuth() {
1066
+ if (!process.env.GH_TOKEN) return false;
1067
+ if (hasGh()) {
617
1068
  run("gh", ["auth", "setup-git"]);
1069
+ return true;
618
1070
  }
1071
+ const key = "credential.https://github.com.helper";
1072
+ run("git", ["config", "--global", "--replace-all", key, ""]);
1073
+ run("git", [
1074
+ "config",
1075
+ "--global",
1076
+ "--add",
1077
+ key,
1078
+ `!f() { echo username=x-access-token; echo "password=$GH_TOKEN"; }; f`
1079
+ ]);
1080
+ return true;
1081
+ }
1082
+ function configureGitForBot(identity) {
1083
+ wireGitHubAuth();
619
1084
  run("git", ["config", "--global", "user.name", identity.name]);
620
1085
  run("git", ["config", "--global", "user.email", identity.email]);
621
1086
  run("git", ["config", "--global", "init.defaultBranch", "main"]);
@@ -771,8 +1236,8 @@ Wired in Claude Code (.mcp.json):
771
1236
  launch-recall \u2014 restore deleted/modified files from shadow git
772
1237
 
773
1238
  Other tools (run on demand via npx):
774
- npx launch-pod radar \u2014 webhook listener (LS pings \u2192 terminal/UI)
775
- npx launch-pod \u2014 full pipeline UI (separate launch-pod login)
1239
+ npx launch-radar \u2014 webhook listener (LS pings \u2192 terminal/UI)
1240
+ npx launch-sequencer \u2014 full pipeline UI (separate sequencer login)
776
1241
  npx launch-beacon monitor \u2014 local HTTP receiver for the launch-kit-beacon
777
1242
  in-browser monitor. Paste the printed URL into
778
1243
  the beacon debug panel; events stream to
@@ -994,9 +1459,23 @@ What it does:
994
1459
  Does NOT clone, re-install deps, re-prompt for PAT, or re-run the onboard
995
1460
  script. Use \`init\` for those.
996
1461
 
1462
+ No cred yet? If the repo already exists here but was never init'd (e.g. the
1463
+ repo was created by hand and connected in LS afterward), pass
1464
+ --token/--org/--project and refresh will SEED the cred file, then proceed \u2014
1465
+ no clone, no init required.
1466
+
997
1467
  Options:
998
1468
  --dir=<path> Target directory (default: cwd). Must contain a
999
- valid .launch-secure.cred.config.
1469
+ valid .launch-secure.cred.config, OR pass the
1470
+ --token/--org/--project flags below to seed one.
1471
+ --token=<ls_pat_\u2026> LaunchSecure PAT. Only needed to seed a cred when
1472
+ none exists yet (otherwise read from the cred file).
1473
+ --org=<orgSlug> Org slug for the seeded cred (with --token).
1474
+ --project=<projectSlug> Project slug for the seeded cred (with --token).
1475
+ --url=<serverUrl> LaunchSecure base URL for the seeded cred
1476
+ (default: ${DEFAULT_SERVER_URL}).
1477
+ --course=<name> Course/profile name for the seeded cred
1478
+ (default: inferred from --url).
1000
1479
  --no-migrate-safety Skip refreshing the migrate-safety scaffold.
1001
1480
  --no-ls-marketplace Skip refreshing the launch-secure marketplace.
1002
1481
  --no-recall-hook Skip refreshing the recall-hook scaffold.
@@ -1058,9 +1537,13 @@ Options:
1058
1537
  --git-identity="N <e>" Non-interactive git identity for service-account /
1059
1538
  CI / Docker runs. Configures git user.name, user.email,
1060
1539
  init.defaultBranch=main, pull.rebase=false; also
1061
- wires GH_TOKEN into git's credential helper via
1062
- \`gh auth setup-git\` when GH_TOKEN is set. Example:
1540
+ wires GH_TOKEN into git's credential helper (via
1541
+ \`gh auth setup-git\`, or a gh-less helper when the
1542
+ GitHub CLI is absent) when GH_TOKEN is set. Example:
1063
1543
  --git-identity="Radar Bot <radar@launchpod.local>".
1544
+ Note: GH_TOKEN is wired for the clone even without
1545
+ --git-identity, so private-repo clones work on hosts
1546
+ with no \`gh\` as long as GH_TOKEN is set.
1064
1547
  --no-install Skip dependency install step (also skips the onboard
1065
1548
  script \u2014 install is its prerequisite).
1066
1549
  --no-onboard Skip the onboard script even when install runs.
@@ -1162,9 +1645,9 @@ function preflight() {
1162
1645
  const nodeMajor = parseInt(process.versions.node.split(".")[0], 10);
1163
1646
  if (nodeMajor < 18) fail2(`Node.js >= 18 required (current: ${process.versions.node}).`);
1164
1647
  if (!which("git")) fail2("git not found in PATH. Install git: https://git-scm.com/downloads");
1165
- const hasGh = which("gh") !== null;
1166
- ok(`preflight ok \u2014 node ${process.versions.node}, git present${hasGh ? ", gh present" : ", gh not found (will use git for clone)"}`);
1167
- return { hasGh };
1648
+ const hasGh2 = which("gh") !== null;
1649
+ ok(`preflight ok \u2014 node ${process.versions.node}, git present${hasGh2 ? ", gh present" : ", gh not found (will use git for clone)"}`);
1650
+ return { hasGh: hasGh2 };
1168
1651
  }
1169
1652
  var PROJECT_INFO_TIMEOUT_MS = 3e4;
1170
1653
  var PROJECT_INFO_MAX_ATTEMPTS = 3;
@@ -1317,11 +1800,11 @@ function dirIsEmpty(dir) {
1317
1800
  if (!fs5.existsSync(dir)) return true;
1318
1801
  return fs5.readdirSync(dir).length === 0;
1319
1802
  }
1320
- function cloneRepo(repoUrl, targetDir, hasGh) {
1803
+ function cloneRepo(repoUrl, targetDir, hasGh2) {
1321
1804
  const isGithub = /github\.com/i.test(repoUrl);
1322
1805
  let cmd;
1323
1806
  let args;
1324
- if (hasGh && isGithub) {
1807
+ if (hasGh2 && isGithub) {
1325
1808
  cmd = "gh";
1326
1809
  args = ["repo", "clone", repoUrl, targetDir];
1327
1810
  info(`cloning via gh: ${repoUrl} \u2192 ${targetDir}`);
@@ -1337,7 +1820,7 @@ function cloneRepo(repoUrl, targetDir, hasGh) {
1337
1820
  const res = (0, import_node_child_process3.spawnSync)(cmd, args, { stdio: "inherit" });
1338
1821
  if (res.status !== 0) {
1339
1822
  fail2(
1340
- `Clone failed (${cmd} exited ${res.status}). For private repos make sure your GitHub auth is set up: \`gh auth login\` or an SSH key on your GitHub account.`
1823
+ `Clone failed (${cmd} exited ${res.status}). For private repos make sure your GitHub auth is set up: \`gh auth login\`, set GH_TOKEN=<a PAT with repo scope> before re-running (works without the GitHub CLI), or add an SSH key to your GitHub account.`
1341
1824
  );
1342
1825
  }
1343
1826
  if (!fs5.existsSync(path5.join(targetDir, ".git"))) {
@@ -1988,9 +2471,26 @@ async function mainRefresh(args) {
1988
2471
  ok(`wrote ${CONFIG_FILENAME} (course: ${courseName})`);
1989
2472
  }
1990
2473
  }
2474
+ if (!cred && args.token && args.orgSlug && args.projectSlug) {
2475
+ const courseName = args.course ?? inferCourseName(args.serverUrl);
2476
+ const seedCfg = {
2477
+ pat: args.token,
2478
+ orgSlug: args.orgSlug,
2479
+ projectSlug: args.projectSlug,
2480
+ serverUrl: args.serverUrl
2481
+ };
2482
+ info(`no ${CONFIG_FILENAME} found \u2014 seeding it from --token/--org/--project (course: ${courseName})`);
2483
+ writeConfigFile(targetDir, seedCfg, courseName);
2484
+ cred = { active: courseName, profiles: { [courseName]: seedCfg } };
2485
+ }
1991
2486
  if (!cred) {
1992
2487
  fail2(
1993
- `no ${CONFIG_FILENAME} found at ${targetDir}, and could not recover from .mcp.json. Refresh requires an existing cred or a hardcoded launch-secure MCP entry \u2014 run \`npx @launchsecure/launch-kit init --token=<pat> --org=<org> --project=<project> --dir=${path5.relative(cwd, targetDir) || "."}\` first.`
2488
+ `no ${CONFIG_FILENAME} found at ${targetDir}, and could not recover from .mcp.json.
2489
+ Refresh re-applies configs to an already-wired checkout, so it needs a cred. Two ways forward:
2490
+ \u2022 Seed it inline (you already have the repo): re-run refresh with credentials \u2014
2491
+ launch-kit refresh --dir=${path5.relative(cwd, targetDir) || "."} --token=<pat> --org=<org> --project=<project>
2492
+ \u2022 Or run a full init in place \u2014 it detects this existing repo and skips the clone:
2493
+ launch-kit init --token=<pat> --org=<org> --project=<project> --dir=${path5.relative(cwd, targetDir) || "."}`
1994
2494
  );
1995
2495
  }
1996
2496
  const nested = toNested(cred);
@@ -2076,11 +2576,13 @@ async function mainInit(args) {
2076
2576
  ["project", args.projectSlug],
2077
2577
  ["server", args.serverUrl]
2078
2578
  ]);
2079
- const { hasGh } = preflight();
2080
- phase("preflight", { status: "ok", summary: `node ${process.versions.node}${hasGh ? " \xB7 git \xB7 gh" : " \xB7 git (gh not found \u2014 will use git for clone)"}` });
2579
+ const { hasGh: hasGh2 } = preflight();
2580
+ phase("preflight", { status: "ok", summary: `node ${process.versions.node}${hasGh2 ? " \xB7 git \xB7 gh" : " \xB7 git (gh not found \u2014 will use git for clone)"}` });
2081
2581
  if (args.gitIdentity) {
2082
2582
  configureGitForBot(args.gitIdentity);
2083
- phase("git identity", { status: "ok", summary: `${args.gitIdentity.name} <${args.gitIdentity.email}>${process.env.GH_TOKEN ? " \xB7 gh credential helper wired" : ""}` });
2583
+ phase("git identity", { status: "ok", summary: `${args.gitIdentity.name} <${args.gitIdentity.email}>${process.env.GH_TOKEN ? " \xB7 git credential helper wired" : ""}` });
2584
+ } else if (wireGitHubAuth()) {
2585
+ phase("git auth", { status: "ok", summary: hasGh2 ? "GH_TOKEN \u2192 gh credential helper" : "GH_TOKEN \u2192 git credential helper (gh not found)" });
2084
2586
  }
2085
2587
  info(`resolving project ${args.orgSlug}/${args.projectSlug} on ${args.serverUrl} \u2026`);
2086
2588
  let resolved;
@@ -2105,7 +2607,8 @@ async function mainInit(args) {
2105
2607
  if (isGitRepo(targetDir)) {
2106
2608
  const existingRemote = gitRemoteUrl(targetDir);
2107
2609
  if (existingRemote && normalizeRepoUrl(existingRemote) === normalizedRemote) {
2108
- ok(`${targetDir} is already a clone of ${repoUrl} \u2014 skipping clone, refreshing configs only`);
2610
+ ok(`${targetDir} is already a clone of ${repoUrl} \u2014 wiring this existing repo (skipping clone, no GitHub auth needed)`);
2611
+ info(`tip: once wired, use \`launch-kit refresh\` here to re-apply configs without re-running init`);
2109
2612
  skipClone = true;
2110
2613
  } else {
2111
2614
  fail2(`${targetDir} is a git repo but its remote (${existingRemote ?? "unknown"}) does not match ${repoUrl}. Refusing to overwrite. Pass --dir=<other-path>.`);
@@ -2117,7 +2620,7 @@ async function mainInit(args) {
2117
2620
  const relTarget = path5.relative(cwd, targetDir) || ".";
2118
2621
  if (!skipClone) {
2119
2622
  section(`Cloning ${repoUrl}`);
2120
- cloneRepo(repoUrl, targetDir, hasGh);
2623
+ cloneRepo(repoUrl, targetDir, hasGh2);
2121
2624
  phase("clone", { status: "ok", summary: `\u2192 ${relTarget}` });
2122
2625
  } else {
2123
2626
  phase("clone", { status: "in-sync", summary: `${relTarget} (already a clone of this repo)` });