@opice/cli 0.5.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opice/cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "CLI for opice — scaffolds projects and wraps `bun test` to stream E2E results to the reporting platform",
5
5
  "type": "module",
6
6
  "bin": {
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= or OPICE_READ_TOKEN (a
30
- read-only share link).
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':
@@ -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 is taken from the URL's `?token=` (when you
9
- * paste a dashboard link) or from OPICE_READ_TOKEN.
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') ?? process.env['OPICE_READ_TOKEN'] ?? undefined
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: process.env['OPICE_READ_TOKEN'] ?? undefined }
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
+ }