@coswic/aipay 0.3.0 → 0.4.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 ADDED
@@ -0,0 +1,40 @@
1
+ # @coswic/aipay
2
+
3
+ ## 0.4.0 — 2026-09-08
4
+
5
+ - Simplify setup instructions around standard OAuth and HTTPS connectivity.
6
+ - Report empty 404 responses without inferring their cause.
7
+ - 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.
8
+ - **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`.
9
+
10
+ ## 0.3.0
11
+
12
+ - `aipay init --framework next|express|fastify|hono|vite|node` overrides framework detection.
13
+ - Vite (browser SPA) projects: `init` writes a browser helper (`aipay.ts`, PKCE in the page,
14
+ tokens in sessionStorage) plus `aipay-boot.ts`, and `VITE_AIPAY_*` aliases in `.env.local`
15
+ (Vite exposes only VITE_-prefixed values). Server frameworks still win when both are present.
16
+ - `aipay test` reports failures to the Console too (`cli.test` with `ok:false` and an error
17
+ code), so the verify step can show what went wrong.
18
+
19
+ ## 0.2.1
20
+
21
+ Same content as 0.2.0. Version 0.2.0 on npm was published by mistake without `dist` (the
22
+ tarball only had README and package.json); it is deprecated and `latest` points past it.
23
+ Install 0.2.1 or later.
24
+
25
+ ## 0.2.0
26
+
27
+ - `aipay login --device`: RFC 8628 device authorization for machines without a browser (SSH
28
+ boxes, containers, CI). Prints a URL and a short code; approve on any device; the CLI polls.
29
+ Requires an AI-Pay whose `/v1/cli/config` reports `device_authorization: true`.
30
+ - Windows: developer tokens are now protected with DPAPI (Windows PowerShell
31
+ `ConvertFrom-SecureString`) in `%APPDATA%\aipay\credentials.dpapi` instead of a plaintext
32
+ `credentials.json`. An existing plaintext file is read once and replaced on the next login.
33
+ `AIPAY_TOKEN_STORE=file` keeps the old behaviour.
34
+ - `aipay login` (browser flow) mentions `--device` when the server supports it.
35
+
36
+ ## 0.1.0
37
+
38
+ - First release: `init`, `test`, `doctor`, `status`, `unlink`, `login`/`logout`/`whoami`,
39
+ `apps list`/`apps create`; loopback PKCE on any free port; tokens in the OS keychain or a
40
+ 0600 file; setup progress reported to the Console.
package/README.md CHANGED
@@ -37,16 +37,12 @@ own. Nothing is written to the project; tokens go to the keychain or the 0600 fi
37
37
  like the browser flow. With `--json`, the URL and code are echoed on stderr as
38
38
  `device_url …` / `user_code …` so an agent can show them to you.
39
39
 
40
- ## Pilot gate
40
+ ## Connection troubleshooting
41
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
- ```
48
-
49
- The CLI never writes the token to any file and never prints it.
42
+ Check the configured API and authorization server URLs and network connectivity if a
43
+ request fails. An empty `404` alone does not identify the cause; it does not establish
44
+ whether a path is missing or why the server returned that response. Run `aipay doctor`
45
+ to check the configured endpoints before retrying.
50
46
 
51
47
  ## What gets written
52
48
 
@@ -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 — or the gate token — to read them later.
5
+ * network access to read them later.
6
6
  *
7
- * Never contains tokens. If the docs are gated (404 with empty body) the file still gets
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';
@@ -33,8 +33,8 @@ ${files}
33
33
  ## Rules for agents
34
34
 
35
35
  - Read the docs below before changing the integration; they are the source of truth.
36
- - The pilot gate token comes only from the \`AIPAY_GATE_TOKEN\` environment variable.
37
- Never write it into code, config files, or version control.
36
+ - Use standard OAuth sign-in and app authorization.
37
+ - Keep OAuth tokens out of code, config files, and version control.
38
38
  - Never request \`offline_access\` unless the product needs background renewal.
39
39
  - Key user accounts on \`sub\` from the userinfo endpoint, never on email.
40
40
 
@@ -53,8 +53,8 @@ ${input.docsMarkdown.trim()}
53
53
  # AI-Pay developer documentation
54
54
 
55
55
  The docs could not be fetched when this file was written (see the \`aipay init\` output).
56
- Read them at ${input.docsUrl} — during the pilot send the header \`X-AIPay-Gate\` with the
57
- value of \`AIPAY_GATE_TOKEN\`, or run \`aipay init\` again with the variable set.
56
+ Check the endpoint URL and network connectivity, then read
57
+ ${input.docsUrl} or run \`aipay init\` again. An empty 404 alone does not identify the cause.
58
58
  `;
