@airwallex/airi-cli 0.0.0-stage → 0.1.0-beta.2
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 +339 -2
- package/bin/_shared.mjs +65 -0
- package/bin/airi-cli.js +94 -0
- package/checksums.json +8 -0
- package/package.json +34 -4
- package/scripts/postinstall.mjs +282 -0
package/README.md
CHANGED
|
@@ -1,3 +1,340 @@
|
|
|
1
|
-
#
|
|
1
|
+
# airi-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+

|
|
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
|
+
|
|
17
|
+
### npm
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install -g @airwallex/airi-cli@beta
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
This installs the `@airwallex/airi-cli` package, which downloads the prebuilt
|
|
24
|
+
binary for your platform, verifies its SHA256 checksum, and puts `airi-cli` on
|
|
25
|
+
your `PATH`.
|
|
26
|
+
|
|
27
|
+
To run it once without installing globally, use npx:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npx @airwallex/airi-cli@beta
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
#### Switching from a `curl | sh` install
|
|
34
|
+
|
|
35
|
+
If you previously installed with the `install.sh` script (via `curl`), remove
|
|
36
|
+
that copy first — otherwise it may stay ahead of the npm one on your `PATH` and
|
|
37
|
+
keep shadowing it, so `airi-cli` keeps running the old binary.
|
|
38
|
+
|
|
39
|
+
Check for more than one `airi-cli` on your `PATH`:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
which -a airi-cli
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
If an entry under `~/.local/bin` (the `install.sh` default) shows up alongside
|
|
46
|
+
the npm one, remove the old install:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
curl -fsSL https://raw.githubusercontent.com/airwallex/airi-cli/master/install.sh | sh -s -- uninstall
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Then reopen your shell and re-check with `which -a airi-cli`.
|
|
53
|
+
|
|
54
|
+
### install.sh (fallback)
|
|
55
|
+
|
|
56
|
+
If npm is not available, install the binary directly with the install script:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
curl -fsSL https://raw.githubusercontent.com/airwallex/airi-cli/master/install.sh | sh
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
To uninstall (removes the binary and local state):
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
curl -fsSL https://raw.githubusercontent.com/airwallex/airi-cli/master/install.sh | sh -s -- uninstall
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Install-time environment variables
|
|
69
|
+
|
|
70
|
+
Both install methods honour the same variables and behave identically, except
|
|
71
|
+
where noted:
|
|
72
|
+
|
|
73
|
+
| Variable | Description |
|
|
74
|
+
|----------|-------------|
|
|
75
|
+
| `AIRI_CLI_VERSION` | Install a specific release tag (e.g. `v0.1.0`) instead of the default. With npm the pinned checksum only matches the package's own version, so overriding it there also requires `AIRI_CLI_SKIP_CHECKSUM=1`. |
|
|
76
|
+
| `AIRI_CLI_INSTALL_DIR` | install.sh only: where to install (default: `$HOME/.local/bin`). npm always installs inside the package and ignores this. |
|
|
77
|
+
| `AIRI_CLI_SKIP_CHECKSUM` | **Local development only.** Set to `1` to bypass SHA256 verification. Never use this in production or CI. |
|
|
78
|
+
| `AIRI_CLI_SKIP_SKILLS` | Set to `1` to skip deploying the bundled agent skills. |
|
|
79
|
+
|
|
80
|
+
Both methods deploy the bundled agent skills at install time. You can also
|
|
81
|
+
deploy them to a specific project with:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npx skills add airwallex/airi-cli
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
or re-run deployment any time with `airi-cli skills install` (see
|
|
88
|
+
[Skills](#skills)).
|
|
89
|
+
|
|
90
|
+
### Checking for updates
|
|
91
|
+
|
|
92
|
+
`airi-cli version` prints the installed version; `airi-cli version --check` also
|
|
93
|
+
asks GitHub for the latest published release and reports whether an update is
|
|
94
|
+
available:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
airi-cli version # e.g. 0.1.0
|
|
98
|
+
airi-cli version --check # reports whether a newer release exists
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
In JSON mode (the default for non-interactive callers) `--check` returns:
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{"current":"0.1.0","latest":"0.2.0","update_available":true,
|
|
105
|
+
"install_command":"npm install -g @airwallex/airi-cli"}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
To update, run the `install_command` from that response to replace the binary
|
|
109
|
+
with the latest release. The check is best-effort and never
|
|
110
|
+
fails the command: if GitHub can't be reached it reports
|
|
111
|
+
`update_available: false` with a `check_error` field, so it is safe to run as a
|
|
112
|
+
pre-flight step.
|
|
113
|
+
|
|
114
|
+
## Quick start
|
|
115
|
+
|
|
116
|
+
Sign in:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
airi-cli auth login
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`login` shows a verification URL and a code:
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
╭─ Sign in to Airi ─────────────────────────────────╮
|
|
126
|
+
│ Open this URL and enter the code to sign in: │
|
|
127
|
+
│ │
|
|
128
|
+
│ https://app.airi.com/device │
|
|
129
|
+
│ Code: WXYZ-1234 │
|
|
130
|
+
│ │
|
|
131
|
+
│ Expires in 10 min. Press Enter to open it in your │
|
|
132
|
+
│ browser. │
|
|
133
|
+
│ Waiting for authorization… │
|
|
134
|
+
╰───────────────────────────────────────────────────╯
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Open the URL in a browser (or press Enter to open it for you), enter the code,
|
|
138
|
+
and approve. The command confirms once you're signed in:
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
╭─ airi-cli ──────────────────────────────────────────────╮
|
|
142
|
+
│ ✓ Signed in │
|
|
143
|
+
│ Session expires: 2026-08-12 18:00 +08 (in 29 days) │
|
|
144
|
+
╰──────────────────────────────────────────────────────────╯
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Check your status at any time:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
airi-cli auth whoami
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
`whoami` reports the current sign-in status and refreshes the session token
|
|
154
|
+
when it is close to expiring, so the reported expiry reflects the renewed
|
|
155
|
+
session. In JSON output (`-o json`, the default for scripts and agents) the
|
|
156
|
+
expiry is the `session_expires` field as an RFC3339 timestamp.
|
|
157
|
+
|
|
158
|
+
Every command refreshes the access token automatically — proactively before
|
|
159
|
+
each call when it is close to expiring, and reactively if the server rejects
|
|
160
|
+
it — so you never have to refresh it manually. If a command still reports an
|
|
161
|
+
expired or unauthenticated session, the token could not be refreshed: sign in
|
|
162
|
+
again with `airi-cli auth login` and retry.
|
|
163
|
+
|
|
164
|
+
List the payment methods available for agentic checkout (a quick way to confirm
|
|
165
|
+
your access token works):
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
airi-cli payment-methods list
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Agentic checkout
|
|
172
|
+
|
|
173
|
+
The `mandate` commands drive a checkout on the user's behalf. The typical flow,
|
|
174
|
+
after `auth login`, is:
|
|
175
|
+
|
|
176
|
+
1. **Pick a payment method** — `airi-cli payment-methods list` returns the cards
|
|
177
|
+
available for agentic checkout; keep the `paymentMethodId` you want to use.
|
|
178
|
+
2. **Create a mandate** — `airi-cli mandate create` records the merchant, order
|
|
179
|
+
total, currency, and line items. The response includes a `mandate_id` and a
|
|
180
|
+
passkey/verification URL for the user to approve the payment.
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
airi-cli mandate create \
|
|
184
|
+
--source "<agent-name>" \
|
|
185
|
+
--payment-method-id "<payment-method-id>" \
|
|
186
|
+
--merchant-name "Acme" --merchant-url "https://acme.example" \
|
|
187
|
+
--merchant-country "US" \
|
|
188
|
+
--total-amount "42.00" --currency "USD" \
|
|
189
|
+
--item "name:Widget,unit_price:21.00,quantity:2" \
|
|
190
|
+
--description "Order #1234"
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
3. **Fetch the credential** — after the user approves in their browser,
|
|
194
|
+
`airi-cli mandate get-credential --mandate-id <id>` polls until the mandate is
|
|
195
|
+
authorized and returns a single-use credential (tokenized PAN, expiry, CVV).
|
|
196
|
+
Prefer `--file <path>`: the full credential is written to a `0600` file and
|
|
197
|
+
stdout shows only a receipt (`status`, `tokenType`, and `tokenExpiration`),
|
|
198
|
+
so the secret stays out of your terminal, logs, and an agent's transcript.
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
airi-cli mandate get-credential --mandate-id "<mandate-id>" --file /tmp/airi-cred.json
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
4. **Report the result** — after submitting the checkout, always report the
|
|
205
|
+
outcome with `airi-cli mandate result --mandate-id <id> --result <SUCCEEDED|CANCELLED|DECLINED>`.
|
|
206
|
+
|
|
207
|
+
## Commands
|
|
208
|
+
|
|
209
|
+
| Command | Description |
|
|
210
|
+
| --- | --- |
|
|
211
|
+
| `airi-cli auth login` | Sign in via the OAuth device flow. |
|
|
212
|
+
| `airi-cli auth poll` | Wait for a non-blocking login to be approved. |
|
|
213
|
+
| `airi-cli auth whoami` | Print the current sign-in status. |
|
|
214
|
+
| `airi-cli auth logout` | Sign out by removing the stored session (local only). |
|
|
215
|
+
| `airi-cli payment-methods list` | List payment methods available for agentic checkout. |
|
|
216
|
+
| `airi-cli mandate create` | Create a payment mandate for a checkout. |
|
|
217
|
+
| `airi-cli mandate get-credential` | Poll for and fetch the single-use checkout credential. |
|
|
218
|
+
| `airi-cli mandate result` | Report the transaction outcome for a mandate. |
|
|
219
|
+
| `airi-cli skills install` | Deploy the bundled agent skills into local AI-agent skill directories. |
|
|
220
|
+
| `airi-cli skills list` | List the agent skills bundled in the binary. |
|
|
221
|
+
| `airi-cli version` | Print the CLI version (add `--check` to check for a newer release). |
|
|
222
|
+
|
|
223
|
+
Flags for `airi-cli version`:
|
|
224
|
+
|
|
225
|
+
| Flag | Default | Description |
|
|
226
|
+
| --- | --- | --- |
|
|
227
|
+
| `--check` | `false` | Check GitHub for the latest release and report whether an update is available. |
|
|
228
|
+
|
|
229
|
+
Flags for `airi-cli auth login`:
|
|
230
|
+
|
|
231
|
+
| Flag | Default | Description |
|
|
232
|
+
| --- | --- | --- |
|
|
233
|
+
| `--client-name` | `CLI` | Name to register this client under. |
|
|
234
|
+
|
|
235
|
+
Flags for `airi-cli auth whoami`:
|
|
236
|
+
|
|
237
|
+
| Flag | Default | Description |
|
|
238
|
+
| --- | --- | --- |
|
|
239
|
+
| `--verbose` | `false` | Include device details (name, platform, device id). |
|
|
240
|
+
|
|
241
|
+
Flags for `airi-cli mandate create` (all required):
|
|
242
|
+
|
|
243
|
+
| Flag | Description |
|
|
244
|
+
| --- | --- |
|
|
245
|
+
| `--source` | Agent source identifier (use the calling agent's name). |
|
|
246
|
+
| `--payment-method-id` | Payment method ID from `payment-methods list`. |
|
|
247
|
+
| `--merchant-name` | Merchant name. |
|
|
248
|
+
| `--merchant-url` | Merchant URL. |
|
|
249
|
+
| `--merchant-country` | Merchant country code. |
|
|
250
|
+
| `--total-amount` | Total order amount. |
|
|
251
|
+
| `--currency` | Order currency code. |
|
|
252
|
+
| `--item` | Line item as `name:<product>,unit_price:<price>,quantity:<n>` (repeat `--item` per item). |
|
|
253
|
+
| `--description` | Mandate description. |
|
|
254
|
+
|
|
255
|
+
Flags for `airi-cli mandate get-credential`:
|
|
256
|
+
|
|
257
|
+
| Flag | Default | Description |
|
|
258
|
+
| --- | --- | --- |
|
|
259
|
+
| `--mandate-id` | — | Mandate ID (required). |
|
|
260
|
+
| `--interval` | `2` | Poll interval while waiting for authorization (seconds). |
|
|
261
|
+
| `--timeout` | `300` | Stop polling after this many seconds. |
|
|
262
|
+
| `--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). |
|
|
263
|
+
| `--force` | `false` | Overwrite the `--file` target if it already exists. |
|
|
264
|
+
|
|
265
|
+
Flags for `airi-cli mandate result`:
|
|
266
|
+
|
|
267
|
+
| Flag | Description |
|
|
268
|
+
| --- | --- |
|
|
269
|
+
| `--mandate-id` | Mandate ID (required). |
|
|
270
|
+
| `--result` | Transaction result: `SUCCEEDED`, `CANCELLED`, or `DECLINED` (required). |
|
|
271
|
+
| `--reason` | Optional reason, e.g. when the result is `DECLINED`. |
|
|
272
|
+
|
|
273
|
+
Global flags:
|
|
274
|
+
|
|
275
|
+
| Flag | Description |
|
|
276
|
+
| --- | --- |
|
|
277
|
+
| `-o`, `--output` | Output format: `auto` (default), `text`, or `json`. |
|
|
278
|
+
| `--pretty` | Pretty-print JSON output (JSON mode only). |
|
|
279
|
+
| `--no-telemetry` | Disable anonymous usage telemetry for the invocation. |
|
|
280
|
+
| `-V`, `--version` | Print the CLI version. |
|
|
281
|
+
|
|
282
|
+
### Output
|
|
283
|
+
|
|
284
|
+
`--output auto` (the default) renders human-friendly boxed **text** on an
|
|
285
|
+
interactive terminal and machine-readable **JSON** when the output is piped or
|
|
286
|
+
redirected — so scripts and agents get JSON automatically without any flag.
|
|
287
|
+
Force a mode with `-o text` or `-o json`. In JSON mode, `--pretty` indents the
|
|
288
|
+
output. `auth whoami` and `auth login` omit device diagnostics
|
|
289
|
+
(`name`/`platform`/`device_id`) by default; `auth whoami --verbose` includes
|
|
290
|
+
them.
|
|
291
|
+
|
|
292
|
+
Times are reported in your **local timezone** (from the environment's `TZ` /
|
|
293
|
+
host setting). In text mode the expiry carries the local offset (e.g.
|
|
294
|
+
`2026-08-12 18:00 +08`); in JSON mode timestamps such as `session_expires` and
|
|
295
|
+
the credential's `tokenExpiration` are RFC3339 values with the local offset.
|
|
296
|
+
|
|
297
|
+
## Telemetry
|
|
298
|
+
|
|
299
|
+
The CLI sends anonymous usage events — command name, exit code, duration,
|
|
300
|
+
platform, and runtime/agent context — to help improve the tool. Events contain
|
|
301
|
+
no tokens, no account information, and no command arguments, and are keyed by a
|
|
302
|
+
random per-install identifier.
|
|
303
|
+
|
|
304
|
+
Disable telemetry with any of:
|
|
305
|
+
|
|
306
|
+
- the `--no-telemetry` flag,
|
|
307
|
+
- `AIRI_TELEMETRY_DISABLED=1` (or `DISABLE_TELEMETRY=1`),
|
|
308
|
+
- an empty `telemetryUrl` in your config.
|
|
309
|
+
|
|
310
|
+
## Skills
|
|
311
|
+
|
|
312
|
+
airi-cli bundles agent skill definitions that describe how an AI agent uses the
|
|
313
|
+
tool. They are embedded into the binary, so every install carries the skills for
|
|
314
|
+
its version. The install script deploys them for you; you can also (re)deploy
|
|
315
|
+
them at any time:
|
|
316
|
+
|
|
317
|
+
```bash
|
|
318
|
+
airi-cli skills install # canonical dir + detected agents
|
|
319
|
+
airi-cli skills install --all # every known agent, detected or not
|
|
320
|
+
airi-cli skills install --dir ./my-skills # explicit destination(s)
|
|
321
|
+
airi-cli skills list # show which skills the binary carries
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
`skills install` **auto-detects** installed agents. It always writes the
|
|
325
|
+
canonical `~/.agents/skills` (the open Agent Skills standard location), and
|
|
326
|
+
additionally writes each known agent whose config directory already exists:
|
|
327
|
+
|
|
328
|
+
| Agent | Detected via | Skills dir |
|
|
329
|
+
| --- | --- | --- |
|
|
330
|
+
| Claude Code | `~/.claude/` | `~/.claude/skills` |
|
|
331
|
+
| opencode | `~/.config/opencode/` | `~/.config/opencode/skills` |
|
|
332
|
+
| Codex | `~/.codex/` | `~/.codex/skills` |
|
|
333
|
+
| Cursor | `~/.cursor/` | `~/.cursor/skills` |
|
|
334
|
+
| Windsurf | `~/.codeium/windsurf/` | `~/.codeium/windsurf/skills` |
|
|
335
|
+
| GitHub Copilot | `~/.copilot/` | `~/.copilot/skills` |
|
|
336
|
+
| Gemini CLI | `~/.gemini/` | `~/.gemini/skills` |
|
|
337
|
+
|
|
338
|
+
Use `--all` to write every known agent dir regardless of detection, or `--dir`
|
|
339
|
+
to pick your own. Skills are copied (not symlinked), so re-running
|
|
340
|
+
`skills install` after an upgrade re-syncs every target.
|
package/bin/_shared.mjs
ADDED
|
@@ -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
|
+
}
|
package/bin/airi-cli.js
ADDED
|
@@ -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": "1654d67477a4673902000467638dd9d8dabeebaf05b6ccc2f3b32776cd18e8a5",
|
|
4
|
+
"linux_arm64": "9962fd90bfd55d5aa722e0c40ce93038656101127aeed60c58a2ea169a36efcd",
|
|
5
|
+
"macos_x86_64": "e81bb619536b8b5c86bf3f9d94c1436022c51a3bf03d14bbf640bc6154e38235",
|
|
6
|
+
"macos_arm64": "5f9fbb14c3d27e7375fa4e9d17f99b27d21b3883f7c94ddc70c89e07d276f849"
|
|
7
|
+
}
|
|
8
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@airwallex/airi-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-beta.2",
|
|
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,282 @@
|
|
|
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. The pinned digest only matches the
|
|
21
|
+
// package's own version, so an override also requires
|
|
22
|
+
// AIRI_CLI_SKIP_CHECKSUM.
|
|
23
|
+
// AIRI_CLI_SKIP_CHECKSUM set to "1" to skip SHA256 verification (local-dev
|
|
24
|
+
// only — never in production or CI).
|
|
25
|
+
// AIRI_CLI_SKIP_SKILLS set to "1" to skip deploying the bundled agent skills.
|
|
26
|
+
// AIRI_CLI_BASE_URL (internal, undocumented) override the release host.
|
|
27
|
+
// The e2e tests point it at a local server; kept out
|
|
28
|
+
// of the README so it isn't a supported user knob.
|
|
29
|
+
//
|
|
30
|
+
// A download or verification failure warns but does NOT hard-fail: a transient
|
|
31
|
+
// network issue must not abort a larger `npm install`. The launcher re-reports
|
|
32
|
+
// a missing binary with the same remediation if the CLI is run.
|
|
33
|
+
|
|
34
|
+
import { createWriteStream, existsSync } from 'node:fs'
|
|
35
|
+
import { mkdir, mkdtemp, readFile, rm, chmod, rename } from 'node:fs/promises'
|
|
36
|
+
import { createHash } from 'node:crypto'
|
|
37
|
+
import { tmpdir } from 'node:os'
|
|
38
|
+
import path from 'node:path'
|
|
39
|
+
import { fileURLToPath } from 'node:url'
|
|
40
|
+
import { spawnSync } from 'node:child_process'
|
|
41
|
+
import { createReadStream } from 'node:fs'
|
|
42
|
+
|
|
43
|
+
import { REPO, BIN_NAME, binDir, binPath, detectPlatform } from '../bin/_shared.mjs'
|
|
44
|
+
|
|
45
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
46
|
+
|
|
47
|
+
// Load the SHA256 pinned into this package at publish time (checksums.json).
|
|
48
|
+
// This digest — not the mutable GitHub .sha256 sidecar — is the source of truth
|
|
49
|
+
// the download is verified against, so a given npm version always resolves the
|
|
50
|
+
// same bytes. Returns "" when the package was not built by the release pipeline
|
|
51
|
+
// (checked-in placeholder), which the caller treats as "cannot verify".
|
|
52
|
+
async function pinnedChecksum(platform) {
|
|
53
|
+
try {
|
|
54
|
+
const raw = await readFile(path.join(here, '..', 'checksums.json'), 'utf8')
|
|
55
|
+
const parsed = JSON.parse(raw)
|
|
56
|
+
const sum = parsed && parsed.checksums ? parsed.checksums[platform] : ''
|
|
57
|
+
return typeof sum === 'string' ? sum.trim().toLowerCase() : ''
|
|
58
|
+
} catch {
|
|
59
|
+
return ''
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function log(msg) {
|
|
64
|
+
process.stdout.write(`airi-cli: ${msg}\n`)
|
|
65
|
+
}
|
|
66
|
+
function warn(msg) {
|
|
67
|
+
process.stderr.write(`airi-cli: ${msg}\n`)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Resolve the release tag: AIRI_CLI_VERSION wins, otherwise this package's own
|
|
71
|
+
// version (so `npm install @airwallex/airi-cli@X` fetches the X binary — the
|
|
72
|
+
// install is reproducible rather than "whatever is newest").
|
|
73
|
+
async function resolveTag() {
|
|
74
|
+
const override = process.env.AIRI_CLI_VERSION
|
|
75
|
+
if (override && override.trim()) {
|
|
76
|
+
const v = override.trim()
|
|
77
|
+
return v.startsWith('v') ? v : `v${v}`
|
|
78
|
+
}
|
|
79
|
+
const pkg = JSON.parse(
|
|
80
|
+
await readFile(path.join(here, '..', 'package.json'), 'utf8')
|
|
81
|
+
)
|
|
82
|
+
return `v${pkg.version}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function download(url, dest) {
|
|
86
|
+
// Node 18+ has global fetch; the package requires node >=14 so fall back to
|
|
87
|
+
// https.get on older runtimes. Both follow redirects (GitHub asset URLs
|
|
88
|
+
// redirect to a CDN).
|
|
89
|
+
if (typeof fetch === 'function') {
|
|
90
|
+
const res = await fetch(url, { redirect: 'follow' })
|
|
91
|
+
if (!res.ok) {
|
|
92
|
+
throw new Error(`HTTP ${res.status} for ${url}`)
|
|
93
|
+
}
|
|
94
|
+
const buf = Buffer.from(await res.arrayBuffer())
|
|
95
|
+
await writeFileAtomic(dest, buf)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
const { get } = await import('node:https')
|
|
99
|
+
await new Promise((resolve, reject) => {
|
|
100
|
+
const followGet = (u, redirects) => {
|
|
101
|
+
if (redirects > 10) return reject(new Error('too many redirects'))
|
|
102
|
+
get(u, (res) => {
|
|
103
|
+
if (
|
|
104
|
+
res.statusCode &&
|
|
105
|
+
res.statusCode >= 300 &&
|
|
106
|
+
res.statusCode < 400 &&
|
|
107
|
+
res.headers.location
|
|
108
|
+
) {
|
|
109
|
+
res.resume()
|
|
110
|
+
const next = new URL(res.headers.location, u).toString()
|
|
111
|
+
return followGet(next, redirects + 1)
|
|
112
|
+
}
|
|
113
|
+
if (res.statusCode !== 200) {
|
|
114
|
+
res.resume()
|
|
115
|
+
return reject(new Error(`HTTP ${res.statusCode} for ${u}`))
|
|
116
|
+
}
|
|
117
|
+
const out = createWriteStream(dest)
|
|
118
|
+
res.pipe(out)
|
|
119
|
+
out.on('finish', () => out.close(resolve))
|
|
120
|
+
out.on('error', reject)
|
|
121
|
+
}).on('error', reject)
|
|
122
|
+
}
|
|
123
|
+
followGet(url, 0)
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function writeFileAtomic(dest, buf) {
|
|
128
|
+
const tmp = `${dest}.tmp`
|
|
129
|
+
await new Promise((resolve, reject) => {
|
|
130
|
+
const out = createWriteStream(tmp)
|
|
131
|
+
out.on('error', reject)
|
|
132
|
+
out.on('finish', resolve)
|
|
133
|
+
out.end(buf)
|
|
134
|
+
})
|
|
135
|
+
await rename(tmp, dest)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function sha256File(file) {
|
|
139
|
+
return new Promise((resolve, reject) => {
|
|
140
|
+
const hash = createHash('sha256')
|
|
141
|
+
const s = createReadStream(file)
|
|
142
|
+
s.on('error', reject)
|
|
143
|
+
s.on('data', (d) => hash.update(d))
|
|
144
|
+
s.on('end', () => resolve(hash.digest('hex')))
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Extract the single binary from the .tar.gz using the platform `tar`, which is
|
|
149
|
+
// present on macOS and Linux (the only platforms we ship). Avoids adding a tar
|
|
150
|
+
// dependency to the package.
|
|
151
|
+
function extract(tarball, destDir) {
|
|
152
|
+
const res = spawnSync('tar', ['-xzf', tarball, '-C', destDir], {
|
|
153
|
+
stdio: 'inherit',
|
|
154
|
+
})
|
|
155
|
+
if (res.error) throw res.error
|
|
156
|
+
if (res.status !== 0) throw new Error(`tar exited ${res.status}`)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Deploy the agent skills bundled in the binary, mirroring install.sh: invoke
|
|
160
|
+
// the freshly installed binary's `skills install`, honouring AIRI_CLI_SKIP_SKILLS.
|
|
161
|
+
// Best-effort — a failure warns but never fails the install (the user can
|
|
162
|
+
// re-run `airi-cli skills install`).
|
|
163
|
+
function deploySkills(bin) {
|
|
164
|
+
if (process.env.AIRI_CLI_SKIP_SKILLS === '1') {
|
|
165
|
+
warn('AIRI_CLI_SKIP_SKILLS=1 — skipping skill deployment.')
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
log('deploying agent skills')
|
|
169
|
+
// stdio inherited so the CLI's own output — including the target directories
|
|
170
|
+
// it installed into — flows through to the npm install log.
|
|
171
|
+
const res = spawnSync(
|
|
172
|
+
bin,
|
|
173
|
+
['--no-telemetry', '-o', 'text', 'skills', 'install'],
|
|
174
|
+
{ stdio: 'inherit' }
|
|
175
|
+
)
|
|
176
|
+
if (res.error || res.status !== 0) {
|
|
177
|
+
warn(`skill deployment failed; retry later with: ${BIN_NAME} skills install`)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function main() {
|
|
182
|
+
let platform
|
|
183
|
+
try {
|
|
184
|
+
platform = detectPlatform()
|
|
185
|
+
} catch (err) {
|
|
186
|
+
// Unsupported platform: warn, don't fail the whole install.
|
|
187
|
+
warn(err.message)
|
|
188
|
+
warn("the CLI will not run on this platform.")
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const tag = await resolveTag()
|
|
193
|
+
const asset = `${BIN_NAME}_${platform}.tar.gz`
|
|
194
|
+
// AIRI_CLI_BASE_URL overrides the release host for testing and for mirrors
|
|
195
|
+
// (e.g. an internal proxy of the GitHub assets). It must point at a location
|
|
196
|
+
// serving the same <tag>/<asset> layout. Defaults to GitHub.
|
|
197
|
+
const host = (process.env.AIRI_CLI_BASE_URL || `https://github.com/${REPO}/releases/download`).replace(/\/+$/, '')
|
|
198
|
+
const base = `${host}/${tag}/${asset}`
|
|
199
|
+
|
|
200
|
+
const skipChecksum = process.env.AIRI_CLI_SKIP_CHECKSUM === '1'
|
|
201
|
+
const expected = await pinnedChecksum(platform)
|
|
202
|
+
|
|
203
|
+
// Fail closed BEFORE downloading: if we have no pinned digest to verify
|
|
204
|
+
// against and the caller has not explicitly opted out, do not install an
|
|
205
|
+
// unverified binary. (A non-release build has empty checksums.json entries.)
|
|
206
|
+
if (!skipChecksum && !expected) {
|
|
207
|
+
warn(
|
|
208
|
+
`no pinned SHA256 for ${platform} in this package — refusing to install ` +
|
|
209
|
+
`an unverified binary.`
|
|
210
|
+
)
|
|
211
|
+
warn(
|
|
212
|
+
`this usually means the package was not built by the release pipeline. ` +
|
|
213
|
+
`Install a released version, or set AIRI_CLI_SKIP_CHECKSUM=1 to bypass ` +
|
|
214
|
+
`verification (local development only).`
|
|
215
|
+
)
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Per-platform temp dir with a random suffix (mkdtemp), not a predictable
|
|
220
|
+
// pid-based path: prevents a pre-planted symlink/file at a known location
|
|
221
|
+
// from being followed during the install.
|
|
222
|
+
const tmp = await mkdtemp(path.join(tmpdir(), 'airi-cli-'))
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
log(`downloading ${BIN_NAME} ${tag} (${platform})`)
|
|
226
|
+
const tarball = path.join(tmp, asset)
|
|
227
|
+
await download(base, tarball)
|
|
228
|
+
|
|
229
|
+
if (skipChecksum) {
|
|
230
|
+
warn('AIRI_CLI_SKIP_CHECKSUM=1 — skipping SHA256 verification.')
|
|
231
|
+
} else {
|
|
232
|
+
const actual = (await sha256File(tarball)).toLowerCase()
|
|
233
|
+
if (expected !== actual) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`SHA256 mismatch for ${asset}\n expected: ${expected}\n actual: ${actual}`
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
log('checksum OK')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const dir = binDir()
|
|
242
|
+
await mkdir(dir, { recursive: true })
|
|
243
|
+
extract(tarball, tmp)
|
|
244
|
+
const extracted = path.join(tmp, BIN_NAME)
|
|
245
|
+
if (!existsSync(extracted)) {
|
|
246
|
+
throw new Error(`archive did not contain expected '${BIN_NAME}' binary`)
|
|
247
|
+
}
|
|
248
|
+
const finalPath = binPath()
|
|
249
|
+
// Stage the new binary INSIDE the vendor dir (same filesystem as finalPath)
|
|
250
|
+
// and rename it into place. Renaming within one filesystem is atomic and
|
|
251
|
+
// avoids EXDEV (which a rename from os.tmpdir() across filesystems would
|
|
252
|
+
// hit) — and because the staged copy is fully in place and executable
|
|
253
|
+
// before the swap, a failure never leaves the old working binary deleted
|
|
254
|
+
// with no replacement.
|
|
255
|
+
const staged = path.join(dir, `.${BIN_NAME}.new`)
|
|
256
|
+
await rm(staged, { force: true })
|
|
257
|
+
await rename(extracted, staged)
|
|
258
|
+
await chmod(staged, 0o755)
|
|
259
|
+
await rename(staged, finalPath)
|
|
260
|
+
log(`installed ${BIN_NAME} ${tag}`)
|
|
261
|
+
|
|
262
|
+
// Only after the binary is verified and in place, so skills are never
|
|
263
|
+
// deployed from a half-installed or unverified CLI.
|
|
264
|
+
deploySkills(finalPath)
|
|
265
|
+
} catch (err) {
|
|
266
|
+
warn(`failed to download the airi-cli binary: ${err.message}`)
|
|
267
|
+
warn(
|
|
268
|
+
`you can retry by reinstalling the package, or set a specific version ` +
|
|
269
|
+
`with AIRI_CLI_VERSION. The binary is also available from:`
|
|
270
|
+
)
|
|
271
|
+
warn(` https://github.com/${REPO}/releases/tag/${tag}`)
|
|
272
|
+
// Do not exit non-zero: a transient failure must not abort a larger
|
|
273
|
+
// `npm install`. The launcher surfaces the same guidance if run.
|
|
274
|
+
} finally {
|
|
275
|
+
await rm(tmp, { recursive: true, force: true })
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
main().catch((err) => {
|
|
280
|
+
warn(`unexpected error: ${err && err.message ? err.message : err}`)
|
|
281
|
+
// Still never fail the install.
|
|
282
|
+
})
|