ardent-cli-beta 0.0.5 → 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,13 +4,15 @@
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. Connector credential delivery is the first resource command; general
13
- resource commands and resource context remain owed.
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.
14
16
 
15
17
  `ardent connectors credentials set` delivers a source database password and a
16
18
  verified CA directly to the selected environment's authoritative AWS account and
@@ -29,6 +31,7 @@ it, the CLI uses `https://production.tryardent.com`.
29
31
  npm ci
30
32
  npm run build
31
33
  ./bin/run.js --help
34
+ ./bin/run.js login
32
35
  ./bin/run.js login --token <token>
33
36
  ARDENT_API_URL=http://localhost:8080 ./bin/run.js login --token <token>
34
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
  }
@@ -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
  }
@@ -182,5 +182,5 @@
182
182
  ]
183
183
  }
184
184
  },
185
- "version": "0.0.5"
185
+ "version": "0.0.6"
186
186
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ardent-cli-beta",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "description": "The Ardent command-line interface",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",