Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,21 @@ bool pull my-project ./local-copy --version 3

- `--version` — Specific version number (default: latest)

### Git push-to-deploy

Deploy with `git push` instead of `bool deploy`:

```bash
bool git init # writes: git remote add bool bool::<slug>
git push bool main # deploys the pushed commit
```

`bool git init` reads the slug from `.bool/config` in the current directory (or pass `--slug <slug>`). After that, any `git push bool <branch>` extracts the pushed commit into a temporary git worktree and runs `bool deploy <slug>` against it — so the deploy reflects exactly what's committed, not whatever is dirty in your working tree.

The commit message becomes `git push <branch> (<sha7>)`. Authentication uses your existing `BOOL_API_KEY` / `~/.config/bool-cli/config.json`.

Under the hood this ships a `git-remote-bool` helper that git invokes when it sees the `bool::<slug>` URL scheme. The helper is installed alongside `bool` by npm, so anything that has `bool` on PATH also has the helper on PATH.

### Claim

```bash
Expand Down Expand Up @@ -174,6 +189,7 @@ Add `.bool/` to your `.gitignore`.
bool-cli/
bin/
bool.js # Entry point + global flags
git-remote-bool.js # `git push bool main` helper
src/
commands/
auth.js # auth login, status, doctor
Expand All @@ -182,6 +198,7 @@ bool-cli/
versions.js # versions, deploy, pull
claim.js # claim anonymous Bool
skill.js # install agent skill
git.js # bool git init (configure push-to-deploy remote)
utils/
action.js # action wrapper: typed errors + global flag plumbing
api.js # API client → typed CliError on HTTP failure
Expand Down
2 changes: 2 additions & 0 deletions bin/bool.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { register as shipit } from '../src/commands/shipit.js';
import { register as versions } from '../src/commands/versions.js';
import { register as skill } from '../src/commands/skill.js';
import { register as claim } from '../src/commands/claim.js';
import { register as git } from '../src/commands/git.js';

const require = createRequire(import.meta.url);
const { version } = require('../package.json');
Expand Down Expand Up @@ -36,6 +37,7 @@ shipit(program);
versions(program);
skill(program);
claim(program);
git(program);

program.parseAsync(process.argv).catch((err) => {
process.stderr.write(`✖ ${err.message}\n`);
Expand Down
145 changes: 145 additions & 0 deletions bin/git-remote-bool.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
#!/usr/bin/env node
//
// git-remote-bool — git remote helper that deploys to bool.com on push.
//
// Set up:
// git remote add bool bool::<slug> (or: bool git init)
//
// Use:
// git push bool main
//
// When the user runs `git push bool <ref>`, git invokes this binary as
// `git-remote-bool bool <slug>` and speaks the remote-helper protocol on
// stdin/stdout. We accept the push, extract the pushed commit's tree into
// a temporary git worktree, and shell out to `bool deploy <slug>` from
// inside it.
//
// Protocol reference: `git help remote-helpers`.

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import readline from 'node:readline';
import { spawnSync } from 'node:child_process';

const address = process.argv[3] || '';

let pushBatch = [];

const rl = readline.createInterface({ input: process.stdin });

rl.on('line', (line) => {
try {
handle(line);
} catch (err) {
process.stderr.write(`✖ git-remote-bool: ${err.message}\n`);
process.exit(1);
}
});

rl.on('close', () => process.exit(0));

function out(s) {
process.stdout.write(s);
}

function handle(line) {
if (line === 'capabilities') {
out('push\noption\n\n');
return;
}
if (line.startsWith('option ')) {
// Accept (and ignore) all options so git doesn't bail.
out('ok\n');
return;
}
if (line === 'list' || line === 'list for-push') {
// We don't track remote refs. An empty list tells git the remote has
// no refs yet, so any local ref is treated as new and gets pushed.
out('\n');
return;
}
if (line.startsWith('push ')) {
pushBatch.push(line.slice(5));
return;
}
if (line === '') {
if (pushBatch.length) {
doPush(pushBatch);
pushBatch = [];
}
return;
}
// Unknown command: end the batch politely.
out('\n');
}

function doPush(specs) {
const slug = resolveSlug();

for (const raw of specs) {
const spec = raw.replace(/^\+/, '');
const [src, dst] = spec.split(':');

if (!slug) {
out(`error ${dst} no slug configured; set: git remote set-url bool bool::<slug>\n`);
continue;
}

const result = deployRef(src, slug, dst);
out(result + '\n');
}
out('\n');
}

function resolveSlug() {
if (address) return address;
try {
const cfg = JSON.parse(fs.readFileSync(path.join(process.cwd(), '.bool', 'config'), 'utf-8'));
return cfg.slug || null;
} catch {
return null;
}
}

function deployRef(src, slug, dst) {
const rev = spawnSync('git', ['rev-parse', src], { encoding: 'utf-8' });
if (rev.status !== 0) {
return `error ${dst} could not resolve ${src}`;
}
const sha = rev.stdout.trim();

const worktree = fs.mkdtempSync(path.join(os.tmpdir(), 'bool-deploy-'));
try {
const add = spawnSync('git', ['worktree', 'add', '--detach', worktree, sha], {
stdio: ['ignore', 'pipe', 'pipe'],
encoding: 'utf-8',
});
if (add.status !== 0) {
return `error ${dst} git worktree add failed: ${(add.stderr || '').trim()}`;
}

const branch = src.replace(/^refs\/heads\//, '');
const message = `git push ${branch} (${sha.slice(0, 7)})`;

// Child stderr forwards to the user; child stdout is discarded so it
// doesn't pollute the helper protocol on our stdout. The explicit `.`
// prevents `bool deploy`'s slug-or-dir heuristic from reinterpreting the
// slug as a directory path if the worktree contains a folder of the
// same name.
const deploy = spawnSync('bool', ['deploy', slug, '.', '-m', message], {
cwd: worktree,
stdio: ['ignore', 'pipe', 'inherit'],
});

if (deploy.status !== 0) {
return `error ${dst} bool deploy exited with status ${deploy.status}`;
}
return `ok ${dst}`;
} finally {
spawnSync('git', ['worktree', 'remove', '--force', worktree], { stdio: 'ignore' });
if (fs.existsSync(worktree)) {
fs.rmSync(worktree, { recursive: true, force: true });
}
}
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"url": "https://github.com/codehs/bool-cli.git"
},
"bin": {
"bool": "./bin/bool.js"
"bool": "./bin/bool.js",
"git-remote-bool": "./bin/git-remote-bool.js"
},
"files": [
"bin/",
Expand Down
53 changes: 53 additions & 0 deletions src/commands/git.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { readProjectConfig } from '../utils/config.js';
import { success, info, data as printData } from '../utils/output.js';
import { action, usage } from '../utils/action.js';

function run(cmd, args) {
return spawnSync(cmd, args, { encoding: 'utf-8' });
}

export function register(program) {
const git = program.command('git').description('Git integration (push-to-deploy)');

git
.command('init')
.description('Add a `bool::<slug>` git remote so `git push bool <branch>` deploys this Bool')
.option('--remote <name>', 'Remote name', 'bool')
.option('--slug <slug>', 'Bool slug (defaults to .bool/config in cwd)')
.action(action(async (opts) => {
const slug = opts.slug || readProjectConfig(process.cwd()).slug;
if (!slug) {
usage('No slug.', {
hint: 'Run `bool create <name>` or `bool shipit` first, or pass --slug.',
});
}

if (!fs.existsSync(path.join(process.cwd(), '.git'))) {
usage('Not a git repository.', { hint: 'Run `git init` first.' });
}

const url = `bool::${slug}`;

if (opts.dryRun) {
info(`[dry-run] Would set git remote "${opts.remote}" to ${url}`);
return;
}

const existing = run('git', ['remote', 'get-url', opts.remote]);
const op = existing.status === 0 ? 'set-url' : 'add';
const result = run('git', ['remote', op, opts.remote, url]);
if (result.status !== 0) {
usage(`git remote ${op} failed: ${(result.stderr || '').trim()}`);
}

const summary = { remote: opts.remote, url, slug, action: op === 'add' ? 'added' : 'updated' };
const shaped = printData(summary);
if (shaped !== undefined) {
success(`${op === 'add' ? 'Added' : 'Updated'} remote "${opts.remote}" → ${url}`);
info(`Deploy with: git push ${opts.remote} <branch>`);
}
}));
}