@abloatai/cli 0.46.0 → 0.47.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.
- package/dist/cli.cjs +302 -96
- package/package.json +3 -3
package/dist/cli.cjs
CHANGED
|
@@ -3755,7 +3755,8 @@ async function confirmFromServer(opts) {
|
|
|
3755
3755
|
}
|
|
3756
3756
|
}
|
|
3757
3757
|
async function nameProject(projectId, organizationId, opts) {
|
|
3758
|
-
const
|
|
3758
|
+
const namingKey = resolveOrgManagementKey() ?? opts.apiKey;
|
|
3759
|
+
const listed = await listProjects(namingKey, opts.url);
|
|
3759
3760
|
const match = listed.ok ? listed.projects.find((p2) => p2.id === projectId) : void 0;
|
|
3760
3761
|
if (match) {
|
|
3761
3762
|
return { id: match.id, slug: match.slug, name: match.name, isDefault: match.default };
|
|
@@ -4098,6 +4099,10 @@ async function loadSchema(schemaPath, exportName) {
|
|
|
4098
4099
|
function maskKey(key) {
|
|
4099
4100
|
return key ? `${key.slice(0, 12)}\u2026` : "(none)";
|
|
4100
4101
|
}
|
|
4102
|
+
function schemaPushPlaneHint(code) {
|
|
4103
|
+
if (code !== "test_database_not_registered") return null;
|
|
4104
|
+
return `The key authenticated and already passed ${import_picocolors5.default.bold("schema:push")} authorization. This is a branch storage-provisioning error, not a key-scope error. Check the plane with ${import_picocolors5.default.bold("npx ablo branch status <branch>")}; a branch reported as hosted should not need a customer database.`;
|
|
4105
|
+
}
|
|
4101
4106
|
function schemaGitState(schemaPath) {
|
|
4102
4107
|
try {
|
|
4103
4108
|
const out = (0, import_child_process.execFileSync)("git", ["status", "--porcelain", "--", schemaPath], {
|
|
@@ -4406,7 +4411,10 @@ async function push(argv) {
|
|
|
4406
4411
|
const serverMsg = body.message ?? body.reason;
|
|
4407
4412
|
console.error(import_picocolors5.default.red(` Forbidden${code ? ` [${code}]` : ""}: ${serverMsg ?? "permission denied"}`));
|
|
4408
4413
|
console.error(import_picocolors5.default.dim(` Push used ${import_picocolors5.default.bold(maskKey(args.apiKey))} from ${describeKeySource(keySource)}.`));
|
|
4409
|
-
|
|
4414
|
+
const planeHint = schemaPushPlaneHint(code);
|
|
4415
|
+
if (planeHint) {
|
|
4416
|
+
console.error(import_picocolors5.default.dim(` ${planeHint}`));
|
|
4417
|
+
} else if (code === "database_role_cannot_enforce_rls") {
|
|
4410
4418
|
console.error(
|
|
4411
4419
|
import_picocolors5.default.dim(
|
|
4412
4420
|
` Your database role bypasses row-level security. Run ${import_picocolors5.default.bold("npx ablo migrate")} to create a scoped (NOBYPASSRLS) role and repoint DATABASE_URL, then re-push.`
|
|
@@ -4566,11 +4574,13 @@ var init_remoteValidation = __esm({
|
|
|
4566
4574
|
wal_level: () => `your database isn't set up to share changes as they happen yet`,
|
|
4567
4575
|
publication: () => `none of your tables are shared with Ablo yet`,
|
|
4568
4576
|
replication_role: () => `the login Ablo reads with can't follow your changes yet`,
|
|
4577
|
+
replication_slot_capacity: (f) => withActual(`this database has no remaining change-stream capacity for this binding`, f.actual),
|
|
4569
4578
|
replica_identity: (f) => withActual(
|
|
4570
4579
|
`some shared tables don't record enough for Ablo to track edits and deletes`,
|
|
4571
4580
|
f.actual
|
|
4572
4581
|
),
|
|
4573
4582
|
table_select: (f) => withActual(`the login Ablo reads with can't read some shared tables`, f.actual),
|
|
4583
|
+
snapshot_row_security: (f) => withActual(`row-level security hides historical rows from Ablo's initial load`, f.actual),
|
|
4574
4584
|
write_role: () => `the login Ablo writes with isn't set up yet`,
|
|
4575
4585
|
row_security: () => `the writer login isn't set to honor your row-level security`,
|
|
4576
4586
|
database_privileges: () => `the writer login can still create things in your database`,
|
|
@@ -4670,7 +4680,7 @@ function quoteIdent(id) {
|
|
|
4670
4680
|
function quoteLiteral(value) {
|
|
4671
4681
|
return `'${value.replace(/'/g, "''")}'`;
|
|
4672
4682
|
}
|
|
4673
|
-
function scopedSequenceGrant(tables, writeRole) {
|
|
4683
|
+
function scopedSequenceGrant(tables, writeRole, schema) {
|
|
4674
4684
|
const names = tables.map(quoteLiteral).join(", ");
|
|
4675
4685
|
return `DO $$
|
|
4676
4686
|
DECLARE seq regclass;
|
|
@@ -4681,7 +4691,7 @@ BEGIN
|
|
|
4681
4691
|
JOIN pg_class s ON s.oid = d.objid AND s.relkind = 'S'
|
|
4682
4692
|
JOIN pg_class t ON t.oid = d.refobjid
|
|
4683
4693
|
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
4684
|
-
WHERE d.deptype IN ('a', 'i') AND n.nspname =
|
|
4694
|
+
WHERE d.deptype IN ('a', 'i') AND n.nspname = ${quoteLiteral(schema)} AND t.relname IN (${names})
|
|
4685
4695
|
LOOP
|
|
4686
4696
|
EXECUTE format('GRANT USAGE, SELECT ON SEQUENCE %s TO ${quoteIdent(writeRole)}', seq);
|
|
4687
4697
|
END LOOP;
|
|
@@ -4691,24 +4701,29 @@ function connectSetupSql(input) {
|
|
|
4691
4701
|
const role = input.role && input.role.length > 0 ? input.role : import_footprint.ABLO_REPLICATION_ROLE;
|
|
4692
4702
|
const writeRole = input.writeRole && input.writeRole.length > 0 ? input.writeRole : import_footprint.ABLO_WRITE_ROLE;
|
|
4693
4703
|
const tables = input.tables ?? [];
|
|
4694
|
-
const
|
|
4695
|
-
const
|
|
4704
|
+
const schema = input.schema ?? "public";
|
|
4705
|
+
const publication = input.publication ?? import_footprint.ABLO_PUBLICATION;
|
|
4706
|
+
const qualifiedTables = tables.map((table) => `${quoteIdent(schema)}.${quoteIdent(table)}`);
|
|
4707
|
+
const publicationTarget = tables.length > 0 ? `FOR TABLE ${qualifiedTables.join(", ")}` : "FOR ALL TABLES";
|
|
4708
|
+
const tableList = qualifiedTables.join(", ");
|
|
4696
4709
|
const scoped = tables.length > 0;
|
|
4697
|
-
const applicationGrant = scoped ? `GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE ${tableList} TO ${quoteIdent(writeRole)};` : `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA
|
|
4710
|
+
const applicationGrant = scoped ? `GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE ${tableList} TO ${quoteIdent(writeRole)};` : `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ${quoteIdent(schema)} TO ${quoteIdent(writeRole)};`;
|
|
4698
4711
|
const replicationReadGrants = scoped ? [`GRANT SELECT ON TABLE ${tableList} TO ${quoteIdent(role)};`] : [
|
|
4699
|
-
`GRANT SELECT ON ALL TABLES IN SCHEMA
|
|
4700
|
-
`ALTER DEFAULT PRIVILEGES IN SCHEMA
|
|
4712
|
+
`GRANT SELECT ON ALL TABLES IN SCHEMA ${quoteIdent(schema)} TO ${quoteIdent(role)};`,
|
|
4713
|
+
`ALTER DEFAULT PRIVILEGES IN SCHEMA ${quoteIdent(schema)} GRANT SELECT ON TABLES TO ${quoteIdent(role)};`
|
|
4701
4714
|
];
|
|
4702
|
-
const writerSequenceGrants = scoped ? [scopedSequenceGrant(tables, writeRole)] : [`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA
|
|
4703
|
-
const ledger = (0, import_source2.idempotencyLedgerMigrations)().map((migration) => migration.up);
|
|
4715
|
+
const writerSequenceGrants = scoped ? [scopedSequenceGrant(tables, writeRole, schema)] : [`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${quoteIdent(schema)} TO ${quoteIdent(writeRole)};`];
|
|
4716
|
+
const ledger = (0, import_source2.idempotencyLedgerMigrations)(schema).map((migration) => migration.up);
|
|
4704
4717
|
return [
|
|
4705
4718
|
// 1. Turn on logical decoding. Requires a restart (it's not reloadable).
|
|
4706
4719
|
`ALTER SYSTEM SET wal_level = 'logical';`,
|
|
4707
4720
|
// 2. Publish the tables Ablo should read.
|
|
4708
|
-
`CREATE PUBLICATION ${quoteIdent(
|
|
4721
|
+
`CREATE PUBLICATION ${quoteIdent(publication)} ${publicationTarget};`,
|
|
4709
4722
|
// 3. A least-privilege role: it can stream replication and SELECT the
|
|
4710
|
-
// published tables,
|
|
4711
|
-
|
|
4723
|
+
// published tables, including the initial snapshot of RLS-protected tables.
|
|
4724
|
+
// Logical decoding already exposes every published row independently of
|
|
4725
|
+
// RLS; BYPASSRLS makes the ordinary SELECT snapshot match that same scope.
|
|
4726
|
+
`CREATE ROLE ${quoteIdent(role)} WITH NOSUPERUSER BYPASSRLS NOCREATEDB NOCREATEROLE REPLICATION NOINHERIT LOGIN PASSWORD '<password>';`,
|
|
4712
4727
|
...replicationReadGrants,
|
|
4713
4728
|
// 4. A distinct DML role: no replication, role administration, ownership,
|
|
4714
4729
|
// schema creation, or DDL. It runs NOBYPASSRLS with row_security on, so on a
|
|
@@ -4729,20 +4744,20 @@ function connectSetupSql(input) {
|
|
|
4729
4744
|
DO $$ BEGIN
|
|
4730
4745
|
EXECUTE format('REVOKE TEMPORARY, CREATE ON DATABASE %I FROM PUBLIC', current_database());
|
|
4731
4746
|
END $$;`,
|
|
4732
|
-
`GRANT USAGE ON SCHEMA
|
|
4747
|
+
`GRANT USAGE ON SCHEMA ${quoteIdent(schema)} TO ${quoteIdent(writeRole)};`,
|
|
4733
4748
|
applicationGrant,
|
|
4734
4749
|
...writerSequenceGrants,
|
|
4735
4750
|
...scoped ? [] : [
|
|
4736
4751
|
// "All tables" mode only: keep future tables/sequences writable so the
|
|
4737
4752
|
// publication doesn't outgrow the grant.
|
|
4738
|
-
`ALTER DEFAULT PRIVILEGES IN SCHEMA
|
|
4739
|
-
`ALTER DEFAULT PRIVILEGES IN SCHEMA
|
|
4753
|
+
`ALTER DEFAULT PRIVILEGES IN SCHEMA ${quoteIdent(schema)} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${quoteIdent(writeRole)};`,
|
|
4754
|
+
`ALTER DEFAULT PRIVILEGES IN SCHEMA ${quoteIdent(schema)} GRANT USAGE, SELECT ON SEQUENCES TO ${quoteIdent(writeRole)};`
|
|
4740
4755
|
],
|
|
4741
4756
|
// 5. Direct uses the durable replay ledger but deliberately no outbox.
|
|
4742
4757
|
...ledger,
|
|
4743
|
-
`REVOKE ALL ON TABLE
|
|
4744
|
-
`GRANT SELECT, INSERT, UPDATE ON TABLE
|
|
4745
|
-
`REVOKE DELETE ON TABLE
|
|
4758
|
+
`REVOKE ALL ON TABLE ${quoteIdent(schema)}.${quoteIdent("ablo_idempotency")} FROM PUBLIC;`,
|
|
4759
|
+
`GRANT SELECT, INSERT, UPDATE ON TABLE ${quoteIdent(schema)}.${quoteIdent("ablo_idempotency")} TO ${quoteIdent(writeRole)};`,
|
|
4760
|
+
`REVOKE DELETE ON TABLE ${quoteIdent(schema)}.${quoteIdent("ablo_idempotency")} FROM ${quoteIdent(writeRole)};`,
|
|
4746
4761
|
// The writer emits a transactional marker on your WAL so Ablo can correlate
|
|
4747
4762
|
// the committed row back to the originating write and confirm it — this
|
|
4748
4763
|
// EXECUTE grant is what makes that confirmation possible. Granted by lookup
|
|
@@ -4765,10 +4780,12 @@ BEGIN
|
|
|
4765
4780
|
END $$;`
|
|
4766
4781
|
];
|
|
4767
4782
|
}
|
|
4768
|
-
function reconcilePublicationPlan(current, desiredTables) {
|
|
4769
|
-
const pub = quoteIdent(import_footprint.ABLO_PUBLICATION);
|
|
4783
|
+
function reconcilePublicationPlan(current, desiredTables, opts = {}) {
|
|
4784
|
+
const pub = quoteIdent(opts.publication ?? import_footprint.ABLO_PUBLICATION);
|
|
4785
|
+
const schema = opts.schema ?? "public";
|
|
4786
|
+
const qualified = (table) => `${quoteIdent(schema)}.${quoteIdent(table)}`;
|
|
4770
4787
|
const desiredAll = desiredTables.length === 0;
|
|
4771
|
-
const target = desiredAll ? "FOR ALL TABLES" : `FOR TABLE ${desiredTables.map(
|
|
4788
|
+
const target = desiredAll ? "FOR ALL TABLES" : `FOR TABLE ${desiredTables.map(qualified).join(", ")}`;
|
|
4772
4789
|
if (!current.exists) {
|
|
4773
4790
|
return {
|
|
4774
4791
|
sql: [`CREATE PUBLICATION ${pub} ${target};`],
|
|
@@ -4794,28 +4811,31 @@ function reconcilePublicationPlan(current, desiredTables) {
|
|
|
4794
4811
|
return { sql: [], added: [], removed: [], recreated: false };
|
|
4795
4812
|
}
|
|
4796
4813
|
return {
|
|
4797
|
-
sql: [`ALTER PUBLICATION ${pub} SET TABLE ${desiredTables.map(
|
|
4814
|
+
sql: [`ALTER PUBLICATION ${pub} SET TABLE ${desiredTables.map(qualified).join(", ")};`],
|
|
4798
4815
|
added,
|
|
4799
4816
|
removed,
|
|
4800
4817
|
recreated: false
|
|
4801
4818
|
};
|
|
4802
4819
|
}
|
|
4803
|
-
async function readPublicationState(sql) {
|
|
4820
|
+
async function readPublicationState(sql, opts = {}) {
|
|
4821
|
+
const publication = opts.publication ?? import_footprint.ABLO_PUBLICATION;
|
|
4822
|
+
const schema = opts.schema ?? "public";
|
|
4804
4823
|
const pubRows = await sql.unsafe(
|
|
4805
4824
|
`SELECT puballtables FROM pg_publication WHERE pubname = $1`,
|
|
4806
|
-
[
|
|
4825
|
+
[publication]
|
|
4807
4826
|
);
|
|
4808
4827
|
const pubRow = pubRows[0];
|
|
4809
4828
|
if (!pubRow) return { exists: false, allTables: false, tables: [] };
|
|
4810
4829
|
if (pubRow.puballtables) return { exists: true, allTables: true, tables: [] };
|
|
4811
4830
|
const tableRows = await sql.unsafe(
|
|
4812
|
-
`SELECT tablename FROM pg_publication_tables WHERE pubname = $1 AND schemaname =
|
|
4813
|
-
[
|
|
4831
|
+
`SELECT tablename FROM pg_publication_tables WHERE pubname = $1 AND schemaname = $2 ORDER BY tablename`,
|
|
4832
|
+
[publication, schema]
|
|
4814
4833
|
);
|
|
4815
4834
|
return { exists: true, allTables: false, tables: tableRows.map((r2) => r2.tablename) };
|
|
4816
4835
|
}
|
|
4817
4836
|
async function probeReadiness(sql, opts = {}) {
|
|
4818
4837
|
const publication = opts.publication ?? import_footprint.ABLO_PUBLICATION;
|
|
4838
|
+
const schema = opts.schema ?? "public";
|
|
4819
4839
|
const coordinated = opts.coordinatedTables && opts.coordinatedTables.length > 0 ? new Set(opts.coordinatedTables) : null;
|
|
4820
4840
|
const items = [];
|
|
4821
4841
|
const walRows = await sql.unsafe(
|
|
@@ -4847,7 +4867,7 @@ On Neon enable Logical Replication in the project (Console \u2192 Settings \u219
|
|
|
4847
4867
|
}
|
|
4848
4868
|
);
|
|
4849
4869
|
const roleRows = await sql.unsafe(
|
|
4850
|
-
`SELECT rolreplication, rolsuper FROM pg_roles WHERE rolname = current_user`
|
|
4870
|
+
`SELECT rolreplication, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user`
|
|
4851
4871
|
);
|
|
4852
4872
|
const role = roleRows[0];
|
|
4853
4873
|
const hasReplication = Boolean(role && (role.rolreplication || role.rolsuper));
|
|
@@ -4863,12 +4883,31 @@ On RDS: GRANT rds_replication TO <your_role>;`
|
|
|
4863
4883
|
}
|
|
4864
4884
|
);
|
|
4865
4885
|
if (pubRows.length > 0) {
|
|
4886
|
+
const rlsRows = await sql.unsafe(
|
|
4887
|
+
`SELECT DISTINCT pt.tablename AS table_name
|
|
4888
|
+
FROM pg_publication_tables pt
|
|
4889
|
+
JOIN pg_namespace n ON n.nspname = pt.schemaname
|
|
4890
|
+
JOIN pg_class c ON c.relnamespace = n.oid AND c.relname = pt.tablename
|
|
4891
|
+
WHERE pt.pubname = $1 AND pt.schemaname = $2
|
|
4892
|
+
AND row_security_active(c.oid)
|
|
4893
|
+
ORDER BY table_name`,
|
|
4894
|
+
[publication, schema]
|
|
4895
|
+
);
|
|
4896
|
+
const rlsRelevant = coordinated ? rlsRows.filter((row) => coordinated.has(row.table_name)) : rlsRows;
|
|
4897
|
+
items.push(
|
|
4898
|
+
rlsRelevant.length === 0 ? { ok: true, label: "the initial snapshot can read every published row" } : {
|
|
4899
|
+
ok: false,
|
|
4900
|
+
label: `${rlsRelevant.length} published table${rlsRelevant.length === 1 ? "" : "s"} hide historical rows behind RLS`,
|
|
4901
|
+
fix: `ALTER ROLE current_user WITH BYPASSRLS;
|
|
4902
|
+
Logical replication already exposes every published row; this lets the ordinary initial SELECT read that same scope.`
|
|
4903
|
+
}
|
|
4904
|
+
);
|
|
4866
4905
|
const badRows = await sql.unsafe(
|
|
4867
4906
|
`SELECT c.relname AS table_name, c.relreplident
|
|
4868
4907
|
FROM pg_publication_tables pt
|
|
4869
4908
|
JOIN pg_class c ON c.relname = pt.tablename
|
|
4870
4909
|
JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = pt.schemaname
|
|
4871
|
-
WHERE pt.pubname = $1
|
|
4910
|
+
WHERE pt.pubname = $1 AND pt.schemaname = $2
|
|
4872
4911
|
AND (
|
|
4873
4912
|
c.relreplident = 'n'
|
|
4874
4913
|
OR (
|
|
@@ -4879,7 +4918,7 @@ On RDS: GRANT rds_replication TO <your_role>;`
|
|
|
4879
4918
|
)
|
|
4880
4919
|
)
|
|
4881
4920
|
)`,
|
|
4882
|
-
[publication]
|
|
4921
|
+
[publication, schema]
|
|
4883
4922
|
);
|
|
4884
4923
|
const relevant = coordinated ? badRows.filter((row) => coordinated.has(row.table_name)) : badRows;
|
|
4885
4924
|
items.push(
|
|
@@ -4887,7 +4926,7 @@ On RDS: GRANT rds_replication TO <your_role>;`
|
|
|
4887
4926
|
ok: false,
|
|
4888
4927
|
label: `${relevant.length} published table${relevant.length === 1 ? "" : "s"} cannot replicate UPDATE/DELETE`,
|
|
4889
4928
|
fix: relevant.map(
|
|
4890
|
-
(r2) => `${r2.table_name}: add a PRIMARY KEY, or ALTER TABLE ${quoteIdent(r2.table_name)} REPLICA IDENTITY FULL;`
|
|
4929
|
+
(r2) => `${r2.table_name}: add a PRIMARY KEY, or ALTER TABLE ${quoteIdent(schema)}.${quoteIdent(r2.table_name)} REPLICA IDENTITY FULL;`
|
|
4891
4930
|
).join("\n")
|
|
4892
4931
|
}
|
|
4893
4932
|
);
|
|
@@ -4904,7 +4943,10 @@ async function registerDirectDataSource(opts) {
|
|
|
4904
4943
|
connection: "direct",
|
|
4905
4944
|
connectionString: opts.replicationUrl,
|
|
4906
4945
|
writeConnectionString: opts.writeUrl,
|
|
4907
|
-
route: opts.route
|
|
4946
|
+
route: opts.route,
|
|
4947
|
+
...opts.schema ? { schema: opts.schema } : {},
|
|
4948
|
+
...opts.replicationSlot ? { replicationSlot: opts.replicationSlot } : {},
|
|
4949
|
+
...opts.publication ? { publication: opts.publication } : {}
|
|
4908
4950
|
},
|
|
4909
4951
|
responseSchema: import_wire3.datasourceSummarySchema
|
|
4910
4952
|
});
|
|
@@ -5218,26 +5260,27 @@ function ownershipRemediation(blockers2, admin) {
|
|
|
5218
5260
|
const unresolved = blockers2.filter((blocker) => !resolvedOwners.has(blocker.owner));
|
|
5219
5261
|
return { inheritGrants, unresolved };
|
|
5220
5262
|
}
|
|
5221
|
-
async function publishedTableBlockers(sql, tables) {
|
|
5263
|
+
async function publishedTableBlockers(sql, tables, schema = "public") {
|
|
5222
5264
|
const scoped = tables.length > 0;
|
|
5223
5265
|
const raw = await sql.unsafe(
|
|
5224
5266
|
`SELECT format('%I.%I', n.nspname, c.relname) AS relation, ${OWNERSHIP_COLUMNS}
|
|
5225
5267
|
${OWNERSHIP_FROM}
|
|
5226
5268
|
WHERE c.relkind = 'r'
|
|
5227
|
-
AND n.nspname =
|
|
5269
|
+
AND n.nspname = $${scoped ? "2" : "1"}
|
|
5228
5270
|
AND c.relname <> 'ablo_idempotency'
|
|
5229
5271
|
${scoped ? "AND c.relname = ANY($1)" : ""}`,
|
|
5230
|
-
scoped ? [tables] : []
|
|
5272
|
+
scoped ? [tables, schema] : [schema]
|
|
5231
5273
|
);
|
|
5232
5274
|
return ownershipBlockers(import_zod3.z.array(ownedRelationRowSchema).parse(raw));
|
|
5233
5275
|
}
|
|
5234
|
-
async function ledgerBlocker(sql) {
|
|
5276
|
+
async function ledgerBlocker(sql, schema = "public") {
|
|
5235
5277
|
const raw = await sql.unsafe(
|
|
5236
5278
|
`SELECT format('%I.%I', n.nspname, c.relname) AS relation, ${OWNERSHIP_COLUMNS}
|
|
5237
5279
|
${OWNERSHIP_FROM}
|
|
5238
5280
|
WHERE c.relkind = 'r'
|
|
5239
|
-
AND n.nspname =
|
|
5240
|
-
AND c.relname = 'ablo_idempotency'
|
|
5281
|
+
AND n.nspname = $1
|
|
5282
|
+
AND c.relname = 'ablo_idempotency'`,
|
|
5283
|
+
[schema]
|
|
5241
5284
|
);
|
|
5242
5285
|
const rows = import_zod3.z.array(ownedRelationRowSchema).parse(raw);
|
|
5243
5286
|
const row = rows[0];
|
|
@@ -5473,13 +5516,15 @@ function connectApplyPlan(input) {
|
|
|
5473
5516
|
const role = input.role && input.role.length > 0 ? input.role : import_footprint.ABLO_REPLICATION_ROLE;
|
|
5474
5517
|
const writeRole = input.writeRole && input.writeRole.length > 0 ? input.writeRole : import_footprint.ABLO_WRITE_ROLE;
|
|
5475
5518
|
const tables = input.tables ?? [];
|
|
5519
|
+
const schema = input.schema ?? "public";
|
|
5520
|
+
const publication = input.publication ?? import_footprint.ABLO_PUBLICATION;
|
|
5476
5521
|
const provider = input.provider ?? "generic";
|
|
5477
|
-
const recipe = connectSetupSql({ tables, role, writeRole });
|
|
5522
|
+
const recipe = connectSetupSql({ tables, role, writeRole, schema, publication });
|
|
5478
5523
|
const isWal = (s) => s.startsWith("ALTER SYSTEM SET wal_level");
|
|
5479
5524
|
const isPublication = (s) => s.startsWith("CREATE PUBLICATION");
|
|
5480
5525
|
const isRoleCreate = (s) => s.startsWith("CREATE ROLE ");
|
|
5481
5526
|
const grants = recipe.filter((s) => !isWal(s) && !isPublication(s) && !isRoleCreate(s));
|
|
5482
|
-
const publicationTarget = tables.length > 0 ? `FOR TABLE ${tables.map(quoteIdent).join(", ")}` : "FOR ALL TABLES";
|
|
5527
|
+
const publicationTarget = tables.length > 0 ? `FOR TABLE ${tables.map((table) => `${quoteIdent(schema)}.${quoteIdent(table)}`).join(", ")}` : "FOR ALL TABLES";
|
|
5483
5528
|
const affectsOthers = effectsOnOthers({ grants, allTables: tables.length === 0 });
|
|
5484
5529
|
const walStep = input.walAlreadyLogical ? [] : [
|
|
5485
5530
|
provider === "generic" ? {
|
|
@@ -5494,10 +5539,10 @@ function connectApplyPlan(input) {
|
|
|
5494
5539
|
sql: []
|
|
5495
5540
|
}
|
|
5496
5541
|
];
|
|
5497
|
-
const reconcile2 = input.existingPublication ? reconcilePublicationPlan(input.existingPublication, tables) : null;
|
|
5542
|
+
const reconcile2 = input.existingPublication ? reconcilePublicationPlan(input.existingPublication, tables, { schema, publication }) : null;
|
|
5498
5543
|
const publicationSql = reconcile2 ? reconcile2.sql : [
|
|
5499
5544
|
`DO $$ BEGIN
|
|
5500
|
-
CREATE PUBLICATION ${quoteIdent(
|
|
5545
|
+
CREATE PUBLICATION ${quoteIdent(publication)} ${publicationTarget};
|
|
5501
5546
|
EXCEPTION WHEN duplicate_object THEN NULL;
|
|
5502
5547
|
END $$;`
|
|
5503
5548
|
];
|
|
@@ -5522,14 +5567,19 @@ END $$;`
|
|
|
5522
5567
|
{
|
|
5523
5568
|
key: "replication-role",
|
|
5524
5569
|
title: "Create the read-only login Ablo reads with",
|
|
5525
|
-
detail: `${role} \u2014 it can follow your changes and
|
|
5570
|
+
detail: `${role} \u2014 it can follow your changes and snapshot the same published rows, including through RLS`,
|
|
5526
5571
|
sql: [
|
|
5527
5572
|
idempotentRole(
|
|
5528
5573
|
role,
|
|
5529
|
-
"REPLICATION",
|
|
5574
|
+
"NOSUPERUSER BYPASSRLS NOCREATEDB NOCREATEROLE REPLICATION NOINHERIT",
|
|
5530
5575
|
input.credentials.replicationClause,
|
|
5531
5576
|
input.rotate === true
|
|
5532
|
-
)
|
|
5577
|
+
),
|
|
5578
|
+
// Unlike a password, this is a required invariant rather than secret
|
|
5579
|
+
// material owned by one plane. Re-assert it for an existing pre-snapshot
|
|
5580
|
+
// role so `connect apply` repairs the exact upgrade gap that otherwise
|
|
5581
|
+
// certifies an RLS-filtered empty snapshot as complete.
|
|
5582
|
+
`ALTER ROLE ${quoteIdent(role)} WITH BYPASSRLS;`
|
|
5533
5583
|
]
|
|
5534
5584
|
},
|
|
5535
5585
|
{
|
|
@@ -5691,16 +5741,23 @@ async function locateExistingConnection(input) {
|
|
|
5691
5741
|
method: "POST",
|
|
5692
5742
|
baseUrl: input.apiUrl,
|
|
5693
5743
|
apiKey: input.apiKey,
|
|
5694
|
-
body: {
|
|
5744
|
+
body: {
|
|
5745
|
+
connectionString: input.connectionString,
|
|
5746
|
+
...input.schema ? { schema: input.schema } : {}
|
|
5747
|
+
},
|
|
5695
5748
|
responseSchema: import_wire6.datasourceLocationResponseSchema
|
|
5696
5749
|
});
|
|
5697
5750
|
if (!result.ok) return null;
|
|
5698
|
-
return result.value.held;
|
|
5751
|
+
if (result.value.held) return result.value.held;
|
|
5752
|
+
return result.value.available === false ? { private: true } : null;
|
|
5699
5753
|
}
|
|
5700
5754
|
function alreadyConnectedElsewhere(held) {
|
|
5701
5755
|
if (!held) return null;
|
|
5756
|
+
if ("private" in held) {
|
|
5757
|
+
return "This database schema is already connected to another Ablo organization. Ablo does not reveal that organization\u2019s project or branch.";
|
|
5758
|
+
}
|
|
5702
5759
|
const where = held.project ? `project ${held.project}, branch ${held.branch}` : `branch ${held.branch}`;
|
|
5703
|
-
return `This database is already connected to ${where}.
|
|
5760
|
+
return `This database schema is already connected to ${where}. A (database, schema) binding belongs to one plane at a time.`;
|
|
5704
5761
|
}
|
|
5705
5762
|
var import_wire6, import_schema5;
|
|
5706
5763
|
var init_connectPreflight = __esm({
|
|
@@ -5849,12 +5906,18 @@ ${ambient}` : ""}`,
|
|
|
5849
5906
|
)
|
|
5850
5907
|
);
|
|
5851
5908
|
}
|
|
5852
|
-
const tables = args.tables;
|
|
5853
5909
|
const coordinatedTables = await schemaDeclaredTables() ?? [];
|
|
5910
|
+
const tables = args.tables.length > 0 ? args.tables : coordinatedTables;
|
|
5911
|
+
if (tables.length === 0) {
|
|
5912
|
+
throw new import_errors9.AbloValidationError(
|
|
5913
|
+
`No mapped tables were found for schema ${args.schema}. Push the Ablo schema first, or pass --tables a,b,c. A project binding must enumerate its own schema-qualified tables; it cannot publish every table in a shared database.`,
|
|
5914
|
+
{ code: "cli_invalid_arguments" }
|
|
5915
|
+
);
|
|
5916
|
+
}
|
|
5854
5917
|
if (args.tables.length === 0) {
|
|
5855
5918
|
console.log(
|
|
5856
5919
|
import_picocolors11.default.dim(
|
|
5857
|
-
` publishing
|
|
5920
|
+
` publishing the ${tables.length} table${tables.length === 1 ? "" : "s"} declared by your Ablo schema in ${import_picocolors11.default.bold(args.schema)} (${import_picocolors11.default.bold("--tables")} to override)
|
|
5858
5921
|
`
|
|
5859
5922
|
)
|
|
5860
5923
|
);
|
|
@@ -5869,8 +5932,28 @@ ${ambient}` : ""}`,
|
|
|
5869
5932
|
console.log(` ${import_picocolors11.default.yellow("!")} ${mismatch}
|
|
5870
5933
|
`);
|
|
5871
5934
|
}
|
|
5935
|
+
const confirmed = connectTarget?.confirmed;
|
|
5936
|
+
if (!confirmed?.branchId) {
|
|
5937
|
+
throw new import_errors9.AbloConnectionError(
|
|
5938
|
+
"Ablo could not confirm the branch this key targets, so it cannot derive an isolated database footprint safely. Check the API URL/key and re-run.",
|
|
5939
|
+
{ code: "cli_database_unreachable" }
|
|
5940
|
+
);
|
|
5941
|
+
}
|
|
5942
|
+
const footprint = (0, import_footprint2.footprintNamesFor)({
|
|
5943
|
+
organizationId: confirmed.organizationId,
|
|
5944
|
+
branchId: confirmed.branchId,
|
|
5945
|
+
...confirmed.projectId ? { projectId: confirmed.projectId } : {}
|
|
5946
|
+
});
|
|
5947
|
+
const role = args.role === import_footprint.ABLO_REPLICATION_ROLE ? footprint.replicationRole : args.role;
|
|
5948
|
+
const writeRole = args.writeRole === import_footprint.ABLO_WRITE_ROLE ? footprint.writeRole : args.writeRole;
|
|
5949
|
+
const publication = footprint.publication;
|
|
5872
5950
|
const heldElsewhere = alreadyConnectedElsewhere(
|
|
5873
|
-
await locateExistingConnection({
|
|
5951
|
+
await locateExistingConnection({
|
|
5952
|
+
apiUrl: apiBaseUrl(),
|
|
5953
|
+
apiKey,
|
|
5954
|
+
connectionString: adminUrl,
|
|
5955
|
+
schema: args.schema
|
|
5956
|
+
})
|
|
5874
5957
|
);
|
|
5875
5958
|
if (heldElsewhere) {
|
|
5876
5959
|
console.error(` ${import_picocolors11.default.yellow("!")} ${heldElsewhere}
|
|
@@ -5930,8 +6013,8 @@ ${ambient}` : ""}`,
|
|
|
5930
6013
|
);
|
|
5931
6014
|
process.exit(1);
|
|
5932
6015
|
}
|
|
5933
|
-
const ledger = await ledgerBlocker(admin).catch(() => null);
|
|
5934
|
-
const foreignTables = await publishedTableBlockers(admin, tables).catch(() => []);
|
|
6016
|
+
const ledger = await ledgerBlocker(admin, args.schema).catch(() => null);
|
|
6017
|
+
const foreignTables = await publishedTableBlockers(admin, tables, args.schema).catch(() => []);
|
|
5935
6018
|
const { inheritGrants, unresolved } = ownershipRemediation(
|
|
5936
6019
|
[...ledger ? [ledger] : [], ...foreignTables],
|
|
5937
6020
|
capability.rolname
|
|
@@ -5955,12 +6038,16 @@ ${ambient}` : ""}`,
|
|
|
5955
6038
|
);
|
|
5956
6039
|
process.exit(1);
|
|
5957
6040
|
}
|
|
5958
|
-
const existingPublication = await readPublicationState(admin
|
|
6041
|
+
const existingPublication = await readPublicationState(admin, {
|
|
6042
|
+
schema: args.schema,
|
|
6043
|
+
publication
|
|
6044
|
+
}).catch(
|
|
5959
6045
|
() => ({ exists: false, allTables: false, tables: [] })
|
|
5960
6046
|
);
|
|
5961
|
-
const pubReconcile = reconcilePublicationPlan(existingPublication, tables
|
|
5962
|
-
|
|
5963
|
-
|
|
6047
|
+
const pubReconcile = reconcilePublicationPlan(existingPublication, tables, {
|
|
6048
|
+
schema: args.schema,
|
|
6049
|
+
publication
|
|
6050
|
+
});
|
|
5964
6051
|
const existingRoles = await presentRoles(admin, [role, writeRole]).catch(() => []);
|
|
5965
6052
|
if (rotatePlane) {
|
|
5966
6053
|
const refusal = rotateWithoutConnection({ rotating, ...rotatePlane, existingRoles });
|
|
@@ -6001,8 +6088,10 @@ ${ambient}` : ""}`,
|
|
|
6001
6088
|
const writePassword = generateRolePassword();
|
|
6002
6089
|
const buildPlan = (mode) => connectApplyPlan({
|
|
6003
6090
|
tables,
|
|
6004
|
-
role
|
|
6005
|
-
writeRole
|
|
6091
|
+
role,
|
|
6092
|
+
writeRole,
|
|
6093
|
+
schema: args.schema,
|
|
6094
|
+
publication,
|
|
6006
6095
|
rotate: rotating,
|
|
6007
6096
|
credentials: {
|
|
6008
6097
|
replicationClause: passwordClause(replicationPassword, mode),
|
|
@@ -6016,7 +6105,7 @@ ${ambient}` : ""}`,
|
|
|
6016
6105
|
const steps = buildPlan("scram-verifier");
|
|
6017
6106
|
if (pubReconcile.removed.length > 0 || pubReconcile.recreated) {
|
|
6018
6107
|
console.log(
|
|
6019
|
-
` ${import_picocolors11.default.yellow("!")} ${import_picocolors11.default.bold(
|
|
6108
|
+
` ${import_picocolors11.default.yellow("!")} ${import_picocolors11.default.bold(publication)} already publishes a different set; reconciling to your mapped tables:`
|
|
6020
6109
|
);
|
|
6021
6110
|
for (const t of pubReconcile.added) console.log(` ${import_picocolors11.default.green("+")} ${t}`);
|
|
6022
6111
|
for (const t of pubReconcile.removed)
|
|
@@ -6093,9 +6182,12 @@ ${ambient}` : ""}`,
|
|
|
6093
6182
|
}
|
|
6094
6183
|
const replicationProbe = await probeAsRole(
|
|
6095
6184
|
replicationUrl,
|
|
6096
|
-
(sql) => probeReadiness(sql, { coordinatedTables })
|
|
6185
|
+
(sql) => probeReadiness(sql, { coordinatedTables, schema: args.schema, publication })
|
|
6186
|
+
);
|
|
6187
|
+
const writeProbe = await probeAsRole(
|
|
6188
|
+
writeUrl,
|
|
6189
|
+
(sql) => probeDirectWriteReadiness(sql, { schema: args.schema, publication })
|
|
6097
6190
|
);
|
|
6098
|
-
const writeProbe = await probeAsRole(writeUrl, probeDirectWriteReadiness);
|
|
6099
6191
|
const refused = [
|
|
6100
6192
|
...replicationProbe.credentialRefused ? [role] : [],
|
|
6101
6193
|
...writeProbe.credentialRefused ? [writeRole] : []
|
|
@@ -6150,7 +6242,10 @@ ${ambient}` : ""}`,
|
|
|
6150
6242
|
apiKey,
|
|
6151
6243
|
replicationUrl,
|
|
6152
6244
|
writeUrl,
|
|
6153
|
-
route: args.route
|
|
6245
|
+
route: args.route,
|
|
6246
|
+
schema: args.schema,
|
|
6247
|
+
replicationSlot: footprint.slot,
|
|
6248
|
+
publication
|
|
6154
6249
|
});
|
|
6155
6250
|
process.off("SIGINT", onRotateInterrupt);
|
|
6156
6251
|
process.off("SIGTERM", onRotateInterrupt);
|
|
@@ -6162,7 +6257,7 @@ ${ambient}` : ""}`,
|
|
|
6162
6257
|
}
|
|
6163
6258
|
process.exit(outcome.exitCode);
|
|
6164
6259
|
}
|
|
6165
|
-
var import_picocolors11, import_errors9, ROTATE_STRANDED_CREDENTIALS_NOTICE;
|
|
6260
|
+
var import_picocolors11, import_errors9, import_footprint2, ROTATE_STRANDED_CREDENTIALS_NOTICE;
|
|
6166
6261
|
var init_connectApply = __esm({
|
|
6167
6262
|
"src/connectApply.ts"() {
|
|
6168
6263
|
"use strict";
|
|
@@ -6171,6 +6266,7 @@ var init_connectApply = __esm({
|
|
|
6171
6266
|
init_src();
|
|
6172
6267
|
init_dist2();
|
|
6173
6268
|
import_errors9 = require("@abloatai/transaction/errors");
|
|
6269
|
+
import_footprint2 = require("@abloatai/transaction/footprint");
|
|
6174
6270
|
init_connectSetup();
|
|
6175
6271
|
init_connectOwnership();
|
|
6176
6272
|
init_connect();
|
|
@@ -6194,6 +6290,7 @@ function parseConnectArgs(argv) {
|
|
|
6194
6290
|
let register = false;
|
|
6195
6291
|
let apply = false;
|
|
6196
6292
|
let rotate = false;
|
|
6293
|
+
let resnapshot = false;
|
|
6197
6294
|
let url;
|
|
6198
6295
|
let envFile;
|
|
6199
6296
|
let yes = false;
|
|
@@ -6202,6 +6299,7 @@ function parseConnectArgs(argv) {
|
|
|
6202
6299
|
let locate = false;
|
|
6203
6300
|
let manual = false;
|
|
6204
6301
|
let tables = [];
|
|
6302
|
+
let schema = "public";
|
|
6205
6303
|
let role = import_footprint.ABLO_REPLICATION_ROLE;
|
|
6206
6304
|
let writeRole = import_footprint.ABLO_WRITE_ROLE;
|
|
6207
6305
|
let route = "public-allowlist";
|
|
@@ -6221,6 +6319,9 @@ function parseConnectArgs(argv) {
|
|
|
6221
6319
|
case "rotate":
|
|
6222
6320
|
rotate = true;
|
|
6223
6321
|
break;
|
|
6322
|
+
case "resnapshot":
|
|
6323
|
+
resnapshot = true;
|
|
6324
|
+
break;
|
|
6224
6325
|
case "scan":
|
|
6225
6326
|
scan = true;
|
|
6226
6327
|
break;
|
|
@@ -6229,7 +6330,7 @@ function parseConnectArgs(argv) {
|
|
|
6229
6330
|
break;
|
|
6230
6331
|
default:
|
|
6231
6332
|
throw new import_errors10.AbloValidationError(
|
|
6232
|
-
`unknown connect subcommand: ${lead} (expected register, deregister, check, apply, rotate, scan, locate)`,
|
|
6333
|
+
`unknown connect subcommand: ${lead} (expected register, deregister, check, apply, rotate, resnapshot, scan, locate)`,
|
|
6233
6334
|
{ code: "cli_invalid_arguments" }
|
|
6234
6335
|
);
|
|
6235
6336
|
}
|
|
@@ -6259,6 +6360,9 @@ function parseConnectArgs(argv) {
|
|
|
6259
6360
|
tables = value.split(",").map((t) => t.trim()).filter((t) => t.length > 0);
|
|
6260
6361
|
break;
|
|
6261
6362
|
}
|
|
6363
|
+
case "--schema":
|
|
6364
|
+
schema = argv[++i] ?? schema;
|
|
6365
|
+
break;
|
|
6262
6366
|
case "--role":
|
|
6263
6367
|
role = argv[++i] ?? role;
|
|
6264
6368
|
break;
|
|
@@ -6290,6 +6394,7 @@ function parseConnectArgs(argv) {
|
|
|
6290
6394
|
register,
|
|
6291
6395
|
apply,
|
|
6292
6396
|
rotate,
|
|
6397
|
+
resnapshot,
|
|
6293
6398
|
url,
|
|
6294
6399
|
envFile,
|
|
6295
6400
|
yes,
|
|
@@ -6297,6 +6402,7 @@ function parseConnectArgs(argv) {
|
|
|
6297
6402
|
scan,
|
|
6298
6403
|
locate,
|
|
6299
6404
|
tables,
|
|
6405
|
+
schema,
|
|
6300
6406
|
role,
|
|
6301
6407
|
writeRole,
|
|
6302
6408
|
route,
|
|
@@ -6307,7 +6413,8 @@ function printConnectRecipe(args) {
|
|
|
6307
6413
|
const sql = connectSetupSql({
|
|
6308
6414
|
tables: args.tables,
|
|
6309
6415
|
role: args.role,
|
|
6310
|
-
writeRole: args.writeRole
|
|
6416
|
+
writeRole: args.writeRole,
|
|
6417
|
+
schema: args.schema
|
|
6311
6418
|
});
|
|
6312
6419
|
console.log(
|
|
6313
6420
|
`
|
|
@@ -6526,16 +6633,17 @@ async function probeDirectWriteReadiness(sql, opts = {}) {
|
|
|
6526
6633
|
);
|
|
6527
6634
|
return items;
|
|
6528
6635
|
}
|
|
6529
|
-
async function auditTenantSyncInfra(sql) {
|
|
6636
|
+
async function auditTenantSyncInfra(sql, opts = {}) {
|
|
6530
6637
|
const artifacts = [];
|
|
6531
|
-
for (const artifact of
|
|
6532
|
-
const
|
|
6638
|
+
for (const artifact of import_footprint3.ABLO_FOOTPRINT) {
|
|
6639
|
+
const name = opts.names ? artifact.name === import_footprint.ABLO_PUBLICATION ? opts.names.publication : artifact.name === import_footprint3.ABLO_REPLICATION_SLOT ? opts.names.slot : artifact.name === import_footprint.ABLO_REPLICATION_ROLE ? opts.names.replicationRole : artifact.name === import_footprint.ABLO_WRITE_ROLE ? opts.names.writeRole : artifact.name : artifact.name;
|
|
6640
|
+
const key = artifact.kind === "table" || artifact.kind === "type" ? `${artifact.retired ? "public" : opts.schema ?? "public"}.${name}` : name;
|
|
6533
6641
|
const rows = await sql.unsafe(FOOTPRINT_LOOKUP[artifact.kind], [
|
|
6534
6642
|
key
|
|
6535
6643
|
]);
|
|
6536
6644
|
artifacts.push({
|
|
6537
6645
|
kind: artifact.kind,
|
|
6538
|
-
name
|
|
6646
|
+
name,
|
|
6539
6647
|
present: rows[0]?.present === true,
|
|
6540
6648
|
purpose: artifact.purpose,
|
|
6541
6649
|
...artifact.hazard ? { hazard: artifact.hazard } : {},
|
|
@@ -6564,12 +6672,12 @@ function requireScopedUrl(kind, verb) {
|
|
|
6564
6672
|
);
|
|
6565
6673
|
process.exit(1);
|
|
6566
6674
|
}
|
|
6567
|
-
async function probeAndReport(dbUrl, kind = "replication") {
|
|
6675
|
+
async function probeAndReport(dbUrl, kind = "replication", opts = {}) {
|
|
6568
6676
|
const sql = src_default(dbUrl, { max: 1, prepare: false, connect_timeout: 10, onnotice: () => {
|
|
6569
6677
|
} });
|
|
6570
6678
|
let items;
|
|
6571
6679
|
try {
|
|
6572
|
-
items = kind === "replication" ? await probeReadiness(sql) : await probeDirectWriteReadiness(sql);
|
|
6680
|
+
items = kind === "replication" ? await probeReadiness(sql, opts) : await probeDirectWriteReadiness(sql, opts);
|
|
6573
6681
|
} catch (err) {
|
|
6574
6682
|
await sql.end({ timeout: 2 }).catch(() => void 0);
|
|
6575
6683
|
const dial = dialFailureReason(err);
|
|
@@ -6699,11 +6807,11 @@ ${ambient}` : ""}`,
|
|
|
6699
6807
|
);
|
|
6700
6808
|
console.log(` ${import_picocolors12.default.bold("Replication role")}
|
|
6701
6809
|
`);
|
|
6702
|
-
const replication = await probeAndReport(dbUrl, "replication");
|
|
6810
|
+
const replication = await probeAndReport(dbUrl, "replication", { schema: args.schema });
|
|
6703
6811
|
console.log(`
|
|
6704
6812
|
${import_picocolors12.default.bold("Direct-write role")}
|
|
6705
6813
|
`);
|
|
6706
|
-
const write = await probeAndReport(writeDbUrl, "write");
|
|
6814
|
+
const write = await probeAndReport(writeDbUrl, "write", { schema: args.schema });
|
|
6707
6815
|
const noDial = [
|
|
6708
6816
|
replication.kind === "no-dial" ? `replication: ${replication.reason}` : null,
|
|
6709
6817
|
write.kind === "no-dial" ? `write: ${write.reason}` : null
|
|
@@ -6731,17 +6839,35 @@ ${ambient}` : ""}`,
|
|
|
6731
6839
|
apiKey,
|
|
6732
6840
|
replicationUrl: dbUrl,
|
|
6733
6841
|
writeUrl: writeDbUrl,
|
|
6734
|
-
route: args.route
|
|
6842
|
+
route: args.route,
|
|
6843
|
+
schema: args.schema
|
|
6735
6844
|
});
|
|
6736
6845
|
process.exit(registered ? 0 : 1);
|
|
6737
6846
|
}
|
|
6738
|
-
async function runScan() {
|
|
6847
|
+
async function runScan(args) {
|
|
6739
6848
|
const dbUrl = requireScopedUrl("replication", "scan");
|
|
6740
6849
|
const sql = src_default(dbUrl, { max: 1, prepare: false, onnotice: () => {
|
|
6741
6850
|
} });
|
|
6742
6851
|
let artifacts;
|
|
6743
6852
|
try {
|
|
6744
|
-
|
|
6853
|
+
const apiKey = resolveRuntimeApiKey().key;
|
|
6854
|
+
const target = apiKey ? await resolveTarget({ url: apiBaseUrl(), apiKey, keySource: "env" }).catch(() => null) : null;
|
|
6855
|
+
const confirmed = target?.confirmed;
|
|
6856
|
+
const names = confirmed?.branchId ? (0, import_footprint3.footprintNamesFor)({
|
|
6857
|
+
organizationId: confirmed.organizationId,
|
|
6858
|
+
branchId: confirmed.branchId,
|
|
6859
|
+
...confirmed.projectId ? { projectId: confirmed.projectId } : {}
|
|
6860
|
+
}) : void 0;
|
|
6861
|
+
const isolated = await auditTenantSyncInfra(sql, {
|
|
6862
|
+
schema: args.schema,
|
|
6863
|
+
...names ? { names } : {}
|
|
6864
|
+
});
|
|
6865
|
+
const legacy = names ? await auditTenantSyncInfra(sql, { schema: args.schema }) : [];
|
|
6866
|
+
artifacts = [...isolated, ...legacy].filter(
|
|
6867
|
+
(artifact, index, all) => all.findIndex(
|
|
6868
|
+
(candidate) => candidate.kind === artifact.kind && candidate.name === artifact.name
|
|
6869
|
+
) === index
|
|
6870
|
+
);
|
|
6745
6871
|
} catch (err) {
|
|
6746
6872
|
const pg = err ?? {};
|
|
6747
6873
|
await sql.end({ timeout: 2 });
|
|
@@ -6822,12 +6948,20 @@ ${ambient}` : ""}`,
|
|
|
6822
6948
|
path: "/v1/datasources/locate",
|
|
6823
6949
|
method: "POST",
|
|
6824
6950
|
apiKey,
|
|
6825
|
-
body: { connectionString: url },
|
|
6951
|
+
body: { connectionString: url, schema: args.schema },
|
|
6826
6952
|
responseSchema: import_wire7.datasourceLocationResponseSchema
|
|
6827
6953
|
});
|
|
6954
|
+
if (answer.available === false && !answer.held) {
|
|
6955
|
+
console.log(
|
|
6956
|
+
` ${import_picocolors12.default.yellow("!")} ${import_picocolors12.default.bold(label)} schema ${import_picocolors12.default.bold(args.schema)} is connected to another organization.
|
|
6957
|
+
` + import_picocolors12.default.dim(` Its project and branch are private. Use another schema or provider database URL.
|
|
6958
|
+
`)
|
|
6959
|
+
);
|
|
6960
|
+
return;
|
|
6961
|
+
}
|
|
6828
6962
|
if (!answer.held) {
|
|
6829
6963
|
console.log(
|
|
6830
|
-
` ${import_picocolors12.default.green("\u2713")} No plane holds ${import_picocolors12.default.bold(label)} \u2014 ${import_picocolors12.default.bold("ablo connect apply")} can register it here.
|
|
6964
|
+
` ${import_picocolors12.default.green("\u2713")} No plane holds ${import_picocolors12.default.bold(`${label}/${args.schema}`)} \u2014 ${import_picocolors12.default.bold("ablo connect apply")} can register it here.
|
|
6831
6965
|
`
|
|
6832
6966
|
);
|
|
6833
6967
|
return;
|
|
@@ -6838,13 +6972,50 @@ ${ambient}` : ""}`,
|
|
|
6838
6972
|
);
|
|
6839
6973
|
console.log(
|
|
6840
6974
|
import_picocolors12.default.dim(
|
|
6841
|
-
` Ablo
|
|
6975
|
+
` Ablo binds one plane to each database schema. To move this schema, disconnect it there
|
|
6842
6976
|
first \u2014 run `
|
|
6843
6977
|
) + import_picocolors12.default.cyan("ablo connect deregister") + import_picocolors12.default.dim(` with a key for that plane, then connect here.
|
|
6844
6978
|
`) + import_picocolors12.default.dim(` Confirm a candidate key first with `) + import_picocolors12.default.bold("ablo whoami --key-env <NAME>") + import_picocolors12.default.dim(`.
|
|
6845
6979
|
`) + import_picocolors12.default.dim(` Match the project id to a name with `) + import_picocolors12.default.bold("ablo projects list --json") + import_picocolors12.default.dim(".") + "\n"
|
|
6846
6980
|
);
|
|
6847
6981
|
}
|
|
6982
|
+
async function runResnapshot() {
|
|
6983
|
+
const apiKey = resolveMutationApiKey();
|
|
6984
|
+
if (!apiKey) {
|
|
6985
|
+
throw new import_errors10.AbloAuthenticationError(
|
|
6986
|
+
"No branch-bound secret key found. Set ABLO_API_KEY to the sk_ key for the branch to resnapshot.",
|
|
6987
|
+
{ code: "cli_api_key_missing" }
|
|
6988
|
+
);
|
|
6989
|
+
}
|
|
6990
|
+
console.log(
|
|
6991
|
+
`
|
|
6992
|
+
${brand("ablo")} ${import_picocolors12.default.dim("connect resnapshot")} ${import_picocolors12.default.dim("reload existing rows")}
|
|
6993
|
+
`
|
|
6994
|
+
);
|
|
6995
|
+
const result = await requestControlPlane({
|
|
6996
|
+
path: "/v1/datasources/resnapshot",
|
|
6997
|
+
method: "POST",
|
|
6998
|
+
apiKey,
|
|
6999
|
+
body: {},
|
|
7000
|
+
responseSchema: import_wire7.datasourceResnapshotResponseSchema
|
|
7001
|
+
});
|
|
7002
|
+
if (result.replication_slot?.released === false) {
|
|
7003
|
+
console.log(` ${import_picocolors12.default.yellow("\u2014")} Snapshot reset recorded, but the old slot is still active.`);
|
|
7004
|
+
if (result.replication_slot.detail) console.log(` ${import_picocolors12.default.dim(result.replication_slot.detail)}`);
|
|
7005
|
+
if (result.replication_slot.remove_with) {
|
|
7006
|
+
console.log(` ${import_picocolors12.default.dim("Remove it with:")} ${import_picocolors12.default.bold(result.replication_slot.remove_with)}`);
|
|
7007
|
+
}
|
|
7008
|
+
console.log(`
|
|
7009
|
+
Re-run ${import_picocolors12.default.bold("ablo connect resnapshot")} after releasing it.
|
|
7010
|
+
`);
|
|
7011
|
+
process.exit(1);
|
|
7012
|
+
}
|
|
7013
|
+
console.log(
|
|
7014
|
+
` ${import_picocolors12.default.green("\u2713")} Fresh snapshot requested. The DataSource and credentials were kept.
|
|
7015
|
+
${import_picocolors12.default.dim(`Run ${import_picocolors12.default.bold("ablo connect check")} until the existing-row load is complete.`)}
|
|
7016
|
+
`
|
|
7017
|
+
);
|
|
7018
|
+
}
|
|
6848
7019
|
async function connect(argv) {
|
|
6849
7020
|
if (argv[0] === "deregister") {
|
|
6850
7021
|
const { disconnect: disconnect2 } = await Promise.resolve().then(() => (init_disconnect(), disconnect_exports));
|
|
@@ -6871,12 +7042,16 @@ async function connect(argv) {
|
|
|
6871
7042
|
await runCheck();
|
|
6872
7043
|
return;
|
|
6873
7044
|
}
|
|
7045
|
+
if (args.resnapshot) {
|
|
7046
|
+
await runResnapshot();
|
|
7047
|
+
return;
|
|
7048
|
+
}
|
|
6874
7049
|
if (args.register) {
|
|
6875
7050
|
await runRegister(args);
|
|
6876
7051
|
return;
|
|
6877
7052
|
}
|
|
6878
7053
|
if (args.scan) {
|
|
6879
|
-
await runScan();
|
|
7054
|
+
await runScan(args);
|
|
6880
7055
|
return;
|
|
6881
7056
|
}
|
|
6882
7057
|
if (args.locate) {
|
|
@@ -6892,7 +7067,7 @@ async function connect(argv) {
|
|
|
6892
7067
|
}
|
|
6893
7068
|
printConnectRecipe(args);
|
|
6894
7069
|
}
|
|
6895
|
-
var import_errors10, import_picocolors12,
|
|
7070
|
+
var import_errors10, import_picocolors12, import_footprint3, import_wire7, FOOTPRINT_LOOKUP, CONNECT_USAGE;
|
|
6896
7071
|
var init_connect = __esm({
|
|
6897
7072
|
"src/connect.ts"() {
|
|
6898
7073
|
"use strict";
|
|
@@ -6900,12 +7075,13 @@ var init_connect = __esm({
|
|
|
6900
7075
|
import_errors10 = require("@abloatai/transaction/errors");
|
|
6901
7076
|
import_picocolors12 = __toESM(require_picocolors(), 1);
|
|
6902
7077
|
init_src();
|
|
6903
|
-
|
|
7078
|
+
import_footprint3 = require("@abloatai/transaction/footprint");
|
|
6904
7079
|
init_dbRole();
|
|
6905
7080
|
init_config();
|
|
6906
7081
|
init_controlPlane();
|
|
6907
7082
|
import_wire7 = require("@abloatai/transaction/wire");
|
|
6908
7083
|
init_theme();
|
|
7084
|
+
init_target();
|
|
6909
7085
|
init_remoteValidation();
|
|
6910
7086
|
init_connectSetup();
|
|
6911
7087
|
FOOTPRINT_LOOKUP = {
|
|
@@ -6927,6 +7103,7 @@ var init_connect = __esm({
|
|
|
6927
7103
|
npx ablo connect deregister Disconnect this project's database \u2014 Ablo stops reading and writing it
|
|
6928
7104
|
npx ablo connect check Confirm the connected database is ready, from Ablo's side (needs only ABLO_API_KEY)
|
|
6929
7105
|
npx ablo connect rotate New passwords for both logins, then re-register
|
|
7106
|
+
npx ablo connect resnapshot Recreate only the slot and reload existing rows
|
|
6930
7107
|
npx ablo connect scan List anything Ablo ever set up in your database (read-only, never drops)
|
|
6931
7108
|
npx ablo connect locate See which plane holds this database (read-only; nothing is changed)
|
|
6932
7109
|
|
|
@@ -6941,6 +7118,7 @@ var init_connect = __esm({
|
|
|
6941
7118
|
--url <admin-conn> Admin connection used once to set up (else DATABASE_URL); never stored
|
|
6942
7119
|
--env-file <path> Explicitly load DATABASE_URL and ABLO_API_KEY from this file
|
|
6943
7120
|
--tables a,b,c Publish only these tables (default: all tables)
|
|
7121
|
+
--schema <name> Bind this project to one database schema (default: public)
|
|
6944
7122
|
--role <name> Name the replication role (default: ablo_replicator)
|
|
6945
7123
|
--write-role <name> Name the DML role (default: ablo_writer)
|
|
6946
7124
|
--route <route> public-allowlist | privatelink | peering | vpn
|
|
@@ -283625,13 +283803,21 @@ function classifyKey(apiKey) {
|
|
|
283625
283803
|
reason: `${import_picocolors15.default.bold("ABLO_API_KEY")} is not a secret Ablo key. Expected a branch-bound ${import_picocolors15.default.bold("sk_\u2026")} credential. Run ${import_picocolors15.default.bold("npx ablo dev")} to prepare a development branch.`
|
|
283626
283804
|
};
|
|
283627
283805
|
}
|
|
283628
|
-
function wireEnvLocal(apiKey, cwd = process.cwd()) {
|
|
283806
|
+
function wireEnvLocal(apiKey, cwd = process.cwd(), projectId, branchId) {
|
|
283629
283807
|
const envPath = (0, import_path5.resolve)(cwd, ".env.local");
|
|
283630
283808
|
const line = `ABLO_API_KEY=${apiKey}`;
|
|
283809
|
+
const projectLine = projectId ? `ABLO_PROJECT_ID=${projectId}` : null;
|
|
283810
|
+
const branchLine = branchId ? `ABLO_BRANCH_ID=${branchId}` : null;
|
|
283631
283811
|
let action;
|
|
283632
283812
|
if (!(0, import_fs7.existsSync)(envPath)) {
|
|
283633
|
-
(0, import_fs7.writeFileSync)(
|
|
283634
|
-
|
|
283813
|
+
(0, import_fs7.writeFileSync)(
|
|
283814
|
+
envPath,
|
|
283815
|
+
`${line}
|
|
283816
|
+
${projectLine ? `${projectLine}
|
|
283817
|
+
` : ""}${branchLine ? `${branchLine}
|
|
283818
|
+
` : ""}`,
|
|
283819
|
+
{ mode: 384 }
|
|
283820
|
+
);
|
|
283635
283821
|
action = `Created ${import_picocolors15.default.bold(".env.local")} with ${import_picocolors15.default.bold("ABLO_API_KEY")}`;
|
|
283636
283822
|
} else {
|
|
283637
283823
|
const content = (0, import_fs7.readFileSync)(envPath, "utf8");
|
|
@@ -283647,6 +283833,24 @@ function wireEnvLocal(apiKey, cwd = process.cwd()) {
|
|
|
283647
283833
|
(0, import_fs7.writeFileSync)(envPath, content.replace(/^ABLO_API_KEY=.*$/m, line));
|
|
283648
283834
|
action = `Updated ${import_picocolors15.default.bold("ABLO_API_KEY")} in ${import_picocolors15.default.bold(".env.local")} ${import_picocolors15.default.dim(`(was ${existing.slice(0, 12)}\u2026)`)}`;
|
|
283649
283835
|
}
|
|
283836
|
+
if (projectLine) {
|
|
283837
|
+
const next = (0, import_fs7.readFileSync)(envPath, "utf8");
|
|
283838
|
+
if (/^ABLO_PROJECT_ID=.*$/m.test(next)) {
|
|
283839
|
+
(0, import_fs7.writeFileSync)(envPath, next.replace(/^ABLO_PROJECT_ID=.*$/m, projectLine));
|
|
283840
|
+
} else {
|
|
283841
|
+
(0, import_fs7.appendFileSync)(envPath, `${next.endsWith("\n") || next.length === 0 ? "" : "\n"}${projectLine}
|
|
283842
|
+
`);
|
|
283843
|
+
}
|
|
283844
|
+
}
|
|
283845
|
+
if (branchLine) {
|
|
283846
|
+
const next = (0, import_fs7.readFileSync)(envPath, "utf8");
|
|
283847
|
+
if (/^ABLO_BRANCH_ID=.*$/m.test(next)) {
|
|
283848
|
+
(0, import_fs7.writeFileSync)(envPath, next.replace(/^ABLO_BRANCH_ID=.*$/m, branchLine));
|
|
283849
|
+
} else {
|
|
283850
|
+
(0, import_fs7.appendFileSync)(envPath, `${next.endsWith("\n") || next.length === 0 ? "" : "\n"}${branchLine}
|
|
283851
|
+
`);
|
|
283852
|
+
}
|
|
283853
|
+
}
|
|
283650
283854
|
}
|
|
283651
283855
|
const gitignorePath = (0, import_path5.resolve)(cwd, ".gitignore");
|
|
283652
283856
|
const gitignore = (0, import_fs7.existsSync)(gitignorePath) ? (0, import_fs7.readFileSync)(gitignorePath, "utf8") : "";
|
|
@@ -283717,7 +283921,7 @@ async function runPush(schema, args) {
|
|
|
283717
283921
|
}
|
|
283718
283922
|
if (status2 === 403) {
|
|
283719
283923
|
const serverSays = body.message ?? body.reason;
|
|
283720
|
-
const hint = body.code === "database_role_cannot_enforce_rls" ? `Run ${import_picocolors15.default.bold("npx ablo migrate")} \u2014 it creates the scoped role for you (your DB credential never leaves this machine).` : `Schema authoring needs a branch-bound ${import_picocolors15.default.bold("sk_")} key with ${import_picocolors15.default.bold("schema:push")} \u2014 manage keys at ${import_picocolors15.default.cyan("https://abloatai.com")}
|
|
283924
|
+
const hint = schemaPushPlaneHint(body.code) ?? (body.code === "database_role_cannot_enforce_rls" ? `Run ${import_picocolors15.default.bold("npx ablo migrate")} \u2014 it creates the scoped role for you (your DB credential never leaves this machine).` : `Schema authoring needs a branch-bound ${import_picocolors15.default.bold("sk_")} key with ${import_picocolors15.default.bold("schema:push")} \u2014 manage keys at ${import_picocolors15.default.cyan("https://abloatai.com")}.`);
|
|
283721
283925
|
return {
|
|
283722
283926
|
ok: false,
|
|
283723
283927
|
message: `${serverSays ?? "This key can't author schema (missing schema:push scope)."}
|
|
@@ -283777,8 +283981,10 @@ async function dev(argv, runtime = {}) {
|
|
|
283777
283981
|
s.stop(first.message, first.ok ? 0 : 1);
|
|
283778
283982
|
if (!first.ok) process.exit(1);
|
|
283779
283983
|
if (runtime.branch) {
|
|
283780
|
-
console.log(
|
|
283781
|
-
|
|
283984
|
+
console.log(
|
|
283985
|
+
`
|
|
283986
|
+
${import_picocolors15.default.green("\u2713")} ${wireEnvLocal(args.apiKey, process.cwd(), runtime.branch.projectId, runtime.branch.id)}`
|
|
283987
|
+
);
|
|
283782
283988
|
console.log(
|
|
283783
283989
|
` ${import_picocolors15.default.dim(`Temporary branch credential expires ${runtime.branch.expiresAt}; rerun ablo dev to rotate it.`)}`
|
|
283784
283990
|
);
|
|
@@ -283953,6 +284159,7 @@ async function runBranchDev(argv, dependencies = {}) {
|
|
|
283953
284159
|
apiKey: result.credential.api_key,
|
|
283954
284160
|
branch: {
|
|
283955
284161
|
id: result.branch.id,
|
|
284162
|
+
projectId: result.branch.project_id,
|
|
283956
284163
|
slug: result.branch.slug,
|
|
283957
284164
|
expiresAt: result.credential.expires_at
|
|
283958
284165
|
}
|
|
@@ -284227,6 +284434,7 @@ var COMMANDS = [
|
|
|
284227
284434
|
{ run: "connect", does: "Connect your database \u2014 shows the setup to run, or applies it for you" },
|
|
284228
284435
|
{ run: "connect apply", does: "Run that setup for you, from a one-time admin URL" },
|
|
284229
284436
|
{ run: "connect check", does: "Confirm your database is ready to share changes with Ablo" },
|
|
284437
|
+
{ run: "connect resnapshot", does: "Reload existing rows without deregistering or rotating credentials" },
|
|
284230
284438
|
{ run: "connect scan", does: "List anything Ablo ever set up in your database (read-only)" },
|
|
284231
284439
|
{ run: "connect locate", does: "See which plane holds a database before connecting it" },
|
|
284232
284440
|
{ run: "connect deregister", does: "Disconnect this project's database \u2014 Ablo stops reading and writing it" }
|
|
@@ -287138,6 +287346,8 @@ import { schema } from './schema';
|
|
|
287138
287346
|
// via the session route and never touches the key.
|
|
287139
287347
|
export const sync = Ablo({
|
|
287140
287348
|
apiKey: process.env.ABLO_API_KEY,${authLine}
|
|
287349
|
+
projectId: process.env.ABLO_PROJECT_ID,
|
|
287350
|
+
branchId: process.env.ABLO_BRANCH_ID,
|
|
287141
287351
|
schema,
|
|
287142
287352
|
});
|
|
287143
287353
|
|
|
@@ -287163,7 +287373,7 @@ function generateEnv(storage, opts = {}) {
|
|
|
287163
287373
|
const { includeApiKey = true } = opts;
|
|
287164
287374
|
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";
|
|
287165
287375
|
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" : "";
|
|
287166
|
-
const apiKeyBlock = includeApiKey ? "# Ablo: a branch-bound sk_ key (`npx ablo dev` wires
|
|
287376
|
+
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" : "";
|
|
287167
287377
|
return `${apiKeyBlock}${webhookBlock}${databaseBlock}`;
|
|
287168
287378
|
}
|
|
287169
287379
|
function generateDataSource(orm) {
|
|
@@ -287324,8 +287534,7 @@ export async function POST(req: Request): Promise<Response> {
|
|
|
287324
287534
|
`;
|
|
287325
287535
|
}
|
|
287326
287536
|
function generateAgent() {
|
|
287327
|
-
return `import
|
|
287328
|
-
import { schema } from './schema';
|
|
287537
|
+
return `import { sync as ablo } from './sync';
|
|
287329
287538
|
|
|
287330
287539
|
/**
|
|
287331
287540
|
* An AI "teammate" that works the same synced tasks a human does.
|
|
@@ -287335,8 +287544,6 @@ import { schema } from './schema';
|
|
|
287335
287544
|
* \`npx ablo logs\`. That's the whole idea: agents and people on one typed,
|
|
287336
287545
|
* synced dataset.
|
|
287337
287546
|
*/
|
|
287338
|
-
const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
287339
|
-
|
|
287340
287547
|
async function main() {
|
|
287341
287548
|
await ablo.ready();
|
|
287342
287549
|
|
|
@@ -287354,7 +287561,6 @@ async function main() {
|
|
|
287354
287561
|
data: { priority: 10 },
|
|
287355
287562
|
readAt: snap.stamp,
|
|
287356
287563
|
onStale: 'reject',
|
|
287357
|
-
wait: 'confirmed',
|
|
287358
287564
|
});
|
|
287359
287565
|
console.log('prioritized:', urgent.title);
|
|
287360
287566
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abloatai/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.47.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",
|
|
@@ -30,10 +30,10 @@
|
|
|
30
30
|
"directory": "packages/cli"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@abloatai/transaction": "^0.
|
|
33
|
+
"@abloatai/transaction": "^0.47.0",
|
|
34
34
|
"jiti": "^2.7.0",
|
|
35
35
|
"zod": "^4.4.3",
|
|
36
|
-
"@abloatai/humans": "^0.
|
|
36
|
+
"@abloatai/humans": "^0.47.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@clack/prompts": "^0.11.0",
|