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.
@@ -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 session = deps.session.read();
6
- if (!session)
19
+ const initialSession = deps.session.read();
20
+ if (!initialSession)
7
21
  throw new NotLoggedInError();
8
- const dpop = resolveDpopKey(deps.keys, session);
9
- const info = await fetchUserInfo(deps, { apiBaseUrl: session.api_base_url, accessToken: session.access_token, dpop });
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: ${activeTenantId ?? 'none set (run `rakomi use <tenant-id>`)'}`);
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
- return raw && isValidTenantId(raw) ? raw : undefined;
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
- export async function request(deps, req) {
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
- ...httpDeps,
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
- ...httpDeps,
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({ ...httpDeps, 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' });
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({ ...httpDeps, session: deps.session, keys: deps.keys, json, stdout: deps.stdout });
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
+ }
@@ -1,11 +1,90 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { CliError, EXIT } from './errors.js';
3
- const SUCCESS_PAGE = `<!doctype html><html><head><title>rakomi login</title></head>
4
- <body style="font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;text-align:center">
5
- <h1>Signed in</h1><p>You can close this tab and return to your terminal.</p></body></html>`;
6
- const ERROR_PAGE = (message) => `<!doctype html><html><head><title>rakomi login</title></head>
7
- <body style="font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;text-align:center">
8
- <h1>Sign-in failed</h1><p>${escapeHtml(message)}</p><p>Return to your terminal and try again.</p></body></html>`;
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">&#10003;</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">&#10005;</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
11
90
  }
@@ -1,7 +1,12 @@
1
- import { readFileSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- export const RAKOMI_SERVER_NAME = 'rakomi';
4
- const BACKUP_SUFFIX = '.rakomi-backup';
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 mcpConfigPath(projectDir) {
16
- return join(projectDir, '.mcp.json');
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}${BACKUP_SUFFIX}`;
27
+ function backupPath(configPath, serverName) {
28
+ return `${configPath}${backupSuffixFor(serverName)}`;
20
29
  }
21
- function readConfig(path) {
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
- export function mergeRakomiServerEntry(config, mcpUrl) {
36
- const previousEntry = config.mcpServers?.[RAKOMI_SERVER_NAME];
37
- const nextEntry = { type: 'http', url: mcpUrl };
38
- const alreadyIdentical = previousEntry !== undefined &&
39
- typeof previousEntry === 'object' &&
40
- previousEntry.type === 'http' &&
41
- previousEntry.url === mcpUrl;
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
- mcpServers: { ...(config.mcpServers ?? {}), [RAKOMI_SERVER_NAME]: nextEntry },
64
+ [topLevelKey]: { ...bucket, [serverName]: nextEntry },
47
65
  };
48
66
  return { config: merged, changed: true, previousEntry };
49
67
  }
50
- export function writeRakomiMcpServerEntry(projectDir, mcpUrl) {
51
- const path = mcpConfigPath(projectDir);
52
- const before = readConfig(path);
53
- const { config: after, changed, previousEntry } = mergeRakomiServerEntry(before, mcpUrl);
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: `${RAKOMI_SERVER_NAME} already configured with ${mcpUrl} — no change.` };
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
- ? `mcpServers.${RAKOMI_SERVER_NAME}: ${JSON.stringify(previousEntry)} -> ${JSON.stringify(after.mcpServers?.[RAKOMI_SERVER_NAME])}`
66
- : `mcpServers.${RAKOMI_SERVER_NAME}: (added) ${JSON.stringify(after.mcpServers?.[RAKOMI_SERVER_NAME])}`;
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 undoMcpConfigWrite(projectDir) {
70
- const path = mcpConfigPath(projectDir);
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
+ }
@@ -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
- return typeof v.active_tenant_id === 'string' && v.active_tenant_id.length > 0;
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
- return isStoredTenantConfig(parsed) ? parsed.active_tenant_id : null;
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 = { active_tenant_id: tenantId };
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);