SPB Git

spb/spbgit Public MIT

SPB Git — the platform hosting itself

JavaScript 73.9% CSS 11.7% Nunjucks 11.6% Shell 2.7%

feat: spbgit CLI — init, tokens, create/clone/status/commit/push/pull/sync/open/info/rm/doctor

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed yesterday (Aug 10, 2026) parent cf0c7c1

Showing 20 changed files with +1,207 and −0

added cli/commands/clone.mjs +69 −0
@@ -0,0 +1,69 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : cli/commands/clone.mjs
8 + * Purpose : `spbgit clone <name>|--all` — clone into the workspace
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { existsSync, mkdirSync } from 'node:fs';
14 +import { join, resolve } from 'node:path';
15 +import pc from 'picocolors';
16 +import { requireCliConfig } from '../lib/config.mjs';
17 +import { api, EXIT } from '../lib/api.mjs';
18 +import { git } from '../lib/git.mjs';
19 +import { mapLimit, SYM } from '../lib/ui.mjs';
20 +
21 +export function registerClone(program) {
22 + program
23 + .command('clone [name] [dir]')
24 + .description('clone a repository into the workspace (or a directory)')
25 + .option('--all', 'clone every server repo missing from the workspace', false)
26 + .action(async (name, dir, opts) => {
27 + const config = requireCliConfig();
28 + mkdirSync(config.workspace, { recursive: true });
29 +
30 + if (opts.all) {
31 + const { repos } = await api(config, 'GET', '/api/v1/repos', undefined, { auth: false });
32 + const missing = repos.filter((r) => !existsSync(join(config.workspace, r.name)));
33 + if (missing.length === 0) {
34 + console.log(`${SYM.ok} Workspace already has every repository (${repos.length}).`);
35 + return;
36 + }
37 + let failures = 0;
38 + await mapLimit(missing, 4, async (repo) => {
39 + const dest = join(config.workspace, repo.name);
40 + const result = await git(config.workspace, ['clone', repo.cloneUrl, dest]);
41 + if (result.ok) console.log(`${SYM.ok} ${repo.name}`);
42 + else {
43 + failures += 1;
44 + console.error(`${SYM.fail} ${repo.name}: ${result.stderr.trim().split('\n').pop()}`);
45 + }
46 + });
47 + console.log(`\nCloned ${missing.length - failures}/${missing.length} repositories into ${config.workspace}`);
48 + if (failures > 0) process.exit(EXIT.PARTIAL);
49 + return;
50 + }
51 +
52 + if (!name) {
53 + console.error(pc.red('✗ Provide a repository name or --all'));
54 + process.exit(EXIT.USER);
55 + }
56 + const dest = dir ? resolve(dir) : join(config.workspace, name);
57 + if (existsSync(dest)) {
58 + console.error(pc.red(`✗ Destination already exists: ${dest}`));
59 + process.exit(EXIT.USER);
60 + }
61 + const url = `${config.server}/${name}.git`;
62 + const result = await git(process.cwd(), ['clone', url, dest]);
63 + if (!result.ok) {
64 + console.error(pc.red(`✗ Clone failed: ${result.stderr.trim().split('\n').pop()}`));
65 + process.exit(EXIT.NETWORK);
66 + }
67 + console.log(`${SYM.ok} Cloned ${pc.bold(name)} → ${dest}`);
68 + });
69 +}
added cli/commands/commit.mjs +63 −0
@@ -0,0 +1,63 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : cli/commands/commit.mjs
8 + * Purpose : `spbgit commit <name>|--all -m` — add-all + commit
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { basename, join } from 'node:path';
14 +import { existsSync } from 'node:fs';
15 +import pc from 'picocolors';
16 +import { requireCliConfig } from '../lib/config.mjs';
17 +import { workspaceRepos, git } from '../lib/git.mjs';
18 +import { mapLimit, SYM } from '../lib/ui.mjs';
19 +import { EXIT } from '../lib/api.mjs';
20 +
21 +/** Resolve the list of target repo dirs for name/--all commands. */
22 +export function resolveTargets(config, name, all) {
23 + if (all) return workspaceRepos(config.workspace);
24 + if (!name) {
25 + console.error(pc.red('✗ Provide a repository name or --all'));
26 + process.exit(EXIT.USER);
27 + }
28 + const dir = join(config.workspace, name);
29 + if (!existsSync(join(dir, '.git'))) {
30 + console.error(pc.red(`✗ No such workspace repo: ${name}`));
31 + process.exit(EXIT.USER);
32 + }
33 + return [dir];
34 +}
35 +
36 +export function registerCommit(program) {
37 + program
38 + .command('commit [name]')
39 + .description('git add -A && git commit in target repo(s)')
40 + .option('--all', 'every workspace repository', false)
41 + .requiredOption('-m, --message <msg>', 'commit message')
42 + .action(async (name, opts) => {
43 + const config = requireCliConfig();
44 + const targets = resolveTargets(config, name, opts.all);
45 + let failures = 0;
46 + await mapLimit(targets, 4, async (dir) => {
47 + const repo = basename(dir);
48 + const status = await git(dir, ['status', '--porcelain']);
49 + if (status.ok && status.stdout.trim() === '') {
50 + console.log(`${SYM.skip} ${repo} ${pc.dim('clean — skipped')}`);
51 + return;
52 + }
53 + await git(dir, ['add', '-A']);
54 + const commit = await git(dir, ['commit', '-m', opts.message]);
55 + if (commit.ok) console.log(`${SYM.ok} ${repo} ${pc.dim('committed')}`);
56 + else {
57 + failures += 1;
58 + console.error(`${SYM.fail} ${repo}: ${commit.stderr.trim().split('\n').pop()}`);
59 + }
60 + });
61 + if (failures > 0) process.exit(EXIT.PARTIAL);
62 + });
63 +}
added cli/commands/create.mjs +76 −0
@@ -0,0 +1,76 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : cli/commands/create.mjs
8 + * Purpose : `spbgit create` — create on server, optionally push cwd
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { existsSync } from 'node:fs';
14 +import { join } from 'node:path';
15 +import pc from 'picocolors';
16 +import { requireCliConfig } from '../lib/config.mjs';
17 +import { api, EXIT } from '../lib/api.mjs';
18 +import { git } from '../lib/git.mjs';
19 +
20 +export function registerCreate(program) {
21 + program
22 + .command('create <name>')
23 + .description('create a repository on the server')
24 + .option('-d, --description <desc>', 'repository description', '')
25 + .option('--topics <topics>', 'comma-separated topics (max 10)')
26 + .option('--pinned', 'pin on the home page', false)
27 + .option('--push', 'also init the current directory, add remote, commit and push', false)
28 + .action(async (name, opts) => {
29 + const config = requireCliConfig();
30 + const topics = (opts.topics ?? '')
31 + .split(',')
32 + .map((t) => t.trim().toLowerCase())
33 + .filter(Boolean)
34 + .slice(0, 10);
35 + const repo = await api(config, 'POST', '/api/v1/repos', {
36 + name,
37 + description: opts.description,
38 + topics,
39 + pinned: Boolean(opts.pinned),
40 + });
41 + console.log(`${pc.green('✓')} Created ${pc.bold(repo.name)} → ${repo.url}`);
42 +
43 + if (!opts.push) return;
44 + const cwd = process.cwd();
45 + if (!existsSync(join(cwd, '.git'))) {
46 + const init = await git(cwd, ['init', '-b', 'main']);
47 + if (!init.ok) {
48 + console.error(pc.red(`✗ git init failed: ${init.stderr}`));
49 + process.exit(EXIT.USER);
50 + }
51 + console.log(`${pc.green('✓')} Initialized git repository in ${cwd}`);
52 + }
53 + const hasRemote = await git(cwd, ['remote', 'get-url', 'origin']);
54 + if (!hasRemote.ok) {
55 + await git(cwd, ['remote', 'add', 'origin', repo.cloneUrl]);
56 + console.log(`${pc.green('✓')} Remote origin → ${repo.cloneUrl}`);
57 + }
58 + const hasCommit = await git(cwd, ['rev-parse', '--verify', 'HEAD']);
59 + if (!hasCommit.ok) {
60 + await git(cwd, ['add', '-A']);
61 + const commit = await git(cwd, ['commit', '-m', 'feat: initial commit']);
62 + if (!commit.ok) {
63 + console.error(pc.red(`✗ Nothing to commit — add files first, then: git push -u origin main`));
64 + process.exit(EXIT.USER);
65 + }
66 + console.log(`${pc.green('✓')} Initial commit created`);
67 + }
68 + const branch = (await git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim() || 'main';
69 + const push = await git(cwd, ['push', '-u', 'origin', branch], { token: config.token });
70 + if (!push.ok) {
71 + console.error(pc.red(`✗ Push failed: ${push.stderr.trim()}`));
72 + process.exit(EXIT.NETWORK);
73 + }
74 + console.log(`${pc.green('✓')} Pushed ${branch} — live at ${repo.url}`);
75 + });
76 +}
added cli/commands/doctor.mjs +74 −0
@@ -0,0 +1,74 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : cli/commands/doctor.mjs
8 + * Purpose : `spbgit doctor` — diagnose config, git, server, token
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { existsSync } from 'node:fs';
14 +import pc from 'picocolors';
15 +import { loadCliConfig, CONFIG_PATH } from '../lib/config.mjs';
16 +import { git } from '../lib/git.mjs';
17 +import { EXIT } from '../lib/api.mjs';
18 +
19 +export function registerDoctor(program) {
20 + program
21 + .command('doctor')
22 + .description('diagnose the local setup end to end')
23 + .action(async () => {
24 + let failures = 0;
25 + const check = (ok, label, hint) => {
26 + console.log(`${ok ? pc.green('✓') : pc.red('✗')} ${label}${ok || !hint ? '' : pc.dim(` — ${hint}`)}`);
27 + if (!ok) failures += 1;
28 + };
29 +
30 + const config = loadCliConfig();
31 + check(Boolean(config), `config present (${CONFIG_PATH})`, 'run: spbgit init');
32 +
33 + const gitVersion = await git(process.cwd(), ['--version']);
34 + check(gitVersion.ok, `git installed ${gitVersion.ok ? pc.dim(gitVersion.stdout.trim()) : ''}`, 'install git');
35 +
36 + if (config) {
37 + check(existsSync(config.workspace), `workspace exists (${config.workspace})`, 'run: spbgit init');
38 +
39 + let health = null;
40 + try {
41 + const response = await fetch(`${config.server}/healthz`);
42 + health = response.ok ? await response.json() : null;
43 + } catch {
44 + health = null;
45 + }
46 + check(Boolean(health), `server reachable (${config.server})`, 'check network / server process');
47 + if (health) {
48 + console.log(pc.dim(` uptime ${health.uptimeSeconds}s · ${health.repos} repos · cache ${health.cache.files} files`));
49 + }
50 +
51 + if (config.token) {
52 + let who = null;
53 + try {
54 + const response = await fetch(`${config.server}/api/v1/whoami`, {
55 + headers: { Authorization: `Bearer ${config.token}` },
56 + });
57 + who = response.ok ? await response.json() : null;
58 + } catch {
59 + who = null;
60 + }
61 + check(Boolean(who), `token valid${who ? pc.dim(` (label: ${who.tokenLabel})`) : ''}`, 'run: spbgit token new, then spbgit init');
62 + } else {
63 + check(false, 'token configured', 'run: spbgit init');
64 + }
65 + }
66 +
67 + console.log('');
68 + if (failures === 0) console.log(pc.green('All checks passed — you are ready to push.'));
69 + else {
70 + console.log(pc.red(`${failures} check(s) failed.`));
71 + process.exit(EXIT.NETWORK);
72 + }
73 + });
74 +}
added cli/commands/info.mjs +60 −0
@@ -0,0 +1,60 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : cli/commands/info.mjs
8 + * Purpose : `spbgit info <name>` — server-side repository details
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import pc from 'picocolors';
14 +import { requireCliConfig } from '../lib/config.mjs';
15 +import { api } from '../lib/api.mjs';
16 +import { printTable } from '../lib/ui.mjs';
17 +import { ago } from './list.mjs';
18 +
19 +function humanBytes(bytes) {
20 + const units = ['B', 'KB', 'MB', 'GB'];
21 + let value = Number(bytes) || 0;
22 + let i = 0;
23 + while (value >= 1024 && i < units.length - 1) {
24 + value /= 1024;
25 + i += 1;
26 + }
27 + return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`;
28 +}
29 +
30 +export function registerInfo(program) {
31 + program
32 + .command('info <name>')
33 + .description('show server-side details of a repository')
34 + .option('--json', 'machine-readable output')
35 + .action(async (name, opts) => {
36 + const config = requireCliConfig();
37 + const repo = await api(config, 'GET', `/api/v1/repos/${encodeURIComponent(name)}`, undefined, { auth: false });
38 + if (opts.json) {
39 + console.log(JSON.stringify(repo, null, 2));
40 + return;
41 + }
42 + console.log(`\n${pc.bold(repo.name)} ${repo.pinned ? pc.yellow('★') : ''}`);
43 + if (repo.description) console.log(pc.dim(repo.description));
44 + console.log('');
45 + printTable([
46 + [pc.bold('URL'), repo.url],
47 + [pc.bold('Clone'), repo.cloneUrl],
48 + [pc.bold('Default branch'), repo.defaultBranch],
49 + [pc.bold('Commits'), String(repo.commitCount)],
50 + [pc.bold('Branches'), String(repo.branchCount)],
51 + [pc.bold('Tags'), String(repo.tagCount)],
52 + [pc.bold('Size'), humanBytes(repo.sizeBytes)],
53 + [pc.bold('Language'), repo.topLanguage ?? '—'],
54 + [pc.bold('License'), repo.license ?? '—'],
55 + [pc.bold('Topics'), repo.topics.join(', ') || '—'],
56 + [pc.bold('Last push'), ago(repo.lastPush)],
57 + [pc.bold('Clones'), String(repo.cloneCount ?? 0)],
58 + ]);
59 + });
60 +}
added cli/commands/init.mjs +64 −0
@@ -0,0 +1,64 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : cli/commands/init.mjs
8 + * Purpose : `spbgit init` — interactive setup wizard
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { mkdirSync } from 'node:fs';
14 +import pc from 'picocolors';
15 +import { loadCliConfig, saveCliConfig, expandHome, CONFIG_PATH } from '../lib/config.mjs';
16 +import { prompt } from '../lib/ui.mjs';
17 +import { git } from '../lib/git.mjs';
18 +import { api } from '../lib/api.mjs';
19 +
20 +export function registerInit(program) {
21 + program
22 + .command('init')
23 + .description('interactive setup: server URL, token, workspace directory')
24 + .action(async () => {
25 + const existing = loadCliConfig() ?? {};
26 + console.log(pc.bold('SPB Git CLI setup\n'));
27 + const server = await prompt('Server URL', existing.server ?? 'https://git.spboucher.ai');
28 + const token = await prompt('Personal access token (paste, hidden after save)', existing.token ? '(keep current)' : '');
29 + const workspace = expandHome(await prompt('Workspace directory', existing.workspace ?? '~/code'));
30 +
31 + const config = {
32 + server: server.replace(/\/+$/, ''),
33 + token: token === '(keep current)' ? existing.token : token,
34 + workspace,
35 + author: existing.author ?? { name: 'Simon-Pierre Boucher', email: 'contact@spboucher.ai' },
36 + };
37 + mkdirSync(workspace, { recursive: true });
38 + saveCliConfig(config);
39 + console.log(`\n${pc.green('✓')} Config written to ${CONFIG_PATH} (mode 600)`);
40 +
41 + // Ensure global git identity exists.
42 + const name = await git(process.cwd(), ['config', '--global', 'user.name']);
43 + if (!name.ok || !name.stdout.trim()) {
44 + await git(process.cwd(), ['config', '--global', 'user.name', config.author.name]);
45 + console.log(`${pc.green('✓')} git user.name set to ${config.author.name}`);
46 + }
47 + const email = await git(process.cwd(), ['config', '--global', 'user.email']);
48 + if (!email.ok || !email.stdout.trim()) {
49 + await git(process.cwd(), ['config', '--global', 'user.email', config.author.email]);
50 + console.log(`${pc.green('✓')} git user.email set to ${config.author.email}`);
51 + }
52 +
53 + if (config.token) {
54 + try {
55 + const who = await api(config, 'GET', '/api/v1/whoami');
56 + console.log(`${pc.green('✓')} Token valid — authenticated as ${who.owner} (${who.tokenLabel})`);
57 + } catch (err) {
58 + console.log(`${pc.yellow('!')} Could not verify token: ${err.message}`);
59 + }
60 + } else {
61 + console.log(`${pc.yellow('!')} No token saved — read-only operations only. Get one with: spbgit token new`);
62 + }
63 + });
64 +}
added cli/commands/list.mjs +57 −0
@@ -0,0 +1,57 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : cli/commands/list.mjs
8 + * Purpose : `spbgit list` — table of server repositories
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import pc from 'picocolors';
14 +import { requireCliConfig } from '../lib/config.mjs';
15 +import { api } from '../lib/api.mjs';
16 +import { printTable } from '../lib/ui.mjs';
17 +
18 +/** Compact "3 h ago" style formatter (client-side). */
19 +export function ago(iso) {
20 + if (!iso) return pc.dim('never');
21 + const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
22 + if (s < 90) return 'just now';
23 + if (s < 3600) return `${Math.floor(s / 60)} min ago`;
24 + if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
25 + if (s < 2592000) return `${Math.floor(s / 86400)} d ago`;
26 + return `${Math.floor(s / 2592000)} mo ago`;
27 +}
28 +
29 +export function registerList(program) {
30 + program
31 + .command('list')
32 + .alias('ls')
33 + .description('list every repository on the server')
34 + .option('--json', 'machine-readable output')
35 + .action(async (opts) => {
36 + const config = requireCliConfig();
37 + const { repos } = await api(config, 'GET', '/api/v1/repos', undefined, { auth: false });
38 + if (opts.json) {
39 + console.log(JSON.stringify(repos, null, 2));
40 + return;
41 + }
42 + if (repos.length === 0) {
43 + console.log(pc.dim('No repositories yet. Create one: spbgit create <name>'));
44 + return;
45 + }
46 + printTable([
47 + [pc.bold('NAME'), pc.bold('DESCRIPTION'), pc.bold('LANGUAGE'), pc.bold('PUSHED'), pc.bold('CLONE URL')],
48 + ...repos.map((r) => [
49 + (r.pinned ? pc.yellow('★ ') : ' ') + pc.bold(r.name),
50 + (r.description || pc.dim('—')).slice(0, 48),
51 + r.topLanguage ?? pc.dim('—'),
52 + ago(r.lastPush),
53 + pc.dim(r.cloneUrl),
54 + ]),
55 + ]);
56 + });
57 +}
added cli/commands/open.mjs +31 −0
@@ -0,0 +1,31 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : cli/commands/open.mjs
8 + * Purpose : `spbgit open [name]` — open the web UI in the browser
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { execFile } from 'node:child_process';
14 +import { platform } from 'node:os';
15 +import pc from 'picocolors';
16 +import { requireCliConfig } from '../lib/config.mjs';
17 +
18 +export function registerOpen(program) {
19 + program
20 + .command('open [name]')
21 + .description('open the repo page (or home) in the default browser')
22 + .action((name) => {
23 + const config = requireCliConfig();
24 + const url = name ? `${config.server}/${name}` : config.server;
25 + const opener = platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'start' : 'xdg-open';
26 + execFile(opener, [url], (err) => {
27 + if (err) console.error(pc.red(`✗ Could not open browser: ${err.message}\n URL: ${url}`));
28 + else console.log(`${pc.green('✓')} ${url}`);
29 + });
30 + });
31 +}
added cli/commands/pull.mjs +45 −0
@@ -0,0 +1,45 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : cli/commands/pull.mjs
8 + * Purpose : `spbgit pull [name]|--all` — ff-only by default
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { basename } from 'node:path';
14 +import pc from 'picocolors';
15 +import { requireCliConfig } from '../lib/config.mjs';
16 +import { git } from '../lib/git.mjs';
17 +import { mapLimit, SYM } from '../lib/ui.mjs';
18 +import { EXIT } from '../lib/api.mjs';
19 +import { resolveTargets } from './commit.mjs';
20 +
21 +export function registerPull(program) {
22 + program
23 + .command('pull [name]')
24 + .description('pull target repo(s) — fast-forward only by default')
25 + .option('--all', 'every workspace repository', false)
26 + .option('--rebase', 'pull with --rebase instead of --ff-only', false)
27 + .action(async (name, opts) => {
28 + const config = requireCliConfig();
29 + const targets = resolveTargets(config, name, opts.all);
30 + let failures = 0;
31 + await mapLimit(targets, 4, async (dir) => {
32 + const repo = basename(dir);
33 + const args = ['pull', opts.rebase ? '--rebase' : '--ff-only'];
34 + const result = await git(dir, args, { token: config.token });
35 + if (result.ok) {
36 + const upToDate = /Already up to date/i.test(result.stdout);
37 + console.log(`${upToDate ? SYM.skip : SYM.pull} ${repo} ${pc.dim(upToDate ? 'up to date' : 'updated')}`);
38 + } else {
39 + failures += 1;
40 + console.error(`${SYM.fail} ${repo}: ${result.stderr.trim().split('\n').pop()}`);
41 + }
42 + });
43 + if (failures > 0) process.exit(EXIT.PARTIAL);
44 + });
45 +}
added cli/commands/push.mjs +51 −0
@@ -0,0 +1,51 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : cli/commands/push.mjs
8 + * Purpose : `spbgit push [name]|--all` — push current branches
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { basename } from 'node:path';
14 +import pc from 'picocolors';
15 +import { requireCliConfig } from '../lib/config.mjs';
16 +import { git, repoStatus } from '../lib/git.mjs';
17 +import { mapLimit, SYM } from '../lib/ui.mjs';
18 +import { EXIT } from '../lib/api.mjs';
19 +import { resolveTargets } from './commit.mjs';
20 +
21 +export function registerPush(program) {
22 + program
23 + .command('push [name]')
24 + .description('push the current branch of target repo(s)')
25 + .option('--all', 'every workspace repo with commits ahead', false)
26 + .action(async (name, opts) => {
27 + const config = requireCliConfig();
28 + const targets = resolveTargets(config, name, opts.all);
29 + let failures = 0;
30 + let pushed = 0;
31 + await mapLimit(targets, 4, async (dir) => {
32 + const repo = basename(dir);
33 + const status = await repoStatus(dir);
34 + if (opts.all && status.hasUpstream && status.ahead === 0) {
35 + console.log(`${SYM.skip} ${repo} ${pc.dim('nothing to push')}`);
36 + return;
37 + }
38 + const args = status.hasUpstream ? ['push'] : ['push', '-u', 'origin', status.branch];
39 + const result = await git(dir, args, { token: config.token });
40 + if (result.ok) {
41 + pushed += 1;
42 + console.log(`${SYM.push} ${repo} ${pc.dim(`pushed ${status.branch}${status.ahead ? ` (+${status.ahead})` : ''}`)}`);
43 + } else {
44 + failures += 1;
45 + console.error(`${SYM.fail} ${repo}: ${result.stderr.trim().split('\n').pop()}`);
46 + }
47 + });
48 + if (failures > 0) process.exit(EXIT.PARTIAL);
49 + if (pushed === 0 && targets.length > 0) console.log(pc.dim('Everything up to date.'));
50 + });
51 +}
added cli/commands/rm.mjs +38 −0
@@ -0,0 +1,38 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : cli/commands/rm.mjs
8 + * Purpose : `spbgit rm <name> --confirm` — soft-delete with typed confirm
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import pc from 'picocolors';
14 +import { requireCliConfig } from '../lib/config.mjs';
15 +import { api, EXIT } from '../lib/api.mjs';
16 +import { prompt } from '../lib/ui.mjs';
17 +
18 +export function registerRm(program) {
19 + program
20 + .command('rm <name>')
21 + .description('soft-delete a repository on the server (recoverable 30 days)')
22 + .option('--confirm', 'required flag — deletion asks you to retype the name', false)
23 + .action(async (name, opts) => {
24 + const config = requireCliConfig();
25 + if (!opts.confirm) {
26 + console.error(pc.red('✗ Deletion requires --confirm'));
27 + process.exit(EXIT.USER);
28 + }
29 + console.log(pc.yellow(`\nThis moves ${pc.bold(name)} to the server trash (recoverable for 30 days).`));
30 + const typed = await prompt(`Type ${pc.bold(name)} to confirm`);
31 + if (typed !== name) {
32 + console.error(pc.red('✗ Name mismatch — aborted.'));
33 + process.exit(EXIT.USER);
34 + }
35 + const result = await api(config, 'DELETE', `/api/v1/repos/${encodeURIComponent(name)}`);
36 + console.log(`${pc.green('✓')} ${result.deleted} deleted. ${pc.dim(result.note)}`);
37 + });
38 +}
added cli/commands/status.mjs +61 −0
@@ -0,0 +1,61 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : cli/commands/status.mjs
8 + * Purpose : `spbgit status` — aggregated status across workspace repos
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { basename, join } from 'node:path';
14 +import { existsSync } from 'node:fs';
15 +import pc from 'picocolors';
16 +import { requireCliConfig } from '../lib/config.mjs';
17 +import { workspaceRepos, repoStatus } from '../lib/git.mjs';
18 +import { printTable, mapLimit } from '../lib/ui.mjs';
19 +import { EXIT } from '../lib/api.mjs';
20 +
21 +export function registerStatus(program) {
22 + program
23 + .command('status [name]')
24 + .description('aggregated status of workspace repositories')
25 + .option('--all', 'include clean repositories (default)', false)
26 + .option('--json', 'machine-readable output')
27 + .action(async (name, opts) => {
28 + const config = requireCliConfig();
29 + let dirs = workspaceRepos(config.workspace);
30 + if (name) {
31 + const target = join(config.workspace, name);
32 + if (!existsSync(target)) {
33 + console.error(pc.red(`✗ No such workspace repo: ${name}`));
34 + process.exit(EXIT.USER);
35 + }
36 + dirs = [target];
37 + }
38 + if (dirs.length === 0) {
39 + console.log(pc.dim(`Workspace ${config.workspace} has no repositories. Try: spbgit clone --all`));
40 + return;
41 + }
42 + const statuses = await mapLimit(dirs, 4, async (dir) => ({ dir, name: basename(dir), ...(await repoStatus(dir)) }));
43 + if (opts.json) {
44 + console.log(JSON.stringify(statuses, null, 2));
45 + return;
46 + }
47 + const rows = [[pc.bold('REPO'), pc.bold('BRANCH'), pc.bold('AHEAD'), pc.bold('BEHIND'), pc.bold('DIRTY')]];
48 + for (const s of statuses) {
49 + const clean = s.ahead === 0 && s.behind === 0 && s.dirty === 0;
50 + const paint = clean ? pc.dim : (x) => x;
51 + rows.push([
52 + paint(s.name),
53 + paint(s.branch + (s.hasUpstream ? '' : ' (no upstream)')),
54 + s.ahead > 0 ? pc.cyan(`↑${s.ahead}`) : paint('0'),
55 + s.behind > 0 ? pc.yellow(`↓${s.behind}`) : paint('0'),
56 + s.dirty > 0 ? pc.red(String(s.dirty)) : paint('0'),
57 + ]);
58 + }
59 + printTable(rows);
60 + });
61 +}
added cli/commands/sync.mjs +97 −0
@@ -0,0 +1,97 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : cli/commands/sync.mjs
8 + * Purpose : `spbgit sync` — the killer command: commit+rebase+push all
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { basename } from 'node:path';
14 +import pc from 'picocolors';
15 +import { requireCliConfig } from '../lib/config.mjs';
16 +import { workspaceRepos, git, repoStatus } from '../lib/git.mjs';
17 +import { printTable, mapLimit } from '../lib/ui.mjs';
18 +import { EXIT } from '../lib/api.mjs';
19 +
20 +function defaultMessage() {
21 + const now = new Date();
22 + const pad = (n) => String(n).padStart(2, '0');
23 + return `chore: sync ${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`;
24 +}
25 +
26 +/**
27 + * Sync one repository. Never force-pushes.
28 + * @returns {Promise<{repo: string, state: 'synced'|'pushed'|'conflict'|'error', pushedCount: number, note: string}>}
29 + */
30 +async function syncRepo(dir, message, token) {
31 + const repo = basename(dir);
32 + const before = await repoStatus(dir);
33 +
34 + if (before.dirty > 0) {
35 + await git(dir, ['add', '-A']);
36 + const commit = await git(dir, ['commit', '-m', message]);
37 + if (!commit.ok) {
38 + return { repo, state: 'error', pushedCount: 0, note: commit.stderr.trim().split('\n').pop() ?? 'commit failed' };
39 + }
40 + }
41 +
42 + if (before.hasUpstream) {
43 + const pull = await git(dir, ['pull', '--rebase'], { token });
44 + if (!pull.ok) {
45 + await git(dir, ['rebase', '--abort']);
46 + return {
47 + repo,
48 + state: 'conflict',
49 + pushedCount: 0,
50 + note: 'rebase conflict — resolve manually: cd ' + dir + ' && git pull --rebase',
51 + };
52 + }
53 + }
54 +
55 + const after = await repoStatus(dir);
56 + const toPush = after.hasUpstream ? after.ahead : 1;
57 + if (after.hasUpstream && after.ahead === 0) {
58 + return { repo, state: 'synced', pushedCount: 0, note: '' };
59 + }
60 + const pushArgs = after.hasUpstream ? ['push'] : ['push', '-u', 'origin', after.branch];
61 + const push = await git(dir, pushArgs, { token });
62 + if (!push.ok) {
63 + return { repo, state: 'error', pushedCount: 0, note: push.stderr.trim().split('\n').pop() ?? 'push failed' };
64 + }
65 + return { repo, state: 'pushed', pushedCount: toPush, note: '' };
66 +}
67 +
68 +export function registerSync(program) {
69 + program
70 + .command('sync')
71 + .description('for every workspace repo: add-all, commit, pull --rebase, push')
72 + .option('-m, --message <msg>', 'commit message', defaultMessage())
73 + .action(async (opts) => {
74 + const config = requireCliConfig();
75 + const dirs = workspaceRepos(config.workspace);
76 + if (dirs.length === 0) {
77 + console.log(pc.dim(`Workspace ${config.workspace} has no repositories. Try: spbgit clone --all`));
78 + return;
79 + }
80 + console.log(pc.dim(`Syncing ${dirs.length} repositories…\n`));
81 + const results = await mapLimit(dirs, 4, (dir) => syncRepo(dir, opts.message, config.token));
82 +
83 + const rows = [[pc.bold('REPO'), pc.bold('RESULT'), pc.bold('NOTE')]];
84 + for (const r of results) {
85 + const cell =
86 + r.state === 'synced' ? pc.green('✓ synced')
87 + : r.state === 'pushed' ? pc.cyan(`↑ pushed ${r.pushedCount}`)
88 + : r.state === 'conflict' ? pc.red('✗ conflict')
89 + : pc.red('✗ error');
90 + rows.push([r.repo, cell, pc.dim(r.note)]);
91 + }
92 + console.log('');
93 + printTable(rows);
94 + const bad = results.filter((r) => r.state === 'conflict' || r.state === 'error');
95 + if (bad.length > 0) process.exit(EXIT.PARTIAL);
96 + });
97 +}
added cli/commands/token.mjs +58 −0
@@ -0,0 +1,58 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : cli/commands/token.mjs
8 + * Purpose : `spbgit token new|list|revoke` — PAT management
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import pc from 'picocolors';
14 +import { requireCliConfig } from '../lib/config.mjs';
15 +import { api } from '../lib/api.mjs';
16 +import { printTable } from '../lib/ui.mjs';
17 +
18 +export function registerToken(program) {
19 + const token = program.command('token').description('manage personal access tokens');
20 +
21 + token
22 + .command('new')
23 + .description('mint a new PAT (shown exactly once)')
24 + .option('--label <label>', 'token label', 'cli')
25 + .action(async (opts) => {
26 + const config = requireCliConfig();
27 + const result = await api(config, 'POST', '/api/v1/tokens', { label: opts.label });
28 + console.log(`${pc.green('✓')} New token (label: ${result.label}) — copy it now, it will never be shown again:\n`);
29 + console.log(` ${pc.bold(result.token)}\n`);
30 + console.log(pc.dim('Store it with `spbgit init` on the machine that will use it.'));
31 + });
32 +
33 + token
34 + .command('list')
35 + .description('list tokens (hashes never leave the server)')
36 + .option('--json', 'machine-readable output')
37 + .action(async (opts) => {
38 + const config = requireCliConfig();
39 + const { tokens } = await api(config, 'GET', '/api/v1/tokens');
40 + if (opts.json) {
41 + console.log(JSON.stringify(tokens, null, 2));
42 + return;
43 + }
44 + printTable([
45 + [pc.bold('ID'), pc.bold('LABEL'), pc.bold('CREATED'), pc.bold('LAST USED')],
46 + ...tokens.map((t) => [t.id, t.label, t.created?.slice(0, 10) ?? '', t.lastUsed?.slice(0, 16)?.replace('T', ' ') ?? pc.dim('never')]),
47 + ]);
48 + });
49 +
50 + token
51 + .command('revoke <id>')
52 + .description('revoke a token by id')
53 + .action(async (id) => {
54 + const config = requireCliConfig();
55 + await api(config, 'DELETE', `/api/v1/tokens/${encodeURIComponent(id)}`);
56 + console.log(`${pc.green('✓')} Token ${id} revoked.`);
57 + });
58 +}
added cli/lib/api.mjs +63 −0
@@ -0,0 +1,63 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : cli/lib/api.mjs
8 + * Purpose : SPB Git API client (native fetch, friendly errors)
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import pc from 'picocolors';
14 +
15 +/** Exit codes per CLAUDE.md §6.3. */
16 +export const EXIT = Object.freeze({ OK: 0, USER: 1, NETWORK: 2, PARTIAL: 3 });
17 +
18 +/**
19 + * Perform an API request against the configured server.
20 + * @param {object} config CLI config
21 + * @param {string} method
22 + * @param {string} path e.g. `/api/v1/repos`
23 + * @param {object} [body]
24 + * @param {{auth?: boolean}} [opts]
25 + * @returns {Promise<any>} parsed JSON
26 + */
27 +export async function api(config, method, path, body, opts = {}) {
28 + const headers = { Accept: 'application/json' };
29 + if (opts.auth !== false && config.token) headers.Authorization = `Bearer ${config.token}`;
30 + if (body !== undefined) headers['Content-Type'] = 'application/json';
31 + let response;
32 + try {
33 + response = await fetch(`${config.server.replace(/\/+$/, '')}${path}`, {
34 + method,
35 + headers,
36 + body: body === undefined ? undefined : JSON.stringify(body),
37 + });
38 + } catch (err) {
39 + console.error(pc.red(`✗ Cannot reach ${config.server} — ${err.cause?.code ?? err.message}`));
40 + console.error(' Check the server URL in ~/.spbgit/config.json or run: spbgit doctor');
41 + process.exit(EXIT.NETWORK);
42 + }
43 + const text = await response.text();
44 + let json = null;
45 + try {
46 + json = text ? JSON.parse(text) : null;
47 + } catch {
48 + /* non-JSON error body */
49 + }
50 + if (!response.ok) {
51 + if (response.status === 401) {
52 + console.error(pc.red('✗ Token invalid or revoked.'));
53 + console.error(' Run `spbgit token new` on a machine with a valid token, then `spbgit init`.');
54 + process.exit(EXIT.NETWORK);
55 + }
56 + const message = json?.error?.message ?? `HTTP ${response.status}`;
57 + const error = new Error(message);
58 + error.status = response.status;
59 + error.code = json?.error?.code;
60 + throw error;
61 + }
62 + return json;
63 +}
added cli/lib/askpass.sh +15 −0
@@ -0,0 +1,15 @@
1 +#!/bin/sh
2 +# ─────────────────────────────────────────────
3 +# SPB Git — Personal Git Platform
4 +# ─────────────────────────────────────────────
5 +# Author : Simon-Pierre Boucher
6 +# Contact : contact@spboucher.ai
7 +# File : cli/lib/askpass.sh
8 +# Purpose : GIT_ASKPASS helper — feeds credentials from env, never argv
9 +# License : MIT © Simon-Pierre Boucher
10 +# ─────────────────────────────────────────────
11 +case "$1" in
12 + Username*) printf '%s\n' "${SPBGIT_USERNAME:-spb}" ;;
13 + Password*) printf '%s\n' "${SPBGIT_TOKEN}" ;;
14 + *) printf '\n' ;;
15 +esac
added cli/lib/config.mjs +67 −0
@@ -0,0 +1,67 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : cli/lib/config.mjs
8 + * Purpose : ~/.spbgit/config.json loading, saving, validation
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from 'node:fs';
14 +import { homedir } from 'node:os';
15 +import { join } from 'node:path';
16 +
17 +export const CONFIG_DIR = join(homedir(), '.spbgit');
18 +export const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
19 +
20 +const DEFAULTS = {
21 + server: 'https://git.spboucher.ai',
22 + token: '',
23 + workspace: join(homedir(), 'code'),
24 + author: { name: 'Simon-Pierre Boucher', email: 'contact@spboucher.ai' },
25 +};
26 +
27 +/** Expand a leading `~` to the home directory. */
28 +export function expandHome(path) {
29 + if (!path) return path;
30 + return path.startsWith('~') ? join(homedir(), path.slice(1)) : path;
31 +}
32 +
33 +/**
34 + * @returns {object|null} parsed config or null when not initialized
35 + */
36 +export function loadCliConfig() {
37 + if (!existsSync(CONFIG_PATH)) return null;
38 + try {
39 + const parsed = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
40 + return { ...DEFAULTS, ...parsed, workspace: expandHome(parsed.workspace ?? DEFAULTS.workspace) };
41 + } catch {
42 + return null;
43 + }
44 +}
45 +
46 +/**
47 + * Load config or exit(1) with a helpful message.
48 + * @returns {object}
49 + */
50 +export function requireCliConfig() {
51 + const config = loadCliConfig();
52 + if (!config || !config.server) {
53 + console.error('spbgit is not configured. Run: spbgit init');
54 + process.exit(1);
55 + }
56 + return config;
57 +}
58 +
59 +/**
60 + * Persist config with 600 permissions (contains the PAT).
61 + * @param {object} config
62 + */
63 +export function saveCliConfig(config) {
64 + mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
65 + writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
66 + chmodSync(CONFIG_PATH, 0o600);
67 +}
added cli/lib/git.mjs +87 −0
@@ -0,0 +1,87 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : cli/lib/git.mjs
8 + * Purpose : Local git helpers — workspace scan, authed push, status
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { execFile } from 'node:child_process';
14 +import { promisify } from 'node:util';
15 +import { readdirSync, existsSync, chmodSync } from 'node:fs';
16 +import { join, dirname } from 'node:path';
17 +import { fileURLToPath } from 'node:url';
18 +
19 +const execFileAsync = promisify(execFile);
20 +const ASKPASS = join(dirname(fileURLToPath(import.meta.url)), 'askpass.sh');
21 +
22 +/**
23 + * Run git in a directory.
24 + * @param {string} cwd
25 + * @param {string[]} args
26 + * @param {{token?: string}} [opts] token → authenticated via GIT_ASKPASS (never argv)
27 + * @returns {Promise<{ok: boolean, stdout: string, stderr: string}>}
28 + */
29 +export async function git(cwd, args, opts = {}) {
30 + const env = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
31 + if (opts.token) {
32 + try {
33 + chmodSync(ASKPASS, 0o755);
34 + } catch {
35 + /* read-only install — askpass already executable from packaging */
36 + }
37 + env.GIT_ASKPASS = ASKPASS;
38 + env.SPBGIT_TOKEN = opts.token;
39 + env.SPBGIT_USERNAME = 'spb';
40 + }
41 + try {
42 + const { stdout, stderr } = await execFileAsync('git', args, { cwd, env, maxBuffer: 32 * 1024 * 1024 });
43 + return { ok: true, stdout, stderr };
44 + } catch (err) {
45 + return { ok: false, stdout: err.stdout ?? '', stderr: err.stderr ?? err.message };
46 + }
47 +}
48 +
49 +/**
50 + * List workspace repositories (immediate subdirectories containing .git).
51 + * @param {string} workspace
52 + * @returns {string[]} absolute paths
53 + */
54 +export function workspaceRepos(workspace) {
55 + if (!existsSync(workspace)) return [];
56 + return readdirSync(workspace)
57 + .filter((entry) => !entry.startsWith('.'))
58 + .map((entry) => join(workspace, entry))
59 + .filter((dir) => existsSync(join(dir, '.git')))
60 + .sort();
61 +}
62 +
63 +/**
64 + * Status snapshot of one repo.
65 + * @param {string} dir
66 + * @returns {Promise<{branch: string, ahead: number, behind: number, dirty: number, hasUpstream: boolean}>}
67 + */
68 +export async function repoStatus(dir) {
69 + const branchRes = await git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']);
70 + const branch = branchRes.ok ? branchRes.stdout.trim() : '?';
71 + const dirtyRes = await git(dir, ['status', '--porcelain']);
72 + const dirty = dirtyRes.ok ? dirtyRes.stdout.split('\n').filter(Boolean).length : 0;
73 + let ahead = 0;
74 + let behind = 0;
75 + let hasUpstream = false;
76 + const upstream = await git(dir, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
77 + if (upstream.ok) {
78 + hasUpstream = true;
79 + const counts = await git(dir, ['rev-list', '--left-right', '--count', '@{u}...HEAD']);
80 + if (counts.ok) {
81 + const [b, a] = counts.stdout.trim().split('\t').map(Number);
82 + behind = b || 0;
83 + ahead = a || 0;
84 + }
85 + }
86 + return { branch, ahead, behind, dirty, hasUpstream };
87 +}
added cli/lib/ui.mjs +83 −0
@@ -0,0 +1,83 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : cli/lib/ui.mjs
8 + * Purpose : CLI output helpers — aligned tables, prompts, concurrency
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { createInterface } from 'node:readline/promises';
14 +import pc from 'picocolors';
15 +
16 +/**
17 + * Print an aligned table.
18 + * @param {string[][]} rows already-colored cells allowed (width uses raw length)
19 + * @param {{pad?: number}} [opts]
20 + */
21 +export function printTable(rows, opts = {}) {
22 + if (rows.length === 0) return;
23 + const pad = opts.pad ?? 2;
24 + // eslint-disable-next-line no-control-regex
25 + const visible = (s) => String(s).replace(/\u001b\[[0-9;]*m/g, '');
26 + const widths = [];
27 + for (const row of rows) {
28 + row.forEach((cell, i) => {
29 + widths[i] = Math.max(widths[i] ?? 0, visible(cell).length);
30 + });
31 + }
32 + for (const row of rows) {
33 + const line = row
34 + .map((cell, i) => cell + ' '.repeat(widths[i] - visible(cell).length + (i < row.length - 1 ? pad : 0)))
35 + .join('');
36 + console.log(line.trimEnd());
37 + }
38 +}
39 +
40 +/**
41 + * Ask one interactive question.
42 + * @param {string} question
43 + * @param {string} [fallback]
44 + * @returns {Promise<string>}
45 + */
46 +export async function prompt(question, fallback = '') {
47 + const rl = createInterface({ input: process.stdin, output: process.stdout });
48 + const suffix = fallback ? pc.dim(` (${fallback})`) : '';
49 + const answer = (await rl.question(`${question}${suffix}: `)).trim();
50 + rl.close();
51 + return answer || fallback;
52 +}
53 +
54 +/**
55 + * Run at most `limit` async jobs concurrently, preserving order.
56 + * @template T,R
57 + * @param {T[]} items
58 + * @param {number} limit
59 + * @param {(item: T, index: number) => Promise<R>} worker
60 + * @returns {Promise<R[]>}
61 + */
62 +export async function mapLimit(items, limit, worker) {
63 + const results = new Array(items.length);
64 + let next = 0;
65 + const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
66 + while (next < items.length) {
67 + const index = next;
68 + next += 1;
69 + results[index] = await worker(items[index], index);
70 + }
71 + });
72 + await Promise.all(runners);
73 + return results;
74 +}
75 +
76 +/** Symbols shared across commands. */
77 +export const SYM = Object.freeze({
78 + ok: pc.green('✓'),
79 + push: pc.cyan('↑'),
80 + pull: pc.yellow('↓'),
81 + fail: pc.red('✗'),
82 + skip: pc.dim('·'),
83 +});
added cli/spbgit.mjs +48 −0
@@ -0,0 +1,48 @@
1 +#!/usr/bin/env node
2 +/**
3 + * ─────────────────────────────────────────────
4 + * SPB Git — Personal Git Platform
5 + * ─────────────────────────────────────────────
6 + * Author : Simon-Pierre Boucher
7 + * Contact : contact@spboucher.ai
8 + * File : cli/spbgit.mjs
9 + * Purpose : spbgit CLI entry point — terminal command center
10 + * License : MIT © Simon-Pierre Boucher
11 + * ─────────────────────────────────────────────
12 + */
13 +
14 +import { Command } from 'commander';
15 +import pc from 'picocolors';
16 +import { registerInit } from './commands/init.mjs';
17 +import { registerToken } from './commands/token.mjs';
18 +import { registerList } from './commands/list.mjs';
19 +import { registerCreate } from './commands/create.mjs';
20 +import { registerClone } from './commands/clone.mjs';
21 +import { registerStatus } from './commands/status.mjs';
22 +import { registerCommit } from './commands/commit.mjs';
23 +import { registerPush } from './commands/push.mjs';
24 +import { registerPull } from './commands/pull.mjs';
25 +import { registerSync } from './commands/sync.mjs';
26 +import { registerOpen } from './commands/open.mjs';
27 +import { registerInfo } from './commands/info.mjs';
28 +import { registerRm } from './commands/rm.mjs';
29 +import { registerDoctor } from './commands/doctor.mjs';
30 +
31 +const program = new Command();
32 +program
33 + .name('spbgit')
34 + .description('SPB Git — manage every repository on git.spboucher.ai from the terminal')
35 + .version('1.0.0');
36 +
37 +for (const register of [
38 + registerInit, registerToken, registerList, registerCreate, registerClone,
39 + registerStatus, registerCommit, registerPush, registerPull, registerSync,
40 + registerOpen, registerInfo, registerRm, registerDoctor,
41 +]) {
42 + register(program);
43 +}
44 +
45 +program.parseAsync(process.argv).catch((err) => {
46 + console.error(pc.red(`✗ ${err.message}`));
47 + process.exit(1);
48 +});
49