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.
@@ -0,0 +1,320 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { delimiter, join } from 'node:path';
5
+ export function configDir(env) {
6
+ if (env.RAKOMI_CONFIG_DIR)
7
+ return env.RAKOMI_CONFIG_DIR;
8
+ return join(homedir(), '.config', 'rakomi');
9
+ }
10
+ function sessionFilePath(env) {
11
+ return join(configDir(env), 'session.json');
12
+ }
13
+ export class FileSessionStore {
14
+ path;
15
+ dir;
16
+ constructor(env) {
17
+ this.dir = configDir(env);
18
+ this.path = sessionFilePath(env);
19
+ }
20
+ read() {
21
+ try {
22
+ const raw = readFileSync(this.path, 'utf8');
23
+ const parsed = JSON.parse(raw);
24
+ if (!isStoredSession(parsed))
25
+ return null;
26
+ return parsed;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ write(session) {
33
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
34
+ writeFileSync(this.path, JSON.stringify(session, null, 2) + '\n', { mode: 0o600 });
35
+ try {
36
+ chmodSync(this.path, 0o600);
37
+ }
38
+ catch {
39
+ }
40
+ }
41
+ clear() {
42
+ try {
43
+ rmSync(this.path, { force: true });
44
+ }
45
+ catch {
46
+ }
47
+ }
48
+ describePath() {
49
+ return this.path;
50
+ }
51
+ }
52
+ export class FileKeyStore {
53
+ dir;
54
+ constructor(dir) {
55
+ this.dir = dir;
56
+ }
57
+ path(name) {
58
+ return join(this.dir, `key-${name}.json`);
59
+ }
60
+ get(name) {
61
+ try {
62
+ return readFileSync(this.path(name), 'utf8');
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ set(name, value) {
69
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
70
+ writeFileSync(this.path(name), value, { mode: 0o600 });
71
+ try {
72
+ chmodSync(this.path(name), 0o600);
73
+ }
74
+ catch {
75
+ }
76
+ }
77
+ clear(name) {
78
+ try {
79
+ rmSync(this.path(name), { force: true });
80
+ }
81
+ catch {
82
+ }
83
+ }
84
+ }
85
+ function isStoredSession(value) {
86
+ if (typeof value !== 'object' || value === null)
87
+ return false;
88
+ const v = value;
89
+ return (typeof v.access_token === 'string' &&
90
+ v.access_token.length > 0 &&
91
+ v.token_type === 'Bearer' &&
92
+ typeof v.expires_at === 'number' &&
93
+ typeof v.api_base_url === 'string' &&
94
+ typeof v.client_id === 'string');
95
+ }
96
+ export function isExpired(session, now) {
97
+ return now >= session.expires_at;
98
+ }
99
+ function realExecFileSync(file, args, options) {
100
+ return execFileSync(file, [...args], { encoding: 'utf8', input: options?.input, maxBuffer: 10 * 1024 * 1024 }).toString();
101
+ }
102
+ function commandExistsOnPath(binaryName, pathEnv, existsFn) {
103
+ return pathEnv.split(delimiter).some((dir) => dir.length > 0 && existsFn(join(dir, binaryName)));
104
+ }
105
+ export class MacKeychainBackend {
106
+ execFileSyncFn;
107
+ platform;
108
+ name = 'macos-keychain';
109
+ constructor(execFileSyncFn = realExecFileSync, platform = process.platform) {
110
+ this.execFileSyncFn = execFileSyncFn;
111
+ this.platform = platform;
112
+ }
113
+ isAvailable() {
114
+ if (this.platform !== 'darwin')
115
+ return false;
116
+ try {
117
+ this.execFileSyncFn('security', ['list-keychains']);
118
+ return true;
119
+ }
120
+ catch {
121
+ return false;
122
+ }
123
+ }
124
+ get(service, account) {
125
+ try {
126
+ const out = this.execFileSyncFn('security', ['find-generic-password', '-a', account, '-s', service, '-w']);
127
+ const value = out.trim();
128
+ return value.length > 0 ? value : null;
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ }
134
+ set(service, account, value) {
135
+ this.execFileSyncFn('security', ['add-generic-password', '-a', account, '-s', service, '-w', value, '-U']);
136
+ }
137
+ delete(service, account) {
138
+ try {
139
+ this.execFileSyncFn('security', ['delete-generic-password', '-a', account, '-s', service]);
140
+ }
141
+ catch {
142
+ }
143
+ }
144
+ }
145
+ export class LinuxSecretToolBackend {
146
+ execFileSyncFn;
147
+ platform;
148
+ pathEnv;
149
+ existsFn;
150
+ name = 'linux-secret-service';
151
+ constructor(execFileSyncFn = realExecFileSync, platform = process.platform, pathEnv = process.env.PATH ?? '', existsFn = existsSync) {
152
+ this.execFileSyncFn = execFileSyncFn;
153
+ this.platform = platform;
154
+ this.pathEnv = pathEnv;
155
+ this.existsFn = existsFn;
156
+ }
157
+ isAvailable() {
158
+ if (this.platform !== 'linux')
159
+ return false;
160
+ return commandExistsOnPath('secret-tool', this.pathEnv, this.existsFn);
161
+ }
162
+ get(service, account) {
163
+ try {
164
+ const out = this.execFileSyncFn('secret-tool', ['lookup', 'service', service, 'account', account]);
165
+ const value = out.replace(/\n$/, '');
166
+ return value.length > 0 ? value : null;
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ }
172
+ set(service, account, value) {
173
+ this.execFileSyncFn('secret-tool', ['store', '--label', `Rakomi CLI (${account})`, 'service', service, 'account', account], { input: value });
174
+ }
175
+ delete(service, account) {
176
+ try {
177
+ this.execFileSyncFn('secret-tool', ['clear', 'service', service, 'account', account]);
178
+ }
179
+ catch {
180
+ }
181
+ }
182
+ }
183
+ export class WindowsDpapiBackend {
184
+ dir;
185
+ execFileSyncFn;
186
+ platform;
187
+ pathEnv;
188
+ existsFn;
189
+ name = 'windows-dpapi';
190
+ constructor(dir, execFileSyncFn = realExecFileSync, platform = process.platform, pathEnv = process.env.PATH ?? '', existsFn = existsSync) {
191
+ this.dir = dir;
192
+ this.execFileSyncFn = execFileSyncFn;
193
+ this.platform = platform;
194
+ this.pathEnv = pathEnv;
195
+ this.existsFn = existsFn;
196
+ }
197
+ isAvailable() {
198
+ if (this.platform !== 'win32')
199
+ return false;
200
+ return commandExistsOnPath('powershell.exe', this.pathEnv, this.existsFn) || commandExistsOnPath('pwsh.exe', this.pathEnv, this.existsFn);
201
+ }
202
+ filePath(service, account) {
203
+ return join(this.dir, `${service}.${account}.dpapi`);
204
+ }
205
+ get(service, account) {
206
+ let cipherB64;
207
+ try {
208
+ cipherB64 = readFileSync(this.filePath(service, account), 'utf8').trim();
209
+ }
210
+ catch {
211
+ return null;
212
+ }
213
+ if (cipherB64.length === 0)
214
+ return null;
215
+ try {
216
+ const script = `[Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String('${cipherB64}'), $null, 'CurrentUser'))`;
217
+ const out = this.execFileSyncFn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script]);
218
+ const value = out.trim();
219
+ return value.length > 0 ? value : null;
220
+ }
221
+ catch {
222
+ return null;
223
+ }
224
+ }
225
+ set(service, account, value) {
226
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
227
+ const valueB64 = Buffer.from(value, 'utf8').toString('base64');
228
+ const script = `[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect([Convert]::FromBase64String('${valueB64}'), $null, 'CurrentUser'))`;
229
+ const cipherB64 = this.execFileSyncFn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script]).trim();
230
+ writeFileSync(this.filePath(service, account), cipherB64 + '\n', { mode: 0o600 });
231
+ }
232
+ delete(service, account) {
233
+ try {
234
+ rmSync(this.filePath(service, account), { force: true });
235
+ }
236
+ catch {
237
+ }
238
+ }
239
+ }
240
+ const SESSION_SERVICE = 'rakomi-cli';
241
+ const SESSION_ACCOUNT = 'session';
242
+ const KEY_SERVICE = 'rakomi-cli-keys';
243
+ export class KeychainSessionStore {
244
+ backend;
245
+ legacyFile;
246
+ constructor(backend, legacyFile) {
247
+ this.backend = backend;
248
+ this.legacyFile = legacyFile;
249
+ }
250
+ read() {
251
+ const raw = this.backend.get(SESSION_SERVICE, SESSION_ACCOUNT);
252
+ if (raw !== null) {
253
+ try {
254
+ const parsed = JSON.parse(raw);
255
+ return isStoredSession(parsed) ? parsed : null;
256
+ }
257
+ catch {
258
+ return null;
259
+ }
260
+ }
261
+ const legacy = this.legacyFile.read();
262
+ if (!legacy)
263
+ return null;
264
+ try {
265
+ this.backend.set(SESSION_SERVICE, SESSION_ACCOUNT, JSON.stringify(legacy));
266
+ this.legacyFile.clear();
267
+ }
268
+ catch {
269
+ }
270
+ return legacy;
271
+ }
272
+ write(session) {
273
+ this.backend.set(SESSION_SERVICE, SESSION_ACCOUNT, JSON.stringify(session));
274
+ }
275
+ clear() {
276
+ this.backend.delete(SESSION_SERVICE, SESSION_ACCOUNT);
277
+ this.legacyFile.clear();
278
+ }
279
+ describePath() {
280
+ return `${this.backend.name} (service "${SESSION_SERVICE}")`;
281
+ }
282
+ }
283
+ export class KeychainKeyStore {
284
+ backend;
285
+ constructor(backend) {
286
+ this.backend = backend;
287
+ }
288
+ get(name) {
289
+ return this.backend.get(KEY_SERVICE, name);
290
+ }
291
+ set(name, value) {
292
+ this.backend.set(KEY_SERVICE, name, value);
293
+ }
294
+ clear(name) {
295
+ this.backend.delete(KEY_SERVICE, name);
296
+ }
297
+ }
298
+ const FALLBACK_DISCLOSURE = 'rakomi: no OS keychain available (or --no-keychain set) — using a local session file instead ' +
299
+ '(~/.config/rakomi/session.json or $RAKOMI_CONFIG_DIR, readable only by your account). ' +
300
+ 'Run without --no-keychain on a supported OS to store it in your platform keychain instead.';
301
+ export function resolveStores(env, opts = {}) {
302
+ const dir = configDir(env);
303
+ const fileSession = new FileSessionStore(env);
304
+ const fileKeys = new FileKeyStore(dir);
305
+ if (opts.noKeychain) {
306
+ return { session: fileSession, keys: fileKeys, backend: 'file', fallbackDisclosure: FALLBACK_DISCLOSURE };
307
+ }
308
+ const candidates = opts.backends ?? [new MacKeychainBackend(), new LinuxSecretToolBackend(), new WindowsDpapiBackend(dir)];
309
+ for (const backend of candidates) {
310
+ if (backend.isAvailable()) {
311
+ return {
312
+ session: new KeychainSessionStore(backend, fileSession),
313
+ keys: new KeychainKeyStore(backend),
314
+ backend: backend.name,
315
+ fallbackDisclosure: null,
316
+ };
317
+ }
318
+ }
319
+ return { session: fileSession, keys: fileKeys, backend: 'file', fallbackDisclosure: FALLBACK_DISCLOSURE };
320
+ }
@@ -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
+ const M2M_OR_OWNER_REQUIRED_CODE = 'tenant/owner_grant_requires_m2m_caller';
5
+ const OWNER_EMAIL_NOT_ALLOWED_CODE = 'tenant/owner_email_not_allowed_for_self_owner';
6
+ export function generateIdempotencyKey() {
7
+ return randomBytes(16).toString('hex');
8
+ }
9
+ function describeCallerRejected(action) {
10
+ return (`Could not ${action}: you are signed in, but you are not a verified owner of the tenant this ` +
11
+ 'session belongs to (and this is not a partner client_credentials token either). Ask your ' +
12
+ 'tenant\'s owner to run this, or integrate directly against `POST/GET /v1/tenants` with your ' +
13
+ 'own partner client_credentials.');
14
+ }
15
+ export async function createTenant(deps, opts) {
16
+ const result = await request(deps, {
17
+ method: 'POST',
18
+ url: `${opts.apiBaseUrl}/v1/tenants`,
19
+ headers: { authorization: `Bearer ${opts.accessToken}` },
20
+ body: { name: opts.name, ...(opts.slug ? { slug: opts.slug } : {}), ...(opts.ownerEmail ? { owner_email: opts.ownerEmail } : {}) },
21
+ idempotencyKey: opts.idempotencyKey ?? generateIdempotencyKey(),
22
+ });
23
+ if (result.status === 401) {
24
+ throw new CliError('Your session has expired. Run `rakomi login` again.', EXIT.NOT_LOGGED_IN);
25
+ }
26
+ if (result.status === 400 && errorCode(result.body) === OWNER_EMAIL_NOT_ALLOWED_CODE) {
27
+ throw new CliError('Could not create the tenant: --owner <email> (a third-party owner) requires a partner ' +
28
+ 'client_credentials integration — use --owner me from an interactive session.', EXIT.FAIL);
29
+ }
30
+ if (result.status === 403) {
31
+ if (errorCode(result.body) === M2M_OR_OWNER_REQUIRED_CODE) {
32
+ throw new CliError(describeCallerRejected('create the tenant'), EXIT.FAIL);
33
+ }
34
+ throw new CliError(`Could not create the tenant: ${describeError(result.body, result.status)} ` +
35
+ '(missing the `tenants:create` scope, or the caller tenant already has a parent — depth-1 only).', EXIT.FAIL);
36
+ }
37
+ if (result.status !== 201) {
38
+ throw new CliError(`Could not create the tenant: ${describeError(result.body, result.status)}`, EXIT.FAIL);
39
+ }
40
+ return result.body;
41
+ }
42
+ export async function listTenants(deps, opts) {
43
+ const url = new URL('/v1/tenants', opts.apiBaseUrl);
44
+ if (opts.parent)
45
+ url.searchParams.set('parent', opts.parent);
46
+ const result = await request(deps, {
47
+ method: 'GET',
48
+ url: url.toString(),
49
+ headers: { authorization: `Bearer ${opts.accessToken}` },
50
+ });
51
+ if (result.status === 401) {
52
+ throw new CliError('Your session has expired. Run `rakomi login` again.', EXIT.NOT_LOGGED_IN);
53
+ }
54
+ if (result.status === 403) {
55
+ if (errorCode(result.body) === M2M_OR_OWNER_REQUIRED_CODE) {
56
+ throw new CliError(describeCallerRejected('list tenants'), EXIT.FAIL);
57
+ }
58
+ throw new CliError(`Could not list tenants: ${describeError(result.body, result.status)}`, EXIT.FAIL);
59
+ }
60
+ if (result.status !== 200) {
61
+ throw new CliError(`Could not list tenants: ${describeError(result.body, result.status)}`, EXIT.FAIL);
62
+ }
63
+ return result.body;
64
+ }
package/dist/usage.js ADDED
@@ -0,0 +1,38 @@
1
+ export function helpText() {
2
+ return [
3
+ 'rakomi — the Rakomi CLI: sign in, connect an AI agent, and provision tenants',
4
+ '',
5
+ 'Usage:',
6
+ ' rakomi <command> [options]',
7
+ '',
8
+ 'Commands:',
9
+ ' login sign in (browser by default; --no-browser for a device code)',
10
+ ' logout clear the local session',
11
+ ' whoami show the signed-in account',
12
+ ' connect connect Claude Code / Claude Desktop to your tenant (read access)',
13
+ ' --write also request write access for the connected client',
14
+ ' --client <name> claude-code | claude-desktop (required if more than one applies)',
15
+ ' --undo restore the .mcp.json this command last backed up',
16
+ ' --cimd-url <url> the connecting client\'s own CIMD document URL — confirms the connection',
17
+ ' --status re-check status only (needs --cimd-url; never rewrites .mcp.json)',
18
+ ' tenants create <name> create a tenant (parent-tenant only; depth-1 enforced server-side)',
19
+ ' --owner <me|email> who becomes the new tenant\'s owner (default: me)',
20
+ ' --slug <slug> optional; auto-derived + suffixed if omitted',
21
+ ' tenants list list tenants you provisioned',
22
+ '',
23
+ 'Global options:',
24
+ ' --json machine-readable output',
25
+ ' --yes, --ci never prompt; fail fast on any step needing a human',
26
+ ' --dry-run print what would happen, make no writes or mutating calls',
27
+ ' --no-browser use the RFC 8628 device-code flow instead of opening a browser',
28
+ ' --no-keychain store the session in a 0600 file instead of the OS keychain',
29
+ ' -h, --help show this help and exit',
30
+ ' -V, --version print the version and exit',
31
+ '',
32
+ 'Exit codes: 0 ok, 1 failure, 2 usage error, 3 not logged in, 4 needs a human (see --ci).',
33
+ '',
34
+ ].join('\n');
35
+ }
36
+ export function usageLine() {
37
+ return 'Usage: rakomi <command> [options] (see --help)';
38
+ }
@@ -0,0 +1,16 @@
1
+ import { CliError, EXIT } from './errors.js';
2
+ import { describeError, request } from './http.js';
3
+ export async function fetchUserInfo(deps, opts) {
4
+ const result = await request(deps, {
5
+ method: 'GET',
6
+ url: `${opts.apiBaseUrl}/oauth/userinfo`,
7
+ headers: { authorization: `Bearer ${opts.accessToken}` },
8
+ });
9
+ if (result.status === 401) {
10
+ throw new CliError('Your session has expired. Run `rakomi login` again.', EXIT.NOT_LOGGED_IN);
11
+ }
12
+ if (result.status !== 200) {
13
+ throw new CliError(`Could not fetch your account info: ${describeError(result.body, result.status)}`, EXIT.FAIL);
14
+ }
15
+ return result.body;
16
+ }
package/package.json CHANGED
@@ -1,29 +1,45 @@
1
1
  {
2
2
  "name": "rakomi",
3
- "version": "0.0.1",
4
- "description": "Reserved package name for the Rakomi CLI by CRE8EVE. This is a placeholder — the official CLI is published under the @rakomi organization scope.",
3
+ "version": "0.1.0",
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",
7
- "cre8eve",
8
7
  "cli",
9
- "reserved",
10
- "placeholder"
8
+ "auth",
9
+ "authentication",
10
+ "mcp",
11
+ "oauth",
12
+ "ai-agent"
11
13
  ],
12
- "homepage": "https://cre8eve.eu",
13
- "license": "UNLICENSED",
14
- "author": "CRE8EVE <hello@cre8eve.eu>",
15
- "private": false,
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/rakomidev/rakomi-js.git"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/rakomidev/rakomi-js/issues"
20
+ },
21
+ "homepage": "https://github.com/rakomidev/rakomi-js#readme",
22
+ "license": "MIT",
23
+ "publishConfig": {
24
+ "provenance": true
25
+ },
26
+ "type": "module",
16
27
  "bin": {
17
- "rakomi": "bin/rakomi.js"
28
+ "rakomi": "./dist/index.js"
18
29
  },
19
- "files": [
20
- "bin/rakomi.js",
21
- "README.md"
22
- ],
23
30
  "engines": {
24
- "node": ">=18"
31
+ "node": ">=22"
25
32
  },
26
- "publishConfig": {
27
- "access": "public"
33
+ "author": "CRE8EVE Sp. z o.o.",
34
+ "files": [
35
+ "dist",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "scripts": {
40
+ "build": "tsc",
41
+ "typecheck": "tsc --noEmit",
42
+ "lint": "eslint src/ test/ --max-warnings=0",
43
+ "test": "vitest run"
28
44
  }
29
- }
45
+ }
package/bin/rakomi.js DELETED
@@ -1,12 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- // Placeholder binary for the reserved `rakomi` package name.
4
- // The official Rakomi CLI is not published here yet.
5
- console.log(
6
- [
7
- 'rakomi — name reserved by CRE8EVE.',
8
- '',
9
- 'This is a placeholder. The official CLI is not yet available under this name.',
10
- 'See https://cre8eve.eu for updates.',
11
- ].join('\n'),
12
- );