@opice/cli 0.6.1 → 0.7.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opice/cli",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
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,8 +3,6 @@ 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'
7
- import { usersCommand } from './commands/users'
8
6
 
9
7
  const HELP = `opice — AI-driven E2E browser test harness
10
8
 
@@ -15,7 +13,7 @@ Commands:
15
13
  Scaffold opice.config.json in the current project. Pass
16
14
  --with-workflow to also drop a .github/workflows/opice.yml.
17
15
 
18
- test [--retries=N] [bun test args...]
16
+ test [--retries=N] [--tier=NAME] [bun test args...]
19
17
  Wrapper around 'bun test' that exports OPICE_* env vars from
20
18
  opice.config.json + git so the harness reporter streams results
21
19
  to the platform. All trailing args pass through to bun test.
@@ -23,6 +21,11 @@ Commands:
23
21
  flaky scenario that fails then passes is reported as flaky, not
24
22
  failed). Falls back to "retries" in opice.config.json; a
25
23
  per-scenario walkthrough/meta retries overrides both.
24
+ --tier=NAME runs a test tier (critical < standard < extended);
25
+ selection is a threshold, so --tier=standard runs critical +
26
+ standard. Scenarios above it are reported "skipped", not run.
27
+ Falls back to OPICE_TIER, then "tier" in opice.config.json;
28
+ omit to run everything.
26
29
 
27
30
  failures <run-url|run-id> [--json]
28
31
  Pull a failed run's details (failed scenarios, the failing step,
@@ -30,21 +33,6 @@ Commands:
30
33
  Read token comes from the URL's ?token=, OPICE_READ_TOKEN, or
31
34
  OPICE_READ_DSN (a read-only project credential).
32
35
 
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.
41
-
42
- users create <email> [--password=...] [--name=...] [--endpoint=URL] [--admin-token=TOKEN]
43
- Create a dashboard login (admin role by default). Needs the bootstrap
44
- admin token (--admin-token or OPICE_ADMIN_TOKEN) and the platform endpoint
45
- (--endpoint, OPICE_ENDPOINT, or opice.config.json). A password is
46
- generated and printed if you don't pass one.
47
-
48
36
  install-skills [--global] [--ref=BRANCH]
49
37
  Install opice's Claude Code skills + author agent into this project's
50
38
  .claude/ (or ~/.claude with --global), fetched from GitHub. Restart
@@ -63,10 +51,6 @@ async function main(argv: string[]): Promise<number> {
63
51
  return testCommand(rest)
64
52
  case 'failures':
65
53
  return failuresCommand(rest)
66
- case 'users':
67
- return usersCommand(rest)
68
- case 'tokens':
69
- return tokensCommand(rest)
70
54
  case 'install-skills':
71
55
  return installSkillsCommand(rest)
72
56
  case 'help':
@@ -171,7 +171,7 @@ async function resolveTarget(ref: string): Promise<Target | null> {
171
171
  }
172
172
 
173
173
  async function rpc<T>(target: Target, method: string, input: unknown): Promise<T> {
174
- const url = `${target.endpoint}/rpc${target.token ? `?token=${target.token}` : ''}`
174
+ const url = `${target.endpoint}/s/rpc${target.token ? `?token=${target.token}` : ''}`
175
175
  const response = await fetch(url, {
176
176
  method: 'POST',
177
177
  headers: { 'content-type': 'application/json' },
@@ -52,10 +52,16 @@ async function writeWorkflow(cwd: string): Promise<string> {
52
52
 
53
53
  const WORKFLOW_TEMPLATE = `name: opice browser tests
54
54
 
55
+ # Tiered runs: a push runs only the critical core (fast gate), a PR runs the
56
+ # standard suite, and the nightly schedule (or a manual dispatch) runs
57
+ # everything. Scenarios above the selected tier are reported "skipped" on the
58
+ # dashboard, not silently dropped. Tune the triggers + tiers to taste.
55
59
  on:
56
60
  push:
57
- branches: [main]
58
61
  pull_request:
62
+ schedule:
63
+ - cron: '0 3 * * *'
64
+ workflow_dispatch:
59
65
 
60
66
  jobs:
61
67
  e2e:
@@ -77,7 +83,7 @@ jobs:
77
83
  sleep 1
78
84
  done
79
85
  - name: Run opice browser tests
80
- run: bunx opice test tests/browser/
86
+ run: bunx opice test tests/browser/ --tier "\${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && 'extended' || github.event_name == 'pull_request' && 'standard' || 'critical' }}"
81
87
  env:
82
88
  OPICE_DSN: \${{ secrets.OPICE_DSN }}
83
89
  PLAYGROUND_URL: http://localhost:5173
@@ -10,6 +10,8 @@ const HANDOFF_DIR = path.join(tmpdir(), 'opice-handoffs')
10
10
 
