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.
Files changed (51) hide show
  1. package/README.md +101 -6
  2. package/bin/codex-snapshot.mjs +1 -6326
  3. package/deploy/aliyun/README.md +311 -0
  4. package/deploy/aliyun/backup-share-data.sh +109 -0
  5. package/deploy/aliyun/check-ecs-status.sh +149 -0
  6. package/deploy/aliyun/codex-snapshot-share.env.example +29 -0
  7. package/deploy/aliyun/codex-snapshot-share.service +26 -0
  8. package/deploy/aliyun/configure-github-pages-api.sh +141 -0
  9. package/deploy/aliyun/configure-local-publisher.sh +197 -0
  10. package/deploy/aliyun/deploy-to-ecs.sh +669 -0
  11. package/deploy/aliyun/deploy.env.example +52 -0
  12. package/deploy/aliyun/doctor.mjs +398 -0
  13. package/deploy/aliyun/install-share-api.sh +252 -0
  14. package/deploy/aliyun/install-system-deps.sh +84 -0
  15. package/deploy/aliyun/nginx-codex-snapshots.bootstrap.conf +34 -0
  16. package/deploy/aliyun/nginx-codex-snapshots.conf +52 -0
  17. package/deploy/aliyun/preflight.mjs +321 -0
  18. package/deploy/aliyun/restore-share-data.sh +141 -0
  19. package/deploy/aliyun/verify-public-share.mjs +404 -0
  20. package/dist/cli/codex-snapshot.mjs +2654 -0
  21. package/dist/core/privacy.js +81 -0
  22. package/dist/core/snapshot.js +1 -0
  23. package/dist/renderers/markdown.mjs +81 -0
  24. package/dist/renderers/transcript.js +195 -0
  25. package/dist/server/http.js +10 -0
  26. package/dist/server/local-security.js +66 -0
  27. package/dist/server/local-viewer-app.mjs +1670 -0
  28. package/dist/server/local-viewer.mjs +210 -0
  29. package/dist/server/share-api.mjs +1149 -0
  30. package/dist/server/share-store.js +136 -0
  31. package/dist/shared/sanitize.js +126 -0
  32. package/dist/shared/transcript.js +1 -0
  33. package/dist/sources/index.mjs +2 -0
  34. package/dist/sources/local-history.mjs +2221 -0
  35. package/package.json +42 -14
  36. package/scripts/build-site.mjs +71 -0
  37. package/scripts/launch-agent.mjs +19 -227
  38. package/scripts/serve-site.mjs +2 -2
  39. package/scripts/test-aliyun-deploy-config.sh +230 -0
  40. package/scripts/test-share-api.mjs +967 -0
  41. package/scripts/test-site-config.mjs +100 -0
  42. package/scripts/test-static-site.mjs +403 -0
  43. package/scripts/write-site-config.mjs +161 -0
  44. package/server/share-api.mjs +1 -771
  45. package/site/assets/config.js +3 -0
  46. package/site/assets/share.js +43 -106
  47. package/site/assets/site.css +3 -605
  48. package/site/assets/site.js +15 -92
  49. package/site/favicon.svg +7 -0
  50. package/site/index.html +3 -83
  51. package/site/share/index.html +3 -8
