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.
Files changed (54) hide show
  1. package/NOTICE +4 -0
  2. package/README.md +96 -18
  3. package/dist/api/client.d.ts +6 -1
  4. package/dist/api/client.js +44 -5
  5. package/dist/auth/credentials.d.ts +1 -0
  6. package/dist/auth/credentials.js +44 -4
  7. package/dist/branches/client.d.ts +3 -1
  8. package/dist/branches/client.js +41 -4
  9. package/dist/branches/connection.d.ts +21 -0
  10. package/dist/branches/connection.js +143 -0
  11. package/dist/branches/credentials.d.ts +18 -0
  12. package/dist/branches/credentials.js +188 -0
  13. package/dist/branches/drivers.d.ts +6 -0
  14. package/dist/branches/drivers.js +77 -0
  15. package/dist/branches/env.d.ts +1 -0
  16. package/dist/branches/env.js +55 -0
  17. package/dist/branches/poll.d.ts +3 -0
  18. package/dist/branches/poll.js +40 -0
  19. package/dist/branches/psql.d.ts +14 -0
  20. package/dist/branches/psql.js +159 -0
  21. package/dist/branches/recovery.d.ts +19 -0
  22. package/dist/branches/recovery.js +211 -0
  23. package/dist/command.d.ts +1 -0
  24. package/dist/command.js +10 -0
  25. package/dist/commands/branch/connect.d.ts +12 -0
  26. package/dist/commands/branch/connect.js +87 -0
  27. package/dist/commands/branch/create.d.ts +9 -1
  28. package/dist/commands/branch/create.js +73 -15
  29. package/dist/commands/branch/recover.d.ts +11 -0
  30. package/dist/commands/branch/recover.js +37 -0
  31. package/dist/commands/branch/suspend.d.ts +11 -0
  32. package/dist/commands/branch/suspend.js +37 -0
  33. package/dist/commands/branch/switch.js +1 -1
  34. package/dist/commands/branch/wake.d.ts +11 -0
  35. package/dist/commands/branch/wake.js +37 -0
  36. package/dist/commands/connector/create.d.ts +2 -0
  37. package/dist/commands/connector/create.js +20 -2
  38. package/dist/commands/connector/delete.d.ts +10 -0
  39. package/dist/commands/connector/delete.js +62 -0
  40. package/dist/commands/connector/status.d.ts +10 -0
  41. package/dist/commands/connector/status.js +28 -0
  42. package/dist/commands/connector/switch.d.ts +12 -0
  43. package/dist/commands/connector/switch.js +45 -0
  44. package/dist/commands/login.js +9 -2
  45. package/dist/commands/project/switch.js +1 -1
  46. package/dist/connectors/client.d.ts +8 -4
  47. package/dist/connectors/client.js +85 -15
  48. package/dist/members/client.js +5 -5
  49. package/dist/projects/client.js +4 -4
  50. package/dist/terminal.d.ts +1 -0
  51. package/dist/terminal.js +7 -0
  52. package/oclif.manifest.json +319 -2
  53. package/openapi.json +1970 -0
  54. package/package.json +8 -2