11
11
  interface Handoff {
12
12
  endpoint: string
13
+ /** Project slug — used to build the /api/v1/<project>/runs/<id>/finish URL. */
14
+ project: string
13
15
  apiKey: string
14
16
  runId: string
15
17
  }
@@ -31,6 +33,13 @@ export async function testCommand(args: string[]): Promise<number> {
31
33
  warn('OPICE_API_KEY not set. Tests will run without reporting.')
32
34
  }
33
35
 
36
+ // `--tier=NAME` selects which test tier runs (critical < standard < extended).
37
+ // CLI flag wins over OPICE_TIER, which wins over opice.config.json's `tier`.
38
+ // The harness reads OPICE_TIER and skips (and reports as `skipped`) any
39
+ // scenario above the selected tier.
40
+ const { tier, rest: afterTier } = extractTier(args)
41
+ const resolvedTier = tier ?? process.env['OPICE_TIER'] ?? config?.tier
42
+
34
43
  const git = detectGitMeta()
35
44
  const env: NodeJS.ProcessEnv = {
36
45
  ...process.env,
@@ -41,12 +50,13 @@ export async function testCommand(args: string[]): Promise<number> {
41
50
  ...(apiKey ? { OPICE_API_KEY: apiKey } : {}),
42
51
  ...(git.branch ? { OPICE_BRANCH: git.branch } : {}),
43
52
  ...(git.commit ? { OPICE_COMMIT: git.commit } : {}),
53
+ ...(resolvedTier ? { OPICE_TIER: resolvedTier } : {}),
44
54
  }
45
55
 
46
56
  // `--retries=N` (opice's spelling) → bun's `--retry=N`, the global default
47
57
  // retry budget for every scenario. CLI flag wins over opice.config.json's
48
58
  // `retries`. A per-scenario `walkthrough`/meta `retries` overrides both.
49
- const { retries, rest } = extractRetries(args)
59
+ const { retries, rest } = extractRetries(afterTier)
50
60
  const resolvedRetries = retries ?? config?.retries
51
61
  const bunArgs = ['test', ...rest]
52
62
  // Don't clobber an explicit `--retry` the caller passed through to bun.
@@ -94,6 +104,33 @@ function extractRetries(args: string[]): { retries: number | undefined; rest: st
94
104
  return { retries, rest }
95
105
  }
96
106
 
107
+ /**
108
+ * Pull opice's `--tier=NAME` / `--tier NAME` out of the arg list (it's an opice
109
+ * concept, not a bun flag) and return the selected tier plus the remaining args.
110
+ * The value is passed straight through to the harness via OPICE_TIER, which
111
+ * validates it — so an unrecognized value isn't rejected here.
112
+ */
113
+ function extractTier(args: string[]): { tier: string | undefined; rest: string[] } {
114
+ const rest: string[] = []
115
+ let tier: string | undefined
116
+ for (let i = 0; i < args.length; i++) {
117
+ const arg = args[i]
118
+ if (arg === undefined) continue
119
+ if (arg.startsWith('--tier=')) {
120
+ tier = arg.slice('--tier='.length)
121
+ } else if (arg === '--tier') {
122
+ const next = args[i + 1]
123
+ if (next !== undefined) {
124
+ tier = next
125
+ i++ // consume the value
126
+ }
127
+ } else {
128
+ rest.push(arg)
129
+ }
130
+ }
131
+ return { tier, rest }
132
+ }
133
+
97
134
  async function finalizeHandoffs(childPid: number | undefined, slug: string | undefined): Promise<void> {
98
135
  let files: string[]
99
136
  try {
@@ -124,7 +161,7 @@ function printRunUrl(handoff: Handoff, slug: string | undefined): void {
124
161
  }
125
162
 
126
163
  async function finishRun(handoff: Handoff): Promise<void> {
127
- const url = `${handoff.endpoint}/api/v1/runs/${handoff.runId}/finish`
164
+ const url = `${handoff.endpoint}/api/v1/${handoff.project}/runs/${handoff.runId}/finish`
128
165
  const response = await fetch(url, {
129
166
  method: 'POST',
130
167
  headers: { authorization: `Bearer ${handoff.apiKey}` },
package/src/config.ts CHANGED
@@ -11,6 +11,13 @@ export interface OpiceConfig {
11
11
  * the command line, and by a per-scenario `walkthrough`/meta `retries`.
12
12
  */
13
13
  retries?: number
14
+ /**
15
+ * Default test tier to run (`critical` < `standard` < `extended`). Selection
16
+ * is a threshold — `standard` runs critical + standard. Overridden by `opice
17
+ * test --tier=NAME` and the `OPICE_TIER` env var. Omitted ⇒ run everything.
18
+ * Scenarios above the selected tier are reported `skipped`, not run.
19
+ */
20
+ tier?: 'critical' | 'standard' | 'extended'
14
21
  }
15
22
 
16
23
  const CONFIG_NAME = 'opice.config.json'
@@ -1,201 +0,0 @@
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
- }
@@ -1,101 +0,0 @@
1
- /**
2
- * `opice users create <email>` — create a dashboard login.
3
- *
4
- * Self-service signup is disabled on the platform; this is the sanctioned way
5
- * to mint an account. It calls the `admin.createUser` RPC with the bootstrap
6
- * admin token as a Bearer credential (--admin-token or OPICE_ADMIN_TOKEN) and
7
- * the platform endpoint (--endpoint, OPICE_ENDPOINT, or opice.config.json).
8
- * Accounts are created with the `admin` role by default.
9
- *
10
- * If no password is given one is generated and printed once.
11
- */
12
-
13
- import { loadConfig } from '../config'
14
- import { parseOpiceDsn } from '../dsn'
15
-
16
- interface CreateUserFlags {
17
- email?: string
18
- password?: string
19
- name?: string
20
- endpoint?: string
21
- adminToken?: string
22
- }
23
-
24
- export async function usersCommand(args: string[]): Promise<number> {
25
- const [sub, ...rest] = args
26
- if (sub !== 'create') {
27
- console.error('Usage: opice users create <email> [--password=...] [--name=...] [--endpoint=URL] [--admin-token=TOKEN]')
28
- return 1
29
- }
30
-
31
- const flags = parseFlags(rest)
32
- if (!flags.email) {
33
- console.error('Usage: opice users create <email> [--password=...] [--name=...] [--endpoint=URL] [--admin-token=TOKEN]')
34
- return 1
35
- }
36
-
37
- const endpoint = flags.endpoint ?? process.env['OPICE_ENDPOINT'] ?? (await loadConfig())?.endpoint ?? parseOpiceDsn(process.env['OPICE_DSN'])?.endpoint
38
- if (!endpoint) {
39
- console.error('Could not determine the platform endpoint. Pass --endpoint=URL, set OPICE_ENDPOINT, or run from a project with opice.config.json.')
40
- return 1
41
- }
42
-
43
- const adminToken = flags.adminToken ?? process.env['OPICE_ADMIN_TOKEN']
44
- if (!adminToken) {
45
- console.error('Missing admin token. Pass --admin-token=TOKEN or set OPICE_ADMIN_TOKEN.')
46
- return 1
47
- }
48
-
49
- const generated = !flags.password
50
- const password = flags.password ?? generatePassword()
51
-
52
- let response: Response
53
- try {
54
- response = await fetch(`${endpoint.replace(/\/$/, '')}/rpc`, {
55
- method: 'POST',
56
- headers: { 'content-type': 'application/json', authorization: `Bearer ${adminToken}` },
57
- body: JSON.stringify({
58
- method: 'admin.createUser',
59
- input: { email: flags.email, password, ...(flags.name ? { name: flags.name } : {}) },
60
- }),
61
- })
62
- } catch (err) {
63
- console.error(`[opice] request failed: ${(err as Error).message}`)
64
- return 1
65
- }
66
-
67
- const data = (await response.json().catch(() => null)) as
68
- | { result?: { email?: string }; error?: { message?: string } }
69
- | null
70
- if (!response.ok || !data || data.error || !data.result) {
71
- const message = data?.error?.message ?? `${response.status} ${response.statusText}`
72
- console.error(`[opice] could not create user: ${message}`)
73
- return 1
74
- }
75
-
76
- console.log(`✓ Created user ${data.result.email ?? flags.email}`)
77
- if (generated) {
78
- console.log(` password: ${password}`)
79
- console.log(' (shown once — store it in your password manager now)')
80
- }
81
- return 0
82
- }
83
-
84
- function parseFlags(args: string[]): CreateUserFlags {
85
- const flags: CreateUserFlags = {}
86
- for (const arg of args) {
87
- if (arg.startsWith('--password=')) flags.password = arg.slice('--password='.length)
88
- else if (arg.startsWith('--name=')) flags.name = arg.slice('--name='.length)
89
- else if (arg.startsWith('--endpoint=')) flags.endpoint = arg.slice('--endpoint='.length)
90
- else if (arg.startsWith('--admin-token=')) flags.adminToken = arg.slice('--admin-token='.length)
91
- else if (!arg.startsWith('--') && !flags.email) flags.email = arg
92
- }
93
- return flags
94
- }
95
-
96
- /** A 20-char base64url password — comfortably over the 10-char server minimum. */
97
- function generatePassword(): string {
98
- const bytes = new Uint8Array(15)
99
- crypto.getRandomValues(bytes)
100
- return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
101
- }