@sunerpy/opencode-kiro-auth 0.2.0 → 0.2.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.
@@ -14,5 +14,23 @@ export declare class AuthHandler {
14
14
  /** Format a single account as a select-option label for the remove flow. */
15
15
  private formatAccountOption;
16
16
  getMethods(): AuthHook['methods'];
17
+ /** Ends the auth flow cleanly with no key prompt and no credential written. */
18
+ private endWithoutCredential;
19
+ /**
20
+ * Ends the flow after a successful deletion. If a healthy account (or any
21
+ * account with a usable access token) remains, returns a SUCCESS callback
22
+ * keyed on that account's token so OpenCode shows success and persists a
23
+ * still-valid credential. If nothing usable remains, falls back to a failed
24
+ * callback (nothing left to authorize with).
25
+ */
26
+ private endWithRemainingCredentialOrFailed;
27
+ /**
28
+ * Self-drawn account-removal flow. No-op paths (cancel / no accounts /
29
+ * not-confirmed / non-TTY) end with method:'auto' + a failed callback so no
30
+ * key prompt appears and no bogus success is shown. The actual-deletion path
31
+ * ends with a remaining-account success (or failed if none remain), so a
32
+ * successful removal is not misreported as "Failed to authorize".
33
+ */
34
+ private authorizeRemoveAccounts;
17
35
  }
18
36
  export {};
@@ -1,6 +1,7 @@
1
1
  import { RegionSchema } from '../../plugin/config/schema.js';
2
2
  import * as logger from '../../plugin/logger.js';
3
3
  import { IdcAuthMethod } from './idc-auth-method.js';
4
+ import { isInteractiveTty, ttyConfirm, ttySelect } from './tty-menu.js';
4
5
  export class AuthHandler {
5
6
  config;
6
7
  repository;
@@ -195,42 +196,84 @@ export class AuthHandler {
195
196
  authorize: (inputs) => idcMethod.authorize(inputs)
196
197
  }
197
198
  ];
198
- const count = currentAccounts.length;
199
- const removeLabel = count > 0 ? `Remove account · ${count} stored` : 'Remove account · none stored';
200
- const removeOptions = currentAccounts.map((acc) => ({
201
- label: this.formatAccountOption(acc),
202
- value: String(acc.id)
203
- }));
204
- removeOptions.push({ label: 'Cancel', value: '__cancel__' });
199
+ // Removal must be `type:'oauth'`, not `type:'api'`: OpenCode forces an
200
+ // "Enter your API key" prompt on `api` methods, breaking a removal flow.
205
201
  methods.push({
206
- label: removeLabel,
207
- type: 'api',
208
- prompts: [
209
- {
210
- type: 'select',
211
- key: 'account_id',
212
- message: 'Select the account to remove',
213
- options: removeOptions
214
- }
215
- ],
216
- authorize: async (inputs) => {
217
- const accountId = inputs?.account_id;
218
- if (!accountId || accountId === '__cancel__') {
219
- logger.log('Remove Kiro account: cancelled (no-op)');
220
- return { type: 'failed' };
221
- }
222
- const target = currentAccounts.find((acc) => String(acc.id) === accountId);
223
- if (!target) {
224
- logger.warn('Remove Kiro account: id not found', { accountId });
225
- return { type: 'failed' };
226
- }
227
- this.accountManager.removeAccount(target);
228
- logger.log('Removed Kiro account', { email: target.email, accountId });
229
- // Return 'failed' after the deletion side-effect: 'success' would make
230
- // OpenCode persist a bogus auth.json credential for a removal action.
231
- return { type: 'failed' };
232
- }
202
+ label: 'Manage / remove accounts',
203
+ type: 'oauth',
204
+ authorize: async () => this.authorizeRemoveAccounts()
233
205
  });
234
206
  return methods;
235
207
  }
