@workit-dev/core 0.0.1 → 1.0.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/LICENSE +21 -0
- package/README.md +22 -43
- package/package.json +56 -6
- package/schemas/config.json +74 -0
- package/schemas/vcs.json +79 -0
- package/schemas/workspaces.json +1029 -0
- package/schemas/youtrack.json +84 -0
- package/scripts/analyze-release-scope.ts +215 -0
- package/scripts/bundled-deps.json +131 -0
- package/scripts/fixtures/sample-plan.md +15 -0
- package/scripts/fixtures/sample-sdd/progress.md +2 -0
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +146 -0
- package/scripts/install-opencode-plugin.sh +79 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/lib/config-dir.sh +28 -0
- package/scripts/publish-changed-packages.ts +395 -0
- package/scripts/rewrite-workspace-deps.ts +53 -0
- package/scripts/run-cursor-mcp.sh +6 -0
- package/scripts/sync-release-manifests.ts +82 -0
- package/scripts/sync-runtime.sh +142 -0
- package/scripts/validate-cursor-marketplace.ts +171 -0
- package/scripts/verify-bundle-sources.ts +134 -0
- package/scripts/write-schemas.ts +16 -0
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- package/skills/workit-verify-app/references/template.md +51 -0
- package/src/autonomy.ts +910 -0
- package/src/check-config.ts +613 -0
- package/src/checks.ts +534 -0
- package/src/ci-waiter.ts +159 -0
- package/src/core/boundary.ts +55 -0
- package/src/core/branch-policy.ts +112 -0
- package/src/core/branch.ts +1192 -0
- package/src/core/changelog-gate.ts +349 -0
- package/src/core/changelog-merge.ts +214 -0
- package/src/core/changelog.ts +392 -0
- package/src/core/cleanup.ts +638 -0
- package/src/core/cli-locate.ts +173 -0
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-schemas.ts +653 -0
- package/src/core/config.ts +351 -0
- package/src/core/context-read.ts +144 -0
- package/src/core/docs-gate.ts +69 -0
- package/src/core/finding-evidence.ts +89 -0
- package/src/core/finding-queue.ts +36 -0
- package/src/core/git.ts +73 -0
- package/src/core/gitignore.ts +52 -0
- package/src/core/heavy-eta.ts +170 -0
- package/src/core/heavy-lease.ts +290 -0
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +83 -0
- package/src/core/heavy.ts +535 -0
- package/src/core/hygiene.ts +149 -0
- package/src/core/init.ts +218 -0
- package/src/core/live-session.ts +333 -0
- package/src/core/logger.ts +310 -0
- package/src/core/methods.ts +185 -0
- package/src/core/model-fallback.ts +208 -0
- package/src/core/node-resolve.mjs +249 -0
- package/src/core/operation-input.ts +632 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/bookkeeping.ts +119 -0
- package/src/core/policy/carry.ts +122 -0
- package/src/core/policy/check-evidence.ts +115 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +387 -0
- package/src/core/policy/judgment.ts +246 -0
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/policy-resolver.ts +94 -0
- package/src/core/pr-create.ts +448 -0
- package/src/core/pr-template.ts +162 -0
- package/src/core/release-notes.ts +197 -0
- package/src/core/release-tracks.ts +779 -0
- package/src/core/remotes.ts +436 -0
- package/src/core/repo-context.ts +542 -0
- package/src/core/repo-paths.ts +29 -0
- package/src/core/resources.ts +1299 -0
- package/src/core/role-models.ts +603 -0
- package/src/core/route-intent.ts +305 -0
- package/src/core/rules.ts +65 -0
- package/src/core/runtime-identity.ts +63 -0
- package/src/core/safe-write.ts +87 -0
- package/src/core/scope-glob.ts +56 -0
- package/src/core/scripts.ts +6 -0
- package/src/core/skill-manifests.ts +173 -0
- package/src/core/store-lock.ts +374 -0
- package/src/core/support-matrix.ts +24 -0
- package/src/core/task-context.ts +247 -0
- package/src/core/task-contract.ts +1279 -0
- package/src/core/task-delivery.ts +109 -0
- package/src/core/task-engine.ts +2248 -0
- package/src/core/task-evaluation.ts +1147 -0
- package/src/core/task-store.ts +2649 -0
- package/src/core/task-view.ts +180 -0
- package/src/core/templates.ts +22 -0
- package/src/core/tracker-issues.ts +174 -0
- package/src/core/vcs-config.ts +540 -0
- package/src/core/workers.ts +62 -0
- package/src/core/workit-paths.mjs +242 -0
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/core/workspaces.ts +1745 -0
- package/src/core/youtrack-backend.ts +288 -0
- package/src/core/youtrack-tools.ts +260 -0
- package/src/core/youtrack-writes.ts +73 -0
- package/src/core/youtrack-ytrack.ts +217 -0
- package/src/core/youtrack.ts +454 -0
- package/src/core/ytrack-skill.ts +297 -0
- package/src/core.ts +171 -0
- package/src/deslop/lex.ts +271 -0
- package/src/deslop/scan.ts +232 -0
- package/src/duration.ts +27 -0
- package/src/fanout-brief.ts +486 -0
- package/src/fanout-check.ts +455 -0
- package/src/fanout-landed.ts +370 -0
- package/src/fanout-stack.ts +108 -0
- package/src/fanout-status.ts +362 -0
- package/src/fanout-worktree.ts +893 -0
- package/src/fanout.ts +1473 -0
- package/src/forge/exec.ts +385 -0
- package/src/forge/github.ts +831 -0
- package/src/forge/gitlab.ts +906 -0
- package/src/forge/issue-forge.ts +209 -0
- package/src/forge/issue-link.ts +468 -0
- package/src/forge/issue-ops.ts +314 -0
- package/src/forge/merge-gate-base-red.ts +72 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-edit.ts +292 -0
- package/src/forge/pr-ops.ts +1109 -0
- package/src/forge/pr-status.ts +10 -0
- package/src/forge/redact.ts +195 -0
- package/src/forge/report.ts +994 -0
- package/src/forge/reruns.ts +132 -0
- package/src/forge/resolve.ts +509 -0
- package/src/forge/types.ts +242 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/default-base.ts +130 -0
- package/src/git/env-redirect.ts +28 -0
- package/src/git/facts.ts +122 -0
- package/src/git/ops.ts +1823 -0
- package/src/git/refs-read.ts +332 -0
- package/src/git/release.ts +1020 -0
- package/src/git/rev.ts +1175 -0
- package/src/git/session-hook.ts +597 -0
- package/src/git/stage.ts +63 -0
- package/src/hooks/agent-model.ts +149 -0
- package/src/hooks/budget-signal.ts +284 -0
- package/src/hooks/context.ts +461 -0
- package/src/hooks/cwd-normalize.ts +79 -0
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/delivery-verbs.ts +182 -0
- package/src/hooks/descriptor.ts +124 -0
- package/src/hooks/handle.ts +433 -0
- package/src/hooks/heavy-gate.ts +99 -0
- package/src/hooks/hook-runs.ts +130 -0
- package/src/hooks/hosts/claude-code.ts +380 -0
- package/src/hooks/hosts/codex.ts +372 -0
- package/src/hooks/hosts/cursor.ts +419 -0
- package/src/hooks/hosts/fields.ts +98 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +78 -0
- package/src/hooks/index.ts +60 -0
- package/src/hooks/model-limit.ts +378 -0
- package/src/hooks/model-switch.ts +35 -0
- package/src/hooks/policy.ts +29 -0
- package/src/hooks/process.ts +48 -0
- package/src/hooks/protocol.ts +135 -0
- package/src/hooks/raw-git.ts +2289 -0
- package/src/hooks/repeat-failure.ts +115 -0
- package/src/hooks/run.ts +54 -0
- package/src/hooks/session-marker.ts +151 -0
- package/src/hooks/shell-words.ts +532 -0
- package/src/hooks/skill-nudge.ts +295 -0
- package/src/hooks/spawn-gate.ts +72 -0
- package/src/hooks/standing-signal.ts +99 -0
- package/src/hooks/standing.ts +225 -0
- package/src/hooks/steering.ts +74 -0
- package/src/hooks/stop.ts +408 -0
- package/src/hooks/worktree-confine.ts +415 -0
- package/src/hooks/write-gate.ts +1174 -0
- package/src/host-session.ts +459 -0
- package/src/knowledge.ts +368 -0
- package/src/ledger.ts +2913 -0
- package/src/stack.ts +2570 -0
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/src/test-audit-mocks.ts +309 -0
- package/src/verdict-cover.ts +151 -0
- package/src/worktree-deps.ts +142 -0
- package/templates/headers.md +3 -0
- package/templates/hygiene/.editorconfig +8 -0
- package/templates/hygiene/.gitattributes +3 -0
- package/templates/hygiene/CHANGELOG.md +14 -0
- package/templates/hygiene/CONTRIBUTING.md +3 -0
- package/templates/hygiene/LICENSE +21 -0
- package/templates/hygiene/README.md +3 -0
- package/templates/issue-update.md +5 -0
- package/templates/plan-template.md +26 -0
- package/templates/spec-template.md +64 -0
- package/templates/workit-contract.md +13 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// The rerun-once record (design §2.1 `ci rerun`): one row per check rerun on
|
|
2
|
+
// a given head (of a PR, or of a branch such as a red main), so a second rerun
|
|
3
|
+
// of the same check on the same head needs `--force`. Rows use the ledger row shape (`type: "ci.rerun"`) so S13 can
|
|
4
|
+
// fold this file into `ledger.jsonl`.
|
|
5
|
+
//
|
|
6
|
+
// It lives in the git common dir (`<common>/workit/ci-reruns.jsonl`, design
|
|
7
|
+
// §0 #5) so every worktree of the repository shares it and `git clean` does
|
|
8
|
+
// not drop it. Append-only; unreadable lines are skipped.
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { gitCommonDir } from "../git/rev";
|
|
12
|
+
|
|
13
|
+
export type RerunRow = {
|
|
14
|
+
v: 1;
|
|
15
|
+
at: string;
|
|
16
|
+
type: "ci.rerun";
|
|
17
|
+
forge: string;
|
|
18
|
+
repo: string;
|
|
19
|
+
/** The PR whose checks were rerun; null for a branch rerun (`--ref`). */
|
|
20
|
+
pr: number | null;
|
|
21
|
+
/** The branch whose latest run was rerun; null for a PR rerun. Absent on older rows. */
|
|
22
|
+
branch?: string | null;
|
|
23
|
+
head: string;
|
|
24
|
+
/** The workflow run / pipeline the check belongs to. Absent on older rows. */
|
|
25
|
+
runId?: number | null;
|
|
26
|
+
check: string;
|
|
27
|
+
reason: string;
|
|
28
|
+
forced: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function rerunLogPath(cwd: string): string | null {
|
|
32
|
+
const common = gitCommonDir(cwd);
|
|
33
|
+
return common ? path.join(common, "workit", "ci-reruns.jsonl") : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const isRow = (value: unknown): value is RerunRow => {
|
|
37
|
+
const row = value as Partial<RerunRow> | null;
|
|
38
|
+
return (
|
|
39
|
+
!!row &&
|
|
40
|
+
row.type === "ci.rerun" &&
|
|
41
|
+
(typeof row.pr === "number" || typeof row.branch === "string") &&
|
|
42
|
+
typeof row.head === "string" &&
|
|
43
|
+
typeof row.check === "string" &&
|
|
44
|
+
typeof row.repo === "string"
|
|
45
|
+
);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* How often each check was rerun on commit `head` in `repo`, whether through
|
|
50
|
+
* its PR or its branch: one commit's checks are one allowance.
|
|
51
|
+
*/
|
|
52
|
+
export function rerunCounts(cwd: string, key: { repo: string; head: string }): Map<string, number> {
|
|
53
|
+
const counts = new Map<string, number>();
|
|
54
|
+
const file = rerunLogPath(cwd);
|
|
55
|
+
if (!file) return counts;
|
|
56
|
+
let text: string;
|
|
57
|
+
try {
|
|
58
|
+
text = fs.readFileSync(file, "utf8");
|
|
59
|
+
} catch {
|
|
60
|
+
return counts;
|
|
61
|
+
}
|
|
62
|
+
for (const raw of text.split("\n")) {
|
|
63
|
+
if (!raw.trim()) continue;
|
|
64
|
+
let row: unknown;
|
|
65
|
+
try {
|
|
66
|
+
row = JSON.parse(raw);
|
|
67
|
+
} catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (isRow(row) && row.repo === key.repo && row.head === key.head)
|
|
71
|
+
counts.set(row.check, (counts.get(row.check) ?? 0) + 1);
|
|
72
|
+
}
|
|
73
|
+
return counts;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Append rows (one write, O_APPEND). Returns false when the file cannot be written. */
|
|
77
|
+
export function recordReruns(cwd: string, rows: readonly RerunRow[]): boolean {
|
|
78
|
+
if (rows.length === 0) return true;
|
|
79
|
+
const file = rerunLogPath(cwd);
|
|
80
|
+
if (!file) return false;
|
|
81
|
+
try {
|
|
82
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
83
|
+
fs.appendFileSync(file, rows.map((row) => `${JSON.stringify(row)}\n`).join(""));
|
|
84
|
+
return true;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const LOCK_WAIT_MS = 2_000;
|
|
91
|
+
const LOCK_STALE_MS = 60_000;
|
|
92
|
+
|
|
93
|
+
const pause = (ms: number): void => {
|
|
94
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Run `fn` holding `<common>/workit/ci-reruns.lock` (atomic create). Two
|
|
99
|
+
* concurrent `ci rerun`s therefore cannot both pass the once-per-head check.
|
|
100
|
+
* A lock older than a minute is from a dead process and is taken over.
|
|
101
|
+
* Null when the lock stays held past the wait (the caller reports busy).
|
|
102
|
+
*/
|
|
103
|
+
export function withRerunLock<T>(cwd: string, fn: () => T): T | null {
|
|
104
|
+
const file = rerunLogPath(cwd);
|
|
105
|
+
if (!file) return fn();
|
|
106
|
+
const lock = `${file.slice(0, -".jsonl".length)}.lock`;
|
|
107
|
+
fs.mkdirSync(path.dirname(lock), { recursive: true });
|
|
108
|
+
const until = Date.now() + LOCK_WAIT_MS;
|
|
109
|
+
for (;;) {
|
|
110
|
+
try {
|
|
111
|
+
fs.closeSync(fs.openSync(lock, "wx"));
|
|
112
|
+
break;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
115
|
+
try {
|
|
116
|
+
if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) {
|
|
117
|
+
fs.rmSync(lock, { force: true });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (Date.now() >= until) return null;
|
|
124
|
+
pause(50);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
return fn();
|
|
129
|
+
} finally {
|
|
130
|
+
fs.rmSync(lock, { force: true });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
// Which forge, which repositories, and which credential (D16, design §0 #6,
|
|
2
|
+
// §2.0 Identity).
|
|
3
|
+
//
|
|
4
|
+
// - The push remote host decides GitHub vs GitLab. A workspace provider that
|
|
5
|
+
// disagrees is `blocked` for the forge verbs, never silently overridden;
|
|
6
|
+
// `git push` (checkPushIdentity) only needs git, so it pushes with a note.
|
|
7
|
+
// - PRs/MRs live in the base repository: an `upstream` remote on the same
|
|
8
|
+
// forge, else the push repo's fork parent, else the push repo itself.
|
|
9
|
+
// - Every API call carries the workspace's credential explicitly, so the
|
|
10
|
+
// machine-wide active gh/glab account is never switched or relied on:
|
|
11
|
+
// the workspace `vcs.tokenFile`, else (GitHub) `gh auth token --user
|
|
12
|
+
// <vcs.account>`, else the CLI's default login. The token is never printed.
|
|
13
|
+
// Config is read, never written.
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import { configFilePath } from "../core/config";
|
|
16
|
+
import { readVcsConfig } from "../core/vcs-config";
|
|
17
|
+
import { resolveRuntimeWorkspaceVcs } from "../core/workspaces";
|
|
18
|
+
import {
|
|
19
|
+
deriveForge,
|
|
20
|
+
forgeConflict,
|
|
21
|
+
pushForge,
|
|
22
|
+
pushUrl,
|
|
23
|
+
remoteNames,
|
|
24
|
+
type DerivedForge,
|
|
25
|
+
type ForgeHosts,
|
|
26
|
+
type SshConfig,
|
|
27
|
+
} from "../git/rev";
|
|
28
|
+
import { FORGE_TIMEOUTS, isNetworkError, systemRunner, type ForgeRunner } from "./exec";
|
|
29
|
+
import { createGitHubForge } from "./github";
|
|
30
|
+
import { createGitLabForge } from "./gitlab";
|
|
31
|
+
import { redactText } from "./redact";
|
|
32
|
+
import { failure, success, type Forge, type ForgeResult, type Identity } from "./types";
|
|
33
|
+
|
|
34
|
+
export type CredentialSource = "workspace_token_file" | "gh_account_token" | "cli_login";
|
|
35
|
+
|
|
36
|
+
export type ResolvedForge = {
|
|
37
|
+
/** Bound to the base repository (where the PR/MR lives). */
|
|
38
|
+
forge: Forge;
|
|
39
|
+
/** Push remote name. */
|
|
40
|
+
remote: string;
|
|
41
|
+
/** Redacted push URL. */
|
|
42
|
+
url: string;
|
|
43
|
+
derived: DerivedForge;
|
|
44
|
+
workspace: string | null;
|
|
45
|
+
/** The workspace `vcs.account`, when configured. */
|
|
46
|
+
expectedAccount: string | null;
|
|
47
|
+
credential: CredentialSource;
|
|
48
|
+
/** The push (head) repository; differs from forge.repo for a fork. */
|
|
49
|
+
headRepo: string;
|
|
50
|
+
/** GitLab project id of the push repository (MR source project). */
|
|
51
|
+
headProjectId: number | null;
|
|
52
|
+
fork: boolean;
|
|
53
|
+
/** Remote to fetch base commits from (the base repo's remote when one exists). */
|
|
54
|
+
baseRemote: string;
|
|
55
|
+
/** Absolute deadline (ms, `now()` clock) every API call is clamped to; null = none. */
|
|
56
|
+
limits: { deadline: number | null };
|
|
57
|
+
/** The workspace credential's runner, clamped to `limits` (another repo on the same host). */
|
|
58
|
+
runner: ForgeRunner;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type ResolveOptions = {
|
|
62
|
+
branch?: string | null;
|
|
63
|
+
env?: NodeJS.ProcessEnv;
|
|
64
|
+
/** Replaces the real gh/glab (tests replay recorded API fixtures). */
|
|
65
|
+
runner?: ForgeRunner;
|
|
66
|
+
/** Clock for the deadline (tests use a virtual one). */
|
|
67
|
+
now?: () => number;
|
|
68
|
+
/** ssh_config for Host aliases (default: the user's ~/.ssh/config). */
|
|
69
|
+
sshConfig?: string | SshConfig;
|
|
70
|
+
/** Absolute deadline (`now()` clock) for every call, resolution included; null = none. */
|
|
71
|
+
deadline?: number | null;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Per-process memo of credential lookups and /user answers (a long-lived MCP
|
|
75
|
+
// server re-asks after the TTL). Scoped by runner, so an injected test runner
|
|
76
|
+
// never sees another's answers, and keyed by the gh/glab config location, so
|
|
77
|
+
// another GH_CONFIG_DIR never reuses them; only successes are kept.
|
|
78
|
+
const MEMO_TTL_MS = 5 * 60_000;
|
|
79
|
+
const SYSTEM_SCOPE = {};
|
|
80
|
+
const memoScopes = new WeakMap<object, Map<string, { at: number; value: unknown }>>();
|
|
81
|
+
|
|
82
|
+
type MemoScope = { owner: object; key: string };
|
|
83
|
+
|
|
84
|
+
const memoScope = (runner: ForgeRunner | undefined, env: NodeJS.ProcessEnv): MemoScope => ({
|
|
85
|
+
owner: runner ?? SYSTEM_SCOPE,
|
|
86
|
+
key: [env.GH_CONFIG_DIR, env.GLAB_CONFIG_DIR, env.XDG_CONFIG_HOME, env.HOME]
|
|
87
|
+
.map((value) => value ?? "")
|
|
88
|
+
.join("|"),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
function memo<T>(
|
|
92
|
+
scope: MemoScope,
|
|
93
|
+
key: string,
|
|
94
|
+
compute: () => { keep: boolean; value: T },
|
|
95
|
+
now = Date.now,
|
|
96
|
+
): T {
|
|
97
|
+
let entries = memoScopes.get(scope.owner);
|
|
98
|
+
if (!entries) memoScopes.set(scope.owner, (entries = new Map()));
|
|
99
|
+
const full = `${scope.key} ${key}`;
|
|
100
|
+
const hit = entries.get(full);
|
|
101
|
+
if (hit && now() - hit.at < MEMO_TTL_MS) return hit.value as T;
|
|
102
|
+
const { keep, value } = compute();
|
|
103
|
+
if (keep) entries.set(full, { at: now(), value });
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A runner whose calls are clamped to `limits.deadline`, carrying `token`. */
|
|
108
|
+
function clampedRunner(
|
|
109
|
+
base: ForgeRunner,
|
|
110
|
+
limits: { deadline: number | null },
|
|
111
|
+
now: () => number,
|
|
112
|
+
token?: string,
|
|
113
|
+
): ForgeRunner {
|
|
114
|
+
return (bin, args, callOptions) => {
|
|
115
|
+
let timeoutMs = callOptions.timeoutMs;
|
|
116
|
+
if (limits.deadline !== null) {
|
|
117
|
+
const remaining = limits.deadline - now();
|
|
118
|
+
if (remaining <= 0)
|
|
119
|
+
return {
|
|
120
|
+
status: null,
|
|
121
|
+
stdout: "",
|
|
122
|
+
stderr: "",
|
|
123
|
+
timedOut: true,
|
|
124
|
+
missing: false,
|
|
125
|
+
timeoutMs: 0,
|
|
126
|
+
};
|
|
127
|
+
timeoutMs = Math.min(timeoutMs, remaining);
|
|
128
|
+
}
|
|
129
|
+
const run = base(bin, args, { ...callOptions, timeoutMs, ...(token ? { token } : {}) });
|
|
130
|
+
return run.timedOut && run.timeoutMs === undefined ? { ...run, timeoutMs } : run;
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* `forge.identity` answered once per explicit credential per TTL (push, then
|
|
136
|
+
* pr create, …). The CLI's own login (`cli_login`) is never memoized: a
|
|
137
|
+
* `glab auth login` / `gh auth switch` must show on the next call.
|
|
138
|
+
*/
|
|
139
|
+
const memoizedIdentity = (forge: Forge, scope: MemoScope, credential: Credential): Forge => {
|
|
140
|
+
if (credential.credential === "cli_login") return forge;
|
|
141
|
+
const identity = (expected: string | null) => forge.identity(expected);
|
|
142
|
+
return {
|
|
143
|
+
...forge,
|
|
144
|
+
identity: (expected: string | null) =>
|
|
145
|
+
memo(
|
|
146
|
+
scope,
|
|
147
|
+
`identity ${forge.kind} ${forge.apiHost} ${credential.credential} ${credential.token ?? ""} ${expected ?? ""}`,
|
|
148
|
+
() => {
|
|
149
|
+
const value = identity(expected);
|
|
150
|
+
return { keep: value.ok, value };
|
|
151
|
+
},
|
|
152
|
+
),
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
type Credential = { token: string | undefined; credential: CredentialSource };
|
|
157
|
+
|
|
158
|
+
/** The credential every API call carries (see the header). */
|
|
159
|
+
function resolveCredential(
|
|
160
|
+
workspace: ReturnType<typeof resolveRuntimeWorkspaceVcs>,
|
|
161
|
+
derived: DerivedForge,
|
|
162
|
+
run: ForgeRunner,
|
|
163
|
+
scope: MemoScope,
|
|
164
|
+
): ForgeResult<Credential> {
|
|
165
|
+
const { kind, apiHost } = derived;
|
|
166
|
+
const expectedAccount = workspace?.vcs?.account ?? null;
|
|
167
|
+
const tokenFile = (workspace?.vcs as { tokenFile?: unknown } | undefined)?.tokenFile;
|
|
168
|
+
if (typeof tokenFile === "string" && tokenFile.trim()) {
|
|
169
|
+
// A stored token goes only to the host it is for: the public forge (also
|
|
170
|
+
// reached through an ~/.ssh/config alias) or a host configured
|
|
171
|
+
// explicitly (vcs.json github.host/gitlab.host, workspace vcs.host).
|
|
172
|
+
// A host merely *named* like a forge (github.evil.com) never gets it.
|
|
173
|
+
const workspaceHost = (workspace?.vcs as { host?: unknown } | undefined)?.host;
|
|
174
|
+
const explicit =
|
|
175
|
+
typeof workspaceHost === "string" &&
|
|
176
|
+
workspaceHost
|
|
177
|
+
.trim()
|
|
178
|
+
.toLowerCase()
|
|
179
|
+
.replace(/^https?:\/\//u, "")
|
|
180
|
+
.replace(/\/.*$/u, "") === apiHost;
|
|
181
|
+
if (derived.via === "host_name" && !explicit)
|
|
182
|
+
return failure(
|
|
183
|
+
"blocked",
|
|
184
|
+
`token_host_unverified: push host ${derived.host} only looks like ${kind}; workspace ${workspace?.name ?? "?"} vcs.tokenFile is not sent to it`,
|
|
185
|
+
`if ${apiHost} is your ${kind} server, set ${kind}.host in ${configFilePath("vcs.json")} (or vcs.host for the workspace); otherwise fix the push remote`,
|
|
186
|
+
);
|
|
187
|
+
// Like youtrack.token: a token other users can read is refused (N14).
|
|
188
|
+
if (process.platform !== "win32") {
|
|
189
|
+
let mode = 0;
|
|
190
|
+
try {
|
|
191
|
+
mode = fs.statSync(tokenFile.trim()).mode;
|
|
192
|
+
} catch {
|
|
193
|
+
// unreadable: reported below
|
|
194
|
+
}
|
|
195
|
+
if ((mode & 0o077) !== 0)
|
|
196
|
+
return failure(
|
|
197
|
+
"blocked",
|
|
198
|
+
`workspace ${workspace?.name ?? "?"} vcs.tokenFile is readable by group or other (mode ${(mode & 0o777).toString(8).padStart(4, "0")})`,
|
|
199
|
+
`chmod 600 ${tokenFile.trim()}`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
let token = "";
|
|
203
|
+
try {
|
|
204
|
+
token = fs.readFileSync(tokenFile.trim(), "utf8").trim();
|
|
205
|
+
} catch {
|
|
206
|
+
return failure(
|
|
207
|
+
"blocked",
|
|
208
|
+
`workspace ${workspace?.name ?? "?"} vcs.tokenFile cannot be read`,
|
|
209
|
+
`fix vcs.tokenFile in ${configFilePath("workspaces.json")}`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
if (!token)
|
|
213
|
+
return failure(
|
|
214
|
+
"blocked",
|
|
215
|
+
`workspace ${workspace?.name ?? "?"} vcs.tokenFile is empty`,
|
|
216
|
+
`fix vcs.tokenFile in ${configFilePath("workspaces.json")}`,
|
|
217
|
+
);
|
|
218
|
+
return success({ token, credential: "workspace_token_file" });
|
|
219
|
+
}
|
|
220
|
+
// glab keeps one login per host, so a GitLab account is checked, not selected.
|
|
221
|
+
if (!expectedAccount || kind !== "github")
|
|
222
|
+
return success({ token: undefined, credential: "cli_login" });
|
|
223
|
+
return memo(scope, `token ${apiHost} ${expectedAccount}`, () => {
|
|
224
|
+
const issued = run("gh", ["auth", "token", "--hostname", apiHost, "--user", expectedAccount], {
|
|
225
|
+
timeoutMs: FORGE_TIMEOUTS.api,
|
|
226
|
+
});
|
|
227
|
+
const value = authTokenResult(issued, apiHost, expectedAccount, workspace?.name ?? "?");
|
|
228
|
+
return { keep: value.ok, value };
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* `gh auth token --user`: a timeout or a transport error is `unavailable`
|
|
234
|
+
* (exit 5, retry); only gh saying it has no such login is identity_unavailable.
|
|
235
|
+
*/
|
|
236
|
+
function authTokenResult(
|
|
237
|
+
issued: ReturnType<ForgeRunner>,
|
|
238
|
+
apiHost: string,
|
|
239
|
+
account: string,
|
|
240
|
+
workspace: string,
|
|
241
|
+
): ForgeResult<Credential> {
|
|
242
|
+
if (issued.missing)
|
|
243
|
+
return failure(
|
|
244
|
+
"unavailable",
|
|
245
|
+
"gh is not installed",
|
|
246
|
+
"install the GitHub CLI (https://cli.github.com) and run: gh auth login",
|
|
247
|
+
);
|
|
248
|
+
if (issued.timedOut)
|
|
249
|
+
return failure(
|
|
250
|
+
"unavailable",
|
|
251
|
+
`gh auth token ${issued.timeoutMs === 0 ? "not attempted: the time budget ran out" : `timed out after ${issued.timeoutMs ?? FORGE_TIMEOUTS.api} ms`} (looking up the ${account} login on ${apiHost})`,
|
|
252
|
+
"gh auth status # check gh and the network, then retry",
|
|
253
|
+
);
|
|
254
|
+
const token = issued.status === 0 ? issued.stdout.trim() : "";
|
|
255
|
+
if (token) return success({ token, credential: "gh_account_token" });
|
|
256
|
+
const stderr = redactText(issued.stderr).trim();
|
|
257
|
+
const first = stderr.split(/\r?\n/u).find((line) => line.trim()) ?? "";
|
|
258
|
+
if (issued.status !== 0 && first && !/no oauth token|not logged in|no account/iu.test(stderr))
|
|
259
|
+
return failure(
|
|
260
|
+
"unavailable",
|
|
261
|
+
`gh auth token failed${isNetworkError(stderr) ? " (network)" : ""}: ${first.slice(0, 200)}`,
|
|
262
|
+
"gh auth status # check gh and the network, then retry",
|
|
263
|
+
);
|
|
264
|
+
return failure(
|
|
265
|
+
"blocked",
|
|
266
|
+
`identity_unavailable: gh has no login for ${account} on ${apiHost} (workspace ${workspace})`,
|
|
267
|
+
`gh auth login --hostname ${apiHost} # sign in as ${account}; workit passes that account's token per call and never switches the active account`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const configuredHosts = (): ForgeHosts => {
|
|
272
|
+
const vcs = readVcsConfig();
|
|
273
|
+
if (vcs.status !== "valid") return {};
|
|
274
|
+
const host = (kind: "github" | "gitlab"): string[] => {
|
|
275
|
+
const value = (vcs.config[kind] as { host?: unknown } | undefined)?.host;
|
|
276
|
+
return typeof value === "string" && value.trim() ? [value.trim()] : [];
|
|
277
|
+
};
|
|
278
|
+
return { github: host("github"), gitlab: host("gitlab") };
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const createForge = (derived: DerivedForge, repo: string, runner: ForgeRunner): Forge =>
|
|
282
|
+
derived.kind === "github"
|
|
283
|
+
? createGitHubForge({ apiHost: derived.apiHost, repo, runner })
|
|
284
|
+
: createGitLabForge({ apiHost: derived.apiHost, repo, runner });
|
|
285
|
+
|
|
286
|
+
export function resolveForge(
|
|
287
|
+
cwd: string,
|
|
288
|
+
options: ResolveOptions = {},
|
|
289
|
+
): ForgeResult<ResolvedForge> {
|
|
290
|
+
let workspace: ReturnType<typeof resolveRuntimeWorkspaceVcs>;
|
|
291
|
+
try {
|
|
292
|
+
workspace = resolveRuntimeWorkspaceVcs(cwd);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
return failure(
|
|
295
|
+
"blocked",
|
|
296
|
+
error instanceof Error ? error.message : String(error),
|
|
297
|
+
`fix ${configFilePath("workspaces.json")} (workit init → Workspaces)`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
const hosts = configuredHosts();
|
|
301
|
+
const pushed = pushForge(cwd, { branch: options.branch, hosts, sshConfig: options.sshConfig });
|
|
302
|
+
if (!pushed.ok) return failure(pushed.code, pushed.error, pushed.unblock);
|
|
303
|
+
const conflict = forgeConflict(pushed.forge, workspace?.vcs?.provider);
|
|
304
|
+
if (conflict) return failure("blocked", conflict.error, conflict.unblock);
|
|
305
|
+
const derived = pushed.forge;
|
|
306
|
+
const { kind, apiHost } = derived;
|
|
307
|
+
const base = options.runner ?? systemRunner(kind, apiHost, options.env ?? process.env);
|
|
308
|
+
const scope = memoScope(options.runner, options.env ?? process.env);
|
|
309
|
+
const now = options.now ?? Date.now;
|
|
310
|
+
const expectedAccount = workspace?.vcs?.account ?? null;
|
|
311
|
+
// The deadline covers resolution too, so a verb's --timeout bounds it all.
|
|
312
|
+
const limits: { deadline: number | null } = { deadline: options.deadline ?? null };
|
|
313
|
+
const credential = resolveCredential(workspace, derived, clampedRunner(base, limits, now), scope);
|
|
314
|
+
if (!credential.ok) return credential;
|
|
315
|
+
const runner = clampedRunner(base, limits, now, credential.data.token);
|
|
316
|
+
|
|
317
|
+
// Base repository: an `upstream` remote on the same forge, else the fork parent.
|
|
318
|
+
const headRepo = derived.repo;
|
|
319
|
+
const probe = memoizedIdentity(createForge(derived, headRepo, runner), scope, credential.data);
|
|
320
|
+
const info = probe.repoInfo(headRepo);
|
|
321
|
+
if (!info.ok && info.code !== "not_found") return info;
|
|
322
|
+
const headProjectId = info.ok ? info.data.id : null;
|
|
323
|
+
const remotes = remoteNames(cwd);
|
|
324
|
+
const repoOfRemote = (name: string): string | null => {
|
|
325
|
+
const url = pushUrl(cwd, name);
|
|
326
|
+
const other = url ? deriveForge(url, { hosts, sshConfig: options.sshConfig }) : null;
|
|
327
|
+
return other && other.kind === kind && other.apiHost === apiHost ? other.repo : null;
|
|
328
|
+
};
|
|
329
|
+
const upstream =
|
|
330
|
+
pushed.remote !== "upstream" && remotes.includes("upstream") ? repoOfRemote("upstream") : null;
|
|
331
|
+
const baseRepo =
|
|
332
|
+
upstream && upstream !== headRepo
|
|
333
|
+
? upstream
|
|
334
|
+
: info.ok && info.data.parent
|
|
335
|
+
? info.data.parent
|
|
336
|
+
: headRepo;
|
|
337
|
+
const baseRemote =
|
|
338
|
+
baseRepo === headRepo
|
|
339
|
+
? pushed.remote
|
|
340
|
+
: (remotes.find((name) => repoOfRemote(name) === baseRepo) ?? pushed.remote);
|
|
341
|
+
|
|
342
|
+
return success({
|
|
343
|
+
forge:
|
|
344
|
+
baseRepo === headRepo
|
|
345
|
+
? probe
|
|
346
|
+
: memoizedIdentity(createForge(derived, baseRepo, runner), scope, credential.data),
|
|
347
|
+
remote: pushed.remote,
|
|
348
|
+
url: pushed.url,
|
|
349
|
+
derived,
|
|
350
|
+
workspace: workspace?.name ?? null,
|
|
351
|
+
expectedAccount,
|
|
352
|
+
credential: credential.data.credential,
|
|
353
|
+
headRepo,
|
|
354
|
+
headProjectId,
|
|
355
|
+
fork: baseRepo !== headRepo,
|
|
356
|
+
baseRemote,
|
|
357
|
+
limits,
|
|
358
|
+
runner,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The credential in use must belong to the workspace account. A mismatch is
|
|
364
|
+
* `blocked` with the fix (never a global account switch); no configured
|
|
365
|
+
* account passes; a credential that cannot read /user passes with a note.
|
|
366
|
+
*/
|
|
367
|
+
export function checkIdentity(resolved: ResolvedForge): ForgeResult<Identity> {
|
|
368
|
+
const { forge, expectedAccount } = resolved;
|
|
369
|
+
const identity = forge.identity(expectedAccount);
|
|
370
|
+
if (!identity.ok) return identity;
|
|
371
|
+
if (identity.data.matches === false)
|
|
372
|
+
return mismatch(resolved, identity.data.login ?? "?", resolved.workspace);
|
|
373
|
+
return identity;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const mismatch = <T>(
|
|
377
|
+
resolved: Pick<ResolvedForge, "forge" | "credential" | "expectedAccount">,
|
|
378
|
+
login: string,
|
|
379
|
+
workspace: string | null,
|
|
380
|
+
): ForgeResult<T> => {
|
|
381
|
+
const { forge, expectedAccount } = resolved;
|
|
382
|
+
const bin = forge.kind === "github" ? "gh" : "glab";
|
|
383
|
+
const where = `workspace ${workspace ?? "?"}`;
|
|
384
|
+
const fileFix = `put a token for ${expectedAccount} in the ${where} vcs.tokenFile`;
|
|
385
|
+
return failure(
|
|
386
|
+
"blocked",
|
|
387
|
+
`identity_mismatch: the ${resolved.credential === "workspace_token_file" ? "vcs.tokenFile token" : `${bin} login`} is ${login} but ${where} expects ${expectedAccount}`,
|
|
388
|
+
resolved.credential === "workspace_token_file"
|
|
389
|
+
? fileFix
|
|
390
|
+
: forge.kind === "github"
|
|
391
|
+
? `gh auth login --hostname ${forge.apiHost} # sign in as ${expectedAccount}`
|
|
392
|
+
: // glab keeps one login per host; switching it would change every repo.
|
|
393
|
+
`${fileFix} (glab keeps one login per host, so workit never switches it)`,
|
|
394
|
+
);
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
/** The forge could not answer at all, as opposed to answering against the account. */
|
|
398
|
+
const unreachable = (result: { code: string; error: string }): boolean =>
|
|
399
|
+
result.code === "unavailable" && !/ is not authenticated for /u.test(result.error);
|
|
400
|
+
|
|
401
|
+
/** Total budget for the identity check before a push (B1). */
|
|
402
|
+
export const PUSH_IDENTITY_BUDGET_MS = 5_000;
|
|
403
|
+
|
|
404
|
+
export type PushIdentity = {
|
|
405
|
+
/**
|
|
406
|
+
* verified: the forge says the credential is the workspace account.
|
|
407
|
+
* no_account: the workspace names no vcs.account, so there is nothing to check.
|
|
408
|
+
* skipped: the check could not run (forge down, slow, unknown, misconfigured);
|
|
409
|
+
* git pushes anyway and `reason` says why.
|
|
410
|
+
*/
|
|
411
|
+
status: "verified" | "no_account" | "skipped";
|
|
412
|
+
forge: "github" | "gitlab" | null;
|
|
413
|
+
account: string | null;
|
|
414
|
+
login: string | null;
|
|
415
|
+
reason?: string;
|
|
416
|
+
unblock?: string;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The best-effort identity check before `git push`: git decides the transport,
|
|
421
|
+
* so a forge that cannot answer (missing, slow, offline, unknown, another
|
|
422
|
+
* provider) pushes with `status: "skipped"` and the reason (S10, B1). Positive
|
|
423
|
+
* evidence still blocks: another account, no gh login for the workspace
|
|
424
|
+
* account, a broken token file or a rejected credential. All calls share one
|
|
425
|
+
* `budgetMs`; explicit credentials are memoized.
|
|
426
|
+
*/
|
|
427
|
+
export function checkPushIdentity(
|
|
428
|
+
cwd: string,
|
|
429
|
+
options: Omit<ResolveOptions, "deadline"> & { budgetMs?: number } = {},
|
|
430
|
+
): ForgeResult<PushIdentity> {
|
|
431
|
+
let workspace: ReturnType<typeof resolveRuntimeWorkspaceVcs>;
|
|
432
|
+
try {
|
|
433
|
+
workspace = resolveRuntimeWorkspaceVcs(cwd);
|
|
434
|
+
} catch (error) {
|
|
435
|
+
return failure(
|
|
436
|
+
"blocked",
|
|
437
|
+
error instanceof Error ? error.message : String(error),
|
|
438
|
+
`fix ${configFilePath("workspaces.json")} (workit init → Workspaces)`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
const account = workspace?.vcs?.account ?? null;
|
|
442
|
+
const skipped = (forge: PushIdentity["forge"], reason: string, unblock?: string) =>
|
|
443
|
+
success<PushIdentity>({
|
|
444
|
+
status: "skipped",
|
|
445
|
+
forge,
|
|
446
|
+
account,
|
|
447
|
+
login: null,
|
|
448
|
+
reason,
|
|
449
|
+
...(unblock ? { unblock } : {}),
|
|
450
|
+
});
|
|
451
|
+
const pushed = pushForge(cwd, {
|
|
452
|
+
branch: options.branch,
|
|
453
|
+
hosts: configuredHosts(),
|
|
454
|
+
sshConfig: options.sshConfig,
|
|
455
|
+
});
|
|
456
|
+
if (!pushed.ok) {
|
|
457
|
+
const host = /^unsupported_forge: .*?"([^"]+)"/u.exec(pushed.error)?.[1];
|
|
458
|
+
const what = /could not resolve ssh alias/u.test(pushed.error)
|
|
459
|
+
? `could not resolve ssh alias ${host} to GitHub or GitLab`
|
|
460
|
+
: pushed.error.startsWith("unsupported_forge: ssh host")
|
|
461
|
+
? `ssh host ${host} is not a GitHub/GitLab host or an alias workit can resolve`
|
|
462
|
+
: `${host} is not a GitHub or GitLab host workit knows`;
|
|
463
|
+
return skipped(
|
|
464
|
+
null,
|
|
465
|
+
host
|
|
466
|
+
? `unsupported_forge: ${what}; account checks skipped, pushed with git only (no PR or CI verbs)`
|
|
467
|
+
: pushed.error,
|
|
468
|
+
pushed.unblock,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
const derived = pushed.forge;
|
|
472
|
+
const conflict = forgeConflict(derived, workspace?.vcs?.provider);
|
|
473
|
+
if (conflict) return skipped(derived.kind, conflict.error, conflict.unblock);
|
|
474
|
+
if (!account) return success({ status: "no_account", forge: derived.kind, account, login: null });
|
|
475
|
+
|
|
476
|
+
const base =
|
|
477
|
+
options.runner ?? systemRunner(derived.kind, derived.apiHost, options.env ?? process.env);
|
|
478
|
+
const scope = memoScope(options.runner, options.env ?? process.env);
|
|
479
|
+
const now = options.now ?? Date.now;
|
|
480
|
+
const limits = { deadline: now() + (options.budgetMs ?? PUSH_IDENTITY_BUDGET_MS) };
|
|
481
|
+
const credential = resolveCredential(workspace, derived, clampedRunner(base, limits, now), scope);
|
|
482
|
+
// A forge that cannot answer (missing CLI, timeout, transport) is skipped;
|
|
483
|
+
// local evidence against the account (no gh login for it, a broken
|
|
484
|
+
// vcs.tokenFile, a rejected credential) still blocks: over HTTPS git would
|
|
485
|
+
// push with whatever account gh's credential helper has active.
|
|
486
|
+
if (!credential.ok)
|
|
487
|
+
return unreachable(credential)
|
|
488
|
+
? skipped(derived.kind, credential.error, credential.unblock)
|
|
489
|
+
: credential;
|
|
490
|
+
const forge = memoizedIdentity(
|
|
491
|
+
createForge(derived, derived.repo, clampedRunner(base, limits, now, credential.data.token)),
|
|
492
|
+
scope,
|
|
493
|
+
credential.data,
|
|
494
|
+
);
|
|
495
|
+
const identity = forge.identity(account);
|
|
496
|
+
if (!identity.ok)
|
|
497
|
+
return unreachable(identity)
|
|
498
|
+
? skipped(derived.kind, identity.error, identity.unblock)
|
|
499
|
+
: identity;
|
|
500
|
+
if (identity.data.matches === false)
|
|
501
|
+
return mismatch(
|
|
502
|
+
{ forge, credential: credential.data.credential, expectedAccount: account },
|
|
503
|
+
identity.data.login ?? "?",
|
|
504
|
+
workspace?.name ?? null,
|
|
505
|
+
);
|
|
506
|
+
if (identity.data.matches === null)
|
|
507
|
+
return skipped(derived.kind, identity.data.note ?? "the credential cannot read /user");
|
|
508
|
+
return success({ status: "verified", forge: derived.kind, account, login: identity.data.login });
|
|
509
|
+
}
|