@brainervirus/workit-core 8.0.1 → 8.1.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/package.json +1 -1
- package/src/core/branch-policy.ts +37 -10
- package/src/core/branch.ts +2 -0
- package/src/core/config.ts +48 -29
- package/src/core/workspaces.ts +89 -39
- package/src/forge/exec.ts +72 -3
- package/src/forge/report.ts +4 -3
- package/src/forge/resolve.ts +328 -78
- package/src/git/ops.ts +363 -34
- package/src/git/rev.ts +10 -5
package/src/forge/resolve.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// §2.0 Identity).
|
|
3
3
|
//
|
|
4
4
|
// - The push remote host decides GitHub vs GitLab. A workspace provider that
|
|
5
|
-
// disagrees is `blocked
|
|
5
|
+
// disagrees is `blocked` for the forge verbs, never silently overridden;
|
|
6
|
+
// `git push` (checkPushIdentity) only needs git, so it pushes with a note.
|
|
6
7
|
// - PRs/MRs live in the base repository: an `upstream` remote on the same
|
|
7
8
|
// forge, else the push repo's fork parent, else the push repo itself.
|
|
8
9
|
// - Every API call carries the workspace's credential explicitly, so the
|
|
@@ -23,9 +24,10 @@ import {
|
|
|
23
24
|
type ForgeHosts,
|
|
24
25
|
type SshConfig,
|
|
25
26
|
} from "../git/rev";
|
|
26
|
-
import { FORGE_TIMEOUTS, systemRunner, type ForgeRunner } from "./exec";
|
|
27
|
+
import { FORGE_TIMEOUTS, isNetworkError, systemRunner, type ForgeRunner } from "./exec";
|
|
27
28
|
import { createGitHubForge } from "./github";
|
|
28
29
|
import { createGitLabForge } from "./gitlab";
|
|
30
|
+
import { redactText } from "./redact";
|
|
29
31
|
import { failure, success, type Forge, type ForgeResult, type Identity } from "./types";
|
|
30
32
|
|
|
31
33
|
export type CredentialSource = "workspace_token_file" | "gh_account_token" | "cli_login";
|
|
@@ -62,51 +64,103 @@ export type ResolveOptions = {
|
|
|
62
64
|
now?: () => number;
|
|
63
65
|
/** ssh_config for Host aliases (default: the user's ~/.ssh/config). */
|
|
64
66
|
sshConfig?: string | SshConfig;
|
|
67
|
+
/** Absolute deadline (`now()` clock) for every call, resolution included; null = none. */
|
|
68
|
+
deadline?: number | null;
|
|
65
69
|
};
|
|
66
70
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
// Per-process memo of credential lookups and /user answers (a long-lived MCP
|
|
72
|
+
// server re-asks after the TTL). Scoped by runner, so an injected test runner
|
|
73
|
+
// never sees another's answers, and keyed by the gh/glab config location, so
|
|
74
|
+
// another GH_CONFIG_DIR never reuses them; only successes are kept.
|
|
75
|
+
const MEMO_TTL_MS = 5 * 60_000;
|
|
76
|
+
const SYSTEM_SCOPE = {};
|
|
77
|
+
const memoScopes = new WeakMap<object, Map<string, { at: number; value: unknown }>>();
|
|
78
|
+
|
|
79
|
+
type MemoScope = { owner: object; key: string };
|
|
80
|
+
|
|
81
|
+
const memoScope = (runner: ForgeRunner | undefined, env: NodeJS.ProcessEnv): MemoScope => ({
|
|
82
|
+
owner: runner ?? SYSTEM_SCOPE,
|
|
83
|
+
key: [env.GH_CONFIG_DIR, env.GLAB_CONFIG_DIR, env.XDG_CONFIG_HOME, env.HOME]
|
|
84
|
+
.map((value) => value ?? "")
|
|
85
|
+
.join("|"),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
function memo<T>(
|
|
89
|
+
scope: MemoScope,
|
|
90
|
+
key: string,
|
|
91
|
+
compute: () => { keep: boolean; value: T },
|
|
92
|
+
now = Date.now,
|
|
93
|
+
): T {
|
|
94
|
+
let entries = memoScopes.get(scope.owner);
|
|
95
|
+
if (!entries) memoScopes.set(scope.owner, (entries = new Map()));
|
|
96
|
+
const full = `${scope.key} ${key}`;
|
|
97
|
+
const hit = entries.get(full);
|
|
98
|
+
if (hit && now() - hit.at < MEMO_TTL_MS) return hit.value as T;
|
|
99
|
+
const { keep, value } = compute();
|
|
100
|
+
if (keep) entries.set(full, { at: now(), value });
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A runner whose calls are clamped to `limits.deadline`, carrying `token`. */
|
|
105
|
+
function clampedRunner(
|
|
106
|
+
base: ForgeRunner,
|
|
107
|
+
limits: { deadline: number | null },
|
|
108
|
+
now: () => number,
|
|
109
|
+
token?: string,
|
|
110
|
+
): ForgeRunner {
|
|
111
|
+
return (bin, args, callOptions) => {
|
|
112
|
+
let timeoutMs = callOptions.timeoutMs;
|
|
113
|
+
if (limits.deadline !== null) {
|
|
114
|
+
const remaining = limits.deadline - now();
|
|
115
|
+
if (remaining <= 0)
|
|
116
|
+
return {
|
|
117
|
+
status: null,
|
|
118
|
+
stdout: "",
|
|
119
|
+
stderr: "",
|
|
120
|
+
timedOut: true,
|
|
121
|
+
missing: false,
|
|
122
|
+
timeoutMs: 0,
|
|
123
|
+
};
|
|
124
|
+
timeoutMs = Math.min(timeoutMs, remaining);
|
|
125
|
+
}
|
|
126
|
+
const run = base(bin, args, { ...callOptions, timeoutMs, ...(token ? { token } : {}) });
|
|
127
|
+
return run.timedOut && run.timeoutMs === undefined ? { ...run, timeoutMs } : run;
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* `forge.identity` answered once per explicit credential per TTL (push, then
|
|
133
|
+
* pr create, …). The CLI's own login (`cli_login`) is never memoized: a
|
|
134
|
+
* `glab auth login` / `gh auth switch` must show on the next call.
|
|
135
|
+
*/
|
|
136
|
+
const memoizedIdentity = (forge: Forge, scope: MemoScope, credential: Credential): Forge => {
|
|
137
|
+
if (credential.credential === "cli_login") return forge;
|
|
138
|
+
const identity = (expected: string | null) => forge.identity(expected);
|
|
139
|
+
return {
|
|
140
|
+
...forge,
|
|
141
|
+
identity: (expected: string | null) =>
|
|
142
|
+
memo(
|
|
143
|
+
scope,
|
|
144
|
+
`identity ${forge.kind} ${forge.apiHost} ${credential.credential} ${credential.token ?? ""} ${expected ?? ""}`,
|
|
145
|
+
() => {
|
|
146
|
+
const value = identity(expected);
|
|
147
|
+
return { keep: value.ok, value };
|
|
148
|
+
},
|
|
149
|
+
),
|
|
73
150
|
};
|
|
74
|
-
return { github: host("github"), gitlab: host("gitlab") };
|
|
75
151
|
};
|
|
76
152
|
|
|
77
|
-
|
|
78
|
-
derived.kind === "github"
|
|
79
|
-
? createGitHubForge({ apiHost: derived.apiHost, repo, runner })
|
|
80
|
-
: createGitLabForge({ apiHost: derived.apiHost, repo, runner });
|
|
153
|
+
type Credential = { token: string | undefined; credential: CredentialSource };
|
|
81
154
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
} catch (error) {
|
|
90
|
-
return failure(
|
|
91
|
-
"blocked",
|
|
92
|
-
error instanceof Error ? error.message : String(error),
|
|
93
|
-
"fix ~/.config/workit/workspaces.json (workit init → Workspaces)",
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
const hosts = configuredHosts();
|
|
97
|
-
const pushed = pushForge(cwd, { branch: options.branch, hosts, sshConfig: options.sshConfig });
|
|
98
|
-
if (!pushed.ok) return failure(pushed.code, pushed.error, pushed.unblock);
|
|
99
|
-
const conflict = forgeConflict(pushed.forge, workspace?.vcs?.provider);
|
|
100
|
-
if (conflict) return failure("blocked", conflict.error, conflict.unblock);
|
|
101
|
-
const derived = pushed.forge;
|
|
155
|
+
/** The credential every API call carries (see the header). */
|
|
156
|
+
function resolveCredential(
|
|
157
|
+
workspace: ReturnType<typeof resolveRuntimeWorkspaceVcs>,
|
|
158
|
+
derived: DerivedForge,
|
|
159
|
+
run: ForgeRunner,
|
|
160
|
+
scope: MemoScope,
|
|
161
|
+
): ForgeResult<Credential> {
|
|
102
162
|
const { kind, apiHost } = derived;
|
|
103
|
-
const base = options.runner ?? systemRunner(kind, apiHost, options.env ?? process.env);
|
|
104
|
-
const now = options.now ?? Date.now;
|
|
105
163
|
const expectedAccount = workspace?.vcs?.account ?? null;
|
|
106
|
-
|
|
107
|
-
// The credential for every call.
|
|
108
|
-
let token: string | undefined;
|
|
109
|
-
let credential: CredentialSource = "cli_login";
|
|
110
164
|
const tokenFile = (workspace?.vcs as { tokenFile?: unknown } | undefined)?.tokenFile;
|
|
111
165
|
if (typeof tokenFile === "string" && tokenFile.trim()) {
|
|
112
166
|
// A stored token goes only to the host it is for: the public forge (also
|
|
@@ -127,6 +181,7 @@ export function resolveForge(
|
|
|
127
181
|
`token_host_unverified: push host ${derived.host} only looks like ${kind}; workspace ${workspace?.name ?? "?"} vcs.tokenFile is not sent to it`,
|
|
128
182
|
`if ${apiHost} is your ${kind} server, set ${kind}.host in ~/.config/workit/vcs.json (or vcs.host for the workspace); otherwise fix the push remote`,
|
|
129
183
|
);
|
|
184
|
+
let token = "";
|
|
130
185
|
try {
|
|
131
186
|
token = fs.readFileSync(tokenFile.trim(), "utf8").trim();
|
|
132
187
|
} catch {
|
|
@@ -142,42 +197,108 @@ export function resolveForge(
|
|
|
142
197
|
`workspace ${workspace?.name ?? "?"} vcs.tokenFile is empty`,
|
|
143
198
|
"fix vcs.tokenFile in ~/.config/workit/workspaces.json",
|
|
144
199
|
);
|
|
145
|
-
credential
|
|
146
|
-
}
|
|
147
|
-
|
|
200
|
+
return success({ token, credential: "workspace_token_file" });
|
|
201
|
+
}
|
|
202
|
+
// glab keeps one login per host, so a GitLab account is checked, not selected.
|
|
203
|
+
if (!expectedAccount || kind !== "github")
|
|
204
|
+
return success({ token: undefined, credential: "cli_login" });
|
|
205
|
+
return memo(scope, `token ${apiHost} ${expectedAccount}`, () => {
|
|
206
|
+
const issued = run("gh", ["auth", "token", "--hostname", apiHost, "--user", expectedAccount], {
|
|
148
207
|
timeoutMs: FORGE_TIMEOUTS.api,
|
|
149
208
|
});
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
"install the GitHub CLI (https://cli.github.com) and run: gh auth login",
|
|
155
|
-
);
|
|
156
|
-
token = issued.status === 0 ? issued.stdout.trim() : "";
|
|
157
|
-
if (!token)
|
|
158
|
-
return failure(
|
|
159
|
-
"blocked",
|
|
160
|
-
`identity_unavailable: gh has no login for ${expectedAccount} on ${apiHost} (workspace ${workspace?.name ?? "?"})`,
|
|
161
|
-
`gh auth login --hostname ${apiHost} # sign in as ${expectedAccount}; workit passes that account's token per call and never switches the active account`,
|
|
162
|
-
);
|
|
163
|
-
credential = "gh_account_token";
|
|
164
|
-
}
|
|
209
|
+
const value = authTokenResult(issued, apiHost, expectedAccount, workspace?.name ?? "?");
|
|
210
|
+
return { keep: value.ok, value };
|
|
211
|
+
});
|
|
212
|
+
}
|
|
165
213
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
214
|
+
/**
|
|
215
|
+
* `gh auth token --user`: a timeout or a transport error is `unavailable`
|
|
216
|
+
* (exit 5, retry); only gh saying it has no such login is identity_unavailable.
|
|
217
|
+
*/
|
|
218
|
+
function authTokenResult(
|
|
219
|
+
issued: ReturnType<ForgeRunner>,
|
|
220
|
+
apiHost: string,
|
|
221
|
+
account: string,
|
|
222
|
+
workspace: string,
|
|
223
|
+
): ForgeResult<Credential> {
|
|
224
|
+
if (issued.missing)
|
|
225
|
+
return failure(
|
|
226
|
+
"unavailable",
|
|
227
|
+
"gh is not installed",
|
|
228
|
+
"install the GitHub CLI (https://cli.github.com) and run: gh auth login",
|
|
229
|
+
);
|
|
230
|
+
if (issued.timedOut)
|
|
231
|
+
return failure(
|
|
232
|
+
"unavailable",
|
|
233
|
+
`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})`,
|
|
234
|
+
"gh auth status # check gh and the network, then retry",
|
|
235
|
+
);
|
|
236
|
+
const token = issued.status === 0 ? issued.stdout.trim() : "";
|
|
237
|
+
if (token) return success({ token, credential: "gh_account_token" });
|
|
238
|
+
const stderr = redactText(issued.stderr).trim();
|
|
239
|
+
const first = stderr.split(/\r?\n/u).find((line) => line.trim()) ?? "";
|
|
240
|
+
if (issued.status !== 0 && first && !/no oauth token|not logged in|no account/iu.test(stderr))
|
|
241
|
+
return failure(
|
|
242
|
+
"unavailable",
|
|
243
|
+
`gh auth token failed${isNetworkError(stderr) ? " (network)" : ""}: ${first.slice(0, 200)}`,
|
|
244
|
+
"gh auth status # check gh and the network, then retry",
|
|
245
|
+
);
|
|
246
|
+
return failure(
|
|
247
|
+
"blocked",
|
|
248
|
+
`identity_unavailable: gh has no login for ${account} on ${apiHost} (workspace ${workspace})`,
|
|
249
|
+
`gh auth login --hostname ${apiHost} # sign in as ${account}; workit passes that account's token per call and never switches the active account`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const configuredHosts = (): ForgeHosts => {
|
|
254
|
+
const vcs = readVcsConfig();
|
|
255
|
+
if (vcs.status !== "valid") return {};
|
|
256
|
+
const host = (kind: "github" | "gitlab"): string[] => {
|
|
257
|
+
const value = (vcs.config[kind] as { host?: unknown } | undefined)?.host;
|
|
258
|
+
return typeof value === "string" && value.trim() ? [value.trim()] : [];
|
|
176
259
|
};
|
|
260
|
+
return { github: host("github"), gitlab: host("gitlab") };
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const createForge = (derived: DerivedForge, repo: string, runner: ForgeRunner): Forge =>
|
|
264
|
+
derived.kind === "github"
|
|
265
|
+
? createGitHubForge({ apiHost: derived.apiHost, repo, runner })
|
|
266
|
+
: createGitLabForge({ apiHost: derived.apiHost, repo, runner });
|
|
267
|
+
|
|
268
|
+
export function resolveForge(
|
|
269
|
+
cwd: string,
|
|
270
|
+
options: ResolveOptions = {},
|
|
271
|
+
): ForgeResult<ResolvedForge> {
|
|
272
|
+
let workspace: ReturnType<typeof resolveRuntimeWorkspaceVcs>;
|
|
273
|
+
try {
|
|
274
|
+
workspace = resolveRuntimeWorkspaceVcs(cwd);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
return failure(
|
|
277
|
+
"blocked",
|
|
278
|
+
error instanceof Error ? error.message : String(error),
|
|
279
|
+
"fix ~/.config/workit/workspaces.json (workit init → Workspaces)",
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
const hosts = configuredHosts();
|
|
283
|
+
const pushed = pushForge(cwd, { branch: options.branch, hosts, sshConfig: options.sshConfig });
|
|
284
|
+
if (!pushed.ok) return failure(pushed.code, pushed.error, pushed.unblock);
|
|
285
|
+
const conflict = forgeConflict(pushed.forge, workspace?.vcs?.provider);
|
|
286
|
+
if (conflict) return failure("blocked", conflict.error, conflict.unblock);
|
|
287
|
+
const derived = pushed.forge;
|
|
288
|
+
const { kind, apiHost } = derived;
|
|
289
|
+
const base = options.runner ?? systemRunner(kind, apiHost, options.env ?? process.env);
|
|
290
|
+
const scope = memoScope(options.runner, options.env ?? process.env);
|
|
291
|
+
const now = options.now ?? Date.now;
|
|
292
|
+
const expectedAccount = workspace?.vcs?.account ?? null;
|
|
293
|
+
// The deadline covers resolution too, so a verb's --timeout bounds it all.
|
|
294
|
+
const limits: { deadline: number | null } = { deadline: options.deadline ?? null };
|
|
295
|
+
const credential = resolveCredential(workspace, derived, clampedRunner(base, limits, now), scope);
|
|
296
|
+
if (!credential.ok) return credential;
|
|
297
|
+
const runner = clampedRunner(base, limits, now, credential.data.token);
|
|
177
298
|
|
|
178
299
|
// Base repository: an `upstream` remote on the same forge, else the fork parent.
|
|
179
300
|
const headRepo = derived.repo;
|
|
180
|
-
const probe = createForge(derived, headRepo, runner);
|
|
301
|
+
const probe = memoizedIdentity(createForge(derived, headRepo, runner), scope, credential.data);
|
|
181
302
|
const info = probe.repoInfo(headRepo);
|
|
182
303
|
if (!info.ok && info.code !== "not_found") return info;
|
|
183
304
|
const headProjectId = info.ok ? info.data.id : null;
|
|
@@ -201,13 +322,16 @@ export function resolveForge(
|
|
|
201
322
|
: (remotes.find((name) => repoOfRemote(name) === baseRepo) ?? pushed.remote);
|
|
202
323
|
|
|
203
324
|
return success({
|
|
204
|
-
forge:
|
|
325
|
+
forge:
|
|
326
|
+
baseRepo === headRepo
|
|
327
|
+
? probe
|
|
328
|
+
: memoizedIdentity(createForge(derived, baseRepo, runner), scope, credential.data),
|
|
205
329
|
remote: pushed.remote,
|
|
206
330
|
url: pushed.url,
|
|
207
331
|
derived,
|
|
208
332
|
workspace: workspace?.name ?? null,
|
|
209
333
|
expectedAccount,
|
|
210
|
-
credential,
|
|
334
|
+
credential: credential.data.credential,
|
|
211
335
|
headRepo,
|
|
212
336
|
headProjectId,
|
|
213
337
|
fork: baseRepo !== headRepo,
|
|
@@ -225,16 +349,142 @@ export function checkIdentity(resolved: ResolvedForge): ForgeResult<Identity> {
|
|
|
225
349
|
const { forge, expectedAccount } = resolved;
|
|
226
350
|
const identity = forge.identity(expectedAccount);
|
|
227
351
|
if (!identity.ok) return identity;
|
|
228
|
-
if (identity.data.matches === false)
|
|
229
|
-
|
|
230
|
-
|
|
352
|
+
if (identity.data.matches === false)
|
|
353
|
+
return mismatch(resolved, identity.data.login ?? "?", resolved.workspace);
|
|
354
|
+
return identity;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const mismatch = <T>(
|
|
358
|
+
resolved: Pick<ResolvedForge, "forge" | "credential" | "expectedAccount">,
|
|
359
|
+
login: string,
|
|
360
|
+
workspace: string | null,
|
|
361
|
+
): ForgeResult<T> => {
|
|
362
|
+
const { forge, expectedAccount } = resolved;
|
|
363
|
+
const bin = forge.kind === "github" ? "gh" : "glab";
|
|
364
|
+
const where = `workspace ${workspace ?? "?"}`;
|
|
365
|
+
const fileFix = `put a token for ${expectedAccount} in the ${where} vcs.tokenFile`;
|
|
366
|
+
return failure(
|
|
367
|
+
"blocked",
|
|
368
|
+
`identity_mismatch: the ${resolved.credential === "workspace_token_file" ? "vcs.tokenFile token" : `${bin} login`} is ${login} but ${where} expects ${expectedAccount}`,
|
|
369
|
+
resolved.credential === "workspace_token_file"
|
|
370
|
+
? fileFix
|
|
371
|
+
: forge.kind === "github"
|
|
372
|
+
? `gh auth login --hostname ${forge.apiHost} # sign in as ${expectedAccount}`
|
|
373
|
+
: // glab keeps one login per host; switching it would change every repo.
|
|
374
|
+
`${fileFix} (glab keeps one login per host, so workit never switches it)`,
|
|
375
|
+
);
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
/** The forge could not answer at all, as opposed to answering against the account. */
|
|
379
|
+
const unreachable = (result: { code: string; error: string }): boolean =>
|
|
380
|
+
result.code === "unavailable" && !/ is not authenticated for /u.test(result.error);
|
|
381
|
+
|
|
382
|
+
/** Total budget for the identity check before a push (B1). */
|
|
383
|
+
export const PUSH_IDENTITY_BUDGET_MS = 5_000;
|
|
384
|
+
|
|
385
|
+
export type PushIdentity = {
|
|
386
|
+
/**
|
|
387
|
+
* verified: the forge says the credential is the workspace account.
|
|
388
|
+
* no_account: the workspace names no vcs.account, so there is nothing to check.
|
|
389
|
+
* skipped: the check could not run (forge down, slow, unknown, misconfigured);
|
|
390
|
+
* git pushes anyway and `reason` says why.
|
|
391
|
+
*/
|
|
392
|
+
status: "verified" | "no_account" | "skipped";
|
|
393
|
+
forge: "github" | "gitlab" | null;
|
|
394
|
+
account: string | null;
|
|
395
|
+
login: string | null;
|
|
396
|
+
reason?: string;
|
|
397
|
+
unblock?: string;
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The best-effort identity check before `git push`: git decides the transport,
|
|
402
|
+
* so a forge that cannot answer (missing, slow, offline, unknown, another
|
|
403
|
+
* provider) pushes with `status: "skipped"` and the reason (S10, B1). Positive
|
|
404
|
+
* evidence still blocks: another account, no gh login for the workspace
|
|
405
|
+
* account, a broken token file or a rejected credential. All calls share one
|
|
406
|
+
* `budgetMs`; explicit credentials are memoized.
|
|
407
|
+
*/
|
|
408
|
+
export function checkPushIdentity(
|
|
409
|
+
cwd: string,
|
|
410
|
+
options: Omit<ResolveOptions, "deadline"> & { budgetMs?: number } = {},
|
|
411
|
+
): ForgeResult<PushIdentity> {
|
|
412
|
+
let workspace: ReturnType<typeof resolveRuntimeWorkspaceVcs>;
|
|
413
|
+
try {
|
|
414
|
+
workspace = resolveRuntimeWorkspaceVcs(cwd);
|
|
415
|
+
} catch (error) {
|
|
231
416
|
return failure(
|
|
232
417
|
"blocked",
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
? `put a token for ${expectedAccount} in the ${where} vcs.tokenFile`
|
|
236
|
-
: `${bin} auth login --hostname ${forge.apiHost} # sign in as ${expectedAccount}`,
|
|
418
|
+
error instanceof Error ? error.message : String(error),
|
|
419
|
+
"fix ~/.config/workit/workspaces.json (workit init → Workspaces)",
|
|
237
420
|
);
|
|
238
421
|
}
|
|
239
|
-
|
|
422
|
+
const account = workspace?.vcs?.account ?? null;
|
|
423
|
+
const skipped = (forge: PushIdentity["forge"], reason: string, unblock?: string) =>
|
|
424
|
+
success<PushIdentity>({
|
|
425
|
+
status: "skipped",
|
|
426
|
+
forge,
|
|
427
|
+
account,
|
|
428
|
+
login: null,
|
|
429
|
+
reason,
|
|
430
|
+
...(unblock ? { unblock } : {}),
|
|
431
|
+
});
|
|
432
|
+
const pushed = pushForge(cwd, {
|
|
433
|
+
branch: options.branch,
|
|
434
|
+
hosts: configuredHosts(),
|
|
435
|
+
sshConfig: options.sshConfig,
|
|
436
|
+
});
|
|
437
|
+
if (!pushed.ok) {
|
|
438
|
+
const host = /^unsupported_forge: .*?"([^"]+)"/u.exec(pushed.error)?.[1];
|
|
439
|
+
const what = /could not resolve ssh alias/u.test(pushed.error)
|
|
440
|
+
? `could not resolve ssh alias ${host} to GitHub or GitLab`
|
|
441
|
+
: pushed.error.startsWith("unsupported_forge: ssh host")
|
|
442
|
+
? `ssh host ${host} is not a GitHub/GitLab host or an alias workit can resolve`
|
|
443
|
+
: `${host} is not a GitHub or GitLab host workit knows`;
|
|
444
|
+
return skipped(
|
|
445
|
+
null,
|
|
446
|
+
host
|
|
447
|
+
? `unsupported_forge: ${what}; account checks skipped, pushed with git only (no PR or CI verbs)`
|
|
448
|
+
: pushed.error,
|
|
449
|
+
pushed.unblock,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
const derived = pushed.forge;
|
|
453
|
+
const conflict = forgeConflict(derived, workspace?.vcs?.provider);
|
|
454
|
+
if (conflict) return skipped(derived.kind, conflict.error, conflict.unblock);
|
|
455
|
+
if (!account) return success({ status: "no_account", forge: derived.kind, account, login: null });
|
|
456
|
+
|
|
457
|
+
const base =
|
|
458
|
+
options.runner ?? systemRunner(derived.kind, derived.apiHost, options.env ?? process.env);
|
|
459
|
+
const scope = memoScope(options.runner, options.env ?? process.env);
|
|
460
|
+
const now = options.now ?? Date.now;
|
|
461
|
+
const limits = { deadline: now() + (options.budgetMs ?? PUSH_IDENTITY_BUDGET_MS) };
|
|
462
|
+
const credential = resolveCredential(workspace, derived, clampedRunner(base, limits, now), scope);
|
|
463
|
+
// A forge that cannot answer (missing CLI, timeout, transport) is skipped;
|
|
464
|
+
// local evidence against the account (no gh login for it, a broken
|
|
465
|
+
// vcs.tokenFile, a rejected credential) still blocks: over HTTPS git would
|
|
466
|
+
// push with whatever account gh's credential helper has active.
|
|
467
|
+
if (!credential.ok)
|
|
468
|
+
return unreachable(credential)
|
|
469
|
+
? skipped(derived.kind, credential.error, credential.unblock)
|
|
470
|
+
: credential;
|
|
471
|
+
const forge = memoizedIdentity(
|
|
472
|
+
createForge(derived, derived.repo, clampedRunner(base, limits, now, credential.data.token)),
|
|
473
|
+
scope,
|
|
474
|
+
credential.data,
|
|
475
|
+
);
|
|
476
|
+
const identity = forge.identity(account);
|
|
477
|
+
if (!identity.ok)
|
|
478
|
+
return unreachable(identity)
|
|
479
|
+
? skipped(derived.kind, identity.error, identity.unblock)
|
|
480
|
+
: identity;
|
|
481
|
+
if (identity.data.matches === false)
|
|
482
|
+
return mismatch(
|
|
483
|
+
{ forge, credential: credential.data.credential, expectedAccount: account },
|
|
484
|
+
identity.data.login ?? "?",
|
|
485
|
+
workspace?.name ?? null,
|
|
486
|
+
);
|
|
487
|
+
if (identity.data.matches === null)
|
|
488
|
+
return skipped(derived.kind, identity.data.note ?? "the credential cannot read /user");
|
|
489
|
+
return success({ status: "verified", forge: derived.kind, account, login: identity.data.login });
|
|
240
490
|
}
|