unitbob 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // The single entry point. Parse `unitbob <verb> [args]`, dispatch to a hands-verb,
3
3
  // and map any thrown error to a non-zero exit with an actionable message — never
4
4
  // a raw stack trace. This is the only place that decides process exit codes.
5
- import { loadConfig } from "./config.js";
5
+ import { ensureLinked } from "./link.js";
6
6
  import { recipe } from "./verbs/recipe.js";
7
7
  import { show } from "./verbs/show.js";
8
8
  import { run } from "./verbs/run.js";
@@ -17,7 +17,7 @@ const USAGE = `unitbob — thin local hands for the Unitbob server.
17
17
  Usage: unitbob <verb> [args]
18
18
 
19
19
  Verbs:
20
- init Write a .unitbob.json template and git-ignore it.
20
+ init Link this project to Unitbob (also happens automatically).
21
21
  recipe <name> Fetch and print a recipe from the server.
22
22
  show Print the link to this project's map.
23
23
  map-prepare Internal: keylessly update the graph (no API key) and write the host map-build request.
@@ -35,7 +35,8 @@ output_path from your local source; \`put-*\` uploads only the structured result
35
35
  Graph extraction is keyless: the connector needs no LLM API key. Inference is the
36
36
  host-LLM's job; any semantic graph enrichment is host-LLM work (the /graphify skill).
37
37
 
38
- Config: .unitbob.json at your project root { "server": "...", "repo_id": <number> }.`;
38
+ Config: .unitbob.json at your project root, created automatically: the first
39
+ run registers the project on the server by its folder name (spec 28).`;
39
40
  async function main(argv) {
40
41
  const [verb, ...args] = argv;
41
42
  if (!verb || verb === '--help' || verb === '-h' || verb === 'help') {
@@ -48,29 +49,29 @@ async function main(argv) {
48
49
  await init(args);
49
50
  return 0;
50
51
  case 'recipe':
51
- await recipe(loadConfig(), args);
52
+ await recipe(await ensureLinked(), args);
52
53
  return 0;
53
54
  case 'show':
54
- await show(loadConfig());
55
+ await show(await ensureLinked());
55
56
  return 0;
56
57
  case 'map-prepare':
57
- await mapPrepare(loadConfig(), args);
58
+ await mapPrepare(await ensureLinked(), args);
58
59
  return 0;
59
60
  case 'put-map-build':
60
- await putMapBuild(loadConfig(), args);
61
+ await putMapBuild(await ensureLinked(), args);
61
62
  return 0;
62
63
  case 'suite-prepare':
63
- await suitePrepare(loadConfig(), args);
64
+ await suitePrepare(await ensureLinked(), args);
64
65
  return 0;
65
66
  case 'put-suite-build':
66
- await putSuiteBuild(loadConfig(), args);
67
+ await putSuiteBuild(await ensureLinked(), args);
67
68
  return 0;
68
69
  case 'fix-prepare':
69
- await fixPrepare(loadConfig(), args);
70
+ await fixPrepare(await ensureLinked(), args);
70
71
  return 0;
71
72
  case 'run':
72
73
  case 'check':
73
- await run(loadConfig(), args);
74
+ await run(await ensureLinked(), args);
74
75
  return 0;
75
76
  default:
76
77
  process.stderr.write(`Unknown verb "${verb}".\n\n${USAGE}\n`);
package/dist/config.js CHANGED
@@ -1,53 +1,29 @@
1
1
  // Per-project config for the connector. Lives in `.unitbob.json` at the project
2
- // root: { "server": "https://…", "repo_id": 3 }. No secret — auth is deferred
3
- // (spec 15, decision #3). Every verb reads it. A missing or malformed file must
4
- // fail with an actionable setup message, never a raw stack trace.
5
- import { existsSync, readFileSync } from 'node:fs';
6
- import { dirname, join } from 'node:path';
7
- const CONFIG_FILE = '.unitbob.json';
8
- const SETUP_HINT = 'run `unitbob init`, or create a `.unitbob.json` at your project root with ' +
9
- '{ "server": "https://your-unitbob-host", "repo_id": <number> }';
10
- // Walk up from `startDir` to the filesystem root looking for `.unitbob.json`.
11
- export function findConfigPath(startDir = process.cwd()) {
12
- let dir = startDir;
13
- for (;;) {
14
- const candidate = join(dir, CONFIG_FILE);
15
- if (existsSync(candidate))
16
- return candidate;
17
- const parent = dirname(dir);
18
- if (parent === dir)
19
- return null;
20
- dir = parent;
21
- }
22
- }
23
- export function loadConfig(startDir = process.cwd()) {
24
- const path = findConfigPath(startDir);
25
- if (!path) {
26
- throw new Error(`No ${CONFIG_FILE} found — ${SETUP_HINT}`);
27
- }
28
- let raw;
29
- try {
30
- raw = readFileSync(path, 'utf8');
31
- }
32
- catch (err) {
33
- throw new Error(`Could not read ${path} (${err.message}) — ${SETUP_HINT}`);
34
- }
2
+ // root: { "server": "http://…", "repo_id": 3 }. No secret — auth is deferred
3
+ // (spec 15, decision #3). Linking is automatic (spec 28): every verb goes
4
+ // through `ensureLinked` (src/link.ts), which registers the project by folder
5
+ // name when there is no working link. Only the project root's own file counts —
6
+ // never a parent directory's (no walk-up).
7
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ export const CONFIG_FILE = '.unitbob.json';
10
+ // The repo id stored at `cwd`, or null when there is no working link: file
11
+ // missing, unreadable, malformed JSON, or repo_id absent / 0 / non-integer
12
+ // (the legacy init template wrote repo_id: 0). Callers re-link on null.
13
+ export function readLocalRepoId(cwd) {
14
+ const path = join(cwd, CONFIG_FILE);
15
+ if (!existsSync(path))
16
+ return null;
35
17
  let parsed;
36
18
  try {
37
- parsed = JSON.parse(raw);
19
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
38
20
  }
39
21
  catch {
40
- throw new Error(`${path} is not valid JSON — ${SETUP_HINT}`);
41
- }
42
- const obj = (parsed && typeof parsed === 'object' ? parsed : {});
43
- const server = obj.server;
44
- const repoId = obj.repo_id;
45
- if (typeof server !== 'string' || server.trim() === '') {
46
- throw new Error(`${path} is missing a "server" string — ${SETUP_HINT}`);
22
+ return null;
47
23
  }
48
- if (typeof repoId !== 'number' || !Number.isInteger(repoId)) {
49
- throw new Error(`${path} is missing an integer "repo_id" ${SETUP_HINT}`);
50
- }
51
- // Trim a trailing slash so callers can join paths without doubling up.
52
- return { server: server.trim().replace(/\/+$/, ''), repoId, projectRoot: dirname(path) };
24
+ const repoId = parsed && typeof parsed === 'object' ? parsed.repo_id : undefined;
25
+ return typeof repoId === 'number' && Number.isInteger(repoId) && repoId > 0 ? repoId : null;
26
+ }
27
+ export function writeConfigFile(cwd, config) {
28
+ writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`);
53
29
  }