208
+ /** Ends the auth flow cleanly with no key prompt and no credential written. */
209
+ endWithoutCredential(instructions) {
210
+ return {
211
+ url: '',
212
+ instructions,
213
+ method: 'auto',
214
+ callback: async () => ({ type: 'failed' })
215
+ };
216
+ }
217
+ /**
218
+ * Ends the flow after a successful deletion. If a healthy account (or any
219
+ * account with a usable access token) remains, returns a SUCCESS callback
220
+ * keyed on that account's token so OpenCode shows success and persists a
221
+ * still-valid credential. If nothing usable remains, falls back to a failed
222
+ * callback (nothing left to authorize with).
223
+ */
224
+ endWithRemainingCredentialOrFailed(instructions) {
225
+ const remaining = this.accountManager?.getAccounts?.() ?? [];
226
+ const hasToken = (acc) => typeof acc?.accessToken === 'string' && acc.accessToken.length > 0;
227
+ const fallback = remaining.find((acc) => acc?.isHealthy && hasToken(acc)) ??
228
+ remaining.find((acc) => hasToken(acc));
229
+ if (fallback) {
230
+ const key = fallback.accessToken;
231
+ return {
232
+ url: '',
233
+ instructions: `${instructions} Using ${fallback.email || 'a remaining account'} for future requests.`,
234
+ method: 'auto',
235
+ callback: async () => ({ type: 'success', key })
236
+ };
237
+ }
238
+ return this.endWithoutCredential(`${instructions} No accounts remain — run \`opencode auth login\` to reauthenticate.`);
239
+ }
240
+ /**
241
+ * Self-drawn account-removal flow. No-op paths (cancel / no accounts /
242
+ * not-confirmed / non-TTY) end with method:'auto' + a failed callback so no
243
+ * key prompt appears and no bogus success is shown. The actual-deletion path
244
+ * ends with a remaining-account success (or failed if none remain), so a
245
+ * successful removal is not misreported as "Failed to authorize".
246
+ */
247
+ async authorizeRemoveAccounts() {
248
+ const accounts = this.accountManager?.getAccounts?.() ?? [];
249
+ if (accounts.length === 0) {
250
+ logger.log('Remove Kiro account: no accounts to remove');
251
+ return this.endWithoutCredential('No accounts to remove.');
252
+ }
253
+ if (!isInteractiveTty()) {
254
+ logger.log('Remove Kiro account: non-TTY, skipping interactive menu');
255
+ const sqliteHint = 'sqlite3 ~/.config/opencode/kiro.db "DELETE FROM accounts WHERE email=\'<email>\';"';
256
+ return this.endWithoutCredential('Account removal requires an interactive terminal. Run `opencode auth login` ' +
257
+ `in a TTY, or remove via: ${sqliteHint}`);
258
+ }
259
+ const items = accounts.map((acc) => ({
260
+ label: this.formatAccountOption(acc),
261
+ value: acc
262
+ }));
263
+ items.push({ label: 'Cancel', value: null });
264
+ const target = await ttySelect(items, { message: 'Select an account to remove' });
265
+ if (!target) {
266
+ logger.log('Remove Kiro account: cancelled (no-op)');
267
+ return this.endWithoutCredential('Cancelled. No account removed.');
268
+ }
269
+ const confirmed = await ttyConfirm(`Delete ${target.email || 'this account'}?`);
270
+ if (!confirmed) {
271
+ logger.log('Remove Kiro account: delete not confirmed (no-op)');
272
+ return this.endWithoutCredential('Cancelled. No account removed.');
273
+ }
274
+ this.accountManager.removeAccount(target);
275
+ logger.log('Removed Kiro account', { email: target.email, accountId: String(target.id) });
276
+ process.stdout.write('Account deleted.\n');
277
+ return this.endWithRemainingCredentialOrFailed(`Account deleted: ${target.email || 'unknown'}.`);
278
+ }
236
279
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Minimal, dependency-free interactive TTY helpers for auth account management.
3
+ *
4
+ * Ported (in spirit) from opencode-antigravity-auth's ui/{select,confirm,ansi}.js.
5
+ * Kept small on purpose: raw-mode stdin, ANSI cursor control, arrow/enter/esc,
6
+ * ctrl-c to cancel. No external deps. All rendering is self-drawn so it never
7
+ * routes through OpenCode's prompt system (which would force a key prompt).
8
+ */
9
+ /** True only when both stdin and stdout are interactive terminals. */
10
+ export declare function isInteractiveTty(): boolean;
11
+ /** Wrap an index into [0, length) — exported for pure unit testing. */
12
+ export declare function wrapIndex(index: number, length: number): number;
13
+ export interface TtySelectItem<T> {
14
+ label: string;
15
+ value: T;
16
+ }
17
+ export interface TtySelectOptions {
18
+ message: string;
19
+ }
20
+ /**
21
+ * Render an interactive single-select menu on the TTY.
22
+ * Resolves with the chosen item's value, or `null` if the user cancels
23
+ * (esc / ctrl-c) or raw mode cannot be enabled.
24
+ *
25
+ * Requires an interactive TTY; callers must gate with {@link isInteractiveTty}.
26
+ */
27
+ export declare function ttySelect<T>(items: ReadonlyArray<TtySelectItem<T>>, options: TtySelectOptions): Promise<T | null>;
28
+ /**
29
+ * Interactive yes/no confirmation built on {@link ttySelect}.
30
+ * Defaults the cursor to "No" for safety. Cancel (esc/ctrl-c) resolves false.
31
+ */
32
+ export declare function ttyConfirm(message: string): Promise<boolean>;
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Minimal, dependency-free interactive TTY helpers for auth account management.
3
+ *
4
+ * Ported (in spirit) from opencode-antigravity-auth's ui/{select,confirm,ansi}.js.
5
+ * Kept small on purpose: raw-mode stdin, ANSI cursor control, arrow/enter/esc,
6
+ * ctrl-c to cancel. No external deps. All rendering is self-drawn so it never
7
+ * routes through OpenCode's prompt system (which would force a key prompt).
8
+ */
9
+ /** ANSI escape codes used by the interactive menu. */
10
+ const ANSI = {
11
+ hide: '\x1b[?25l',
12
+ show: '\x1b[?25h',
13
+ up: (n = 1) => `\x1b[${n}A`,
14
+ clearLine: '\x1b[2K',
15
+ cyan: '\x1b[36m',
16
+ green: '\x1b[32m',
17
+ dim: '\x1b[2m',
18
+ reset: '\x1b[0m'
19
+ };
20
+ /**
21
+ * Parse a raw keyboard input buffer into a key action.
22
+ * Handles Windows/Mac/Linux differences in arrow-key sequences.
23
+ */
24
+ function parseKey(data) {
25
+ const s = data.toString();
26
+ if (s === '\x1b[A' || s === '\x1bOA')
27
+ return 'up';
28
+ if (s === '\x1b[B' || s === '\x1bOB')
29
+ return 'down';
30
+ if (s === '\r' || s === '\n')
31
+ return 'enter';
32
+ if (s === '\x03')
33
+ return 'cancel'; // ctrl-c
34
+ if (s === '\x1b')
35
+ return 'escape-start'; // bare esc
36
+ return null;
37
+ }
38
+ /** True only when both stdin and stdout are interactive terminals. */
39
+ export function isInteractiveTty() {
40
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
41
+ }
42
+ /** Wrap an index into [0, length) — exported for pure unit testing. */
43
+ export function wrapIndex(index, length) {
44
+ if (length <= 0)
45
+ return 0;
46
+ return ((index % length) + length) % length;
47
+ }
48
+ const ESCAPE_TIMEOUT_MS = 50;
49
+ /**
50
+ * Render an interactive single-select menu on the TTY.
51
+ * Resolves with the chosen item's value, or `null` if the user cancels
52
+ * (esc / ctrl-c) or raw mode cannot be enabled.
53
+ *
54
+ * Requires an interactive TTY; callers must gate with {@link isInteractiveTty}.
55
+ */
56
+ export async function ttySelect(items, options) {
57
+ if (items.length === 0)
58
+ return null;
59
+ const { message } = options;
60
+ const { stdin, stdout } = process;
61
+ let cursor = 0;
62
+ let renderedLines = 0;
63
+ let escapeTimeout = null;
64
+ let isCleanedUp = false;
65
+ const render = () => {
66
+ if (renderedLines > 0) {
67
+ stdout.write(ANSI.up(renderedLines));
68
+ }
69
+ let linesWritten = 0;
70
+ const writeLine = (line) => {
71
+ stdout.write(`${ANSI.clearLine}${line}\n`);
72
+ linesWritten += 1;
73
+ };
74
+ writeLine(`${ANSI.dim}?${ANSI.reset} ${message}`);
75
+ for (let i = 0; i < items.length; i++) {
76
+ const item = items[i];
77
+ if (!item)
78
+ continue;
79
+ const isSelected = i === cursor;
80
+ if (isSelected) {
81
+ writeLine(`${ANSI.cyan}│${ANSI.reset} ${ANSI.green}●${ANSI.reset} ${item.label}`);
82
+ }
83
+ else {
84
+ writeLine(`${ANSI.cyan}│${ANSI.reset} ${ANSI.dim}○ ${item.label}${ANSI.reset}`);
85
+ }
86
+ }
87
+ writeLine(`${ANSI.cyan}└${ANSI.reset} ${ANSI.dim}Up/Down: move | Enter: confirm | Esc: cancel${ANSI.reset}`);
88
+ renderedLines = linesWritten;
89
+ };
90
+ return new Promise((resolve) => {
91
+ const wasRaw = stdin.isRaw ?? false;
92
+ const cleanup = () => {
93
+ if (isCleanedUp)
94
+ return;
95
+ isCleanedUp = true;
96
+ if (escapeTimeout) {
97
+ clearTimeout(escapeTimeout);
98
+ escapeTimeout = null;
99
+ }
100
+ try {
101
+ stdin.removeListener('data', onKey);
102
+ stdin.setRawMode(wasRaw);
103
+ stdin.pause();
104
+ stdout.write(ANSI.show);
105
+ }
106
+ catch {
107
+ // best-effort cleanup
108
+ }
109
+ process.removeListener('SIGINT', onSignal);
110
+ process.removeListener('SIGTERM', onSignal);
111
+ };
112
+ const finish = (value) => {
113
+ cleanup();
114
+ resolve(value);
115
+ };
116
+ const onSignal = () => finish(null);
117
+ const onKey = (data) => {
118
+ if (escapeTimeout) {
119
+ clearTimeout(escapeTimeout);
120
+ escapeTimeout = null;
121
+ }
122
+ const action = parseKey(data);
123
+ switch (action) {
124
+ case 'up':
125
+ cursor = wrapIndex(cursor - 1, items.length);
126
+ render();
127
+ return;
128
+ case 'down':
129
+ cursor = wrapIndex(cursor + 1, items.length);
130
+ render();
131
+ return;
132
+ case 'enter':
133
+ finish(items[cursor]?.value ?? null);
134
+ return;
135
+ case 'cancel':
136
+ finish(null);
137
+ return;
138
+ case 'escape-start':
139
+ // Bare escape byte — wait briefly to see if it's an arrow sequence.
140
+ escapeTimeout = setTimeout(() => finish(null), ESCAPE_TIMEOUT_MS);
141
+ return;
142
+ default:
143
+ return;
144
+ }
145
+ };
146
+ process.once('SIGINT', onSignal);
147
+ process.once('SIGTERM', onSignal);
148
+ try {
149
+ stdin.setRawMode(true);
150
+ }
151
+ catch {
152
+ cleanup();
153
+ resolve(null);
154
+ return;
155
+ }
156
+ stdin.resume();
157
+ stdout.write(ANSI.hide);
158
+ render();
159
+ stdin.on('data', onKey);
160
+ });
161
+ }
162
+ /**
163
+ * Interactive yes/no confirmation built on {@link ttySelect}.
164
+ * Defaults the cursor to "No" for safety. Cancel (esc/ctrl-c) resolves false.
165
+ */
166
+ export async function ttyConfirm(message) {
167
+ const result = await ttySelect([
168
+ { label: 'No', value: false },
169
+ { label: 'Yes', value: true }
170
+ ], { message });
171
+ return result ?? false;
172
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sunerpy/opencode-kiro-auth",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "OpenCode plugin for AWS Kiro (CodeWhisperer) providing access to Claude models",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",