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>
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 | ||