@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,575 @@
1
+ 'use strict';
2
+
3
+ const readline = require('node:readline');
4
+
5
+ const core = require('../core');
6
+ const shared = require('./shared');
7
+
8
+ // ── Interactive selection ──────────────────────────────────────────
9
+
10
+ async function promptChoice(label, items, inputFunc) {
11
+ if (!items || items.length === 0) {
12
+ throw new core.PingCodeError(`No ${label} options are available`);
13
+ }
14
+ console.log(`\nSelect current ${label}:`);
15
+ for (let index = 0; index < items.length; index++) {
16
+ const item = items[index];
17
+ const entity = core.normalizedEntity(item);
18
+ const details = [];
19
+ const itemIdentifier = entity.identifier;
20
+ const itemEmail = entity.email;
21
+ const itemName = entity.name;
22
+ if (typeof itemIdentifier === 'string' && itemIdentifier) {
23
+ details.push(itemIdentifier);
24
+ }
25
+ if (typeof itemName === 'string' && itemName && itemName !== core.displayName(item)) {
26
+ details.push(itemName);
27
+ }
28
+ if (typeof itemEmail === 'string' && itemEmail) {
29
+ details.push(itemEmail);
30
+ }
31
+ const suffix = details.length > 0 ? ` (${details.join(', ')})` : '';
32
+ console.log(` ${index + 1}. ${core.displayName(item)} [${core.itemId(item, label)}]${suffix}`);
33
+ }
34
+
35
+ while (true) {
36
+ const raw = await inputFunc(`Enter ${label} number, id, or name: `);
37
+ const trimmed = raw.trim();
38
+ if (!trimmed) continue;
39
+ if (/^\d+$/.test(trimmed)) {
40
+ const index = parseInt(trimmed, 10);
41
+ if (index >= 1 && index <= items.length) {
42
+ return items[index - 1];
43
+ }
44
+ }
45
+ try {
46
+ return core.findCachedItem(items, trimmed, label);
47
+ } catch (exc) {
48
+ console.log(`Invalid ${label} selection: ${exc.message}`);
49
+ }
50
+ }
51
+ }
52
+
53
+ async function fetchProjects(client, refresh = false) {
54
+ if (refresh || !client.workspaceCache.projects || typeof client.workspaceCache.projects !== 'object') {
55
+ return await core.cacheProjects(client);
56
+ }
57
+ return client.workspaceCache.projects;
58
+ }
59
+
60
+ async function fetchSprints(client, projectId, refresh = false) {
61
+ const sprintsCache = client.workspaceCache.sprints || {};
62
+ if (refresh || !sprintsCache[projectId] || typeof sprintsCache[projectId] !== 'object') {
63
+ return await core.cacheSprints(client, projectId);
64
+ }
65
+ return sprintsCache[projectId];
66
+ }
67
+
68
+ async function fetchUsers(client, projectId, refresh = false) {
69
+ const usersCache = client.workspaceCache.users;
70
+ if (
71
+ refresh ||
72
+ !usersCache ||
73
+ typeof usersCache !== 'object' ||
74
+ (usersCache.project_id !== null && usersCache.project_id !== undefined && usersCache.project_id !== projectId)
75
+ ) {
76
+ return await core.cacheUsers(client, projectId);
77
+ }
78
+ return usersCache;
79
+ }
80
+
81
+ async function cacheContext(client, project, sprint, user) {
82
+ if (!client.workspaceCache.preferences) client.workspaceCache.preferences = {};
83
+ const preferences = client.workspaceCache.preferences;
84
+ preferences.current_project_id = core.itemId(project, 'project');
85
+ preferences.current_project_name = core.displayName(project);
86
+ preferences.current_sprint_id = core.itemId(sprint, 'sprint');
87
+ preferences.current_sprint_name = core.displayName(sprint);
88
+ preferences.current_user_id = core.itemId(user, 'user');
89
+ preferences.current_user_name = core.displayName(user);
90
+ client.saveWorkspaceCache();
91
+ return {
92
+ message: 'PingCode workspace context cached',
93
+ workspace_cache: client.workspaceCachePath || null,
94
+ preferences,
95
+ };
96
+ }
97
+
98
+ // ── Help ───────────────────────────────────────────────────────────
99
+
100
+ function printHelp(subcommand) {
101
+ if (subcommand === 'init') {
102
+ console.log([
103
+ 'Usage: pingcode context init [options]',
104
+ '',
105
+ 'Interactively configure PingCode workspace context.',
106
+ 'Prompts for project, sprint/iteration, and user selection,',
107
+ 'then writes the results to the workspace cache.',
108
+ '',
109
+ 'Options:',
110
+ ' --workspace-cache PATH Cache file path',
111
+ ' --no-workspace-cache Disable workspace cache',
112
+ ' --refresh Re-fetch dictionaries from API',
113
+ ].join('\n'));
114
+ return;
115
+ }
116
+
117
+ if (subcommand === 'list') {
118
+ console.log([
119
+ 'Usage: pingcode context list [options]',
120
+ '',
121
+ 'Print current workspace preferences and a summary of cached dictionaries.',
122
+ '',
123
+ 'Options:',
124
+ ' --workspace-cache PATH Cache file path',
125
+ ' --no-workspace-cache Disable workspace cache',
126
+ ].join('\n'));
127
+ return;
128
+ }
129
+
130
+ if (subcommand === 'set-current-user') {
131
+ console.log([
132
+ 'Usage: pingcode context set-current-user <id|name|@me> [options]',
133
+ '',
134
+ 'Set the current PingCode user for this workspace.',
135
+ '',
136
+ 'Arguments:',
137
+ ' <id|name|@me> User ID, cached user name/display name, or @me to use the',
138
+ ' already-cached current user ID.',
139
+ '',
140
+ 'Options:',
141
+ ' --workspace-cache PATH Cache file path',
142
+ ' --no-workspace-cache Disable workspace cache (requires env vars)',
143
+ ' --dry-run Show what would be set without writing',
144
+ ].join('\n'));
145
+ return;
146
+ }
147
+
148
+ if (subcommand === 'set-current-project') {
149
+ console.log([
150
+ 'Usage: pingcode context set-current-project <id|name> [options]',
151
+ '',
152
+ 'Set the current PingCode project for this workspace.',
153
+ '',
154
+ 'Arguments:',
155
+ ' <id|name> Project ID or cached project name.',
156
+ '',
157
+ 'Options:',
158
+ ' --workspace-cache PATH Cache file path',
159
+ ' --no-workspace-cache Disable workspace cache',
160
+ ' --dry-run Show what would be set without writing',
161
+ ].join('\n'));
162
+ return;
163
+ }
164
+
165
+ if (subcommand === 'set-current-sprint') {
166
+ console.log([
167
+ 'Usage: pingcode context set-current-sprint <id|name> [options]',
168
+ '',
169
+ 'Set the current PingCode sprint/iteration for this workspace.',
170
+ '',
171
+ 'Arguments:',
172
+ ' <id|name> Sprint ID or cached sprint name.',
173
+ '',
174
+ 'Options:',
175
+ ' --workspace-cache PATH Cache file path',
176
+ ' --no-workspace-cache Disable workspace cache',
177
+ ' --dry-run Show what would be set without writing',
178
+ ].join('\n'));
179
+ return;
180
+ }
181
+
182
+ // Default: module-level help
183
+ console.log([
184
+ 'PingCode context — Manage workspace context',
185
+ '',
186
+ 'Usage: pingcode context <subcommand> [options]',
187
+ '',
188
+ 'Subcommands:',
189
+ ' init Initialize workspace context (interactive)',
190
+ ' set-current-user <id> Set the current user (id, name, or @me)',
191
+ ' set-current-project <id> Set the current project (id or name)',
192
+ ' set-current-sprint <id> Set the current sprint/iteration (id or name)',
193
+ ' list Show current preferences and cached dictionary summary',
194
+ '',
195
+ 'Global options:',
196
+ ' --base-url URL PingCode base URL',
197
+ ' --workspace-cache PATH Workspace cache file path',
198
+ ' --no-workspace-cache Disable workspace cache',
199
+ ' --dry-run Show what would be done without executing',
200
+ ' --grant-type TYPE OAuth grant type: client_credentials, authorization_code, or auto (default; uses cached token type)',
201
+ ' --help Show this help',
202
+ ].join('\n'));
203
+ }
204
+
205
+ // ── Parser ─────────────────────────────────────────────────────────
206
+
207
+ const BOOLEAN_FLAGS = new Set([
208
+ '--no-workspace-cache', '--no-token-cache',
209
+ '--dry-run', '--refresh',
210
+ ]);
211
+
212
+ const STRING_FLAGS = {
213
+ '--base-url': 'base_url',
214
+ '--client-id': 'client_id',
215
+ '--client-secret': 'client_secret',
216
+ '--token': 'token',
217
+ '--workspace-cache': 'workspace_cache',
218
+ '--grant-type': 'grant_type',
219
+ };
220
+
221
+ function parseContextArgs(tokens) {
222
+ const opts = {
223
+ base_url: process.env.PINGCODE_BASE_URL || core.DEFAULT_BASE_URL,
224
+ client_id: process.env.PINGCODE_CLIENT_ID || null,
225
+ client_secret: process.env.PINGCODE_CLIENT_SECRET || null,
226
+ token: process.env.PINGCODE_ACCESS_TOKEN || null,
227
+ workspace_cache: process.env.PINGCODE_WORKSPACE_CACHE || core.DEFAULT_WORKSPACE_CACHE,
228
+ no_workspace_cache: false,
229
+ no_token_cache: false,
230
+ dry_run: false,
231
+ refresh: false,
232
+ grant_type: 'auto',
233
+ };
234
+
235
+ const positionals = [];
236
+ let helpRequested = false;
237
+
238
+ for (let i = 0; i < tokens.length; i++) {
239
+ const arg = tokens[i];
240
+ if (arg === '--help' || arg === '-h') {
241
+ helpRequested = true;
242
+ continue;
243
+ }
244
+ if (BOOLEAN_FLAGS.has(arg)) {
245
+ const key = arg.replace(/^--/, '').replace(/-/g, '_');
246
+ opts[key] = true;
247
+ continue;
248
+ }
249
+ if (arg.startsWith('--')) {
250
+ const eqIndex = arg.indexOf('=');
251
+ let flag, value;
252
+ if (eqIndex !== -1) {
253
+ flag = arg.slice(0, eqIndex);
254
+ value = arg.slice(eqIndex + 1);
255
+ } else {
256
+ flag = arg;
257
+ if (i + 1 < tokens.length) {
258
+ value = tokens[i + 1];
259
+ i += 1;
260
+ } else {
261
+ throw new core.PingCodeError(`Flag ${flag} requires a value`);
262
+ }
263
+ }
264
+ if (!(flag in STRING_FLAGS)) {
265
+ throw new core.PingCodeError(`Unknown option: ${flag}`);
266
+ }
267
+ opts[STRING_FLAGS[flag]] = value;
268
+ continue;
269
+ }
270
+ positionals.push(arg);
271
+ }
272
+
273
+ return {
274
+ subcommand: positionals[0] || null,
275
+ value: positionals[1] || null,
276
+ opts,
277
+ helpRequested,
278
+ };
279
+ }
280
+
281
+ // ── Client ─────────────────────────────────────────────────────────
282
+
283
+ function createClient(opts) {
284
+ const tokenCache = opts.no_token_cache
285
+ ? null
286
+ : (process.env.PINGCODE_TOKEN_CACHE || core.DEFAULT_TOKEN_CACHE);
287
+ const workspaceCache = opts.no_workspace_cache ? null : opts.workspace_cache;
288
+ return new core.PingCodeClient({
289
+ base_url: opts.base_url,
290
+ client_id: opts.client_id,
291
+ client_secret: opts.client_secret,
292
+ token: opts.token,
293
+ token_cache: tokenCache,
294
+ workspace_cache: workspaceCache,
295
+ grant_type: opts.grant_type,
296
+ });
297
+ }
298
+
299
+ // ── Dictionary counts (copied from config.js; not exported from core) ──
300
+
301
+ function countDictionaryEntries(cache) {
302
+ const counts = {};
303
+
304
+ const users = core.pageValues(cache.users);
305
+ counts.users = users.length;
306
+
307
+ const projects = core.pageValues(cache.projects);
308
+ counts.projects = projects.length;
309
+
310
+ let sprintTotal = 0;
311
+ for (const v of Object.values(cache.sprints || {})) {
312
+ sprintTotal += core.pageValues(v).length;
313
+ }
314
+ counts.sprints = sprintTotal;
315
+
316
+ let typesTotal = 0;
317
+ for (const v of Object.values(cache.work_item_types || {})) {
318
+ typesTotal += core.pageValues(v).length;
319
+ }
320
+ counts.work_item_types = typesTotal;
321
+
322
+ let statesTotal = 0;
323
+ for (const v of Object.values(cache.work_item_states || {})) {
324
+ statesTotal += core.pageValues(v).length;
325
+ }
326
+ counts.work_item_states = statesTotal;
327
+
328
+ let prioTotal = 0;
329
+ for (const v of Object.values(cache.work_item_priorities || {})) {
330
+ prioTotal += core.pageValues(v).length;
331
+ }
332
+ counts.work_item_priorities = prioTotal;
333
+
334
+ let propsTotal = 0;
335
+ for (const v of Object.values(cache.work_item_properties || {})) {
336
+ propsTotal += core.pageValues(v).length;
337
+ }
338
+ counts.work_item_properties = propsTotal;
339
+
340
+ let ideaStatesTotal = 0;
341
+ for (const v of Object.values(cache.idea_states || {})) {
342
+ ideaStatesTotal += core.pageValues(v).length;
343
+ }
344
+ counts.idea_states = ideaStatesTotal;
345
+
346
+ let ideaPrioTotal = 0;
347
+ for (const v of Object.values(cache.idea_priorities || {})) {
348
+ ideaPrioTotal += core.pageValues(v).length;
349
+ }
350
+ counts.idea_priorities = ideaPrioTotal;
351
+
352
+ return counts;
353
+ }
354
+
355
+ // ── Handlers ───────────────────────────────────────────────────────
356
+
357
+ async function handleInit(opts, inputFunc) {
358
+ const client = createClient(opts);
359
+ const refresh = Boolean(opts.refresh);
360
+
361
+ let rl = null;
362
+ if (!inputFunc) {
363
+ rl = readline.createInterface({
364
+ input: process.stdin,
365
+ output: process.stdout,
366
+ });
367
+ inputFunc = (prompt) => new Promise((resolve) => rl.question(prompt, resolve));
368
+ }
369
+
370
+ try {
371
+ const project = await promptChoice('project', core.pageValues(await fetchProjects(client, refresh)), inputFunc);
372
+ const projectId = core.itemId(project, 'project');
373
+ const sprint = await promptChoice('sprint', core.pageValues(await fetchSprints(client, projectId, refresh)), inputFunc);
374
+ const user = await promptChoice('user', core.pageValues(await fetchUsers(client, projectId, refresh)), inputFunc);
375
+ const result = await cacheContext(client, project, sprint, user);
376
+ core.printJson(result);
377
+ } finally {
378
+ if (rl) rl.close();
379
+ }
380
+ }
381
+
382
+ async function handleSetCurrentUser(value, opts) {
383
+ if (!value) {
384
+ throw new core.PingCodeError(
385
+ 'Usage: context set-current-user <id|name|@me>\n' +
386
+ ' <id> Direct user ID\n' +
387
+ ' <name> Cached user name or display name\n' +
388
+ ' @me Use the already-cached current user ID',
389
+ );
390
+ }
391
+
392
+ const client = createClient(opts);
393
+
394
+ // Expand @me placeholder to the cached current user ID.
395
+ let userId = value;
396
+ if (value === '@me') {
397
+ userId = core.currentUserId(null, client.workspaceCache);
398
+ }
399
+
400
+ if (opts.dry_run) {
401
+ core.printJson({
402
+ dry_run: true,
403
+ action: 'set-current-user',
404
+ input: value,
405
+ resolved_user_id: userId,
406
+ });
407
+ return;
408
+ }
409
+
410
+ const result = await core.setCurrentUser(client, userId);
411
+ core.printJson(result);
412
+ }
413
+
414
+ async function handleSetCurrentProject(value, opts) {
415
+ if (!value) {
416
+ throw new core.PingCodeError(
417
+ 'Usage: context set-current-project <id|name>',
418
+ );
419
+ }
420
+
421
+ const client = createClient(opts);
422
+
423
+ if (opts.dry_run) {
424
+ // Try to resolve the project ID from the cache for dry-run display.
425
+ let resolvedId = value;
426
+ const projects = core.pageValues(client.workspaceCache.projects);
427
+ try {
428
+ const found = core.findCachedItem(projects, value, 'project');
429
+ if (found && found.id) resolvedId = found.id;
430
+ } catch (_) {
431
+ // Keep the raw value if resolution fails.
432
+ }
433
+ core.printJson({
434
+ dry_run: true,
435
+ action: 'set-current-project',
436
+ input: value,
437
+ resolved_project_id: resolvedId,
438
+ });
439
+ return;
440
+ }
441
+
442
+ const result = await core.setCurrentProject(client, value);
443
+ core.printJson(result);
444
+ }
445
+
446
+ async function handleSetCurrentSprint(value, opts) {
447
+ if (!value) {
448
+ throw new core.PingCodeError(
449
+ 'Usage: context set-current-sprint <id|name>',
450
+ );
451
+ }
452
+
453
+ const client = createClient(opts);
454
+
455
+ if (opts.dry_run) {
456
+ // Try to resolve the sprint ID from the cache for dry-run display.
457
+ let resolvedId = value;
458
+ const allSprints = [];
459
+ for (const payload of Object.values(client.workspaceCache.sprints || {})) {
460
+ allSprints.push(...core.pageValues(payload));
461
+ }
462
+ try {
463
+ const found = core.findCachedItem(allSprints, value, 'sprint');
464
+ if (found && found.id) resolvedId = found.id;
465
+ } catch (_) {
466
+ // Keep the raw value if resolution fails.
467
+ }
468
+ core.printJson({
469
+ dry_run: true,
470
+ action: 'set-current-sprint',
471
+ input: value,
472
+ resolved_sprint_id: resolvedId,
473
+ });
474
+ return;
475
+ }
476
+
477
+ const result = await core.setCurrentSprint(client, value);
478
+ core.printJson(result);
479
+ }
480
+
481
+ async function handleList(opts) {
482
+ const client = createClient(opts);
483
+ const cache = client.workspaceCache;
484
+
485
+ // Print preferences
486
+ console.log('Preferences:');
487
+ const prefs = cache.preferences || {};
488
+ const prefKeys = Object.keys(prefs).sort();
489
+ if (prefKeys.length === 0) {
490
+ console.log(' (none)');
491
+ } else {
492
+ for (const key of prefKeys) {
493
+ console.log(` ${key}: ${prefs[key]}`);
494
+ }
495
+ }
496
+
497
+ // Print cached dictionary summary
498
+ console.log('');
499
+ console.log('Cached dictionaries:');
500
+ const counts = countDictionaryEntries(cache);
501
+ const countKeys = Object.keys(counts).sort();
502
+ for (const key of countKeys) {
503
+ const label = key.replace(/_/g, ' ');
504
+ console.log(` ${label}: ${counts[key]} items`);
505
+ }
506
+ }
507
+
508
+ // ── Subcommand name mapping ────────────────────────────────────────
509
+
510
+ const SUBCOMMAND_ALIASES = {
511
+ 'set-current-user': 'set-current-user',
512
+ 'set-current-project': 'set-current-project',
513
+ 'set-current-sprint': 'set-current-sprint',
514
+ init: 'init',
515
+ list: 'list',
516
+ };
517
+
518
+ // ── Run ────────────────────────────────────────────────────────────
519
+
520
+ async function run(argv) {
521
+ const tokens = argv || [];
522
+
523
+ // No args or first arg is help → module-level help.
524
+ if (tokens.length === 0 || tokens[0] === '--help' || tokens[0] === '-h') {
525
+ printHelp();
526
+ return;
527
+ }
528
+
529
+ const parsed = parseContextArgs(tokens);
530
+
531
+ // If --help was found anywhere, show help for the subcommand if known.
532
+ if (parsed.helpRequested) {
533
+ printHelp(parsed.subcommand);
534
+ return;
535
+ }
536
+
537
+ const subcommand = parsed.subcommand;
538
+
539
+ if (!subcommand || !(subcommand in SUBCOMMAND_ALIASES)) {
540
+ throw new core.PingCodeError(
541
+ `Unknown context subcommand: ${subcommand || '(none)'}. ` +
542
+ 'Valid subcommands: init, set-current-user, set-current-project, set-current-sprint, list.',
543
+ );
544
+ }
545
+
546
+ switch (subcommand) {
547
+ case 'init':
548
+ await handleInit(parsed.opts);
549
+ break;
550
+ case 'set-current-user':
551
+ await handleSetCurrentUser(parsed.value, parsed.opts);
552
+ break;
553
+ case 'set-current-project':
554
+ await handleSetCurrentProject(parsed.value, parsed.opts);
555
+ break;
556
+ case 'set-current-sprint':
557
+ await handleSetCurrentSprint(parsed.value, parsed.opts);
558
+ break;
559
+ case 'list':
560
+ await handleList(parsed.opts);
561
+ break;
562
+ default:
563
+ throw new core.PingCodeError(`Unknown context subcommand: ${subcommand}`);
564
+ }
565
+ }
566
+
567
+ // ── Register ───────────────────────────────────────────────────────
568
+
569
+ shared.registerModule('context', {
570
+ name: 'context',
571
+ description: 'Manage PingCode workspace context',
572
+ run,
573
+ });
574
+
575
+ module.exports = { run, printHelp, parseContextArgs, createClient };
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ // Module registry shared by the dispatcher and command modules.
4
+ // Command modules register themselves here; the dispatcher discovers
5
+ // them without importing command files directly (avoids circular deps).
6
+
7
+ const registry = new Map();
8
+
9
+ function registerModule(name, config) {
10
+ registry.set(name, config);
11
+ }
12
+
13
+ function getModule(name) {
14
+ return registry.get(name);
15
+ }
16
+
17
+ function listModules() {
18
+ const modules = [];
19
+ for (const [name, config] of registry) {
20
+ modules.push({ name, description: config.description });
21
+ }
22
+ modules.sort((a, b) => a.name.localeCompare(b.name));
23
+ return modules;
24
+ }
25
+
26
+ function printModulesHelp() {
27
+ const modules = listModules();
28
+ const maxLen = Math.max(...modules.map(m => m.name.length));
29
+ const lines = [
30
+ 'Usage: pingcode <module> [subcommand] [options]',
31
+ '',
32
+ 'Modules:',
33
+ ];
34
+ for (const m of modules) {
35
+ const padded = m.name.padEnd(maxLen + 2);
36
+ lines.push(` ${padded}${m.description}`);
37
+ }
38
+ console.log(lines.join('\n'));
39
+ }
40
+
41
+ module.exports = { registerModule, getModule, listModules, printModulesHelp };