@coswic/aipay 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/README.md +47 -10
- package/dist/agent-guide.d.ts +2 -2
- package/dist/agent-guide.js +12 -7
- package/dist/callback-url.d.ts +3 -0
- package/dist/callback-url.js +14 -0
- package/dist/commands/app-callbacks.d.ts +3 -0
- package/dist/commands/app-callbacks.js +304 -0
- package/dist/commands/apps.js +10 -1
- package/dist/commands/doctor.d.ts +1 -2
- package/dist/commands/doctor.js +9 -17
- package/dist/commands/init.js +13 -15
- package/dist/commands/login.d.ts +1 -1
- package/dist/commands/login.js +9 -11
- package/dist/commands/session.js +2 -3
- package/dist/commands/test.js +16 -10
- package/dist/developer-api.d.ts +2 -1
- package/dist/developer-api.js +10 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/loopback.js +3 -0
- package/dist/main.d.ts +1 -1
- package/dist/main.js +5 -3
- package/dist/network.d.ts +3 -0
- package/dist/network.js +5 -0
- package/dist/project.js +1 -2
- package/dist/remote.d.ts +6 -6
- package/dist/remote.js +10 -10
- package/dist/setup-events.js +1 -1
- package/package.json +5 -4
- package/dist/gate.d.ts +0 -13
- package/dist/gate.js +0 -29
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# @coswic/aipay
|
|
2
|
+
|
|
3
|
+
## 0.5.0 — 2026-09-08
|
|
4
|
+
|
|
5
|
+
- Add `apps callbacks list/add/remove` with the developer’s own management authorization and linked-project defaults.
|
|
6
|
+
- Preserve other registered callbacks, make no-op changes idempotent, and refuse to remove the final callback.
|
|
7
|
+
- Require server ETag support for mutations; concurrent edits and uncertain outcomes require readback instead of blind retries.
|
|
8
|
+
- Guide agents through developer CLI login and callback management; local `init` remains distinct from server registration.
|
|
9
|
+
- Allow HTTPS application callbacks in `init` and `doctor`; `test --callback` can use a separately registered loopback callback without changing project settings.
|
|
10
|
+
- Bound developer API requests to 30 seconds and allow conditional mutations to disable automatic retries after a 401.
|
|
11
|
+
|
|
12
|
+
## 0.4.0 — 2026-09-08
|
|
13
|
+
|
|
14
|
+
- Simplify setup instructions around standard OAuth and HTTPS connectivity.
|
|
15
|
+
- Report empty 404 responses without inferring their cause.
|
|
16
|
+
- Remove the retired `AIPAY_GATE_TOKEN` setting and automatic `X-AIPay-Gate` header injection. OAuth tokens, PKCE, device authorization, refresh and revocation retain their existing behavior.
|
|
17
|
+
- **Programmatic compatibility:** remove the `GATE_ENV`, `gatedFetch`, `gateHeaders` and `looksGated` exports; use `isEmpty404` to identify an empty 404 response. Remove the gate-only environment arguments from `createRemote`, `fetchCliConfig`, `openDeveloperApi` and the doctor dependency interface. `Remote.fetchDocs`/`probe` results and init JSON use `empty404` instead of `gated`; the corresponding error code is `empty_404`.
|
|
18
|
+
|
|
19
|
+
## 0.3.0
|
|
20
|
+
|
|
21
|
+
- `aipay init --framework next|express|fastify|hono|vite|node` overrides framework detection.
|
|
22
|
+
- Vite (browser SPA) projects: `init` writes a browser helper (`aipay.ts`, PKCE in the page,
|
|
23
|
+
tokens in sessionStorage) plus `aipay-boot.ts`, and `VITE_AIPAY_*` aliases in `.env.local`
|
|
24
|
+
(Vite exposes only VITE_-prefixed values). Server frameworks still win when both are present.
|
|
25
|
+
- `aipay test` reports failures to the Console too (`cli.test` with `ok:false` and an error
|
|
26
|
+
code), so the verify step can show what went wrong.
|
|
27
|
+
|
|
28
|
+
## 0.2.1
|
|
29
|
+
|
|
30
|
+
Same content as 0.2.0. Version 0.2.0 on npm was published by mistake without `dist` (the
|
|
31
|
+
tarball only had README and package.json); it is deprecated and `latest` points past it.
|
|
32
|
+
Install 0.2.1 or later.
|
|
33
|
+
|
|
34
|
+
## 0.2.0
|
|
35
|
+
|
|
36
|
+
- `aipay login --device`: RFC 8628 device authorization for machines without a browser (SSH
|
|
37
|
+
boxes, containers, CI). Prints a URL and a short code; approve on any device; the CLI polls.
|
|
38
|
+
Requires an AI-Pay whose `/v1/cli/config` reports `device_authorization: true`.
|
|
39
|
+
- Windows: developer tokens are now protected with DPAPI (Windows PowerShell
|
|
40
|
+
`ConvertFrom-SecureString`) in `%APPDATA%\aipay\credentials.dpapi` instead of a plaintext
|
|
41
|
+
`credentials.json`. An existing plaintext file is read once and replaced on the next login.
|
|
42
|
+
`AIPAY_TOKEN_STORE=file` keeps the old behaviour.
|
|
43
|
+
- `aipay login` (browser flow) mentions `--device` when the server supports it.
|
|
44
|
+
|
|
45
|
+
## 0.1.0
|
|
46
|
+
|
|
47
|
+
- First release: `init`, `test`, `doctor`, `status`, `unlink`, `login`/`logout`/`whoami`,
|
|
48
|
+
`apps list`/`apps create`; loopback PKCE on any free port; tokens in the OS keychain or a
|
|
49
|
+
0600 file; setup progress reported to the Console.
|
package/README.md
CHANGED
|
@@ -4,7 +4,8 @@ The command-line companion to [`@coswic/aipay-sdk`](https://www.npmjs.com/packag
|
|
|
4
4
|
Made for developers and for the coding agents they hand the Console prompt to.
|
|
5
5
|
|
|
6
6
|
```bash
|
|
7
|
-
npx @coswic/aipay
|
|
7
|
+
npx @coswic/aipay@latest login --api <api base>
|
|
8
|
+
npx @coswic/aipay@latest init --app <client_id> --auth <authorization server> --api <api base>
|
|
8
9
|
npx @coswic/aipay test
|
|
9
10
|
```
|
|
10
11
|
|
|
@@ -12,6 +13,11 @@ npx @coswic/aipay test
|
|
|
12
13
|
|
|
13
14
|
| Command | What it does |
|
|
14
15
|
|---|---|
|
|
16
|
+
| `aipay login` / `whoami` / `logout` | Sign in as the developer to manage your Apps; on a remote machine use `login --device`. This is separate from an end user authorizing AI spending in your App. |
|
|
17
|
+
| `aipay apps list` / `apps create --name <name>` | List or create Apps using your developer authorization. |
|
|
18
|
+
| `aipay apps callbacks list --app <client_id>` | Read the registered callback URLs. In a linked project, `--app` can be omitted; `--dir` selects the project. |
|
|
19
|
+
| `aipay apps callbacks add --app <client_id> --callback <url>` | Add a registered callback while preserving the others. Adding an existing URL makes no write. |
|
|
20
|
+
| `aipay apps callbacks remove --app <client_id> --callback <url>` | Remove one callback; removing an absent URL makes no write. The final callback cannot be removed. |
|
|
15
21
|
| `aipay init` | Links the project to your app: writes `.aipay/project.json` (commit it), `.env.local` (public values only, git-ignored), a start/callback route for your framework (Next.js, Express, Fastify, Hono, plain Node) or a browser helper for a Vite SPA (`--framework` overrides detection), `aipay-agent.md` (the developer docs, fetched once, for your agent to read offline) and installs the SDK. Never overwrites a file it did not write this run. `--dry-run` shows the plan. |
|
|
16
22
|
| `aipay test` | The proof: a real sign-in through the AI-Pay authorization server, received by the CLI on a loopback callback, then one chat completion on that user's behalf. Prints the receipt (`request_id`, amount charged). The Console's verify step turns green from the server side. The authorization server ignores the port for `127.0.0.1` redirect URIs (RFC 8252), so the CLI binds any free port and your dev server can keep running; `--port <n>` forces one. `--no-open` prints the URL instead of opening a browser; `--model <id>` picks the model. |
|
|
17
23
|
| `aipay doctor` | Checks Node, the link, `.env.local`, the SDK install, and that the authorization server and API are reachable. `--offline` skips the network. |
|
|
@@ -22,6 +28,41 @@ Every command accepts `--json` (one JSON document on stdout) and `--dir <path>`.
|
|
|
22
28
|
|
|
23
29
|
Exit codes: `0` ok · `1` a problem you can fix locally · `2` usage · `3` network/auth.
|
|
24
30
|
|
|
31
|
+
## Registering a deployed callback
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx @coswic/aipay@latest whoami
|
|
35
|
+
# If signed out, or signed into another API environment:
|
|
36
|
+
npx @coswic/aipay@latest login --api https://api.example
|
|
37
|
+
npx @coswic/aipay@latest apps callbacks list --app <client_id>
|
|
38
|
+
npx @coswic/aipay@latest apps callbacks add --app <client_id> --callback https://yourapp.example/auth/aipay/callback
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`init` and `doctor` accept HTTPS application callbacks as well as HTTP loopback callbacks.
|
|
42
|
+
For a project configured with HTTPS, verify the hosted app in the browser or use
|
|
43
|
+
`aipay test --callback <registered-http-loopback-url-without-query>` without changing its configuration.
|
|
44
|
+
|
|
45
|
+
`init --callback` configures the project locally; it does not register the URL with the
|
|
46
|
+
server. Register the deployed callback with `apps callbacks add`, then use that same URL
|
|
47
|
+
in the app configuration. Keep the local callback for development. Callback commands
|
|
48
|
+
always use the API and authorization server associated with your developer login.
|
|
49
|
+
When `--app` is omitted, the linked project's `api_base` and `auth_origin` must match that
|
|
50
|
+
login; a mismatch blocks the command before any request, so sign in to the correct environment
|
|
51
|
+
first. An explicit `--app` uses the current login environment without consulting the project.
|
|
52
|
+
Human and JSON output include `api_base` and `auth_origin` for verification; check `whoami`
|
|
53
|
+
when switching environments.
|
|
54
|
+
|
|
55
|
+
Changes use the server's callback ETag in `If-Match`. If the server does not support this
|
|
56
|
+
precondition, the CLI refuses to change callbacks and asks for a server update. A concurrent
|
|
57
|
+
edit returns a conflict: list again before deciding whether to repeat your change. There
|
|
58
|
+
is no automatic write retry. After a timeout or an uncertain provider result, the CLI reads
|
|
59
|
+
back the list; readback can confirm the requested state, otherwise the outcome remains
|
|
60
|
+
unconfirmed. JSON output distinguishes success, no-op, conflict, and unknown outcome.
|
|
61
|
+
|
|
62
|
+
The platform's verification callback stays system-managed and is excluded from the editable
|
|
63
|
+
list. No client secret, owner cookie, or admin credential is needed: approve the normal CLI
|
|
64
|
+
developer login in your browser.
|
|
65
|
+
|
|
25
66
|
## Signing in from a remote machine
|
|
26
67
|
|
|
27
68
|
`aipay login` opens a browser on this machine (loopback PKCE). On an SSH box, a container or
|
|
@@ -37,16 +78,12 @@ own. Nothing is written to the project; tokens go to the keychain or the 0600 fi
|
|
|
37
78
|
like the browser flow. With `--json`, the URL and code are echoed on stderr as
|
|
38
79
|
`device_url …` / `user_code …` so an agent can show them to you.
|
|
39
80
|
|
|
40
|
-
##
|
|
41
|
-
|
|
42
|
-
During the closed pilot, non-public AI-Pay paths answer `404` with an empty body unless the
|
|
43
|
-
request carries the gate token. Export it and the CLI sends it as `X-AIPay-Gate`:
|
|
44
|
-
|
|
45
|
-
```bash
|
|
46
|
-
export AIPAY_GATE_TOKEN=<token from the Console>
|
|
47
|
-
```
|
|
81
|
+
## Connection troubleshooting
|
|
48
82
|
|
|
49
|
-
|
|
83
|
+
Check the configured API and authorization server URLs and network connectivity if a
|
|
84
|
+
request fails. An empty `404` alone does not identify the cause; it does not establish
|
|
85
|
+
whether a path is missing or why the server returned that response. Run `aipay doctor`
|
|
86
|
+
to check the configured endpoints before retrying.
|
|
50
87
|
|
|
51
88
|
## What gets written
|
|
52
89
|
|
package/dist/agent-guide.d.ts
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* `aipay-agent.md`: the file a coding agent reads first. Header = the facts of THIS
|
|
3
3
|
* project (app, endpoints, what was scaffolded, what "done" means); body = the AI-Pay
|
|
4
4
|
* developer docs fetched from the API (`/docs.md?lang=en`), so the agent does not need
|
|
5
|
-
* network access
|
|
5
|
+
* network access to read them later.
|
|
6
6
|
*
|
|
7
|
-
* Never contains tokens. If the docs
|
|
7
|
+
* Never contains tokens. If the docs cannot be fetched, the file still gets
|
|
8
8
|
* written with the header and a pointer, and the CLI warns.
|
|
9
9
|
*/
|
|
10
10
|
import type { ProjectConfig } from './project.js';
|
package/dist/agent-guide.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export const AGENT_GUIDE_FILE = 'aipay-agent.md';
|
|
2
2
|
export function agentGuide(input) {
|
|
3
3
|
const p = input.project;
|
|
4
|
+
const callback = new URL(p.callback);
|
|
5
|
+
const verification = callback.protocol === 'https:' || callback.search
|
|
6
|
+
? 'Sign in through the running app and make one chat completion whose response carries a receipt (`aipay.request_id`). Alternatively, use `aipay test --callback <registered-http-loopback-url>` with an already registered HTTP loopback URL without a query; leave the project callback unchanged.'
|
|
7
|
+
: '`aipay test` completes: a real sign-in through the AI-Pay authorization server and one chat completion whose response carries a receipt (`aipay.request_id`).';
|
|
4
8
|
const files = input.scaffolded.length
|
|
5
9
|
? input.scaffolded.map((f) => `- \`${f}\``).join('\n')
|
|
6
10
|
: '- (none — the project had them already)';
|
|
@@ -16,7 +20,7 @@ export function agentGuide(input) {
|
|
|
16
20
|
| client_id | \`${p.app.client_id}\` (public client, PKCE S256, no secret) |
|
|
17
21
|
| Authorization server | ${p.auth_origin} |
|
|
18
22
|
| API base | ${p.api_base} |
|
|
19
|
-
|
|
|
23
|
+
| Configured callback (register with CLI before use) | \`${p.callback}\` (for 127.0.0.1 the port is not compared, so any local port works) |
|
|
20
24
|
| Local config | \`.aipay/project.json\` (commit it), \`.env.local\` (do not commit) |
|
|
21
25
|
|
|
22
26
|
Files scaffolded by the CLI (edit freely; the CLI never overwrites existing files):
|
|
@@ -26,15 +30,16 @@ ${files}
|
|
|
26
30
|
|
|
27
31
|
1. \`aipay doctor\` reports no problems.
|
|
28
32
|
2. The app's dev server serves the start route and the callback route above.
|
|
29
|
-
3.
|
|
30
|
-
one chat completion whose response carries a receipt (\`aipay.request_id\`).
|
|
33
|
+
3. ${verification}
|
|
31
34
|
4. Typecheck/build pass. Do not commit automatically and do not deploy.
|
|
32
35
|
|
|
33
36
|
## Rules for agents
|
|
34
37
|
|
|
35
38
|
- Read the docs below before changing the integration; they are the source of truth.
|
|
36
|
-
-
|
|
37
|
-
|
|
39
|
+
- Use standard OAuth sign-in and app authorization.
|
|
40
|
+
- Manage App settings with the developer’s own CLI authorization: check \`aipay whoami\`, then run \`aipay login --api ${p.api_base}\` if needed (add \`--device\` on a remote machine). Ask the user to approve the CLI login; never substitute another App’s token.
|
|
41
|
+
- Use \`aipay apps callbacks list|add|remove --app ${p.app.client_id}\` to manage registered URLs. \`init --callback\` only writes local configuration; it does not register a URL. Preserve existing callbacks; on a conflict or unknown outcome, read the current list before acting again.
|
|
42
|
+
- Keep OAuth tokens out of code, config files, and version control.
|
|
38
43
|
- Never request \`offline_access\` unless the product needs background renewal.
|
|
39
44
|
- Key user accounts on \`sub\` from the userinfo endpoint, never on email.
|
|
40
45
|
|
|
@@ -53,8 +58,8 @@ ${input.docsMarkdown.trim()}
|
|
|
53
58
|
# AI-Pay developer documentation
|
|
54
59
|
|
|
55
60
|
The docs could not be fetched when this file was written (see the \`aipay init\` output).
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
Check the endpoint URL and network connectivity, then read
|
|
62
|
+
${input.docsUrl} or run \`aipay init\` again. An empty 404 alone does not identify the cause.
|
|
58
63
|
`;
|
|
59
64
|
return head + body;
|
|
60
65
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function hasUnsafeCallbackText(value) {
|
|
2
|
+
return (/[\s*]/.test(value) ||
|
|
3
|
+
[...value].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127));
|
|
4
|
+
}
|
|
5
|
+
/** Application callbacks may use HTTPS; CLI test receivers remain HTTP loopback. */
|
|
6
|
+
export function parseAppCallback(callback) {
|
|
7
|
+
if (!/^https?:\/\//.test(callback) || hasUnsafeCallbackText(callback))
|
|
8
|
+
throw new Error('callback must be an exact URL without whitespace, control characters or wildcards');
|
|
9
|
+
const url = new URL(callback);
|
|
10
|
+
const loopback = url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname);
|
|
11
|
+
if ((!loopback && url.protocol !== 'https:') || url.username || url.password || url.hash)
|
|
12
|
+
throw new Error('callback must be HTTPS or HTTP on 127.0.0.1/localhost, without credentials or a fragment');
|
|
13
|
+
return url;
|
|
14
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/** Owner-managed callback changes use one conditional PUT and readback, never a blind retry. */
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { flagString } from '../args.js';
|
|
4
|
+
import { createAuthStore } from '../auth-store.js';
|
|
5
|
+
import { hasUnsafeCallbackText } from '../callback-url.js';
|
|
6
|
+
import { openDeveloperApi } from '../developer-api.js';
|
|
7
|
+
import { CliError, EXIT } from '../output.js';
|
|
8
|
+
import { readProject } from '../project.js';
|
|
9
|
+
class CallbackError extends CliError {
|
|
10
|
+
publicError;
|
|
11
|
+
constructor(publicError, exitCode = EXIT.remote) {
|
|
12
|
+
super(publicError.code, publicError.message, exitCode);
|
|
13
|
+
this.publicError = publicError;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function record(value) {
|
|
17
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
18
|
+
? value
|
|
19
|
+
: {};
|
|
20
|
+
}
|
|
21
|
+
function remoteError(status, body) {
|
|
22
|
+
const outer = record(body);
|
|
23
|
+
const nested = record(outer.error);
|
|
24
|
+
const error = Object.keys(nested).length ? nested : outer;
|
|
25
|
+
const details = record(error.details);
|
|
26
|
+
const safe = {};
|
|
27
|
+
for (const key of ['outcome', 'retryable', 'readback_required', 'current_redirect_uris_etag'])
|
|
28
|
+
if (typeof details[key] === 'string' || typeof details[key] === 'boolean')
|
|
29
|
+
safe[key] = details[key];
|
|
30
|
+
if (Array.isArray(details.current_redirect_uris) &&
|
|
31
|
+
details.current_redirect_uris.every((u) => typeof u === 'string'))
|
|
32
|
+
safe.current_redirect_uris = details.current_redirect_uris;
|
|
33
|
+
const code = typeof error.code === 'string'
|
|
34
|
+
? error.code
|
|
35
|
+
: typeof outer.error === 'string'
|
|
36
|
+
? outer.error
|
|
37
|
+
: 'remote';
|
|
38
|
+
return {
|
|
39
|
+
code,
|
|
40
|
+
status,
|
|
41
|
+
message: status === 401
|
|
42
|
+
? 'The developer authorization was rejected. Run aipay login before trying again.'
|
|
43
|
+
: status === 409 || status === 412
|
|
44
|
+
? 'The callback list changed or another update is in progress. Run aipay apps callbacks list before making another change; this update was not retried.'
|
|
45
|
+
: `Callback management request failed (HTTP ${status}).`,
|
|
46
|
+
...(typeof error.request_id === 'string' ? { request_id: error.request_id } : {}),
|
|
47
|
+
...(typeof details.outcome === 'string' ? { outcome: details.outcome } : {}),
|
|
48
|
+
...(Object.keys(safe).length ? { details: safe } : {}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function thrownError(error) {
|
|
52
|
+
return error instanceof CallbackError
|
|
53
|
+
? error.publicError
|
|
54
|
+
: error instanceof CliError
|
|
55
|
+
? { code: error.code, message: error.message }
|
|
56
|
+
: { code: 'network_error', message: 'Could not confirm the callback request with AI-Pay.' };
|
|
57
|
+
}
|
|
58
|
+
/** Preserve exact URI strings: OAuth callback matching is not URL normalization. */
|
|
59
|
+
function validateCallback(value) {
|
|
60
|
+
try {
|
|
61
|
+
const url = new URL(value);
|
|
62
|
+
if (!/^https?:\/\//.test(value) ||
|
|
63
|
+
hasUnsafeCallbackText(value) ||
|
|
64
|
+
url.username ||
|
|
65
|
+
url.password ||
|
|
66
|
+
url.hash ||
|
|
67
|
+
(url.protocol !== 'https:' &&
|
|
68
|
+
!(url.protocol === 'http:' &&
|
|
69
|
+
(url.hostname === '127.0.0.1' || url.hostname === 'localhost'))))
|
|
70
|
+
throw new Error();
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
throw new CliError('usage', '--callback must be an exact HTTPS URL or HTTP localhost/127.0.0.1 URL, without credentials, whitespace, control characters, a fragment or wildcard.', EXIT.usage);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function inputOf(args) {
|
|
77
|
+
const action = args.positionals[1];
|
|
78
|
+
if (!['list', 'add', 'remove'].includes(action ?? '') || args.positionals.length !== 2)
|
|
79
|
+
throw new CliError('usage', 'usage: aipay apps callbacks list|add|remove --app <id> [--callback <url>] [--dir <path>]', EXIT.usage);
|
|
80
|
+
for (const name of Object.keys(args.flags))
|
|
81
|
+
if (!['app', 'callback', 'dir', 'json', 'help'].includes(name))
|
|
82
|
+
throw new CliError('usage', `unknown callbacks flag --${name}`, EXIT.usage);
|
|
83
|
+
for (const name of ['app', 'callback', 'dir'])
|
|
84
|
+
if (name in args.flags && !flagString(args.flags, name))
|
|
85
|
+
throw new CliError('usage', `--${name} requires a value`, EXIT.usage);
|
|
86
|
+
const callback = flagString(args.flags, 'callback');
|
|
87
|
+
if (action === 'list' ? callback !== null : callback === null)
|
|
88
|
+
throw new CliError('usage', action === 'list' ? 'callbacks list does not accept --callback' : 'missing --callback <url>', EXIT.usage);
|
|
89
|
+
if (callback !== null)
|
|
90
|
+
validateCallback(callback);
|
|
91
|
+
let appId = flagString(args.flags, 'app');
|
|
92
|
+
let projectEnvironment;
|
|
93
|
+
if (!appId) {
|
|
94
|
+
try {
|
|
95
|
+
const project = readProject(resolve(flagString(args.flags, 'dir') ?? '.'));
|
|
96
|
+
appId = project?.app.client_id ?? null;
|
|
97
|
+
if (project)
|
|
98
|
+
projectEnvironment = { api_base: project.api_base, auth_origin: project.auth_origin };
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
throw new CliError('invalid_project', 'Cannot read a valid .aipay/project.json; pass --app <id> explicitly.', EXIT.usage);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!appId || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(appId))
|
|
105
|
+
throw new CliError('usage', 'Provide --app <app/client UUID> or a linked project with --dir <path>.', EXIT.usage);
|
|
106
|
+
return {
|
|
107
|
+
action: action,
|
|
108
|
+
appId,
|
|
109
|
+
callback,
|
|
110
|
+
...(projectEnvironment ? { projectEnvironment } : {}),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** Compare endpoints without changing either the credential destination or project files. */
|
|
114
|
+
function checkEnvironment(project, auth) {
|
|
115
|
+
if (!project)
|
|
116
|
+
return;
|
|
117
|
+
const normalized = (value) => {
|
|
118
|
+
try {
|
|
119
|
+
const url = new URL(value);
|
|
120
|
+
if (!['https:', 'http:'].includes(url.protocol) ||
|
|
121
|
+
url.username ||
|
|
122
|
+
url.password ||
|
|
123
|
+
url.search ||
|
|
124
|
+
url.hash)
|
|
125
|
+
return null;
|
|
126
|
+
return url.href.replace(/\/$/, '');
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
for (const key of ['api_base', 'auth_origin']) {
|
|
133
|
+
const expected = normalized(project[key]);
|
|
134
|
+
if (!expected || expected !== normalized(auth[key]))
|
|
135
|
+
throw new CallbackError({
|
|
136
|
+
code: 'environment_mismatch',
|
|
137
|
+
message: 'The linked project belongs to a different AI-Pay environment. Sign in to the correct environment first; use --app explicitly only to manage an app in the current login environment.',
|
|
138
|
+
outcome: 'not_attempted',
|
|
139
|
+
}, EXIT.problem);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async function readCallbacks(api, path, appId) {
|
|
143
|
+
const response = await api.request('GET', `${path}/oauth-client`);
|
|
144
|
+
if (response.status !== 200)
|
|
145
|
+
throw new CallbackError(remoteError(response.status, response.body));
|
|
146
|
+
const body = record(response.body);
|
|
147
|
+
if (!Array.isArray(body.redirect_uris) ||
|
|
148
|
+
!body.redirect_uris.every((u) => typeof u === 'string') ||
|
|
149
|
+
(body.app_id !== undefined && body.app_id !== appId) ||
|
|
150
|
+
(body.client_id !== undefined && body.client_id !== appId))
|
|
151
|
+
throw new CallbackError({
|
|
152
|
+
code: 'invalid_response',
|
|
153
|
+
message: 'AI-Pay returned an invalid callback list.',
|
|
154
|
+
});
|
|
155
|
+
const system = typeof body.test_callback_uri === 'string' ? body.test_callback_uri : undefined;
|
|
156
|
+
return {
|
|
157
|
+
redirect_uris: body.redirect_uris.filter((u) => u !== system),
|
|
158
|
+
...(typeof body.redirect_uris_etag === 'string'
|
|
159
|
+
? { redirect_uris_etag: body.redirect_uris_etag }
|
|
160
|
+
: {}),
|
|
161
|
+
...(system !== undefined ? { test_callback_uri: system } : {}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function sameUris(left, right) {
|
|
165
|
+
const expected = new Set(left);
|
|
166
|
+
const actual = new Set(right);
|
|
167
|
+
return expected.size === actual.size && [...expected].every((uri) => actual.has(uri));
|
|
168
|
+
}
|
|
169
|
+
async function execute(api, input) {
|
|
170
|
+
const path = `/v1/developer/apps/${encodeURIComponent(input.appId)}`;
|
|
171
|
+
const before = await readCallbacks(api, path, input.appId);
|
|
172
|
+
const base = { app_id: input.appId, action: input.action };
|
|
173
|
+
if (input.action === 'list')
|
|
174
|
+
return { ok: true, ...base, ...before };
|
|
175
|
+
if (!before.redirect_uris_etag ||
|
|
176
|
+
!/^"redirect-uris-[0-9a-f]{64}"$/.test(before.redirect_uris_etag))
|
|
177
|
+
throw new CallbackError({
|
|
178
|
+
code: 'conditional_update_unavailable',
|
|
179
|
+
message: 'This AI-Pay service does not provide a supported callback version. Update the service before changing callbacks; callbacks list remains available.',
|
|
180
|
+
outcome: 'not_attempted',
|
|
181
|
+
}, EXIT.problem);
|
|
182
|
+
if (input.callback === before.test_callback_uri)
|
|
183
|
+
throw new CallbackError({
|
|
184
|
+
code: 'system_callback',
|
|
185
|
+
message: 'This callback is managed by AI-Pay and cannot be added or removed.',
|
|
186
|
+
outcome: 'not_attempted',
|
|
187
|
+
}, EXIT.problem);
|
|
188
|
+
const present = before.redirect_uris.includes(input.callback);
|
|
189
|
+
if ((input.action === 'add' && present) || (input.action === 'remove' && !present))
|
|
190
|
+
return { ok: true, ...base, ...before, changed: false, outcome: 'unchanged' };
|
|
191
|
+
const desired = input.action === 'add'
|
|
192
|
+
? [...before.redirect_uris, input.callback]
|
|
193
|
+
: before.redirect_uris.filter((uri) => uri !== input.callback);
|
|
194
|
+
if (!desired.length)
|
|
195
|
+
throw new CallbackError({
|
|
196
|
+
code: 'last_callback',
|
|
197
|
+
message: 'Keep at least one developer callback; the system callback does not count.',
|
|
198
|
+
outcome: 'not_attempted',
|
|
199
|
+
}, EXIT.problem);
|
|
200
|
+
let writeError;
|
|
201
|
+
try {
|
|
202
|
+
const response = await api.request('PUT', `${path}/redirect-uris`, {
|
|
203
|
+
body: { redirect_uris: desired },
|
|
204
|
+
headers: { 'if-match': before.redirect_uris_etag },
|
|
205
|
+
retryUnauthorized: false,
|
|
206
|
+
});
|
|
207
|
+
if (response.status < 200 || response.status >= 300) {
|
|
208
|
+
const error = remoteError(response.status, response.body);
|
|
209
|
+
if (response.status < 500)
|
|
210
|
+
throw new CallbackError({
|
|
211
|
+
...error,
|
|
212
|
+
outcome: response.status === 412 || response.status === 409 ? 'conflict' : 'rejected',
|
|
213
|
+
});
|
|
214
|
+
writeError = error;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
if (error instanceof CallbackError)
|
|
219
|
+
throw error;
|
|
220
|
+
const detail = thrownError(error);
|
|
221
|
+
if (['unauthorized', 'expired', 'not_logged_in'].includes(detail.code))
|
|
222
|
+
throw new CallbackError({ ...detail, outcome: 'rejected' });
|
|
223
|
+
writeError = detail;
|
|
224
|
+
}
|
|
225
|
+
let observed;
|
|
226
|
+
try {
|
|
227
|
+
observed = await readCallbacks(api, path, input.appId);
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
throw new CallbackError({
|
|
231
|
+
code: 'callback_update_unknown',
|
|
232
|
+
message: 'The callback update could not be verified. Run callbacks list before making another change; no update was retried.',
|
|
233
|
+
outcome: 'unknown',
|
|
234
|
+
...(typeof writeError?.request_id === 'string' ? { request_id: writeError.request_id } : {}),
|
|
235
|
+
...(writeError ? { write_error: writeError } : {}),
|
|
236
|
+
readback_error: thrownError(error),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
if (!sameUris(desired, observed.redirect_uris))
|
|
240
|
+
throw new CallbackError({
|
|
241
|
+
code: 'callback_update_unconfirmed',
|
|
242
|
+
message: 'The current callback list does not match the requested list. Review callbacks list before making another change; no update was retried.',
|
|
243
|
+
outcome: 'unknown',
|
|
244
|
+
...(typeof writeError?.request_id === 'string' ? { request_id: writeError.request_id } : {}),
|
|
245
|
+
...(writeError ? { write_error: writeError } : {}),
|
|
246
|
+
current_redirect_uris: observed.redirect_uris,
|
|
247
|
+
expected_redirect_uris: desired,
|
|
248
|
+
});
|
|
249
|
+
return {
|
|
250
|
+
ok: true,
|
|
251
|
+
...base,
|
|
252
|
+
...observed,
|
|
253
|
+
changed: true,
|
|
254
|
+
outcome: 'confirmed',
|
|
255
|
+
verified: true,
|
|
256
|
+
...(writeError ? { reconciled: true, write_error: writeError } : {}),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
export async function runCallbacks(args, out, env) {
|
|
260
|
+
// Reject malformed invocations before loading credentials or contacting AI-Pay.
|
|
261
|
+
const input = inputOf(args);
|
|
262
|
+
let environment;
|
|
263
|
+
try {
|
|
264
|
+
const store = createAuthStore(env);
|
|
265
|
+
const stored = store.load();
|
|
266
|
+
if (stored) {
|
|
267
|
+
environment = { api_base: stored.api_base, auth_origin: stored.auth_origin };
|
|
268
|
+
// Check before openDeveloperApi can renew an expired token.
|
|
269
|
+
checkEnvironment(input.projectEnvironment, environment);
|
|
270
|
+
}
|
|
271
|
+
const api = await openDeveloperApi(store);
|
|
272
|
+
environment = { api_base: api.auth.api_base, auth_origin: api.auth.auth_origin };
|
|
273
|
+
// A concurrent CLI login may have replaced the stored environment between reads.
|
|
274
|
+
checkEnvironment(input.projectEnvironment, environment);
|
|
275
|
+
const result = await execute(api, input);
|
|
276
|
+
out.line('ok', input.action === 'list'
|
|
277
|
+
? 'Registered developer callbacks'
|
|
278
|
+
: result.changed
|
|
279
|
+
? 'Requested callback list verified.'
|
|
280
|
+
: 'Callback list already matches; no update needed.');
|
|
281
|
+
for (const uri of result.redirect_uris)
|
|
282
|
+
out.line('info', uri);
|
|
283
|
+
out.kv('api_base', environment.api_base);
|
|
284
|
+
out.kv('auth_origin', environment.auth_origin);
|
|
285
|
+
out.result({ ...result, ...environment });
|
|
286
|
+
return EXIT.ok;
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
const detail = thrownError(error);
|
|
290
|
+
out.line('bad', detail.message);
|
|
291
|
+
if (environment) {
|
|
292
|
+
out.kv('api_base', environment.api_base);
|
|
293
|
+
out.kv('auth_origin', environment.auth_origin);
|
|
294
|
+
}
|
|
295
|
+
out.result({
|
|
296
|
+
ok: false,
|
|
297
|
+
app_id: input.appId,
|
|
298
|
+
action: input.action,
|
|
299
|
+
...environment,
|
|
300
|
+
error: detail,
|
|
301
|
+
});
|
|
302
|
+
return error instanceof CliError ? error.exitCode : EXIT.remote;
|
|
303
|
+
}
|
|
304
|
+
}
|
package/dist/commands/apps.js
CHANGED
|
@@ -10,6 +10,7 @@ import { flagString } from '../args.js';
|
|
|
10
10
|
import { createAuthStore } from '../auth-store.js';
|
|
11
11
|
import { errorMessage, openDeveloperApi } from '../developer-api.js';
|
|
12
12
|
import { CliError, EXIT } from '../output.js';
|
|
13
|
+
import { runCallbacks } from './app-callbacks.js';
|
|
13
14
|
import { DEFAULT_CALLBACK } from './init.js';
|
|
14
15
|
export async function createApp(api, input) {
|
|
15
16
|
const key = input.key ?? `aipay-cli-${randomUUID()}`;
|
|
@@ -23,8 +24,16 @@ export async function createApp(api, input) {
|
|
|
23
24
|
}
|
|
24
25
|
export async function runApps(args, out, env = process.env) {
|
|
25
26
|
const sub = args.positionals[0] ?? 'list';
|
|
27
|
+
if (sub === 'callbacks')
|
|
28
|
+
return runCallbacks(args, out, env);
|
|
29
|
+
if (sub !== 'list' && sub !== 'create')
|
|
30
|
+
throw new CliError('usage', `unknown apps subcommand "${sub}" (list | create | callbacks)`, EXIT.usage);
|
|
31
|
+
if (args.positionals.length > 1)
|
|
32
|
+
throw new CliError('usage', 'unexpected apps positional argument', EXIT.usage);
|
|
33
|
+
if (sub === 'create' && !flagString(args.flags, 'name'))
|
|
34
|
+
throw new CliError('usage', 'missing --name <app name>', EXIT.usage);
|
|
26
35
|
const store = createAuthStore(env);
|
|
27
|
-
const api = await openDeveloperApi(store
|
|
36
|
+
const api = await openDeveloperApi(store);
|
|
28
37
|
if (sub === 'list') {
|
|
29
38
|
const res = await api.request('GET', '/v1/developer/apps');
|
|
30
39
|
if (res.status !== 200)
|
|
@@ -8,10 +8,9 @@ export interface Check {
|
|
|
8
8
|
}
|
|
9
9
|
export interface DoctorDeps {
|
|
10
10
|
remote?: Remote;
|
|
11
|
-
env?: NodeJS.ProcessEnv;
|
|
12
11
|
nodeVersion?: string;
|
|
13
12
|
}
|
|
14
13
|
export declare function runDoctor(args: ParsedArgs, out: Reporter, deps?: DoctorDeps): Promise<number>;
|
|
15
14
|
export declare function collectChecks(dir: string, opts: {
|
|
16
15
|
offline: boolean;
|
|
17
|
-
}, deps: DoctorDeps
|
|
16
|
+
}, deps: DoctorDeps): Promise<Check[]>;
|
package/dist/commands/doctor.js
CHANGED
|
@@ -6,16 +6,14 @@
|
|
|
6
6
|
import { existsSync, readFileSync } from 'node:fs';
|
|
7
7
|
import { join, resolve } from 'node:path';
|
|
8
8
|
import { flagBool, flagString } from '../args.js';
|
|
9
|
+
import { parseAppCallback } from '../callback-url.js';
|
|
9
10
|
import { detectProject } from '../detect.js';
|
|
10
|
-
import { GATE_ENV } from '../gate.js';
|
|
11
|
-
import { parseCallback } from '../loopback.js';
|
|
12
11
|
import { EXIT } from '../output.js';
|
|
13
12
|
import { ENV_FILE, ENV_KEYS, readProject } from '../project.js';
|
|
14
|
-
import { createRemote, docsUrl,
|
|
13
|
+
import { createRemote, docsUrl, EMPTY_404_HINT } from '../remote.js';
|
|
15
14
|
export async function runDoctor(args, out, deps = {}) {
|
|
16
15
|
const dir = resolve(flagString(args.flags, 'dir') ?? '.');
|
|
17
|
-
const
|
|
18
|
-
const checks = await collectChecks(dir, { offline: flagBool(args.flags, 'offline') }, deps, env);
|
|
16
|
+
const checks = await collectChecks(dir, { offline: flagBool(args.flags, 'offline') }, deps);
|
|
19
17
|
for (const c of checks)
|
|
20
18
|
out.line(c.level, c.text);
|
|
21
19
|
const problems = checks.filter((c) => c.level === 'bad');
|
|
@@ -24,7 +22,7 @@ export async function runDoctor(args, out, deps = {}) {
|
|
|
24
22
|
out.result({ ok: problems.length === 0, dir, checks });
|
|
25
23
|
return problems.length ? EXIT.problem : EXIT.ok;
|
|
26
24
|
}
|
|
27
|
-
export async function collectChecks(dir, opts, deps
|
|
25
|
+
export async function collectChecks(dir, opts, deps) {
|
|
28
26
|
const checks = [];
|
|
29
27
|
const major = Number((deps.nodeVersion ?? process.versions.node).split('.')[0]);
|
|
30
28
|
checks.push(major >= 20
|
|
@@ -56,11 +54,11 @@ export async function collectChecks(dir, opts, deps, env) {
|
|
|
56
54
|
text: `linked to ${project.app.name ?? project.app.client_id} (.aipay/project.json)`,
|
|
57
55
|
});
|
|
58
56
|
try {
|
|
59
|
-
const t =
|
|
57
|
+
const t = parseAppCallback(project.callback);
|
|
60
58
|
checks.push({
|
|
61
59
|
id: 'callback',
|
|
62
60
|
level: 'ok',
|
|
63
|
-
text: `callback ${project.callback} (loopback, port ${t.port})`,
|
|
61
|
+
text: `callback ${project.callback} (${t.protocol === 'https:' ? 'HTTPS' : `loopback, port ${t.port || 80}`})`,
|
|
64
62
|
});
|
|
65
63
|
}
|
|
66
64
|
catch (e) {
|
|
@@ -114,14 +112,8 @@ export async function collectChecks(dir, opts, deps, env) {
|
|
|
114
112
|
if (!existsSync(join(dir, f)))
|
|
115
113
|
checks.push({ id: 'scaffold', level: 'warn', text: `scaffolded file removed: ${f}` });
|
|
116
114
|
}
|
|
117
|
-
if (!env[GATE_ENV])
|
|
118
|
-
checks.push({
|
|
119
|
-
id: 'gate',
|
|
120
|
-
level: 'info',
|
|
121
|
-
text: `${GATE_ENV} not set (fine after the pilot; required during it)`,
|
|
122
|
-
});
|
|
123
115
|
if (!opts.offline) {
|
|
124
|
-
const remote = deps.remote ?? createRemote(
|
|
116
|
+
const remote = deps.remote ?? createRemote();
|
|
125
117
|
const oidc = await remote.probe(new URL('/.well-known/openid-configuration', project.auth_origin).toString());
|
|
126
118
|
checks.push(oidc.ok
|
|
127
119
|
? {
|
|
@@ -137,8 +129,8 @@ export async function collectChecks(dir, opts, deps, env) {
|
|
|
137
129
|
const api = await remote.probe(docsUrl(project.api_base, 'en'));
|
|
138
130
|
if (api.ok)
|
|
139
131
|
checks.push({ id: 'api', level: 'ok', text: `API reachable (${project.api_base})` });
|
|
140
|
-
else if (api.
|
|
141
|
-
checks.push({ id: 'api', level: 'bad', text: `API ${project.api_base}: ${
|
|
132
|
+
else if (api.empty404)
|
|
133
|
+
checks.push({ id: 'api', level: 'bad', text: `API ${project.api_base}: ${EMPTY_404_HINT}` });
|
|
142
134
|
else
|
|
143
135
|
checks.push({
|
|
144
136
|
id: 'api',
|
package/dist/commands/init.js
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Links the project to an app and scaffolds the integration. Idempotent: rerunning
|
|
6
6
|
* updates .aipay/project.json + .env.local and never overwrites files it did not write
|
|
7
|
-
* this run.
|
|
8
|
-
* token, if any, is read from the environment for the docs fetch.
|
|
7
|
+
* this run. Linking an existing app uses public client info.
|
|
9
8
|
*/
|
|
10
9
|
import { spawnSync } from 'node:child_process';
|
|
11
10
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
@@ -13,13 +12,12 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
13
12
|
import { AGENT_GUIDE_FILE, agentGuide } from '../agent-guide.js';
|
|
14
13
|
import { flagBool, flagString } from '../args.js';
|
|
15
14
|
import { createAuthStore } from '../auth-store.js';
|
|
15
|
+
import { parseAppCallback } from '../callback-url.js';
|
|
16
16
|
import { detectProject, installCommand } from '../detect.js';
|
|
17
17
|
import { openDeveloperApi } from '../developer-api.js';
|
|
18
|
-
import { GATE_ENV } from '../gate.js';
|
|
19
|
-
import { parseCallback } from '../loopback.js';
|
|
20
18
|
import { CliError, EXIT } from '../output.js';
|
|
21
19
|
import { ENV_FILE, ENV_KEYS, FRAMEWORKS, isFramework, mergeEnvFile, mergeGitignore, readProject, VITE_ENV_KEYS, writeProject, } from '../project.js';
|
|
22
|
-
import { createRemote, docsUrl,
|
|
20
|
+
import { createRemote, docsUrl, EMPTY_404_HINT } from '../remote.js';
|
|
23
21
|
import { reportSetupEvent } from '../setup-events.js';
|
|
24
22
|
import { scaffoldFiles } from '../templates.js';
|
|
25
23
|
import { cliVersion } from '../version.js';
|
|
@@ -43,7 +41,7 @@ export async function runInit(args, out, deps = {}) {
|
|
|
43
41
|
throw new CliError('usage', 'pass either --app <client_id> or --create <name>, not both', EXIT.usage);
|
|
44
42
|
if (!login)
|
|
45
43
|
throw new CliError('not_logged_in', '--create needs a developer sign-in first: `aipay login --api <base>`', EXIT.problem);
|
|
46
|
-
const api = deps.developerApi ?? (await openDeveloperApi(createAuthStore(env)
|
|
44
|
+
const api = deps.developerApi ?? (await openDeveloperApi(createAuthStore(env)));
|
|
47
45
|
const created = await createApp(api, {
|
|
48
46
|
name: createName,
|
|
49
47
|
callback,
|
|
@@ -59,11 +57,11 @@ export async function runInit(args, out, deps = {}) {
|
|
|
59
57
|
throw new CliError('usage', `--app must be the app's client_id (a UUID), got "${clientId}"`, EXIT.usage);
|
|
60
58
|
const authOrigin = originOf(flagString(args.flags, 'auth') ?? existing?.auth_origin ?? login?.auth_origin ?? null, '--auth');
|
|
61
59
|
const apiBase = originOf(flagString(args.flags, 'api') ?? existing?.api_base ?? login?.api_base ?? null, '--api');
|
|
62
|
-
const target =
|
|
60
|
+
const target = parseAppCallback(callback);
|
|
63
61
|
const dryRun = flagBool(args.flags, 'dry-run');
|
|
64
62
|
const install = flagBool(args.flags, 'install', true);
|
|
65
63
|
const name = flagString(args.flags, 'name') ?? createdName ?? existing?.app.name ?? null;
|
|
66
|
-
const remote = deps.remote ?? createRemote(
|
|
64
|
+
const remote = deps.remote ?? createRemote();
|
|
67
65
|
const now = (deps.now ?? (() => new Date()))();
|
|
68
66
|
const det = detectProject(dir);
|
|
69
67
|
// --framework: detection can be wrong (monorepos, unusual layouts) — the developer/agent may
|
|
@@ -145,7 +143,7 @@ export async function runInit(args, out, deps = {}) {
|
|
|
145
143
|
framework: det.framework,
|
|
146
144
|
typescript: det.typescript,
|
|
147
145
|
srcDir: det.srcDir,
|
|
148
|
-
callbackPath: target.
|
|
146
|
+
callbackPath: target.pathname,
|
|
149
147
|
})) {
|
|
150
148
|
const abs = join(dir, f.path);
|
|
151
149
|
if (existsSync(abs))
|
|
@@ -162,8 +160,8 @@ export async function runInit(args, out, deps = {}) {
|
|
|
162
160
|
project.scaffold.files = Array.from(new Set([...project.scaffold.files, ...scaffolded]));
|
|
163
161
|
// 5. docs → agent guide
|
|
164
162
|
const docs = await remote.fetchDocs(apiBase, 'en');
|
|
165
|
-
if (docs.
|
|
166
|
-
out.line('warn',
|
|
163
|
+
if (docs.empty404)
|
|
164
|
+
out.line('warn', EMPTY_404_HINT);
|
|
167
165
|
else if (!docs.markdown)
|
|
168
166
|
out.line('warn', `docs fetch failed (${docs.status}); guide written without the docs body`);
|
|
169
167
|
const guidePath = join(dir, AGENT_GUIDE_FILE);
|
|
@@ -218,9 +216,9 @@ export async function runInit(args, out, deps = {}) {
|
|
|
218
216
|
}, out, env);
|
|
219
217
|
out.blank();
|
|
220
218
|
out.line('ok', dryRun ? 'dry run — nothing written' : `linked to app ${name ?? clientId}`);
|
|
221
|
-
out.line('info', '
|
|
222
|
-
|
|
223
|
-
|
|
219
|
+
out.line('info', target.protocol === 'https:' || target.search
|
|
220
|
+
? 'next: verify sign-in in the running app, or use `aipay test --callback <registered-http-loopback-url>` (without a query) to verify with a local receiver; project settings stay unchanged'
|
|
221
|
+
: 'next: run `aipay test` — it receives the callback on a free loopback port, so your dev server can keep running');
|
|
224
222
|
out.result({
|
|
225
223
|
ok: installExit === null || installExit === 0,
|
|
226
224
|
dry_run: dryRun,
|
|
@@ -229,7 +227,7 @@ export async function runInit(args, out, deps = {}) {
|
|
|
229
227
|
detection: det,
|
|
230
228
|
plan,
|
|
231
229
|
install: installCmd ? { command: installCmd, exit: installExit } : null,
|
|
232
|
-
docs: { fetched: !!docs.markdown,
|
|
230
|
+
docs: { fetched: !!docs.markdown, empty404: docs.empty404, status: docs.status },
|
|
233
231
|
reported,
|
|
234
232
|
});
|
|
235
233
|
return installExit === null || installExit === 0 ? EXIT.ok : EXIT.problem;
|
package/dist/commands/login.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ export interface LoginDeps {
|
|
|
21
21
|
fetchImpl?: typeof fetch;
|
|
22
22
|
sleep?: (ms: number) => Promise<void>;
|
|
23
23
|
}
|
|
24
|
-
export declare function fetchCliConfig(apiBase: string
|
|
24
|
+
export declare function fetchCliConfig(apiBase: string): Promise<CliConfig>;
|
|
25
25
|
/** Resolve the API base: flag → env → linked project → error. */
|
|
26
26
|
export declare function resolveApiBase(args: ParsedArgs, env: NodeJS.ProcessEnv, dir: string): string;
|
|
27
27
|
/** Authorization URL for the management login. The SDK's createConnectUrl fixes the
|
package/dist/commands/login.js
CHANGED
|
@@ -15,23 +15,23 @@ import { flagBool, flagString } from '../args.js';
|
|
|
15
15
|
import { createAuthStore } from '../auth-store.js';
|
|
16
16
|
import { openBrowser } from '../browser.js';
|
|
17
17
|
import { pollDeviceToken, requestDeviceCode } from '../device.js';
|
|
18
|
-
import { GATE_ENV, gatedFetch, looksGated } from '../gate.js';
|
|
19
18
|
import { startLoopback } from '../loopback.js';
|
|
19
|
+
import { isEmpty404 } from '../network.js';
|
|
20
20
|
import { CliError, EXIT } from '../output.js';
|
|
21
21
|
import { readProject } from '../project.js';
|
|
22
|
-
import {
|
|
23
|
-
export async function fetchCliConfig(apiBase
|
|
24
|
-
const f =
|
|
22
|
+
import { EMPTY_404_HINT } from '../remote.js';
|
|
23
|
+
export async function fetchCliConfig(apiBase) {
|
|
24
|
+
const f = fetch;
|
|
25
25
|
let res;
|
|
26
26
|
try {
|
|
27
27
|
res = await f(new URL('/v1/cli/config', apiBase));
|
|
28
28
|
}
|
|
29
29
|
catch (e) {
|
|
30
|
-
throw new CliError('network', `cannot reach ${apiBase}: ${e.message}
|
|
30
|
+
throw new CliError('network', `cannot reach ${apiBase}: ${e.message}. Check the endpoint URL and network connectivity, then retry.`, EXIT.remote);
|
|
31
31
|
}
|
|
32
32
|
const text = await res.text();
|
|
33
|
-
if (
|
|
34
|
-
throw new CliError('
|
|
33
|
+
if (isEmpty404(res.status, text))
|
|
34
|
+
throw new CliError('empty_404', EMPTY_404_HINT, EXIT.remote);
|
|
35
35
|
if (res.status === 404)
|
|
36
36
|
throw new CliError('cli_disabled', `${apiBase} does not offer CLI sign-in (developer management is not enabled there)`, EXIT.remote);
|
|
37
37
|
if (!res.ok)
|
|
@@ -91,9 +91,9 @@ export async function runLogin(args, out, deps = {}) {
|
|
|
91
91
|
const env = deps.env ?? process.env;
|
|
92
92
|
const dir = flagString(args.flags, 'dir') ?? '.';
|
|
93
93
|
const apiBase = resolveApiBase(args, env, dir);
|
|
94
|
-
const cfg = await fetchCliConfig(apiBase
|
|
94
|
+
const cfg = await fetchCliConfig(apiBase);
|
|
95
95
|
const timeoutS = Number(flagString(args.flags, 'timeout') ?? '300');
|
|
96
|
-
const fetchImpl = deps.fetchImpl ??
|
|
96
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
97
97
|
if (flagBool(args.flags, 'device', false))
|
|
98
98
|
return deviceLogin(cfg, apiBase, timeoutS, fetchImpl, env, out, deps);
|
|
99
99
|
const loop = await startLoopback({ host: '127.0.0.1', port: 0, path: cfg.redirect_path }, { timeoutMs: timeoutS * 1000 });
|
|
@@ -174,8 +174,6 @@ async function finishLogin(tokens, cfg, apiBase, fetchImpl, env, out) {
|
|
|
174
174
|
out.line('info', `tokens kept in ${store.where}${store.kind === 'file' ? ' — no keychain tool found; the file is readable only by you' : ''}`);
|
|
175
175
|
if (!tokens.refreshToken)
|
|
176
176
|
out.line('warn', 'no refresh token was granted; you will need to sign in again when this one expires');
|
|
177
|
-
if (!env[GATE_ENV])
|
|
178
|
-
out.line('info', `pilot: if the API answers 404 with an empty body later, export ${GATE_ENV}`);
|
|
179
177
|
out.result({
|
|
180
178
|
ok: true,
|
|
181
179
|
api_base: apiBase,
|
package/dist/commands/session.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { createAuthStore } from '../auth-store.js';
|
|
2
2
|
import { openDeveloperApi } from '../developer-api.js';
|
|
3
|
-
import { gatedFetch } from '../gate.js';
|
|
4
3
|
import { CliError, EXIT } from '../output.js';
|
|
5
4
|
export async function runWhoami(_args, out, env = process.env) {
|
|
6
5
|
const store = createAuthStore(env);
|
|
@@ -10,7 +9,7 @@ export async function runWhoami(_args, out, env = process.env) {
|
|
|
10
9
|
out.result({ signed_in: false });
|
|
11
10
|
return EXIT.ok;
|
|
12
11
|
}
|
|
13
|
-
const api = await openDeveloperApi(store
|
|
12
|
+
const api = await openDeveloperApi(store); // refreshes if needed
|
|
14
13
|
out.line('ok', `${api.auth.email ?? api.auth.sub} @ ${api.auth.api_base}`);
|
|
15
14
|
out.kv('scope', api.auth.scope || '—');
|
|
16
15
|
out.kv('expires', new Date(api.auth.expires_at).toISOString());
|
|
@@ -36,7 +35,7 @@ export async function runLogout(_args, out, env = process.env) {
|
|
|
36
35
|
}
|
|
37
36
|
// Best effort: revoke the refresh token (and with it the access token) at the authorization server.
|
|
38
37
|
let revoked = false;
|
|
39
|
-
const f =
|
|
38
|
+
const f = fetch;
|
|
40
39
|
for (const token of [auth.refresh_token, auth.access_token]) {
|
|
41
40
|
if (!token)
|
|
42
41
|
continue;
|
package/dist/commands/test.js
CHANGED
|
@@ -9,11 +9,9 @@
|
|
|
9
9
|
import { AIPayClient, AuthorizationError, ConnectError, createConnectUrl, getUserInfo, handleCallback, InsufficientBalanceError, } from '@coswic/aipay-sdk';
|
|
10
10
|
import { flagBool, flagString } from '../args.js';
|
|
11
11
|
import { openBrowser } from '../browser.js';
|
|
12
|
-
import { GATE_ENV, gatedFetch, looksGated } from '../gate.js';
|
|
13
12
|
import { parseCallback, portIsVariable, redirectUriFor, startLoopback } from '../loopback.js';
|
|
14
13
|
import { CliError, EXIT } from '../output.js';
|
|
15
14
|
import { readProject } from '../project.js';
|
|
16
|
-
import { GATE_HINT } from '../remote.js';
|
|
17
15
|
import { reportSetupEvent } from '../setup-events.js';
|
|
18
16
|
export async function runTest(args, out, deps = {}) {
|
|
19
17
|
const env = deps.env ?? process.env;
|
|
@@ -22,8 +20,17 @@ export async function runTest(args, out, deps = {}) {
|
|
|
22
20
|
if (!project)
|
|
23
21
|
throw new CliError('not_linked', 'this directory is not linked to an app — run `aipay init --app <client_id> ...` first', EXIT.problem);
|
|
24
22
|
const timeoutS = Number(flagString(args.flags, 'timeout') ?? '300');
|
|
25
|
-
const fetchImpl =
|
|
26
|
-
|
|
23
|
+
const fetchImpl = fetch;
|
|
24
|
+
if ('callback' in args.flags && !flagString(args.flags, 'callback'))
|
|
25
|
+
throw new CliError('usage', '--callback requires a registered HTTP loopback URL', EXIT.usage);
|
|
26
|
+
const callback = flagString(args.flags, 'callback') ?? project.callback;
|
|
27
|
+
let target;
|
|
28
|
+
try {
|
|
29
|
+
target = parseCallback(callback);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw new CliError('loopback_required', 'CLI test needs a registered HTTP loopback callback without query, fragment or credentials. Verify the hosted app in your browser, or pass --callback <registered-loopback-url>; the project configuration is preserved.', EXIT.usage);
|
|
33
|
+
}
|
|
27
34
|
// 127.0.0.1: the authorization server ignores the port for loopback redirect URIs
|
|
28
35
|
// (RFC 8252 §7.3), so the CLI takes any free port and your dev server can keep running.
|
|
29
36
|
// localhost / explicit --port: bind exactly that port.
|
|
@@ -37,9 +44,9 @@ export async function runTest(args, out, deps = {}) {
|
|
|
37
44
|
const redirectUri = redirectUriFor(target, loop.port);
|
|
38
45
|
const cfg = { hydraPublicUrl: project.auth_origin, clientId: project.app.client_id, redirectUri };
|
|
39
46
|
const { url, state, codeVerifier } = await createConnectUrl(cfg);
|
|
40
|
-
const note = redirectUri ===
|
|
47
|
+
const note = redirectUri === callback
|
|
41
48
|
? ''
|
|
42
|
-
: ` (registered: ${
|
|
49
|
+
: ` (registered: ${callback}; the port is not compared for 127.0.0.1)`;
|
|
43
50
|
out.line('info', `listening on ${redirectUri}${note}`);
|
|
44
51
|
out.line('info', `sign in here (opens in your browser):\n\n ${url}\n`);
|
|
45
52
|
deps.onAuthorizeUrl?.(url);
|
|
@@ -135,11 +142,10 @@ export async function runTest(args, out, deps = {}) {
|
|
|
135
142
|
return e.exitCode;
|
|
136
143
|
}
|
|
137
144
|
const msg = e.message ?? String(e);
|
|
138
|
-
const gated = /404/.test(msg) && looksGated(404, '');
|
|
139
145
|
result.error = { code: 'failed', message: msg };
|
|
140
|
-
out.line('bad',
|
|
141
|
-
if (
|
|
142
|
-
out.line('info',
|
|
146
|
+
out.line('bad', msg);
|
|
147
|
+
if (/\b404\b/.test(msg))
|
|
148
|
+
out.line('info', 'Check the endpoint URL and network connectivity before retrying.');
|
|
143
149
|
out.result(result);
|
|
144
150
|
return EXIT.remote;
|
|
145
151
|
}
|
package/dist/developer-api.d.ts
CHANGED
|
@@ -8,7 +8,8 @@ export interface DeveloperApi {
|
|
|
8
8
|
request<T = Record<string, unknown>>(method: string, path: string, opts?: {
|
|
9
9
|
body?: unknown;
|
|
10
10
|
headers?: Record<string, string>;
|
|
11
|
+
retryUnauthorized?: boolean;
|
|
11
12
|
}): Promise<ApiResult<T>>;
|
|
12
13
|
}
|
|
13
|
-
export declare function openDeveloperApi(store: AuthStore
|
|
14
|
+
export declare function openDeveloperApi(store: AuthStore): Promise<DeveloperApi>;
|
|
14
15
|
export declare function errorMessage(body: unknown, status: number): string;
|
package/dist/developer-api.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Calls to the developer management API with the developer's own token (audience
|
|
3
|
-
* aipay-developer-api). Refreshes once on 401 when a refresh token exists
|
|
4
|
-
* comes from the environment like everywhere else.
|
|
3
|
+
* aipay-developer-api). Refreshes once on 401 when a refresh token exists.
|
|
5
4
|
*/
|
|
6
5
|
import { refreshAccessToken } from '@coswic/aipay-sdk';
|
|
7
|
-
import {
|
|
6
|
+
import { isEmpty404 } from './network.js';
|
|
8
7
|
import { CliError, EXIT } from './output.js';
|
|
9
|
-
import {
|
|
10
|
-
export async function openDeveloperApi(store
|
|
8
|
+
import { EMPTY_404_HINT } from './remote.js';
|
|
9
|
+
export async function openDeveloperApi(store) {
|
|
11
10
|
let auth = store.load();
|
|
12
11
|
if (!auth)
|
|
13
12
|
throw new CliError('not_logged_in', 'not signed in — run `aipay login` first', EXIT.problem);
|
|
14
|
-
const f =
|
|
13
|
+
const f = fetch;
|
|
15
14
|
if (auth.expires_at - 30_000 < Date.now())
|
|
16
15
|
auth = await refresh(store, auth, f);
|
|
17
16
|
const api = {
|
|
@@ -19,6 +18,7 @@ export async function openDeveloperApi(store, env = process.env) {
|
|
|
19
18
|
async request(method, path, opts = {}) {
|
|
20
19
|
const call = async (a) => f(new URL(path, a.api_base), {
|
|
21
20
|
method,
|
|
21
|
+
signal: AbortSignal.timeout(30_000),
|
|
22
22
|
headers: {
|
|
23
23
|
authorization: `Bearer ${a.access_token}`,
|
|
24
24
|
...(opts.body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
@@ -27,14 +27,14 @@ export async function openDeveloperApi(store, env = process.env) {
|
|
|
27
27
|
...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
|
|
28
28
|
});
|
|
29
29
|
let res = await call(api.auth);
|
|
30
|
-
if (res.status === 401 && api.auth.refresh_token) {
|
|
30
|
+
if (res.status === 401 && api.auth.refresh_token && opts.retryUnauthorized !== false) {
|
|
31
31
|
api.auth = await refresh(store, api.auth, f);
|
|
32
32
|
res = await call(api.auth);
|
|
33
33
|
}
|
|
34
34
|
const text = await res.text();
|
|
35
|
-
if (
|
|
36
|
-
throw new CliError('
|
|
37
|
-
if (res.status === 401)
|
|
35
|
+
if (isEmpty404(res.status, text))
|
|
36
|
+
throw new CliError('empty_404', EMPTY_404_HINT, EXIT.remote);
|
|
37
|
+
if (res.status === 401 && opts.retryUnauthorized !== false)
|
|
38
38
|
throw new CliError('unauthorized', 'the API rejected the developer token — run `aipay login` again', EXIT.remote);
|
|
39
39
|
let body = null;
|
|
40
40
|
try {
|
package/dist/index.d.ts
CHANGED
|
@@ -12,9 +12,9 @@ export { formatUusd, pickModel, runTest } from './commands/test.js';
|
|
|
12
12
|
export { runUnlink, stripEnvKeys } from './commands/unlink.js';
|
|
13
13
|
export { detectFramework, detectPackageManager, detectProject, installCommand } from './detect.js';
|
|
14
14
|
export { openDeveloperApi } from './developer-api.js';
|
|
15
|
-
export { GATE_ENV, gatedFetch, gateHeaders, looksGated } from './gate.js';
|
|
16
15
|
export { parseCallback, startLoopback } from './loopback.js';
|
|
17
16
|
export { main } from './main.js';
|
|
17
|
+
export { isEmpty404 } from './network.js';
|
|
18
18
|
export { CliError, createReporter, EXIT, type Reporter } from './output.js';
|
|
19
19
|
export { ENV_KEYS, isProjectConfig, mergeEnvFile, mergeGitignore, type ProjectConfig, readProject, writeProject, } from './project.js';
|
|
20
20
|
export { createRemote, docsUrl, type Remote } from './remote.js';
|
package/dist/index.js
CHANGED
|
@@ -12,9 +12,9 @@ export { formatUusd, pickModel, runTest } from './commands/test.js';
|
|
|
12
12
|
export { runUnlink, stripEnvKeys } from './commands/unlink.js';
|
|
13
13
|
export { detectFramework, detectPackageManager, detectProject, installCommand } from './detect.js';
|
|
14
14
|
export { openDeveloperApi } from './developer-api.js';
|
|
15
|
-
export { GATE_ENV, gatedFetch, gateHeaders, looksGated } from './gate.js';
|
|
16
15
|
export { parseCallback, startLoopback } from './loopback.js';
|
|
17
16
|
export { main } from './main.js';
|
|
17
|
+
export { isEmpty404 } from './network.js';
|
|
18
18
|
export { CliError, createReporter, EXIT } from './output.js';
|
|
19
19
|
export { ENV_KEYS, isProjectConfig, mergeEnvFile, mergeGitignore, readProject, writeProject, } from './project.js';
|
|
20
20
|
export { createRemote, docsUrl } from './remote.js';
|
package/dist/loopback.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* disk; the token exchange happens in the caller's memory.
|
|
6
6
|
*/
|
|
7
7
|
import { createServer } from 'node:http';
|
|
8
|
+
import { hasUnsafeCallbackText } from './callback-url.js';
|
|
8
9
|
/**
|
|
9
10
|
* Hydra compares loopback redirect URIs per RFC 8252 §7.3: for 127.0.0.1 / [::1] the port
|
|
10
11
|
* is ignored, so the CLI may receive the callback on any free port while the app keeps
|
|
@@ -20,6 +21,8 @@ export function redirectUriFor(target, port) {
|
|
|
20
21
|
}
|
|
21
22
|
export function parseCallback(callback) {
|
|
22
23
|
const u = new URL(callback);
|
|
24
|
+
if (hasUnsafeCallbackText(callback) || u.username || u.password || u.search || u.hash)
|
|
25
|
+
throw new Error('CLI loopback callbacks cannot contain credentials, query, fragment, whitespace or wildcards');
|
|
23
26
|
if (u.protocol !== 'http:')
|
|
24
27
|
throw new Error(`callback must be http:// on a loopback host (got ${callback})`);
|
|
25
28
|
const host = u.hostname;
|
package/dist/main.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const HELP = "aipay \u2014 AI-Pay CLI\n\nUsage\n aipay login [--api <base>] [--device] [--json] developer sign-in for the CLI itself\n aipay whoami [--json] \u00B7 aipay logout [--json]\n aipay apps list [--json]\n aipay apps create --name <name> [--callback <url>] [--json]\n aipay init --app <client_id> [--auth <origin>] [--api <base>] [--callback <url>] [--name <app name>]\n [--framework next|express|fastify|hono|vite|node] override detection\n [--dir <path>] [--dry-run] [--no-install] [--json]\n aipay init --create <app name> [--api <base>] ... create the app (needs login) and link it\n aipay test [--model <id>] [--port <n>] [--no-open] [--timeout <seconds>] [--dir <path>] [--json]\n aipay doctor [--offline] [--dir <path>] [--json]\n aipay status [--dir <path>] [--json]\n aipay unlink [--dry-run] [--dir <path>] [--json]\n\nlogin sign in as a developer (loopback PKCE, audience aipay-developer-api); tokens go to the\n OS keychain or a 0600 file \u2014 never into the project\napps list your apps /
|
|
1
|
+
export declare const HELP = "aipay \u2014 AI-Pay CLI\n\nUsage\n aipay login [--api <base>] [--device] [--json] developer sign-in for the CLI itself\n aipay whoami [--json] \u00B7 aipay logout [--json]\n aipay apps list [--json]\n aipay apps create --name <name> [--callback <url>] [--json]\n aipay apps callbacks list [--app <client_id>] [--dir <path>] [--json]\n aipay apps callbacks add|remove --callback <url> [--app <client_id>] [--dir <path>] [--json]\n aipay init --app <client_id> [--auth <origin>] [--api <base>] [--callback <url>] [--name <app name>]\n [--framework next|express|fastify|hono|vite|node] override detection\n [--dir <path>] [--dry-run] [--no-install] [--json]\n aipay init --create <app name> [--api <base>] ... create the app (needs login) and link it\n aipay test [--model <id>] [--callback <registered-loopback-url>] [--port <n>] [--no-open] [--timeout <seconds>] [--dir <path>] [--json]\n aipay doctor [--offline] [--dir <path>] [--json]\n aipay status [--dir <path>] [--json]\n aipay unlink [--dry-run] [--dir <path>] [--json]\n\nlogin sign in as a developer (loopback PKCE, audience aipay-developer-api); tokens go to the\n OS keychain or a 0600 file \u2014 never into the project\napps list/create your apps; callbacks list/add/remove manages registered redirects\n callback changes preserve other URLs and reject concurrent edits\ninit link this project to an app: .aipay/project.json, .env.local, .gitignore,\n a callback route for your framework, aipay-agent.md, and the SDK install\n (--auth/--api are optional once you are signed in; --create makes the app first)\ntest real sign-in via a loopback callback (any free port on 127.0.0.1) + one chat completion \u2192 receipt\ndoctor check the link, files, SDK, and that AI-Pay is reachable\nstatus what this directory is linked to\nunlink forget the link (keeps your code)\n\nExit codes: 0 ok \u00B7 1 problem you can fix \u00B7 2 usage \u00B7 3 network/auth\n";
|
|
2
2
|
export declare function main(argv: string[], io?: {
|
|
3
3
|
stdout?: (s: string) => void;
|
|
4
4
|
stderr?: (s: string) => void;
|
package/dist/main.js
CHANGED
|
@@ -16,18 +16,21 @@ Usage
|
|
|
16
16
|
aipay whoami [--json] · aipay logout [--json]
|
|
17
17
|
aipay apps list [--json]
|
|
18
18
|
aipay apps create --name <name> [--callback <url>] [--json]
|
|
19
|
+
aipay apps callbacks list [--app <client_id>] [--dir <path>] [--json]
|
|
20
|
+
aipay apps callbacks add|remove --callback <url> [--app <client_id>] [--dir <path>] [--json]
|
|
19
21
|
aipay init --app <client_id> [--auth <origin>] [--api <base>] [--callback <url>] [--name <app name>]
|
|
20
22
|
[--framework next|express|fastify|hono|vite|node] override detection
|
|
21
23
|
[--dir <path>] [--dry-run] [--no-install] [--json]
|
|
22
24
|
aipay init --create <app name> [--api <base>] ... create the app (needs login) and link it
|
|
23
|
-
aipay test [--model <id>] [--port <n>] [--no-open] [--timeout <seconds>] [--dir <path>] [--json]
|
|
25
|
+
aipay test [--model <id>] [--callback <registered-loopback-url>] [--port <n>] [--no-open] [--timeout <seconds>] [--dir <path>] [--json]
|
|
24
26
|
aipay doctor [--offline] [--dir <path>] [--json]
|
|
25
27
|
aipay status [--dir <path>] [--json]
|
|
26
28
|
aipay unlink [--dry-run] [--dir <path>] [--json]
|
|
27
29
|
|
|
28
30
|
login sign in as a developer (loopback PKCE, audience aipay-developer-api); tokens go to the
|
|
29
31
|
OS keychain or a 0600 file — never into the project
|
|
30
|
-
apps list your apps /
|
|
32
|
+
apps list/create your apps; callbacks list/add/remove manages registered redirects
|
|
33
|
+
callback changes preserve other URLs and reject concurrent edits
|
|
31
34
|
init link this project to an app: .aipay/project.json, .env.local, .gitignore,
|
|
32
35
|
a callback route for your framework, aipay-agent.md, and the SDK install
|
|
33
36
|
(--auth/--api are optional once you are signed in; --create makes the app first)
|
|
@@ -37,7 +40,6 @@ status what this directory is linked to
|
|
|
37
40
|
unlink forget the link (keeps your code)
|
|
38
41
|
|
|
39
42
|
Exit codes: 0 ok · 1 problem you can fix · 2 usage · 3 network/auth
|
|
40
|
-
Pilot gate: export AIPAY_GATE_TOKEN=<token from the Console>; never write it to files.
|
|
41
43
|
`;
|
|
42
44
|
export async function main(argv, io) {
|
|
43
45
|
const args = parseArgs(argv);
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** Describe the observed response without inferring its network or server-side cause. */
|
|
2
|
+
export declare function isEmpty404(status: number, bodyText: string): boolean;
|
|
3
|
+
export declare const EMPTY_404_HINT = "AI-Pay answered 404 with an empty body. Check the endpoint URL and network connectivity, then retry. An empty 404 alone does not identify the cause.";
|
package/dist/network.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Describe the observed response without inferring its network or server-side cause. */
|
|
2
|
+
export function isEmpty404(status, bodyText) {
|
|
3
|
+
return status === 404 && bodyText.trim() === '';
|
|
4
|
+
}
|
|
5
|
+
export const EMPTY_404_HINT = 'AI-Pay answered 404 with an empty body. Check the endpoint URL and network connectivity, then retry. An empty 404 alone does not identify the cause.';
|
package/dist/project.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Local artifacts (consultant report §6.5):
|
|
3
3
|
* .aipay/project.json — committable: which app this project is linked to (no secrets)
|
|
4
|
-
* .env.local — NOT committed: only public values
|
|
5
|
-
* gate token is read from the environment and never written
|
|
4
|
+
* .env.local — NOT committed: only public values (client_id, URLs)
|
|
6
5
|
* .gitignore — gets `.env.local` and `.aipay/local/` appended once
|
|
7
6
|
*
|
|
8
7
|
* Tokens live only in memory for the duration of `aipay test`. Nothing here writes them.
|
package/dist/remote.d.ts
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Everything that talks to AI-Pay over the network, in one place so the commands stay
|
|
3
|
-
* readable and tests can stub it.
|
|
3
|
+
* readable and tests can stub it.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
5
|
+
import { EMPTY_404_HINT } from './network.js';
|
|
6
6
|
export interface Remote {
|
|
7
7
|
fetchDocs(apiBase: string, locale?: 'en' | 'zh-Hant'): Promise<{
|
|
8
8
|
markdown: string | null;
|
|
9
|
-
|
|
9
|
+
empty404: boolean;
|
|
10
10
|
status: number;
|
|
11
11
|
}>;
|
|
12
12
|
probe(url: string): Promise<{
|
|
13
13
|
ok: boolean;
|
|
14
14
|
status: number;
|
|
15
|
-
|
|
15
|
+
empty404: boolean;
|
|
16
16
|
error?: string;
|
|
17
17
|
}>;
|
|
18
18
|
}
|
|
19
|
-
export declare function createRemote(
|
|
19
|
+
export declare function createRemote(): Remote;
|
|
20
20
|
export declare function docsUrl(apiBase: string, locale?: 'en' | 'zh-Hant'): string;
|
|
21
|
-
export {
|
|
21
|
+
export { EMPTY_404_HINT };
|
package/dist/remote.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Everything that talks to AI-Pay over the network, in one place so the commands stay
|
|
3
|
-
* readable and tests can stub it.
|
|
3
|
+
* readable and tests can stub it.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
5
|
+
import { EMPTY_404_HINT, isEmpty404 } from './network.js';
|
|
6
6
|
import { CliError, EXIT } from './output.js';
|
|
7
|
-
export function createRemote(
|
|
8
|
-
const f =
|
|
7
|
+
export function createRemote() {
|
|
8
|
+
const f = fetch;
|
|
9
9
|
return {
|
|
10
10
|
async fetchDocs(apiBase, locale = 'en') {
|
|
11
11
|
const url = docsUrl(apiBase, locale);
|
|
@@ -14,21 +14,21 @@ export function createRemote(env = process.env) {
|
|
|
14
14
|
res = await f(url, { headers: { accept: 'text/markdown, text/plain' } });
|
|
15
15
|
}
|
|
16
16
|
catch (e) {
|
|
17
|
-
throw new CliError('network', `cannot reach ${url}: ${e.message}
|
|
17
|
+
throw new CliError('network', `cannot reach ${url}: ${e.message}. Check the endpoint URL and network connectivity, then retry.`, EXIT.remote);
|
|
18
18
|
}
|
|
19
19
|
const text = await res.text();
|
|
20
20
|
if (res.ok)
|
|
21
|
-
return { markdown: text,
|
|
22
|
-
return { markdown: null,
|
|
21
|
+
return { markdown: text, empty404: false, status: res.status };
|
|
22
|
+
return { markdown: null, empty404: isEmpty404(res.status, text), status: res.status };
|
|
23
23
|
},
|
|
24
24
|
async probe(url) {
|
|
25
25
|
try {
|
|
26
26
|
const res = await f(url, { method: 'GET', redirect: 'manual' });
|
|
27
27
|
const text = await res.text();
|
|
28
|
-
return { ok: res.ok, status: res.status,
|
|
28
|
+
return { ok: res.ok, status: res.status, empty404: isEmpty404(res.status, text) };
|
|
29
29
|
}
|
|
30
30
|
catch (e) {
|
|
31
|
-
return { ok: false, status: 0,
|
|
31
|
+
return { ok: false, status: 0, empty404: false, error: e.message };
|
|
32
32
|
}
|
|
33
33
|
},
|
|
34
34
|
};
|
|
@@ -38,4 +38,4 @@ export function docsUrl(apiBase, locale = 'en') {
|
|
|
38
38
|
u.searchParams.set('lang', locale);
|
|
39
39
|
return u.toString();
|
|
40
40
|
}
|
|
41
|
-
export {
|
|
41
|
+
export { EMPTY_404_HINT };
|
package/dist/setup-events.js
CHANGED
|
@@ -11,7 +11,7 @@ export async function reportSetupEvent(appId, kind, detail, out, env = process.e
|
|
|
11
11
|
if (!store.load())
|
|
12
12
|
return false; // not signed in with the CLI → the Console still sees server facts
|
|
13
13
|
try {
|
|
14
|
-
const api = await openDeveloperApi(store
|
|
14
|
+
const api = await openDeveloperApi(store);
|
|
15
15
|
const res = await api.request('POST', `/v1/developer/apps/${encodeURIComponent(appId)}/setup-events`, {
|
|
16
16
|
body: { kind, detail },
|
|
17
17
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coswic/aipay",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "AI-Pay CLI —
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "AI-Pay CLI — developer sign-in, App and callback management, project integration, diagnostics, and real sign-in + AI receipt verification.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"dist",
|
|
18
|
-
"README.md"
|
|
18
|
+
"README.md",
|
|
19
|
+
"CHANGELOG.md"
|
|
19
20
|
],
|
|
20
21
|
"publishConfig": {
|
|
21
22
|
"access": "public"
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
"node": ">=20"
|
|
25
26
|
},
|
|
26
27
|
"dependencies": {
|
|
27
|
-
"@coswic/aipay-sdk": "^0.3.
|
|
28
|
+
"@coswic/aipay-sdk": "^0.3.2"
|
|
28
29
|
},
|
|
29
30
|
"devDependencies": {
|
|
30
31
|
"@types/node": "^26.1.1",
|
package/dist/gate.d.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pilot gate: during the closed pilot, non-public paths answer 404 with an empty body
|
|
3
|
-
* unless the request carries `X-AIPay-Gate: <token>` (or the browser cookie set by
|
|
4
|
-
* `/__gate/<token>`). The CLI only ever reads the token from the environment — it is
|
|
5
|
-
* never written to any file and never printed.
|
|
6
|
-
*/
|
|
7
|
-
export declare const GATE_ENV = "AIPAY_GATE_TOKEN";
|
|
8
|
-
export declare function gateHeaders(env?: NodeJS.ProcessEnv): Record<string, string>;
|
|
9
|
-
/** fetch wrapper that adds the gate header; passed to the SDK as `fetchImpl`. */
|
|
10
|
-
export declare function gatedFetch(env?: NodeJS.ProcessEnv): typeof fetch;
|
|
11
|
-
/** Explain a 404-with-empty-body in gate terms (the SDK/API otherwise say "not found"). */
|
|
12
|
-
export declare function looksGated(status: number, bodyText: string): boolean;
|
|
13
|
-
export declare const GATE_HINT = "AI-Pay answered 404 with an empty body \u2014 that is the pilot gate, not a missing route. Set AIPAY_GATE_TOKEN in your environment (the value is in the Console next to the app) and retry. Do not put it in code or version control.";
|
package/dist/gate.js
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pilot gate: during the closed pilot, non-public paths answer 404 with an empty body
|
|
3
|
-
* unless the request carries `X-AIPay-Gate: <token>` (or the browser cookie set by
|
|
4
|
-
* `/__gate/<token>`). The CLI only ever reads the token from the environment — it is
|
|
5
|
-
* never written to any file and never printed.
|
|
6
|
-
*/
|
|
7
|
-
export const GATE_ENV = 'AIPAY_GATE_TOKEN';
|
|
8
|
-
export function gateHeaders(env = process.env) {
|
|
9
|
-
const token = env[GATE_ENV];
|
|
10
|
-
return token ? { 'X-AIPay-Gate': token } : {};
|
|
11
|
-
}
|
|
12
|
-
/** fetch wrapper that adds the gate header; passed to the SDK as `fetchImpl`. */
|
|
13
|
-
export function gatedFetch(env = process.env) {
|
|
14
|
-
const extra = gateHeaders(env);
|
|
15
|
-
if (!Object.keys(extra).length)
|
|
16
|
-
return fetch;
|
|
17
|
-
return (input, init) => {
|
|
18
|
-
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
19
|
-
for (const [k, v] of Object.entries(extra))
|
|
20
|
-
if (!headers.has(k))
|
|
21
|
-
headers.set(k, v);
|
|
22
|
-
return fetch(input, { ...init, headers });
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
/** Explain a 404-with-empty-body in gate terms (the SDK/API otherwise say "not found"). */
|
|
26
|
-
export function looksGated(status, bodyText) {
|
|
27
|
-
return status === 404 && bodyText.trim() === '';
|
|
28
|
-
}
|
|
29
|
-
export const GATE_HINT = `AI-Pay answered 404 with an empty body — that is the pilot gate, not a missing route. Set ${GATE_ENV} in your environment (the value is in the Console next to the app) and retry. Do not put it in code or version control.`;
|