rakomi 0.0.1 → 0.1.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/LICENSE +21 -0
- package/README.md +76 -14
- package/dist/browser.js +15 -0
- package/dist/clients.js +24 -0
- package/dist/commands/connect.js +99 -0
- package/dist/commands/login.js +70 -0
- package/dist/commands/logout.js +4 -0
- package/dist/commands/tenants.js +54 -0
- package/dist/commands/whoami.js +18 -0
- package/dist/connect-client.js +32 -0
- package/dist/env.js +23 -0
- package/dist/errors.js +33 -0
- package/dist/http.js +70 -0
- package/dist/index.js +198 -0
- package/dist/loopback-server.js +63 -0
- package/dist/mcp-config.js +76 -0
- package/dist/oauth-authcode.js +30 -0
- package/dist/oauth-device.js +57 -0
- package/dist/pkce.js +22 -0
- package/dist/session.js +320 -0
- package/dist/tenants-client.js +64 -0
- package/dist/usage.js +38 -0
- package/dist/userinfo-client.js +16 -0
- package/package.json +34 -18
- package/bin/rakomi.js +0 -12
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CRE8EVE Sp. z o.o.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,24 +1,86 @@
|
|
|
1
1
|
# rakomi
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
> the `rakomi` name on the public npm registry.
|
|
3
|
+
The [Rakomi](https://rakomi.com) CLI. EU-native authentication as a service.
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
CLI will be installable globally:
|
|
5
|
+
## Getting started
|
|
8
6
|
|
|
9
|
-
```
|
|
10
|
-
|
|
7
|
+
```sh
|
|
8
|
+
npx rakomi login
|
|
11
9
|
```
|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
Signs you in with your browser (loopback OAuth + PKCE). Add `--no-browser` on a headless/SSH
|
|
12
|
+
session to get a short code to enter on another device instead.
|
|
14
13
|
|
|
15
|
-
##
|
|
14
|
+
## Connect an AI agent
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
```sh
|
|
17
|
+
npx rakomi connect
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Detects Claude Code and writes the `.mcp.json` entry for you, or prints Claude Desktop's
|
|
21
|
+
Custom-Connectors instructions when run with `--client claude-desktop`. Read access works
|
|
22
|
+
immediately once the client finishes its own sign-in; write access is a separate step your
|
|
23
|
+
tenant owner approves — see the [Connect an AI Agent guide](https://docs.rakomi.dev/guides/connect-an-ai-agent).
|
|
24
|
+
|
|
25
|
+
Pass `--cimd-url <url>` (find it in your MCP client's own connection diagnostics) and `rakomi`
|
|
26
|
+
confirms the connection for you instead of leaving you to check the dashboard; add `--write` to
|
|
27
|
+
also request owner-granted write access once it's found. `--status --cimd-url <url>` re-checks
|
|
28
|
+
later without rewriting `.mcp.json` — the resume path when you weren't ready to approve yet.
|
|
29
|
+
|
|
30
|
+
## Provision a tenant
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
npx rakomi tenants create "Acme staging"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Creates a new tenant with you as the owner by default (`--owner me`) — no invitation round trip, and
|
|
37
|
+
the response includes a freshly minted API key (shown once — save it). Pass `--owner <email>` to hand
|
|
38
|
+
ownership to someone else instead; that requires a partner API credential (a `client_credentials`
|
|
39
|
+
token), sends a real invitation e-mail, and is refused under `--ci` — see
|
|
40
|
+
[Provision Tenants](https://docs.rakomi.dev/guides/provision-tenants) for both flows end to end.
|
|
41
|
+
|
|
42
|
+
## Commands
|
|
43
|
+
|
|
44
|
+
| Command | Description |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| `login` | sign in (browser by default; `--no-browser` for a device code) |
|
|
47
|
+
| `logout` | clear the local session |
|
|
48
|
+
| `whoami` | show the signed-in account |
|
|
49
|
+
| `connect` | connect Claude Code / Claude Desktop to your tenant (read access) |
|
|
50
|
+
| `tenants create <name>` | create a tenant (`--owner me\|<email>`, `--slug <slug>`) |
|
|
51
|
+
| `tenants list` | list tenants you provisioned |
|
|
52
|
+
|
|
53
|
+
## Global options
|
|
54
|
+
|
|
55
|
+
| Option | Meaning |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| `--json` | machine-readable output |
|
|
58
|
+
| `--yes`, `--ci` | never prompt; fail fast on any step needing a human |
|
|
59
|
+
| `--dry-run` | print what would happen, make no writes or mutating calls |
|
|
60
|
+
| `--no-browser` | use the device-code flow instead of opening a browser |
|
|
61
|
+
| `--no-keychain` | store the session in a `0600` file instead of the OS keychain |
|
|
62
|
+
| `--client <name>` | `claude-code` or `claude-desktop` — required when more than one applies |
|
|
63
|
+
| `--undo` | restore the `.mcp.json` `connect` last backed up |
|
|
64
|
+
| `--cimd-url <url>` | (`connect`) the connecting client's own CIMD document URL — confirms/re-checks the connection |
|
|
65
|
+
| `--status` | (`connect`) re-check status only — needs `--cimd-url`, never rewrites `.mcp.json` |
|
|
66
|
+
| `-h`, `--help` | show help |
|
|
67
|
+
| `-V`, `--version` | print the version |
|
|
68
|
+
|
|
69
|
+
## Caveats
|
|
70
|
+
|
|
71
|
+
- **Your session lives in your OS keychain** — macOS Keychain, the Linux Secret Service (via
|
|
72
|
+
`secret-tool`), or a Data-Protection-API-encrypted file on Windows — whichever is available.
|
|
73
|
+
`rakomi whoami` names the exact backend in use. When none is available (or you pass
|
|
74
|
+
`--no-keychain`, or `CI` is set), the CLI falls back to a `0600` file under
|
|
75
|
+
`~/.config/rakomi/session.json` (or `RAKOMI_CONFIG_DIR` if set) and prints a one-line notice the
|
|
76
|
+
first time it does. An existing `0600` file from an older install is imported into the keychain
|
|
77
|
+
automatically, then removed. `rakomi logout` clears whichever backend is in use.
|
|
78
|
+
- **The access token is never printed, logged, or included in `--json` output.**
|
|
79
|
+
- **No telemetry.** The CLI collects nothing and phones home about nothing beyond the Rakomi API
|
|
80
|
+
calls a command actually needs to make.
|
|
81
|
+
- **`--owner <email>` sends a real invitation e-mail** — it always requires interactive
|
|
82
|
+
confirmation and is refused under `--ci`.
|
|
21
83
|
|
|
22
|
-
##
|
|
84
|
+
## License
|
|
23
85
|
|
|
24
|
-
CRE8EVE
|
|
86
|
+
[MIT](./LICENSE) © CRE8EVE Sp. z o.o.
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
export function systemBrowserOpener(platform = process.platform) {
|
|
3
|
+
return (url) => new Promise((resolve) => {
|
|
4
|
+
const done = () => resolve();
|
|
5
|
+
if (platform === 'darwin') {
|
|
6
|
+
execFile('open', [url], done);
|
|
7
|
+
}
|
|
8
|
+
else if (platform === 'win32') {
|
|
9
|
+
execFile('cmd', ['/c', 'start', '""', url], done);
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
execFile('xdg-open', [url], done);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
}
|
package/dist/clients.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const KNOWN_CLIENTS = ['claude-code', 'claude-desktop'];
|
|
2
|
+
export function isKnownClient(value) {
|
|
3
|
+
return KNOWN_CLIENTS.includes(value);
|
|
4
|
+
}
|
|
5
|
+
export function clientDisplayName(client) {
|
|
6
|
+
return client === 'claude-code' ? 'Claude Code' : 'Claude Desktop';
|
|
7
|
+
}
|
|
8
|
+
export function resolveTargetClients(opts) {
|
|
9
|
+
if (opts.explicitClient) {
|
|
10
|
+
if (!isKnownClient(opts.explicitClient)) {
|
|
11
|
+
throw new AmbiguousClientError(`Unknown --client "${opts.explicitClient}". Known clients: ${KNOWN_CLIENTS.join(', ')}.`);
|
|
12
|
+
}
|
|
13
|
+
return [opts.explicitClient];
|
|
14
|
+
}
|
|
15
|
+
if (opts.claudeCodeDetected)
|
|
16
|
+
return ['claude-code'];
|
|
17
|
+
throw new AmbiguousClientError(`Could not detect an installed MCP client automatically. Pass --client <${KNOWN_CLIENTS.join('|')}>.`);
|
|
18
|
+
}
|
|
19
|
+
export class AmbiguousClientError extends Error {
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'AmbiguousClientError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { AmbiguousClientError, clientDisplayName, resolveTargetClients } from '../clients.js';
|
|
2
|
+
import { pollCimdMaterialization, requestWriteElevation } from '../connect-client.js';
|
|
3
|
+
import { DEFAULT_MCP_URL } from '../env.js';
|
|
4
|
+
import { InteractiveRequiredError, NotLoggedInError, UsageError } from '../errors.js';
|
|
5
|
+
import { mcpConfigPath, undoMcpConfigWrite, writeRakomiMcpServerEntry } from '../mcp-config.js';
|
|
6
|
+
const CLAUDE_DESKTOP_INSTRUCTIONS = [
|
|
7
|
+
'Claude Desktop connects to remote MCP servers through Connectors, configured from your Claude',
|
|
8
|
+
'account rather than a local file — there is nothing for `rakomi connect` to write.',
|
|
9
|
+
'',
|
|
10
|
+
'1. Open Settings -> Connectors in Claude Desktop and add a custom connector.',
|
|
11
|
+
`2. Enter Rakomi's MCP server URL: ${DEFAULT_MCP_URL}`,
|
|
12
|
+
'3. Click Connect. Claude Desktop opens your browser at accounts.rakomi.com — sign in and',
|
|
13
|
+
' approve the read-only consent screen.',
|
|
14
|
+
].join('\n');
|
|
15
|
+
export async function runConnect(deps) {
|
|
16
|
+
if (deps.undo) {
|
|
17
|
+
const restored = undoMcpConfigWrite(deps.cwd);
|
|
18
|
+
deps.stdout.write(restored
|
|
19
|
+
? `Restored ${mcpConfigPath(deps.cwd)} from the backup \`rakomi connect\` made.\n`
|
|
20
|
+
: `No \`rakomi connect\` backup found at ${mcpConfigPath(deps.cwd)}.rakomi-backup — nothing to undo.\n`);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const session = deps.session.read();
|
|
24
|
+
if (!session)
|
|
25
|
+
throw new NotLoggedInError();
|
|
26
|
+
if (deps.status) {
|
|
27
|
+
await reportStatus(deps, session.access_token);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
let targets;
|
|
31
|
+
try {
|
|
32
|
+
targets = resolveTargetClients({ explicitClient: deps.explicitClient, claudeCodeDetected: deps.detectClaudeCode() });
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
if (e instanceof AmbiguousClientError)
|
|
36
|
+
throw new UsageError(e.message);
|
|
37
|
+
throw e;
|
|
38
|
+
}
|
|
39
|
+
for (const client of targets) {
|
|
40
|
+
if (client === 'claude-desktop') {
|
|
41
|
+
deps.stdout.write(`${clientDisplayName(client)}:\n${CLAUDE_DESKTOP_INSTRUCTIONS}\n`);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
await connectClaudeCode(deps, session.access_token);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function reportStatus(deps, accessToken) {
|
|
48
|
+
if (!deps.cimdUrl) {
|
|
49
|
+
throw new UsageError('`connect --status` needs `--cimd-url <url>` — the CIMD URL `connect` printed when it started waiting.');
|
|
50
|
+
}
|
|
51
|
+
const row = await pollCimdMaterialization(deps, { apiBaseUrl: deps.apiBaseUrl, accessToken, cimdUrl: deps.cimdUrl });
|
|
52
|
+
if (!row) {
|
|
53
|
+
deps.stdout.write('Not connected yet — finish sign-in in your MCP client, then run this command again.\n');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
deps.stdout.write(`Connected — access: ${row.agent_access.level}${row.agent_access.owner_granted ? ' (owner-granted)' : ''}.\n`);
|
|
57
|
+
if (deps.write && row.agent_access.level !== 'write') {
|
|
58
|
+
await requestWrite(deps, accessToken, row.id);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function connectClaudeCode(deps, accessToken) {
|
|
62
|
+
if (deps.dryRun) {
|
|
63
|
+
deps.stdout.write(`Would write ${mcpConfigPath(deps.cwd)} with mcpServers.rakomi -> ${deps.mcpUrl}. No file written (--dry-run).\n`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const result = writeRakomiMcpServerEntry(deps.cwd, deps.mcpUrl);
|
|
67
|
+
if (result.wrote) {
|
|
68
|
+
deps.stdout.write(`Wrote ${mcpConfigPath(deps.cwd)}${result.backedUp ? ' (previous file backed up)' : ''}.\n${result.diff}\n`);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
deps.stdout.write(`${result.diff}\n`);
|
|
72
|
+
}
|
|
73
|
+
deps.stdout.write('Now open Claude Code and run `claude mcp login rakomi` to finish sign-in in your browser.\n' +
|
|
74
|
+
'Once approved, Claude Code has READ access to your tenant immediately.\n');
|
|
75
|
+
if (!deps.cimdUrl) {
|
|
76
|
+
deps.stdout.write('\nOnce connected, Rakomi can confirm it and (with --write) request write access for you — pass ' +
|
|
77
|
+
'`--cimd-url <url>` (find it in your MCP client\'s own connection diagnostics) to use it, or ' +
|
|
78
|
+
'check the Agents page in the dashboard.\n');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const row = await pollCimdMaterialization(deps, { apiBaseUrl: deps.apiBaseUrl, accessToken, cimdUrl: deps.cimdUrl });
|
|
82
|
+
if (!row) {
|
|
83
|
+
deps.stdout.write(`\nNot connected yet. Ctrl+C is safe — resume any time with:\n` +
|
|
84
|
+
` rakomi connect --status --cimd-url ${deps.cimdUrl}\n`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
deps.stdout.write(`\nConnected — access: ${row.agent_access.level}.\n`);
|
|
88
|
+
if (deps.write) {
|
|
89
|
+
if (deps.ci) {
|
|
90
|
+
throw new InteractiveRequiredError('`connect --write` cannot run under --ci — write-access needs a human owner decision.');
|
|
91
|
+
}
|
|
92
|
+
await requestWrite(deps, accessToken, row.id);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async function requestWrite(deps, accessToken, oauthClientId) {
|
|
96
|
+
await requestWriteElevation(deps, { apiBaseUrl: deps.apiBaseUrl, accessToken, oauthClientId });
|
|
97
|
+
deps.stdout.write("Write-access request sent to your tenant's owner(s) — if that's you, you'll approve it yourself " +
|
|
98
|
+
'from the dashboard Agents page. Destructive actions will still ask a human every time.\n');
|
|
99
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { accountsBaseUrl, apiBaseUrl, clientId as resolveClientId, isCimdClientId } from '../env.js';
|
|
2
|
+
import { CliError, EXIT } from '../errors.js';
|
|
3
|
+
import { buildAuthorizeUrl, exchangeAuthorizationCode } from '../oauth-authcode.js';
|
|
4
|
+
import { loginWithDeviceGrant } from '../oauth-device.js';
|
|
5
|
+
import { generatePkcePair } from '../pkce.js';
|
|
6
|
+
export const LOGIN_SCOPE = 'openid profile email clients:read elevation:request';
|
|
7
|
+
const LOOPBACK_TIMEOUT_MS = 5 * 60_000;
|
|
8
|
+
export async function runLogin(deps) {
|
|
9
|
+
const apiUrl = apiBaseUrl(deps.env);
|
|
10
|
+
const cid = resolveClientId(deps.env);
|
|
11
|
+
if (deps.noBrowser && isCimdClientId(cid)) {
|
|
12
|
+
throw new CliError('rakomi login --no-browser needs a client registered for the device-code grant; the ' +
|
|
13
|
+
"default CIMD client_id does not support it. Pass --client <your-client-id> (create one " +
|
|
14
|
+
'in the dashboard) for headless/SSH sign-in, or omit --no-browser to sign in via loopback.', EXIT.FAIL);
|
|
15
|
+
}
|
|
16
|
+
const token = deps.noBrowser
|
|
17
|
+
? await runDeviceGrantLogin(deps, apiUrl, cid)
|
|
18
|
+
: await runLoopbackLogin(deps, apiUrl, cid);
|
|
19
|
+
const session = {
|
|
20
|
+
access_token: token.access_token,
|
|
21
|
+
refresh_token: token.refresh_token,
|
|
22
|
+
token_type: 'Bearer',
|
|
23
|
+
expires_at: deps.now() + token.expires_in * 1000,
|
|
24
|
+
api_base_url: apiUrl,
|
|
25
|
+
client_id: cid,
|
|
26
|
+
};
|
|
27
|
+
deps.session.write(session);
|
|
28
|
+
deps.stdout.write('Logged in to Rakomi.\n');
|
|
29
|
+
}
|
|
30
|
+
async function runLoopbackLogin(deps, apiUrl, cid) {
|
|
31
|
+
const listener = await deps.startLoopback();
|
|
32
|
+
try {
|
|
33
|
+
const pkce = generatePkcePair();
|
|
34
|
+
const authorizeUrl = buildAuthorizeUrl({
|
|
35
|
+
accountsBaseUrl: accountsBaseUrl(deps.env),
|
|
36
|
+
clientId: cid,
|
|
37
|
+
redirectUri: listener.redirectUri,
|
|
38
|
+
pkce,
|
|
39
|
+
scope: LOGIN_SCOPE,
|
|
40
|
+
});
|
|
41
|
+
deps.stdout.write(`Opening your browser to sign in:\n ${authorizeUrl}\n`);
|
|
42
|
+
await deps.openBrowser(authorizeUrl);
|
|
43
|
+
const callback = await listener.waitForCallback(LOOPBACK_TIMEOUT_MS);
|
|
44
|
+
if (callback.error) {
|
|
45
|
+
throw new CliError(`Sign-in failed: ${callback.errorDescription ?? callback.error}`, EXIT.FAIL);
|
|
46
|
+
}
|
|
47
|
+
if (!callback.code || callback.state !== pkce.state) {
|
|
48
|
+
throw new CliError('Sign-in callback was missing the authorization code or had a mismatched state.', EXIT.FAIL);
|
|
49
|
+
}
|
|
50
|
+
return exchangeAuthorizationCode(deps, {
|
|
51
|
+
apiBaseUrl: apiUrl,
|
|
52
|
+
clientId: cid,
|
|
53
|
+
redirectUri: listener.redirectUri,
|
|
54
|
+
code: callback.code,
|
|
55
|
+
codeVerifier: pkce.codeVerifier,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
listener.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function runDeviceGrantLogin(deps, apiUrl, cid) {
|
|
63
|
+
return loginWithDeviceGrant({
|
|
64
|
+
...deps,
|
|
65
|
+
onWaiting: (issued) => {
|
|
66
|
+
deps.stdout.write(`To sign in, open ${issued.verification_uri} and enter the code ${issued.user_code}\n` +
|
|
67
|
+
`(or open ${issued.verification_uri_complete} directly).\n`);
|
|
68
|
+
},
|
|
69
|
+
}, { apiBaseUrl: apiUrl, clientId: cid, scope: LOGIN_SCOPE });
|
|
70
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { InteractiveRequiredError, NotLoggedInError, UsageError } from '../errors.js';
|
|
2
|
+
import { createTenant, listTenants } from '../tenants-client.js';
|
|
3
|
+
export async function runTenantsCreate(deps, args) {
|
|
4
|
+
const session = deps.session.read();
|
|
5
|
+
if (!session)
|
|
6
|
+
throw new NotLoggedInError();
|
|
7
|
+
const ownerIsSelf = args.owner === 'me';
|
|
8
|
+
if (!ownerIsSelf && !args.owner.includes('@')) {
|
|
9
|
+
throw new UsageError(`--owner must be "me" or an e-mail address, got "${args.owner}"`);
|
|
10
|
+
}
|
|
11
|
+
if (!ownerIsSelf && deps.ci) {
|
|
12
|
+
throw new InteractiveRequiredError('`tenants create --owner <email>` (a third-party owner) requires interactive confirmation; use --owner me under --ci.');
|
|
13
|
+
}
|
|
14
|
+
if (deps.dryRun) {
|
|
15
|
+
deps.stdout.write(ownerIsSelf
|
|
16
|
+
? `Would create tenant "${args.name}"${args.slug ? ` (slug: ${args.slug})` : ''} with yourself as owner (self-owner — mints an API key). No request sent (--dry-run).\n`
|
|
17
|
+
: `Would create tenant "${args.name}"${args.slug ? ` (slug: ${args.slug})` : ''} with owner ${args.owner}. No request sent (--dry-run).\n`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const result = await createTenant(deps, {
|
|
21
|
+
apiBaseUrl: session.api_base_url,
|
|
22
|
+
accessToken: session.access_token,
|
|
23
|
+
name: args.name,
|
|
24
|
+
slug: args.slug,
|
|
25
|
+
ownerEmail: ownerIsSelf ? undefined : args.owner,
|
|
26
|
+
});
|
|
27
|
+
if (deps.json) {
|
|
28
|
+
deps.stdout.write(JSON.stringify(result) + '\n');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const lines = [`Created tenant "${result.tenant.slug}" (${result.tenant.id}) — status: ${result.tenant.status}`];
|
|
32
|
+
if (result.api_key) {
|
|
33
|
+
lines.push(`You are the owner. API key (shown ONLY this once — save it now): ${result.api_key.key}`);
|
|
34
|
+
}
|
|
35
|
+
else if (result.owner_invitation) {
|
|
36
|
+
lines.push(`An owner invitation was sent to ${args.owner} (expires ${result.owner_invitation.expires_at}).`);
|
|
37
|
+
}
|
|
38
|
+
deps.stdout.write(lines.join('\n') + '\n');
|
|
39
|
+
}
|
|
40
|
+
export async function runTenantsList(deps) {
|
|
41
|
+
const session = deps.session.read();
|
|
42
|
+
if (!session)
|
|
43
|
+
throw new NotLoggedInError();
|
|
44
|
+
const result = await listTenants(deps, { apiBaseUrl: session.api_base_url, accessToken: session.access_token, parent: 'me' });
|
|
45
|
+
if (deps.json) {
|
|
46
|
+
deps.stdout.write(JSON.stringify(result) + '\n');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (result.data.length === 0) {
|
|
50
|
+
deps.stdout.write('No provisioned tenants.\n');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
deps.stdout.write(result.data.map((t) => `${t.slug}\t${t.id}\t${t.status}`).join('\n') + '\n');
|
|
54
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { NotLoggedInError } from '../errors.js';
|
|
2
|
+
import { fetchUserInfo } from '../userinfo-client.js';
|
|
3
|
+
export async function runWhoami(deps) {
|
|
4
|
+
const session = deps.session.read();
|
|
5
|
+
if (!session)
|
|
6
|
+
throw new NotLoggedInError();
|
|
7
|
+
const info = await fetchUserInfo(deps, { apiBaseUrl: session.api_base_url, accessToken: session.access_token });
|
|
8
|
+
if (deps.json) {
|
|
9
|
+
deps.stdout.write(JSON.stringify({ sub: info.sub, email: info.email, org_id: info.org_id, org_role: info.org_role, session_store: deps.session.describePath() }) + '\n');
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const lines = [`Signed in as: ${info.email ?? info.sub}`];
|
|
13
|
+
if (info.org_id)
|
|
14
|
+
lines.push(`Organization: ${info.org_id}${info.org_role ? ` (${info.org_role})` : ''}`);
|
|
15
|
+
lines.push(`API: ${session.api_base_url}`);
|
|
16
|
+
lines.push(`Session stored in: ${deps.session.describePath()}`);
|
|
17
|
+
deps.stdout.write(lines.join('\n') + '\n');
|
|
18
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { CliError, EXIT } from './errors.js';
|
|
2
|
+
import { describeError, request } from './http.js';
|
|
3
|
+
export async function pollCimdMaterialization(deps, opts) {
|
|
4
|
+
const url = new URL('/v1/oauth-clients', opts.apiBaseUrl);
|
|
5
|
+
url.searchParams.set('cimd_url', opts.cimdUrl);
|
|
6
|
+
const result = await request(deps, {
|
|
7
|
+
method: 'GET',
|
|
8
|
+
url: url.toString(),
|
|
9
|
+
headers: { authorization: `Bearer ${opts.accessToken}` },
|
|
10
|
+
});
|
|
11
|
+
if (result.status === 401) {
|
|
12
|
+
throw new CliError('Your session has expired. Run `rakomi login` again.', EXIT.NOT_LOGGED_IN);
|
|
13
|
+
}
|
|
14
|
+
if (result.status !== 200) {
|
|
15
|
+
throw new CliError(`Could not check connection status: ${describeError(result.body, result.status)}`, EXIT.FAIL);
|
|
16
|
+
}
|
|
17
|
+
return result.body.data[0] ?? null;
|
|
18
|
+
}
|
|
19
|
+
export async function requestWriteElevation(deps, opts) {
|
|
20
|
+
const result = await request(deps, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
url: `${opts.apiBaseUrl}/v1/oauth-clients/${encodeURIComponent(opts.oauthClientId)}/elevation-requests`,
|
|
23
|
+
headers: { authorization: `Bearer ${opts.accessToken}` },
|
|
24
|
+
body: { scopes: ['mcp:tools:write'] },
|
|
25
|
+
});
|
|
26
|
+
if (result.status === 401) {
|
|
27
|
+
throw new CliError('Your session has expired. Run `rakomi login` again.', EXIT.NOT_LOGGED_IN);
|
|
28
|
+
}
|
|
29
|
+
if (result.status !== 201 && result.status !== 200) {
|
|
30
|
+
throw new CliError(`Could not request write access: ${describeError(result.body, result.status)}`, EXIT.FAIL);
|
|
31
|
+
}
|
|
32
|
+
}
|
package/dist/env.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const DEFAULT_API_BASE_URL = 'https://api.rakomi.com';
|
|
2
|
+
export const DEFAULT_ACCOUNTS_BASE_URL = 'https://accounts.rakomi.com';
|
|
3
|
+
export const DEFAULT_MCP_URL = 'https://mcp.rakomi.com/mcp';
|
|
4
|
+
export const DEFAULT_CIMD_CLIENT_ID_URL = 'https://api.rakomi.com/.well-known/rakomi-cli/client-metadata.json';
|
|
5
|
+
export function isCimdClientId(id) {
|
|
6
|
+
let url;
|
|
7
|
+
try {
|
|
8
|
+
url = new URL(id);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
return url.protocol === 'https:' && url.username === '' && url.password === '' && url.hash === '';
|
|
14
|
+
}
|
|
15
|
+
export function apiBaseUrl(env) {
|
|
16
|
+
return env.RAKOMI_API_URL || DEFAULT_API_BASE_URL;
|
|
17
|
+
}
|
|
18
|
+
export function accountsBaseUrl(env) {
|
|
19
|
+
return env.RAKOMI_ACCOUNTS_URL || DEFAULT_ACCOUNTS_BASE_URL;
|
|
20
|
+
}
|
|
21
|
+
export function clientId(env) {
|
|
22
|
+
return env.RAKOMI_CLIENT_ID || DEFAULT_CIMD_CLIENT_ID_URL;
|
|
23
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export const EXIT = {
|
|
2
|
+
OK: 0,
|
|
3
|
+
FAIL: 1,
|
|
4
|
+
USAGE: 2,
|
|
5
|
+
NOT_LOGGED_IN: 3,
|
|
6
|
+
INTERACTIVE_REQUIRED: 4,
|
|
7
|
+
};
|
|
8
|
+
export class CliError extends Error {
|
|
9
|
+
exitCode;
|
|
10
|
+
constructor(message, exitCode = EXIT.FAIL) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'CliError';
|
|
13
|
+
this.exitCode = exitCode;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class UsageError extends CliError {
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message, EXIT.USAGE);
|
|
19
|
+
this.name = 'UsageError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export class NotLoggedInError extends CliError {
|
|
23
|
+
constructor(message = 'Not logged in. Run `rakomi login` first.') {
|
|
24
|
+
super(message, EXIT.NOT_LOGGED_IN);
|
|
25
|
+
this.name = 'NotLoggedInError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export class InteractiveRequiredError extends CliError {
|
|
29
|
+
constructor(message) {
|
|
30
|
+
super(message, EXIT.INTERACTIVE_REQUIRED);
|
|
31
|
+
this.name = 'InteractiveRequiredError';
|
|
32
|
+
}
|
|
33
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { CliError, EXIT } from './errors.js';
|
|
2
|
+
export const DEFAULT_TIMEOUT_MS = 15_000;
|
|
3
|
+
function isErrorEnvelope(v) {
|
|
4
|
+
return typeof v === 'object' && v !== null && 'error' in v;
|
|
5
|
+
}
|
|
6
|
+
function isProblemDetails(v) {
|
|
7
|
+
return typeof v === 'object' && v !== null && !('error' in v) && ('code' in v || 'detail' in v || 'title' in v);
|
|
8
|
+
}
|
|
9
|
+
export function describeError(body, status) {
|
|
10
|
+
if (isErrorEnvelope(body)) {
|
|
11
|
+
if (typeof body.error === 'string')
|
|
12
|
+
return body.error_description || body.error;
|
|
13
|
+
if (typeof body.error === 'object')
|
|
14
|
+
return body.error.message || body.error.code;
|
|
15
|
+
}
|
|
16
|
+
if (isProblemDetails(body)) {
|
|
17
|
+
return body.detail || body.title || `Request failed with HTTP ${status}`;
|
|
18
|
+
}
|
|
19
|
+
return `Request failed with HTTP ${status}`;
|
|
20
|
+
}
|
|
21
|
+
export function errorCode(body) {
|
|
22
|
+
if (isErrorEnvelope(body) && typeof body.error === 'object')
|
|
23
|
+
return body.error.code;
|
|
24
|
+
if (isProblemDetails(body))
|
|
25
|
+
return body.code;
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
export async function request(deps, req) {
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
31
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
32
|
+
try {
|
|
33
|
+
const headers = { accept: 'application/json', ...req.headers };
|
|
34
|
+
let body;
|
|
35
|
+
if (req.form) {
|
|
36
|
+
headers['content-type'] = 'application/x-www-form-urlencoded';
|
|
37
|
+
body = new URLSearchParams(req.form).toString();
|
|
38
|
+
}
|
|
39
|
+
else if (req.body !== undefined) {
|
|
40
|
+
headers['content-type'] = 'application/json';
|
|
41
|
+
body = JSON.stringify(req.body);
|
|
42
|
+
}
|
|
43
|
+
if (req.idempotencyKey)
|
|
44
|
+
headers['idempotency-key'] = req.idempotencyKey;
|
|
45
|
+
let res;
|
|
46
|
+
try {
|
|
47
|
+
res = await deps.fetchImpl(req.url, { method: req.method, headers, body, signal: controller.signal });
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
if (controller.signal.aborted) {
|
|
51
|
+
throw new CliError('Request timed out talking to the Rakomi API.', EXIT.FAIL);
|
|
52
|
+
}
|
|
53
|
+
throw new CliError('Could not reach the Rakomi API. Check your network connection.', EXIT.FAIL);
|
|
54
|
+
}
|
|
55
|
+
const text = await res.text();
|
|
56
|
+
let parsed = {};
|
|
57
|
+
if (text.length > 0) {
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(text);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new CliError('The Rakomi API returned a response the CLI could not understand.', EXIT.FAIL);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { status: res.status, body: parsed, headers: res.headers };
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
}
|
|
70
|
+
}
|