rakomi 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { delimiter, join } from 'node:path';
4
+ import process from 'node:process';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { parseArgs } from 'node:util';
7
+ import { systemBrowserOpener } from './browser.js';
8
+ import { runConnect } from './commands/connect.js';
9
+ import { runLogin } from './commands/login.js';
10
+ import { runLogout } from './commands/logout.js';
11
+ import { runTenantsCreate, runTenantsList } from './commands/tenants.js';
12
+ import { runWhoami } from './commands/whoami.js';
13
+ import { apiBaseUrl, DEFAULT_MCP_URL } from './env.js';
14
+ import { CliError, EXIT, UsageError } from './errors.js';
15
+ import { startLoopbackListener } from './loopback-server.js';
16
+ import { resolveStores } from './session.js';
17
+ import { helpText, usageLine } from './usage.js';
18
+ const GLOBAL_OPTIONS = {
19
+ json: { type: 'boolean' },
20
+ yes: { type: 'boolean' },
21
+ ci: { type: 'boolean' },
22
+ 'dry-run': { type: 'boolean' },
23
+ 'no-browser': { type: 'boolean' },
24
+ 'no-keychain': { type: 'boolean' },
25
+ client: { type: 'string' },
26
+ undo: { type: 'boolean' },
27
+ write: { type: 'boolean' },
28
+ owner: { type: 'string' },
29
+ slug: { type: 'string' },
30
+ 'cimd-url': { type: 'string' },
31
+ status: { type: 'boolean' },
32
+ help: { type: 'boolean', short: 'h' },
33
+ version: { type: 'boolean', short: 'V' },
34
+ };
35
+ export async function run(args, deps) {
36
+ try {
37
+ return await dispatch(args, deps);
38
+ }
39
+ catch (e) {
40
+ if (e instanceof UsageError) {
41
+ deps.stderr.write(`${e.message}\n${usageLine()}\n`);
42
+ return e.exitCode;
43
+ }
44
+ if (e instanceof CliError) {
45
+ deps.stderr.write(`${e.message}\n`);
46
+ return e.exitCode;
47
+ }
48
+ deps.stderr.write('An unexpected error occurred.\n');
49
+ return EXIT.FAIL;
50
+ }
51
+ }
52
+ async function dispatch(args, deps) {
53
+ let values;
54
+ let positionals;
55
+ try {
56
+ const parsed = parseArgs({ args: [...args], allowPositionals: true, options: GLOBAL_OPTIONS });
57
+ values = parsed.values;
58
+ positionals = parsed.positionals;
59
+ }
60
+ catch {
61
+ throw new UsageError('Unknown or malformed argument.');
62
+ }
63
+ if (values.help === true || args.length === 0) {
64
+ deps.stdout.write(helpText());
65
+ return EXIT.OK;
66
+ }
67
+ if (values.version === true) {
68
+ deps.stdout.write(`${deps.version}\n`);
69
+ return EXIT.OK;
70
+ }
71
+ const ci = values.ci === true || values.yes === true || Boolean(deps.env.CI);
72
+ const dryRun = values['dry-run'] === true;
73
+ const json = values.json === true;
74
+ const httpDeps = { fetchImpl: deps.fetchImpl };
75
+ const [command, ...rest] = positionals;
76
+ switch (command) {
77
+ case 'login':
78
+ await runLogin({
79
+ ...httpDeps,
80
+ env: deps.env,
81
+ session: deps.session,
82
+ noBrowser: values['no-browser'] === true || ci,
83
+ openBrowser: systemBrowserOpener(),
84
+ startLoopback: startLoopbackListener,
85
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
86
+ stdout: deps.stdout,
87
+ now: () => Date.now(),
88
+ });
89
+ return EXIT.OK;
90
+ case 'logout':
91
+ runLogout({ session: deps.session, stdout: deps.stdout });
92
+ return EXIT.OK;
93
+ case 'whoami':
94
+ await runWhoami({ ...httpDeps, session: deps.session, json, stdout: deps.stdout });
95
+ return EXIT.OK;
96
+ case 'connect':
97
+ await runConnect({
98
+ ...httpDeps,
99
+ session: deps.session,
100
+ cwd: deps.cwd,
101
+ apiBaseUrl: apiBaseUrl(deps.env),
102
+ mcpUrl: deps.env.RAKOMI_API_URL ? `${apiBaseUrl(deps.env)}/mcp` : DEFAULT_MCP_URL,
103
+ detectClaudeCode: deps.detectClaudeCode,
104
+ stdout: deps.stdout,
105
+ write: values.write === true,
106
+ dryRun,
107
+ ci,
108
+ undo: values.undo === true,
109
+ explicitClient: typeof values.client === 'string' ? values.client : undefined,
110
+ cimdUrl: typeof values['cimd-url'] === 'string' ? values['cimd-url'] : undefined,
111
+ status: values.status === true,
112
+ });
113
+ return EXIT.OK;
114
+ case 'tenants': {
115
+ const [sub, ...tenantArgs] = rest;
116
+ if (sub === 'create') {
117
+ const name = tenantArgs[0];
118
+ if (!name)
119
+ throw new UsageError('Usage: rakomi tenants create <name> [--owner me|<email>] [--slug <slug>]');
120
+ await runTenantsCreate({ ...httpDeps, session: deps.session, json, dryRun, ci, stdout: deps.stdout }, { name, slug: typeof values.slug === 'string' ? values.slug : undefined, owner: typeof values.owner === 'string' ? values.owner : 'me' });
121
+ return EXIT.OK;
122
+ }
123
+ if (sub === 'list') {
124
+ await runTenantsList({ ...httpDeps, session: deps.session, json, stdout: deps.stdout });
125
+ return EXIT.OK;
126
+ }
127
+ throw new UsageError('Usage: rakomi tenants <create|list> ...');
128
+ }
129
+ default:
130
+ throw new UsageError(`Unknown command "${command ?? ''}".`);
131
+ }
132
+ }
133
+ async function main() {
134
+ const major = Number(process.versions.node.split('.')[0]);
135
+ if (Number.isFinite(major) && major < 22) {
136
+ process.stderr.write('rakomi needs Node.js 22 or newer.\n');
137
+ process.exitCode = EXIT.FAIL;
138
+ return;
139
+ }
140
+ let version = '0.0.0';
141
+ try {
142
+ version = String(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
143
+ }
144
+ catch {
145
+ }
146
+ const env = {
147
+ RAKOMI_API_URL: process.env.RAKOMI_API_URL,
148
+ RAKOMI_ACCOUNTS_URL: process.env.RAKOMI_ACCOUNTS_URL,
149
+ RAKOMI_CLIENT_ID: process.env.RAKOMI_CLIENT_ID,
150
+ RAKOMI_CONFIG_DIR: process.env.RAKOMI_CONFIG_DIR,
151
+ CI: process.env.CI,
152
+ NO_COLOR: process.env.NO_COLOR,
153
+ };
154
+ const noKeychainEnv = process.env.RAKOMI_NO_KEYCHAIN;
155
+ const noKeychainFlag = process.argv.slice(2).includes('--no-keychain');
156
+ const resolved = resolveStores(env, { noKeychain: noKeychainFlag || Boolean(env.CI) || Boolean(noKeychainEnv) });
157
+ if (resolved.fallbackDisclosure) {
158
+ process.stderr.write(resolved.fallbackDisclosure + '\n');
159
+ }
160
+ process.exitCode = await run(process.argv.slice(2), {
161
+ stdout: { write: (text) => void process.stdout.write(text) },
162
+ stderr: { write: (text) => void process.stderr.write(text) },
163
+ env,
164
+ cwd: process.cwd(),
165
+ version,
166
+ isTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY),
167
+ fetchImpl: fetch,
168
+ session: resolved.session,
169
+ detectClaudeCode: () => detectClaudeCodeCli(),
170
+ });
171
+ }
172
+ function detectClaudeCodeCli() {
173
+ const pathEnv = process.env.PATH ?? '';
174
+ const binaryName = process.platform === 'win32' ? 'claude.cmd' : 'claude';
175
+ return pathEnv.split(delimiter).some((dir) => dir.length > 0 && existsSync(join(dir, binaryName)));
176
+ }
177
+ function isCliEntry() {
178
+ const argv1 = process.argv[1];
179
+ if (!argv1)
180
+ return false;
181
+ const real = (p) => {
182
+ try {
183
+ return realpathSync(p);
184
+ }
185
+ catch {
186
+ return p;
187
+ }
188
+ };
189
+ try {
190
+ return real(argv1) === real(fileURLToPath(import.meta.url));
191
+ }
192
+ catch {
193
+ return false;
194
+ }
195
+ }
196
+ if (isCliEntry()) {
197
+ void main();
198
+ }
@@ -0,0 +1,63 @@
1
+ import { createServer } from 'node:http';
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>`;
9
+ function escapeHtml(s) {
10
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
11
+ }
12
+ export async function startLoopbackListener() {
13
+ let resolveCallback;
14
+ let rejectCallback;
15
+ let settled = false;
16
+ const server = createServer((req, res) => {
17
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
18
+ if (url.pathname !== '/callback') {
19
+ res.writeHead(404).end();
20
+ return;
21
+ }
22
+ const code = url.searchParams.get('code') ?? undefined;
23
+ const state = url.searchParams.get('state') ?? undefined;
24
+ const error = url.searchParams.get('error') ?? undefined;
25
+ const errorDescription = url.searchParams.get('error_description') ?? undefined;
26
+ res.writeHead(error ? 400 : 200, { 'content-type': 'text/html; charset=utf-8' });
27
+ res.end(error ? ERROR_PAGE(errorDescription ?? error) : SUCCESS_PAGE);
28
+ if (!settled) {
29
+ settled = true;
30
+ resolveCallback?.({ code, state, error, errorDescription });
31
+ }
32
+ });
33
+ await new Promise((resolve, reject) => {
34
+ server.once('error', reject);
35
+ server.listen(0, '127.0.0.1', () => resolve());
36
+ });
37
+ const address = server.address();
38
+ if (address === null || typeof address === 'string') {
39
+ server.close();
40
+ throw new CliError('Could not start the local sign-in listener.', EXIT.FAIL);
41
+ }
42
+ const redirectUri = `http://127.0.0.1:${address.port}/callback`;
43
+ return {
44
+ redirectUri,
45
+ waitForCallback(timeoutMs) {
46
+ return new Promise((resolve, reject) => {
47
+ resolveCallback = resolve;
48
+ rejectCallback = reject;
49
+ const timer = setTimeout(() => {
50
+ if (!settled) {
51
+ settled = true;
52
+ reject(new CliError('Timed out waiting for the browser sign-in to complete.', EXIT.FAIL));
53
+ }
54
+ }, timeoutMs);
55
+ timer.unref?.();
56
+ });
57
+ },
58
+ close() {
59
+ rejectCallback?.(new CliError('The local sign-in listener was closed before a callback arrived.', EXIT.FAIL));
60
+ server.close();
61
+ },
62
+ };
63
+ }
@@ -0,0 +1,76 @@
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';
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
+ export function mcpConfigPath(projectDir) {
16
+ return join(projectDir, '.mcp.json');
17
+ }
18
+ function backupPath(configPath) {
19
+ return `${configPath}${BACKUP_SUFFIX}`;
20
+ }
21
+ function readConfig(path) {
22
+ const text = readIfPresent(path);
23
+ if (text === null)
24
+ return {};
25
+ try {
26
+ const parsed = JSON.parse(text);
27
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed))
28
+ return parsed;
29
+ return {};
30
+ }
31
+ catch {
32
+ return {};
33
+ }
34
+ }
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;
42
+ if (alreadyIdentical)
43
+ return { config, changed: false, previousEntry };
44
+ const merged = {
45
+ ...config,
46
+ mcpServers: { ...(config.mcpServers ?? {}), [RAKOMI_SERVER_NAME]: nextEntry },
47
+ };
48
+ return { config: merged, changed: true, previousEntry };
49
+ }
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);
54
+ if (!changed) {
55
+ return { wrote: false, backedUp: false, diff: `${RAKOMI_SERVER_NAME} already configured with ${mcpUrl} — no change.` };
56
+ }
57
+ let backedUp = false;
58
+ const existing = readIfPresent(path);
59
+ if (existing !== null) {
60
+ writeFileSync(backupPath(path), existing);
61
+ backedUp = true;
62
+ }
63
+ writeFileSync(path, JSON.stringify(after, null, 2) + '\n');
64
+ 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 };
68
+ }
69
+ export function undoMcpConfigWrite(projectDir) {
70
+ const path = mcpConfigPath(projectDir);
71
+ const backup = readIfPresent(backupPath(path));
72
+ if (backup === null)
73
+ return false;
74
+ writeFileSync(path, backup);
75
+ return true;
76
+ }
@@ -0,0 +1,30 @@
1
+ import { CliError, EXIT } from './errors.js';
2
+ import { describeError, request } from './http.js';
3
+ export function buildAuthorizeUrl(opts) {
4
+ const url = new URL('/authorize', opts.accountsBaseUrl);
5
+ url.searchParams.set('response_type', 'code');
6
+ url.searchParams.set('client_id', opts.clientId);
7
+ url.searchParams.set('redirect_uri', opts.redirectUri);
8
+ url.searchParams.set('scope', opts.scope);
9
+ url.searchParams.set('state', opts.pkce.state);
10
+ url.searchParams.set('code_challenge', opts.pkce.codeChallenge);
11
+ url.searchParams.set('code_challenge_method', opts.pkce.codeChallengeMethod);
12
+ return url.toString();
13
+ }
14
+ export async function exchangeAuthorizationCode(deps, opts) {
15
+ const result = await request(deps, {
16
+ method: 'POST',
17
+ url: `${opts.apiBaseUrl}/oauth/token`,
18
+ form: {
19
+ grant_type: 'authorization_code',
20
+ code: opts.code,
21
+ redirect_uri: opts.redirectUri,
22
+ client_id: opts.clientId,
23
+ code_verifier: opts.codeVerifier,
24
+ },
25
+ });
26
+ if (result.status !== 200) {
27
+ throw new CliError(`Could not complete sign-in: ${describeError(result.body, result.status)}`, EXIT.FAIL);
28
+ }
29
+ return result.body;
30
+ }
@@ -0,0 +1,57 @@
1
+ import { CliError, EXIT } from './errors.js';
2
+ import { describeError, request } from './http.js';
3
+ export async function requestDeviceCode(deps, opts) {
4
+ const result = await request(deps, {
5
+ method: 'POST',
6
+ url: `${opts.apiBaseUrl}/oauth/device/code`,
7
+ form: { client_id: opts.clientId, ...(opts.scope ? { scope: opts.scope } : {}) },
8
+ });
9
+ if (result.status !== 200) {
10
+ throw new CliError(`Could not start device sign-in: ${describeError(result.body, result.status)}`, EXIT.FAIL);
11
+ }
12
+ return result.body;
13
+ }
14
+ export async function pollDeviceToken(deps, opts) {
15
+ const result = await request(deps, {
16
+ method: 'POST',
17
+ url: `${opts.apiBaseUrl}/oauth/token`,
18
+ form: {
19
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
20
+ device_code: opts.deviceCode,
21
+ client_id: opts.clientId,
22
+ },
23
+ });
24
+ if (result.status === 200)
25
+ return { kind: 'success', token: result.body };
26
+ const error = result.body.error;
27
+ if (error === 'authorization_pending')
28
+ return { kind: 'pending' };
29
+ if (error === 'slow_down')
30
+ return { kind: 'slow_down' };
31
+ if (error === 'access_denied')
32
+ return { kind: 'denied' };
33
+ if (error === 'expired_token')
34
+ return { kind: 'expired' };
35
+ throw new CliError(`Device sign-in failed: ${describeError(result.body, result.status)}`, EXIT.FAIL);
36
+ }
37
+ export async function loginWithDeviceGrant(deps, opts) {
38
+ const issued = await requestDeviceCode(deps, opts);
39
+ deps.onWaiting?.(issued);
40
+ let intervalMs = issued.interval * 1000;
41
+ const deadline = Date.now() + issued.expires_in * 1000;
42
+ for (;;) {
43
+ if (Date.now() >= deadline) {
44
+ throw new CliError('The device sign-in code expired before it was approved.', EXIT.FAIL);
45
+ }
46
+ await deps.sleep(intervalMs);
47
+ const outcome = await pollDeviceToken(deps, { apiBaseUrl: opts.apiBaseUrl, clientId: opts.clientId, deviceCode: issued.device_code });
48
+ if (outcome.kind === 'success')
49
+ return outcome.token;
50
+ if (outcome.kind === 'denied')
51
+ throw new CliError('Sign-in was denied.', EXIT.FAIL);
52
+ if (outcome.kind === 'expired')
53
+ throw new CliError('The device sign-in code expired before it was approved.', EXIT.FAIL);
54
+ if (outcome.kind === 'slow_down')
55
+ intervalMs += 5000;
56
+ }
57
+ }
package/dist/pkce.js ADDED
@@ -0,0 +1,22 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ function base64url(buf) {
3
+ return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
4
+ }
5
+ export function generateCodeVerifier() {
6
+ return base64url(randomBytes(32));
7
+ }
8
+ export function codeChallengeS256(codeVerifier) {
9
+ return base64url(createHash('sha256').update(codeVerifier, 'ascii').digest());
10
+ }
11
+ export function generateState() {
12
+ return base64url(randomBytes(16));
13
+ }
14
+ export function generatePkcePair() {
15
+ const codeVerifier = generateCodeVerifier();
16
+ return {
17
+ codeVerifier,
18
+ codeChallenge: codeChallengeS256(codeVerifier),
19
+ codeChallengeMethod: 'S256',
20
+ state: generateState(),
21
+ };
22
+ }