@@ -0,0 +1,211 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { link, open, readFile, readdir, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import { createConnection, createServer } from 'node:net';
4
+ import { dirname, join } from 'node:path';
5
+ import { API_URL } from '../api/client.js';
6
+ import { issueBranchCredential, refusedBeforeIssuance, revokeBranchCredential, validSourceRole } from './credentials.js';
7
+ export class CredentialRecoveryCleanupError extends Error {
8
+ stage;
9
+ constructor(stage, cause) {
10
+ super(`Could not ${stage === 'remove' ? 'remove' : 'sync'} the local credential recovery record.`, { cause });
11
+ this.stage = stage;
12
+ }
13
+ }
14
+ const prefix = 'branch-connect-recovery-';
15
+ const filename = /^branch-connect-recovery-(.+)-([0-9a-f]{32})-([0-9a-f]{32})-([1-9][0-9]*)-([0-9a-f-]{36})\.json$/;
16
+ function digest(value) {
17
+ return createHash('sha256').update(value).digest('hex').slice(0, 32);
18
+ }
19
+ function recoveryPath(directory, branchID, token, port, requestID) {
20
+ const branch = Buffer.from(branchID, 'utf8').toString('base64url');
21
+ return join(directory, `${prefix}${branch}-${digest(new URL(API_URL).origin)}-${digest(token)}-${port}-${requestID}.json`);
22
+ }
23
+ async function syncDirectory(file) {
24
+ const directory = await open(dirname(file), 'r');
25
+ try {
26
+ await directory.sync();
27
+ }
28
+ finally {
29
+ await directory.close();
30
+ }
31
+ }
32
+ export async function openCredentialWitness(requestID) {
33
+ const server = createServer((socket) => {
34
+ let received = '';
35
+ socket.setTimeout(1000, () => socket.destroy());
36
+ socket.setEncoding('utf8');
37
+ socket.on('data', (chunk) => {
38
+ received += chunk;
39
+ if (received === `${requestID}\n`)
40
+ socket.end(`ardent-branch-connect-live:${requestID}\n`);
41
+ else if (received.length > 64)
42
+ socket.destroy();
43
+ });
44
+ });
45
+ await new Promise((resolve, reject) => {
46
+ server.once('error', reject);
47
+ server.listen(0, '127.0.0.1', resolve);
48
+ });
49
+ const address = server.address();
50
+ if (!address || typeof address === 'string')
51
+ throw new Error('Could not reserve a local branch connection witness.');
52
+ return {
53
+ port: address.port,
54
+ close: () => new Promise((resolve) => server.close(() => resolve())),
55
+ };
56
+ }
57
+ async function witnessState(port, requestID) {
58
+ return new Promise((resolve) => {
59
+ const socket = createConnection({ host: '127.0.0.1', port });
60
+ let settled = false;
61
+ let response = '';
62
+ const finish = (state) => {
63
+ if (settled)
64
+ return;
65
+ settled = true;
66
+ socket.destroy();
67
+ resolve(state);
68
+ };
69
+ socket.setTimeout(1000, () => finish('unknown'));
70
+ socket.once('connect', () => socket.write(`${requestID}\n`));
71
+ socket.on('data', (chunk) => {
72
+ response += chunk.toString('utf8');
73
+ if (response.includes('\n'))
74
+ finish(response === `ardent-branch-connect-live:${requestID}\n` ? 'live' : 'dead');
75
+ else if (response.length > 128)
76
+ finish('dead');
77
+ });
78
+ socket.once('end', () => finish('dead'));
79
+ socket.once('error', (error) => finish(error.code === 'ECONNREFUSED' ? 'dead' : 'unknown'));
80
+ });
81
+ }
82
+ async function writeRecord(file, record, replace) {
83
+ const temporary = `${file}.${randomUUID()}.tmp`;
84
+ try {
85
+ await writeFile(temporary, `${JSON.stringify(record)}\n`, { flag: 'wx', mode: 0o600 });
86
+ const handle = await open(temporary, 'r');
87
+ try {
88
+ await handle.sync();
89
+ }
90
+ finally {
91
+ await handle.close();
92
+ }
93
+ if (replace)
94
+ await rename(temporary, file);
95
+ else
96
+ await link(temporary, file);
97
+ await syncDirectory(file);
98
+ }
99
+ finally {
100
+ await unlink(temporary).catch((error) => {
101
+ if (error.code !== 'ENOENT')
102
+ throw error;
103
+ });
104
+ }
105
+ }
106
+ export async function rememberCredentialRequest(directory, branchID, token, port, requestID, sourceRole) {
107
+ const file = recoveryPath(directory, branchID, token, port, requestID);
108
+ await writeRecord(file, { request_id: requestID, ...(sourceRole === undefined ? {} : { source_role: sourceRole }) }, false);
109
+ return { file, branchID, requestID, sourceRole };
110
+ }
111
+ export async function rememberCredentialIssuance(recovery, issuanceID) {
112
+ recovery.issuanceID = issuanceID;
113
+ await writeRecord(recovery.file, { request_id: recovery.requestID, issuance_id: issuanceID, ...(recovery.sourceRole === undefined ? {} : { source_role: recovery.sourceRole }) }, true);
114
+ }
115
+ // forgetCredentialRecovery removes a record and syncs its directory. A record
116
+ // already removed, by an earlier attempt whose sync failed, is removed; the
117
+ // sync is still retried.
118
+ export async function forgetCredentialRecovery(recovery) {
119
+ try {
120
+ await unlink(recovery.file);
121
+ }
122
+ catch (error) {
123
+ if (error.code !== 'ENOENT')
124
+ throw new CredentialRecoveryCleanupError('remove', error);
125
+ }
126
+ try {
127
+ await syncDirectory(recovery.file);
128
+ }
129
+ catch (error) {
130
+ throw new CredentialRecoveryCleanupError('sync', error);
131
+ }
132
+ }
133
+ export async function recoverBranchCredentials(directory, token) {
134
+ let names;
135
+ try {
136
+ names = await readdir(directory);
137
+ }
138
+ catch (error) {
139
+ if (error.code === 'ENOENT')
140
+ return 0;
141
+ throw error;
142
+ }
143
+ let recovered = 0;
144
+ const failures = [];
145
+ for (const name of names.filter((entry) => entry.startsWith(prefix) && entry.endsWith('.json')).sort()) {
146
+ const match = filename.exec(name);
147
+ if (!match) {
148
+ failures.push(`Unrecognized branch credential recovery file ${name}.`);
149
+ continue;
150
+ }
151
+ const [, encodedBranch, originHash, tokenHash, portText, requestID] = match;
152
+ const branchID = Buffer.from(encodedBranch, 'base64url').toString('utf8');
153
+ if (!branchID || Buffer.from(branchID, 'utf8').toString('base64url') !== encodedBranch) {
154
+ failures.push(`Invalid branch credential recovery file ${name}.`);
155
+ continue;
156
+ }
157
+ const file = join(directory, name);
158
+ let record;
159
+ try {
160
+ record = JSON.parse(await readFile(file, 'utf8'));
161
+ }
162
+ catch {
163
+ failures.push(`Cannot read branch credential recovery file ${name}.`);
164
+ continue;
165
+ }
166
+ if (!record || typeof record !== 'object' || record.request_id !== requestID ||
167
+ (record.issuance_id !== undefined && (typeof record.issuance_id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(record.issuance_id))) ||
168
+ (record.source_role !== undefined && (typeof record.source_role !== 'string' || !validSourceRole(record.source_role)))) {
169
+ failures.push(`Invalid branch credential recovery file ${name}.`);
170
+ continue;
171
+ }
172
+ if (originHash !== digest(new URL(API_URL).origin) || tokenHash !== digest(token)) {
173
+ const id = record.issuance_id ? ` issuance ${record.issuance_id}` : ` request ${requestID}`;
174
+ failures.push(`Pending branch credential${id} belongs to another API target or login; restore those settings to revoke it.`);
175
+ continue;
176
+ }
177
+ const port = Number(portText);
178
+ const state = Number.isSafeInteger(port) && port <= 65535 ? await witnessState(port, requestID) : 'unknown';
179
+ if (state !== 'dead') {
180
+ failures.push(`Branch credential ${record.issuance_id ?? `request ${requestID}`} has a ${state === 'live' ? 'running session' : 'session whose state is unknown'}; finish or resolve that session before connecting again.`);
181
+ continue;
182
+ }
183
+ const recovery = { file, branchID, requestID, issuanceID: record.issuance_id, sourceRole: record.source_role };
184
+ try {
185
+ if (!recovery.issuanceID) {
186
+ let issuanceID;
187
+ try {
188
+ issuanceID = await issueBranchCredential(token, branchID, requestID, recovery.sourceRole);
189
+ }
190
+ catch (error) {
191
+ // The API refuses a source role before it records an issuance and
192
+ // after it replays one, so this request created nothing to revoke.
193
+ if (!refusedBeforeIssuance(error))
194
+ throw error;
195
+ await forgetCredentialRecovery(recovery);
196
+ continue;
197
+ }
198
+ await rememberCredentialIssuance(recovery, issuanceID);
199
+ }
200
+ await revokeBranchCredential(token, recovery.issuanceID);
201
+ await forgetCredentialRecovery(recovery);
202
+ recovered++;
203
+ }
204
+ catch {
205
+ failures.push(`Could not confirm cleanup of branch credential issuance ${recovery.issuanceID ?? `from request ${requestID}`}; retry with the same API target and login, or revoke it manually.`);
206
+ }
207
+ }
208
+ if (failures.length)
209
+ throw new Error(failures.join(' '));
210
+ return recovered;
211
+ }
package/dist/command.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Command } from '@oclif/core';
2
2
  export declare abstract class ArdentCommand extends Command {
3
3
  static enableJsonFlag: boolean;
4
+ protected catch(error: Error): Promise<void>;
4
5
  }
