@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +6 -10
  3. package/dist/agent-guide.d.ts +18 -0
  4. package/dist/agent-guide.js +60 -0
  5. package/dist/args.d.ts +15 -0
  6. package/dist/args.js +47 -0
  7. package/dist/auth-store.d.ts +47 -0
  8. package/dist/auth-store.js +176 -0
  9. package/dist/bin.d.ts +2 -0
  10. package/dist/bin.js +8 -0
  11. package/dist/browser.d.ts +1 -0
  12. package/dist/browser.js +18 -0
  13. package/dist/commands/apps.d.ts +15 -0
  14. package/dist/commands/apps.js +53 -0
  15. package/dist/commands/doctor.d.ts +16 -0
  16. package/dist/commands/doctor.js +145 -0
  17. package/dist/commands/init.d.ts +18 -0
  18. package/dist/commands/init.js +265 -0
  19. package/dist/commands/login.d.ts +34 -0
  20. package/dist/commands/login.js +187 -0
  21. package/dist/commands/session.d.ts +6 -0
  22. package/dist/commands/session.js +62 -0
  23. package/dist/commands/status.d.ts +3 -0
  24. package/dist/commands/status.js +24 -0
  25. package/dist/commands/test.d.ts +12 -0
  26. package/dist/commands/test.js +159 -0
  27. package/dist/commands/unlink.d.ts +5 -0
  28. package/dist/commands/unlink.js +73 -0
  29. package/dist/detect.d.ts +27 -0
  30. package/dist/detect.js +83 -0
  31. package/dist/developer-api.d.ts +14 -0
  32. package/dist/developer-api.js +73 -0
  33. package/dist/device.d.ts +38 -0
  34. package/dist/device.js +122 -0
  35. package/dist/index.d.ts +22 -0
  36. package/dist/index.js +22 -0
  37. package/dist/loopback.d.ts +37 -0
  38. package/dist/loopback.js +95 -0
  39. package/dist/main.d.ts +5 -0
  40. package/dist/main.js +90 -0
  41. package/dist/network.d.ts +3 -0
  42. package/dist/network.js +5 -0
  43. package/dist/output.d.ts +36 -0
  44. package/dist/output.js +52 -0
  45. package/dist/project.d.ts +61 -0
  46. package/dist/project.js +126 -0
  47. package/dist/remote.d.ts +21 -0
  48. package/dist/remote.js +41 -0
  49. package/dist/setup-events.d.ts +3 -0
  50. package/dist/setup-events.js +28 -0
  51. package/dist/templates.d.ts +24 -0
  52. package/dist/templates.js +333 -0
  53. package/dist/version.d.ts +1 -0
  54. package/dist/version.js +10 -0
  55. package/package.json +4 -3
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Local artifacts (consultant report §6.5):
3
+ * .aipay/project.json — committable: which app this project is linked to (no secrets)
4
+ * .env.local — NOT committed: only public values (client_id, URLs)
5
+ * .gitignore — gets `.env.local` and `.aipay/local/` appended once
6
+ *
7
+ * Tokens live only in memory for the duration of `aipay test`. Nothing here writes them.
8
+ */
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ /** Accepted by `--framework`; detection picks server frameworks first, then Vite, then plain Node. */
12
+ export const FRAMEWORKS = [
13
+ 'next',
14
+ 'express',
15
+ 'fastify',
16
+ 'hono',
17
+ 'vite',
18
+ 'node',
19
+ ];
20
+ export function isFramework(v) {
21
+ return typeof v === 'string' && FRAMEWORKS.includes(v);
22
+ }
23
+ export const PROJECT_DIR = '.aipay';
24
+ export const PROJECT_FILE = 'project.json';
25
+ export const ENV_FILE = '.env.local';
26
+ export function projectPath(dir) {
27
+ return join(dir, PROJECT_DIR, PROJECT_FILE);
28
+ }
29
+ export function readProject(dir) {
30
+ const p = projectPath(dir);
31
+ if (!existsSync(p))
32
+ return null;
33
+ const raw = readFileSync(p, 'utf8');
34
+ const parsed = JSON.parse(raw);
35
+ if (!isProjectConfig(parsed))
36
+ throw new Error(`${p} is not a valid AI-Pay project file`);
37
+ return parsed;
38
+ }
39
+ export function writeProject(dir, cfg) {
40
+ mkdirSync(join(dir, PROJECT_DIR), { recursive: true });
41
+ const p = projectPath(dir);
42
+ writeFileSync(p, `${JSON.stringify(cfg, null, 2)}\n`);
43
+ return p;
44
+ }
45
+ export function isProjectConfig(v) {
46
+ if (!v || typeof v !== 'object')
47
+ return false;
48
+ const o = v;
49
+ const app = o.app;
50
+ return (o.version === 1 &&
51
+ !!app &&
52
+ typeof app.client_id === 'string' &&
53
+ typeof o.auth_origin === 'string' &&
54
+ typeof o.api_base === 'string' &&
55
+ typeof o.callback === 'string');
56
+ }
57
+ /** Env var names the SDK samples and the scaffolded route read. Public values only. */
58
+ export const ENV_KEYS = {
59
+ clientId: 'AIPAY_CLIENT_ID',
60
+ authOrigin: 'AIPAY_AUTH_ORIGIN',
61
+ apiBase: 'AIPAY_API_BASE',
62
+ callback: 'AIPAY_CALLBACK_URL',
63
+ };
64
+ /** Vite only exposes VITE_-prefixed values to browser code, so a Vite project gets the same four
65
+ * public values twice: the plain names (for any server-side tooling) and these aliases. */
66
+ export const VITE_ENV_KEYS = {
67
+ clientId: 'VITE_AIPAY_CLIENT_ID',
68
+ authOrigin: 'VITE_AIPAY_AUTH_ORIGIN',
69
+ apiBase: 'VITE_AIPAY_API_BASE',
70
+ callback: 'VITE_AIPAY_CALLBACK_URL',
71
+ };
72
+ /**
73
+ * Merge our keys into `.env.local` without touching anything else in it: existing lines
74
+ * for our keys are replaced in place, missing ones appended under a marker comment.
75
+ * Returns the keys that actually changed (so `--dry-run` and `status` can report).
76
+ */
77
+ export function mergeEnvFile(existing, values) {
78
+ const lines = existing ? existing.split(/\r?\n/) : [];
79
+ if (lines.length && lines[lines.length - 1] === '')
80
+ lines.pop();
81
+ const changed = [];
82
+ const seen = new Set();
83
+ const out = lines.map((line) => {
84
+ const m = /^\s*(?:export\s+)?([A-Z0-9_]+)\s*=(.*)$/.exec(line);
85
+ if (!m)
86
+ return line;
87
+ const key = m[1];
88
+ if (!(key in values))
89
+ return line;
90
+ seen.add(key);
91
+ const want = values[key];
92
+ if (unquote(m[2] ?? '') === want)
93
+ return line;
94
+ changed.push(key);
95
+ return `${key}=${want}`;
96
+ });
97
+ const missing = Object.keys(values).filter((k) => !seen.has(k));
98
+ if (missing.length) {
99
+ if (out.length)
100
+ out.push('');
101
+ out.push('# AI-Pay (written by `aipay init`; public values, safe to keep local)');
102
+ for (const k of missing) {
103
+ out.push(`${k}=${values[k]}`);
104
+ changed.push(k);
105
+ }
106
+ }
107
+ return { content: `${out.join('\n')}\n`, changed };
108
+ }
109
+ function unquote(s) {
110
+ const t = s.trim();
111
+ if (t.length >= 2 &&
112
+ ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))))
113
+ return t.slice(1, -1);
114
+ return t;
115
+ }
116
+ /** Append missing ignore entries; never reorder or remove anything. */
117
+ export function mergeGitignore(existing, entries) {
118
+ const lines = existing ? existing.split(/\r?\n/) : [];
119
+ const have = new Set(lines.map((l) => l.trim()));
120
+ const added = entries.filter((e) => !have.has(e));
121
+ if (!added.length)
122
+ return { content: existing ?? '', added };
123
+ const base = existing ? (existing.endsWith('\n') ? existing : `${existing}\n`) : '';
124
+ const block = `${base}${base ? '\n' : ''}# AI-Pay local files (never commit)\n${added.join('\n')}\n`;
125
+ return { content: block, added };
126
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Everything that talks to AI-Pay over the network, in one place so the commands stay
3
+ * readable and tests can stub it.
4
+ */
5
+ import { EMPTY_404_HINT } from './network.js';
6
+ export interface Remote {
7
+ fetchDocs(apiBase: string, locale?: 'en' | 'zh-Hant'): Promise<{
8
+ markdown: string | null;
9
+ empty404: boolean;
10
+ status: number;
11
+ }>;
12
+ probe(url: string): Promise<{
13
+ ok: boolean;
14
+ status: number;
15
+ empty404: boolean;
16
+ error?: string;
17
+ }>;
18
+ }
19
+ export declare function createRemote(): Remote;
20
+ export declare function docsUrl(apiBase: string, locale?: 'en' | 'zh-Hant'): string;
21
+ export { EMPTY_404_HINT };
package/dist/remote.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Everything that talks to AI-Pay over the network, in one place so the commands stay
3
+ * readable and tests can stub it.
4
+ */
5
+ import { EMPTY_404_HINT, isEmpty404 } from './network.js';
6
+ import { CliError, EXIT } from './output.js';
7
+ export function createRemote() {
8
+ const f = fetch;
9
+ return {
10
+ async fetchDocs(apiBase, locale = 'en') {
11
+ const url = docsUrl(apiBase, locale);
12
+ let res;
13
+ try {
14
+ res = await f(url, { headers: { accept: 'text/markdown, text/plain' } });
15
+ }
16
+ catch (e) {
17
+ throw new CliError('network', `cannot reach ${url}: ${e.message}. Check the endpoint URL and network connectivity, then retry.`, EXIT.remote);
18
+ }
19
+ const text = await res.text();
20
+ if (res.ok)
21
+ return { markdown: text, empty404: false, status: res.status };
22
+ return { markdown: null, empty404: isEmpty404(res.status, text), status: res.status };
23
+ },
24
+ async probe(url) {
25
+ try {
26
+ const res = await f(url, { method: 'GET', redirect: 'manual' });
27
+ const text = await res.text();
28
+ return { ok: res.ok, status: res.status, empty404: isEmpty404(res.status, text) };
29
+ }
30
+ catch (e) {
31
+ return { ok: false, status: 0, empty404: false, error: e.message };
32
+ }
33
+ },
34
+ };
35
+ }
36
+ export function docsUrl(apiBase, locale = 'en') {
37
+ const u = new URL('/docs.md', apiBase);
38
+ u.searchParams.set('lang', locale);
39
+ return u.toString();
40
+ }
41
+ export { EMPTY_404_HINT };
@@ -0,0 +1,3 @@
1
+ import type { Reporter } from './output.js';
2
+ export type SetupEventKind = 'cli.init' | 'cli.test' | 'cli.doctor';
3
+ export declare function reportSetupEvent(appId: string, kind: SetupEventKind, detail: Record<string, string | number | boolean | string[]>, out: Reporter, env?: NodeJS.ProcessEnv): Promise<boolean>;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Setup-session events (consultant §6.3): after `init` / `test` the CLI tells the Console
3
+ * what happened so the "waiting for the tool" line turns into "the CLI finished init".
4
+ * Best effort and strictly opt-in by state: only when the developer is signed in with the
5
+ * CLI (`aipay login`); never blocks the command, never sends tokens or file contents.
6
+ */
7
+ import { createAuthStore } from './auth-store.js';
8
+ import { openDeveloperApi } from './developer-api.js';
9
+ export async function reportSetupEvent(appId, kind, detail, out, env = process.env) {
10
+ const store = createAuthStore(env);
11
+ if (!store.load())
12
+ return false; // not signed in with the CLI → the Console still sees server facts
13
+ try {
14
+ const api = await openDeveloperApi(store);
15
+ const res = await api.request('POST', `/v1/developer/apps/${encodeURIComponent(appId)}/setup-events`, {
16
+ body: { kind, detail },
17
+ });
18
+ if (res.status === 201) {
19
+ out.line('info', `reported ${kind} to the Console`);
20
+ return true;
21
+ }
22
+ out.debug(`setup event ${kind} not recorded (HTTP ${res.status})`);
23
+ }
24
+ catch (e) {
25
+ out.debug(`setup event ${kind} skipped: ${e.message}`);
26
+ }
27
+ return false;
28
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Scaffold templates: one `aipay` helper module (sign-in flow + first completion) and a
3
+ * thin wiring file per framework. Authored twice (TypeScript / JavaScript) so nothing is
4
+ * generated by stripping types. The callback path is the one registered in the Console
5
+ * for the app (default /auth/aipay/callback); the start path sits next to it.
6
+ *
7
+ * Sessions are the developer's business — the template keeps pending flows and signed-in
8
+ * users in memory with a loud comment, which is correct for a dev server and obviously
9
+ * wrong for production.
10
+ */
11
+ import type { Framework } from './project.js';
12
+ export interface TemplateFile {
13
+ /** Path relative to the project root. */
14
+ path: string;
15
+ content: string;
16
+ }
17
+ export interface TemplateInput {
18
+ framework: Framework;
19
+ typescript: boolean;
20
+ srcDir: string;
21
+ callbackPath: string;
22
+ }
23
+ export declare function startPathFor(callbackPath: string): string;
24
+ export declare function scaffoldFiles(input: TemplateInput): TemplateFile[];
@@ -0,0 +1,333 @@
1
+ export function startPathFor(callbackPath) {
2
+ return callbackPath.replace(/\/[^/]*$/, '/start');
3
+ }
4
+ export function scaffoldFiles(input) {
5
+ const ext = input.typescript ? 'ts' : 'js';
6
+ const startPath = startPathFor(input.callbackPath);
7
+ const core = {
8
+ path: `${prefix(input.srcDir)}aipay.${ext}`,
9
+ content: coreModule(input.typescript),
10
+ };
11
+ switch (input.framework) {
12
+ case 'next':
13
+ return [
14
+ core,
15
+ { path: `app${startPath}/route.${ext}`, content: nextStart(input) },
16
+ { path: `app${input.callbackPath}/route.${ext}`, content: nextCallback(input) },
17
+ ];
18
+ case 'express':
19
+ return [
20
+ core,
21
+ {
22
+ path: `${prefix(input.srcDir)}aipay-routes.${ext}`,
23
+ content: expressRoutes(input, startPath),
24
+ },
25
+ ];
26
+ case 'fastify':
27
+ return [
28
+ core,
29
+ {
30
+ path: `${prefix(input.srcDir)}aipay-routes.${ext}`,
31
+ content: fastifyRoutes(input, startPath),
32
+ },
33
+ ];
34
+ case 'hono':
35
+ return [
36
+ core,
37
+ {
38
+ path: `${prefix(input.srcDir)}aipay-routes.${ext}`,
39
+ content: honoRoutes(input, startPath),
40
+ },
41
+ ];
42
+ case 'vite':
43
+ // Browser SPA: no server, so the helper module itself runs in the page (PKCE public
44
+ // client; tokens live in sessionStorage for the tab) and a boot file handles the callback.
45
+ return [
46
+ { path: `${prefix(input.srcDir)}aipay.${ext}`, content: browserModule(input) },
47
+ { path: `${prefix(input.srcDir)}aipay-boot.${ext}`, content: viteBoot(input) },
48
+ ];
49
+ default:
50
+ return [
51
+ core,
52
+ {
53
+ path: `${prefix(input.srcDir)}aipay-routes.${ext}`,
54
+ content: nodeRoutes(input, startPath),
55
+ },
56
+ ];
57
+ }
58
+ }
59
+ function prefix(srcDir) {
60
+ return srcDir === '.' ? '' : `${srcDir.replace(/\/$/, '')}/`;
61
+ }
62
+ const ENV_HEADER = `// Reads the public values \`aipay init\` wrote to .env.local (load them with your usual
63
+ // dotenv/framework mechanism). No secret exists: the app is a public PKCE client.`;
64
+ function coreModule(ts) {
65
+ const t = (typed, plain) => (ts ? typed : plain);
66
+ return `${ENV_HEADER}
67
+ import {
68
+ AIPayClient,
69
+ createConnectUrl,
70
+ getUserInfo,
71
+ handleCallback,${t('\n type TokenSet,', '')}
72
+ } from '@coswic/aipay-sdk'
73
+
74
+ const cfg = {
75
+ hydraPublicUrl: process.env.AIPAY_AUTH_ORIGIN${t(' as string', '')},
76
+ clientId: process.env.AIPAY_CLIENT_ID${t(' as string', '')},
77
+ redirectUri: process.env.AIPAY_CALLBACK_URL${t(' as string', '')},
78
+ }
79
+ const apiBase = process.env.AIPAY_API_BASE${t(' as string', '')}
80
+
81
+ // DEV ONLY: in-memory stores. Replace both with your session store before shipping.
82
+ const pendingFlows = new Map${t('<string, { codeVerifier: string; expires: number }>', '')}()
83
+ const sessions = new Map${t('<string, TokenSet>', '')}()
84
+
85
+ /** Step 1 — build the AI-Pay sign-in URL; remember the PKCE verifier under the state. */
86
+ export async function beginSignIn()${t(': Promise<string>', '')} {
87
+ const { url, state, codeVerifier } = await createConnectUrl(cfg)
88
+ pendingFlows.set(state, { codeVerifier, expires: Date.now() + 10 * 60_000 })
89
+ return url
90
+ }
91
+
92
+ /** Step 2 — on the callback, exchange the code (state + PKCE are verified by the SDK). */
93
+ export async function finishSignIn(callbackUrl${t(': string', '')})${t(': Promise<{ sessionId: string; sub: string; email: string | null }>', '')} {
94
+ const state = new URL(callbackUrl).searchParams.get('state') ?? ''
95
+ const pending = pendingFlows.get(state)
96
+ pendingFlows.delete(state)
97
+ const saved = pending && pending.expires > Date.now() ? { state, codeVerifier: pending.codeVerifier } : null
98
+ const tokens = await handleCallback(cfg, callbackUrl, saved)
99
+ const user = await getUserInfo(cfg, tokens.accessToken)
100
+ const sessionId = crypto.randomUUID()
101
+ sessions.set(sessionId, tokens)
102
+ // user.sub is the stable per-app id of this user — key your accounts on it, not on email.
103
+ return { sessionId, sub: user.sub, email: user.email ?? null }
104
+ }
105
+
106
+ /** Step 3 — call a model on the user's behalf; the response carries the billing receipt. */
107
+ export async function firstCompletion(sessionId${t(': string', '')}, model${t(': string', '')}) {
108
+ const tokens = sessions.get(sessionId)
109
+ if (!tokens) throw new Error('not signed in')
110
+ const client = new AIPayClient({ baseUrl: apiBase, accessToken: tokens.accessToken })
111
+ const res = await client.chat.completions.create({
112
+ model,
113
+ messages: [{ role: 'user', content: 'Say hello in five words.' }],
114
+ max_tokens: 32,
115
+ })
116
+ return { text: res.choices[0]?.message.content ?? '', receipt: res.aipay }
117
+ }
118
+
119
+ export function sessionCookie(sessionId${t(': string', '')})${t(': string', '')} {
120
+ return \`aipay_session=\${sessionId}; Path=/; HttpOnly; SameSite=Lax\`
121
+ }
122
+
123
+ export function sessionFromCookie(cookieHeader${t(': string | null | undefined', '')})${t(': string | null', '')} {
124
+ const m = /(?:^|;\\s*)aipay_session=([^;]+)/.exec(cookieHeader ?? '')
125
+ return m ? m[1]${t(' ?? null', '')} : null
126
+ }
127
+ `;
128
+ }
129
+ function nextStart(input) {
130
+ const imp = importPath(input, 'app/x/y/z'); // depth: app/<a>/<b>/<start>/route
131
+ return `import { beginSignIn } from '${imp}'
132
+
133
+ export async function GET() {
134
+ const url = await beginSignIn()
135
+ return Response.redirect(url, 302)
136
+ }
137
+ `;
138
+ }
139
+ function nextCallback(input) {
140
+ const imp = importPath(input, 'app/x/y/z');
141
+ return `import { finishSignIn, sessionCookie } from '${imp}'
142
+
143
+ export async function GET(request${input.typescript ? ': Request' : ''}) {
144
+ const { sessionId, email } = await finishSignIn(request.url)
145
+ return new Response(\`Signed in as \${email ?? 'user'}. You can now call firstCompletion(sessionId, model).\`, {
146
+ status: 200,
147
+ headers: { 'set-cookie': sessionCookie(sessionId), 'content-type': 'text/plain; charset=utf-8' },
148
+ })
149
+ }
150
+ `;
151
+ }
152
+ /** Relative import from a route file to the core module (depth-aware, extension-less). */
153
+ function importPath(input, routeDir) {
154
+ const depth = routeDir.split('/').length;
155
+ const up = '../'.repeat(depth);
156
+ const core = `${prefix(input.srcDir)}aipay`;
157
+ return `${up}${core}${input.typescript ? '' : '.js'}`;
158
+ }
159
+ function expressRoutes(input, startPath) {
160
+ const ts = input.typescript;
161
+ return `import { Router } from 'express'
162
+ import { beginSignIn, finishSignIn, sessionCookie } from './aipay${ts ? '' : '.js'}'
163
+
164
+ export const aipayRouter = Router()
165
+
166
+ aipayRouter.get('${startPath}', async (_req, res) => {
167
+ res.redirect(await beginSignIn())
168
+ })
169
+
170
+ aipayRouter.get('${input.callbackPath}', async (req, res) => {
171
+ const full = new URL(req.originalUrl, \`\${req.protocol}://\${req.get('host')}\`).toString()
172
+ const { sessionId, email } = await finishSignIn(full)
173
+ res.setHeader('set-cookie', sessionCookie(sessionId))
174
+ res.type('text/plain').send(\`Signed in as \${email ?? 'user'}.\`)
175
+ })
176
+ // app.use(aipayRouter)
177
+ `;
178
+ }
179
+ function fastifyRoutes(input, startPath) {
180
+ const ts = input.typescript;
181
+ return `${ts ? "import type { FastifyInstance } from 'fastify'\n" : ''}import { beginSignIn, finishSignIn, sessionCookie } from './aipay${ts ? '' : '.js'}'
182
+
183
+ export async function aipayRoutes(app${ts ? ': FastifyInstance' : ''}) {
184
+ app.get('${startPath}', async (_req, reply) => reply.redirect(await beginSignIn()))
185
+ app.get('${input.callbackPath}', async (req, reply) => {
186
+ const full = new URL(req.url, \`\${req.protocol}://\${req.headers.host}\`).toString()
187
+ const { sessionId, email } = await finishSignIn(full)
188
+ reply.header('set-cookie', sessionCookie(sessionId))
189
+ return \`Signed in as \${email ?? 'user'}.\`
190
+ })
191
+ }
192
+ // await app.register(aipayRoutes)
193
+ `;
194
+ }
195
+ function honoRoutes(input, startPath) {
196
+ const ts = input.typescript;
197
+ return `import { Hono } from 'hono'
198
+ import { beginSignIn, finishSignIn, sessionCookie } from './aipay${ts ? '' : '.js'}'
199
+
200
+ export const aipay = new Hono()
201
+
202
+ aipay.get('${startPath}', async (c) => c.redirect(await beginSignIn()))
203
+
204
+ aipay.get('${input.callbackPath}', async (c) => {
205
+ const { sessionId, email } = await finishSignIn(c.req.url)
206
+ c.header('set-cookie', sessionCookie(sessionId))
207
+ return c.text(\`Signed in as \${email ?? 'user'}.\`)
208
+ })
209
+ // app.route('/', aipay)
210
+ `;
211
+ }
212
+ function nodeRoutes(input, startPath) {
213
+ const ts = input.typescript;
214
+ return `${ts ? "import type { IncomingMessage, ServerResponse } from 'node:http'\n" : ''}import { beginSignIn, finishSignIn, sessionCookie } from './aipay${ts ? '' : '.js'}'
215
+
216
+ /** Plug into your http server: \`if (await handleAIPay(req, res)) return\` */
217
+ export async function handleAIPay(req${ts ? ': IncomingMessage' : ''}, res${ts ? ': ServerResponse' : ''})${ts ? ': Promise<boolean>' : ''} {
218
+ const url = new URL(req.url ?? '/', \`http://\${req.headers.host}\`)
219
+ if (url.pathname === '${startPath}') {
220
+ res.writeHead(302, { location: await beginSignIn() })
221
+ res.end()
222
+ return true
223
+ }
224
+ if (url.pathname === '${input.callbackPath}') {
225
+ const { sessionId, email } = await finishSignIn(url.toString())
226
+ res.writeHead(200, { 'set-cookie': sessionCookie(sessionId), 'content-type': 'text/plain; charset=utf-8' })
227
+ res.end(\`Signed in as \${email ?? 'user'}.\`)
228
+ return true
229
+ }
230
+ return false
231
+ }
232
+ `;
233
+ }
234
+ /**
235
+ * Vite/browser variant of the helper module. Same three steps as the server module, but the
236
+ * pending flow and the tokens live in sessionStorage (tab-scoped, gone when the tab closes) and
237
+ * the config comes from `import.meta.env.VITE_AIPAY_*` — Vite only exposes VITE_-prefixed values.
238
+ */
239
+ function browserModule(input) {
240
+ const ts = input.typescript;
241
+ const t = (typed, plain) => (ts ? typed : plain);
242
+ return `${ts ? '/// <reference types="vite/client" />\n' : ''}// Browser (Vite) build of the AI-Pay helper. Reads the VITE_AIPAY_* values \`aipay init\`
243
+ // wrote to .env.local (Vite exposes only VITE_-prefixed values to the page). No secret
244
+ // exists: the app is a public PKCE client and the token endpoint accepts browser calls.
245
+ import {
246
+ AIPayClient,
247
+ createConnectUrl,
248
+ getUserInfo,
249
+ handleCallback,${t('\n type TokenSet,', '')}
250
+ } from '@coswic/aipay-sdk'
251
+
252
+ const cfg = {
253
+ hydraPublicUrl: import.meta.env.VITE_AIPAY_AUTH_ORIGIN${t(' as string', '')},
254
+ clientId: import.meta.env.VITE_AIPAY_CLIENT_ID${t(' as string', '')},
255
+ redirectUri: import.meta.env.VITE_AIPAY_CALLBACK_URL${t(' as string', '')},
256
+ }
257
+ const apiBase = import.meta.env.VITE_AIPAY_API_BASE${t(' as string', '')}
258
+ /** Path part of the registered callback — the page that must call completeSignInIfCallback(). */
259
+ export const CALLBACK_PATH = ${JSON.stringify(input.callbackPath)}
260
+
261
+ const PENDING_KEY = 'aipay:pending' // { state, codeVerifier } while the user is at AI-Pay
262
+ const TOKENS_KEY = 'aipay:tokens' // TokenSet for this tab (sessionStorage: cleared when the tab closes)
263
+
264
+ /** Step 1 — send the user to AI-Pay. The PKCE verifier is kept under the state for the callback. */
265
+ export async function beginSignIn()${t(': Promise<void>', '')} {
266
+ const { url, state, codeVerifier } = await createConnectUrl(cfg)
267
+ sessionStorage.setItem(PENDING_KEY, JSON.stringify({ state, codeVerifier }))
268
+ location.assign(url)
269
+ }
270
+
271
+ /**
272
+ * Step 2 — call once at startup. On the callback page it exchanges the code (state + PKCE are
273
+ * verified by the SDK), stores the tokens for this tab and cleans the URL; elsewhere it is a no-op.
274
+ */
275
+ export async function completeSignInIfCallback()${t(': Promise<{ sub: string; email: string | null } | null>', '')} {
276
+ if (location.pathname !== CALLBACK_PATH) return null
277
+ const raw = sessionStorage.getItem(PENDING_KEY)
278
+ sessionStorage.removeItem(PENDING_KEY)
279
+ const saved = raw ? ${t('(JSON.parse(raw) as { state: string; codeVerifier: string })', 'JSON.parse(raw)')} : null
280
+ const tokens = await handleCallback(cfg, location.href, saved)
281
+ sessionStorage.setItem(TOKENS_KEY, JSON.stringify(tokens))
282
+ history.replaceState(null, '', '/')
283
+ const user = await getUserInfo(cfg, tokens.accessToken)
284
+ // user.sub is the stable per-app id of this user — key your accounts on it, not on email.
285
+ return { sub: user.sub, email: user.email ?? null }
286
+ }
287
+
288
+ export function currentTokens()${t(': TokenSet | null', '')} {
289
+ const raw = sessionStorage.getItem(TOKENS_KEY)
290
+ return raw ? ${t('(JSON.parse(raw) as TokenSet)', 'JSON.parse(raw)')} : null
291
+ }
292
+
293
+ export function signOut()${t(': void', '')} {
294
+ sessionStorage.removeItem(TOKENS_KEY)
295
+ }
296
+
297
+ /** Step 3 — call a model on the user's behalf; the response carries the billing receipt. */
298
+ export async function firstCompletion(model${t(': string', '')}) {
299
+ const tokens = currentTokens()
300
+ if (!tokens) throw new Error('not signed in')
301
+ const client = new AIPayClient({ baseUrl: apiBase, accessToken: tokens.accessToken })
302
+ const res = await client.chat.completions.create({
303
+ model,
304
+ messages: [{ role: 'user', content: 'Say hello in five words.' }],
305
+ max_tokens: 32,
306
+ })
307
+ return { text: res.choices[0]?.message.content ?? '', receipt: res.aipay }
308
+ }
309
+ `;
310
+ }
311
+ /** Startup wiring for a Vite app: handle the callback page, expose the two actions to your UI. */
312
+ function viteBoot(input) {
313
+ const ts = input.typescript;
314
+ const from = ts ? './aipay' : './aipay.js';
315
+ return `// Call bootAIPay() once when your app starts (top of main.${ts ? 'ts' : 'js'} / before rendering).
316
+ // It completes the sign-in when the page is the AI-Pay callback (${input.callbackPath}) and
317
+ // otherwise does nothing. Vite's dev server serves index.html for that path (SPA fallback),
318
+ // so no route configuration is needed; with a client-side router, keep the path unrouted.
319
+ import { beginSignIn, completeSignInIfCallback, currentTokens, firstCompletion } from '${from}'
320
+
321
+ export async function bootAIPay() {
322
+ const justSignedIn = await completeSignInIfCallback()
323
+ const signedIn = justSignedIn !== null || currentTokens() !== null
324
+ return {
325
+ signedIn,
326
+ /** Wire this to your "Sign in with AI-Pay" button. */
327
+ beginSignIn,
328
+ /** After sign-in: one model call; the receipt (cost, request id) is on the result. */
329
+ firstCompletion,
330
+ }
331
+ }
332
+ `;
333
+ }
@@ -0,0 +1 @@
1
+ export declare function cliVersion(): string;
@@ -0,0 +1,10 @@
1
+ import { createRequire } from 'node:module';
2
+ export function cliVersion() {
3
+ try {
4
+ const require = createRequire(import.meta.url);
5
+ return require('../package.json').version;
6
+ }
7
+ catch {
8
+ return '0.0.0';
9
+ }
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coswic/aipay",
3
- "version": "0.2.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",