@coswic/aipay 0.2.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 +40 -0
- package/README.md +6 -10
- package/dist/agent-guide.d.ts +18 -0
- package/dist/agent-guide.js +60 -0
- package/dist/args.d.ts +15 -0
- package/dist/args.js +47 -0
- package/dist/auth-store.d.ts +47 -0
- package/dist/auth-store.js +176 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +8 -0
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +18 -0
- package/dist/commands/apps.d.ts +15 -0
- package/dist/commands/apps.js +53 -0
- package/dist/commands/doctor.d.ts +16 -0
- package/dist/commands/doctor.js +145 -0
- package/dist/commands/init.d.ts +18 -0
- package/dist/commands/init.js +265 -0
- package/dist/commands/login.d.ts +34 -0
- package/dist/commands/login.js +187 -0
- package/dist/commands/session.d.ts +6 -0
- package/dist/commands/session.js +62 -0
- package/dist/commands/status.d.ts +3 -0
- package/dist/commands/status.js +24 -0
- package/dist/commands/test.d.ts +12 -0
- package/dist/commands/test.js +159 -0
- package/dist/commands/unlink.d.ts +5 -0
- package/dist/commands/unlink.js +73 -0
- package/dist/detect.d.ts +27 -0
- package/dist/detect.js +83 -0
- package/dist/developer-api.d.ts +14 -0
- package/dist/developer-api.js +73 -0
- package/dist/device.d.ts +38 -0
- package/dist/device.js +122 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +22 -0
- package/dist/loopback.d.ts +37 -0
- package/dist/loopback.js +95 -0
- package/dist/main.d.ts +5 -0
- package/dist/main.js +90 -0
- package/dist/network.d.ts +3 -0
- package/dist/network.js +5 -0
- package/dist/output.d.ts +36 -0
- package/dist/output.js +52 -0
- package/dist/project.d.ts +61 -0
- package/dist/project.js +126 -0
- package/dist/remote.d.ts +21 -0
- package/dist/remote.js +41 -0
- package/dist/setup-events.d.ts +3 -0
- package/dist/setup-events.js +28 -0
- package/dist/templates.d.ts +24 -0
- package/dist/templates.js +333 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +10 -0
- package/package.json +4 -3
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/index.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Programmatic surface (used by tests and by anyone embedding the commands). */
|
|
2
|
+
export { AGENT_GUIDE_FILE, agentGuide } from './agent-guide.js';
|
|
3
|
+
export { type ParsedArgs, parseArgs } from './args.js';
|
|
4
|
+
export { configDir, createAuthStore, fileStore, type StoredAuth } from './auth-store.js';
|
|
5
|
+
export { createApp, runApps } from './commands/apps.js';
|
|
6
|
+
export { collectChecks, runDoctor } from './commands/doctor.js';
|
|
7
|
+
export { DEFAULT_CALLBACK, runInit, SDK_PACKAGE } from './commands/init.js';
|
|
8
|
+
export { fetchCliConfig, managementAuthorizeUrl, resolveApiBase, runLogin, } from './commands/login.js';
|
|
9
|
+
export { runLogout, runWhoami } from './commands/session.js';
|
|
10
|
+
export { runStatus } from './commands/status.js';
|
|
11
|
+
export { formatUusd, pickModel, runTest } from './commands/test.js';
|
|
12
|
+
export { runUnlink, stripEnvKeys } from './commands/unlink.js';
|
|
13
|
+
export { detectFramework, detectPackageManager, detectProject, installCommand } from './detect.js';
|
|
14
|
+
export { openDeveloperApi } from './developer-api.js';
|
|
15
|
+
export { parseCallback, startLoopback } from './loopback.js';
|
|
16
|
+
export { main } from './main.js';
|
|
17
|
+
export { isEmpty404 } from './network.js';
|
|
18
|
+
export { CliError, createReporter, EXIT, type Reporter } from './output.js';
|
|
19
|
+
export { ENV_KEYS, isProjectConfig, mergeEnvFile, mergeGitignore, type ProjectConfig, readProject, writeProject, } from './project.js';
|
|
20
|
+
export { createRemote, docsUrl, type Remote } from './remote.js';
|
|
21
|
+
export { scaffoldFiles, startPathFor } from './templates.js';
|
|
22
|
+
export { cliVersion } from './version.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Programmatic surface (used by tests and by anyone embedding the commands). */
|
|
2
|
+
export { AGENT_GUIDE_FILE, agentGuide } from './agent-guide.js';
|
|
3
|
+
export { parseArgs } from './args.js';
|
|
4
|
+
export { configDir, createAuthStore, fileStore } from './auth-store.js';
|
|
5
|
+
export { createApp, runApps } from './commands/apps.js';
|
|
6
|
+
export { collectChecks, runDoctor } from './commands/doctor.js';
|
|
7
|
+
export { DEFAULT_CALLBACK, runInit, SDK_PACKAGE } from './commands/init.js';
|
|
8
|
+
export { fetchCliConfig, managementAuthorizeUrl, resolveApiBase, runLogin, } from './commands/login.js';
|
|
9
|
+
export { runLogout, runWhoami } from './commands/session.js';
|
|
10
|
+
export { runStatus } from './commands/status.js';
|
|
11
|
+
export { formatUusd, pickModel, runTest } from './commands/test.js';
|
|
12
|
+
export { runUnlink, stripEnvKeys } from './commands/unlink.js';
|
|
13
|
+
export { detectFramework, detectPackageManager, detectProject, installCommand } from './detect.js';
|
|
14
|
+
export { openDeveloperApi } from './developer-api.js';
|
|
15
|
+
export { parseCallback, startLoopback } from './loopback.js';
|
|
16
|
+
export { main } from './main.js';
|
|
17
|
+
export { isEmpty404 } from './network.js';
|
|
18
|
+
export { CliError, createReporter, EXIT } from './output.js';
|
|
19
|
+
export { ENV_KEYS, isProjectConfig, mergeEnvFile, mergeGitignore, readProject, writeProject, } from './project.js';
|
|
20
|
+
export { createRemote, docsUrl } from './remote.js';
|
|
21
|
+
export { scaffoldFiles, startPathFor } from './templates.js';
|
|
22
|
+
export { cliVersion } from './version.js';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopback redirect receiver (RFC 8252 §7.3) for `aipay test`: listens on the exact
|
|
3
|
+
* registered callback URI (host + port + path), captures ONE redirect, answers the
|
|
4
|
+
* browser with a small HTML page, then closes. The authorization code never touches
|
|
5
|
+
* disk; the token exchange happens in the caller's memory.
|
|
6
|
+
*/
|
|
7
|
+
import { type Server } from 'node:http';
|
|
8
|
+
export interface LoopbackTarget {
|
|
9
|
+
host: string;
|
|
10
|
+
port: number;
|
|
11
|
+
path: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Hydra compares loopback redirect URIs per RFC 8252 §7.3: for 127.0.0.1 / [::1] the port
|
|
15
|
+
* is ignored, so the CLI may receive the callback on any free port while the app keeps
|
|
16
|
+
* its registered URI. `localhost` is NOT treated as loopback by the server — exact port.
|
|
17
|
+
*/
|
|
18
|
+
export declare function portIsVariable(target: LoopbackTarget): boolean;
|
|
19
|
+
/** The redirect_uri to send for a receiver bound to `port` (same host + path). */
|
|
20
|
+
export declare function redirectUriFor(target: LoopbackTarget, port: number): string;
|
|
21
|
+
export declare function parseCallback(callback: string): LoopbackTarget;
|
|
22
|
+
export interface LoopbackResult {
|
|
23
|
+
/** Full callback URL as received (query included) — hand it to the SDK's handleCallback. */
|
|
24
|
+
url: string;
|
|
25
|
+
}
|
|
26
|
+
export interface LoopbackHandle {
|
|
27
|
+
/** Resolves with the first request on the callback path; rejects on timeout. */
|
|
28
|
+
waitForCallback: Promise<LoopbackResult>;
|
|
29
|
+
close(): Promise<void>;
|
|
30
|
+
server: Server;
|
|
31
|
+
/** Port actually bound (differs from target.port when target.port is 0 = ephemeral). */
|
|
32
|
+
port: number;
|
|
33
|
+
}
|
|
34
|
+
export declare function startLoopback(target: LoopbackTarget, opts?: {
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
pageHtml?: (ok: boolean) => string;
|
|
37
|
+
}): Promise<LoopbackHandle>;
|
package/dist/loopback.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopback redirect receiver (RFC 8252 §7.3) for `aipay test`: listens on the exact
|
|
3
|
+
* registered callback URI (host + port + path), captures ONE redirect, answers the
|
|
4
|
+
* browser with a small HTML page, then closes. The authorization code never touches
|
|
5
|
+
* disk; the token exchange happens in the caller's memory.
|
|
6
|
+
*/
|
|
7
|
+
import { createServer } from 'node:http';
|
|
8
|
+
/**
|
|
9
|
+
* Hydra compares loopback redirect URIs per RFC 8252 §7.3: for 127.0.0.1 / [::1] the port
|
|
10
|
+
* is ignored, so the CLI may receive the callback on any free port while the app keeps
|
|
11
|
+
* its registered URI. `localhost` is NOT treated as loopback by the server — exact port.
|
|
12
|
+
*/
|
|
13
|
+
export function portIsVariable(target) {
|
|
14
|
+
return target.host === '127.0.0.1' || target.host === '::1';
|
|
15
|
+
}
|
|
16
|
+
/** The redirect_uri to send for a receiver bound to `port` (same host + path). */
|
|
17
|
+
export function redirectUriFor(target, port) {
|
|
18
|
+
const host = target.host === '::1' ? '[::1]' : target.host;
|
|
19
|
+
return `http://${host}:${port}${target.path}`;
|
|
20
|
+
}
|
|
21
|
+
export function parseCallback(callback) {
|
|
22
|
+
const u = new URL(callback);
|
|
23
|
+
if (u.protocol !== 'http:')
|
|
24
|
+
throw new Error(`callback must be http:// on a loopback host (got ${callback})`);
|
|
25
|
+
const host = u.hostname;
|
|
26
|
+
if (host !== '127.0.0.1' && host !== 'localhost' && host !== '[::1]' && host !== '::1')
|
|
27
|
+
throw new Error(`callback host must be a loopback address (got ${host})`);
|
|
28
|
+
const port = u.port ? Number(u.port) : 80;
|
|
29
|
+
return { host: host === '[::1]' ? '::1' : host, port, path: u.pathname || '/' };
|
|
30
|
+
}
|
|
31
|
+
export function startLoopback(target, opts = {}) {
|
|
32
|
+
const timeoutMs = opts.timeoutMs ?? 5 * 60_000;
|
|
33
|
+
const page = opts.pageHtml ?? defaultPage;
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
let settle = null;
|
|
36
|
+
let fail = null;
|
|
37
|
+
const waitForCallback = new Promise((res, rej) => {
|
|
38
|
+
settle = res;
|
|
39
|
+
fail = rej;
|
|
40
|
+
});
|
|
41
|
+
const server = createServer((req, res) => {
|
|
42
|
+
const addr = server.address();
|
|
43
|
+
const boundPort = typeof addr === 'object' && addr ? addr.port : target.port;
|
|
44
|
+
const url = new URL(req.url ?? '/', redirectUriFor(target, boundPort));
|
|
45
|
+
if (url.pathname !== target.path) {
|
|
46
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
47
|
+
res.end('not the callback path');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const ok = !url.searchParams.get('error');
|
|
51
|
+
res.writeHead(200, {
|
|
52
|
+
'content-type': 'text/html; charset=utf-8',
|
|
53
|
+
'cache-control': 'no-store',
|
|
54
|
+
'referrer-policy': 'no-referrer',
|
|
55
|
+
});
|
|
56
|
+
res.end(page(ok));
|
|
57
|
+
settle?.({ url: url.toString() });
|
|
58
|
+
settle = null;
|
|
59
|
+
});
|
|
60
|
+
const timer = setTimeout(() => {
|
|
61
|
+
fail?.(new Error(`no callback received within ${Math.round(timeoutMs / 1000)}s`));
|
|
62
|
+
fail = null;
|
|
63
|
+
}, timeoutMs);
|
|
64
|
+
server.on('error', (err) => {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
reject(err);
|
|
67
|
+
});
|
|
68
|
+
server.listen(target.port, target.host, () => {
|
|
69
|
+
const addr = server.address();
|
|
70
|
+
const port = typeof addr === 'object' && addr ? addr.port : target.port;
|
|
71
|
+
resolve({
|
|
72
|
+
waitForCallback,
|
|
73
|
+
server,
|
|
74
|
+
port,
|
|
75
|
+
close: () => new Promise((done) => {
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
server.close(() => done());
|
|
78
|
+
// Keep-alive connections would otherwise hold the process open.
|
|
79
|
+
server.closeAllConnections?.();
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
function defaultPage(ok) {
|
|
86
|
+
const title = ok ? 'AI-Pay: signed in' : 'AI-Pay: authorization failed';
|
|
87
|
+
const body = ok
|
|
88
|
+
? 'You can close this tab and return to the terminal.'
|
|
89
|
+
: 'The terminal shows the error. You can close this tab.';
|
|
90
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${title}</title>
|
|
91
|
+
<style>body{font:16px/1.5 system-ui,sans-serif;margin:0;display:grid;place-items:center;min-height:100vh;background:#f6f7f9;color:#1b1f24}
|
|
92
|
+
main{padding:32px 40px;border-radius:12px;background:#fff;box-shadow:0 2px 12px rgba(0,0,0,.08);text-align:center}
|
|
93
|
+
h1{font-size:20px;margin:0 0 8px}</style></head>
|
|
94
|
+
<body><main><h1>${title}</h1><p>${body}</p></main></body></html>`;
|
|
95
|
+
}
|
package/dist/main.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
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
|
+
export declare function main(argv: string[], io?: {
|
|
3
|
+
stdout?: (s: string) => void;
|
|
4
|
+
stderr?: (s: string) => void;
|
|
5
|
+
}): Promise<number>;
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { flagBool, parseArgs } from './args.js';
|
|
2
|
+
import { runApps } from './commands/apps.js';
|
|
3
|
+
import { runDoctor } from './commands/doctor.js';
|
|
4
|
+
import { runInit } from './commands/init.js';
|
|
5
|
+
import { runLogin } from './commands/login.js';
|
|
6
|
+
import { runLogout, runWhoami } from './commands/session.js';
|
|
7
|
+
import { runStatus } from './commands/status.js';
|
|
8
|
+
import { runTest } from './commands/test.js';
|
|
9
|
+
import { runUnlink } from './commands/unlink.js';
|
|
10
|
+
import { CliError, createReporter, EXIT } from './output.js';
|
|
11
|
+
import { cliVersion } from './version.js';
|
|
12
|
+
export const HELP = `aipay — AI-Pay CLI
|
|
13
|
+
|
|
14
|
+
Usage
|
|
15
|
+
aipay login [--api <base>] [--device] [--json] developer sign-in for the CLI itself
|
|
16
|
+
aipay whoami [--json] · aipay logout [--json]
|
|
17
|
+
aipay apps list [--json]
|
|
18
|
+
aipay apps create --name <name> [--callback <url>] [--json]
|
|
19
|
+
aipay init --app <client_id> [--auth <origin>] [--api <base>] [--callback <url>] [--name <app name>]
|
|
20
|
+
[--framework next|express|fastify|hono|vite|node] override detection
|
|
21
|
+
[--dir <path>] [--dry-run] [--no-install] [--json]
|
|
22
|
+
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]
|
|
24
|
+
aipay doctor [--offline] [--dir <path>] [--json]
|
|
25
|
+
aipay status [--dir <path>] [--json]
|
|
26
|
+
aipay unlink [--dry-run] [--dir <path>] [--json]
|
|
27
|
+
|
|
28
|
+
login sign in as a developer (loopback PKCE, audience aipay-developer-api); tokens go to the
|
|
29
|
+
OS keychain or a 0600 file — never into the project
|
|
30
|
+
apps list your apps / create one (Idempotency-Key: a retried create never makes a second app)
|
|
31
|
+
init link this project to an app: .aipay/project.json, .env.local, .gitignore,
|
|
32
|
+
a callback route for your framework, aipay-agent.md, and the SDK install
|
|
33
|
+
(--auth/--api are optional once you are signed in; --create makes the app first)
|
|
34
|
+
test real sign-in via a loopback callback (any free port on 127.0.0.1) + one chat completion → receipt
|
|
35
|
+
doctor check the link, files, SDK, and that AI-Pay is reachable
|
|
36
|
+
status what this directory is linked to
|
|
37
|
+
unlink forget the link (keeps your code)
|
|
38
|
+
|
|
39
|
+
Exit codes: 0 ok · 1 problem you can fix · 2 usage · 3 network/auth
|
|
40
|
+
`;
|
|
41
|
+
export async function main(argv, io) {
|
|
42
|
+
const args = parseArgs(argv);
|
|
43
|
+
const json = flagBool(args.flags, 'json');
|
|
44
|
+
const out = createReporter({ json, ...(io ?? {}) });
|
|
45
|
+
if (args.flags.version || args.command === 'version') {
|
|
46
|
+
;
|
|
47
|
+
(io?.stdout ?? ((s) => process.stdout.write(`${s}\n`)))(json ? JSON.stringify({ version: cliVersion() }) : cliVersion());
|
|
48
|
+
return EXIT.ok;
|
|
49
|
+
}
|
|
50
|
+
if (!args.command || args.flags.help || args.command === 'help') {
|
|
51
|
+
;
|
|
52
|
+
(io?.stdout ?? ((s) => process.stdout.write(`${s}\n`)))(HELP);
|
|
53
|
+
return args.command || args.flags.help ? EXIT.ok : EXIT.usage;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
switch (args.command) {
|
|
57
|
+
case 'login':
|
|
58
|
+
return await runLogin(args, out);
|
|
59
|
+
case 'logout':
|
|
60
|
+
return await runLogout(args, out);
|
|
61
|
+
case 'whoami':
|
|
62
|
+
return await runWhoami(args, out);
|
|
63
|
+
case 'apps':
|
|
64
|
+
return await runApps(args, out);
|
|
65
|
+
case 'init':
|
|
66
|
+
return await runInit(args, out);
|
|
67
|
+
case 'test':
|
|
68
|
+
return await runTest(args, out);
|
|
69
|
+
case 'doctor':
|
|
70
|
+
return await runDoctor(args, out);
|
|
71
|
+
case 'status':
|
|
72
|
+
return runStatus(args, out);
|
|
73
|
+
case 'unlink':
|
|
74
|
+
return runUnlink(args, out);
|
|
75
|
+
default:
|
|
76
|
+
throw new CliError('usage', `unknown command "${args.command}" (see \`aipay help\`)`, EXIT.usage);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
if (e instanceof CliError) {
|
|
81
|
+
out.line('bad', e.message);
|
|
82
|
+
out.result({ ok: false, error: { code: e.code, message: e.message } });
|
|
83
|
+
return e.exitCode;
|
|
84
|
+
}
|
|
85
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
86
|
+
out.line('bad', msg);
|
|
87
|
+
out.result({ ok: false, error: { code: 'unexpected', message: msg } });
|
|
88
|
+
return EXIT.problem;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -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/output.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output channel: human-readable lines on stderr-ish stdout, or one JSON document when
|
|
3
|
+
* `--json` is set (agents parse it; humans read the other one). Both go to stdout so
|
|
4
|
+
* `aipay status --json | jq` works; diagnostics that must never pollute JSON go to stderr.
|
|
5
|
+
*/
|
|
6
|
+
export type Level = 'ok' | 'warn' | 'bad' | 'info';
|
|
7
|
+
export interface Reporter {
|
|
8
|
+
readonly json: boolean;
|
|
9
|
+
line(level: Level, text: string): void;
|
|
10
|
+
kv(label: string, value: string): void;
|
|
11
|
+
blank(): void;
|
|
12
|
+
/** Final structured result — printed as JSON in --json mode, ignored otherwise. */
|
|
13
|
+
result(doc: Record<string, unknown>): void;
|
|
14
|
+
/** Debug/diagnostic text — always stderr, never in JSON. */
|
|
15
|
+
debug(text: string): void;
|
|
16
|
+
}
|
|
17
|
+
export declare function createReporter(opts: {
|
|
18
|
+
json: boolean;
|
|
19
|
+
stdout?: (s: string) => void;
|
|
20
|
+
stderr?: (s: string) => void;
|
|
21
|
+
}): Reporter;
|
|
22
|
+
/** Exit codes are part of the contract (agents branch on them). */
|
|
23
|
+
export declare const EXIT: {
|
|
24
|
+
readonly ok: 0;
|
|
25
|
+
/** Something in the project/config is wrong and the user can fix it. */
|
|
26
|
+
readonly problem: 1;
|
|
27
|
+
/** Bad invocation (unknown command/flag, missing required flag). */
|
|
28
|
+
readonly usage: 2;
|
|
29
|
+
/** Network/auth failure talking to AI-Pay. */
|
|
30
|
+
readonly remote: 3;
|
|
31
|
+
};
|
|
32
|
+
export declare class CliError extends Error {
|
|
33
|
+
readonly exitCode: number;
|
|
34
|
+
readonly code: string;
|
|
35
|
+
constructor(code: string, message: string, exitCode?: number);
|
|
36
|
+
}
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const GLYPH = { ok: '✔', warn: '⚠', bad: '✘', info: '·' };
|
|
2
|
+
export function createReporter(opts) {
|
|
3
|
+
const stdout = opts.stdout ?? ((s) => process.stdout.write(`${s}\n`));
|
|
4
|
+
const stderr = opts.stderr ?? ((s) => process.stderr.write(`${s}\n`));
|
|
5
|
+
const json = opts.json;
|
|
6
|
+
return {
|
|
7
|
+
json,
|
|
8
|
+
line(level, text) {
|
|
9
|
+
if (json)
|
|
10
|
+
return;
|
|
11
|
+
stdout(`${GLYPH[level]} ${text}`);
|
|
12
|
+
},
|
|
13
|
+
kv(label, value) {
|
|
14
|
+
if (json)
|
|
15
|
+
return;
|
|
16
|
+
stdout(` ${label.padEnd(14)} ${value}`);
|
|
17
|
+
},
|
|
18
|
+
blank() {
|
|
19
|
+
if (json)
|
|
20
|
+
return;
|
|
21
|
+
stdout('');
|
|
22
|
+
},
|
|
23
|
+
result(doc) {
|
|
24
|
+
if (!json)
|
|
25
|
+
return;
|
|
26
|
+
stdout(JSON.stringify(doc, null, 2));
|
|
27
|
+
},
|
|
28
|
+
debug(text) {
|
|
29
|
+
stderr(text);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** Exit codes are part of the contract (agents branch on them). */
|
|
34
|
+
export const EXIT = {
|
|
35
|
+
ok: 0,
|
|
36
|
+
/** Something in the project/config is wrong and the user can fix it. */
|
|
37
|
+
problem: 1,
|
|
38
|
+
/** Bad invocation (unknown command/flag, missing required flag). */
|
|
39
|
+
usage: 2,
|
|
40
|
+
/** Network/auth failure talking to AI-Pay. */
|
|
41
|
+
remote: 3,
|
|
42
|
+
};
|
|
43
|
+
export class CliError extends Error {
|
|
44
|
+
exitCode;
|
|
45
|
+
code;
|
|
46
|
+
constructor(code, message, exitCode = EXIT.problem) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = 'CliError';
|
|
49
|
+
this.code = code;
|
|
50
|
+
this.exitCode = exitCode;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export interface ProjectConfig {
|
|
2
|
+
/** Schema version of this file. */
|
|
3
|
+
version: 1;
|
|
4
|
+
app: {
|
|
5
|
+
client_id: string;
|
|
6
|
+
name: string | null;
|
|
7
|
+
};
|
|
8
|
+
/** Authorization server origin (Hydra public), e.g. https://auth.ai-pay.example */
|
|
9
|
+
auth_origin: string;
|
|
10
|
+
/** API base for the SDK client, e.g. https://api.ai-pay.example */
|
|
11
|
+
api_base: string;
|
|
12
|
+
/** Registered loopback callback the CLI and the sample route use. */
|
|
13
|
+
callback: string;
|
|
14
|
+
/** What `aipay init` detected/scaffolded, for `doctor`/`status` and for humans. */
|
|
15
|
+
scaffold: {
|
|
16
|
+
package_manager: 'pnpm' | 'npm' | 'yarn' | 'bun' | null;
|
|
17
|
+
framework: Framework | null;
|
|
18
|
+
files: string[];
|
|
19
|
+
};
|
|
20
|
+
linked_at: string;
|
|
21
|
+
}
|
|
22
|
+
export type Framework = 'next' | 'express' | 'fastify' | 'hono' | 'vite' | 'node';
|
|
23
|
+
/** Accepted by `--framework`; detection picks server frameworks first, then Vite, then plain Node. */
|
|
24
|
+
export declare const FRAMEWORKS: readonly Framework[];
|
|
25
|
+
export declare function isFramework(v: unknown): v is Framework;
|
|
26
|
+
export declare const PROJECT_DIR = ".aipay";
|
|
27
|
+
export declare const PROJECT_FILE = "project.json";
|
|
28
|
+
export declare const ENV_FILE = ".env.local";
|
|
29
|
+
export declare function projectPath(dir: string): string;
|
|
30
|
+
export declare function readProject(dir: string): ProjectConfig | null;
|
|
31
|
+
export declare function writeProject(dir: string, cfg: ProjectConfig): string;
|
|
32
|
+
export declare function isProjectConfig(v: unknown): v is ProjectConfig;
|
|
33
|
+
/** Env var names the SDK samples and the scaffolded route read. Public values only. */
|
|
34
|
+
export declare const ENV_KEYS: {
|
|
35
|
+
readonly clientId: 'AIPAY_CLIENT_ID';
|
|
36
|
+
readonly authOrigin: 'AIPAY_AUTH_ORIGIN';
|
|
37
|
+
readonly apiBase: 'AIPAY_API_BASE';
|
|
38
|
+
readonly callback: 'AIPAY_CALLBACK_URL';
|
|
39
|
+
};
|
|
40
|
+
/** Vite only exposes VITE_-prefixed values to browser code, so a Vite project gets the same four
|
|
41
|
+
* public values twice: the plain names (for any server-side tooling) and these aliases. */
|
|
42
|
+
export declare const VITE_ENV_KEYS: {
|
|
43
|
+
readonly clientId: 'VITE_AIPAY_CLIENT_ID';
|
|
44
|
+
readonly authOrigin: 'VITE_AIPAY_AUTH_ORIGIN';
|
|
45
|
+
readonly apiBase: 'VITE_AIPAY_API_BASE';
|
|
46
|
+
readonly callback: 'VITE_AIPAY_CALLBACK_URL';
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Merge our keys into `.env.local` without touching anything else in it: existing lines
|
|
50
|
+
* for our keys are replaced in place, missing ones appended under a marker comment.
|
|
51
|
+
* Returns the keys that actually changed (so `--dry-run` and `status` can report).
|
|
52
|
+
*/
|
|
53
|
+
export declare function mergeEnvFile(existing: string | null, values: Record<string, string>): {
|
|
54
|
+
content: string;
|
|
55
|
+
changed: string[];
|
|
56
|
+
};
|
|
57
|
+
/** Append missing ignore entries; never reorder or remove anything. */
|
|
58
|
+
export declare function mergeGitignore(existing: string | null, entries: string[]): {
|
|
59
|
+
content: string;
|
|
60
|
+
added: string[];
|
|
61
|
+
};
|