@bevel-software/platform-core-backend 0.12.0 → 0.12.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.
- package/THIRD-PARTY-NOTICES.md +4 -4
- package/dist/modules/kb-fs/clone-config.d.ts +40 -2
- package/dist/modules/kb-fs/clone-config.d.ts.map +1 -1
- package/dist/modules/kb-fs/clone-config.js +94 -2
- package/dist/modules/kb-fs/clone-config.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +38 -0
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +112 -6
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-git.js +21 -4
- package/dist/modules/workspace/startup/kb-git.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +52 -8
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +121 -23
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/package.json +5 -5
- package/src/modules/kb-fs/__tests__/clone-config.test.ts +63 -2
- package/src/modules/kb-fs/clone-config.ts +97 -2
- package/src/modules/secrets-vault/secrets-vault.routes.ts +582 -582
- package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +11 -5
- package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +172 -7
- package/src/modules/workflow/workflow.service.ts +118 -6
- package/src/modules/workspace/__tests__/workspace.service.test.ts +1 -1
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +141 -0
- package/src/modules/workspace/startup/kb-git.ts +20 -7
- package/src/modules/workspace/workspace.service.ts +132 -25
|
@@ -3,6 +3,7 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { execFile } from 'node:child_process';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
+
import { cloneCredentialArgs, credentialHelperValue } from '../../kb-fs/clone-config.js';
|
|
6
7
|
|
|
7
8
|
const execFileAsync = promisify(execFile);
|
|
8
9
|
|
|
@@ -36,18 +37,30 @@ export function redactSecret(text: string): string {
|
|
|
36
37
|
*/
|
|
37
38
|
function credArgs(gitUsername: string): string[] {
|
|
38
39
|
const args = ['-c', 'core.longpaths=true'];
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
'-c',
|
|
42
|
-
`credential.helper=!f() { echo "username=${gitUsername}"; echo "password=$GITHUB_TOKEN"; }; f`,
|
|
43
|
-
);
|
|
44
|
-
}
|
|
40
|
+
const helper = credentialHelperValue(gitUsername);
|
|
41
|
+
if (helper) args.push('-c', `credential.helper=${helper}`);
|
|
45
42
|
return args;
|
|
46
43
|
}
|
|
47
44
|
|
|
45
|
+
/**
|
|
46
|
+
* `credArgs` authenticates the invocation and nothing more — the `-c` pairs sit
|
|
47
|
+
* BEFORE the subcommand, so git applies them to this process and forgets them.
|
|
48
|
+
* That is right for every command here except `clone`, whose product is a
|
|
49
|
+
* repository other code pushes from later: the phase clones the default branch
|
|
50
|
+
* into `<workspacesRoot>/<id>/<kbDirName>`, exactly where `WorkspaceService`
|
|
51
|
+
* adopts it, and `GitService.push` then runs a bare `git push` expecting the
|
|
52
|
+
* clone to carry its own credentials. Persist them with `clone --config` (which
|
|
53
|
+
* only means "write into the new repo" after the subcommand) so it does.
|
|
54
|
+
*/
|
|
55
|
+
function withPersistedCloneConfig(gitUsername: string, args: string[]): string[] {
|
|
56
|
+
if (args[0] !== 'clone') return args;
|
|
57
|
+
return [args[0], ...cloneCredentialArgs(gitUsername), ...args.slice(1)];
|
|
58
|
+
}
|
|
59
|
+
|
|
48
60
|
export async function git(cwd: string, gitUsername: string, args: string[]): Promise<string> {
|
|
49
61
|
try {
|
|
50
|
-
const
|
|
62
|
+
const argv = [...credArgs(gitUsername), ...withPersistedCloneConfig(gitUsername, args)];
|
|
63
|
+
const { stdout } = await execFileAsync('git', argv, {
|
|
51
64
|
cwd,
|
|
52
65
|
// A stalled remote must FAIL the phase, not hang the boot forever —
|
|
53
66
|
// fail-closed (and KB_SAFE_BOOT's demotion) can only engage on an error
|
|
@@ -9,7 +9,12 @@ import { BevelIgnoreStack } from './bevel-ignore.js';
|
|
|
9
9
|
import { workspaceIdForBranch, branchForWorkspaceId } from '../../shared/workspace-id.js';
|
|
10
10
|
import { WorkflowDomainError } from '../../shared/domain-errors.js';
|
|
11
11
|
import { assertValidBranchName } from '../kb-fs/branch-name.js';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
cloneCredentialArgs,
|
|
14
|
+
cloneCredentialConfigArgs,
|
|
15
|
+
cloneTrackingConfigArgs,
|
|
16
|
+
SAFE_IMPLICIT_FETCH_ARGS,
|
|
17
|
+
} from '../kb-fs/clone-config.js';
|
|
13
18
|
import type { IDiffService } from '../diff/diff.interface.js';
|
|
14
19
|
|
|
15
20
|
/**
|
|
@@ -155,9 +160,13 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
155
160
|
this.onWorkspaceCloned = listener;
|
|
156
161
|
}
|
|
157
162
|
|
|
158
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* Build git clone args. Credentials go in as `clone --config`, which both
|
|
165
|
+
* authenticates this clone and leaves the helper in the new repo's config for
|
|
166
|
+
* every later push — and keeps the token itself out of the repo URL, out of
|
|
167
|
+
* argv, and out of `.git/config` (the helper reads it from the environment).
|
|
168
|
+
*/
|
|
159
169
|
private gitCloneArgs(targetDir: string, branch: string, referenceRepo?: string): string[] {
|
|
160
|
-
const token = process.env.GITHUB_TOKEN;
|
|
161
170
|
// core.longpaths=true: KB has paths >260 chars; without this the clone silently drops those files on Windows.
|
|
162
171
|
const args = ['clone', '-c', 'core.longpaths=true', '-b', branch];
|
|
163
172
|
// Borrow the object database of an existing sibling clone. Every commit
|
|
@@ -171,14 +180,10 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
171
180
|
if (referenceRepo) {
|
|
172
181
|
args.push('--reference', referenceRepo, '--dissociate');
|
|
173
182
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
args.push(
|
|
179
|
-
'-c', `credential.helper=!f() { echo "username=${this.gitUsername()}"; echo "password=$GITHUB_TOKEN"; }; f`,
|
|
180
|
-
);
|
|
181
|
-
}
|
|
183
|
+
// Persisted into the new repo's config (`clone --config`), not just applied
|
|
184
|
+
// to this invocation — every later push from this clone depends on finding
|
|
185
|
+
// the helper there. See `cloneCredentialArgs`.
|
|
186
|
+
args.push(...cloneCredentialArgs(this.gitUsername()));
|
|
182
187
|
args.push(this.kbRepoUrl(), targetDir);
|
|
183
188
|
return args;
|
|
184
189
|
}
|
|
@@ -288,6 +293,11 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
288
293
|
// back a half-cloned workspace.
|
|
289
294
|
if (this.branchDirs.has(branch)) {
|
|
290
295
|
await this.awaitBootstrapOrEvict(branch);
|
|
296
|
+
// A token or username set/changed through the setup screen after this
|
|
297
|
+
// branch was first opened has to reach the already-cached clone, or its
|
|
298
|
+
// pushes keep failing — the fast path is exactly where that rotation is
|
|
299
|
+
// otherwise missed.
|
|
300
|
+
await this.refreshCredentialHelperIfStale(branch, repoDir);
|
|
291
301
|
return this.buildWorkspaceInfo(branch, workspaceDir);
|
|
292
302
|
}
|
|
293
303
|
|
|
@@ -301,6 +311,7 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
301
311
|
// single-flight wait the cached fast path already uses.
|
|
302
312
|
await this.awaitBootstrapOrEvict(branch);
|
|
303
313
|
if (this.branchDirs.has(branch)) {
|
|
314
|
+
await this.refreshCredentialHelperIfStale(branch, repoDir);
|
|
304
315
|
return this.buildWorkspaceInfo(branch, workspaceDir);
|
|
305
316
|
}
|
|
306
317
|
|
|
@@ -313,7 +324,7 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
313
324
|
// (duplicate fetch refspec / merge ref) is repaired before anything
|
|
314
325
|
// pulls it. Once per branch per process — the cached paths above return
|
|
315
326
|
// before reaching here.
|
|
316
|
-
await this.
|
|
327
|
+
await this.normalizeCloneConfig(repoDir, branch);
|
|
317
328
|
this.branchDirs.set(branch, workspaceDir);
|
|
318
329
|
return this.buildWorkspaceInfo(branch, workspaceDir);
|
|
319
330
|
} catch {
|
|
@@ -396,17 +407,26 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
396
407
|
}
|
|
397
408
|
|
|
398
409
|
/**
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
410
|
+
* Bring an adopted clone's config back to what a fresh clone would carry.
|
|
411
|
+
*
|
|
412
|
+
* Tracking: collapse it to a single fetch refspec and a single upstream ref
|
|
413
|
+
* for `branch` (see `kb-fs/clone-config.ts`). A clone that accumulated a
|
|
414
|
+
* second `remote.origin.fetch` refspec or `branch.<b>.merge` value makes git
|
|
415
|
+
* refuse to refresh it — "Cannot rebase onto multiple branches" — which is
|
|
416
|
+
* how a post-merge pull of the target branch fails.
|
|
417
|
+
*
|
|
418
|
+
* Credentials: re-stamp the helper. This is the repair for clones that never
|
|
419
|
+
* got one — anything created by a build whose clone authenticated only its
|
|
420
|
+
* own invocation, or by a deployment that had no token configured until
|
|
421
|
+
* after the clone existed. Without it those clones are permanently
|
|
422
|
+
* unpushable: nothing re-clones a directory that is already on disk, so no
|
|
423
|
+
* restart, re-pull or settings change would ever fix them.
|
|
404
424
|
*
|
|
405
425
|
* Never throws: it only ever touches `.git/config`, so a failure leaves the
|
|
406
426
|
* workspace exactly as usable as it was, and the git layer self-heals the
|
|
407
|
-
*
|
|
427
|
+
* tracking keys before it pulls.
|
|
408
428
|
*/
|
|
409
|
-
private async
|
|
429
|
+
private async normalizeCloneConfig(repoDir: string, branch: string): Promise<void> {
|
|
410
430
|
try {
|
|
411
431
|
for (const args of cloneTrackingConfigArgs(branch)) {
|
|
412
432
|
await execFileAsync('git', ['-C', repoDir, ...args]);
|
|
@@ -415,12 +435,99 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
415
435
|
// One line per branch, not per key: every key writes to the same
|
|
416
436
|
// `.git/config`, so what fails for one fails for all.
|
|
417
437
|
console.warn(
|
|
418
|
-
`[workspace] could not normalize the
|
|
438
|
+
`[workspace] could not normalize the config of the "${branch}" clone:`,
|
|
439
|
+
redactError(err),
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
await this.stampCredentialHelper(repoDir, branch);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Fingerprint of the credential state a clone's helper is derived from: the
|
|
447
|
+
* username (baked into the helper literal) and whether a token exists at all
|
|
448
|
+
* (the helper is present-or-absent on that). The token VALUE is deliberately
|
|
449
|
+
* excluded — the helper reads `$GITHUB_TOKEN` at call time, so rotating the
|
|
450
|
+
* token needs no re-stamp; only a username change or a token appearing /
|
|
451
|
+
* disappearing does.
|
|
452
|
+
*/
|
|
453
|
+
private credentialFingerprint(): string {
|
|
454
|
+
return `${this.gitUsername()}::${process.env.GITHUB_TOKEN ? '1' : '0'}`;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Last credential fingerprint stamped into each branch's clone, this process. */
|
|
458
|
+
private readonly stampedCredentialFingerprint = new Map<string, string>();
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Bring `repoDir`'s credential helper into line with the current deployment
|
|
462
|
+
* config (stamp when a token is set, unset when none is), and record the
|
|
463
|
+
* fingerprint so the cached fast path can skip a no-op re-stamp. Runs the
|
|
464
|
+
* config args tolerantly and one at a time: `--unset-all` on a clone that
|
|
465
|
+
* never had a helper exits non-zero, and that must neither abort the rest
|
|
466
|
+
* nor read as a failure.
|
|
467
|
+
*/
|
|
468
|
+
private async stampCredentialHelper(repoDir: string, branch: string): Promise<void> {
|
|
469
|
+
let argLists: string[][];
|
|
470
|
+
try {
|
|
471
|
+
argLists = cloneCredentialConfigArgs(this.gitUsername());
|
|
472
|
+
} catch (err) {
|
|
473
|
+
// `credentialHelperValue` fails closed on a username outside its safe
|
|
474
|
+
// charset. Normally unreachable (CoreConfig and the settings service
|
|
475
|
+
// validate first), but if a fourth, unvalidated path ever appears the
|
|
476
|
+
// throw must not escape here: in the disk-adoption block it would land
|
|
477
|
+
// in a catch that reads every error as "not on disk" and kick off a
|
|
478
|
+
// re-clone that fails on the same validation — masking the real cause.
|
|
479
|
+
// Contain it as a loud non-stamp instead; `normalizeCloneConfig`'s
|
|
480
|
+
// never-throws contract stays true.
|
|
481
|
+
console.warn(
|
|
482
|
+
`[workspace] refusing to stamp the credential helper of the "${branch}" clone:`,
|
|
419
483
|
redactError(err),
|
|
420
484
|
);
|
|
485
|
+
this.stampedCredentialFingerprint.delete(branch);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
let stamped = true;
|
|
489
|
+
for (const args of argLists) {
|
|
490
|
+
try {
|
|
491
|
+
await execFileAsync('git', ['-C', repoDir, ...args]);
|
|
492
|
+
} catch (err) {
|
|
493
|
+
// `--unset-all` with no matching value (exit 5) is the expected no-op
|
|
494
|
+
// for a clone that never carried an app helper; anything else is a
|
|
495
|
+
// real failure.
|
|
496
|
+
const code = (err as { code?: number } | null)?.code;
|
|
497
|
+
if (!(args.includes('--unset-all') && code === 5)) {
|
|
498
|
+
stamped = false;
|
|
499
|
+
console.warn(
|
|
500
|
+
`[workspace] could not stamp the credential helper of the "${branch}" clone:`,
|
|
501
|
+
redactError(err),
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (stamped) {
|
|
507
|
+
this.stampedCredentialFingerprint.set(branch, this.credentialFingerprint());
|
|
508
|
+
} else {
|
|
509
|
+
// Recording the fingerprint after a FAILED write would make every later
|
|
510
|
+
// cached open skip the retry as "already up to date" — the clone would
|
|
511
|
+
// keep pushing with stale or missing credentials until a restart.
|
|
512
|
+
// Dropping the entry keeps the fast path retrying until a stamp lands.
|
|
513
|
+
this.stampedCredentialFingerprint.delete(branch);
|
|
421
514
|
}
|
|
422
515
|
}
|
|
423
516
|
|
|
517
|
+
/**
|
|
518
|
+
* Re-stamp a cached clone's credential helper when the deployment's
|
|
519
|
+
* credential config changed since we last stamped it — the rotation case the
|
|
520
|
+
* disk-adoption path in `getOrCreateForBranch` never reaches, because the
|
|
521
|
+
* fast path returns before it. Without this, a token added (or a username
|
|
522
|
+
* changed) through the setup screen after a branch was first opened would
|
|
523
|
+
* never reach that branch's clone until a process restart, and its pushes
|
|
524
|
+
* would keep failing exactly as in the original incident.
|
|
525
|
+
*/
|
|
526
|
+
private async refreshCredentialHelperIfStale(branch: string, repoDir: string): Promise<void> {
|
|
527
|
+
if (this.stampedCredentialFingerprint.get(branch) === this.credentialFingerprint()) return;
|
|
528
|
+
await this.stampCredentialHelper(repoDir, branch);
|
|
529
|
+
}
|
|
530
|
+
|
|
424
531
|
private async cloneProcessMapForBranch(workspaceDir: string, branch: string): Promise<void> {
|
|
425
532
|
const targetDir = path.join(workspaceDir, this.kbDirName);
|
|
426
533
|
|
|
@@ -465,10 +572,10 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
465
572
|
}
|
|
466
573
|
if (alreadyCloned) {
|
|
467
574
|
// We don't auto-pull because that could clobber another user's
|
|
468
|
-
// in-progress lock-held edits. The
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
await this.
|
|
575
|
+
// in-progress lock-held edits. The CONFIG is re-stamped though — it
|
|
576
|
+
// never touches the working tree, and a clone with drifted tracking
|
|
577
|
+
// or a missing credential helper is one that can't pull or push.
|
|
578
|
+
await this.normalizeCloneConfig(targetDir, branch);
|
|
472
579
|
return;
|
|
473
580
|
}
|
|
474
581
|
// Half-built dir — wipe and re-clone.
|
|
@@ -492,7 +599,7 @@ export class WorkspaceService implements IWorkspaceService {
|
|
|
492
599
|
// this branch. `git clone -b` already produces that shape; stamping it
|
|
493
600
|
// explicitly means the shape is asserted rather than assumed, and the
|
|
494
601
|
// same call is what repairs an existing clone that drifted.
|
|
495
|
-
await this.
|
|
602
|
+
await this.normalizeCloneConfig(targetDir, branch);
|
|
496
603
|
console.log(
|
|
497
604
|
`[workspace] Cloned ${this.kbDirName} for branch "${branch}"` +
|
|
498
605
|
(reference ? ' (referenced a sibling clone)' : ''),
|