package/dist/link.js ADDED
@@ -0,0 +1,63 @@
1
+ // Zero-touch linking (spec 28): every verb starts here. A project is identified
2
+ // by its folder name — basename(cwd), nothing else — and resolved on the server
3
+ // idempotently each run. The user never supplies a repo_id; it is an internal
4
+ // server key nobody is expected to know.
5
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { basename, dirname, join } from 'node:path';
8
+ import { CONFIG_FILE, readLocalRepoId, writeConfigFile } from "./config.js";
9
+ import { registerRepo, WireError } from "./wire.js";
10
+ // Grill decision #1: hosted deployment is deferred; the local brain is the only
11
+ // server for now, so the URL is a literal.
12
+ export const DEFAULT_SERVER = 'http://localhost:3000';
13
+ // Make sure this project is linked, resolving the repo by name on every run
14
+ // (cheap — the server find-or-creates) and reconciling with the local file:
15
+ // - no working link (file missing / repo_id 0 / non-int) → register, write
16
+ // the file, announce in one calm line;
17
+ // - the file's id matches the name's server id → proceed silently;
18
+ // - mismatch → fail here, before any expensive work — never silently re-link,
19
+ // the file may point at a real repo the user cares about.
20
+ export async function ensureLinked(cwd = process.cwd(), server = DEFAULT_SERVER) {
21
+ const fileId = readLocalRepoId(cwd); // only cwd's own file — no walk-up
22
+ const name = basename(cwd);
23
+ // Refuse before touching the server, so a stray run can't mint a junk repo.
24
+ if (fileId === null)
25
+ assertProjectRoot(cwd);
26
+ const authId = await registerRepo(server, name);
27
+ if (fileId === null) {
28
+ writeConfigFile(cwd, { server, repo_id: authId });
29
+ ensureGitignored(cwd);
30
+ process.stdout.write(`Linked this project to Unitbob as ${name}.\n`);
31
+ }
32
+ else if (fileId !== authId) {
33
+ throw new WireError(`${CONFIG_FILE} points at repo ${fileId}, but "${name}" is repo ${authId} on the server. ` +
34
+ `Fix or remove ${CONFIG_FILE} before continuing.`);
35
+ }
36
+ return { server, repoId: authId, projectRoot: cwd };
37
+ }
38
+ // Linking writes a file and creates a server row — only do that at a real
39
+ // project root: a `.git` present, and never at $HOME or the filesystem root.
40
+ export function assertProjectRoot(cwd) {
41
+ const isFsRoot = dirname(cwd) === cwd;
42
+ if (isFsRoot || cwd === homedir() || !existsSync(join(cwd, '.git'))) {
43
+ throw new WireError(`${cwd} does not look like a project root (no .git here) — ` +
44
+ "run this from your project's root folder.");
45
+ }
46
+ }
47
+ const ARTIFACT_DIR = '.unitbob/';
48
+ const GITIGNORE = '.gitignore';
49
+ // The config and the artifact dir are per-machine state, never committed.
50
+ export function ensureGitignored(cwd) {
51
+ const gitignorePath = join(cwd, GITIGNORE);
52
+ let current = '';
53
+ if (existsSync(gitignorePath)) {
54
+ current = readFileSync(gitignorePath, 'utf8');
55
+ }
56
+ const lines = current.split('\n').map((line) => line.trim());
57
+ const additions = [CONFIG_FILE, ARTIFACT_DIR].filter((line) => !lines.includes(line));
58
+ if (additions.length === 0)
59
+ return;
60
+ const prefix = current.length > 0 && !current.endsWith('\n') ? '\n' : '';
61
+ writeFileSync(gitignorePath, `${current}${prefix}${additions.join('\n')}\n`);
62
+ process.stdout.write(`Added ${additions.join(', ')} to ${GITIGNORE}.\n`);
63
+ }
package/dist/proc.js CHANGED
@@ -51,8 +51,9 @@ export async function requireGraphify() {
51
51
  throw new Error(result.stderr.trim() || result.stdout.trim() || `graphify --help exited ${result.code}`);
52
52
  }
