alpha-gate 0.1.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.
Files changed (140) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +21 -0
  3. package/README.md +138 -0
  4. package/bin/alpha-gate.mjs +75 -0
  5. package/deploy/backup.sh +45 -0
  6. package/deploy/deploy.sh +21 -0
  7. package/deploy/dev.sh +56 -0
  8. package/deploy/lib/statedir.sh +11 -0
  9. package/deploy/teardown.sh +17 -0
  10. package/docs/ONBOARDING.md +16 -0
  11. package/docs/PRINCIPLES.md +195 -0
  12. package/docs/README.md +54 -0
  13. package/docs/UPLOADING.md +14 -0
  14. package/docs/integrate/activation.md +56 -0
  15. package/docs/integrate/sparkle-go.md +106 -0
  16. package/docs/integrate/sparkle-swift.md +63 -0
  17. package/docs/maintain/backup.md +52 -0
  18. package/docs/maintain/migrate-account.md +95 -0
  19. package/docs/maintain/teardown.md +46 -0
  20. package/docs/maintain/troubleshooting.md +102 -0
  21. package/docs/maintain/updating.md +92 -0
  22. package/docs/operate/add-users.md +55 -0
  23. package/docs/operate/channels.md +54 -0
  24. package/docs/operate/email.md +51 -0
  25. package/docs/operate/monitoring.md +56 -0
  26. package/docs/operate/publish.md +90 -0
  27. package/docs/operate/remove-users.md +47 -0
  28. package/docs/setup/cloudflare-account.md +41 -0
  29. package/docs/setup/deploy.md +84 -0
  30. package/docs/setup/install.md +65 -0
  31. package/migrations/0001_clients.sql +12 -0
  32. package/migrations/0002_builds_streams.sql +30 -0
  33. package/migrations/0003_access_log.sql +14 -0
  34. package/migrations/0004_meta.sql +5 -0
  35. package/migrations/0005_admin_audit.sql +14 -0
  36. package/migrations/0006_build_dmg.sql +6 -0
  37. package/migrations/0007_access_requests.sql +12 -0
  38. package/migrations/0008_build_rollback_target.sql +7 -0
  39. package/migrations/0009_hidden.sql +6 -0
  40. package/migrations/0010_purged_at.sql +5 -0
  41. package/package.json +65 -0
  42. package/publish.sh +302 -0
  43. package/release.json +7 -0
  44. package/src/auth/access-jwt.ts +210 -0
  45. package/src/auth/token-gate.ts +27 -0
  46. package/src/core/appcast.ts +107 -0
  47. package/src/core/audit-chain.ts +145 -0
  48. package/src/core/invite-template.ts +127 -0
  49. package/src/core/no-build.ts +160 -0
  50. package/src/core/resolver.ts +60 -0
  51. package/src/core/tokens.ts +51 -0
  52. package/src/core/types.ts +76 -0
  53. package/src/core/validation.ts +60 -0
  54. package/src/core/verdict.ts +195 -0
  55. package/src/core/version.ts +113 -0
  56. package/src/cron.ts +37 -0
  57. package/src/db/access-log.ts +168 -0
  58. package/src/db/access-requests.ts +90 -0
  59. package/src/db/admin-audit.ts +101 -0
  60. package/src/db/builds.ts +169 -0
  61. package/src/db/client.ts +80 -0
  62. package/src/db/clients.ts +103 -0
  63. package/src/db/meta.ts +30 -0
  64. package/src/db/streams.ts +85 -0
  65. package/src/deploy/cli.ts +181 -0
  66. package/src/deploy/commands/deploy.ts +392 -0
  67. package/src/deploy/commands/dev.ts +190 -0
  68. package/src/deploy/commands/teardown.ts +159 -0
  69. package/src/deploy/core/args.ts +205 -0
  70. package/src/deploy/core/colors.ts +49 -0
  71. package/src/deploy/core/config.ts +65 -0
  72. package/src/deploy/core/parse.ts +51 -0
  73. package/src/deploy/core/paths.ts +27 -0
  74. package/src/deploy/core/plan.ts +138 -0
  75. package/src/deploy/core/result.ts +13 -0
  76. package/src/deploy/core/state.ts +64 -0
  77. package/src/deploy/core/table.ts +49 -0
  78. package/src/deploy/core/types.ts +39 -0
  79. package/src/deploy/core/ui.ts +107 -0
  80. package/src/deploy/seams/clock.ts +10 -0
  81. package/src/deploy/seams/files.ts +52 -0
  82. package/src/deploy/seams/io.ts +56 -0
  83. package/src/deploy/seams/wrangler.ts +100 -0
  84. package/src/deploy/ui-preview.ts +112 -0
  85. package/src/deps.ts +41 -0
  86. package/src/dev/admin-entry.ts +104 -0
  87. package/src/env.ts +47 -0
  88. package/src/lib/clock.ts +17 -0
  89. package/src/lib/hosts.ts +27 -0
  90. package/src/lib/text.ts +6 -0
  91. package/src/r2/builds-bucket.ts +50 -0
  92. package/src/r2/keys.ts +27 -0
  93. package/src/routes/admin/admin-context.ts +9 -0
  94. package/src/routes/admin/audit-fields.ts +22 -0
  95. package/src/routes/admin/branding.tsx +161 -0
  96. package/src/routes/admin/builds.tsx +388 -0
  97. package/src/routes/admin/clients.tsx +396 -0
  98. package/src/routes/admin/confirm.tsx +80 -0
  99. package/src/routes/admin/flash.ts +72 -0
  100. package/src/routes/admin/form.ts +43 -0
  101. package/src/routes/admin/index.ts +146 -0
  102. package/src/routes/admin/invite.ts +57 -0
  103. package/src/routes/admin/middleware.ts +52 -0
  104. package/src/routes/admin/negotiate.ts +13 -0
  105. package/src/routes/admin/pending.tsx +73 -0
  106. package/src/routes/admin/read-model.ts +518 -0
  107. package/src/routes/admin/streams.tsx +182 -0
  108. package/src/routes/admin/theme.ts +34 -0
  109. package/src/routes/admin/upload.tsx +320 -0
  110. package/src/routes/admin/views.tsx +293 -0
  111. package/src/routes/app/access.tsx +38 -0
  112. package/src/routes/app/app-context.ts +8 -0
  113. package/src/routes/app/appcast.ts +68 -0
  114. package/src/routes/app/assets.ts +25 -0
  115. package/src/routes/app/download.ts +45 -0
  116. package/src/routes/app/get.tsx +35 -0
  117. package/src/routes/app/index.ts +31 -0
  118. package/src/routes/app/resolve.ts +16 -0
  119. package/src/services/anchor.ts +54 -0
  120. package/src/services/audit.ts +19 -0
  121. package/src/services/branding.ts +51 -0
  122. package/src/services/email-cloudflare.ts +80 -0
  123. package/src/services/email.ts +68 -0
  124. package/src/services/self-update.ts +62 -0
  125. package/src/views/access-page.tsx +39 -0
  126. package/src/views/admin/ci-page.tsx +76 -0
  127. package/src/views/admin/combobox.tsx +191 -0
  128. package/src/views/admin/forms.tsx +37 -0
  129. package/src/views/admin/layout.tsx +496 -0
  130. package/src/views/admin/manage-pages.tsx +223 -0
  131. package/src/views/admin/manage.tsx +1120 -0
  132. package/src/views/admin/plist-extract.ts +233 -0
  133. package/src/views/admin/read-pages.tsx +1098 -0
  134. package/src/views/admin/setup-page.tsx +108 -0
  135. package/src/views/admin/table-enhance.ts +176 -0
  136. package/src/views/admin/ui.tsx +159 -0
  137. package/src/views/get-page.tsx +39 -0
  138. package/src/views/layout.tsx +57 -0
  139. package/src/worker.ts +23 -0
  140. package/tsconfig.json +35 -0
