@parix/cli 0.1.5
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/README.md +123 -0
- package/dist/cli.cjs +30 -0
- package/dist/cli.d.cts +2 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +28 -0
- package/dist/commands/api.cjs +81 -0
- package/dist/commands/api.d.cts +2 -0
- package/dist/commands/api.d.ts +2 -0
- package/dist/commands/api.js +78 -0
- package/dist/commands/auth.cjs +128 -0
- package/dist/commands/auth.d.cts +2 -0
- package/dist/commands/auth.d.ts +2 -0
- package/dist/commands/auth.js +125 -0
- package/dist/commands/database.cjs +259 -0
- package/dist/commands/database.d.cts +2 -0
- package/dist/commands/database.d.ts +2 -0
- package/dist/commands/database.js +256 -0
- package/dist/commands/tb.cjs +242 -0
- package/dist/commands/tb.d.cts +2 -0
- package/dist/commands/tb.d.ts +2 -0
- package/dist/commands/tb.js +239 -0
- package/dist/lib/api-client.cjs +44 -0
- package/dist/lib/api-client.d.cts +10 -0
- package/dist/lib/api-client.d.ts +10 -0
- package/dist/lib/api-client.js +40 -0
- package/dist/lib/config.cjs +24 -0
- package/dist/lib/config.d.cts +10 -0
- package/dist/lib/config.d.ts +10 -0
- package/dist/lib/config.js +18 -0
- package/dist/lib/loopback-server.cjs +158 -0
- package/dist/lib/loopback-server.d.cts +20 -0
- package/dist/lib/loopback-server.d.ts +20 -0
- package/dist/lib/loopback-server.js +155 -0
- package/dist/lib/oauth-api.cjs +73 -0
- package/dist/lib/oauth-api.d.cts +31 -0
- package/dist/lib/oauth-api.d.ts +31 -0
- package/dist/lib/oauth-api.js +68 -0
- package/dist/lib/oauth-session.cjs +108 -0
- package/dist/lib/oauth-session.d.cts +32 -0
- package/dist/lib/oauth-session.d.ts +32 -0
- package/dist/lib/oauth-session.js +103 -0
- package/dist/lib/oauth.cjs +58 -0
- package/dist/lib/oauth.d.cts +19 -0
- package/dist/lib/oauth.d.ts +19 -0
- package/dist/lib/oauth.js +49 -0
- package/dist/lib/open-url.cjs +38 -0
- package/dist/lib/open-url.d.cts +1 -0
- package/dist/lib/open-url.d.ts +1 -0
- package/dist/lib/open-url.js +32 -0
- package/dist/lib/output.cjs +20 -0
- package/dist/lib/output.d.cts +6 -0
- package/dist/lib/output.d.ts +6 -0
- package/dist/lib/output.js +15 -0
- package/dist/lib/parix-api.cjs +59 -0
- package/dist/lib/parix-api.d.cts +12 -0
- package/dist/lib/parix-api.d.ts +12 -0
- package/dist/lib/parix-api.js +55 -0
- package/dist/lib/session.cjs +80 -0
- package/dist/lib/session.d.cts +28 -0
- package/dist/lib/session.d.ts +28 -0
- package/dist/lib/session.js +74 -0
- package/dist/lib/tb-payloads.cjs +153 -0
- package/dist/lib/tb-payloads.d.cts +61 -0
- package/dist/lib/tb-payloads.d.ts +61 -0
- package/dist/lib/tb-payloads.js +144 -0
- package/package.json +74 -0
package/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# parix
|
|
2
|
+
|
|
3
|
+
`parix` is the Parix command line interface.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @parix/cli
|
|
9
|
+
parix --help
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Current commands:
|
|
13
|
+
|
|
14
|
+
- `parix auth login`
|
|
15
|
+
- `parix auth status`
|
|
16
|
+
- `parix auth logout`
|
|
17
|
+
- `parix api <path>`
|
|
18
|
+
- `parix database catalog`
|
|
19
|
+
- `parix database create <database>`
|
|
20
|
+
- `parix database list`
|
|
21
|
+
- `parix database info <database-id>`
|
|
22
|
+
- `parix database remove <database-id>`
|
|
23
|
+
- `parix tb create-accounts <database-id>`
|
|
24
|
+
- `parix tb create-transfers <database-id>`
|
|
25
|
+
- `parix tb lookup-accounts <database-id>`
|
|
26
|
+
- `parix tb lookup-transfers <database-id>`
|
|
27
|
+
- `parix tb get-account-transfers <database-id>`
|
|
28
|
+
- `parix tb get-account-balances <database-id>`
|
|
29
|
+
- `parix tb query-accounts <database-id>`
|
|
30
|
+
- `parix tb query-transfers <database-id>`
|
|
31
|
+
|
|
32
|
+
## Auth flow
|
|
33
|
+
|
|
34
|
+
- Starts a local loopback callback server on `127.0.0.1`
|
|
35
|
+
- Starts the Better Auth OIDC flow for the first-party `parix` client
|
|
36
|
+
- Opens the browser authorization flow with PKCE
|
|
37
|
+
- Forces the Better Auth consent screen on every `parix auth login`
|
|
38
|
+
- Receives an authorization code through the local callback
|
|
39
|
+
- Redirects the browser to the Parix success page at `/cli-oauth-consent-granted`
|
|
40
|
+
- Exchanges the code for access and refresh tokens
|
|
41
|
+
- Stores the local session at `~/.config/parix/session.json`
|
|
42
|
+
|
|
43
|
+
`parix auth status` validates the stored session, refreshes tokens if needed, and prints the authenticated user plus active organization context.
|
|
44
|
+
|
|
45
|
+
If you want to override the OIDC prompt manually, use `--prompt <value>`.
|
|
46
|
+
|
|
47
|
+
## Database commands
|
|
48
|
+
|
|
49
|
+
- `parix database catalog`: show the available provider, region, cluster config, cluster size, and storage tier options for database creation
|
|
50
|
+
- `parix database create <database>`: create a database in the active organization, using defaults or explicit `--provider`, `--region`, `--cluster-config-id`, `--cluster-size-id`, `--storage-tier-id`, and `--storage-gb` inputs
|
|
51
|
+
- `parix database list`: list databases in the active organization, including database IDs, with optional `--search`, `--limit`, and `--json`
|
|
52
|
+
- `parix database info <database-id>`: show database metadata, profile state, gateway URL, latest provision job, and metrics summary
|
|
53
|
+
- `parix database remove <database-id>`: queue database removal; use `--yes` for non-interactive runs
|
|
54
|
+
|
|
55
|
+
## TigerBeetle commands
|
|
56
|
+
|
|
57
|
+
Supported operations:
|
|
58
|
+
|
|
59
|
+
- `create-accounts`
|
|
60
|
+
- `create-transfers`
|
|
61
|
+
- `lookup-accounts`
|
|
62
|
+
- `lookup-transfers`
|
|
63
|
+
- `get-account-transfers`
|
|
64
|
+
- `get-account-balances`
|
|
65
|
+
- `query-accounts`
|
|
66
|
+
- `query-transfers`
|
|
67
|
+
|
|
68
|
+
TigerBeetle commands support both styles:
|
|
69
|
+
|
|
70
|
+
- flag-driven payloads for common workflows such as `--from`, `--to`, `--amount`, `--ledger`, `--code`, repeated `--id`, and repeated `--flag`
|
|
71
|
+
- raw JSON overrides with `--payload '<json>'` or `--file ./payload.json`
|
|
72
|
+
|
|
73
|
+
Operational notes:
|
|
74
|
+
|
|
75
|
+
- database and TB commands use the active organization from the current `parix` token
|
|
76
|
+
- `db:read` and `db:write` OIDC scopes are enforced on the Worker API surface
|
|
77
|
+
- newly provisioned databases may take a short time to become ready for TB operations; the CLI/API includes readiness retries for fresh databases
|
|
78
|
+
|
|
79
|
+
## Development
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
bun install
|
|
83
|
+
bun run build
|
|
84
|
+
bun run test
|
|
85
|
+
bun run typecheck
|
|
86
|
+
bun run cli -- auth login --base-url http://localhost:5173
|
|
87
|
+
bun run cli -- auth status --base-url http://localhost:5173
|
|
88
|
+
bun run cli -- api /api/v1/session --base-url http://localhost:5173
|
|
89
|
+
bun run cli -- database catalog --json
|
|
90
|
+
bun run cli -- database create demo-ledger --provider gcp --region asia-southeast1 --json
|
|
91
|
+
bun run cli -- database list --json
|
|
92
|
+
bun run cli -- database info <database-id>
|
|
93
|
+
bun run cli -- database remove <database-id> --yes --json
|
|
94
|
+
bun run cli -- tb query-accounts <database-id> --limit 10 --json
|
|
95
|
+
bun run cli -- tb create-transfers <database-id> --from 1000 --to 1001 --amount 1 --ledger 1 --code 1
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
By default, the CLI targets `https://parix.io`. You can override the target app URL with `--base-url`
|
|
99
|
+
or `PARIX_BASE_URL`.
|
|
100
|
+
|
|
101
|
+
## Publishing
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
bun run publish:check
|
|
105
|
+
bun run publish:npm
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`publish:check` runs formatting, linting, tests, type checking, a fresh build, and `npm pack --dry-run` using a repo-local npm cache directory. `publish:npm` publishes the package publicly to npmjs.com using that same local cache.
|
|
109
|
+
|
|
110
|
+
## GitHub Actions
|
|
111
|
+
|
|
112
|
+
- `.github/workflows/ci.yml` runs lint, tests, typecheck, build, and `npm pack --dry-run` on pushes and pull requests
|
|
113
|
+
- `.github/workflows/publish.yml` publishes to npm when a GitHub Release is published, using the `NPM_ACCESS_TOKEN` secret, and uploads the published npm tarball to the GitHub Release
|
|
114
|
+
|
|
115
|
+
Recommended release flow:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
# bump version in package.json
|
|
119
|
+
git tag v0.1.2
|
|
120
|
+
git push origin main --tags
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Then publish a GitHub Release from tag `v0.1.2`. The publish workflow verifies that the release tag matches `package.json`, packs the npm tarball, publishes that tarball to npm, and uploads the same `.tgz` as a GitHub Release asset.
|
package/dist/cli.cjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const prompts_1 = require("@clack/prompts");
|
|
5
|
+
const commander_1 = require("commander");
|
|
6
|
+
const node_module_1 = require("node:module");
|
|
7
|
+
const api_1 = require("./commands/api.cjs");
|
|
8
|
+
const auth_1 = require("./commands/auth.cjs");
|
|
9
|
+
const database_1 = require("./commands/database.cjs");
|
|
10
|
+
const tb_1 = require("./commands/tb.cjs");
|
|
11
|
+
const loadPackageJson = (0, node_module_1.createRequire)(require("url").pathToFileURL(__filename));
|
|
12
|
+
const packageJson = loadPackageJson('../package.json');
|
|
13
|
+
async function main() {
|
|
14
|
+
const program = new commander_1.Command();
|
|
15
|
+
program
|
|
16
|
+
.name('parix')
|
|
17
|
+
.version(packageJson.version, '-v, --version')
|
|
18
|
+
.description('Parix command line interface')
|
|
19
|
+
.showHelpAfterError()
|
|
20
|
+
.addCommand((0, auth_1.createAuthCommand)())
|
|
21
|
+
.addCommand((0, api_1.createApiCommand)())
|
|
22
|
+
.addCommand((0, database_1.createDatabaseCommand)())
|
|
23
|
+
.addCommand((0, tb_1.createTbCommand)());
|
|
24
|
+
await program.parseAsync(process.argv);
|
|
25
|
+
}
|
|
26
|
+
main().catch((error) => {
|
|
27
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
28
|
+
prompts_1.log.error(message);
|
|
29
|
+
process.exitCode = 1;
|
|
30
|
+
});
|
package/dist/cli.d.cts
ADDED
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { log } from '@clack/prompts';
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { createApiCommand } from "./commands/api.js";
|
|
6
|
+
import { createAuthCommand } from "./commands/auth.js";
|
|
7
|
+
import { createDatabaseCommand } from "./commands/database.js";
|
|
8
|
+
import { createTbCommand } from "./commands/tb.js";
|
|
9
|
+
const loadPackageJson = createRequire(import.meta.url);
|
|
10
|
+
const packageJson = loadPackageJson('../package.json');
|
|
11
|
+
async function main() {
|
|
12
|
+
const program = new Command();
|
|
13
|
+
program
|
|
14
|
+
.name('parix')
|
|
15
|
+
.version(packageJson.version, '-v, --version')
|
|
16
|
+
.description('Parix command line interface')
|
|
17
|
+
.showHelpAfterError()
|
|
18
|
+
.addCommand(createAuthCommand())
|
|
19
|
+
.addCommand(createApiCommand())
|
|
20
|
+
.addCommand(createDatabaseCommand())
|
|
21
|
+
.addCommand(createTbCommand());
|
|
22
|
+
await program.parseAsync(process.argv);
|
|
23
|
+
}
|
|
24
|
+
main().catch((error) => {
|
|
25
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
26
|
+
log.error(message);
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createApiCommand = createApiCommand;
|
|
4
|
+
const prompts_1 = require("@clack/prompts");
|
|
5
|
+
const commander_1 = require("commander");
|
|
6
|
+
const api_client_1 = require("../lib/api-client.cjs");
|
|
7
|
+
const config_1 = require("../lib/config.cjs");
|
|
8
|
+
const session_1 = require("../lib/session.cjs");
|
|
9
|
+
function createApiCommand() {
|
|
10
|
+
return new commander_1.Command('api')
|
|
11
|
+
.description('Make an authenticated API request with the stored bearer token')
|
|
12
|
+
.argument('<path>', 'Relative API path or absolute URL')
|
|
13
|
+
.option('-X, --method <method>', 'HTTP method', 'GET')
|
|
14
|
+
.option('-d, --body <body>', 'Raw request body')
|
|
15
|
+
.option('-H, --header <header>', 'Additional header in key:value form', collectValues, [])
|
|
16
|
+
.option('-b, --base-url <url>', 'Override the stored base URL')
|
|
17
|
+
.action(async (requestPath, options) => {
|
|
18
|
+
await handleApiRequest(requestPath, options);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
async function handleApiRequest(requestPath, options) {
|
|
22
|
+
const storedSession = await (0, session_1.readStoredSession)();
|
|
23
|
+
if (!storedSession) {
|
|
24
|
+
throw new Error('No local session found. Run `parix auth login` first.');
|
|
25
|
+
}
|
|
26
|
+
const baseUrl = (0, config_1.resolveBaseUrl)(options.baseUrl, storedSession);
|
|
27
|
+
const headers = parseHeaders(options.header ?? []);
|
|
28
|
+
const body = options.body ?? undefined;
|
|
29
|
+
if (body && !hasHeader(headers, 'content-type')) {
|
|
30
|
+
headers.set('content-type', 'application/json');
|
|
31
|
+
}
|
|
32
|
+
const client = new api_client_1.ApiClient({
|
|
33
|
+
session: {
|
|
34
|
+
...storedSession,
|
|
35
|
+
baseUrl,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
const response = await client.request(requestPath, {
|
|
39
|
+
method: options.method ?? 'GET',
|
|
40
|
+
headers,
|
|
41
|
+
body,
|
|
42
|
+
});
|
|
43
|
+
const contentType = response.headers.get('content-type') ?? 'unknown';
|
|
44
|
+
const payload = await response.text();
|
|
45
|
+
(0, prompts_1.note)([`Status: ${response.status} ${response.statusText}`, `Content-Type: ${contentType}`].join('\n'), 'Response');
|
|
46
|
+
if (contentType.includes('application/json') && payload.length > 0) {
|
|
47
|
+
try {
|
|
48
|
+
prompts_1.log.message(JSON.stringify(JSON.parse(payload), null, 2));
|
|
49
|
+
(0, prompts_1.outro)('Request completed');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
catch { }
|
|
53
|
+
}
|
|
54
|
+
if (payload.length > 0) {
|
|
55
|
+
prompts_1.log.message(payload);
|
|
56
|
+
}
|
|
57
|
+
(0, prompts_1.outro)('Request completed');
|
|
58
|
+
}
|
|
59
|
+
function collectValues(value, previous) {
|
|
60
|
+
previous.push(value);
|
|
61
|
+
return previous;
|
|
62
|
+
}
|
|
63
|
+
function parseHeaders(values) {
|
|
64
|
+
const headers = new Headers();
|
|
65
|
+
for (const value of values) {
|
|
66
|
+
const separatorIndex = value.indexOf(':');
|
|
67
|
+
if (separatorIndex <= 0) {
|
|
68
|
+
throw new Error(`Invalid header format: ${value}. Use key:value.`);
|
|
69
|
+
}
|
|
70
|
+
const key = value.slice(0, separatorIndex).trim();
|
|
71
|
+
const headerValue = value.slice(separatorIndex + 1).trim();
|
|
72
|
+
if (!key) {
|
|
73
|
+
throw new Error(`Invalid header name: ${value}`);
|
|
74
|
+
}
|
|
75
|
+
headers.set(key, headerValue);
|
|
76
|
+
}
|
|
77
|
+
return headers;
|
|
78
|
+
}
|
|
79
|
+
function hasHeader(headers, name) {
|
|
80
|
+
return headers.has(name) || headers.has(name.toLowerCase());
|
|
81
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { log, note, outro } from '@clack/prompts';
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { ApiClient } from "../lib/api-client.js";
|
|
4
|
+
import { resolveBaseUrl } from "../lib/config.js";
|
|
5
|
+
import { readStoredSession } from "../lib/session.js";
|
|
6
|
+
export function createApiCommand() {
|
|
7
|
+
return new Command('api')
|
|
8
|
+
.description('Make an authenticated API request with the stored bearer token')
|
|
9
|
+
.argument('<path>', 'Relative API path or absolute URL')
|
|
10
|
+
.option('-X, --method <method>', 'HTTP method', 'GET')
|
|
11
|
+
.option('-d, --body <body>', 'Raw request body')
|
|
12
|
+
.option('-H, --header <header>', 'Additional header in key:value form', collectValues, [])
|
|
13
|
+
.option('-b, --base-url <url>', 'Override the stored base URL')
|
|
14
|
+
.action(async (requestPath, options) => {
|
|
15
|
+
await handleApiRequest(requestPath, options);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
async function handleApiRequest(requestPath, options) {
|
|
19
|
+
const storedSession = await readStoredSession();
|
|
20
|
+
if (!storedSession) {
|
|
21
|
+
throw new Error('No local session found. Run `parix auth login` first.');
|
|
22
|
+
}
|
|
23
|
+
const baseUrl = resolveBaseUrl(options.baseUrl, storedSession);
|
|
24
|
+
const headers = parseHeaders(options.header ?? []);
|
|
25
|
+
const body = options.body ?? undefined;
|
|
26
|
+
if (body && !hasHeader(headers, 'content-type')) {
|
|
27
|
+
headers.set('content-type', 'application/json');
|
|
28
|
+
}
|
|
29
|
+
const client = new ApiClient({
|
|
30
|
+
session: {
|
|
31
|
+
...storedSession,
|
|
32
|
+
baseUrl,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
const response = await client.request(requestPath, {
|
|
36
|
+
method: options.method ?? 'GET',
|
|
37
|
+
headers,
|
|
38
|
+
body,
|
|
39
|
+
});
|
|
40
|
+
const contentType = response.headers.get('content-type') ?? 'unknown';
|
|
41
|
+
const payload = await response.text();
|
|
42
|
+
note([`Status: ${response.status} ${response.statusText}`, `Content-Type: ${contentType}`].join('\n'), 'Response');
|
|
43
|
+
if (contentType.includes('application/json') && payload.length > 0) {
|
|
44
|
+
try {
|
|
45
|
+
log.message(JSON.stringify(JSON.parse(payload), null, 2));
|
|
46
|
+
outro('Request completed');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
catch { }
|
|
50
|
+
}
|
|
51
|
+
if (payload.length > 0) {
|
|
52
|
+
log.message(payload);
|
|
53
|
+
}
|
|
54
|
+
outro('Request completed');
|
|
55
|
+
}
|
|
56
|
+
function collectValues(value, previous) {
|
|
57
|
+
previous.push(value);
|
|
58
|
+
return previous;
|
|
59
|
+
}
|
|
60
|
+
function parseHeaders(values) {
|
|
61
|
+
const headers = new Headers();
|
|
62
|
+
for (const value of values) {
|
|
63
|
+
const separatorIndex = value.indexOf(':');
|
|
64
|
+
if (separatorIndex <= 0) {
|
|
65
|
+
throw new Error(`Invalid header format: ${value}. Use key:value.`);
|
|
66
|
+
}
|
|
67
|
+
const key = value.slice(0, separatorIndex).trim();
|
|
68
|
+
const headerValue = value.slice(separatorIndex + 1).trim();
|
|
69
|
+
if (!key) {
|
|
70
|
+
throw new Error(`Invalid header name: ${value}`);
|
|
71
|
+
}
|
|
72
|
+
headers.set(key, headerValue);
|
|
73
|
+
}
|
|
74
|
+
return headers;
|
|
75
|
+
}
|
|
76
|
+
function hasHeader(headers, name) {
|
|
77
|
+
return headers.has(name) || headers.has(name.toLowerCase());
|
|
78
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createAuthCommand = createAuthCommand;
|
|
4
|
+
const prompts_1 = require("@clack/prompts");
|
|
5
|
+
const commander_1 = require("commander");
|
|
6
|
+
const config_1 = require("../lib/config.cjs");
|
|
7
|
+
const loopback_server_1 = require("../lib/loopback-server.cjs");
|
|
8
|
+
const oauth_1 = require("../lib/oauth.cjs");
|
|
9
|
+
const oauth_api_1 = require("../lib/oauth-api.cjs");
|
|
10
|
+
const oauth_session_1 = require("../lib/oauth-session.cjs");
|
|
11
|
+
const open_url_1 = require("../lib/open-url.cjs");
|
|
12
|
+
const session_1 = require("../lib/session.cjs");
|
|
13
|
+
const DEFAULT_OIDC_PROMPT = 'consent';
|
|
14
|
+
function createAuthCommand() {
|
|
15
|
+
const auth = new commander_1.Command('auth').description('Authenticate with Parix');
|
|
16
|
+
auth
|
|
17
|
+
.command('login')
|
|
18
|
+
.description('Sign in with OAuth consent and PKCE')
|
|
19
|
+
.option('-b, --base-url <url>', 'Parix base URL')
|
|
20
|
+
.option('-p, --port <port>', 'Loopback callback port', config_1.parsePortOption)
|
|
21
|
+
.option('--prompt <prompt>', 'OAuth prompt override', DEFAULT_OIDC_PROMPT)
|
|
22
|
+
.action(async (options) => {
|
|
23
|
+
await handleLogin(options);
|
|
24
|
+
});
|
|
25
|
+
auth
|
|
26
|
+
.command('status')
|
|
27
|
+
.description('Show the current authenticated session')
|
|
28
|
+
.option('-b, --base-url <url>', 'Override the stored base URL')
|
|
29
|
+
.action(async (options) => {
|
|
30
|
+
await handleStatus(options);
|
|
31
|
+
});
|
|
32
|
+
auth
|
|
33
|
+
.command('logout')
|
|
34
|
+
.description('Remove the local session file')
|
|
35
|
+
.action(async () => {
|
|
36
|
+
await (0, session_1.clearStoredSession)();
|
|
37
|
+
(0, prompts_1.outro)(`Removed local session at ${session_1.SESSION_FILE_PATH}`);
|
|
38
|
+
});
|
|
39
|
+
return auth;
|
|
40
|
+
}
|
|
41
|
+
async function handleLogin(options) {
|
|
42
|
+
(0, prompts_1.intro)('Parix sign in');
|
|
43
|
+
const storedSession = await (0, session_1.readStoredSession)();
|
|
44
|
+
const baseUrl = (0, config_1.resolveBaseUrl)(options.baseUrl, storedSession, { useStoredSession: false });
|
|
45
|
+
const loopbackServer = await (0, loopback_server_1.startLoopbackServer)({
|
|
46
|
+
callbackPath: oauth_1.PARIX_LOCAL_CALLBACK_PATH,
|
|
47
|
+
port: options.port,
|
|
48
|
+
successPage: {
|
|
49
|
+
redirectUrl: new URL(oauth_1.CLI_OAUTH_CONSENT_GRANTED_PATH, baseUrl).toString(),
|
|
50
|
+
},
|
|
51
|
+
timeoutMs: config_1.LOGIN_TIMEOUT_MS,
|
|
52
|
+
});
|
|
53
|
+
try {
|
|
54
|
+
const pkcePair = (0, oauth_1.createPkcePair)();
|
|
55
|
+
const state = (0, oauth_1.buildCliState)(loopbackServer.callbackUrl);
|
|
56
|
+
loopbackServer.setState(state);
|
|
57
|
+
const browserUrl = (0, oauth_1.buildAuthorizeUrl)({
|
|
58
|
+
baseUrl,
|
|
59
|
+
codeChallenge: pkcePair.codeChallenge,
|
|
60
|
+
prompt: options.prompt,
|
|
61
|
+
state,
|
|
62
|
+
});
|
|
63
|
+
const prepareSpinner = (0, prompts_1.spinner)();
|
|
64
|
+
prepareSpinner.start('Preparing browser sign-in');
|
|
65
|
+
prepareSpinner.stop('Browser sign-in ready');
|
|
66
|
+
const opened = await (0, open_url_1.openUrlInBrowser)(browserUrl);
|
|
67
|
+
(0, prompts_1.note)(opened ? browserUrl : `Open this URL manually:\n${browserUrl}`, opened ? 'Opened browser URL' : 'Manual browser URL');
|
|
68
|
+
const waitSpinner = (0, prompts_1.spinner)();
|
|
69
|
+
waitSpinner.start('Waiting for browser callback');
|
|
70
|
+
const callbackResult = await loopbackServer.waitForResult();
|
|
71
|
+
waitSpinner.stop('Received browser callback');
|
|
72
|
+
const tokenSet = await (0, oauth_api_1.exchangeAuthorizationCode)({
|
|
73
|
+
baseUrl,
|
|
74
|
+
code: callbackResult.code,
|
|
75
|
+
codeVerifier: pkcePair.codeVerifier,
|
|
76
|
+
});
|
|
77
|
+
const nextSession = await (0, oauth_session_1.createStoredSession)({
|
|
78
|
+
baseUrl,
|
|
79
|
+
createdAt: storedSession?.createdAt,
|
|
80
|
+
tokenSet,
|
|
81
|
+
});
|
|
82
|
+
await (0, session_1.writeStoredSession)(nextSession);
|
|
83
|
+
(0, prompts_1.note)(session_1.SESSION_FILE_PATH, 'Stored session file');
|
|
84
|
+
(0, prompts_1.outro)(`Signed in as ${formatUserLabel(nextSession.user)}`);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
await loopbackServer.close().catch(() => { });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function handleStatus(options) {
|
|
91
|
+
(0, prompts_1.intro)('Parix auth status');
|
|
92
|
+
const storedSession = await (0, session_1.readStoredSession)();
|
|
93
|
+
if (!storedSession) {
|
|
94
|
+
prompts_1.log.warn(`No local session found at ${session_1.SESSION_FILE_PATH}`);
|
|
95
|
+
process.exitCode = 1;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const baseUrl = (0, config_1.resolveBaseUrl)(options.baseUrl, storedSession);
|
|
99
|
+
try {
|
|
100
|
+
const refreshedSession = await (0, oauth_session_1.hydrateStoredSessionOrganization)(await (0, oauth_session_1.ensureFreshSession)({
|
|
101
|
+
...storedSession,
|
|
102
|
+
baseUrl,
|
|
103
|
+
}));
|
|
104
|
+
const userInfo = await (0, oauth_api_1.fetchOAuthUserInfo)({
|
|
105
|
+
accessToken: refreshedSession.accessToken,
|
|
106
|
+
baseUrl,
|
|
107
|
+
});
|
|
108
|
+
(0, prompts_1.note)([
|
|
109
|
+
`User: ${userInfo.email ?? formatUserLabel(refreshedSession.user)}`,
|
|
110
|
+
`Base URL: ${baseUrl}`,
|
|
111
|
+
`Expires: ${refreshedSession.accessTokenExpiresAt}`,
|
|
112
|
+
`Scopes: ${refreshedSession.scopes.join(', ')}`,
|
|
113
|
+
`Organization: ${refreshedSession.organization.name ?? 'none'}`,
|
|
114
|
+
`Organization slug: ${refreshedSession.organization.slug ?? 'none'}`,
|
|
115
|
+
`Member role: ${refreshedSession.organization.memberRole ?? 'none'}`,
|
|
116
|
+
`Session file: ${session_1.SESSION_FILE_PATH}`,
|
|
117
|
+
].join('\n'), 'Authenticated session');
|
|
118
|
+
(0, prompts_1.outro)('Session is valid');
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
122
|
+
prompts_1.log.warn(`Stored session is invalid: ${message}`);
|
|
123
|
+
process.exitCode = 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function formatUserLabel(user) {
|
|
127
|
+
return user.email ?? user.name ?? user.id;
|
|
128
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { intro, log, note, outro, spinner } from '@clack/prompts';
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { LOGIN_TIMEOUT_MS, parsePortOption, resolveBaseUrl } from "../lib/config.js";
|
|
4
|
+
import { startLoopbackServer } from "../lib/loopback-server.js";
|
|
5
|
+
import { buildAuthorizeUrl, buildCliState, CLI_OAUTH_CONSENT_GRANTED_PATH, createPkcePair, PARIX_LOCAL_CALLBACK_PATH, } from "../lib/oauth.js";
|
|
6
|
+
import { exchangeAuthorizationCode, fetchOAuthUserInfo } from "../lib/oauth-api.js";
|
|
7
|
+
import { createStoredSession, ensureFreshSession, hydrateStoredSessionOrganization } from "../lib/oauth-session.js";
|
|
8
|
+
import { openUrlInBrowser } from "../lib/open-url.js";
|
|
9
|
+
import { clearStoredSession, readStoredSession, SESSION_FILE_PATH, writeStoredSession } from "../lib/session.js";
|
|
10
|
+
const DEFAULT_OIDC_PROMPT = 'consent';
|
|
11
|
+
export function createAuthCommand() {
|
|
12
|
+
const auth = new Command('auth').description('Authenticate with Parix');
|
|
13
|
+
auth
|
|
14
|
+
.command('login')
|
|
15
|
+
.description('Sign in with OAuth consent and PKCE')
|
|
16
|
+
.option('-b, --base-url <url>', 'Parix base URL')
|
|
17
|
+
.option('-p, --port <port>', 'Loopback callback port', parsePortOption)
|
|
18
|
+
.option('--prompt <prompt>', 'OAuth prompt override', DEFAULT_OIDC_PROMPT)
|
|
19
|
+
.action(async (options) => {
|
|
20
|
+
await handleLogin(options);
|
|
21
|
+
});
|
|
22
|
+
auth
|
|
23
|
+
.command('status')
|
|
24
|
+
.description('Show the current authenticated session')
|
|
25
|
+
.option('-b, --base-url <url>', 'Override the stored base URL')
|
|
26
|
+
.action(async (options) => {
|
|
27
|
+
await handleStatus(options);
|
|
28
|
+
});
|
|
29
|
+
auth
|
|
30
|
+
.command('logout')
|
|
31
|
+
.description('Remove the local session file')
|
|
32
|
+
.action(async () => {
|
|
33
|
+
await clearStoredSession();
|
|
34
|
+
outro(`Removed local session at ${SESSION_FILE_PATH}`);
|
|
35
|
+
});
|
|
36
|
+
return auth;
|
|
37
|
+
}
|
|
38
|
+
async function handleLogin(options) {
|
|
39
|
+
intro('Parix sign in');
|
|
40
|
+
const storedSession = await readStoredSession();
|
|
41
|
+
const baseUrl = resolveBaseUrl(options.baseUrl, storedSession, { useStoredSession: false });
|
|
42
|
+
const loopbackServer = await startLoopbackServer({
|
|
43
|
+
callbackPath: PARIX_LOCAL_CALLBACK_PATH,
|
|
44
|
+
port: options.port,
|
|
45
|
+
successPage: {
|
|
46
|
+
redirectUrl: new URL(CLI_OAUTH_CONSENT_GRANTED_PATH, baseUrl).toString(),
|
|
47
|
+
},
|
|
48
|
+
timeoutMs: LOGIN_TIMEOUT_MS,
|
|
49
|
+
});
|
|
50
|
+
try {
|
|
51
|
+
const pkcePair = createPkcePair();
|
|
52
|
+
const state = buildCliState(loopbackServer.callbackUrl);
|
|
53
|
+
loopbackServer.setState(state);
|
|
54
|
+
const browserUrl = buildAuthorizeUrl({
|
|
55
|
+
baseUrl,
|
|
56
|
+
codeChallenge: pkcePair.codeChallenge,
|
|
57
|
+
prompt: options.prompt,
|
|
58
|
+
state,
|
|
59
|
+
});
|
|
60
|
+
const prepareSpinner = spinner();
|
|
61
|
+
prepareSpinner.start('Preparing browser sign-in');
|
|
62
|
+
prepareSpinner.stop('Browser sign-in ready');
|
|
63
|
+
const opened = await openUrlInBrowser(browserUrl);
|
|
64
|
+
note(opened ? browserUrl : `Open this URL manually:\n${browserUrl}`, opened ? 'Opened browser URL' : 'Manual browser URL');
|
|
65
|
+
const waitSpinner = spinner();
|
|
66
|
+
waitSpinner.start('Waiting for browser callback');
|
|
67
|
+
const callbackResult = await loopbackServer.waitForResult();
|
|
68
|
+
waitSpinner.stop('Received browser callback');
|
|
69
|
+
const tokenSet = await exchangeAuthorizationCode({
|
|
70
|
+
baseUrl,
|
|
71
|
+
code: callbackResult.code,
|
|
72
|
+
codeVerifier: pkcePair.codeVerifier,
|
|
73
|
+
});
|
|
74
|
+
const nextSession = await createStoredSession({
|
|
75
|
+
baseUrl,
|
|
76
|
+
createdAt: storedSession?.createdAt,
|
|
77
|
+
tokenSet,
|
|
78
|
+
});
|
|
79
|
+
await writeStoredSession(nextSession);
|
|
80
|
+
note(SESSION_FILE_PATH, 'Stored session file');
|
|
81
|
+
outro(`Signed in as ${formatUserLabel(nextSession.user)}`);
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
await loopbackServer.close().catch(() => { });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function handleStatus(options) {
|
|
88
|
+
intro('Parix auth status');
|
|
89
|
+
const storedSession = await readStoredSession();
|
|
90
|
+
if (!storedSession) {
|
|
91
|
+
log.warn(`No local session found at ${SESSION_FILE_PATH}`);
|
|
92
|
+
process.exitCode = 1;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const baseUrl = resolveBaseUrl(options.baseUrl, storedSession);
|
|
96
|
+
try {
|
|
97
|
+
const refreshedSession = await hydrateStoredSessionOrganization(await ensureFreshSession({
|
|
98
|
+
...storedSession,
|
|
99
|
+
baseUrl,
|
|
100
|
+
}));
|
|
101
|
+
const userInfo = await fetchOAuthUserInfo({
|
|
102
|
+
accessToken: refreshedSession.accessToken,
|
|
103
|
+
baseUrl,
|
|
104
|
+
});
|
|
105
|
+
note([
|
|
106
|
+
`User: ${userInfo.email ?? formatUserLabel(refreshedSession.user)}`,
|
|
107
|
+
`Base URL: ${baseUrl}`,
|
|
108
|
+
`Expires: ${refreshedSession.accessTokenExpiresAt}`,
|
|
109
|
+
`Scopes: ${refreshedSession.scopes.join(', ')}`,
|
|
110
|
+
`Organization: ${refreshedSession.organization.name ?? 'none'}`,
|
|
111
|
+
`Organization slug: ${refreshedSession.organization.slug ?? 'none'}`,
|
|
112
|
+
`Member role: ${refreshedSession.organization.memberRole ?? 'none'}`,
|
|
113
|
+
`Session file: ${SESSION_FILE_PATH}`,
|
|
114
|
+
].join('\n'), 'Authenticated session');
|
|
115
|
+
outro('Session is valid');
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
119
|
+
log.warn(`Stored session is invalid: ${message}`);
|
|
120
|
+
process.exitCode = 1;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function formatUserLabel(user) {
|
|
124
|
+
return user.email ?? user.name ?? user.id;
|
|
125
|
+
}
|