// Typed wrappers over s3d's user/key subcommands. s3d has no HTTP API for // these; the CLI opens the same SQLite file the daemon uses (WAL mode, so it // is safe while the daemon is running). import { spawn } from 'node:child_process'; export class CliError extends Error {} function check({ code, stdout, stderr }) { if (code !== 0) throw new CliError((stderr || stdout).trim() || `s3d exited with code ${code}`); return stdout; } export function makeCli(daemon) { return { // Whether this s3d holds an app key. `s3d login` reports an existing // registration before it reads any input; with stdin closed an // unregistered instance hits EOF at the first prompt and exits. async registration() { const { stdout } = await new Promise((resolve, reject) => { const child = spawn(daemon.binary, ['login'], { cwd: daemon.cwd(), env: daemon.env(), stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); let out = ''; child.stdout.on('data', (d) => { out += d; }); child.stderr.on('data', (d) => { out += d; }); const timer = setTimeout(() => child.kill(), 15_000); child.on('error', reject); child.on('exit', () => { clearTimeout(timer); resolve({ stdout: out.replace(/\x1b\[[0-9;]*m/g, '') }); }); }); const m = stdout.match(/already registered with (\S+?)\.?\s*$/m); return m ? { registered: true, indexerUrl: m[1] } : { registered: false }; }, async version() { const out = check(await daemon.run(['version'])); const m = out.match(/s3d v?(\S+)/); const commit = out.match(/Commit:\s*(\S+)/); return { version: m ? m[1] : null, commit: commit ? commit[1] : null, raw: out.trim() }; }, async listUsers() { const out = check(await daemon.run(['users', 'list'])); if (/^No users found/m.test(out)) return []; return out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean); }, async createUser(name) { validateName(name); check(await daemon.run(['users', 'create', name])); }, async deleteUser(name) { validateName(name); check(await daemon.run(['users', 'delete', name])); }, // [{ accessKeyId, secretKey, user }] async listKeys(user) { const out = check(await daemon.run(user ? ['keys', 'list', user] : ['keys', 'list'])); if (/^No access keys found/m.test(out)) return []; return out.split(/\r?\n/).filter(Boolean).map((line) => { const [accessKeyId, secretKey, ...rest] = line.split('\t'); return { accessKeyId, secretKey, user: rest.join('\t') }; }); }, async createKey(user, { accessKey, secretKey } = {}) { validateName(user); const args = ['keys', 'create']; if (accessKey || secretKey) args.push('--access-key', accessKey || '', '--secret-key', secretKey || ''); args.push(user); const out = check(await daemon.run(args)); const ak = out.match(/Access Key:\s*(\S+)/); const sk = out.match(/Secret Key:\s*(\S+)/); if (!ak || !sk) throw new CliError(`unexpected output from s3d keys create: ${out.trim()}`); return { accessKeyId: ak[1], secretKey: sk[1], user }; }, async deleteKey(accessKeyId) { if (!/^[\x21-\x7e]{1,128}$/.test(accessKeyId)) throw new CliError('invalid access key id'); check(await daemon.run(['keys', 'delete', accessKeyId])); }, }; } // Users are passed as argv, never through a shell, but a leading '-' would be // read as a flag by s3d's parser. export function validateName(name) { if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._@-]{0,63}$/.test(name)) { throw new CliError('user names are 1-64 characters: letters, digits, . _ @ -, not starting with a symbol'); } }