ardent-cli-beta 0.0.4 → 0.0.6

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 CHANGED
@@ -4,12 +4,25 @@
4
4
 
5
5
  The Ardent CLI is the customer command-line client for the control-plane API.
6
6
 
7
- The Oclif foundation and token authentication are implemented. `ardent login
8
- --token <token>` validates and stores an API token; `ARDENT_TOKEN` supplies the
9
- same flag non-interactively. `ardent status` and `ardent logout` inspect and
10
- remove that local session. The CLI always talks to Ardent's production control
11
- plane. Oclif provides help, version, autocomplete, and native JSON command
12
- behavior. Resource commands and resource context are not implemented yet.
7
+ The Oclif foundation and authentication are implemented. `ardent login` opens
8
+ the Ardent frontend and returns the approved API key through a loopback callback
9
+ bound with PKCE. `ardent login --token <token>` validates and stores an existing
10
+ API token for CI and automation; `ARDENT_TOKEN` supplies the same flag
11
+ non-interactively. `ardent status` and `ardent logout` inspect and remove that
12
+ local session. The CLI always talks to Ardent's production control plane. Oclif
13
+ provides help, version, autocomplete, and native JSON command behavior.
14
+ Connector credential delivery is the first resource command; general resource
15
+ commands and resource context remain owed.
16
+
17
+ `ardent connectors credentials set` delivers a source database password and a
18
+ verified CA directly to the selected environment's authoritative AWS account and
19
+ region. The caller must be logged in to Ardent and authenticated to that AWS account. Supply
20
+ the password only as `{"password":"..."}` on standard input and the CA with
21
+ `--ca-file`; `--request` is a caller-generated UUID that must be reused for a
22
+ retry. The command checks connector ownership through the control-plane API,
23
+ checks the local AWS account through STS, prints its target, then creates the CA
24
+ and password secrets in that order. It never sends either value to the control
25
+ plane or places one on the AWS command's argument vector.
13
26
 
14
27
  Set `ARDENT_API_URL` to point the CLI at a development control plane. Without
15
28
  it, the CLI uses `https://production.tryardent.com`.
@@ -18,6 +31,7 @@ it, the CLI uses `https://production.tryardent.com`.
18
31
  npm ci
19
32
  npm run build
20
33
  ./bin/run.js --help
34
+ ./bin/run.js login
21
35
  ./bin/run.js login --token <token>
22
36
  ARDENT_API_URL=http://localhost:8080 ./bin/run.js login --token <token>
