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 +12 -11
- package/dist/config.js +22 -46
- package/dist/link.js +63 -0
- package/dist/proc.js +3 -2
- package/dist/verbs/init.js +10 -30
- package/dist/wire.js +33 -0
- package/package.json +1 -1
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 {
|
|
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
|
|
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
|
|
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(
|
|
52
|
+
await recipe(await ensureLinked(), args);
|
|
52
53
|
return 0;
|
|
53
54
|
case 'show':
|
|
54
|
-
await show(
|
|
55
|
+
await show(await ensureLinked());
|
|
55
56
|
return 0;
|
|
56
57
|
case 'map-prepare':
|
|
57
|
-
await mapPrepare(
|
|
58
|
+
await mapPrepare(await ensureLinked(), args);
|
|
58
59
|
return 0;
|
|
59
60
|
case 'put-map-build':
|
|
60
|
-
await putMapBuild(
|
|
61
|
+
await putMapBuild(await ensureLinked(), args);
|
|
61
62
|
return 0;
|
|
62
63
|
case 'suite-prepare':
|
|
63
|
-
await suitePrepare(
|
|
64
|
+
await suitePrepare(await ensureLinked(), args);
|
|
64
65
|
return 0;
|
|
65
66
|
case 'put-suite-build':
|
|
66
|
-
await putSuiteBuild(
|
|
67
|
+
await putSuiteBuild(await ensureLinked(), args);
|
|
67
68
|
return 0;
|
|
68
69
|
case 'fix-prepare':
|
|
69
|
-
await fixPrepare(
|
|
70
|
+
await fixPrepare(await ensureLinked(), args);
|
|
70
71
|
return 0;
|
|
71
72
|
case 'run':
|
|
72
73
|
case 'check':
|
|
73
|
-
await run(
|
|
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": "
|
|
3
|
-
// (spec 15, decision #3).
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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(
|
|
19
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
38
20
|
}
|
|
39
21
|
catch {
|
|
40
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
55
|
-
|
|
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) {
|
package/dist/verbs/init.js
CHANGED
|
@@ -1,35 +1,15 @@
|
|
|
1
|
-
// `unitbob init` —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
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.
|
|
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": {
|