59
59
  return head + body;
60
60
  }
@@ -24,7 +24,7 @@ export async function createApp(api, input) {
24
24
  export async function runApps(args, out, env = process.env) {
25
25
  const sub = args.positionals[0] ?? 'list';
26
26
  const store = createAuthStore(env);
27
- const api = await openDeveloperApi(store, env);
27
+ const api = await openDeveloperApi(store);
28
28
  if (sub === 'list') {
29
29
  const res = await api.request('GET', '/v1/developer/apps');
30
30
  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, env: NodeJS.ProcessEnv): Promise<Check[]>;
16
+ }, deps: DoctorDeps): Promise<Check[]>;
@@ -7,15 +7,13 @@ import { existsSync, readFileSync } from 'node:fs';
7
7
  import { join, resolve } from 'node:path';
8
8
  import { flagBool, flagString } from '../args.js';
9
9
  import { detectProject } from '../detect.js';
10
- import { GATE_ENV } from '../gate.js';
11
10
  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, GATE_HINT } from '../remote.js';
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 env = deps.env ?? process.env;
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, env) {
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
@@ -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(env);
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.gated)
141
- checks.push({ id: 'api', level: 'bad', text: `API ${project.api_base}: ${GATE_HINT}` });
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',
@@ -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. Nothing here needs an AI-Pay login (public client info only) — the gate
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';
@@ -15,11 +14,10 @@ import { flagBool, flagString } from '../args.js';
15
14
  import { createAuthStore } from '../auth-store.js';
16
15
  import { detectProject, installCommand } from '../detect.js';
17
16
  import { openDeveloperApi } from '../developer-api.js';
