ardent-cli-beta 0.0.17 → 0.0.19
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/NOTICE +4 -0
- package/README.md +96 -18
- package/dist/api/client.d.ts +6 -1
- package/dist/api/client.js +44 -5
- package/dist/auth/credentials.d.ts +1 -0
- package/dist/auth/credentials.js +44 -4
- package/dist/branches/client.d.ts +3 -1
- package/dist/branches/client.js +41 -4
- package/dist/branches/connection.d.ts +21 -0
- package/dist/branches/connection.js +143 -0
- package/dist/branches/credentials.d.ts +18 -0
- package/dist/branches/credentials.js +188 -0
- package/dist/branches/drivers.d.ts +6 -0
- package/dist/branches/drivers.js +77 -0
- package/dist/branches/env.d.ts +1 -0
- package/dist/branches/env.js +55 -0
- package/dist/branches/poll.d.ts +3 -0
- package/dist/branches/poll.js +40 -0
- package/dist/branches/psql.d.ts +14 -0
- package/dist/branches/psql.js +159 -0
- package/dist/branches/recovery.d.ts +19 -0
- package/dist/branches/recovery.js +211 -0
- package/dist/command.d.ts +1 -0
- package/dist/command.js +10 -0
- package/dist/commands/branch/connect.d.ts +12 -0
- package/dist/commands/branch/connect.js +87 -0
- package/dist/commands/branch/create.d.ts +9 -1
- package/dist/commands/branch/create.js +73 -15
- package/dist/commands/branch/recover.d.ts +11 -0
- package/dist/commands/branch/recover.js +37 -0
- package/dist/commands/branch/suspend.d.ts +11 -0
- package/dist/commands/branch/suspend.js +37 -0
- package/dist/commands/branch/switch.js +1 -1
- package/dist/commands/branch/wake.d.ts +11 -0
- package/dist/commands/branch/wake.js +37 -0
- package/dist/commands/connector/create.d.ts +2 -0
- package/dist/commands/connector/create.js +20 -2
- package/dist/commands/connector/delete.d.ts +10 -0
- package/dist/commands/connector/delete.js +62 -0
- package/dist/commands/connector/status.d.ts +10 -0
- package/dist/commands/connector/status.js +28 -0
- package/dist/commands/connector/switch.d.ts +12 -0
- package/dist/commands/connector/switch.js +45 -0
- package/dist/commands/login.js +9 -2
- package/dist/commands/project/switch.js +1 -1
- package/dist/connectors/client.d.ts +8 -4
- package/dist/connectors/client.js +85 -15
- package/dist/members/client.js +5 -5
- package/dist/projects/client.js +4 -4
- package/dist/terminal.d.ts +1 -0
- package/dist/terminal.js +7 -0
- package/oclif.manifest.json +319 -2
- package/openapi.json +1970 -0
- package/package.json +8 -2
package/NOTICE
ADDED
package/README.md
CHANGED
|
@@ -6,21 +6,76 @@ The Ardent CLI is the customer command-line client for the control-plane API.
|
|
|
6
6
|
|
|
7
7
|
Install the beta with `npm install --global ardent-cli-beta`, then run
|
|
8
8
|
`ardent-beta <command>`. The package registers only the `ardent-beta` executable,
|
|
9
|
-
so it can be installed alongside an existing `ardent` command.
|
|
9
|
+
so it can be installed alongside an existing `ardent` command. `package.json`
|
|
10
|
+
carries version `0.0.0` in source; the release pipeline sets the published
|
|
11
|
+
version when it publishes, so a local build reports `0.0.0`.
|
|
10
12
|
|
|
11
13
|
The Oclif foundation and authentication are implemented. `ardent-beta login` opens
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
API token for CI and automation; `ARDENT_TOKEN` supplies the same flag
|
|
15
|
-
|
|
16
|
-
local session. The CLI
|
|
17
|
-
provides help, version, autocomplete, and native JSON command behavior.
|
|
18
|
-
context is retained from login. Project listing, creation, deletion, and local
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
and
|
|
22
|
-
|
|
23
|
-
|
|
14
|
+
Ardent's login in a browser, receives an authorization code on a loopback callback, and
|
|
15
|
+
exchanges it for an API key with PKCE. `ardent-beta login --token <token>` validates and stores an
|
|
16
|
+
existing API token for CI and automation; `ARDENT_TOKEN` supplies the same flag non-interactively,
|
|
17
|
+
and one that is set but empty fails rather than starting a browser login. `ardent-beta status` and
|
|
18
|
+
`ardent-beta logout` inspect and remove that local session. The CLI defaults to Ardent's production
|
|
19
|
+
control plane. Oclif provides help, version, autocomplete, and native JSON command behavior.
|
|
20
|
+
Resource context is retained from login. Project listing, creation, deletion, and local context
|
|
21
|
+
switching are implemented. Organization member listing, invitation, removal, and
|
|
22
|
+
pending-invitation cancellation are implemented. Connector listing, registration,
|
|
23
|
+
local switching, status lookup by ID and deletion are implemented; registration
|
|
24
|
+
follows connector status and sends the connection URL for the control plane's
|
|
25
|
+
credential delivery, while replication readiness remains owed. Connector-scoped branch
|
|
26
|
+
creation, deletion, listing, local switching, `branch wake`, `branch suspend`, `branch recover`
|
|
27
|
+
and `branch connect` are implemented. A suspended (parked) branch refuses connections until
|
|
28
|
+
`branch wake` completes; nothing wakes a branch on connect, and `branch connect` names the wake
|
|
29
|
+
command when the selected branch is parked. `branch recover` brings a failed branch back on its
|
|
30
|
+
retained disk, and reports the control plane's refusal, with its stated fallback, when it cannot.
|
|
31
|
+
`branch create <name>` waits until ready, selects the branch unless local context
|
|
32
|
+
changed, and prints only its connection URL to stdout. Progress, credential
|
|
33
|
+
revocation instructions and driver requirements go to stderr. `--driver` selects
|
|
34
|
+
`libpq` (default), `node`, `jdbc` or `prisma`; `--json` returns the active branch,
|
|
35
|
+
operation and credential. `--print` remains accepted. The credential stays active
|
|
36
|
+
after exit. This uses `branch connect --print`'s lifecycle and requires a control
|
|
37
|
+
plane that issues credentials while branches start (`docs/DECISIONS.md` section 17).
|
|
38
|
+
|
|
39
|
+
Use `branch create <name> --to-env` to write `DATABASE_URL` to `.env` in the
|
|
40
|
+
current directory instead of printing the URL. The selected `--driver` still
|
|
41
|
+
applies. This option cannot be combined with `--print` or `--json`.
|
|
42
|
+
`writeBranchEnv` preserves unrelated entries and refuses an existing
|
|
43
|
+
`DATABASE_URL`; remove that key explicitly before retrying. It replaces the
|
|
44
|
+
file with mode 0600 through a synced temporary file, syncs the destination
|
|
45
|
+
directory, and refuses symlinks and
|
|
46
|
+
hard links. Avoid concurrent edits to `.env` while the command runs: replacement
|
|
47
|
+
does not coordinate with other editors. Values use a quote delimiter absent
|
|
48
|
+
from the connection string; newline, NUL, or values containing all three dotenv
|
|
49
|
+
quote delimiters are refused. Use a dotenv reader without variable expansion.
|
|
50
|
+
Custom destination filenames remain future work.
|
|
51
|
+
|
|
52
|
+
`branch connect` needs a selected active branch, an installed PostgreSQL 16+ `psql`, and the
|
|
53
|
+
system `script` utility on macOS or Linux. It requests a read/write credential for the branch,
|
|
54
|
+
or with `--role <name>` a fresh password on that source role (for example `postgres`) that works
|
|
55
|
+
on this branch alone, acknowledges delivery, runs `psql` under `script`, and revokes the credential when the session
|
|
56
|
+
ends. `--print --driver <libpq|node|jdbc|prisma>` instead writes one driver's connection string
|
|
57
|
+
to stdout and leaves that credential active for an application (`docs/DECISIONS.md` section 15);
|
|
58
|
+
only the libpq form opens psql. Otherwise the credential stays out of arguments and ordinary terminal output: requirement 5.10 in
|
|
59
|
+
`docs/REQUIREMENTS.md` section 5 says "`runPsql` passes fields through child environment
|
|
60
|
+
variables ... and redacts the username, API key and password from both `script` output
|
|
61
|
+
streams". It does not stop a user from printing a credential
|
|
62
|
+
deliberately through SQL or a shell escape. A mode-0600 recovery
|
|
63
|
+
file holds only request and issuance IDs, and any source role name, until revocation succeeds, and the next `branch connect`
|
|
64
|
+
attempts to revoke every abandoned issuance before requesting another. A forced process kill
|
|
65
|
+
can leave a credential active until that recovery runs. Failed cleanup retains
|
|
66
|
+
the file and names the issuance ID for manual revocation.
|
|
67
|
+
|
|
68
|
+
[openapi.json](openapi.json) ships in the npm package. It is a documentation snapshot of the control-plane
|
|
69
|
+
specification as of 2026-09-24, from Ardent's private source revision
|
|
70
|
+
`c8510ab3e2422756a93e1df09acf994cf6bac34d`, and not proof of deployed
|
|
71
|
+
compatibility. It predates the branch credential endpoints used by `branch connect`, and the
|
|
72
|
+
branch wake, suspend and recover operations used by those commands, and connector
|
|
73
|
+
deletion; the source specification documents wake, suspend and recover, and
|
|
74
|
+
refreshing this snapshot from a merged revision that carries them is owed.
|
|
75
|
+
Documenting the credential and connector-deletion endpoints in the source
|
|
76
|
+
specification is also owed. To refresh it, replace it with the reviewed
|
|
77
|
+
control-plane specification, record its source revision here, and run `make ci`.
|
|
78
|
+
Ownership and snapshot limits are recorded in [docs/DECISIONS.md](docs/DECISIONS.md).
|
|
24
79
|
|
|
25
80
|
Set `ARDENT_API_URL` to point the CLI at a development control plane. Without
|
|
26
81
|
it, the CLI uses `https://production.tryardent.com`.
|
|
@@ -40,11 +95,15 @@ npm run build
|
|
|
40
95
|
./bin/run.js member invite administrator@example.com --role admin
|
|
41
96
|
./bin/run.js member delete teammate@example.com
|
|
42
97
|
./bin/run.js connector list
|
|
43
|
-
./bin/run.js connector create postgresql 'postgresql://user:password@database.example.com/app'
|
|
98
|
+
./bin/run.js connector create postgresql 'postgresql://user:password@database.example.com/app' --name Primary
|
|
99
|
+
./bin/run.js connector switch Primary
|
|
100
|
+
./bin/run.js connector status <connector-id>
|
|
44
101
|
./bin/run.js branch create "my feature"
|
|
45
102
|
./bin/run.js branch list
|
|
46
103
|
./bin/run.js branch switch "my feature"
|
|
104
|
+
./bin/run.js branch connect
|
|
47
105
|
./bin/run.js branch delete "my feature"
|
|
106
|
+
./bin/run.js connector delete Primary
|
|
48
107
|
ARDENT_API_URL=http://localhost:8080 ./bin/run.js login --token <token>
|
|
49
108
|
```
|
|
50
109
|
|
|
@@ -52,14 +111,33 @@ Branch commands operate on a connector. When discovering connectors, the CLI use
|
|
|
52
111
|
its project context; one connector is selected automatically. In an interactive
|
|
53
112
|
terminal, multiple connectors appear in a numbered picker with the saved connector
|
|
54
113
|
as the default. Automation uses the saved connector and fails if selection is
|
|
55
|
-
ambiguous.
|
|
114
|
+
ambiguous. `connector switch <name>` selects from the project's current connector
|
|
115
|
+
list for both terminals and automation. Project changes clear connector and branch context.
|
|
116
|
+
|
|
117
|
+
`connector delete <name>` resolves the name from the selected project's current
|
|
118
|
+
list. The API refuses deletion while branches remain. In a terminal the command
|
|
119
|
+
waits for `deleted` before removing the connector from local context; automation
|
|
120
|
+
and `--json` return `deleting` after acceptance and retain local context. Use
|
|
121
|
+
`connector status <connector-id>` to follow registration or deletion, including
|
|
122
|
+
after a deleted connector disappears from `connector list`. Deletion status may
|
|
123
|
+
report source cleanup residue; the delete command warns about it and status
|
|
124
|
+
preserves its code in text and JSON. A failed or interrupted wait retains local
|
|
125
|
+
context and names the status command before retrying.
|
|
56
126
|
|
|
57
127
|
Creation uses the connector's latest published snapshot. If that snapshot is
|
|
58
128
|
unavailable, creation fails without choosing an older one. Names are unique within
|
|
59
129
|
a connector while the branch is not deleted. `branch delete` sends the connector
|
|
60
130
|
and name to the API, which resolves the target. `branch list` fetches current state
|
|
61
|
-
and marks the locally selected branch with
|
|
131
|
+
and marks the locally selected branch with `●` in its `Selected` column; `branch switch` changes local
|
|
62
132
|
context. Create and delete follow the operation in an interactive terminal;
|
|
63
|
-
|
|
133
|
+
creation also waits outside a terminal and with `--json` so it can return a usable
|
|
134
|
+
URL. Non-interactive deletion and deletion with `--json` return the accepted
|
|
135
|
+
operation immediately.
|
|
136
|
+
|
|
137
|
+
Before changing this repository, read `AGENTS.md` and [ARDENT_CLI_DEVELOPMENT.md](ARDENT_CLI_DEVELOPMENT.md).
|
|
64
138
|
|
|
65
|
-
|
|
139
|
+
Running `npm test` or `make ci` requires Node 22+, npm and `tar`. The interactive
|
|
140
|
+
tests and installed `branch connect` command require macOS's system `script`, or
|
|
141
|
+
Linux's util-linux `script` with `-e` support, on `PATH`. The interactive tests
|
|
142
|
+
also use util-linux `script -c`. The PTY tests skip other operating systems and
|
|
143
|
+
fail on Linux if the required utility is missing or incompatible.
|
package/dist/api/client.d.ts
CHANGED
|
@@ -2,7 +2,12 @@ export declare const API_URL: string;
|
|
|
2
2
|
export declare class APIError extends Error {
|
|
3
3
|
readonly status: number;
|
|
4
4
|
readonly code?: string | undefined;
|
|
5
|
-
|
|
5
|
+
readonly workflowID?: string | undefined;
|
|
6
|
+
readonly location?: string | undefined;
|
|
7
|
+
constructor(message: string, status: number, code?: string | undefined, workflowID?: string | undefined, location?: string | undefined);
|
|
6
8
|
}
|
|
9
|
+
export declare class APITransportError extends Error {
|
|
10
|
+
}
|
|
11
|
+
export declare function requestDurableMutation<T>(token: string, path: string, init: RequestInit, refreshCommand: string): Promise<T>;
|
|
7
12
|
export declare function request<T>(token: string, path: string, init?: RequestInit): Promise<T>;
|
|
8
13
|
export declare function requestWithoutAuthentication<T>(path: string, init?: RequestInit): Promise<T>;
|
package/dist/api/client.js
CHANGED
|
@@ -1,12 +1,44 @@
|
|
|
1
|
+
import { terminalText } from '../terminal.js';
|
|
1
2
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
3
|
+
const DURABLE_MUTATION_TIMEOUT_MS = 90_000;
|
|
2
4
|
export const API_URL = process.env.ARDENT_API_URL || 'https://production.tryardent.com';
|
|
3
5
|
export class APIError extends Error {
|
|
4
6
|
status;
|
|
5
7
|
code;
|
|
6
|
-
|
|
8
|
+
workflowID;
|
|
9
|
+
location;
|
|
10
|
+
constructor(message, status, code, workflowID, location) {
|
|
7
11
|
super(message);
|
|
8
12
|
this.status = status;
|
|
9
13
|
this.code = code;
|
|
14
|
+
this.workflowID = workflowID;
|
|
15
|
+
this.location = location;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
// APITransportError is a request whose response never arrived whole: the
|
|
19
|
+
// request could not be sent, or its response was lost or timed out while it
|
|
20
|
+
// was read. Unlike an APIError, it carries no status the API chose.
|
|
21
|
+
export class APITransportError extends Error {
|
|
22
|
+
}
|
|
23
|
+
export async function requestDurableMutation(token, path, init, refreshCommand) {
|
|
24
|
+
// The API can await its durable workflow for 75 seconds. Allow response transit
|
|
25
|
+
// time too, without replacing an explicit caller deadline or retrying a write.
|
|
26
|
+
try {
|
|
27
|
+
return await request(token, path, {
|
|
28
|
+
...init,
|
|
29
|
+
signal: init.signal ?? AbortSignal.timeout(DURABLE_MUTATION_TIMEOUT_MS),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error instanceof APIError) {
|
|
34
|
+
if (error.status !== 503 || error.code !== 'workflow_result_timeout')
|
|
35
|
+
throw error;
|
|
36
|
+
const workflow = error.workflowID ? ` Workflow: ${terminalText(error.workflowID)}.` : '';
|
|
37
|
+
const location = error.location ? ` Check its status with an authenticated GET ${terminalText(error.location)}.` : '';
|
|
38
|
+
error.message = `The mutation was still running when the API stopped waiting.${workflow}${location} Run ${refreshCommand} to refresh resource state; confirm the workflow outcome before retrying.`;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`The mutation response was not received completely; completion is unknown and work may still be running. Run ${refreshCommand} to refresh resource state and confirm the outcome before retrying.`, { cause: error });
|
|
10
42
|
}
|
|
11
43
|
}
|
|
12
44
|
export async function request(token, path, init = {}) {
|
|
@@ -35,18 +67,25 @@ async function requestJSON(path, init) {
|
|
|
35
67
|
});
|
|
36
68
|
}
|
|
37
69
|
catch (error) {
|
|
38
|
-
throw new
|
|
70
|
+
throw new APITransportError(`Could not reach the Ardent API: ${error instanceof Error ? error.message : String(error)}`);
|
|
71
|
+
}
|
|
72
|
+
let text;
|
|
73
|
+
try {
|
|
74
|
+
text = await response.text();
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
throw new APITransportError(`The Ardent API response was not received completely: ${error instanceof Error ? error.message : String(error)}`);
|
|
39
78
|
}
|
|
40
|
-
const text = await response.text();
|
|
41
79
|
if (!response.ok) {
|
|
42
|
-
let detail
|
|
80
|
+
let detail;
|
|
43
81
|
try {
|
|
44
82
|
detail = JSON.parse(text);
|
|
45
83
|
}
|
|
46
84
|
catch {
|
|
47
85
|
// Plain-text errors still receive the status-based fallback below.
|
|
48
86
|
}
|
|
49
|
-
|
|
87
|
+
const fields = typeof detail === 'object' && detail !== null ? detail : {};
|
|
88
|
+
throw new APIError(typeof fields.message === 'string' ? fields.message : `Ardent API request failed with HTTP ${response.status}.`, response.status, typeof fields.error === 'string' ? fields.error : undefined, typeof fields.workflow_id === 'string' ? fields.workflow_id : undefined, response.headers.get('Location') ?? undefined);
|
|
50
89
|
}
|
|
51
90
|
if (response.status === 204)
|
|
52
91
|
return undefined;
|
|
@@ -27,4 +27,5 @@ export interface ProjectIdentity {
|
|
|
27
27
|
}
|
|
28
28
|
export declare function loadSession(directory: string): Promise<Session | undefined>;
|
|
29
29
|
export declare function saveSession(directory: string, session: Session): Promise<void>;
|
|
30
|
+
export declare function updateSession(directory: string, change: (current: Session | undefined) => Session | undefined): Promise<boolean>;
|
|
30
31
|
export declare function clearSession(directory: string): Promise<void>;
|
package/dist/auth/credentials.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { chmod, mkdir, open, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { setTimeout as pause } from 'node:timers/promises';
|
|
4
5
|
const configPath = (directory) => join(directory, 'config.json');
|
|
5
6
|
export async function loadSession(directory) {
|
|
6
7
|
let contents;
|
|
@@ -101,8 +102,45 @@ function isProjectIdentity(value) {
|
|
|
101
102
|
return typeof project.id === 'string' && project.id.length > 0 && typeof project.name === 'string' && project.name.length > 0;
|
|
102
103
|
}
|
|
103
104
|
export async function saveSession(directory, session) {
|
|
105
|
+
await withSessionLock(directory, () => writeSession(directory, session));
|
|
106
|
+
}
|
|
107
|
+
// Undefined leaves the current session untouched, including after logout.
|
|
108
|
+
export async function updateSession(directory, change) {
|
|
109
|
+
return withSessionLock(directory, async () => {
|
|
110
|
+
const updated = change(await loadSession(directory));
|
|
111
|
+
if (!updated)
|
|
112
|
+
return false;
|
|
113
|
+
await writeSession(directory, updated);
|
|
114
|
+
return true;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
async function withSessionLock(directory, action) {
|
|
104
118
|
await mkdir(directory, { mode: 0o700, recursive: true });
|
|
105
119
|
await chmod(directory, 0o700);
|
|
120
|
+
const path = join(directory, 'config.lock');
|
|
121
|
+
const deadline = Date.now() + 5000;
|
|
122
|
+
let lock;
|
|
123
|
+
while (!lock) {
|
|
124
|
+
try {
|
|
125
|
+
lock = await open(path, 'wx', 0o600);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (error.code !== 'EEXIST')
|
|
129
|
+
throw error;
|
|
130
|
+
if (Date.now() >= deadline)
|
|
131
|
+
throw new Error(`Local configuration is locked at ${path}. If no other CLI is writing it, remove that stale lock and retry.`);
|
|
132
|
+
await pause(25);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
return await action();
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
await lock.close();
|
|
140
|
+
await unlink(path);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async function writeSession(directory, session) {
|
|
106
144
|
const destination = configPath(directory);
|
|
107
145
|
const temporary = `${destination}.${process.pid}.${randomUUID()}.tmp`;
|
|
108
146
|
try {
|
|
@@ -115,8 +153,10 @@ export async function saveSession(directory, session) {
|
|
|
115
153
|
}
|
|
116
154
|
}
|
|
117
155
|
export async function clearSession(directory) {
|
|
118
|
-
await
|
|
119
|
-
|
|
120
|
-
|
|
156
|
+
await withSessionLock(directory, async () => {
|
|
157
|
+
await unlink(configPath(directory)).catch((error) => {
|
|
158
|
+
if (error.code !== 'ENOENT')
|
|
159
|
+
throw error;
|
|
160
|
+
});
|
|
121
161
|
});
|
|
122
162
|
}
|
|
@@ -15,5 +15,7 @@ export interface BranchOperation {
|
|
|
15
15
|
export declare function listBranches(token: string, connectorID: string): Promise<Branch[]>;
|
|
16
16
|
export declare function createBranch(token: string, connectorID: string, name: string): Promise<BranchOperation>;
|
|
17
17
|
export declare function deleteBranch(token: string, connectorID: string, name: string): Promise<BranchOperation>;
|
|
18
|
-
export
|
|
18
|
+
export type BranchLifecycleAction = 'wake' | 'suspend' | 'recover';
|
|
19
|
+
export declare function transitionBranch(token: string, connectorID: string, name: string, action: BranchLifecycleAction): Promise<BranchOperation>;
|
|
20
|
+
export declare function waitForBranchOperation(token: string, workflowID: string, onStatus?: (status: string) => void, failedReads?: 'stop' | 'retry'): Promise<void>;
|
|
19
21
|
export declare function branchName(arguments_: unknown[]): string;
|
package/dist/branches/client.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { request } from '../api/client.js';
|
|
2
|
+
import { APIError, APITransportError, request, requestDurableMutation } from '../api/client.js';
|
|
3
|
+
import { terminalText } from '../terminal.js';
|
|
4
|
+
import { pauseFor, pollPauser } from './poll.js';
|
|
3
5
|
export async function listBranches(token, connectorID) {
|
|
4
6
|
const branches = await request(token, `/branches?${new URLSearchParams({ connector_id: connectorID })}`);
|
|
5
7
|
if (!Array.isArray(branches) || branches.some((branch) => !isBranch(branch, connectorID)) || new Set(branches.map(({ id }) => id)).size !== branches.length || new Set(branches.map(({ name }) => name)).size !== branches.length) {
|
|
@@ -21,10 +23,45 @@ export async function deleteBranch(token, connectorID, name) {
|
|
|
21
23
|
validateOperation(result, connectorID, name);
|
|
22
24
|
return result;
|
|
23
25
|
}
|
|
24
|
-
|
|
26
|
+
// The control plane addresses these transitions by branch ID, so the command
|
|
27
|
+
// resolves the name in the selected connector first and acts on exactly that
|
|
28
|
+
// branch. The response is the operation alone; the resolved branch is returned
|
|
29
|
+
// with it so --json output has the same shape as create and delete.
|
|
30
|
+
export async function transitionBranch(token, connectorID, name, action) {
|
|
31
|
+
const matches = (await listBranches(token, connectorID)).filter((candidate) => candidate.name === name);
|
|
32
|
+
if (matches.length !== 1)
|
|
33
|
+
throw new Error(`No branch named ${terminalText(JSON.stringify(name))} in the selected connector. Run ardent-beta branch list to see its branches.`);
|
|
34
|
+
const [branch] = matches;
|
|
35
|
+
// A response lost after the request arrived leaves the workflow possibly
|
|
36
|
+
// queued; requestDurableMutation says so instead of reporting a failed request.
|
|
37
|
+
const operation = await requestDurableMutation(token, `/branches/${encodeURIComponent(branch.id)}/${action}`, { method: 'POST' }, 'ardent-beta branch list');
|
|
38
|
+
if (!operation || typeof operation.operation_id !== 'string' || !operation.operation_id ||
|
|
39
|
+
typeof operation.workflow_id !== 'string' || !operation.workflow_id) {
|
|
40
|
+
// The request was accepted, so its workflow may be running even though the
|
|
41
|
+
// response does not say which one: the same unknown outcome as a lost response.
|
|
42
|
+
throw new Error(`The Ardent API accepted the branch ${action} request but returned no valid operation; completion is unknown and work may still be running. Run ardent-beta branch list to refresh resource state and confirm the outcome before retrying.`);
|
|
43
|
+
}
|
|
44
|
+
return { branch, operation };
|
|
45
|
+
}
|
|
46
|
+
// failedReads 'stop' ends the wait on the first read that fails. 'retry'
|
|
47
|
+
// re-reads a lost response or a 5xx two seconds later, until the deadline; a
|
|
48
|
+
// 4xx still ends it. docs/DECISIONS.md section 17, "`branch create --print`
|
|
49
|
+
// asks for the credential while the branch starts", says which caller retries
|
|
50
|
+
// and why.
|
|
51
|
+
export async function waitForBranchOperation(token, workflowID, onStatus, failedReads = 'stop') {
|
|
25
52
|
const deadline = Date.now() + 30 * 60 * 1000;
|
|
53
|
+
const pause = pollPauser();
|
|
26
54
|
while (Date.now() < deadline) {
|
|
27
|
-
|
|
55
|
+
let result;
|
|
56
|
+
try {
|
|
57
|
+
result = await request(token, `/workflows/${encodeURIComponent(workflowID)}`);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (failedReads === 'stop' || !(error instanceof APITransportError || (error instanceof APIError && error.status >= 500)))
|
|
61
|
+
throw error;
|
|
62
|
+
await pauseFor(2000);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
28
65
|
if (!result || result.id !== workflowID || !['DELAYED', 'ENQUEUED', 'PENDING', 'SUCCESS', 'ERROR', 'CANCELLED', 'MAX_RECOVERY_ATTEMPTS_EXCEEDED'].includes(result.status) || (result.failure !== undefined && typeof result.failure !== 'string')) {
|
|
29
66
|
throw new Error('Ardent API returned an invalid branch operation status.');
|
|
30
67
|
}
|
|
@@ -33,7 +70,7 @@ export async function waitForBranchOperation(token, workflowID, onStatus) {
|
|
|
33
70
|
return;
|
|
34
71
|
if (!['DELAYED', 'ENQUEUED', 'PENDING'].includes(result.status))
|
|
35
72
|
throw new Error(result.failure || `Branch operation ${result.status.toLowerCase()}.`);
|
|
36
|
-
await
|
|
73
|
+
await pause();
|
|
37
74
|
}
|
|
38
75
|
throw new Error('Timed out waiting for the branch operation. Run ardent-beta branch list to check its state.');
|
|
39
76
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Driver } from './drivers.js';
|
|
2
|
+
export interface BranchConnection {
|
|
3
|
+
configDir: string;
|
|
4
|
+
token: string;
|
|
5
|
+
branchID: string;
|
|
6
|
+
sourceRole?: string;
|
|
7
|
+
print: boolean;
|
|
8
|
+
driver: Driver;
|
|
9
|
+
json: boolean;
|
|
10
|
+
deliver?: (value: string) => Promise<void>;
|
|
11
|
+
serving?: () => Promise<void>;
|
|
12
|
+
say: (message: string) => void;
|
|
13
|
+
log: (message: string) => void;
|
|
14
|
+
warn: (message: string) => void;
|
|
15
|
+
}
|
|
16
|
+
export interface PrintedConnection {
|
|
17
|
+
issuance_id: string;
|
|
18
|
+
driver: Driver;
|
|
19
|
+
connection_string: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function connectToBranch(connection: BranchConnection): Promise<PrintedConnection | void>;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { acknowledgeBranchCredential, CredentialIssueRefused, issueBranchCredential, revokeBranchCredential, waitForBranchCredential } from './credentials.js';
|
|
3
|
+
import { CLIENT_REQUIREMENTS, connectionString } from './drivers.js';
|
|
4
|
+
import { connectionEnvironment, runPsql } from './psql.js';
|
|
5
|
+
import { CredentialRecoveryCleanupError, forgetCredentialRecovery, openCredentialWitness, rememberCredentialIssuance, rememberCredentialRequest } from './recovery.js';
|
|
6
|
+
import { terminalText } from '../terminal.js';
|
|
7
|
+
// connectToBranch issues one branch credential and either prints it, kept, or
|
|
8
|
+
// opens psql with it and revokes it afterwards. Every outcome short of a kept
|
|
9
|
+
// credential revokes it, and a recovery record lets the next branch connect
|
|
10
|
+
// finish a revocation this process could not.
|
|
11
|
+
export async function connectToBranch(connection) {
|
|
12
|
+
const { token, branchID, sourceRole, print, driver, say, warn } = connection;
|
|
13
|
+
const requestID = randomUUID();
|
|
14
|
+
const witness = await openCredentialWitness(requestID);
|
|
15
|
+
try {
|
|
16
|
+
const recovery = await rememberCredentialRequest(connection.configDir, branchID, token, witness.port, requestID, sourceRole);
|
|
17
|
+
let issuanceID;
|
|
18
|
+
let issueRefused = false;
|
|
19
|
+
// Set once a printed credential's recovery record is gone: the
|
|
20
|
+
// credential is the user's from then on, and nothing here revokes it.
|
|
21
|
+
let kept = false;
|
|
22
|
+
// The error that ended the command, kept in front of any cleanup error
|
|
23
|
+
// that follows it (withFailure), so a failed revocation never hides why
|
|
24
|
+
// the command failed.
|
|
25
|
+
let failure;
|
|
26
|
+
try {
|
|
27
|
+
try {
|
|
28
|
+
issuanceID = await issueBranchCredential(token, branchID, requestID, sourceRole);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error instanceof CredentialIssueRefused) {
|
|
32
|
+
issueRefused = true;
|
|
33
|
+
try {
|
|
34
|
+
await forgetCredentialRecovery(recovery);
|
|
35
|
+
}
|
|
36
|
+
catch (cleanupError) {
|
|
37
|
+
if (cleanupError instanceof CredentialRecoveryCleanupError && cleanupError.stage === 'sync') {
|
|
38
|
+
throw new Error(`${error.message} The local recovery record was removed, but its directory could not be synced.`);
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`${error.message} Its local recovery record could not be removed. Inspect ${terminalText(recovery.file)} before retrying.`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
await rememberCredentialIssuance(recovery, issuanceID);
|
|
46
|
+
const delivery = await deliverWhenServing(token, issuanceID, connection.serving);
|
|
47
|
+
if (print) {
|
|
48
|
+
// Checked before acknowledgement, so a form that would not verify
|
|
49
|
+
// the server is revoked, never handed over.
|
|
50
|
+
const value = connectionString(driver, delivery.uri, delivery.connection_strings);
|
|
51
|
+
await acknowledgeBranchCredential(token, issuanceID, delivery.delivery_id);
|
|
52
|
+
if (connection.deliver)
|
|
53
|
+
await connection.deliver(value);
|
|
54
|
+
// The record goes before the string is printed: a record left
|
|
55
|
+
// behind would make the next branch connect revoke what was printed.
|
|
56
|
+
// Any failure, an unconfirmed directory sync included, revokes the
|
|
57
|
+
// credential instead: a record a crash could restore would revoke
|
|
58
|
+
// what was printed, breaking "stays active". The record is written
|
|
59
|
+
// back first, so a revocation that also fails is still retried by
|
|
60
|
+
// the next branch connect.
|
|
61
|
+
try {
|
|
62
|
+
await forgetCredentialRecovery(recovery);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
await rememberCredentialIssuance(recovery, issuanceID);
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
kept = true;
|
|
69
|
+
say(`Credential issuance ${terminalText(issuanceID)} stays active until it is revoked with an authenticated DELETE /branch-credentials/${encodeURIComponent(issuanceID)}.`);
|
|
70
|
+
if (CLIENT_REQUIREMENTS[driver].length > 0) {
|
|
71
|
+
say(`The ${driver} form needs, in your client:\n${CLIENT_REQUIREMENTS[driver].map((requirement) => ` - ${requirement}`).join('\n')}`);
|
|
72
|
+
}
|
|
73
|
+
if (connection.json)
|
|
74
|
+
return { issuance_id: issuanceID, driver, connection_string: value };
|
|
75
|
+
connection.log(value);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const environment = connectionEnvironment(delivery.uri);
|
|
79
|
+
await acknowledgeBranchCredential(token, issuanceID, delivery.delivery_id);
|
|
80
|
+
connection.log(`psql target: ${terminalText(environment.PGHOST)}:${environment.PGPORT}/${terminalText(environment.PGDATABASE)}`);
|
|
81
|
+
await runPsql(environment);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
failure = error;
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
// A kept credential was asked to outlive the command.
|
|
89
|
+
if (!kept && !issuanceID && !issueRefused) {
|
|
90
|
+
warn(`Credential request ${requestID} has an unknown outcome. The next branch connect will recover it before issuing another credential.`);
|
|
91
|
+
}
|
|
92
|
+
else if (!kept && issuanceID) {
|
|
93
|
+
try {
|
|
94
|
+
await revokeBranchCredential(token, issuanceID);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
warn(`Credential issuance ${terminalText(issuanceID)} may remain active. The next branch connect will retry revocation; an authenticated DELETE /branch-credentials/${encodeURIComponent(issuanceID)} can revoke it manually.`);
|
|
98
|
+
throw withFailure(failure, 'Could not confirm branch credential revocation.');
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
await forgetCredentialRecovery(recovery);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (error instanceof CredentialRecoveryCleanupError && error.stage === 'sync') {
|
|
105
|
+
throw withFailure(failure, 'The branch credential was revoked, but the local recovery directory could not be synced.');
|
|
106
|
+
}
|
|
107
|
+
throw withFailure(failure, `The branch credential was revoked, but its local recovery record could not be removed. Inspect ${terminalText(recovery.file)} before retrying.`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
await witness.close();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// withFailure is the error a cleanup reports: its own message alone, or after
|
|
117
|
+
// the message of the failure that ended the command.
|
|
118
|
+
function withFailure(failure, message) {
|
|
119
|
+
if (failure === undefined)
|
|
120
|
+
return new Error(message);
|
|
121
|
+
return new Error(`${failure instanceof Error ? failure.message : String(failure)} ${message}`);
|
|
122
|
+
}
|
|
123
|
+
// deliverWhenServing reports the branch's own outcome before the credential's:
|
|
124
|
+
// a branch whose materialization fails revokes the credential issued for it,
|
|
125
|
+
// and that failure, not the revocation, is what the user needs to read.
|
|
126
|
+
async function deliverWhenServing(token, issuanceID, serving) {
|
|
127
|
+
if (!serving)
|
|
128
|
+
return waitForBranchCredential(token, issuanceID);
|
|
129
|
+
const stop = new AbortController();
|
|
130
|
+
const served = serving();
|
|
131
|
+
const delivered = waitForBranchCredential(token, issuanceID, stop.signal, served);
|
|
132
|
+
// Observed after serving settles; this keeps an early refusal from being
|
|
133
|
+
// reported as unhandled in the meantime.
|
|
134
|
+
delivered.catch(() => { });
|
|
135
|
+
try {
|
|
136
|
+
await served;
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
stop.abort();
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
return delivered;
|
|
143
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type ConnectionStrings } from './drivers.js';
|
|
2
|
+
interface DeliveredCredential {
|
|
3
|
+
issuance_id: string;
|
|
4
|
+
delivery_id: string;
|
|
5
|
+
uri: string;
|
|
6
|
+
connection_strings?: ConnectionStrings;
|
|
7
|
+
}
|
|
8
|
+
export declare class CredentialIssueRefused extends Error {
|
|
9
|
+
readonly code?: string | undefined;
|
|
10
|
+
constructor(message: string, code?: string | undefined);
|
|
11
|
+
}
|
|
12
|
+
export declare function validSourceRole(role: string): boolean;
|
|
13
|
+
export declare function refusedBeforeIssuance(error: unknown): boolean;
|
|
14
|
+
export declare function issueBranchCredential(token: string, branchID: string, requestID: string, sourceRole?: string): Promise<string>;
|
|
15
|
+
export declare function waitForBranchCredential(token: string, issuanceID: string, stop?: AbortSignal, served?: Promise<void>): Promise<DeliveredCredential>;
|
|
16
|
+
export declare function acknowledgeBranchCredential(token: string, issuanceID: string, deliveryID: string): Promise<void>;
|
|
17
|
+
export declare function revokeBranchCredential(token: string, issuanceID: string): Promise<void>;
|
|
18
|
+
export {};
|