codex-snapshots 0.1.0 → 0.1.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/README.md +101 -6
- package/bin/codex-snapshot.mjs +1 -6326
- package/deploy/aliyun/README.md +311 -0
- package/deploy/aliyun/backup-share-data.sh +109 -0
- package/deploy/aliyun/check-ecs-status.sh +149 -0
- package/deploy/aliyun/codex-snapshot-share.env.example +29 -0
- package/deploy/aliyun/codex-snapshot-share.service +26 -0
- package/deploy/aliyun/configure-github-pages-api.sh +141 -0
- package/deploy/aliyun/configure-local-publisher.sh +197 -0
- package/deploy/aliyun/deploy-to-ecs.sh +669 -0
- package/deploy/aliyun/deploy.env.example +52 -0
- package/deploy/aliyun/doctor.mjs +398 -0
- package/deploy/aliyun/install-share-api.sh +252 -0
- package/deploy/aliyun/install-system-deps.sh +84 -0
- package/deploy/aliyun/nginx-codex-snapshots.bootstrap.conf +34 -0
- package/deploy/aliyun/nginx-codex-snapshots.conf +52 -0
- package/deploy/aliyun/preflight.mjs +321 -0
- package/deploy/aliyun/restore-share-data.sh +141 -0
- package/deploy/aliyun/verify-public-share.mjs +404 -0
- package/dist/cli/codex-snapshot.mjs +2654 -0
- package/dist/core/privacy.js +81 -0
- package/dist/core/snapshot.js +1 -0
- package/dist/renderers/markdown.mjs +81 -0
- package/dist/renderers/transcript.js +195 -0
- package/dist/server/http.js +10 -0
- package/dist/server/local-security.js +66 -0
- package/dist/server/local-viewer-app.mjs +1670 -0
- package/dist/server/local-viewer.mjs +210 -0
- package/dist/server/share-api.mjs +1149 -0
- package/dist/server/share-store.js +136 -0
- package/dist/shared/sanitize.js +126 -0
- package/dist/shared/transcript.js +1 -0
- package/dist/sources/index.mjs +2 -0
- package/dist/sources/local-history.mjs +2221 -0
- package/package.json +42 -14
- package/scripts/build-site.mjs +71 -0
- package/scripts/launch-agent.mjs +19 -227
- package/scripts/serve-site.mjs +2 -2
- package/scripts/test-aliyun-deploy-config.sh +230 -0
- package/scripts/test-share-api.mjs +967 -0
- package/scripts/test-site-config.mjs +100 -0
- package/scripts/test-static-site.mjs +403 -0
- package/scripts/write-site-config.mjs +161 -0
- package/server/share-api.mjs +1 -771
- package/site/assets/config.js +3 -0
- package/site/assets/share.js +43 -106
- package/site/assets/site.css +3 -605
- package/site/assets/site.js +15 -92
- package/site/favicon.svg +7 -0
- package/site/index.html +3 -83
- package/site/share/index.html +3 -8
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Copy to deploy/aliyun/deploy.env and edit locally.
|
|
2
|
+
# Keep deploy.env out of git because it contains OAuth secrets, optional tokens, and host details.
|
|
3
|
+
|
|
4
|
+
# ECS SSH target. Use root or a user with passwordless sudo.
|
|
5
|
+
SSH_TARGET=root@1.2.3.4
|
|
6
|
+
|
|
7
|
+
# Optional SSH settings.
|
|
8
|
+
SSH_IDENTITY_FILE=
|
|
9
|
+
SSH_PORT=
|
|
10
|
+
|
|
11
|
+
# Public API domain. Add an A record for this domain pointing to the ECS public IP.
|
|
12
|
+
DOMAIN=snapshots.example.com
|
|
13
|
+
API_URL=https://snapshots.example.com
|
|
14
|
+
|
|
15
|
+
# Public GitHub Pages site that renders sessions.
|
|
16
|
+
SITE_URL=https://ffffhx.github.io/codex-snapshots/
|
|
17
|
+
|
|
18
|
+
# Optional legacy token auth. GitHub OAuth mode ignores TOKEN=change-me.
|
|
19
|
+
# Generate a strong token with: openssl rand -base64 32
|
|
20
|
+
# Or set TOKEN=auto to let deploy-to-ecs.sh generate one for this deployment.
|
|
21
|
+
TOKEN=change-me
|
|
22
|
+
# GENERATE_TOKEN=0
|
|
23
|
+
|
|
24
|
+
# Optional GitHub OAuth publish/delete auth.
|
|
25
|
+
# Register an OAuth App callback URL:
|
|
26
|
+
# https://snapshots.example.com/api/auth/github/callback
|
|
27
|
+
# SNAPSHOT_GITHUB_CLIENT_ID=change-me
|
|
28
|
+
# SNAPSHOT_GITHUB_CLIENT_SECRET=change-me
|
|
29
|
+
# SNAPSHOT_SESSION_SECRET=change-me-generate-with-openssl-rand-base64-48
|
|
30
|
+
# SNAPSHOT_GITHUB_OWNER_LOGIN=your-github-login
|
|
31
|
+
# SNAPSHOT_GITHUB_OWNER_ID=
|
|
32
|
+
# SNAPSHOT_AUTH_ALLOWED_ORIGINS=https://ffffhx.github.io
|
|
33
|
+
|
|
34
|
+
# Certbot email used when ISSUE_CERT=1.
|
|
35
|
+
CERTBOT_EMAIL=you@example.com
|
|
36
|
+
|
|
37
|
+
# GitHub Pages repository/workflow.
|
|
38
|
+
PAGES_REPO=ffffhx/codex-snapshots
|
|
39
|
+
PAGES_WORKFLOW=pages.yml
|
|
40
|
+
|
|
41
|
+
# Deployment behavior. Use 1 for enabled, 0 for disabled.
|
|
42
|
+
INSTALL_DEPS=1
|
|
43
|
+
ISSUE_CERT=1
|
|
44
|
+
RUN_PREFLIGHT=1
|
|
45
|
+
RUN_VERIFY=1
|
|
46
|
+
CONFIGURE_PAGES=1
|
|
47
|
+
WAIT_PAGES=1
|
|
48
|
+
CONFIGURE_LOCAL=1
|
|
49
|
+
REINSTALL_DAEMON=1
|
|
50
|
+
|
|
51
|
+
# Remote rsync staging directory.
|
|
52
|
+
REMOTE_DIR=/tmp/codex-snapshots-deploy
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import dns from "node:dns/promises";
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
11
|
+
const DEFAULT_CONFIG = path.join(ROOT_DIR, "deploy/aliyun/deploy.env");
|
|
12
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
13
|
+
|
|
14
|
+
if (parsed.help) {
|
|
15
|
+
printHelp();
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
main().catch((error) => {
|
|
20
|
+
console.error(`✗ ${error instanceof Error ? error.message : String(error)}`);
|
|
21
|
+
process.exitCode = 1;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
async function main() {
|
|
25
|
+
const configPath = path.resolve(parsed.options.config || process.env.CODEX_SNAPSHOTS_ALIYUN_CONFIG || DEFAULT_CONFIG);
|
|
26
|
+
const checks = [];
|
|
27
|
+
|
|
28
|
+
if (!existsSync(configPath)) {
|
|
29
|
+
checks.push(fail("Config file", `not found: ${configPath}`));
|
|
30
|
+
printChecks(checks);
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
checks.push(pass("Config file", configPath));
|
|
36
|
+
|
|
37
|
+
const config = parseEnvFile(readFileSync(configPath, "utf8"));
|
|
38
|
+
const resolved = resolveConfig(config);
|
|
39
|
+
|
|
40
|
+
checks.push(...validateConfig(resolved));
|
|
41
|
+
checks.push(...checkLocalCommands(resolved));
|
|
42
|
+
checks.push(checkLocalTokenSource(resolved));
|
|
43
|
+
|
|
44
|
+
if (!parsed.options.offline && resolved.domain && !isPlaceholderDomain(resolved.domain)) {
|
|
45
|
+
checks.push(await checkDns(resolved.domain));
|
|
46
|
+
} else {
|
|
47
|
+
checks.push(skip("DNS", parsed.options.offline ? "offline mode" : "domain is not ready"));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
checks.push(runDryRun(configPath));
|
|
51
|
+
|
|
52
|
+
printChecks(checks);
|
|
53
|
+
|
|
54
|
+
const failures = checks.filter((check) => check.status === "fail");
|
|
55
|
+
const warnings = checks.filter((check) => check.status === "warn");
|
|
56
|
+
|
|
57
|
+
if (failures.length) {
|
|
58
|
+
console.log("\nNext: fix the failed checks above, then rerun this doctor command.");
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log("\nReady for local deployment orchestration.");
|
|
64
|
+
if (warnings.length) {
|
|
65
|
+
console.log("Warnings remain; deployment may still need external setup such as DNS propagation or GitHub CLI auth.");
|
|
66
|
+
}
|
|
67
|
+
console.log(`Run: deploy/aliyun/deploy-to-ecs.sh --config ${relativeToRoot(configPath)}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseEnvFile(text) {
|
|
71
|
+
const values = {};
|
|
72
|
+
|
|
73
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
74
|
+
const line = rawLine.trim();
|
|
75
|
+
if (!line || line.startsWith("#")) continue;
|
|
76
|
+
|
|
77
|
+
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
78
|
+
if (!match) continue;
|
|
79
|
+
|
|
80
|
+
let value = match[2].trim();
|
|
81
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
82
|
+
value = value.slice(1, -1);
|
|
83
|
+
}
|
|
84
|
+
values[match[1]] = value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return values;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resolveConfig(config) {
|
|
91
|
+
const domain = first(config.DOMAIN, config.ALIYUN_DOMAIN);
|
|
92
|
+
const resolved = {
|
|
93
|
+
apiUrl: first(config.API_URL, config.SNAPSHOT_SHARE_PUBLIC_API_URL) || (domain ? `https://${domain}` : ""),
|
|
94
|
+
certbotEmail: first(config.CERTBOT_EMAIL),
|
|
95
|
+
configureLocal: isEnabled(config.CONFIGURE_LOCAL),
|
|
96
|
+
configurePages: isEnabled(config.CONFIGURE_PAGES),
|
|
97
|
+
domain,
|
|
98
|
+
generateToken: isEnabled(config.GENERATE_TOKEN) || isAutoToken(config.TOKEN),
|
|
99
|
+
githubClientId: first(config.SNAPSHOT_GITHUB_CLIENT_ID, config.GITHUB_CLIENT_ID),
|
|
100
|
+
githubClientSecret: first(config.SNAPSHOT_GITHUB_CLIENT_SECRET, config.GITHUB_CLIENT_SECRET),
|
|
101
|
+
githubOwner: first(config.SNAPSHOT_GITHUB_OWNER_LOGIN, config.SNAPSHOT_GITHUB_OWNER, config.SNAPSHOT_GITHUB_OWNER_ID),
|
|
102
|
+
sessionSecret: first(config.SNAPSHOT_SESSION_SECRET),
|
|
103
|
+
issueCert: isEnabled(config.ISSUE_CERT),
|
|
104
|
+
pagesRepo: first(config.PAGES_REPO, process.env.GITHUB_REPOSITORY, "ffffhx/codex-snapshots"),
|
|
105
|
+
siteUrl: first(config.SITE_URL, config.SNAPSHOT_SHARE_SITE_URL, "https://ffffhx.github.io/codex-snapshots/"),
|
|
106
|
+
sshTarget: first(config.SSH_TARGET, config.ALIYUN_SSH_TARGET),
|
|
107
|
+
token: first(config.TOKEN, config.SNAPSHOT_SHARE_TOKEN),
|
|
108
|
+
};
|
|
109
|
+
if (hasGithubAuthConfig(resolved) && resolved.token === "change-me") {
|
|
110
|
+
resolved.token = "";
|
|
111
|
+
}
|
|
112
|
+
return resolved;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function validateConfig(config) {
|
|
116
|
+
const checks = [];
|
|
117
|
+
const apiHost = urlHost(config.apiUrl);
|
|
118
|
+
const siteHost = urlHost(config.siteUrl);
|
|
119
|
+
const githubAuthConfigured = hasGithubAuthConfig(config);
|
|
120
|
+
|
|
121
|
+
checks.push(config.sshTarget && !config.sshTarget.includes("1.2.3.4")
|
|
122
|
+
? pass("SSH target", maskSshTarget(config.sshTarget))
|
|
123
|
+
: fail("SSH target", "set SSH_TARGET to your ECS user and public IP"));
|
|
124
|
+
checks.push(config.domain && !isPlaceholderDomain(config.domain)
|
|
125
|
+
? pass("Domain", config.domain)
|
|
126
|
+
: fail("Domain", "set DOMAIN to your public API domain"));
|
|
127
|
+
checks.push(isHttpUrl(config.apiUrl) && !isLocalHost(apiHost) && !isPlaceholderDomain(apiHost)
|
|
128
|
+
? pass("Public API URL", config.apiUrl.replace(/\/+$/, ""))
|
|
129
|
+
: fail("Public API URL", "set API_URL to the public HTTPS API URL"));
|
|
130
|
+
checks.push(isHttpUrl(config.siteUrl) && !isLocalHost(siteHost)
|
|
131
|
+
? pass("Site URL", config.siteUrl.replace(/\/+$/, ""))
|
|
132
|
+
: fail("Site URL", "set SITE_URL to the public GitHub Pages URL"));
|
|
133
|
+
|
|
134
|
+
if (githubAuthConfigured && (!config.githubClientId || !config.githubClientSecret || !config.githubOwner)) {
|
|
135
|
+
checks.push(fail("GitHub OAuth", "set SNAPSHOT_GITHUB_CLIENT_ID, SNAPSHOT_GITHUB_CLIENT_SECRET, and SNAPSHOT_GITHUB_OWNER_LOGIN/ID"));
|
|
136
|
+
} else if (githubAuthConfigured) {
|
|
137
|
+
checks.push(config.sessionSecret && config.sessionSecret.length >= 32
|
|
138
|
+
? pass("GitHub OAuth", `configured for ${config.githubOwner}`)
|
|
139
|
+
: pass("GitHub OAuth", `configured for ${config.githubOwner}; deploy-to-ecs.sh will generate SNAPSHOT_SESSION_SECRET`));
|
|
140
|
+
} else {
|
|
141
|
+
checks.push(skip("GitHub OAuth", "not configured"));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (config.generateToken) {
|
|
145
|
+
checks.push(pass("Publish token", "will be generated by deploy-to-ecs.sh"));
|
|
146
|
+
} else if (config.token === "change-me") {
|
|
147
|
+
checks.push(fail("Publish token", "replace TOKEN=change-me with TOKEN=auto, GENERATE_TOKEN=1, or a real token"));
|
|
148
|
+
} else if (config.token && config.token !== "change-me" && config.token.length >= 16) {
|
|
149
|
+
checks.push(pass("Publish token", `set (${config.token.length} chars)`));
|
|
150
|
+
} else if (githubAuthConfigured) {
|
|
151
|
+
checks.push(skip("Publish token", "not required when GitHub OAuth is configured"));
|
|
152
|
+
} else if (!config.token && readLocalToken()) {
|
|
153
|
+
checks.push(pass("Publish token", "will be reused from local publisher config"));
|
|
154
|
+
} else {
|
|
155
|
+
checks.push(fail("Publish token", "set TOKEN=auto, GENERATE_TOKEN=1, --generate-token, or a real TOKEN"));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (config.issueCert) {
|
|
159
|
+
checks.push(config.certbotEmail && config.certbotEmail !== "you@example.com" && config.certbotEmail.includes("@")
|
|
160
|
+
? pass("Certbot email", config.certbotEmail)
|
|
161
|
+
: fail("Certbot email", "set CERTBOT_EMAIL when ISSUE_CERT=1"));
|
|
162
|
+
} else {
|
|
163
|
+
checks.push(skip("Certbot email", "ISSUE_CERT=0"));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return checks;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function checkLocalCommands(config) {
|
|
170
|
+
const checks = [];
|
|
171
|
+
checks.push(commandExists("ssh") ? pass("Local ssh", "available") : fail("Local ssh", "ssh command not found"));
|
|
172
|
+
checks.push(commandExists("rsync") ? pass("Local rsync", "available") : fail("Local rsync", "rsync command not found"));
|
|
173
|
+
checks.push(commandExists("node") ? pass("Local node", "available") : fail("Local node", "node command not found"));
|
|
174
|
+
|
|
175
|
+
if (config.configurePages) {
|
|
176
|
+
checks.push(commandExists("gh")
|
|
177
|
+
? pass("GitHub CLI", `available for ${config.pagesRepo}`)
|
|
178
|
+
: warn("GitHub CLI", "gh not found; --configure-pages cannot run until installed and authenticated"));
|
|
179
|
+
} else {
|
|
180
|
+
checks.push(skip("GitHub CLI", "CONFIGURE_PAGES=0"));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return checks;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function checkLocalTokenSource(config) {
|
|
187
|
+
if (!config.configureLocal) {
|
|
188
|
+
return skip("Local publisher config", "CONFIGURE_LOCAL=0");
|
|
189
|
+
}
|
|
190
|
+
if (hasGithubAuthConfig(config) && !config.token && !config.generateToken) {
|
|
191
|
+
return pass("Local publisher config", "will be written with public API/site URLs for GitHub OAuth");
|
|
192
|
+
}
|
|
193
|
+
if (config.generateToken) {
|
|
194
|
+
return pass("Local publisher config", "will be written with generated token");
|
|
195
|
+
}
|
|
196
|
+
if (config.token === "change-me") {
|
|
197
|
+
return fail("Local publisher config", "CONFIGURE_LOCAL=1 cannot use TOKEN=change-me");
|
|
198
|
+
}
|
|
199
|
+
if (config.token && config.token !== "change-me" && config.token.length >= 16) {
|
|
200
|
+
return pass("Local publisher config", "will be written with configured token");
|
|
201
|
+
}
|
|
202
|
+
if (!config.token && readLocalToken()) {
|
|
203
|
+
return pass("Local publisher config", "existing token can be reused");
|
|
204
|
+
}
|
|
205
|
+
return fail("Local publisher config", "CONFIGURE_LOCAL=1 needs TOKEN=auto, GENERATE_TOKEN=1, or a real TOKEN");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function hasGithubAuthConfig(config) {
|
|
209
|
+
return Boolean(config.githubClientId || config.githubClientSecret || config.githubOwner || config.sessionSecret);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function checkDns(domain) {
|
|
213
|
+
try {
|
|
214
|
+
const addresses = await resolveHost(domain);
|
|
215
|
+
if (!addresses.length) {
|
|
216
|
+
return fail("DNS", "no A or AAAA records found");
|
|
217
|
+
}
|
|
218
|
+
return pass("DNS", `${domain} -> ${addresses.join(", ")}`);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
return warn("DNS", error instanceof Error ? error.message : String(error));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function runDryRun(configPath) {
|
|
225
|
+
const result = spawnSync("bash", [path.join(ROOT_DIR, "deploy/aliyun/deploy-to-ecs.sh"), "--config", configPath, "--dry-run"], {
|
|
226
|
+
cwd: ROOT_DIR,
|
|
227
|
+
encoding: "utf8",
|
|
228
|
+
env: process.env,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
if (result.status === 0) {
|
|
232
|
+
return pass("Deploy dry-run", "deploy-to-ecs.sh accepted the config");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const output = `${result.stdout || ""}\n${result.stderr || ""}`.trim().split(/\r?\n/).slice(0, 8).join(" ");
|
|
236
|
+
return fail("Deploy dry-run", output || `exited with ${result.status}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function commandExists(command) {
|
|
240
|
+
const result = spawnSync("command", ["-v", command], {
|
|
241
|
+
encoding: "utf8",
|
|
242
|
+
shell: true,
|
|
243
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
244
|
+
});
|
|
245
|
+
return result.status === 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function readLocalToken() {
|
|
249
|
+
const candidates = [
|
|
250
|
+
process.env.CODEX_SNAPSHOTS_AGENT_FILE,
|
|
251
|
+
process.env.SNAPSHOT_SHARE_TOKEN_FILE,
|
|
252
|
+
path.join(os.homedir(), ".codex-snapshots-agent.json"),
|
|
253
|
+
].filter(Boolean);
|
|
254
|
+
|
|
255
|
+
for (const filePath of candidates) {
|
|
256
|
+
try {
|
|
257
|
+
const payload = JSON.parse(readFileSync(filePath, "utf8"));
|
|
258
|
+
const token = first(payload.snapshotShareToken, payload.agentToken, payload.token, payload.uploadToken);
|
|
259
|
+
if (token) return token;
|
|
260
|
+
} catch {}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return "";
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function resolveHost(host) {
|
|
267
|
+
const [v4, v6] = await Promise.allSettled([dns.resolve4(host), dns.resolve6(host)]);
|
|
268
|
+
return [
|
|
269
|
+
...(v4.status === "fulfilled" ? v4.value : []),
|
|
270
|
+
...(v6.status === "fulfilled" ? v6.value : []),
|
|
271
|
+
];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function pass(label, message) {
|
|
275
|
+
return { label, message, status: "pass" };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function warn(label, message) {
|
|
279
|
+
return { label, message, status: "warn" };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function fail(label, message) {
|
|
283
|
+
return { label, message, status: "fail" };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function skip(label, message) {
|
|
287
|
+
return { label, message, status: "skip" };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function printChecks(checks) {
|
|
291
|
+
for (const check of checks) {
|
|
292
|
+
const marker = {
|
|
293
|
+
fail: "✗",
|
|
294
|
+
pass: "✓",
|
|
295
|
+
skip: "•",
|
|
296
|
+
warn: "!",
|
|
297
|
+
}[check.status];
|
|
298
|
+
console.log(`${marker} ${check.label}: ${check.message}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function first(...values) {
|
|
303
|
+
for (const value of values) {
|
|
304
|
+
if (typeof value === "string" && value.trim()) {
|
|
305
|
+
return value.trim();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return "";
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function lowercase(value) {
|
|
312
|
+
return String(value || "").toLowerCase();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function isEnabled(value) {
|
|
316
|
+
return ["1", "true", "yes", "on"].includes(lowercase(value));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function isAutoToken(value) {
|
|
320
|
+
return ["auto", "generate", "generated"].includes(lowercase(value));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function isPlaceholderDomain(value) {
|
|
324
|
+
const text = lowercase(value);
|
|
325
|
+
return text === "example.com" || text.endsWith(".example.com") || text === "snapshots.example.com";
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function isLocalHost(value) {
|
|
329
|
+
const text = lowercase(value);
|
|
330
|
+
return text === "127.0.0.1" || text === "localhost" || text === "::1";
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function isHttpUrl(value) {
|
|
334
|
+
try {
|
|
335
|
+
const url = new URL(value);
|
|
336
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
337
|
+
} catch {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function urlHost(value) {
|
|
343
|
+
try {
|
|
344
|
+
return new URL(value).hostname.toLowerCase();
|
|
345
|
+
} catch {
|
|
346
|
+
return "";
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function maskSshTarget(value) {
|
|
351
|
+
const text = String(value || "");
|
|
352
|
+
return text.replace(/@(.{2}).+$/, "@$1...");
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function relativeToRoot(filePath) {
|
|
356
|
+
const relative = path.relative(ROOT_DIR, filePath);
|
|
357
|
+
return relative.startsWith("..") ? filePath : relative;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function parseArgs(args) {
|
|
361
|
+
const options = {
|
|
362
|
+
config: "",
|
|
363
|
+
offline: false,
|
|
364
|
+
};
|
|
365
|
+
let help = false;
|
|
366
|
+
|
|
367
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
368
|
+
const arg = args[index];
|
|
369
|
+
if (arg === "-h" || arg === "--help") {
|
|
370
|
+
help = true;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (arg === "--config") {
|
|
374
|
+
options.config = String(args[++index] || "");
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (arg === "--offline") {
|
|
378
|
+
options.offline = true;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
throw new Error(`unknown option: ${arg}`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return { help, options };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function printHelp() {
|
|
388
|
+
console.log(`aliyun deploy doctor
|
|
389
|
+
|
|
390
|
+
Usage:
|
|
391
|
+
node deploy/aliyun/doctor.mjs --config deploy/aliyun/deploy.env
|
|
392
|
+
|
|
393
|
+
Options:
|
|
394
|
+
--config FILE Deployment env file. Defaults to deploy/aliyun/deploy.env.
|
|
395
|
+
--offline Skip DNS lookup.
|
|
396
|
+
-h, --help Show help.
|
|
397
|
+
`);
|
|
398
|
+
}
|