18
- import { GATE_ENV } from '../gate.js';
19
17
  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, GATE_HINT } from '../remote.js';
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), env));
44
+ const api = deps.developerApi ?? (await openDeveloperApi(createAuthStore(env)));
47
45
  const created = await createApp(api, {
48
46
  name: createName,
49
47
  callback,
@@ -63,7 +61,7 @@ export async function runInit(args, out, deps = {}) {
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(env);
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
@@ -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.gated)
166
- out.line('warn', GATE_HINT);
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);
@@ -219,8 +217,6 @@ export async function runInit(args, out, deps = {}) {
219
217
  out.blank();
220
218
  out.line('ok', dryRun ? 'dry run — nothing written' : `linked to app ${name ?? clientId}`);
221
219
  out.line('info', 'next: run `aipay test` — it receives the callback on a free loopback port, so your dev server can keep running');
222
- if (!env[GATE_ENV])
223
- out.line('info', `pilot: if AI-Pay answers 404 with an empty body, export ${GATE_ENV} (Console → your app)`);
224
220
  out.result({
225
221
  ok: installExit === null || installExit === 0,
226
222
  dry_run: dryRun,
@@ -229,7 +225,7 @@ export async function runInit(args, out, deps = {}) {
229
225
  detection: det,
230
226
  plan,
231
227
  install: installCmd ? { command: installCmd, exit: installExit } : null,
232
- docs: { fetched: !!docs.markdown, gated: docs.gated, status: docs.status },
228
+ docs: { fetched: !!docs.markdown, empty404: docs.empty404, status: docs.status },
233
229
  reported,
234
230
  });
235
231
  return installExit === null || installExit === 0 ? EXIT.ok : EXIT.problem;
@@ -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, env: NodeJS.ProcessEnv): Promise<CliConfig>;
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
@@ -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 { GATE_HINT } from '../remote.js';
23
- export async function fetchCliConfig(apiBase, env) {
24
- const f = gatedFetch(env);
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}`, EXIT.remote);
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 (looksGated(res.status, text))
34
- throw new CliError('gated', GATE_HINT, EXIT.remote);
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, env);
94
+ const cfg = await fetchCliConfig(apiBase);
95
95
  const timeoutS = Number(flagString(args.flags, 'timeout') ?? '300');
96
- const fetchImpl = deps.fetchImpl ?? gatedFetch(env);
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,
@@ -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, env); // refreshes if needed
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 = gatedFetch(env);
38
+ const f = fetch;
40
39
  for (const token of [auth.refresh_token, auth.access_token]) {
41
40
  if (!token)
42
41
  continue;
@@ -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,7 +20,7 @@ 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 = gatedFetch(env);
23
+ const fetchImpl = fetch;
26
24
  const target = parseCallback(project.callback);
27
25
  // 127.0.0.1: the authorization server ignores the port for loopback redirect URIs
28
26
  // (RFC 8252 §7.3), so the CLI takes any free port and your dev server can keep running.
@@ -135,11 +133,10 @@ export async function runTest(args, out, deps = {}) {
135
133
  return e.exitCode;
136
134
  }
137
135
  const msg = e.message ?? String(e);
138
- const gated = /404/.test(msg) && looksGated(404, '');
139
136
  result.error = { code: 'failed', message: msg };
140
- out.line('bad', gated ? `${msg}\n${GATE_HINT}` : msg);
141
- if (!env[GATE_ENV])
142
- out.line('info', `pilot: if this was a 404 with an empty body, export ${GATE_ENV} and retry`);
137
+ out.line('bad', msg);
138
+ if (/\b404\b/.test(msg))
139
+ out.line('info', 'Check the endpoint URL and network connectivity before retrying.');
143
140
  out.result(result);
144
141
  return EXIT.remote;
145
142
  }
@@ -10,5 +10,5 @@ export interface DeveloperApi {
10
10
  headers?: Record<string, string>;
11
11
  }): Promise<ApiResult<T>>;
12
12
  }
13
- export declare function openDeveloperApi(store: AuthStore, env?: NodeJS.ProcessEnv): Promise<DeveloperApi>;
13
+ export declare function openDeveloperApi(store: AuthStore): Promise<DeveloperApi>;
14
14
  export declare function errorMessage(body: unknown, status: number): string;
@@ -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; the gate header
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 { gatedFetch, looksGated } from './gate.js';
6
+ import { isEmpty404 } from './network.js';
8
7
  import { CliError, EXIT } from './output.js';
9
- import { GATE_HINT } from './remote.js';
10
- export async function openDeveloperApi(store, env = process.env) {
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 = gatedFetch(env);
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 = {
@@ -32,8 +31,8 @@ export async function openDeveloperApi(store, env = process.env) {
32
31
  res = await call(api.auth);
33
32
  }
34
33
  const text = await res.text();
35
- if (looksGated(res.status, text))
36
- throw new CliError('gated', GATE_HINT, EXIT.remote);
34
+ if (isEmpty404(res.status, text))
35
+ throw new CliError('empty_404', EMPTY_404_HINT, EXIT.remote);
37
36
  if (res.status === 401)
38
37
  throw new CliError('unauthorized', 'the API rejected the developer token — run `aipay login` again', EXIT.remote);
39
38
  let body = null;
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/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 / create one (Idempotency-Key: a retried create never makes a second app)\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\nPilot gate: export AIPAY_GATE_TOKEN=<token from the Console>; never write it to files.\n";
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 / create one (Idempotency-Key: a retried create never makes a second app)\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
@@ -37,7 +37,6 @@ status what this directory is linked to
37
37
  unlink forget the link (keeps your code)
38
38
 
39
39
  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
40
  `;
42
41
  export async function main(argv, io) {
43
42
  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.";
@@ -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 today (client_id, URLs); the
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. Gate header via the environment (see gate.ts).
3
+ * readable and tests can stub it.
4
4
  */
5
- import { GATE_HINT } from './gate.js';
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
- gated: boolean;
9
+ empty404: boolean;
10
10
  status: number;
11
11
  }>;
12
12
  probe(url: string): Promise<{
13
13
  ok: boolean;
14
14
  status: number;
15
- gated: boolean;
15
+ empty404: boolean;
16
16
  error?: string;
17
17
  }>;
18
18
  }
19
- export declare function createRemote(env?: NodeJS.ProcessEnv): Remote;
19
+ export declare function createRemote(): Remote;
20
20
  export declare function docsUrl(apiBase: string, locale?: 'en' | 'zh-Hant'): string;
21
- export { GATE_HINT };
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. Gate header via the environment (see gate.ts).
3
+ * readable and tests can stub it.
4
4
  */
5
- import { GATE_HINT, gatedFetch, looksGated } from './gate.js';
5
+ import { EMPTY_404_HINT, isEmpty404 } from './network.js';
6
6
  import { CliError, EXIT } from './output.js';
7
- export function createRemote(env = process.env) {
8
- const f = gatedFetch(env);
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}`, EXIT.remote);
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, gated: false, status: res.status };
22
- return { markdown: null, gated: looksGated(res.status, text), status: res.status };
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, gated: looksGated(res.status, text) };
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, gated: false, error: e.message };
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 { GATE_HINT };
41
+ export { EMPTY_404_HINT };
@@ -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, env);
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@coswic/aipay",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "AI-Pay CLI — init (link an app + scaffold the integration), doctor, test (real sign-in + first receipt), status, unlink. For developers and their coding agents.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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.1"
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.`;