@montytools/cli 0.2.0 → 0.2.2

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/bin/monty.mjs CHANGED
@@ -7,6 +7,7 @@ import { spawn, spawnSync } from "node:child_process";
7
7
  import { randomBytes } from "node:crypto";
8
8
  import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync, readdirSync, statSync, existsSync } from "node:fs";
9
9
  import { createServer } from "node:http";
10
+ import { connect as netConnect } from "node:net";
10
11
  import { homedir } from "node:os";
11
12
  import { basename, dirname, join, relative } from "node:path";
12
13
  import { fileURLToPath } from "node:url";
@@ -375,6 +376,7 @@ function installDeps() {
375
376
  const pm = spawnSync("pnpm", ["--version"], { stdio: "ignore" }).status === 0 ? "pnpm" : "npm";
376
377
  run(appDir, "install", [pm, "install"],
377
378
  "Dependency install failed. Read the package manager error above; usually network or a bad package.json edit.");
379
+ ensureSdk(appDir);
378
380
  }
379
381
 
380
382
  function buildApp() {
@@ -396,18 +398,68 @@ function typecheckApp() {
396
398
  }
397
399
 
398
400
 
401
+ // Probe by CONNECTING (not binding): SO_REUSEADDR lets a wildcard bind
402
+ // "succeed" on macOS even when a specific loopback address holds the port,
403
+ // so bind-probes lie. A successful connect = someone is listening.
404
+ function portTaken(host, port) {
405
+ return new Promise((resolve) => {
406
+ const sock = netConnect({ host, port });
407
+ const done = (taken) => {
408
+ sock.destroy();
409
+ resolve(taken);
410
+ };
411
+ sock.once("connect", () => done(true));
412
+ sock.once("error", () => done(false));
413
+ sock.setTimeout(400, () => done(false));
414
+ });
415
+ }
416
+
399
417
  async function freePort(start) {
400
418
  for (let p = start; p < start + 50; p++) {
401
- const ok = await new Promise((resolve) => {
402
- const probe = createServer();
403
- probe.once("error", () => resolve(false));
404
- probe.listen(p, "127.0.0.1", () => probe.close(() => resolve(true)));
405
- });
406
- if (ok) return p;
419
+ if (!(await portTaken("127.0.0.1", p)) && !(await portTaken("::1", p))) return p;
407
420
  }
408
421
  fail("NO_FREE_PORT", `No free port between ${start} and ${start + 49}. Pass --port <n>.`);
409
422
  }
410
423
 
424
+
425
+ // Apps pin @montytools/sdk at scaffold time and go stale — the CLI knows the
426
+ // minimum SDK its workflows need (e.g. tunnel-host allowlisting lives in the
427
+ // SDK's vite plugin) and upgrades the app automatically before dev/deploy.
428
+ const MIN_SDK = "0.1.1";
429
+
430
+ function installedSdkVersion(appDir) {
431
+ try {
432
+ return JSON.parse(
433
+ readFileSync(join(appDir, "node_modules", "@montytools", "sdk", "package.json"), "utf8"),
434
+ ).version;
435
+ } catch {
436
+ return null;
437
+ }
438
+ }
439
+
440
+ function semverLt(a, b) {
441
+ const pa = a.split(".").map(Number);
442
+ const pb = b.split(".").map(Number);
443
+ for (let i = 0; i < 3; i++) {
444
+ if ((pa[i] || 0) < (pb[i] || 0)) return true;
445
+ if ((pa[i] || 0) > (pb[i] || 0)) return false;
446
+ }
447
+ return false;
448
+ }
449
+
450
+ function ensureSdk(appDir) {
451
+ const v = installedSdkVersion(appDir);
452
+ if (v && !semverLt(v, MIN_SDK)) return;
453
+ console.log(
454
+ v
455
+ ? `sdk: installed ${v} < required ${MIN_SDK} — updating @montytools/sdk`
456
+ : "sdk: @montytools/sdk missing — installing",
457
+ );
458
+ const pm = spawnSync("pnpm", ["--version"], { stdio: "ignore" }).status === 0 ? "pnpm" : "npm";
459
+ run(appDir, "sdk-update", [pm, "install", "@montytools/sdk@latest"],
460
+ "Could not update @montytools/sdk. Run the install manually in the app folder, then retry.");
461
+ }
462
+
411
463
  // ── monty dev ──────────────────────────────────────────────────────────────
412
464
  // Development mode: vite locally + a Cloudflare quick tunnel registered as
413
465
  // the app's DEV channel, so workspace admins see the app live (HMR included)
@@ -416,6 +468,7 @@ async function freePort(start) {
416
468
  // clicks Publish in the workspace, this process builds + uploads for real.
417
469
  async function dev() {
418
470
  const appDir = requireAppDir("dev");
471
+ ensureSdk(appDir);
419
472
  const meta = await compileConfig(appDir);
420
473
  const cfg = loadConfig();
421
474
  const host = cfg?.host ?? DEFAULT_HOST;
@@ -499,8 +552,14 @@ async function dev() {
499
552
  const t = await startTunnel(port);
500
553
  tunnelChild = t.child;
501
554
  if (t.url) {
502
- originUrl = t.url;
503
555
  console.log(`tunnel: ${t.url}`);
556
+ console.log("tunnel: waiting for DNS to go live (prevents cached failures in your browser)…");
557
+ if (await waitForDns(t.url.replace("https://", ""))) {
558
+ originUrl = t.url;
559
+ console.log("tunnel: DNS live");
560
+ } else {
561
+ console.log("tunnel: DNS never propagated — dev mode registered on localhost (visible on this machine's browser only)");
562
+ }
504
563
  } else {
505
564
  console.log("tunnel: unavailable — dev mode registered on localhost (visible on this machine's browser only)");
506
565
  }
@@ -532,6 +591,26 @@ async function dev() {
532
591
  });
533
592
  }
534
593
 
594
+
595
+ // trycloudflare DNS takes up to a couple of minutes to propagate. Registering
596
+ // the origin before it resolves would make admins' browsers cache NXDOMAIN
597
+ // (macOS negative cache ≈ 30 min of a broken iframe) — so gate on DNS via
598
+ // DoH, which never touches the local resolver.
599
+ async function waitForDns(hostname) {
600
+ for (let i = 0; i < 36; i++) {
601
+ try {
602
+ const r = await fetch(
603
+ `https://cloudflare-dns.com/dns-query?name=${hostname}&type=A`,
604
+ { headers: { accept: "application/dns-json" } },
605
+ );
606
+ const d = await r.json();
607
+ if (Array.isArray(d.Answer) && d.Answer.some((a) => a.type === 1)) return true;
608
+ } catch { /* transient — retry */ }
609
+ await new Promise((res) => setTimeout(res, 5000));
610
+ }
611
+ return false;
612
+ }
613
+
535
614
  // Cloudflare quick tunnel via the cloudflared npm wrapper (downloads the
536
615
  // binary on first use). Resolves with the public URL, or null on failure —
537
616
  // dev mode then falls back to localhost-only registration.
@@ -673,6 +752,7 @@ async function docs() {
673
752
  // ── monty deploy ───────────────────────────────────────────────────────────
674
753
  async function deploy() {
675
754
  const appDir = requireAppDir("deploy");
755
+ ensureSdk(appDir);
676
756
  if (!rest.includes("--from-dev")) {
677
757
  console.log("note: direct deploy skips workspace review — the usual flow is `monty dev` + the Publish button in the workspace.");
678
758
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@montytools/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "monty": "./bin/monty.mjs"
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@fontsource-variable/roboto": "^5.2.10",
13
- "@montytools/sdk": "^0.1.0",
13
+ "@montytools/sdk": "^0.1.1",
14
14
  "@tanstack/react-router": "1.170.17",
15
15
  "class-variance-authority": "^0.7.1",
16
16
  "clsx": "^2.1.1",