@sunerpy/opencode-kiro-auth 0.1.0 → 0.2.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.
- package/README.md +14 -0
- package/dist/core/auth/auth-handler.d.ts +7 -0
- package/dist/core/auth/auth-handler.js +51 -34
- package/dist/core/auth/tty-menu.d.ts +32 -0
- package/dist/core/auth/tty-menu.js +172 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -225,6 +225,20 @@ make build # tsc + fix-esm-imports -> dist/
|
|
|
225
225
|
Agent contributors: see [AGENTS.md](AGENTS.md) for the codebase architecture
|
|
226
226
|
map, invariants that must not break, and the CodeGraph-assisted workflow.
|
|
227
227
|
|
|
228
|
+
### Releasing
|
|
229
|
+
|
|
230
|
+
Releases are automated with
|
|
231
|
+
[release-please](https://github.com/googleapis/release-please):
|
|
232
|
+
|
|
233
|
+
- Use [Conventional Commits](https://www.conventionalcommits.org/) for commit
|
|
234
|
+
messages and pull request titles (`feat:`, `fix:`, `chore:`, …) — these drive
|
|
235
|
+
the next version bump automatically.
|
|
236
|
+
- release-please opens and maintains a release pull request on `main`.
|
|
237
|
+
**Merging that PR** cuts the git tag + GitHub Release and triggers the
|
|
238
|
+
workflow that runs typecheck/test/build and then publishes to npm.
|
|
239
|
+
- Contributors never hand-edit the version in `package.json` or the files under
|
|
240
|
+
[`changelog/`](changelog/) — release-please maintains both.
|
|
241
|
+
|
|
228
242
|
## Storage
|
|
229
243
|
|
|
230
244
|
**Linux/macOS:**
|
|
@@ -14,5 +14,12 @@ 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
|
+
* Self-drawn account-removal flow returning method:'auto' + failed-callback in
|
|
21
|
+
* all cases, so the auth flow ends with no key prompt and NO credential written.
|
|
22
|
+
*/
|
|
23
|
+
private authorizeRemoveAccounts;
|
|
17
24
|
}
|
|
18
25
|
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,58 @@ 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
|
+
* Self-drawn account-removal flow returning method:'auto' + failed-callback in
|
|
219
|
+
* all cases, so the auth flow ends with no key prompt and NO credential written.
|
|
220
|
+
*/
|
|
221
|
+
async authorizeRemoveAccounts() {
|
|
222
|
+
const accounts = this.accountManager?.getAccounts?.() ?? [];
|
|
223
|
+
if (accounts.length === 0) {
|
|
224
|
+
logger.log('Remove Kiro account: no accounts to remove');
|
|
225
|
+
return this.endWithoutCredential('No accounts to remove.');
|
|
226
|
+
}
|
|
227
|
+
if (!isInteractiveTty()) {
|
|
228
|
+
logger.log('Remove Kiro account: non-TTY, skipping interactive menu');
|
|
229
|
+
const sqliteHint = 'sqlite3 ~/.config/opencode/kiro.db "DELETE FROM accounts WHERE email=\'<email>\';"';
|
|
230
|
+
return this.endWithoutCredential('Account removal requires an interactive terminal. Run `opencode auth login` ' +
|
|
231
|
+
`in a TTY, or remove via: ${sqliteHint}`);
|
|
232
|
+
}
|
|
233
|
+
const items = accounts.map((acc) => ({
|
|
234
|
+
label: this.formatAccountOption(acc),
|
|
235
|
+
value: acc
|
|
236
|
+
}));
|
|
237
|
+
items.push({ label: 'Cancel', value: null });
|
|
238
|
+
const target = await ttySelect(items, { message: 'Select an account to remove' });
|
|
239
|
+
if (!target) {
|
|
240
|
+
logger.log('Remove Kiro account: cancelled (no-op)');
|
|
241
|
+
return this.endWithoutCredential('Cancelled. No account removed.');
|
|
242
|
+
}
|
|
243
|
+
const confirmed = await ttyConfirm(`Delete ${target.email || 'this account'}?`);
|
|
244
|
+
if (!confirmed) {
|
|
245
|
+
logger.log('Remove Kiro account: delete not confirmed (no-op)');
|
|
246
|
+
return this.endWithoutCredential('Cancelled. No account removed.');
|
|
247
|
+
}
|
|
248
|
+
this.accountManager.removeAccount(target);
|
|
249
|
+
logger.log('Removed Kiro account', { email: target.email, accountId: String(target.id) });
|
|
250
|
+
process.stdout.write('Account deleted.\n');
|
|
251
|
+
return this.endWithoutCredential(`Account deleted: ${target.email || 'unknown'}.`);
|
|
252
|
+
}
|
|
236
253
|
}
|
|
@@ -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
|
+
}
|