pithos/core/cli.js
Local Dev ff79593471 Pithos: rename S3 users
s3d has no rename command, so a user named once kept that name. A user is
one row in s3d's database (id, name) and keys and drives point at the id,
so Pithos stops s3d (gently, after any upload), changes the name in
place, updates its own public-link records and starts s3d again: access
keys, secrets and drives stay as they are and S3 apps keep working.
Checked against s3d v0.2.0: the old key still lists the same drives,
taken and invalid names are refused.
2026-10-07 00:13:13 +02:00

93 lines
3.7 KiB
JavaScript

// 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');
}
}