@@ -0,0 +1,321 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from "node:child_process";
4
+ import dns from "node:dns/promises";
5
+ import net from "node:net";
6
+
7
+ const parsed = parseArgs(process.argv.slice(2));
8
+
9
+ if (parsed.help) {
10
+ printHelp();
11
+ process.exit(0);
12
+ }
13
+
14
+ const options = parsed.options;
15
+
16
+ main().catch((error) => {
17
+ console.error(`✗ ${error instanceof Error ? error.message : String(error)}`);
18
+ process.exitCode = 1;
19
+ });
20
+
21
+ async function main() {
22
+ if (!options.domain) {
23
+ throw new Error("Missing --domain.");
24
+ }
25
+
26
+ const results = [];
27
+
28
+ results.push(await runCheck("Resolve API domain", () => checkDomain(options.domain)));
29
+
30
+ if (options.sshTarget) {
31
+ results.push(await runCheck("Compare DNS with SSH host", () => compareDnsWithSshHost(options.domain, options.sshTarget), false));
32
+ results.push(await runCheck("Open SSH session", () => checkSsh(options)));
33
+ results.push(await runCheck("Check remote dependencies", () => checkRemoteDependencies(options)));
34
+ } else {
35
+ results.push({ ok: true, skipped: true, label: "SSH checks skipped; pass --ssh to verify the ECS host" });
36
+ }
37
+
38
+ if (options.checkPublicPorts) {
39
+ results.push(await runCheck("Reach public HTTP port 80", () => checkTcp(options.domain, 80)));
40
+ results.push(await runCheck("Reach public HTTPS port 443", () => checkTcp(options.domain, 443), false));
41
+ }
42
+
43
+ const failed = results.filter((result) => !result.ok && result.required);
44
+ const warnings = results.filter((result) => !result.ok && !result.required);
45
+
46
+ if (failed.length) {
47
+ throw new Error(`${failed.length} required preflight check(s) failed`);
48
+ }
49
+
50
+ if (warnings.length) {
51
+ console.log(`! ${warnings.length} warning(s); deployment may still work after the missing external setup is completed.`);
52
+ }
53
+
54
+ console.log("✓ Aliyun deployment preflight passed");
55
+ }
56
+
57
+ async function runCheck(label, check, required = true) {
58
+ try {
59
+ const message = await check();
60
+ console.log(`✓ ${label}${message ? `: ${message}` : ""}`);
61
+ return { ok: true, label, required };
62
+ } catch (error) {
63
+ const message = error instanceof Error ? error.message : String(error);
64
+ console.log(`${required ? "✗" : "!"} ${label}: ${message}`);
65
+ return { ok: false, label, required };
66
+ }
67
+ }
68
+
69
+ async function checkDomain(domain) {
70
+ const addresses = await resolveHost(domain);
71
+ if (!addresses.length) {
72
+ throw new Error("no A or AAAA records found");
73
+ }
74
+ return addresses.join(", ");
75
+ }
76
+
77
+ async function compareDnsWithSshHost(domain, sshTarget) {
78
+ const domainAddresses = await resolveHost(domain);
79
+ const sshHost = extractSshHost(sshTarget);
80
+
81
+ if (!sshHost) {
82
+ throw new Error("could not parse SSH host");
83
+ }
84
+
85
+ const sshAddresses = net.isIP(sshHost) ? [sshHost] : await resolveHost(sshHost);
86
+ const overlap = domainAddresses.filter((address) => sshAddresses.includes(address));
87
+
88
+ if (!overlap.length) {
89
+ throw new Error(`domain resolves to ${domainAddresses.join(", ")}, SSH host resolves to ${sshAddresses.join(", ")}`);
90
+ }
91
+
92
+ return overlap.join(", ");
93
+ }
94
+
95
+ async function checkSsh({ sshTarget, sshPort, identityFile, timeoutMs }) {
96
+ const { stdout } = await runCommand(
97
+ "ssh",
98
+ buildSshArgs({ sshPort, identityFile, timeoutMs }).concat(sshTarget, "printf ready"),
99
+ timeoutMs
100
+ );
101
+
102
+ if (stdout.trim() !== "ready") {
103
+ throw new Error(`unexpected SSH output: ${stdout.trim() || "(empty)"}`);
104
+ }
105
+
106
+ return sshTarget;
107
+ }
108
+
109
+ async function checkRemoteDependencies({ sshTarget, sshPort, identityFile, proxyMode, requireCertbotNginx, timeoutMs }) {
110
+ const requiredCommands =
111
+ proxyMode === "caddy"
112
+ ? ["node", "caddy", "rsync", "openssl", "systemctl"]
113
+ : ["node", "nginx", "certbot", "rsync", "openssl", "systemctl"];
114
+ const certbotNginxCheck = requireCertbotNginx
115
+ ? `
116
+ if ! certbot plugins 2>/dev/null | grep -qi nginx; then
117
+ echo "missing: certbot nginx plugin"
118
+ exit 14
119
+ fi
120
+ `
121
+ : "";
122
+ const remoteScript = `
123
+ set -eu
124
+ PATH="$PATH:/usr/local/sbin:/usr/sbin:/sbin"
125
+ missing=""
126
+ for cmd in ${requiredCommands.join(" ")}; do
127
+ if ! command -v "$cmd" >/dev/null 2>&1; then
128
+ missing="$missing $cmd"
129
+ fi
130
+ done
131
+ if [ -n "$missing" ]; then
132
+ echo "missing:$missing"
133
+ exit 11
134
+ fi
135
+ node -e 'const major=Number(process.versions.node.split(".")[0]); if (!Number.isFinite(major) || major < 18) process.exit(12);'
136
+ ${certbotNginxCheck}
137
+ if [ "$(id -u)" -eq 0 ]; then
138
+ echo "privilege:root"
139
+ elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
140
+ echo "privilege:sudo"
141
+ else
142
+ echo "missing: passwordless sudo"
143
+ exit 13
144
+ fi
145
+ `;
146
+ const args = buildSshArgs({ sshPort, identityFile, timeoutMs }).concat(sshTarget, `sh -lc ${shellQuote(remoteScript)}`);
147
+ const { stdout } = await runCommand("ssh", args, timeoutMs);
148
+ return stdout.trim().replace(/\s+/g, " ");
149
+ }
150
+
151
+ function buildSshArgs({ sshPort, identityFile, timeoutMs }) {
152
+ const args = [
153
+ "-o",
154
+ "BatchMode=yes",
155
+ "-o",
156
+ `ConnectTimeout=${Math.max(1, Math.ceil(Number(timeoutMs || 8000) / 1000))}`,
157
+ ];
158
+
159
+ if (identityFile) {
160
+ args.push("-i", identityFile);
161
+ }
162
+ if (sshPort) {
163
+ args.push("-p", String(sshPort));
164
+ }
165
+
166
+ return args;
167
+ }
168
+
169
+ async function checkTcp(host, port) {
170
+ await new Promise((resolve, reject) => {
171
+ const socket = net.createConnection({ host, port });
172
+ const timer = setTimeout(() => {
173
+ socket.destroy();
174
+ reject(new Error("connection timed out"));
175
+ }, Number(options.timeoutMs || 8000));
176
+
177
+ socket.once("connect", () => {
178
+ clearTimeout(timer);
179
+ socket.end();
180
+ resolve();
181
+ });
182
+ socket.once("error", (error) => {
183
+ clearTimeout(timer);
184
+ reject(error);
185
+ });
186
+ });
187
+
188
+ return `${host}:${port}`;
189
+ }
190
+
191
+ async function resolveHost(host) {
192
+ const [v4, v6] = await Promise.allSettled([dns.resolve4(host), dns.resolve6(host)]);
193
+ return [
194
+ ...(v4.status === "fulfilled" ? v4.value : []),
195
+ ...(v6.status === "fulfilled" ? v6.value : []),
196
+ ];
197
+ }
198
+
199
+ function extractSshHost(target) {
200
+ const withoutUser = String(target || "").replace(/^ssh:\/\//, "").split("@").pop() || "";
201
+ if (withoutUser.startsWith("[")) {
202
+ return withoutUser.slice(1, withoutUser.indexOf("]"));
203
+ }
204
+ return withoutUser.split(":")[0];
205
+ }
206
+
207
+ async function runCommand(command, args, timeoutMs) {
208
+ return await new Promise((resolve, reject) => {
209
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
210
+ let stdout = "";
211
+ let stderr = "";
212
+ const timer = setTimeout(() => {
213
+ child.kill("SIGTERM");
214
+ reject(new Error(`${command} timed out`));
215
+ }, Number(timeoutMs || 8000));
216
+
217
+ child.stdout.setEncoding("utf8");
218
+ child.stderr.setEncoding("utf8");
219
+ child.stdout.on("data", (chunk) => {
220
+ stdout += chunk;
221
+ });
222
+ child.stderr.on("data", (chunk) => {
223
+ stderr += chunk;
224
+ });
225
+ child.once("error", (error) => {
226
+ clearTimeout(timer);
227
+ reject(error);
228
+ });
229
+ child.once("exit", (code) => {
230
+ clearTimeout(timer);
231
+ if (code === 0) {
232
+ resolve({ stdout, stderr });
233
+ } else {
234
+ reject(new Error((stderr || stdout || `${command} exited with ${code}`).trim()));
235
+ }
236
+ });
237
+ });
238
+ }
239
+
240
+ function shellQuote(value) {
241
+ return `'${String(value).replace(/'/g, `'\"'\"'`)}'`;
242
+ }
243
+
244
+ function parseArgs(args) {
245
+ const options = {
246
+ checkPublicPorts: false,
247
+ domain: "",
248
+ identityFile: "",
249
+ requireCertbotNginx: false,
250
+ proxyMode: "nginx",
251
+ sshPort: "",
252
+ sshTarget: "",
253
+ timeoutMs: 8000,
254
+ };
255
+ let help = false;
256
+
257
+ for (let index = 0; index < args.length; index += 1) {
258
+ const arg = args[index];
259
+ if (arg === "-h" || arg === "--help") {
260
+ help = true;
261
+ continue;
262
+ }
263
+ if (arg === "--domain") {
264
+ options.domain = String(args[++index] || "");
265
+ continue;
266
+ }
267
+ if (arg === "--ssh") {
268
+ options.sshTarget = String(args[++index] || "");
269
+ continue;
270
+ }
271
+ if (arg === "--identity-file") {
272
+ options.identityFile = String(args[++index] || "");
273
+ continue;
274
+ }
275
+ if (arg === "--port") {
276
+ options.sshPort = String(args[++index] || "");
277
+ continue;
278
+ }
279
+ if (arg === "--timeout-ms") {
280
+ options.timeoutMs = Number(args[++index] || 8000);
281
+ continue;
282
+ }
283
+ if (arg === "--check-public-ports") {
284
+ options.checkPublicPorts = true;
285
+ continue;
286
+ }
287
+ if (arg === "--require-certbot-nginx") {
288
+ options.requireCertbotNginx = true;
289
+ continue;
290
+ }
291
+ if (arg === "--proxy-mode") {
292
+ options.proxyMode = String(args[++index] || "nginx");
293
+ if (!["nginx", "caddy"].includes(options.proxyMode)) {
294
+ throw new Error("--proxy-mode must be nginx or caddy");
295
+ }
296
+ continue;
297
+ }
298
+ throw new Error(`unknown option: ${arg}`);
299
+ }
300
+
301
+ return { help, options };
302
+ }
303
+
304
+ function printHelp() {
305
+ console.log(`aliyun preflight
306
+
307
+ Usage:
308
+ node deploy/aliyun/preflight.mjs --domain snapshots.example.com --ssh root@1.2.3.4
309
+
310
+ Options:
311
+ --domain DOMAIN Public API domain that should point to ECS.
312
+ --ssh TARGET SSH target, for example root@1.2.3.4.
313
+ --identity-file FILE SSH private key for the ECS host.
314
+ --port PORT SSH port.
315
+ --timeout-ms MS Per-check timeout. Defaults to 8000.
316
+ --check-public-ports Also test public TCP 80 and 443.
317
+ --proxy-mode MODE Remote reverse proxy: nginx or caddy. Defaults to nginx.
318
+ --require-certbot-nginx Check that certbot has the Nginx plugin.
319
+ -h, --help Show help.
320
+ `);
321
+ }
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SSH_TARGET=""
5
+ SSH_OPTS=()
6
+ INPUT_FILE=""
7
+ REMOTE_FILE="/var/lib/codex-snapshots/shares.json"
8
+ RESTART_SERVICE=1
9
+
10
+ usage() {
11
+ cat <<'EOF'
12
+ Usage:
13
+ deploy/aliyun/restore-share-data.sh --ssh root@1.2.3.4 --file backups/codex-snapshots/shares-20260101T000000Z.json
14
+
15
+ Options:
16
+ --ssh TARGET SSH target, for example root@1.2.3.4.
17
+ --file FILE Local share data JSON backup to restore.
18
+ --identity-file FILE SSH private key for the ECS host.
19
+ --port PORT SSH port.
20
+ --remote-file FILE Remote share data file. Defaults to /var/lib/codex-snapshots/shares.json.
21
+ --no-restart Restore the file without restarting codex-snapshot-share.
22
+ -h, --help Show help.
23
+ EOF
24
+ }
25
+
26
+ while [[ $# -gt 0 ]]; do
27
+ case "$1" in
28
+ --ssh)
29
+ SSH_TARGET="${2:-}"
30
+ shift 2
31
+ ;;
32
+ --file)
33
+ INPUT_FILE="${2:-}"
34
+ shift 2
35
+ ;;
36
+ --identity-file)
37
+ SSH_OPTS+=("-i" "${2:-}")
38
+ shift 2
39
+ ;;
40
+ --port)
41
+ SSH_OPTS+=("-p" "${2:-}")
42
+ shift 2
43
+ ;;
44
+ --remote-file)
45
+ REMOTE_FILE="${2:-}"
46
+ shift 2
47
+ ;;
48
+ --no-restart)
49
+ RESTART_SERVICE=0
50
+ shift
51
+ ;;
52
+ -h|--help)
53
+ usage
54
+ exit 0
55
+ ;;
56
+ *)
57
+ echo "Unknown option: $1" >&2
58
+ usage >&2
59
+ exit 1
60
+ ;;
61
+ esac
62
+ done
63
+
64
+ if [[ -z "${SSH_TARGET}" ]]; then
65
+ echo "Missing --ssh target." >&2
66
+ usage >&2
67
+ exit 1
68
+ fi
69
+
70
+ if [[ -z "${INPUT_FILE}" || ! -f "${INPUT_FILE}" ]]; then
71
+ echo "Missing --file JSON backup." >&2
72
+ usage >&2
73
+ exit 1
74
+ fi
75
+
76
+ node -e '
77
+ const { readFileSync } = require("node:fs");
78
+ const file = process.argv[1];
79
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
80
+ if (!Array.isArray(parsed) && !(parsed && typeof parsed === "object" && Array.isArray(parsed.entries))) {
81
+ throw new Error("share data must be an array or an object with an entries array");
82
+ }
83
+ ' "${INPUT_FILE}"
84
+
85
+ shell_quote() {
86
+ printf "'"
87
+ printf "%s" "$1" | sed "s/'/'\"'\"'/g"
88
+ printf "'"
89
+ }
90
+
91
+ remote_file_q="$(shell_quote "${REMOTE_FILE}")"
92
+
93
+ remote_cmd=$(
94
+ cat <<EOF
95
+ set -e
96
+ tmp_file="\$(mktemp /tmp/codex-snapshots-restore.XXXXXX.json)"
97
+ root_script="\$(mktemp /tmp/codex-snapshots-restore-root.XXXXXX.sh)"
98
+ cleanup() {
99
+ rm -f "\${tmp_file}" "\${root_script}"
100
+ }
101
+ trap cleanup EXIT
102
+
103
+ cat > "\${tmp_file}"
104
+ cat > "\${root_script}" <<'ROOT_SCRIPT'
105
+ #!/usr/bin/env sh
106
+ set -e
107
+ remote_file="\$1"
108
+ restart_service="\$2"
109
+ tmp_file="\$3"
110
+ backup_file="\${remote_file}.restore-backup.\$(date -u +%Y%m%dT%H%M%SZ)"
111
+
112
+ mkdir -p "\$(dirname "\${remote_file}")"
113
+ if [ -f "\${remote_file}" ]; then
114
+ cp -a "\${remote_file}" "\${backup_file}"
115
+ echo "Saved previous remote data to \${backup_file}"
116
+ fi
117
+
118
+ install -o codexsnap -g codexsnap -m 0640 "\${tmp_file}" "\${remote_file}"
119
+
120
+ if [ "\${restart_service}" = "1" ]; then
121
+ systemctl restart codex-snapshot-share.service
122
+ fi
123
+ ROOT_SCRIPT
124
+ chmod +x "\${root_script}"
125
+
126
+ if [ "\$(id -u)" -eq 0 ]; then
127
+ "\${root_script}" ${remote_file_q} ${RESTART_SERVICE} "\${tmp_file}"
128
+ else
129
+ sudo -n "\${root_script}" ${remote_file_q} ${RESTART_SERVICE} "\${tmp_file}"
130
+ fi
131
+ EOF
132
+ )
133
+
134
+ SSH_CMD=(ssh)
135
+ if ((${#SSH_OPTS[@]})); then
136
+ SSH_CMD+=("${SSH_OPTS[@]}")
137
+ fi
138
+
139
+ "${SSH_CMD[@]}" "${SSH_TARGET}" "${remote_cmd}" < "${INPUT_FILE}"
140
+
141
+ echo "Restored ${INPUT_FILE} to ${SSH_TARGET}:${REMOTE_FILE}"