@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.
- package/README.md +54 -49
- package/dist/commands/migrate.d.ts +1 -0
- package/dist/commands/migrate.js +25 -1
- package/dist/commands/pull.js +45 -12
- package/dist/commands/push.js +86 -9
- package/dist/migrate/local-write.d.ts +61 -1
- package/dist/migrate/local-write.js +134 -1
- package/dist/migrate/push-to-cloud.d.ts +11 -2
- package/dist/migrate/push-to-cloud.js +78 -42
- package/dist/migrate/source.d.ts +24 -0
- package/dist/migrate/sources/launch.js +21 -0
- package/dist/push/config-push-client.d.ts +15 -1
- package/dist/push/config-push-client.js +102 -0
- package/dist/push/git-pack.d.ts +70 -0
- package/dist/push/git-pack.js +127 -0
- package/dist/push/identity-check.d.ts +27 -6
- package/dist/push/identity-check.js +74 -19
- package/dist/push/run-push.d.ts +133 -1
- package/dist/push/run-push.js +183 -46
- package/dist/util/clone-and-stack-push.d.ts +40 -9
- package/dist/util/clone-and-stack-push.js +59 -20
- package/dist/util/git-ops.d.ts +9 -0
- package/dist/util/git-ops.js +38 -0
- package/dist/util/resolve-workspace-dir.d.ts +44 -0
- package/dist/util/resolve-workspace-dir.js +61 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +10 -4
- package/package.json +7 -7
|
@@ -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
|
|
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
|
|
17
|
+
* - abort any defined signal disagrees
|
|
18
18
|
* - requires-typed-slug-confirmation the repo is unpinned and has no
|
|
19
|
-
*
|
|
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
|
-
/**
|
|
35
|
-
|
|
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
|
|
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
|
|
17
|
+
* - abort any defined signal disagrees
|
|
18
18
|
* - requires-typed-slug-confirmation the repo is unpinned and has no
|
|
19
|
-
*
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
//
|
|
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
|
-
|
|
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.
|
|
114
|
-
|
|
115
|
-
|
|
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.
|
|
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
|
|
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
|
+
}
|