rakomi 0.2.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 +29 -12
- 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/tenants.js +75 -1
- package/dist/commands/upgrade.js +21 -0
- package/dist/commands/use.js +23 -7
- package/dist/commands/whoami.js +25 -5
- package/dist/env.js +5 -1
- package/dist/http.js +58 -1
- package/dist/index.js +68 -8
- package/dist/lease-client.js +64 -0
- 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/tenants-client.js +43 -0
- package/dist/token-refresh.js +56 -0
- package/dist/toml-config.js +96 -0
- package/dist/usage.js +17 -6
- package/package.json +2 -2
- package/sbom.cdx.json +3 -3
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/env.js
CHANGED
|
@@ -3,6 +3,7 @@ export const DEFAULT_API_BASE_URL = 'https://api.rakomi.com';
|
|
|
3
3
|
export const DEFAULT_ACCOUNTS_BASE_URL = 'https://accounts.rakomi.com';
|
|
4
4
|
export const DEFAULT_MCP_URL = 'https://mcp.rakomi.com/mcp';
|
|
5
5
|
export const DEFAULT_CIMD_CLIENT_ID_URL = 'https://api.rakomi.com/.well-known/rakomi-cli/client-metadata.json';
|
|
6
|
+
export const DEFAULT_PLATFORM_TENANT_ID = '01ef847e-4c40-4960-af05-11112c4764b2';
|
|
6
7
|
export const CI_FEDERATION_SESSION_CLIENT_ID = 'oidc-federation';
|
|
7
8
|
export function isCimdClientId(id) {
|
|
8
9
|
let url;
|
|
@@ -36,7 +37,10 @@ export function isValidTenantId(id) {
|
|
|
36
37
|
}
|
|
37
38
|
export function platformTenantId(env) {
|
|
38
39
|
const raw = env.RAKOMI_PLATFORM_TENANT_ID;
|
|
39
|
-
|
|
40
|
+
if (raw)
|
|
41
|
+
return isValidTenantId(raw) ? raw : undefined;
|
|
42
|
+
const isUnoverriddenProdHost = apiBaseUrl(env) === DEFAULT_API_BASE_URL && accountsBaseUrl(env) === DEFAULT_ACCOUNTS_BASE_URL;
|
|
43
|
+
return isUnoverriddenProdHost ? DEFAULT_PLATFORM_TENANT_ID : undefined;
|
|
40
44
|
}
|
|
41
45
|
const CLIENT_ID_METADATA_DOCUMENT_SUPPORTED_FIELD = 'client_id_metadata_document_supported';
|
|
42
46
|
export async function isCimdPlatformSupported(deps, apiUrl) {
|
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';
|
|
@@ -9,7 +10,8 @@ import { runConnect } from './commands/connect.js';
|
|
|
9
10
|
import { runLogin, runLoginCi } from './commands/login.js';
|
|
10
11
|
import { runLogout } from './commands/logout.js';
|
|
11
12
|
import { runMcpToolsCi } from './commands/mcp.js';
|
|
12
|
-
import { runTenantsCreate, runTenantsList } from './commands/tenants.js';
|
|
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' },
|
|
@@ -33,8 +36,11 @@ const GLOBAL_OPTIONS = {
|
|
|
33
36
|
slug: { type: 'string' },
|
|
34
37
|
'tenant-id': { type: 'string' },
|
|
35
38
|
tenant: { type: 'string' },
|
|
39
|
+
'ttl-seconds': { type: 'string' },
|
|
40
|
+
label: { type: 'string' },
|
|
36
41
|
'cimd-url': { type: 'string' },
|
|
37
42
|
status: { type: 'boolean' },
|
|
43
|
+
name: { type: 'string' },
|
|
38
44
|
help: { type: 'boolean', short: 'h' },
|
|
39
45
|
version: { type: 'boolean', short: 'V' },
|
|
40
46
|
};
|
|
@@ -55,6 +61,15 @@ export async function run(args, deps) {
|
|
|
55
61
|
return EXIT.FAIL;
|
|
56
62
|
}
|
|
57
63
|
}
|
|
64
|
+
function parseTtlSeconds(raw) {
|
|
65
|
+
if (typeof raw !== 'string')
|
|
66
|
+
return undefined;
|
|
67
|
+
const n = Number(raw);
|
|
68
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
69
|
+
throw new UsageError(`--ttl-seconds must be a positive integer, got "${raw}"`);
|
|
70
|
+
}
|
|
71
|
+
return n;
|
|
72
|
+
}
|
|
58
73
|
async function dispatch(args, deps) {
|
|
59
74
|
let values;
|
|
60
75
|
let positionals;
|
|
@@ -78,6 +93,13 @@ async function dispatch(args, deps) {
|
|
|
78
93
|
const dryRun = values['dry-run'] === true;
|
|
79
94
|
const json = values.json === true;
|
|
80
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 };
|
|
81
103
|
const [command, ...rest] = positionals;
|
|
82
104
|
switch (command) {
|
|
83
105
|
case 'login': {
|
|
@@ -115,7 +137,7 @@ async function dispatch(args, deps) {
|
|
|
115
137
|
return EXIT.OK;
|
|
116
138
|
case 'whoami':
|
|
117
139
|
await runWhoami({
|
|
118
|
-
...
|
|
140
|
+
...authHttpDeps,
|
|
119
141
|
session: deps.session,
|
|
120
142
|
keys: deps.keys,
|
|
121
143
|
json,
|
|
@@ -127,16 +149,28 @@ async function dispatch(args, deps) {
|
|
|
127
149
|
case 'use': {
|
|
128
150
|
const tenantId = rest[0];
|
|
129
151
|
if (!tenantId)
|
|
130
|
-
throw new UsageError('Usage: rakomi use <tenant-id>');
|
|
131
|
-
runUse({ tenantConfig: deps.tenantConfig, stdout: deps.stdout }, { tenantId });
|
|
152
|
+
throw new UsageError('Usage: rakomi use <tenant-id-or-slug>');
|
|
153
|
+
await runUse({ ...authHttpDeps, session: deps.session, keys: deps.keys, tenantConfig: deps.tenantConfig, stdout: deps.stdout }, { tenantId });
|
|
132
154
|
return EXIT.OK;
|
|
133
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;
|
|
134
167
|
case 'connect':
|
|
135
168
|
await runConnect({
|
|
136
|
-
...
|
|
169
|
+
...authHttpDeps,
|
|
137
170
|
session: deps.session,
|
|
138
171
|
keys: deps.keys,
|
|
139
172
|
cwd: deps.cwd,
|
|
173
|
+
homeDir: homedir(),
|
|
140
174
|
apiBaseUrl: apiBaseUrl(deps.env),
|
|
141
175
|
mcpUrl: deps.env.RAKOMI_API_URL ? `${apiBaseUrl(deps.env)}/mcp` : DEFAULT_MCP_URL,
|
|
142
176
|
detectClaudeCode: deps.detectClaudeCode,
|
|
@@ -148,6 +182,7 @@ async function dispatch(args, deps) {
|
|
|
148
182
|
explicitClient: typeof values.client === 'string' ? values.client : undefined,
|
|
149
183
|
cimdUrl: typeof values['cimd-url'] === 'string' ? values['cimd-url'] : undefined,
|
|
150
184
|
status: values.status === true,
|
|
185
|
+
serverName: typeof values.name === 'string' ? values.name : undefined,
|
|
151
186
|
});
|
|
152
187
|
return EXIT.OK;
|
|
153
188
|
case 'mcp': {
|
|
@@ -176,14 +211,39 @@ async function dispatch(args, deps) {
|
|
|
176
211
|
const name = tenantArgs[0];
|
|
177
212
|
if (!name)
|
|
178
213
|
throw new UsageError('Usage: rakomi tenants create <name> [--owner me|<email>] [--slug <slug>]');
|
|
179
|
-
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' });
|
|
180
215
|
return EXIT.OK;
|
|
181
216
|
}
|
|
182
217
|
if (sub === 'list') {
|
|
183
|
-
await runTenantsList({ ...
|
|
218
|
+
await runTenantsList({ ...authHttpDeps, session: deps.session, keys: deps.keys, json, stdout: deps.stdout });
|
|
219
|
+
return EXIT.OK;
|
|
220
|
+
}
|
|
221
|
+
if (sub === 'memberships') {
|
|
222
|
+
await runTenantsMemberships({ ...authHttpDeps, session: deps.session, keys: deps.keys, json, stdout: deps.stdout });
|
|
223
|
+
return EXIT.OK;
|
|
224
|
+
}
|
|
225
|
+
if (sub === 'claim') {
|
|
226
|
+
if (values.ci !== true) {
|
|
227
|
+
throw new UsageError('rakomi tenants claim requires --ci (no interactive form exists yet).');
|
|
228
|
+
}
|
|
229
|
+
await runTenantsClaim({ ...authHttpDeps, session: deps.session, keys: deps.keys, tenantConfig: deps.tenantConfig, json, stdout: deps.stdout }, {
|
|
230
|
+
parentTenantId: typeof values.tenant === 'string' ? values.tenant : undefined,
|
|
231
|
+
ttlSeconds: parseTtlSeconds(values['ttl-seconds']),
|
|
232
|
+
label: typeof values.label === 'string' ? values.label : undefined,
|
|
233
|
+
});
|
|
234
|
+
return EXIT.OK;
|
|
235
|
+
}
|
|
236
|
+
if (sub === 'release') {
|
|
237
|
+
if (values.ci !== true) {
|
|
238
|
+
throw new UsageError('rakomi tenants release requires --ci (no interactive form exists yet).');
|
|
239
|
+
}
|
|
240
|
+
const tenantId = tenantArgs[0];
|
|
241
|
+
if (!tenantId)
|
|
242
|
+
throw new UsageError('Usage: rakomi tenants release --ci <tenant-id> [--tenant <parent-tenant-id>]');
|
|
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 });
|
|
184
244
|
return EXIT.OK;
|
|
185
245
|
}
|
|
186
|
-
throw new UsageError('Usage: rakomi tenants <create|list> ...');
|
|
246
|
+
throw new UsageError('Usage: rakomi tenants <create|list|claim|release> ...');
|
|
187
247
|
}
|
|
188
248
|
default:
|
|
189
249
|
throw new UsageError(`Unknown command "${command ?? ''}".`);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { CliError, EXIT } from './errors.js';
|
|
3
|
+
import { describeError, errorCode, request } from './http.js';
|
|
4
|
+
export const EPHEMERAL_TENANT_TTL_SECONDS_MIN = 600;
|
|
5
|
+
export const EPHEMERAL_TENANT_TTL_SECONDS_MAX = 1_209_600;
|
|
6
|
+
export const EPHEMERAL_LEASE_LABEL_MAX_LENGTH = 200;
|
|
7
|
+
function generateIdempotencyKey() {
|
|
8
|
+
return randomBytes(16).toString('hex');
|
|
9
|
+
}
|
|
10
|
+
const CHILD_TENANT_LIMIT_REACHED_CODE = 'tenant/child_tenant_limit_reached';
|
|
11
|
+
export async function claimEphemeralTenant(deps, opts) {
|
|
12
|
+
const result = await request(deps, {
|
|
13
|
+
method: 'POST',
|
|
14
|
+
url: `${opts.apiBaseUrl}/v1/tenants/${encodeURIComponent(opts.parentTenantId)}/ephemeral/claims`,
|
|
15
|
+
headers: opts.dpop ? undefined : { authorization: `Bearer ${opts.accessToken}` },
|
|
16
|
+
body: {
|
|
17
|
+
...(opts.ttlSeconds !== undefined ? { ttl_seconds: opts.ttlSeconds } : {}),
|
|
18
|
+
...(opts.label ? { label: opts.label } : {}),
|
|
19
|
+
},
|
|
20
|
+
idempotencyKey: opts.idempotencyKey ?? generateIdempotencyKey(),
|
|
21
|
+
dpop: opts.dpop ? { key: opts.dpop, accessToken: opts.accessToken } : undefined,
|
|
22
|
+
});
|
|
23
|
+
if (result.status === 401) {
|
|
24
|
+
throw new CliError('Your session has expired. Run `rakomi login --ci` again.', EXIT.NOT_LOGGED_IN);
|
|
25
|
+
}
|
|
26
|
+
if (result.status === 403) {
|
|
27
|
+
throw new CliError(`Could not claim an ephemeral tenant: ${describeError(result.body, result.status)} ` +
|
|
28
|
+
"(missing the `tenants:lease` scope — check this CI identity's trust policy).", EXIT.FAIL);
|
|
29
|
+
}
|
|
30
|
+
if (result.status === 404) {
|
|
31
|
+
throw new CliError(`Could not claim an ephemeral tenant: ${describeError(result.body, result.status)} ` +
|
|
32
|
+
"(the given tenant is not the caller's own tenant, or the caller's own tenant is itself " +
|
|
33
|
+
'ephemeral — ephemeral tenants cannot nest).', EXIT.FAIL);
|
|
34
|
+
}
|
|
35
|
+
if (result.status === 409 && errorCode(result.body) === CHILD_TENANT_LIMIT_REACHED_CODE) {
|
|
36
|
+
throw new CliError(`Could not claim an ephemeral tenant: ${describeError(result.body, result.status)}.`, EXIT.FAIL);
|
|
37
|
+
}
|
|
38
|
+
if (result.status !== 201) {
|
|
39
|
+
throw new CliError(`Could not claim an ephemeral tenant: ${describeError(result.body, result.status)}`, EXIT.FAIL);
|
|
40
|
+
}
|
|
41
|
+
return result.body;
|
|
42
|
+
}
|
|
43
|
+
export async function releaseEphemeralTenant(deps, opts) {
|
|
44
|
+
const result = await request(deps, {
|
|
45
|
+
method: 'DELETE',
|
|
46
|
+
url: `${opts.apiBaseUrl}/v1/tenants/${encodeURIComponent(opts.parentTenantId)}/ephemeral/claims/${encodeURIComponent(opts.tenantId)}`,
|
|
47
|
+
headers: opts.dpop ? undefined : { authorization: `Bearer ${opts.accessToken}` },
|
|
48
|
+
dpop: opts.dpop ? { key: opts.dpop, accessToken: opts.accessToken } : undefined,
|
|
49
|
+
});
|
|
50
|
+
if (result.status === 401) {
|
|
51
|
+
throw new CliError('Your session has expired. Run `rakomi login --ci` again.', EXIT.NOT_LOGGED_IN);
|
|
52
|
+
}
|
|
53
|
+
if (result.status === 403) {
|
|
54
|
+
throw new CliError(`Could not release the ephemeral tenant: ${describeError(result.body, result.status)} ` +
|
|
55
|
+
"(missing the `tenants:lease` scope — check this CI identity's trust policy).", EXIT.FAIL);
|
|
56
|
+
}
|
|
57
|
+
if (result.status === 404) {
|
|
58
|
+
throw new CliError(`Could not release the ephemeral tenant: ${describeError(result.body, result.status)} ` +
|
|
59
|
+
'(the given tenant id does not exist under the given parent, or is not an ephemeral tenant).', EXIT.FAIL);
|
|
60
|
+
}
|
|
61
|
+
if (result.status !== 204) {
|
|
62
|
+
throw new CliError(`Could not release the ephemeral tenant: ${describeError(result.body, result.status)}`, EXIT.FAIL);
|
|
63
|
+
}
|
|
64
|
+
}
|
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);
|