@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 CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "@uniqbit/mate-core",
3
- "version": "0.18.0",
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
- execOrThrow(["fetch"]);
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 = exec(["push", "--follow-tags"]);
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.command(
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 args = ["clone"];
221
- if (source.ref) args.push("--branch", source.ref);
222
- args.push(source.url!, destination);
223
- gitOutputOrThrow(git(hubPath, args), `Cloning ${source.url}`);
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 = git(memberPath, ["fetch", "origin"]);
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 = runGitSync(
134
- companionPath,
134
+ const fetch = runPreferringSsh(
135
+ (args) => runGitSync(companionPath, args, remaining()),
135
136
  ["fetch", "--no-progress", target.remote, target.branch],
136
- remaining(),
137
+ (result) => result,
137
138
  );
138
139
  if (fetch.status !== 0) {
139
140
  if (isAuthenticationFailure(fetch)) {