@eide/foir-cli 0.27.0 → 0.29.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.
Files changed (2) hide show
  1. package/dist/cli.js +367 -12
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1632,10 +1632,11 @@ function createRecordsMethods(client) {
1632
1632
  const resp = await client.bulkUpdateRecords(
1633
1633
  create4(BulkUpdateRecordsRequestSchema, {
1634
1634
  modelKey: params.modelKey,
1635
- data: sanitizeData(params.data)
1635
+ where: params.where ? sanitizeData(params.where) : void 0,
1636
+ update: sanitizeData(params.update)
1636
1637
  })
1637
1638
  );
1638
- return { count: resp.count };
1639
+ return { count: resp.count, ids: resp.ids };
1639
1640
  },
1640
1641
  // ── Versioning ────────────────────────────────────────────
1641
1642
  async createVersion(parentId, data, changeDescription) {
@@ -9361,10 +9362,167 @@ function registerConfigsCommands(program2, globalOpts) {
9361
9362
  }
9362
9363
 
9363
9364
  // src/commands/apps.ts
9365
+ import chalk17 from "chalk";
9366
+ import { spawn as spawn2 } from "child_process";
9367
+ import { existsSync as existsSync7, watch as fsWatch } from "fs";
9368
+ import { resolve as resolvePath } from "path";
9364
9369
  import {
9365
9370
  AppSchema,
9366
9371
  ValidateManifestResponseSchema
9367
9372
  } from "@eide/foir-proto-ts/apps/v1/apps_service_pb";
9373
+
9374
+ // src/lib/tunnel.ts
9375
+ import { spawn } from "child_process";
9376
+ import { once } from "events";
9377
+ import chalk16 from "chalk";
9378
+ async function startTunnel(opts) {
9379
+ const host = opts.host ?? "localhost";
9380
+ if (opts.kind === "cloudflared") return startCloudflared(host, opts.port, opts.logs ?? true);
9381
+ if (opts.kind === "ngrok") return startNgrok(opts.port, opts.logs ?? true);
9382
+ throw new Error(`Unknown tunnel kind: ${opts.kind}`);
9383
+ }
9384
+ var CLOUDFLARED_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
9385
+ function startCloudflared(host, port, logs) {
9386
+ return new Promise((resolve8, reject) => {
9387
+ let child;
9388
+ try {
9389
+ child = spawn(
9390
+ "cloudflared",
9391
+ [
9392
+ "tunnel",
9393
+ "--no-autoupdate",
9394
+ // Ignore ~/.cloudflared/config.yaml if present. Without this, a
9395
+ // user's existing named-tunnel ingress takes precedence over --url
9396
+ // and every quick-tunnel request falls through to the catch-all
9397
+ // 404. (Spent half a day diagnosing this once — don't relive it.)
9398
+ "--config",
9399
+ "/dev/null",
9400
+ "--url",
9401
+ `http://${host}:${port}`
9402
+ ],
9403
+ { stdio: ["ignore", "pipe", "pipe"] }
9404
+ );
9405
+ } catch (err) {
9406
+ reject(new Error(`Failed to spawn cloudflared: ${err.message}`));
9407
+ return;
9408
+ }
9409
+ let resolved = false;
9410
+ let buffered = "";
9411
+ const onData = (chunk) => {
9412
+ const text = chunk.toString("utf-8");
9413
+ if (logs) process.stderr.write(chalk16.dim(text));
9414
+ buffered += text;
9415
+ const match = buffered.match(CLOUDFLARED_URL_RE);
9416
+ if (match && !resolved) {
9417
+ resolved = true;
9418
+ const url = match[0];
9419
+ const exited = waitForExit(child);
9420
+ resolve8({
9421
+ url,
9422
+ exited,
9423
+ stop: () => stopChild(child)
9424
+ });
9425
+ }
9426
+ };
9427
+ child.stdout.on("data", onData);
9428
+ child.stderr.on("data", onData);
9429
+ child.once("error", (err) => {
9430
+ if (resolved) return;
9431
+ const msg = err.code === "ENOENT" ? "cloudflared not found on PATH. Install: `brew install cloudflared` or https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/" : `cloudflared failed to start: ${err.message}`;
9432
+ reject(new Error(msg));
9433
+ });
9434
+ child.once("exit", (code) => {
9435
+ if (!resolved) {
9436
+ reject(new Error(`cloudflared exited (code ${code ?? "null"}) before a tunnel URL appeared. Output:
9437
+ ${buffered}`));
9438
+ }
9439
+ });
9440
+ setTimeout(() => {
9441
+ if (!resolved) {
9442
+ resolved = true;
9443
+ void stopChild(child).catch(() => void 0);
9444
+ reject(new Error("Timed out waiting for cloudflared to print a tunnel URL."));
9445
+ }
9446
+ }, 3e4).unref();
9447
+ });
9448
+ }
9449
+ function startNgrok(port, logs) {
9450
+ return new Promise((resolve8, reject) => {
9451
+ let child;
9452
+ try {
9453
+ child = spawn("ngrok", ["http", String(port), "--log=stdout"], {
9454
+ stdio: ["ignore", "pipe", "pipe"]
9455
+ });
9456
+ } catch (err) {
9457
+ reject(new Error(`Failed to spawn ngrok: ${err.message}`));
9458
+ return;
9459
+ }
9460
+ if (logs) {
9461
+ child.stdout.on("data", (c) => process.stderr.write(chalk16.dim(c.toString("utf-8"))));
9462
+ child.stderr.on("data", (c) => process.stderr.write(chalk16.dim(c.toString("utf-8"))));
9463
+ }
9464
+ let resolved = false;
9465
+ child.once("error", (err) => {
9466
+ if (resolved) return;
9467
+ resolved = true;
9468
+ const msg = err.code === "ENOENT" ? "ngrok not found on PATH. Install: `brew install ngrok` or https://ngrok.com/download" : `ngrok failed to start: ${err.message}`;
9469
+ reject(new Error(msg));
9470
+ });
9471
+ child.once("exit", (code) => {
9472
+ if (resolved) return;
9473
+ resolved = true;
9474
+ reject(new Error(`ngrok exited (code ${code ?? "null"}) before a tunnel URL was available.`));
9475
+ });
9476
+ const deadline = Date.now() + 3e4;
9477
+ const poll = async () => {
9478
+ while (!resolved && Date.now() < deadline) {
9479
+ try {
9480
+ const resp = await fetch("http://127.0.0.1:4040/api/tunnels");
9481
+ if (resp.ok) {
9482
+ const data = await resp.json();
9483
+ const https = data.tunnels.find((t) => t.public_url.startsWith("https://"));
9484
+ if (https) {
9485
+ resolved = true;
9486
+ resolve8({
9487
+ url: https.public_url,
9488
+ exited: waitForExit(child),
9489
+ stop: () => stopChild(child)
9490
+ });
9491
+ return;
9492
+ }
9493
+ }
9494
+ } catch {
9495
+ }
9496
+ await new Promise((r) => setTimeout(r, 250));
9497
+ }
9498
+ if (!resolved) {
9499
+ resolved = true;
9500
+ void stopChild(child).catch(() => void 0);
9501
+ reject(new Error("Timed out waiting for ngrok local API at http://127.0.0.1:4040."));
9502
+ }
9503
+ };
9504
+ void poll();
9505
+ });
9506
+ }
9507
+ function waitForExit(child) {
9508
+ return once(child, "exit").then(([code]) => code ?? null);
9509
+ }
9510
+ async function stopChild(child) {
9511
+ if (child.exitCode !== null || child.signalCode !== null) return;
9512
+ child.kill("SIGTERM");
9513
+ const killed = await Promise.race([
9514
+ once(child, "exit").then(() => true),
9515
+ new Promise((r) => setTimeout(() => r(false), 3e3))
9516
+ ]);
9517
+ if (!killed && child.exitCode === null) {
9518
+ child.kill("SIGKILL");
9519
+ await once(child, "exit").catch(() => void 0);
9520
+ }
9521
+ }
9522
+
9523
+ // src/commands/apps.ts
9524
+ var FOIR_APPS_HOST_ENV = "FOIR_APPS_HOST";
9525
+ var WATCH_FILES = ["foir.config.ts", "foir.config.js", "foir.config.mjs", "foir.config.json"];
9368
9526
  function registerAppsCommands(program2, globalOpts) {
9369
9527
  const apps = program2.command("apps").description("Install and manage apps");
9370
9528
  apps.command("list").description("List installed apps").action(
@@ -9596,6 +9754,203 @@ function registerAppsCommands(program2, globalOpts) {
9596
9754
  throw new Error(`manifest has ${resp.issues.length} issue(s)`);
9597
9755
  })
9598
9756
  );
9757
+ apps.command("dev").description(
9758
+ "Spin up a public tunnel to a local app dev server so foir admin can iframe it."
9759
+ ).option("-p, --port <port>", "Local port serving the app (default 8787 \u2014 wrangler default)", "8787").option(
9760
+ "--host <host>",
9761
+ "Local host to forward to. Use 127.0.0.1 or [::1] when the dev server binds to only one IP family.",
9762
+ "localhost"
9763
+ ).option(
9764
+ "-t, --tunnel <kind>",
9765
+ "Tunnel provider: cloudflared (default), ngrok, or none (use --url)",
9766
+ "cloudflared"
9767
+ ).option("--url <url>", "BYO tunnel URL; pairs with --tunnel none").option("--push", "Run `foir push` once after the tunnel comes up, with FOIR_APPS_HOST set").option("--watch", "Re-run `foir push` on foir.config.* changes (implies --push)").action(
9768
+ withErrorHandler(
9769
+ globalOpts,
9770
+ async (cmdOpts) => {
9771
+ const opts = globalOpts();
9772
+ const port = Number(cmdOpts.port);
9773
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
9774
+ throw new Error(`--port must be an integer between 1 and 65535 (got "${cmdOpts.port}")`);
9775
+ }
9776
+ const kind = cmdOpts.tunnel;
9777
+ if (kind !== "cloudflared" && kind !== "ngrok" && kind !== "none") {
9778
+ throw new Error(`--tunnel must be one of: cloudflared, ngrok, none (got "${kind}")`);
9779
+ }
9780
+ const doPush = !!cmdOpts.push || !!cmdOpts.watch;
9781
+ const doWatch = !!cmdOpts.watch;
9782
+ if (kind === "none") {
9783
+ if (!cmdOpts.url) {
9784
+ throw new Error("--tunnel none requires --url <public-https-url>");
9785
+ }
9786
+ if (!/^https:\/\//.test(cmdOpts.url)) {
9787
+ throw new Error("--url must be an https:// URL");
9788
+ }
9789
+ await runDevSession({
9790
+ publicUrl: cmdOpts.url,
9791
+ port,
9792
+ kind: "byo",
9793
+ json: !!opts.json
9794
+ });
9795
+ if (doPush) {
9796
+ await runPush(hostOf(cmdOpts.url), !!opts.json);
9797
+ }
9798
+ if (doWatch) {
9799
+ await runWatchLoop(hostOf(cmdOpts.url), !!opts.json);
9800
+ }
9801
+ return;
9802
+ }
9803
+ if (!opts.json) {
9804
+ console.error(chalk17.dim(`Starting ${kind} tunnel \u2192 http://${cmdOpts.host}:${port}\u2026`));
9805
+ }
9806
+ const handle = await startTunnel({
9807
+ kind,
9808
+ port,
9809
+ host: cmdOpts.host,
9810
+ logs: !opts.json
9811
+ });
9812
+ let stopped = false;
9813
+ let watcher = null;
9814
+ const stop = async () => {
9815
+ if (stopped) return;
9816
+ stopped = true;
9817
+ watcher?.close();
9818
+ await handle.stop();
9819
+ };
9820
+ process.once("SIGINT", () => {
9821
+ void stop().finally(() => process.exit(0));
9822
+ });
9823
+ process.once("SIGTERM", () => {
9824
+ void stop().finally(() => process.exit(0));
9825
+ });
9826
+ await runDevSession({
9827
+ publicUrl: handle.url,
9828
+ port,
9829
+ localHost: cmdOpts.host,
9830
+ kind,
9831
+ json: !!opts.json
9832
+ });
9833
+ const host = hostOf(handle.url);
9834
+ if (doPush) {
9835
+ await runPush(host, !!opts.json);
9836
+ }
9837
+ if (doWatch) {
9838
+ watcher = startWatchLoop(host, !!opts.json);
9839
+ }
9840
+ await handle.exited;
9841
+ watcher?.close();
9842
+ }
9843
+ )
9844
+ );
9845
+ }
9846
+ function runDevSession(info) {
9847
+ if (info.json) {
9848
+ formatOutput(
9849
+ {
9850
+ tunnel: info.kind,
9851
+ port: info.port,
9852
+ publicUrl: info.publicUrl
9853
+ },
9854
+ { json: true }
9855
+ );
9856
+ return Promise.resolve();
9857
+ }
9858
+ const host = hostOf(info.publicUrl);
9859
+ console.log();
9860
+ console.log(chalk17.bold.green(" Tunnel ready"));
9861
+ console.log(` ${chalk17.bold(info.publicUrl)} ${chalk17.dim("\u2192")} http://${info.localHost ?? "localhost"}:${info.port}`);
9862
+ console.log();
9863
+ console.log(chalk17.dim(" Point `foir push` at the tunnel so app manifests resolve here."));
9864
+ console.log(chalk17.dim(" If your foir.config.ts uses an env-gated source (recommended):"));
9865
+ console.log(chalk17.dim(` FOIR_APPS_HOST=${host} foir push`));
9866
+ console.log(chalk17.dim(" Otherwise: update apps.<name>.source in foir.config.ts to a URL on"));
9867
+ console.log(chalk17.dim(` ${host} and run foir push.`));
9868
+ console.log();
9869
+ if (info.kind === "byo") {
9870
+ console.log(chalk17.dim(" (BYO tunnel \u2014 leaving lifecycle to you.)"));
9871
+ return Promise.resolve();
9872
+ }
9873
+ console.log(chalk17.dim(" Press Ctrl+C to stop the tunnel."));
9874
+ return Promise.resolve();
9875
+ }
9876
+ function hostOf(url) {
9877
+ try {
9878
+ return new URL(url).host;
9879
+ } catch {
9880
+ return url;
9881
+ }
9882
+ }
9883
+ function runPush(host, jsonMode) {
9884
+ return new Promise((resolve8, reject) => {
9885
+ if (!jsonMode) {
9886
+ console.log();
9887
+ console.log(chalk17.dim(`\u2192 foir push (${FOIR_APPS_HOST_ENV}=${host})`));
9888
+ }
9889
+ const entry = process.argv[1];
9890
+ if (!entry) {
9891
+ reject(new Error("Cannot locate foir CLI entry from process.argv; --push/--watch unavailable."));
9892
+ return;
9893
+ }
9894
+ const child = spawn2(process.execPath, [entry, "push"], {
9895
+ stdio: "inherit",
9896
+ env: { ...process.env, [FOIR_APPS_HOST_ENV]: host }
9897
+ });
9898
+ child.once("error", (err) => reject(err));
9899
+ child.once("exit", (code) => {
9900
+ if (code === 0) resolve8();
9901
+ else reject(new Error(`foir push exited with code ${code ?? "null"}`));
9902
+ });
9903
+ });
9904
+ }
9905
+ function startWatchLoop(host, jsonMode) {
9906
+ const cwd = process.cwd();
9907
+ const target = WATCH_FILES.map((n) => resolvePath(cwd, n)).find((p) => existsSync7(p));
9908
+ if (!target) {
9909
+ if (!jsonMode) {
9910
+ console.error(chalk17.yellow(`! --watch: no foir.config.* in ${cwd}; skipping watch loop.`));
9911
+ }
9912
+ return null;
9913
+ }
9914
+ if (!jsonMode) {
9915
+ console.log(chalk17.dim(`Watching ${target} \u2014 saving triggers foir push.`));
9916
+ }
9917
+ let pending = false;
9918
+ let running = false;
9919
+ let timer = null;
9920
+ const tick = () => {
9921
+ if (running) {
9922
+ pending = true;
9923
+ return;
9924
+ }
9925
+ running = true;
9926
+ pending = false;
9927
+ runPush(host, jsonMode).catch((err) => {
9928
+ if (!jsonMode) console.error(chalk17.red(`push failed: ${err.message}`));
9929
+ }).finally(() => {
9930
+ running = false;
9931
+ if (pending) tick();
9932
+ });
9933
+ };
9934
+ const watcher = fsWatch(target, { persistent: true }, () => {
9935
+ if (timer) clearTimeout(timer);
9936
+ timer = setTimeout(tick, 200);
9937
+ });
9938
+ return watcher;
9939
+ }
9940
+ function runWatchLoop(host, jsonMode) {
9941
+ return new Promise((resolve8) => {
9942
+ const watcher = startWatchLoop(host, jsonMode);
9943
+ if (!watcher) {
9944
+ resolve8();
9945
+ return;
9946
+ }
9947
+ const close = () => {
9948
+ watcher.close();
9949
+ resolve8();
9950
+ };
9951
+ process.once("SIGINT", close);
9952
+ process.once("SIGTERM", close);
9953
+ });
9599
9954
  }
