@airwallex/airi-cli 0.0.0-stage → 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,288 @@
1
- # Temporary Holding Version
1
+ # airi-cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ![Version](https://img.shields.io/badge/version-0.1.0--beta.1-orange)
4
+
5
+ Command-line tool for the Airi wallet. Authenticate with OAuth, inspect your
6
+ session, and drive an **agentic checkout** — list payment methods, create a
7
+ payment mandate, fetch the single-use checkout credential, and report the
8
+ result — from your terminal or an AI agent.
9
+
10
+ Results are printed to stdout as JSON, so they are easy to consume from scripts
11
+ and agents.
12
+
13
+ ## Installation
14
+
15
+ airi-cli is available for macOS and Linux as a single self-contained binary.
16
+ Install it with npm:
17
+
18
+ ```bash
19
+ npm install -g @airwallex/airi-cli@beta
20
+ ```
21
+
22
+ This installs the `@airwallex/airi-cli` package, which downloads the prebuilt
23
+ binary for your platform, verifies its SHA256 checksum, and puts `airi-cli` on
24
+ your `PATH`.
25
+
26
+ > **Agent skills:** deploy the bundled agent skills with `airi-cli skills install`
27
+ > after installing (see [Skills](#skills)).
28
+
29
+ Environment variables (install time):
30
+
31
+ | Variable | Description |
32
+ |----------|-------------|
33
+ | `AIRI_CLI_VERSION` | Download a specific release tag (e.g. `v0.1.0`) instead of the version bundled with the package. |
34
+ | `AIRI_CLI_SKIP_DOWNLOAD` | Set to `1` to skip the binary download during install. |
35
+ | `AIRI_CLI_SKIP_CHECKSUM` | **Local development only.** Set to `1` to bypass SHA256 verification. Never use this in production or CI. |
36
+ | `AIRI_CLI_BASE_URL` | Override the release host the binary is downloaded from (for internal mirrors). |
37
+
38
+ ### Checking for updates
39
+
40
+ `airi-cli version` prints the installed version; `airi-cli version --check` also
41
+ asks GitHub for the latest published release and reports whether an update is
42
+ available:
43
+
44
+ ```bash
45
+ airi-cli version # e.g. 0.1.0
46
+ airi-cli version --check # reports whether a newer release exists
47
+ ```
48
+
49
+ In JSON mode (the default for non-interactive callers) `--check` returns:
50
+
51
+ ```json
52
+ {"current":"0.1.0","latest":"0.2.0","update_available":true,
53
+ "install_command":"npm install -g @airwallex/airi-cli"}
54
+ ```
55
+
56
+ To update, run the `install_command` from that response to replace the binary
57
+ with the latest release. The check is best-effort and never
58
+ fails the command: if GitHub can't be reached it reports
59
+ `update_available: false` with a `check_error` field, so it is safe to run as a
60
+ pre-flight step.
61
+
62
+ ## Quick start
63
+
64
+ Sign in:
65
+
66
+ ```bash
67
+ airi-cli auth login
68
+ ```
69
+
70
+ `login` shows a verification URL and a code:
71
+
72
+ ```
73
+ ╭─ Sign in to Airi ─────────────────────────────────╮
74
+ │ Open this URL and enter the code to sign in: │
75
+ │ │
76
+ │ https://app.airi.com/device │
77
+ │ Code: WXYZ-1234 │
78
+ │ │
79
+ │ Expires in 10 min. Press Enter to open it in your │
80
+ │ browser. │
81
+ │ Waiting for authorization… │
82
+ ╰───────────────────────────────────────────────────╯
83
+ ```
84
+
85
+ Open the URL in a browser (or press Enter to open it for you), enter the code,
86
+ and approve. The command confirms once you're signed in:
87
+
88
+ ```
89
+ ╭─ airi-cli ──────────────────────────────────────────────╮
90
+ │ ✓ Signed in │
91
+ │ Session expires: 2026-08-12 18:00 +08 (in 29 days) │
92
+ ╰──────────────────────────────────────────────────────────╯
93
+ ```
94
+
95
+ Check your status at any time:
96
+
97
+ ```bash
98
+ airi-cli auth whoami
99
+ ```
100
+
101
+ `whoami` reports the current sign-in status and refreshes the session token
102
+ when it is close to expiring, so the reported expiry reflects the renewed
103
+ session. In JSON output (`-o json`, the default for scripts and agents) the
104
+ expiry is the `session_expires` field as an RFC3339 timestamp.
105
+
106
+ Every command refreshes the access token automatically — proactively before
107
+ each call when it is close to expiring, and reactively if the server rejects
108
+ it — so you never have to refresh it manually. If a command still reports an
109
+ expired or unauthenticated session, the token could not be refreshed: sign in
110
+ again with `airi-cli auth login` and retry.
111
+
112
+ List the payment methods available for agentic checkout (a quick way to confirm
113
+ your access token works):
114
+
115
+ ```bash
116
+ airi-cli payment-methods list
117
+ ```
118
+
119
+ ## Agentic checkout
120
+
121
+ The `mandate` commands drive a checkout on the user's behalf. The typical flow,
122
+ after `auth login`, is:
123
+
124
+ 1. **Pick a payment method** — `airi-cli payment-methods list` returns the cards
125
+ available for agentic checkout; keep the `paymentMethodId` you want to use.
126
+ 2. **Create a mandate** — `airi-cli mandate create` records the merchant, order
127
+ total, currency, and line items. The response includes a `mandate_id` and a
128
+ passkey/verification URL for the user to approve the payment.
129
+
130
+ ```bash
131
+ airi-cli mandate create \
132
+ --source "<agent-name>" \
133
+ --payment-method-id "<payment-method-id>" \
134
+ --merchant-name "Acme" --merchant-url "https://acme.example" \
135
+ --merchant-country "US" \
136
+ --total-amount "42.00" --currency "USD" \
137
+ --item "name:Widget,unit_price:21.00,quantity:2" \
138
+ --description "Order #1234"
139
+ ```
140
+
141
+ 3. **Fetch the credential** — after the user approves in their browser,
142
+ `airi-cli mandate get-credential --mandate-id <id>` polls until the mandate is
143
+ authorized and returns a single-use credential (tokenized PAN, expiry, CVV).
144
+ Prefer `--file <path>`: the full credential is written to a `0600` file and
145
+ stdout shows only a receipt (`status`, `tokenType`, and `tokenExpiration`),
146
+ so the secret stays out of your terminal, logs, and an agent's transcript.
147
+
148
+ ```bash
149
+ airi-cli mandate get-credential --mandate-id "<mandate-id>" --file /tmp/airi-cred.json
150
+ ```
151
+
152
+ 4. **Report the result** — after submitting the checkout, always report the
153
+ outcome with `airi-cli mandate result --mandate-id <id> --result <SUCCEEDED|CANCELLED|DECLINED>`.
154
+
155
+ ## Commands
156
+
157
+ | Command | Description |
158
+ | --- | --- |
159
+ | `airi-cli auth login` | Sign in via the OAuth device flow. |
160
+ | `airi-cli auth poll` | Wait for a non-blocking login to be approved. |
161
+ | `airi-cli auth whoami` | Print the current sign-in status. |
162
+ | `airi-cli auth logout` | Sign out by removing the stored session (local only). |
163
+ | `airi-cli payment-methods list` | List payment methods available for agentic checkout. |
164
+ | `airi-cli mandate create` | Create a payment mandate for a checkout. |
165
+ | `airi-cli mandate get-credential` | Poll for and fetch the single-use checkout credential. |
166
+ | `airi-cli mandate result` | Report the transaction outcome for a mandate. |
167
+ | `airi-cli skills install` | Deploy the bundled agent skills into local AI-agent skill directories. |
168
+ | `airi-cli skills list` | List the agent skills bundled in the binary. |
169
+ | `airi-cli version` | Print the CLI version (add `--check` to check for a newer release). |
170
+
171
+ Flags for `airi-cli version`:
172
+
173
+ | Flag | Default | Description |
174
+ | --- | --- | --- |
175
+ | `--check` | `false` | Check GitHub for the latest release and report whether an update is available. |
176
+
177
+ Flags for `airi-cli auth login`:
178
+
179
+ | Flag | Default | Description |
180
+ | --- | --- | --- |
181
+ | `--client-name` | `CLI` | Name to register this client under. |
182
+
183
+ Flags for `airi-cli auth whoami`:
184
+
185
+ | Flag | Default | Description |
186
+ | --- | --- | --- |
187
+ | `--verbose` | `false` | Include device details (name, platform, device id). |
188
+
189
+ Flags for `airi-cli mandate create` (all required):
190
+
191
+ | Flag | Description |
192
+ | --- | --- |
193
+ | `--source` | Agent source identifier (use the calling agent's name). |
194
+ | `--payment-method-id` | Payment method ID from `payment-methods list`. |
195
+ | `--merchant-name` | Merchant name. |
196
+ | `--merchant-url` | Merchant URL. |
197
+ | `--merchant-country` | Merchant country code. |
198
+ | `--total-amount` | Total order amount. |
199
+ | `--currency` | Order currency code. |
200
+ | `--item` | Line item as `name:<product>,unit_price:<price>,quantity:<n>` (repeat `--item` per item). |
201
+ | `--description` | Mandate description. |
202
+
203
+ Flags for `airi-cli mandate get-credential`:
204
+
205
+ | Flag | Default | Description |
206
+ | --- | --- | --- |
207
+ | `--mandate-id` | — | Mandate ID (required). |
208
+ | `--interval` | `2` | Poll interval while waiting for authorization (seconds). |
209
+ | `--timeout` | `300` | Stop polling after this many seconds. |
210
+ | `--file` | — | Write the credential to this file (`0600`) instead of printing it; stdout shows only a receipt with `status`, `tokenType`, and the token `tokenExpiration` (no sensitive values). |
211
+ | `--force` | `false` | Overwrite the `--file` target if it already exists. |
212
+
213
+ Flags for `airi-cli mandate result`:
214
+
215
+ | Flag | Description |
216
+ | --- | --- |
217
+ | `--mandate-id` | Mandate ID (required). |
218
+ | `--result` | Transaction result: `SUCCEEDED`, `CANCELLED`, or `DECLINED` (required). |
219
+ | `--reason` | Optional reason, e.g. when the result is `DECLINED`. |
220
+
221
+ Global flags:
222
+
223
+ | Flag | Description |
224
+ | --- | --- |
225
+ | `-o`, `--output` | Output format: `auto` (default), `text`, or `json`. |
226
+ | `--pretty` | Pretty-print JSON output (JSON mode only). |
227
+ | `--no-telemetry` | Disable anonymous usage telemetry for the invocation. |
228
+ | `-V`, `--version` | Print the CLI version. |
229
+
230
+ ### Output
231
+
232
+ `--output auto` (the default) renders human-friendly boxed **text** on an
233
+ interactive terminal and machine-readable **JSON** when the output is piped or
234
+ redirected — so scripts and agents get JSON automatically without any flag.
235
+ Force a mode with `-o text` or `-o json`. In JSON mode, `--pretty` indents the
236
+ output. `auth whoami` and `auth login` omit device diagnostics
237
+ (`name`/`platform`/`device_id`) by default; `auth whoami --verbose` includes
238
+ them.
239
+
240
+ Times are reported in your **local timezone** (from the environment's `TZ` /
241
+ host setting). In text mode the expiry carries the local offset (e.g.
242
+ `2026-08-12 18:00 +08`); in JSON mode timestamps such as `session_expires` and
243
+ the credential's `tokenExpiration` are RFC3339 values with the local offset.
244
+
245
+ ## Telemetry
246
+
247
+ The CLI sends anonymous usage events — command name, exit code, duration,
248
+ platform, and runtime/agent context — to help improve the tool. Events contain
249
+ no tokens, no account information, and no command arguments, and are keyed by a
250
+ random per-install identifier.
251
+
252
+ Disable telemetry with any of:
253
+
254
+ - the `--no-telemetry` flag,
255
+ - `AIRI_TELEMETRY_DISABLED=1` (or `DISABLE_TELEMETRY=1`),
256
+ - an empty `telemetryUrl` in your config.
257
+
258
+ ## Skills
259
+
260
+ airi-cli bundles agent skill definitions that describe how an AI agent uses the
261
+ tool. They are embedded into the binary, so every install carries the skills for
262
+ its version. The install script deploys them for you; you can also (re)deploy
263
+ them at any time:
264
+
265
+ ```bash
266
+ airi-cli skills install # canonical dir + detected agents
267
+ airi-cli skills install --all # every known agent, detected or not
268
+ airi-cli skills install --dir ./my-skills # explicit destination(s)
269
+ airi-cli skills list # show which skills the binary carries
270
+ ```
271
+
272
+ `skills install` **auto-detects** installed agents. It always writes the
273
+ canonical `~/.agents/skills` (the open Agent Skills standard location), and
274
+ additionally writes each known agent whose config directory already exists:
275
+
276
+ | Agent | Detected via | Skills dir |
277
+ | --- | --- | --- |
278
+ | Claude Code | `~/.claude/` | `~/.claude/skills` |
279
+ | opencode | `~/.config/opencode/` | `~/.config/opencode/skills` |
280
+ | Codex | `~/.codex/` | `~/.codex/skills` |
281
+ | Cursor | `~/.cursor/` | `~/.cursor/skills` |
282
+ | Windsurf | `~/.codeium/windsurf/` | `~/.codeium/windsurf/skills` |
283
+ | GitHub Copilot | `~/.copilot/` | `~/.copilot/skills` |
284
+ | Gemini CLI | `~/.gemini/` | `~/.gemini/skills` |
285
+
286
+ Use `--all` to write every known agent dir regardless of detection, or `--dir`
287
+ to pick your own. Skills are copied (not symlinked), so re-running
288
+ `skills install` after an upgrade re-syncs every target.
@@ -0,0 +1,65 @@
1
+ // Shared constants and helpers for the @airwallex/airi-cli launcher and its
2
+ // postinstall downloader. Keeping the platform matrix and binary paths in one
3
+ // place means the downloader and the launcher can never disagree about where
4
+ // the binary lives or what asset to fetch.
5
+
6
+ import path from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+
9
+ // GitHub mirror that hosts the release assets. Matches release-github.sh and
10
+ // install.sh.
11
+ export const REPO = 'airwallex/airi-cli'
12
+
13
+ // Base binary name inside each release archive (airi-cli_<os>_<arch>.tar.gz).
14
+ export const BIN_NAME = 'airi-cli'
15
+
16
+ const here = path.dirname(fileURLToPath(import.meta.url))
17
+
18
+ // The downloaded binary lives in <package>/vendor/airi-cli, separate from the
19
+ // bin/ launcher so `npm pack`'s `files` list (which ships bin/airi-cli.js) is
20
+ // never confused with the runtime-downloaded artifact.
21
+ export function binDir() {
22
+ return path.join(here, '..', 'vendor')
23
+ }
24
+ export function binPath() {
25
+ return path.join(binDir(), BIN_NAME)
26
+ }
27
+
28
+ // Map Node's process.platform/process.arch to the "<os>_<arch>" fragment used
29
+ // in release asset filenames (and the extracted binary). macOS uses
30
+ // macos/arm64|x86_64; Linux uses linux/amd64|arm64 — matching the
31
+ // build-binaries CI job and install.sh's detect_platform.
32
+ export function detectPlatform(
33
+ platform = process.platform,
34
+ arch = process.arch
35
+ ) {
36
+ let os
37
+ switch (platform) {
38
+ case 'linux':
39
+ os = 'linux'
40
+ break
41
+ case 'darwin':
42
+ os = 'macos'
43
+ break
44
+ default:
45
+ throw new Error(
46
+ `unsupported OS: ${platform} (airi-cli supports linux and darwin)`
47
+ )
48
+ }
49
+
50
+ let cpu
51
+ switch (arch) {
52
+ case 'x64':
53
+ cpu = os === 'macos' ? 'x86_64' : 'amd64'
54
+ break
55
+ case 'arm64':
56
+ cpu = 'arm64'
57
+ break
58
+ default:
59
+ throw new Error(
60
+ `unsupported architecture: ${arch} (airi-cli supports x64 and arm64)`
61
+ )
62
+ }
63
+
64
+ return `${os}_${cpu}`
65
+ }
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ // Launcher for @airwallex/airi-cli.
5
+ //
6
+ // The real CLI is a Go binary downloaded by this package's postinstall script
7
+ // (scripts/postinstall.mjs) into <package>/vendor/airi-cli. This launcher spawns
8
+ // that binary and forwards everything to it — argv, stdio, exit code, and
9
+ // signals — so callers cannot tell the Go binary was reached via a Node shim.
10
+ //
11
+ // If the binary is missing (postinstall was skipped with --ignore-scripts, or
12
+ // the download/verification failed), it prints remediation and exits non-zero.
13
+
14
+ const { spawn } = require('child_process')
15
+ const fs = require('fs')
16
+ const path = require('path')
17
+
18
+ const REPO = 'airwallex/airi-cli'
19
+ const BIN_NAME = 'airi-cli'
20
+ const BIN_PATH = path.join(__dirname, '..', 'vendor', BIN_NAME)
21
+
22
+ function fail(message) {
23
+ process.stderr.write(`airi-cli: ${message}\n`)
24
+ process.exit(1)
25
+ }
26
+
27
+ function main() {
28
+ if (!fs.existsSync(BIN_PATH)) {
29
+ fail(
30
+ `binary not found at ${BIN_PATH}.\n` +
31
+ ` The postinstall step did not install it — this happens with ` +
32
+ `--ignore-scripts, or if the download or checksum verification failed.\n` +
33
+ ` Reinstall the package (npm rebuild --global @airwallex/airi-cli for a ` +
34
+ `global install, or npm rebuild @airwallex/airi-cli locally), or ` +
35
+ `download it manually from:\n` +
36
+ ` https://github.com/${REPO}/releases`
37
+ )
38
+ }
39
+
40
+ // Async spawn (not spawnSync) so this process keeps its event loop running
41
+ // and can forward signals to the child while it executes. stdio is inherited
42
+ // so the child owns the terminal directly.
43
+ const child = spawn(BIN_PATH, process.argv.slice(2), {
44
+ stdio: 'inherit',
45
+ windowsHide: true,
46
+ })
47
+
48
+ // Forward the signals a supervisor (shell, init, CI) is likely to send to the
49
+ // wrapper on to the child, so killing the wrapper kills the real CLI rather
50
+ // than orphaning it. SIGINT/SIGTERM/SIGHUP/SIGQUIT cover the common cases.
51
+ const forwarded = ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT']
52
+ const handlers = {}
53
+ for (const sig of forwarded) {
54
+ handlers[sig] = () => {
55
+ if (!child.killed) {
56
+ try {
57
+ child.kill(sig)
58
+ } catch (_err) {
59
+ // Child may have already exited between the signal and this handler.
60
+ }
61
+ }
62
+ }
63
+ // Registering a handler also stops the default action from killing the
64
+ // wrapper before the child has a chance to exit and report its status.
65
+ process.on(sig, handlers[sig])
66
+ }
67
+
68
+ child.on('error', (err) => {
69
+ if (err.code === 'ENOENT') {
70
+ fail(`binary not found at ${BIN_PATH}; reinstall airi-cli.`)
71
+ }
72
+ if (err.code === 'EACCES') {
73
+ fail(`cannot execute ${BIN_PATH}: permission denied.`)
74
+ }
75
+ fail(`failed to launch ${BIN_PATH}: ${err.message}`)
76
+ })
77
+
78
+ child.on('exit', (code, signal) => {
79
+ // Stop forwarding so the re-raise below isn't intercepted by our own handler.
80
+ for (const sig of forwarded) {
81
+ process.removeListener(sig, handlers[sig])
82
+ }
83
+ // Mirror the child's termination: if it was killed by a signal, re-raise
84
+ // that signal on ourselves so the parent sees the real cause; otherwise
85
+ // exit with the child's status.
86
+ if (signal) {
87
+ process.kill(process.pid, signal)
88
+ return
89
+ }
90
+ process.exit(code === null ? 1 : code)
91
+ })
92
+ }
93
+
94
+ main()
package/checksums.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "checksums": {
3
+ "linux_amd64": "b6f0ea7935b018ef1752ddbde54eee6033b754709972ddffd1be4182b527934f",
4
+ "linux_arm64": "595b6f2ee28b8a254ad38d0e3db20947d06c4d0be77afa123348b2ba4bd6b8d4",
5
+ "macos_x86_64": "de210b14b727cecaf8f9d2feeb228d8374b3881e4dc52f7572466c13750cc2d5",
6
+ "macos_arm64": "11cfeaf377cae47048891735f616b40740b11c92f882d47739bcc035332fe4f0"
7
+ }
8
+ }
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "@airwallex/airi-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-beta.1",
4
+ "description": "Command-line tool for the Airi wallet — OAuth sign-in and agentic checkout from your terminal or an AI agent.",
5
+ "bin": {
6
+ "airi-cli": "bin/airi-cli.js"
7
+ },
8
+ "files": [
9
+ "bin/airi-cli.js",
10
+ "bin/_shared.mjs",
11
+ "scripts/postinstall.mjs",
12
+ "checksums.json",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "postinstall": "node scripts/postinstall.mjs"
17
+ },
18
+ "engines": {
19
+ "node": ">=14.14"
20
+ },
21
+ "license": "UNLICENSED",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://gitlab.awx.im/acquiring/airi-cli.git"
25
+ },
26
+ "keywords": [
27
+ "airwallex",
28
+ "airi",
29
+ "cli",
30
+ "agentic-checkout"
31
+ ],
32
+ "publishConfig": {
33
+ "access": "public",
34
+ "registry": "https://registry.npmjs.org/"
35
+ }
36
+ }
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env node
2
+ // postinstall for @airwallex/airi-cli.
3
+ //
4
+ // airi-cli is a Go binary. This npm package ships only a small JS launcher;
5
+ // the actual binary is downloaded here from the GitHub release whose tag
6
+ // matches this package's version, verified against the SHA256 digest pinned
7
+ // into the package at publish time (checksums.json), and unpacked next to the
8
+ // launcher.
9
+ //
10
+ // Mirrors the asset layout that scripts/release-github.sh uploads and
11
+ // install.sh downloads:
12
+ // https://github.com/<REPO>/releases/download/v<version>/airi-cli_<os>_<arch>.tar.gz
13
+ //
14
+ // Verification is fail-closed: if the package has no pinned digest for the host
15
+ // platform (e.g. a non-release build), or the download does not match it, the
16
+ // binary is NOT installed. An unverified executable is never left on disk.
17
+ //
18
+ // Environment variables:
19
+ // AIRI_CLI_VERSION download this release tag instead of the package's
20
+ // own version (e.g. "v0.2.0" or "0.2.0"). The pinned
21
+ // digest only matches the package's own version, so
22
+ // an override requires AIRI_CLI_SKIP_CHECKSUM.
23
+ // AIRI_CLI_SKIP_DOWNLOAD set to "1" to skip the download entirely. Useful
24
+ // for offline CI that provides the binary another
25
+ // way; the CLI won't run until it is present.
26
+ // AIRI_CLI_SKIP_CHECKSUM set to "1" to skip SHA256 verification (local-dev
27
+ // only — never in production or CI).
28
+ // AIRI_CLI_BASE_URL override the release host (internal mirror/testing).
29
+ //
30
+ // The script is resilient by design: a download or verification failure prints
31
+ // clear guidance but does NOT hard-fail the install, so installing a larger
32
+ // dependency tree is not aborted by a transient network issue. The launcher
33
+ // re-reports a missing binary with the same remediation if the CLI is run.
34
+
35
+ import { createWriteStream, existsSync } from 'node:fs'
36
+ import { mkdir, mkdtemp, readFile, rm, chmod, rename } from 'node:fs/promises'
37
+ import { createHash } from 'node:crypto'
38
+ import { tmpdir } from 'node:os'
39
+ import path from 'node:path'
40
+ import { fileURLToPath } from 'node:url'
41
+ import { spawnSync } from 'node:child_process'
42
+ import { createReadStream } from 'node:fs'
43
+
44
+ import { REPO, BIN_NAME, binDir, binPath, detectPlatform } from '../bin/_shared.mjs'
45
+
46
+ const here = path.dirname(fileURLToPath(import.meta.url))
47
+
48
+ // Load the SHA256 pinned into this package at publish time (checksums.json).
49
+ // This digest — not the mutable GitHub .sha256 sidecar — is the source of truth
50
+ // the download is verified against, so a given npm version always resolves the
51
+ // same bytes. Returns "" when the package was not built by the release pipeline
52
+ // (checked-in placeholder), which the caller treats as "cannot verify".
53
+ async function pinnedChecksum(platform) {
54
+ try {
55
+ const raw = await readFile(path.join(here, '..', 'checksums.json'), 'utf8')
56
+ const parsed = JSON.parse(raw)
57
+ const sum = parsed && parsed.checksums ? parsed.checksums[platform] : ''
58
+ return typeof sum === 'string' ? sum.trim().toLowerCase() : ''
59
+ } catch {
60
+ return ''
61
+ }
62
+ }
63
+
64
+ function log(msg) {
65
+ process.stdout.write(`airi-cli: ${msg}\n`)
66
+ }
67
+ function warn(msg) {
68
+ process.stderr.write(`airi-cli: ${msg}\n`)
69
+ }
70
+
71
+ // Resolve the release tag: AIRI_CLI_VERSION wins, otherwise this package's own
72
+ // version (so `npm install @airwallex/airi-cli@X` fetches the X binary — the
73
+ // install is reproducible rather than "whatever is newest").
74
+ async function resolveTag() {
75
+ const override = process.env.AIRI_CLI_VERSION
76
+ if (override && override.trim()) {
77
+ const v = override.trim()
78
+ return v.startsWith('v') ? v : `v${v}`
79
+ }
80
+ const pkg = JSON.parse(
81
+ await readFile(path.join(here, '..', 'package.json'), 'utf8')
82
+ )
83
+ return `v${pkg.version}`
84
+ }
85
+
86
+ async function download(url, dest) {
87
+ // Node 18+ has global fetch; the package requires node >=14 so fall back to
88
+ // https.get on older runtimes. Both follow redirects (GitHub asset URLs
89
+ // redirect to a CDN).
90
+ if (typeof fetch === 'function') {
91
+ const res = await fetch(url, { redirect: 'follow' })
92
+ if (!res.ok) {
93
+ throw new Error(`HTTP ${res.status} for ${url}`)
94
+ }
95
+ const buf = Buffer.from(await res.arrayBuffer())
96
+ await writeFileAtomic(dest, buf)
97
+ return
98
+ }
99
+ const { get } = await import('node:https')
100
+ await new Promise((resolve, reject) => {
101
+ const followGet = (u, redirects) => {
102
+ if (redirects > 10) return reject(new Error('too many redirects'))
103
+ get(u, (res) => {
104
+ if (
105
+ res.statusCode &&
106
+ res.statusCode >= 300 &&
107
+ res.statusCode < 400 &&
108
+ res.headers.location
109
+ ) {
110
+ res.resume()
111
+ const next = new URL(res.headers.location, u).toString()
112
+ return followGet(next, redirects + 1)
113
+ }
114
+ if (res.statusCode !== 200) {
115
+ res.resume()
116
+ return reject(new Error(`HTTP ${res.statusCode} for ${u}`))
117
+ }
118
+ const out = createWriteStream(dest)
119
+ res.pipe(out)
120
+ out.on('finish', () => out.close(resolve))
121
+ out.on('error', reject)
122
+ }).on('error', reject)
123
+ }
124
+ followGet(url, 0)
125
+ })
126
+ }
127
+
128
+ async function writeFileAtomic(dest, buf) {
129
+ const tmp = `${dest}.tmp`
130
+ await new Promise((resolve, reject) => {
131
+ const out = createWriteStream(tmp)
132
+ out.on('error', reject)
133
+ out.on('finish', resolve)
134
+ out.end(buf)
135
+ })
136
+ await rename(tmp, dest)
137
+ }
138
+
139
+ function sha256File(file) {
140
+ return new Promise((resolve, reject) => {
141
+ const hash = createHash('sha256')
142
+ const s = createReadStream(file)
143
+ s.on('error', reject)
144
+ s.on('data', (d) => hash.update(d))
145
+ s.on('end', () => resolve(hash.digest('hex')))
146
+ })
147
+ }
148
+
149
+ // Extract the single binary from the .tar.gz using the platform `tar`, which is
150
+ // present on macOS and Linux (the only platforms we ship). Avoids adding a tar
151
+ // dependency to the package.
152
+ function extract(tarball, destDir) {
153
+ const res = spawnSync('tar', ['-xzf', tarball, '-C', destDir], {
154
+ stdio: 'inherit',
155
+ })
156
+ if (res.error) throw res.error
157
+ if (res.status !== 0) throw new Error(`tar exited ${res.status}`)
158
+ }
159
+
160
+ async function main() {
161
+ if (process.env.AIRI_CLI_SKIP_DOWNLOAD === '1') {
162
+ warn('AIRI_CLI_SKIP_DOWNLOAD=1 — skipping binary download.')
163
+ return
164
+ }
165
+
166
+ let platform
167
+ try {
168
+ platform = detectPlatform()
169
+ } catch (err) {
170
+ // Unsupported platform: warn, don't fail the whole install.
171
+ warn(err.message)
172
+ warn("the CLI will not run on this platform.")
173
+ return
174
+ }
175
+
176
+ const tag = await resolveTag()
177
+ const asset = `${BIN_NAME}_${platform}.tar.gz`
178
+ // AIRI_CLI_BASE_URL overrides the release host for testing and for mirrors
179
+ // (e.g. an internal proxy of the GitHub assets). It must point at a location
180
+ // serving the same <tag>/<asset> layout. Defaults to GitHub.
181
+ const host = (process.env.AIRI_CLI_BASE_URL || `https://github.com/${REPO}/releases/download`).replace(/\/+$/, '')
182
+ const base = `${host}/${tag}/${asset}`
183
+
184
+ const skipChecksum = process.env.AIRI_CLI_SKIP_CHECKSUM === '1'
185
+ const expected = await pinnedChecksum(platform)
186
+
187
+ // Fail closed BEFORE downloading: if we have no pinned digest to verify
188
+ // against and the caller has not explicitly opted out, do not install an
189
+ // unverified binary. (A non-release build has empty checksums.json entries.)
190
+ if (!skipChecksum && !expected) {
191
+ warn(
192
+ `no pinned SHA256 for ${platform} in this package — refusing to install ` +
193
+ `an unverified binary.`
194
+ )
195
+ warn(
196
+ `this usually means the package was not built by the release pipeline. ` +
197
+ `Install a released version, or set AIRI_CLI_SKIP_CHECKSUM=1 to bypass ` +
198
+ `verification (local development only).`
199
+ )
200
+ return
201
+ }
202
+
203
+ // Per-platform temp dir with a random suffix (mkdtemp), not a predictable
204
+ // pid-based path: prevents a pre-planted symlink/file at a known location
205
+ // from being followed during the install.
206
+ const tmp = await mkdtemp(path.join(tmpdir(), 'airi-cli-'))
207
+
208
+ try {
209
+ log(`downloading ${BIN_NAME} ${tag} (${platform})`)
210
+ const tarball = path.join(tmp, asset)
211
+ await download(base, tarball)
212
+
213
+ if (skipChecksum) {
214
+ warn('AIRI_CLI_SKIP_CHECKSUM=1 — skipping SHA256 verification.')
215
+ } else {
216
+ const actual = (await sha256File(tarball)).toLowerCase()
217
+ if (expected !== actual) {
218
+ throw new Error(
219
+ `SHA256 mismatch for ${asset}\n expected: ${expected}\n actual: ${actual}`
220
+ )
221
+ }
222
+ log('checksum OK')
223
+ }
224
+
225
+ const dir = binDir()
226
+ await mkdir(dir, { recursive: true })
227
+ extract(tarball, tmp)
228
+ const extracted = path.join(tmp, BIN_NAME)
229
+ if (!existsSync(extracted)) {
230
+ throw new Error(`archive did not contain expected '${BIN_NAME}' binary`)
231
+ }
232
+ const finalPath = binPath()
233
+ // Stage the new binary INSIDE the vendor dir (same filesystem as finalPath)
234
+ // and rename it into place. Renaming within one filesystem is atomic and
235
+ // avoids EXDEV (which a rename from os.tmpdir() across filesystems would
236
+ // hit) — and because the staged copy is fully in place and executable
237
+ // before the swap, a failure never leaves the old working binary deleted
238
+ // with no replacement.
239
+ const staged = path.join(dir, `.${BIN_NAME}.new`)
240
+ await rm(staged, { force: true })
241
+ await rename(extracted, staged)
242
+ await chmod(staged, 0o755)
243
+ await rename(staged, finalPath)
244
+ log(`installed binary to ${finalPath}`)
245
+ } catch (err) {
246
+ warn(`failed to download the airi-cli binary: ${err.message}`)
247
+ warn(
248
+ `you can retry by reinstalling the package, or set a specific version ` +
249
+ `with AIRI_CLI_VERSION. The binary is also available from:`
250
+ )
251
+ warn(` https://github.com/${REPO}/releases/tag/${tag}`)
252
+ // Do not exit non-zero: a transient failure must not abort a larger
253
+ // `npm install`. The launcher surfaces the same guidance if run.
254
+ } finally {
255
+ await rm(tmp, { recursive: true, force: true })
256
+ }
257
+ }
258
+
259
+ main().catch((err) => {
260
+ warn(`unexpected error: ${err && err.message ? err.message : err}`)
261
+ // Still never fail the install.
262
+ })