package/dist/command.js CHANGED
@@ -1,4 +1,14 @@
1
1
  import { Command } from '@oclif/core';
2
+ import { APIError } from './api/client.js';
3
+ import { terminalText } from './terminal.js';
2
4
  export class ArdentCommand extends Command {
3
5
  static enableJsonFlag = true;
6
+ async catch(error) {
7
+ if (error instanceof APIError && !this.jsonEnabled()) {
8
+ const displayError = new APIError(terminalText(error.message), error.status, error.code === undefined ? undefined : terminalText(String(error.code)));
9
+ displayError.stack = error.stack?.replace(error.message, () => displayError.message);
10
+ return super.catch(displayError);
11
+ }
12
+ return super.catch(error);
13
+ }
4
14
  }
@@ -0,0 +1,12 @@
1
+ import { type PrintedConnection } from '../../branches/connection.js';
2
+ import { ArdentCommand } from '../../command.js';
3
+ export default class BranchConnect extends ArdentCommand {
4
+ static description: string;
5
+ static flags: {
6
+ driver: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
7
+ print: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
+ role: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
+ };
10
+ static helpGroup: string;
11
+ run(): Promise<PrintedConnection | void>;
12
+ }
@@ -0,0 +1,87 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { API_URL } from '../../api/client.js';
3
+ import { loadSession } from '../../auth/credentials.js';
4
+ import { listBranches } from '../../branches/client.js';
5
+ import { connectToBranch } from '../../branches/connection.js';
6
+ import { validSourceRole } from '../../branches/credentials.js';
7
+ import { DRIVERS, validDriver } from '../../branches/drivers.js';
8
+ import { checkPsql } from '../../branches/psql.js';
9
+ import { recoverBranchCredentials } from '../../branches/recovery.js';
10
+ import { ArdentCommand } from '../../command.js';
11
+ import { shellWord, terminalText } from '../../terminal.js';
12
+ export default class BranchConnect extends ArdentCommand {
13
+ static description = 'Connect to the selected active branch with psql, or print a connection string for your driver';
14
+ static flags = {
15
+ driver: Flags.string({
16
+ default: 'libpq',
17
+ description: 'The driver --print writes a connection string for: libpq (psql, psycopg, pgx), node (node-postgres, postgres.js), jdbc (pgjdbc) or prisma (Prisma)',
18
+ options: [...DRIVERS],
19
+ }),
20
+ print: Flags.boolean({ description: 'Print a connection string for --driver and keep the credential active, instead of opening psql' }),
21
+ 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
+ };
23
+ static helpGroup = 'Branches';
24
+ async run() {
25
+ const { flags } = await this.parse(BranchConnect);
26
+ const sourceRole = flags.role;
27
+ const print = flags.print;
28
+ if (!validDriver(flags.driver))
29
+ this.error(`--driver must be one of ${DRIVERS.join(', ')}.`);
30
+ const driver = flags.driver;
31
+ // psql is a libpq client: only the libpq form opens it, and any other form
32
+ // is for an application, so it is only printed.
33
+ if (!print && driver !== 'libpq')
34
+ this.error(`psql connects with the libpq form. Add --print to output the ${driver} connection string instead.`);
35
+ // Checked before anything is recorded or sent: an empty value would
36
+ // otherwise ask for an ordinary credential, and a mistyped one would be
37
+ // kept in a recovery record.
38
+ if (sourceRole !== undefined && !validSourceRole(sourceRole)) {
39
+ this.error(`--role must name a source role as a lower-case PostgreSQL identifier that begins with neither pg_ nor ardent; got ${JSON.stringify(terminalText(sourceRole))}.`);
40
+ }
41
+ if (this.jsonEnabled() && !print)
42
+ this.error('branch connect opens interactive psql and supports --json only with --print.');
43
+ // With --print, stdout carries the connection string alone; everything
44
+ // else goes to stderr.
45
+ const say = (message) => { if (print)
46
+ this.logToStderr(message);
47
+ else
48
+ this.log(message); };
49
+ const session = await loadSession(this.config.configDir);
50
+ if (!session)
51
+ this.error('Log in before connecting to a branch.');
52
+ const recovered = await recoverBranchCredentials(this.config.configDir, session.token);
53
+ if (recovered)
54
+ say(`Revoked ${recovered} credential issuance(s) left by an earlier branch connect.`);
55
+ if (!session.selectedConnector || !session.selectedBranch) {
56
+ this.error('Select a branch first with ardent-beta branch switch <name>.');
57
+ }
58
+ const branch = session.selectedBranch;
59
+ const connector = session.selectedConnector;
60
+ const branches = await listBranches(session.token, connector.id);
61
+ const current = branches.find((candidate) => candidate.id === branch.id && candidate.name === branch.name);
62
+ if (current?.state === 'PARKED') {
63
+ const parked = 'The selected branch is parked, and a parked branch refuses connections until it is woken.';
64
+ // A name terminalText would alter cannot be printed as a command to copy:
65
+ // the copy would name a different branch.
66
+ if (terminalText(branch.name) !== branch.name) {
67
+ this.error(`${parked} Its name contains terminal control characters (shown escaped: ${terminalText(branch.name)}), so no command is printed to copy; run ardent-beta branch wake with that exact name.`);
68
+ }
69
+ // -- ends option parsing, so a name such as --json stays the name. The
70
+ // command sits alone on its own line with no trailing punctuation, so a
71
+ // copy names exactly this branch.
72
+ this.error(`${parked} To wake it, run:\n ardent-beta branch wake -- ${shellWord(branch.name)}`);
73
+ }
74
+ if (current?.state !== 'ACTIVE') {
75
+ this.error('The selected branch is not active. Run ardent-beta branch list to refresh its status.');
76
+ }
77
+ if (!print)
78
+ await checkPsql();
79
+ const as = sourceRole === undefined ? '' : ` as source role ${sourceRole}, with a new branch-only password,`;
80
+ const verb = print ? `Issuing a ${driver} connection string for` : 'Connecting to';
81
+ say(`${verb} branch ${terminalText(branch.name)} in connector ${terminalText(connector.name)}${as} via ${terminalText(new URL(API_URL).origin)}...`);
82
+ return connectToBranch({
83
+ configDir: this.config.configDir, token: session.token, branchID: branch.id, sourceRole, print, driver,
84
+ json: this.jsonEnabled(), say, log: (message) => this.log(message), warn: (message) => this.warn(message),
85
+ });
86
+ }
87
+ }
@@ -1,11 +1,19 @@
1
1
  import { ArdentCommand } from '../../command.js';
