@coswic/aipay 0.2.0 → 0.3.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.
Files changed (54) hide show
  1. package/README.md +1 -1
  2. package/dist/agent-guide.d.ts +18 -0
  3. package/dist/agent-guide.js +60 -0
  4. package/dist/args.d.ts +15 -0
  5. package/dist/args.js +47 -0
  6. package/dist/auth-store.d.ts +47 -0
  7. package/dist/auth-store.js +176 -0
  8. package/dist/bin.d.ts +2 -0
  9. package/dist/bin.js +8 -0
  10. package/dist/browser.d.ts +1 -0
  11. package/dist/browser.js +18 -0
  12. package/dist/commands/apps.d.ts +15 -0
  13. package/dist/commands/apps.js +53 -0
  14. package/dist/commands/doctor.d.ts +17 -0
  15. package/dist/commands/doctor.js +153 -0
  16. package/dist/commands/init.d.ts +18 -0
  17. package/dist/commands/init.js +269 -0
  18. package/dist/commands/login.d.ts +34 -0
  19. package/dist/commands/login.js +189 -0
  20. package/dist/commands/session.d.ts +6 -0
  21. package/dist/commands/session.js +63 -0
  22. package/dist/commands/status.d.ts +3 -0
  23. package/dist/commands/status.js +24 -0
  24. package/dist/commands/test.d.ts +12 -0
  25. package/dist/commands/test.js +162 -0
  26. package/dist/commands/unlink.d.ts +5 -0
  27. package/dist/commands/unlink.js +73 -0
  28. package/dist/detect.d.ts +27 -0
  29. package/dist/detect.js +83 -0
  30. package/dist/developer-api.d.ts +14 -0
  31. package/dist/developer-api.js +74 -0
  32. package/dist/device.d.ts +38 -0
  33. package/dist/device.js +122 -0
  34. package/dist/gate.d.ts +13 -0
  35. package/dist/gate.js +29 -0
  36. package/dist/index.d.ts +22 -0
  37. package/dist/index.js +22 -0
  38. package/dist/loopback.d.ts +37 -0
  39. package/dist/loopback.js +95 -0
  40. package/dist/main.d.ts +5 -0
  41. package/dist/main.js +91 -0
  42. package/dist/output.d.ts +36 -0
  43. package/dist/output.js +52 -0
  44. package/dist/project.d.ts +61 -0
  45. package/dist/project.js +127 -0
  46. package/dist/remote.d.ts +21 -0
  47. package/dist/remote.js +41 -0
  48. package/dist/setup-events.d.ts +3 -0
  49. package/dist/setup-events.js +28 -0
  50. package/dist/templates.d.ts +24 -0
  51. package/dist/templates.js +333 -0
  52. package/dist/version.d.ts +1 -0
  53. package/dist/version.js +10 -0
  54. package/package.json +1 -1
