@abloatai/cli 0.54.0 → 0.56.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.cjs +137 -46
  2. package/package.json +3 -3
package/dist/cli.cjs CHANGED
@@ -3970,7 +3970,7 @@ var init_observeCliError = __esm({
3970
3970
  import_errorObservation = require("@abloatai/transaction/errorObservation");
3971
3971
  import_errors5 = require("@abloatai/transaction/errors");
3972
3972
  dsn = process.env.ABLO_CLI_SENTRY_DSN ?? "https://1ac154bff10b06836e1ea9de9e0d92f0@o4510928209772544.ingest.de.sentry.io/4511660691423312" ?? "";
3973
- release = process.env.ABLO_CLI_RELEASE ?? "@abloatai/cli@0.54.0";
3973
+ release = process.env.ABLO_CLI_RELEASE ?? "@abloatai/cli@0.56.0";
3974
3974
  initialized = false;
3975
3975
  nativeProcessExit = process.exit.bind(process);
3976
3976
  exitBoundaryInstalled = false;
@@ -4297,7 +4297,7 @@ var init_src2 = __esm({
4297
4297
 
4298
4298
  // src/cliEnvironment.ts
4299
4299
  function cliVersion() {
4300
- return "0.54.0";
4300
+ return "0.56.0";
4301
4301
  }
4302
4302
  function cliOs() {
4303
4303
  const value = (0, import_node_os.platform)();
@@ -5968,6 +5968,28 @@ async function fetchPushedSchema(apiUrl3, apiKey) {
5968
5968
  clearTimeout(t);
5969
5969
  }
5970
5970
  }
5971
+ async function fetchDeliveryState(apiUrl3, apiKey, timeoutMs = 4e3) {
5972
+ if (!apiKey) return { kind: "unknown", detail: "no key" };
5973
+ const ctrl = new AbortController();
5974
+ const t = setTimeout(() => {
5975
+ ctrl.abort();
5976
+ }, timeoutMs);
5977
+ try {
5978
+ const res = await fetch(`${apiUrl3}/api/v1/logs/delivery`, {
5979
+ headers: { authorization: `Bearer ${apiKey}` },
5980
+ signal: ctrl.signal
5981
+ });
5982
+ if (!res.ok) return { kind: "unknown", detail: `HTTP ${res.status}` };
5983
+ const parsed = import_wire5.logDeliveryResponseSchema.safeParse(await res.json());
5984
+ if (!parsed.success) return { kind: "unknown", detail: "unrecognized response" };
5985
+ const { window_seconds, recorded, unroutable, sample } = parsed.data;
5986
+ return { kind: "known", window_seconds, recorded, unroutable, sample: sample ?? null };
5987
+ } catch {
5988
+ return { kind: "unknown", detail: "unreachable" };
5989
+ } finally {
5990
+ clearTimeout(t);
5991
+ }
5992
+ }
5971
5993
  async function fetchDataSourceState(apiUrl3, apiKey, timeoutMs = 4e3) {
5972
5994
  if (!apiKey) return { kind: "unknown", detail: "no key" };
5973
5995
  const ctrl = new AbortController();
@@ -6069,7 +6091,7 @@ function blockers(input) {
6069
6091
  }
6070
6092
  return found;
6071
6093
  }
6072
- var import_wire5, import_schema4;
6094
+ var import_wire5, import_schema4, WRITE_READY_VERDICT;
6073
6095
  var init_readiness = __esm({
6074
6096
  "src/readiness.ts"() {
6075
6097
  "use strict";
@@ -6079,6 +6101,7 @@ var init_readiness = __esm({
6079
6101
  init_dbProvider();
6080
6102
  init_remoteValidation();
6081
6103
  import_schema4 = require("@abloatai/transaction/schema");
6104
+ WRITE_READY_VERDICT = "write infrastructure is ready. Your database constraints and row-level policies still apply.";
6082
6105
  }
6083
6106
  });
6084
6107
 
@@ -284769,21 +284792,14 @@ function classifyKey(apiKey) {
284769
284792
  reason: `${import_picocolors16.default.bold("ABLO_API_KEY")} is not a secret Ablo key. Expected a branch-bound ${import_picocolors16.default.bold("sk_\u2026")} credential. Run ${import_picocolors16.default.bold("npx ablo dev")} to prepare a development branch.`
284770
284793
  };
284771
284794
  }
284772
- function wireEnvLocal(apiKey, cwd = process.cwd(), projectId, branchId) {
284795
+ function wireEnvLocal(apiKey, cwd = process.cwd()) {
284773
284796
  const envPath = (0, import_path5.resolve)(cwd, ".env.local");
284774
284797
  const line = `ABLO_API_KEY=${apiKey}`;
284775
- const projectLine = projectId ? `ABLO_PROJECT_ID=${projectId}` : null;
284776
- const branchLine = branchId ? `ABLO_BRANCH_ID=${branchId}` : null;
284777
284798
  let action;
284799
+ let removedPins = [];
284778
284800
  if (!(0, import_fs7.existsSync)(envPath)) {
284779
- (0, import_fs7.writeFileSync)(
284780
- envPath,
284781
- `${line}
284782
- ${projectLine ? `${projectLine}
284783
- ` : ""}${branchLine ? `${branchLine}
284784
- ` : ""}`,
284785
- { mode: 384 }
284786
- );
284801
+ (0, import_fs7.writeFileSync)(envPath, `${line}
284802
+ `, { mode: 384 });
284787
284803
  action = `Created ${import_picocolors16.default.bold(".env.local")} with ${import_picocolors16.default.bold("ABLO_API_KEY")}`;
284788
284804
  } else {
284789
284805
  const content = (0, import_fs7.readFileSync)(envPath, "utf8");
@@ -284799,25 +284815,16 @@ ${projectLine ? `${projectLine}
284799
284815
  (0, import_fs7.writeFileSync)(envPath, content.replace(/^ABLO_API_KEY=.*$/m, line));
284800
284816
  action = `Updated ${import_picocolors16.default.bold("ABLO_API_KEY")} in ${import_picocolors16.default.bold(".env.local")} ${import_picocolors16.default.dim(`(was ${existing.slice(0, 12)}\u2026)`)}`;
284801
284817
  }
284802
- if (projectLine) {
284803
- const next = (0, import_fs7.readFileSync)(envPath, "utf8");
284804
- if (/^ABLO_PROJECT_ID=.*$/m.test(next)) {
284805
- (0, import_fs7.writeFileSync)(envPath, next.replace(/^ABLO_PROJECT_ID=.*$/m, projectLine));
284806
- } else {
284807
- (0, import_fs7.appendFileSync)(envPath, `${next.endsWith("\n") || next.length === 0 ? "" : "\n"}${projectLine}
284808
- `);
284809
- }
284810
- }
284811
- if (branchLine) {
284812
- const next = (0, import_fs7.readFileSync)(envPath, "utf8");
284813
- if (/^ABLO_BRANCH_ID=.*$/m.test(next)) {
284814
- (0, import_fs7.writeFileSync)(envPath, next.replace(/^ABLO_BRANCH_ID=.*$/m, branchLine));
284815
- } else {
284816
- (0, import_fs7.appendFileSync)(envPath, `${next.endsWith("\n") || next.length === 0 ? "" : "\n"}${branchLine}
284817
- `);
284818
- }
284818
+ const before = (0, import_fs7.readFileSync)(envPath, "utf8");
284819
+ const after = before.replace(/^ABLO_(?:PROJECT|BRANCH)_ID=.*\n?/gm, "");
284820
+ if (after !== before) {
284821
+ removedPins = ["ABLO_PROJECT_ID", "ABLO_BRANCH_ID"].filter(
284822
+ (key) => new RegExp(`^${key}=`, "m").test(before)
284823
+ );
284824
+ (0, import_fs7.writeFileSync)(envPath, after);
284819
284825
  }
284820
284826
  }
284827
+ const pinNote = removedPins.length ? ` Removed ${removedPins.map((key) => import_picocolors16.default.bold(key)).join(" and ")}; the key names its own project and branch.` : "";
284821
284828
  const gitignorePath = (0, import_path5.resolve)(cwd, ".gitignore");
284822
284829
  const gitignore = (0, import_fs7.existsSync)(gitignorePath) ? (0, import_fs7.readFileSync)(gitignorePath, "utf8") : "";
284823
284830
  const ignored = /^(\.env\.local|\.env\*|\.env\.\*|\.env.*)$/m.test(gitignore);
@@ -284831,7 +284838,7 @@ ${projectLine ? `${projectLine}
284831
284838
  );
284832
284839
  gitignoreNote = ` Added ${import_picocolors16.default.bold(".env.local")} to ${import_picocolors16.default.bold(".gitignore")} so the key can't be committed.`;
284833
284840
  }
284834
- return `${action}.${gitignoreNote}`;
284841
+ return `${action}.${pinNote}${gitignoreNote}`;
284835
284842
  }
284836
284843
  async function runPush(schema, args) {
284837
284844
  const { ok, status: status2, body, bodyText } = await pushSchema(schema, {
@@ -284992,7 +284999,7 @@ async function dev(argv, runtime = {}) {
284992
284999
  if (runtime.branch) {
284993
285000
  console.log(
284994
285001
  `
284995
- ${import_picocolors16.default.green("\u2713")} ${wireEnvLocal(args.apiKey, process.cwd(), runtime.branch.projectId, runtime.branch.id)}`
285002
+ ${import_picocolors16.default.green("\u2713")} ${wireEnvLocal(args.apiKey, process.cwd())}`
284996
285003
  );
284997
285004
  console.log(
284998
285005
  ` ${import_picocolors16.default.dim(`Temporary branch credential expires ${runtime.branch.expiresAt}; rerun ablo dev to rotate it.`)}`
@@ -285423,6 +285430,10 @@ init_controlPlane();
285423
285430
  init_config();
285424
285431
  init_target();
285425
285432
  init_readiness();
285433
+ function doctorVerdict(counts) {
285434
+ if (counts.blockers > 0 || counts.failed > 0) return "blocked";
285435
+ return counts.skipped > 0 ? "unverified" : "ready";
285436
+ }
285426
285437
  function render(check2) {
285427
285438
  const mark = check2.state === "ok" ? import_picocolors18.default.green("\u2713") : check2.state === "fail" ? import_picocolors18.default.red("\u2717") : import_picocolors18.default.dim("\u2013");
285428
285439
  console.log(
@@ -285430,6 +285441,14 @@ function render(check2) {
285430
285441
  );
285431
285442
  if (check2.fix) console.log(` ${" ".repeat(11)}${import_picocolors18.default.dim(`\u2192 ${check2.fix}`)}`);
285432
285443
  }
285444
+ function windowWords(seconds) {
285445
+ if (seconds % 3600 === 0) {
285446
+ const hours = seconds / 3600;
285447
+ return hours === 1 ? "the last hour" : `the last ${hours} hours`;
285448
+ }
285449
+ const minutes = Math.max(1, Math.round(seconds / 60));
285450
+ return minutes === 1 ? "the last minute" : `the last ${minutes} minutes`;
285451
+ }
285433
285452
  async function ping(apiUrl3) {
285434
285453
  const ctrl = new AbortController();
285435
285454
  const t = setTimeout(() => {
@@ -285572,6 +285591,32 @@ async function inspectDoctor(options = {}) {
285572
285591
  } else {
285573
285592
  checks.push({ label: "database", state: "skip", detail: "nothing connected to check" });
285574
285593
  }
285594
+ const delivery = reachable ? await fetchDeliveryState(apiUrl3, runtimeKey.key) : { kind: "unknown", detail: "unreachable" };
285595
+ if (delivery.kind === "known") {
285596
+ const when = windowWords(delivery.window_seconds);
285597
+ checks.push(
285598
+ delivery.unroutable > 0 ? delivery.unroutable === delivery.recorded ? {
285599
+ // Nothing at all was routable. One bad row does not look like
285600
+ // this, so the cause is the plane rather than the rows: reporting
285601
+ // it as "N of N" would send the reader hunting for the N.
285602
+ label: "delivery",
285603
+ state: "fail",
285604
+ detail: `no change reached anyone (${delivery.recorded} in ${when})`,
285605
+ fix: "run `ablo check`. When nothing routes, the tenancy value is usually missing for the whole plane rather than for particular rows."
285606
+ } : {
285607
+ label: "delivery",
285608
+ state: "fail",
285609
+ detail: `${delivery.unroutable} of ${delivery.recorded} change${delivery.recorded === 1 ? "" : "s"} in ${when} reached nobody` + (delivery.sample ? ` (e.g. ${delivery.sample.model}/${delivery.sample.id})` : ""),
285610
+ fix: "run `ablo check`. Rows written to Postgres outside Ablo carry no tenancy value, so nothing can route the change."
285611
+ } : {
285612
+ label: "delivery",
285613
+ state: "ok",
285614
+ detail: delivery.recorded === 0 ? `no changes in ${when}` : `${delivery.recorded} change${delivery.recorded === 1 ? "" : "s"} in ${when}, all deliverable`
285615
+ }
285616
+ );
285617
+ } else {
285618
+ checks.push({ label: "delivery", state: "skip", detail: `not determined (${delivery.detail})` });
285619
+ }
285575
285620
  const blocking = blockers({
285576
285621
  reachable,
285577
285622
  hasKey: Boolean(runtimeKey.key),
@@ -285592,7 +285637,9 @@ async function inspectDoctor(options = {}) {
285592
285637
  target,
285593
285638
  dataSource,
285594
285639
  pushedSchema: pushed,
285595
- schemaDrift: drift
285640
+ schemaDrift: drift,
285641
+ delivery,
285642
+ verdict: doctorVerdict({ blockers: blocking.length, failed, skipped })
285596
285643
  };
285597
285644
  }
285598
285645
  function renderDoctorReport(report) {
@@ -285610,19 +285657,35 @@ function renderDoctorReport(report) {
285610
285657
  );
285611
285658
  } else if (report.skipped > 0) {
285612
285659
  console.log(
285613
- ` ${import_picocolors18.default.yellow("?")} ${import_picocolors18.default.dim(`nothing is blocking a write, but ${report.skipped} check${report.skipped === 1 ? "" : "s"} could not be run`)}`
285660
+ ` ${import_picocolors18.default.yellow("?")} ${import_picocolors18.default.dim(`nothing found a problem, but ${report.skipped} check${report.skipped === 1 ? "" : "s"} could not be run. This is not a clean bill of health.`)}`
285614
285661
  );
285615
285662
  } else {
285616
285663
  console.log(
285617
- ` ${import_picocolors18.default.green("\u2713")} ${import_picocolors18.default.dim("write infrastructure is ready \u2014 your database constraints and row-level policies still apply")}`
285664
+ ` ${import_picocolors18.default.green("\u2713")} ${import_picocolors18.default.dim(WRITE_READY_VERDICT)}`
285618
285665
  );
285619
285666
  }
285620
285667
  console.log();
285621
285668
  }
285622
- async function doctor() {
285669
+ function doctorReportAsJson(report) {
285670
+ return JSON.stringify(
285671
+ {
285672
+ object: "doctor_report",
285673
+ verdict: report.verdict,
285674
+ checks: report.checks,
285675
+ blockers: report.blockers
285676
+ },
285677
+ null,
285678
+ 2
285679
+ );
285680
+ }
285681
+ async function doctor(argv = []) {
285623
285682
  const report = await inspectDoctor();
285624
- renderDoctorReport(report);
285625
- if (!report.ready) process.exitCode = 1;
285683
+ if (argv.includes("--json") || process.env.ABLO_JSON === "1") {
285684
+ console.log(doctorReportAsJson(report));
285685
+ } else {
285686
+ renderDoctorReport(report);
285687
+ }
285688
+ if (report.verdict !== "ready") process.exitCode = 1;
285626
285689
  }
285627
285690
 
285628
285691
  // src/setup/contracts.ts
@@ -287458,7 +287521,10 @@ var COMMANDS = [
287458
287521
  core: { group: "Every day", does: "Check the whole setup at once and list everything that would block a write" },
287459
287522
  full: {
287460
287523
  group: "See what's happening",
287461
- rows: [{ run: "doctor", does: "Every setup check at once \u2014 exits non-zero when a write would fail" }]
287524
+ rows: [
287525
+ { run: "doctor", does: "Every setup check at once. Exits non-zero when a write would fail, or when a check could not be run" },
287526
+ { run: "doctor --json", does: "The same verdict as data, for a script or an agent" }
287527
+ ]
287462
287528
  }
287463
287529
  },
287464
287530
  {
@@ -288136,7 +288202,7 @@ async function status(args = []) {
288136
288202
  );
288137
288203
  } else {
288138
288204
  console.log(
288139
- ` ${import_picocolors22.default.green("\u2713")} ${import_picocolors22.default.dim("write infrastructure is ready \u2014 database constraints and row-level policies still apply")}`
288205
+ ` ${import_picocolors22.default.green("\u2713")} ${import_picocolors22.default.dim(WRITE_READY_VERDICT)}`
288140
288206
  );
288141
288207
  }
288142
288208
  console.log();
@@ -288623,6 +288689,24 @@ function parseCheckArgs(argv) {
288623
288689
  }
288624
288690
  return { schemaPath, exportName, appSchema };
288625
288691
  }
288692
+ function quoteIdent2(raw) {
288693
+ return `"${raw.replace(/"/g, '""')}"`;
288694
+ }
288695
+ var UNSTAMPED_CAP = 500;
288696
+ async function countUnstamped(sql, appSchema, table, column) {
288697
+ try {
288698
+ const rows = await sql.unsafe(
288699
+ `SELECT count(*)::int AS n FROM (
288700
+ SELECT 1 FROM ${quoteIdent2(appSchema)}.${quoteIdent2(table)}
288701
+ WHERE ${quoteIdent2(column)} IS NULL
288702
+ LIMIT ${UNSTAMPED_CAP + 1}
288703
+ ) s`
288704
+ );
288705
+ return rows[0]?.n ?? 0;
288706
+ } catch {
288707
+ return null;
288708
+ }
288709
+ }
288626
288710
  function hostOf(connectionString) {
288627
288711
  try {
288628
288712
  return new URL(connectionString).hostname || null;
@@ -288700,7 +288784,6 @@ async function check(argv) {
288700
288784
  await sql.end({ timeout: 2 });
288701
288785
  process.exit(1);
288702
288786
  }
288703
- await sql.end({ timeout: 2 });
288704
288787
  const colsByTable = /* @__PURE__ */ new Map();
288705
288788
  for (const r2 of rows) {
288706
288789
  let set = colsByTable.get(r2.table_name);
@@ -288739,6 +288822,15 @@ async function check(argv) {
288739
288822
  if (BASE_COLUMNS.has(col) || col === orgCol) continue;
288740
288823
  if (!present.has(col)) problems.push(`missing column "${col}" (field ${fieldName})`);
288741
288824
  }
288825
+ if (orgCol && present.has(orgCol)) {
288826
+ const unstamped = await countUnstamped(sql, args.appSchema, table, orgCol);
288827
+ if (unstamped !== null && unstamped > 0) {
288828
+ const count = unstamped > UNSTAMPED_CAP ? `${UNSTAMPED_CAP}+ rows` : `${unstamped} row${unstamped === 1 ? "" : "s"}`;
288829
+ problems.push(
288830
+ `${count} have no "${orgCol}", so nothing can route them. Ablo stamps it on writes it makes; an insert straight into Postgres does not. Backfill the value, then run \`ablo connect resnapshot\`.`
288831
+ );
288832
+ }
288833
+ }
288742
288834
  if (problems.length > 0) {
288743
288835
  console.log(` ${import_picocolors25.default.red("\u2717")} ${import_picocolors25.default.bold(key)} ${import_picocolors25.default.dim("\u2192")} ${table}`);
288744
288836
  for (const p2 of problems) console.log(` ${import_picocolors25.default.red("\u2022")} ${p2}`);
@@ -288752,6 +288844,7 @@ async function check(argv) {
288752
288844
  console.log(` ${import_picocolors25.default.green("\u2713")} ${import_picocolors25.default.bold(key)} ${import_picocolors25.default.dim(`\u2192 ${table} (id, ${orgCol ?? "no org"} ok)`)}`);
288753
288845
  }
288754
288846
  }
288847
+ await sql.end({ timeout: 2 });
288755
288848
  const modelCount = Object.keys(schemaJson.models).length;
288756
288849
  const ignored = [...colsByTable.keys()].filter((t) => !declaredTables.has(t)).length;
288757
288850
  console.log(
@@ -289736,8 +289829,6 @@ import { schema } from './schema';
289736
289829
  // via the session route and never touches the key.
289737
289830
  export const sync = Ablo({
289738
289831
  apiKey: process.env.ABLO_API_KEY,${authLine}
289739
- workspaceId: process.env.ABLO_PROJECT_ID,
289740
- branchId: process.env.ABLO_BRANCH_ID,
289741
289832
  schema,
289742
289833
  });
289743
289834
 
@@ -289763,7 +289854,7 @@ function generateEnv(storage, opts = {}) {
289763
289854
  const { includeApiKey = true } = opts;
289764
289855
  const databaseBlock = storage === "replication" ? "# Used by `npx ablo connect` to set up + register logical replication \u2014 the\n# DIRECT (un-pooled) endpoint. Ablo TAILS your WAL from here; it never writes.\n# The client never sees it; the browser never sees it. Your DB stays yours.\nDATABASE_URL=postgres://user:password@host:5432/db\n" : "# Used by ablo/data-source.ts (your DB endpoint) + `ablo migrate` \u2014 NOT the client.\n# Ablo never sees it; the browser never sees it. Your DB stays in your app.\nDATABASE_URL=postgres://user:password@host:5432/db\n";
289765
289856
  const webhookBlock = storage === "endpoint" ? "# Signing secret for the webhook receiver (app/api/ablo/webhooks/route.ts).\n# Ablo mints this when you register the endpoint's URL (POST /v1/webhook_endpoints\n# or the dashboard) and returns it once \u2014 paste it here.\nABLO_WEBHOOK_SECRET=whsec_your_endpoint_secret_here\n" : "";
289766
- const apiKeyBlock = includeApiKey ? "# Ablo: a branch-bound sk_ key (`npx ablo dev` wires both values for you).\n# Project + branch are assertions: the SDK rejects a key for another app or environment.\nABLO_API_KEY=sk_your_key_here\nABLO_PROJECT_ID=proj_your_project_id\nABLO_BRANCH_ID=br_your_branch_id\n" : "";
289857
+ const apiKeyBlock = includeApiKey ? "# Ablo: a branch-bound sk_ key (`npx ablo dev` writes it for you).\n# The key names its own project and branch, so nothing else is needed here.\nABLO_API_KEY=sk_your_key_here\n" : "";
289767
289858
  return `${apiKeyBlock}${webhookBlock}${databaseBlock}`;
289768
289859
  }
289769
289860
  function generateDataSource(orm) {
@@ -290454,7 +290545,7 @@ var HANDLERS = {
290454
290545
  branch: (argv) => branches([...argv]),
290455
290546
  status: (argv) => status([...argv]),
290456
290547
  whoami: (argv) => whoami([...argv]),
290457
- doctor: () => doctor(),
290548
+ doctor: (argv) => doctor([...argv]),
290458
290549
  logs: (argv) => logs([...argv]),
290459
290550
  webhooks: (argv) => webhooks([...argv]),
290460
290551
  check: (argv) => check([...argv]),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/cli",
3
- "version": "0.54.0",
3
+ "version": "0.56.0",
4
4
  "description": "The ablo command line: set up Ablo, connect your database, and push your schema from the terminal.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -35,10 +35,10 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@sentry/node": "^10.18.0",
38
- "@abloatai/transaction": "^0.54.0",
38
+ "@abloatai/transaction": "^0.56.0",
39
39
  "jiti": "^2.7.0",
40
40
  "zod": "^4.4.3",
41
- "@abloatai/humans": "^0.54.0"
41
+ "@abloatai/humans": "^0.56.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@ablo/product-analytics": "file:../product-analytics",