@abloatai/ablo 0.29.1 → 0.29.3

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 (3) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/cli.cjs +214 -23
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.29.3
4
+
5
+ ### Patch Changes
6
+
7
+ - **`ablo connect` validates from Ablo's network when your machine can't reach the database.** Replication runs from Ablo's infrastructure, not from your laptop, so a database that is IPv6-only (Supabase direct hosts), IP-allowlisted, or behind a VPN can be perfectly replicable even when every local dial fails. When `--check` can't connect at all, it now asks the engine to run the same readiness checklist from its own network instead of reporting a false failure; `--register` no longer blocks on local unreachability and lets the registration preflight decide. A host that _was_ reached and rejected the connection — bad credentials, a TLS error, a Postgres error — still fails locally, because the engine would see exactly the same thing.
8
+
9
+ ## 0.29.2
10
+
11
+ ### Patch Changes
12
+
13
+ - **Switching to a project you never minted a key for no longer locks you out.** Project-management commands — `ablo projects list`, `create`, `rename`, and `use` — are organization-level operations, so they now accept any of the organization's stored keys (the active project's key first, then the `default` profile, then any profile still holding an unexpired key) instead of strictly the active project's key. Selecting a keyless project could previously leave `ablo projects use default` unauthorized — the one command that would undo the switch. Data commands keep the strict resolver, so this permissive fallback never routes a read or write through an unintended project.
14
+
3
15
  ## 0.29.1
4
16
 
5
17
  ### Patch Changes
package/dist/cli.cjs CHANGED
@@ -280036,13 +280036,10 @@ function clearCredential() {
280036
280036
  return removed;
280037
280037
  }
280038
280038
  function resolveApiKey(modeOverride) {
280039
- if (process.env.ABLO_API_KEY) return process.env.ABLO_API_KEY;
280040
- const cfg = readConfig();
280041
- if (!cfg) return void 0;
280042
- const entry = cfg.profiles[activeProfileName(cfg)]?.[modeOverride ?? cfg.mode];
280043
- if (!entry) return void 0;
280044
- if (entry.expiresAt && Date.parse(entry.expiresAt) <= Date.now()) return void 0;
280045
- return entry.apiKey;
280039
+ return resolveKey({ purpose: "data", mode: modeOverride }).key;
280040
+ }
280041
+ function resolveOrgKey(modeOverride) {
280042
+ return resolveKey({ purpose: "org-read", mode: modeOverride }).key;
280046
280043
  }
