rakomi 0.3.0 → 0.3.1
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 +23 -9
- package/SECURITY.md +2 -2
- package/dist/billing-client.js +19 -0
- package/dist/client-registry.generated.js +156 -0
- package/dist/clients.js +10 -2
- package/dist/commands/connect.js +85 -29
- package/dist/commands/login.js +1 -0
- package/dist/commands/upgrade.js +21 -0
- package/dist/commands/use.js +1 -1
- package/dist/commands/whoami.js +25 -5
- package/dist/http.js +58 -1
- package/dist/index.js +32 -8
- package/dist/loopback-server.js +85 -6
- package/dist/mcp-config.js +52 -29
- package/dist/server-name.js +15 -0
- package/dist/tenant-config.js +15 -4
- package/dist/token-refresh.js +56 -0
- package/dist/toml-config.js +96 -0
- package/dist/usage.js +9 -5
- package/package.json +2 -2
- package/sbom.cdx.json +3 -3
package/README.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
The [Rakomi](https://rakomi.com) CLI. EU-native authentication as a service.
|
|
4
4
|
|
|
5
|
+
## Ask your agent
|
|
6
|
+
|
|
7
|
+
Add the Rakomi MCP server to your coding agent, then ask it directly:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
claude mcp add --transport http rakomi https://mcp.rakomi.com/mcp
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
"List the AI agents connected to my Rakomi tenant."
|
|
14
|
+
|
|
5
15
|
## Getting started
|
|
6
16
|
|
|
7
17
|
```sh
|
|
@@ -15,7 +25,8 @@ session to get a short code to enter on another device instead. If you don't alr
|
|
|
15
25
|
Rakomi administrator for the right value.
|
|
16
26
|
|
|
17
27
|
`rakomi whoami` shows the tenant you signed in against ("Home tenant") and the tenant your other
|
|
18
|
-
commands should treat as active ("Active tenant")
|
|
28
|
+
commands should treat as active ("Active tenant") — including whether that active selection was
|
|
29
|
+
confirmed by the server or only remembered locally. `rakomi tenants memberships` lists every tenant
|
|
19
30
|
your account is a verified member of, with your role in each. `rakomi use <slug>` looks one up from
|
|
20
31
|
that list and remembers it — a slug you don't belong to fails with a plain "not found" rather than
|
|
21
32
|
confirming the tenant exists; `rakomi use <tenant-id>` (a full id) remembers it directly without a
|
|
@@ -27,10 +38,12 @@ lookup, and `--tenant <tenant-id>` overrides it for one command.
|
|
|
27
38
|
npx rakomi connect
|
|
28
39
|
```
|
|
29
40
|
|
|
30
|
-
Detects Claude Code and writes
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
Detects Claude Code and writes its config entry for you. Pass `--client <name>` for any other known
|
|
42
|
+
client — a config file is written in that client's own shape for one that supports it, or its own
|
|
43
|
+
connection steps are printed for a UI-only client (Claude Desktop, ChatGPT, JetBrains). Run
|
|
44
|
+
`rakomi --help` for the current client list. Read access works immediately once the client finishes
|
|
45
|
+
its own sign-in; write access is a separate step your tenant owner approves — see the
|
|
46
|
+
[Connect an AI Agent guide](https://docs.rakomi.dev/guides/connect-an-ai-agent).
|
|
34
47
|
|
|
35
48
|
Pass `--cimd-url <url>` (find it in your MCP client's own connection diagnostics) and `rakomi`
|
|
36
49
|
confirms the connection for you instead of leaving you to check the dashboard; add `--write` to
|
|
@@ -57,7 +70,7 @@ token), sends a real invitation e-mail, and is refused under `--ci` — see
|
|
|
57
70
|
| `logout` | clear the local session |
|
|
58
71
|
| `whoami` | show the signed-in account, home tenant, and active tenant |
|
|
59
72
|
| `use <tenant-id-or-slug>` | remember a tenant locally for `whoami`/future commands; a slug is server-verified |
|
|
60
|
-
| `connect` | connect Claude Code
|
|
73
|
+
| `connect` | connect an MCP client (Claude Code, Claude Desktop, and others — see `--help`) to your tenant (read access) |
|
|
61
74
|
| `tenants create <name>` | create a tenant (`--owner me\|<email>`, `--slug <slug>`) |
|
|
62
75
|
| `tenants list` | list tenants you provisioned |
|
|
63
76
|
| `tenants memberships` | list tenants you're a verified member of |
|
|
@@ -73,10 +86,11 @@ token), sends a real invitation e-mail, and is refused under `--ci` — see
|
|
|
73
86
|
| `--no-keychain` | store the session in a `0600` file instead of the OS keychain |
|
|
74
87
|
| `--tenant-id <uuid>` | (`login`) the tenant to sign in against, if `RAKOMI_PLATFORM_TENANT_ID` isn't set for you |
|
|
75
88
|
| `--tenant <tenant-id>` | override the active tenant for a single command (`whoami` today) |
|
|
76
|
-
| `--client <name>` |
|
|
77
|
-
| `--
|
|
89
|
+
| `--client <name>` | one of the known MCP clients (`connect --help` prints the current list) — required when more than one applies |
|
|
90
|
+
| `--name <server-name>` | (`connect`) the config key to write the entry under (default `rakomi`) — one name per workspace on a machine; a client keeps one login per server name |
|
|
91
|
+
| `--undo` | restore the config file `connect` last backed up (for the same `--name`) |
|
|
78
92
|
| `--cimd-url <url>` | (`connect`) the connecting client's own CIMD document URL — confirms/re-checks the connection |
|
|
79
|
-
| `--status` | (`connect`) re-check status only — needs `--cimd-url`, never rewrites
|
|
93
|
+
| `--status` | (`connect`) re-check status only — needs `--cimd-url`, never rewrites the config file |
|
|
80
94
|
| `-h`, `--help` | show help |
|
|
81
95
|
| `-V`, `--version` | print the version |
|
|
82
96
|
|
package/SECURITY.md
CHANGED
|
@@ -9,8 +9,8 @@ While these packages remain pre-1.0 (`0.x`), they carry **no stability or suppor
|
|
|
9
9
|
(SemVer 2.0.0 §4); the latest `0.x` line receives security updates on a best-effort basis.
|
|
10
10
|
|
|
11
11
|
From version **1.0** onward, Rakomi maintains the current (N) and previous (N-1) MAJOR in parallel, with N-1 receiving
|
|
12
|
-
security-only fixes.
|
|
13
|
-
**
|
|
12
|
+
security-only fixes. For each MAJOR version of the Rakomi SDKs, CRE8EVE commits to a CRA support period of
|
|
13
|
+
**at least five years (60 months)**, meeting the support-period requirement of **CRA Art. 13(8)**. The authoritative, machine-readable support windows are published at
|
|
14
14
|
[`https://api.rakomi.com/.well-known/sdk-support.json`](https://api.rakomi.com/.well-known/sdk-support.json)
|
|
15
15
|
and rendered for humans on the [SDK Support & Lifecycle page](https://rakomi.com/sdk-support). This
|
|
16
16
|
document points at that single source rather than re-typing dated rows.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { CliError, EXIT } from './errors.js';
|
|
2
|
+
import { describeError, request } from './http.js';
|
|
3
|
+
export async function getBillingPlan(deps, opts) {
|
|
4
|
+
const url = new URL('/v1/billing/plan', opts.apiBaseUrl);
|
|
5
|
+
url.searchParams.set('tenant_id', opts.tenantId);
|
|
6
|
+
const result = await request(deps, {
|
|
7
|
+
method: 'GET',
|
|
8
|
+
url: url.toString(),
|
|
9
|
+
headers: opts.dpop ? undefined : { authorization: `Bearer ${opts.accessToken}` },
|
|
10
|
+
dpop: opts.dpop ? { key: opts.dpop, accessToken: opts.accessToken } : undefined,
|
|
11
|
+
});
|
|
12
|
+
if (result.status === 401) {
|
|
13
|
+
throw new CliError('Your session has expired. Run `rakomi login` again.', EXIT.NOT_LOGGED_IN);
|
|
14
|
+
}
|
|
15
|
+
if (result.status !== 200) {
|
|
16
|
+
throw new CliError(`Could not fetch your billing plan: ${describeError(result.body, result.status)}`, EXIT.FAIL);
|
|
17
|
+
}
|
|
18
|
+
return result.body;
|
|
19
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
export const RAKOMI_SERVER_NAME = "rakomi";
|
|
2
|
+
export const CLIENT_REGISTRY = [
|
|
3
|
+
{
|
|
4
|
+
id: "claude-code",
|
|
5
|
+
displayName: "Claude Code",
|
|
6
|
+
tier: "written",
|
|
7
|
+
status: "verified",
|
|
8
|
+
finishInstruction: "1. Run `claude` in this directory and approve the \"{{SERVER_NAME}}\" project server when prompted.\n2. Then run `claude mcp login {{SERVER_NAME}}` to finish sign-in in your browser (or use `/mcp` inside that session).",
|
|
9
|
+
config: {
|
|
10
|
+
scope: "project",
|
|
11
|
+
pathTemplate: ".mcp.json",
|
|
12
|
+
serialization: "json",
|
|
13
|
+
topLevelKey: "mcpServers",
|
|
14
|
+
remoteUrlField: "url",
|
|
15
|
+
extraFields: { "type": "http" },
|
|
16
|
+
},
|
|
17
|
+
evidenceUrl: "https://code.claude.com/docs/en/mcp",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: "vscode",
|
|
21
|
+
displayName: "VS Code",
|
|
22
|
+
tier: "written",
|
|
23
|
+
status: "documented-shape-only",
|
|
24
|
+
finishInstruction: "Open the Command Palette and run \"MCP: List Servers\", or the Extensions view — VS Code starts the OAuth flow automatically the first time it connects.",
|
|
25
|
+
config: {
|
|
26
|
+
scope: "project",
|
|
27
|
+
pathTemplate: ".vscode/mcp.json",
|
|
28
|
+
serialization: "json",
|
|
29
|
+
topLevelKey: "servers",
|
|
30
|
+
remoteUrlField: "url",
|
|
31
|
+
extraFields: { "type": "http" },
|
|
32
|
+
},
|
|
33
|
+
evidenceUrl: "https://code.visualstudio.com/docs/agents/reference/mcp-configuration",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "cursor",
|
|
37
|
+
displayName: "Cursor",
|
|
38
|
+
tier: "written",
|
|
39
|
+
status: "documented-shape-only",
|
|
40
|
+
finishInstruction: "Open Cursor's MCP settings and connect — for a server that supports OAuth, Cursor completes the flow for you (or accepts static client credentials in mcp.json instead of dynamic client registration).",
|
|
41
|
+
config: {
|
|
42
|
+
scope: "project",
|
|
43
|
+
pathTemplate: ".cursor/mcp.json",
|
|
44
|
+
serialization: "json",
|
|
45
|
+
topLevelKey: "mcpServers",
|
|
46
|
+
remoteUrlField: "url",
|
|
47
|
+
extraFields: {},
|
|
48
|
+
},
|
|
49
|
+
evidenceUrl: "https://cursor.com/docs/mcp",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "antigravity",
|
|
53
|
+
displayName: "Google Antigravity",
|
|
54
|
+
tier: "written",
|
|
55
|
+
status: "documented-shape-only",
|
|
56
|
+
finishInstruction: "Open the MCP Store or your MCP settings in Antigravity — it handles OAuth automatically for servers that support dynamic client registration (DCR).",
|
|
57
|
+
config: {
|
|
58
|
+
scope: "project",
|
|
59
|
+
pathTemplate: ".agents/mcp_config.json",
|
|
60
|
+
serialization: "json",
|
|
61
|
+
topLevelKey: "mcpServers",
|
|
62
|
+
remoteUrlField: "serverUrl",
|
|
63
|
+
extraFields: {},
|
|
64
|
+
},
|
|
65
|
+
evidenceUrl: "https://antigravity.google/docs/ide/mcp/",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "gemini-cli",
|
|
69
|
+
displayName: "Gemini CLI",
|
|
70
|
+
tier: "written",
|
|
71
|
+
status: "documented-shape-only",
|
|
72
|
+
finishInstruction: "Run `gemini mcp list`, or just start a session — the Gemini CLI supports OAuth 2.0 for remote MCP servers and negotiates it automatically.",
|
|
73
|
+
config: {
|
|
74
|
+
scope: "project",
|
|
75
|
+
pathTemplate: ".gemini/settings.json",
|
|
76
|
+
serialization: "json",
|
|
77
|
+
topLevelKey: "mcpServers",
|
|
78
|
+
remoteUrlField: "httpUrl",
|
|
79
|
+
extraFields: {},
|
|
80
|
+
},
|
|
81
|
+
evidenceUrl: "https://google-gemini.github.io/gemini-cli/docs/tools/mcp-server.html",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: "zed",
|
|
85
|
+
displayName: "Zed",
|
|
86
|
+
tier: "written",
|
|
87
|
+
status: "documented-shape-only",
|
|
88
|
+
finishInstruction: "Open Settings -> AI -> MCP Servers in Zed — when a remote server has no Authorization header configured, Zed prompts you to authenticate with the standard MCP OAuth flow.",
|
|
89
|
+
config: {
|
|
90
|
+
scope: "project",
|
|
91
|
+
pathTemplate: ".zed/settings.json",
|
|
92
|
+
serialization: "json",
|
|
93
|
+
topLevelKey: "context_servers",
|
|
94
|
+
remoteUrlField: "url",
|
|
95
|
+
extraFields: {},
|
|
96
|
+
},
|
|
97
|
+
evidenceUrl: "https://zed.dev/docs/ai/mcp",
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: "codex-cli",
|
|
101
|
+
displayName: "OpenAI Codex CLI",
|
|
102
|
+
tier: "written",
|
|
103
|
+
status: "documented-shape-only",
|
|
104
|
+
finishInstruction: "Run `codex mcp login {{SERVER_NAME}}` to finish sign-in in your browser.",
|
|
105
|
+
config: {
|
|
106
|
+
scope: "user",
|
|
107
|
+
pathTemplate: ".codex/config.toml",
|
|
108
|
+
serialization: "toml",
|
|
109
|
+
topLevelKey: "mcp_servers",
|
|
110
|
+
remoteUrlField: "url",
|
|
111
|
+
extraFields: {},
|
|
112
|
+
},
|
|
113
|
+
evidenceUrl: "https://learn.chatgpt.com/docs/extend/mcp?surface=cli",
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: "devin-desktop",
|
|
117
|
+
displayName: "Devin Desktop",
|
|
118
|
+
tier: "written",
|
|
119
|
+
status: "documented-shape-only",
|
|
120
|
+
finishInstruction: "Open Devin Desktop — it supports OAuth for each transport type and prompts you to connect the first time.",
|
|
121
|
+
config: {
|
|
122
|
+
scope: "user",
|
|
123
|
+
pathTemplate: ".codeium/windsurf/mcp_config.json",
|
|
124
|
+
serialization: "json",
|
|
125
|
+
topLevelKey: "mcpServers",
|
|
126
|
+
remoteUrlField: "serverUrl",
|
|
127
|
+
extraFields: {},
|
|
128
|
+
},
|
|
129
|
+
evidenceUrl: "https://docs.devin.ai/desktop/cascade/mcp",
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: "claude-desktop",
|
|
133
|
+
displayName: "Claude Desktop",
|
|
134
|
+
tier: "instructed",
|
|
135
|
+
status: "verified",
|
|
136
|
+
finishInstruction: "Claude Desktop connects to remote MCP servers through Connectors, configured from your Claude account rather than a local file — there is nothing for `rakomi connect` to write.\n\n1. Open Settings -> Connectors in Claude Desktop and add a custom connector.\n2. Enter Rakomi's MCP server URL: {{MCP_URL}}\n3. Click Connect. Claude Desktop opens your browser at accounts.rakomi.com — sign in and\n approve the read-only consent screen.",
|
|
137
|
+
evidenceUrl: "https://support.claude.com/en/articles/11175166-about-custom-connectors-remote-mcp",
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
id: "chatgpt",
|
|
141
|
+
displayName: "ChatGPT",
|
|
142
|
+
tier: "instructed",
|
|
143
|
+
status: "documented-shape-only",
|
|
144
|
+
finishInstruction: "In ChatGPT, go to Workspace Settings -> Permissions & Roles -> Developer mode, then add {{MCP_URL}} as a remote server. ChatGPT starts an OAuth flow to your workspace's identity provider once it's added.",
|
|
145
|
+
evidenceUrl: "https://developers.openai.com/api/docs/mcp",
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: "jetbrains",
|
|
149
|
+
displayName: "JetBrains AI Assistant",
|
|
150
|
+
tier: "instructed",
|
|
151
|
+
status: "documented-shape-only",
|
|
152
|
+
finishInstruction: "In your JetBrains IDE, go to Settings -> Tools -> AI Assistant -> Model Context Protocol (MCP), click Add, and paste: {\"mcpServers\":{\"{{SERVER_NAME}}\":{\"url\":\"{{MCP_URL}}\"}}}\nJetBrains' own documentation does not state whether it drives OAuth for you — be ready to complete sign-in in whatever browser tab it opens.",
|
|
153
|
+
evidenceUrl: "https://www.jetbrains.com/help/ai-assistant/mcp.html",
|
|
154
|
+
},
|
|
155
|
+
];
|
|
156
|
+
export const KNOWN_CLIENTS = CLIENT_REGISTRY.map((c) => c.id);
|
package/dist/clients.js
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
import { CLIENT_REGISTRY, KNOWN_CLIENTS } from './client-registry.generated.js';
|
|
2
|
+
import { CliError, EXIT } from './errors.js';
|
|
3
|
+
export { CLIENT_REGISTRY, KNOWN_CLIENTS };
|
|
2
4
|
export function isKnownClient(value) {
|
|
3
5
|
return KNOWN_CLIENTS.includes(value);
|
|
4
6
|
}
|
|
7
|
+
export function clientRegistryEntry(id) {
|
|
8
|
+
const entry = CLIENT_REGISTRY.find((c) => c.id === id);
|
|
9
|
+
if (!entry)
|
|
10
|
+
throw new CliError(`clientRegistryEntry: unknown client '${id}' — this should be unreachable, isKnownClient() gates every caller`, EXIT.FAIL);
|
|
11
|
+
return entry;
|
|
12
|
+
}
|
|
5
13
|
export function clientDisplayName(client) {
|
|
6
|
-
return client
|
|
14
|
+
return clientRegistryEntry(client).displayName;
|
|
7
15
|
}
|
|
8
16
|
export function resolveTargetClients(opts) {
|
|
9
17
|
if (opts.explicitClient) {
|
package/dist/commands/connect.js
CHANGED
|
@@ -1,24 +1,29 @@
|
|
|
1
|
-
import { AmbiguousClientError,
|
|
1
|
+
import { AmbiguousClientError, clientRegistryEntry, resolveTargetClients } from '../clients.js';
|
|
2
2
|
import { pollCimdMaterialization, requestWriteElevation } from '../connect-client.js';
|
|
3
|
-
import { DEFAULT_MCP_URL } from '../env.js';
|
|
4
3
|
import { InteractiveRequiredError, NotLoggedInError, UsageError } from '../errors.js';
|
|
5
4
|
import { resolveDpopKey } from '../install-key.js';
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
'',
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
5
|
+
import { backupSuffixFor, clientConfigPath, RAKOMI_SERVER_NAME, undoClientConfigWrite, writeClientMcpConfigEntry, } from '../mcp-config.js';
|
|
6
|
+
import { resolveServerName } from '../server-name.js';
|
|
7
|
+
import { undoClientTomlConfigWrite, writeClientTomlConfigEntry } from '../toml-config.js';
|
|
8
|
+
function renderInstruction(instruction, mcpUrl, serverName) {
|
|
9
|
+
return instruction.replaceAll('{{MCP_URL}}', mcpUrl).replaceAll('{{SERVER_NAME}}', serverName);
|
|
10
|
+
}
|
|
11
|
+
function resolveSingleTargetClient(deps) {
|
|
12
|
+
let targets;
|
|
13
|
+
try {
|
|
14
|
+
targets = resolveTargetClients({ explicitClient: deps.explicitClient, claudeCodeDetected: deps.detectClaudeCode() });
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
if (e instanceof AmbiguousClientError)
|
|
18
|
+
throw new UsageError(e.message);
|
|
19
|
+
throw e;
|
|
20
|
+
}
|
|
21
|
+
return clientRegistryEntry(targets[0]);
|
|
22
|
+
}
|
|
16
23
|
export async function runConnect(deps) {
|
|
24
|
+
const serverName = resolveServerName(deps.serverName);
|
|
17
25
|
if (deps.undo) {
|
|
18
|
-
|
|
19
|
-
deps.stdout.write(restored
|
|
20
|
-
? `Restored ${mcpConfigPath(deps.cwd)} from the backup \`rakomi connect\` made.\n`
|
|
21
|
-
: `No \`rakomi connect\` backup found at ${mcpConfigPath(deps.cwd)}.rakomi-backup — nothing to undo.\n`);
|
|
26
|
+
runUndo(deps, serverName);
|
|
22
27
|
return;
|
|
23
28
|
}
|
|
24
29
|
const session = deps.session.read();
|
|
@@ -37,13 +42,33 @@ export async function runConnect(deps) {
|
|
|
37
42
|
throw new UsageError(e.message);
|
|
38
43
|
throw e;
|
|
39
44
|
}
|
|
40
|
-
for (const
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
for (const clientId of targets) {
|
|
46
|
+
const client = clientRegistryEntry(clientId);
|
|
47
|
+
if (client.tier === 'instructed') {
|
|
48
|
+
deps.stdout.write(`${client.displayName}:\n${renderInstruction(client.finishInstruction, deps.mcpUrl, serverName)}\n`);
|
|
43
49
|
continue;
|
|
44
50
|
}
|
|
45
|
-
|
|
51
|
+
if (clientId === 'claude-code') {
|
|
52
|
+
await connectClaudeCode(deps, session, client, serverName);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
connectGenericWrittenClient(deps, client, serverName);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function runUndo(deps, serverName) {
|
|
60
|
+
const client = resolveSingleTargetClient(deps);
|
|
61
|
+
if (!client.config) {
|
|
62
|
+
deps.stdout.write(`${client.displayName} writes no config file — nothing to undo.\n`);
|
|
63
|
+
return;
|
|
46
64
|
}
|
|
65
|
+
const path = clientConfigPath(client, deps.cwd, deps.homeDir);
|
|
66
|
+
const restored = client.config.serialization === 'toml'
|
|
67
|
+
? undoClientTomlConfigWrite(client, deps.cwd, deps.homeDir, serverName)
|
|
68
|
+
: undoClientConfigWrite(client, deps.cwd, deps.homeDir, serverName);
|
|
69
|
+
deps.stdout.write(restored
|
|
70
|
+
? `Restored ${path} from the backup \`rakomi connect\` made.\n`
|
|
71
|
+
: `No \`rakomi connect\` backup found at ${path}${backupSuffixFor(serverName)} — nothing to undo.\n`);
|
|
47
72
|
}
|
|
48
73
|
async function reportStatus(deps, session) {
|
|
49
74
|
if (!deps.cimdUrl) {
|
|
@@ -65,20 +90,35 @@ async function reportStatus(deps, session) {
|
|
|
65
90
|
await requestWrite(deps, session, row.id);
|
|
66
91
|
}
|
|
67
92
|
}
|
|
68
|
-
|
|
69
|
-
if (deps.dryRun) {
|
|
70
|
-
deps.stdout.write(`Would write ${mcpConfigPath(deps.cwd)} with mcpServers.rakomi -> ${deps.mcpUrl}. No file written (--dry-run).\n`);
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
const result = writeRakomiMcpServerEntry(deps.cwd, deps.mcpUrl);
|
|
93
|
+
function writeSummary(deps, result) {
|
|
74
94
|
if (result.wrote) {
|
|
75
|
-
deps.stdout.write(`Wrote ${
|
|
95
|
+
deps.stdout.write(`Wrote ${result.path}${result.backedUp ? ' (previous file backed up)' : ''}.\n${result.diff}\n`);
|
|
76
96
|
}
|
|
77
97
|
else {
|
|
78
98
|
deps.stdout.write(`${result.diff}\n`);
|
|
79
99
|
}
|
|
80
|
-
|
|
100
|
+
}
|
|
101
|
+
function secondWorkspaceHint(client, explicitClient) {
|
|
102
|
+
const example = `${RAKOMI_SERVER_NAME}-<workspace>`;
|
|
103
|
+
const clientFlag = explicitClient ? ` --client ${explicitClient}` : '';
|
|
104
|
+
const where = client.config?.scope === 'user' ? '' : ' in that project';
|
|
105
|
+
const login = client.id === 'claude-code' ? `, then \`claude mcp login ${example}\`` : ', then sign in under that name';
|
|
106
|
+
return (`\nTip: another project on this machine bound to a different workspace? Sign-ins are kept per server ` +
|
|
107
|
+
`name, so give it its own: run \`rakomi connect${clientFlag} --name ${example}\`${where}${login}.\n`);
|
|
108
|
+
}
|
|
109
|
+
async function connectClaudeCode(deps, session, client, serverName) {
|
|
110
|
+
const path = clientConfigPath(client, deps.cwd, deps.homeDir);
|
|
111
|
+
if (deps.dryRun) {
|
|
112
|
+
deps.stdout.write(`Would write ${path} with ${client.config.topLevelKey}.${serverName} -> ${deps.mcpUrl}. No file written (--dry-run).\n`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const result = writeClientMcpConfigEntry(client, deps.cwd, deps.homeDir, deps.mcpUrl, serverName);
|
|
116
|
+
writeSummary(deps, result);
|
|
117
|
+
deps.stdout.write(`${renderInstruction(client.finishInstruction, deps.mcpUrl, serverName)}\n` +
|
|
81
118
|
'Once approved, Claude Code has READ access to your tenant immediately.\n');
|
|
119
|
+
if (result.wrote && serverName === RAKOMI_SERVER_NAME) {
|
|
120
|
+
deps.stdout.write(secondWorkspaceHint(client, deps.explicitClient));
|
|
121
|
+
}
|
|
82
122
|
if (!deps.cimdUrl) {
|
|
83
123
|
deps.stdout.write('\nOnce connected, Rakomi can confirm it and (with --write) request write access for you — pass ' +
|
|
84
124
|
'`--cimd-url <url>` (find it in your MCP client\'s own connection diagnostics) to use it, or ' +
|
|
@@ -93,8 +133,9 @@ async function connectClaudeCode(deps, session) {
|
|
|
93
133
|
dpop,
|
|
94
134
|
});
|
|
95
135
|
if (!row) {
|
|
136
|
+
const nameFlag = serverName === RAKOMI_SERVER_NAME ? '' : ` --name ${serverName}`;
|
|
96
137
|
deps.stdout.write(`\nNot connected yet. Ctrl+C is safe — resume any time with:\n` +
|
|
97
|
-
` rakomi connect --status --cimd-url ${deps.cimdUrl}\n`);
|
|
138
|
+
` rakomi connect --status${nameFlag} --cimd-url ${deps.cimdUrl}\n`);
|
|
98
139
|
return;
|
|
99
140
|
}
|
|
100
141
|
deps.stdout.write(`\nConnected — access: ${row.agent_access.level}.\n`);
|
|
@@ -105,6 +146,21 @@ async function connectClaudeCode(deps, session) {
|
|
|
105
146
|
await requestWrite(deps, session, row.id);
|
|
106
147
|
}
|
|
107
148
|
}
|
|
149
|
+
function connectGenericWrittenClient(deps, client, serverName) {
|
|
150
|
+
const path = clientConfigPath(client, deps.cwd, deps.homeDir);
|
|
151
|
+
if (deps.dryRun) {
|
|
152
|
+
deps.stdout.write(`Would write ${path} with ${client.config.topLevelKey}.${serverName} -> ${deps.mcpUrl}. No file written (--dry-run).\n`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const result = client.config.serialization === 'toml'
|
|
156
|
+
? writeClientTomlConfigEntry(client, deps.cwd, deps.homeDir, deps.mcpUrl, serverName)
|
|
157
|
+
: writeClientMcpConfigEntry(client, deps.cwd, deps.homeDir, deps.mcpUrl, serverName);
|
|
158
|
+
writeSummary(deps, result);
|
|
159
|
+
deps.stdout.write(`${renderInstruction(client.finishInstruction, deps.mcpUrl, serverName)}\n`);
|
|
160
|
+
if (result.wrote && serverName === RAKOMI_SERVER_NAME) {
|
|
161
|
+
deps.stdout.write(secondWorkspaceHint(client, deps.explicitClient));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
108
164
|
async function requestWrite(deps, session, oauthClientId) {
|
|
109
165
|
const dpop = resolveDpopKey(deps.keys, session);
|
|
110
166
|
await requestWriteElevation(deps, { apiBaseUrl: deps.apiBaseUrl, accessToken: session.access_token, oauthClientId, dpop });
|
package/dist/commands/login.js
CHANGED
|
@@ -89,6 +89,7 @@ async function runLoopbackLogin(deps, apiUrl, cid, installKey, tenantId) {
|
|
|
89
89
|
scope: LOGIN_SCOPE,
|
|
90
90
|
tenantId,
|
|
91
91
|
});
|
|
92
|
+
deps.stdout.write('Signing you in to your Rakomi developer account (separate from your tenants\' end-user accounts)…\n');
|
|
92
93
|
deps.stdout.write(`Opening your browser to sign in:\n ${authorizeUrl}\n`);
|
|
93
94
|
await deps.openBrowser(authorizeUrl);
|
|
94
95
|
const callback = await listener.waitForCallback(LOOPBACK_TIMEOUT_MS);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { getBillingPlan } from '../billing-client.js';
|
|
2
|
+
import { NotLoggedInError, UsageError } from '../errors.js';
|
|
3
|
+
import { resolveDpopKey } from '../install-key.js';
|
|
4
|
+
export async function runUpgrade(deps) {
|
|
5
|
+
const session = deps.session.read();
|
|
6
|
+
if (!session)
|
|
7
|
+
throw new NotLoggedInError();
|
|
8
|
+
const tenantId = deps.explicitTenant ?? deps.tenantConfig.read() ?? undefined;
|
|
9
|
+
if (!tenantId) {
|
|
10
|
+
throw new UsageError('No active tenant set. Run `rakomi use <tenant-slug>` first, or pass --tenant <id>.');
|
|
11
|
+
}
|
|
12
|
+
const dpop = resolveDpopKey(deps.keys, session);
|
|
13
|
+
const plan = await getBillingPlan(deps, {
|
|
14
|
+
apiBaseUrl: session.api_base_url,
|
|
15
|
+
accessToken: session.access_token,
|
|
16
|
+
tenantId,
|
|
17
|
+
dpop,
|
|
18
|
+
});
|
|
19
|
+
deps.stdout.write(`Current plan: ${plan.effective_plan}\nOpening ${plan.upgrade_url} in your browser...\n`);
|
|
20
|
+
await deps.openBrowser(plan.upgrade_url);
|
|
21
|
+
}
|
package/dist/commands/use.js
CHANGED
|
@@ -21,6 +21,6 @@ export async function runUse(deps, args) {
|
|
|
21
21
|
throw new UsageError(`No tenant membership found for "${args.tenantId}" — check the spelling, or ask a tenant ` +
|
|
22
22
|
'admin to invite you. (Run `rakomi tenants memberships` to see the tenants you already belong to.)');
|
|
23
23
|
}
|
|
24
|
-
deps.tenantConfig.write(membership.id);
|
|
24
|
+
deps.tenantConfig.write(membership.id, membership.slug);
|
|
25
25
|
deps.stdout.write(`Active tenant set to ${membership.slug} (${membership.id}) — role: ${membership.role}.\n`);
|
|
26
26
|
}
|
package/dist/commands/whoami.js
CHANGED
|
@@ -1,12 +1,29 @@
|
|
|
1
1
|
import { NotLoggedInError } from '../errors.js';
|
|
2
2
|
import { resolveDpopKey } from '../install-key.js';
|
|
3
3
|
import { fetchUserInfo } from '../userinfo-client.js';
|
|
4
|
+
function describeTokenStatus(expiresAt, now) {
|
|
5
|
+
return now >= expiresAt ? 'expired (will auto-refresh on next use)' : 'valid';
|
|
6
|
+
}
|
|
7
|
+
function describeActiveTenant(deps, activeTenantId) {
|
|
8
|
+
if (!activeTenantId)
|
|
9
|
+
return 'none set (run `rakomi use <tenant-slug>`)';
|
|
10
|
+
if (deps.explicitTenant)
|
|
11
|
+
return `${activeTenantId} (--tenant override for this command only — not verified)`;
|
|
12
|
+
const active = deps.tenantConfig.readActive?.() ?? null;
|
|
13
|
+
if (active && active.tenantId === activeTenantId && active.verifiedViaSlug) {
|
|
14
|
+
return `${activeTenantId} (verified member of "${active.verifiedViaSlug}")`;
|
|
15
|
+
}
|
|
16
|
+
return `${activeTenantId} (remembered locally — membership not verified; re-run \`rakomi use <tenant-slug>\` to verify)`;
|
|
17
|
+
}
|
|
4
18
|
export async function runWhoami(deps) {
|
|
5
|
-
const
|
|
6
|
-
if (!
|
|
19
|
+
const initialSession = deps.session.read();
|
|
20
|
+
if (!initialSession)
|
|
7
21
|
throw new NotLoggedInError();
|
|
8
|
-
const dpop = resolveDpopKey(deps.keys,
|
|
9
|
-
const info = await fetchUserInfo(deps, { apiBaseUrl:
|
|
22
|
+
const dpop = resolveDpopKey(deps.keys, initialSession);
|
|
23
|
+
const info = await fetchUserInfo(deps, { apiBaseUrl: initialSession.api_base_url, accessToken: initialSession.access_token, dpop });
|
|
24
|
+
const session = deps.session.read() ?? initialSession;
|
|
25
|
+
const now = (deps.now ?? Date.now)();
|
|
26
|
+
const tokenStatus = describeTokenStatus(session.expires_at, now);
|
|
10
27
|
const activeTenantId = deps.explicitTenant ?? deps.tenantConfig.read() ?? undefined;
|
|
11
28
|
if (deps.json) {
|
|
12
29
|
deps.stdout.write(JSON.stringify({
|
|
@@ -16,6 +33,8 @@ export async function runWhoami(deps) {
|
|
|
16
33
|
org_role: info.org_role,
|
|
17
34
|
session_store: deps.session.describePath(),
|
|
18
35
|
token_type: session.token_type,
|
|
36
|
+
expires_at: new Date(session.expires_at).toISOString(),
|
|
37
|
+
token_status: tokenStatus,
|
|
19
38
|
home_tenant_id: session.home_tenant_id,
|
|
20
39
|
active_tenant_id: activeTenantId,
|
|
21
40
|
}) + '\n');
|
|
@@ -26,8 +45,9 @@ export async function runWhoami(deps) {
|
|
|
26
45
|
lines.push(`Organization: ${info.org_id}${info.org_role ? ` (${info.org_role})` : ''}`);
|
|
27
46
|
lines.push(`API: ${session.api_base_url}`);
|
|
28
47
|
lines.push(`Home tenant: ${session.home_tenant_id ?? 'unknown (this session did not authenticate via the CIMD-default flow)'}`);
|
|
29
|
-
lines.push(`Active tenant: ${
|
|
48
|
+
lines.push(`Active tenant: ${describeActiveTenant(deps, activeTenantId)}`);
|
|
30
49
|
lines.push(`Session stored in: ${deps.session.describePath()}`);
|
|
31
50
|
lines.push(`Token type: ${session.token_type}`);
|
|
51
|
+
lines.push(`Token expires: ${new Date(session.expires_at).toISOString()} (${tokenStatus})`);
|
|
32
52
|
deps.stdout.write(lines.join('\n') + '\n');
|
|
33
53
|
}
|
package/dist/http.js
CHANGED
|
@@ -7,7 +7,40 @@ function isErrorEnvelope(v) {
|
|
|
7
7
|
function isProblemDetails(v) {
|
|
8
8
|
return typeof v === 'object' && v !== null && !('error' in v) && ('code' in v || 'detail' in v || 'title' in v);
|
|
9
9
|
}
|
|
10
|
+
function errorDetails(body) {
|
|
11
|
+
if (isErrorEnvelope(body) && typeof body.error === 'object') {
|
|
12
|
+
const d = body.error.details;
|
|
13
|
+
return d && typeof d === 'object' ? d : undefined;
|
|
14
|
+
}
|
|
15
|
+
if (isProblemDetails(body)) {
|
|
16
|
+
return body.details && typeof body.details === 'object' ? body.details : undefined;
|
|
17
|
+
}
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
export function upsellFromProblem(body, status) {
|
|
21
|
+
if (status !== 403)
|
|
22
|
+
return undefined;
|
|
23
|
+
const url = errorDetails(body)?.upgrade_url;
|
|
24
|
+
return typeof url === 'string' && url.length > 0 ? { upgradeUrl: url } : undefined;
|
|
25
|
+
}
|
|
26
|
+
export function requestIdFromError(body) {
|
|
27
|
+
if (isErrorEnvelope(body) && typeof body.error === 'object') {
|
|
28
|
+
const id = body.error.request_id;
|
|
29
|
+
return typeof id === 'string' && id.length > 0 ? id : undefined;
|
|
30
|
+
}
|
|
31
|
+
if (isProblemDetails(body)) {
|
|
32
|
+
return typeof body.request_id === 'string' && body.request_id.length > 0 ? body.request_id : undefined;
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
10
36
|
export function describeError(body, status) {
|
|
37
|
+
const base = describeErrorBase(body, status);
|
|
38
|
+
const upsell = upsellFromProblem(body, status);
|
|
39
|
+
const withUpsell = upsell ? `${base}\nUpgrade: ${upsell.upgradeUrl}\nRun \`rakomi upgrade\` to open this in your browser.` : base;
|
|
40
|
+
const id = requestIdFromError(body);
|
|
41
|
+
return id ? `${withUpsell}\nRequest ID: ${id}` : withUpsell;
|
|
42
|
+
}
|
|
43
|
+
function describeErrorBase(body, status) {
|
|
11
44
|
if (isErrorEnvelope(body)) {
|
|
12
45
|
if (typeof body.error === 'string')
|
|
13
46
|
return body.error_description || body.error;
|
|
@@ -72,6 +105,8 @@ async function performOnce(deps, req, dpopNonce) {
|
|
|
72
105
|
parsed = JSON.parse(text);
|
|
73
106
|
}
|
|
74
107
|
catch {
|
|
108
|
+
if (res.status < 200 || res.status >= 300)
|
|
109
|
+
return { status: res.status, body: {}, headers: res.headers };
|
|
75
110
|
throw new CliError('The Rakomi API returned a response the CLI could not understand.', EXIT.FAIL);
|
|
76
111
|
}
|
|
77
112
|
}
|
|
@@ -89,7 +124,7 @@ function isDpopNonceChallenge(result) {
|
|
|
89
124
|
const nonce = result.headers.get('dpop-nonce');
|
|
90
125
|
return nonce && nonce.length > 0 ? nonce : undefined;
|
|
91
126
|
}
|
|
92
|
-
|
|
127
|
+
async function performWithNonceRetry(deps, req) {
|
|
93
128
|
const first = await performOnce(deps, req);
|
|
94
129
|
if (!req.dpop)
|
|
95
130
|
return first;
|
|
@@ -102,3 +137,25 @@ export async function request(deps, req) {
|
|
|
102
137
|
}
|
|
103
138
|
return retry;
|
|
104
139
|
}
|
|
140
|
+
function isAuthenticatedRequest(req) {
|
|
141
|
+
if (req.dpop?.accessToken !== undefined)
|
|
142
|
+
return true;
|
|
143
|
+
return typeof req.headers?.authorization === 'string' && req.headers.authorization.length > 0;
|
|
144
|
+
}
|
|
145
|
+
function withRefreshedCredentials(req, credentials) {
|
|
146
|
+
if (req.dpop?.accessToken !== undefined) {
|
|
147
|
+
return { ...req, dpop: { ...req.dpop, accessToken: credentials.accessToken } };
|
|
148
|
+
}
|
|
149
|
+
return { ...req, headers: { ...req.headers, authorization: `Bearer ${credentials.accessToken}` } };
|
|
150
|
+
}
|
|
151
|
+
export async function request(deps, req) {
|
|
152
|
+
const first = await performWithNonceRetry(deps, req);
|
|
153
|
+
if (first.status !== 401)
|
|
154
|
+
return first;
|
|
155
|
+
if (!deps.onUnauthorized || !isAuthenticatedRequest(req))
|
|
156
|
+
return first;
|
|
157
|
+
const refreshed = await deps.onUnauthorized();
|
|
158
|
+
if (!refreshed)
|
|
159
|
+
return first;
|
|
160
|
+
return performWithNonceRetry(deps, withRefreshedCredentials(req, refreshed));
|
|
161
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
3
4
|
import { delimiter, join } from 'node:path';
|
|
4
5
|
import process from 'node:process';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
@@ -10,6 +11,7 @@ import { runLogin, runLoginCi } from './commands/login.js';
|
|
|
10
11
|
import { runLogout } from './commands/logout.js';
|
|
11
12
|
import { runMcpToolsCi } from './commands/mcp.js';
|
|
12
13
|
import { runTenantsClaim, runTenantsCreate, runTenantsList, runTenantsMemberships, runTenantsRelease } from './commands/tenants.js';
|
|
14
|
+
import { runUpgrade } from './commands/upgrade.js';
|
|
13
15
|
import { runUse } from './commands/use.js';
|
|
14
16
|
import { runWhoami } from './commands/whoami.js';
|
|
15
17
|
import { apiBaseUrl, DEFAULT_MCP_URL } from './env.js';
|
|
@@ -17,6 +19,7 @@ import { CliError, EXIT, UsageError } from './errors.js';
|
|
|
17
19
|
import { startLoopbackListener } from './loopback-server.js';
|
|
18
20
|
import { resolveStores } from './session.js';
|
|
19
21
|
import { FileTenantConfigStore } from './tenant-config.js';
|
|
22
|
+
import { refreshSession } from './token-refresh.js';
|
|
20
23
|
import { helpText, usageLine } from './usage.js';
|
|
21
24
|
const GLOBAL_OPTIONS = {
|
|
22
25
|
json: { type: 'boolean' },
|
|
@@ -37,6 +40,7 @@ const GLOBAL_OPTIONS = {
|
|
|
37
40
|
label: { type: 'string' },
|
|
38
41
|
'cimd-url': { type: 'string' },
|
|
39
42
|
status: { type: 'boolean' },
|
|
43
|
+
name: { type: 'string' },
|
|
40
44
|
help: { type: 'boolean', short: 'h' },
|
|
41
45
|
version: { type: 'boolean', short: 'V' },
|
|
42
46
|
};
|
|
@@ -89,6 +93,13 @@ async function dispatch(args, deps) {
|
|
|
89
93
|
const dryRun = values['dry-run'] === true;
|
|
90
94
|
const json = values.json === true;
|
|
91
95
|
const httpDeps = { fetchImpl: deps.fetchImpl };
|
|
96
|
+
const onUnauthorized = async () => {
|
|
97
|
+
const current = deps.session.read();
|
|
98
|
+
if (!current)
|
|
99
|
+
return undefined;
|
|
100
|
+
return refreshSession({ fetchImpl: deps.fetchImpl }, current, deps.keys, deps.session, () => Date.now());
|
|
101
|
+
};
|
|
102
|
+
const authHttpDeps = { ...httpDeps, onUnauthorized };
|
|
92
103
|
const [command, ...rest] = positionals;
|
|
93
104
|
switch (command) {
|
|
94
105
|
case 'login': {
|
|
@@ -126,7 +137,7 @@ async function dispatch(args, deps) {
|
|
|
126
137
|
return EXIT.OK;
|
|
127
138
|
case 'whoami':
|
|
128
139
|
await runWhoami({
|
|
129
|
-
...
|
|
140
|
+
...authHttpDeps,
|
|
130
141
|
session: deps.session,
|
|
131
142
|
keys: deps.keys,
|
|
132
143
|
json,
|
|
@@ -139,15 +150,27 @@ async function dispatch(args, deps) {
|
|
|
139
150
|
const tenantId = rest[0];
|
|
140
151
|
if (!tenantId)
|
|
141
152
|
throw new UsageError('Usage: rakomi use <tenant-id-or-slug>');
|
|
142
|
-
await runUse({ ...
|
|
153
|
+
await runUse({ ...authHttpDeps, session: deps.session, keys: deps.keys, tenantConfig: deps.tenantConfig, stdout: deps.stdout }, { tenantId });
|
|
143
154
|
return EXIT.OK;
|
|
144
155
|
}
|
|
156
|
+
case 'upgrade':
|
|
157
|
+
await runUpgrade({
|
|
158
|
+
...authHttpDeps,
|
|
159
|
+
session: deps.session,
|
|
160
|
+
keys: deps.keys,
|
|
161
|
+
tenantConfig: deps.tenantConfig,
|
|
162
|
+
explicitTenant: typeof values.tenant === 'string' ? values.tenant : undefined,
|
|
163
|
+
openBrowser: systemBrowserOpener(),
|
|
164
|
+
stdout: deps.stdout,
|
|
165
|
+
});
|
|
166
|
+
return EXIT.OK;
|
|
145
167
|
case 'connect':
|
|
146
168
|
await runConnect({
|
|
147
|
-
...
|
|
169
|
+
...authHttpDeps,
|
|
148
170
|
session: deps.session,
|
|
149
171
|
keys: deps.keys,
|
|
150
172
|
cwd: deps.cwd,
|
|
173
|
+
homeDir: homedir(),
|
|
151
174
|
apiBaseUrl: apiBaseUrl(deps.env),
|
|
152
175
|
mcpUrl: deps.env.RAKOMI_API_URL ? `${apiBaseUrl(deps.env)}/mcp` : DEFAULT_MCP_URL,
|
|
153
176
|
detectClaudeCode: deps.detectClaudeCode,
|
|
@@ -159,6 +182,7 @@ async function dispatch(args, deps) {
|
|
|
159
182
|
explicitClient: typeof values.client === 'string' ? values.client : undefined,
|
|
160
183
|
cimdUrl: typeof values['cimd-url'] === 'string' ? values['cimd-url'] : undefined,
|
|
161
184
|
status: values.status === true,
|
|
185
|
+
serverName: typeof values.name === 'string' ? values.name : undefined,
|
|
162
186
|
});
|
|
163
187
|
return EXIT.OK;
|
|
164
188
|
case 'mcp': {
|
|
@@ -187,22 +211,22 @@ async function dispatch(args, deps) {
|
|
|
187
211
|
const name = tenantArgs[0];
|
|
188
212
|
if (!name)
|
|
189
213
|
throw new UsageError('Usage: rakomi tenants create <name> [--owner me|<email>] [--slug <slug>]');
|
|
190
|
-
await runTenantsCreate({ ...
|
|
214
|
+
await runTenantsCreate({ ...authHttpDeps, session: deps.session, keys: deps.keys, json, dryRun, ci, stdout: deps.stdout }, { name, slug: typeof values.slug === 'string' ? values.slug : undefined, owner: typeof values.owner === 'string' ? values.owner : 'me' });
|
|
191
215
|
return EXIT.OK;
|
|
192
216
|
}
|
|
193
217
|
if (sub === 'list') {
|
|
194
|
-
await runTenantsList({ ...
|
|
218
|
+
await runTenantsList({ ...authHttpDeps, session: deps.session, keys: deps.keys, json, stdout: deps.stdout });
|
|
195
219
|
return EXIT.OK;
|
|
196
220
|
}
|
|
197
221
|
if (sub === 'memberships') {
|
|
198
|
-
await runTenantsMemberships({ ...
|
|
222
|
+
await runTenantsMemberships({ ...authHttpDeps, session: deps.session, keys: deps.keys, json, stdout: deps.stdout });
|
|
199
223
|
return EXIT.OK;
|
|
200
224
|
}
|
|
201
225
|
if (sub === 'claim') {
|
|
202
226
|
if (values.ci !== true) {
|
|
203
227
|
throw new UsageError('rakomi tenants claim requires --ci (no interactive form exists yet).');
|
|
204
228
|
}
|
|
205
|
-
await runTenantsClaim({ ...
|
|
229
|
+
await runTenantsClaim({ ...authHttpDeps, session: deps.session, keys: deps.keys, tenantConfig: deps.tenantConfig, json, stdout: deps.stdout }, {
|
|
206
230
|
parentTenantId: typeof values.tenant === 'string' ? values.tenant : undefined,
|
|
207
231
|
ttlSeconds: parseTtlSeconds(values['ttl-seconds']),
|
|
208
232
|
label: typeof values.label === 'string' ? values.label : undefined,
|
|
@@ -216,7 +240,7 @@ async function dispatch(args, deps) {
|
|
|
216
240
|
const tenantId = tenantArgs[0];
|
|
217
241
|
if (!tenantId)
|
|
218
242
|
throw new UsageError('Usage: rakomi tenants release --ci <tenant-id> [--tenant <parent-tenant-id>]');
|
|
219
|
-
await runTenantsRelease({ ...
|
|
243
|
+
await runTenantsRelease({ ...authHttpDeps, session: deps.session, keys: deps.keys, tenantConfig: deps.tenantConfig, json, stdout: deps.stdout }, { tenantId, parentTenantId: typeof values.tenant === 'string' ? values.tenant : undefined });
|
|
220
244
|
return EXIT.OK;
|
|
221
245
|
}
|
|
222
246
|
throw new UsageError('Usage: rakomi tenants <create|list|claim|release> ...');
|
package/dist/loopback-server.js
CHANGED
|
@@ -1,11 +1,90 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { CliError, EXIT } from './errors.js';
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
const PAGE_STYLE = `
|
|
4
|
+
:root { color-scheme: light dark; }
|
|
5
|
+
* { box-sizing: border-box; }
|
|
6
|
+
body {
|
|
7
|
+
margin: 0;
|
|
8
|
+
min-height: 100vh;
|
|
9
|
+
display: flex;
|
|
10
|
+
align-items: center;
|
|
11
|
+
justify-content: center;
|
|
12
|
+
font-family: "Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
|
13
|
+
background: linear-gradient(to bottom, #F3F4F7, #ECEEF2, #E4E6EB);
|
|
14
|
+
color: #2A2A2A;
|
|
15
|
+
}
|
|
16
|
+
.card {
|
|
17
|
+
max-width: 26rem;
|
|
18
|
+
width: calc(100% - 3rem);
|
|
19
|
+
margin: 1.5rem;
|
|
20
|
+
padding: 2.5rem 2rem;
|
|
21
|
+
border-radius: 1rem;
|
|
22
|
+
text-align: center;
|
|
23
|
+
background: rgba(255, 255, 255, 0.75);
|
|
24
|
+
border: 1px solid rgba(29, 85, 102, 0.14);
|
|
25
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 8px 24px rgba(29, 85, 102, 0.08);
|
|
26
|
+
}
|
|
27
|
+
.wordmark {
|
|
28
|
+
font-size: 0.8125rem;
|
|
29
|
+
font-weight: 600;
|
|
30
|
+
letter-spacing: 0.08em;
|
|
31
|
+
text-transform: uppercase;
|
|
32
|
+
color: #1d5566;
|
|
33
|
+
margin: 0 0 1.5rem;
|
|
34
|
+
}
|
|
35
|
+
.icon {
|
|
36
|
+
width: 3rem;
|
|
37
|
+
height: 3rem;
|
|
38
|
+
margin: 0 auto 1.25rem;
|
|
39
|
+
border-radius: 999px;
|
|
40
|
+
display: flex;
|
|
41
|
+
align-items: center;
|
|
42
|
+
justify-content: center;
|
|
43
|
+
font-size: 1.5rem;
|
|
44
|
+
line-height: 1;
|
|
45
|
+
}
|
|
46
|
+
.icon.success { background: rgba(40, 114, 77, 0.14); color: #28724D; }
|
|
47
|
+
.icon.error { background: rgba(189, 46, 46, 0.14); color: #BD2E2E; }
|
|
48
|
+
h1 {
|
|
49
|
+
font-size: 1.25rem;
|
|
50
|
+
font-weight: 700;
|
|
51
|
+
line-height: 1.25;
|
|
52
|
+
margin: 0 0 0.5rem;
|
|
53
|
+
color: #2A2A2A;
|
|
54
|
+
}
|
|
55
|
+
p {
|
|
56
|
+
font-size: 0.9375rem;
|
|
57
|
+
line-height: 1.5;
|
|
58
|
+
color: #4A4A4A;
|
|
59
|
+
margin: 0 0 0.375rem;
|
|
60
|
+
}
|
|
61
|
+
p.hint { color: #616161; font-size: 0.875rem; }
|
|
62
|
+
@media (prefers-color-scheme: dark) {
|
|
63
|
+
body { background: linear-gradient(to bottom right, #23242c, #1e1f26, #191a20); color: #e6e7ec; }
|
|
64
|
+
.card { background: rgba(255, 255, 255, 0.07); border-color: rgba(255, 255, 255, 0.10); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); }
|
|
65
|
+
.wordmark { color: #51b0cd; }
|
|
66
|
+
.icon.success { background: rgba(98, 200, 146, 0.16); color: #62C892; }
|
|
67
|
+
.icon.error { background: rgba(242, 159, 159, 0.16); color: #F29F9F; }
|
|
68
|
+
h1 { color: #e6e7ec; }
|
|
69
|
+
p { color: #a1a3ad; }
|
|
70
|
+
p.hint { color: #a1a3ad; }
|
|
71
|
+
}
|
|
72
|
+
`;
|
|
73
|
+
const SUCCESS_PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>rakomi login</title><style>${PAGE_STYLE}</style></head>
|
|
74
|
+
<body><main class="card">
|
|
75
|
+
<p class="wordmark">rakomi</p>
|
|
76
|
+
<div class="icon success" aria-hidden="true">✓</div>
|
|
77
|
+
<h1>Signed in</h1>
|
|
78
|
+
<p>You can close this tab and return to your terminal.</p>
|
|
79
|
+
</main></body></html>`;
|
|
80
|
+
const ERROR_PAGE = (message) => `<!doctype html><html><head><meta charset="utf-8"><title>rakomi login</title><style>${PAGE_STYLE}</style></head>
|
|
81
|
+
<body><main class="card">
|
|
82
|
+
<p class="wordmark">rakomi</p>
|
|
83
|
+
<div class="icon error" aria-hidden="true">✕</div>
|
|
84
|
+
<h1>Sign-in failed</h1>
|
|
85
|
+
<p>${escapeHtml(message)}</p>
|
|
86
|
+
<p class="hint">Return to your terminal and try again.</p>
|
|
87
|
+
</main></body></html>`;
|
|
9
88
|
function escapeHtml(s) {
|
|
10
89
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
11
90
|
}
|
package/dist/mcp-config.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { RAKOMI_SERVER_NAME } from './client-registry.generated.js';
|
|
4
|
+
import { CliError, EXIT } from './errors.js';
|
|
5
|
+
export { RAKOMI_SERVER_NAME };
|
|
6
|
+
export function backupSuffixFor(serverName) {
|
|
7
|
+
return `.${serverName}-backup`;
|
|
8
|
+
}
|
|
9
|
+
export const BACKUP_SUFFIX = backupSuffixFor(RAKOMI_SERVER_NAME);
|
|
5
10
|
function readIfPresent(file) {
|
|
6
11
|
try {
|
|
7
12
|
return readFileSync(file, 'utf8');
|
|
@@ -12,13 +17,17 @@ function readIfPresent(file) {
|
|
|
12
17
|
throw err;
|
|
13
18
|
}
|
|
14
19
|
}
|
|
15
|
-
export function
|
|
16
|
-
|
|
20
|
+
export function clientConfigPath(client, cwd, homeDir) {
|
|
21
|
+
if (!client.config) {
|
|
22
|
+
throw new CliError(`clientConfigPath: '${client.id}' is instructed-tier — it has no config file to write`, EXIT.FAIL);
|
|
23
|
+
}
|
|
24
|
+
const base = client.config.scope === 'user' ? homeDir : cwd;
|
|
25
|
+
return join(base, client.config.pathTemplate);
|
|
17
26
|
}
|
|
18
|
-
function backupPath(configPath) {
|
|
19
|
-
return `${configPath}${
|
|
27
|
+
function backupPath(configPath, serverName) {
|
|
28
|
+
return `${configPath}${backupSuffixFor(serverName)}`;
|
|
20
29
|
}
|
|
21
|
-
function
|
|
30
|
+
function readJsonConfig(path) {
|
|
22
31
|
const text = readIfPresent(path);
|
|
23
32
|
if (text === null)
|
|
24
33
|
return {};
|
|
@@ -32,43 +41,57 @@ function readConfig(path) {
|
|
|
32
41
|
return {};
|
|
33
42
|
}
|
|
34
43
|
}
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
function shallowEqualStringRecord(a, b) {
|
|
45
|
+
const aKeys = Object.keys(a);
|
|
46
|
+
const bKeys = Object.keys(b);
|
|
47
|
+
if (aKeys.length !== bKeys.length)
|
|
48
|
+
return false;
|
|
49
|
+
return aKeys.every((k) => a[k] === b[k]);
|
|
50
|
+
}
|
|
51
|
+
export function mergeClientServerEntry(config, client, mcpUrl, serverName = RAKOMI_SERVER_NAME) {
|
|
52
|
+
if (!client.config || client.config.serialization !== 'json') {
|
|
53
|
+
throw new CliError(`mergeClientServerEntry: '${client.id}' is not a JSON-shaped written client`, EXIT.FAIL);
|
|
54
|
+
}
|
|
55
|
+
const { topLevelKey, remoteUrlField, extraFields } = client.config;
|
|
56
|
+
const bucket = config[topLevelKey] ?? {};
|
|
57
|
+
const previousEntry = bucket[serverName];
|
|
58
|
+
const nextEntry = { ...extraFields, [remoteUrlField]: mcpUrl };
|
|
59
|
+
const alreadyIdentical = previousEntry !== undefined && shallowEqualStringRecord(previousEntry, nextEntry);
|
|
42
60
|
if (alreadyIdentical)
|
|
43
61
|
return { config, changed: false, previousEntry };
|
|
44
62
|
const merged = {
|
|
45
63
|
...config,
|
|
46
|
-
|
|
64
|
+
[topLevelKey]: { ...bucket, [serverName]: nextEntry },
|
|
47
65
|
};
|
|
48
66
|
return { config: merged, changed: true, previousEntry };
|
|
49
67
|
}
|
|
50
|
-
export function
|
|
51
|
-
const path =
|
|
52
|
-
const before =
|
|
53
|
-
const { config: after, changed, previousEntry } =
|
|
68
|
+
export function writeClientMcpConfigEntry(client, cwd, homeDir, mcpUrl, serverName = RAKOMI_SERVER_NAME) {
|
|
69
|
+
const path = clientConfigPath(client, cwd, homeDir);
|
|
70
|
+
const before = readJsonConfig(path);
|
|
71
|
+
const { config: after, changed, previousEntry } = mergeClientServerEntry(before, client, mcpUrl, serverName);
|
|
54
72
|
if (!changed) {
|
|
55
|
-
return { wrote: false, backedUp: false, diff: `${
|
|
73
|
+
return { wrote: false, backedUp: false, diff: `${serverName} already configured with ${mcpUrl} — no change.`, path };
|
|
56
74
|
}
|
|
57
75
|
let backedUp = false;
|
|
58
76
|
const existing = readIfPresent(path);
|
|
59
77
|
if (existing !== null) {
|
|
60
|
-
writeFileSync(backupPath(path), existing);
|
|
78
|
+
writeFileSync(backupPath(path, serverName), existing);
|
|
61
79
|
backedUp = true;
|
|
62
80
|
}
|
|
81
|
+
else {
|
|
82
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
83
|
+
}
|
|
63
84
|
writeFileSync(path, JSON.stringify(after, null, 2) + '\n');
|
|
85
|
+
const topLevelKey = client.config.topLevelKey;
|
|
86
|
+
const nowEntry = after[topLevelKey][serverName];
|
|
64
87
|
const diff = previousEntry
|
|
65
|
-
?
|
|
66
|
-
:
|
|
67
|
-
return { wrote: true, backedUp, diff };
|
|
88
|
+
? `${topLevelKey}.${serverName}: ${JSON.stringify(previousEntry)} -> ${JSON.stringify(nowEntry)}`
|
|
89
|
+
: `${topLevelKey}.${serverName}: (added) ${JSON.stringify(nowEntry)}`;
|
|
90
|
+
return { wrote: true, backedUp, diff, path };
|
|
68
91
|
}
|
|
69
|
-
export function
|
|
70
|
-
const path =
|
|
71
|
-
const backup = readIfPresent(backupPath(path));
|
|
92
|
+
export function undoClientConfigWrite(client, cwd, homeDir, serverName = RAKOMI_SERVER_NAME) {
|
|
93
|
+
const path = clientConfigPath(client, cwd, homeDir);
|
|
94
|
+
const backup = readIfPresent(backupPath(path, serverName));
|
|
72
95
|
if (backup === null)
|
|
73
96
|
return false;
|
|
74
97
|
writeFileSync(path, backup);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { RAKOMI_SERVER_NAME } from './client-registry.generated.js';
|
|
2
|
+
import { UsageError } from './errors.js';
|
|
3
|
+
export const SERVER_NAME_PATTERN = /^[a-z][a-z0-9-]{0,39}$/;
|
|
4
|
+
export const SERVER_NAME_MAX_LENGTH = 40;
|
|
5
|
+
export function isValidServerName(value) {
|
|
6
|
+
return SERVER_NAME_PATTERN.test(value);
|
|
7
|
+
}
|
|
8
|
+
export function resolveServerName(raw) {
|
|
9
|
+
if (raw === undefined)
|
|
10
|
+
return RAKOMI_SERVER_NAME;
|
|
11
|
+
if (isValidServerName(raw))
|
|
12
|
+
return raw;
|
|
13
|
+
throw new UsageError(`Invalid --name "${raw}": use lowercase letters, digits and hyphens only, starting with a letter, ` +
|
|
14
|
+
`at most ${SERVER_NAME_MAX_LENGTH} characters (for example \`${RAKOMI_SERVER_NAME}-acme\`).`);
|
|
15
|
+
}
|
package/dist/tenant-config.js
CHANGED
|
@@ -5,7 +5,9 @@ function isStoredTenantConfig(value) {
|
|
|
5
5
|
if (typeof value !== 'object' || value === null)
|
|
6
6
|
return false;
|
|
7
7
|
const v = value;
|
|
8
|
-
|
|
8
|
+
if (typeof v.active_tenant_id !== 'string' || v.active_tenant_id.length === 0)
|
|
9
|
+
return false;
|
|
10
|
+
return v.verified_via_slug === undefined || typeof v.verified_via_slug === 'string';
|
|
9
11
|
}
|
|
10
12
|
function tenantConfigFilePath(env) {
|
|
11
13
|
return join(configDir(env), 'tenant.json');
|
|
@@ -18,18 +20,27 @@ export class FileTenantConfigStore {
|
|
|
18
20
|
this.path = tenantConfigFilePath(env);
|
|
19
21
|
}
|
|
20
22
|
read() {
|
|
23
|
+
return this.readActive()?.tenantId ?? null;
|
|
24
|
+
}
|
|
25
|
+
readActive() {
|
|
21
26
|
try {
|
|
22
27
|
const raw = readFileSync(this.path, 'utf8');
|
|
23
28
|
const parsed = JSON.parse(raw);
|
|
24
|
-
|
|
29
|
+
if (!isStoredTenantConfig(parsed))
|
|
30
|
+
return null;
|
|
31
|
+
return parsed.verified_via_slug !== undefined && parsed.verified_via_slug.length > 0
|
|
32
|
+
? { tenantId: parsed.active_tenant_id, verifiedViaSlug: parsed.verified_via_slug }
|
|
33
|
+
: { tenantId: parsed.active_tenant_id };
|
|
25
34
|
}
|
|
26
35
|
catch {
|
|
27
36
|
return null;
|
|
28
37
|
}
|
|
29
38
|
}
|
|
30
|
-
write(tenantId) {
|
|
39
|
+
write(tenantId, verifiedViaSlug) {
|
|
31
40
|
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
32
|
-
const config =
|
|
41
|
+
const config = verifiedViaSlug !== undefined && verifiedViaSlug.length > 0
|
|
42
|
+
? { active_tenant_id: tenantId, verified_via_slug: verifiedViaSlug }
|
|
43
|
+
: { active_tenant_id: tenantId };
|
|
33
44
|
writeFileSync(this.path, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
34
45
|
try {
|
|
35
46
|
chmodSync(this.path, 0o600);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { CliError, EXIT } from './errors.js';
|
|
2
|
+
import { request, requestIdFromError } from './http.js';
|
|
3
|
+
import { resolveDpopKey } from './install-key.js';
|
|
4
|
+
export async function refreshSession(deps, session, keys, sessionStore, now) {
|
|
5
|
+
if (!session.refresh_token)
|
|
6
|
+
return undefined;
|
|
7
|
+
const dpopKey = resolveDpopKey(keys, session);
|
|
8
|
+
let result;
|
|
9
|
+
try {
|
|
10
|
+
result = await request({ fetchImpl: deps.fetchImpl, timeoutMs: deps.timeoutMs }, {
|
|
11
|
+
method: 'POST',
|
|
12
|
+
url: `${session.api_base_url}/oauth/token`,
|
|
13
|
+
form: {
|
|
14
|
+
grant_type: 'refresh_token',
|
|
15
|
+
refresh_token: session.refresh_token,
|
|
16
|
+
client_id: session.client_id,
|
|
17
|
+
},
|
|
18
|
+
dpop: dpopKey ? { key: dpopKey } : undefined,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
const cause = err instanceof CliError ? err.message : 'The Rakomi API could not be reached.';
|
|
23
|
+
throw new CliError(`Could not refresh your session: ${cause} You are still logged in — try again in a moment.`, EXIT.FAIL);
|
|
24
|
+
}
|
|
25
|
+
if (isTransientStatus(result.status)) {
|
|
26
|
+
const retryAfter = retryAfterSeconds(result.headers.get('retry-after'));
|
|
27
|
+
const wait = retryAfter !== undefined ? `in ${retryAfter}s` : 'in a moment';
|
|
28
|
+
const id = requestIdFromError(result.body);
|
|
29
|
+
throw new CliError(`Could not refresh your session: the Rakomi API is temporarily unavailable (HTTP ${result.status}). ` +
|
|
30
|
+
`You are still logged in — try again ${wait}.${id ? `\nRequest ID: ${id}` : ''}`, EXIT.FAIL);
|
|
31
|
+
}
|
|
32
|
+
if (result.status !== 200)
|
|
33
|
+
return undefined;
|
|
34
|
+
const token = result.body;
|
|
35
|
+
if (typeof token.access_token !== 'string' || token.access_token.length === 0)
|
|
36
|
+
return undefined;
|
|
37
|
+
if (typeof token.expires_in !== 'number' || !Number.isFinite(token.expires_in))
|
|
38
|
+
return undefined;
|
|
39
|
+
const updated = {
|
|
40
|
+
...session,
|
|
41
|
+
access_token: token.access_token,
|
|
42
|
+
refresh_token: token.refresh_token ?? session.refresh_token,
|
|
43
|
+
token_type: token.token_type === 'DPoP' ? 'DPoP' : 'Bearer',
|
|
44
|
+
expires_at: now() + token.expires_in * 1000,
|
|
45
|
+
};
|
|
46
|
+
sessionStore.write(updated);
|
|
47
|
+
return { accessToken: updated.access_token };
|
|
48
|
+
}
|
|
49
|
+
function isTransientStatus(status) {
|
|
50
|
+
return status >= 500 || status === 429 || status === 408;
|
|
51
|
+
}
|
|
52
|
+
function retryAfterSeconds(header) {
|
|
53
|
+
if (header === null || !/^\d+$/.test(header.trim()))
|
|
54
|
+
return undefined;
|
|
55
|
+
return Number(header.trim());
|
|
56
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { CliError, EXIT } from './errors.js';
|
|
4
|
+
import { backupSuffixFor, clientConfigPath, RAKOMI_SERVER_NAME } from './mcp-config.js';
|
|
5
|
+
function readIfPresent(file) {
|
|
6
|
+
try {
|
|
7
|
+
return readFileSync(file, 'utf8');
|
|
8
|
+
}
|
|
9
|
+
catch (err) {
|
|
10
|
+
if (err.code === 'ENOENT')
|
|
11
|
+
return null;
|
|
12
|
+
throw err;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function backupPath(configPath, serverName) {
|
|
16
|
+
return `${configPath}${backupSuffixFor(serverName)}`;
|
|
17
|
+
}
|
|
18
|
+
function tomlBasicString(value) {
|
|
19
|
+
return JSON.stringify(value);
|
|
20
|
+
}
|
|
21
|
+
function renderTomlBlockLines(header, fields) {
|
|
22
|
+
const lines = [`[${header}]`];
|
|
23
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
24
|
+
lines.push(`${key} = ${tomlBasicString(value)}`);
|
|
25
|
+
}
|
|
26
|
+
return lines;
|
|
27
|
+
}
|
|
28
|
+
export function mergeTomlServerTable(text, client, mcpUrl, serverName = RAKOMI_SERVER_NAME) {
|
|
29
|
+
if (!client.config || client.config.serialization !== 'toml') {
|
|
30
|
+
throw new CliError(`mergeTomlServerTable: '${client.id}' is not a TOML-shaped written client`, EXIT.FAIL);
|
|
31
|
+
}
|
|
32
|
+
const header = `${client.config.topLevelKey}.${serverName}`;
|
|
33
|
+
const fields = { ...client.config.extraFields, [client.config.remoteUrlField]: mcpUrl };
|
|
34
|
+
const newBlockLines = renderTomlBlockLines(header, fields);
|
|
35
|
+
const newBlockText = newBlockLines.join('\n');
|
|
36
|
+
const lines = text.length > 0 ? text.split('\n') : [];
|
|
37
|
+
const headerLine = `[${header}]`;
|
|
38
|
+
let start = -1;
|
|
39
|
+
for (let i = 0; i < lines.length; i++) {
|
|
40
|
+
if (lines[i]?.trim() === headerLine) {
|
|
41
|
+
start = i;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (start === -1) {
|
|
46
|
+
const needsSeparator = lines.length > 0 && lines[lines.length - 1] !== '';
|
|
47
|
+
const prefix = needsSeparator ? [...lines, ''] : lines;
|
|
48
|
+
const nextLines = [...prefix, ...newBlockLines, ''];
|
|
49
|
+
return { text: nextLines.join('\n'), changed: true, previousBlock: undefined };
|
|
50
|
+
}
|
|
51
|
+
let end = lines.length;
|
|
52
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
53
|
+
if (/^\[/.test(lines[i] ?? '')) {
|
|
54
|
+
end = i;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
let blockEnd = end;
|
|
59
|
+
while (blockEnd > start + 1 && lines[blockEnd - 1] === '')
|
|
60
|
+
blockEnd--;
|
|
61
|
+
const previousBlock = lines.slice(start, blockEnd).join('\n');
|
|
62
|
+
if (previousBlock === newBlockText) {
|
|
63
|
+
return { text, changed: false, previousBlock };
|
|
64
|
+
}
|
|
65
|
+
const nextLines = [...lines.slice(0, start), ...newBlockLines, ...lines.slice(end)];
|
|
66
|
+
return { text: nextLines.join('\n'), changed: true, previousBlock };
|
|
67
|
+
}
|
|
68
|
+
export function writeClientTomlConfigEntry(client, cwd, homeDir, mcpUrl, serverName = RAKOMI_SERVER_NAME) {
|
|
69
|
+
const path = clientConfigPath(client, cwd, homeDir);
|
|
70
|
+
const existing = readIfPresent(path);
|
|
71
|
+
const before = existing ?? '';
|
|
72
|
+
const { text: after, changed, previousBlock } = mergeTomlServerTable(before, client, mcpUrl, serverName);
|
|
73
|
+
if (!changed) {
|
|
74
|
+
return { wrote: false, backedUp: false, diff: `${serverName} already configured with ${mcpUrl} — no change.`, path };
|
|
75
|
+
}
|
|
76
|
+
let backedUp = false;
|
|
77
|
+
if (existing !== null) {
|
|
78
|
+
writeFileSync(backupPath(path, serverName), existing);
|
|
79
|
+
backedUp = true;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
83
|
+
}
|
|
84
|
+
writeFileSync(path, after.endsWith('\n') ? after : `${after}\n`);
|
|
85
|
+
const header = `${client.config.topLevelKey}.${serverName}`;
|
|
86
|
+
const diff = previousBlock !== undefined ? `[${header}]: updated` : `[${header}]: (added)`;
|
|
87
|
+
return { wrote: true, backedUp, diff, path };
|
|
88
|
+
}
|
|
89
|
+
export function undoClientTomlConfigWrite(client, cwd, homeDir, serverName = RAKOMI_SERVER_NAME) {
|
|
90
|
+
const path = clientConfigPath(client, cwd, homeDir);
|
|
91
|
+
const backup = readIfPresent(backupPath(path, serverName));
|
|
92
|
+
if (backup === null)
|
|
93
|
+
return false;
|
|
94
|
+
writeFileSync(path, backup);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
package/dist/usage.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { KNOWN_CLIENTS } from './clients.js';
|
|
1
2
|
export function helpText() {
|
|
3
|
+
const clientList = KNOWN_CLIENTS.join(' | ');
|
|
2
4
|
return [
|
|
3
5
|
'rakomi — the Rakomi CLI: sign in, connect an AI agent, and provision tenants',
|
|
4
6
|
'',
|
|
@@ -13,12 +15,14 @@ export function helpText() {
|
|
|
13
15
|
' logout clear the local session',
|
|
14
16
|
' whoami show the signed-in account, home tenant, and active tenant',
|
|
15
17
|
' use <tenant-id-or-slug> remember a tenant locally (whoami/future commands); a UUID is stored as-is, a slug is server-verified',
|
|
16
|
-
'
|
|
18
|
+
' upgrade open your current tenant\'s upgrade page in the browser',
|
|
19
|
+
' connect connect an MCP client to your tenant (read access)',
|
|
17
20
|
' --write also request write access for the connected client',
|
|
18
|
-
|
|
19
|
-
' --
|
|
21
|
+
` --client <name> ${clientList} (required if more than one applies)`,
|
|
22
|
+
' --name <server-name> the config key to write the entry under (default: rakomi) — one name per workspace on a machine',
|
|
23
|
+
' --undo restore the config file this command last backed up (for the same --name)',
|
|
20
24
|
' --cimd-url <url> the connecting client\'s own CIMD document URL — confirms the connection',
|
|
21
|
-
' --status re-check status only (needs --cimd-url; never rewrites
|
|
25
|
+
' --status re-check status only (needs --cimd-url; never rewrites the config file)',
|
|
22
26
|
' tenants create <name> create a tenant (parent-tenant only; depth-1 enforced server-side)',
|
|
23
27
|
' --owner <me|email> who becomes the new tenant\'s owner (default: me)',
|
|
24
28
|
' --slug <slug> optional; auto-derived + suffixed if omitted',
|
|
@@ -38,7 +42,7 @@ export function helpText() {
|
|
|
38
42
|
' --dry-run print what would happen, make no writes or mutating calls',
|
|
39
43
|
' --no-browser use the RFC 8628 device-code flow instead of opening a browser',
|
|
40
44
|
' --no-keychain store the session in a 0600 file instead of the OS keychain',
|
|
41
|
-
' --tenant <id> the tenant for this invocation (whoami, tenants claim/release)',
|
|
45
|
+
' --tenant <id> the tenant for this invocation (whoami, upgrade, tenants claim/release)',
|
|
42
46
|
' -h, --help show this help and exit',
|
|
43
47
|
' -V, --version print the version and exit',
|
|
44
48
|
'',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rakomi",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "The Rakomi CLI — sign in, connect an AI agent (Claude Code, Claude Desktop) to your tenant, and provision tenants. EU-native auth-as-a-service.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"rakomi",
|
|
@@ -42,6 +42,6 @@
|
|
|
42
42
|
"build": "tsc",
|
|
43
43
|
"typecheck": "tsc --noEmit",
|
|
44
44
|
"lint": "eslint src/ test/ --max-warnings=0",
|
|
45
|
-
"test": "vitest run"
|
|
45
|
+
"test": "node ../../scripts/assert-not-story-agent-worktree.mjs test && node ../../scripts/ci/vitest-fork-crash-retry.mjs --results ./test-results.json -- vitest run"
|
|
46
46
|
}
|
|
47
47
|
}
|
package/sbom.cdx.json
CHANGED
|
@@ -26,10 +26,10 @@
|
|
|
26
26
|
},
|
|
27
27
|
"component": {
|
|
28
28
|
"type": "library",
|
|
29
|
-
"bom-ref": "pkg:npm/rakomi@0.3.
|
|
29
|
+
"bom-ref": "pkg:npm/rakomi@0.3.1",
|
|
30
30
|
"name": "rakomi",
|
|
31
|
-
"version": "0.3.
|
|
32
|
-
"purl": "pkg:npm/rakomi@0.3.
|
|
31
|
+
"version": "0.3.1",
|
|
32
|
+
"purl": "pkg:npm/rakomi@0.3.1",
|
|
33
33
|
"licenses": [
|
|
34
34
|
{
|
|
35
35
|
"license": {
|