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,669 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ CONFIG_FILE="${CODEX_SNAPSHOTS_ALIYUN_CONFIG:-}"
5
+
6
+ for ((index = 1; index <= $#; index += 1)); do
7
+ if [[ "${!index}" == "--config" ]]; then
8
+ next_index=$((index + 1))
9
+ CONFIG_FILE="${!next_index:-}"
10
+ break
11
+ fi
12
+ done
13
+
14
+ if [[ -n "${CONFIG_FILE}" ]]; then
15
+ if [[ ! -f "${CONFIG_FILE}" ]]; then
16
+ echo "Config file not found: ${CONFIG_FILE}" >&2
17
+ exit 1
18
+ fi
19
+ # shellcheck source=/dev/null
20
+ source "${CONFIG_FILE}"
21
+ fi
22
+
23
+ SSH_TARGET="${SSH_TARGET:-${ALIYUN_SSH_TARGET:-}}"
24
+ DOMAIN="${DOMAIN:-${ALIYUN_DOMAIN:-}}"
25
+ SITE_URL="${SITE_URL:-${SNAPSHOT_SHARE_SITE_URL:-https://ffffhx.github.io/codex-snapshots/}}"
26
+ API_URL="${API_URL:-${SNAPSHOT_SHARE_PUBLIC_API_URL:-}}"
27
+ TOKEN="${TOKEN:-${SNAPSHOT_SHARE_TOKEN:-}}"
28
+ GITHUB_CLIENT_ID="${SNAPSHOT_GITHUB_CLIENT_ID:-${GITHUB_CLIENT_ID:-}}"
29
+ GITHUB_CLIENT_SECRET="${SNAPSHOT_GITHUB_CLIENT_SECRET:-${GITHUB_CLIENT_SECRET:-}}"
30
+ GITHUB_OWNER_LOGIN="${SNAPSHOT_GITHUB_OWNER_LOGIN:-${SNAPSHOT_GITHUB_OWNER:-}}"
31
+ GITHUB_OWNER_ID="${SNAPSHOT_GITHUB_OWNER_ID:-}"
32
+ SESSION_SECRET="${SNAPSHOT_SESSION_SECRET:-}"
33
+ AUTH_ALLOWED_ORIGINS="${SNAPSHOT_AUTH_ALLOWED_ORIGINS:-}"
34
+ REMOTE_DIR="${REMOTE_DIR:-/tmp/codex-snapshots-deploy}"
35
+ SSH_IDENTITY_FILE="${SSH_IDENTITY_FILE:-${ALIYUN_SSH_IDENTITY_FILE:-}}"
36
+ SSH_PORT="${SSH_PORT:-${ALIYUN_SSH_PORT:-}}"
37
+ SHARE_PORT="${SHARE_PORT:-${SNAPSHOT_SHARE_PORT:-8787}}"
38
+ PROXY_MODE="${PROXY_MODE:-${SNAPSHOT_SHARE_PROXY_MODE:-auto}}"
39
+ PUBLIC_PATH="${PUBLIC_PATH:-${SNAPSHOT_SHARE_PUBLIC_PATH:-}}"
40
+ GENERATE_TOKEN="${GENERATE_TOKEN:-0}"
41
+ ISSUE_CERT="${ISSUE_CERT:-0}"
42
+ CERTBOT_EMAIL="${CERTBOT_EMAIL:-}"
43
+ RUN_VERIFY="${RUN_VERIFY:-1}"
44
+ RUN_PREFLIGHT="${RUN_PREFLIGHT:-1}"
45
+ INSTALL_DEPS="${INSTALL_DEPS:-0}"
46
+ DRY_RUN="${DRY_RUN:-0}"
47
+ CONFIGURE_PAGES="${CONFIGURE_PAGES:-0}"
48
+ PAGES_REPO="${PAGES_REPO:-${GITHUB_REPOSITORY:-ffffhx/codex-snapshots}}"
49
+ PAGES_WORKFLOW="${PAGES_WORKFLOW:-pages.yml}"
50
+ WAIT_PAGES="${WAIT_PAGES:-0}"
51
+ CONFIGURE_LOCAL="${CONFIGURE_LOCAL:-0}"
52
+ REINSTALL_DAEMON="${REINSTALL_DAEMON:-0}"
53
+
54
+ usage() {
55
+ cat <<'EOF'
56
+ Usage:
57
+ deploy/aliyun/deploy-to-ecs.sh \
58
+ --ssh root@1.2.3.4 \
59
+ --domain snapshots.example.com \
60
+ --configure-local \
61
+ [--issue-cert --email you@example.com]
62
+
63
+ Options:
64
+ --ssh TARGET SSH target, for example root@1.2.3.4.
65
+ --config FILE Source deployment variables from a local env file.
66
+ --domain DOMAIN Public API domain pointing to the ECS public IP.
67
+ --token TOKEN Optional legacy publish token. Defaults to SNAPSHOT_SHARE_TOKEN or
68
+ ~/.codex-snapshots-agent.json when present.
69
+ --generate-token Generate a strong legacy publish token for this run.
70
+ GitHub OAuth vars Set SNAPSHOT_GITHUB_CLIENT_ID, SNAPSHOT_GITHUB_CLIENT_SECRET,
71
+ SNAPSHOT_SESSION_SECRET, and SNAPSHOT_GITHUB_OWNER_LOGIN/ID
72
+ in the config file to require GitHub login for publish/delete.
73
+ --site-url URL Public static site URL. Defaults to https://ffffhx.github.io/codex-snapshots/.
74
+ --api-url URL Public API URL. Defaults to https://<domain>.
75
+ --remote-dir DIR Temporary remote deployment directory. Defaults to /tmp/codex-snapshots-deploy.
76
+ --identity-file FILE SSH private key for the ECS host.
77
+ --port PORT SSH port. Defaults to the ssh client default.
78
+ --service-port PORT Local share API port on ECS. Defaults to 8787.
79
+ --proxy-mode MODE Reverse proxy mode: auto, nginx, caddy, or none. Defaults to auto.
80
+ --public-path PATH Public path prefix for Caddy path proxy, for example /codex-snapshots.
81
+ --issue-cert Run certbot on the ECS host, then reinstall HTTPS Nginx config.
82
+ --email EMAIL Certbot email. Required with --issue-cert for non-interactive certbot.
83
+ --install-deps Install Node.js 20, Nginx, Certbot, Git, OpenSSL, and rsync on ECS first.
84
+ --dry-run Print the resolved deployment plan without connecting to ECS.
85
+ --no-preflight Skip DNS, SSH, and remote dependency checks before deploying.
86
+ --no-verify Skip final ECS API verification.
87
+ --configure-pages Set the GitHub Pages API variable and trigger the Pages workflow.
88
+ --repo OWNER/REPO GitHub repository for --configure-pages. Defaults to ffffhx/codex-snapshots.
89
+ --workflow FILE GitHub Pages workflow for --configure-pages. Defaults to pages.yml.
90
+ --wait-pages With --configure-pages, wait for Pages and run full public verification.
91
+ --configure-local Write the local viewer API/site config after deploy.
92
+ --reinstall-daemon With --configure-local, reinstall the macOS LaunchAgent.
93
+ -h, --help Show help.
94
+ EOF
95
+ }
96
+
97
+ while [[ $# -gt 0 ]]; do
98
+ case "$1" in
99
+ --ssh)
100
+ SSH_TARGET="${2:-}"
101
+ shift 2
102
+ ;;
103
+ --config)
104
+ shift 2
105
+ ;;
106
+ --domain)
107
+ DOMAIN="${2:-}"
108
+ shift 2
109
+ ;;
110
+ --token)
111
+ TOKEN="${2:-}"
112
+ shift 2
113
+ ;;
114
+ --generate-token)
115
+ GENERATE_TOKEN=1
116
+ shift
117
+ ;;
118
+ --site-url)
119
+ SITE_URL="${2:-}"
120
+ shift 2
121
+ ;;
122
+ --api-url)
123
+ API_URL="${2:-}"
124
+ shift 2
125
+ ;;
126
+ --remote-dir)
127
+ REMOTE_DIR="${2:-}"
128
+ shift 2
129
+ ;;
130
+ --identity-file)
131
+ SSH_IDENTITY_FILE="${2:-}"
132
+ shift 2
133
+ ;;
134
+ --port)
135
+ SSH_PORT="${2:-}"
136
+ shift 2
137
+ ;;
138
+ --service-port)
139
+ SHARE_PORT="${2:-}"
140
+ shift 2
141
+ ;;
142
+ --proxy-mode)
143
+ PROXY_MODE="${2:-}"
144
+ shift 2
145
+ ;;
146
+ --public-path)
147
+ PUBLIC_PATH="${2:-}"
148
+ shift 2
149
+ ;;
150
+ --issue-cert)
151
+ ISSUE_CERT=1
152
+ shift
153
+ ;;
154
+ --email)
155
+ CERTBOT_EMAIL="${2:-}"
156
+ shift 2
157
+ ;;
158
+ --install-deps)
159
+ INSTALL_DEPS=1
160
+ shift
161
+ ;;
162
+ --dry-run)
163
+ DRY_RUN=1
164
+ shift
165
+ ;;
166
+ --no-preflight)
167
+ RUN_PREFLIGHT=0
168
+ shift
169
+ ;;
170
+ --no-verify)
171
+ RUN_VERIFY=0
172
+ shift
173
+ ;;
174
+ --configure-pages)
175
+ CONFIGURE_PAGES=1
176
+ shift
177
+ ;;
178
+ --repo)
179
+ PAGES_REPO="${2:-}"
180
+ shift 2
181
+ ;;
182
+ --workflow)
183
+ PAGES_WORKFLOW="${2:-}"
184
+ shift 2
185
+ ;;
186
+ --wait-pages)
187
+ CONFIGURE_PAGES=1
188
+ WAIT_PAGES=1
189
+ shift
190
+ ;;
191
+ --configure-local)
192
+ CONFIGURE_LOCAL=1
193
+ shift
194
+ ;;
195
+ --reinstall-daemon)
196
+ CONFIGURE_LOCAL=1
197
+ REINSTALL_DAEMON=1
198
+ shift
199
+ ;;
200
+ -h|--help)
201
+ usage
202
+ exit 0
203
+ ;;
204
+ *)
205
+ echo "Unknown option: $1" >&2
206
+ usage >&2
207
+ exit 1
208
+ ;;
209
+ esac
210
+ done
211
+
212
+ if [[ -z "${SSH_TARGET}" ]]; then
213
+ echo "Missing --ssh target." >&2
214
+ usage >&2
215
+ exit 1
216
+ fi
217
+
218
+ if [[ -z "${DOMAIN}" ]]; then
219
+ echo "Missing --domain." >&2
220
+ usage >&2
221
+ exit 1
222
+ fi
223
+
224
+ if [[ "${ISSUE_CERT}" -eq 1 && -z "${CERTBOT_EMAIL}" ]]; then
225
+ echo "--issue-cert requires --email for non-interactive certbot." >&2
226
+ exit 1
227
+ fi
228
+
229
+ if [[ -z "${API_URL}" ]]; then
230
+ API_URL="https://${DOMAIN}"
231
+ fi
232
+
233
+ if [[ -z "${PUBLIC_PATH}" ]]; then
234
+ PUBLIC_PATH="$(node -e 'const url = new URL(process.argv[1]); process.stdout.write(url.pathname === "/" ? "" : url.pathname.replace(/\/+$/, ""));' "${API_URL}")"
235
+ fi
236
+
237
+ if [[ -n "${PUBLIC_PATH}" && "${PUBLIC_PATH}" != /* ]]; then
238
+ PUBLIC_PATH="/${PUBLIC_PATH}"
239
+ fi
240
+
241
+ lowercase() {
242
+ printf "%s" "$1" | tr '[:upper:]' '[:lower:]'
243
+ }
244
+
245
+ is_placeholder_domain() {
246
+ local value
247
+ value="$(lowercase "$1")"
248
+ [[ "${value}" == "example.com" || "${value}" == *.example.com || "${value}" == "snapshots.example.com" ]]
249
+ }
250
+
251
+ url_host() {
252
+ local value="${1#http://}"
253
+ value="${value#https://}"
254
+ value="${value%%/*}"
255
+ if [[ "${value}" == \[*\]* ]]; then
256
+ value="${value#\[}"
257
+ value="${value%%\]*}"
258
+ else
259
+ value="${value%%:*}"
260
+ fi
261
+ lowercase "${value}"
262
+ }
263
+
264
+ is_http_url() {
265
+ [[ "$1" == http://* || "$1" == https://* ]]
266
+ }
267
+
268
+ is_ipv4_address() {
269
+ [[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]
270
+ }
271
+
272
+ is_enabled() {
273
+ local value
274
+ value="$(lowercase "$1")"
275
+ [[ "${value}" == "1" || "${value}" == "true" || "${value}" == "yes" || "${value}" == "on" ]]
276
+ }
277
+
278
+ is_auto_token() {
279
+ local value
280
+ value="$(lowercase "$1")"
281
+ [[ "${value}" == "auto" || "${value}" == "generate" || "${value}" == "generated" ]]
282
+ }
283
+
284
+ generate_publish_token() {
285
+ node -e 'const { randomBytes } = require("node:crypto"); process.stdout.write(randomBytes(32).toString("base64url"));'
286
+ }
287
+
288
+ read_local_publish_token() {
289
+ node <<'NODE'
290
+ const fs = require("node:fs");
291
+ const os = require("node:os");
292
+ const path = require("node:path");
293
+
294
+ const candidates = [
295
+ process.env.CODEX_SNAPSHOTS_AGENT_FILE,
296
+ process.env.SNAPSHOT_SHARE_TOKEN_FILE,
297
+ path.join(os.homedir(), ".codex-snapshots-agent.json"),
298
+ ].filter(Boolean);
299
+
300
+ for (const filePath of candidates) {
301
+ try {
302
+ const payload = JSON.parse(fs.readFileSync(filePath, "utf8"));
303
+ const token = [
304
+ payload.snapshotShareToken,
305
+ payload.agentToken,
306
+ payload.token,
307
+ payload.uploadToken,
308
+ ].find((value) => typeof value === "string" && value.trim());
309
+ if (token) {
310
+ process.stdout.write(token.trim());
311
+ process.exit(0);
312
+ }
313
+ } catch {}
314
+ }
315
+ NODE
316
+ }
317
+
318
+ TOKEN_GENERATED=0
319
+ if [[ -z "${TOKEN}" ]]; then
320
+ TOKEN="$(read_local_publish_token)"
321
+ fi
322
+
323
+ if is_enabled "${GENERATE_TOKEN}" || is_auto_token "${TOKEN}"; then
324
+ TOKEN="$(generate_publish_token)"
325
+ TOKEN_GENERATED=1
326
+ fi
327
+
328
+ GITHUB_AUTH_ENABLED=0
329
+ if [[ -n "${GITHUB_CLIENT_ID}${GITHUB_CLIENT_SECRET}${GITHUB_OWNER_LOGIN}${GITHUB_OWNER_ID}" ]]; then
330
+ GITHUB_AUTH_ENABLED=1
331
+ fi
332
+
333
+ if [[ "${GITHUB_AUTH_ENABLED}" -eq 1 && "${TOKEN}" == "change-me" ]]; then
334
+ TOKEN=""
335
+ fi
336
+
337
+ if [[ "${GITHUB_AUTH_ENABLED}" -eq 1 && -z "${SESSION_SECRET}" ]]; then
338
+ SESSION_SECRET="$(node -e 'const { randomBytes } = require("node:crypto"); process.stdout.write(randomBytes(48).toString("base64url"));')"
339
+ fi
340
+
341
+ if [[ -z "${TOKEN}" && "${GITHUB_AUTH_ENABLED}" -ne 1 ]]; then
342
+ echo "Missing --token, SNAPSHOT_SHARE_TOKEN, ~/.codex-snapshots-agent.json token, TOKEN=auto, or --generate-token." >&2
343
+ usage >&2
344
+ exit 1
345
+ fi
346
+
347
+ validate_deploy_inputs() {
348
+ local errors=()
349
+ local ssh_host="${SSH_TARGET##*@}"
350
+ ssh_host="${ssh_host#\[}"
351
+ ssh_host="${ssh_host%%]*}"
352
+ ssh_host="${ssh_host%%:*}"
353
+ local api_host
354
+ api_host="$(url_host "${API_URL}")"
355
+ local site_host
356
+ site_host="$(url_host "${SITE_URL}")"
357
+
358
+ if [[ "${SSH_TARGET}" == "root@1.2.3.4" || "${ssh_host}" == "1.2.3.4" ]]; then
359
+ errors+=("SSH target still uses the placeholder root@1.2.3.4.")
360
+ fi
361
+ if is_placeholder_domain "${DOMAIN}"; then
362
+ errors+=("DOMAIN still uses the placeholder snapshots.example.com.")
363
+ fi
364
+ if is_placeholder_domain "${api_host}"; then
365
+ errors+=("API_URL still uses the placeholder https://snapshots.example.com.")
366
+ fi
367
+ if [[ "${api_host}" == "127.0.0.1" || "${api_host}" == "localhost" || "${api_host}" == "::1" ]]; then
368
+ errors+=("API_URL must be a public URL, not ${API_URL}.")
369
+ fi
370
+ if [[ "${site_host}" == "127.0.0.1" || "${site_host}" == "localhost" || "${site_host}" == "::1" ]]; then
371
+ errors+=("SITE_URL must be the public site URL, not ${SITE_URL}.")
372
+ fi
373
+ if ! is_http_url "${API_URL}"; then
374
+ errors+=("API_URL must start with http:// or https://.")
375
+ fi
376
+ if ! is_http_url "${SITE_URL}"; then
377
+ errors+=("SITE_URL must start with http:// or https://.")
378
+ fi
379
+ if [[ -n "${TOKEN}" ]]; then
380
+ if [[ "${TOKEN}" == "change-me" ]]; then
381
+ errors+=("TOKEN still uses the placeholder change-me.")
382
+ elif [[ "${#TOKEN}" -lt 16 ]]; then
383
+ errors+=("TOKEN should be at least 16 characters.")
384
+ fi
385
+ elif [[ "${GITHUB_AUTH_ENABLED}" -ne 1 ]]; then
386
+ errors+=("TOKEN is required unless GitHub OAuth is configured.")
387
+ fi
388
+ if [[ "${GITHUB_AUTH_ENABLED}" -eq 1 ]]; then
389
+ if [[ -z "${GITHUB_CLIENT_ID}" || -z "${GITHUB_CLIENT_SECRET}" ]]; then
390
+ errors+=("GitHub OAuth needs SNAPSHOT_GITHUB_CLIENT_ID and SNAPSHOT_GITHUB_CLIENT_SECRET.")
391
+ fi
392
+ if [[ -z "${GITHUB_OWNER_LOGIN}${GITHUB_OWNER_ID}" ]]; then
393
+ errors+=("GitHub OAuth needs SNAPSHOT_GITHUB_OWNER_LOGIN or SNAPSHOT_GITHUB_OWNER_ID.")
394
+ fi
395
+ if [[ "${SESSION_SECRET}" == "change-me" || "${#SESSION_SECRET}" -lt 32 ]]; then
396
+ errors+=("SNAPSHOT_SESSION_SECRET should be a real secret with at least 32 characters.")
397
+ fi
398
+ fi
399
+ if [[ "${ISSUE_CERT}" -eq 1 ]]; then
400
+ if [[ "${CERTBOT_EMAIL}" == "you@example.com" || "${CERTBOT_EMAIL}" != *@* ]]; then
401
+ errors+=("CERTBOT_EMAIL must be a real email when ISSUE_CERT=1.")
402
+ fi
403
+ if is_ipv4_address "${DOMAIN}"; then
404
+ errors+=("DOMAIN must be a DNS name, not an IP address, when ISSUE_CERT=1.")
405
+ fi
406
+ fi
407
+ if [[ ! "${PROXY_MODE}" =~ ^(auto|nginx|caddy|none)$ ]]; then
408
+ errors+=("PROXY_MODE must be auto, nginx, caddy, or none.")
409
+ fi
410
+ if [[ ! "${SHARE_PORT}" =~ ^[0-9]+$ || "${SHARE_PORT}" -le 0 || "${SHARE_PORT}" -gt 65535 ]]; then
411
+ errors+=("SHARE_PORT must be a valid TCP port.")
412
+ fi
413
+ if [[ "${PROXY_MODE}" == "caddy" && -z "${PUBLIC_PATH}" ]]; then
414
+ errors+=("PUBLIC_PATH is required when PROXY_MODE=caddy.")
415
+ fi
416
+
417
+ if [[ "${#errors[@]}" -gt 0 ]]; then
418
+ echo "Deployment config needs real values before continuing:" >&2
419
+ printf " - %s\n" "${errors[@]}" >&2
420
+ echo "Edit deploy/aliyun/deploy.env or pass the corresponding CLI flags." >&2
421
+ exit 1
422
+ fi
423
+ }
424
+
425
+ validate_deploy_inputs
426
+
427
+ shell_quote() {
428
+ printf "'"
429
+ printf "%s" "$1" | sed "s/'/'\"'\"'/g"
430
+ printf "'"
431
+ }
432
+
433
+ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
434
+ RSYNC_SSH=(ssh)
435
+ if [[ -n "${SSH_IDENTITY_FILE}" ]]; then
436
+ RSYNC_SSH+=("-i" "${SSH_IDENTITY_FILE}")
437
+ fi
438
+ if [[ -n "${SSH_PORT}" ]]; then
439
+ RSYNC_SSH+=("-p" "${SSH_PORT}")
440
+ fi
441
+ REMOTE_DIR_Q="$(shell_quote "${REMOTE_DIR}")"
442
+ DOMAIN_Q="$(shell_quote "${DOMAIN}")"
443
+ TOKEN_Q="$(shell_quote "${TOKEN}")"
444
+ GITHUB_CLIENT_ID_Q="$(shell_quote "${GITHUB_CLIENT_ID}")"
445
+ GITHUB_CLIENT_SECRET_Q="$(shell_quote "${GITHUB_CLIENT_SECRET}")"
446
+ GITHUB_OWNER_LOGIN_Q="$(shell_quote "${GITHUB_OWNER_LOGIN}")"
447
+ GITHUB_OWNER_ID_Q="$(shell_quote "${GITHUB_OWNER_ID}")"
448
+ SESSION_SECRET_Q="$(shell_quote "${SESSION_SECRET}")"
449
+ AUTH_ALLOWED_ORIGINS_Q="$(shell_quote "${AUTH_ALLOWED_ORIGINS}")"
450
+ SITE_URL_Q="$(shell_quote "${SITE_URL}")"
451
+ API_URL_Q="$(shell_quote "${API_URL}")"
452
+ CERTBOT_EMAIL_Q="$(shell_quote "${CERTBOT_EMAIL}")"
453
+ SHARE_PORT_Q="$(shell_quote "${SHARE_PORT}")"
454
+ PROXY_MODE_Q="$(shell_quote "${PROXY_MODE}")"
455
+ PUBLIC_PATH_Q="$(shell_quote "${PUBLIC_PATH}")"
456
+
457
+ echo "Deploying Codex Snapshots share API to ${SSH_TARGET}"
458
+ echo "Domain: ${DOMAIN}"
459
+ echo "API URL: ${API_URL}"
460
+ echo "Site URL: ${SITE_URL}"
461
+ if [[ "${TOKEN_GENERATED}" -eq 1 ]]; then
462
+ echo "Publish token: generated for this deployment"
463
+ fi
464
+
465
+ if [[ "${DRY_RUN}" -eq 1 ]]; then
466
+ cat <<EOF
467
+
468
+ Resolved deployment plan:
469
+ SSH target: ${SSH_TARGET}
470
+ SSH identity file: ${SSH_IDENTITY_FILE:-"(default)"}
471
+ SSH port: ${SSH_PORT:-"(default)"}
472
+ Domain: ${DOMAIN}
473
+ API URL: ${API_URL}
474
+ Site URL: ${SITE_URL}
475
+ Remote dir: ${REMOTE_DIR}
476
+ Service port: ${SHARE_PORT}
477
+ Proxy mode: ${PROXY_MODE}
478
+ Public path: ${PUBLIC_PATH:-"(none)"}
479
+ Token: $(if [[ -z "${TOKEN}" ]]; then printf "not configured (GitHub OAuth mode)"; elif [[ "${TOKEN_GENERATED}" -eq 1 ]]; then printf "generated (%s chars)" "${#TOKEN}"; else printf "set (%s chars)" "${#TOKEN}"; fi)
480
+ GitHub OAuth: $([[ -n "${GITHUB_CLIENT_ID}${GITHUB_CLIENT_SECRET}" ]] && printf "configured" || printf "not configured")
481
+ GitHub site owner: ${GITHUB_OWNER_LOGIN:-${GITHUB_OWNER_ID:-"(unset)"}}
482
+ Install deps: ${INSTALL_DEPS}
483
+ Preflight: ${RUN_PREFLIGHT}
484
+ Issue cert: ${ISSUE_CERT}
485
+ Certbot email: ${CERTBOT_EMAIL:-"(unset)"}
486
+ Verify ECS API: ${RUN_VERIFY}
487
+ Configure Pages: ${CONFIGURE_PAGES}
488
+ Wait Pages: ${WAIT_PAGES}
489
+ Pages repo: ${PAGES_REPO}
490
+ Pages workflow: ${PAGES_WORKFLOW}
491
+ Configure local publisher: ${CONFIGURE_LOCAL}
492
+ Reinstall local daemon: ${REINSTALL_DAEMON}
493
+ EOF
494
+ exit 0
495
+ fi
496
+
497
+ if [[ "${INSTALL_DEPS}" -eq 1 ]]; then
498
+ remote_install_deps_cmd=$(
499
+ cat <<EOF
500
+ set -e
501
+ tmp_script="\$(mktemp /tmp/codex-snapshots-install-deps.XXXXXX.sh)"
502
+ cat > "\${tmp_script}"
503
+ chmod +x "\${tmp_script}"
504
+ if [ "\$(id -u)" -eq 0 ]; then
505
+ SNAPSHOT_SHARE_PROXY_MODE=${PROXY_MODE_Q} "\${tmp_script}"
506
+ else
507
+ sudo -n env SNAPSHOT_SHARE_PROXY_MODE=${PROXY_MODE_Q} "\${tmp_script}"
508
+ fi
509
+ rm -f "\${tmp_script}"
510
+ EOF
511
+ )
512
+ "${RSYNC_SSH[@]}" "${SSH_TARGET}" "${remote_install_deps_cmd}" < "${REPO_ROOT}/deploy/aliyun/install-system-deps.sh"
513
+ fi
514
+
515
+ if [[ "${RUN_PREFLIGHT}" -eq 1 ]]; then
516
+ preflight_args=(--domain "${DOMAIN}" --ssh "${SSH_TARGET}")
517
+ if [[ -n "${SSH_IDENTITY_FILE}" ]]; then
518
+ preflight_args+=(--identity-file "${SSH_IDENTITY_FILE}")
519
+ fi
520
+ if [[ -n "${SSH_PORT}" ]]; then
521
+ preflight_args+=(--port "${SSH_PORT}")
522
+ fi
523
+ if [[ "${ISSUE_CERT}" -eq 1 ]]; then
524
+ preflight_args+=(--require-certbot-nginx)
525
+ fi
526
+ if [[ "${PROXY_MODE}" == "caddy" ]]; then
527
+ preflight_args+=(--proxy-mode caddy)
528
+ fi
529
+ node "${REPO_ROOT}/deploy/aliyun/preflight.mjs" "${preflight_args[@]}"
530
+ fi
531
+
532
+ if ! command -v pnpm >/dev/null 2>&1; then
533
+ echo "pnpm is required to build dist assets before deploy." >&2
534
+ exit 1
535
+ fi
536
+
537
+ (cd "${REPO_ROOT}" && pnpm build)
538
+
539
+ "${RSYNC_SSH[@]}" "${SSH_TARGET}" "mkdir -p ${REMOTE_DIR_Q}"
540
+ rsync -az --delete \
541
+ -e "$(printf '%q ' "${RSYNC_SSH[@]}")" \
542
+ --exclude ".git" \
543
+ --exclude ".env" \
544
+ --exclude "node_modules" \
545
+ --exclude ".codex-snapshots" \
546
+ --exclude "backups" \
547
+ --exclude "deploy/aliyun/deploy.env" \
548
+ --exclude "*.pem" \
549
+ --exclude "*.key" \
550
+ "${REPO_ROOT}/" "${SSH_TARGET}:${REMOTE_DIR}/"
551
+
552
+ remote_install_cmd=$(
553
+ cat <<EOF
554
+ set -e
555
+ cd ${REMOTE_DIR_Q}
556
+ rm -f .env deploy/aliyun/deploy.env
557
+ rm -rf backups
558
+ if [ "\$(id -u)" -eq 0 ]; then
559
+ env DOMAIN=${DOMAIN_Q} SNAPSHOT_SHARE_TOKEN=${TOKEN_Q} SNAPSHOT_SHARE_SITE_URL=${SITE_URL_Q} SNAPSHOT_SHARE_PUBLIC_API_URL=${API_URL_Q} SNAPSHOT_GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID_Q} SNAPSHOT_GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET_Q} SNAPSHOT_GITHUB_OWNER_LOGIN=${GITHUB_OWNER_LOGIN_Q} SNAPSHOT_GITHUB_OWNER_ID=${GITHUB_OWNER_ID_Q} SNAPSHOT_SESSION_SECRET=${SESSION_SECRET_Q} SNAPSHOT_AUTH_ALLOWED_ORIGINS=${AUTH_ALLOWED_ORIGINS_Q} PORT=${SHARE_PORT_Q} SNAPSHOT_SHARE_PROXY_MODE=${PROXY_MODE_Q} SNAPSHOT_SHARE_PUBLIC_PATH=${PUBLIC_PATH_Q} deploy/aliyun/install-share-api.sh
560
+ else
561
+ sudo env DOMAIN=${DOMAIN_Q} SNAPSHOT_SHARE_TOKEN=${TOKEN_Q} SNAPSHOT_SHARE_SITE_URL=${SITE_URL_Q} SNAPSHOT_SHARE_PUBLIC_API_URL=${API_URL_Q} SNAPSHOT_GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID_Q} SNAPSHOT_GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET_Q} SNAPSHOT_GITHUB_OWNER_LOGIN=${GITHUB_OWNER_LOGIN_Q} SNAPSHOT_GITHUB_OWNER_ID=${GITHUB_OWNER_ID_Q} SNAPSHOT_SESSION_SECRET=${SESSION_SECRET_Q} SNAPSHOT_AUTH_ALLOWED_ORIGINS=${AUTH_ALLOWED_ORIGINS_Q} PORT=${SHARE_PORT_Q} SNAPSHOT_SHARE_PROXY_MODE=${PROXY_MODE_Q} SNAPSHOT_SHARE_PUBLIC_PATH=${PUBLIC_PATH_Q} deploy/aliyun/install-share-api.sh
562
+ fi
563
+ EOF
564
+ )
565
+
566
+ "${RSYNC_SSH[@]}" "${SSH_TARGET}" "${remote_install_cmd}"
567
+
568
+ if [[ "${ISSUE_CERT}" -eq 1 ]]; then
569
+ remote_cert_cmd=$(
570
+ cat <<EOF
571
+ set -e
572
+ if [ "\$(id -u)" -eq 0 ]; then
573
+ certbot --nginx -d ${DOMAIN_Q} --non-interactive --agree-tos --email ${CERTBOT_EMAIL_Q}
574
+ else
575
+ sudo certbot --nginx -d ${DOMAIN_Q} --non-interactive --agree-tos --email ${CERTBOT_EMAIL_Q}
576
+ fi
577
+ cd ${REMOTE_DIR_Q}
578
+ rm -f .env deploy/aliyun/deploy.env
579
+ rm -rf backups
580
+ if [ "\$(id -u)" -eq 0 ]; then
581
+ env DOMAIN=${DOMAIN_Q} SNAPSHOT_SHARE_TOKEN=${TOKEN_Q} SNAPSHOT_SHARE_SITE_URL=${SITE_URL_Q} SNAPSHOT_SHARE_PUBLIC_API_URL=${API_URL_Q} SNAPSHOT_GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID_Q} SNAPSHOT_GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET_Q} SNAPSHOT_GITHUB_OWNER_LOGIN=${GITHUB_OWNER_LOGIN_Q} SNAPSHOT_GITHUB_OWNER_ID=${GITHUB_OWNER_ID_Q} SNAPSHOT_SESSION_SECRET=${SESSION_SECRET_Q} SNAPSHOT_AUTH_ALLOWED_ORIGINS=${AUTH_ALLOWED_ORIGINS_Q} PORT=${SHARE_PORT_Q} SNAPSHOT_SHARE_PROXY_MODE=${PROXY_MODE_Q} SNAPSHOT_SHARE_PUBLIC_PATH=${PUBLIC_PATH_Q} deploy/aliyun/install-share-api.sh
582
+ else
583
+ sudo env DOMAIN=${DOMAIN_Q} SNAPSHOT_SHARE_TOKEN=${TOKEN_Q} SNAPSHOT_SHARE_SITE_URL=${SITE_URL_Q} SNAPSHOT_SHARE_PUBLIC_API_URL=${API_URL_Q} SNAPSHOT_GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID_Q} SNAPSHOT_GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET_Q} SNAPSHOT_GITHUB_OWNER_LOGIN=${GITHUB_OWNER_LOGIN_Q} SNAPSHOT_GITHUB_OWNER_ID=${GITHUB_OWNER_ID_Q} SNAPSHOT_SESSION_SECRET=${SESSION_SECRET_Q} SNAPSHOT_AUTH_ALLOWED_ORIGINS=${AUTH_ALLOWED_ORIGINS_Q} PORT=${SHARE_PORT_Q} SNAPSHOT_SHARE_PROXY_MODE=${PROXY_MODE_Q} SNAPSHOT_SHARE_PUBLIC_PATH=${PUBLIC_PATH_Q} deploy/aliyun/install-share-api.sh
584
+ fi
585
+ EOF
586
+ )
587
+ "${RSYNC_SSH[@]}" "${SSH_TARGET}" "${remote_cert_cmd}"
588
+ fi
589
+
590
+ if [[ "${RUN_VERIFY}" -eq 1 ]]; then
591
+ if [[ "${GITHUB_AUTH_ENABLED}" -eq 1 ]]; then
592
+ node "${REPO_ROOT}/deploy/aliyun/verify-public-share.mjs" \
593
+ --api-url "${API_URL}" \
594
+ --site-url "${SITE_URL}" \
595
+ --skip-site-config
596
+ echo "Skipped token publish verification because GitHub OAuth is configured; verify browser publishing after logging in with GitHub."
597
+ else
598
+ SNAPSHOT_SHARE_TOKEN="${TOKEN}" node "${REPO_ROOT}/deploy/aliyun/verify-public-share.mjs" \
599
+ --api-url "${API_URL}" \
600
+ --site-url "${SITE_URL}" \
601
+ --skip-site-config \
602
+ --publish
603
+ fi
604
+ fi
605
+
606
+ if [[ "${CONFIGURE_PAGES}" -eq 1 ]]; then
607
+ pages_args=(
608
+ --api-url "${API_URL}"
609
+ --repo "${PAGES_REPO}"
610
+ --workflow "${PAGES_WORKFLOW}"
611
+ )
612
+ if [[ "${WAIT_PAGES}" -eq 1 ]]; then
613
+ pages_args+=(--wait)
614
+ fi
615
+ "${REPO_ROOT}/deploy/aliyun/configure-github-pages-api.sh" "${pages_args[@]}"
616
+
617
+ if [[ "${WAIT_PAGES}" -eq 1 && "${RUN_VERIFY}" -eq 1 ]]; then
618
+ if [[ "${GITHUB_AUTH_ENABLED}" -eq 1 ]]; then
619
+ node "${REPO_ROOT}/deploy/aliyun/verify-public-share.mjs" \
620
+ --api-url "${API_URL}" \
621
+ --site-url "${SITE_URL}"
622
+ echo "Skipped token publish verification because GitHub OAuth is configured; verify browser publishing after logging in with GitHub."
623
+ else
624
+ SNAPSHOT_SHARE_TOKEN="${TOKEN}" node "${REPO_ROOT}/deploy/aliyun/verify-public-share.mjs" \
625
+ --api-url "${API_URL}" \
626
+ --site-url "${SITE_URL}" \
627
+ --publish
628
+ fi
629
+ fi
630
+ fi
631
+
632
+ if [[ "${CONFIGURE_LOCAL}" -eq 1 ]]; then
633
+ local_args=(
634
+ --api-url "${API_URL}"
635
+ --site-url "${SITE_URL}"
636
+ )
637
+ if [[ -n "${TOKEN}" ]]; then
638
+ local_args+=(--token "${TOKEN}")
639
+ fi
640
+ if [[ "${REINSTALL_DAEMON}" -eq 1 ]]; then
641
+ local_args+=(--reinstall-daemon)
642
+ fi
643
+ "${REPO_ROOT}/deploy/aliyun/configure-local-publisher.sh" "${local_args[@]}"
644
+
645
+ if [[ "${RUN_VERIFY}" -eq 1 ]]; then
646
+ node "${REPO_ROOT}/deploy/aliyun/verify-public-share.mjs" \
647
+ --api-url "${API_URL}" \
648
+ --site-url "${SITE_URL}" \
649
+ --skip-site-config \
650
+ --check-local-config
651
+ fi
652
+ fi
653
+
654
+ cat <<EOF
655
+
656
+ Deployment command finished.
657
+
658
+ Next:
659
+ 1. Set GitHub repository variable:
660
+ CODEX_SNAPSHOTS_PUBLIC_API_URL=${API_URL}
661
+ or run:
662
+ deploy/aliyun/configure-github-pages-api.sh --api-url ${API_URL} --repo ffffhx/codex-snapshots
663
+ 2. Trigger the GitHub Pages workflow and wait for it to finish.
664
+ 3. Run the public site verification:
665
+ node deploy/aliyun/verify-public-share.mjs --api-url ${API_URL} --site-url ${SITE_URL}
666
+ 4. Configure your local viewer's public API/site settings:
667
+ SNAPSHOT_SHARE_API_URL=${API_URL} SNAPSHOT_SHARE_SITE_URL=${SITE_URL} deploy/aliyun/configure-local-publisher.sh --reinstall-daemon
668
+ 5. If GitHub OAuth is configured, publish once from the browser after logging in with GitHub.
669
+ EOF