@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
|
-
|
|
199
|
-
|
|
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:
|
|
207
|
-
type: '
|
|
208
|
-
|
|
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
|
+
}
|