@@ -0,0 +1,65 @@
1
+ # Install
2
+
3
+ How to install the Alpha Gate CLI, from npm or from a git clone, and run both Workers on your machine without a Cloudflare account.
4
+
5
+ ## Requirements
6
+
7
+ - **Node ≥ 20** and npm.
8
+ - A Cloudflare account — only when you deploy (the free tier is enough). Local development and tests need no account.
9
+ - macOS — only when you [publish builds](../operate/publish.md); Apple signing and notarization run on your Mac. Deploying works from any OS.
10
+
11
+ ## Install from npm
12
+
13
+ No clone; you run a pinned, versioned release:
14
+
15
+ ```bash
16
+ npx alpha-gate deploy --instance <slug>
17
+ ```
18
+
19
+ `npx alpha-gate <command>` runs the latest release; `npx alpha-gate@0.1.0 <command>` pins one. (Until the first release lands on npm, install from a clone, below.) You can also install globally with `npm i -g alpha-gate` and run `alpha-gate <command>`. The commands are `deploy`, `dev`, `publish`, `backup`, and `teardown`; run `alpha-gate <command> --help` for a command's options.
20
+
21
+ State lives in `~/.alpha-gate`: per-instance records (`<slug>.state.json`) and the rendered wrangler configs. Set `$ALPHA_GATE_HOME` to move it. The package files themselves sit in npm's versioned cache, so nothing durable is written there.
22
+
23
+ ## Install from a clone
24
+
25
+ For contributors, or to run unreleased `main`:
26
+
27
+ ```bash
28
+ git clone <your-fork> alpha-gate
29
+ cd alpha-gate
30
+ npm install
31
+ ```
32
+
33
+ The `deploy/*.sh` wrappers are the same CLI: `./deploy/deploy.sh --instance <slug>` takes the same flags as `npx alpha-gate deploy --instance <slug>`. A clone keeps its state in the repo's `.deploy/` directory instead of `~/.alpha-gate`, so a checkout keeps finding the instances it deployed. `npm install` needs no Cloudflare account, and the test suite (`npm test`) runs offline.
34
+
35
+ ## Run it locally
36
+
37
+ ```bash
38
+ ./deploy/dev.sh
39
+ ```
40
+
41
+ This starts both Workers on Miniflare with no Cloudflare account: the app on `http://localhost:8787` and the admin on `http://localhost:8788/admin`. It renders a local wrangler config, applies migrations to a local database, seeds a demo user and a demo build so `/get`, `/appcast`, and `/download` return real data, and prints the seeded `/get?token=` link. The first run downloads the `workerd` runtime, so it needs the network once. Ctrl-C stops both Workers.
42
+
43
+ Flags:
44
+
45
+ | Flag | Effect |
46
+ |---|---|
47
+ | `--port <n>` | App port (default 8787); when both Workers run, the admin takes the next port up. With `--role admin`, `--port` is the admin's port. |
48
+ | `--no-seed` | Skip the demo user and build. |
49
+ | `--reset` | Wipe the local D1/R2 state before starting. |
50
+ | `--role app` or `--role admin` | Start one Worker instead of both. |
51
+
52
+ The local admin runs behind a dev-only auth shim: there is no login, every request acts as the admin `dev@local`, and mutations are audited under that name. **The shim is localhost-only and cannot ship** — nothing in the deployed Worker references it, and it refuses to serve unless `DEV_ADMIN=1`, which only the local `dev` command sets when it starts the admin. Production admin auth is unchanged.
53
+
54
+ From an npm install, `alpha-gate dev` starts one Worker at a time: the app by default, or the admin with `--role admin`.
55
+
56
+ ## Where state lives
57
+
58
+ | Install | State directory |
59
+ |---|---|
60
+ | npm (`npx alpha-gate`) | `~/.alpha-gate` |
61
+ | git clone | `<repo>/.deploy` |
62
+
63
+ `$ALPHA_GATE_HOME` overrides both. The directory holds per-instance deploy records and the rendered wrangler configs; the data itself (users, tokens, builds, channels) lives in your instance's D1 database (see [Backup](../maintain/backup.md)).
64
+
65
+ Next: [Prepare your Cloudflare account](cloudflare-account.md)
@@ -0,0 +1,12 @@
1
+ -- 0001_clients.sql
2
+ CREATE TABLE clients (
3
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
4
+ email TEXT NOT NULL UNIQUE,
5
+ token TEXT NOT NULL UNIQUE,
6
+ status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'revoked'
7
+ pinned_build_id INTEGER, -- nullable; overrides stream resolution
8
+ label TEXT,
9
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
10
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
11
+ );
12
+ CREATE INDEX idx_clients_token ON clients(token);
@@ -0,0 +1,30 @@
1
+ -- 0002_builds_streams.sql
2
+ CREATE TABLE builds (
3
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
4
+ short_version TEXT NOT NULL, -- human, e.g. '1.4.0'
5
+ build_number INTEGER NOT NULL UNIQUE, -- machine CFBundleVersion, monotonic
6
+ object_key TEXT NOT NULL, -- R2 key of the archive
7
+ ed_signature TEXT NOT NULL, -- Sparkle EdDSA (from generate_appcast)
8
+ length INTEGER NOT NULL,
9
+ min_os TEXT,
10
+ critical INTEGER NOT NULL DEFAULT 0, -- mandatory/critical update flag
11
+ status TEXT NOT NULL DEFAULT 'available', -- 'available' | 'withdrawn'
12
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
13
+ );
14
+
15
+ CREATE TABLE streams (
16
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
17
+ name TEXT NOT NULL UNIQUE -- e.g. 'stable', 'beta', 'canary'
18
+ );
19
+
20
+ CREATE TABLE build_streams (
21
+ build_id INTEGER NOT NULL REFERENCES builds(id),
22
+ stream_id INTEGER NOT NULL REFERENCES streams(id),
23
+ PRIMARY KEY (build_id, stream_id)
24
+ );
25
+
26
+ CREATE TABLE user_streams (
27
+ client_id INTEGER NOT NULL REFERENCES clients(id),
28
+ stream_id INTEGER NOT NULL REFERENCES streams(id),
29
+ PRIMARY KEY (client_id, stream_id)
30
+ );
@@ -0,0 +1,14 @@
1
+ -- 0003_access_log.sql
2
+ CREATE TABLE access_log (
3
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
4
+ client_id INTEGER,
5
+ email TEXT, -- denormalized; survives client deletion
6
+ event TEXT NOT NULL, -- 'check' | 'download' | 'update'
7
+ short_version TEXT,
8
+ build_number INTEGER,
9
+ ip TEXT,
10
+ user_agent TEXT,
11
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
12
+ );
13
+ CREATE INDEX idx_access_log_client ON access_log(client_id);
14
+ CREATE INDEX idx_access_log_created ON access_log(created_at);
@@ -0,0 +1,5 @@
1
+ -- 0004_meta.sql
2
+ CREATE TABLE meta (
3
+ key TEXT PRIMARY KEY, -- tool-update bookkeeping, etc.
4
+ value TEXT
5
+ );
@@ -0,0 +1,14 @@
1
+ -- 0005_admin_audit.sql
2
+ CREATE TABLE admin_audit (
3
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
4
+ actor_email TEXT NOT NULL, -- from the Access JWT
5
+ action TEXT NOT NULL, -- e.g. 'client.revoke', 'build.withdraw', 'stream.assign'
6
+ target TEXT, -- entity affected (email, build_number, stream)
7
+ detail TEXT, -- JSON: params / before-after
8
+ ip TEXT,
9
+ ray_id TEXT, -- Cloudflare Ray ID, to cross-reference platform logs
10
+ prev_hash TEXT, -- hash of the previous row (chain)
11
+ hash TEXT NOT NULL, -- SHA-256(prev_hash ‖ canonical(entry)) — tamper-evidence
12
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
13
+ );
14
+ CREATE INDEX idx_admin_audit_created ON admin_audit(created_at);
@@ -0,0 +1,6 @@
1
+ -- 0006_build_dmg.sql
2
+ -- Second build artifact (decision 0003): the first-install DMG, alongside the EdDSA-signed .app zip.
3
+ -- The DMG carries no EdDSA — notarization/Gatekeeper seal it and Sparkle never touches it. Both
4
+ -- nullable: a build may ship the zip only (the Sparkle enclosure is always object_key/ed_signature/length).
5
+ ALTER TABLE builds ADD COLUMN dmg_object_key TEXT;
6
+ ALTER TABLE builds ADD COLUMN dmg_length INTEGER;
@@ -0,0 +1,12 @@
1
+ -- 0007_access_requests.sql
2
+ -- §13 — submissions from the public "request access" page (§13 IA #10). The admin reviews these and
3
+ -- invites or dismisses each. No FK to clients: a request is from someone who may not have a row yet.
4
+ CREATE TABLE access_requests (
5
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
6
+ email TEXT NOT NULL,
7
+ ip TEXT,
8
+ user_agent TEXT,
9
+ status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'handled' | 'dismissed'
10
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
11
+ );
12
+ CREATE INDEX idx_access_requests_status ON access_requests(status);
@@ -0,0 +1,7 @@
1
+ -- 0008_build_rollback_target.sql
2
+ -- §9/§13 #7 — an operator annotation marking a known-good build as the designated rollback target.
3
+ -- This is a LABEL ONLY, not a downgrade switch: Sparkle cannot downgrade, so a real rollback is a
4
+ -- roll-forward (rebuild the good code with a higher build_number, withdraw the bad one — see §9). The
5
+ -- flag records which build to roll forward FROM and surfaces it on the builds list / manage page; the
6
+ -- resolver (§8) is unaffected and keeps serving the highest available build.
7
+ ALTER TABLE builds ADD COLUMN rollback_target INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,6 @@
1
+ -- Admin-list visibility: a soft "hide" that declutters the Users/Builds lists. UI-only — it does NOT
2
+ -- affect resolution or serving (a hidden available build still serves; a hidden user still gets updates).
3
+ -- Withdraw/revoke remain the functional controls. The list views exclude hidden rows by default and a
4
+ -- "show hidden" toggle reveals them.
5
+ ALTER TABLE clients ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;
6
+ ALTER TABLE builds ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,5 @@
1
+ -- Archive purge (storage lifecycle): when the R2 bytes of a WITHDRAWN build are deleted to reclaim
2
+ -- free-tier space, the D1 row is kept (build_number uniqueness, download/update counts, and the
3
+ -- audit chain stay intact) and stamped here. A purged build can be restored to "available" only
4
+ -- after re-uploading its archive — purged_at gates that. NULL = archive still present.
5
+ ALTER TABLE builds ADD COLUMN purged_at TEXT;
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "alpha-gate",
3
+ "version": "0.1.0",
4
+ "description": "Self-hosted Cloudflare distribution gate for a Sparkle-updated macOS app",
5
+ "keywords": [
6
+ "sparkle",
7
+ "macos",
8
+ "auto-update",
9
+ "cloudflare-workers",
10
+ "self-hosted",
11
+ "distribution"
12
+ ],
13
+ "homepage": "https://github.com/alex-ivanov/alpha-gate/blob/main/CHANGELOG.md",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/alex-ivanov/alpha-gate.git"
17
+ },
18
+ "license": "MIT",
19
+ "type": "module",
20
+ "bin": {
21
+ "alpha-gate": "bin/alpha-gate.mjs"
22
+ },
23
+ "engines": {
24
+ "node": ">=20"
25
+ },
26
+ "files": [
27
+ "bin/",
28
+ "src/",
29
+ "migrations/",
30
+ "deploy/",
31
+ "publish.sh",
32
+ "tsconfig.json",
33
+ "release.json",
34
+ "CHANGELOG.md",
35
+ "docs/"
36
+ ],
37
+ "alphaGate": {
38
+ "minSupported": "0.1.0",
39
+ "breaking": false
40
+ },
41
+ "scripts": {
42
+ "test": "vitest run --config test/vitest.config.ts && vitest run --config test/vitest.deploy.config.ts",
43
+ "test:app": "vitest run --config test/vitest.config.ts",
44
+ "test:deploy": "vitest run --config test/vitest.deploy.config.ts",
45
+ "test:watch": "vitest --config test/vitest.config.ts",
46
+ "ui:preview": "tsx src/deploy/ui-preview.ts",
47
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.deploy.json",
48
+ "lint": "biome check .",
49
+ "format": "biome format --write .",
50
+ "check": "biome check . && npm run typecheck && npm run test"
51
+ },
52
+ "dependencies": {
53
+ "hono": "^4.12.25",
54
+ "tsx": "^4.22.4",
55
+ "wrangler": "^4.100.0"
56
+ },
57
+ "devDependencies": {
58
+ "@biomejs/biome": "^2.5.0",
59
+ "@cloudflare/vitest-pool-workers": "^0.16.15",
60
+ "@cloudflare/workers-types": "^4.20260613.1",
61
+ "@types/node": "^25.9.3",
62
+ "typescript": "^6.0.3",
63
+ "vitest": "^4.1.8"
64
+ }
65
+ }
package/publish.sh ADDED
@@ -0,0 +1,302 @@
1
+ #!/usr/bin/env bash
2
+ # ONE command to publish a signed, notarized macOS app to Alpha Gate. Give it the artifact:
3
+ #
4
+ # ./publish.sh MyApp.dmg # into the only deployed instance, no channel
5
+ # ./publish.sh MyApp.zip --channel beta # a signed .app .zip, linked to a channel by NAME
6
+ # ./publish.sh MyApp.dmg --instance myalpha --critical
7
+ #
8
+ # It reads the version straight from the app's Info.plist (never retype it), makes the Sparkle EdDSA
9
+ # signature with sign_update, and uploads. The Worker never signs and never holds the key. It handles
10
+ # BOTH artifact formats and the >90 MB register path itself, and pre-checks the build number against
11
+ # the running instance so you never fail after a multi-minute upload.
12
+ #
13
+ # What it needs on macOS: hdiutil (built in), Sparkle's sign_update (auto-found in Xcode's
14
+ # DerivedData, or pass --sign-update / $SIGN_UPDATE, or bypass with ED_SIGNATURE=<sig>). For a real
15
+ # instance it needs a Cloudflare Access service token — entered once, then stored in your Keychain.
16
+ #
17
+ # CI / headless: set CF_ACCESS_CLIENT_ID + CF_ACCESS_CLIENT_SECRET and pass --admin-url; on a runner
18
+ # with no app to read (already-built zip), pass --build-number/--short-version and ED_SIGNATURE.
19
+ set -euo pipefail
20
+
21
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
22
+ # shellcheck source=deploy/lib/statedir.sh
23
+ . "${ROOT}/deploy/lib/statedir.sh"
24
+ STATE_DIR="$(alpha_gate_state_dir "${ROOT}")"
25
+
26
+ ARTIFACT=""; INSTANCE=""; ADMIN_URL=""; CHANNEL=""; STREAM_ID=""; CRITICAL_FLAG=""
27
+ DRY_RUN=0; RESET_TOKEN=0; BUILD_OVERRIDE=""; SHORT_OVERRIDE=""; MIN_OS_OVERRIDE=""
28
+ SIGN_UPDATE="${SIGN_UPDATE:-}"
29
+
30
+ need() { [ "$#" -ge 2 ] || { echo "missing value for $1" >&2; exit 1; }; }
31
+ while [ "$#" -gt 0 ]; do
32
+ case "$1" in
33
+ --instance) need "$@"; INSTANCE="$2"; shift 2 ;;
34
+ --admin-url) need "$@"; ADMIN_URL="$2"; shift 2 ;;
35
+ --channel) need "$@"; CHANNEL="$2"; shift 2 ;;
36
+ --stream-id) need "$@"; STREAM_ID="$2"; shift 2 ;; # legacy; --channel is preferred
37
+ --sign-update) need "$@"; SIGN_UPDATE="$2"; shift 2 ;;
38
+ --build-number) need "$@"; BUILD_OVERRIDE="$2"; shift 2 ;;
39
+ --short-version) need "$@"; SHORT_OVERRIDE="$2"; shift 2 ;;
40
+ --min-os) need "$@"; MIN_OS_OVERRIDE="$2"; shift 2 ;;
41
+ --critical) CRITICAL_FLAG="true"; shift ;;
42
+ --reset-token) RESET_TOKEN=1; shift ;;
43
+ --dry-run) DRY_RUN=1; shift ;;
44
+ -h|--help)
45
+ sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
46
+ exit 0 ;;
47
+ -*) echo "unknown flag: $1" >&2; exit 1 ;;
48
+ *) ARTIFACT="$1"; shift ;;
49
+ esac
50
+ done
51
+
52
+ [ -n "${ARTIFACT}" ] || { echo "usage: ./publish.sh <MyApp.dmg|MyApp.zip> [--channel <name>] [--instance <slug>]" >&2; exit 1; }
53
+ [ -f "${ARTIFACT}" ] || { echo "artifact not found: ${ARTIFACT}" >&2; exit 1; }
54
+
55
+ # ─── Resolve the instance + admin URL ──────────────────────────────────────────────────────────────
56
+ # Default --instance when exactly one instance is deployed (its .deploy/<slug>.state.json). This makes
57
+ # the steady-state command './publish.sh MyApp.dmg' with no flags at all.
58
+ if [ -z "${ADMIN_URL}" ] && [ -z "${INSTANCE}" ]; then
59
+ shopt -s nullglob
60
+ STATES=("${STATE_DIR}"/*.state.json)
61
+ shopt -u nullglob
62
+ if [ "${#STATES[@]}" -eq 1 ]; then
63
+ INSTANCE="$(basename "${STATES[0]}" .state.json)"
64
+ echo "using the only deployed instance: ${INSTANCE}" >&2
65
+ elif [ "${#STATES[@]}" -gt 1 ]; then
66
+ echo "multiple instances deployed — pass --instance <slug>. Found:" >&2
67
+ for s in "${STATES[@]}"; do echo " $(basename "$s" .state.json)" >&2; done
68
+ exit 1
69
+ fi
70
+ fi
71
+ if [ -z "${ADMIN_URL}" ] && [ -n "${INSTANCE}" ]; then
72
+ STATE="${STATE_DIR}/${INSTANCE}.state.json"
73
+ [ -f "${STATE}" ] || { echo "no deploy state for instance '${INSTANCE}'; pass --admin-url" >&2; exit 1; }
74
+ ADMIN_URL="$(node -e 'process.stdout.write(String(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).admin_url||""))' "${STATE}" 2>/dev/null)" \
75
+ || { echo "could not read admin_url from ${STATE}; pass --admin-url" >&2; exit 1; }
76
+ fi
77
+ [ -n "${ADMIN_URL}" ] || { echo "--admin-url or --instance is required" >&2; exit 1; }
78
+
79
+ case "${ADMIN_URL}" in
80
+ http://localhost*|http://127.0.0.1*|http://0.0.0.0*|"http://[::1]"*) LOCAL=1 ;;
81
+ *) LOCAL=0 ;;
82
+ esac
83
+
84
+ # ─── Cloudflare Access service token (Keychain, entered once) ───────────────────────────────────────
85
+ # The admin Worker is behind Access; reaching it needs a service token. Stored in the login Keychain
86
+ # keyed by instance/host. Skipped for a localhost dev admin (auto-authenticated) and for --dry-run.
87
+ if [ "${LOCAL}" -eq 0 ] && [ "${DRY_RUN}" -eq 0 ]; then
88
+ KEY="${INSTANCE:-$(printf '%s' "${ADMIN_URL}" | sed -E 's#^https?://([^/]+).*#\1#')}"
89
+ KC_SERVICE="alpha-gate-access"
90
+ kc_get() { security find-generic-password -s "${KC_SERVICE}" -a "$1" -w 2>/dev/null || true; }
91
+ if [ "${RESET_TOKEN}" -eq 1 ]; then
92
+ security delete-generic-password -s "${KC_SERVICE}" -a "${KEY}-client-id" >/dev/null 2>&1 || true
93
+ security delete-generic-password -s "${KC_SERVICE}" -a "${KEY}-client-secret" >/dev/null 2>&1 || true
94
+ CF_ACCESS_CLIENT_ID=""; CF_ACCESS_CLIENT_SECRET=""
95
+ echo "Forgot the stored token for '${KEY}'." >&2
96
+ fi
97
+ [ -z "${CF_ACCESS_CLIENT_ID:-}" ] && CF_ACCESS_CLIENT_ID="$(kc_get "${KEY}-client-id")"
98
+ [ -z "${CF_ACCESS_CLIENT_SECRET:-}" ] && CF_ACCESS_CLIENT_SECRET="$(kc_get "${KEY}-client-secret")"
99
+ if [ -n "${CF_ACCESS_CLIENT_ID}" ] && [ -n "${CF_ACCESS_CLIENT_SECRET}" ]; then
100
+ :
101
+ elif [ -t 0 ]; then
102
+ echo "" >&2
103
+ echo "No Access service token stored for '${KEY}'. Create one (see the admin /admin/ci page):" >&2
104
+ echo " 1. Cloudflare Zero Trust → Access → Service Auth → 'Create service token'." >&2
105
+ echo " 2. On the admin Access application, add a policy (Action: Service Auth) allowing it." >&2
106
+ printf 'Service Token Client ID: ' >&2; IFS= read -r CF_ACCESS_CLIENT_ID || true
107
+ printf 'Service Token Client Secret: ' >&2; IFS= read -rs CF_ACCESS_CLIENT_SECRET || true; echo "" >&2
108
+ if [ -z "${CF_ACCESS_CLIENT_ID}" ] || [ -z "${CF_ACCESS_CLIENT_SECRET}" ]; then
109
+ echo "both are required" >&2; exit 1
110
+ fi
111
+ security add-generic-password -U -s "${KC_SERVICE}" -a "${KEY}-client-id" -w "${CF_ACCESS_CLIENT_ID}" >/dev/null
112
+ security add-generic-password -U -s "${KC_SERVICE}" -a "${KEY}-client-secret" -w "${CF_ACCESS_CLIENT_SECRET}" >/dev/null
113
+ echo "Stored in your login Keychain; future runs read it automatically." >&2
114
+ else
115
+ echo "No service token for '${KEY}' and no TTY. Set CF_ACCESS_CLIENT_ID / CF_ACCESS_CLIENT_SECRET." >&2
116
+ exit 1
117
+ fi
118
+ export CF_ACCESS_CLIENT_ID CF_ACCESS_CLIENT_SECRET
119
+ fi
120
+
121
+ # curl with the Access headers (or dev-local defaults). We check the status ourselves because a
122
+ # rejected service token comes back as a 3xx redirect to the Access login, not a 4xx.
123
+ api() { # METHOD PATH [curl args…] → body on stdout, returns nonzero on failure
124
+ local method="$1" path="$2"; shift 2
125
+ local id="${CF_ACCESS_CLIENT_ID:-dev-local}" secret="${CF_ACCESS_CLIENT_SECRET:-dev-local}"
126
+ local body status
127
+ body="$(mktemp)"
128
+ status="$(curl -sS -o "${body}" -w '%{http_code}' -X "${method}" \
129
+ -H "CF-Access-Client-Id: ${id}" -H "CF-Access-Client-Secret: ${secret}" \
130
+ "$@" "${ADMIN_URL}${path}")" || { echo "could not reach ${ADMIN_URL} (network error)" >&2; rm -f "${body}"; return 1; }
131
+ if [ "${status}" -ge 200 ] && [ "${status}" -lt 300 ]; then cat "${body}"; rm -f "${body}"; return 0; fi
132
+ echo "request failed: HTTP ${status}" >&2
133
+ if [ "${status}" -ge 300 ] && [ "${status}" -lt 400 ]; then
134
+ echo " Cloudflare Access rejected the service token. Confirm the token + a Service-Auth policy" >&2
135
+ echo " on the admin Access app (see /admin/ci), or re-enter: ./publish.sh … --reset-token." >&2
136
+ else
137
+ sed 's/^/ /' "${body}" >&2
138
+ fi
139
+ rm -f "${body}"; return 1
140
+ }
141
+
142
+ # ─── Read version + min-OS from the app inside the artifact ─────────────────────────────────────────
143
+ # One helper reads an Info.plist with PlistBuddy (not `defaults`, which caches). DMGs are mounted;
144
+ # zips are read with unzip -p through plutil. Overrides (flags/env) beat what we read.
145
+ SHORT_VERSION=""; BUILD_NUMBER=""; MIN_OS=""
146
+ pb() { /usr/libexec/PlistBuddy -c "Print :$1" "$2" 2>/dev/null || true; }
147
+
148
+ read_from_plist() { # PLIST_PATH
149
+ SHORT_VERSION="${SHORT_OVERRIDE:-${SHORT_VERSION:-$(pb CFBundleShortVersionString "$1")}}"
150
+ BUILD_NUMBER="${BUILD_OVERRIDE:-${BUILD_NUMBER:-$(pb CFBundleVersion "$1")}}"
151
+ MIN_OS="${MIN_OS_OVERRIDE:-${MIN_OS:-$(pb LSMinimumSystemVersion "$1")}}"
152
+ }
153
+
154
+ case "${ARTIFACT##*.}" in
155
+ dmg)
156
+ ATTACH="$(hdiutil attach "${ARTIFACT}" -nobrowse -readonly -noautoopen -mountrandom /tmp 2>&1)" \
157
+ || { echo "could not mount ${ARTIFACT}:" >&2; printf '%s\n' "${ATTACH}" | sed 's/^/ /' >&2; exit 1; }
158
+ DEV="$(printf '%s\n' "${ATTACH}" | grep -Eo '^/dev/disk[0-9]+' | head -1 || true)"
159
+ MOUNTS="$(printf '%s\n' "${ATTACH}" | grep -Eo '/tmp/[^[:space:]]+' || true)"
160
+ cleanup() {
161
+ if [ -n "${DEV:-}" ]; then hdiutil detach "${DEV}" -quiet >/dev/null 2>&1 || true; fi
162
+ for m in ${MOUNTS:-}; do hdiutil detach "${m}" -quiet >/dev/null 2>&1 || true; done
163
+ }
164
+ trap cleanup EXIT INT TERM
165
+ APP=""
166
+ for M in ${MOUNTS:-}; do
167
+ A="$(/bin/ls -d "${M}"/*.app 2>/dev/null | head -1 || true)"
168
+ [ -n "${A}" ] && { APP="${A}"; break; }
169
+ done
170
+ [ -n "${APP}" ] || { echo "no .app found in ${ARTIFACT}" >&2; exit 1; }
171
+ if [ -L "${APP}" ]; then
172
+ echo "error: '${APP##*/}' in the DMG is a symlink → $(readlink "${APP}"); its version would come" >&2
173
+ echo " from the link target, not the DMG. Rebuild it, or pass --build-number/--short-version." >&2
174
+ exit 1
175
+ fi
176
+ PLIST="${APP}/Contents/Info.plist"
177
+ [ -f "${PLIST}" ] || { echo "no Info.plist in ${APP##*/}" >&2; exit 1; }
178
+ read_from_plist "${PLIST}"
179
+ cleanup; trap - EXIT INT TERM # release before signing/uploading — neither needs it mounted
180
+ ;;
181
+ zip)
182
+ # A signed .app .zip: find the Info.plist inside and read it via a temp copy (PlistBuddy needs a
183
+ # file). If the zip has no readable .app plist, the operator must pass the version flags.
184
+ PLIST_ENTRY="$(unzip -Z1 "${ARTIFACT}" 2>/dev/null | grep -E '\.app/Contents/Info\.plist$' | head -1 || true)"
185
+ if [ -n "${PLIST_ENTRY}" ]; then
186
+ TMP_PLIST="$(mktemp)"
187
+ if unzip -p "${ARTIFACT}" "${PLIST_ENTRY}" > "${TMP_PLIST}" 2>/dev/null && [ -s "${TMP_PLIST}" ]; then
188
+ plutil -convert xml1 "${TMP_PLIST}" >/dev/null 2>&1 || true
189
+ read_from_plist "${TMP_PLIST}"
190
+ fi
191
+ rm -f "${TMP_PLIST}"
192
+ fi
193
+ ;;
194
+ *)
195
+ # Any other extension (e.g. .tar): can't read a plist — require the version flags.
196
+ : ;;
197
+ esac
198
+
199
+ # Fall back to overrides for formats we couldn't read.
200
+ SHORT_VERSION="${SHORT_OVERRIDE:-${SHORT_VERSION}}"
201
+ BUILD_NUMBER="${BUILD_OVERRIDE:-${BUILD_NUMBER}}"
202
+ MIN_OS="${MIN_OS_OVERRIDE:-${MIN_OS}}"
203
+ [ -n "${SHORT_VERSION}" ] || { echo "could not read the short version; pass --short-version" >&2; exit 1; }
204
+ [ -n "${BUILD_NUMBER}" ] || { echo "could not read the build number; pass --build-number" >&2; exit 1; }
205
+ echo "read ${SHORT_VERSION} (build ${BUILD_NUMBER}${MIN_OS:+, min macOS ${MIN_OS}}) from ${ARTIFACT##*/}" >&2
206
+
207
+ # Sparkle compares CFBundleVersion (= build_number) numerically; the server rejects non-integers.
208
+ case "${BUILD_NUMBER}" in
209
+ ''|*[!0-9]*)
210
+ echo "error: build number '${BUILD_NUMBER}' (CFBundleVersion) is not a positive integer." >&2
211
+ echo " Set CFBundleVersion to a monotonic integer, or pass --build-number <n>." >&2
212
+ exit 1 ;;
213
+ esac
214
+
215
+ # ─── Pre-flight against the running instance (publish-info): catch a duplicate/typo BEFORE signing ──
216
+ if [ "${DRY_RUN}" -eq 0 ]; then
217
+ INFO="$(api GET /admin/publish-info || true)"
218
+ if [ -n "${INFO}" ]; then
219
+ TOP="$(printf '%s' "${INFO}" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.stdout.write(String(JSON.parse(s).topBuild??""))}catch{}})' 2>/dev/null || true)"
220
+ if [ -n "${TOP}" ] && [ "${BUILD_NUMBER}" -le "${TOP}" ] 2>/dev/null; then
221
+ echo "error: build number ${BUILD_NUMBER} is not above the current highest (${TOP})." >&2
222
+ echo " Each build must increase — use $((TOP + 1)) or higher." >&2
223
+ exit 1
224
+ fi
225
+ if [ -n "${CHANNEL}" ]; then
226
+ NAMES="$(printf '%s' "${INFO}" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const c=(JSON.parse(s).channels||[]).map(x=>x.name);process.stdout.write(c.join(", "))}catch{}})' 2>/dev/null || true)"
227
+ case ", ${NAMES}," in
228
+ *", ${CHANNEL},"*) : ;;
229
+ *)
230
+ echo "error: channel '${CHANNEL}' does not exist on this instance." >&2
231
+ echo " Existing channels: ${NAMES:-(none)}" >&2
232
+ exit 1 ;;
233
+ esac
234
+ fi
235
+ fi
236
+ fi
237
+
238
+ # ─── Sparkle EdDSA signature ────────────────────────────────────────────────────────────────────────
239
+ find_sign_update() {
240
+ [ -n "${SIGN_UPDATE}" ] && { printf '%s' "${SIGN_UPDATE}"; return; }
241
+ command -v sign_update >/dev/null 2>&1 && { printf 'sign_update'; return; }
242
+ # Sparkle SPM builds land its tools under Xcode DerivedData; find the newest.
243
+ local dd="${HOME}/Library/Developer/Xcode/DerivedData"
244
+ [ -d "${dd}" ] && find "${dd}" -type f -name sign_update -path '*/artifacts/*' \
245
+ -exec stat -f '%m %N' {} + 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-
246
+ }
247
+
248
+ if [ -z "${ED_SIGNATURE:-}" ]; then
249
+ SIGN_BIN="$(find_sign_update)"
250
+ if [ -n "${SIGN_BIN}" ] && { [ "${SIGN_BIN}" = "sign_update" ] || [ -x "${SIGN_BIN}" ]; }; then
251
+ echo "signing with ${SIGN_BIN}" >&2
252
+ SU_OUT="$("${SIGN_BIN}" "${ARTIFACT}")"
253
+ ED_SIGNATURE="$(printf '%s' "${SU_OUT}" | sed -n 's/.*edSignature="\([^"]*\)".*/\1/p')"
254
+ [ -n "${ED_SIGNATURE}" ] || ED_SIGNATURE="$(printf '%s' "${SU_OUT}" | tr -d '[:space:]')"
255
+ elif [ "${DRY_RUN}" -eq 1 ]; then
256
+ ED_SIGNATURE="DRY-RUN-SIGNATURE"
257
+ else
258
+ echo "sign_update not found. Pass --sign-update <path>, set \$SIGN_UPDATE, or ED_SIGNATURE=<sig>." >&2
259
+ echo " (Sparkle ships it inside the package; SPM builds put it under Xcode DerivedData.)" >&2
260
+ exit 1
261
+ fi
262
+ fi
263
+
264
+ # ─── Upload (or register for >90 MB) ────────────────────────────────────────────────────────────────
265
+ SIZE="$(wc -c < "${ARTIFACT}" | tr -d ' ')"
266
+ CEILING=$((90 * 1024 * 1024))
267
+ FILENAME="${ARTIFACT##*/}"
268
+
269
+ common_fields=(
270
+ --data-urlencode "short_version=${SHORT_VERSION}"
271
+ --data-urlencode "build_number=${BUILD_NUMBER}"
272
+ --data-urlencode "ed_signature=${ED_SIGNATURE}"
273
+ )
274
+ [ -n "${MIN_OS}" ] && common_fields+=(--data-urlencode "min_os=${MIN_OS}")
275
+ [ -n "${CRITICAL_FLAG}" ] && common_fields+=(--data-urlencode "critical=true")
276
+ [ -n "${CHANNEL}" ] && common_fields+=(--data-urlencode "channel=${CHANNEL}")
277
+ [ -n "${STREAM_ID}" ] && common_fields+=(--data-urlencode "stream_id=${STREAM_ID}")
278
+
279
+ if [ "${DRY_RUN}" -eq 1 ]; then
280
+ echo "[dry-run] would publish build ${BUILD_NUMBER} (${SHORT_VERSION})${CHANNEL:+ → channel ${CHANNEL}} to ${ADMIN_URL}" >&2
281
+ exit 0
282
+ fi
283
+
284
+ if [ "${SIZE}" -le "${CEILING}" ]; then
285
+ # Full upload: multipart. -F for the file, the metadata fields converted to -F.
286
+ up=(-F "archive=@${ARTIFACT}")
287
+ for ((i=0; i<${#common_fields[@]}; i+=2)); do up+=(-F "${common_fields[$((i+1))]}"); done
288
+ api POST /admin/builds/upload "${up[@]}" >/dev/null
289
+ else
290
+ # Over the Worker body cap: PUT to R2 with the operator's OWN wrangler auth (no extra token), then
291
+ # register metadata-only. This removes the old manual two-step for large DMGs.
292
+ echo "artifact is ${SIZE} bytes (> 90 MB) — uploading to R2 with wrangler, then registering." >&2
293
+ OBJECT_KEY="build/${BUILD_NUMBER}/${FILENAME}"
294
+ BUCKET="$(node -e 'const s=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(s.bucket||("alpha-gate-"+(process.argv[2]||"")))' "${STATE_DIR}/${INSTANCE}.state.json" "${INSTANCE}" 2>/dev/null || echo "alpha-gate-${INSTANCE}")"
295
+ ( cd "${ROOT}" && npx wrangler r2 object put "${BUCKET}/${OBJECT_KEY}" --file "${ARTIFACT}" --remote ) \
296
+ || { echo "wrangler R2 upload failed" >&2; exit 1; }
297
+ api POST /admin/builds/register \
298
+ --data-urlencode "object_key=${OBJECT_KEY}" --data-urlencode "size=${SIZE}" \
299
+ "${common_fields[@]}" >/dev/null
300
+ fi
301
+
302
+ echo "✓ published build ${BUILD_NUMBER} (${SHORT_VERSION})${CHANNEL:+ → ${CHANNEL}} to ${ADMIN_URL}"
package/release.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "_comment": "\u00a722 self-update manifest FALLBACK. By default the deployed tool's daily cron polls registry.npmjs.org/<name>/latest; this file exists for forks that point $UPDATE_MANIFEST_URL at a static manifest instead. Keep `latest` in sync with package.json's `version` on every release; bump `min_supported` and set `breaking` when a deploy requires manual steps. Extra keys (like this one) are ignored by the parser.",
3
+ "latest": "0.1.0",
4
+ "min_supported": "0.1.0",
5
+ "notes_url": "https://github.com/alex-ivanov/alpha-gate/blob/main/CHANGELOG.md",
6
+ "breaking": false
7
+ }