@uniqbit/mate-core 0.18.0 → 0.18.1-canary.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 +6 -1
- package/src/cli/commands/artifact/finish/git.ts +13 -4
- package/src/cli/commands/companion/link.ts +1 -17
- package/src/lib/orchestrator/companion-git-sync.ts +23 -5
- package/src/lib/orchestrator/companion-hub.ts +15 -5
- package/src/runtime/companion-git.ts +66 -0
- package/src/runtime/companion-sync.ts +4 -3
package/package.json
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniqbit/mate-core",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.1-canary.1",
|
|
4
4
|
"description": "Core framework and plugin APIs for Mate.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/uniqbit-ag/mate-cli.git",
|
|
9
|
+
"directory": "packages/mate-core"
|
|
10
|
+
},
|
|
6
11
|
"files": [
|
|
7
12
|
"src/",
|
|
8
13
|
"wrappers/",
|
|
@@ -2,6 +2,8 @@ import fs from "node:fs";
|
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
|
+
import { runPreferringSsh, type GitResult } from "../../../../runtime/companion-git";
|
|
6
|
+
|
|
5
7
|
export interface RebaseResult {
|
|
6
8
|
ok: boolean;
|
|
7
9
|
conflictedPaths: string[];
|
|
@@ -46,7 +48,7 @@ export interface GitOps {
|
|
|
46
48
|
|
|
47
49
|
function gitRun(
|
|
48
50
|
companionPath: string,
|
|
49
|
-
args: string[],
|
|
51
|
+
args: readonly string[],
|
|
50
52
|
): { status: number; out: string; err: string } {
|
|
51
53
|
const {
|
|
52
54
|
GIT_DIR: _gitDir,
|
|
@@ -112,7 +114,13 @@ export function defaultGitOps(
|
|
|
112
114
|
workingRepoPath = process.env.MATE_REPO_PATH,
|
|
113
115
|
): GitOps {
|
|
114
116
|
assertSafeCompanionRoot(companionPath, workingRepoPath);
|
|
115
|
-
const exec = (args: string[]) => gitRun(companionPath, args);
|
|
117
|
+
const exec = (args: readonly string[]) => gitRun(companionPath, args);
|
|
118
|
+
const toResult = (res: ReturnType<typeof exec>): GitResult => ({
|
|
119
|
+
status: res.status,
|
|
120
|
+
stdout: res.out,
|
|
121
|
+
stderr: res.err,
|
|
122
|
+
});
|
|
123
|
+
const execNetwork = (args: string[]) => runPreferringSsh(exec, args, toResult);
|
|
116
124
|
const execOrThrow = (args: string[]) => {
|
|
117
125
|
const res = exec(args);
|
|
118
126
|
if (res.status !== 0) {
|
|
@@ -185,7 +193,8 @@ export function defaultGitOps(
|
|
|
185
193
|
return exec(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).status === 0;
|
|
186
194
|
},
|
|
187
195
|
async fetch() {
|
|
188
|
-
|
|
196
|
+
const res = execNetwork(["fetch"]);
|
|
197
|
+
if (res.status !== 0) throw new Error(`git fetch failed: ${res.err || res.out}`);
|
|
189
198
|
},
|
|
190
199
|
async rebaseOntoUpstream() {
|
|
191
200
|
const res = exec(["rebase", "--autostash", "@{u}"]);
|
|
@@ -205,7 +214,7 @@ export function defaultGitOps(
|
|
|
205
214
|
execOrThrow(["tag", "-a", name, "-m", message]);
|
|
206
215
|
},
|
|
207
216
|
async push() {
|
|
208
|
-
const res =
|
|
217
|
+
const res = execNetwork(["push", "--follow-tags"]);
|
|
209
218
|
return { ok: res.status === 0, error: res.err || res.out };
|
|
210
219
|
},
|
|
211
220
|
};
|
|
@@ -19,6 +19,7 @@ import { runSetupFlowAtPath } from "../setup";
|
|
|
19
19
|
import { runInstallCommand } from "../install";
|
|
20
20
|
import type { CompanionSource, LinkedRepository } from "../../../lib/orchestrator/types";
|
|
21
21
|
import { invalidateInstallState } from "../../../lib/install";
|
|
22
|
+
import { toSshUrl } from "../../../runtime/companion-git";
|
|
22
23
|
|
|
23
24
|
interface CompanionLinkCommandDeps {
|
|
24
25
|
selectCompanionLinkInputs?: (
|
|
@@ -61,23 +62,6 @@ export async function runCompanionLinkCommand(argv: string[]): Promise<void> {
|
|
|
61
62
|
return companionLinkCommandDeps.runCompanionLinkCommandWithDeps(argv);
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
function toSshUrl(url: string): string | null {
|
|
65
|
-
try {
|
|
66
|
-
const parsed = new URL(url.trim());
|
|
67
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
68
|
-
if (!parsed.hostname || parsed.pathname === "/") return null;
|
|
69
|
-
|
|
70
|
-
const repositoryPath = parsed.pathname.replace(/^\/+/, "");
|
|
71
|
-
if (!repositoryPath) return null;
|
|
72
|
-
|
|
73
|
-
// Keep the usual scp-style form when no SSH port is specified.
|
|
74
|
-
if (!parsed.port) return `git@${parsed.hostname}:${repositoryPath}`;
|
|
75
|
-
return `ssh://git@${parsed.hostname}:${parsed.port}/${repositoryPath}`;
|
|
76
|
-
} catch {
|
|
77
|
-
return null;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
65
|
function exitWithError(message: string): never {
|
|
82
66
|
process.stderr.write(`${message}\n`);
|
|
83
67
|
process.exit(1);
|
|
@@ -10,7 +10,10 @@ import {
|
|
|
10
10
|
gitEnvironment,
|
|
11
11
|
isAuthenticationFailure,
|
|
12
12
|
outputLines,
|
|
13
|
+
REMOTE_URLS_QUERY,
|
|
13
14
|
resolveUpstreamTargetWith,
|
|
15
|
+
shouldRetryWithoutSsh,
|
|
16
|
+
sshRewriteArgs,
|
|
14
17
|
type GitResult,
|
|
15
18
|
} from "../../runtime/companion-git";
|
|
16
19
|
import { recordCompanionSync } from "../../runtime/companion-git-state";
|
|
@@ -172,11 +175,7 @@ export class CompanionGitSync {
|
|
|
172
175
|
}
|
|
173
176
|
const dirty = outputLines(before.stdout).length > 0;
|
|
174
177
|
|
|
175
|
-
const fetch = await this.
|
|
176
|
-
companionPath,
|
|
177
|
-
["fetch", target.remote, target.branch],
|
|
178
|
-
interactiveGit ? "interactive" : undefined,
|
|
179
|
-
);
|
|
178
|
+
const fetch = await this.fetch(companionPath, target, interactiveGit);
|
|
180
179
|
if (fetch.status !== 0) {
|
|
181
180
|
const authenticationFailure = isAuthenticationFailure(fetch);
|
|
182
181
|
const recovery = interactiveGit
|
|
@@ -248,6 +247,25 @@ export class CompanionGitSync {
|
|
|
248
247
|
};
|
|
249
248
|
}
|
|
250
249
|
|
|
250
|
+
/**
|
|
251
|
+
* SSH attempt stays captured: a failure there is expected noise, and SSH
|
|
252
|
+
* still prompts for passphrases or host keys on the terminal itself.
|
|
253
|
+
*/
|
|
254
|
+
private async fetch(
|
|
255
|
+
companionPath: string,
|
|
256
|
+
target: SyncTarget,
|
|
257
|
+
interactiveGit: boolean,
|
|
258
|
+
): Promise<GitCommandResult> {
|
|
259
|
+
const args = ["fetch", target.remote, target.branch];
|
|
260
|
+
const remotes = await this.command(companionPath, REMOTE_URLS_QUERY);
|
|
261
|
+
const rewrite = remotes.status === 0 ? sshRewriteArgs(remotes.stdout) : [];
|
|
262
|
+
if (rewrite.length > 0) {
|
|
263
|
+
const overSsh = await this.command(companionPath, [...rewrite, ...args]);
|
|
264
|
+
if (!shouldRetryWithoutSsh(overSsh)) return overSsh;
|
|
265
|
+
}
|
|
266
|
+
return this.command(companionPath, args, interactiveGit ? "interactive" : undefined);
|
|
267
|
+
}
|
|
268
|
+
|
|
251
269
|
private async mergeAndRestore(
|
|
252
270
|
companionPath: string,
|
|
253
271
|
target: SyncTarget,
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import { hydrateDynamicPlugins } from "../../tools/setup/dynamic-plugins/hydrate";
|
|
18
18
|
import { applySetupCompatibilities } from "../../tools/setup";
|
|
19
19
|
import { findRepoLocalRegistryFile } from "./repo-local-registry";
|
|
20
|
+
import { runPreferringSsh, toSshUrl } from "../../runtime/companion-git";
|
|
20
21
|
|
|
21
22
|
export interface GitCommandResult {
|
|
22
23
|
status: number | null;
|
|
@@ -217,10 +218,15 @@ export async function materializeHubMember(
|
|
|
217
218
|
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
218
219
|
try {
|
|
219
220
|
if (source.kind === "git") {
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
221
|
+
const branch = source.ref ? ["--branch", source.ref] : [];
|
|
222
|
+
const cloneFrom = (url: string) => git(hubPath, ["clone", ...branch, url, destination]);
|
|
223
|
+
const sshUrl = toSshUrl(source.url!);
|
|
224
|
+
let clone = sshUrl ? cloneFrom(sshUrl) : undefined;
|
|
225
|
+
if (!clone || clone.status !== 0) {
|
|
226
|
+
if (clone) await fs.rm(destination, { recursive: true, force: true });
|
|
227
|
+
clone = cloneFrom(source.url!);
|
|
228
|
+
}
|
|
229
|
+
gitOutputOrThrow(clone, `Cloning ${source.url}`);
|
|
224
230
|
} else {
|
|
225
231
|
await copyWithoutGit(source.path!, destination);
|
|
226
232
|
}
|
|
@@ -288,7 +294,11 @@ async function syncHubMember(
|
|
|
288
294
|
return { id: member.id, status: "dirty", message: "local changes must be resolved first" };
|
|
289
295
|
}
|
|
290
296
|
|
|
291
|
-
const fetch =
|
|
297
|
+
const fetch = runPreferringSsh(
|
|
298
|
+
(args) => git(memberPath, [...args]),
|
|
299
|
+
["fetch", "origin"],
|
|
300
|
+
(result) => ({ ...result, status: result.status ?? 1 }),
|
|
301
|
+
);
|
|
292
302
|
if (fetch.status !== 0) {
|
|
293
303
|
return { id: member.id, status: "failed", message: fetch.stderr || "fetch failed" };
|
|
294
304
|
}
|
|
@@ -61,6 +61,72 @@ export function isAuthenticationFailure(result: GitResult): boolean {
|
|
|
61
61
|
].some((marker) => output.includes(marker));
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/** `git@host:path` (`ssh://` when a port is given) for an HTTP(S) Git URL; `null` otherwise. */
|
|
65
|
+
export function toSshUrl(url: string): string | null {
|
|
66
|
+
try {
|
|
67
|
+
const parsed = new URL(url.trim());
|
|
68
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
69
|
+
if (!parsed.hostname || parsed.pathname === "/") return null;
|
|
70
|
+
|
|
71
|
+
const repositoryPath = parsed.pathname.replace(/^\/+/, "");
|
|
72
|
+
if (!repositoryPath) return null;
|
|
73
|
+
|
|
74
|
+
if (!parsed.port) return `git@${parsed.hostname}:${repositoryPath}`;
|
|
75
|
+
return `ssh://git@${parsed.hostname}:${parsed.port}/${repositoryPath}`;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Lists every configured remote URL, one `remote.<name>.<key> <url>` per line. */
|
|
82
|
+
export const REMOTE_URLS_QUERY: readonly string[] = [
|
|
83
|
+
"config",
|
|
84
|
+
"--get-regexp",
|
|
85
|
+
String.raw`^remote\..+\.(url|pushurl)$`,
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `-c url.<ssh>.insteadOf=<https>` per HTTP(S) remote in `REMOTE_URLS_QUERY`
|
|
90
|
+
* output, so one invocation reaches every remote over SSH without rewriting
|
|
91
|
+
* the stored configuration. Empty when no remote is HTTP(S).
|
|
92
|
+
*/
|
|
93
|
+
export function sshRewriteArgs(remoteUrlsOutput: string): string[] {
|
|
94
|
+
const urls = new Set(
|
|
95
|
+
outputLines(remoteUrlsOutput).flatMap((line) => {
|
|
96
|
+
const url = line.slice(line.indexOf(" ") + 1).trim();
|
|
97
|
+
return url ? [url] : [];
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
return [...urls].flatMap((url) => {
|
|
101
|
+
const ssh = toSshUrl(url);
|
|
102
|
+
return ssh ? ["-c", `url.${ssh}.insteadOf=${url}`] : [];
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A remote rejection is a verdict the configured URL would repeat; anything else may be SSH-only. */
|
|
107
|
+
export function shouldRetryWithoutSsh(result: GitResult): boolean {
|
|
108
|
+
if (result.status === 0) return false;
|
|
109
|
+
return !/\[(?:remote )?rejected\]/.test(`${result.stderr}\n${result.stdout}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Runs a network command over SSH first when a remote is HTTP(S), then as
|
|
114
|
+
* configured. `run` is the caller's own Git driver.
|
|
115
|
+
*/
|
|
116
|
+
export function runPreferringSsh<R extends { status: number | null }>(
|
|
117
|
+
run: (args: readonly string[]) => R,
|
|
118
|
+
args: readonly string[],
|
|
119
|
+
toResult: (result: R) => GitResult,
|
|
120
|
+
): R {
|
|
121
|
+
const remotes = toResult(run(REMOTE_URLS_QUERY));
|
|
122
|
+
const rewrite = remotes.status === 0 ? sshRewriteArgs(remotes.stdout) : [];
|
|
123
|
+
if (rewrite.length > 0) {
|
|
124
|
+
const overSsh = run([...rewrite, ...args]);
|
|
125
|
+
if (!shouldRetryWithoutSsh(toResult(overSsh))) return overSsh;
|
|
126
|
+
}
|
|
127
|
+
return run(args);
|
|
128
|
+
}
|
|
129
|
+
|
|
64
130
|
/**
|
|
65
131
|
* Inherited `GIT_*` overrides would point Git at the session's own repository
|
|
66
132
|
* rather than the companion, so they are stripped from every invocation.
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
outputLines,
|
|
31
31
|
resolveUpstreamTargetSync,
|
|
32
32
|
runGitSync,
|
|
33
|
+
runPreferringSsh,
|
|
33
34
|
type CompanionForkState,
|
|
34
35
|
} from "./companion-git";
|
|
35
36
|
import { hasLaunchEnvironment } from "./env";
|
|
@@ -130,10 +131,10 @@ export function syncCompanionUnattended(
|
|
|
130
131
|
);
|
|
131
132
|
}
|
|
132
133
|
|
|
133
|
-
const fetch =
|
|
134
|
-
companionPath,
|
|
134
|
+
const fetch = runPreferringSsh(
|
|
135
|
+
(args) => runGitSync(companionPath, args, remaining()),
|
|
135
136
|
["fetch", "--no-progress", target.remote, target.branch],
|
|
136
|
-
|
|
137
|
+
(result) => result,
|
|
137
138
|
);
|
|
138
139
|
if (fetch.status !== 0) {
|
|
139
140
|
if (isAuthenticationFailure(fetch)) {
|