23
37
  ```
@@ -5,3 +5,4 @@ export declare class APIError extends Error {
5
5
  constructor(message: string, status: number, code?: string | undefined);
6
6
  }
7
7
  export declare function request<T>(token: string, path: string, init?: RequestInit): Promise<T>;
8
+ export declare function requestWithoutAuthentication<T>(path: string, init?: RequestInit): Promise<T>;
@@ -10,13 +10,24 @@ export class APIError extends Error {
10
10
  }
11
11
  }
12
12
  export async function request(token, path, init = {}) {
13
+ return requestJSON(path, {
14
+ ...init,
15
+ headers: {
16
+ Authorization: `Bearer ${token}`,
17
+ ...init.headers,
18
+ },
19
+ });
20
+ }
21
+ export async function requestWithoutAuthentication(path, init = {}) {
22
+ return requestJSON(path, init);
23
+ }
24
+ async function requestJSON(path, init) {
13
25
  let response;
14
26
  try {
15
27
  response = await fetch(new URL(path, `${API_URL}/`), {
16
28
  ...init,
17
29
  headers: {
18
30
  Accept: 'application/json',
19
- Authorization: `Bearer ${token}`,
20
31
  ...(init.body === undefined ? {} : { 'Content-Type': 'application/json' }),
21
32
  ...init.headers,
22
33
  },
@@ -0,0 +1,8 @@
1
+ import { type Organization } from './token.js';
2
+ interface BrowserLoginOptions {
3
+ onAuthorizationURL?: (url: string, opened: boolean) => void;
4
+ open?: (url: string) => Promise<void>;
5
+ timeoutMs?: number;
6
+ }
7
+ export declare function loginWithBrowser(directory: string, options?: BrowserLoginOptions): Promise<Organization[]>;
8
+ export {};
@@ -0,0 +1,149 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import { createServer } from 'node:http';
4
+ import { setTimeout as wait } from 'node:timers/promises';
5
+ import { APIError, API_URL, requestWithoutAuthentication } from '../api/client.js';
6
+ import { loginWithToken } from './token.js';
7
+ const LOGIN_TIMEOUT_MS = 30 * 60 * 1000;
8
+ export async function loginWithBrowser(directory, options = {}) {
9
+ const state = randomValue();
10
+ const verifier = randomValue();
11
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
12
+ const deadline = new AbortController();
13
+ let callbackURI = '';
14
+ let exchanging = false;
15
+ let resolveLogin;
16
+ let rejectLogin;
17
+ const completion = new Promise((resolve, reject) => {
18
+ resolveLogin = resolve;
19
+ rejectLogin = reject;
20
+ });
21
+ const server = createServer(async (request, response) => {
22
+ const callback = new URL(request.url ?? '/', 'http://127.0.0.1');
23
+ if (request.method !== 'GET' || callback.pathname !== '/callback') {
24
+ response.writeHead(404).end();
25
+ return;
26
+ }
27
+ if (callback.searchParams.get('state') !== state) {
28
+ browserResponse(response, 400, 'Ardent CLI login was rejected because the request did not match.');
29
+ return;
30
+ }
31
+ const authorizationError = callback.searchParams.get('error');
32
+ if (authorizationError) {
33
+ const error = new Error(`Ardent CLI login was not authorized: ${authorizationError}`);
34
+ browserResponse(response, 400, error.message, () => rejectLogin(error));
35
+ return;
36
+ }
37
+ const code = callback.searchParams.get('code');
38
+ if (!code || exchanging) {
39
+ browserResponse(response, 400, 'Ardent CLI login could not be completed.');
40
+ return;
41
+ }
42
+ exchanging = true;
43
+ try {
44
+ const token = await exchange(code, verifier, deadline.signal);
45
+ const organizations = await loginWithToken(directory, token, deadline.signal);
46
+ browserResponse(response, 200, 'Return to your terminal to continue. You can close this tab.', () => resolveLogin(organizations));
47
+ }
48
+ catch (error) {
49
+ const loginError = error instanceof Error ? error : new Error(String(error));
50
+ browserResponse(response, 500, 'Ardent CLI login failed. Return to your terminal for details.', () => rejectLogin(loginError));
51
+ }
52
+ });
53
+ await new Promise((resolve, reject) => {
54
+ server.once('error', reject);
55
+ server.listen(0, '127.0.0.1', resolve);
56
+ });
57
+ const address = server.address();
58
+ callbackURI = `http://127.0.0.1:${address.port}/callback`;
59
+ const authorizationURL = new URL('/auth/cli', `${API_URL}/`);
60
+ authorizationURL.search = new URLSearchParams({
61
+ callback_uri: callbackURI,
62
+ state,
63
+ code_challenge: challenge,
64
+ code_challenge_method: 'S256',
65
+ }).toString();
66
+ let opened = true;
67
+ try {
68
+ await (options.open ?? openBrowser)(authorizationURL.toString());
69
+ }
70
+ catch {
71
+ opened = false;
72
+ }
73
+ options.onAuthorizationURL?.(authorizationURL.toString(), opened);
74
+ const timeout = setTimeout(() => {
75
+ deadline.abort();
76
+ rejectLogin(new Error('Ardent CLI login timed out. Run ardent login to try again.'));
77
+ }, options.timeoutMs ?? LOGIN_TIMEOUT_MS);
78
+ try {
79
+ return await completion;
80
+ }
81
+ finally {
82
+ clearTimeout(timeout);
83
+ await new Promise((resolve) => {
84
+ server.close(() => resolve());
85
+ server.closeAllConnections();
86
+ });
87
+ }
88
+ }
89
+ async function exchange(code, verifier, signal) {
90
+ for (;;) {
91
+ try {
92
+ const result = await requestWithoutAuthentication('/auth/cli/exchange', {
93
+ body: JSON.stringify({ code, code_verifier: verifier }),
94
+ method: 'POST',
95
+ signal: AbortSignal.any([signal, AbortSignal.timeout(90_000)]),
96
+ });
97
+ if (typeof result.key !== 'string' || result.key.length === 0)
98
+ throw new Error('Ardent API returned an invalid CLI credential.');
99
+ return result.key;
100
+ }
101
+ catch (error) {
102
+ if (!(error instanceof APIError) || error.status !== 503 || error.code !== 'workflow_result_timeout')
103
+ throw error;
104
+ await wait(5000, undefined, { signal });
105
+ }
106
+ }
107
+ }
108
+ function randomValue() {
109
+ return randomBytes(32).toString('base64url');
110
+ }
111
+ function browserResponse(response, status, message, complete) {
112
+ response.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
113
+ const escapedMessage = message.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
114
+ const succeeded = status < 400;
115
+ response.end(`<!doctype html>
116
+ <html lang="en">
117
+ <head>
118
+ <meta charset="utf-8">
119
+ <meta name="viewport" content="width=device-width, initial-scale=1">
120
+ <title>Ardent CLI</title>
121
+ <style>
122
+ * { box-sizing: border-box; }
123
+ html { color-scheme: dark; }
124
+ body { margin: 0; background: #0a0a0a; color: #f4f4f5; font-family: "Helvetica Neue", Helvetica, sans-serif; }
125
+ header { display: flex; height: 48px; align-items: center; border-bottom: 1px solid #242424; padding: 0 20px; color: #d4d4d8; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; font-weight: 600; letter-spacing: .16em; }
126
+ main { min-height: calc(100vh - 48px); display: grid; place-items: center; padding: 24px; }
127
+ section { width: 100%; max-width: 400px; border: 1px solid #2a2a2a; background: #111; }
128
+ .content { padding: 24px; }
129
+ .status { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; color: ${succeeded ? '#22c55e' : '#f87171'}; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; }
130
+ .indicator { width: 7px; height: 7px; background: currentColor; }
131
+ h1 { margin: 0 0 8px; font-size: 24px; line-height: 1.2; letter-spacing: -.02em; }
132
+ p { margin: 0; color: #a1a1aa; font-size: 15px; line-height: 1.6; }
133
+ </style>
134
+ </head>
135
+ <body><header>ARDENT</header><main><section><div class="content"><div class="status"><span class="indicator"></span>${succeeded ? 'Connected' : 'Not connected'}</div><h1>${succeeded ? 'You\'re signed in' : 'Login failed'}</h1><p>${escapedMessage}</p></div></section></main></body>
136
+ </html>`, complete);
137
+ }
138
+ async function openBrowser(url) {
139
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32' : 'xdg-open';
140
+ const arguments_ = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
141
+ await new Promise((resolve, reject) => {
142
+ const child = spawn(command, arguments_, { detached: true, stdio: 'ignore' });
143
+ child.once('error', reject);
144
+ child.once('spawn', () => {
145
+ child.unref();
146
+ resolve();
147
+ });
148
+ });
149
+ }
@@ -2,4 +2,4 @@ export interface Organization {
2
2
  id: string;
3
3
  name: string;
4
4
  }
