unitbob 0.2.8 → 0.3.0

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/README.md CHANGED
@@ -7,7 +7,8 @@ Works with Ruby on Rails (RSpec), JavaScript/TypeScript (Vitest), and Python
7
7
  (pytest) projects — the guardrail tests are generated in your project's own
8
8
  language and run with its native test runner.
9
9
 
10
- You work through Claude Code. You need: Node 18+, Python 3.10+.
10
+ You work through a coding agent — Claude Code or Codex both work. You need:
11
+ Node 18+, Python 3.10+.
11
12
 
12
13
  ---
13
14
 
@@ -27,6 +28,8 @@ claude plugin install unitbob@unitbob
27
28
 
28
29
  Restart the session so the commands load.
29
30
 
31
+ In Codex it is the same — the same install, and the same phrasings below.
32
+
30
33
  ---
31
34
 
32
35
  ## Full cycle
@@ -36,15 +39,21 @@ Just type it in the chat. There is nothing to memorise and no command to get rig
36
39
  | Step | Say this |
37
40
  |------|----------|
38
41
  | 1. Build the map | `Build my Unitbob map` |
39
- | 2. Generate tests | `Generate the guardrail tests` |
40
- | 3. Run the checks | `Run the checks` |
41
- | 4. Fix a red lamp | `Fix guardrail <id>` |
42
- | 5. Open the map | `Open my Unitbob map` |
42
+ | 2. Generate the tests | `Generate the guardrail tests` |
43
+ | 3. Fix a red lamp | `Fix guardrail <id>` |
44
+ | 4. Open the map | `Open my Unitbob map` |
45
+
46
+ Step 2 lights the lamps by itself: generating the tests also runs them and sends
47
+ the results. You do not have to ask for a run to see the first result.
48
+
49
+ Say `Run the checks` later, whenever you want the lamps refreshed against your
50
+ current code — or to finish the job if a generation was interrupted after the
51
+ tests were saved but before they ran.
43
52
 
44
- There are also `/unitbob:map`, `/unitbob:suite` and friends, but they work only
45
- inside a Claude Code terminal, and only in a session started after the plugin was
46
- installed — in a browser or desktop window they are not recognised at all. The
47
- phrasings above work everywhere, so they are the ones documented here.
53
+ In Claude Code there are also `/unitbob:map`, `/unitbob:suite` and friends, but
54
+ they work only in a terminal session started after the plugin was installed — in
55
+ a browser or desktop window they are not recognised at all. The phrasings above
56
+ work everywhere, so they are the ones documented here.
48
57
 
49
58
  ---
50
59
 
@@ -52,5 +61,5 @@ phrasings above work everywhere, so they are the ones documented here.
52
61
 
53
62
  - **Green lamp** — the behavior works.
54
63
  - **Red lamp** — something the structure relied on broke. Copy its `id` and run
55
- step 4.
64
+ step 3.
56
65
  - The project links itself by folder name — nothing to set up by hand.
package/dist/cli.js CHANGED
@@ -8,30 +8,44 @@
8
8
  // into a single user operation, so `publishAndRun` at the bottom of this file
9
9
  // holds that sequence and the exit code it implies (spec 32-4). Every other verb
10
10
  // keeps its whole flow in its own module under `verbs/`.
11
+ import { existsSync, statSync } from 'node:fs';
12
+ import { resolve } from 'node:path';
11
13
  import { ensureLinked } from "./link.js";
12
14
  import { recipe } from "./verbs/recipe.js";
13
15
  import { show } from "./verbs/show.js";
14
16
  import { run, runOnly } from "./verbs/run.js";
15
17
  import { init } from "./verbs/init.js";
16
18
  import { mapPrepare } from "./verbs/mapPrepare.js";
19
+ import { extractSurfaces } from "./verbs/extractSurfaces.js";
17
20
  import { putMapBuild } from "./verbs/putMapBuild.js";
