@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 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
- 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
+ * 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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sunerpy/opencode-kiro-auth",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "OpenCode plugin for AWS Kiro (CodeWhisperer) providing access to Claude models",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",