@maxceem/agw 0.1.5 → 0.1.7

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 CHANGED
@@ -1,63 +1,134 @@
1
1
  # App AI Gateway CLI
2
2
 
3
- The `@maxceem/agw` package provides the `agw` executable. Node.js 22.19 or newer is required. It includes the compatible gateway release and Wrangler toolchain; end users do not need Git, pnpm, a global Wrangler installation, or a repository checkout.
3
+ `agw` sets up and manages [App AI Gateway](https://appaigateway.com/)
4
+ from a terminal or a coding agent: providers, apps, usage, and your own
5
+ deployment. Requires Node.js 22.19+.
4
6
 
5
7
  ```sh
6
- npx @maxceem/agw provider add --type openai
7
- npx @maxceem/agw app add --type ios --team-id ABCDE12345 --bundle-id com.example.app
8
+ npm install -g @maxceem/agw
8
9
  ```
9
10
 
10
- A fresh cloud connection creates your account only when adding a provider, provider gateway, or application. Help, lists, status, validation and dry runs never create accounts. Logout retains your selected deployment and prior account marker; invalid or revoked access requires login, with no automatic fallback or second account.
11
+ ## Quick start
11
12
 
12
- For automation, have a human supply a provider credential in their own browser:
13
+ Sign in with a management key. Create one in the console at
14
+ [appaigateway.com](https://appaigateway.com/): user menu, then **Management keys**.
13
15
 
14
16
  ```sh
15
- agw provider add --type openai --browser --no-open --json
16
- agw operation wait <operation-id> --timeout 300 --json
17
+ agw account login
17
18
  ```
18
19
 
19
- Share the returned URL. Hidden terminal input only prevents echo; an agent controlling that terminal can still observe it. The browser must be outside the agent's observable browser surface for credential isolation. Never pass credentials as command arguments. `--key-stdin` explicitly reads a single secret line. `--no-input` prohibits terminal prompts, and `--no-open` returns a browser operation without waiting.
20
+ No account yet? Skip login. A free anonymous account is created for you
21
+ automatically as soon as you add your first provider or app below. You can
22
+ claim it later with `agw account claim`.
20
23
 
21
- Run `agw --help` for the complete command surface and `agw <group> <command> --help` for its flags. Resource IDs and slugs are exact; display names are not selectors. File outputs never overwrite existing files. Server application keys are written with mode 0600 and never included in stdout. If persistence fails, the CLI attempts immediate revocation and reports the key ID for recovery.
24
+ Add a provider. Paste its API key into the hidden prompt.
22
25
 
23
- Application JSON uses the gateway's shared complete write schema (`name`, `config`, optional `status`). Derive an editable definition from a saved app with `agw app show <app-id> --json | jq '.result.app | {name, config, status}'`; `app validate --file` and `app add --file --dry-run` validate it without account creation. Offline validation reports skipped remote checks. File mode preserves omission of App Attest environments; the simple iOS flags explicitly enable production and development. Application updates require the original ETag in `If-Match`; conflicting edits return an error instead of overwriting changes. Retained selected providers keep their detailed policies.
24
-
25
- Swift snippets use the current package at `https://github.com/maxceem/app-ai-gateway-swift` (from version 1.0.0), actual application/provider IDs, and the saved authentication mode. Issuer examples require replacing the indicated identity SDK call. Requests still need a provider-native body and must be sent by the caller. No setup or readiness check sends paid inference or proves physical-device attestation.
26
+ ```sh
27
+ agw provider add --type openai
28
+ ```
26
29
 
27
- ## Output and state
30
+ Add an app. It uses every provider you have added.
28
31
 
29
- `--json` emits exactly one JSON document to stdout. Progress goes to stderr. The result envelope is `{schemaVersion:1,ok:true,context:{url,accountId?,deploymentId?},result}`. Errors are `{schemaVersion:1,ok:false,error:{code,message,nextAction,details?}}`. Unknown remote fields are excluded from output; complete validated application config preserves provider-native endpoint parameters.
32
+ ```sh
33
+ agw app add --type ios --team-id ABCDE12345 --bundle-id com.example.app
34
+ ```
30
35
 
31
- Exit statuses: `0` success (including pending handoff initiation), `2` input/validation error, `3` remote/connectivity or failed handoff, `4` local state/recovery/authentication issue, `5` wait timeout. Timeout includes still-pending operation metadata and does not cancel or extend the server operation. Poll authorization is stored separately from public operation IDs/URLs. Authentication handoff completion is persisted before output, and replay cannot undo a logout or later connection switch.
36
+ Print the Swift code that calls the gateway from that app.
32
37
 
33
- State is `$XDG_STATE_HOME/agw` or `~/.local/state/agw` on Unix, and `%LOCALAPPDATA%/agw` on Windows. The directory is mode 0700 and files are mode 0600. It contains one active connection, the previous connection for intentional recovery, pending operations, creation retry proofs, application keys, and protected installation journals. No public profile selectors or gateway credential environment overrides exist. Back up this directory securely. Concurrent commands use an exclusive lock; after a crashed process, inspect the PID in `connection.lock` before removing that lock. Never delete connection state merely to resolve an authentication error.
38
+ ```sh
39
+ agw app snippet <app-id> --provider openai
40
+ ```
34
41
 
35
- Public API URLs require HTTPS, no userinfo, query, fragment or path. Only `localhost`, `127.0.0.1` and `[::1]` permit HTTP for local development. Authenticated HTTP redirects are refused. Connecting authenticates only to the requested origin and selects it after success.
42
+ ## Self-host on Cloudflare
36
43
 
37
- ## Self-hosting
44
+ The package drives Wrangler and installs the gateway release of its own version.
45
+ No checkout needed. The gateway itself downloads once from the project's GitHub
46
+ release and is cached, after the CLI matches it against a digest built into the
47
+ executable.
38
48
 
39
49
  ```sh
40
- agw deployment setup --name my-gateway --no-domain
41
- agw deployment update --dry-run
42
- agw deployment domain --hostname ai.example.com --dry-run
50
+ agw deployment setup --name my-gateway --no-domain # deploy and connect
51
+ agw deployment update # move to this CLI's release
52
+ agw deployment domain --hostname ai.example.com # attach a custom domain
53
+ ```
54
+
55
+ Setup signs in to Cloudflare through Wrangler, or uses `CLOUDFLARE_API_TOKEN`
56
+ if set. Add `--dry-run` to any of these to see what would happen. See the
57
+ [self-hosting guide](https://docs.appaigateway.com/self-hosting/deploy-to-cloudflare).
58
+
59
+ No route to GitHub from that machine? Fetch the `gateway-<version>.tar.gz` asset
60
+ elsewhere and pass `--release-archive <path>`.
61
+
62
+ ## Commands
63
+
64
+ | Group | What it manages |
65
+ | --- | --- |
66
+ | `provider` | Provider credentials: `add`, `list`, `show`, `update`, `rotate-key`, `remove` |
67
+ | `provider-gateway` | Cloudflare AI Gateway in front of a provider |
68
+ | `app` | Apps and their config: `add`, `list`, `show`, `update`, `validate`, `check`, `snippet`, `remove`, `key` |
69
+ | `usage` | Spend and requests: `show`, `breakdown` |
70
+ | `account` | `status`, `claim`, `login`, `logout` |
71
+ | `deployment` | `setup`, `connect`, `status`, `update`, `domain` |
72
+ | `operation` | `status` and `wait` for browser handoffs |
73
+
74
+ `agw --help` lists every command; `agw <group> <command> --help` lists its flags.
75
+
76
+ ## Using `agw` from an agent
77
+
78
+ Full guide: [docs.appaigateway.com/automation/cli](https://docs.appaigateway.com/automation/cli).
79
+ The whole documentation is also published as
80
+ [agents.md](https://docs.appaigateway.com/agents.md).
81
+
82
+ **Output.** Add `--json` for exactly one JSON document on stdout; progress goes
83
+ to stderr. Add `--no-input` to fail instead of prompting.
84
+
85
+ ```json
86
+ { "schemaVersion": 1, "ok": true, "context": { "url": "...", "accountId": "..." }, "result": { } }
87
+ { "schemaVersion": 1, "ok": false, "error": { "code": "...", "message": "...", "nextAction": "...", "details": { } } }
43
88
  ```
44
89
 
45
- Setup obtains Cloudflare authorization using bundled Wrangler OAuth, or accepts standard securely configured Cloudflare credentials such as `CLOUDFLARE_API_TOKEN`. If more than one Cloudflare account is accessible, choose one with `--cloudflare-account-id`. Noninteractive mutations require explicit configuration and `--yes`; `--yes` does not grant Cloudflare permissions or approve browser identity ceremonies.
90
+ Every error names a `code` and a `nextAction`. Follow the `nextAction`.
46
91
 
47
- The npm package contains a versioned Worker bundle, console assets, migrations, configuration, and SHA-256 manifest. npm package integrity authenticates the package; local manifest/file digests detect corrupt assets before deployment. `--version` selects only the bundled compatible release; use the matching CLI package version for another release. No unversioned source download or arbitrary downgrade is performed. Release format/schema versions are checked before mutation.
92
+ | Exit | Meaning |
93
+ | --- | --- |
94
+ | `0` | Success, including a browser handoff that is still pending |
95
+ | `2` | Invalid input or validation failure |
96
+ | `3` | Gateway unreachable, remote error, or failed handoff |
97
+ | `4` | Local state or authentication problem |
98
+ | `5` | `operation wait` timed out; the operation is still pending |
48
99
 
49
- Installation journals are written before creating resources, keep generated vault/signing/bootstrap secrets across retries, and bind the Cloudflare account, Worker, D1 database and immutable deployment ID. A Worker name collision never authorizes takeover. Retry an incomplete setup with the same name/account; its previous active gateway connection remains selected until bootstrap succeeds. A completed setup does not replay retired bootstrap credentials or overwrite subsequently rotated secrets. Updates preserve existing secrets and reject unsupported schema transitions. Database migrations do not have an automatic rollback.
100
+ **Secrets.** Never put a key in a command argument. Either pipe it with
101
+ `--key-stdin`, or hand the browser to a human:
50
102
 
51
- A URL-only connection can be matched interactively to an explicitly selected accessible Worker only when its Cloudflare `DEPLOYMENT_ID` matches the gateway. Unsupported extra resource bindings fail rather than being discarded. Noninteractive deployment administration needs the existing installation journal. Dry runs discover/validate only; they do not provision resources, generate/upload secrets, change DNS, bootstrap accounts or select connections.
103
+ ```sh
104
+ agw provider add --type openai --browser --no-open --json # prints a URL
105
+ agw operation wait <operation-id> --timeout 300 --json # resumes when done
106
+ ```
107
+
108
+ Share the URL with the person who holds the credential. Nothing typed on that
109
+ page reaches the CLI or its output.
52
110
 
53
- Custom domains require an accessible active zone. Existing unrelated DNS or Worker domain attachments are refused. The new endpoint must identify the same deployment before credentials follow it. Existing hostnames remain served; mobile applications keep their embedded URL until updated. Changing from cloud to self-hosting does not migrate app IDs, provider credentials, usage or existing applications.
111
+ **Editing apps.** Read before you write. `app show --json` returns the full
112
+ config; save `{name, config, status}` from `result.app`, edit it, and pass it
113
+ back with `app update <id> --file`. A concurrent change is rejected rather than
114
+ overwritten. `app validate --file` checks a file without touching the account.
54
115
 
55
- ## Release verification for contributors
116
+ **State.** Connection, credentials and pending operations live in
117
+ `~/.local/state/agw` (`$XDG_STATE_HOME/agw`, or `%LOCALAPPDATA%/agw` on
118
+ Windows), mode 0700. Do not delete it to fix an authentication error; run
119
+ `agw account login` instead. Output files are never overwritten.
56
120
 
57
- `pnpm run cli:check` type-checks the CLI with `tsgo` and then bundles the portable schemas and executable. `pnpm run cli:test` runs parser, state, transport, command and mocked deployment tests; both are included in root verification. `pnpm --filter @maxceem/agw release:build` builds the actual deployable bundle locally. `npm pack` runs that release build and packages only `dist`, this README and the Apache-2.0 license. Verify the tarball and invoke its installed executable outside the repository before publishing. Publishing and live Cloudflare operations are separate release decisions.
121
+ ## Contributing
58
122
 
59
- Creation retries persist a request proof before sending provider, gateway, app or application-key creation. Repeating an unfinished saved request returns its original result. After successful output delivery, its local pending receipt is acknowledged, so a later invocation is a new intentional creation. A server key response can be recovered remotely for 15 minutes; after that, the error identifies the existing resource so you can inspect and replace its key intentionally. Non-secret receipt records prevent duplicate creation for the lifetime of the account. An unfinished local request older than 90 days refuses to submit again automatically.
123
+ The CLI lives in `cli/` of the
124
+ [main repository](https://github.com/maxceem/app-ai-gateway). From the repo root:
60
125
 
61
- The CLI saves a one-time key response in protected local state before writing the selected `--key-output` file. If the file write fails, repeat the same command to complete that file, or choose a new unused output path; this uses the saved active key and does not mint another. Once file delivery completes, the recovery copy is removed; a retry before successful command output verifies that original file. After stdout delivery is acknowledged, a later key creation is a new request and requires a new unused output file. Existing unrelated files and replaced output reservations are never overwritten.
126
+ ```sh
127
+ pnpm run cli:check # type-check and bundle
128
+ pnpm run cli:test # run the CLI tests
129
+ ```
62
130
 
63
- Unfinished bootstrap attempts also have a 90-day local retry deadline. Missing or expired timestamps fail closed and preserve the proof for recovery. Server cleanup retains only a non-secret, proof-bound bootstrap tombstone after deleting an expired account, so an old retry cannot recreate that account. The tombstone contains no account identity or credential and is intentionally retained beyond ordinary challenge expiry.
131
+ Both are part of `pnpm run verify`. Releasing is two steps in order:
132
+ `pnpm run cli:release:github` builds the gateway archive and uploads it to the
133
+ GitHub release, then `npm publish` from `cli/` ships the executable that
134
+ demands that exact archive. Licensed under Apache-2.0.
package/dist/agw.mjs CHANGED
@@ -6,7 +6,7 @@ var __export = (target, all) => {
6
6
  };
7
7
 
8
8
  // <define:__AGW_MANIFEST__>
9
- var define_AGW_MANIFEST_default = { version: "0.1.5", wrangler: "4.96.0", upgradeFrom: ["0.1.5", "0.1.4", "0.1.3", "0.1.2", "0.1.1", "0.1.0"] };
9
+ var define_AGW_MANIFEST_default = { version: "0.1.7", wrangler: "4.96.0", upgradeFrom: ["0.1.7", "0.1.6", "0.1.5", "0.1.4", "0.1.3", "0.1.2", "0.1.1", "0.1.0"], release: { url: "https://github.com/maxceem/app-ai-gateway/releases/download/v0.1.7/gateway-0.1.7.tar.gz", sha256: "e9a154b8b0c71e38296995b8ae478f26b563d2eb7fb1111e575d3fde33815b88", manifest: "ac56329e6cc34a01ecb3c8820b671b74bd4fabfb69d2c9353307a5306332a663" } };
10
10
 
11
11
  // src/main.ts
12
12
  import { Writable } from "node:stream";
@@ -7649,8 +7649,8 @@ function ko_default() {
7649
7649
  }
7650
7650
 
7651
7651
  // ../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/locales/lt.js
7652
- var capitalizeFirstCharacter = (text) => {
7653
- return text.charAt(0).toUpperCase() + text.slice(1);
7652
+ var capitalizeFirstCharacter = (text2) => {
7653
+ return text2.charAt(0).toUpperCase() + text2.slice(1);
7654
7654
  };
7655
7655
  function getUnitTypeFromNumber(number4) {
7656
7656
  const abs = Math.abs(number4);
@@ -15529,7 +15529,10 @@ var CliAccountSchema = external_exports.object({
15529
15529
  claimed: external_exports.boolean(),
15530
15530
  expiresAt: external_exports.string().nullable()
15531
15531
  });
15532
- var CliApprovalRefusalSchema = external_exports.enum(["session_required", "account_exists"]);
15532
+ var CliApprovalRefusalSchema = external_exports.enum([
15533
+ "registration_required",
15534
+ "sign_out_required"
15535
+ ]);
15533
15536
  var CliViewerSchema = external_exports.object({
15534
15537
  name: external_exports.string().nullable(),
15535
15538
  email: external_exports.string().nullable()
@@ -15542,7 +15545,9 @@ var CliBrowserDetailsResponseSchema = external_exports.object({
15542
15545
  /**
15543
15546
  * What stands between this browser and the Approve button, or null when
15544
15547
  * nothing does — which is every non-claim kind, since only a claim asks who
15545
- * is holding the browser.
15548
+ * is holding the browser. One field decides the whole screen: each value
15549
+ * names the single thing the page may offer, so the page never has to
15550
+ * consult the handoff kind to know what to put in front of a person.
15546
15551
  */
15547
15552
  blockedBy: CliApprovalRefusalSchema.nullable(),
15548
15553
  googleEnabled: external_exports.boolean(),
@@ -15969,11 +15974,12 @@ function fromPackageJson() {
15969
15974
  if (typeof packaged.version !== "string" || typeof wrangler !== "string") {
15970
15975
  throw new Error("cli/package.json must declare a version and a pinned wrangler dependency");
15971
15976
  }
15972
- return { version: packaged.version, wrangler };
15977
+ return { version: packaged.version, wrangler, release: null };
15973
15978
  }
15974
15979
  var manifest = typeof define_AGW_MANIFEST_default === "object" && define_AGW_MANIFEST_default !== null ? define_AGW_MANIFEST_default : fromPackageJson();
15975
15980
  var VERSION = manifest.version;
15976
15981
  var WRANGLER_VERSION = manifest.wrangler;
15982
+ var RELEASE = manifest.release ?? null;
15977
15983
 
15978
15984
  // src/common.ts
15979
15985
  var CLOUD = "https://api.appaigateway.com";
@@ -16112,15 +16118,21 @@ var common = "json! no-input!";
16112
16118
  var commands = {
16113
16119
  "deployment setup": {
16114
16120
  args: 0,
16115
- flags: "name cloudflare-account-id domain no-domain! version dry-run! yes!"
16121
+ flags: "name cloudflare-account-id domain no-domain! version release-archive dry-run! yes!"
16116
16122
  },
16117
16123
  "deployment connect": {
16118
16124
  args: 0,
16119
16125
  flags: `url cloud! ${managementKey}`
16120
16126
  },
16121
16127
  "deployment status": { args: 0, flags: "" },
16122
- "deployment update": { args: 0, flags: "version dry-run! yes!" },
16123
- "deployment domain": { args: 0, flags: "hostname dry-run! yes!" },
16128
+ "deployment update": {
16129
+ args: 0,
16130
+ flags: "version release-archive dry-run! yes!"
16131
+ },
16132
+ "deployment domain": {
16133
+ args: 0,
16134
+ flags: "hostname release-archive dry-run! yes!"
16135
+ },
16124
16136
  "account status": { args: 0, flags: "" },
16125
16137
  "account claim": { args: 0, flags: browser },
16126
16138
  "account login": { args: 0, flags: managementKey },
@@ -16536,6 +16548,14 @@ function stateDirectory() {
16536
16548
  "agw"
16537
16549
  );
16538
16550
  }
16551
+ function cacheDirectory() {
16552
+ if (platform() === "win32")
16553
+ return join(
16554
+ process.env["LOCALAPPDATA"] || join(homedir(), "AppData", "Local"),
16555
+ "agw"
16556
+ );
16557
+ return join(process.env["XDG_CACHE_HOME"] || join(homedir(), ".cache"), "agw");
16558
+ }
16539
16559
  async function protectedDirectory(path) {
16540
16560
  await mkdir(path, { recursive: true, mode: 448 });
16541
16561
  const s = await lstat(path);
@@ -16574,14 +16594,14 @@ async function reserveOutput(path, { reservation, retain = false } = {}) {
16574
16594
  },
16575
16595
  async write(value) {
16576
16596
  if (!handle) throw new Error("The reserved output is closed");
16577
- const text = typeof value === "string" ? value : JSON.stringify(value, null, 2) + "\n";
16597
+ const text2 = typeof value === "string" ? value : JSON.stringify(value, null, 2) + "\n";
16578
16598
  try {
16579
16599
  if (retain) {
16580
16600
  const existing = await handle.readFile("utf8");
16581
- if (existing && !text.startsWith(existing)) fail("output_changed", "The reserved output contains different data.", "Choose a new --key-output path; no existing data was overwritten.", 4);
16601
+ if (existing && !text2.startsWith(existing)) fail("output_changed", "The reserved output contains different data.", "Choose a new --key-output path; no existing data was overwritten.", 4);
16582
16602
  await handle.truncate(0);
16583
- await handle.write(text, 0, "utf8");
16584
- } else await handle.writeFile(text);
16603
+ await handle.write(text2, 0, "utf8");
16604
+ } else await handle.writeFile(text2);
16585
16605
  await handle.sync();
16586
16606
  await handle.close();
16587
16607
  handle = null;
@@ -17864,42 +17884,226 @@ async function openBrowser(url2) {
17864
17884
  import { spawn as spawn2 } from "node:child_process";
17865
17885
  import { createRequire as createRequire2 } from "node:module";
17866
17886
  import { randomUUID } from "node:crypto";
17867
- import { cp, writeFile, unlink as unlink2 } from "node:fs/promises";
17887
+ import { cp, writeFile as writeFile2, unlink as unlink2 } from "node:fs/promises";
17868
17888
  import { join as join3 } from "node:path";
17869
17889
  import { setTimeout as delay3 } from "node:timers/promises";
17870
17890
 
17871
17891
  // src/release.ts
17872
- import { readFile as readFile3, lstat as lstat2 } from "node:fs/promises";
17892
+ import { readFile as readFile3, lstat as lstat2, mkdir as mkdir2, rename as rename2, rm, writeFile } from "node:fs/promises";
17873
17893
  import { join as join2, dirname } from "node:path";
17874
17894
  import { fileURLToPath } from "node:url";
17875
- import { createHash as createHash4 } from "node:crypto";
17876
- async function release(requested = VERSION, base = dirname(fileURLToPath(import.meta.url))) {
17895
+ import { createHash as createHash4, randomBytes as randomBytes3 } from "node:crypto";
17896
+
17897
+ // src/archive.ts
17898
+ import { gunzipSync } from "node:zlib";
17899
+ var BLOCK = 512;
17900
+ var ARCHIVE_LIMIT = 64 * 1024 * 1024;
17901
+ function text(header, start, length) {
17902
+ const raw = header.subarray(start, start + length);
17903
+ const end = raw.indexOf(0);
17904
+ return raw.subarray(0, end === -1 ? raw.length : end).toString("latin1");
17905
+ }
17906
+ function octal(header, start, length, field) {
17907
+ const value = text(header, start, length).trim();
17908
+ if (!/^[0-7]+$/.test(value))
17909
+ fail("release_archive", `Release archive has an unreadable ${field} field.`);
17910
+ const parsed = Number.parseInt(value, 8);
17911
+ if (!Number.isSafeInteger(parsed))
17912
+ fail("release_archive", `Release archive has an out-of-range ${field} field.`);
17913
+ return parsed;
17914
+ }
17915
+ function checksumMatches(header) {
17916
+ const stored = octal(header, 148, 8, "checksum");
17917
+ let unsigned = 0;
17918
+ let signed = 0;
17919
+ for (let i = 0; i < BLOCK; i++) {
17920
+ const byte = i >= 148 && i < 156 ? 32 : header[i];
17921
+ unsigned += byte;
17922
+ signed += byte > 127 ? byte - 256 : byte;
17923
+ }
17924
+ return stored === unsigned || stored === signed;
17925
+ }
17926
+ function safePath(raw, directory) {
17927
+ const path = directory ? raw.replace(/\/+$/, "") : raw;
17928
+ if (!path || path.startsWith("/") || path.includes("\\") || path.split("/").some((segment) => segment === "" || segment === "." || segment === ".."))
17929
+ fail("release_archive", "Release archive contains an unsafe path.");
17930
+ return path;
17931
+ }
17932
+ function readArchive(gzipped, limit = ARCHIVE_LIMIT) {
17933
+ let tar;
17934
+ try {
17935
+ tar = gunzipSync(gzipped, { maxOutputLength: limit });
17936
+ } catch {
17937
+ fail("release_archive", "Release archive is not readable gzip within the size limit.");
17938
+ }
17939
+ if (tar.length % BLOCK !== 0)
17940
+ fail("release_archive", "Release archive is not a whole number of tar blocks.");
17941
+ const entries = [];
17942
+ let total = 0;
17943
+ let offset = 0;
17944
+ while (offset + BLOCK <= tar.length) {
17945
+ const header = tar.subarray(offset, offset + BLOCK);
17946
+ if (header.every((byte) => byte === 0)) {
17947
+ if (!tar.subarray(offset).every((byte) => byte === 0))
17948
+ fail("release_archive", "Release archive continues past its end marker.");
17949
+ return entries;
17950
+ }
17951
+ if (text(header, 257, 6).trim() !== "ustar")
17952
+ fail("release_archive", "Release archive is not in ustar format.");
17953
+ if (!checksumMatches(header))
17954
+ fail("release_archive", "Release archive has a corrupt header.");
17955
+ const type = String.fromCharCode(header[156]);
17956
+ if (type === "L" || type === "K")
17957
+ fail("release_archive", "Release archive uses GNU long-name extensions.");
17958
+ if (type !== "0" && type !== "\0" && type !== "5")
17959
+ fail(
17960
+ "release_archive",
17961
+ "Release archives may contain only regular files and directories."
17962
+ );
17963
+ const directory = type === "5";
17964
+ const prefix = text(header, 345, 155);
17965
+ const name = text(header, 0, 100);
17966
+ const path = safePath(prefix ? `${prefix}/${name}` : name, directory);
17967
+ const size = octal(header, 124, 12, "size");
17968
+ if (directory && size !== 0)
17969
+ fail("release_archive", "Release archive has a directory entry with contents.");
17970
+ total += size;
17971
+ if (total > limit) fail("release_archive", "Release archive exceeds its size limit.");
17972
+ const start = offset + BLOCK;
17973
+ if (start + size > tar.length)
17974
+ fail("release_archive", "Release archive is truncated.");
17975
+ entries.push({
17976
+ path,
17977
+ directory,
17978
+ data: directory ? Buffer.alloc(0) : Buffer.from(tar.subarray(start, start + size))
17979
+ });
17980
+ offset = start + Math.ceil(size / BLOCK) * BLOCK;
17981
+ }
17982
+ fail("release_archive", "Release archive has no end marker.");
17983
+ }
17984
+
17985
+ // src/release.ts
17986
+ var digest = (data) => createHash4("sha256").update(data).digest("hex");
17987
+ async function fetchArchive(url2) {
17988
+ let response;
17989
+ try {
17990
+ response = await fetch(url2, {
17991
+ redirect: "follow",
17992
+ signal: AbortSignal.timeout(3e5),
17993
+ headers: { "user-agent": `agw/${VERSION}` }
17994
+ });
17995
+ } catch {
17996
+ fail(
17997
+ "release_download",
17998
+ `Cannot download the gateway release from ${url2}.`,
17999
+ "Check network access, or pass --release-archive with a copy downloaded elsewhere.",
18000
+ 3
18001
+ );
18002
+ }
18003
+ if (!response.ok)
18004
+ fail(
18005
+ "release_download",
18006
+ `Downloading the gateway release from ${url2} failed with HTTP ${response.status}.`,
18007
+ "Check network access, or pass --release-archive with a copy downloaded elsewhere.",
18008
+ 3
18009
+ );
18010
+ const body = Buffer.from(await response.arrayBuffer());
18011
+ if (body.length > ARCHIVE_LIMIT)
18012
+ fail("release_download", "The downloaded gateway release exceeds its size limit.", void 0, 3);
18013
+ return body;
18014
+ }
18015
+ async function extract(archive, directory) {
18016
+ await mkdir2(directory, { recursive: true, mode: 448 });
18017
+ for (const entry of readArchive(archive)) {
18018
+ const path = join2(directory, entry.path);
18019
+ if (entry.directory) {
18020
+ await mkdir2(path, { recursive: true, mode: 448 });
18021
+ continue;
18022
+ }
18023
+ await mkdir2(dirname(path), { recursive: true, mode: 448 });
18024
+ await writeFile(path, entry.data, { mode: 384, flag: "wx" });
18025
+ }
18026
+ }
18027
+ async function cachedRelease(version2, integrity, source) {
18028
+ const root = source.cache ?? join2(cacheDirectory(), "releases");
18029
+ const directory = join2(root, version2);
18030
+ const cached2 = await readFile3(join2(directory, "manifest.json")).catch(() => null);
18031
+ if (cached2 && digest(cached2) === integrity.manifest) return directory;
18032
+ const supplied = source.archive ?? process.env["AGW_RELEASE_ARCHIVE"] ?? void 0;
18033
+ let archive;
18034
+ if (supplied) {
18035
+ archive = await readFile3(supplied).catch(
18036
+ () => fail(
18037
+ "release_unavailable",
18038
+ `Cannot read the gateway release archive at ${supplied}.`,
18039
+ "Supply the gateway-<version>.tar.gz asset published with this CLI release.",
18040
+ 4
18041
+ )
18042
+ );
18043
+ } else {
18044
+ archive = await (source.download ?? fetchArchive)(integrity.url);
18045
+ }
18046
+ if (digest(archive) !== integrity.sha256)
18047
+ fail(
18048
+ "release_integrity",
18049
+ "The gateway release archive does not match the digest built into this CLI.",
18050
+ "Nothing was extracted. Obtain the archive published with this CLI version.",
18051
+ 4
18052
+ );
18053
+ await protectedDirectory(root);
18054
+ const staging = join2(root, `.${version2}-${randomBytes3(8).toString("hex")}`);
18055
+ try {
18056
+ await extract(archive, staging);
18057
+ const manifest2 = await readFile3(join2(staging, "manifest.json")).catch(() => null);
18058
+ if (!manifest2 || digest(manifest2) !== integrity.manifest)
18059
+ fail(
18060
+ "release_integrity",
18061
+ "The gateway release archive does not contain the expected manifest.",
18062
+ "Nothing was installed. Obtain the archive published with this CLI version.",
18063
+ 4
18064
+ );
18065
+ await rm(directory, { recursive: true, force: true });
18066
+ await rename2(staging, directory);
18067
+ } finally {
18068
+ await rm(staging, { recursive: true, force: true });
18069
+ }
18070
+ return directory;
18071
+ }
18072
+ async function release(requested = VERSION, base = dirname(fileURLToPath(import.meta.url)), source = {}) {
17877
18073
  if (requested !== VERSION)
17878
18074
  fail(
17879
18075
  "unsupported_release",
17880
- `This CLI bundles gateway ${VERSION} only.`,
18076
+ `This CLI deploys gateway ${VERSION} only.`,
17881
18077
  `Install @maxceem/agw@${requested} to use its matching release.`
17882
18078
  );
17883
18079
  const [major, minor] = process.versions.node.split(".").map(Number);
17884
18080
  if (major === void 0 || minor === void 0 || major < 22 || major === 22 && minor < 19)
17885
18081
  fail("unsupported_runtime", "Node.js 22.19.0 or newer is required.");
17886
- const directory = join2(base, "release", requested);
18082
+ const integrity = source.integrity === void 0 ? RELEASE : source.integrity;
18083
+ const packaged = join2(base, "release", requested);
18084
+ const local = await lstat2(packaged).catch(() => null);
18085
+ let directory;
18086
+ if (local?.isDirectory()) directory = packaged;
18087
+ else if (integrity) directory = await cachedRelease(requested, integrity, source);
18088
+ else
18089
+ fail(
18090
+ "release_missing",
18091
+ "This CLI build does not know where to obtain its deployable release.",
18092
+ "Install a published release; repository contributors must run the CLI release build.",
18093
+ 4
18094
+ );
17887
18095
  let serialized;
17888
- let integrity;
17889
18096
  try {
17890
18097
  serialized = await readFile3(join2(directory, "manifest.json"), "utf8");
17891
- integrity = JSON.parse(
17892
- await readFile3(join2(base, "release-integrity.json"), "utf8")
17893
- );
17894
18098
  } catch {
17895
18099
  fail(
17896
18100
  "release_missing",
17897
- "The CLI package does not contain its deployable release.",
17898
- "Install a packaged release; repository contributors must run the CLI release build.",
18101
+ "The deployable release is missing its manifest.",
18102
+ "Install a published release; repository contributors must run the CLI release build.",
17899
18103
  4
17900
18104
  );
17901
18105
  }
17902
- if (createHash4("sha256").update(serialized).digest("hex") !== integrity.sha256)
18106
+ if (integrity && digest(serialized) !== integrity.manifest)
17903
18107
  fail("release_integrity", "Release manifest integrity check failed.");
17904
18108
  const manifest2 = JSON.parse(serialized);
17905
18109
  if (manifest2.format !== 1 || manifest2.version !== requested || manifest2.wrangler !== WRANGLER_VERSION || manifest2.schema !== 1)
@@ -17912,7 +18116,7 @@ async function release(requested = VERSION, base = dirname(fileURLToPath(import.
17912
18116
  fail("release_integrity", "Release includes an unsafe path.");
17913
18117
  const file2 = join2(directory, path);
17914
18118
  const info = await lstat2(file2);
17915
- if (!info.isFile() || info.isSymbolicLink() || createHash4("sha256").update(await readFile3(file2)).digest("hex") !== hash2)
18119
+ if (!info.isFile() || info.isSymbolicLink() || digest(await readFile3(file2)) !== hash2)
17916
18120
  fail("release_integrity", "Release asset integrity check failed.");
17917
18121
  }
17918
18122
  return {
@@ -17928,7 +18132,7 @@ async function release(requested = VERSION, base = dirname(fileURLToPath(import.
17928
18132
  var require2 = createRequire2(import.meta.url);
17929
18133
  var OUTPUT_TAIL = 2e3;
17930
18134
  function wranglerFailure(args, code, output, redact = []) {
17931
- const tail = redact.filter((value) => value.length >= 8).reduce((text, value) => text.split(value).join("[redacted]"), output).trim().slice(-OUTPUT_TAIL);
18135
+ const tail = redact.filter((value) => value.length >= 8).reduce((text2, value) => text2.split(value).join("[redacted]"), output).trim().slice(-OUTPUT_TAIL);
17932
18136
  return new CliError(
17933
18137
  "wrangler_failed",
17934
18138
  `wrangler ${args[0] ?? ""} failed${code === null ? "" : ` (exit ${code})`}.`,
@@ -18115,7 +18319,7 @@ async function configFile(ctx, journal, artifact) {
18115
18319
  } : {}
18116
18320
  };
18117
18321
  const path = join3(directory, "wrangler.json");
18118
- await writeFile(path, JSON.stringify(config2, null, 2), { mode: 384 });
18322
+ await writeFile2(path, JSON.stringify(config2, null, 2), { mode: 384 });
18119
18323
  return { path, directory, config: config2 };
18120
18324
  }
18121
18325
  async function verifyDeployment(ctx, url2, id) {
@@ -18335,7 +18539,9 @@ async function matchExisting(ctx, cf, flags) {
18335
18539
  return journal;
18336
18540
  }
18337
18541
  async function deploymentCommand(ctx, command, flags, cf = new Cloudflare(), loadRelease = release) {
18338
- const artifact = await loadRelease(flags.version);
18542
+ const artifact = await loadRelease(flags.version, void 0, {
18543
+ archive: flags["release-archive"]
18544
+ });
18339
18545
  await cf.authenticate(flags);
18340
18546
  if (command !== "deployment setup") {
18341
18547
  const journal2 = await matchExisting(ctx, cf, flags);
@@ -18501,7 +18707,7 @@ async function deploymentCommand(ctx, command, flags, cf = new Cloudflare(), loa
18501
18707
  SECRET_VAULT_LOCAL_KEK_V1: await ctx.store.vaultKey(journal.id)
18502
18708
  };
18503
18709
  const secretPath = join3(file2.directory, "secrets.json");
18504
- await writeFile(secretPath, JSON.stringify(secrets), {
18710
+ await writeFile2(secretPath, JSON.stringify(secrets), {
18505
18711
  mode: 384
18506
18712
  });
18507
18713
  try {
@@ -18617,10 +18823,10 @@ function* strings(value) {
18617
18823
  }
18618
18824
  function reported(value, sent) {
18619
18825
  if (typeof value !== "string") return "";
18620
- let text = value.replace(new RegExp("\\p{C}", "gu"), " ").replace(/\s+/g, " ").trim();
18826
+ let text2 = value.replace(new RegExp("\\p{C}", "gu"), " ").replace(/\s+/g, " ").trim();
18621
18827
  for (const secret3 of strings(sent))
18622
- if (secret3.length >= 8) text = text.split(secret3).join("[redacted]");
18623
- return text.length > MESSAGE_LIMIT ? text.slice(0, MESSAGE_LIMIT) + "\u2026" : text;
18828
+ if (secret3.length >= 8) text2 = text2.split(secret3).join("[redacted]");
18829
+ return text2.length > MESSAGE_LIMIT ? text2.slice(0, MESSAGE_LIMIT) + "\u2026" : text2;
18624
18830
  }
18625
18831
  var RECOVERABLE_RESOURCE_CODES = ["resource_receipt_expired", "resource_key_unavailable"];
18626
18832
  var RECOVERABLE_RESOURCE_FIELDS = ["appId", "keyId", "providerId", "providerGatewayId"];
@@ -18815,16 +19021,16 @@ async function main(argv = process.argv.slice(2), {
18815
19021
  };
18816
19022
  const rendered = ctx.onboarding ? { ...result, ...ctx.onboarding } : result;
18817
19023
  const output = { schemaVersion: 1, ok: true, context, result: rendered };
18818
- const text = json2 ? JSON.stringify(output) + "\n" : humanResult(parsed.command, rendered, context);
19024
+ const text2 = json2 ? JSON.stringify(output) + "\n" : humanResult(parsed.command, rendered, context);
18819
19025
  if (stdout instanceof Writable) {
18820
19026
  await new Promise(
18821
19027
  (resolve3, reject2) => stdout.write(
18822
- text,
19028
+ text2,
18823
19029
  (error51) => error51 ? reject2(error51) : resolve3()
18824
19030
  )
18825
19031
  );
18826
19032
  } else {
18827
- stdout.write(text);
19033
+ stdout.write(text2);
18828
19034
  }
18829
19035
  await ctx.acknowledgeOutput();
18830
19036
  return 0;