@@ -0,0 +1,162 @@
1
+ /**
2
+ * `aipay test [--model <id>] [--no-open] [--timeout <s>] [--json]`
3
+ *
4
+ * The one check only a CLI can do end to end: a real sign-in through the AI-Pay
5
+ * authorization server on the registered loopback callback, then one chat completion
6
+ * on the user's behalf. The receipt in the response is the proof (and the Console's
7
+ * verify step turns green from the server-side facts). Tokens live in memory only.
8
+ */
9
+ import { AIPayClient, AuthorizationError, ConnectError, createConnectUrl, getUserInfo, handleCallback, InsufficientBalanceError, } from '@coswic/aipay-sdk';
10
+ import { flagBool, flagString } from '../args.js';
11
+ import { openBrowser } from '../browser.js';
12
+ import { GATE_ENV, gatedFetch, looksGated } from '../gate.js';
13
+ import { parseCallback, portIsVariable, redirectUriFor, startLoopback } from '../loopback.js';
14
+ import { CliError, EXIT } from '../output.js';
15
+ import { readProject } from '../project.js';
16
+ import { GATE_HINT } from '../remote.js';
17
+ import { reportSetupEvent } from '../setup-events.js';
18
+ export async function runTest(args, out, deps = {}) {
19
+ const env = deps.env ?? process.env;
20
+ const dir = flagString(args.flags, 'dir') ?? '.';
21
+ const project = readProject(dir);
22
+ if (!project)
23
+ throw new CliError('not_linked', 'this directory is not linked to an app — run `aipay init --app <client_id> ...` first', EXIT.problem);
24
+ const timeoutS = Number(flagString(args.flags, 'timeout') ?? '300');
25
+ const fetchImpl = gatedFetch(env);
26
+ const target = parseCallback(project.callback);
27
+ // 127.0.0.1: the authorization server ignores the port for loopback redirect URIs
28
+ // (RFC 8252 §7.3), so the CLI takes any free port and your dev server can keep running.
29
+ // localhost / explicit --port: bind exactly that port.
30
+ const portFlag = flagString(args.flags, 'port');
31
+ const bindPort = portFlag ? Number(portFlag) : portIsVariable(target) ? 0 : target.port;
32
+ const loop = await startLoopback({ ...target, port: bindPort }, { timeoutMs: timeoutS * 1000 }).catch((e) => {
33
+ if (e.code === 'EADDRINUSE')
34
+ throw new CliError('port_busy', `port ${bindPort} is in use — stop what is listening there, or pass --port <free port>`, EXIT.problem);
35
+ throw e;
36
+ });
37
+ const redirectUri = redirectUriFor(target, loop.port);
38
+ const cfg = { hydraPublicUrl: project.auth_origin, clientId: project.app.client_id, redirectUri };
39
+ const { url, state, codeVerifier } = await createConnectUrl(cfg);
40
+ const note = redirectUri === project.callback
41
+ ? ''
42
+ : ` (registered: ${project.callback}; the port is not compared for 127.0.0.1)`;
43
+ out.line('info', `listening on ${redirectUri}${note}`);
44
+ out.line('info', `sign in here (opens in your browser):\n\n ${url}\n`);
45
+ deps.onAuthorizeUrl?.(url);
46
+ if (out.json)
47
+ out.debug(`authorize_url ${url}`); // --json keeps stdout for the result; drivers read this
48
+ if (flagBool(args.flags, 'open', true))
49
+ (deps.open ?? openBrowser)(url);
50
+ const result = {
51
+ ok: false,
52
+ app: project.app,
53
+ signin: null,
54
+ completion: null,
55
+ };
56
+ try {
57
+ const cb = await loop.waitForCallback;
58
+ let tokens;
59
+ try {
60
+ tokens = await handleCallback(cfg, cb.url, { state, codeVerifier }, { fetchImpl });
61
+ }
62
+ catch (e) {
63
+ if (e instanceof ConnectError)
64
+ throw new CliError('signin_failed', `sign-in failed: ${e.message}${e.body ? ` — ${e.body}` : ''}`, EXIT.remote);
65
+ throw e;
66
+ }
67
+ const user = await getUserInfo(cfg, tokens.accessToken, { fetchImpl });
68
+ result.signin = {
69
+ sub: user.sub,
70
+ email: user.email ?? null,
71
+ scope: tokens.scope,
72
+ expires_in: tokens.expiresInSeconds,
73
+ };
74
+ out.line('ok', `signed in: ${user.email ?? user.sub} (scope: ${tokens.scope || '—'})`);
75
+ const client = new AIPayClient({
76
+ baseUrl: project.api_base,
77
+ accessToken: tokens.accessToken,
78
+ fetchImpl,
79
+ });
80
+ let model = flagString(args.flags, 'model');
81
+ if (!model) {
82
+ const models = await client.models.list().catch((e) => {
83
+ throw new CliError('models', `cannot list models: ${e.message}`, EXIT.remote);
84
+ });
85
+ model = pickModel(models.map((m) => m.id));
86
+ if (!model)
87
+ throw new CliError('models', 'no sellable model right now — pass --model <id>', EXIT.remote);
88
+ }
89
+ out.line('info', `calling ${model} …`);
90
+ try {
91
+ const res = await client.chat.completions.create({
92
+ model,
93
+ messages: [{ role: 'user', content: 'Reply with exactly one word: pong' }],
94
+ max_tokens: 8,
95
+ });
96
+ const r = res.aipay;
97
+ const charged = r.total_charged_uusd ?? r.cost_uusd;
98
+ result.completion = {
99
+ model: res.model,
100
+ text: res.choices[0]?.message.content ?? '',
101
+ request_id: r.request_id,
102
+ usage_event_id: r.usage_event_id,
103
+ charged_uusd: charged,
104
+ currency: r.currency,
105
+ usage: res.usage,
106
+ };
107
+ result.ok = true;
108
+ out.line('ok', `receipt: request_id ${r.request_id}, charged ${formatUusd(charged)} ${r.currency}`);
109
+ // C2 setup session:登入狀態下回報 test 通過(伺服器本來就看得到回執;這條是給 Console 的時間戳)
110
+ await reportSetupEvent(project.app.client_id, 'cli.test', { request_id: r.request_id, model: res.model, ok: true }, out, env);
111
+ out.line('ok', "done — the Console's verify step reads this from the server side");
112
+ }
113
+ catch (e) {
114
+ if (e instanceof InsufficientBalanceError) {
115
+ result.completion = { error: 'insufficient_balance' };
116
+ out.line('warn', 'signed in fine, but this account has no balance for a completion — top up in the Console and rerun');
117
+ await reportSetupEvent(project.app.client_id, 'cli.test', { ok: false, error_code: 'insufficient_balance' }, out, env);
118
+ out.result(result);
119
+ return EXIT.problem;
120
+ }
121
+ if (e instanceof AuthorizationError)
122
+ throw new CliError('unauthorized', `the API rejected the token: ${e.message}`, EXIT.remote);
123
+ throw e;
124
+ }
125
+ out.result(result);
126
+ return EXIT.ok;
127
+ }
128
+ catch (e) {
129
+ if (e instanceof CliError) {
130
+ result.error = { code: e.code, message: e.message };
131
+ out.line('bad', e.message);
132
+ // 顧問 §13.2:失敗也回報(error_code 進 Console 的驗證步與 setup 事件;best effort、無 token)
133
+ await reportSetupEvent(project.app.client_id, 'cli.test', { ok: false, error_code: e.code }, out, env);
134
+ out.result(result);
135
+ return e.exitCode;
136
+ }
137
+ const msg = e.message ?? String(e);
138
+ const gated = /404/.test(msg) && looksGated(404, '');
139
+ 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`);
143
+ out.result(result);
144
+ return EXIT.remote;
145
+ }
146
+ finally {
147
+ await loop.close();
148
+ }
149
+ }
150
+ /** Cheapest sensible default: prefer a small/cheap model id if present, else the first. */
151
+ export function pickModel(ids) {
152
+ if (!ids.length)
153
+ return null;
154
+ const cheap = ids.find((id) => /mini|flash|haiku|nano|small|8b|7b/i.test(id));
155
+ return cheap ?? ids[0];
156
+ }
157
+ export function formatUusd(uusd) {
158
+ const n = Number(uusd);
159
+ if (!Number.isFinite(n))
160
+ return `${uusd} µUSD`;
161
+ return `$${(n / 1_000_000).toFixed(6)}`;
162
+ }
@@ -0,0 +1,5 @@
1
+ import { type ParsedArgs } from '../args.js';
2
+ import { type Reporter } from '../output.js';
3
+ export declare function runUnlink(args: ParsedArgs, out: Reporter): number;
4
+ /** Drop our keys and the marker comment; keep everything else byte-for-byte. */
5
+ export declare function stripEnvKeys(content: string, keys: string[]): string;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `aipay unlink [--dir] [--dry-run] [--json]` — forgets the link: removes
3
+ * .aipay/project.json and our four keys from .env.local. Scaffolded code and the agent
4
+ * guide are the developer's files now; they are listed, not deleted.
5
+ */
6
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { join, resolve } from 'node:path';
8
+ import { flagBool, flagString } from '../args.js';
9
+ import { EXIT } from '../output.js';
10
+ import { ENV_FILE, ENV_KEYS, PROJECT_DIR, projectPath, readProject, VITE_ENV_KEYS, } from '../project.js';
11
+ export function runUnlink(args, out) {
12
+ const dir = resolve(flagString(args.flags, 'dir') ?? '.');
13
+ const dryRun = flagBool(args.flags, 'dry-run');
14
+ const project = readProject(dir);
15
+ if (!project) {
16
+ out.line('info', 'nothing to unlink here');
17
+ out.result({ ok: true, unlinked: false, dir });
18
+ return EXIT.ok;
19
+ }
20
+ const removed = [];
21
+ const envPath = join(dir, ENV_FILE);
22
+ let envChanged = false;
23
+ if (existsSync(envPath)) {
24
+ const before = readFileSync(envPath, 'utf8');
25
+ const after = stripEnvKeys(before, [
26
+ ...Object.values(ENV_KEYS),
27
+ ...Object.values(VITE_ENV_KEYS),
28
+ ]);
29
+ envChanged = after !== before;
30
+ if (envChanged && !dryRun)
31
+ writeFileSync(envPath, after);
32
+ }
33
+ if (!dryRun) {
34
+ rmSync(projectPath(dir));
35
+ try {
36
+ rmSync(join(dir, PROJECT_DIR), { recursive: false });
37
+ }
38
+ catch {
39
+ /* not empty (e.g. .aipay/local/) — leave it */
40
+ }
41
+ }
42
+ removed.push('.aipay/project.json');
43
+ out.line('ok', `${dryRun ? 'would remove' : 'removed'} .aipay/project.json`);
44
+ if (envChanged)
45
+ out.line('ok', `${dryRun ? 'would strip' : 'stripped'} AIPAY_* from ${ENV_FILE}`);
46
+ if (project.scaffold.files.length)
47
+ out.line('info', `kept your files: ${project.scaffold.files.join(', ')}, aipay-agent.md`);
48
+ out.result({
49
+ ok: true,
50
+ unlinked: !dryRun,
51
+ dry_run: dryRun,
52
+ dir,
53
+ removed,
54
+ env_stripped: envChanged,
55
+ kept: project.scaffold.files,
56
+ });
57
+ return EXIT.ok;
58
+ }
59
+ /** Drop our keys and the marker comment; keep everything else byte-for-byte. */
60
+ export function stripEnvKeys(content, keys) {
61
+ const set = new Set(keys);
62
+ const lines = content.split(/\r?\n/);
63
+ const kept = lines.filter((line) => {
64
+ if (line.startsWith('# AI-Pay (written by `aipay init`'))
65
+ return false;
66
+ const m = /^\s*(?:export\s+)?([A-Z0-9_]+)\s*=/.exec(line);
67
+ return !(m && set.has(m[1]));
68
+ });
69
+ // collapse a trailing blank run left behind by the removed block
70
+ while (kept.length > 1 && kept[kept.length - 1] === '' && kept[kept.length - 2] === '')
71
+ kept.pop();
72
+ return kept.join('\n');
73
+ }
@@ -0,0 +1,27 @@
1
+ import type { Framework } from './project.js';
2
+ export type PackageManager = 'pnpm' | 'npm' | 'yarn' | 'bun';
3
+ export interface Detection {
4
+ packageManager: PackageManager | null;
5
+ framework: Framework | null;
6
+ hasPackageJson: boolean;
7
+ typescript: boolean;
8
+ /** Whether @coswic/aipay-sdk is already a dependency. */
9
+ sdkInstalled: boolean;
10
+ /** `src/` exists → scaffold under it, else project root. */
11
+ srcDir: string;
12
+ }
13
+ export interface PackageJsonLike {
14
+ packageManager?: string;
15
+ dependencies?: Record<string, string>;
16
+ devDependencies?: Record<string, string>;
17
+ }
18
+ export declare function detectPackageManager(pkg: PackageJsonLike | null, lockfiles: {
19
+ pnpm: boolean;
20
+ yarn: boolean;
21
+ bun: boolean;
22
+ npm: boolean;
23
+ }): PackageManager | null;
24
+ export declare function detectFramework(pkg: PackageJsonLike | null): Framework | null;
25
+ export declare function detectProject(dir: string): Detection;
26
+ /** The install command for the detected manager (printed in --dry-run, run otherwise). */
27
+ export declare function installCommand(pm: PackageManager, pkgName: string): string[];
package/dist/detect.js ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Project detection: package manager (from lockfile / packageManager field) and web
3
+ * framework (from dependencies). Pure functions over the file contents so they are
4
+ * unit-testable without a filesystem.
5
+ */
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ export function detectPackageManager(pkg, lockfiles) {
9
+ const pm = pkg?.packageManager;
10
+ if (typeof pm === 'string') {
11
+ const name = pm.split('@')[0];
12
+ if (name === 'pnpm' || name === 'npm' || name === 'yarn' || name === 'bun')
13
+ return name;
14
+ }
15
+ if (lockfiles.pnpm)
16
+ return 'pnpm';
17
+ if (lockfiles.bun)
18
+ return 'bun';
19
+ if (lockfiles.yarn)
20
+ return 'yarn';
21
+ if (lockfiles.npm)
22
+ return 'npm';
23
+ return pkg ? 'npm' : null;
24
+ }
25
+ export function detectFramework(pkg) {
26
+ if (!pkg)
27
+ return null;
28
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
29
+ if ('next' in deps)
30
+ return 'next';
31
+ if ('hono' in deps)
32
+ return 'hono';
33
+ if ('fastify' in deps)
34
+ return 'fastify';
35
+ if ('express' in deps)
36
+ return 'express';
37
+ // Browser SPA (React/Vue/Svelte on Vite): PKCE runs in the browser; no server route to write.
38
+ // A project with both a server framework and Vite is treated as the server (routes win above).
39
+ if ('vite' in deps)
40
+ return 'vite';
41
+ return 'node';
42
+ }
43
+ export function detectProject(dir) {
44
+ const pkgPath = join(dir, 'package.json');
45
+ const hasPackageJson = existsSync(pkgPath);
46
+ let pkg = null;
47
+ if (hasPackageJson) {
48
+ try {
49
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
50
+ }
51
+ catch {
52
+ pkg = null;
53
+ }
54
+ }
55
+ const lockfiles = {
56
+ pnpm: existsSync(join(dir, 'pnpm-lock.yaml')),
57
+ yarn: existsSync(join(dir, 'yarn.lock')),
58
+ bun: existsSync(join(dir, 'bun.lockb')) || existsSync(join(dir, 'bun.lock')),
59
+ npm: existsSync(join(dir, 'package-lock.json')),
60
+ };
61
+ const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
62
+ return {
63
+ packageManager: detectPackageManager(pkg, lockfiles),
64
+ framework: detectFramework(pkg),
65
+ hasPackageJson,
66
+ typescript: 'typescript' in deps || existsSync(join(dir, 'tsconfig.json')),
67
+ sdkInstalled: '@coswic/aipay-sdk' in deps,
68
+ srcDir: existsSync(join(dir, 'src')) ? 'src' : '.',
69
+ };
70
+ }
71
+ /** The install command for the detected manager (printed in --dry-run, run otherwise). */
72
+ export function installCommand(pm, pkgName) {
73
+ switch (pm) {
74
+ case 'pnpm':
75
+ return ['pnpm', 'add', pkgName];
76
+ case 'yarn':
77
+ return ['yarn', 'add', pkgName];
78
+ case 'bun':
79
+ return ['bun', 'add', pkgName];
80
+ default:
81
+ return ['npm', 'install', pkgName];
82
+ }
83
+ }
@@ -0,0 +1,14 @@
1
+ import type { AuthStore, StoredAuth } from './auth-store.js';
2
+ export interface ApiResult<T> {
3
+ status: number;
4
+ body: T;
5
+ }
6
+ export interface DeveloperApi {
7
+ auth: StoredAuth;
8
+ request<T = Record<string, unknown>>(method: string, path: string, opts?: {
9
+ body?: unknown;
10
+ headers?: Record<string, string>;
11
+ }): Promise<ApiResult<T>>;
12
+ }
13
+ export declare function openDeveloperApi(store: AuthStore, env?: NodeJS.ProcessEnv): Promise<DeveloperApi>;
14
+ export declare function errorMessage(body: unknown, status: number): string;
@@ -0,0 +1,74 @@
1
+ /**
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.
5
+ */
6
+ import { refreshAccessToken } from '@coswic/aipay-sdk';
7
+ import { gatedFetch, looksGated } from './gate.js';
8
+ import { CliError, EXIT } from './output.js';
9
+ import { GATE_HINT } from './remote.js';
10
+ export async function openDeveloperApi(store, env = process.env) {
11
+ let auth = store.load();
12
+ if (!auth)
13
+ throw new CliError('not_logged_in', 'not signed in — run `aipay login` first', EXIT.problem);
14
+ const f = gatedFetch(env);
15
+ if (auth.expires_at - 30_000 < Date.now())
16
+ auth = await refresh(store, auth, f);
17
+ const api = {
18
+ auth,
19
+ async request(method, path, opts = {}) {
20
+ const call = async (a) => f(new URL(path, a.api_base), {
21
+ method,
22
+ headers: {
23
+ authorization: `Bearer ${a.access_token}`,
24
+ ...(opts.body !== undefined ? { 'content-type': 'application/json' } : {}),
25
+ ...(opts.headers ?? {}),
26
+ },
27
+ ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
28
+ });
29
+ let res = await call(api.auth);
30
+ if (res.status === 401 && api.auth.refresh_token) {
31
+ api.auth = await refresh(store, api.auth, f);
32
+ res = await call(api.auth);
33
+ }
34
+ const text = await res.text();
35
+ if (looksGated(res.status, text))
36
+ throw new CliError('gated', GATE_HINT, EXIT.remote);
37
+ if (res.status === 401)
38
+ throw new CliError('unauthorized', 'the API rejected the developer token — run `aipay login` again', EXIT.remote);
39
+ let body = null;
40
+ try {
41
+ body = text ? JSON.parse(text) : null;
42
+ }
43
+ catch {
44
+ body = { raw: text.slice(0, 500) };
45
+ }
46
+ return { status: res.status, body: body };
47
+ },
48
+ };
49
+ return api;
50
+ }
51
+ async function refresh(store, auth, fetchImpl) {
52
+ if (!auth.refresh_token)
53
+ throw new CliError('expired', 'the developer token expired — run `aipay login` again', EXIT.problem);
54
+ const t = await refreshAccessToken({
55
+ hydraPublicUrl: auth.auth_origin,
56
+ clientId: auth.client_id,
57
+ }, auth.refresh_token, { fetchImpl }).catch(() => {
58
+ throw new CliError('expired', 'could not renew the developer token — run `aipay login` again', EXIT.problem);
59
+ });
60
+ const next = {
61
+ ...auth,
62
+ access_token: t.accessToken,
63
+ refresh_token: t.refreshToken ?? auth.refresh_token,
64
+ expires_at: Date.now() + t.expiresInSeconds * 1000,
65
+ scope: t.scope || auth.scope,
66
+ };
67
+ store.save(next);
68
+ return next;
69
+ }
70
+ export function errorMessage(body, status) {
71
+ const b = (body ?? {});
72
+ const msg = typeof b.message === 'string' ? b.message : typeof b.error === 'string' ? b.error : '';
73
+ return msg ? `${msg} (HTTP ${status})` : `HTTP ${status}`;
74
+ }
@@ -0,0 +1,38 @@
1
+ export declare const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
2
+ export interface DeviceAuthorization {
3
+ deviceCode: string;
4
+ userCode: string;
5
+ verificationUri: string;
6
+ /** Same URL with the user code embedded (RFC 8628 §3.3.1) when the server offers one. */
7
+ verificationUriComplete: string | null;
8
+ expiresInSeconds: number;
9
+ /** Minimum seconds between polls (server-suggested; slow_down adds 5s). */
10
+ intervalSeconds: number;
11
+ }
12
+ export interface DeviceTokens {
13
+ accessToken: string;
14
+ refreshToken: string | null;
15
+ expiresInSeconds: number;
16
+ scope: string;
17
+ }
18
+ export interface DeviceClientConfig {
19
+ authOrigin: string;
20
+ clientId: string;
21
+ scopes: string[];
22
+ audience: string;
23
+ }
24
+ /** POST /oauth2/device/auth — public client, so only client_id/scope/audience are sent. */
25
+ export declare function requestDeviceCode(cfg: DeviceClientConfig, fetchImpl?: typeof fetch): Promise<DeviceAuthorization>;
26
+ export interface PollOptions {
27
+ fetchImpl?: typeof fetch;
28
+ sleep?: (ms: number) => Promise<void>;
29
+ now?: () => number;
30
+ /** Called before each wait (progress dots for humans). */
31
+ onWait?: (secondsLeft: number) => void;
32
+ }
33
+ /**
34
+ * Poll POST /oauth2/token with the device grant until the developer approves, denies, or the
35
+ * code expires. `authorization_pending` → wait `interval`; `slow_down` → interval += 5s
36
+ * (RFC 8628 §3.5); anything else is final.
37
+ */
38
+ export declare function pollDeviceToken(cfg: DeviceClientConfig, auth: DeviceAuthorization, opts?: PollOptions): Promise<DeviceTokens>;
package/dist/device.js ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * RFC 8628 device authorization for `aipay login --device` (consultant §10.2, auth model A2):
3
+ * for machines without a browser (SSH boxes, containers, CI). The CLI asks the
4
+ * authorization server for a device code + a short user code, prints the verification
5
+ * URL and the code, then polls the token endpoint until the developer approves it in
6
+ * a browser on any device. No loopback listener, no ports.
7
+ */
8
+ import { CliError, EXIT } from './output.js';
9
+ export const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
10
+ /** POST /oauth2/device/auth — public client, so only client_id/scope/audience are sent. */
11
+ export async function requestDeviceCode(cfg, fetchImpl = fetch) {
12
+ const form = new URLSearchParams({
13
+ client_id: cfg.clientId,
14
+ scope: cfg.scopes.join(' '),
15
+ audience: cfg.audience,
16
+ });
17
+ let res;
18
+ try {
19
+ res = await fetchImpl(new URL('/oauth2/device/auth', cfg.authOrigin), {
20
+ method: 'POST',
21
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
22
+ body: form.toString(),
23
+ });
24
+ }
25
+ catch (e) {
26
+ throw new CliError('network', `cannot reach ${cfg.authOrigin}: ${e.message}`, EXIT.remote);
27
+ }
28
+ const text = await res.text();
29
+ if (!res.ok) {
30
+ const err = parseOAuthError(text);
31
+ if (err?.error === 'invalid_grant' || err?.error === 'unauthorized_client')
32
+ throw new CliError('device_unsupported', 'this AI-Pay does not allow device sign-in for the CLI yet — use `aipay login` on a machine with a browser', EXIT.remote);
33
+ throw new CliError('remote', `device authorization failed: HTTP ${res.status} ${err?.error ?? ''} ${err?.error_description ?? ''}`.trim(), EXIT.remote);
34
+ }
35
+ const j = JSON.parse(text);
36
+ if (typeof j.device_code !== 'string' ||
37
+ typeof j.user_code !== 'string' ||
38
+ typeof j.verification_uri !== 'string')
39
+ throw new CliError('remote', 'unexpected device authorization response', EXIT.remote);
40
+ return {
41
+ deviceCode: j.device_code,
42
+ userCode: j.user_code,
43
+ verificationUri: j.verification_uri,
44
+ verificationUriComplete: typeof j.verification_uri_complete === 'string' ? j.verification_uri_complete : null,
45
+ expiresInSeconds: typeof j.expires_in === 'number' ? j.expires_in : 600,
46
+ intervalSeconds: typeof j.interval === 'number' && j.interval > 0 ? j.interval : 5,
47
+ };
48
+ }
49
+ /**
50
+ * Poll POST /oauth2/token with the device grant until the developer approves, denies, or the
51
+ * code expires. `authorization_pending` → wait `interval`; `slow_down` → interval += 5s
52
+ * (RFC 8628 §3.5); anything else is final.
53
+ */
54
+ export async function pollDeviceToken(cfg, auth, opts = {}) {
55
+ const fetchImpl = opts.fetchImpl ?? fetch;
56
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
57
+ const now = opts.now ?? Date.now;
58
+ const deadline = now() + auth.expiresInSeconds * 1000;
59
+ let interval = auth.intervalSeconds;
60
+ for (;;) {
61
+ if (now() >= deadline)
62
+ throw new CliError('expired', 'the sign-in code expired — run `aipay login --device` again', EXIT.remote);
63
+ opts.onWait?.(Math.max(0, Math.round((deadline - now()) / 1000)));
64
+ await sleep(interval * 1000);
65
+ const form = new URLSearchParams({
66
+ grant_type: DEVICE_CODE_GRANT,
67
+ device_code: auth.deviceCode,
68
+ client_id: cfg.clientId,
69
+ });
70
+ let res;
71
+ try {
72
+ res = await fetchImpl(new URL('/oauth2/token', cfg.authOrigin), {
73
+ method: 'POST',
74
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
75
+ body: form.toString(),
76
+ });
77
+ }
78
+ catch {
79
+ continue; // transient network blip: keep polling until the deadline
80
+ }
81
+ const text = await res.text();
82
+ if (res.ok) {
83
+ const j = JSON.parse(text);
84
+ if (typeof j.access_token !== 'string')
85
+ throw new CliError('remote', 'token response missing access_token', EXIT.remote);
86
+ return {
87
+ accessToken: j.access_token,
88
+ refreshToken: typeof j.refresh_token === 'string' ? j.refresh_token : null,
89
+ expiresInSeconds: typeof j.expires_in === 'number' ? j.expires_in : 0,
90
+ scope: typeof j.scope === 'string' ? j.scope : cfg.scopes.join(' '),
91
+ };
92
+ }
93
+ const err = parseOAuthError(text);
94
+ switch (err?.error) {
95
+ case 'authorization_pending':
96
+ continue;
97
+ case 'slow_down':
98
+ interval += 5;
99
+ continue;
100
+ case 'expired_token':
101
+ throw new CliError('expired', 'the sign-in code expired — run `aipay login --device` again', EXIT.remote);
102
+ case 'access_denied':
103
+ throw new CliError('denied', 'sign-in was denied in the browser', EXIT.remote);
104
+ default:
105
+ throw new CliError('remote', `token endpoint returned ${res.status} ${err?.error ?? ''} ${err?.error_description ?? ''}`.trim(), EXIT.remote);
106
+ }
107
+ }
108
+ }
109
+ function parseOAuthError(text) {
110
+ try {
111
+ const j = JSON.parse(text);
112
+ return {
113
+ ...(typeof j.error === 'string' ? { error: j.error } : {}),
114
+ ...(typeof j.error_description === 'string'
115
+ ? { error_description: j.error_description }
116
+ : {}),
117
+ };
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ }
package/dist/gate.d.ts ADDED
@@ -0,0 +1,13 @@
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 ADDED
@@ -0,0 +1,29 @@
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.`;