@getrouter/getrouter-cli 0.1.0
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/.github/workflows/ci.yml +19 -0
- package/AGENTS.md +78 -0
- package/README.ja.md +116 -0
- package/README.md +116 -0
- package/README.zh-cn.md +116 -0
- package/biome.json +10 -0
- package/bun.lock +397 -0
- package/dist/bin.mjs +1422 -0
- package/docs/plans/2026-01-01-getrouter-cli-config-command-plan.md +231 -0
- package/docs/plans/2026-01-01-getrouter-cli-config-core-plan.md +307 -0
- package/docs/plans/2026-01-01-getrouter-cli-design.md +106 -0
- package/docs/plans/2026-01-01-getrouter-cli-scaffold-plan.md +327 -0
- package/docs/plans/2026-01-02-getrouter-cli-auth-design.md +68 -0
- package/docs/plans/2026-01-02-getrouter-cli-auth-device-design.md +73 -0
- package/docs/plans/2026-01-02-getrouter-cli-auth-device-plan.md +411 -0
- package/docs/plans/2026-01-02-getrouter-cli-auth-plan.md +435 -0
- package/docs/plans/2026-01-02-getrouter-cli-http-client-plan.md +235 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-create-update-output-design.md +24 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-create-update-output-plan.md +141 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-delete-output-design.md +22 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-delete-output-plan.md +122 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-get-output-design.md +23 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-get-output-plan.md +141 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-interactive-design.md +28 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-interactive-plan.md +247 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-output-design.md +31 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-output-plan.md +187 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-subscription-design.md +52 -0
- package/docs/plans/2026-01-02-getrouter-cli-keys-subscription-plan.md +306 -0
- package/docs/plans/2026-01-02-getrouter-cli-setup-env-design.md +67 -0
- package/docs/plans/2026-01-02-getrouter-cli-setup-env-plan.md +441 -0
- package/docs/plans/2026-01-02-getrouter-cli-subscription-output-design.md +34 -0
- package/docs/plans/2026-01-02-getrouter-cli-subscription-output-plan.md +157 -0
- package/docs/plans/2026-01-03-bun-migration-plan.md +103 -0
- package/docs/plans/2026-01-03-cli-emoji-output.md +45 -0
- package/docs/plans/2026-01-03-cli-english-output.md +123 -0
- package/docs/plans/2026-01-03-cli-simplify-design.md +62 -0
- package/docs/plans/2026-01-03-cli-simplify-implementation.md +468 -0
- package/docs/plans/2026-01-03-readme-command-descriptions.md +116 -0
- package/docs/plans/2026-01-03-tsdown-migration-plan.md +75 -0
- package/docs/plans/2026-01-04-cli-docs-cleanup-design.md +49 -0
- package/docs/plans/2026-01-04-cli-docs-cleanup-plan.md +126 -0
- package/docs/plans/2026-01-04-codex-multistep-design.md +76 -0
- package/docs/plans/2026-01-04-codex-multistep-plan.md +240 -0
- package/docs/plans/2026-01-04-env-hook-design.md +48 -0
- package/docs/plans/2026-01-04-env-hook-plan.md +173 -0
- package/docs/plans/2026-01-04-models-keys-fuzzy-design.md +75 -0
- package/docs/plans/2026-01-04-models-keys-fuzzy-implementation.md +704 -0
- package/package.json +37 -0
- package/src/.gitkeep +0 -0
- package/src/bin.ts +4 -0
- package/src/cli.ts +12 -0
- package/src/cmd/auth.ts +44 -0
- package/src/cmd/claude.ts +10 -0
- package/src/cmd/codex.ts +119 -0
- package/src/cmd/config-helpers.ts +16 -0
- package/src/cmd/config.ts +31 -0
- package/src/cmd/env.ts +103 -0
- package/src/cmd/index.ts +20 -0
- package/src/cmd/keys.ts +207 -0
- package/src/cmd/models.ts +48 -0
- package/src/cmd/status.ts +106 -0
- package/src/cmd/usages.ts +29 -0
- package/src/core/api/client.ts +79 -0
- package/src/core/auth/device.ts +105 -0
- package/src/core/auth/index.ts +37 -0
- package/src/core/config/fs.ts +13 -0
- package/src/core/config/index.ts +37 -0
- package/src/core/config/paths.ts +5 -0
- package/src/core/config/redact.ts +18 -0
- package/src/core/config/types.ts +23 -0
- package/src/core/http/errors.ts +32 -0
- package/src/core/http/request.ts +41 -0
- package/src/core/http/url.ts +12 -0
- package/src/core/interactive/clipboard.ts +61 -0
- package/src/core/interactive/codex.ts +75 -0
- package/src/core/interactive/fuzzy.ts +64 -0
- package/src/core/interactive/keys.ts +164 -0
- package/src/core/output/table.ts +34 -0
- package/src/core/output/usages.ts +75 -0
- package/src/core/paths.ts +4 -0
- package/src/core/setup/codex.ts +129 -0
- package/src/core/setup/env.ts +220 -0
- package/src/core/usages/aggregate.ts +69 -0
- package/src/generated/router/dashboard/v1/index.ts +1104 -0
- package/src/index.ts +1 -0
- package/tests/.gitkeep +0 -0
- package/tests/auth/device.test.ts +75 -0
- package/tests/auth/status.test.ts +64 -0
- package/tests/cli.test.ts +31 -0
- package/tests/cmd/auth.test.ts +90 -0
- package/tests/cmd/claude.test.ts +132 -0
- package/tests/cmd/codex.test.ts +147 -0
- package/tests/cmd/config-helpers.test.ts +18 -0
- package/tests/cmd/config.test.ts +56 -0
- package/tests/cmd/keys.test.ts +163 -0
- package/tests/cmd/models.test.ts +63 -0
- package/tests/cmd/status.test.ts +82 -0
- package/tests/cmd/usages.test.ts +42 -0
- package/tests/config/fs.test.ts +14 -0
- package/tests/config/index.test.ts +63 -0
- package/tests/config/paths.test.ts +10 -0
- package/tests/config/redact.test.ts +17 -0
- package/tests/config/types.test.ts +10 -0
- package/tests/core/api/client.test.ts +92 -0
- package/tests/core/interactive/clipboard.test.ts +44 -0
- package/tests/core/interactive/codex.test.ts +17 -0
- package/tests/core/interactive/fuzzy.test.ts +30 -0
- package/tests/core/setup/codex.test.ts +38 -0
- package/tests/core/setup/env.test.ts +84 -0
- package/tests/core/usages/aggregate.test.ts +55 -0
- package/tests/http/errors.test.ts +15 -0
- package/tests/http/request.test.ts +82 -0
- package/tests/http/url.test.ts +17 -0
- package/tests/output/table.test.ts +29 -0
- package/tests/output/usages.test.ts +71 -0
- package/tests/paths.test.ts +9 -0
- package/tsconfig.json +13 -0
- package/tsdown.config.ts +5 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# Keys Interactive Selection Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Add interactive key selection for `keys get/update/delete` when `id` is omitted, with non-TTY fallback errors and delete confirmation.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Introduce a small interactive helper using `prompts` to select a key from `ListConsumers` (sorted by `createdAt` desc) and confirm deletes. Update `cmd/keys` to make `id` optional, gate on `process.stdin.isTTY`, and route through selection/confirmation while keeping `--json` output unchanged.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript, Commander, Vitest, prompts.
|
|
10
|
+
|
|
11
|
+
**Skills:** @superpowers:test-driven-development, @superpowers:systematic-debugging (if failures)
|
|
12
|
+
|
|
13
|
+
### Task 1: Add prompts dependency and failing tests for interactive selection
|
|
14
|
+
|
|
15
|
+
**Files:**
|
|
16
|
+
- Modify: `package.json`
|
|
17
|
+
- Modify: `package-lock.json`
|
|
18
|
+
- Modify: `tests/cmd/keys.test.ts`
|
|
19
|
+
|
|
20
|
+
**Step 1: Add prompts dependency**
|
|
21
|
+
|
|
22
|
+
Run: `npm install prompts`
|
|
23
|
+
|
|
24
|
+
**Step 2: Write the failing tests**
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import prompts from "prompts";
|
|
28
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
29
|
+
|
|
30
|
+
const originalIsTTY = process.stdin.isTTY;
|
|
31
|
+
const setStdinTTY = (value: boolean) => {
|
|
32
|
+
Object.defineProperty(process.stdin, "isTTY", {
|
|
33
|
+
value,
|
|
34
|
+
configurable: true,
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
setStdinTTY(originalIsTTY);
|
|
40
|
+
prompts.inject([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("get selects newest key when id is missing", async () => {
|
|
44
|
+
setStdinTTY(true);
|
|
45
|
+
prompts.inject([0]);
|
|
46
|
+
const listConsumers = vi.fn().mockResolvedValue({
|
|
47
|
+
consumers: [
|
|
48
|
+
{ id: "old", name: "old", enabled: true, createdAt: "2026-01-01T00:00:00Z" },
|
|
49
|
+
{ id: "new", name: "new", enabled: true, createdAt: "2026-01-02T00:00:00Z" },
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
const getConsumer = vi
|
|
53
|
+
.fn()
|
|
54
|
+
.mockResolvedValue({ ...mockConsumer, id: "new" });
|
|
55
|
+
(createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
56
|
+
consumerService: { ListConsumers: listConsumers, GetConsumer: getConsumer },
|
|
57
|
+
subscriptionService: {} as any,
|
|
58
|
+
});
|
|
59
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
60
|
+
const program = createProgram();
|
|
61
|
+
await program.parseAsync(["node", "getrouter", "keys", "get"]);
|
|
62
|
+
expect(getConsumer).toHaveBeenCalledWith({ id: "new" });
|
|
63
|
+
log.mockRestore();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("delete does not run when confirmation is declined", async () => {
|
|
67
|
+
setStdinTTY(true);
|
|
68
|
+
prompts.inject([0, false]);
|
|
69
|
+
const deleteConsumer = vi.fn().mockResolvedValue({});
|
|
70
|
+
const listConsumers = vi.fn().mockResolvedValue({
|
|
71
|
+
consumers: [
|
|
72
|
+
{ id: "c1", name: "dev", enabled: true, createdAt: "2026-01-01T00:00:00Z" },
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
(createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
76
|
+
consumerService: { ListConsumers: listConsumers, DeleteConsumer: deleteConsumer },
|
|
77
|
+
subscriptionService: {} as any,
|
|
78
|
+
});
|
|
79
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
80
|
+
const program = createProgram();
|
|
81
|
+
await program.parseAsync(["node", "getrouter", "keys", "delete"]);
|
|
82
|
+
expect(deleteConsumer).not.toHaveBeenCalled();
|
|
83
|
+
log.mockRestore();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("get without id fails in non-tty mode", async () => {
|
|
87
|
+
setStdinTTY(false);
|
|
88
|
+
const program = createProgram();
|
|
89
|
+
await expect(
|
|
90
|
+
program.parseAsync(["node", "getrouter", "keys", "get"])
|
|
91
|
+
).rejects.toThrow("缺少 id");
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**Step 3: Run test to verify it fails**
|
|
96
|
+
|
|
97
|
+
Run: `npm test -- tests/cmd/keys.test.ts`
|
|
98
|
+
|
|
99
|
+
Expected: FAIL because `keys get/delete` still require explicit `id` and no interactive selection exists.
|
|
100
|
+
|
|
101
|
+
**Step 4: Commit the failing tests**
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
git add package.json package-lock.json tests/cmd/keys.test.ts
|
|
105
|
+
git commit -m "test: cover keys interactive selection"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Task 2: Implement interactive selection helper and wire commands
|
|
109
|
+
|
|
110
|
+
**Files:**
|
|
111
|
+
- Create: `src/core/interactive/keys.ts`
|
|
112
|
+
- Modify: `src/cmd/keys.ts`
|
|
113
|
+
- Test: `tests/cmd/keys.test.ts`
|
|
114
|
+
|
|
115
|
+
**Step 1: Create interactive helper**
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import prompts from "prompts";
|
|
119
|
+
|
|
120
|
+
type Consumer = {
|
|
121
|
+
id?: string;
|
|
122
|
+
name?: string;
|
|
123
|
+
enabled?: boolean;
|
|
124
|
+
createdAt?: string;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
type ConsumerService = {
|
|
128
|
+
ListConsumers: (args: { pageSize?: number; pageToken?: string }) => Promise<{ consumers?: Consumer[] }>;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const sortByCreatedAtDesc = (consumers: Consumer[]) =>
|
|
132
|
+
consumers
|
|
133
|
+
.slice()
|
|
134
|
+
.sort((a, b) => {
|
|
135
|
+
const aTime = Date.parse(a.createdAt ?? "") || 0;
|
|
136
|
+
const bTime = Date.parse(b.createdAt ?? "") || 0;
|
|
137
|
+
return bTime - aTime;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const formatChoice = (consumer: Consumer) => {
|
|
141
|
+
const name = consumer.name ?? "-";
|
|
142
|
+
const id = consumer.id ?? "-";
|
|
143
|
+
const enabled = consumer.enabled == null ? "-" : consumer.enabled ? "ENABLED" : "DISABLED";
|
|
144
|
+
const createdAt = consumer.createdAt ?? "-";
|
|
145
|
+
return `${name} (${id}) | ${enabled} | ${createdAt}`;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export const selectConsumer = async (consumerService: ConsumerService) => {
|
|
149
|
+
const res = await consumerService.ListConsumers({ pageSize: undefined, pageToken: undefined });
|
|
150
|
+
const consumers = res?.consumers ?? [];
|
|
151
|
+
if (consumers.length === 0) {
|
|
152
|
+
throw new Error("没有可用的 API key");
|
|
153
|
+
}
|
|
154
|
+
const sorted = sortByCreatedAtDesc(consumers);
|
|
155
|
+
const response = await prompts({
|
|
156
|
+
type: "select",
|
|
157
|
+
name: "id",
|
|
158
|
+
message: "选择 API key",
|
|
159
|
+
choices: sorted.map((consumer) => ({
|
|
160
|
+
title: formatChoice(consumer),
|
|
161
|
+
value: consumer.id,
|
|
162
|
+
})),
|
|
163
|
+
});
|
|
164
|
+
if (!response.id) return null;
|
|
165
|
+
return sorted.find((consumer) => consumer.id === response.id) ?? { id: response.id };
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export const confirmDelete = async (consumer: Consumer) => {
|
|
169
|
+
const name = consumer.name ?? "-";
|
|
170
|
+
const id = consumer.id ?? "-";
|
|
171
|
+
const response = await prompts({
|
|
172
|
+
type: "confirm",
|
|
173
|
+
name: "confirm",
|
|
174
|
+
message: `确认删除 ${name} (${id})?`,
|
|
175
|
+
initial: false,
|
|
176
|
+
});
|
|
177
|
+
return Boolean(response.confirm);
|
|
178
|
+
};
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Step 2: Wire interactive selection into commands**
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
// imports
|
|
185
|
+
import { selectConsumer, confirmDelete } from "../core/interactive/keys";
|
|
186
|
+
|
|
187
|
+
// make args optional
|
|
188
|
+
.argument("[id]")
|
|
189
|
+
|
|
190
|
+
// in get/update/delete
|
|
191
|
+
if (!id) {
|
|
192
|
+
if (!process.stdin.isTTY) {
|
|
193
|
+
throw new Error("缺少 id");
|
|
194
|
+
}
|
|
195
|
+
const selected = await selectConsumer(consumerService);
|
|
196
|
+
if (!selected?.id) return;
|
|
197
|
+
id = selected.id;
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
For delete confirmation:
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
let selected: any = null;
|
|
205
|
+
if (!id) {
|
|
206
|
+
if (!process.stdin.isTTY) {
|
|
207
|
+
throw new Error("缺少 id");
|
|
208
|
+
}
|
|
209
|
+
selected = await selectConsumer(consumerService);
|
|
210
|
+
if (!selected?.id) return;
|
|
211
|
+
id = selected.id;
|
|
212
|
+
const confirmed = await confirmDelete(selected);
|
|
213
|
+
if (!confirmed) return;
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
**Step 3: Run tests to verify pass**
|
|
218
|
+
|
|
219
|
+
Run: `npm test -- tests/cmd/keys.test.ts`
|
|
220
|
+
|
|
221
|
+
Expected: PASS
|
|
222
|
+
|
|
223
|
+
**Step 4: Commit**
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
git add src/cmd/keys.ts src/core/interactive/keys.ts tests/cmd/keys.test.ts
|
|
227
|
+
git commit -m "feat: add interactive selection for keys"
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Task 3: Full test run
|
|
231
|
+
|
|
232
|
+
**Files:**
|
|
233
|
+
- None
|
|
234
|
+
|
|
235
|
+
**Step 1: Run full test suite**
|
|
236
|
+
|
|
237
|
+
Run: `npm test`
|
|
238
|
+
|
|
239
|
+
Expected: PASS
|
|
240
|
+
|
|
241
|
+
**Step 2: Commit (if needed)**
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
git status -sb
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
If clean, no commit needed.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# getrouter CLI Keys 列表输出设计(表格)
|
|
2
|
+
|
|
3
|
+
日期:2026-01-02
|
|
4
|
+
状态:已确认
|
|
5
|
+
|
|
6
|
+
## 目标
|
|
7
|
+
- `keys list` 默认输出改为表格,提升可读性。
|
|
8
|
+
- 保留 `--json` 作为脚本接口;`apiKey` 默认脱敏。
|
|
9
|
+
|
|
10
|
+
## 表格列与顺序
|
|
11
|
+
- 列顺序固定:`ID | NAME | ENABLED | LAST_ACCESS | CREATED_AT | API_KEY`
|
|
12
|
+
- 空值显示 `-`,避免列塌陷。
|
|
13
|
+
- `apiKey` 默认脱敏(前 4 后 4),`--show-secret` 明文。
|
|
14
|
+
|
|
15
|
+
## 表格渲染组件
|
|
16
|
+
新增轻量表格工具(建议 `src/core/output/table.ts`):
|
|
17
|
+
- 输入:`headers: string[]`、`rows: Array<Array<string>>`
|
|
18
|
+
- 列宽 = `max(表头宽度, 各行最大宽度)`,再受 `MAX_COL_WIDTH` 约束(如 32)。
|
|
19
|
+
- 超宽截断:保留前 `MAX-3` 字符并追加 `...`。
|
|
20
|
+
- 左对齐,列间空两格。
|
|
21
|
+
- 不做脱敏,仅渲染字符串。
|
|
22
|
+
|
|
23
|
+
## 命令层行为
|
|
24
|
+
- `keys list` 在非 `--json` 情况使用表格输出。
|
|
25
|
+
- `--json` 输出完整结构(已脱敏)。
|
|
26
|
+
- 其他 keys 子命令暂保持 key=value 输出(可后续统一为表格)。
|
|
27
|
+
|
|
28
|
+
## 测试策略
|
|
29
|
+
- 表格渲染单测:宽度计算、截断、空值、对齐。
|
|
30
|
+
- `keys list` 输出单测:确保表头与列顺序;校验脱敏结果。
|
|
31
|
+
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Keys List Table Output Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Implement a reusable table renderer and switch `keys list` default output to a readable table format with truncation and redaction.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Add `core/output/table` for width calculation + truncation, then update `cmd/keys` to render list rows via table when not `--json`.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript, Node.js, vitest.
|
|
10
|
+
|
|
11
|
+
### Task 1: Add table renderer utility
|
|
12
|
+
|
|
13
|
+
**Files:**
|
|
14
|
+
- Create: `src/core/output/table.ts`
|
|
15
|
+
- Create: `tests/output/table.test.ts`
|
|
16
|
+
|
|
17
|
+
**Step 1: Write the failing test**
|
|
18
|
+
|
|
19
|
+
Create `tests/output/table.test.ts`:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { describe, it, expect } from "vitest";
|
|
23
|
+
import { renderTable } from "../../src/core/output/table";
|
|
24
|
+
|
|
25
|
+
describe("table renderer", () => {
|
|
26
|
+
it("renders headers and rows with alignment", () => {
|
|
27
|
+
const out = renderTable(
|
|
28
|
+
["ID", "NAME"],
|
|
29
|
+
[
|
|
30
|
+
["1", "alpha"],
|
|
31
|
+
["2", "beta"],
|
|
32
|
+
]
|
|
33
|
+
);
|
|
34
|
+
expect(out).toContain("ID");
|
|
35
|
+
expect(out).toContain("NAME");
|
|
36
|
+
expect(out).toContain("alpha");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("truncates long cells", () => {
|
|
40
|
+
const out = renderTable(["ID"], [["0123456789ABCDEFGHIJ"]], {
|
|
41
|
+
maxColWidth: 8,
|
|
42
|
+
});
|
|
43
|
+
expect(out).toContain("0123...");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("fills empty with dash", () => {
|
|
47
|
+
const out = renderTable(["ID"], [[""]]);
|
|
48
|
+
expect(out).toContain("-");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Step 2: Run test to verify it fails**
|
|
54
|
+
|
|
55
|
+
Run: `npm test -- tests/output/table.test.ts`
|
|
56
|
+
Expected: FAIL (module missing)
|
|
57
|
+
|
|
58
|
+
**Step 3: Write minimal implementation**
|
|
59
|
+
|
|
60
|
+
Create `src/core/output/table.ts`:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
type TableOptions = { maxColWidth?: number };
|
|
64
|
+
|
|
65
|
+
const truncate = (value: string, max: number) => {
|
|
66
|
+
if (value.length <= max) return value;
|
|
67
|
+
if (max <= 3) return value.slice(0, max);
|
|
68
|
+
return `${value.slice(0, max - 3)}...`;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const renderTable = (
|
|
72
|
+
headers: string[],
|
|
73
|
+
rows: string[][],
|
|
74
|
+
options: TableOptions = {}
|
|
75
|
+
) => {
|
|
76
|
+
const maxColWidth = options.maxColWidth ?? 32;
|
|
77
|
+
const normalized = rows.map((row) =>
|
|
78
|
+
row.map((cell) => (cell && cell.length > 0 ? cell : "-"))
|
|
79
|
+
);
|
|
80
|
+
const widths = headers.map((h, i) => {
|
|
81
|
+
const colValues = normalized.map((row) => row[i] ?? "-");
|
|
82
|
+
const maxLen = Math.max(h.length, ...colValues.map((v) => v.length));
|
|
83
|
+
return Math.min(maxLen, maxColWidth);
|
|
84
|
+
});
|
|
85
|
+
const renderRow = (cells: string[]) =>
|
|
86
|
+
cells
|
|
87
|
+
.map((cell, i) => {
|
|
88
|
+
const raw = cell ?? "-";
|
|
89
|
+
const clipped = truncate(raw, widths[i]);
|
|
90
|
+
return clipped.padEnd(widths[i], " ");
|
|
91
|
+
})
|
|
92
|
+
.join(" ");
|
|
93
|
+
const headerRow = renderRow(headers);
|
|
94
|
+
const body = normalized.map((row) => renderRow(row)).join("\n");
|
|
95
|
+
return `${headerRow}\n${body}`;
|
|
96
|
+
};
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**Step 4: Run test to verify it passes**
|
|
100
|
+
|
|
101
|
+
Run: `npm test -- tests/output/table.test.ts`
|
|
102
|
+
Expected: PASS
|
|
103
|
+
|
|
104
|
+
**Step 5: Commit**
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
git add src/core/output/table.ts tests/output/table.test.ts
|
|
108
|
+
git commit -m "feat: add table renderer"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Task 2: Switch keys list to table output
|
|
112
|
+
|
|
113
|
+
**Files:**
|
|
114
|
+
- Modify: `src/cmd/keys.ts`
|
|
115
|
+
- Modify: `tests/cmd/keys.test.ts`
|
|
116
|
+
|
|
117
|
+
**Step 1: Write the failing test**
|
|
118
|
+
|
|
119
|
+
Update `tests/cmd/keys.test.ts` with a human-readable output assertion:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
it("list prints table header in default mode", async () => {
|
|
123
|
+
(createApiClients as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
124
|
+
consumerService: {
|
|
125
|
+
ListConsumers: vi.fn().mockResolvedValue({ consumers: [mockConsumer] }),
|
|
126
|
+
},
|
|
127
|
+
subscriptionService: {} as any,
|
|
128
|
+
});
|
|
129
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
130
|
+
const program = createProgram();
|
|
131
|
+
await program.parseAsync(["node", "getrouter", "keys", "list"]);
|
|
132
|
+
const output = log.mock.calls.map((c) => c[0]).join("\n");
|
|
133
|
+
expect(output).toContain("ID");
|
|
134
|
+
expect(output).toContain("NAME");
|
|
135
|
+
expect(output).toContain("API_KEY");
|
|
136
|
+
log.mockRestore();
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
**Step 2: Run test to verify it fails**
|
|
141
|
+
|
|
142
|
+
Run: `npm test -- tests/cmd/keys.test.ts`
|
|
143
|
+
Expected: FAIL (still key=value)
|
|
144
|
+
|
|
145
|
+
**Step 3: Write minimal implementation**
|
|
146
|
+
|
|
147
|
+
Update `src/cmd/keys.ts`:
|
|
148
|
+
- import `renderTable`
|
|
149
|
+
- in `keys list`, when not `--json`, build headers/rows and `console.log(renderTable(...))`
|
|
150
|
+
- map row cells to strings, use `-` for empty
|
|
151
|
+
|
|
152
|
+
Example mapping:
|
|
153
|
+
```ts
|
|
154
|
+
const headers = ["ID", "NAME", "ENABLED", "LAST_ACCESS", "CREATED_AT", "API_KEY"];
|
|
155
|
+
const rows = consumers.map((c) => [
|
|
156
|
+
String(c.id ?? ""),
|
|
157
|
+
String(c.name ?? ""),
|
|
158
|
+
String(c.enabled ?? ""),
|
|
159
|
+
String(c.lastAccess ?? ""),
|
|
160
|
+
String(c.createdAt ?? ""),
|
|
161
|
+
String(c.apiKey ?? ""),
|
|
162
|
+
]);
|
|
163
|
+
console.log(renderTable(headers, rows));
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
**Step 4: Run test to verify it passes**
|
|
167
|
+
|
|
168
|
+
Run: `npm test -- tests/cmd/keys.test.ts`
|
|
169
|
+
Expected: PASS
|
|
170
|
+
|
|
171
|
+
**Step 5: Commit**
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
git add src/cmd/keys.ts tests/cmd/keys.test.ts
|
|
175
|
+
git commit -m "feat: use table output for keys list"
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
Plan complete and saved to `docs/plans/2026-01-02-getrouter-cli-keys-output-plan.md`.
|
|
181
|
+
|
|
182
|
+
Two execution options:
|
|
183
|
+
|
|
184
|
+
1. Subagent-Driven (this session) — use superpowers:subagent-driven-development
|
|
185
|
+
2. Parallel Session (separate) — open a new session with executing-plans in this worktree
|
|
186
|
+
|
|
187
|
+
Which approach?
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# getrouter CLI Keys & Subscription 设计(v1)
|
|
2
|
+
|
|
3
|
+
日期:2026-01-02
|
|
4
|
+
状态:已确认
|
|
5
|
+
|
|
6
|
+
## 背景
|
|
7
|
+
- `keys` 命令映射 dashboard `consumer` 资源,用于管理 API key。
|
|
8
|
+
- `subscription show` 用于展示当前订阅信息。
|
|
9
|
+
- 复用 dashboard 生成的 TypeScript client,避免路径与类型漂移。
|
|
10
|
+
|
|
11
|
+
## 方案选型
|
|
12
|
+
**采用**:复用 dashboard 生成的 TS client(ConsumerService/SubscriptionService),CLI 仅做参数解析与输出层。
|
|
13
|
+
|
|
14
|
+
## 命令设计
|
|
15
|
+
### Keys(consumer)
|
|
16
|
+
- `getrouter keys list`
|
|
17
|
+
- `getrouter keys get <id>`
|
|
18
|
+
- `getrouter keys create --name <name> [--enabled <true|false>]`
|
|
19
|
+
- `getrouter keys update <id> --name <name> --enabled <true|false>`
|
|
20
|
+
- `getrouter keys delete <id>`
|
|
21
|
+
|
|
22
|
+
约定:
|
|
23
|
+
- `list` 暂不支持分页参数(使用服务端默认)。
|
|
24
|
+
- `update` 仅允许更新 `name` 与 `enabled`。未提供任何可更新字段时报错。
|
|
25
|
+
- `apiKey` 默认脱敏展示,仅在 `--show-secret` 下明文。
|
|
26
|
+
- `create` 成功后默认脱敏展示 `apiKey`,并提示“请妥善保存 API Key”。
|
|
27
|
+
|
|
28
|
+
### Subscription
|
|
29
|
+
- `getrouter subscription show`
|
|
30
|
+
|
|
31
|
+
默认输出字段:
|
|
32
|
+
- `plan.name`, `status`, `startAt`, `endAt`, `plan.requestPerMinute`, `plan.tokenPerMinute`
|
|
33
|
+
- `--json` 输出原始结构
|
|
34
|
+
|
|
35
|
+
## 组件与数据流
|
|
36
|
+
- `core/api`:包装生成 client(ConsumerService/SubscriptionService)。
|
|
37
|
+
- `core/http`:统一鉴权与错误封装(Authorization: Bearer)。
|
|
38
|
+
- `cmd/keys`/`cmd/subscription`:参数解析、调用 `core/api`。
|
|
39
|
+
- 输出层:默认人类可读,`--json` 输出结构化 JSON。
|
|
40
|
+
|
|
41
|
+
## 错误处理
|
|
42
|
+
- 401/403:提示 `getrouter auth login`。
|
|
43
|
+
- `subscription show` 若 404/空响应,提示“未订阅”。
|
|
44
|
+
- 其他错误:默认输出 message;JSON 模式输出 `{code,message,details,status}`。
|
|
45
|
+
|
|
46
|
+
## 测试策略
|
|
47
|
+
- `core/api`/adapter:验证路径、方法、请求体与 `updateMask` 生成。
|
|
48
|
+
- `cmd/keys`:list/create/update/delete/get 输出与脱敏/`--show-secret` 行为。
|
|
49
|
+
- `cmd/subscription`:正常与 404 场景,JSON 与默认输出。
|
|
50
|
+
|
|
51
|
+
## 备注
|
|
52
|
+
- `apiBase` 默认 `https://getrouter.dev`,生成 client path 已含 `v1/...`。
|