keystone-cli 0.2.0 → 0.3.1

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.
@@ -34,7 +34,7 @@ interface RequestOutput {
34
34
  // Mock node:readline/promises
35
35
  const mockRl = {
36
36
  question: mock(() => Promise.resolve('')),
37
- close: mock(() => {}),
37
+ close: mock(() => { }),
38
38
  };
39
39
 
40
40
  mock.module('node:readline/promises', () => ({
@@ -49,13 +49,13 @@ describe('step-executor', () => {
49
49
  beforeAll(() => {
50
50
  try {
51
51
  mkdirSync(tempDir, { recursive: true });
52
- } catch (e) {}
52
+ } catch (e) { }
53
53
  });
54
54
 
55
55
  afterAll(() => {
56
56
  try {
57
57
  rmSync(tempDir, { recursive: true, force: true });
58
- } catch (e) {}
58
+ } catch (e) { }
59
59
  });
60
60
 
61
61
  beforeEach(() => {
@@ -330,7 +330,7 @@ describe('step-executor', () => {
330
330
  };
331
331
 
332
332
  // @ts-ignore
333
- const result = await executeStep(step, context, { log: () => {} });
333
+ const result = await executeStep(step, context, { log: () => { } });
334
334
  expect(result.status).toBe('success');
335
335
  expect(result.output).toBe(true);
336
336
  expect(mockRl.question).toHaveBeenCalled();
@@ -347,11 +347,54 @@ describe('step-executor', () => {
347
347
  };
348
348
 
349
349
  // @ts-ignore
350
- const result = await executeStep(step, context, { log: () => {} });
350
+ const result = await executeStep(step, context, { log: () => { } });
351
351
  expect(result.status).toBe('success');
352
352
  expect(result.output).toBe('user response');
353
353
  });
354
354
 
355
+ it('should handle human confirmation (yes/no/empty)', async () => {
356
+ const step: HumanStep = {
357
+ id: 'h1',
358
+ type: 'human',
359
+ message: 'Proceed?',
360
+ inputType: 'confirm',
361
+ };
362
+
363
+ // Test 'yes'
364
+ mockRl.question.mockResolvedValue('yes');
365
+ // @ts-ignore
366
+ let result = await executeStep(step, context, { log: () => { } });
367
+ expect(result.output).toBe(true);
368
+
369
+ // Test 'no'
370
+ mockRl.question.mockResolvedValue('no');
371
+ // @ts-ignore
372
+ result = await executeStep(step, context, { log: () => { } });
373
+ expect(result.output).toBe(false);
374
+
375
+ // Test empty string (default to true)
376
+ mockRl.question.mockResolvedValue('');
377
+ // @ts-ignore
378
+ result = await executeStep(step, context, { log: () => { } });
379
+ expect(result.output).toBe(true);
380
+ });
381
+
382
+ it('should fallback to text in confirm mode', async () => {
383
+ mockRl.question.mockResolvedValue('some custom response');
384
+
385
+ const step: HumanStep = {
386
+ id: 'h1',
387
+ type: 'human',
388
+ message: 'Proceed?',
389
+ inputType: 'confirm',
390
+ };
391
+
392
+ // @ts-ignore
393
+ const result = await executeStep(step, context, { log: () => { } });
394
+ expect(result.status).toBe('success');
395
+ expect(result.output).toBe('some custom response');
396
+ });
397
+
355
398
  it('should suspend if not a TTY', async () => {
356
399
  process.stdin.isTTY = false;
357
400
 
@@ -363,7 +406,7 @@ describe('step-executor', () => {
363
406
  };
364
407
 
365
408
  // @ts-ignore
366
- const result = await executeStep(step, context, { log: () => {} });
409
+ const result = await executeStep(step, context, { log: () => { } });
367
410
  expect(result.status).toBe('suspended');
368
411
  expect(result.error).toBe('Proceed?');
369
412
  });
@@ -374,7 +417,7 @@ describe('step-executor', () => {
374
417
  const step: WorkflowStep = {
375
418
  id: 'w1',
376
419
  type: 'workflow',
377
- workflow: 'child.yaml',
420
+ path: 'child.yaml',
378
421
  };
379
422
  // @ts-ignore
380
423
  const executeWorkflowFn = mock(() =>
@@ -392,7 +435,7 @@ describe('step-executor', () => {
392
435
  const step: WorkflowStep = {
393
436
  id: 'w1',
394
437
  type: 'workflow',
395
- workflow: 'child.yaml',
438
+ path: 'child.yaml',
396
439
  };
397
440
  const result = await executeStep(step, context);
398
441
  expect(result.status).toBe('failed');
@@ -5,6 +5,7 @@ import type {
5
5
  FileStep,
6
6
  HumanStep,
7
7
  RequestStep,
8
+ ScriptStep,
8
9
  ShellStep,
9
10
  SleepStep,
10
11
  Step,
@@ -14,6 +15,7 @@ import { executeShell } from './shell-executor.ts';
14
15
  import type { Logger } from './workflow-runner.ts';
15
16
 
16
17
  import * as readline from 'node:readline/promises';
18
+ import { SafeSandbox } from '../utils/sandbox.ts';
17
19
  import { executeLlmStep } from './llm-executor.ts';
18
20
  import type { MCPManager } from './mcp-manager.ts';
19
21
 
@@ -42,7 +44,8 @@ export async function executeStep(
42
44
  context: ExpressionContext,
43
45
  logger: Logger = console,
44
46
  executeWorkflowFn?: (step: WorkflowStep, context: ExpressionContext) => Promise<StepResult>,
45
- mcpManager?: MCPManager
47
+ mcpManager?: MCPManager,
48
+ workflowDir?: string
46
49
  ): Promise<StepResult> {
47
50
  try {
48
51
  let result: StepResult;
@@ -66,9 +69,10 @@ export async function executeStep(
66
69
  result = await executeLlmStep(
67
70
  step,
68
71
  context,
69
- (s, c) => executeStep(s, c, logger, executeWorkflowFn, mcpManager),
72
+ (s, c) => executeStep(s, c, logger, executeWorkflowFn, mcpManager, workflowDir),
70
73
  logger,
71
- mcpManager
74
+ mcpManager,
75
+ workflowDir
72
76
  );
73
77
  break;
74
78
  case 'workflow':
@@ -77,6 +81,9 @@ export async function executeStep(
77
81
  }
78
82
  result = await executeWorkflowFn(step, context);
79
83
  break;
84
+ case 'script':
85
+ result = await executeScriptStep(step, context, logger);
86
+ break;
80
87
  default:
81
88
  throw new Error(`Unknown step type: ${(step as Step).type}`);
82
89
  }
@@ -180,6 +187,13 @@ async function executeFileStep(
180
187
  throw new Error('Content is required for write operation');
181
188
  }
182
189
  const content = ExpressionEvaluator.evaluateString(step.content, context);
190
+
191
+ // Ensure parent directory exists
192
+ const fs = await import('node:fs/promises');
193
+ const pathModule = await import('node:path');
194
+ const dir = pathModule.dirname(path);
195
+ await fs.mkdir(dir, { recursive: true });
196
+
183
197
  const bytes = await Bun.write(path, content);
184
198
  return {
185
199
  output: { path, bytes },
@@ -193,8 +207,13 @@ async function executeFileStep(
193
207
  }
194
208
  const content = ExpressionEvaluator.evaluateString(step.content, context);
195
209
 
196
- // Use Node.js fs for efficient append operation
210
+ // Ensure parent directory exists
197
211
  const fs = await import('node:fs/promises');
212
+ const pathModule = await import('node:path');
213
+ const dir = pathModule.dirname(path);
214
+ await fs.mkdir(dir, { recursive: true });
215
+
216
+ // Use Node.js fs for efficient append operation
198
217
  await fs.appendFile(path, content, 'utf-8');
199
218
 
200
219
  return {
@@ -310,10 +329,25 @@ async function executeHumanStep(
310
329
  try {
311
330
  if (step.inputType === 'confirm') {
312
331
  logger.log(`\n❓ ${message}`);
313
- logger.log('Press Enter to continue, or Ctrl+C to cancel...');
314
- await rl.question('');
332
+ const answer = (await rl.question('Response (Y/n/text): ')).trim();
333
+
334
+ const lowerAnswer = answer.toLowerCase();
335
+ if (lowerAnswer === '' || lowerAnswer === 'y' || lowerAnswer === 'yes') {
336
+ return {
337
+ output: true,
338
+ status: 'success',
339
+ };
340
+ }
341
+ if (lowerAnswer === 'n' || lowerAnswer === 'no') {
342
+ return {
343
+ output: false,
344
+ status: 'success',
345
+ };
346
+ }
347
+
348
+ // Fallback to text if it's not a clear yes/no
315
349
  return {
316
- output: true,
350
+ output: answer,
317
351
  status: 'success',
318
352
  };
319
353
  }
@@ -353,3 +387,31 @@ async function executeSleepStep(
353
387
  status: 'success',
354
388
  };
355
389
  }
390
+ /**
391
+ * Execute a script step in a safe sandbox
392
+ */
393
+ async function executeScriptStep(
394
+ step: ScriptStep,
395
+ context: ExpressionContext,
396
+ _logger: Logger
397
+ ): Promise<StepResult> {
398
+ try {
399
+ const result = await SafeSandbox.execute(step.run, {
400
+ inputs: context.inputs,
401
+ secrets: context.secrets,
402
+ steps: context.steps,
403
+ env: context.env,
404
+ });
405
+
406
+ return {
407
+ output: result,
408
+ status: 'success',
409
+ };
410
+ } catch (error) {
411
+ return {
412
+ output: null,
413
+ status: 'failed',
414
+ error: error instanceof Error ? error.message : String(error),
415
+ };
416
+ }
417
+ }
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { dirname } from 'node:path';
2
3
  import { WorkflowDb } from '../db/workflow-db.ts';
3
4
  import type { ExpressionContext } from '../expression/evaluator.ts';
4
5
  import { ExpressionEvaluator } from '../expression/evaluator.ts';
@@ -24,7 +25,7 @@ class RedactingLogger implements Logger {
24
25
  constructor(
25
26
  private inner: Logger,
26
27
  private redactor: Redactor
27
- ) {}
28
+ ) { }
28
29
 
29
30
  log(msg: string): void {
30
31
  this.inner.log(this.redactor.redact(msg));
@@ -46,12 +47,13 @@ export interface RunOptions {
46
47
  logger?: Logger;
47
48
  mcpManager?: MCPManager;
48
49
  preventExit?: boolean; // Defaults to false
50
+ workflowDir?: string;
49
51
  }
50
52
 
51
53
  export interface StepContext {
52
54
  output?: unknown;
53
55
  outputs?: Record<string, unknown>;
54
- status: 'success' | 'failed' | 'skipped';
56
+ status: 'success' | 'failed' | 'skipped' | 'pending' | 'suspended';
55
57
  }
56
58
 
57
59
  // Type for foreach results - wraps array to ensure JSON serialization preserves all properties
@@ -194,7 +196,7 @@ export class WorkflowRunner {
194
196
  items[exec.iteration_index] = {
195
197
  output: null,
196
198
  outputs: {},
197
- status: exec.status as 'failed' | 'running' | 'pending',
199
+ status: exec.status as 'failed' | 'pending' | 'success' | 'skipped' | 'suspended',
198
200
  };
199
201
  }
200
202
  }
@@ -303,9 +305,37 @@ export class WorkflowRunner {
303
305
  private loadSecrets(): Record<string, string> {
304
306
  const secrets: Record<string, string> = {};
305
307
 
308
+ // Common non-secret environment variables to exclude from redaction
309
+ const blocklist = new Set([
310
+ 'USER',
311
+ 'PATH',
312
+ 'SHELL',
313
+ 'HOME',
314
+ 'PWD',
315
+ 'LOGNAME',
316
+ 'LANG',
317
+ 'TERM',
318
+ 'EDITOR',
319
+ 'VISUAL',
320
+ '_',
321
+ 'SHLVL',
322
+ 'LC_ALL',
323
+ 'OLDPWD',
324
+ 'DISPLAY',
325
+ 'TMPDIR',
326
+ 'SSH_AUTH_SOCK',
327
+ 'XPC_FLAGS',
328
+ 'XPC_SERVICE_NAME',
329
+ 'ITERM_SESSION_ID',
330
+ 'ITERM_PROFILE',
331
+ 'TERM_PROGRAM',
332
+ 'TERM_PROGRAM_VERSION',
333
+ 'COLORTERM',
334
+ ]);
335
+
306
336
  // Bun automatically loads .env file
307
337
  for (const [key, value] of Object.entries(Bun.env)) {
308
- if (value) {
338
+ if (value && !blocklist.has(key)) {
309
339
  secrets[key] = value;
310
340
  }
311
341
  }
@@ -456,7 +486,8 @@ export class WorkflowRunner {
456
486
  context,
457
487
  this.logger,
458
488
  this.executeSubWorkflow.bind(this),
459
- this.mcpManager
489
+ this.mcpManager,
490
+ this.options.workflowDir
460
491
  );
461
492
  if (result.status === 'failed') {
462
493
  throw new Error(result.error || 'Step failed');
@@ -482,11 +513,7 @@ export class WorkflowRunner {
482
513
  return result;
483
514
  }
484
515
 
485
- // Redact secrets from output and error before storing
486
- const redactedOutput = this.redactor.redactValue(result.output);
487
- const redactedError = result.error ? this.redactor.redact(result.error) : undefined;
488
-
489
- await this.db.completeStep(stepExecId, result.status, redactedOutput, redactedError);
516
+ await this.db.completeStep(stepExecId, result.status, result.output, result.error);
490
517
 
491
518
  // Ensure outputs is always an object for consistent access
492
519
  let outputs: Record<string, unknown>;
@@ -618,6 +645,7 @@ export class WorkflowRunner {
618
645
 
619
646
  // Execute and store result at correct index
620
647
  try {
648
+ this.logger.log(` ⤷ [${i + 1}/${items.length}] Executing iteration...`);
621
649
  itemResults[i] = await this.executeStepInternal(step, itemContext, stepExecId);
622
650
  if (itemResults[i].status === 'failed') {
623
651
  aborted = true;
@@ -700,6 +728,7 @@ export class WorkflowRunner {
700
728
  ): Promise<StepResult> {
701
729
  const workflowPath = WorkflowRegistry.resolvePath(step.path);
702
730
  const workflow = WorkflowParser.loadWorkflow(workflowPath);
731
+ const subWorkflowDir = dirname(workflowPath);
703
732
 
704
733
  // Evaluate inputs for the sub-workflow
705
734
  const inputs: Record<string, unknown> = {};
@@ -716,6 +745,7 @@ export class WorkflowRunner {
716
745
  dbPath: this.db.dbPath,
717
746
  logger: this.logger,
718
747
  mcpManager: this.mcpManager,
748
+ workflowDir: subWorkflowDir,
719
749
  });
720
750
 
721
751
  try {
@@ -755,7 +785,7 @@ export class WorkflowRunner {
755
785
  this.logger.log(`Run ID: ${this.runId}`);
756
786
  this.logger.log(
757
787
  '\n⚠️ Security Warning: Only run workflows from trusted sources.\n' +
758
- ' Workflows can execute arbitrary shell commands and access your environment.\n'
788
+ ' Workflows can execute arbitrary shell commands and access your environment.\n'
759
789
  );
760
790
 
761
791
  // Apply defaults and validate inputs
@@ -782,8 +812,7 @@ export class WorkflowRunner {
782
812
  this.logger.log('All steps already completed. Nothing to resume.\n');
783
813
  // Evaluate outputs from completed state
784
814
  const outputs = this.evaluateOutputs();
785
- const redactedOutputs = this.redactor.redactValue(outputs) as Record<string, unknown>;
786
- await this.db.updateRunStatus(this.runId, 'completed', redactedOutputs);
815
+ await this.db.updateRunStatus(this.runId, 'completed', outputs);
787
816
  this.logger.log('✨ Workflow already completed!\n');
788
817
  return outputs;
789
818
  }
@@ -794,6 +823,9 @@ export class WorkflowRunner {
794
823
 
795
824
  this.logger.log(`Execution order: ${executionOrder.join(' → ')}\n`);
796
825
 
826
+ const totalSteps = executionOrder.length;
827
+ const stepIndices = new Map(executionOrder.map((id, index) => [id, index + 1]));
828
+
797
829
  // Execute steps in parallel where possible (respecting dependencies)
798
830
  const pendingSteps = new Set(remainingSteps);
799
831
  const runningPromises = new Map<string, Promise<void>>();
@@ -806,18 +838,21 @@ export class WorkflowRunner {
806
838
  if (!step) {
807
839
  throw new Error(`Step ${stepId} not found in workflow`);
808
840
  }
809
- const dependenciesMet = step.needs.every((dep) => completedSteps.has(dep));
841
+ const dependenciesMet = step.needs.every((dep: string) => completedSteps.has(dep));
810
842
 
811
843
  if (dependenciesMet) {
812
844
  pendingSteps.delete(stepId);
813
845
 
814
846
  // Start execution
815
- this.logger.log(`▶ Executing step: ${step.id} (${step.type})`);
847
+ const stepIndex = stepIndices.get(stepId);
848
+ this.logger.log(
849
+ `[${stepIndex}/${totalSteps}] ▶ Executing step: ${step.id} (${step.type})`
850
+ );
816
851
  const promise = this.executeStepWithForeach(step)
817
852
  .then(() => {
818
853
  completedSteps.add(stepId);
819
854
  runningPromises.delete(stepId);
820
- this.logger.log(` ✓ Step ${step.id} completed\n`);
855
+ this.logger.log(`[${stepIndex}/${totalSteps}] ✓ Step ${step.id} completed\n`);
821
856
  })
822
857
  .catch((err) => {
823
858
  runningPromises.delete(stepId);
@@ -852,11 +887,8 @@ export class WorkflowRunner {
852
887
  // Evaluate outputs
853
888
  const outputs = this.evaluateOutputs();
854
889
 
855
- // Redact secrets from outputs before storing
856
- const redactedOutputs = this.redactor.redactValue(outputs) as Record<string, unknown>;
857
-
858
890
  // Mark run as complete
859
- await this.db.updateRunStatus(this.runId, 'completed', redactedOutputs);
891
+ await this.db.updateRunStatus(this.runId, 'completed', outputs);
860
892
 
861
893
  this.logger.log('✨ Workflow completed successfully!\n');
862
894
 
@@ -874,7 +906,9 @@ export class WorkflowRunner {
874
906
  } finally {
875
907
  this.removeSignalHandlers();
876
908
  await this.runFinally();
877
- await this.mcpManager.stopAll();
909
+ if (!this.options.mcpManager) {
910
+ await this.mcpManager.stopAll();
911
+ }
878
912
  this.db.close();
879
913
  }
880
914
  }
@@ -893,6 +927,8 @@ export class WorkflowRunner {
893
927
  const completedFinallySteps = new Set<string>();
894
928
  const pendingFinallySteps = new Set(this.workflow.finally.map((s) => s.id));
895
929
  const runningPromises = new Map<string, Promise<void>>();
930
+ const totalFinallySteps = this.workflow.finally.length;
931
+ const finallyStepIndices = new Map(this.workflow.finally.map((s, index) => [s.id, index + 1]));
896
932
 
897
933
  try {
898
934
  while (pendingFinallySteps.size > 0 || runningPromises.size > 0) {
@@ -902,18 +938,23 @@ export class WorkflowRunner {
902
938
 
903
939
  // Dependencies can be from main steps (already in this.stepContexts) or previous finally steps
904
940
  const dependenciesMet = step.needs.every(
905
- (dep) => this.stepContexts.has(dep) || completedFinallySteps.has(dep)
941
+ (dep: string) => this.stepContexts.has(dep) || completedFinallySteps.has(dep)
906
942
  );
907
943
 
908
944
  if (dependenciesMet) {
909
945
  pendingFinallySteps.delete(stepId);
910
946
 
911
- this.logger.log(`▶ Executing finally step: ${step.id} (${step.type})`);
947
+ const finallyStepIndex = finallyStepIndices.get(stepId);
948
+ this.logger.log(
949
+ `[${finallyStepIndex}/${totalFinallySteps}] ▶ Executing finally step: ${step.id} (${step.type})`
950
+ );
912
951
  const promise = this.executeStepWithForeach(step)
913
952
  .then(() => {
914
953
  completedFinallySteps.add(stepId);
915
954
  runningPromises.delete(stepId);
916
- this.logger.log(` ✓ Finally step ${step.id} completed\n`);
955
+ this.logger.log(
956
+ `[${finallyStepIndex}/${totalFinallySteps}] ✓ Finally step ${step.id} completed\n`
957
+ );
917
958
  })
918
959
  .catch((err) => {
919
960
  runningPromises.delete(stepId);
@@ -149,4 +149,90 @@ describe('AuthManager', () => {
149
149
  consoleSpy.mockRestore();
150
150
  });
151
151
  });
152
+
153
+ describe('Device Login', () => {
154
+ it('initGitHubDeviceLogin should return device code data', async () => {
155
+ const mockFetch = mock(() =>
156
+ Promise.resolve(
157
+ new Response(
158
+ JSON.stringify({
159
+ device_code: 'dev_code',
160
+ user_code: 'USER-CODE',
161
+ verification_uri: 'https://github.com/login/device',
162
+ expires_in: 900,
163
+ interval: 5,
164
+ }),
165
+ { status: 200 }
166
+ )
167
+ )
168
+ );
169
+ // @ts-ignore
170
+ global.fetch = mockFetch;
171
+
172
+ const result = await AuthManager.initGitHubDeviceLogin();
173
+ expect(result.device_code).toBe('dev_code');
174
+ expect(result.user_code).toBe('USER-CODE');
175
+ expect(mockFetch).toHaveBeenCalled();
176
+ });
177
+
178
+ it('pollGitHubDeviceLogin should return token when successful', async () => {
179
+ let callCount = 0;
180
+ const mockFetch = mock(() => {
181
+ callCount++;
182
+ if (callCount === 1) {
183
+ return Promise.resolve(
184
+ new Response(
185
+ JSON.stringify({
186
+ error: 'authorization_pending',
187
+ }),
188
+ { status: 200 }
189
+ )
190
+ );
191
+ }
192
+ return Promise.resolve(
193
+ new Response(
194
+ JSON.stringify({
195
+ access_token: 'gh_access_token',
196
+ }),
197
+ { status: 200 }
198
+ )
199
+ );
200
+ });
201
+ // @ts-ignore
202
+ global.fetch = mockFetch;
203
+
204
+ // Mock setTimeout to resolve immediately
205
+ const originalTimeout = global.setTimeout;
206
+ // @ts-ignore
207
+ global.setTimeout = (fn) => fn();
208
+
209
+ try {
210
+ const token = await AuthManager.pollGitHubDeviceLogin('dev_code');
211
+ expect(token).toBe('gh_access_token');
212
+ expect(callCount).toBe(2);
213
+ } finally {
214
+ global.setTimeout = originalTimeout;
215
+ }
216
+ });
217
+
218
+ it('pollGitHubDeviceLogin should throw on other errors', async () => {
219
+ const mockFetch = mock(() =>
220
+ Promise.resolve(
221
+ new Response(
222
+ JSON.stringify({
223
+ error: 'expired_token',
224
+ error_description: 'The device code has expired',
225
+ }),
226
+ { status: 200 }
227
+ )
228
+ )
229
+ );
230
+ // @ts-ignore
231
+ global.fetch = mockFetch;
232
+
233
+ await expect(AuthManager.pollGitHubDeviceLogin('dev_code')).rejects.toThrow(
234
+ 'The device code has expired'
235
+ );
236
+ });
237
+ });
152
238
  });
@@ -6,6 +6,16 @@ export interface AuthData {
6
6
  github_token?: string;
7
7
  copilot_token?: string;
8
8
  copilot_expires_at?: number;
9
+ openai_api_key?: string;
10
+ anthropic_api_key?: string;
11
+ mcp_tokens?: Record<
12
+ string,
13
+ {
14
+ access_token: string;
15
+ expires_at?: number;
16
+ refresh_token?: string;
17
+ }
18
+ >;
9
19
  }
10
20
 
11
21
  export const COPILOT_HEADERS = {
@@ -14,6 +24,8 @@ export const COPILOT_HEADERS = {
14
24
  'User-Agent': 'GithubCopilot/1.255.0',
15
25
  };
16
26
 
27
+ const GITHUB_CLIENT_ID = '013444988716b5155f4c'; // GitHub CLI Client ID
28
+
17
29
  export class AuthManager {
18
30
  private static getAuthPath(): string {
19
31
  if (process.env.KEYSTONE_AUTH_PATH) {
@@ -44,6 +56,83 @@ export class AuthManager {
44
56
  writeFileSync(path, JSON.stringify({ ...current, ...data }, null, 2));
45
57
  }
46
58
 
59
+ static async initGitHubDeviceLogin(): Promise<{
60
+ device_code: string;
61
+ user_code: string;
62
+ verification_uri: string;
63
+ expires_in: number;
64
+ interval: number;
65
+ }> {
66
+ const response = await fetch('https://github.com/login/device/code', {
67
+ method: 'POST',
68
+ headers: {
69
+ 'Content-Type': 'application/json',
70
+ Accept: 'application/json',
71
+ },
72
+ body: JSON.stringify({
73
+ client_id: GITHUB_CLIENT_ID,
74
+ scope: 'read:user workflow repo',
75
+ }),
76
+ });
77
+
78
+ if (!response.ok) {
79
+ throw new Error(`Failed to initialize device login: ${response.statusText}`);
80
+ }
81
+
82
+ return response.json() as Promise<{
83
+ device_code: string;
84
+ user_code: string;
85
+ verification_uri: string;
86
+ expires_in: number;
87
+ interval: number;
88
+ }>;
89
+ }
90
+
91
+ static async pollGitHubDeviceLogin(deviceCode: string): Promise<string> {
92
+ const poll = async (): Promise<string> => {
93
+ const response = await fetch('https://github.com/login/oauth/access_token', {
94
+ method: 'POST',
95
+ headers: {
96
+ 'Content-Type': 'application/json',
97
+ Accept: 'application/json',
98
+ },
99
+ body: JSON.stringify({
100
+ client_id: GITHUB_CLIENT_ID,
101
+ device_code: deviceCode,
102
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
103
+ }),
104
+ });
105
+
106
+ if (!response.ok) {
107
+ throw new Error(`Failed to poll device login: ${response.statusText}`);
108
+ }
109
+
110
+ const data = (await response.json()) as {
111
+ access_token?: string;
112
+ error?: string;
113
+ error_description?: string;
114
+ };
115
+
116
+ if (data.access_token) {
117
+ return data.access_token;
118
+ }
119
+
120
+ if (data.error === 'authorization_pending') {
121
+ return ''; // Continue polling
122
+ }
123
+
124
+ throw new Error(data.error_description || data.error || 'Failed to get access token');
125
+ };
126
+
127
+ // Poll every 5 seconds (GitHub's default interval is usually 5)
128
+ // In a real implementation, we should use the interval from initGitHubDeviceLogin
129
+ while (true) {
130
+ const token = await poll();
131
+ if (token) return token;
132
+ await new Promise((resolve) => setTimeout(resolve, 5000));
133
+ }
134
+ }
135
+
47
136
  static async getCopilotToken(): Promise<string | undefined> {
48
137
  const auth = AuthManager.load();
49
138