codekanban 0.31.0 → 0.32.0

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,1019 @@
1
+ import { pbkdf2Sync } from 'node:crypto';
2
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+
7
+ export const APP_NAME = 'codekanban-cli';
8
+ export const DEFAULT_BASE_URL = 'http://127.0.0.1:3007';
9
+ export const SESSION_FILE_NAME = 'session.json';
10
+
11
+ const BASE_URL_ENV_VARS = ['CODEKANBAN_BASE_URL', 'BASE_URL'];
12
+ const TOKEN_ENV_VARS = ['CODEKANBAN_TOKEN', 'TOKEN'];
13
+ const USERNAME_ENV_VARS = ['CODEKANBAN_USERNAME', 'USERNAME'];
14
+ const PASSWORD_ENV_VARS = ['CODEKANBAN_PASSWORD', 'PASSWORD'];
15
+ const COOKIE_NAME = 'codekanban_auth';
16
+ const VALUE_FLAGS = new Set([
17
+ '--base-url',
18
+ '--project-id',
19
+ '--path',
20
+ '--prompt',
21
+ '--text',
22
+ '--agent',
23
+ '--model',
24
+ '--profile',
25
+ '--sandbox',
26
+ '--approval-policy',
27
+ '--add-dir',
28
+ '--attachment-id',
29
+ '--extra-arg',
30
+ '--title',
31
+ '--working-dir',
32
+ '--worktree-id',
33
+ '--session-id',
34
+ '--id',
35
+ '--tool-use-id',
36
+ '--reasoning-effort',
37
+ '--workflow-mode',
38
+ '--permission-level',
39
+ '--permission-mode',
40
+ '--limit',
41
+ '--before-cursor',
42
+ '--mode',
43
+ '--group-id',
44
+ '--scope-id',
45
+ '--file',
46
+ '--item-id',
47
+ '--answers-json',
48
+ '--answer-strategy',
49
+ '--prev-session-id',
50
+ '--next-session-id',
51
+ '--idle-timeout-ms',
52
+ '--max-events',
53
+ '--interval-ms',
54
+ '--timeout-ms',
55
+ '--until',
56
+ '--delete-file-before',
57
+ '--read-file-after',
58
+ '--project-index',
59
+ ]);
60
+ const BOOLEAN_FLAGS = new Set([
61
+ '--no-terminal',
62
+ '--no-ai',
63
+ '--refresh',
64
+ '--clear-existing',
65
+ '--raw',
66
+ '--strict-cwd',
67
+ '--if-exists',
68
+ ]);
69
+
70
+ function readFlagValue(argv, index, flag) {
71
+ const value = argv[index + 1];
72
+ if (value == null || value.startsWith('--')) {
73
+ throw new Error(`${flag} requires a value`);
74
+ }
75
+ return value;
76
+ }
77
+
78
+ function firstEnvValue(env, keys) {
79
+ for (const key of keys) {
80
+ const value = typeof env[key] === 'string' ? env[key].trim() : '';
81
+ if (value) {
82
+ return value;
83
+ }
84
+ }
85
+ return '';
86
+ }
87
+
88
+ function normalizeBaseUrl(value) {
89
+ const trimmed = String(value || '').trim();
90
+ if (!trimmed) {
91
+ return DEFAULT_BASE_URL;
92
+ }
93
+ return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed;
94
+ }
95
+
96
+ function extractPositionals(argv) {
97
+ const positionals = [];
98
+ for (let index = 0; index < argv.length; index += 1) {
99
+ const token = argv[index];
100
+ if (!token.startsWith('--')) {
101
+ positionals.push(token);
102
+ continue;
103
+ }
104
+ if (BOOLEAN_FLAGS.has(token)) {
105
+ continue;
106
+ }
107
+ if (VALUE_FLAGS.has(token) && index + 1 < argv.length) {
108
+ index += 1;
109
+ }
110
+ }
111
+ return positionals;
112
+ }
113
+
114
+ export function getConfigDir({ env = process.env, homeDir = os.homedir(), platform = process.platform } = {}) {
115
+ if (platform === 'win32') {
116
+ const appData = typeof env.APPDATA === 'string' ? env.APPDATA.trim() : '';
117
+ return path.join(appData || path.join(homeDir, 'AppData', 'Roaming'), APP_NAME);
118
+ }
119
+ const xdg = typeof env.XDG_CONFIG_HOME === 'string' ? env.XDG_CONFIG_HOME.trim() : '';
120
+ return path.join(xdg || path.join(homeDir, '.config'), APP_NAME);
121
+ }
122
+
123
+ export function getSessionFilePath(options = {}) {
124
+ return path.join(getConfigDir(options), SESSION_FILE_NAME);
125
+ }
126
+
127
+ function normalizeSavedSession(value) {
128
+ if (!value || typeof value !== 'object') {
129
+ return null;
130
+ }
131
+ const baseUrl = normalizeBaseUrl(value.base_url || value.baseURL || '');
132
+ const accessToken = typeof value.access_token === 'string' ? value.access_token.trim() : '';
133
+ const username = typeof value.username === 'string' ? value.username.trim() : '';
134
+ const savedAt = typeof value.saved_at === 'string' ? value.saved_at.trim() : '';
135
+ if (!baseUrl && !accessToken) {
136
+ return null;
137
+ }
138
+ return {
139
+ base_url: baseUrl,
140
+ access_token: accessToken,
141
+ username,
142
+ saved_at: savedAt,
143
+ };
144
+ }
145
+
146
+ export async function readSavedSession(options = {}) {
147
+ try {
148
+ const raw = await readFile(getSessionFilePath(options), 'utf8');
149
+ return normalizeSavedSession(JSON.parse(raw));
150
+ } catch (error) {
151
+ if (error && (error.code === 'ENOENT' || error.name === 'SyntaxError')) {
152
+ return null;
153
+ }
154
+ throw error;
155
+ }
156
+ }
157
+
158
+ export async function writeSavedSession(session, options = {}) {
159
+ const normalized = normalizeSavedSession(session);
160
+ if (!normalized || !normalized.access_token) {
161
+ throw new Error('saved session requires base_url and access_token');
162
+ }
163
+ const filePath = getSessionFilePath(options);
164
+ await mkdir(path.dirname(filePath), { recursive: true });
165
+ await writeFile(filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
166
+ if ((options.platform || process.platform) !== 'win32') {
167
+ await safeChmod(filePath, 0o600);
168
+ }
169
+ return filePath;
170
+ }
171
+
172
+ export async function clearSavedSession(options = {}) {
173
+ const filePath = getSessionFilePath(options);
174
+ await rm(filePath, { force: true });
175
+ return filePath;
176
+ }
177
+
178
+ async function safeChmod(filePath, mode) {
179
+ const fs = await import('node:fs/promises');
180
+ try {
181
+ await fs.chmod(filePath, mode);
182
+ } catch {
183
+ // Best-effort only.
184
+ }
185
+ }
186
+
187
+ export function parseCliState(argv) {
188
+ const passthrough = [];
189
+ const flags = {
190
+ help: false,
191
+ version: false,
192
+ };
193
+
194
+ for (let index = 0; index < argv.length; index += 1) {
195
+ const token = argv[index];
196
+ switch (token) {
197
+ case '--help':
198
+ case '-h':
199
+ flags.help = true;
200
+ break;
201
+ case '--version':
202
+ case '-v':
203
+ flags.version = true;
204
+ break;
205
+ case '--token':
206
+ flags.token = readFlagValue(argv, index, token);
207
+ index += 1;
208
+ break;
209
+ case '--token-file':
210
+ flags.tokenFile = readFlagValue(argv, index, token);
211
+ index += 1;
212
+ break;
213
+ case '--token-stdin':
214
+ flags.tokenStdin = true;
215
+ break;
216
+ case '--password':
217
+ flags.password = readFlagValue(argv, index, token);
218
+ index += 1;
219
+ break;
220
+ case '--password-file':
221
+ flags.passwordFile = readFlagValue(argv, index, token);
222
+ index += 1;
223
+ break;
224
+ case '--password-stdin':
225
+ flags.passwordStdin = true;
226
+ break;
227
+ case '--username':
228
+ flags.username = readFlagValue(argv, index, token);
229
+ index += 1;
230
+ break;
231
+ case '--project-name':
232
+ flags.projectName = readFlagValue(argv, index, token);
233
+ index += 1;
234
+ break;
235
+ case '--project-index':
236
+ flags.projectIndex = readFlagValue(argv, index, token);
237
+ index += 1;
238
+ break;
239
+ default:
240
+ passthrough.push(token);
241
+ break;
242
+ }
243
+ }
244
+
245
+ return {
246
+ flags,
247
+ passthrough,
248
+ positionals: extractPositionals(passthrough),
249
+ };
250
+ }
251
+
252
+ function createRuntimeHelpText({ sessionFilePath, commandHelpText }) {
253
+ return `codekanban-cli - installable Codex skill CLI for CodeKanban
254
+
255
+ Usage:
256
+ codekanban-cli <scope> <action> [options]
257
+ codekanban-cli auth save-token [options]
258
+ codekanban-cli auth clear-token
259
+ codekanban-cli project list
260
+ codekanban-cli project resolve --project-name <name>
261
+
262
+ Defaults:
263
+ Base URL: ${DEFAULT_BASE_URL}
264
+ Session file: ${sessionFilePath}
265
+
266
+ Global auth/config options:
267
+ --base-url <url> Override the CodeKanban server base URL
268
+ --token <token> Use an auth token for this command
269
+ --token-file <path> Read the auth token from a file
270
+ --token-stdin Read the auth token from stdin
271
+ --password <password> Login with a password for this command
272
+ --password-file <path> Read the password from a file
273
+ --password-stdin Read the password from stdin
274
+ --username <name> Optional label saved with auth save-token
275
+ --project-name <name> Resolve a project by name before running a project command
276
+ --project-index <n> Choose the nth matching project candidate when names are ambiguous
277
+ --help Show this help text
278
+ --version Show the CLI version
279
+
280
+ Environment variables:
281
+ CODEKANBAN_BASE_URL, BASE_URL
282
+ CODEKANBAN_TOKEN, TOKEN
283
+ CODEKANBAN_PASSWORD, PASSWORD
284
+ CODEKANBAN_USERNAME, USERNAME
285
+
286
+ Auth helpers:
287
+ codekanban-cli auth save-token --password-stdin
288
+ codekanban-cli auth save-token --token-file <path>
289
+ codekanban-cli auth clear-token
290
+
291
+ Project helpers:
292
+ codekanban-cli project list
293
+ codekanban-cli project resolve --project-name codekanban
294
+ codekanban-cli web-session create --project-name codekanban --agent codex --title "Planning session"
295
+
296
+ Command surface:
297
+ ${indentLines(String(commandHelpText || '').trim(), ' ')}
298
+ `;
299
+ }
300
+
301
+ function indentLines(text, prefix) {
302
+ return String(text || '')
303
+ .split('\n')
304
+ .map(line => `${prefix}${line}`)
305
+ .join('\n');
306
+ }
307
+
308
+ async function readSecretFromFile(filePath) {
309
+ return String(await readFile(filePath, 'utf8')).trim();
310
+ }
311
+
312
+ async function readSecretFromStdin(stdin) {
313
+ if (!stdin) {
314
+ throw new Error('stdin is unavailable');
315
+ }
316
+ const chunks = [];
317
+ for await (const chunk of stdin) {
318
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
319
+ }
320
+ return Buffer.concat(chunks).toString('utf8').trim();
321
+ }
322
+
323
+ async function resolveSecretValue({ directValue, filePath, useStdin, stdin, label }) {
324
+ const inlineValue = typeof directValue === 'string' ? directValue.trim() : '';
325
+ if (inlineValue) {
326
+ return inlineValue;
327
+ }
328
+ if (filePath) {
329
+ return await readSecretFromFile(filePath);
330
+ }
331
+ if (useStdin) {
332
+ const secret = await readSecretFromStdin(stdin);
333
+ if (!secret) {
334
+ throw new Error(`${label} stdin was empty`);
335
+ }
336
+ return secret;
337
+ }
338
+ return '';
339
+ }
340
+
341
+ export function resolveBaseUrl({ flags, env = process.env, savedSession } = {}) {
342
+ const explicit = extractBaseUrlFromArgs(flags);
343
+ if (explicit) {
344
+ return explicit;
345
+ }
346
+ const fromEnv = firstEnvValue(env, BASE_URL_ENV_VARS);
347
+ if (fromEnv) {
348
+ return normalizeBaseUrl(fromEnv);
349
+ }
350
+ if (savedSession?.base_url) {
351
+ return normalizeBaseUrl(savedSession.base_url);
352
+ }
353
+ return DEFAULT_BASE_URL;
354
+ }
355
+
356
+ function extractBaseUrlFromArgs(flags) {
357
+ const value = typeof flags?.baseUrl === 'string' ? flags.baseUrl.trim() : '';
358
+ return value ? normalizeBaseUrl(value) : '';
359
+ }
360
+
361
+ function parseBaseUrlFromPassthrough(argv) {
362
+ for (let index = 0; index < argv.length; index += 1) {
363
+ if (argv[index] === '--base-url') {
364
+ return normalizeBaseUrl(readFlagValue(argv, index, '--base-url'));
365
+ }
366
+ }
367
+ return '';
368
+ }
369
+
370
+ function parseFlagValue(argv, flagName) {
371
+ for (let index = 0; index < argv.length; index += 1) {
372
+ if (argv[index] === flagName) {
373
+ return readFlagValue(argv, index, flagName);
374
+ }
375
+ }
376
+ return '';
377
+ }
378
+
379
+ function hasPassthroughFlag(argv, flagName) {
380
+ return argv.includes(flagName);
381
+ }
382
+
383
+ function withoutFlag(argv, flagName, { takesValue = false } = {}) {
384
+ const next = [];
385
+ for (let index = 0; index < argv.length; index += 1) {
386
+ const token = argv[index];
387
+ if (token !== flagName) {
388
+ next.push(token);
389
+ continue;
390
+ }
391
+ if (takesValue) {
392
+ index += 1;
393
+ }
394
+ }
395
+ return next;
396
+ }
397
+
398
+ function withFlagValue(argv, flagName, value) {
399
+ const next = withoutFlag(argv, flagName, { takesValue: true });
400
+ next.push(flagName, value);
401
+ return next;
402
+ }
403
+
404
+ function isLocalBaseUrl(baseUrl) {
405
+ try {
406
+ const url = new URL(`${normalizeBaseUrl(baseUrl)}/`);
407
+ return ['127.0.0.1', 'localhost', '::1'].includes(url.hostname);
408
+ } catch {
409
+ return false;
410
+ }
411
+ }
412
+
413
+ function commandNeedsProjectTarget(scope, action) {
414
+ const key = `${scope} ${action}`;
415
+ return new Set([
416
+ 'session list',
417
+ 'workflow start',
418
+ 'web-session list',
419
+ 'web-session create',
420
+ 'file scopes',
421
+ 'file read',
422
+ 'file delete',
423
+ 'web-session snapshot',
424
+ 'web-session history',
425
+ 'web-session sync',
426
+ 'web-session state',
427
+ 'web-session answer-pending',
428
+ 'web-session execute-plan',
429
+ 'web-session wait',
430
+ 'web-session run',
431
+ 'web-session archive',
432
+ 'web-session unarchive',
433
+ 'web-session rename',
434
+ 'web-session close',
435
+ 'web-session delete',
436
+ 'web-session command-group',
437
+ 'web-session attach',
438
+ ]).has(key);
439
+ }
440
+
441
+ function normalizeProjectSearchToken(value) {
442
+ return String(value || '').trim().toLowerCase();
443
+ }
444
+
445
+ function pathBaseName(projectPath) {
446
+ const normalized = String(projectPath || '').replace(/[\\/]+$/, '');
447
+ const parts = normalized.split(/[\\/]/).filter(Boolean);
448
+ return parts.length > 0 ? parts[parts.length - 1].toLowerCase() : '';
449
+ }
450
+
451
+ function uniqueById(items) {
452
+ const seen = new Set();
453
+ return items.filter(item => {
454
+ if (!item?.id || seen.has(item.id)) {
455
+ return false;
456
+ }
457
+ seen.add(item.id);
458
+ return true;
459
+ });
460
+ }
461
+
462
+ function describeProject(project) {
463
+ return {
464
+ id: project.id,
465
+ name: project.name || '',
466
+ path: project.path || '',
467
+ pathBaseName: pathBaseName(project.path || ''),
468
+ };
469
+ }
470
+
471
+ function parseProjectIndex(value) {
472
+ if (value == null || value === '') {
473
+ return null;
474
+ }
475
+ const parsed = Number(value);
476
+ if (!Number.isInteger(parsed) || parsed < 1) {
477
+ throw new Error('--project-index must be a positive integer');
478
+ }
479
+ return parsed;
480
+ }
481
+
482
+ function formatProjectCandidate(project, index) {
483
+ return `${index}) ${project.name || '(unnamed)'} [${project.id}] ${project.path || ''}`.trim();
484
+ }
485
+
486
+ function selectProjectCandidate(projectName, candidates, projectIndex, label) {
487
+ const normalizedIndex = parseProjectIndex(projectIndex);
488
+ if (normalizedIndex != null) {
489
+ const selected = candidates[normalizedIndex - 1];
490
+ if (!selected) {
491
+ throw new Error(
492
+ `project index ${normalizedIndex} is out of range for "${projectName}"; available candidates: ${candidates.map((project, index) => formatProjectCandidate(project, index + 1)).join('; ')}`,
493
+ );
494
+ }
495
+ return selected;
496
+ }
497
+ throw new Error(
498
+ `project name "${projectName}" ${label}: ${candidates.map((project, index) => formatProjectCandidate(project, index + 1)).join('; ')}. Re-run with --project-index <n> or --project-id <id>.`,
499
+ );
500
+ }
501
+
502
+ function resolveProjectByName(projects, projectName, projectIndex) {
503
+ const needle = normalizeProjectSearchToken(projectName);
504
+ if (!needle) {
505
+ throw new Error('--project-name requires a value');
506
+ }
507
+ const exact = uniqueById(projects.filter(project => normalizeProjectSearchToken(project.name) == needle));
508
+ if (exact.length === 1) {
509
+ return exact[0];
510
+ }
511
+ if (exact.length > 1) {
512
+ return selectProjectCandidate(projectName, exact, projectIndex, 'matches multiple projects');
513
+ }
514
+ const baseMatches = uniqueById(projects.filter(project => pathBaseName(project.path) == needle));
515
+ if (baseMatches.length === 1) {
516
+ return baseMatches[0];
517
+ }
518
+ if (baseMatches.length > 1) {
519
+ return selectProjectCandidate(projectName, baseMatches, projectIndex, 'matches multiple project paths');
520
+ }
521
+ const fuzzy = uniqueById(projects.filter(project => normalizeProjectSearchToken(project.name).includes(needle) || pathBaseName(project.path).includes(needle)));
522
+ if (fuzzy.length === 1) {
523
+ return fuzzy[0];
524
+ }
525
+ if (fuzzy.length > 1) {
526
+ return selectProjectCandidate(projectName, fuzzy, projectIndex, 'is ambiguous');
527
+ }
528
+ throw new Error(`project name "${projectName}" was not found on ${baseUrlLabel(projects)}`);
529
+ }
530
+
531
+ function baseUrlLabel(projects) {
532
+ return Array.isArray(projects) ? 'the target CodeKanban server' : 'the target CodeKanban server';
533
+ }
534
+
535
+ async function listProjectsFromServer({ baseUrl, headers, fetchImpl }) {
536
+ const { response, payload } = await requestJson(baseUrl, '/api/v1/projects', {
537
+ headers,
538
+ fetchImpl,
539
+ });
540
+ if (!response.ok) {
541
+ throw new Error(extractErrorMessage(payload, `project list request failed with ${response.status}`));
542
+ }
543
+ const items = Array.isArray(payload?.body?.items)
544
+ ? payload.body.items
545
+ : Array.isArray(payload?.items)
546
+ ? payload.items
547
+ : [];
548
+ return items;
549
+ }
550
+
551
+ function ensureProjectTarget({ argv, scope, action, baseUrl, cwd }) {
552
+ const hasProjectId = hasPassthroughFlag(argv, '--project-id');
553
+ const hasPath = hasPassthroughFlag(argv, '--path');
554
+ if (hasProjectId || hasPath) {
555
+ return argv;
556
+ }
557
+ if (!commandNeedsProjectTarget(scope, action)) {
558
+ return argv;
559
+ }
560
+ if (!isLocalBaseUrl(baseUrl)) {
561
+ throw new Error(`remote CodeKanban commands require --project-id, --project-name, or a server-side --path for ${scope} ${action}`);
562
+ }
563
+ const resolvedCwd = String(cwd || process.cwd()).trim();
564
+ if (!resolvedCwd) {
565
+ throw new Error(`unable to resolve a working directory for ${scope} ${action}`);
566
+ }
567
+ return [...argv, '--path', resolvedCwd];
568
+ }
569
+
570
+ async function rewriteProjectNameToProjectId({ argv, projectName, projectIndex, baseUrl, headers, fetchImpl, stdout, scope, action }) {
571
+ if (!projectName) {
572
+ return argv;
573
+ }
574
+ const projects = await listProjectsFromServer({ baseUrl, headers, fetchImpl });
575
+ const project = resolveProjectByName(projects, projectName, projectIndex);
576
+ if (stdout) {
577
+ // Keep this silent during normal command execution.
578
+ }
579
+ const withoutProjectFlags = withoutFlag(
580
+ withoutFlag(argv, '--project-name', { takesValue: true }),
581
+ '--project-index',
582
+ { takesValue: true },
583
+ );
584
+ return withFlagValue(withoutProjectFlags, '--project-id', project.id);
585
+ }
586
+
587
+ async function handleProjectCommand({ action, flags, baseUrl, headers, fetchImpl, stdout }) {
588
+ const projects = await listProjectsFromServer({ baseUrl, headers, fetchImpl });
589
+ if (action == 'list') {
590
+ printJson(stdout, {
591
+ baseUrl,
592
+ items: projects.map(describeProject),
593
+ });
594
+ return 0;
595
+ }
596
+ if (action == 'resolve') {
597
+ const project = resolveProjectByName(projects, flags.projectName, flags.projectIndex);
598
+ printJson(stdout, {
599
+ baseUrl,
600
+ item: describeProject(project),
601
+ });
602
+ return 0;
603
+ }
604
+ throw new Error(`unsupported project command: ${action}`);
605
+ }
606
+
607
+ export function createAuthHeaders(token) {
608
+ const trimmed = typeof token === 'string' ? token.trim() : '';
609
+ if (!trimmed) {
610
+ return {};
611
+ }
612
+ return {
613
+ Authorization: `Bearer ${trimmed}`,
614
+ Cookie: `${COOKIE_NAME}=${encodeURIComponent(trimmed)}`,
615
+ };
616
+ }
617
+
618
+ function extractItem(payload) {
619
+ if (payload?.body?.item) {
620
+ return payload.body.item;
621
+ }
622
+ if (payload?.item) {
623
+ return payload.item;
624
+ }
625
+ return payload ?? null;
626
+ }
627
+
628
+ function extractErrorMessage(payload, fallback) {
629
+ if (payload?.detail) {
630
+ return String(payload.detail);
631
+ }
632
+ if (payload?.error?.message) {
633
+ return String(payload.error.message);
634
+ }
635
+ if (payload?.body?.message) {
636
+ return String(payload.body.message);
637
+ }
638
+ if (payload?.message) {
639
+ return String(payload.message);
640
+ }
641
+ return fallback;
642
+ }
643
+
644
+ async function requestJson(baseUrl, pathname, { method = 'GET', headers = {}, body, fetchImpl = globalThis.fetch } = {}) {
645
+ if (!fetchImpl) {
646
+ throw new Error('fetch implementation is unavailable');
647
+ }
648
+ const response = await fetchImpl(new URL(pathname, `${normalizeBaseUrl(baseUrl)}/`), {
649
+ method,
650
+ headers: {
651
+ Accept: 'application/json',
652
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
653
+ ...headers,
654
+ },
655
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
656
+ });
657
+ const raw = await response.text();
658
+ let payload = null;
659
+ if (raw) {
660
+ try {
661
+ payload = JSON.parse(raw);
662
+ } catch {
663
+ payload = { raw };
664
+ }
665
+ }
666
+ return { response, payload };
667
+ }
668
+
669
+ function deriveClientHash(password, salt, rounds) {
670
+ return pbkdf2Sync(password, salt, Math.max(1, Number(rounds) || 1), 64, 'sha512').toString('hex');
671
+ }
672
+
673
+ function extractSetCookieHeaders(response) {
674
+ if (!response?.headers) {
675
+ return [];
676
+ }
677
+ if (typeof response.headers.getSetCookie === 'function') {
678
+ return response.headers.getSetCookie();
679
+ }
680
+ const combined = response.headers.get('set-cookie');
681
+ return combined ? [combined] : [];
682
+ }
683
+
684
+ function extractAuthTokenFromResponse(response) {
685
+ const pattern = new RegExp(`(?:^|\\s|,)${COOKIE_NAME}=([^;]+)`);
686
+ for (const raw of extractSetCookieHeaders(response)) {
687
+ const match = String(raw).match(pattern);
688
+ if (match?.[1]) {
689
+ return decodeURIComponent(match[1]);
690
+ }
691
+ }
692
+ return '';
693
+ }
694
+
695
+ export async function fetchAuthStatus({ baseUrl, token = '', fetchImpl = globalThis.fetch } = {}) {
696
+ const headers = createAuthHeaders(token);
697
+ const { response, payload } = await requestJson(baseUrl, '/api/v1/auth/status', {
698
+ headers,
699
+ fetchImpl,
700
+ });
701
+ if (!response.ok) {
702
+ throw new Error(extractErrorMessage(payload, `auth status request failed with ${response.status}`));
703
+ }
704
+ return extractItem(payload);
705
+ }
706
+
707
+ export async function validateToken({ baseUrl, token, fetchImpl = globalThis.fetch } = {}) {
708
+ const status = await fetchAuthStatus({ baseUrl, token, fetchImpl });
709
+ if (!status?.enabled) {
710
+ throw new Error('password protection is disabled on this server, so there is no token to save');
711
+ }
712
+ if (!status?.authenticated) {
713
+ throw new Error('token validation failed');
714
+ }
715
+ return status;
716
+ }
717
+
718
+ export async function loginWithPassword({ baseUrl, password, fetchImpl = globalThis.fetch } = {}) {
719
+ const status = await fetchAuthStatus({ baseUrl, fetchImpl });
720
+ if (!status?.enabled) {
721
+ return {
722
+ enabled: false,
723
+ token: '',
724
+ status,
725
+ };
726
+ }
727
+ const clientHash = deriveClientHash(password, status.frontendSalt || '', status.frontendPBKDF2Rounds || 20000);
728
+ const { response, payload } = await requestJson(baseUrl, '/api/v1/auth/login', {
729
+ method: 'POST',
730
+ body: { clientHash },
731
+ fetchImpl,
732
+ });
733
+ if (!response.ok) {
734
+ throw new Error(extractErrorMessage(payload, `login failed with ${response.status}`));
735
+ }
736
+ const token = extractAuthTokenFromResponse(response);
737
+ if (!token) {
738
+ throw new Error('login succeeded but no auth session token was returned');
739
+ }
740
+ const validated = await validateToken({ baseUrl, token, fetchImpl });
741
+ return {
742
+ enabled: true,
743
+ token,
744
+ status: validated,
745
+ };
746
+ }
747
+
748
+ function resolveUsername({ flags, env = process.env, savedSession, baseUrl }) {
749
+ const explicit = typeof flags.username === 'string' ? flags.username.trim() : '';
750
+ if (explicit) {
751
+ return explicit;
752
+ }
753
+ const fromEnv = firstEnvValue(env, USERNAME_ENV_VARS);
754
+ if (fromEnv) {
755
+ return fromEnv;
756
+ }
757
+ if (savedSession?.base_url === baseUrl && savedSession?.username) {
758
+ return savedSession.username;
759
+ }
760
+ return '';
761
+ }
762
+
763
+ async function resolveRuntimeAuth({ flags, env = process.env, savedSession, baseUrl, stdin, fetchImpl = globalThis.fetch } = {}) {
764
+ const explicitToken = await resolveSecretValue({
765
+ directValue: flags.token,
766
+ label: 'token',
767
+ });
768
+ if (explicitToken) {
769
+ return { token: explicitToken, source: 'arg' };
770
+ }
771
+
772
+ const explicitPassword = await resolveSecretValue({
773
+ directValue: flags.password,
774
+ label: 'password',
775
+ });
776
+ if (explicitPassword) {
777
+ const login = await loginWithPassword({ baseUrl, password: explicitPassword, fetchImpl });
778
+ return { token: login.token, source: 'arg-password', enabled: login.enabled };
779
+ }
780
+
781
+ const tokenFromAltInput = await resolveSecretValue({
782
+ filePath: flags.tokenFile,
783
+ useStdin: flags.tokenStdin,
784
+ stdin,
785
+ label: 'token',
786
+ });
787
+ if (tokenFromAltInput) {
788
+ return { token: tokenFromAltInput, source: 'input' };
789
+ }
790
+
791
+ const passwordFromAltInput = await resolveSecretValue({
792
+ filePath: flags.passwordFile,
793
+ useStdin: flags.passwordStdin,
794
+ stdin,
795
+ label: 'password',
796
+ });
797
+ if (passwordFromAltInput) {
798
+ const login = await loginWithPassword({ baseUrl, password: passwordFromAltInput, fetchImpl });
799
+ return { token: login.token, source: 'input-password', enabled: login.enabled };
800
+ }
801
+
802
+ const envToken = firstEnvValue(env, TOKEN_ENV_VARS);
803
+ if (envToken) {
804
+ return { token: envToken, source: 'env' };
805
+ }
806
+
807
+ const envPassword = firstEnvValue(env, PASSWORD_ENV_VARS);
808
+ if (envPassword) {
809
+ const login = await loginWithPassword({ baseUrl, password: envPassword, fetchImpl });
810
+ return { token: login.token, source: 'env-password', enabled: login.enabled };
811
+ }
812
+
813
+ if (savedSession?.access_token && savedSession?.base_url === baseUrl) {
814
+ return { token: savedSession.access_token, source: 'session' };
815
+ }
816
+
817
+ return { token: '', source: 'none' };
818
+ }
819
+
820
+ async function buildSaveTokenPayload({ flags, env, savedSession, baseUrl, stdin, fetchImpl, now }) {
821
+ const username = resolveUsername({ flags, env, savedSession, baseUrl });
822
+ const runtimeAuth = await resolveRuntimeAuth({
823
+ flags,
824
+ env,
825
+ savedSession,
826
+ baseUrl,
827
+ stdin,
828
+ fetchImpl,
829
+ });
830
+ if (!runtimeAuth.token) {
831
+ if (runtimeAuth.source.includes('password') && runtimeAuth.enabled === false) {
832
+ throw new Error('password protection is disabled on this server, so there is no token to save');
833
+ }
834
+ throw new Error('auth save-token requires a token or password from args, stdin, env, or an existing saved session');
835
+ }
836
+ const validated = await validateToken({
837
+ baseUrl,
838
+ token: runtimeAuth.token,
839
+ fetchImpl,
840
+ });
841
+ return {
842
+ base_url: baseUrl,
843
+ access_token: runtimeAuth.token,
844
+ username,
845
+ saved_at: (now || new Date()).toISOString(),
846
+ authenticated: validated.authenticated === true,
847
+ };
848
+ }
849
+
850
+ function printJson(stream, payload) {
851
+ stream.write(`${JSON.stringify(payload, null, 2)}\n`);
852
+ }
853
+
854
+ async function handleAuthCommand({ action, flags, env, stdout, stderr, stdin, savedSession, fetchImpl, now, pathOptions }) {
855
+ const baseUrl = resolveBaseUrl({ flags: { ...flags, baseUrl: flags.baseUrl }, env, savedSession });
856
+
857
+ if (action === 'save-token') {
858
+ const session = await buildSaveTokenPayload({
859
+ flags,
860
+ env,
861
+ savedSession,
862
+ baseUrl,
863
+ stdin,
864
+ fetchImpl,
865
+ now,
866
+ });
867
+ const filePath = await writeSavedSession(session, pathOptions);
868
+ printJson(stdout, {
869
+ message: 'token saved',
870
+ sessionFile: filePath,
871
+ session,
872
+ });
873
+ return 0;
874
+ }
875
+
876
+ if (action === 'clear-token') {
877
+ const filePath = await clearSavedSession(pathOptions);
878
+ printJson(stdout, {
879
+ message: 'saved token cleared',
880
+ sessionFile: filePath,
881
+ });
882
+ return 0;
883
+ }
884
+
885
+ if (action === 'status') {
886
+ const runtimeAuth = await resolveRuntimeAuth({
887
+ flags,
888
+ env,
889
+ savedSession,
890
+ baseUrl,
891
+ stdin,
892
+ fetchImpl,
893
+ });
894
+ const status = await fetchAuthStatus({
895
+ baseUrl,
896
+ token: runtimeAuth.token,
897
+ fetchImpl,
898
+ });
899
+ printJson(stdout, {
900
+ baseUrl,
901
+ authSource: runtimeAuth.source,
902
+ status,
903
+ });
904
+ return 0;
905
+ }
906
+
907
+ stderr.write(`unsupported auth command: ${action}\n`);
908
+ return 1;
909
+ }
910
+
911
+ export async function runCodeKanbanCliWithRuntime(argv, runtimeBindings, options = {}) {
912
+ const stdout = options.stdout || process.stdout;
913
+ const stderr = options.stderr || process.stderr;
914
+ const stdin = options.stdin || process.stdin;
915
+ const env = options.env || process.env;
916
+ const fetchImpl = options.fetchImpl || globalThis.fetch;
917
+ const runtimeRunner = options.runner || runtimeBindings?.runCli;
918
+ const pathOptions = {
919
+ env,
920
+ homeDir: options.homeDir,
921
+ platform: options.platform,
922
+ };
923
+ const sessionFilePath = getSessionFilePath(pathOptions);
924
+
925
+ try {
926
+ const state = parseCliState(argv);
927
+ const baseUrlFromArgs = parseBaseUrlFromPassthrough(state.passthrough);
928
+ state.flags.baseUrl = baseUrlFromArgs;
929
+
930
+ if (state.flags.version) {
931
+ const version = options.version || '0.1.0';
932
+ stdout.write(`${APP_NAME} ${version}\n`);
933
+ return 0;
934
+ }
935
+
936
+ if (state.flags.help || state.positionals.length === 0) {
937
+ stdout.write(createRuntimeHelpText({ sessionFilePath, commandHelpText: runtimeBindings?.createHelpText?.(APP_NAME) }));
938
+ return 0;
939
+ }
940
+
941
+ const [scope, action] = state.positionals;
942
+ const savedSession = await readSavedSession(pathOptions);
943
+
944
+ if (scope === 'auth') {
945
+ return await handleAuthCommand({
946
+ action,
947
+ flags: state.flags,
948
+ env,
949
+ stdout,
950
+ stderr,
951
+ stdin,
952
+ savedSession,
953
+ fetchImpl,
954
+ now: options.now,
955
+ pathOptions,
956
+ });
957
+ }
958
+
959
+ const baseUrl = resolveBaseUrl({ flags: state.flags, env, savedSession });
960
+ const runtimeAuth = await resolveRuntimeAuth({
961
+ flags: state.flags,
962
+ env,
963
+ savedSession,
964
+ baseUrl,
965
+ stdin,
966
+ fetchImpl,
967
+ });
968
+ const headers = createAuthHeaders(runtimeAuth.token);
969
+ const webSocketOptions = Object.keys(headers).length > 0 ? { headers } : undefined;
970
+
971
+ if (scope === 'project') {
972
+ return await handleProjectCommand({
973
+ action,
974
+ flags: state.flags,
975
+ baseUrl,
976
+ headers,
977
+ fetchImpl,
978
+ stdout,
979
+ });
980
+ }
981
+
982
+ let argvForSdk = state.passthrough;
983
+ argvForSdk = await rewriteProjectNameToProjectId({
984
+ argv: argvForSdk,
985
+ projectName: state.flags.projectName,
986
+ projectIndex: state.flags.projectIndex,
987
+ baseUrl,
988
+ headers,
989
+ fetchImpl,
990
+ });
991
+ argvForSdk = ensureProjectTarget({
992
+ argv: argvForSdk,
993
+ scope,
994
+ action,
995
+ baseUrl,
996
+ cwd: options.cwd,
997
+ });
998
+
999
+ return await runtimeRunner(argvForSdk, {
1000
+ stdout,
1001
+ stderr,
1002
+ commandName: APP_NAME,
1003
+ defaultBaseURL: baseUrl,
1004
+ clientOptions: {
1005
+ headers,
1006
+ fetchImpl,
1007
+ webSocketOptions,
1008
+ },
1009
+ });
1010
+ } catch (error) {
1011
+ printJson(stderr, {
1012
+ error: {
1013
+ name: error instanceof Error ? error.name : 'Error',
1014
+ message: error instanceof Error ? error.message : String(error),
1015
+ },
1016
+ });
1017
+ return 1;
1018
+ }
1019
+ }