ardent-cli-beta 0.0.19 → 0.1.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/README.md +17 -3
- package/dist/branches/client.d.ts +4 -1
- package/dist/branches/client.js +46 -2
- package/dist/branches/connection.js +7 -4
- package/dist/branches/credentials.d.ts +9 -1
- package/dist/branches/credentials.js +53 -2
- package/dist/branches/psql.js +3 -2
- package/dist/branches/recovery.js +1 -1
- package/dist/commands/branch/connect.d.ts +1 -0
- package/dist/commands/branch/connect.js +12 -3
- package/dist/commands/branch/create.d.ts +1 -0
- package/dist/commands/branch/create.js +2 -1
- package/dist/commands/branch/delete.js +22 -3
- package/oclif.manifest.json +19 -2
- package/openapi.json +12 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,11 +32,21 @@ retained disk, and reports the control plane's refusal, with its stated fallback
|
|
|
32
32
|
changed, and prints only its connection URL to stdout. Progress, credential
|
|
33
33
|
revocation instructions and driver requirements go to stderr. `--driver` selects
|
|
34
34
|
`libpq` (default), `node`, `jdbc` or `prisma`; `--json` returns the active branch,
|
|
35
|
-
operation and credential. `--
|
|
35
|
+
operation and credential. Repeat `--allow-cidr` at creation to restrict branch
|
|
36
|
+
connections, for example `--allow-cidr 192.0.2.0/24 --allow-cidr 2001:db8::/32`.
|
|
37
|
+
The allowlist is fixed at creation and applies to every credential on the branch;
|
|
38
|
+
omitting it preserves unrestricted ingress. `--print` remains accepted. The credential stays active
|
|
36
39
|
after exit. This uses `branch connect --print`'s lifecycle and requires a control
|
|
37
40
|
plane that issues credentials while branches start (`docs/DECISIONS.md` section 17).
|
|
38
41
|
|
|
39
|
-
|
|
42
|
+
For tokenized credentials, the CLI receives the database token once, keeps it
|
|
43
|
+
in memory while waiting for readiness, and composes the delivered URL template
|
|
44
|
+
locally. A lost one-time token requires revoking and issuing another credential;
|
|
45
|
+
recovery records contain only request and issuance metadata, never that token
|
|
46
|
+
(`docs/DECISIONS.md` section 20).
|
|
47
|
+
|
|
48
|
+
Use `branch create <name> --to-env` or `branch connect --to-env` to write
|
|
49
|
+
`DATABASE_URL` to `.env` in the
|
|
40
50
|
current directory instead of printing the URL. The selected `--driver` still
|
|
41
51
|
applies. This option cannot be combined with `--print` or `--json`.
|
|
42
52
|
`writeBranchEnv` preserves unrelated entries and refuses an existing
|
|
@@ -48,6 +58,8 @@ does not coordinate with other editors. Values use a quote delimiter absent
|
|
|
48
58
|
from the connection string; newline, NUL, or values containing all three dotenv
|
|
49
59
|
quote delimiters are refused. Use a dotenv reader without variable expansion.
|
|
50
60
|
Custom destination filenames remain future work.
|
|
61
|
+
`branch connect --to-env` keeps the issued credential instead of opening psql;
|
|
62
|
+
`--role` still selects a source role with a fresh branch-only password.
|
|
51
63
|
|
|
52
64
|
`branch connect` needs a selected active branch, an installed PostgreSQL 16+ `psql`, and the
|
|
53
65
|
system `script` utility on macOS or Linux. It requests a read/write credential for the branch,
|
|
@@ -127,7 +139,9 @@ context and names the status command before retrying.
|
|
|
127
139
|
Creation uses the connector's latest published snapshot. If that snapshot is
|
|
128
140
|
unavailable, creation fails without choosing an older one. Names are unique within
|
|
129
141
|
a connector while the branch is not deleted. `branch delete` sends the connector
|
|
130
|
-
and name to the API, which resolves the target.
|
|
142
|
+
and name to the API, which resolves the target. When no branch has that name and
|
|
143
|
+
the argument is the ID of a deleted branch whose erasure is unconfirmed, as a
|
|
144
|
+
refused `connector delete` names one, it retries that erasure. `branch list` fetches current state
|
|
131
145
|
and marks the locally selected branch with `●` in its `Selected` column; `branch switch` changes local
|
|
132
146
|
context. Create and delete follow the operation in an interactive terminal;
|
|
133
147
|
creation also waits outside a terminal and with `--json` so it can return a usable
|
|
@@ -13,8 +13,11 @@ export interface BranchOperation {
|
|
|
13
13
|
};
|
|
14
14
|
}
|
|
15
15
|
export declare function listBranches(token: string, connectorID: string): Promise<Branch[]>;
|
|
16
|
-
export declare function createBranch(token: string, connectorID: string, name: string): Promise<BranchOperation>;
|
|
16
|
+
export declare function createBranch(token: string, connectorID: string, name: string, allowedCIDRs?: string[]): Promise<BranchOperation>;
|
|
17
17
|
export declare function deleteBranch(token: string, connectorID: string, name: string): Promise<BranchOperation>;
|
|
18
|
+
export declare function isBranchID(value: string): boolean;
|
|
19
|
+
export declare function loadDeletedBranch(token: string, connectorID: string, id: string): Promise<Branch>;
|
|
20
|
+
export declare function retryBranchErasure(token: string, branch: Branch): Promise<BranchOperation>;
|
|
18
21
|
export type BranchLifecycleAction = 'wake' | 'suspend' | 'recover';
|
|
19
22
|
export declare function transitionBranch(token: string, connectorID: string, name: string, action: BranchLifecycleAction): Promise<BranchOperation>;
|
|
20
23
|
export declare function waitForBranchOperation(token: string, workflowID: string, onStatus?: (status: string) => void, failedReads?: 'stop' | 'retry'): Promise<void>;
|
package/dist/branches/client.js
CHANGED
|
@@ -9,9 +9,9 @@ export async function listBranches(token, connectorID) {
|
|
|
9
9
|
}
|
|
10
10
|
return branches;
|
|
11
11
|
}
|
|
12
|
-
export async function createBranch(token, connectorID, name) {
|
|
12
|
+
export async function createBranch(token, connectorID, name, allowedCIDRs) {
|
|
13
13
|
const result = await request(token, '/branches', {
|
|
14
|
-
method: 'POST', body: JSON.stringify({ connector_id: connectorID, name, request_id: randomUUID() }),
|
|
14
|
+
method: 'POST', body: JSON.stringify({ connector_id: connectorID, name, allowed_cidrs: allowedCIDRs, request_id: randomUUID() }),
|
|
15
15
|
});
|
|
16
16
|
validateOperation(result, connectorID, name);
|
|
17
17
|
return result;
|
|
@@ -23,6 +23,50 @@ export async function deleteBranch(token, connectorID, name) {
|
|
|
23
23
|
validateOperation(result, connectorID, name);
|
|
24
24
|
return result;
|
|
25
25
|
}
|
|
26
|
+
// A deleted branch whose erasure no node has confirmed is in no branch list
|
|
27
|
+
// and unreachable by name, yet keeps its connector from being deleted; that
|
|
28
|
+
// refusal names it by ID. These two calls resolve such an ID and retry its
|
|
29
|
+
// erasure by ID. docs/DECISIONS.md section 19 says why only a DELETED branch.
|
|
30
|
+
export function isBranchID(value) {
|
|
31
|
+
return /^branch_[0-9A-Za-z_-]+$/.test(value);
|
|
32
|
+
}
|
|
33
|
+
export async function loadDeletedBranch(token, connectorID, id) {
|
|
34
|
+
const missing = `No branch ${terminalText(id)} in the selected connector.`;
|
|
35
|
+
let branch;
|
|
36
|
+
try {
|
|
37
|
+
branch = await request(token, `/branches/${encodeURIComponent(id)}`);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error instanceof APIError && error.status === 404)
|
|
41
|
+
throw new Error(missing, { cause: error });
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
if (!isBranch(branch, connectorID) || branch.id !== id)
|
|
45
|
+
throw new Error(missing);
|
|
46
|
+
if (branch.state !== 'DELETED')
|
|
47
|
+
throw new Error(`Branch ${terminalText(id)} is not deleted. Delete it by its name, ${terminalText(JSON.stringify(branch.name))}.`);
|
|
48
|
+
return branch;
|
|
49
|
+
}
|
|
50
|
+
export async function retryBranchErasure(token, branch) {
|
|
51
|
+
let operation;
|
|
52
|
+
try {
|
|
53
|
+
// branch list omits a deleted branch and no CLI command reads one, so the
|
|
54
|
+
// refresh is the read loadDeletedBranch made.
|
|
55
|
+
operation = await requestDurableMutation(token, `/branches/${encodeURIComponent(branch.id)}`, { method: 'DELETE' }, `an authenticated GET /branches/${terminalText(encodeURIComponent(branch.id))}`);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
// This route refuses in plain text, so the API's reason does not reach here.
|
|
59
|
+
if (error instanceof APIError && error.status === 409) {
|
|
60
|
+
throw new Error(`The Ardent API refused to retry the erasure of branch ${terminalText(branch.id)}. An erasure still running can be retried once it fails, an hour after it was requested; otherwise report the branch ID to Ardent support.`, { cause: error });
|
|
61
|
+
}
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
if (!operation || typeof operation.operation_id !== 'string' || !operation.operation_id ||
|
|
65
|
+
typeof operation.workflow_id !== 'string' || !operation.workflow_id) {
|
|
66
|
+
throw new Error(`The Ardent API accepted the erasure retry of branch ${terminalText(branch.id)} but returned no valid operation; completion is unknown and work may still be running.`);
|
|
67
|
+
}
|
|
68
|
+
return { branch, operation };
|
|
69
|
+
}
|
|
26
70
|
// The control plane addresses these transitions by branch ID, so the command
|
|
27
71
|
// resolves the name in the selected connector first and acts on exactly that
|
|
28
72
|
// branch. The response is the operation alone; the resolved branch is returned
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { acknowledgeBranchCredential, CredentialIssueRefused, issueBranchCredential, revokeBranchCredential, waitForBranchCredential } from './credentials.js';
|
|
2
|
+
import { acknowledgeBranchCredential, composeCredentialDelivery, CredentialIssueRefused, issuedConnectKey, issueBranchCredential, revokeBranchCredential, waitForBranchCredential } from './credentials.js';
|
|
3
3
|
import { CLIENT_REQUIREMENTS, connectionString } from './drivers.js';
|
|
4
4
|
import { connectionEnvironment, runPsql } from './psql.js';
|
|
5
5
|
import { CredentialRecoveryCleanupError, forgetCredentialRecovery, openCredentialWitness, rememberCredentialIssuance, rememberCredentialRequest } from './recovery.js';
|
|
@@ -15,6 +15,7 @@ export async function connectToBranch(connection) {
|
|
|
15
15
|
try {
|
|
16
16
|
const recovery = await rememberCredentialRequest(connection.configDir, branchID, token, witness.port, requestID, sourceRole);
|
|
17
17
|
let issuanceID;
|
|
18
|
+
let connectKey;
|
|
18
19
|
let issueRefused = false;
|
|
19
20
|
// Set once a printed credential's recovery record is gone: the
|
|
20
21
|
// credential is the user's from then on, and nothing here revokes it.
|
|
@@ -25,7 +26,10 @@ export async function connectToBranch(connection) {
|
|
|
25
26
|
let failure;
|
|
26
27
|
try {
|
|
27
28
|
try {
|
|
28
|
-
|
|
29
|
+
const issued = await issueBranchCredential(token, branchID, requestID, sourceRole);
|
|
30
|
+
issuanceID = issued.issuanceID;
|
|
31
|
+
await rememberCredentialIssuance(recovery, issuanceID);
|
|
32
|
+
connectKey = issuedConnectKey(issued);
|
|
29
33
|
}
|
|
30
34
|
catch (error) {
|
|
31
35
|
if (error instanceof CredentialIssueRefused) {
|
|
@@ -42,8 +46,7 @@ export async function connectToBranch(connection) {
|
|
|
42
46
|
}
|
|
43
47
|
throw error;
|
|
44
48
|
}
|
|
45
|
-
await
|
|
46
|
-
const delivery = await deliverWhenServing(token, issuanceID, connection.serving);
|
|
49
|
+
const delivery = composeCredentialDelivery(await deliverWhenServing(token, issuanceID, connection.serving), connectKey);
|
|
47
50
|
if (print) {
|
|
48
51
|
// Checked before acknowledgement, so a form that would not verify
|
|
49
52
|
// the server is revoked, never handed over.
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { type ConnectionStrings } from './drivers.js';
|
|
2
|
+
export interface IssuedCredential {
|
|
3
|
+
issuanceID: string;
|
|
4
|
+
connectKey?: unknown;
|
|
5
|
+
connectKeyRequired?: unknown;
|
|
6
|
+
}
|
|
2
7
|
interface DeliveredCredential {
|
|
3
8
|
issuance_id: string;
|
|
4
9
|
delivery_id: string;
|
|
5
10
|
uri: string;
|
|
6
11
|
connection_strings?: ConnectionStrings;
|
|
12
|
+
connect_key_required?: boolean;
|
|
7
13
|
}
|
|
8
14
|
export declare class CredentialIssueRefused extends Error {
|
|
9
15
|
readonly code?: string | undefined;
|
|
@@ -11,7 +17,9 @@ export declare class CredentialIssueRefused extends Error {
|
|
|
11
17
|
}
|
|
12
18
|
export declare function validSourceRole(role: string): boolean;
|
|
13
19
|
export declare function refusedBeforeIssuance(error: unknown): boolean;
|
|
14
|
-
export declare function issueBranchCredential(token: string, branchID: string, requestID: string, sourceRole?: string): Promise<
|
|
20
|
+
export declare function issueBranchCredential(token: string, branchID: string, requestID: string, sourceRole?: string): Promise<IssuedCredential>;
|
|
21
|
+
export declare function issuedConnectKey(issued: IssuedCredential): string | undefined;
|
|
22
|
+
export declare function composeCredentialDelivery(delivered: DeliveredCredential, connectKey?: string): DeliveredCredential;
|
|
15
23
|
export declare function waitForBranchCredential(token: string, issuanceID: string, stop?: AbortSignal, served?: Promise<void>): Promise<DeliveredCredential>;
|
|
16
24
|
export declare function acknowledgeBranchCredential(token: string, issuanceID: string, deliveryID: string): Promise<void>;
|
|
17
25
|
export declare function revokeBranchCredential(token: string, issuanceID: string): Promise<void>;
|
|
@@ -11,7 +11,7 @@ export class CredentialIssueRefused extends Error {
|
|
|
11
11
|
}
|
|
12
12
|
// The issuance refusals that come before any issuance is recorded: a request
|
|
13
13
|
// refused with one of these left nothing to revoke.
|
|
14
|
-
const sourceRoleIssueRefusals = new Set(['invalid_source_role', 'source_role_unsupported', 'source_role_held', 'source_role_revocation_pending']);
|
|
14
|
+
const sourceRoleIssueRefusals = new Set(['invalid_source_role', 'source_role_unsupported', 'source_role_held', 'source_role_revocation_pending', 'connect_key_unsupported']);
|
|
15
15
|
// sourceRolePattern is the name the API accepts for a source role: an
|
|
16
16
|
// unquoted lower-case PostgreSQL identifier that begins with neither pg_ nor
|
|
17
17
|
// ardent. The CLI checks it before it records or sends anything, so a mistyped
|
|
@@ -53,7 +53,58 @@ export async function issueBranchCredential(token, branchID, requestID, sourceRo
|
|
|
53
53
|
if (!result || typeof result.issuance_id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(result.issuance_id) || result.status !== 'pending') {
|
|
54
54
|
throw new Error('Ardent API returned an invalid branch credential issuance.');
|
|
55
55
|
}
|
|
56
|
-
|
|
56
|
+
// Return the identity before checking the one-time secret, so the caller
|
|
57
|
+
// records and revokes an issuance even when its secret is absent or malformed.
|
|
58
|
+
return { issuanceID: result.issuance_id, connectKey: result.connect_key, connectKeyRequired: result.connect_key_required };
|
|
59
|
+
}
|
|
60
|
+
export function issuedConnectKey(issued) {
|
|
61
|
+
if (issued.connectKeyRequired === undefined || issued.connectKeyRequired === false) {
|
|
62
|
+
if (issued.connectKey !== undefined)
|
|
63
|
+
throw new Error('Ardent API returned an unexpected database token. Revoke this credential and issue another.');
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
if (issued.connectKeyRequired !== true || typeof issued.connectKey !== 'string' ||
|
|
67
|
+
!/^bck_[a-z2-7]{16}_[A-Za-z0-9_-]{43}$/.test(issued.connectKey) ||
|
|
68
|
+
Buffer.from(issued.connectKey.slice(21), 'base64url').toString('base64url') !== issued.connectKey.slice(21)) {
|
|
69
|
+
throw new Error('The one-time database token was not received. Revoke this credential and issue another; the token cannot be recovered.');
|
|
70
|
+
}
|
|
71
|
+
return issued.connectKey;
|
|
72
|
+
}
|
|
73
|
+
// Tokenized delivery contains templates, never a recoverable database token.
|
|
74
|
+
// Compose only the username field locally; neither password nor query settings
|
|
75
|
+
// are interpolated. The ordinary driver checks still validate TLS afterwards.
|
|
76
|
+
export function composeCredentialDelivery(delivered, connectKey) {
|
|
77
|
+
if (delivered.connect_key_required !== undefined && typeof delivered.connect_key_required !== 'boolean') {
|
|
78
|
+
throw new Error('Ardent API returned an invalid database token requirement.');
|
|
79
|
+
}
|
|
80
|
+
if (Boolean(connectKey) !== (delivered.connect_key_required === true)) {
|
|
81
|
+
throw new Error('Ardent API returned an inconsistent database token requirement.');
|
|
82
|
+
}
|
|
83
|
+
if (!connectKey)
|
|
84
|
+
return delivered;
|
|
85
|
+
const compose = (value, jdbc = false) => {
|
|
86
|
+
try {
|
|
87
|
+
if (jdbc && !value.startsWith('jdbc:postgresql://'))
|
|
88
|
+
throw new Error();
|
|
89
|
+
const url = new URL(jdbc ? value.slice('jdbc:'.length) : value);
|
|
90
|
+
const user = jdbc ? url.searchParams.get('user') : decodeURIComponent(url.username);
|
|
91
|
+
if (!user || !/^[a-z_][a-z0-9_]{0,62}\+<connect-key>$/.test(user) || (jdbc && url.searchParams.getAll('user').length !== 1))
|
|
92
|
+
throw new Error();
|
|
93
|
+
const username = `${user.slice(0, -'<connect-key>'.length)}${connectKey}`;
|
|
94
|
+
if (jdbc)
|
|
95
|
+
url.searchParams.set('user', username);
|
|
96
|
+
else
|
|
97
|
+
url.username = username;
|
|
98
|
+
return `${jdbc ? 'jdbc:' : ''}${url.toString()}`;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
throw new Error('Ardent API returned an invalid database token template.');
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const uri = compose(delivered.uri);
|
|
105
|
+
const strings = delivered.connection_strings;
|
|
106
|
+
const connectionStrings = strings && Object.fromEntries(DRIVERS.map((driver) => [driver, compose(strings[driver], driver === 'jdbc')]));
|
|
107
|
+
return { ...delivered, uri, connection_strings: connectionStrings };
|
|
57
108
|
}
|
|
58
109
|
// stop, when given, ends the wait with its abort reason: a caller that learns
|
|
59
110
|
// the branch failed has no delivery left to wait for. served, when given,
|
package/dist/branches/psql.js
CHANGED
|
@@ -26,7 +26,8 @@ export function connectionEnvironment(uri) {
|
|
|
26
26
|
}
|
|
27
27
|
const parameters = [...url.searchParams.entries()];
|
|
28
28
|
const controls = /[\u0000-\u001f\u007f]/;
|
|
29
|
-
// A terminating environment's username is <role>+<
|
|
29
|
+
// A terminating environment's username is <role>+<database token> (or a
|
|
30
|
+
// legacy API key), which its node
|
|
30
31
|
// proxy splits; a passthrough environment's is the role alone, because no
|
|
31
32
|
// proxy is there to split it. Either way neither half may be empty.
|
|
32
33
|
const [role, ...apiKey] = user.split('+');
|
|
@@ -80,7 +81,7 @@ export async function checkPsql() {
|
|
|
80
81
|
});
|
|
81
82
|
}
|
|
82
83
|
export async function runPsql(connection) {
|
|
83
|
-
// The API key is
|
|
84
|
+
// The database token or legacy API key is in PGUSER. Neither it nor the URI reaches argv.
|
|
84
85
|
const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('PG')));
|
|
85
86
|
await new Promise((resolve, reject) => {
|
|
86
87
|
const child = spawn('script', terminalCommand(['-X', '-v', 'PROMPT1=ardent=> ', '-v', 'PROMPT2=ardent-> ', '-v', 'PROMPT3=ardent>> ']), {
|
|
@@ -185,7 +185,7 @@ export async function recoverBranchCredentials(directory, token) {
|
|
|
185
185
|
if (!recovery.issuanceID) {
|
|
186
186
|
let issuanceID;
|
|
187
187
|
try {
|
|
188
|
-
issuanceID = await issueBranchCredential(token, branchID, requestID, recovery.sourceRole);
|
|
188
|
+
issuanceID = (await issueBranchCredential(token, branchID, requestID, recovery.sourceRole)).issuanceID;
|
|
189
189
|
}
|
|
190
190
|
catch (error) {
|
|
191
191
|
// The API refuses a source role before it records an issuance and
|
|
@@ -5,6 +5,7 @@ export default class BranchConnect extends ArdentCommand {
|
|
|
5
5
|
static flags: {
|
|
6
6
|
driver: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
7
7
|
print: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
|
+
'to-env': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
8
9
|
role: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
9
10
|
};
|
|
10
11
|
static helpGroup: string;
|
|
@@ -5,6 +5,7 @@ import { listBranches } from '../../branches/client.js';
|
|
|
5
5
|
import { connectToBranch } from '../../branches/connection.js';
|
|
6
6
|
import { validSourceRole } from '../../branches/credentials.js';
|
|
7
7
|
import { DRIVERS, validDriver } from '../../branches/drivers.js';
|
|
8
|
+
import { writeBranchEnv } from '../../branches/env.js';
|
|
8
9
|
import { checkPsql } from '../../branches/psql.js';
|
|
9
10
|
import { recoverBranchCredentials } from '../../branches/recovery.js';
|
|
10
11
|
import { ArdentCommand } from '../../command.js';
|
|
@@ -18,13 +19,15 @@ export default class BranchConnect extends ArdentCommand {
|
|
|
18
19
|
options: [...DRIVERS],
|
|
19
20
|
}),
|
|
20
21
|
print: Flags.boolean({ description: 'Print a connection string for --driver and keep the credential active, instead of opening psql' }),
|
|
22
|
+
'to-env': Flags.boolean({ description: 'Write DATABASE_URL to .env instead of opening psql', exclusive: ['print', 'json'] }),
|
|
21
23
|
role: Flags.string({ description: 'Sign in as this source role on the branch (for example postgres), with a fresh password that works on this branch only' }),
|
|
22
24
|
};
|
|
23
25
|
static helpGroup = 'Branches';
|
|
24
26
|
async run() {
|
|
25
27
|
const { flags } = await this.parse(BranchConnect);
|
|
26
28
|
const sourceRole = flags.role;
|
|
27
|
-
const
|
|
29
|
+
const toEnv = flags['to-env'];
|
|
30
|
+
const print = flags.print || toEnv;
|
|
28
31
|
if (!validDriver(flags.driver))
|
|
29
32
|
this.error(`--driver must be one of ${DRIVERS.join(', ')}.`);
|
|
30
33
|
const driver = flags.driver;
|
|
@@ -79,9 +82,15 @@ export default class BranchConnect extends ArdentCommand {
|
|
|
79
82
|
const as = sourceRole === undefined ? '' : ` as source role ${sourceRole}, with a new branch-only password,`;
|
|
80
83
|
const verb = print ? `Issuing a ${driver} connection string for` : 'Connecting to';
|
|
81
84
|
say(`${verb} branch ${terminalText(branch.name)} in connector ${terminalText(connector.name)}${as} via ${terminalText(new URL(API_URL).origin)}...`);
|
|
82
|
-
|
|
85
|
+
const result = await connectToBranch({
|
|
83
86
|
configDir: this.config.configDir, token: session.token, branchID: branch.id, sourceRole, print, driver,
|
|
84
|
-
json: this.jsonEnabled()
|
|
87
|
+
json: this.jsonEnabled() || toEnv, deliver: toEnv ? writeBranchEnv : undefined,
|
|
88
|
+
say, log: (message) => this.log(message), warn: (message) => this.warn(message),
|
|
85
89
|
});
|
|
90
|
+
if (toEnv) {
|
|
91
|
+
say('Wrote DATABASE_URL to .env.');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
86
95
|
}
|
|
87
96
|
}
|
|
@@ -8,6 +8,7 @@ export default class BranchCreate extends ArdentCommand {
|
|
|
8
8
|
static strict: boolean;
|
|
9
9
|
static description: string;
|
|
10
10
|
static flags: {
|
|
11
|
+
'allow-cidr': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
12
|
driver: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
13
|
print: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
13
14
|
'to-env': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
@@ -14,6 +14,7 @@ export default class BranchCreate extends ArdentCommand {
|
|
|
14
14
|
static strict = false;
|
|
15
15
|
static description = 'Create a branch in the selected connector and print its connection URL when ready';
|
|
16
16
|
static flags = {
|
|
17
|
+
'allow-cidr': Flags.string({ multiple: true, multipleNonGreedy: true, description: 'Allow connections only from these IPv4/IPv6 CIDRs; repeat for each network. Fixed at creation; omitted means unrestricted.' }),
|
|
17
18
|
driver: Flags.string({ default: 'libpq', options: [...DRIVERS], description: 'Connection string format: libpq, node, jdbc or prisma' }),
|
|
18
19
|
print: Flags.boolean({ description: 'Print the connection string when ready (the default behavior)' }),
|
|
19
20
|
'to-env': Flags.boolean({ description: 'Write DATABASE_URL to .env instead of printing it', exclusive: ['print', 'json'] }),
|
|
@@ -38,7 +39,7 @@ export default class BranchCreate extends ArdentCommand {
|
|
|
38
39
|
say(`Creating branch ${target}...`);
|
|
39
40
|
let accepted;
|
|
40
41
|
try {
|
|
41
|
-
const result = await createBranch(session.token, session.selectedConnector.id, name);
|
|
42
|
+
const result = await createBranch(session.token, session.selectedConnector.id, name, flags['allow-cidr']);
|
|
42
43
|
accepted = result;
|
|
43
44
|
const credential = await connectToBranch({
|
|
44
45
|
configDir: this.config.configDir, token: session.token, branchID: result.branch.id,
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { Args, ux } from '@oclif/core';
|
|
2
|
+
import { APIError } from '../../api/client.js';
|
|
2
3
|
import { ArdentCommand } from '../../command.js';
|
|
3
|
-
import { branchName, deleteBranch, waitForBranchOperation } from '../../branches/client.js';
|
|
4
|
+
import { branchName, deleteBranch, isBranchID, loadDeletedBranch, retryBranchErasure, waitForBranchOperation } from '../../branches/client.js';
|
|
4
5
|
import { replaceBranchCache } from '../../branches/context.js';
|
|
5
6
|
import { requireConnector } from '../../connectors/context.js';
|
|
6
7
|
import { terminalText } from '../../terminal.js';
|
|
7
8
|
export default class BranchDelete extends ArdentCommand {
|
|
8
|
-
static args = { name: Args.string({ description: 'Branch name', required: true }) };
|
|
9
|
+
static args = { name: Args.string({ description: 'Branch name, or the ID of a deleted branch whose erasure to retry', required: true }) };
|
|
9
10
|
static strict = false;
|
|
10
11
|
static description = 'Delete a branch in the selected connector';
|
|
11
12
|
static helpGroup = 'Branches';
|
|
@@ -20,7 +21,25 @@ export default class BranchDelete extends ArdentCommand {
|
|
|
20
21
|
else if (!this.jsonEnabled())
|
|
21
22
|
this.log(`Deleting branch ${target}...`);
|
|
22
23
|
try {
|
|
23
|
-
|
|
24
|
+
let result;
|
|
25
|
+
try {
|
|
26
|
+
result = await deleteBranch(session.token, session.selectedConnector.id, name);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
// A name that matches no branch may be the ID of a deleted branch
|
|
30
|
+
// whose erasure is unconfirmed (retryBranchErasure).
|
|
31
|
+
if (!(error instanceof APIError && error.status === 404 && error.code === 'branch_not_found' && isBranchID(name)))
|
|
32
|
+
throw error;
|
|
33
|
+
const branch = await loadDeletedBranch(session.token, session.selectedConnector.id, name);
|
|
34
|
+
const retry = `Retrying the erasure of deleted branch ${terminalText(branch.name)} (${terminalText(branch.id)}) in connector ${terminalText(session.selectedConnector.name)}`;
|
|
35
|
+
if (interactive) {
|
|
36
|
+
ux.action.stop('no branch has that name');
|
|
37
|
+
ux.action.start(retry);
|
|
38
|
+
}
|
|
39
|
+
else if (!this.jsonEnabled())
|
|
40
|
+
this.log(`${retry}...`);
|
|
41
|
+
result = await retryBranchErasure(session.token, branch);
|
|
42
|
+
}
|
|
24
43
|
if (interactive) {
|
|
25
44
|
await waitForBranchOperation(session.token, result.operation.workflow_id, (status) => { ux.action.status = status; });
|
|
26
45
|
await replaceBranchCache(this.config.configDir, session, (session.branches ?? []).filter(({ id }) => id !== result.branch.id));
|
package/oclif.manifest.json
CHANGED
|
@@ -127,6 +127,16 @@
|
|
|
127
127
|
"allowNo": false,
|
|
128
128
|
"type": "boolean"
|
|
129
129
|
},
|
|
130
|
+
"to-env": {
|
|
131
|
+
"description": "Write DATABASE_URL to .env instead of opening psql",
|
|
132
|
+
"exclusive": [
|
|
133
|
+
"print",
|
|
134
|
+
"json"
|
|
135
|
+
],
|
|
136
|
+
"name": "to-env",
|
|
137
|
+
"allowNo": false,
|
|
138
|
+
"type": "boolean"
|
|
139
|
+
},
|
|
130
140
|
"role": {
|
|
131
141
|
"description": "Sign in as this source role on the branch (for example postgres), with a fresh password that works on this branch only",
|
|
132
142
|
"name": "role",
|
|
@@ -170,6 +180,13 @@
|
|
|
170
180
|
"allowNo": false,
|
|
171
181
|
"type": "boolean"
|
|
172
182
|
},
|
|
183
|
+
"allow-cidr": {
|
|
184
|
+
"description": "Allow connections only from these IPv4/IPv6 CIDRs; repeat for each network. Fixed at creation; omitted means unrestricted.",
|
|
185
|
+
"name": "allow-cidr",
|
|
186
|
+
"hasDynamicHelp": false,
|
|
187
|
+
"multiple": true,
|
|
188
|
+
"type": "option"
|
|
189
|
+
},
|
|
173
190
|
"driver": {
|
|
174
191
|
"description": "Connection string format: libpq, node, jdbc or prisma",
|
|
175
192
|
"name": "driver",
|
|
@@ -222,7 +239,7 @@
|
|
|
222
239
|
"aliases": [],
|
|
223
240
|
"args": {
|
|
224
241
|
"name": {
|
|
225
|
-
"description": "Branch name",
|
|
242
|
+
"description": "Branch name, or the ID of a deleted branch whose erasure to retry",
|
|
226
243
|
"name": "name",
|
|
227
244
|
"required": true
|
|
228
245
|
}
|
|
@@ -891,5 +908,5 @@
|
|
|
891
908
|
]
|
|
892
909
|
}
|
|
893
910
|
},
|
|
894
|
-
"version": "0.0
|
|
911
|
+
"version": "0.1.0"
|
|
895
912
|
}
|
package/openapi.json
CHANGED
|
@@ -1608,6 +1608,12 @@
|
|
|
1608
1608
|
"updated_at"
|
|
1609
1609
|
],
|
|
1610
1610
|
"properties": {
|
|
1611
|
+
"allowed_cidrs": {
|
|
1612
|
+
"type": "array",
|
|
1613
|
+
"maxItems": 64,
|
|
1614
|
+
"items": {"type": "string"},
|
|
1615
|
+
"description": "IPv4/IPv6 CIDRs allowed to connect. Empty or omitted means unrestricted. Immutable after creation; host bits are masked and duplicates removed. Mapped IPv6 prefixes are refused."
|
|
1616
|
+
},
|
|
1611
1617
|
"id": {
|
|
1612
1618
|
"type": "string"
|
|
1613
1619
|
},
|
|
@@ -1872,6 +1878,12 @@
|
|
|
1872
1878
|
"request_id"
|
|
1873
1879
|
],
|
|
1874
1880
|
"properties": {
|
|
1881
|
+
"allowed_cidrs": {
|
|
1882
|
+
"type": "array",
|
|
1883
|
+
"maxItems": 64,
|
|
1884
|
+
"items": {"type": "string"},
|
|
1885
|
+
"description": "IPv4/IPv6 CIDRs allowed to connect. Empty or omitted means unrestricted. Immutable after creation; host bits are masked and duplicates removed. Mapped IPv6 prefixes are refused."
|
|
1886
|
+
},
|
|
1875
1887
|
"connector_id": {
|
|
1876
1888
|
"type": "string",
|
|
1877
1889
|
"minLength": 1
|