9600
9955
  async function requireProject(opts) {
9601
9956
  const resolved = await resolveProjectContext(opts);
@@ -9628,9 +9983,9 @@ function classToLabel(n) {
9628
9983
  }
9629
9984
 
9630
9985
  // src/commands/secrets.ts
9631
- import { existsSync as existsSync7 } from "fs";
9986
+ import { existsSync as existsSync8 } from "fs";
9632
9987
  import { promises as fs5 } from "fs";
9633
- import { resolve as resolvePath } from "path";
9988
+ import { resolve as resolvePath2 } from "path";
9634
9989
  function registerSecretsCommands(program2, globalOpts) {
9635
9990
  const secrets = program2.command("secrets").description("Manage vault secrets");
9636
9991
  secrets.command("put").description("Store a new secret and print its ref").option("--label <label>", "Optional human-readable label").option("--app <name>", "Owner: app name (defaults to project-owned)").option("--file <path>", "Read plaintext from file (binary-safe)").option("--value <plaintext>", "Plaintext value (string only; prefer --file for binary)").action(
@@ -9928,14 +10283,14 @@ var PLAINTEXT_CONFIG_NAMES = [
9928
10283
  ];
9929
10284
  async function resolveSecretsConfigPath(explicit) {
9930
10285
  if (explicit) {
9931
- if (!existsSync7(explicit)) {
10286
+ if (!existsSync8(explicit)) {
9932
10287
  throw new Error(`Secrets config not found: ${explicit}`);
9933
10288
  }
9934
- return resolvePath(explicit);
10289
+ return resolvePath2(explicit);
9935
10290
  }
9936
10291
  for (const name of SECRETS_CONFIG_NAMES) {
9937
- const path3 = resolvePath(process.cwd(), name);
9938
- if (existsSync7(path3)) return path3;
10292
+ const path3 = resolvePath2(process.cwd(), name);
10293
+ if (existsSync8(path3)) return path3;
9939
10294
  }
9940
10295
  throw new Error(
9941
10296
  `No secrets config found. Looked for: ${SECRETS_CONFIG_NAMES.join(", ")}.`
@@ -9943,14 +10298,14 @@ async function resolveSecretsConfigPath(explicit) {
9943
10298
  }
9944
10299
  async function resolvePlaintextPath(explicit) {
9945
10300
  if (explicit) {
9946
- if (!existsSync7(explicit)) {
10301
+ if (!existsSync8(explicit)) {
9947
10302
  throw new Error(`Plaintext file not found: ${explicit}`);
9948
10303
  }
9949
- return resolvePath(explicit);
10304
+ return resolvePath2(explicit);
9950
10305
  }
9951
10306
  for (const name of PLAINTEXT_CONFIG_NAMES) {
9952
- const path3 = resolvePath(process.cwd(), name);
9953
- if (existsSync7(path3)) return path3;
10307
+ const path3 = resolvePath2(process.cwd(), name);
10308
+ if (existsSync8(path3)) return path3;
9954
10309
  }
9955
10310
  return null;
9956
10311
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eide/foir-cli",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "description": "Universal platform CLI for Foir platform",
5
5
  "type": "module",
6
6
  "publishConfig": {