2
2
  import { type BranchOperation } from '../../branches/client.js';
3
+ import { type PrintedConnection } from '../../branches/connection.js';
3
4
  export default class BranchCreate extends ArdentCommand {
4
5
  static args: {
5
6
  name: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
6
7
  };
7
8
  static strict: boolean;
8
9
  static description: string;
10
+ static flags: {
11
+ driver: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
12
+ print: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
+ 'to-env': import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
+ };
9
15
  static helpGroup: string;
10
- run(): Promise<BranchOperation>;
16
+ run(): Promise<BranchOperation & {
17
+ credential: PrintedConnection;
18
+ }>;
11
19
  }
@@ -1,38 +1,96 @@
1
- import { Args, ux } from '@oclif/core';
1
+ import { Args, Flags, ux } from '@oclif/core';
2
+ import { APIError, API_URL } from '../../api/client.js';
3
+ import { updateSession } from '../../auth/credentials.js';
2
4
  import { ArdentCommand } from '../../command.js';
3
- import { branchName, createBranch, waitForBranchOperation } from '../../branches/client.js';
4
- import { replaceBranchCache } from '../../branches/context.js';
5
+ import { branchName, createBranch, listBranches, waitForBranchOperation } from '../../branches/client.js';
6
+ import { connectToBranch } from '../../branches/connection.js';
7
+ import { DRIVERS, validDriver } from '../../branches/drivers.js';
8
+ import { writeBranchEnv } from '../../branches/env.js';
9
+ import { recoverBranchCredentials } from '../../branches/recovery.js';
5
10
  import { requireConnector } from '../../connectors/context.js';
