@opice/cli 0.5.0 → 0.6.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/package.json +1 -1
- package/src/cli.ts +14 -2
- package/src/commands/failures.ts +11 -6
- package/src/commands/tokens.ts +201 -0
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { failuresCommand } from './commands/failures'
|
|
|
3
3
|
import { initCommand } from './commands/init'
|
|
4
4
|
import { installSkillsCommand } from './commands/install-skills'
|
|
5
5
|
import { testCommand } from './commands/test'
|
|
6
|
+
import { tokensCommand } from './commands/tokens'
|
|
6
7
|
import { usersCommand } from './commands/users'
|
|
7
8
|
|
|
8
9
|
const HELP = `opice — AI-driven E2E browser test harness
|
|
@@ -26,8 +27,17 @@ Commands:
|
|
|
26
27
|
failures <run-url|run-id> [--json]
|
|
27
28
|
Pull a failed run's details (failed scenarios, the failing step,
|
|
28
29
|
error, screenshot URL, and source files) for the re-eval workflow.
|
|
29
|
-
Read token comes from the URL's ?token
|
|
30
|
-
read-only
|
|
30
|
+
Read token comes from the URL's ?token=, OPICE_READ_TOKEN, or
|
|
31
|
+
OPICE_READ_DSN (a read-only project credential).
|
|
32
|
+
|
|
33
|
+
tokens create [--project=SLUG] [--capability=read|write] [--label=...] [--expires-days=N]
|
|
34
|
+
tokens list [--project=SLUG]
|
|
35
|
+
tokens revoke <token-id>
|
|
36
|
+
Manage API tokens. Needs the admin token (--admin-token or
|
|
37
|
+
OPICE_ADMIN_TOKEN) and the platform endpoint (--endpoint,
|
|
38
|
+
OPICE_ENDPOINT, or opice.config.json). 'create' defaults to a
|
|
39
|
+
project-scoped read token and prints a ready OPICE_READ_DSN an
|
|
40
|
+
authoring agent can drop into .env to read results.
|
|
31
41
|
|
|
32
42
|
users create <email> [--password=...] [--name=...] [--endpoint=URL] [--admin-token=TOKEN]
|
|
33
43
|
Create a dashboard login (admin role by default). Needs the bootstrap
|
|
@@ -55,6 +65,8 @@ async function main(argv: string[]): Promise<number> {
|
|
|
55
65
|
return failuresCommand(rest)
|
|
56
66
|
case 'users':
|
|
57
67
|
return usersCommand(rest)
|
|
68
|
+
case 'tokens':
|
|
69
|
+
return tokensCommand(rest)
|
|
58
70
|
case 'install-skills':
|
|
59
71
|
return installSkillsCommand(rest)
|
|
60
72
|
case 'help':
|
package/src/commands/failures.ts
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
* source test file that produced them (the test is the spec — each step
|
|
6
6
|
* carries its `intent`, so there's no separate scenario file).
|
|
7
7
|
*
|
|
8
|
-
* Reads are token-gated. The token
|
|
9
|
-
* paste a dashboard link
|
|
8
|
+
* Reads are token-gated. The read token (and endpoint/project) come from, in
|
|
9
|
+
* order: the URL's `?token=` when you paste a dashboard link, OPICE_READ_TOKEN,
|
|
10
|
+
* or OPICE_READ_DSN (the self-contained `https://<readKey>@host/slug` the
|
|
11
|
+
* dashboard hands out at project creation).
|
|
10
12
|
*/
|
|
11
13
|
|
|
12
14
|
import { loadConfig } from '../config'
|
|
@@ -144,9 +146,12 @@ function absoluteScreenshot(relativeOrAbsolute: string, target: Target): string
|
|
|
144
146
|
}
|
|
145
147
|
|
|
146
148
|
async function resolveTarget(ref: string): Promise<Target | null> {
|
|
149
|
+
const readDsn = parseOpiceDsn(process.env['OPICE_READ_DSN'])
|
|
150
|
+
const envToken = process.env['OPICE_READ_TOKEN'] ?? readDsn?.apiKey ?? undefined
|
|
151
|
+
|
|
147
152
|
if (/^https?:\/\//.test(ref)) {
|
|
148
153
|
const url = new URL(ref)
|
|
149
|
-
const token = url.searchParams.get('token') ??
|
|
154
|
+
const token = url.searchParams.get('token') ?? envToken
|
|
150
155
|
const match = url.pathname.match(/\/p\/([^/]+)\/r\/([^/]+)/)
|
|
151
156
|
if (match) {
|
|
152
157
|
return { endpoint: url.origin, runId: decodeURIComponent(match[2]!), token, slug: decodeURIComponent(match[1]!) }
|
|
@@ -158,11 +163,11 @@ async function resolveTarget(ref: string): Promise<Target | null> {
|
|
|
158
163
|
return null
|
|
159
164
|
}
|
|
160
165
|
|
|
161
|
-
// Bare run id — endpoint from config/env/DSN, token from env.
|
|
166
|
+
// Bare run id — endpoint from config/env/DSN, token from env/read DSN.
|
|
162
167
|
const config = await loadConfig()
|
|
163
|
-
const endpoint = process.env['OPICE_ENDPOINT'] ?? config?.endpoint ?? parseOpiceDsn(process.env['OPICE_DSN'])?.endpoint
|
|
168
|
+
const endpoint = process.env['OPICE_ENDPOINT'] ?? config?.endpoint ?? readDsn?.endpoint ?? parseOpiceDsn(process.env['OPICE_DSN'])?.endpoint
|
|
164
169
|
if (!endpoint) return null
|
|
165
|
-
return { endpoint, runId: ref, token:
|
|
170
|
+
return { endpoint, runId: ref, token: envToken }
|
|
166
171
|
}
|
|
167
172
|
|
|
168
173
|
async function rpc<T>(target: Target, method: string, input: unknown): Promise<T> {
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `opice tokens <create|list|revoke>` — manage API tokens from the terminal.
|
|
3
|
+
*
|
|
4
|
+
* Like `opice users create`, these are operator actions: they call the
|
|
5
|
+
* `admin.*` RPCs with the bootstrap admin token as a Bearer credential
|
|
6
|
+
* (--admin-token or OPICE_ADMIN_TOKEN) against the platform endpoint
|
|
7
|
+
* (--endpoint, OPICE_ENDPOINT, or opice.config.json).
|
|
8
|
+
*
|
|
9
|
+
* `create` is the headless counterpart to the dashboard's "new project" read
|
|
10
|
+
* key: mint a project-scoped read token and it prints a ready-to-paste
|
|
11
|
+
* `OPICE_READ_DSN` an authoring agent can drop into `.env` to read results.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { loadConfig } from '../config'
|
|
15
|
+
import { parseOpiceDsn } from '../dsn'
|
|
16
|
+
|
|
17
|
+
interface CommonFlags {
|
|
18
|
+
endpoint?: string
|
|
19
|
+
adminToken?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface CreateFlags extends CommonFlags {
|
|
23
|
+
project?: string
|
|
24
|
+
capability: 'read' | 'write'
|
|
25
|
+
label?: string
|
|
26
|
+
expiresInDays?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface TokenSummary {
|
|
30
|
+
id: string
|
|
31
|
+
capability: 'read' | 'write' | 'admin'
|
|
32
|
+
projectSlug: string | null
|
|
33
|
+
runId: string | null
|
|
34
|
+
label: string | null
|
|
35
|
+
createdAt: number
|
|
36
|
+
expiresAt: number | null
|
|
37
|
+
lastUsedAt: number | null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const USAGE = `Usage:
|
|
41
|
+
opice tokens create [--project=SLUG] [--capability=read|write] [--label=...] [--expires-days=N] [--endpoint=URL] [--admin-token=TOKEN]
|
|
42
|
+
opice tokens list [--project=SLUG] [--endpoint=URL] [--admin-token=TOKEN]
|
|
43
|
+
opice tokens revoke <token-id> [--endpoint=URL] [--admin-token=TOKEN]`
|
|
44
|
+
|
|
45
|
+
export async function tokensCommand(args: string[]): Promise<number> {
|
|
46
|
+
const [sub, ...rest] = args
|
|
47
|
+
switch (sub) {
|
|
48
|
+
case 'create':
|
|
49
|
+
return createToken(rest)
|
|
50
|
+
case 'list':
|
|
51
|
+
return listTokens(rest)
|
|
52
|
+
case 'revoke':
|
|
53
|
+
return revokeToken(rest)
|
|
54
|
+
default:
|
|
55
|
+
console.error(USAGE)
|
|
56
|
+
return 1
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function createToken(args: string[]): Promise<number> {
|
|
61
|
+
const flags = parseCreateFlags(args)
|
|
62
|
+
const target = await resolveTarget(flags)
|
|
63
|
+
if (!target) return 1
|
|
64
|
+
|
|
65
|
+
if (!flags.project && flags.capability !== 'read') {
|
|
66
|
+
console.error('A global (project-less) token must be read-only. Pass --project=SLUG for a write token.')
|
|
67
|
+
return 1
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const result = await rpc<{ id: string; token: string; expiresAt: number | null }>(target, 'admin.createToken', {
|
|
71
|
+
...(flags.project ? { projectSlug: flags.project } : {}),
|
|
72
|
+
capability: flags.capability,
|
|
73
|
+
...(flags.label ? { label: flags.label } : {}),
|
|
74
|
+
...(flags.expiresInDays != null ? { expiresInDays: flags.expiresInDays } : {}),
|
|
75
|
+
})
|
|
76
|
+
if (!result) return 1
|
|
77
|
+
|
|
78
|
+
console.log(`✓ Created ${flags.capability} token ${result.id}`)
|
|
79
|
+
console.log(` token: ${result.token}`)
|
|
80
|
+
if (result.expiresAt != null) console.log(` expires: ${new Date(result.expiresAt).toISOString()}`)
|
|
81
|
+
if (flags.project) {
|
|
82
|
+
const host = new URL(target.endpoint).host
|
|
83
|
+
const envVar = flags.capability === 'read' ? 'OPICE_READ_DSN' : 'OPICE_DSN'
|
|
84
|
+
console.log('')
|
|
85
|
+
console.log(` ${envVar}=https://${result.token}@${host}/${flags.project}`)
|
|
86
|
+
}
|
|
87
|
+
console.log(' (shown once — store it now; only its hash is kept)')
|
|
88
|
+
return 0
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function listTokens(args: string[]): Promise<number> {
|
|
92
|
+
const flags = parseCommonFlags(args)
|
|
93
|
+
const project = flags['project']
|
|
94
|
+
const target = await resolveTarget(flags)
|
|
95
|
+
if (!target) return 1
|
|
96
|
+
|
|
97
|
+
const tokens = await rpc<TokenSummary[]>(target, 'admin.listTokens', project ? { projectSlug: project } : {})
|
|
98
|
+
if (!tokens) return 1
|
|
99
|
+
|
|
100
|
+
if (tokens.length === 0) {
|
|
101
|
+
console.log('No tokens.')
|
|
102
|
+
return 0
|
|
103
|
+
}
|
|
104
|
+
for (const t of tokens) {
|
|
105
|
+
const scope = t.runId ? `run ${t.runId}` : (t.projectSlug ?? 'all projects')
|
|
106
|
+
const meta = [
|
|
107
|
+
t.label ?? '(no label)',
|
|
108
|
+
t.capability,
|
|
109
|
+
scope,
|
|
110
|
+
t.expiresAt ? `expires ${new Date(t.expiresAt).toISOString().slice(0, 10)}` : 'no expiry',
|
|
111
|
+
t.lastUsedAt ? `used ${new Date(t.lastUsedAt).toISOString().slice(0, 10)}` : 'never used',
|
|
112
|
+
].join(' · ')
|
|
113
|
+
console.log(`${t.id} ${meta}`)
|
|
114
|
+
}
|
|
115
|
+
return 0
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function revokeToken(args: string[]): Promise<number> {
|
|
119
|
+
const positional = args.filter((a) => !a.startsWith('--'))
|
|
120
|
+
const tokenId = positional[0]
|
|
121
|
+
if (!tokenId) {
|
|
122
|
+
console.error('Usage: opice tokens revoke <token-id> [--endpoint=URL] [--admin-token=TOKEN]')
|
|
123
|
+
return 1
|
|
124
|
+
}
|
|
125
|
+
const flags = parseCommonFlags(args)
|
|
126
|
+
const target = await resolveTarget(flags)
|
|
127
|
+
if (!target) return 1
|
|
128
|
+
|
|
129
|
+
const result = await rpc<{ revoked: boolean }>(target, 'admin.revokeToken', { tokenId })
|
|
130
|
+
if (!result) return 1
|
|
131
|
+
console.log(result.revoked ? `✓ Revoked ${tokenId}` : `Token not found or already revoked: ${tokenId}`)
|
|
132
|
+
return 0
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface Target {
|
|
136
|
+
endpoint: string
|
|
137
|
+
adminToken: string
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function resolveTarget(flags: CommonFlags | Record<string, string | undefined>): Promise<Target | null> {
|
|
141
|
+
const endpoint =
|
|
142
|
+
flags['endpoint'] ?? process.env['OPICE_ENDPOINT'] ?? (await loadConfig())?.endpoint ?? parseOpiceDsn(process.env['OPICE_DSN'])?.endpoint
|
|
143
|
+
if (!endpoint) {
|
|
144
|
+
console.error('Could not determine the platform endpoint. Pass --endpoint=URL, set OPICE_ENDPOINT, or run from a project with opice.config.json.')
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
const adminToken = flags['adminToken'] ?? process.env['OPICE_ADMIN_TOKEN']
|
|
148
|
+
if (!adminToken) {
|
|
149
|
+
console.error('Missing admin token. Pass --admin-token=TOKEN or set OPICE_ADMIN_TOKEN.')
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
return { endpoint: endpoint.replace(/\/$/, ''), adminToken }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function rpc<T>(target: Target, method: string, input: unknown): Promise<T | null> {
|
|
156
|
+
let response: Response
|
|
157
|
+
try {
|
|
158
|
+
response = await fetch(`${target.endpoint}/rpc`, {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${target.adminToken}` },
|
|
161
|
+
body: JSON.stringify({ method, input }),
|
|
162
|
+
})
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.error(`[opice] request failed: ${(err as Error).message}`)
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
const data = (await response.json().catch(() => null)) as { result?: T; error?: { message?: string } } | null
|
|
168
|
+
if (!response.ok || !data || data.error || data.result === undefined) {
|
|
169
|
+
const message = data?.error?.message ?? `${response.status} ${response.statusText}`
|
|
170
|
+
console.error(`[opice] ${method} failed: ${message}`)
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
return data.result as T
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parseCreateFlags(args: string[]): CreateFlags {
|
|
177
|
+
const flags: CreateFlags = { capability: 'read' }
|
|
178
|
+
for (const arg of args) {
|
|
179
|
+
if (arg.startsWith('--project=')) flags.project = arg.slice('--project='.length)
|
|
180
|
+
else if (arg.startsWith('--capability=')) {
|
|
181
|
+
const v = arg.slice('--capability='.length)
|
|
182
|
+
if (v === 'read' || v === 'write') flags.capability = v
|
|
183
|
+
} else if (arg.startsWith('--label=')) flags.label = arg.slice('--label='.length)
|
|
184
|
+
else if (arg.startsWith('--expires-days=')) {
|
|
185
|
+
const n = Number(arg.slice('--expires-days='.length))
|
|
186
|
+
if (Number.isFinite(n)) flags.expiresInDays = n
|
|
187
|
+
} else if (arg.startsWith('--endpoint=')) flags.endpoint = arg.slice('--endpoint='.length)
|
|
188
|
+
else if (arg.startsWith('--admin-token=')) flags.adminToken = arg.slice('--admin-token='.length)
|
|
189
|
+
}
|
|
190
|
+
return flags
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function parseCommonFlags(args: string[]): Record<string, string | undefined> {
|
|
194
|
+
const flags: Record<string, string | undefined> = {}
|
|
195
|
+
for (const arg of args) {
|
|
196
|
+
if (arg.startsWith('--project=')) flags['project'] = arg.slice('--project='.length)
|
|
197
|
+
else if (arg.startsWith('--endpoint=')) flags['endpoint'] = arg.slice('--endpoint='.length)
|
|
198
|
+
else if (arg.startsWith('--admin-token=')) flags['adminToken'] = arg.slice('--admin-token='.length)
|
|
199
|
+
}
|
|
200
|
+
return flags
|
|
201
|
+
}
|