53
53
  catch (err) {
54
- throw new Error(`graphify is required but was not found or did not run. Install graphify and make sure ` +
55
- `it is available in PATH (${err.message}).`);
54
+ throw new Error(`graphify is required but was not found or did not run. Install it with ` +
55
+ `\`pip install graphifyy && graphify install\` (PyPI package "graphifyy", command ` +
56
+ `"graphify", needs Python 3.10+), then retry (${err.message}).`);
56
57
  }
57
58
  }
58
59
  export function ensureUnitbobIgnored(projectRoot) {
@@ -1,35 +1,15 @@
1
- // `unitbob init` — write a `.unitbob.json` template at the current directory and
2
- // make sure it is git-ignored. This is the one verb that does not require an
3
- // existing config, since its job is to create one.
4
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
5
- import { join } from 'node:path';
6
- const CONFIG_FILE = '.unitbob.json';
7
- const ARTIFACT_DIR = '.unitbob/';
8
- const GITIGNORE = '.gitignore';
1
+ // `unitbob init` — link this project to Unitbob (spec 28: zero-touch). Kept as
2
+ // an explicit verb so setup can be scripted, but every other verb links
3
+ // automatically too; running it twice is safe.
4
+ import { basename } from 'node:path';
5
+ import { readLocalRepoId } from "../config.js";
6
+ import { ensureGitignored, ensureLinked } from "../link.js";
9
7
  export async function init(_args) {
10
8
  const cwd = process.cwd();
11
- const configPath = join(cwd, CONFIG_FILE);
12
- if (existsSync(configPath)) {
13
- process.stdout.write(`${CONFIG_FILE} already exists at ${configPath} — leaving it untouched.\n`);
14
- }
15
- else {
16
- const template = { server: 'http://localhost:3000', repo_id: 0 };
17
- writeFileSync(configPath, `${JSON.stringify(template, null, 2)}\n`);
18
- process.stdout.write(`Wrote ${configPath}. Edit "server" and "repo_id" to point at your Unitbob host.\n`);
9
+ const alreadyLinked = readLocalRepoId(cwd) !== null;
10
+ await ensureLinked(cwd); // announces on a fresh link; throws on a mismatch
11
+ if (alreadyLinked) {
12
+ process.stdout.write(`Already linked as ${basename(cwd)} — leaving .unitbob.json untouched.\n`);
19
13
  }
20
14
  ensureGitignored(cwd);
21
15
  }
22
- function ensureGitignored(cwd) {
23
- const gitignorePath = join(cwd, GITIGNORE);
24
- let current = '';
25
- if (existsSync(gitignorePath)) {
26
- current = readFileSync(gitignorePath, 'utf8');
27
- }
28
- const lines = current.split('\n').map((line) => line.trim());
29
- const additions = [CONFIG_FILE, ARTIFACT_DIR].filter((line) => !lines.includes(line));
30
- if (additions.length === 0)
31
- return;
32
- const prefix = current.length > 0 && !current.endsWith('\n') ? '\n' : '';
33
- writeFileSync(gitignorePath, `${current}${prefix}${additions.join('\n')}\n`);
34
- process.stdout.write(`Added ${additions.join(', ')} to ${GITIGNORE}.\n`);
35
- }
package/dist/wire.js CHANGED
@@ -2,6 +2,39 @@
2
2
  // Verbs surface its message and exit non-zero; they never fabricate a result.
3
3
  export class WireError extends Error {
4
4
  }
5
+ // POST /repos/register — the linking bootstrap (spec 28). A standalone function
6
+ // rather than a Wire method because at link time there is no Config yet: only a
7
+ // server URL and the project's folder name. Idempotent on the server.
8
+ export async function registerRepo(server, name) {
9
+ const url = `${server}/repos/register`;
10
+ let res;
11
+ try {
12
+ res = await fetch(url, {
13
+ method: 'POST',
14
+ headers: { 'content-type': 'application/json' },
15
+ body: JSON.stringify({ name }),
16
+ });
17
+ }
18
+ catch (err) {
19
+ throw new WireError(`Cannot reach the Unitbob server at ${server} (${err.message}). ` +
20
+ 'Check that the server is running.');
21
+ }
22
+ if (!res.ok) {
23
+ let detail = '';
24
+ try {
25
+ detail = (await res.text()).slice(0, 500);
26
+ }
27
+ catch {
28
+ // ignore — the status alone is actionable enough
29
+ }
30
+ throw new WireError(`POST ${url} failed: ${res.status} ${res.statusText}${detail ? ` — ${detail}` : ''}`);
31
+ }
32
+ const payload = (await res.json());
33
+ if (typeof payload.id !== 'number' || !Number.isInteger(payload.id)) {
34
+ throw new WireError(`POST ${url} returned a malformed payload: expected an integer id.`);
35
+ }
36
+ return payload.id;
37
+ }
5
38
  export class Wire {
6
39
  config;
7
40
  constructor(config) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Unitbob connector — thin local hands for the Unitbob Rails brain. Owns no domain logic: it runs tools, relays bytes over the wire, and prints what the server returns.",
5
5
  "type": "module",
6
6
  "bin": {