5
- export declare function loginWithToken(directory: string, value: string): Promise<Organization[]>;
5
+ export declare function loginWithToken(directory: string, value: string, signal?: AbortSignal): Promise<Organization[]>;
@@ -1,10 +1,10 @@
1
1
  import { request } from '../api/client.js';
2
2
  import { saveSession } from './credentials.js';
3
- export async function loginWithToken(directory, value) {
3
+ export async function loginWithToken(directory, value, signal) {
4
4
  const token = value.trim();
5
5
  if (!token)
6
6
  throw new Error('Ardent API token cannot be empty.');
7
- const organizations = await request(token, '/organizations');
7
+ const organizations = await request(token, '/organizations', { signal });
8
8
  if (!Array.isArray(organizations) ||
9
9
  organizations.some((organization) => typeof organization !== 'object' ||
10
10
  organization === null ||
@@ -12,6 +12,7 @@ export async function loginWithToken(directory, value) {
12
12
  typeof organization.name !== 'string')) {
13
13
  throw new Error('Ardent API returned an invalid organization response.');
14
14
  }
15
+ signal?.throwIfAborted();
15
16
  await saveSession(directory, { token });
16
17
  return organizations;
17
18
  }
@@ -0,0 +1,18 @@
1
+ export interface Target {
2
+ account: string;
3
+ region: string;
4
+ environment: string;
5
+ connector: string;
6
+ request: string;
7
+ }
8
+ type Run = (arguments_: string[], input?: string) => Promise<string>;
9
+ export declare function validateCA(pem: string, now?: Date): string;
10
+ export declare function parsePasswordDocument(raw: string): string;
11
+ export declare function deliver(target: Target, ca: string, password: string, run?: Run): Promise<void>;
12
+ export declare class AWSFailure extends Error {
13
+ readonly notFound: boolean;
14
+ constructor(notFound?: boolean);
15
+ }
16
+ export declare function account(run?: Run): Promise<string>;
17
+ export declare function runAWS(arguments_: string[], input?: string, executable?: string, timeoutMilliseconds?: number): Promise<string>;
18
+ export {};
@@ -0,0 +1,143 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { timingSafeEqual, X509Certificate } from 'node:crypto';
3
+ const secretName = (target, suffix) => `ardent/${target.environment}/connectors/${target.connector}/${suffix}`;
4
+ export function validateCA(pem, now = new Date()) {
5
+ if (Buffer.byteLength(pem) > 64 * 1024 || !pem.endsWith('\n'))
6
+ throw new Error('The source CA must be a newline-terminated PEM certificate under 64 KiB.');
7
+ if ((pem.match(/-----BEGIN CERTIFICATE-----/g) ?? []).length !== 1 || /PRIVATE KEY/.test(pem))
8
+ throw new Error('The source CA must contain exactly one certificate and no private key.');
9
+ let certificate;
10
+ try {
11
+ certificate = new X509Certificate(pem);
12
+ }
13
+ catch {
14
+ throw new Error('The source CA is not a valid PEM certificate.');
15
+ }
16
+ if (!certificate.ca || now < new Date(certificate.validFrom) || now >= new Date(certificate.validTo))
17
+ throw new Error('The source CA must be a currently valid certificate authority.');
18
+ return pem;
19
+ }
20
+ export function parsePasswordDocument(raw) {
21
+ if (Buffer.byteLength(raw) > 64 * 1024)
22
+ throw new Error('The source credential document is too large.');
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(raw);
26
+ }
27
+ catch {
28
+ throw new Error('Standard input must be one JSON object containing password.');
29
+ }
30
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed) || Object.keys(parsed).length !== 1 || typeof parsed.password !== 'string' || parsed.password.length === 0 || parsed.password.includes('\0')) {
31
+ throw new Error('Standard input must be one JSON object containing a non-empty password and no other fields.');
32
+ }
33
+ return parsed.password;
34
+ }
35
+ export async function deliver(target, ca, password, run = runAWS) {
36
+ for (const [name, value] of [
37
+ [secretName(target, 'source-database-ca'), ca],
38
+ [secretName(target, 'source-database-password'), password],
39
+ ]) {
40
+ const description = await describe(run, target, name);
41
+ if (description !== undefined) {
42
+ const tags = new Map((description.Tags ?? []).map((tag) => [tag.Key, tag.Value]));
43
+ if (tags.get('ardent:credential-request') !== target.request || tags.get('ardent:environment') !== target.environment || tags.get('ardent:connector') !== target.connector) {
44
+ throw new Error(`The deterministic ${name.endsWith('-ca') ? 'CA' : 'password'} secret already belongs to another request.`);
45
+ }
46
+ const encodedExisting = await run(['secretsmanager', 'get-secret-value', '--region', target.region, '--secret-id', name, '--query', 'SecretString', '--output', 'json']);
47
+ let existing;
48
+ try {
49
+ existing = JSON.parse(encodedExisting);
50
+ }
51
+ catch {
52
+ throw new AWSFailure();
53
+ }
54
+ if (typeof existing !== 'string')
55
+ throw new AWSFailure();
56
+ const existingBytes = Buffer.from(existing);
57
+ const requestedBytes = Buffer.from(value);
58
+ if (existingBytes.length !== requestedBytes.length || !timingSafeEqual(existingBytes, requestedBytes)) {
59
+ throw new Error(`The deterministic ${name.endsWith('-ca') ? 'CA' : 'password'} secret already has different contents for this request.`);
60
+ }
61
+ continue;
62
+ }
63
+ await run([
64
+ 'secretsmanager', 'create-secret', '--region', target.region, '--name', name,
65
+ '--secret-string', 'file:///dev/stdin', '--tags',
66
+ `Key=ardent:credential-request,Value=${target.request}`,
67
+ `Key=ardent:environment,Value=${target.environment}`,
68
+ `Key=ardent:connector,Value=${target.connector}`,
69
+ '--output', 'json',
70
+ ], value);
71
+ }
72
+ }
73
+ async function describe(run, target, name) {
74
+ try {
75
+ return JSON.parse(await run(['secretsmanager', 'describe-secret', '--region', target.region, '--secret-id', name, '--output', 'json']));
76
+ }
77
+ catch (error) {
78
+ if (error instanceof AWSFailure && error.notFound)
79
+ return undefined;
80
+ throw error;
81
+ }
82
+ }
83
+ export class AWSFailure extends Error {
84
+ notFound;
85
+ constructor(notFound = false) {
86
+ super(notFound ? 'The AWS secret does not exist.' : 'The AWS credential operation failed.');
87
+ this.notFound = notFound;
88
+ }
89
+ }
90
+ export async function account(run = runAWS) {
91
+ const value = (await run(['sts', 'get-caller-identity', '--query', 'Account', '--output', 'text'])).trim();
92
+ if (!/^\d{12}$/.test(value))
93
+ throw new Error('AWS returned an invalid account identity.');
94
+ return value;
95
+ }
96
+ export function runAWS(arguments_, input, executable = 'aws', timeoutMilliseconds = 30_000) {
97
+ return new Promise((resolve, reject) => {
98
+ const child = spawn(executable, arguments_, { shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
99
+ let settled = false;
100
+ let stdout = '';
101
+ let stderr = '';
102
+ let timeout;
103
+ const finish = (error) => {
104
+ if (settled)
105
+ return;
106
+ settled = true;
107
+ if (timeout)
108
+ clearTimeout(timeout);
109
+ if (error)
110
+ reject(error);
111
+ else
112
+ resolve(stdout);
113
+ };
114
+ timeout = setTimeout(() => {
115
+ child.kill('SIGKILL');
116
+ finish(new AWSFailure());
117
+ }, timeoutMilliseconds);
118
+ const failInput = () => {
119
+ child.kill('SIGKILL');
120
+ finish(new AWSFailure());
121
+ };
122
+ child.stdout.setEncoding('utf8').on('data', (chunk) => {
123
+ if (stdout.length <= 128 * 1024)
124
+ stdout += chunk;
125
+ });
126
+ child.stderr.setEncoding('utf8').on('data', (chunk) => {
127
+ if (stderr.length <= 16 * 1024)
128
+ stderr += chunk;
129
+ });
130
+ child.on('error', () => finish(new AWSFailure()));
131
+ child.stdin.on('error', failInput);
132
+ child.on('close', (status) => {
133
+ if (status === 0 && Buffer.byteLength(stdout) <= 128 * 1024)
134
+ finish();
135
+ else
136
+ finish(new AWSFailure(/ResourceNotFoundException/.test(stderr)));
137
+ });
138
+ child.stdin.end(input, (error) => {
139
+ if (error)
140
+ failInput();
141
+ });
142
+ });
143
+ }
@@ -0,0 +1,20 @@
1
+ import { ArdentCommand } from '../../../command.js';
2
+ export default class SetSourceCredentials extends ArdentCommand {
3
+ static description: string;
4
+ static helpGroup: string;
5
+ static flags: {
6
+ organization: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
7
+ project: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
+ environment: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
+ connector: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
+ request: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
+ 'ca-file': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
12
+ 'password-stdin': import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
+ };
14
+ run(): Promise<{
15
+ connector: string;
16
+ environment: string;
17
+ account: string;
18
+ delivered: true;
19
+ }>;
20
+ }
@@ -0,0 +1,59 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { request } from '../../../api/client.js';
4
+ import { loadSession } from '../../../auth/credentials.js';
5
+ import { account, deliver, parsePasswordDocument, validateCA } from '../../../cloud/source-credentials.js';
6
+ import { ArdentCommand } from '../../../command.js';
7
+ export default class SetSourceCredentials extends ArdentCommand {
8
+ static description = 'Deliver a connector password and source CA directly to its environment AWS account';
9
+ static helpGroup = 'Connectors';
10
+ static flags = {
11
+ organization: Flags.string({ required: true, description: 'Owning Ardent organization ID' }),
12
+ project: Flags.string({ required: true, description: 'Owning Ardent project ID' }),
13
+ environment: Flags.string({ required: true, description: 'Target Ardent environment ID' }),
14
+ connector: Flags.string({ required: true, description: 'Target Ardent connector ID' }),
15
+ request: Flags.string({ required: true, description: 'Stable UUID for safe retries' }),
16
+ 'ca-file': Flags.string({ required: true, description: 'PEM source certificate-authority file' }),
17
+ 'password-stdin': Flags.boolean({ required: true, description: 'Read {"password":"…"} from standard input' }),
18
+ };
19
+ async run() {
20
+ const { flags } = await this.parse(SetSourceCredentials);
21
+ const uuid = '[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}';
22
+ if (!new RegExp(`^environment_${uuid}$`).test(flags.environment) || !new RegExp(`^connector_${uuid}$`).test(flags.connector) || !new RegExp(`^${uuid}$`).test(flags.request))
23
+ this.error('Environment, connector, or request identity is invalid.');
24
+ const session = await loadSession(this.config.configDir);
25
+ if (!session)
26
+ this.error('Log in before delivering source credentials.');
27
+ const connectors = await request(session.token, `/connectors?organization_id=${encodeURIComponent(flags.organization)}&project_id=${encodeURIComponent(flags.project)}`);
28
+ if (!connectors.some((connector) => connector.id === flags.connector && connector.environment_id === flags.environment))
29
+ this.error('The selected connector does not belong to the selected project and environment.');
30
+ const environments = await request(session.token, `/connector-environments?organization_id=${encodeURIComponent(flags.organization)}&project_id=${encodeURIComponent(flags.project)}`);
31
+ const environment = environments.find((candidate) => candidate.id === flags.environment);
32
+ if (!environment || !/^\d{12}$/.test(environment.cloud_account_id) || !/^[a-z]{2}(?:-gov)?-[a-z]+-\d$/.test(environment.cloud_region))
33
+ this.error('The selected environment has no authoritative AWS placement.');
34
+ const actualAccount = await account();
35
+ if (actualAccount !== environment.cloud_account_id)
36
+ this.error(`AWS is authenticated to account ${actualAccount}, not the environment account ${environment.cloud_account_id}.`);
37
+ const ca = validateCA(await readFile(flags['ca-file'], 'utf8'));
38
+ const input = await readStdin();
39
+ const password = parsePasswordDocument(input);
40
+ if (!this.jsonEnabled())
41
+ this.log(`Delivering source credentials for ${flags.connector} in ${flags.environment} to AWS account ${environment.cloud_account_id} (${environment.cloud_region})`);
42
+ await deliver({ account: environment.cloud_account_id, region: environment.cloud_region, environment: flags.environment, connector: flags.connector, request: flags.request }, ca, password);
43
+ if (!this.jsonEnabled())
44
+ this.log('✓ Source password and CA delivered');
45
+ return { connector: flags.connector, environment: flags.environment, account: environment.cloud_account_id, delivered: true };
46
+ }
47
+ }
48
+ async function readStdin() {
49
+ const chunks = [];
50
+ let size = 0;
51
+ for await (const chunk of process.stdin) {
52
+ const value = Buffer.from(chunk);
53
+ size += value.length;
54
+ if (size > 64 * 1024)
55
+ throw new Error('The source credential document is too large.');
56
+ chunks.push(value);
57
+ }
58
+ return Buffer.concat(chunks).toString('utf8');
59
+ }
@@ -1,4 +1,5 @@
1
1
  import { Flags } from '@oclif/core';
2
+ import { loginWithBrowser } from '../auth/browser.js';
2
3
  import { loginWithToken } from '../auth/token.js';
3
4
  import { ArdentCommand } from '../command.js';
4
5
  export default class Login extends ArdentCommand {
@@ -9,16 +10,19 @@ export default class Login extends ArdentCommand {
9
10
  };
10
11
  async run() {
11
12
  const { flags } = await this.parse(Login);
12
- if (!flags.token)
13
- this.error('Interactive login is not implemented yet.');
14
- const organizations = await loginWithToken(this.config.configDir, flags.token);
15
- if (!this.jsonEnabled()) {
16
- this.log('✓ Logged in to Ardent');
17
- if (organizations.length === 1)
18
- this.log(` Organization: ${organizations[0].name} (${organizations[0].id})`);
19
- else
20
- this.log(` Organizations: ${organizations.length}`);
21
- }
13
+ const organizations = flags.token
14
+ ? await loginWithToken(this.config.configDir, flags.token)
15
+ : await loginWithBrowser(this.config.configDir, {
16
+ onAuthorizationURL: (url, opened) => {
17
+ this.log(opened ? 'Opening Ardent in your browser…' : `Open this URL to log in:\n\n${url}`);
18
+ this.log('Complete login in your browser. Keep this terminal open.');
19
+ },
20
+ });
21
+ this.log('✓ Logged in to Ardent');
22
+ if (organizations.length === 1)
23
+ this.log(` Organization: ${organizations[0].name} (${organizations[0].id})`);
24
+ else
25
+ this.log(` Organizations: ${organizations.length}`);
22
26
  return { authenticated: true, organizations };
23
27
  }
24
28
  }
@@ -94,7 +94,93 @@
94
94
  "commands",
95
95
  "status.js"
96
96
  ]
97
+ },
98
+ "connectors:credentials:set": {
99
+ "aliases": [],
100
+ "args": {},
101
+ "description": "Deliver a connector password and source CA directly to its environment AWS account",
102
+ "flags": {
103
+ "json": {
104
+ "description": "Format output as json.",
105
+ "helpGroup": "GLOBAL",
106
+ "name": "json",
107
+ "allowNo": false,
108
+ "type": "boolean"
109
+ },
110
+ "organization": {
111
+ "description": "Owning Ardent organization ID",
112
+ "name": "organization",
113
+ "required": true,
114
+ "hasDynamicHelp": false,
115
+ "multiple": false,
116
+ "type": "option"
117
+ },
118
+ "project": {
119
+ "description": "Owning Ardent project ID",
120
+ "name": "project",
121
+ "required": true,
122
+ "hasDynamicHelp": false,
123
+ "multiple": false,
124
+ "type": "option"
125
+ },
126
+ "environment": {
127
+ "description": "Target Ardent environment ID",
128
+ "name": "environment",
129
+ "required": true,
130
+ "hasDynamicHelp": false,
131
+ "multiple": false,
132
+ "type": "option"
133
+ },
134
+ "connector": {
135
+ "description": "Target Ardent connector ID",
136
+ "name": "connector",
137
+ "required": true,
138
+ "hasDynamicHelp": false,
139
+ "multiple": false,
140
+ "type": "option"
141
+ },
142
+ "request": {
143
+ "description": "Stable UUID for safe retries",
144
+ "name": "request",
145
+ "required": true,
146
+ "hasDynamicHelp": false,
147
+ "multiple": false,
148
+ "type": "option"
149
+ },
150
+ "ca-file": {
151
+ "description": "PEM source certificate-authority file",
152
+ "name": "ca-file",
153
+ "required": true,
154
+ "hasDynamicHelp": false,
155
+ "multiple": false,
156
+ "type": "option"
157
+ },
158
+ "password-stdin": {
159
+ "description": "Read {\"password\":\"…\"} from standard input",
160
+ "name": "password-stdin",
161
+ "required": true,
162
+ "allowNo": false,
163
+ "type": "boolean"
164
+ }
165
+ },
166
+ "hasDynamicHelp": false,
167
+ "hiddenAliases": [],
168
+ "id": "connectors:credentials:set",
169
+ "pluginAlias": "ardent-cli-beta",
170
+ "pluginName": "ardent-cli-beta",
171
+ "pluginType": "core",
172
+ "strict": true,
173
+ "enableJsonFlag": true,
174
+ "helpGroup": "Connectors",
175
+ "isESM": true,
176
+ "relativePath": [
177
+ "dist",
178
+ "commands",
179
+ "connectors",
180
+ "credentials",
181
+ "set.js"
182
+ ]
97
183
  }
98
184
  },
99
- "version": "0.0.4"
185
+ "version": "0.0.6"
100
186
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ardent-cli-beta",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "The Ardent command-line interface",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",