6
11
  import { terminalText } from '../../terminal.js';
7
12
  export default class BranchCreate extends ArdentCommand {
8
13
  static args = { name: Args.string({ description: 'Branch name', required: true }) };
9
14
  static strict = false;
10
- static description = 'Create a branch in the selected connector';
15
+ static description = 'Create a branch in the selected connector and print its connection URL when ready';
16
+ static flags = {
17
+ driver: Flags.string({ default: 'libpq', options: [...DRIVERS], description: 'Connection string format: libpq, node, jdbc or prisma' }),
18
+ print: Flags.boolean({ description: 'Print the connection string when ready (the default behavior)' }),
19
+ 'to-env': Flags.boolean({ description: 'Write DATABASE_URL to .env instead of printing it', exclusive: ['print', 'json'] }),
20
+ };
11
21
  static helpGroup = 'Branches';
12
22
  async run() {
13
- const { argv } = await this.parse(BranchCreate);
23
+ const { argv, flags } = await this.parse(BranchCreate);
14
24
  const name = branchName(argv);
25
+ if (!validDriver(flags.driver))
26
+ this.error(`--driver must be one of ${DRIVERS.join(', ')}.`);
15
27
  const session = await requireConnector(this.config.configDir, !this.jsonEnabled());
28
+ const say = (message) => { if (!this.jsonEnabled())
29
+ this.logToStderr(message); };
30
+ const recovered = await recoverBranchCredentials(this.config.configDir, session.token);
31
+ if (recovered)
32
+ say(`Revoked ${recovered} credential issuance(s) left by an earlier command.`);
16
33
  const interactive = !this.jsonEnabled() && ux.action.type === 'spinner';
17
- const target = `${terminalText(name)} in connector ${terminalText(session.selectedConnector.name)}`;
34
+ const target = `${terminalText(name)} in connector ${terminalText(session.selectedConnector.name)} via ${terminalText(new URL(API_URL).origin)}`;
18
35
  if (interactive)
19
36
  ux.action.start(`Creating branch ${target}`);
20
- else if (!this.jsonEnabled())
21
- this.log(`Creating branch ${target}...`);
37
+ else
38
+ say(`Creating branch ${target}...`);
39
+ let accepted;
22
40
  try {
23
41
  const result = await createBranch(session.token, session.selectedConnector.id, name);
24
- await replaceBranchCache(this.config.configDir, session, [...(session.branches ?? []).filter(({ id, name }) => id !== result.branch.id && name !== result.branch.name), result.branch]);
25
- if (interactive) {
26
- await waitForBranchOperation(session.token, result.operation.workflow_id, (status) => { ux.action.status = status; });
27
- ux.action.stop(ux.colorize('green', '✓ created'));
28
- }
42
+ accepted = result;
43
+ const credential = await connectToBranch({
44
+ configDir: this.config.configDir, token: session.token, branchID: result.branch.id,
45
+ print: true, driver: flags.driver, json: true, say, log: (message) => this.log(message), warn: (message) => this.warn(message),
46
+ deliver: flags['to-env'] ? writeBranchEnv : undefined,
47
+ serving: async () => {
48
+ await waitForBranchOperation(session.token, result.operation.workflow_id, (status) => { if (interactive)
49
+ ux.action.status = status; }, 'retry');
50
+ const branches = await listBranches(session.token, session.selectedConnector.id);
51
+ const branch = branches.find(({ id, name }) => id === result.branch.id && name === result.branch.name);
52
+ if (branch?.state !== 'ACTIVE')
53
+ throw new Error('The created branch is not active. Run ardent-beta branch list to check its state.');
54
+ result.branch = branch;
55
+ // A long-running create must not undo a switch or logout in another
56
+ // terminal. Read and conditionally write under the session writer lock.
57
+ const selected = await updateSession(this.config.configDir, (current) => {
58
+ if (!current || current.token !== session.token ||
59
+ current.organization?.id !== session.organization?.id ||
60
+ current.selectedProject?.id !== session.selectedProject?.id ||
61
+ current.selectedConnector?.id !== session.selectedConnector.id ||
62
+ current.selectedBranch?.id !== session.selectedBranch?.id)
63
+ return;
64
+ const identities = branches.map(({ id, name, connector_id }) => ({ id, name, connector_id }));
65
+ return { ...current, branches: identities, selectedBranch: { id: branch.id, name: branch.name, connector_id: branch.connector_id } };
66
+ });
67
+ if (!selected)
68
+ say('Local context changed while creating the branch; its selection was left unchanged.');
69
+ if (interactive)
70
+ ux.action.status = 'preparing connection URL';
71
+ },
72
+ });
73
+ if (!credential)
74
+ throw new Error('No connection URL was returned.');
75
+ if (interactive)
76
+ ux.action.stop(ux.colorize('green', '✓ ready'));
77
+ else
78
+ say(`Branch ${terminalText(name)} is ready.`);
79
+ if (flags['to-env'])
80
+ say('Wrote DATABASE_URL to .env.');
29
81
  else if (!this.jsonEnabled())
30
- this.log(`Branch creation started (${terminalText(result.branch.id)}).`);
31
- return result;
82
+ this.log(credential.connection_string);
83
+ return { ...result, credential };
32
84
  }
33
85
  catch (error) {
34
86
  if (interactive)
35
87
  ux.action.stop('failed');
88
+ if (accepted) {
89
+ const message = `Branch creation was accepted (branch ${terminalText(accepted.branch.id)}, workflow ${terminalText(accepted.operation.workflow_id)}). ${error instanceof Error ? error.message : 'Connection setup failed.'} Run ardent-beta branch list to check its state before retrying creation.`;
90
+ if (error instanceof APIError)
91
+ throw new APIError(message, error.status, error.code);
92
+ throw new Error(this.jsonEnabled() ? message : terminalText(message));
93
+ }
36
94
  throw error;
37
95
  }
38
96
  }
@@ -0,0 +1,11 @@
1
+ import { ArdentCommand } from '../../command.js';
2
+ import { type BranchOperation } from '../../branches/client.js';
3
+ export default class BranchRecover extends ArdentCommand {
4
+ static args: {
5
+ name: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
6
+ };
7
+ static strict: boolean;
8
+ static description: string;
9
+ static helpGroup: string;
10
+ run(): Promise<BranchOperation>;
11
+ }
@@ -0,0 +1,37 @@
1
+ import { Args, ux } from '@oclif/core';
2
+ import { ArdentCommand } from '../../command.js';
3
+ import { branchName, transitionBranch, waitForBranchOperation } from '../../branches/client.js';
4
+ import { requireConnector } from '../../connectors/context.js';
5
+ import { terminalText } from '../../terminal.js';
6
+ export default class BranchRecover extends ArdentCommand {
7
+ static args = { name: Args.string({ description: 'Branch name', required: true }) };
8
+ static strict = false;
9
+ static description = 'Recover a failed branch in the selected connector on its retained disk';
10
+ static helpGroup = 'Branches';
11
+ async run() {
12
+ const { argv } = await this.parse(BranchRecover);
13
+ const name = branchName(argv);
14
+ const session = await requireConnector(this.config.configDir, !this.jsonEnabled());
15
+ const interactive = !this.jsonEnabled() && ux.action.type === 'spinner';
16
+ const target = `${terminalText(name)} in connector ${terminalText(session.selectedConnector.name)}`;
17
+ if (interactive)
18
+ ux.action.start(`Recovering branch ${target}`);
19
+ else if (!this.jsonEnabled())
20
+ this.log(`Recovering branch ${target}...`);
21
+ try {
22
+ const result = await transitionBranch(session.token, session.selectedConnector.id, name, 'recover');
23
+ if (interactive) {
24
+ await waitForBranchOperation(session.token, result.operation.workflow_id, (status) => { ux.action.status = status; });
25
+ ux.action.stop(ux.colorize('green', '✓ active'));
26
+ }
27
+ else if (!this.jsonEnabled())
28
+ this.log(`Branch recovery started (${terminalText(result.branch.id)}).`);
29
+ return result;
30
+ }
31
+ catch (error) {
32
+ if (interactive)
33
+ ux.action.stop('failed');
34
+ throw error;
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,11 @@
1
+ import { ArdentCommand } from '../../command.js';
2
+ import { type BranchOperation } from '../../branches/client.js';
3
+ export default class BranchSuspend extends ArdentCommand {
4
+ static args: {
5
+ name: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
6
+ };
7
+ static strict: boolean;
8
+ static description: string;
9
+ static helpGroup: string;
10
+ run(): Promise<BranchOperation>;
11
+ }
@@ -0,0 +1,37 @@
1
+ import { Args, ux } from '@oclif/core';
2
+ import { ArdentCommand } from '../../command.js';
3
+ import { branchName, transitionBranch, waitForBranchOperation } from '../../branches/client.js';
4
+ import { requireConnector } from '../../connectors/context.js';
5
+ import { terminalText } from '../../terminal.js';
6
+ export default class BranchSuspend extends ArdentCommand {
7
+ static args = { name: Args.string({ description: 'Branch name', required: true }) };
8
+ static strict = false;
9
+ static description = 'Park a branch in the selected connector; it refuses connections until woken';
10
+ static helpGroup = 'Branches';
11
+ async run() {
12
+ const { argv } = await this.parse(BranchSuspend);
13
+ const name = branchName(argv);
14
+ const session = await requireConnector(this.config.configDir, !this.jsonEnabled());
15
+ const interactive = !this.jsonEnabled() && ux.action.type === 'spinner';
16
+ const target = `${terminalText(name)} in connector ${terminalText(session.selectedConnector.name)}`;
17
+ if (interactive)
18
+ ux.action.start(`Suspending branch ${target}`);
19
+ else if (!this.jsonEnabled())
20
+ this.log(`Suspending branch ${target}...`);
21
+ try {
22
+ const result = await transitionBranch(session.token, session.selectedConnector.id, name, 'suspend');
23
+ if (interactive) {
24
+ await waitForBranchOperation(session.token, result.operation.workflow_id, (status) => { ux.action.status = status; });
25
+ ux.action.stop(ux.colorize('green', '✓ parked'));
26
+ }
27
+ else if (!this.jsonEnabled())
28
+ this.log(`Branch suspension started (${terminalText(result.branch.id)}).`);
29
+ return result;
30
+ }
31
+ catch (error) {
32
+ if (interactive)
33
+ ux.action.stop('failed');
34
+ throw error;
35
+ }
36
+ }
37
+ }
@@ -18,7 +18,7 @@ export default class BranchSwitch extends ArdentCommand {
18
18
  const updated = await replaceBranchCache(this.config.configDir, session, branches);
19
19
  const branch = branches.find((candidate) => candidate.name === name);
20
20
  if (!branch)
21
- this.error(`Branch ${JSON.stringify(name)} not found in connector ${JSON.stringify(session.selectedConnector.name)}.`);
21
+ this.error(`Branch ${JSON.stringify(name)} not found in connector ${terminalText(JSON.stringify(session.selectedConnector.name))}.`);
22
22
  await saveSession(this.config.configDir, { ...updated, selectedBranch: { id: branch.id, name: branch.name, connector_id: branch.connector_id } });
23
23
  if (!this.jsonEnabled())
24
24
  this.log(`✓ Switched to branch ${terminalText(name)} in connector ${terminalText(session.selectedConnector.name)}`);
@@ -0,0 +1,11 @@
1
+ import { ArdentCommand } from '../../command.js';
2
+ import { type BranchOperation } from '../../branches/client.js';
3
+ export default class BranchWake extends ArdentCommand {
4
+ static args: {
5
+ name: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
6
+ };
7
+ static strict: boolean;
8
+ static description: string;
9
+ static helpGroup: string;
10
+ run(): Promise<BranchOperation>;
11
+ }
@@ -0,0 +1,37 @@
1
+ import { Args, ux } from '@oclif/core';
2
+ import { ArdentCommand } from '../../command.js';
3
+ import { branchName, transitionBranch, waitForBranchOperation } from '../../branches/client.js';
4
+ import { requireConnector } from '../../connectors/context.js';
5
+ import { terminalText } from '../../terminal.js';
6
+ export default class BranchWake extends ArdentCommand {
7
+ static args = { name: Args.string({ description: 'Branch name', required: true }) };
8
+ static strict = false;
9
+ static description = 'Wake a parked branch in the selected connector; a parked branch refuses connections until woken';
10
+ static helpGroup = 'Branches';
11
+ async run() {
12
+ const { argv } = await this.parse(BranchWake);
13
+ const name = branchName(argv);
14
+ const session = await requireConnector(this.config.configDir, !this.jsonEnabled());
15
+ const interactive = !this.jsonEnabled() && ux.action.type === 'spinner';
16
+ const target = `${terminalText(name)} in connector ${terminalText(session.selectedConnector.name)}`;
17
+ if (interactive)
18
+ ux.action.start(`Waking branch ${target}`);
19
+ else if (!this.jsonEnabled())
20
+ this.log(`Waking branch ${target}...`);
21
+ try {
22
+ const result = await transitionBranch(session.token, session.selectedConnector.id, name, 'wake');
23
+ if (interactive) {
24
+ await waitForBranchOperation(session.token, result.operation.workflow_id, (status) => { ux.action.status = status; });
25
+ ux.action.stop(ux.colorize('green', '✓ active'));
26
+ }
27
+ else if (!this.jsonEnabled())
28
+ this.log(`Branch wake started (${terminalText(result.branch.id)}).`);
29
+ return result;
30
+ }
31
+ catch (error) {
32
+ if (interactive)
33
+ ux.action.stop('failed');
34
+ throw error;
35
+ }
36
+ }
37
+ }
@@ -13,6 +13,8 @@ export default class ConnectorCreate extends ArdentCommand {
13
13
  static flags: {
14
14
  environment: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
15
15
  name: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
16
+ 'tls-client-cert': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
+ 'tls-client-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
16
18
  };
17
19
  run(): Promise<ConnectorCreation>;
18
20
  }