@opice/cli 0.6.1 → 0.8.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.8.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] [--fail-on-report-error] [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,16 @@ 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.
29
+ --fail-on-report-error exits non-zero if reporting to the platform
30
+ fails (default: reporting is best-effort and never reddens CI).
31
+ Use it so a bad token / unreachable endpoint can't leave CI green
32
+ while nothing reaches the dashboard. Falls back to
33
+ OPICE_REPORT_STRICT, then "failOnReportError" in opice.config.json.
26
34
 
27
35
  failures <run-url|run-id> [--json]
28
36
  Pull a failed run's details (failed scenarios, the failing step,
@@ -30,21 +38,6 @@ Commands:
30
38
  Read token comes from the URL's ?token=, OPICE_READ_TOKEN, or
31
39
  OPICE_READ_DSN (a read-only project credential).
32
40
 
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
41
  install-skills [--global] [--ref=BRANCH]
49
42
  Install opice's Claude Code skills + author agent into this project's
50
43
  .claude/ (or ~/.claude with --global), fetched from GitHub. Restart
@@ -63,10 +56,6 @@ async function main(argv: string[]): Promise<number> {
63
56
  return testCommand(rest)
64
57
  case 'failures':
65
58
  return failuresCommand(rest)
66
- case 'users':
67
- return usersCommand(rest)
68
- case 'tokens':
69
- return tokensCommand(rest)
70
59
  case 'install-skills':
71
60
  return installSkillsCommand(rest)
72
61
  case 'help':
@@ -5,10 +5,11 @@
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 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).
8
+ * Two read modes:
9
+ * - SERVICE TOKEN (OPICE_READ_DSN): a propustka service-token principal REST
10
+ * GET /api/v1/<slug>/… with the CF-Access-Client-* headers.
11
+ * - CAPABILITY SHARE: a pasted dashboard link's `?token=` (or OPICE_READ_TOKEN)
12
+ * → the anonymous read RPC at /s/rpc.
12
13
  */
13
14
 
14
15
  import { loadConfig } from '../config'
@@ -45,10 +46,18 @@ interface Step {
45
46
  interface Target {
46
47
  endpoint: string
47
48
  runId: string
48
- token: string | undefined
49
49
  slug?: string
50
+ // Exactly one auth mode: a pasted share link / OPICE_READ_TOKEN is a capability on /s/rpc;
51
+ // OPICE_READ_DSN is a service token (CF-Access-Client-*) on /api/v1.
52
+ shareToken?: string
53
+ service?: { clientId: string; clientSecret: string }
50
54
  }
51
55
 
56
+ type ReadOp =
57
+ | { kind: 'run'; runId: string }
58
+ | { kind: 'scenarios'; runId: string }
59
+ | { kind: 'steps'; scenarioId: string }
60
+
52
61
  export async function failuresCommand(args: string[]): Promise<number> {
53
62
  const asJson = args.includes('--json')
54
63
  const positional = args.filter((a) => !a.startsWith('--'))
@@ -67,8 +76,8 @@ export async function failuresCommand(args: string[]): Promise<number> {
67
76
  let run: Run
68
77
  let scenarios: Scenario[]
69
78
  try {
70
- run = await rpc<Run>(target, 'runs.get', { runId: target.runId })
71
- scenarios = await rpc<Scenario[]>(target, 'runs.scenarios', { runId: target.runId })
79
+ run = await read<Run>(target, { kind: 'run', runId: target.runId })
80
+ scenarios = await read<Scenario[]>(target, { kind: 'scenarios', runId: target.runId })
72
81
  } catch (err) {
73
82
  console.error(`[opice] ${(err as Error).message}`)
74
83
  return 1
@@ -78,7 +87,7 @@ export async function failuresCommand(args: string[]): Promise<number> {
78
87
  const detailed = await Promise.all(
79
88
  failed.map(async (s) => ({
80
89
  scenario: s,
81
- steps: await rpc<Step[]>(target, 'scenarios.steps', { scenarioId: s.id }).catch(() => [] as Step[]),
90
+ steps: await read<Step[]>(target, { kind: 'steps', scenarioId: s.id }).catch(() => [] as Step[]),
82
91
  })),
83
92
  )
84
93
 
@@ -141,37 +150,68 @@ function printDigest(run: Run, detailed: { scenario: Scenario; steps: Step[] }[]
141
150
 
142
151
  function absoluteScreenshot(relativeOrAbsolute: string, target: Target): string {
143
152
  const base = relativeOrAbsolute.startsWith('http') ? relativeOrAbsolute : `${target.endpoint}${relativeOrAbsolute}`
144
- if (!target.token) return base
145
- return `${base}${base.includes('?') ? '&' : '?'}token=${target.token}`
153
+ // A share link can carry its token in the URL; a service token needs headers, so leave it bare.
154
+ if (!target.shareToken) return base
155
+ return `${base}${base.includes('?') ? '&' : '?'}token=${target.shareToken}`
146
156
  }
147
157
 
148
158
  async function resolveTarget(ref: string): Promise<Target | null> {
149
159
  const readDsn = parseOpiceDsn(process.env['OPICE_READ_DSN'])
150
- const envToken = process.env['OPICE_READ_TOKEN'] ?? readDsn?.apiKey ?? undefined
160
+ const service = readDsn ? { clientId: readDsn.clientId, clientSecret: readDsn.clientSecret } : undefined
161
+ const envShareToken = process.env['OPICE_READ_TOKEN']
151
162
 
152
163
  if (/^https?:\/\//.test(ref)) {
153
164
  const url = new URL(ref)
154
- const token = url.searchParams.get('token') ?? envToken
165
+ const urlToken = url.searchParams.get('token')
155
166
  const match = url.pathname.match(/\/p\/([^/]+)\/r\/([^/]+)/)
156
- if (match) {
157
- return { endpoint: url.origin, runId: decodeURIComponent(match[2]!), token, slug: decodeURIComponent(match[1]!) }
158
- }
159
- // Fall back to the last path segment as the run id.
160
- const segments = url.pathname.split('/').filter(Boolean)
161
- const runId = segments[segments.length - 1]
162
- if (runId) return { endpoint: url.origin, runId, token }
163
- return null
167
+ const slug = match ? decodeURIComponent(match[1]!) : (process.env['OPICE_PROJECT'] ?? readDsn?.project)
168
+ const runId = match ? decodeURIComponent(match[2]!) : url.pathname.split('/').filter(Boolean).pop()
169
+ if (!runId) return null
170
+ // A pasted share link (?token=) or OPICE_READ_TOKEN capability; otherwise the read DSN.
171
+ const shareToken = urlToken ?? envShareToken
172
+ if (shareToken) return { endpoint: url.origin, runId, slug, shareToken }
173
+ return { endpoint: url.origin, runId, slug, service }
164
174
  }
165
175
 
166
- // Bare run id — endpoint from config/env/DSN, token from env/read DSN.
176
+ // Bare run id — endpoint + slug from config/env/DSN, auth from the read DSN or OPICE_READ_TOKEN.
167
177
  const config = await loadConfig()
168
178
  const endpoint = process.env['OPICE_ENDPOINT'] ?? config?.endpoint ?? readDsn?.endpoint ?? parseOpiceDsn(process.env['OPICE_DSN'])?.endpoint
169
179
  if (!endpoint) return null
170
- return { endpoint, runId: ref, token: envToken }
180
+ const slug = process.env['OPICE_PROJECT'] ?? config?.project ?? readDsn?.project
181
+ if (envShareToken) return { endpoint, runId: ref, slug, shareToken: envShareToken }
182
+ return { endpoint, runId: ref, slug, service }
171
183
  }
172
184
 
173
- async function rpc<T>(target: Target, method: string, input: unknown): Promise<T> {
174
- const url = `${target.endpoint}/rpc${target.token ? `?token=${target.token}` : ''}`
185
+ /**
186
+ * Read one resource, routed by the target's auth mode: a service token → REST GET on /api/v1
187
+ * (which needs the project slug); a capability share token → the /s/rpc read RPC.
188
+ */
189
+ async function read<T>(target: Target, op: ReadOp): Promise<T> {
190
+ if (target.service) {
191
+ if (!target.slug) {
192
+ throw new Error('reading via OPICE_READ_DSN needs the project slug — paste a full run URL or set OPICE_PROJECT')
193
+ }
194
+ const path = op.kind === 'run'
195
+ ? `runs/${op.runId}`
196
+ : op.kind === 'scenarios'
197
+ ? `runs/${op.runId}/scenarios`
198
+ : `scenarios/${op.scenarioId}/steps`
199
+ const response = await fetch(`${target.endpoint}/api/v1/${target.slug}/${path}`, {
200
+ headers: {
201
+ 'cf-access-client-id': target.service.clientId,
202
+ 'cf-access-client-secret': target.service.clientSecret,
203
+ },
204
+ })
205
+ if (!response.ok) {
206
+ throw new Error(`${op.kind}: ${response.status} ${response.statusText}`)
207
+ }
208
+ return (await response.json()) as T
209
+ }
210
+
211
+ // Capability share → the anonymous read RPC.
212
+ const method = op.kind === 'run' ? 'runs.get' : op.kind === 'scenarios' ? 'runs.scenarios' : 'scenarios.steps'
213
+ const input = op.kind === 'steps' ? { scenarioId: op.scenarioId } : { runId: op.runId }
214
+ const url = `${target.endpoint}/s/rpc${target.shareToken ? `?token=${target.shareToken}` : ''}`
175
215
  const response = await fetch(url, {
176
216
  method: 'POST',
177
217
  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,7 +10,11 @@ const HANDOFF_DIR = path.join(tmpdir(), 'opice-handoffs')
10
10
 
11
11
  interface Handoff {
12
12
  endpoint: string
13
- apiKey: string
13
+ /** Project slug — used to build the /api/v1/<project>/runs/<id>/finish URL. */
14
+ project: string
15
+ /** Service-token credentials for the CF-Access-Client-* headers on POST /finish. */
16
+ clientId: string
17
+ clientSecret: string
14
18
  runId: string
15
19
  }
16
20
 
@@ -19,7 +23,8 @@ export async function testCommand(args: string[]): Promise<number> {
19
23
  const dsn = parseOpiceDsn(process.env['OPICE_DSN'])
20
24
  const project = process.env['OPICE_PROJECT'] ?? config?.project ?? dsn?.project
21
25
  const endpoint = process.env['OPICE_ENDPOINT'] ?? config?.endpoint ?? dsn?.endpoint
22
- const apiKey = process.env['OPICE_API_KEY'] ?? dsn?.apiKey
26
+ const clientId = process.env['OPICE_CLIENT_ID'] ?? dsn?.clientId
27
+ const clientSecret = process.env['OPICE_CLIENT_SECRET'] ?? dsn?.clientSecret
23
28
 
24
29
  if (!project) {
25
30
  warn('OPICE_PROJECT not set and no opice.config.json found. Run `opice init` or set the env var.')
@@ -27,26 +32,44 @@ export async function testCommand(args: string[]): Promise<number> {
27
32
  if (!endpoint) {
28
33
  warn('OPICE_ENDPOINT not set and no opice.config.json found. Tests will run without reporting.')
29
34
  }
30
- if (!apiKey) {
31
- warn('OPICE_API_KEY not set. Tests will run without reporting.')
35
+ if (!clientId || !clientSecret) {
36
+ warn('OPICE_CLIENT_ID / OPICE_CLIENT_SECRET not set (the OPICE_DSN userinfo). Tests will run without reporting.')
32
37
  }
33
38
 
39
+ // `--tier=NAME` selects which test tier runs (critical < standard < extended).
40
+ // CLI flag wins over OPICE_TIER, which wins over opice.config.json's `tier`.
41
+ // The harness reads OPICE_TIER and skips (and reports as `skipped`) any
42
+ // scenario above the selected tier.
43
+ const { tier, rest: afterTier } = extractTier(args)
44
+ const resolvedTier = tier ?? process.env['OPICE_TIER'] ?? config?.tier
45
+
46
+ // `--fail-on-report-error` turns a swallowed reporting failure into a non-zero
47
+ // exit (default is best-effort: reporting never reddens CI). CLI flag wins over
48
+ // OPICE_REPORT_STRICT, which wins over opice.config.json's `failOnReportError`.
49
+ // We propagate it to the harness via OPICE_REPORT_STRICT (it fails the run from
50
+ // a scenario's afterAll) AND honour it here for the POST /finish finalize.
51
+ const { strict: strictFlag, rest: afterStrict } = extractStrict(afterTier)
52
+ const strict = strictFlag || isTruthy(process.env['OPICE_REPORT_STRICT']) || config?.failOnReportError === true
53
+
34
54
  const git = detectGitMeta()
35
55
  const env: NodeJS.ProcessEnv = {
36
56
  ...process.env,
37
57
  ...(project ? { OPICE_PROJECT: project } : {}),
38
58
  ...(endpoint ? { OPICE_ENDPOINT: endpoint } : {}),
39
- // Resolve the api key (incl. from a DSN) into the explicit var the
59
+ // Resolve the service-token pair (incl. from a DSN) into the explicit vars the
40
60
  // harness reporter reads, so a bare OPICE_DSN is enough to report.
41
- ...(apiKey ? { OPICE_API_KEY: apiKey } : {}),
61
+ ...(clientId ? { OPICE_CLIENT_ID: clientId } : {}),
62
+ ...(clientSecret ? { OPICE_CLIENT_SECRET: clientSecret } : {}),
42
63
  ...(git.branch ? { OPICE_BRANCH: git.branch } : {}),
43
64
  ...(git.commit ? { OPICE_COMMIT: git.commit } : {}),
65
+ ...(resolvedTier ? { OPICE_TIER: resolvedTier } : {}),
66
+ ...(strict ? { OPICE_REPORT_STRICT: '1' } : {}),
44
67
  }
45
68
 
46
69
  // `--retries=N` (opice's spelling) → bun's `--retry=N`, the global default
47
70
  // retry budget for every scenario. CLI flag wins over opice.config.json's
48
71
  // `retries`. A per-scenario `walkthrough`/meta `retries` overrides both.
49
- const { retries, rest } = extractRetries(args)
72
+ const { retries, rest } = extractRetries(afterStrict)
50
73
  const resolvedRetries = retries ?? config?.retries
51
74
  const bunArgs = ['test', ...rest]
52
75
  // Don't clobber an explicit `--retry` the caller passed through to bun.
@@ -62,7 +85,16 @@ export async function testCommand(args: string[]): Promise<number> {
62
85
 
63
86
  // After bun test exits, look for handoff files the reporter wrote and
64
87
  // POST /finish for each run so it leaves "running" state.
65
- await finalizeHandoffs(child.pid, project)
88
+ const finalizeFailed = await finalizeHandoffs(child.pid, project)
89
+
90
+ // Under strict reporting, a failed finalize (POST /finish) reddens an
91
+ // otherwise-green run — the same contract the harness enforces for in-test
92
+ // reports. Don't mask a real test failure: only escalate when bun itself was
93
+ // green. (An in-test report failure already failed bun via the harness.)
94
+ if (exitCode === 0 && strict && finalizeFailed) {
95
+ warn('reporting failed and --fail-on-report-error is set — exiting non-zero.')
96
+ return 1
97
+ }
66
98
 
67
99
  return exitCode
68
100
  }
@@ -94,14 +126,65 @@ function extractRetries(args: string[]): { retries: number | undefined; rest: st
94
126
  return { retries, rest }
95
127
  }
96
128
 
97
- async function finalizeHandoffs(childPid: number | undefined, slug: string | undefined): Promise<void> {
129
+ /**
130
+ * Pull opice's `--tier=NAME` / `--tier NAME` out of the arg list (it's an opice
131
+ * concept, not a bun flag) and return the selected tier plus the remaining args.
132
+ * The value is passed straight through to the harness via OPICE_TIER, which
133
+ * validates it — so an unrecognized value isn't rejected here.
134
+ */
135
+ function extractTier(args: string[]): { tier: string | undefined; rest: string[] } {
136
+ const rest: string[] = []
137
+ let tier: string | undefined
138
+ for (let i = 0; i < args.length; i++) {
139
+ const arg = args[i]
140
+ if (arg === undefined) continue
141
+ if (arg.startsWith('--tier=')) {
142
+ tier = arg.slice('--tier='.length)
143
+ } else if (arg === '--tier') {
144
+ const next = args[i + 1]
145
+ if (next !== undefined) {
146
+ tier = next
147
+ i++ // consume the value
148
+ }
149
+ } else {
150
+ rest.push(arg)
151
+ }
152
+ }
153
+ return { tier, rest }
154
+ }
155
+
156
+ /** Returns true if finalizing any run failed (so strict mode can escalate). */
157
+ /**
158
+ * Pull opice's `--fail-on-report-error` boolean flag out of the arg list (it's
159
+ * an opice concept, not a bun flag) and return whether it was present plus the
160
+ * remaining args.
161
+ */
162
+ function extractStrict(args: string[]): { strict: boolean; rest: string[] } {
163
+ const rest: string[] = []
164
+ let strict = false
165
+ for (const arg of args) {
166
+ if (arg === '--fail-on-report-error') strict = true
167
+ else rest.push(arg)
168
+ }
169
+ return { strict, rest }
170
+ }
171
+
172
+ function isTruthy(value: string | undefined): boolean {
173
+ if (!value) return false
174
+ const v = value.toLowerCase()
175
+ return v === '1' || v === 'true' || v === 'yes' || v === 'on'
176
+ }
177
+
178
+ /** Returns true if finalizing any run failed (so strict mode can escalate). */
179
+ async function finalizeHandoffs(childPid: number | undefined, slug: string | undefined): Promise<boolean> {
98
180
  let files: string[]
99
181
  try {
100
182
  files = await fs.readdir(HANDOFF_DIR)
101
183
  } catch {
102
- return // no handoff dir → no runs reported, nothing to finalize
184
+ return false // no handoff dir → no runs reported, nothing to finalize
103
185
  }
104
186
  const matching = childPid ? files.filter((f) => f === `${childPid}.json`) : files
187
+ let failed = false
105
188
  for (const file of matching) {
106
189
  const full = path.join(HANDOFF_DIR, file)
107
190
  try {
@@ -109,11 +192,13 @@ async function finalizeHandoffs(childPid: number | undefined, slug: string | und
109
192
  await finishRun(handoff)
110
193
  printRunUrl(handoff, slug)
111
194
  } catch (err) {
195
+ failed = true
112
196
  warn(`Failed to finalize run from ${file}: ${(err as Error).message}`)
113
197
  } finally {
114
198
  await fs.unlink(full).catch(() => {})
115
199
  }
116
200
  }
201
+ return failed
117
202
  }
118
203
 
119
204
  function printRunUrl(handoff: Handoff, slug: string | undefined): void {
@@ -124,10 +209,13 @@ function printRunUrl(handoff: Handoff, slug: string | undefined): void {
124
209
  }
125
210
 
126
211
  async function finishRun(handoff: Handoff): Promise<void> {
127
- const url = `${handoff.endpoint}/api/v1/runs/${handoff.runId}/finish`
212
+ const url = `${handoff.endpoint}/api/v1/${handoff.project}/runs/${handoff.runId}/finish`
128
213
  const response = await fetch(url, {
129
214
  method: 'POST',
130
- headers: { authorization: `Bearer ${handoff.apiKey}` },
215
+ headers: {
216
+ 'cf-access-client-id': handoff.clientId,
217
+ 'cf-access-client-secret': handoff.clientSecret,
218
+ },
131
219
  })
132
220
  if (!response.ok) {
133
221
  throw new Error(`${response.status} ${await response.text()}`)
package/src/config.ts CHANGED
@@ -11,6 +11,21 @@ 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'
21
+ /**
22
+ * Fail the run if reporting to the platform fails (default best-effort: a
23
+ * reporting failure is logged but never reddens CI). When set, a failed report
24
+ * during the test (harness) or a failed `POST /finish` (this CLI) exits
25
+ * non-zero. Overridden by `opice test --fail-on-report-error` and the
26
+ * `OPICE_REPORT_STRICT` env var.
27
+ */
28
+ failOnReportError?: boolean
14
29
  }
15
30
 
16
31
  const CONFIG_NAME = 'opice.config.json'
package/src/dsn.ts CHANGED
@@ -1,18 +1,20 @@
1
1
  /**
2
2
  * An opice DSN packs everything a project needs to report into one string:
3
3
  *
4
- * OPICE_DSN=https://<apiKey>@<host>/<slug>
4
+ * OPICE_DSN=https://<clientId>:<clientSecret>@<host>/<slug>
5
5
  *
6
- * The api key rides in the userinfo, the host is the platform endpoint, and
7
- * the first path segment is the project slug. The individual `OPICE_*` vars
8
- * (and opice.config.json) still win when present the DSN is a convenience
9
- * fallback so the dashboard can hand out a single value to drop into `.env`.
6
+ * The DSN is a Cloudflare Access SERVICE TOKEN: the client id + secret ride in the
7
+ * userinfo (sent as `CF-Access-Client-Id` / `CF-Access-Client-Secret`), the host is
8
+ * the platform endpoint, and the first path segment is the project slug. The
9
+ * individual `OPICE_*` vars (and opice.config.json) still win when present the DSN
10
+ * is a convenience fallback so the dashboard can hand out a single value.
10
11
  *
11
12
  * Kept in sync with `@opice/harness`'s copy; duplicated to avoid a CLI→harness
12
13
  * dependency.
13
14
  */
14
15
  export interface OpiceDsn {
15
- apiKey: string
16
+ clientId: string
17
+ clientSecret: string
16
18
  endpoint: string
17
19
  project: string
18
20
  }
@@ -25,8 +27,9 @@ export function parseOpiceDsn(raw: string | undefined | null): OpiceDsn | null {
25
27
  } catch {
26
28
  return null
27
29
  }
28
- const apiKey = decodeURIComponent(url.username)
30
+ const clientId = decodeURIComponent(url.username)
31
+ const clientSecret = decodeURIComponent(url.password)
29
32
  const project = url.pathname.replace(/^\/+/, '').split('/')[0] ?? ''
30
- if (!apiKey || !project) return null
31
- return { apiKey, endpoint: `${url.protocol}//${url.host}`, project }
33
+ if (!clientId || !clientSecret || !project) return null
34
+ return { clientId, clientSecret, endpoint: `${url.protocol}//${url.host}`, project }
32
35
  }
@@ -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
- }