18
21
  import { suitePrepare } from "./verbs/suitePrepare.js";
19
22
  import { classifyPublication, putSuiteBuild } from "./verbs/putSuiteBuild.js";
23
+ import { validateBuild } from "./verbs/validateBuild.js";
20
24
  import { fixPrepare } from "./verbs/fixPrepare.js";
21
25
  import { contractPrompt } from "./verbs/contractPrompt.js";
22
26
  import { suiteReviewPrepare } from "./verbs/suiteReviewPrepare.js";
23
27
  const USAGE = `unitbob — thin local hands for the Unitbob server.
24
28
 
25
- Usage: unitbob <verb> [args]
29
+ Usage: unitbob [--project-root <dir>] <verb> [args]
30
+
31
+ Options:
32
+ --project-root <dir> Run against this project instead of the current folder.
33
+ Without it, an already-linked project is found by walking
34
+ up from where you are, so a subfolder works too.
26
35
 
27
36
  Verbs:
28
37
  init Link this project to Unitbob (also happens automatically).
29
38
  recipe <name> Fetch and print a recipe from the server.
30
39
  show Print the link to this project's map.
31
40
  map-prepare Internal: keylessly update the graph (no API key) and write the host map-build request.
41
+ extract-surfaces Internal: write the addresses this project's own router declares, when the stack
42
+ can be asked for them. Says nothing when it cannot, which is a normal answer;
43
+ map-prepare runs it for you.
32
44
  put-map-build Internal: upload the host-built map and graph.
33
45
  suite-prepare Internal: fetch the recipe and capability assignment, write the host suite-build request.
34
46
  suite-review-prepare Internal: bind an independent BDD quality review to the built behavioral candidate.
47
+ validate-build Internal: check the host's suite answer against the request, locally, before
48
+ uploading. Reports every problem at once; put-suite-build runs it too.
35
49
  put-suite-build Internal: upload the host-built guardrail suite (whole spec file + test_metadata),
36
50
  then run every branch it published and report the server's results.
37
51
  fix-prepare <id> Internal: fetch the per-capability repair packet for one red guard (by interface_id).
@@ -51,45 +65,57 @@ host-LLM's job; any semantic graph enrichment is host-LLM work (the /graphify sk
51
65
  Config: .unitbob.json at your project root, created automatically: the first
52
66
  run registers the project on the server by its folder name (spec 28).`;
