@akira-tl/forgerelay 0.8.4 → 0.8.5
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/CHANGELOG.md +15 -0
- package/README.md +2 -0
- package/dist/remote-workspace-relay.js +150 -22
- package/dist/server.js +64 -25
- package/package.json +4 -1
- package/scripts/debug/relay-accept.mjs +890 -0
- package/scripts/wiki/sync.mjs +246 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
cpSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
statSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { basename, join, resolve } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
|
|
17
|
+
const scriptDirectory = resolve(fileURLToPath(new URL(".", import.meta.url)));
|
|
18
|
+
const repositoryRoot = resolve(scriptDirectory, "..", "..");
|
|
19
|
+
const sourceDirectory = join(repositoryRoot, "docs", "wiki");
|
|
20
|
+
const command = process.argv[2] ?? "check";
|
|
21
|
+
|
|
22
|
+
class WikiSyncError extends Error {}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
if (!new Set(["check", "publish"]).has(command)) {
|
|
26
|
+
fail(`Unknown wiki command: ${command}\nUsage: node scripts/wiki/sync.mjs <check|publish>`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const sourceFiles = validateWikiSource(sourceDirectory);
|
|
30
|
+
console.log(`Wiki source check passed (${sourceFiles.length} files).`);
|
|
31
|
+
|
|
32
|
+
if (command === "publish") {
|
|
33
|
+
publishWiki();
|
|
34
|
+
}
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error instanceof WikiSyncError) {
|
|
37
|
+
console.error(error.message);
|
|
38
|
+
} else if (error instanceof Error) {
|
|
39
|
+
console.error(error.stack ?? error.message);
|
|
40
|
+
} else {
|
|
41
|
+
console.error(String(error));
|
|
42
|
+
}
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validateWikiSource(directory) {
|
|
47
|
+
if (!existsSync(directory) || !statSync(directory).isDirectory()) {
|
|
48
|
+
fail(`Wiki source directory does not exist: ${directory}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const files = listFiles(directory);
|
|
52
|
+
const names = new Set(files.map((file) => file.relativePath));
|
|
53
|
+
|
|
54
|
+
for (const required of ["Home.md", "_Sidebar.md"]) {
|
|
55
|
+
if (!names.has(required)) {
|
|
56
|
+
fail(`Wiki source is missing required page: ${required}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const invalidNames = files
|
|
61
|
+
.map((file) => file.relativePath)
|
|
62
|
+
.filter((name) => !isSafeWikiPath(name));
|
|
63
|
+
if (invalidNames.length > 0) {
|
|
64
|
+
fail(`Wiki source contains unsupported paths:\n${invalidNames.map((name) => `- ${name}`).join("\n")}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const markdownNames = new Set(
|
|
68
|
+
files
|
|
69
|
+
.filter((file) => file.relativePath.endsWith(".md"))
|
|
70
|
+
.map((file) => file.relativePath),
|
|
71
|
+
);
|
|
72
|
+
const brokenLinks = [];
|
|
73
|
+
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
if (!file.relativePath.endsWith(".md")) continue;
|
|
76
|
+
const text = readFileSync(file.absolutePath, "utf8");
|
|
77
|
+
for (const match of text.matchAll(/\[[^\]]+\]\(([^)]+)\)/g)) {
|
|
78
|
+
const rawTarget = match[1]?.trim();
|
|
79
|
+
if (!rawTarget || isExternalOrAnchorLink(rawTarget)) continue;
|
|
80
|
+
|
|
81
|
+
const target = decodeWikiTarget(rawTarget);
|
|
82
|
+
if (!target) continue;
|
|
83
|
+
if (target.startsWith("../") || target.startsWith("./")) {
|
|
84
|
+
brokenLinks.push(`${file.relativePath}: relative repository link ${rawTarget}`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const candidate = target.endsWith(".md") ? target : `${target}.md`;
|
|
89
|
+
if (!markdownNames.has(candidate)) {
|
|
90
|
+
brokenLinks.push(`${file.relativePath}: ${rawTarget}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (brokenLinks.length > 0) {
|
|
96
|
+
fail(`Wiki source has unresolved local links:\n${brokenLinks.map((entry) => `- ${entry}`).join("\n")}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return files;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function publishWiki() {
|
|
103
|
+
const repository = process.env.GITHUB_REPOSITORY?.trim() || "Akira-TL/forgerelay";
|
|
104
|
+
const remote = process.env.FORGERELAY_WIKI_REMOTE?.trim() || `git@github.com:${repository}.wiki.git`;
|
|
105
|
+
const sourceRef = process.env.FORGERELAY_WIKI_SOURCE_REF?.trim() || currentSourceRef();
|
|
106
|
+
const temporaryRoot = mkdtempSync(join(tmpdir(), "forgerelay-wiki-"));
|
|
107
|
+
const wikiCheckout = join(temporaryRoot, "wiki");
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const clone = runGit(["clone", "--quiet", remote, wikiCheckout], repositoryRoot, { allowFailure: true });
|
|
111
|
+
if (clone.status !== 0) {
|
|
112
|
+
const message = sanitizeGitOutput(`${clone.stdout}\n${clone.stderr}`, remote);
|
|
113
|
+
if (/repository not found|not found/i.test(message)) {
|
|
114
|
+
console.error(
|
|
115
|
+
[
|
|
116
|
+
"ForgeRelay GitHub Wiki has not been initialized yet.",
|
|
117
|
+
`Create the first page once at https://github.com/${repository}/wiki, then rerun wiki publishing.`,
|
|
118
|
+
"GitHub does not create the cloneable .wiki.git repository until the first Wiki page exists.",
|
|
119
|
+
].join("\n"),
|
|
120
|
+
);
|
|
121
|
+
process.exitCode = 2;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
fail(`Could not clone the Wiki repository:\n${message || `git exited with ${clone.status}`}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
mirrorSource(sourceDirectory, wikiCheckout);
|
|
128
|
+
runGit(["add", "--all"], wikiCheckout);
|
|
129
|
+
|
|
130
|
+
const diff = runGit(["diff", "--cached", "--quiet"], wikiCheckout, { allowFailure: true });
|
|
131
|
+
if (diff.status === 0) {
|
|
132
|
+
console.log("GitHub Wiki is already up to date.");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (diff.status !== 1) {
|
|
136
|
+
fail(`Could not inspect Wiki changes (git diff exited with ${diff.status}).`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
runGit(["config", "user.name", process.env.FORGERELAY_WIKI_GIT_NAME?.trim() || "ForgeRelay Wiki Sync"], wikiCheckout);
|
|
140
|
+
runGit(
|
|
141
|
+
[
|
|
142
|
+
"config",
|
|
143
|
+
"user.email",
|
|
144
|
+
process.env.FORGERELAY_WIKI_GIT_EMAIL?.trim() || "41898282+github-actions[bot]@users.noreply.github.com",
|
|
145
|
+
],
|
|
146
|
+
wikiCheckout,
|
|
147
|
+
);
|
|
148
|
+
runGit(["commit", "--quiet", "-m", `Sync Wiki from ${sourceRef}`], wikiCheckout);
|
|
149
|
+
|
|
150
|
+
if (process.env.FORGERELAY_WIKI_DRY_RUN === "1") {
|
|
151
|
+
console.log(`Wiki mirror prepared from ${sourceRef}; dry-run requested, skipping push.`);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const push = runGit(["push", "--quiet", "origin", "HEAD"], wikiCheckout, { allowFailure: true });
|
|
156
|
+
if (push.status !== 0) {
|
|
157
|
+
const message = sanitizeGitOutput(`${push.stdout}\n${push.stderr}`, remote);
|
|
158
|
+
fail(`Could not push the Wiki mirror:\n${message || `git exited with ${push.status}`}`);
|
|
159
|
+
}
|
|
160
|
+
console.log(`Published GitHub Wiki from ${sourceRef}.`);
|
|
161
|
+
} finally {
|
|
162
|
+
rmSync(temporaryRoot, { recursive: true, force: true });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function mirrorSource(source, destination) {
|
|
167
|
+
for (const entry of readdirSync(destination)) {
|
|
168
|
+
if (entry === ".git") continue;
|
|
169
|
+
rmSync(join(destination, entry), { recursive: true, force: true });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for (const entry of readdirSync(source)) {
|
|
173
|
+
cpSync(join(source, entry), join(destination, entry), { recursive: true });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function listFiles(directory, prefix = "") {
|
|
178
|
+
const files = [];
|
|
179
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
180
|
+
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
181
|
+
const absolutePath = join(directory, entry.name);
|
|
182
|
+
if (entry.isDirectory()) {
|
|
183
|
+
files.push(...listFiles(absolutePath, relativePath));
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (!entry.isFile()) {
|
|
187
|
+
fail(`Wiki source contains a non-regular file: ${relativePath}`);
|
|
188
|
+
}
|
|
189
|
+
files.push({ relativePath, absolutePath });
|
|
190
|
+
}
|
|
191
|
+
return files.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isSafeWikiPath(relativePath) {
|
|
195
|
+
if (!relativePath.endsWith(".md")) return false;
|
|
196
|
+
if (relativePath.includes("/")) return false;
|
|
197
|
+
return !/[\\:*?"<>|]/.test(relativePath) && basename(relativePath) === relativePath;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function isExternalOrAnchorLink(target) {
|
|
201
|
+
return /^(?:https?:|mailto:|#)/i.test(target);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function decodeWikiTarget(target) {
|
|
205
|
+
const withoutAnchor = target.split("#", 1)[0]?.trim();
|
|
206
|
+
if (!withoutAnchor) return "";
|
|
207
|
+
try {
|
|
208
|
+
return decodeURIComponent(withoutAnchor);
|
|
209
|
+
} catch {
|
|
210
|
+
return withoutAnchor;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function currentSourceRef() {
|
|
215
|
+
const result = runGit(["rev-parse", "--short=12", "HEAD"], repositoryRoot, { allowFailure: true });
|
|
216
|
+
return result.status === 0 ? result.stdout.trim() : "local source";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function runGit(args, cwd, { allowFailure = false } = {}) {
|
|
220
|
+
const result = spawnSync("git", args, {
|
|
221
|
+
cwd,
|
|
222
|
+
env: process.env,
|
|
223
|
+
encoding: "utf8",
|
|
224
|
+
windowsHide: true,
|
|
225
|
+
shell: false,
|
|
226
|
+
});
|
|
227
|
+
if (result.error) throw result.error;
|
|
228
|
+
if (!allowFailure && result.status !== 0) {
|
|
229
|
+
const output = sanitizeGitOutput(`${result.stdout}\n${result.stderr}`, args.join(" "));
|
|
230
|
+
fail(`git ${args[0]} failed with exit ${result.status}:\n${output}`);
|
|
231
|
+
}
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function sanitizeGitOutput(output, secretSource) {
|
|
236
|
+
let sanitized = String(output ?? "").trim();
|
|
237
|
+
sanitized = sanitized.replace(/https:\/\/[^/@\s]+@github\.com\//gi, "https://github.com/");
|
|
238
|
+
if (secretSource && secretSource.includes("@") && /^https:\/\//i.test(secretSource)) {
|
|
239
|
+
sanitized = sanitized.split(secretSource).join("<wiki remote>");
|
|
240
|
+
}
|
|
241
|
+
return sanitized;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function fail(message) {
|
|
245
|
+
throw new WikiSyncError(message);
|
|
246
|
+
}
|