@api-now/cli 1.0.4
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/LICENSE.md +15 -0
- package/README.md +164 -0
- package/dist/commands/auth.js +314 -0
- package/dist/commands/catalog.js +289 -0
- package/dist/commands/config.js +59 -0
- package/dist/commands/files.js +293 -0
- package/dist/commands/orgs.js +104 -0
- package/dist/commands/tokens.js +145 -0
- package/dist/index.js +83 -0
- package/dist/utils/api.js +54 -0
- package/dist/utils/config.js +202 -0
- package/dist/utils/formatter.js +195 -0
- package/dist/utils/org-setup.js +96 -0
- package/dist/utils/prompt.js +41 -0
- package/package.json +59 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# API Now Ecosystem License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 API Now
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to use, copy, modify, merge, publish, and distribute the Software **exclusively within the API Now ecosystem**.
|
|
6
|
+
|
|
7
|
+
**Restrictions:**
|
|
8
|
+
|
|
9
|
+
- The Software may not be used, copied, modified, merged, published, or distributed outside of the API Now ecosystem.
|
|
10
|
+
- The Software may not be sublicensed, sold, or used as part of any product or service that is not part of the API Now ecosystem.
|
|
11
|
+
- Any use of the Software outside the API Now ecosystem is strictly prohibited.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
14
|
+
|
|
15
|
+
For questions about permitted use, please contact: <info@apinow.app>
|
package/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# API NOW! CLI
|
|
2
|
+
|
|
3
|
+
The **API NOW! CLI** is a robust, cross-platform command-line tool designed to manage settings, handle authentication, organize directories/files, and publish assets directly to the Data Catalog from your terminal.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **OAuth2 Loopback Authentication**: Authenticate using your Google, GitHub, or LinkedIn accounts.
|
|
10
|
+
- **Interactive Onboarding**: Guided setup prompts for first-time users to configure organization names and slugs (validated against reserved keywords/taken values).
|
|
11
|
+
- **Default Organization Management**: List organizations and pin a default workspace ID.
|
|
12
|
+
- **Metadata and Media Uploads**: Create, list, and read blueprints, domains, and multimedia assets.
|
|
13
|
+
- **Data Catalog Publishing**: Publish schemas and datasets to the global/private catalog with automatic semantic versioning support.
|
|
14
|
+
- **Developer Formatting Options**: Toggle outputs between human-friendly ASCII tables and machine-readable JSON.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
Install the CLI globally from npm:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install -g @api-now/cli
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Once installed, the CLI is available as the `apinow` command.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Usage & Commands
|
|
31
|
+
|
|
32
|
+
All commands support the following global options:
|
|
33
|
+
- `--api-url <url>`: Override the target API Server URL (Default: `http://localhost:8080`). Note that the platform is actively under development and the final default URL will be set once the platform is officially released.
|
|
34
|
+
- `--format <text|json>`: Define the output layout (Default: `text`).
|
|
35
|
+
|
|
36
|
+
### 1. Configuration (`config`)
|
|
37
|
+
Read and write persistent CLI configurations stored in the OS settings folder depending on the platform:
|
|
38
|
+
- **Linux**: `~/.config/apinow-cli/config.json` (or respects `$XDG_CONFIG_HOME`)
|
|
39
|
+
- **macOS**: `~/Library/Preferences/apinow-cli/config.json`
|
|
40
|
+
- **Windows**: `%APPDATA%\apinow-cli\config.json`
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Get a configuration property (e.g. apiUrl, defaultOrg)
|
|
44
|
+
apinow config get <key>
|
|
45
|
+
|
|
46
|
+
# Set a configuration property
|
|
47
|
+
apinow config set <key> <value>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 2. Authentication (`auth`)
|
|
51
|
+
Securely log in to the API platform using OAuth2.
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# Log in using Google, GitHub, or LinkedIn
|
|
55
|
+
apinow auth login <google|github|linkedin>
|
|
56
|
+
|
|
57
|
+
# Verify current authentication status and user identity details
|
|
58
|
+
apinow auth status
|
|
59
|
+
|
|
60
|
+
# Log out and erase stored authentication tokens
|
|
61
|
+
apinow auth logout
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
*Note: On your first login or checking status with no registered organization, an interactive step will automatically guide you through creating your first organization with live slug verification.*
|
|
65
|
+
|
|
66
|
+
#### Personal Access Tokens (`auth tokens`)
|
|
67
|
+
Generate and manage Personal Access Tokens (PATs) for programmatic access.
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# List all personal access tokens
|
|
71
|
+
apinow auth tokens list
|
|
72
|
+
|
|
73
|
+
# Create a new personal access token
|
|
74
|
+
apinow auth tokens create [--name <token_name>] [--expires-at <duration_or_timestamp>]
|
|
75
|
+
|
|
76
|
+
Example:
|
|
77
|
+
apinow auth tokens create --name "Test Token" --expires-at "30 days"
|
|
78
|
+
|
|
79
|
+
# Delete a personal access token by its ID
|
|
80
|
+
apinow auth tokens delete <token_id>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### 3. Organizations (`orgs`)
|
|
84
|
+
Manage organization contexts.
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
# List all organizations you belong to
|
|
88
|
+
apinow orgs list
|
|
89
|
+
|
|
90
|
+
# Get the default configured organization
|
|
91
|
+
apinow orgs get-default
|
|
92
|
+
|
|
93
|
+
# Set default organization to avoid passing --org CLI parameters
|
|
94
|
+
apinow orgs set-default <organization_id>
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 4. Files (`files`)
|
|
98
|
+
Manage domain and API files in your organizations.
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
# List files with optional filters
|
|
102
|
+
apinow files list --org <org_id> --parent <parent_id> --kind <domain|api>
|
|
103
|
+
|
|
104
|
+
# Create file metadata and upload local media
|
|
105
|
+
apinow files create --name "My Domain File" --kind "domain" --org <org_id> --parent <parent_id> --media ./path/to/schema.ts
|
|
106
|
+
|
|
107
|
+
# Create file metadata and pipe media contents from STDIN
|
|
108
|
+
echo '{"schema": "content"}' | apinow files create --name "My Piped File" --kind "api" --org <org_id> --stdin
|
|
109
|
+
|
|
110
|
+
# Read file metadata (default) or media content (--media)
|
|
111
|
+
apinow files read --id <file_id> --org <org_id> [--media]
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### 5. Data Catalog (`catalog`)
|
|
115
|
+
Publish and browse published catalog items.
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
# Publish a local data domain file to the catalog
|
|
119
|
+
apinow catalog publish --file <file_id> --name "Catalog Name" --description "Catalog Description" --scope public --catalog-version 1.0.0
|
|
120
|
+
|
|
121
|
+
# List all published data domains in the catalog
|
|
122
|
+
apinow catalog list --scope <all|public|organization|private> [--key <domain_key>]
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Development
|
|
128
|
+
|
|
129
|
+
### Local Setup & Building
|
|
130
|
+
1. Clone the repository and install dependencies:
|
|
131
|
+
```bash
|
|
132
|
+
npm install
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
2. Build the package:
|
|
136
|
+
```bash
|
|
137
|
+
npm run build
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
3. Run the CLI locally:
|
|
141
|
+
```bash
|
|
142
|
+
# Run directly via tsx (Development)
|
|
143
|
+
npm run dev -- [command]
|
|
144
|
+
|
|
145
|
+
# Or execute the compiled build
|
|
146
|
+
node dist/index.js [command]
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Formatting and Linting
|
|
150
|
+
To format the source code with Prettier:
|
|
151
|
+
```bash
|
|
152
|
+
npm run format
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
To run the ESLint static code analysis checks:
|
|
156
|
+
```bash
|
|
157
|
+
npm run lint
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Type Checking
|
|
161
|
+
To run the TypeScript compiler in dry-run mode:
|
|
162
|
+
```bash
|
|
163
|
+
npm run typecheck
|
|
164
|
+
```
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { Argument } from 'commander';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import open from 'open';
|
|
4
|
+
import { config } from '../utils/config.js';
|
|
5
|
+
import { getSdkClient, handleApiError } from '../utils/api.js';
|
|
6
|
+
import { Formatter, style, debug } from '../utils/formatter.js';
|
|
7
|
+
import { setupFirstOrganizationIfNeeded } from '../utils/org-setup.js';
|
|
8
|
+
import { registerTokensCommands } from './tokens.js';
|
|
9
|
+
/**
|
|
10
|
+
* Registers authentication management commands (login, status, logout) with the main Commander program.
|
|
11
|
+
*
|
|
12
|
+
* @param program - The root Commander program.
|
|
13
|
+
*/
|
|
14
|
+
export function registerAuthCommands(program) {
|
|
15
|
+
const authCmd = program.command('auth').description('Manage CLI authentication');
|
|
16
|
+
registerTokensCommands(authCmd);
|
|
17
|
+
authCmd
|
|
18
|
+
.command('login')
|
|
19
|
+
.description('Authenticate with the API platform using OAuth2 (google, github, or linkedin)')
|
|
20
|
+
.addArgument(new Argument('provider', 'Authentication provider').choices(['google', 'github', 'linkedin']))
|
|
21
|
+
.action(async (provider) => {
|
|
22
|
+
const formatter = new Formatter({ format: config.resolved.format });
|
|
23
|
+
const allowedProviders = ['google', 'github', 'linkedin'];
|
|
24
|
+
if (!allowedProviders.includes(provider)) {
|
|
25
|
+
formatter.error({
|
|
26
|
+
message: `Invalid provider '${provider}'. Allowed: ${allowedProviders.join(', ')}`,
|
|
27
|
+
});
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
const apiUrl = config.resolved.apiUrl;
|
|
31
|
+
if (config.resolved.format !== 'json') {
|
|
32
|
+
console.log(`Starting authentication flow using provider '${provider}'...`);
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
await runLoginServer(apiUrl, provider, formatter);
|
|
36
|
+
formatter.success('Successfully authenticated!');
|
|
37
|
+
const client = getSdkClient();
|
|
38
|
+
await setupFirstOrganizationIfNeeded(client, formatter);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
if (err instanceof Error) {
|
|
42
|
+
formatter.error({ message: err.message });
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
formatter.error({ message: 'Authentication failed.' });
|
|
46
|
+
}
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
authCmd
|
|
51
|
+
.command('logout')
|
|
52
|
+
.description('Log out and clear the stored token')
|
|
53
|
+
.action(() => {
|
|
54
|
+
const formatter = new Formatter({ format: config.resolved.format });
|
|
55
|
+
config.clearConfigKey('token');
|
|
56
|
+
formatter.success('Logged out successfully.');
|
|
57
|
+
});
|
|
58
|
+
authCmd
|
|
59
|
+
.command('status')
|
|
60
|
+
.description('Check current authentication status')
|
|
61
|
+
.action(async () => {
|
|
62
|
+
const formatter = new Formatter({ format: config.resolved.format });
|
|
63
|
+
const resolved = config.resolved;
|
|
64
|
+
if (!resolved.token) {
|
|
65
|
+
formatter.error({ message: 'Not logged in. Use `auth login <provider>` to authenticate.' });
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const client = getSdkClient();
|
|
70
|
+
const user = await client.users.me({ token: client.token });
|
|
71
|
+
if (resolved.format === 'json') {
|
|
72
|
+
formatter.object({ authenticated: true, user });
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
console.log(style.green('✔ Authenticated successfully!'));
|
|
76
|
+
console.log(`User: ${user.name} (${user.email})`);
|
|
77
|
+
console.log(`Default Org: ${resolved.org || 'None'}`);
|
|
78
|
+
}
|
|
79
|
+
await setupFirstOrganizationIfNeeded(client, formatter);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
const apiErr = handleApiError(err);
|
|
83
|
+
formatter.error({
|
|
84
|
+
message: 'Authentication token is invalid or expired.',
|
|
85
|
+
detail: apiErr.message,
|
|
86
|
+
});
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Spins up a temporary local HTTP server to receive the OAuth authentication token
|
|
93
|
+
* from the browser-based callback page.
|
|
94
|
+
*
|
|
95
|
+
* @param apiUrl - The target backend API server base URL.
|
|
96
|
+
* @param provider - The OAuth login provider.
|
|
97
|
+
* @param formatter - Output formatter.
|
|
98
|
+
* @returns A promise resolving to the retrieved authentication token.
|
|
99
|
+
*/
|
|
100
|
+
function runLoginServer(apiUrl, provider, formatter) {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
const server = http.createServer((req, res) => {
|
|
103
|
+
if (req.method === 'GET' && req.url?.startsWith('/callback')) {
|
|
104
|
+
debug('auth', `Received request for callback: ${req.url}`);
|
|
105
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
106
|
+
const html = `
|
|
107
|
+
<!DOCTYPE html>
|
|
108
|
+
<html>
|
|
109
|
+
<head>
|
|
110
|
+
<title>API NOW! CLI Login</title>
|
|
111
|
+
<style>
|
|
112
|
+
body {
|
|
113
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
114
|
+
display: flex;
|
|
115
|
+
align-items: center;
|
|
116
|
+
justify-content: center;
|
|
117
|
+
height: 100vh;
|
|
118
|
+
margin: 0;
|
|
119
|
+
background: #121214;
|
|
120
|
+
color: #e1e1e6;
|
|
121
|
+
}
|
|
122
|
+
.card {
|
|
123
|
+
background: #202024;
|
|
124
|
+
padding: 2rem;
|
|
125
|
+
border-radius: 8px;
|
|
126
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
|
|
127
|
+
text-align: center;
|
|
128
|
+
max-width: 450px;
|
|
129
|
+
width: 100%;
|
|
130
|
+
}
|
|
131
|
+
h1 { color: #04d361; margin-top: 0; }
|
|
132
|
+
.status { font-weight: bold; margin: 1rem 0; line-height: 1.4; }
|
|
133
|
+
.diagnostics {
|
|
134
|
+
display: none;
|
|
135
|
+
text-align: left;
|
|
136
|
+
margin-top: 1.5rem;
|
|
137
|
+
padding: 1rem;
|
|
138
|
+
background: #2f2f33;
|
|
139
|
+
border-radius: 4px;
|
|
140
|
+
font-size: 0.8rem;
|
|
141
|
+
font-family: monospace;
|
|
142
|
+
white-space: pre-wrap;
|
|
143
|
+
word-break: break-all;
|
|
144
|
+
border-left: 4px solid #f75a68;
|
|
145
|
+
}
|
|
146
|
+
</style>
|
|
147
|
+
</head>
|
|
148
|
+
<body>
|
|
149
|
+
<div class="card">
|
|
150
|
+
<h1>API NOW!</h1>
|
|
151
|
+
<p class="status" id="status">Retrieving token from server...</p>
|
|
152
|
+
<div class="diagnostics" id="diagnostics">
|
|
153
|
+
<strong>Diagnostic Details:</strong>
|
|
154
|
+
<div id="diagnostics-content" style="margin-top: 0.5rem;"></div>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
<script>
|
|
158
|
+
fetch('${apiUrl}/auth/token', { credentials: 'include' })
|
|
159
|
+
.then(r => {
|
|
160
|
+
if (!r.ok) {
|
|
161
|
+
throw new Error('Token request failed: ' + r.statusText + ' (status: ' + r.status + ')');
|
|
162
|
+
}
|
|
163
|
+
return r.json();
|
|
164
|
+
})
|
|
165
|
+
.then(data => {
|
|
166
|
+
console.log("[DEBUG] Parse response JSON:", data);
|
|
167
|
+
const token = data.token || data.data?.token || data.data;
|
|
168
|
+
if (!token) {
|
|
169
|
+
throw new Error('Token not found in API response structure');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
console.log("[DEBUG] POSTing token to local server callback...");
|
|
173
|
+
return fetch('/token', {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: { 'Content-Type': 'application/json' },
|
|
176
|
+
body: JSON.stringify({ token })
|
|
177
|
+
});
|
|
178
|
+
})
|
|
179
|
+
.then(r => {
|
|
180
|
+
if (!r.ok) {
|
|
181
|
+
throw new Error('Failed to save token in CLI server (status: ' + r.status + ')');
|
|
182
|
+
}
|
|
183
|
+
document.getElementById('status').innerText = 'Authentication successful! You can close this tab now.';
|
|
184
|
+
document.getElementById('status').style.color = '#04d361';
|
|
185
|
+
})
|
|
186
|
+
.catch(err => {
|
|
187
|
+
console.error("[ERROR]", err);
|
|
188
|
+
document.getElementById('status').innerText = 'Authentication failed.';
|
|
189
|
+
document.getElementById('status').style.color = '#f75a68';
|
|
190
|
+
|
|
191
|
+
const diag = document.getElementById('diagnostics');
|
|
192
|
+
const content = document.getElementById('diagnostics-content');
|
|
193
|
+
diag.style.display = 'block';
|
|
194
|
+
content.innerText =
|
|
195
|
+
'Error Message: ' + err.message + '\\n\\n' +
|
|
196
|
+
'Originating URL: ' + window.location.href + '\\n' +
|
|
197
|
+
'Target API URL: ${apiUrl}/auth/token\\n\\n' +
|
|
198
|
+
'TIP: If you get "Unauthorized (status: 401)", make sure that:\\n' +
|
|
199
|
+
'1. You completed the login flow on the browser window that opened.\\n' +
|
|
200
|
+
'2. The host of the page you are on (' + window.location.hostname + ') matches the host of the API (' + new URL('${apiUrl}').hostname + ') to satisfy SameSite cookie policy.\\n' +
|
|
201
|
+
'3. Check browser developer console (F12) for detailed CORS/Network errors.';
|
|
202
|
+
|
|
203
|
+
fetch('/error', {
|
|
204
|
+
method: 'POST',
|
|
205
|
+
headers: { 'Content-Type': 'application/json' },
|
|
206
|
+
body: JSON.stringify({ message: err.message })
|
|
207
|
+
}).catch(console.error);
|
|
208
|
+
});
|
|
209
|
+
</script>
|
|
210
|
+
</body>
|
|
211
|
+
</html>
|
|
212
|
+
`;
|
|
213
|
+
res.end(html);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (req.method === 'POST' && req.url === '/token') {
|
|
217
|
+
let body = '';
|
|
218
|
+
req.on('data', (chunk) => {
|
|
219
|
+
body += chunk;
|
|
220
|
+
});
|
|
221
|
+
req.on('end', () => {
|
|
222
|
+
try {
|
|
223
|
+
const data = JSON.parse(body);
|
|
224
|
+
if (data.token) {
|
|
225
|
+
debug('auth', 'Storing retrieved token to CLI configuration...');
|
|
226
|
+
config.writeProperty('token', data.token);
|
|
227
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
228
|
+
res.end(JSON.stringify({ success: true }));
|
|
229
|
+
resolve(data.token);
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
233
|
+
res.end(JSON.stringify({ error: 'Token missing' }));
|
|
234
|
+
reject(new Error('Token missing from browser callback.'));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
catch (e) {
|
|
238
|
+
if (e instanceof Error) {
|
|
239
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
240
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
241
|
+
reject(new Error('Invalid body received: ' + e.message));
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
245
|
+
res.end(JSON.stringify({ error: 'Invalid body received' }));
|
|
246
|
+
reject(new Error('Invalid body received'));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
cleanup();
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (req.method === 'POST' && req.url === '/error') {
|
|
256
|
+
let body = '';
|
|
257
|
+
req.on('data', (chunk) => {
|
|
258
|
+
body += chunk;
|
|
259
|
+
});
|
|
260
|
+
req.on('end', () => {
|
|
261
|
+
try {
|
|
262
|
+
const data = JSON.parse(body);
|
|
263
|
+
debug('auth', `Received login error from browser callback: ${data.message}`);
|
|
264
|
+
reject(new Error(data.message || 'Browser login failed'));
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
reject(new Error('Browser login failed'));
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
cleanup();
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
res.writeHead(404);
|
|
276
|
+
res.end();
|
|
277
|
+
});
|
|
278
|
+
const handleSigInt = () => {
|
|
279
|
+
cleanup();
|
|
280
|
+
process.exit(130);
|
|
281
|
+
};
|
|
282
|
+
process.on('SIGINT', handleSigInt);
|
|
283
|
+
process.on('SIGTERM', handleSigInt);
|
|
284
|
+
const timeout = setTimeout(() => {
|
|
285
|
+
reject(new Error('Authentication timed out.'));
|
|
286
|
+
cleanup();
|
|
287
|
+
}, 300000);
|
|
288
|
+
function cleanup() {
|
|
289
|
+
clearTimeout(timeout);
|
|
290
|
+
process.off('SIGINT', handleSigInt);
|
|
291
|
+
process.off('SIGTERM', handleSigInt);
|
|
292
|
+
server.close();
|
|
293
|
+
}
|
|
294
|
+
server.listen(0, '127.0.0.1', async () => {
|
|
295
|
+
const port = server.address().port;
|
|
296
|
+
// Smart hostname matching: resolve 127.0.0.1 vs localhost context dynamically to enable cookie transport
|
|
297
|
+
const apiHost = new URL(apiUrl).hostname;
|
|
298
|
+
const redirectHost = apiHost === 'localhost' ? 'localhost' : '127.0.0.1';
|
|
299
|
+
const loginUrl = `${apiUrl}/auth/${provider}/redirect?r=http://${redirectHost}:${port}/callback`;
|
|
300
|
+
debug('auth', `Ephemeral loopback server listening on http://127.0.0.1:${port}`);
|
|
301
|
+
debug('auth', `Browser callback origin configured to http://${redirectHost}:${port}`);
|
|
302
|
+
if (formatter.format !== 'json') {
|
|
303
|
+
console.log(`Opening browser to log in...`);
|
|
304
|
+
console.log(`If it doesn't open automatically, navigate to:\n ${loginUrl}`);
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
await open(loginUrl);
|
|
308
|
+
}
|
|
309
|
+
catch (_err) {
|
|
310
|
+
// Ignore open failure
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
}
|