53
67
  export async function main(argv, deps = { ensureLinked }) {
54
- const [verb, ...args] = argv;
68
+ const parsed = parseGlobalFlags(argv);
69
+ if (parsed.error) {
70
+ process.stderr.write(`${parsed.error}\n`);
71
+ return 1;
72
+ }
73
+ const [verb, ...args] = parsed.rest;
55
74
  if (!verb || verb === '--help' || verb === '-h' || verb === 'help') {
56
75
  process.stdout.write(`${USAGE}\n`);
57
76
  return verb ? 0 : 1;
58
77
  }
78
+ const linked = () => deps.ensureLinked(parsed.root);
59
79
  try {
60
80
  switch (verb) {
61
81
  case 'init':
62
82
  await init(args);
63
83
  return 0;
64
84
  case 'recipe':
65
- await recipe(await deps.ensureLinked(), args);
85
+ await recipe(await linked(), args);
66
86
  return 0;
67
87
  case 'show':
68
- await show(await deps.ensureLinked());
88
+ await show(await linked());
69
89
  return 0;
70
90
  case 'map-prepare':
71
- await mapPrepare(await deps.ensureLinked(), args);
91
+ await mapPrepare(await linked(), args);
92
+ return 0;
93
+ case 'extract-surfaces':
94
+ await extractSurfaces(await linked(), args);
72
95
  return 0;
73
96
  case 'put-map-build':
74
- await putMapBuild(await deps.ensureLinked(), args);
97
+ await putMapBuild(await linked(), args);
75
98
  return 0;
76
99
  case 'suite-prepare':
77
- await suitePrepare(await deps.ensureLinked(), args);
100
+ await suitePrepare(await linked(), args);
78
101
  return 0;
79
102
  case 'suite-review-prepare':
80
- await suiteReviewPrepare(await deps.ensureLinked(), args);
103
+ await suiteReviewPrepare(await linked(), args);
104
+ return 0;
105
+ case 'validate-build':
106
+ await validateBuild(await linked(), args);
81
107
  return 0;
82
108
  case 'put-suite-build':
83
- return await publishAndRun(await deps.ensureLinked(), args);
109
+ return await publishAndRun(await linked(), args);
84
110
  case 'fix-prepare':
85
- await fixPrepare(await deps.ensureLinked(), args);
111
+ await fixPrepare(await linked(), args);
86
112
  return 0;
87
113
  case 'contract-prompt':
88
- await contractPrompt(await deps.ensureLinked(), args);
114
+ await contractPrompt(await linked(), args);
89
115
  return 0;
90
116
  case 'run':
91
117
  case 'check':
92
- await run(await deps.ensureLinked(), args);
118
+ await run(await linked(), args);
93
119
  return 0;
94
120
  default:
95
121
  process.stderr.write(`Unknown verb "${verb}".\n\n${USAGE}\n`);
@@ -101,6 +127,42 @@ export async function main(argv, deps = { ensureLinked }) {
101
127
  return 1;
102
128
  }
103
129
  }
130
+ // `--project-root` names the folder the verb runs in, for callers that cannot
131
+ // change directory — an agent driving the CLI from a scratch directory, a hook,
132
+ // a monorepo script. It is the explicit form of what `ensureLinked` already does
133
+ // by walking up, and it accepts the `project_root` a request packet prints, so
134
+ // the packet's own answer can be handed straight back.
135
+ //
136
+ // Parsed here rather than per verb: every verb resolves the same project, and a
137
+ // flag that means one thing for `check` and another for `suite-prepare` is worse
138
+ // than no flag.
139
+ function parseGlobalFlags(argv) {
140
+ const rest = [];
141
+ let root;
142
+ for (let index = 0; index < argv.length; index += 1) {
143
+ const arg = argv[index];
144
+ if (arg === PROJECT_ROOT_FLAG) {
145
+ root = argv[index + 1];
146
+ index += 1;
147
+ }
148
+ else if (arg.startsWith(`${PROJECT_ROOT_FLAG}=`)) {
149
+ root = arg.slice(PROJECT_ROOT_FLAG.length + 1);
150
+ }
151
+ else {
152
+ rest.push(arg);
153
+ }
154
+ if (root !== undefined && !root)
155
+ return { rest, error: `${PROJECT_ROOT_FLAG} requires a path.` };
156
+ }
157
+ if (root === undefined)
158
+ return { rest };
159
+ const resolved = resolve(root);
160
+ if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
161
+ return { rest, error: `${PROJECT_ROOT_FLAG} ${root} is not a directory.` };
162
+ }
163
+ return { root: resolved, rest };
164
+ }
165
+ const PROJECT_ROOT_FLAG = '--project-root';
104
166
  // Publishing a suite and running it the first time are one user operation
105
167
  // (spec 32-4). They used to be two commands, and the second one was a step the
106
168
  // host LLM could simply not take: the suite was stored, nothing had ever run it,
@@ -114,11 +176,17 @@ export async function main(argv, deps = { ensureLinked }) {
114
176
  // comes from the build response, and what the results are comes only from the
115
177
  // server's answer to the run.
116
178
  export async function publishAndRun(config, args, deps) {
179
+ // Resolved before the collaborators, so both halves of the command write to
180
+ // the same stream. Injecting `stdout` has to capture everything the command
181
+ // prints — publication lines and run summaries included — or a test can hold a
182
+ // fraction of the output and read it as the whole of it.
183
+ const stdout = deps?.stdout ?? process.stdout;
184
+ const stderr = deps?.stderr ?? process.stderr;
117
185
  const d = {
118
- putSuiteBuild: (cfg, a) => putSuiteBuild(cfg, a),
119
- runOnly: (cfg, digests) => runOnly(cfg, digests),
120
- stdout: process.stdout,
121
- stderr: process.stderr,
186
+ putSuiteBuild: (cfg, a) => putSuiteBuild(cfg, a, { stdout }),
187
+ runOnly: (cfg, digests) => runOnly(cfg, digests, { stdout }),
188
+ stdout,
189
+ stderr,
122
190
  ...deps,
123
191
  };
124
192
  const { digests, unpublished } = classifyPublication(await d.putSuiteBuild(config, args));
package/dist/config.js CHANGED
@@ -1,11 +1,19 @@
1
1
  // Per-project config for the connector. Lives in `.unitbob.json` at the project
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
2
+ // root: { "server": "http://…", "repo_id": 3, "token": "…" }.
3
+ //
4
+ // The token is the project's key (spec 33): the brain mints it at register, every
5
+ // wire call carries it, and this file is the only place a person has it. It is
6
+ // gitignored — see ensureGitignored — because a committed token hands the project
7
+ // to whoever reads the repository. Lose the file and the project is gone: there
8
+ // is no recovery and no rotation, which is written down in the spec as accepted.
9
+ //
10
+ // Linking is automatic (spec 28): every verb goes
4
11
  // through `ensureLinked` (src/link.ts), which registers the project by folder
5
12
  // name when there is no working link. Only the project root's own file counts —
6
13
  // never a parent directory's (no walk-up).
7
14
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
8
- import { join } from 'node:path';
15
+ import { homedir } from 'node:os';
16
+ import { dirname, join } from 'node:path';
9
17
  export const CONFIG_FILE = '.unitbob.json';
10
18
  // The repo id stored at `cwd`, or null when there is no working link: file
11
19
  // missing, unreadable, malformed JSON, or repo_id absent / 0 / non-integer
@@ -22,6 +30,13 @@ export function readLocalServer(cwd) {
22
30
  const server = readConfigField(cwd, 'server');
23
31
  return typeof server === 'string' && /^https?:\/\//.test(server.trim()) ? server.trim() : null;
24
32
  }
33
+ // The token stored at `cwd`, or null when there is none. A project linked
34
+ // before spec 33 has an id and no token; its calls now 404, and the connector
35
+ // says so in words rather than showing a bare status.
36
+ export function readLocalToken(cwd) {
37
+ const token = readConfigField(cwd, 'token');
38
+ return typeof token === 'string' && token.length > 0 ? token : null;
39
+ }
25
40
  function readConfigField(cwd, field) {
26
41
  const path = join(cwd, CONFIG_FILE);
27
42
  if (!existsSync(path))
@@ -35,6 +50,29 @@ function readConfigField(cwd, field) {
35
50
  }
36
51
  return parsed && typeof parsed === 'object' ? parsed[field] : undefined;
37
52
  }
53
+ // The nearest directory at or above `cwd` that already carries a working link,
54
+ // or null when there is none.
55
+ //
56
+ // This does not weaken the no-walk-up rule above — it is what makes it usable.
57
+ // The rule exists so a parent's repo_id can never stand in for the directory
58
+ // the verb is running in; a sub-package would silently report as its monorepo.
59
+ // Relocating to the directory whose own file names the link keeps that intact:
60
+ // the config still belongs to the root it sits in, and the verb runs there.
61
+ // Without this, every command had to be run from exactly the right folder, and
62
+ // the request packet's own `project_root` could not be used as a working
63
+ // directory even though it names the answer.
64
+ //
65
+ // $HOME is the ceiling: a stray `.unitbob.json` in the home directory must not
66
+ // adopt every project underneath it.
67
+ export function locateLinkedRoot(cwd) {
68
+ const home = homedir();
69
+ for (let dir = cwd;; dir = dirname(dir)) {
70
+ if (readLocalRepoId(dir) !== null)
71
+ return dir;
72
+ if (dir === home || dirname(dir) === dir)
73
+ return null;
74
+ }
75
+ }
38
76
  export function writeConfigFile(cwd, config) {
39
77
  writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`);
40
78
  }
@@ -1,6 +1,7 @@
1
1
  import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { assertUnitbobPath } from "./artifactPath.js";
4
+ import { BDD_RUN_ARTIFACTS } from "../runner/bdd.js";
4
5
  // The behavioral suite lives under its own root: the main `.feature` plus its
5
6
  // step definitions and any helper files, all under `.unitbob/behavioral/`
6
7
  // (spec 32). Nothing here is ever written into the project's own `spec/`,
@@ -51,7 +52,7 @@ export function filesLostOnMaterialize(projectRoot, artifact, runner) {
51
52
  const listed = new Set([artifact.path, ...(artifact.support_files ?? []).map((file) => file.path)]);
52
53
  const runnerEntries = RUNNER_ENVIRONMENT_ENTRIES[runner] ?? EMPTY_ENTRIES;
53
54
  return readdirSync(behavioralRoot)
54
- .filter((entry) => !runnerEntries.has(entry))
55
+ .filter((entry) => !runnerEntries.has(entry) && !CONNECTOR_RUN_ARTIFACTS.has(entry))
55
56
  .flatMap((entry) => filesUnder(projectRoot, `${BEHAVIORAL_DIR}/${entry}`))
56
57
  .filter((path) => !listed.has(path))
57
58
  .sort();
@@ -78,6 +79,13 @@ export function copyBehavioralRunnerEnvironment(sourceRoot, targetRoot, runner)
78
79
  cpSync(source, target, { recursive: true });
79
80
  }
80
81
  }
82
+ // Written into the behavioral root by the connector's own BDD run: the machine
83
+ // report and the pytest-bdd harness it drives the run with. They are regenerated
84
+ // by the next run, so materialization is right to clear them — but warning about
85
+ // them names the connector's own files as the user's loss. It made the warning
86
+ // fire on every single review, which is how a real forgotten step file learns to
87
+ // look like noise. Taken from the runner that writes them, never re-typed here.
88
+ const CONNECTOR_RUN_ARTIFACTS = new Set(BDD_RUN_ARTIFACTS);
81
89
  const EMPTY_ENTRIES = new Set();
82
90
  const RUNNER_ENVIRONMENT_ENTRIES = {
83
91
  cucumber: new Set(['.bundle', 'Gemfile', 'Gemfile.lock']),
@@ -28,13 +28,14 @@ export function readFreshGraph(projectRoot) {
28
28
  parseJson(rawGraphJson, path);
29
29
  return rawGraphJson;
30
30
  }
31
- export function writeMapBuildRequest(projectRoot, recipes) {
31
+ export function writeMapBuildRequest(projectRoot, recipes, routeInventoryPath) {
32
32
  const packet = {
33
33
  project_root: projectRoot,
34
34
  graph_path: graphPath(projectRoot),
35
35
  output_path: outputPath(projectRoot),
36
36
  surfaces_path: surfacesPath(projectRoot),
37
37
  surface_output_path: surfaceOutputPath(projectRoot),
38
+ ...(routeInventoryPath ? { route_inventory_path: routeInventoryPath } : {}),
38
39
  recipes,
39
40
  };
40
41
  const path = requestPath(projectRoot);
@@ -222,6 +222,39 @@ function readKnownDefectContext(value, path) {
222
222
  // build_error, which the connector relays. Anything unparseable throws and
223
223
  // nothing is uploaded.
224
224
  export function readHostSuiteOutputs(path, request) {
225
+ const { entries, rootFor } = openAnswer(path, request);
226
+ return entries.map((entry) => readBranch(entry, rootFor, path, request.project_root));
227
+ }
228
+ // The same read, but one branch's bad entry does not hide the next branch's.
229
+ //
230
+ // Spec 32-6, after review: the throwing form above stops at the first problem,
231
+ // so an unsafe path in one branch concealed every remaining problem in its peer
232
+ // — and the promise this validation was built on is that all problems are named
233
+ // in one pass. It also sank a finished peer branch, which is the exact rule spec
234
+ // 32-5 Phase 4 established against.
235
+ //
236
+ // What still throws is the answer as a whole: a file that is missing, is not
237
+ // JSON, or carries no branches array has no second problem to go and find,
238
+ // because there is no document left to read.
239
+ export function readHostSuiteOutputsPerBranch(path, request) {
240
+ const { entries, rootFor } = openAnswer(path, request);
241
+ const outputs = [];
242
+ const unreadable = [];
243
+ for (const entry of entries) {
244
+ try {
245
+ outputs.push(readBranch(entry, rootFor, path, request.project_root));
246
+ }
247
+ catch (err) {
248
+ const kind = entry?.suite_kind;
249
+ unreadable.push({
250
+ suite_kind: typeof kind === 'string' && kind ? kind : 'unknown branch',
251
+ message: err.message,
252
+ });
253
+ }
254
+ }
255
+ return { outputs, unreadable };
256
+ }
257
+ function openAnswer(path, request) {
225
258
  if (!existsSync(path)) {
226
259
  throw new Error(`${path} not found — the host suite builder did not write its output.`);
227
260
  }
@@ -230,8 +263,10 @@ export function readHostSuiteOutputs(path, request) {
230
263
  if (!branches) {
231
264
  throw new Error(`${path} is malformed: expected a branches array, one entry per contract system.`);
232
265
  }
233
- const rootFor = new Map(request.branches.map((branch) => [branch.suite_kind, branch.path_root]));
234
- return branches.map((entry) => readBranch(entry, rootFor, path, request.project_root));
266
+ return {
267
+ entries: branches,
268
+ rootFor: new Map(request.branches.map((branch) => [branch.suite_kind, branch.path_root])),
269
+ };
235
270
  }
236
271
  function readBranch(entry, rootFor, path, projectRoot) {
237
272
  if (!entry || typeof entry !== 'object') {
package/dist/link.js CHANGED
@@ -5,29 +5,42 @@
5
5
  import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
6
6
  import { homedir } from 'node:os';
7
7
  import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path';
8
- import { CONFIG_FILE, readLocalRepoId, readLocalServer, writeConfigFile } from "./config.js";
8
+ import { CONFIG_FILE, locateLinkedRoot, readLocalRepoId, readLocalServer, readLocalToken, writeConfigFile, } from "./config.js";
9
9
  import { registerRepo, WireError } from "./wire.js";
10
10
  // Public Unitbob brain used by default. A `server` in `.unitbob.json` (or an
11
11
  // explicit argument) overrides it — see resolution order in ensureLinked.
12
12
  export const DEFAULT_SERVER = 'https://unitbob-73a4082838d3.herokuapp.com';
13
13
  export async function ensureLinked(cwd = process.cwd(), server, out = process.stdout) {
14
- const resolvedServer = server ?? readLocalServer(cwd) ?? DEFAULT_SERVER;
15
- const fileId = readLocalRepoId(cwd); // only cwd's own file no walk-up
16
- const name = projectName(cwd);
17
- // Refuse before touching the server, so a stray run can't mint a junk repo.
18
- if (fileId === null)
19
- assertProjectRoot(cwd);
20
- const authId = await registerRepo(resolvedServer, name);
21
- if (fileId === null) {
22
- writeConfigFile(cwd, { server: resolvedServer, repo_id: authId });
23
- ensureGitignored(cwd, out);
24
- out.write(`Linked this project to Unitbob as ${name}.\n`);
25
- }
26
- else if (fileId !== authId) {
27
- throw new WireError(`${CONFIG_FILE} points at repo ${fileId}, but "${name}" is repo ${authId} on the server. ` +
28
- `Fix or remove ${CONFIG_FILE} before continuing.`);
14
+ // An already-linked project answers from its own root, wherever the command
15
+ // was typed. Nothing is linked yet? Then `cwd` is the candidate root and every
16
+ // guard below applies to it unchanged.
17
+ const root = locateLinkedRoot(cwd) ?? cwd;
18
+ const resolvedServer = server ?? readLocalServer(root) ?? DEFAULT_SERVER;
19
+ const fileId = readLocalRepoId(root); // only the root's own file — no walk-up
20
+ const name = projectName(root);
21
+ if (fileId !== null) {
22
+ const token = readLocalToken(root);
23
+ if (token === null) {
24
+ // Linked before the project had a key of its own. There is no way to mint
25
+ // one for an existing project — that would be a door into it — so the only
26
+ // honest instruction is to link again.
27
+ throw new WireError(`${CONFIG_FILE} has no project token. It was written by an older Unitbob, and the ` +
28
+ `server now requires one. Delete ${CONFIG_FILE} to link this project again ` +
29
+ '(the old project, along with its map and checks, stays where it is).');
30
+ }
31
+ // Checked on every command, not only at linking: the file holds the only key
32
+ // the project has, and a `.gitignore` rewritten since is how that key ends
33
+ // up in a public repository. Costs nothing when the entry is already there.
34
+ ensureGitignored(root, out);
35
+ return { server: resolvedServer, repoId: fileId, token, projectRoot: root };
29
36
  }
30
- return { server: resolvedServer, repoId: authId, projectRoot: cwd };
37
+ // Refuse before touching the server, so a stray run can't mint a junk repo.
38
+ assertProjectRoot(root);
39
+ const { id, token } = await registerRepo(resolvedServer, name);
40
+ writeConfigFile(root, { server: resolvedServer, repo_id: id, token });
41
+ ensureGitignored(root, out);
42
+ out.write(`Linked this project to Unitbob as ${name}.\n`);
43
+ return { server: resolvedServer, repoId: id, token, projectRoot: root };
31
44
  }
32
45
  // The linking name is the *project's* name, not the checkout's (spec 29). A
33
46
  // `.git` directory means cwd is the main checkout; a `.git` file is a worktree
package/dist/links.js ADDED
@@ -0,0 +1,24 @@
1
+ // `destination` is where the person should end up: an absolute URL the server
2
+ // handed back (`map_url`), or a path. Only its path and query travel on; the
3
+ // server checks that it stays inside this project before honouring it.
4
+ export function enterUrl(config, destination) {
5
+ const next = pathOf(destination) ?? `/repos/${config.repoId}`;
6
+ const query = `?next=${encodeURIComponent(next)}`;
7
+ return `${config.server}/repos/${config.repoId}/enter${query}#t=${config.token}`;
8
+ }
9
+ // The console, in a given tab.
10
+ export function consoleUrl(config, tab) {
11
+ const base = `/repos/${config.repoId}`;
12
+ return enterUrl(config, tab ? `${base}?tab=${tab}` : base);
13
+ }
14
+ function pathOf(destination) {
15
+ if (destination.startsWith('/'))
16
+ return destination;
17
+ try {
18
+ const url = new URL(destination);
19
+ return `${url.pathname}${url.search}`;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
package/dist/proc.js CHANGED
@@ -2,8 +2,22 @@
2
2
  // captures stdout/stderr/exit code and hands them back untouched — shaping or
3
3
  // interpreting that output is the caller's (and ultimately Rails') job.
4
4
  import { spawn } from 'node:child_process';
5
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
+ // Can this path actually be spawned? A binstub that exists but has lost its
8
+ // executable bit is a real state — checkouts over a filesystem with no
9
+ // permission bits, an archive unpacked without them — and `spawn` answers it
10
+ // with an EACCES `error` event, which arrives as a thrown exception rather than
11
+ // an exit code. Callers that pick "the project's own binstub, else the global
12
+ // tool" have to ask this before choosing, or the fallback never gets its turn.
13
+ export function executable(path) {
14
+ try {
15
+ return existsSync(path) && (statSync(path).mode & 0o111) !== 0;
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
7
21
  export const GRAPHIFY_TIMEOUT_MS = 10 * 60 * 1000;
8
22
  export function runProcess(command, args = [], options = {}) {
9
23
  return new Promise((resolve, reject) => {
@@ -1,16 +1,31 @@
1
- import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { runProcess } from "../proc.js";
3
+ import { executable, runProcess } from "../proc.js";
4
4
  import { readReport } from "./types.js";
5
5
  import { PYTEST_BDD_PLUGIN } from "./pytestBddPlugin.js";
6
6
  export const BDD_TIMEOUT_MS = 10 * 60 * 1000;
7
7
  // The behavioral suite lives under one root; the report is written inside it so
8
8
  // the app under test cannot pollute it and it travels with the suite.
9
9
  const BEHAVIORAL_ROOT = '.unitbob/behavioral';
10
- const CUCUMBER_REPORT = join(BEHAVIORAL_ROOT, 'cucumber_messages.ndjson');
11
- const PYTEST_BDD_REPORT = join(BEHAVIORAL_ROOT, 'pytest_bdd_report.json');
12
- const PYTEST_BDD_PLUGIN_FILE = join(BEHAVIORAL_ROOT, 'unitbob_pytest_bdd_plugin.py');
13
- const PYTEST_INI_FILE = join(BEHAVIORAL_ROOT, 'pytest.ini');
10
+ const CUCUMBER_REPORT_NAME = 'cucumber_messages.ndjson';
11
+ const PYTEST_BDD_REPORT_NAME = 'pytest_bdd_report.json';
12
+ const PYTEST_BDD_PLUGIN_NAME = 'unitbob_pytest_bdd_plugin.py';
13
+ const PYTEST_INI_NAME = 'pytest.ini';
14
+ // Everything a run writes into that root, listed once here — where it is
15
+ // written. The review's "these files will be lost" warning reads this list to
16
+ // stay quiet about them (see `files/behavioral.ts`). A second hand-kept copy
17
+ // drifts the moment a strategy gains a file, and the warning goes back to
18
+ // shouting about the connector's own output.
19
+ export const BDD_RUN_ARTIFACTS = [
20
+ CUCUMBER_REPORT_NAME,
21
+ PYTEST_BDD_REPORT_NAME,
22
+ PYTEST_BDD_PLUGIN_NAME,
23
+ PYTEST_INI_NAME,
24
+ ];
25
+ const CUCUMBER_REPORT = join(BEHAVIORAL_ROOT, CUCUMBER_REPORT_NAME);
26
+ const PYTEST_BDD_REPORT = join(BEHAVIORAL_ROOT, PYTEST_BDD_REPORT_NAME);
27
+ const PYTEST_BDD_PLUGIN_FILE = join(BEHAVIORAL_ROOT, PYTEST_BDD_PLUGIN_NAME);
28
+ const PYTEST_INI_FILE = join(BEHAVIORAL_ROOT, PYTEST_INI_NAME);
14
29
  const PYTEST_INI = '[pytest]\naddopts =\n';
15
30
  // The connector-owned BDD strategy table (spec 32): the `runner` enum names one
16
31
  // of these; the connector never executes a host-provided command string. Each
@@ -126,11 +141,3 @@ async function pickPython(projectRoot) {
126
141
  }
127
142
  throw missingRunner('pytest-bdd');
128
143
  }
129
- function executable(path) {
130
- try {
131
- return existsSync(path) && (statSync(path).mode & 0o111) !== 0;
132
- }
133
- catch {
134
- return false;
135
- }
136
- }