280047
280044
  function guardActiveProjectKey() {
280048
280045
  if (process.env.ABLO_API_KEY) {
@@ -280054,11 +280051,31 @@ function guardActiveProjectKey() {
280054
280051
  const available = Object.entries(profiles).filter(([, keys]) => hasKey(keys)).map(([name]) => name);
280055
280052
  return { ok: hasKey(profiles[activeProfile]), activeProfile, available };
280056
280053
  }
280054
+ function resolveKey(policy) {
280055
+ if (policy.scanEnvFiles) {
280056
+ const fromProject = readProjectApiKey(policy.cwd);
280057
+ if (fromProject) return { key: fromProject.key, source: fromProject.source };
280058
+ } else if (process.env.ABLO_API_KEY) {
280059
+ return { key: process.env.ABLO_API_KEY, source: "env" };
280060
+ }
280061
+ const cfg = readConfig();
280062
+ if (!cfg) return { key: void 0, source: null };
280063
+ const mode2 = policy.mode ?? cfg.mode;
280064
+ const profiles = policy.purpose === "org-read" ? (
280065
+ // Active first (a scoped key is preferred when present), then the
280066
+ // org-default, then any remaining profile — deduplicated, order-preserving.
280067
+ [.../* @__PURE__ */ new Set([activeProfileName(cfg), DEFAULT_PROFILE, ...Object.keys(cfg.profiles)])]
280068
+ ) : [activeProfileName(cfg)];
280069
+ for (const name of profiles) {
280070
+ const entry = cfg.profiles[name]?.[mode2];
280071
+ if (!entry) continue;
280072
+ if (entry.expiresAt && Date.parse(entry.expiresAt) <= Date.now()) continue;
280073
+ return { key: entry.apiKey, source: "stored" };
280074
+ }
280075
+ return { key: void 0, source: null };
280076
+ }
280057
280077
  function resolveEffectiveApiKey(modeOverride, cwd) {
280058
- const fromProject = readProjectApiKey(cwd);
280059
- if (fromProject) return { key: fromProject.key, source: fromProject.source };
280060
- const key = resolveApiKey(modeOverride);
280061
- return key ? { key, source: "stored" } : { key: void 0, source: null };
280078
+ return resolveKey({ purpose: "data", mode: modeOverride, scanEnvFiles: true, cwd });
280062
280079
  }
280063
280080
  function resolvePushPlan() {
280064
280081
  const { key, source } = resolveEffectiveApiKey();
@@ -280254,7 +280271,7 @@ function apiUrl() {
280254
280271
  return (process.env.ABLO_API_URL ?? ABLO_DEFAULT_BASE_URL).replace(/\/+$/, "");
280255
280272
  }
280256
280273
  function requireKey() {
280257
- const apiKey = resolveApiKey();
280274
+ const apiKey = resolveOrgKey();
280258
280275
  if (!apiKey) {
280259
280276
  console.error(
280260
280277
  import_picocolors3.default.red(" No API key.") + import_picocolors3.default.dim(
@@ -280460,7 +280477,7 @@ async function resolveTarget(opts) {
280460
280477
  keyEnv,
280461
280478
  confirmed,
280462
280479
  localProject,
280463
- mismatches: reconcile({ confirmed, keyEnv, localProject })
280480
+ mismatches: reconcile({ confirmed, keyEnv, localProject, cliMode: getMode() })
280464
280481
  };
280465
280482
  }
280466
280483
  async function confirmFromServer(opts) {
@@ -280505,10 +280522,9 @@ function confirmedProjectSlug(project) {
280505
280522
  return project.isDefault ? DEFAULT_PROJECT_SLUG : project.slug;
280506
280523
  }
280507
280524
  function reconcile(input) {
280508
- const { confirmed, keyEnv, localProject } = input;
280525
+ const { confirmed, keyEnv, localProject, cliMode } = input;
280509
280526
  const mismatches = [];
280510
280527
  const realEnv = confirmed?.environment ?? keyEnv;
280511
- const cliMode = getMode();
280512
280528
  if (realEnv && realEnv !== cliMode) {
280513
280529
  mismatches.push({ kind: "environment", keyEnv: realEnv, cliMode });
280514
280530
  }
@@ -281132,6 +281148,106 @@ async function migrate(argv) {
281132
281148
  // src/cli/connect.ts
281133
281149
  init_cjs_shims();
281134
281150
  var import_picocolors6 = __toESM(require_picocolors(), 1);
281151
+
281152
+ // src/cli/remoteValidation.ts
281153
+ init_cjs_shims();
281154
+ var DIAL_FAILURE_CODES = /* @__PURE__ */ new Set([
281155
+ "ENOTFOUND",
281156
+ "EAI_AGAIN",
281157
+ "ECONNREFUSED",
281158
+ "ECONNRESET",
281159
+ "ETIMEDOUT",
281160
+ "ENETUNREACH",
281161
+ "EHOSTUNREACH",
281162
+ "CONNECT_TIMEOUT"
281163
+ ]);
281164
+ function dialFailureReason(err) {
281165
+ if (err === null || typeof err !== "object") return null;
281166
+ const coded = err;
281167
+ const code = typeof coded.code === "string" ? coded.code : null;
281168
+ if (code && DIAL_FAILURE_CODES.has(code)) {
281169
+ return typeof coded.message === "string" && coded.message.length > 0 ? coded.message : code;
281170
+ }
281171
+ if (Array.isArray(coded.errors)) {
281172
+ for (const member of coded.errors) {
281173
+ const reason = dialFailureReason(member);
281174
+ if (reason) return reason;
281175
+ }
281176
+ }
281177
+ return null;
281178
+ }
281179
+ function validateEndpoint(baseUrl2) {
281180
+ return `${baseUrl2.replace(/\/+$/, "")}/api/v1/datasources/validate`;
281181
+ }
281182
+ function describeRemoteFailure(failure) {
281183
+ switch (failure.item) {
281184
+ case "wal_level":
281185
+ return {
281186
+ label: failure.actual ? `wal_level is ${failure.actual} (need logical)` : `wal_level must be logical`,
281187
+ fix: failure.fix
281188
+ };
281189
+ case "publication":
281190
+ return { label: "the Ablo publication does not exist", fix: failure.fix };
281191
+ case "replication_role":
281192
+ return { label: "the DATABASE_URL role lacks the REPLICATION attribute", fix: failure.fix };
281193
+ case "replica_identity":
281194
+ return {
281195
+ label: `published tables cannot replicate UPDATE/DELETE${failure.actual ? ` (${failure.actual})` : ""}`,
281196
+ fix: failure.fix
281197
+ };
281198
+ default:
281199
+ return { label: failure.item, fix: failure.fix };
281200
+ }
281201
+ }
281202
+ function parseWireFailures(value) {
281203
+ if (!Array.isArray(value)) return [];
281204
+ const failures = [];
281205
+ for (const entry of value) {
281206
+ if (entry === null || typeof entry !== "object") continue;
281207
+ const { item, actual, fix } = entry;
281208
+ if (typeof item !== "string" || typeof fix !== "string") continue;
281209
+ failures.push({ item, fix, ...typeof actual === "string" ? { actual } : {} });
281210
+ }
281211
+ return failures;
281212
+ }
281213
+ async function requestRemoteValidation(input) {
281214
+ const doFetch = input.fetchImpl ?? fetch;
281215
+ let res;
281216
+ try {
281217
+ res = await doFetch(validateEndpoint(input.apiUrl), {
281218
+ method: "POST",
281219
+ headers: { "content-type": "application/json", authorization: `Bearer ${input.apiKey}` },
281220
+ body: JSON.stringify({ connectionString: input.connectionString })
281221
+ });
281222
+ } catch (err) {
281223
+ return {
281224
+ ok: false,
281225
+ status: 0,
281226
+ message: `couldn't reach ${input.apiUrl}: ${err instanceof Error ? err.message : String(err)}`
281227
+ };
281228
+ }
281229
+ const body = await res.json().catch(() => ({}));
281230
+ if (!res.ok) {
281231
+ const code = typeof body.code === "string" ? body.code : body.error?.code;
281232
+ const message = typeof body.message === "string" ? body.message : typeof body.error?.message === "string" ? body.error.message : `HTTP ${res.status}`;
281233
+ return {
281234
+ ok: false,
281235
+ status: res.status,
281236
+ message,
281237
+ ...typeof code === "string" ? { code } : {}
281238
+ };
281239
+ }
281240
+ const reachable = body.reachable === true;
281241
+ return {
281242
+ ok: true,
281243
+ reachable,
281244
+ ready: body.ready === true,
281245
+ ...typeof body.reason === "string" ? { reason: body.reason } : {},
281246
+ failures: parseWireFailures(body.failures)
281247
+ };
281248
+ }
281249
+
281250
+ // src/cli/connect.ts
281135
281251
  var ABLO_PUBLICATION = "ablo_publication";
281136
281252
  var ABLO_REPLICATION_ROLE = "ablo_replicator";
281137
281253
  function parseConnectArgs(argv) {
@@ -281361,27 +281477,94 @@ function requireDatabaseUrl(verb) {
281361
281477
  return dbUrl;
281362
281478
  }
281363
281479
  async function probeAndReport(dbUrl) {
281364
- const sql = src_default(dbUrl, { max: 1, prepare: false, onnotice: () => {
281480
+ const sql = src_default(dbUrl, { max: 1, prepare: false, connect_timeout: 10, onnotice: () => {
281365
281481
  } });
281366
281482
  let items;
281367
281483
  try {
281368
281484
  items = await probeReadiness(sql);
281369
281485
  } catch (err) {
281486
+ await sql.end({ timeout: 2 }).catch(() => void 0);
281487
+ const dial = dialFailureReason(err);
281488
+ if (dial) return { kind: "no-dial", reason: dial };
281370
281489
  const pg = err ?? {};
281371
281490
  console.error(import_picocolors6.default.red(` Couldn't read the database: ${pg.message ?? String(err)}`));
281372
- await sql.end({ timeout: 2 });
281373
281491
  process.exit(1);
281374
281492
  }
281375
281493
  await sql.end({ timeout: 2 });
281376
281494
  for (const item of items) printCheckItem(item);
281377
- return items.filter((i) => !i.ok).length;
281495
+ return { kind: "probed", failures: items.filter((i) => !i.ok).length };
281496
+ }
281497
+ async function runRemoteCheck(dbUrl, localReason) {
281498
+ console.log(
281499
+ ` This machine can't reach the database (${import_picocolors6.default.dim(localReason)}).
281500
+ That is not the verdict: replication runs from Ablo's infrastructure, not from here.
281501
+ Asking Ablo to check the database from its side\u2026
281502
+ `
281503
+ );
281504
+ const apiKey = resolveApiKey();
281505
+ if (!apiKey) {
281506
+ console.error(
281507
+ import_picocolors6.default.red(` The engine-side check needs an API key, and none was found.`) + import_picocolors6.default.dim(
281508
+ ` Run ${import_picocolors6.default.bold("ablo login")} (or set ${import_picocolors6.default.bold("ABLO_API_KEY")}), then re-run ${import_picocolors6.default.bold("ablo connect --check")}.`
281509
+ )
281510
+ );
281511
+ process.exit(1);
281512
+ }
281513
+ const apiUrl2 = (process.env.ABLO_API_URL ?? DEFAULT_URL).replace(/\/+$/, "");
281514
+ const result = await requestRemoteValidation({ apiUrl: apiUrl2, apiKey, connectionString: dbUrl });
281515
+ if (!result.ok) {
281516
+ console.error(import_picocolors6.default.red(` The engine-side check failed: ${result.message}`));
281517
+ if (result.code === "forbidden") {
281518
+ console.error(
281519
+ import_picocolors6.default.dim(` Checking a database from Ablo's side needs a ${import_picocolors6.default.bold("secret")} key (sk_\u2026). Run ${import_picocolors6.default.bold("ablo login")} for one.`)
281520
+ );
281521
+ }
281522
+ console.error();
281523
+ process.exit(1);
281524
+ }
281525
+ if (!result.reachable) {
281526
+ console.error(
281527
+ ` ${import_picocolors6.default.red("\u2717")} Ablo's infrastructure can't reach it either${result.reason ? ` ${import_picocolors6.default.dim(`(${result.reason})`)}` : ""}.`
281528
+ );
281529
+ console.error(
281530
+ import_picocolors6.default.dim(
281531
+ ` Replication needs a host Ablo's servers can dial \u2014 a localhost or private-network
281532
+ Postgres can't be replicated by the hosted service. Use a reachable host (or a tunnel),
281533
+ or the signed ${import_picocolors6.default.bold("dataSource()")} endpoint fallback.
281534
+ `
281535
+ )
281536
+ );
281537
+ process.exit(1);
281538
+ }
281539
+ for (const failure of result.failures) {
281540
+ const { label, fix } = describeRemoteFailure(failure);
281541
+ printCheckItem({ ok: false, label, fix });
281542
+ }
281543
+ console.log();
281544
+ if (result.ready) {
281545
+ console.log(
281546
+ ` ${import_picocolors6.default.green("\u2713")} Ready \u2014 checked from Ablo's infrastructure. Ablo can connect and tail this database's WAL.
281547
+ `
281548
+ );
281549
+ process.exit(0);
281550
+ }
281551
+ const count = result.failures.length;
281552
+ console.log(
281553
+ ` ${import_picocolors6.default.red(`${count} item${count === 1 ? "" : "s"} to fix`)} ${import_picocolors6.default.dim(`\u2014 found by Ablo's infrastructure. Apply the fixes above, then re-run ${import_picocolors6.default.bold("ablo connect --check")}.`)}
281554
+ `
281555
+ );
281556
+ process.exit(1);
281378
281557
  }
281379
281558
  async function runCheck() {
281380
281559
  const dbUrl = requireDatabaseUrl("--check");
281381
281560
  console.log(`
281382
281561
  ${brand("ablo")} ${import_picocolors6.default.dim("connect --check")} ${import_picocolors6.default.dim("logical-replication readiness")}
281383
281562
  `);
281384
- const failures = await probeAndReport(dbUrl);
281563
+ const outcome = await probeAndReport(dbUrl);
281564
+ if (outcome.kind === "no-dial") {
281565
+ return runRemoteCheck(dbUrl, outcome.reason);
281566
+ }
281567
+ const failures = outcome.failures;
281385
281568
  console.log();
281386
281569
  if (failures === 0) {
281387
281570
  console.log(` ${import_picocolors6.default.green("\u2713")} Ready \u2014 Ablo can connect and tail this database's WAL.
@@ -281409,11 +281592,18 @@ async function runRegister() {
281409
281592
  console.log(`
281410
281593
  ${brand("ablo")} ${import_picocolors6.default.dim("connect --register")} ${import_picocolors6.default.dim("register this database for replication")}
281411
281594
  `);
281412
- const failures = await probeAndReport(dbUrl);
281413
- if (failures > 0) {
281595
+ const outcome = await probeAndReport(dbUrl);
281596
+ if (outcome.kind === "no-dial") {
281597
+ console.log(
281598
+ ` This machine can't reach the database (${import_picocolors6.default.dim(outcome.reason)}) \u2014 continuing anyway.
281599
+ Replication runs from Ablo's infrastructure, which validates the database during
281600
+ registration and refuses with the full checklist if it isn't ready.
281601
+ `
281602
+ );
281603
+ } else if (outcome.failures > 0) {
281414
281604
  console.log(
281415
281605
  `
281416
- ${import_picocolors6.default.red(`${failures} item${failures === 1 ? "" : "s"} to fix`)} ${import_picocolors6.default.dim("\u2014 a database that isn\u2019t replication-ready can\u2019t stream. Fix the above, then re-run.")}
281606
+ ${import_picocolors6.default.red(`${outcome.failures} item${outcome.failures === 1 ? "" : "s"} to fix`)} ${import_picocolors6.default.dim("\u2014 a database that isn\u2019t replication-ready can\u2019t stream. Fix the above, then re-run.")}
281417
281607
  `
281418
281608
  );
281419
281609
  process.exit(1);
@@ -281536,7 +281726,8 @@ var CONNECT_USAGE = ` ablo connect \u2014 connect your database for the read pa
281536
281726
  npx ablo connect Print the exact setup SQL for your Postgres
281537
281727
  npx ablo connect --tables a,b,c Publish only these tables (default: all tables)
281538
281728
  npx ablo connect --role <name> Name the replication role (default: ablo_replicator)
281539
- npx ablo connect --check Validate DATABASE_URL is replication-ready
281729
+ npx ablo connect --check Validate DATABASE_URL is replication-ready (runs from Ablo's
281730
+ infrastructure when this machine can't reach the host)
281540
281731
  npx ablo connect --register Register DATABASE_URL so Ablo replicates it (one self-service step)
281541
281732
  npx ablo connect --audit-infra Read-only Stage 5 audit for deprecated Ablo sync tables/types`;
281542
281733
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.29.1",
3
+ "version": "0.29.3",
4
4
  "description": "The Collaboration Layer For AI Agents",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",