@metaphorli/pingcode-cli 0.3.2

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.
@@ -0,0 +1,389 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const os = require('node:os');
5
+ const readline = require('node:readline');
6
+ const { spawn } = require('node:child_process');
7
+
8
+ const core = require('../core');
9
+ const shared = require('./shared');
10
+
11
+ // ── Defaults ───────────────────────────────────────────────────────────
12
+
13
+ const DEFAULT_REDIRECT_URI = 'http://127.0.0.1:8765/callback';
14
+ const DEFAULT_PORT = 8765;
15
+
16
+ // ── Flag maps ──────────────────────────────────────────────────────────
17
+
18
+ const BOOLEAN_FLAGS = new Set([
19
+ '--no-browser', '--no-token-cache', '--no-workspace-cache', '--dry-run',
20
+ ]);
21
+
22
+ const STRING_FLAGS = {
23
+ '--redirect-uri': 'redirect_uri',
24
+ '--port': 'port',
25
+ '--code': 'code',
26
+ '--grant-type': 'grant_type',
27
+ '--client-id': 'client_id',
28
+ '--client-secret': 'client_secret',
29
+ '--base-url': 'base_url',
30
+ '--token-cache': 'token_cache',
31
+ '--workspace-cache': 'workspace_cache',
32
+ };
33
+
34
+ // ── Parser ─────────────────────────────────────────────────────────────
35
+
36
+ function parseLoginArgs(tokens) {
37
+ const opts = {
38
+ redirect_uri: process.env.PINGCODE_REDIRECT_URI || DEFAULT_REDIRECT_URI,
39
+ port: process.env.PINGCODE_CALLBACK_PORT
40
+ ? parseInt(process.env.PINGCODE_CALLBACK_PORT, 10)
41
+ : DEFAULT_PORT,
42
+ code: null,
43
+ grant_type: 'authorization_code',
44
+ client_id: process.env.PINGCODE_CLIENT_ID || null,
45
+ client_secret: process.env.PINGCODE_CLIENT_SECRET || null,
46
+ base_url: process.env.PINGCODE_BASE_URL || core.DEFAULT_BASE_URL,
47
+ token_cache: process.env.PINGCODE_TOKEN_CACHE || core.DEFAULT_TOKEN_CACHE,
48
+ workspace_cache: process.env.PINGCODE_WORKSPACE_CACHE || core.DEFAULT_WORKSPACE_CACHE,
49
+ no_browser: false,
50
+ no_token_cache: false,
51
+ no_workspace_cache: false,
52
+ dry_run: false,
53
+ };
54
+
55
+ let helpRequested = false;
56
+
57
+ for (let i = 0; i < tokens.length; i++) {
58
+ const arg = tokens[i];
59
+ if (arg === '--help' || arg === '-h') {
60
+ helpRequested = true;
61
+ continue;
62
+ }
63
+ if (BOOLEAN_FLAGS.has(arg)) {
64
+ const key = arg.replace(/^--/, '').replace(/-/g, '_');
65
+ opts[key] = true;
66
+ continue;
67
+ }
68
+ if (arg.startsWith('--')) {
69
+ const eqIndex = arg.indexOf('=');
70
+ let flag, value;
71
+ if (eqIndex !== -1) {
72
+ flag = arg.slice(0, eqIndex);
73
+ value = arg.slice(eqIndex + 1);
74
+ } else {
75
+ flag = arg;
76
+ if (!(flag in STRING_FLAGS)) {
77
+ throw new core.PingCodeError(`Unknown option: ${flag}`);
78
+ }
79
+ if (i + 1 < tokens.length) {
80
+ value = tokens[i + 1];
81
+ i += 1;
82
+ } else {
83
+ throw new core.PingCodeError(`Flag ${flag} requires a value`);
84
+ }
85
+ }
86
+ if (!(flag in STRING_FLAGS)) {
87
+ throw new core.PingCodeError(`Unknown option: ${flag}`);
88
+ }
89
+ opts[STRING_FLAGS[flag]] = value;
90
+ continue;
91
+ }
92
+ throw new core.PingCodeError(`Unexpected argument: ${arg}. Use auth login --help for usage.`);
93
+ }
94
+
95
+ // Convert --port to number
96
+ if (typeof opts.port === 'string') {
97
+ const parsed = parseInt(opts.port, 10);
98
+ if (Number.isNaN(parsed) || parsed < 1 || parsed > 65535) {
99
+ throw new core.PingCodeError(`Invalid port: ${opts.port}`);
100
+ }
101
+ opts.port = parsed;
102
+ }
103
+
104
+ return { opts, helpRequested };
105
+ }
106
+
107
+ // ── Client ─────────────────────────────────────────────────────────────
108
+
109
+ function createClient(opts) {
110
+ const tokenCache = opts.no_token_cache ? null : opts.token_cache;
111
+ const workspaceCache = opts.no_workspace_cache ? null : opts.workspace_cache;
112
+ return new core.PingCodeClient({
113
+ base_url: opts.base_url,
114
+ client_id: opts.client_id,
115
+ client_secret: opts.client_secret,
116
+ token_cache: tokenCache,
117
+ workspace_cache: workspaceCache,
118
+ grant_type: opts.grant_type,
119
+ });
120
+ }
121
+
122
+ // ── Browser opening ────────────────────────────────────────────────────
123
+
124
+ function openBrowser(url) {
125
+ const platform = os.platform();
126
+ let command, args;
127
+
128
+ if (platform === 'darwin') {
129
+ command = 'open';
130
+ args = [url];
131
+ } else if (platform === 'win32') {
132
+ command = 'cmd';
133
+ args = ['/c', 'start', '""', url];
134
+ } else {
135
+ command = 'xdg-open';
136
+ args = [url];
137
+ }
138
+
139
+ return new Promise((resolve, reject) => {
140
+ const child = spawn(command, args, {
141
+ stdio: 'ignore',
142
+ detached: true,
143
+ });
144
+ child.on('error', (err) => reject(err));
145
+ child.on('close', (code) => {
146
+ if (code === 0) {
147
+ resolve(true);
148
+ } else {
149
+ reject(new Error(`Browser process exited with code ${code}`));
150
+ }
151
+ });
152
+ child.unref();
153
+ });
154
+ }
155
+
156
+ // ── Help ───────────────────────────────────────────────────────────────
157
+
158
+ function printHelp() {
159
+ console.log([
160
+ 'PingCode auth — Authenticate with PingCode',
161
+ '',
162
+ 'Usage: pingcode auth <subcommand> [options]',
163
+ '',
164
+ 'Subcommands:',
165
+ ' login Authenticate with your PingCode user account (OAuth2 authorization_code)',
166
+ '',
167
+ 'Run `pingcode auth <subcommand> --help` for subcommand-specific usage.',
168
+ ].join('\n'));
169
+ }
170
+
171
+ function printLoginHelp() {
172
+ console.log([
173
+ 'PingCode auth login — Authenticate with your PingCode user account',
174
+ '',
175
+ 'Usage: pingcode auth login [options]',
176
+ '',
177
+ 'Options:',
178
+ ' --redirect-uri URI Redirect URI for OAuth callback',
179
+ ` (default: ${DEFAULT_REDIRECT_URI})`,
180
+ ` --port PORT Local callback server port (default: ${DEFAULT_PORT})`,
181
+ ' --no-browser Print the authorization URL and prompt for code',
182
+ ' --code CODE Authorization code (skip browser and callback)',
183
+ ' --grant-type TYPE OAuth grant type (default: authorization_code)',
184
+ ' --client-id ID OAuth client ID',
185
+ ' --client-secret SECRET OAuth client secret',
186
+ ' --base-url URL PingCode base URL',
187
+ ' --token-cache PATH Token cache file path',
188
+ ' --no-token-cache Disable token cache',
189
+ ' --workspace-cache PATH Workspace cache file path',
190
+ ' --no-workspace-cache Disable workspace cache',
191
+ ' --dry-run Show planned actions without executing',
192
+ ' --help Show this help',
193
+ '',
194
+ 'Credentials can also be set via environment variables:',
195
+ ' PINGCODE_CLIENT_ID OAuth client ID',
196
+ ' PINGCODE_CLIENT_SECRET OAuth client secret',
197
+ ' PINGCODE_BASE_URL PingCode base URL',
198
+ ' PINGCODE_TOKEN_CACHE Token cache file path',
199
+ ' PINGCODE_WORKSPACE_CACHE Workspace cache file path',
200
+ ' PINGCODE_REDIRECT_URI Redirect URI (default: http://127.0.0.1:8765/callback)',
201
+ ' PINGCODE_CALLBACK_PORT Callback server port (default: 8765)',
202
+ ].join('\n'));
203
+ }
204
+
205
+ // ── Helper: prompt for code from stdin ──────────────────────────────────
206
+
207
+ function promptForCode(inputFunc) {
208
+ if (inputFunc) {
209
+ return inputFunc('Paste the authorization code from the URL: ');
210
+ }
211
+ return new Promise((resolve) => {
212
+ const rl = readline.createInterface({
213
+ input: process.stdin,
214
+ output: process.stdout,
215
+ });
216
+ rl.question('Paste the authorization code from the URL: ', (code) => {
217
+ rl.close();
218
+ resolve(code.trim());
219
+ });
220
+ });
221
+ }
222
+
223
+ // ── Helper: extract path from redirect URI ─────────────────────────────
224
+
225
+ function extractCallbackPath(redirectUri) {
226
+ try {
227
+ const url = new URL(redirectUri);
228
+ return url.pathname || '/callback';
229
+ } catch (_) {
230
+ return '/callback';
231
+ }
232
+ }
233
+
234
+ // ── Dry-run: exchange request shape ────────────────────────────────────
235
+
236
+ function buildDryRunExchange(opts, code) {
237
+ const params = {
238
+ grant_type: opts.grant_type,
239
+ code: code,
240
+ client_id: opts.client_id,
241
+ client_secret: opts.client_secret,
242
+ };
243
+ // Include redirect_uri if the API requires it (authorization_code flow)
244
+ if (opts.grant_type === 'authorization_code' && opts.redirect_uri) {
245
+ params.redirect_uri = opts.redirect_uri;
246
+ }
247
+ return {
248
+ dry_run: true,
249
+ method: 'GET',
250
+ path: '/v1/auth/token',
251
+ params: params,
252
+ };
253
+ }
254
+
255
+ // ── Main ────────────────────────────────────────────────────────────────
256
+
257
+ async function runLogin(argv, inputFunc) {
258
+ const tokens = argv || [];
259
+
260
+ // No args → help
261
+ if (tokens.length === 0) {
262
+ printLoginHelp();
263
+ return;
264
+ }
265
+
266
+ const { opts, helpRequested } = parseLoginArgs(tokens);
267
+
268
+ if (helpRequested) {
269
+ printLoginHelp();
270
+ return;
271
+ }
272
+
273
+ // Validate credentials
274
+ if (!opts.client_id) {
275
+ throw new core.PingCodeError(
276
+ 'Missing credentials. Set PINGCODE_CLIENT_ID and PINGCODE_CLIENT_SECRET, ' +
277
+ 'or pass --client-id and --client-secret.\n' + core.AUTH_ENV_GUIDANCE
278
+ );
279
+ }
280
+ if (!opts.client_secret) {
281
+ throw new core.PingCodeError(
282
+ 'Missing credentials. Set PINGCODE_CLIENT_ID and PINGCODE_CLIENT_SECRET, ' +
283
+ 'or pass --client-id and --client-secret.\n' + core.AUTH_ENV_GUIDANCE
284
+ );
285
+ }
286
+
287
+ // ── Dry-run mode ──────────────────────────────────────────────────
288
+ if (opts.dry_run) {
289
+ if (opts.code) {
290
+ // With --code: show the exchange request shape
291
+ core.printJson(buildDryRunExchange(opts, opts.code));
292
+ return;
293
+ }
294
+
295
+ // Without --code: show the authorization URL (no server, no browser)
296
+ const client = createClient(opts);
297
+ const state = crypto.randomBytes(16).toString('hex');
298
+ const authUrl = client.buildAuthorizationUrl(opts.redirect_uri, state);
299
+ console.log(authUrl);
300
+ return;
301
+ }
302
+
303
+ // ── Code provided directly ────────────────────────────────────────
304
+ if (opts.code) {
305
+ const client = createClient(opts);
306
+ await client.exchangeAuthorizationCode(opts.code, opts.redirect_uri);
307
+ console.log(`User token saved for grant_type ${opts.grant_type}`);
308
+ return;
309
+ }
310
+
311
+ // ── Browser + callback flow ───────────────────────────────────────
312
+ const client = createClient(opts);
313
+ const state = crypto.randomBytes(16).toString('hex');
314
+ const authUrl = client.buildAuthorizationUrl(opts.redirect_uri, state);
315
+ const callbackPath = extractCallbackPath(opts.redirect_uri);
316
+
317
+ if (opts.no_browser) {
318
+ // Print URL and prompt for code
319
+ console.log('Open this URL in your browser to authorize:');
320
+ console.log(authUrl);
321
+ const code = await promptForCode(inputFunc);
322
+ if (!code) {
323
+ throw new core.PingCodeError('No authorization code provided');
324
+ }
325
+ await client.exchangeAuthorizationCode(code, opts.redirect_uri);
326
+ console.log(`User token saved for grant_type ${opts.grant_type}`);
327
+ return;
328
+ }
329
+
330
+ // Try to open the browser; fall back to URL + prompt on failure
331
+ let browserOpened = false;
332
+ try {
333
+ await openBrowser(authUrl);
334
+ browserOpened = true;
335
+ } catch (_) {
336
+ // Browser spawn failed; fall back to printing the URL
337
+ console.log('Could not open browser automatically.');
338
+ console.log('Open this URL in your browser to authorize:');
339
+ console.log(authUrl);
340
+ const code = await promptForCode(inputFunc);
341
+ if (!code) {
342
+ throw new core.PingCodeError('No authorization code provided');
343
+ }
344
+ await client.exchangeAuthorizationCode(code, opts.redirect_uri);
345
+ console.log(`User token saved for grant_type ${opts.grant_type}`);
346
+ return;
347
+ }
348
+
349
+ if (browserOpened) {
350
+ // Start callback server and wait for the redirect
351
+ const result = await core.startAuthCallbackServer({
352
+ port: opts.port,
353
+ path: callbackPath,
354
+ state: state,
355
+ });
356
+ await client.exchangeAuthorizationCode(result.code, opts.redirect_uri);
357
+ console.log(`User token saved for grant_type ${opts.grant_type}`);
358
+ }
359
+ }
360
+
361
+ // ── Register ───────────────────────────────────────────────────────────
362
+
363
+ const SUBCOMMANDS = ['login'];
364
+
365
+ async function run(argv, inputFunc) {
366
+ const tokens = argv || [];
367
+ const subcommand = tokens[0];
368
+
369
+ if (!subcommand || subcommand === '--help' || subcommand === '-h') {
370
+ printHelp();
371
+ return;
372
+ }
373
+
374
+ if (subcommand !== 'login') {
375
+ throw new core.PingCodeError(
376
+ `Unknown auth subcommand: ${subcommand}. Valid subcommands: ${SUBCOMMANDS.join(', ')}.`
377
+ );
378
+ }
379
+
380
+ await runLogin(tokens.slice(1), inputFunc);
381
+ }
382
+
383
+ shared.registerModule('auth', {
384
+ name: 'auth',
385
+ description: 'Authenticate with PingCode',
386
+ run,
387
+ });
388
+
389
+ module.exports = { run, runLogin, printHelp, printLoginHelp, parseLoginArgs, createClient, buildDryRunExchange };