@opice/cli 0.6.0 → 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 +1 -1
- package/src/cli.ts +6 -22
- package/src/commands/failures.ts +1 -1
- package/src/commands/init.ts +8 -2
- package/src/commands/test.ts +39 -2
- package/src/config.ts +7 -0
- 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] [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':
|
package/src/commands/failures.ts
CHANGED
|
@@ -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' },
|
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,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(
|
|
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'
|
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
|
-
}
|