@multiplatform.one/cli 7.28.0 → 7.29.0
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/lib/commands/scaffold.mjs +3 -0
- package/lib/source.json +2 -2
- package/lib/source.mjs +96 -1
- package/package.json +2 -2
- package/scripts/scaffold.sh +1 -1
- package/src/commands/scaffold.ts +6 -4
- package/src/source.spec.ts +189 -3
- package/src/source.ts +123 -0
- package/templates/AGENTS.md +9 -9
- package/types/commands/scaffold.d.ts.map +1 -1
- package/types/source.d.ts +19 -0
- package/types/source.d.ts.map +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { cliRoot, readProvenance } from "./provenance.mjs";
|
|
2
|
+
import { checkSourceAccess } from "../source.mjs";
|
|
2
3
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import spawn from "nano-spawn";
|
|
@@ -25,6 +26,7 @@ function reportKept(stdout) {
|
|
|
25
26
|
}
|
|
26
27
|
/** Run scaffold.sh and return the provenance it wrote. */
|
|
27
28
|
async function runScaffold(request) {
|
|
29
|
+
await checkSourceAccess(request.source);
|
|
28
30
|
const args = [scaffoldScript()];
|
|
29
31
|
for (const app of request.apps) args.push("--app", app);
|
|
30
32
|
for (const service of request.services) args.push("--service", service);
|
|
@@ -75,6 +77,7 @@ async function scaffoldToTemp(request) {
|
|
|
75
77
|
* templates of an older CLI version, for adopted-project merge bases).
|
|
76
78
|
*/
|
|
77
79
|
async function archiveSubtree(source, ref, subtree, dest) {
|
|
80
|
+
await checkSourceAccess(source);
|
|
78
81
|
await spawn("sh", [
|
|
79
82
|
"-c",
|
|
80
83
|
[
|
package/lib/source.json
CHANGED
package/lib/source.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { cliRoot, cliVersion } from "./commands/provenance.mjs";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
2
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
4
|
import { join, resolve } from "node:path";
|
|
4
5
|
|
|
@@ -58,6 +59,100 @@ function resolveSource(flags = {}) {
|
|
|
58
59
|
warning: `this build of @multiplatform.one/cli carries no source commit (lib/source.json); scaffolding from ${ref} instead`
|
|
59
60
|
};
|
|
60
61
|
}
|
|
62
|
+
const authFailures = [
|
|
63
|
+
/access denied/i,
|
|
64
|
+
/authentication failed/i,
|
|
65
|
+
/could not read (username|password)/i,
|
|
66
|
+
/terminal prompts disabled/i,
|
|
67
|
+
/permission denied \(/i,
|
|
68
|
+
/host key verification failed/i,
|
|
69
|
+
/returned error: 40[13]/i,
|
|
70
|
+
/repository not found/i,
|
|
71
|
+
/you don't have permission/i
|
|
72
|
+
];
|
|
73
|
+
const networkFailures = [
|
|
74
|
+
/could not resolve (host|hostname|proxy)/i,
|
|
75
|
+
/failed to connect/i,
|
|
76
|
+
/couldn't connect/i,
|
|
77
|
+
/connection (refused|reset|timed out|closed)/i,
|
|
78
|
+
/operation timed out/i,
|
|
79
|
+
/network is unreachable/i,
|
|
80
|
+
/no route to host/i,
|
|
81
|
+
/ssl|certificate/i
|
|
82
|
+
];
|
|
83
|
+
/** What a failed `git ls-remote` says about why the source is unreadable. */
|
|
84
|
+
function classifySourceFailure(stderr) {
|
|
85
|
+
if (authFailures.some((pattern) => pattern.test(stderr))) return "auth";
|
|
86
|
+
if (networkFailures.some((pattern) => pattern.test(stderr))) return "network";
|
|
87
|
+
return "unknown";
|
|
88
|
+
}
|
|
89
|
+
var SourceAccessError = class extends Error {
|
|
90
|
+
constructor(url, kind, gitOutput, reason) {
|
|
91
|
+
const host = /^[a-z]+:\/\/(?:[^@/]*@)?([^/:]+)/i.exec(url)?.[1] ?? /@([^:]+):/.exec(url)?.[1];
|
|
92
|
+
const lines = kind === "network" ? [`Cannot reach the source repository ${url}${reason ? `: ${reason}` : ""}.`, `The CLI scaffolds from this git repository, so it needs a network path to ${host ?? "its host"} (check the connection, proxy or VPN).`] : [`Cannot read the source repository ${url}${kind === "auth" ? ": access denied" : ""}.`, "The CLI scaffolds from this git repository, so it needs read access to it: an account that can clone it, and a credential git uses without asking (a token in a git credential helper for https, an SSH key for ssh)."];
|
|
93
|
+
lines.push(`Check with: git ls-remote ${url}`);
|
|
94
|
+
lines.push("Or scaffold from a copy you can read: --source <path|url>");
|
|
95
|
+
const detail = gitOutput.split("\n").map((line) => line.trim()).filter(Boolean).slice(-4);
|
|
96
|
+
if (detail.length) lines.push("git said:", ...detail.map((line) => ` ${line}`));
|
|
97
|
+
super(lines.join("\n"));
|
|
98
|
+
this.name = "SourceAccessError";
|
|
99
|
+
this.url = url;
|
|
100
|
+
this.kind = kind;
|
|
101
|
+
this.gitOutput = gitOutput;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const readableSources = /* @__PURE__ */ new Set();
|
|
105
|
+
/**
|
|
106
|
+
* Fail before cloning when a git url source cannot be read. The probe runs
|
|
107
|
+
* with every prompt off and, on POSIX, in its own session with no terminal,
|
|
108
|
+
* so a missing credential is an answer instead of a password prompt. The
|
|
109
|
+
* timeout kills the whole process group: git's transport helper holds stderr
|
|
110
|
+
* open after git itself exits.
|
|
111
|
+
*/
|
|
112
|
+
async function checkSourceAccess(source, { timeoutMs = 3e4 } = {}) {
|
|
113
|
+
if (!isGitUrl(source) || readableSources.has(source)) return;
|
|
114
|
+
const detached = process.platform !== "win32";
|
|
115
|
+
const child = spawn("git", [
|
|
116
|
+
"ls-remote",
|
|
117
|
+
source,
|
|
118
|
+
"HEAD"
|
|
119
|
+
], {
|
|
120
|
+
detached,
|
|
121
|
+
stdio: [
|
|
122
|
+
"ignore",
|
|
123
|
+
"ignore",
|
|
124
|
+
"pipe"
|
|
125
|
+
],
|
|
126
|
+
windowsHide: true,
|
|
127
|
+
env: {
|
|
128
|
+
...process.env,
|
|
129
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
130
|
+
GIT_ASKPASS: "",
|
|
131
|
+
SSH_ASKPASS_REQUIRE: "never",
|
|
132
|
+
GCM_INTERACTIVE: "never",
|
|
133
|
+
LC_ALL: "C"
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
let stderr = "";
|
|
137
|
+
child.stderr.on("data", (chunk) => {
|
|
138
|
+
stderr += chunk.toString();
|
|
139
|
+
});
|
|
140
|
+
let timedOut = false;
|
|
141
|
+
const timer = setTimeout(() => {
|
|
142
|
+
timedOut = true;
|
|
143
|
+
try {
|
|
144
|
+
if (detached && child.pid) process.kill(-child.pid, "SIGKILL");
|
|
145
|
+
else child.kill("SIGKILL");
|
|
146
|
+
} catch {}
|
|
147
|
+
}, timeoutMs);
|
|
148
|
+
const code = await new Promise((resolveExit, reject) => {
|
|
149
|
+
child.on("error", (err) => reject(err.code === "ENOENT" ? /* @__PURE__ */ new Error(`git is required to scaffold from ${source}`) : err));
|
|
150
|
+
child.on("close", resolveExit);
|
|
151
|
+
}).finally(() => clearTimeout(timer));
|
|
152
|
+
if (timedOut) throw new SourceAccessError(source, "network", stderr, `no answer within ${Math.round(timeoutMs / 1e3)}s`);
|
|
153
|
+
if (code !== 0) throw new SourceAccessError(source, classifySourceFailure(stderr), stderr);
|
|
154
|
+
readableSources.add(source);
|
|
155
|
+
}
|
|
61
156
|
|
|
62
157
|
//#endregion
|
|
63
|
-
export { isGitUrl, monorepoCheckout, monorepoUrl, readSourceStamp, resolveSource };
|
|
158
|
+
export { SourceAccessError, checkSourceAccess, classifySourceFailure, isGitUrl, monorepoCheckout, monorepoUrl, readSourceStamp, resolveSource };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplatform.one/cli",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.29.0",
|
|
4
4
|
"description": "multiplatform.one cli — mpo init clones and prunes the monorepo, mpo update merges it back",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"create-multiplatform-app",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"access": "public"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
|
-
"@multiplatform.one/utils": "7.
|
|
59
|
+
"@multiplatform.one/utils": "7.29.0",
|
|
60
60
|
"commander": "^14.0.3",
|
|
61
61
|
"dotenv": "^17.4.2",
|
|
62
62
|
"inquirer": "^13.4.2",
|
package/scripts/scaffold.sh
CHANGED
|
@@ -287,7 +287,7 @@ else
|
|
|
287
287
|
# shellcheck disable=SC2086
|
|
288
288
|
git -C "$REPO" archive --format=tar "$SHA" $PATHS | tar -xf - -C "$TREE"
|
|
289
289
|
fi
|
|
290
|
-
rm -rf "$TREE/apps/storybook/.lostpixel/baseline"
|
|
290
|
+
rm -rf "$TREE/apps/storybook/.lostpixel/baseline" "$TREE/apps/storybook/.lostpixel/matrix-tier2/baseline"
|
|
291
291
|
# patches/public/ patches packages only the published packages' build
|
|
292
292
|
# installs; pnpm fails an install whose patch matches nothing.
|
|
293
293
|
case " $REMOVED " in
|
package/src/commands/scaffold.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import spawn from "nano-spawn";
|
|
5
|
+
import { checkSourceAccess } from "../source";
|
|
5
6
|
import { cliRoot, readProvenance, type MpoProvenance } from "./provenance";
|
|
6
7
|
|
|
7
8
|
/** The scaffold itself: POSIX sh, runs standalone. The TS commands only call it. */
|
|
@@ -32,11 +33,10 @@ export interface ScaffoldRequest {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
export class ScaffoldError extends Error {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
readonly request: ScaffoldRequest,
|
|
38
|
-
) {
|
|
36
|
+
readonly request: ScaffoldRequest;
|
|
37
|
+
constructor(message: string, request: ScaffoldRequest) {
|
|
39
38
|
super(message);
|
|
39
|
+
this.request = request;
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -54,6 +54,7 @@ function reportKept(stdout: string): void {
|
|
|
54
54
|
|
|
55
55
|
/** Run scaffold.sh and return the provenance it wrote. */
|
|
56
56
|
export async function runScaffold(request: ScaffoldRequest): Promise<MpoProvenance> {
|
|
57
|
+
await checkSourceAccess(request.source);
|
|
57
58
|
const args = [scaffoldScript()];
|
|
58
59
|
for (const app of request.apps) args.push("--app", app);
|
|
59
60
|
for (const service of request.services) args.push("--service", service);
|
|
@@ -113,6 +114,7 @@ export async function archiveSubtree(
|
|
|
113
114
|
subtree: string,
|
|
114
115
|
dest: string,
|
|
115
116
|
): Promise<void> {
|
|
117
|
+
await checkSourceAccess(source);
|
|
116
118
|
const script = [
|
|
117
119
|
"set -eu",
|
|
118
120
|
'SOURCE="$1"; REF="$2"; SUBTREE="$3"; DEST="$4"',
|
package/src/source.spec.ts
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createServer, type Server } from "node:http";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { type AddressInfo, createServer as createTcpServer, type Socket } from "node:net";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
2
7
|
import { join } from "node:path";
|
|
3
8
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { describe, expect, it } from "vitest";
|
|
5
|
-
import {
|
|
9
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
10
|
+
import {
|
|
11
|
+
checkSourceAccess,
|
|
12
|
+
classifySourceFailure,
|
|
13
|
+
isGitUrl,
|
|
14
|
+
monorepoCheckout,
|
|
15
|
+
resolveSource,
|
|
16
|
+
SourceAccessError,
|
|
17
|
+
} from "./source";
|
|
6
18
|
|
|
7
19
|
const monorepo = fileURLToPath(new URL("../../..", import.meta.url)).replace(/\/$/, "");
|
|
8
20
|
|
|
@@ -43,3 +55,177 @@ describe("resolveSource", () => {
|
|
|
43
55
|
}
|
|
44
56
|
});
|
|
45
57
|
});
|
|
58
|
+
|
|
59
|
+
function listen(server: Server | ReturnType<typeof createTcpServer>): Promise<number> {
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
server.listen(0, "127.0.0.1", () => resolve((server.address() as AddressInfo).port));
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function close(server: Server | ReturnType<typeof createTcpServer>): Promise<void> {
|
|
66
|
+
return new Promise((resolve) => server.close(() => resolve()));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Sources the CLI cannot read: a server that answers every request the way
|
|
71
|
+
* GitLab answers an anonymous clone of a private project, a port nothing
|
|
72
|
+
* listens on, and a server that accepts and never answers. Git runs with no
|
|
73
|
+
* credential helper, no global or system config, and an askpass program that
|
|
74
|
+
* never answers, so any prompt it reaches hangs the run.
|
|
75
|
+
*/
|
|
76
|
+
describe("a source the CLI cannot read", () => {
|
|
77
|
+
const sandbox = mkdtempSync(join(tmpdir(), "mpo-source-access-"));
|
|
78
|
+
const home = join(sandbox, "home");
|
|
79
|
+
const prompt = join(sandbox, "prompt.sh");
|
|
80
|
+
const sockets = new Set<Socket>();
|
|
81
|
+
const denying = createServer((_request, response) => {
|
|
82
|
+
response.writeHead(401, {
|
|
83
|
+
"Content-Type": "text/plain",
|
|
84
|
+
"WWW-Authenticate": 'Basic realm="GitLab"',
|
|
85
|
+
});
|
|
86
|
+
response.end("HTTP Basic: Access denied\n");
|
|
87
|
+
});
|
|
88
|
+
const silent = createTcpServer((socket) => {
|
|
89
|
+
sockets.add(socket);
|
|
90
|
+
});
|
|
91
|
+
let deniedUrl = "";
|
|
92
|
+
let refusedUrl = "";
|
|
93
|
+
let silentUrl = "";
|
|
94
|
+
const env: Record<string, string> = {
|
|
95
|
+
HOME: home,
|
|
96
|
+
XDG_CONFIG_HOME: join(home, ".config"),
|
|
97
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
98
|
+
GIT_ASKPASS: prompt,
|
|
99
|
+
SSH_ASKPASS: prompt,
|
|
100
|
+
no_proxy: "*",
|
|
101
|
+
NO_PROXY: "*",
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
beforeAll(async () => {
|
|
105
|
+
mkdirSync(home, { recursive: true });
|
|
106
|
+
writeFileSync(prompt, "#!/bin/sh\nsleep 300\n");
|
|
107
|
+
chmodSync(prompt, 0o755);
|
|
108
|
+
for (const [name, value] of Object.entries(env)) vi.stubEnv(name, value);
|
|
109
|
+
deniedUrl = `http://127.0.0.1:${await listen(denying)}/multiplatform.one/mpo.git`;
|
|
110
|
+
silentUrl = `http://127.0.0.1:${await listen(silent)}/multiplatform.one/mpo.git`;
|
|
111
|
+
const closed = createTcpServer();
|
|
112
|
+
const refusedPort = await listen(closed);
|
|
113
|
+
await close(closed);
|
|
114
|
+
refusedUrl = `http://127.0.0.1:${refusedPort}/multiplatform.one/mpo.git`;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
afterAll(async () => {
|
|
118
|
+
vi.unstubAllEnvs();
|
|
119
|
+
for (const socket of sockets) socket.destroy();
|
|
120
|
+
await Promise.all([close(denying), close(silent)]);
|
|
121
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("an unauthorized source is an access error naming the url, without waiting on a prompt", async () => {
|
|
125
|
+
const started = Date.now();
|
|
126
|
+
const error = await checkSourceAccess(deniedUrl).catch((err: unknown) => err);
|
|
127
|
+
expect(Date.now() - started).toBeLessThan(15_000);
|
|
128
|
+
expect(error).toBeInstanceOf(SourceAccessError);
|
|
129
|
+
const { kind, url, message } = error as SourceAccessError;
|
|
130
|
+
expect(kind).toBe("auth");
|
|
131
|
+
expect(url).toBe(deniedUrl);
|
|
132
|
+
expect(message).toContain(deniedUrl);
|
|
133
|
+
expect(message).toMatch(/needs read access/);
|
|
134
|
+
expect(message).toContain("--source <path|url>");
|
|
135
|
+
}, 20_000);
|
|
136
|
+
|
|
137
|
+
it("an unreachable source is a network error naming the url", async () => {
|
|
138
|
+
const error = await checkSourceAccess(refusedUrl).catch((err: unknown) => err);
|
|
139
|
+
expect(error).toBeInstanceOf(SourceAccessError);
|
|
140
|
+
expect((error as SourceAccessError).kind).toBe("network");
|
|
141
|
+
expect((error as SourceAccessError).message).toContain(refusedUrl);
|
|
142
|
+
expect((error as SourceAccessError).message).toMatch(/cannot reach/i);
|
|
143
|
+
}, 20_000);
|
|
144
|
+
|
|
145
|
+
it("a source that never answers gives up at the timeout", async () => {
|
|
146
|
+
const started = Date.now();
|
|
147
|
+
const error = await checkSourceAccess(silentUrl, { timeoutMs: 1_000 }).catch(
|
|
148
|
+
(err: unknown) => err,
|
|
149
|
+
);
|
|
150
|
+
expect(Date.now() - started).toBeLessThan(10_000);
|
|
151
|
+
expect(error).toBeInstanceOf(SourceAccessError);
|
|
152
|
+
expect((error as SourceAccessError).kind).toBe("network");
|
|
153
|
+
expect((error as SourceAccessError).message).toContain(silentUrl);
|
|
154
|
+
expect((error as SourceAccessError).message).toMatch(/no answer within 1s/);
|
|
155
|
+
}, 20_000);
|
|
156
|
+
|
|
157
|
+
it("a readable url and a local path pass", async () => {
|
|
158
|
+
const bare = join(sandbox, "empty.git");
|
|
159
|
+
spawnSync("git", ["init", "--quiet", "--bare", bare]);
|
|
160
|
+
await expect(checkSourceAccess(`file://${bare}`)).resolves.toBeUndefined();
|
|
161
|
+
await expect(checkSourceAccess(join(sandbox, "no-such-clone"))).resolves.toBeUndefined();
|
|
162
|
+
}, 20_000);
|
|
163
|
+
|
|
164
|
+
it("mpo init exits nonzero with the message and creates nothing", async () => {
|
|
165
|
+
const cli = fileURLToPath(new URL("..", import.meta.url));
|
|
166
|
+
const tsx = createRequire(import.meta.url).resolve("tsx");
|
|
167
|
+
const child = spawn(
|
|
168
|
+
process.execPath,
|
|
169
|
+
[
|
|
170
|
+
"--import",
|
|
171
|
+
tsx,
|
|
172
|
+
join(cli, "src/bin/multiplatformOne.ts"),
|
|
173
|
+
"init",
|
|
174
|
+
"my-app",
|
|
175
|
+
"--source",
|
|
176
|
+
deniedUrl,
|
|
177
|
+
"--yes",
|
|
178
|
+
"--skip-install",
|
|
179
|
+
"--skip-git",
|
|
180
|
+
],
|
|
181
|
+
{
|
|
182
|
+
cwd: sandbox,
|
|
183
|
+
env: { ...process.env, ...env, TSX_TSCONFIG_PATH: join(cli, "tsconfig.json") },
|
|
184
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
let stderr = "";
|
|
188
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
189
|
+
stderr += chunk.toString();
|
|
190
|
+
});
|
|
191
|
+
child.stdout.resume();
|
|
192
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), 45_000);
|
|
193
|
+
const [code, signal] = await new Promise<[number | null, NodeJS.Signals | null]>((resolve) =>
|
|
194
|
+
child.on("close", (exitCode, exitSignal) => resolve([exitCode, exitSignal])),
|
|
195
|
+
);
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
expect(signal, stderr).toBeNull();
|
|
198
|
+
expect(code, stderr).toBe(1);
|
|
199
|
+
expect(stderr).toContain(deniedUrl);
|
|
200
|
+
expect(stderr).toMatch(/needs read access/);
|
|
201
|
+
expect(existsSync(join(sandbox, "my-app"))).toBe(false);
|
|
202
|
+
}, 60_000);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("classifySourceFailure", () => {
|
|
206
|
+
it("tells access failures from network failures", () => {
|
|
207
|
+
for (const text of [
|
|
208
|
+
"remote: HTTP Basic: Access denied. If a password was provided for Git authentication, the password was incorrect",
|
|
209
|
+
"fatal: Authentication failed for 'https://git.corp.bitspur.com/multiplatform.one/mpo.git/'",
|
|
210
|
+
"fatal: could not read Username for 'https://git.corp.bitspur.com': terminal prompts disabled",
|
|
211
|
+
"git@git.corp.bitspur.com: Permission denied (publickey).\nfatal: Could not read from remote repository.",
|
|
212
|
+
"remote: The project you were looking for could not be found or you don't have permission to view it.",
|
|
213
|
+
"remote: Repository not found.",
|
|
214
|
+
"fatal: unable to access 'https://host/repo.git/': The requested URL returned error: 403",
|
|
215
|
+
"Host key verification failed.\nfatal: Could not read from remote repository.",
|
|
216
|
+
]) {
|
|
217
|
+
expect(classifySourceFailure(text), text).toBe("auth");
|
|
218
|
+
}
|
|
219
|
+
for (const text of [
|
|
220
|
+
"fatal: unable to access 'https://git.corp.bitspur.com/x.git/': Could not resolve host: git.corp.bitspur.com",
|
|
221
|
+
"ssh: Could not resolve hostname git.corp.bitspur.com: nodename nor servname provided\nfatal: Could not read from remote repository.",
|
|
222
|
+
"fatal: unable to access 'http://127.0.0.1:9/x.git/': Failed to connect to 127.0.0.1 port 9 after 0 ms: Couldn't connect to server",
|
|
223
|
+
"ssh: connect to host git.corp.bitspur.com port 22: Connection refused",
|
|
224
|
+
"ssh: connect to host git.corp.bitspur.com port 22: Operation timed out",
|
|
225
|
+
"fatal: unable to access 'https://host/x.git/': SSL certificate problem: unable to get local issuer certificate",
|
|
226
|
+
]) {
|
|
227
|
+
expect(classifySourceFailure(text), text).toBe("network");
|
|
228
|
+
}
|
|
229
|
+
expect(classifySourceFailure("fatal: something else went wrong")).toBe("unknown");
|
|
230
|
+
});
|
|
231
|
+
});
|
package/src/source.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
1
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
3
|
import { join, resolve } from "node:path";
|
|
3
4
|
import { cliRoot, cliVersion } from "./commands/provenance";
|
|
@@ -86,3 +87,125 @@ export function resolveSource(flags: { source?: string; checkout?: string } = {}
|
|
|
86
87
|
`scaffolding from ${ref} instead`,
|
|
87
88
|
};
|
|
88
89
|
}
|
|
90
|
+
|
|
91
|
+
export type SourceFailure = "auth" | "network" | "unknown";
|
|
92
|
+
|
|
93
|
+
const authFailures = [
|
|
94
|
+
/access denied/i,
|
|
95
|
+
/authentication failed/i,
|
|
96
|
+
/could not read (username|password)/i,
|
|
97
|
+
/terminal prompts disabled/i,
|
|
98
|
+
/permission denied \(/i,
|
|
99
|
+
/host key verification failed/i,
|
|
100
|
+
/returned error: 40[13]/i,
|
|
101
|
+
/repository not found/i,
|
|
102
|
+
/you don't have permission/i,
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
const networkFailures = [
|
|
106
|
+
/could not resolve (host|hostname|proxy)/i,
|
|
107
|
+
/failed to connect/i,
|
|
108
|
+
/couldn't connect/i,
|
|
109
|
+
/connection (refused|reset|timed out|closed)/i,
|
|
110
|
+
/operation timed out/i,
|
|
111
|
+
/network is unreachable/i,
|
|
112
|
+
/no route to host/i,
|
|
113
|
+
/ssl|certificate/i,
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
/** What a failed `git ls-remote` says about why the source is unreadable. */
|
|
117
|
+
export function classifySourceFailure(stderr: string): SourceFailure {
|
|
118
|
+
if (authFailures.some((pattern) => pattern.test(stderr))) return "auth";
|
|
119
|
+
if (networkFailures.some((pattern) => pattern.test(stderr))) return "network";
|
|
120
|
+
return "unknown";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export class SourceAccessError extends Error {
|
|
124
|
+
readonly url: string;
|
|
125
|
+
readonly kind: SourceFailure;
|
|
126
|
+
readonly gitOutput: string;
|
|
127
|
+
constructor(url: string, kind: SourceFailure, gitOutput: string, reason?: string) {
|
|
128
|
+
const host = /^[a-z]+:\/\/(?:[^@/]*@)?([^/:]+)/i.exec(url)?.[1] ?? /@([^:]+):/.exec(url)?.[1];
|
|
129
|
+
const lines =
|
|
130
|
+
kind === "network"
|
|
131
|
+
? [
|
|
132
|
+
`Cannot reach the source repository ${url}${reason ? `: ${reason}` : ""}.`,
|
|
133
|
+
`The CLI scaffolds from this git repository, so it needs a network path to ${host ?? "its host"} (check the connection, proxy or VPN).`,
|
|
134
|
+
]
|
|
135
|
+
: [
|
|
136
|
+
`Cannot read the source repository ${url}${kind === "auth" ? ": access denied" : ""}.`,
|
|
137
|
+
"The CLI scaffolds from this git repository, so it needs read access to it: an account that can clone it, and a credential git uses without asking (a token in a git credential helper for https, an SSH key for ssh).",
|
|
138
|
+
];
|
|
139
|
+
lines.push(`Check with: git ls-remote ${url}`);
|
|
140
|
+
lines.push("Or scaffold from a copy you can read: --source <path|url>");
|
|
141
|
+
const detail = gitOutput
|
|
142
|
+
.split("\n")
|
|
143
|
+
.map((line) => line.trim())
|
|
144
|
+
.filter(Boolean)
|
|
145
|
+
.slice(-4);
|
|
146
|
+
if (detail.length) lines.push("git said:", ...detail.map((line) => ` ${line}`));
|
|
147
|
+
super(lines.join("\n"));
|
|
148
|
+
this.name = "SourceAccessError";
|
|
149
|
+
this.url = url;
|
|
150
|
+
this.kind = kind;
|
|
151
|
+
this.gitOutput = gitOutput;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const readableSources = new Set<string>();
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Fail before cloning when a git url source cannot be read. The probe runs
|
|
159
|
+
* with every prompt off and, on POSIX, in its own session with no terminal,
|
|
160
|
+
* so a missing credential is an answer instead of a password prompt. The
|
|
161
|
+
* timeout kills the whole process group: git's transport helper holds stderr
|
|
162
|
+
* open after git itself exits.
|
|
163
|
+
*/
|
|
164
|
+
export async function checkSourceAccess(
|
|
165
|
+
source: string,
|
|
166
|
+
{ timeoutMs = 30_000 }: { timeoutMs?: number } = {},
|
|
167
|
+
): Promise<void> {
|
|
168
|
+
if (!isGitUrl(source) || readableSources.has(source)) return;
|
|
169
|
+
const detached = process.platform !== "win32";
|
|
170
|
+
const child = spawn("git", ["ls-remote", source, "HEAD"], {
|
|
171
|
+
detached,
|
|
172
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
173
|
+
windowsHide: true,
|
|
174
|
+
env: {
|
|
175
|
+
...process.env,
|
|
176
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
177
|
+
GIT_ASKPASS: "",
|
|
178
|
+
SSH_ASKPASS_REQUIRE: "never",
|
|
179
|
+
GCM_INTERACTIVE: "never",
|
|
180
|
+
LC_ALL: "C",
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
let stderr = "";
|
|
184
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
185
|
+
stderr += chunk.toString();
|
|
186
|
+
});
|
|
187
|
+
let timedOut = false;
|
|
188
|
+
const timer = setTimeout(() => {
|
|
189
|
+
timedOut = true;
|
|
190
|
+
try {
|
|
191
|
+
if (detached && child.pid) process.kill(-child.pid, "SIGKILL");
|
|
192
|
+
else child.kill("SIGKILL");
|
|
193
|
+
} catch {}
|
|
194
|
+
}, timeoutMs);
|
|
195
|
+
const code = await new Promise<number | null>((resolveExit, reject) => {
|
|
196
|
+
child.on("error", (err: NodeJS.ErrnoException) =>
|
|
197
|
+
reject(err.code === "ENOENT" ? new Error(`git is required to scaffold from ${source}`) : err),
|
|
198
|
+
);
|
|
199
|
+
child.on("close", resolveExit);
|
|
200
|
+
}).finally(() => clearTimeout(timer));
|
|
201
|
+
if (timedOut) {
|
|
202
|
+
throw new SourceAccessError(
|
|
203
|
+
source,
|
|
204
|
+
"network",
|
|
205
|
+
stderr,
|
|
206
|
+
`no answer within ${Math.round(timeoutMs / 1000)}s`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (code !== 0) throw new SourceAccessError(source, classifySourceFailure(stderr), stderr);
|
|
210
|
+
readableSources.add(source);
|
|
211
|
+
}
|
package/templates/AGENTS.md
CHANGED
|
@@ -41,15 +41,15 @@ stack-navigation seam (`@multiplatform.one/router/seam`).
|
|
|
41
41
|
|
|
42
42
|
## Which package for what
|
|
43
43
|
|
|
44
|
-
| Package | Owns
|
|
45
|
-
| ------------------------------- |
|
|
46
|
-
| `@multiplatform.one/theme` | Knobs, presets, intents, recipes, `useResolvedKnobs`
|
|
47
|
-
| `@multiplatform.one/components` | Catalog UI (layouts, surfaces, feedback, …)
|
|
48
|
-
| `@multiplatform.one/forms` | Fields, Form, InputParts, field contracts, form color ramps
|
|
49
|
-
| `@multiplatform.one/table` | DataTable, cells, filters, URL table state
|
|
50
|
-
| `@multiplatform.one/frappe-ui` | Frappe adapters (`FrappeTable`, field registry)
|
|
51
|
-
| `@multiplatform.one/frappe` | Frappe client / live query
|
|
52
|
-
| `@multiplatform.one/backoffice` | Embeddable desk recreation (BackofficeProvider, shell/list/form surfaces)
|
|
44
|
+
| Package | Owns |
|
|
45
|
+
| ------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
46
|
+
| `@multiplatform.one/theme` | Knobs, presets, intents, recipes, `useResolvedKnobs` |
|
|
47
|
+
| `@multiplatform.one/components` | Catalog UI (layouts, surfaces, feedback, …) |
|
|
48
|
+
| `@multiplatform.one/forms` | Fields, Form, InputParts, field contracts, form color ramps |
|
|
49
|
+
| `@multiplatform.one/table` | DataTable, cells, filters, URL table state |
|
|
50
|
+
| `@multiplatform.one/frappe-ui` | Frappe adapters (`FrappeTable`, field registry, the query-report and report-view models) |
|
|
51
|
+
| `@multiplatform.one/frappe` | Frappe client / live query |
|
|
52
|
+
| `@multiplatform.one/backoffice` | Embeddable desk recreation (BackofficeProvider, shell/list/form surfaces) |
|
|
53
53
|
|
|
54
54
|
`table` and `forms` know nothing of Frappe; Frappe integration belongs in
|
|
55
55
|
`frappe-ui`. `features/backoffice` is a thin wrapper over
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scaffold.d.ts","sourceRoot":"","sources":["../../src/commands/scaffold.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"scaffold.d.ts","sourceRoot":"","sources":["../../src/commands/scaffold.ts"],"names":[],"mappings":"AAKA,OAAO,EAA2B,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC;AAE3E,oFAAoF;AACpF,wBAAgB,cAAc,IAAI,MAAM,CAEvC;AAED,MAAM,WAAW,eAAe;IAC9B,0CAA0C;IAC1C,MAAM,EAAE,MAAM,CAAC;IACf,6BAA6B;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,0CAA0C;IAC1C,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,uCAAuC;IACvC,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,mFAAmF;IACnF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,kFAAkF;IAClF,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,+DAA+D;IAC/D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,uDAAuD;IACvD,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,qBAAa,aAAc,SAAQ,KAAK;IACtC,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;gBACtB,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,eAAe;CAItD;AAcD,0DAA0D;AAC1D,wBAAsB,WAAW,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,aAAa,CAAC,CA2BlF;AAED,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,aAAa,CAAC;IAC1B,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,kDAAkD;AAClD,wBAAsB,cAAc,CAClC,OAAO,EAAE,IAAI,CAAC,eAAe,EAAE,QAAQ,GAAG,MAAM,CAAC,GAChD,OAAO,CAAC,YAAY,CAAC,CAWvB;AAED;;;GAGG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,IAAI,CAAC,CAiBf"}
|
package/types/source.d.ts
CHANGED
|
@@ -32,4 +32,23 @@ export declare function resolveSource(flags?: {
|
|
|
32
32
|
source?: string;
|
|
33
33
|
checkout?: string;
|
|
34
34
|
}): SourceSpec;
|
|
35
|
+
export type SourceFailure = "auth" | "network" | "unknown";
|
|
36
|
+
/** What a failed `git ls-remote` says about why the source is unreadable. */
|
|
37
|
+
export declare function classifySourceFailure(stderr: string): SourceFailure;
|
|
38
|
+
export declare class SourceAccessError extends Error {
|
|
39
|
+
readonly url: string;
|
|
40
|
+
readonly kind: SourceFailure;
|
|
41
|
+
readonly gitOutput: string;
|
|
42
|
+
constructor(url: string, kind: SourceFailure, gitOutput: string, reason?: string);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Fail before cloning when a git url source cannot be read. The probe runs
|
|
46
|
+
* with every prompt off and, on POSIX, in its own session with no terminal,
|
|
47
|
+
* so a missing credential is an answer instead of a password prompt. The
|
|
48
|
+
* timeout kills the whole process group: git's transport helper holds stderr
|
|
49
|
+
* open after git itself exits.
|
|
50
|
+
*/
|
|
51
|
+
export declare function checkSourceAccess(source: string, { timeoutMs }?: {
|
|
52
|
+
timeoutMs?: number;
|
|
53
|
+
}): Promise<void>;
|
|
35
54
|
//# sourceMappingURL=source.d.ts.map
|
package/types/source.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"source.d.ts","sourceRoot":"","sources":["../src/source.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"source.d.ts","sourceRoot":"","sources":["../src/source.ts"],"names":[],"mappings":"AAKA,eAAO,MAAM,WAAW,2DAA2D,CAAC;AAEpF,MAAM,WAAW,UAAU;IACzB,2CAA2C;IAC3C,MAAM,EAAE,MAAM,CAAC;IACf,8CAA8C;IAC9C,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;OAKG;IACH,MAAM,EAAE,MAAM,GAAG,UAAU,GAAG,OAAO,GAAG,KAAK,CAAC;IAC9C,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEhD;AAED,6FAA6F;AAC7F,wBAAgB,gBAAgB,IAAI,MAAM,GAAG,SAAS,CASrD;AAED,wBAAgB,eAAe,IAAI,WAAW,GAAG,SAAS,CAMzD;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,KAAK,GAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,UAAU,CA8B5F;AAED,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,SAAS,GAAG,SAAS,CAAC;AAyB3D,6EAA6E;AAC7E,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,aAAa,CAInE;AAED,qBAAa,iBAAkB,SAAQ,KAAK;IAC1C,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAC7B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;gBACf,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM;CA0BjF;AAID;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,MAAM,EACd,EAAE,SAAkB,EAAE,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GAClD,OAAO,CAAC,IAAI,CAAC,CA4Cf"}
|