@quonfig/cli 0.0.45 → 0.0.47

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.
@@ -59,6 +59,108 @@ export const callConfigsPush = async (input, orgSlug) => {
59
59
  }
60
60
  throw new Error(`configs.push failed (HTTP ${res.status}): ${message}`);
61
61
  };
62
+ /**
63
+ * Pack-push wire client for `configs.gitPush` (qfg-7429.4). Mirrors
64
+ * `callConfigsPush` shape — same oRPC `{json: ...}` envelope, same
65
+ * status-to-tagged-union mapping. Two differences:
66
+ *
67
+ * - The pack is base64-encoded so the JSON envelope stays text-safe;
68
+ * the server-side handler (`git-push-handler.ts`) decodes it via
69
+ * `Buffer.from(input.pack, 'base64')`.
70
+ * - 403 denials carry `{commitSha, path, reason, requiredPermission}`
71
+ * plus an optional `suggestedRecovery` block for the GitHub-fork
72
+ * dead-end UX (see §6 of the design plan and qfg-7429.5 for the
73
+ * polished renderer).
74
+ */
75
+ export const callConfigsGitPush = async (input, orgSlug) => {
76
+ const accessToken = await getValidAccessTokenForOrgSlug(orgSlug);
77
+ const apiUrl = getApiUrl();
78
+ const wirePayload = {
79
+ workspaceId: input.workspaceId,
80
+ targetRef: input.targetRef,
81
+ expectedSha: input.expectedSha,
82
+ newSha: input.newSha,
83
+ pack: Buffer.from(input.pack).toString('base64'),
84
+ hasUpstreamRemote: input.hasUpstreamRemote,
85
+ };
86
+ const res = await fetch(`${apiUrl}/api/v1/configs/gitPush`, {
87
+ method: 'POST',
88
+ headers: {
89
+ Authorization: `Bearer ${accessToken}`,
90
+ 'Content-Type': 'application/json',
91
+ },
92
+ body: JSON.stringify({ json: wirePayload }),
93
+ });
94
+ if (res.ok) {
95
+ const body = (await res.json());
96
+ const commitSha = body.json?.commitSha;
97
+ const ref = body.json?.ref;
98
+ if (!commitSha || !ref) {
99
+ throw new Error(`configs.gitPush returned 200 but missing commitSha/ref (body=${JSON.stringify(body)})`);
100
+ }
101
+ return { kind: 'success', commitSha, ref };
102
+ }
103
+ const text = await res.text().catch(() => '');
104
+ let parsed;
105
+ try {
106
+ const body = JSON.parse(text);
107
+ parsed = body.json ?? body;
108
+ }
109
+ catch {
110
+ /* keep parsed undefined */
111
+ }
112
+ const message = parsed?.message ?? text ?? `HTTP ${res.status}`;
113
+ if (res.status === 403) {
114
+ const denials = extractGitPushDenials(parsed?.data);
115
+ if (denials.length > 0) {
116
+ const suggestedRecovery = extractSuggestedRecovery(parsed?.data);
117
+ return suggestedRecovery ? { kind: 'denied', denials, suggestedRecovery } : { kind: 'denied', denials };
118
+ }
119
+ }
120
+ if (res.status === 409) {
121
+ return { kind: 'conflict', message };
122
+ }
123
+ if (res.status === 400) {
124
+ return { kind: 'bad-request', message };
125
+ }
126
+ throw new Error(`configs.gitPush failed (HTTP ${res.status}): ${message}`);
127
+ };
128
+ function extractGitPushDenials(data) {
129
+ // Narrowing helper: the gitPush handler's denial shape extends the
130
+ // bare-path denial with `commitSha`. Other fields parsed identically.
131
+ if (!data || typeof data !== 'object')
132
+ return [];
133
+ const denials = data.denials;
134
+ if (!Array.isArray(denials))
135
+ return [];
136
+ const out = [];
137
+ for (const raw of denials) {
138
+ if (!raw || typeof raw !== 'object')
139
+ continue;
140
+ const r = raw;
141
+ if (typeof r.commitSha !== 'string' || typeof r.path !== 'string' || typeof r.requiredPermission !== 'string')
142
+ continue;
143
+ out.push({
144
+ commitSha: r.commitSha,
145
+ path: r.path,
146
+ reason: typeof r.reason === 'string' ? r.reason : `Missing required permission: ${r.requiredPermission}`,
147
+ requiredPermission: r.requiredPermission,
148
+ });
149
+ }
150
+ return out;
151
+ }
152
+ function extractSuggestedRecovery(data) {
153
+ if (!data || typeof data !== 'object')
154
+ return undefined;
155
+ const sr = data.suggestedRecovery;
156
+ if (!sr || typeof sr !== 'object')
157
+ return undefined;
158
+ const r = sr;
159
+ if (r.kind !== 'revert-upstream' || typeof r.offendingCommitSha !== 'string' || typeof r.message !== 'string') {
160
+ return undefined;
161
+ }
162
+ return { kind: 'revert-upstream', offendingCommitSha: r.offendingCommitSha, message: r.message };
163
+ }
62
164
  function extractDenials(data) {
63
165
  if (!data || typeof data !== 'object')
64
166
  return [];
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Pack-push primitives for the CLI clone-path flow (qfg-7429.4, §4.1
3
+ * of project/plans/qfg-git-commit-push-pull-improvements.md).
4
+ *
5
+ * The CLI ships actual git commit objects to app-quonfig instead of a
6
+ * file-delta list. This module owns the two preflight checks and the
7
+ * `git pack-objects` wrapper:
8
+ *
9
+ * - `getCurrentBranch(dir)` — refuse detached HEAD and `master`
10
+ * before any pack work happens. Returns the branch name to use as
11
+ * the basename of `targetRef` on success.
12
+ * - `buildPack(dir, expectedSha, newSha)` — produce the packfile of
13
+ * `<expectedSha>..<newSha>` and return it as raw bytes. Capped at
14
+ * 25 MiB per §7 open Q #7; throws `PackTooLargeError` on overflow.
15
+ *
16
+ * Pure(ish): every git invocation goes through `runGit`/`spawn`. Tests
17
+ * stand up real tmp-dir git repos rather than mocking the shell — the
18
+ * pack format is too fiddly to fake convincingly.
19
+ */
20
+ /** Per §7 open Q #7 of the design plan: pack size cap of 25 MiB. */
21
+ export declare const MAX_PACK_BYTES: number;
22
+ /**
23
+ * Result of `getCurrentBranch`. `branch` carries the name to forward
24
+ * into the server `targetRef`. `detached` and `master` carry the exact
25
+ * user-facing refusal message — callers throw it as-is.
26
+ */
27
+ export type CurrentBranchResult = {
28
+ kind: 'branch';
29
+ name: string;
30
+ } | {
31
+ kind: 'detached';
32
+ message: string;
33
+ } | {
34
+ kind: 'master';
35
+ message: string;
36
+ };
37
+ /**
38
+ * Resolve HEAD via `git symbolic-ref HEAD`. Empty/failed resolution
39
+ * means detached HEAD (per §4.1 step 1). `master` is treated as a
40
+ * structural workspace error rather than a transient one — the
41
+ * workspace template uses `main`, and a `master` checkout is almost
42
+ * always a leftover from cloning a non-Quonfig template.
43
+ */
44
+ export declare function getCurrentBranch(dir: string): Promise<CurrentBranchResult>;
45
+ export declare class PackTooLargeError extends Error {
46
+ bytes: number;
47
+ limit: number;
48
+ constructor(bytes: number, limit: number);
49
+ }
50
+ export interface BuildPackOptions {
51
+ /** Override the size cap. Defaults to MAX_PACK_BYTES. */
52
+ maxBytes?: number;
53
+ }
54
+ /**
55
+ * Produce a packfile covering `<expectedSha>..<newSha>` — i.e. the new
56
+ * commit objects, their trees, and their blobs that are not already
57
+ * reachable from `expectedSha`. Internally:
58
+ *
59
+ * printf '<newSha>\n^<expectedSha>\n' | git pack-objects --revs --stdout
60
+ *
61
+ * which is the canonical "pack everything reachable from newSha but not
62
+ * from expectedSha" recipe. Buffers the entire output to memory so we
63
+ * can enforce the §7 cap before shipping anything to the server.
64
+ *
65
+ * Returns an empty `Uint8Array` when `expectedSha === newSha` — there
66
+ * are no new commits, so `pack-objects` would either error on stdin
67
+ * with no candidate revs or produce an empty pack. Skipping the spawn
68
+ * keeps the no-op caller path simple.
69
+ */
70
+ export declare function buildPack(dir: string, expectedSha: string, newSha: string, options?: BuildPackOptions): Promise<Uint8Array>;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Pack-push primitives for the CLI clone-path flow (qfg-7429.4, §4.1
3
+ * of project/plans/qfg-git-commit-push-pull-improvements.md).
4
+ *
5
+ * The CLI ships actual git commit objects to app-quonfig instead of a
6
+ * file-delta list. This module owns the two preflight checks and the
7
+ * `git pack-objects` wrapper:
8
+ *
9
+ * - `getCurrentBranch(dir)` — refuse detached HEAD and `master`
10
+ * before any pack work happens. Returns the branch name to use as
11
+ * the basename of `targetRef` on success.
12
+ * - `buildPack(dir, expectedSha, newSha)` — produce the packfile of
13
+ * `<expectedSha>..<newSha>` and return it as raw bytes. Capped at
14
+ * 25 MiB per §7 open Q #7; throws `PackTooLargeError` on overflow.
15
+ *
16
+ * Pure(ish): every git invocation goes through `runGit`/`spawn`. Tests
17
+ * stand up real tmp-dir git repos rather than mocking the shell — the
18
+ * pack format is too fiddly to fake convincingly.
19
+ */
20
+ import { spawn } from 'node:child_process';
21
+ import { GIT_SAFE_ARGS, GIT_SAFE_ENV, runGit } from '../util/git-ops.js';
22
+ /** Per §7 open Q #7 of the design plan: pack size cap of 25 MiB. */
23
+ export const MAX_PACK_BYTES = 25 * 1024 * 1024;
24
+ /**
25
+ * Resolve HEAD via `git symbolic-ref HEAD`. Empty/failed resolution
26
+ * means detached HEAD (per §4.1 step 1). `master` is treated as a
27
+ * structural workspace error rather than a transient one — the
28
+ * workspace template uses `main`, and a `master` checkout is almost
29
+ * always a leftover from cloning a non-Quonfig template.
30
+ */
31
+ export async function getCurrentBranch(dir) {
32
+ let stdout;
33
+ try {
34
+ const res = await runGit(['-C', dir, 'symbolic-ref', '--quiet', '--short', 'HEAD']);
35
+ stdout = res.stdout.trim();
36
+ }
37
+ catch {
38
+ return { kind: 'detached', message: 'qfg push requires a checked-out branch.' };
39
+ }
40
+ if (stdout.length === 0) {
41
+ return { kind: 'detached', message: 'qfg push requires a checked-out branch.' };
42
+ }
43
+ if (stdout === 'master') {
44
+ return {
45
+ kind: 'master',
46
+ message: 'Quonfig workspaces use `main`; rename with `git branch -m master main`.',
47
+ };
48
+ }
49
+ return { kind: 'branch', name: stdout };
50
+ }
51
+ export class PackTooLargeError extends Error {
52
+ bytes;
53
+ limit;
54
+ constructor(bytes, limit) {
55
+ super(`Pack exceeds ${limit} bytes (got ${bytes} bytes). Reduce the size of new commits or split them across multiple pushes.`);
56
+ this.name = 'PackTooLargeError';
57
+ this.bytes = bytes;
58
+ this.limit = limit;
59
+ }
60
+ }
61
+ /**
62
+ * Produce a packfile covering `<expectedSha>..<newSha>` — i.e. the new
63
+ * commit objects, their trees, and their blobs that are not already
64
+ * reachable from `expectedSha`. Internally:
65
+ *
66
+ * printf '<newSha>\n^<expectedSha>\n' | git pack-objects --revs --stdout
67
+ *
68
+ * which is the canonical "pack everything reachable from newSha but not
69
+ * from expectedSha" recipe. Buffers the entire output to memory so we
70
+ * can enforce the §7 cap before shipping anything to the server.
71
+ *
72
+ * Returns an empty `Uint8Array` when `expectedSha === newSha` — there
73
+ * are no new commits, so `pack-objects` would either error on stdin
74
+ * with no candidate revs or produce an empty pack. Skipping the spawn
75
+ * keeps the no-op caller path simple.
76
+ */
77
+ export async function buildPack(dir, expectedSha, newSha, options = {}) {
78
+ if (expectedSha === newSha) {
79
+ return new Uint8Array(0);
80
+ }
81
+ const limit = options.maxBytes ?? MAX_PACK_BYTES;
82
+ return new Promise((resolve, reject) => {
83
+ const env = { ...process.env, ...GIT_SAFE_ENV };
84
+ const args = [...GIT_SAFE_ARGS, '-C', dir, 'pack-objects', '--revs', '--stdout'];
85
+ const child = spawn('git', args, { env, stdio: ['pipe', 'pipe', 'pipe'] });
86
+ const chunks = [];
87
+ let totalBytes = 0;
88
+ let stderr = '';
89
+ let killedForOverflow = false;
90
+ child.stdout.on('data', (chunk) => {
91
+ totalBytes += chunk.byteLength;
92
+ if (totalBytes > limit) {
93
+ if (!killedForOverflow) {
94
+ killedForOverflow = true;
95
+ child.kill('SIGKILL');
96
+ }
97
+ return;
98
+ }
99
+ chunks.push(chunk);
100
+ });
101
+ child.stderr.on('data', (chunk) => {
102
+ stderr += chunk.toString();
103
+ });
104
+ child.on('error', (err) => reject(err));
105
+ child.on('close', (code, signal) => {
106
+ if (killedForOverflow) {
107
+ reject(new PackTooLargeError(totalBytes, limit));
108
+ return;
109
+ }
110
+ if (code !== 0) {
111
+ reject(new Error(`git pack-objects exited ${code ?? signal}: ${stderr}`));
112
+ return;
113
+ }
114
+ const out = Buffer.concat(chunks, totalBytes);
115
+ // Defensive: if git produced more than the limit but stayed under
116
+ // a chunk boundary that triggered our SIGKILL, double-check here.
117
+ if (out.byteLength > limit) {
118
+ reject(new PackTooLargeError(out.byteLength, limit));
119
+ return;
120
+ }
121
+ resolve(new Uint8Array(out.buffer, out.byteOffset, out.byteLength));
122
+ });
123
+ // Feed the rev-list spec on stdin: include newSha, exclude expectedSha.
124
+ // `--revs` tells pack-objects to read this as `git rev-list` would.
125
+ child.stdin.end(`${newSha}\n^${expectedSha}\n`);
126
+ });
127
+ }
@@ -8,18 +8,24 @@
8
8
  *
9
9
  * A. Requested target — slug or UUID the user asked for (--workspace or profile default)
10
10
  * B. Repo pin — `workspace` slug from `quonfig.json`, if present
11
- * C. Remote origin — `remote.origin.url` on the local git repo, if present
11
+ * C. Remote URLs — `git remote -v` URLs on the local repo, if any
12
12
  *
13
13
  * ...and the canonical `backend` identity (the workspace the gitea.token
14
14
  * response was minted for). This module cross-checks them and returns one of:
15
15
  *
16
16
  * - ok all defined signals agree
17
- * - abort any two defined signals disagree
17
+ * - abort any defined signal disagrees
18
18
  * - requires-typed-slug-confirmation the repo is unpinned and has no
19
- * origin, so only the requested
19
+ * remotes, so only the requested
20
20
  * target supports our identity —
21
21
  * we demand the user type the slug
22
22
  *
23
+ * Multi-remote support (qfg-glrd.3): customers often use GitHub for PR review
24
+ * (origin = github.com/their-org/configs) and a secondary remote for Quonfig.
25
+ * As long as ANY configured remote matches the backend's repo URL, the remote
26
+ * signal is `match`. Only when every configured remote points elsewhere do we
27
+ * abort — and the abort details list every remote that was considered.
28
+ *
23
29
  * The module is intentionally pure: no I/O, no prompting, no UI. The UI layer
24
30
  * (confirm prompts, diff summary) and the data-fetch layer (reading quonfig.json,
25
31
  * shelling out to git, calling gitea.token) live elsewhere.
@@ -31,8 +37,13 @@ export interface IdentityCheckInput {
31
37
  workspaceId: string;
32
38
  repoUrl: string;
33
39
  };
34
- /** "C. Remote origin" — the origin URL on the local git repo, if present. */
35
- remoteOriginUrl: string | undefined;
40
+ /**
41
+ * "C. Remote URLs" — every configured `git remote` URL on the local repo.
42
+ * Empty array means "no remotes configured" (either not a git repo, or a
43
+ * fresh repo with no remotes). Length ≥ 1 means at least one remote must
44
+ * match the backend's repo URL.
45
+ */
46
+ remoteUrls: string[];
36
47
  /** "B. Repo pin" — slug from `quonfig.json.workspace`, if present. */
37
48
  repoPinSlug: string | undefined;
38
49
  /** "A. Requested target" — what the user asked for. Slug or UUID. */
@@ -44,10 +55,20 @@ export type IdentityCheckOutcome = {
44
55
  } | {
45
56
  kind: 'abort';
46
57
  reason: string;
47
- details: Record<string, string>;
58
+ details: Record<string, string | string[]>;
48
59
  } | {
49
60
  kind: 'requires-typed-slug-confirmation';
50
61
  canonicalSlug: string;
51
62
  reason: string;
52
63
  };
53
64
  export declare function checkIdentity(input: IdentityCheckInput): IdentityCheckOutcome;
65
+ /**
66
+ * Walk a list of configured git remote URLs and return the first one that
67
+ * normalizes to the same repo as `backendRepoUrl`. Returns `undefined` if no
68
+ * configured remote matches.
69
+ *
70
+ * Useful for callers that have already resolved the backend and want to know
71
+ * *which* remote authenticated against it (e.g. `qfg pull` updating the URL
72
+ * to embed a fresh token).
73
+ */
74
+ export declare function findQuonfigRemote(remotes: string[], backendRepoUrl: string): string | undefined;
@@ -8,18 +8,24 @@
8
8
  *
9
9
  * A. Requested target — slug or UUID the user asked for (--workspace or profile default)
10
10
  * B. Repo pin — `workspace` slug from `quonfig.json`, if present
11
- * C. Remote origin — `remote.origin.url` on the local git repo, if present
11
+ * C. Remote URLs — `git remote -v` URLs on the local repo, if any
12
12
  *
13
13
  * ...and the canonical `backend` identity (the workspace the gitea.token
14
14
  * response was minted for). This module cross-checks them and returns one of:
15
15
  *
16
16
  * - ok all defined signals agree
17
- * - abort any two defined signals disagree
17
+ * - abort any defined signal disagrees
18
18
  * - requires-typed-slug-confirmation the repo is unpinned and has no
19
- * origin, so only the requested
19
+ * remotes, so only the requested
20
20
  * target supports our identity —
21
21
  * we demand the user type the slug
22
22
  *
23
+ * Multi-remote support (qfg-glrd.3): customers often use GitHub for PR review
24
+ * (origin = github.com/their-org/configs) and a secondary remote for Quonfig.
25
+ * As long as ANY configured remote matches the backend's repo URL, the remote
26
+ * signal is `match`. Only when every configured remote points elsewhere do we
27
+ * abort — and the abort details list every remote that was considered.
28
+ *
23
29
  * The module is intentionally pure: no I/O, no prompting, no UI. The UI layer
24
30
  * (confirm prompts, diff summary) and the data-fetch layer (reading quonfig.json,
25
31
  * shelling out to git, calling gitea.token) live elsewhere.
@@ -63,12 +69,36 @@ const resolvePin = (pin, backend) => {
63
69
  return 'match';
64
70
  return 'mismatch';
65
71
  };
66
- const resolveOrigin = (origin, backend) => {
67
- const a = normalizeRemoteUrl(origin);
68
- const b = normalizeRemoteUrl(backend.repoUrl);
69
- if (!a || !b)
72
+ /**
73
+ * Resolve the remote signal: any single configured remote that normalizes to
74
+ * the backend's repo URL counts as a `match`. If we hit at least one such
75
+ * match, the overall resolution is `match` regardless of how many other
76
+ * remotes are configured. Otherwise, if every remote is either a clean
77
+ * mismatch or malformed, the resolution is `mismatch` — the user's git
78
+ * config doesn't agree with the workspace we're pushing to.
79
+ */
80
+ const resolveRemotes = (remotes, backend) => {
81
+ const target = normalizeRemoteUrl(backend.repoUrl);
82
+ if (!target)
70
83
  return 'malformed';
71
- return a === b ? 'match' : 'mismatch';
84
+ let sawMalformed = false;
85
+ for (const remote of remotes) {
86
+ const normalized = normalizeRemoteUrl(remote);
87
+ if (!normalized) {
88
+ sawMalformed = true;
89
+ continue;
90
+ }
91
+ if (normalized === target)
92
+ return 'match';
93
+ }
94
+ // No remote matched. If every remote was malformed and we never saw a
95
+ // resolvable mismatch, surface the malformed case so the abort message
96
+ // can speak about unreadable URLs specifically. Otherwise it's a plain
97
+ // mismatch — none of the configured remotes point at us.
98
+ if (sawMalformed && remotes.every((r) => normalizeRemoteUrl(r) === undefined)) {
99
+ return 'malformed';
100
+ }
101
+ return 'mismatch';
72
102
  };
73
103
  export function checkIdentity(input) {
74
104
  if (!input.requestedTarget || input.requestedTarget.length === 0) {
@@ -81,15 +111,18 @@ export function checkIdentity(input) {
81
111
  const backend = input.backend;
82
112
  const requested = resolveRequested(input.requestedTarget, backend);
83
113
  const pin = input.repoPinSlug === undefined ? undefined : resolvePin(input.repoPinSlug, backend);
84
- const origin = input.remoteOriginUrl === undefined ? undefined : resolveOrigin(input.remoteOriginUrl, backend);
85
- // Judgment call (flagged in tests): a malformed origin URL is a signal we
86
- // cannot resolve. We treat it as `mismatch` rather than `missing` so the
87
- // guard aborts — it is safer to refuse a push with an unreadable remote
88
- // than to silently drop that signal and rely on the pin alone.
114
+ // Empty `remoteUrls` means "no remotes configured" — we have no signal to
115
+ // resolve, identical to the old `remoteOriginUrl === undefined` case.
116
+ const remote = input.remoteUrls.length === 0 ? undefined : resolveRemotes(input.remoteUrls, backend);
117
+ // Judgment call (flagged in tests): a malformed remote with no resolvable
118
+ // alternative is a signal we cannot resolve. We treat it as `mismatch`
119
+ // rather than `missing` so the guard aborts — it is safer to refuse a push
120
+ // with an unreadable remote than to silently drop that signal and rely on
121
+ // the pin alone.
89
122
  const signalLabels = {
90
123
  requested,
91
124
  pin,
92
- origin,
125
+ remote,
93
126
  };
94
127
  // Collect which sources disagree with the backend (or are malformed).
95
128
  const disagreeing = [];
@@ -110,9 +143,11 @@ export function checkIdentity(input) {
110
143
  details.repoPinSlug = input.repoPinSlug;
111
144
  details.pinResolution = pin;
112
145
  }
113
- if (input.remoteOriginUrl !== undefined) {
114
- details.remoteOriginUrl = input.remoteOriginUrl;
115
- details.originResolution = origin;
146
+ if (input.remoteUrls.length > 0) {
147
+ // Surface every remote URL that was considered. When multiple remotes
148
+ // disagree the user needs to see each one to figure out which to fix.
149
+ details.remoteUrls = input.remoteUrls;
150
+ details.remoteResolution = remote;
116
151
  }
117
152
  if (disagreeing.length > 0) {
118
153
  // Any single defined source pointing somewhere other than the backend is
@@ -124,12 +159,32 @@ export function checkIdentity(input) {
124
159
  }
125
160
  // No disagreement among defined sources. Are we in the "bare dir" case
126
161
  // where only the requested target speaks for identity?
127
- if (input.repoPinSlug === undefined && input.remoteOriginUrl === undefined) {
162
+ if (input.repoPinSlug === undefined && input.remoteUrls.length === 0) {
128
163
  return {
129
164
  kind: 'requires-typed-slug-confirmation',
130
165
  canonicalSlug: backend.workspaceSlug,
131
- reason: 'Local directory has no workspace pin and no git origin — cannot cross-check the requested target. Confirm by typing the workspace slug.',
166
+ reason: 'Local directory has no workspace pin and no git remotes — cannot cross-check the requested target. Confirm by typing the workspace slug.',
132
167
  };
133
168
  }
134
169
  return { kind: 'ok', canonicalSlug: backend.workspaceSlug };
135
170
  }
171
+ /**
172
+ * Walk a list of configured git remote URLs and return the first one that
173
+ * normalizes to the same repo as `backendRepoUrl`. Returns `undefined` if no
174
+ * configured remote matches.
175
+ *
176
+ * Useful for callers that have already resolved the backend and want to know
177
+ * *which* remote authenticated against it (e.g. `qfg pull` updating the URL
178
+ * to embed a fresh token).
179
+ */
180
+ export function findQuonfigRemote(remotes, backendRepoUrl) {
181
+ const target = normalizeRemoteUrl(backendRepoUrl);
182
+ if (!target)
183
+ return undefined;
184
+ for (const remote of remotes) {
185
+ const normalized = normalizeRemoteUrl(remote);
186
+ if (normalized === target)
187
+ return remote;
188
+ }
189
+ return undefined;
190
+ }