@bridge4dev/runner 0.63.0 → 0.64.1

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.
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { promisify } from 'node:util';
5
5
  import { createHash } from 'node:crypto';
6
+ import { cleanGitEnv } from './environment.js';
6
7
  import { checkpointsDir } from './paths.js';
7
8
  import { EMPTY_TREE_SHA } from './gitops.js';
8
9
  import { isSecretPath } from './policy.js';
@@ -90,7 +91,7 @@ async function gitIn(cwd, ...args) {
90
91
  // The project's own environment must not leak in: a GIT_INDEX_FILE or
91
92
  // GIT_DIR inherited from a parent process would silently retarget every
92
93
  // command below at the wrong repository.
93
- env: cleanEnv(),
94
+ env: cleanGitEnv(),
94
95
  });
95
96
  return stdout.replace(/\n$/, '');
96
97
  }
@@ -107,7 +108,7 @@ async function gitStore(store, worktreePath, indexFile, ...args) {
107
108
  timeout: GIT_TIMEOUT_MS,
108
109
  maxBuffer: 32 * 1024 * 1024,
109
110
  env: {
110
- ...cleanEnv(),
111
+ ...cleanGitEnv(),
111
112
  GIT_DIR: store,
112
113
  GIT_WORK_TREE: worktreePath,
113
114
  GIT_INDEX_FILE: indexFile,
@@ -119,22 +120,13 @@ async function gitStore(store, worktreePath, indexFile, ...args) {
119
120
  });
120
121
  return stdout.replace(/\n$/, '');
121
122
  }
