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.
@@ -64,3 +64,46 @@ export async function listTenants(deps, opts) {
64
64
  }
65
65
  return result.body;
66
66
  }
67
+ function describeCallerUnverified(action) {
68
+ return (`Could not ${action}: your account e-mail is not verified (or is disabled). Verify your e-mail ` +
69
+ 'and sign in again.');
70
+ }
71
+ export async function listTenantMemberships(deps, opts) {
72
+ const result = await request(deps, {
73
+ method: 'GET',
74
+ url: `${opts.apiBaseUrl}/v1/tenants/memberships`,
75
+ headers: opts.dpop ? undefined : { authorization: `Bearer ${opts.accessToken}` },
76
+ dpop: opts.dpop ? { key: opts.dpop, accessToken: opts.accessToken } : undefined,
77
+ });
78
+ if (result.status === 401) {
79
+ throw new CliError('Your session has expired. Run `rakomi login` again.', EXIT.NOT_LOGGED_IN);
80
+ }
81
+ if (result.status === 403) {
82
+ throw new CliError(describeCallerUnverified('list your tenant memberships'), EXIT.FAIL);
83
+ }
84
+ if (result.status !== 200) {
85
+ throw new CliError(`Could not list your tenant memberships: ${describeError(result.body, result.status)}`, EXIT.FAIL);
86
+ }
87
+ return result.body;
88
+ }
89
+ export async function getTenantMembershipBySlug(deps, opts) {
90
+ const result = await request(deps, {
91
+ method: 'GET',
92
+ url: `${opts.apiBaseUrl}/v1/tenants/memberships/${encodeURIComponent(opts.slug)}`,
93
+ headers: opts.dpop ? undefined : { authorization: `Bearer ${opts.accessToken}` },
94
+ dpop: opts.dpop ? { key: opts.dpop, accessToken: opts.accessToken } : undefined,
95
+ });
96
+ if (result.status === 401) {
97
+ throw new CliError('Your session has expired. Run `rakomi login` again.', EXIT.NOT_LOGGED_IN);
98
+ }
99
+ if (result.status === 404) {
100
+ return null;
101
+ }
102
+ if (result.status === 403) {
103
+ throw new CliError(describeCallerUnverified('look up this tenant membership'), EXIT.FAIL);
104
+ }
105
+ if (result.status !== 200) {
106
+ throw new CliError(`Could not look up tenant "${opts.slug}": ${describeError(result.body, result.status)}`, EXIT.FAIL);
107
+ }
108
+ return result.body;
109
+ }
@@ -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
  '',
@@ -12,17 +14,26 @@ export function helpText() {
12
14
  ' --tenant-id <uuid> the tenant to authenticate against (or set RAKOMI_PLATFORM_TENANT_ID)',
13
15
  ' logout clear the local session',
14
16
  ' whoami show the signed-in account, home tenant, and active tenant',
15
- ' use <tenant-id> remember a tenant id locally for whoami/future commands (a value, not a verified membership)',
16
- ' connect connect Claude Code / Claude Desktop to your tenant (read access)',
17
+ ' use <tenant-id-or-slug> remember a tenant locally (whoami/future commands); a UUID is stored as-is, a slug is server-verified',
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
- ' --client <name> claude-code | claude-desktop (required if more than one applies)',
19
- ' --undo restore the .mcp.json this command last backed up',
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 .mcp.json)',
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',
25
29
  ' tenants list list tenants you provisioned',
30
+ ' tenants memberships list tenants your account is a verified member of',
31
+ ' tenants claim --ci claim a temporary, self-expiring tenant under your own tenant',
32
+ ' --tenant <id> your own tenant to claim under (or run `rakomi use <tenant-id>` first)',
33
+ ' --ttl-seconds <n> how long the claim should live, in seconds (only for a freshly-created tenant)',
34
+ ' --label <text> optional label, stored and echoed back, never interpreted',
35
+ ' tenants release --ci <tenant-id> release a tenant claimed by `tenants claim`',
36
+ ' --tenant <id> the tenant it was claimed under (or run `rakomi use <tenant-id>` first)',
26
37
  ' mcp tools --ci list MCP read tools your CI identity can invoke (no login needed first)',
27
38
  '',
28
39
  'Global options:',
@@ -31,7 +42,7 @@ export function helpText() {
31
42
  ' --dry-run print what would happen, make no writes or mutating calls',
32
43
  ' --no-browser use the RFC 8628 device-code flow instead of opening a browser',
33
44
  ' --no-keychain store the session in a 0600 file instead of the OS keychain',
34
- ' --tenant <id> override the active tenant for this invocation (whoami only today)',
45
+ ' --tenant <id> the tenant for this invocation (whoami, upgrade, tenants claim/release)',
35
46
  ' -h, --help show this help and exit',
36
47
  ' -V, --version print the version and exit',
37
48
  '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rakomi",
3
- "version": "0.2.0",
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.2.0",
29
+ "bom-ref": "pkg:npm/rakomi@0.3.1",
30
30
  "name": "rakomi",
31
- "version": "0.2.0",
32
- "purl": "pkg:npm/rakomi@0.2.0",
31
+ "version": "0.3.1",
32
+ "purl": "pkg:npm/rakomi@0.3.1",
33
33
  "licenses": [
34
34
  {
35
35
  "license": {