@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 +1 -1
- package/src/cli.ts +11 -22
- package/src/commands/failures.ts +64 -24
- package/src/commands/init.ts +8 -2
- package/src/commands/test.ts +100 -12
- package/src/config.ts +15 -0
- package/src/dsn.ts +12 -9
- package/src/commands/tokens.ts +0 -201
- package/src/commands/users.ts +0 -101
package/package.json
CHANGED
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':
|
package/src/commands/failures.ts
CHANGED
|
@@ -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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* dashboard
|
|
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
|
|
71
|
-
scenarios = await
|
|
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
|
|
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
|
-
|
|
145
|
-
|
|
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
|
|
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
|
|
165
|
+
const urlToken = url.searchParams.get('token')
|
|
155
166
|
const match = url.pathname.match(/\/p\/([^/]+)\/r\/([^/]+)/)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
//
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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' },
|
package/src/commands/init.ts
CHANGED
|
@@ -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
|
package/src/commands/test.ts
CHANGED
|
@@ -10,7 +10,11 @@ const HANDOFF_DIR = path.join(tmpdir(), 'opice-handoffs')
|
|
|
10
10
|
|
|
11
11
|
interface Handoff {
|
|
12
12
|
endpoint: string
|
|
13
|
-
|
|
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
|
|
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 (!
|
|
31
|
-
warn('
|
|
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
|
|
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
|
-
...(
|
|
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(
|
|
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
|
-
|
|
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: {
|
|
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://<
|
|
4
|
+
* OPICE_DSN=https://<clientId>:<clientSecret>@<host>/<slug>
|
|
5
5
|
*
|
|
6
|
-
* The
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
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
|
|
30
|
+
const clientId = decodeURIComponent(url.username)
|
|
31
|
+
const clientSecret = decodeURIComponent(url.password)
|
|
29
32
|
const project = url.pathname.replace(/^\/+/, '').split('/')[0] ?? ''
|
|
30
|
-
if (!
|
|
31
|
-
return {
|
|
33
|
+
if (!clientId || !clientSecret || !project) return null
|
|
34
|
+
return { clientId, clientSecret, endpoint: `${url.protocol}//${url.host}`, project }
|
|
32
35
|
}
|
package/src/commands/tokens.ts
DELETED
|
@@ -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
|
-
}
|
package/src/commands/users.ts
DELETED
|
@@ -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
|
-
}
|