122
- function cleanEnv() {
123
- const env = { ...process.env };
124
- delete env['GIT_DIR'];
125
- delete env['GIT_WORK_TREE'];
126
- delete env['GIT_INDEX_FILE'];
127
- delete env['GIT_OBJECT_DIRECTORY'];
128
- delete env['GIT_ALTERNATE_OBJECT_DIRECTORIES'];
129
- return env;
130
- }
131
123
  async function ensureStore(worktreePath) {
132
124
  const { store, objectDir } = await storeFor(worktreePath);
133
125
  if (!fs.existsSync(store)) {
134
126
  fs.mkdirSync(path.dirname(store), { recursive: true, mode: 0o700 });
135
127
  await execFileAsync('git', ['init', '--quiet', '--bare', store], {
136
128
  timeout: GIT_TIMEOUT_MS,
137
- env: cleanEnv(),
129
+ env: cleanGitEnv(),
138
130
  });
139
131
  fs.chmodSync(store, 0o700);
140
132
  }
@@ -326,7 +318,7 @@ async function gitRefs(store, ...args) {
326
318
  const { stdout } = await execFileAsync('git', [...GIT_GLOBAL_ARGS, '--git-dir', store, ...args], {
327
319
  timeout: GIT_TIMEOUT_MS,
328
320
  maxBuffer: 32 * 1024 * 1024,
329
- env: cleanEnv(),
321
+ env: cleanGitEnv(),
330
322
  });
331
323
  return stdout.replace(/\n$/, '');
332
324
  }
@@ -20,6 +20,21 @@ export interface RunnerIdentity {
20
20
  isRoot: boolean;
21
21
  }
22
22
  export declare function runnerIdentity(): RunnerIdentity;
23
+ /**
24
+ * The environment git must be run in, with the caller's own repository scrubbed
25
+ * out of it.
26
+ *
27
+ * A `GIT_DIR` or `GIT_INDEX_FILE` inherited from a parent process silently
28
+ * retargets every git command at a DIFFERENT repository — the runner is started
29
+ * by systemd, but a session's agent is not, and neither is a test. The restore
30
+ * points have run this way since #126; #417 needs the same guarantee for a much
31
+ * blunter reason: `git init` under an inherited `GIT_DIR` initialises somewhere
32
+ * else entirely and reports success.
33
+ *
34
+ * Here rather than in `checkpoints.ts`, where it was written, because it is now
35
+ * the answer to «how does this package run git» and has two callers.
36
+ */
37
+ export declare function cleanGitEnv(): NodeJS.ProcessEnv;
23
38
  export interface PathAccess {
24
39
  path: string;
25
40
  exists: boolean;
@@ -19,6 +19,29 @@ export function runnerIdentity() {
19
19
  }
20
20
  return { user, uid, gid, home: os.homedir(), isRoot: uid === 0 };
21
21
  }
22
+ /**
23
+ * The environment git must be run in, with the caller's own repository scrubbed
24
+ * out of it.
25
+ *
26
+ * A `GIT_DIR` or `GIT_INDEX_FILE` inherited from a parent process silently
27
+ * retargets every git command at a DIFFERENT repository — the runner is started
28
+ * by systemd, but a session's agent is not, and neither is a test. The restore
29
+ * points have run this way since #126; #417 needs the same guarantee for a much
30
+ * blunter reason: `git init` under an inherited `GIT_DIR` initialises somewhere
31
+ * else entirely and reports success.
32
+ *
33
+ * Here rather than in `checkpoints.ts`, where it was written, because it is now
34
+ * the answer to «how does this package run git» and has two callers.
35
+ */
36
+ export function cleanGitEnv() {
37
+ const env = { ...process.env };
38
+ delete env['GIT_DIR'];
39
+ delete env['GIT_WORK_TREE'];
40
+ delete env['GIT_INDEX_FILE'];
41
+ delete env['GIT_OBJECT_DIRECTORY'];
42
+ delete env['GIT_ALTERNATE_OBJECT_DIRECTORIES'];
43
+ return env;
44
+ }
22
45
  /** As root every access check passes, which is true and worth saying out loud. */
23
46
  function canAccess(target, mode) {
24
47
  try {
package/dist/git.d.ts CHANGED
@@ -106,6 +106,42 @@ export interface PathValidation {
106
106
  * '/opt/ids'». That sentence is true and unactionable.
107
107
  */
108
108
  export declare function validateWorkspacePath(workspacePath: string): Promise<PathValidation>;
109
+ /**
110
+ * What became of «create this folder and put git in it» (#417).
111
+ *
112
+ * `created` and `exists` are separate answers on purpose: a folder that was
113
+ * already there is not a failure — the wizard simply goes on and binds it —
114
+ * while `created: false, exists: false` is never a success.
115
+ */
116
+ export interface ProjectDirInit {
117
+ ok: boolean;
118
+ /** This call made the directory. */
119
+ created: boolean;
120
+ /** There was already a directory at this path when we looked. */
121
+ exists: boolean;
122
+ /** The branch the new repository is on, read back rather than assumed. */
123
+ branch?: string;
124
+ error?: string;
125
+ }
126
+ /**
127
+ * Create the project folder and initialise git in it, from the binding window
128
+ * (#417).
129
+ *
130
+ * Two things and no more: ONE directory — the last segment of the path, never a
131
+ * chain of parents — and `git init` with `main` as the branch. No first commit:
132
+ * an empty repository is a legitimate state (#137, and the runner has known how
133
+ * to work in one since 0.63.0), while an «Initial commit» nobody asked for is
134
+ * the thing that makes `git pull` from an existing remote refuse with
135
+ * «unrelated histories» later on.
136
+ *
137
+ * Its own refusal list, because `validateWorkspacePath` has none to share: that
138
+ * function asks «can the runner work here», which is a question about
139
+ * permissions, and every answer it gives is about reaching, reading and writing.
140
+ * «Should anything be created here at all» is a different question and this is
141
+ * the only place that asks it. The API cannot ask it either — it sees the
142
+ * runner's verdict and nothing of the machine — so the list lives here.
143
+ */
144
+ export declare function initProjectDir(target: string): Promise<ProjectDirInit>;
109
145
  /**
110
146
  * The repository's main branch, read locally (ADR 0004).
111
147
  *
package/dist/git.js CHANGED
@@ -2,8 +2,10 @@ import { execFile } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { promisify } from 'node:util';
5
- import { previewsDir, worktreesDir } from './paths.js';
6
- import { firstUnreachableAncestor, inspectPath, looksLikeDubiousOwnership, runnerIdentity, safeDirectoryCommand, } from './environment.js';
5
+ import { previewsDir, stateDir, worktreesDir } from './paths.js';
6
+ import { cleanGitEnv, firstUnreachableAncestor, inspectPath, looksLikeDubiousOwnership, runnerIdentity, safeDirectoryCommand, } from './environment.js';
7
+ import { isGitInternalPath, isInsideWorktree, isSecretPath } from './policy.js';
8
+ import { log } from './log.js';
7
9
  const execFileAsync = promisify(execFile);
8
10
  const GIT_TIMEOUT_MS = 30_000;
9
11
  /**
@@ -14,12 +16,27 @@ const GIT_TIMEOUT_MS = 30_000;
14
16
  * Ninety seconds is the owner's number (plan §9, 06.09.2026).
15
17
  */
16
18
  const WORKTREE_ADD_TIMEOUT_MS = 90_000;
19
+ /**
20
+ * Every git call in this file names its repository with `cwd` and nothing else,
21
+ * so an inherited `GIT_DIR` (or index, or object store) can only ever point
22
+ * somewhere we did not mean — see `cleanGitEnv`. Free everywhere; load-bearing
23
+ * in `initProjectDir`, where `git init` under an inherited `GIT_DIR` would
24
+ * initialise a different directory and report success (#417).
25
+ */
17
26
  async function git(cwd, ...args) {
18
- const { stdout } = await execFileAsync('git', args, { cwd, timeout: GIT_TIMEOUT_MS });
27
+ const { stdout } = await execFileAsync('git', args, {
28
+ cwd,
29
+ timeout: GIT_TIMEOUT_MS,
30
+ env: cleanGitEnv(),
31
+ });
19
32
  return stdout.trim();
20
33
  }
21
34
  async function gitSlow(cwd, timeoutMs, ...args) {
22
- const { stdout } = await execFileAsync('git', args, { cwd, timeout: timeoutMs });
35
+ const { stdout } = await execFileAsync('git', args, {
36
+ cwd,
37
+ timeout: timeoutMs,
38
+ env: cleanGitEnv(),
39
+ });
23
40
  return stdout.trim();
24
41
  }
25
42
  /**
@@ -252,6 +269,311 @@ export async function validateWorkspacePath(workspacePath) {
252
269
  ...(defaultBranch ? { defaultBranch } : {}),
253
270
  };
254
271
  }
272
+ /**
273
+ * The branch a repository created from the binding window starts on (#417).
274
+ *
275
+ * One name, here, because two calls ask for it — `git init --initial-branch`
276
+ * and the `symbolic-ref` that stands in for it on a git older than 2.28 — and a
277
+ * folder that came out on `master` because the two disagreed would be a bug
278
+ * nobody sees until a push.
279
+ */
280
+ const INITIAL_BRANCH = 'main';
281
+ /**
282
+ * Directories a project folder is never created inside (#417).
283
+ *
284
+ * The root itself and the trees a Linux system is assembled from. Deliberately
285
+ * a short, literal list rather than a clever rule: everything here is a place
286
+ * where `mkdir` succeeding is worse than it failing, and a person who typed one
287
+ * of them meant something else. `/opt`, `/srv`, `/home`, `/var/www` are NOT on
288
+ * it — those are exactly where projects live.
289
+ *
290
+ * `/usr/local` and friends are covered by their parents: the test is «is this
291
+ * path at or below one of these».
292
+ */
293
+ const SYSTEM_DIRECTORIES = [
294
+ '/etc',
295
+ '/usr',
296
+ '/bin',
297
+ '/sbin',
298
+ '/lib',
299
+ '/lib32',
300
+ '/lib64',
301
+ '/libx32',
302
+ '/boot',
303
+ '/proc',
304
+ '/sys',
305
+ '/dev',
306
+ '/run',
307
+ ];
308
+ function refuseInit(error) {
309
+ return { ok: false, created: false, exists: false, error };
310
+ }
311
+ /**
312
+ * Why this path may not be created, or `null` — asked of a STRING (#417).
313
+ *
314
+ * Pulled out of `initProjectDir` because it has to be asked twice: once about
315
+ * what the person typed, and again about where that path really lands once the
316
+ * filesystem has had its say. See `realLandingPlace`.
317
+ */
318
+ function pathClassRefusal(candidate) {
319
+ if (isGitInternalPath(candidate)) {
320
+ return `${candidate} is inside a .git directory, which belongs to git itself — pick a folder for the project instead`;
321
+ }
322
+ if (candidate === path.sep || SYSTEM_DIRECTORIES.some((dir) => isAtOrBelow(candidate, dir))) {
323
+ return `${candidate} is a system directory on this server — pick a folder for the project instead`;
324
+ }
325
+ // Said separately from the line above, because it would not be true there:
326
+ // `~/.ssh` is not a system directory, and a refusal that misnames what it is
327
+ // refusing teaches the person the wrong thing about their own machine.
328
+ if (isSecretPath(candidate)) {
329
+ return `${candidate} is on this server's protected list — pick a folder for the project instead`;
330
+ }
331
+ // `isSecretPath` cannot answer this one: the runner tree is on its list, but
332
+ // `worktrees/` and `previews/` are excused from it so an agent can read its
333
+ // own workspace. Everything under the state directory is the runner's own
334
+ // bookkeeping either way, and a project living inside it would be rewritten by
335
+ // the next session cleanup. Both sides go through the real filesystem, because
336
+ // `stateDir()` is built from `os.homedir()` and a symlinked home would
337
+ // otherwise make the two paths look unrelated.
338
+ const state = realPathOf(stateDir());
339
+ if (isAtOrBelow(candidate, state)) {
340
+ return `${candidate} is inside the runner's own state directory — pick a folder for the project instead`;
341
+ }
342
+ return null;
343
+ }
344
+ /** `realpathSync` where it works, the path itself where it does not. */
345
+ function realPathOf(target) {
346
+ try {
347
+ return fs.realpathSync(target);
348
+ }
349
+ catch {
350
+ return path.resolve(target);
351
+ }
352
+ }
353
+ /**
354
+ * Where `mkdir` would actually land, symlinks and all (#417).
355
+ *
356
+ * Every refusal above is string work — `path.resolve` does not follow symlinks,
357
+ * `isSecretPath` normalises nothing, and containment is `path.relative`. The
358
+ * kernel disagrees: on a stock Debian `/var/run` IS `/run`, so `/var/run/shop`
359
+ * passes a list that exists to refuse exactly that, and the repository ends up
360
+ * on tmpfs and is gone at the next reboot.
361
+ *
362
+ * `policy.ts` solves the same problem in `normalize()` by resolving the parent
363
+ * and putting the last segment back on. That is not enough here, because this
364
+ * is asked BEFORE anything about the parent is known: with `/tmp/link -> /etc`
365
+ * and the path `/tmp/link/new/project`, the parent does not exist, its
366
+ * `realpath` fails, and the fallback is the unresolved string — which would then
367
+ * earn the advice «create /tmp/link/new first», i.e. make a folder in `/etc`. So
368
+ * the walk goes up to the deepest ancestor that DOES exist, resolves that, and
369
+ * puts every missing segment back on.
370
+ */
371
+ function realLandingPlace(resolved) {
372
+ const missing = [];
373
+ let existing = resolved;
374
+ for (;;) {
375
+ try {
376
+ const real = fs.realpathSync(existing);
377
+ return path.join(real, ...missing.reverse());
378
+ }
379
+ catch {
380
+ const up = path.dirname(existing);
381
+ if (up === existing)
382
+ return resolved;
383
+ missing.push(path.basename(existing));
384
+ existing = up;
385
+ }
386
+ }
387
+ }
388
+ /**
389
+ * Is `target` at or below `root`?
390
+ *
391
+ * `isInsideWorktree` is the containment helper this package already has, and it
392
+ * answers exactly this question — the name says worktree because that was its
393
+ * first caller, not because it knows anything about worktrees.
394
+ */
395
+ function isAtOrBelow(target, root) {
396
+ return isInsideWorktree(target, root);
397
+ }
398
+ /**
399
+ * Create the project folder and initialise git in it, from the binding window
400
+ * (#417).
401
+ *
402
+ * Two things and no more: ONE directory — the last segment of the path, never a
403
+ * chain of parents — and `git init` with `main` as the branch. No first commit:
404
+ * an empty repository is a legitimate state (#137, and the runner has known how
405
+ * to work in one since 0.63.0), while an «Initial commit» nobody asked for is
406
+ * the thing that makes `git pull` from an existing remote refuse with
407
+ * «unrelated histories» later on.
408
+ *
409
+ * Its own refusal list, because `validateWorkspacePath` has none to share: that
410
+ * function asks «can the runner work here», which is a question about
411
+ * permissions, and every answer it gives is about reaching, reading and writing.
412
+ * «Should anything be created here at all» is a different question and this is
413
+ * the only place that asks it. The API cannot ask it either — it sees the
414
+ * runner's verdict and nothing of the machine — so the list lives here.
415
+ */
416
+ export async function initProjectDir(target) {
417
+ const me = runnerIdentity();
418
+ // The same rule the API's `WorkspacePathSchema` applies, re-derived here: the
419
+ // runner is the process that actually calls `mkdir`, and it must not trust the
420
+ // wire (QA-99 MAJOR-4 is the same argument for `validate_path`).
421
+ if (!target.startsWith('/') || target.split('/').includes('..')) {
422
+ return refuseInit('The project folder must be an absolute path with no ".." segments');
423
+ }
424
+ const resolved = path.resolve(target);
425
+ // The cheap pass, on what the person typed: nothing touches the disk for a
426
+ // path that is obviously not a project folder.
427
+ const typedRefusal = pathClassRefusal(resolved);
428
+ if (typedRefusal)
429
+ return refuseInit(typedRefusal);
430
+ // The second pass, and the one that decides: where would `mkdir` LAND? A
431
+ // symlinked ancestor otherwise walks straight through every refusal above,
432
+ // and `/var/run/shop` on a stock Debian is not a hypothetical: that directory
433
+ // IS `/run`, and the repository would sit on tmpfs until the next reboot.
434
+ //
435
+ // Asked BEFORE any permission check, and that order is the point (the 0.64.0
436
+ // build had it the other way round and was never published): for a runner that is
437
+ // not root, `/tmp/link -> /etc` failed «can the runner write to the parent»
438
+ // first — and the refusal then advised `chown <runner> /tmp/link`, which is
439
+ // `chown` on `/etc`. «This is a system directory» must win over «grant
440
+ // yourself access to it».
441
+ const landing = realLandingPlace(resolved);
442
+ if (landing !== resolved) {
443
+ const realRefusal = pathClassRefusal(landing);
444
+ // Both paths named, in that order: the person recognises what they typed,
445
+ // and then learns where it actually goes — which is the fact they have to
446
+ // act on.
447
+ if (realRefusal)
448
+ return refuseInit(`${resolved} leads to ${realRefusal}`);
449
+ }
450
+ const here = inspectPath(resolved);
451
+ if (here.unreachable) {
452
+ const blocked = firstUnreachableAncestor(resolved) ?? resolved;
453
+ return refuseInit(`The runner runs as ${me.user} and is not allowed into ${blocked}, so it cannot create ${resolved}. ` +
454
+ `Grant that user access to the directory (for example \`chmod o+x ${blocked}\`, ` +
455
+ `or \`setfacl -m u:${me.user}:x ${blocked}\`), then try again.`);
456
+ }
457
+ if (here.exists) {
458
+ // Not a refusal, and deliberately not a `mkdir` either: a directory that is
459
+ // already there is the ordinary case of pressing the button twice, and the
460
+ // window's next move — bind it — is the right one.
461
+ if (here.isDirectory)
462
+ return { ok: true, created: false, exists: true };
463
+ return refuseInit(`${resolved} already exists and is not a directory`);
464
+ }
465
+ const parent = path.dirname(resolved);
466
+ const above = inspectPath(parent);
467
+ if (above.unreachable) {
468
+ const blocked = firstUnreachableAncestor(parent) ?? parent;
469
+ return refuseInit(`The runner runs as ${me.user} and is not allowed into ${blocked}, so it cannot create ${resolved}. ` +
470
+ `Grant that user access to the directory (for example \`chmod o+x ${blocked}\`, ` +
471
+ `or \`setfacl -m u:${me.user}:x ${blocked}\`), then try again.`);
472
+ }
473
+ if (!above.exists || !above.isDirectory) {
474
+ return refuseInit(`${parent} does not exist on this server, and the runner creates only the last folder of the path. ` +
475
+ `Create ${parent} there first (\`mkdir -p ${parent}\` as a user who may write to it), then try again.`);
476
+ }
477
+ if (!above.writable) {
478
+ return refuseInit(`The runner runs as ${me.user} and cannot write to ${parent}, so it cannot create ${resolved} there. ` +
479
+ `Give that user write access (for example \`setfacl -m u:${me.user}:rwx ${parent}\`, ` +
480
+ `or \`chown ${me.user} ${parent}\` if the directory is meant to be theirs), then try again.`);
481
+ }
482
+ try {
483
+ // Deliberately NOT `recursive: true`: the chain of parents is somebody
484
+ // else's decision about the layout of their machine, and a button that
485
+ // quietly makes `/opt/a/b/c` out of a typo is worse than one that says the
486
+ // folder above is missing.
487
+ fs.mkdirSync(resolved);
488
+ }
489
+ catch (error) {
490
+ const code = error.code;
491
+ if (code === 'EEXIST') {
492
+ // Somebody else got there between `inspectPath` and here — or the name was
493
+ // never free at all: `mkdir(2)` answers EEXIST for ANY existing name, and
494
+ // a dangling symlink is invisible to the `statSync` above, which follows
495
+ // it and reports «not there». Look again rather than assume: «a directory
496
+ // is there» is what the window binds on next.
497
+ const now = inspectPath(resolved);
498
+ if (now.exists && now.isDirectory)
499
+ return { ok: true, created: false, exists: true };
500
+ return refuseInit(`${resolved} already exists and is not a directory`);
501
+ }
502
+ return refuseInit(`The runner runs as ${me.user} and could not create ${resolved} (${code ?? 'unknown error'}). ` +
503
+ `Check that ${parent} is writable by that user, then try again.`);
504
+ }
505
+ try {
506
+ await initRepositoryOnMain(resolved);
507
+ }
508
+ catch (error) {
509
+ // All-or-nothing, like laying out a worktree (#360): a folder that exists
510
+ // but is not a repository binds and then fails on «Not a git work tree»,
511
+ // which is a worse place to learn about it. `rmdirSync` refuses a directory
512
+ // with anything in it, which is exactly the safety wanted here — we are only
513
+ // ever removing the empty one made three lines up.
514
+ let takenBack = true;
515
+ try {
516
+ fs.rmdirSync(resolved);
517
+ }
518
+ catch {
519
+ // It is not empty, so it is not only ours to remove. Leave it — and say
520
+ // so, because the two outcomes want opposite things from the person.
521
+ takenBack = false;
522
+ }
523
+ // Git's own words go to the runner's log, where the person who can act on
524
+ // them is; the sentence that travels names the folder and what to do next.
525
+ // `describeGitFailure` would have carried `fatal: …` into a browser, and
526
+ // the API's net for that (`actionableRunnerRefusal`) rewrites the WHOLE
527
+ // text — so forwarding it would have cost this sentence as well.
528
+ log.warn('init_project_dir: git init failed', {
529
+ path: resolved,
530
+ takenBack,
531
+ error: describeGitFailure(error),
532
+ });
533
+ const said = takenBack
534
+ ? `Git would not start in ${resolved}, so the folder was removed again and nothing was left behind. ` +
535
+ `The runner's log on this server has git's own words — check that git is installed and that the disk is writable, then try again.`
536
+ : `${resolved} was created, but git would not start in it, and the folder is still there. ` +
537
+ `The runner's log on this server has git's own words. Run \`git init -b main\` in that folder yourself, or remove it and try again.`;
538
+ return { ok: false, created: !takenBack, exists: !takenBack, error: said };
539
+ }
540
+ // Read back rather than reported from intent: on a git old enough to need the
541
+ // fallback below, «main» is something we asked for, not something we saw.
542
+ const head = await headState(resolved);
543
+ return {
544
+ ok: true,
545
+ created: true,
546
+ exists: true,
547
+ ...(head.branch ? { branch: head.branch } : {}),
548
+ };
549
+ }
550
+ /**
551
+ * `git init` with `main` as the initial branch, on every git the fleet has.
552
+ *
553
+ * `--initial-branch` arrived in git 2.28 (2020); Debian 10 and CentOS 7 ship
554
+ * older ones and are still out there. The fallback is not a lesser outcome —
555
+ * `symbolic-ref` on a repository with no commits in it is exactly what `-b`
556
+ * does — it just takes two calls.
557
+ *
558
+ * Tried rather than version-parsed on purpose: the question is whether THIS git
559
+ * accepts the flag, and the flag itself answers it in one cheap call.
560
+ */
561
+ async function initRepositoryOnMain(repoPath) {
562
+ try {
563
+ await git(repoPath, 'init', '--quiet', '--initial-branch', INITIAL_BRANCH);
564
+ return;
565
+ }
566
+ catch {
567
+ // Falls through: either the flag is unknown, or `git init` itself failed —
568
+ // and the plain form below tells the two apart by either working or
569
+ // throwing the real reason to the caller.
570
+ }
571
+ await git(repoPath, 'init', '--quiet');
572
+ // Best effort, and deliberately not fatal: `git init` has already made a
573
+ // working repository, and the caller reads the branch back off it rather than
574
+ // trusting this call. Throwing here would throw away a folder that is fine.
575
+ await git(repoPath, 'symbolic-ref', 'HEAD', `refs/heads/${INITIAL_BRANCH}`).catch(() => undefined);
576
+ }
255
577
  /**
256
578
  * The repository's main branch, read locally (ADR 0004).
257
579
  *
package/dist/index.js CHANGED
@@ -566,6 +566,11 @@ function runnerCapabilities(apiUrlOverride) {
566
566
  // это чтение, а не установка, и машину, чей владелец запретил ставить из
567
567
  // дашборда, спросить о том, что на ней стоит, по-прежнему можно.
568
568
  'agent_versions_refresh',
569
+ // #417: create the project folder and `git init` in it, from the
570
+ // binding window. Announced here and nowhere else — the API refuses the
571
+ // button on a machine that does not name this command, because an older
572
+ // runner drops a command it cannot parse and never answers.
573
+ 'init_project_dir',
569
574
  // #396. Listed unconditionally, unlike the `restart` flag above: the
570
575
  // flag is what the dashboard draws a button from, and this list is what
571
576
  // `runCommand` dispatches on. A machine that cannot restart still
@@ -9,7 +9,7 @@ import { classifyFailure, isRepeatOfSameFailure, MAX_RETRIES_PER_SESSION, retryD
9
9
  import { evaluateRecipeCommand, maskSecrets, maskString } from './policy.js';
10
10
  import { agentPromptSizeLabel, inspectAgentPrompt, quotePath, readAgentPrompt, } from './agent-prompt.js';
11
11
  import { JournalStore } from './journal.js';
12
- import { deleteSessionBranch, ensurePreviewWorktree, ensureSessionWorktree, prepareDirectWorkspace, previewWorktreePath, removePreviewWorktree, removeSessionWorktree, repoKeyFor, sessionWorktreePath, validateWorkspacePath, WorktreePrepareError, } from './git.js';
12
+ import { deleteSessionBranch, ensurePreviewWorktree, ensureSessionWorktree, initProjectDir, prepareDirectWorkspace, previewWorktreePath, removePreviewWorktree, removeSessionWorktree, repoKeyFor, sessionWorktreePath, validateWorkspacePath, WorktreePrepareError, } from './git.js';
13
13
  import { readRecipeProposal } from './recipe.js';
14
14
  import { RECIPE_STEP_NAMES, parseProjectRecipe } from './recipe-schema.js';
15
15
  import { proposeCommitMessage } from './commit-message.js';
@@ -6140,6 +6140,37 @@ export class Supervisor {
6140
6140
  ...(validation.ok ? {} : { error: validation.error }),
6141
6141
  });
6142
6142
  }
6143
+ /**
6144
+ * «Create this folder and put git in it», from the binding window
6145
+ * (#417).
6146
+ *
6147
+ * Next to `validate_path` because it is the same conversation: that
6148
+ * command answers «the folder is not there», and this one is what the
6149
+ * person presses next. Everything it is allowed and not allowed to do
6150
+ * is decided in `initProjectDir` — the API sees a verdict and never the
6151
+ * machine.
6152
+ *
6153
+ * Under the repository lock like every other operation on a folder,
6154
+ * even though there is no repository yet: `repoKeyFor` falls back to the
6155
+ * path itself when git cannot answer, so two presses of the button
6156
+ * serialise against each other rather than racing into one `mkdir`.
6157
+ */
6158
+ case 'init_project_dir': {
6159
+ const path = typeof frame.args?.['path'] === 'string' ? frame.args['path'] : null;
6160
+ if (!path)
6161
+ return void reply({ ok: false, error: 'path argument is required' });
6162
+ const init = await this.withRepoLockFor(path, () => initProjectDir(path));
6163
+ // Remembered for the same reason `validate_path` remembers it: this
6164
+ // is a real project directory now, and `devbridge-runner doctor`
6165
+ // checks the permissions of the ones it knows about.
6166
+ if (init.ok)
6167
+ rememberWorkspacePath(path);
6168
+ return void reply({
6169
+ ok: init.ok,
6170
+ result: init,
6171
+ ...(init.ok ? {} : { error: init.error }),
6172
+ });
6173
+ }
6143
6174
  case 'clean': {
6144
6175
  const sessionId = frame.sessionId;
6145
6176
  if (!sessionId)
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.63.0";
1
+ export declare const RUNNER_VERSION = "0.64.1";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in sync with package.json by the release script (manual for now).
2
- export const RUNNER_VERSION = '0.63.0';
2
+ export const RUNNER_VERSION = '0.64.1';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.63.0",
3
+ "version": "0.64.1",
4
4
  "description": "DevBridge dev runner — connects a dev server to DevBridge and runs agent sessions (Claude Code / Codex)",
5
5
  "homepage": "https://bridge4.dev",
6
6
  "license": "MIT",