@coswic/aipay 0.1.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.
- package/README.md +23 -1
- package/dist/auth-store.d.ts +25 -1
- package/dist/auth-store.js +65 -2
- package/dist/commands/init.js +19 -2
- package/dist/commands/login.d.ts +9 -0
- package/dist/commands/login.js +77 -37
- package/dist/commands/test.js +3 -0
- package/dist/commands/unlink.js +5 -2
- package/dist/detect.js +4 -0
- package/dist/device.d.ts +38 -0
- package/dist/device.js +122 -0
- package/dist/main.d.ts +1 -1
- package/dist/main.js +2 -1
- package/dist/project.d.ts +12 -1
- package/dist/project.js +20 -0
- package/dist/templates.js +107 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ npx @coswic/aipay test
|
|
|
12
12
|
|
|
13
13
|
| Command | What it does |
|
|
14
14
|
|---|---|
|
|
15
|
-
| `aipay init` | Links the project to your app: writes `.aipay/project.json` (commit it), `.env.local` (public values only, git-ignored), a start/callback route for your framework (Next.js, Express, Fastify, Hono, plain Node), `aipay-agent.md` (the developer docs, fetched once, for your agent to read offline) and installs the SDK. Never overwrites a file it did not write this run. `--dry-run` shows the plan. |
|
|
15
|
+
| `aipay init` | Links the project to your app: writes `.aipay/project.json` (commit it), `.env.local` (public values only, git-ignored), a start/callback route for your framework (Next.js, Express, Fastify, Hono, plain Node) or a browser helper for a Vite SPA (`--framework` overrides detection), `aipay-agent.md` (the developer docs, fetched once, for your agent to read offline) and installs the SDK. Never overwrites a file it did not write this run. `--dry-run` shows the plan. |
|
|
16
16
|
| `aipay test` | The proof: a real sign-in through the AI-Pay authorization server, received by the CLI on a loopback callback, then one chat completion on that user's behalf. Prints the receipt (`request_id`, amount charged). The Console's verify step turns green from the server side. The authorization server ignores the port for `127.0.0.1` redirect URIs (RFC 8252), so the CLI binds any free port and your dev server can keep running; `--port <n>` forces one. `--no-open` prints the URL instead of opening a browser; `--model <id>` picks the model. |
|
|
17
17
|
| `aipay doctor` | Checks Node, the link, `.env.local`, the SDK install, and that the authorization server and API are reachable. `--offline` skips the network. |
|
|
18
18
|
| `aipay status` | What this directory is linked to. |
|
|
@@ -22,6 +22,21 @@ Every command accepts `--json` (one JSON document on stdout) and `--dir <path>`.
|
|
|
22
22
|
|
|
23
23
|
Exit codes: `0` ok · `1` a problem you can fix locally · `2` usage · `3` network/auth.
|
|
24
24
|
|
|
25
|
+
## Signing in from a remote machine
|
|
26
|
+
|
|
27
|
+
`aipay login` opens a browser on this machine (loopback PKCE). On an SSH box, a container or
|
|
28
|
+
CI there is no browser, so use the device flow (RFC 8628):
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
aipay login --device --api https://api.example
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The CLI prints a URL and a short code. Open the URL in a browser on any device (your laptop,
|
|
35
|
+
your phone), enter the code, sign in and confirm — the CLI is polling and finishes on its
|
|
36
|
+
own. Nothing is written to the project; tokens go to the keychain or the 0600 file exactly
|
|
37
|
+
like the browser flow. With `--json`, the URL and code are echoed on stderr as
|
|
38
|
+
`device_url …` / `user_code …` so an agent can show them to you.
|
|
39
|
+
|
|
25
40
|
## Pilot gate
|
|
26
41
|
|
|
27
42
|
During the closed pilot, non-public AI-Pay paths answer `404` with an empty body unless the
|
|
@@ -45,6 +60,13 @@ src/aipay-routes.ts # framework wiring (or app/auth/aipay/*/route.ts for Next.
|
|
|
45
60
|
|
|
46
61
|
Access and refresh tokens obtained by `aipay test` live in memory only.
|
|
47
62
|
|
|
63
|
+
Your own developer tokens (`aipay login`) never touch the project either. They go to the
|
|
64
|
+
macOS Keychain (`security`), the Linux keyring (`secret-tool`), or on Windows a file
|
|
65
|
+
encrypted with DPAPI for your user account (`%APPDATA%\aipay\credentials.dpapi`, via
|
|
66
|
+
Windows PowerShell) — and only when none of those exist, a `credentials.json` readable by
|
|
67
|
+
you alone (mode 0600), with a warning at login. `AIPAY_TOKEN_STORE=file` forces the plain
|
|
68
|
+
file; `AIPAY_CONFIG_DIR` moves the directory.
|
|
69
|
+
|
|
48
70
|
## Development
|
|
49
71
|
|
|
50
72
|
```bash
|
package/dist/auth-store.d.ts
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the developer's management tokens live between commands.
|
|
3
|
+
*
|
|
4
|
+
* Order of preference (decision D5, 2026-09-04): the OS keychain when a CLI for it exists
|
|
5
|
+
* (macOS `security`, Linux `secret-tool`), on Windows a DPAPI-protected file (Windows
|
|
6
|
+
* PowerShell's ConvertFrom-SecureString — encrypted for this Windows user, no plaintext on
|
|
7
|
+
* disk), else a 0600 file under the user's config dir with a one-line warning at login.
|
|
8
|
+
* Tokens are never written into the project directory. `AIPAY_TOKEN_STORE=file` forces the
|
|
9
|
+
* plain file everywhere (escape hatch when a keychain misbehaves).
|
|
10
|
+
*/
|
|
11
|
+
import { type SpawnSyncOptions, type SpawnSyncReturns } from 'node:child_process';
|
|
1
12
|
export interface StoredAuth {
|
|
2
13
|
api_base: string;
|
|
3
14
|
auth_origin: string;
|
|
@@ -11,7 +22,8 @@ export interface StoredAuth {
|
|
|
11
22
|
sub: string;
|
|
12
23
|
}
|
|
13
24
|
export interface AuthStore {
|
|
14
|
-
|
|
25
|
+
/** keychain = OS secret service; dpapi = Windows user-scoped encryption; file = plain 0600 file */
|
|
26
|
+
kind: 'keychain' | 'dpapi' | 'file';
|
|
15
27
|
load(): StoredAuth | null;
|
|
16
28
|
save(auth: StoredAuth): void;
|
|
17
29
|
clear(): void;
|
|
@@ -20,4 +32,16 @@ export interface AuthStore {
|
|
|
20
32
|
}
|
|
21
33
|
export declare function configDir(env?: NodeJS.ProcessEnv): string;
|
|
22
34
|
export declare function createAuthStore(env?: NodeJS.ProcessEnv): AuthStore;
|
|
35
|
+
/** Minimal spawn shape the DPAPI store needs (injectable so the round-trip is unit-testable off Windows). */
|
|
36
|
+
export type Exec = (cmd: string, args: string[], opts: SpawnSyncOptions & {
|
|
37
|
+
encoding: 'utf8';
|
|
38
|
+
}) => Pick<SpawnSyncReturns<string>, 'status' | 'stdout'>;
|
|
39
|
+
/**
|
|
40
|
+
* Windows: DPAPI (user scope) via PowerShell — the same primitive the OS uses for saved
|
|
41
|
+
* credentials. The file holds only the encrypted blob; another Windows account (or a copy
|
|
42
|
+
* of the file on another machine) cannot decrypt it. The token JSON is base64-encoded
|
|
43
|
+
* before protection so no PowerShell console encoding can mangle it. A plain
|
|
44
|
+
* `credentials.json` left by CLI 0.1.0 is still read, and replaced on the next save.
|
|
45
|
+
*/
|
|
46
|
+
export declare function dpapiStore(dir: string, exec?: Exec): AuthStore;
|
|
23
47
|
export declare function fileStore(dir: string): AuthStore;
|
package/dist/auth-store.js
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
* Where the developer's management tokens live between commands.
|
|
3
3
|
*
|
|
4
4
|
* Order of preference (decision D5, 2026-09-04): the OS keychain when a CLI for it exists
|
|
5
|
-
* (macOS `security`, Linux `secret-tool`),
|
|
6
|
-
*
|
|
5
|
+
* (macOS `security`, Linux `secret-tool`), on Windows a DPAPI-protected file (Windows
|
|
6
|
+
* PowerShell's ConvertFrom-SecureString — encrypted for this Windows user, no plaintext on
|
|
7
|
+
* disk), else a 0600 file under the user's config dir with a one-line warning at login.
|
|
8
|
+
* Tokens are never written into the project directory. `AIPAY_TOKEN_STORE=file` forces the
|
|
9
|
+
* plain file everywhere (escape hatch when a keychain misbehaves).
|
|
7
10
|
*/
|
|
8
11
|
import { spawnSync } from 'node:child_process';
|
|
9
12
|
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
@@ -28,9 +31,69 @@ export function createAuthStore(env = process.env) {
|
|
|
28
31
|
return macKeychain();
|
|
29
32
|
if (process.platform === 'linux' && has('secret-tool'))
|
|
30
33
|
return secretTool();
|
|
34
|
+
if (process.platform === 'win32' && has('powershell'))
|
|
35
|
+
return dpapiStore(configDir(env));
|
|
31
36
|
}
|
|
32
37
|
return fileStore(configDir(env));
|
|
33
38
|
}
|
|
39
|
+
/** Windows PowerShell 5.1 ships with every Windows; `-NoProfile -NonInteractive` keeps it quiet and fast. */
|
|
40
|
+
const PS_ARGS = ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command'];
|
|
41
|
+
/** stdin → DPAPI blob (hex). The secret travels over stdin only — never in argv (visible in Task Manager/ETW). */
|
|
42
|
+
const PS_PROTECT = '$in=[Console]::In.ReadToEnd().Trim(); $s=ConvertTo-SecureString -String $in -AsPlainText -Force; ConvertFrom-SecureString -SecureString $s';
|
|
43
|
+
/** DPAPI blob (hex) on stdin → plaintext on stdout. */
|
|
44
|
+
const PS_UNPROTECT = '$in=[Console]::In.ReadToEnd().Trim(); $s=ConvertTo-SecureString -String $in; [System.Net.NetworkCredential]::new("", $s).Password';
|
|
45
|
+
/**
|
|
46
|
+
* Windows: DPAPI (user scope) via PowerShell — the same primitive the OS uses for saved
|
|
47
|
+
* credentials. The file holds only the encrypted blob; another Windows account (or a copy
|
|
48
|
+
* of the file on another machine) cannot decrypt it. The token JSON is base64-encoded
|
|
49
|
+
* before protection so no PowerShell console encoding can mangle it. A plain
|
|
50
|
+
* `credentials.json` left by CLI 0.1.0 is still read, and replaced on the next save.
|
|
51
|
+
*/
|
|
52
|
+
export function dpapiStore(dir, exec = spawnSync) {
|
|
53
|
+
const file = join(dir, 'credentials.dpapi');
|
|
54
|
+
const legacy = fileStore(dir);
|
|
55
|
+
const run = (script, input) => {
|
|
56
|
+
const r = exec('powershell', [...PS_ARGS, script], {
|
|
57
|
+
input,
|
|
58
|
+
encoding: 'utf8',
|
|
59
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
60
|
+
windowsHide: true,
|
|
61
|
+
});
|
|
62
|
+
if (r.status !== 0 || typeof r.stdout !== 'string')
|
|
63
|
+
return null;
|
|
64
|
+
const out = r.stdout.trim();
|
|
65
|
+
return out ? out : null;
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
kind: 'dpapi',
|
|
69
|
+
where: `${file} (encrypted for this Windows user with DPAPI)`,
|
|
70
|
+
load() {
|
|
71
|
+
if (!existsSync(file))
|
|
72
|
+
return legacy.load();
|
|
73
|
+
const plain = run(PS_UNPROTECT, readFileSync(file, 'utf8'));
|
|
74
|
+
if (!plain)
|
|
75
|
+
return null;
|
|
76
|
+
try {
|
|
77
|
+
return parse(Buffer.from(plain, 'base64').toString('utf8'));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
save(auth) {
|
|
84
|
+
const blob = run(PS_PROTECT, Buffer.from(JSON.stringify(auth), 'utf8').toString('base64'));
|
|
85
|
+
if (!blob)
|
|
86
|
+
throw new Error('could not protect the tokens with Windows DPAPI (PowerShell failed)');
|
|
87
|
+
mkdirSync(dir, { recursive: true });
|
|
88
|
+
writeFileSync(file, `${blob}\n`);
|
|
89
|
+
legacy.clear(); // a 0.1.0 plaintext file must not outlive the encrypted one
|
|
90
|
+
},
|
|
91
|
+
clear() {
|
|
92
|
+
rmSync(file, { force: true });
|
|
93
|
+
legacy.clear();
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
34
97
|
function macKeychain() {
|
|
35
98
|
const account = 'developer';
|
|
36
99
|
return {
|
package/dist/commands/init.js
CHANGED
|
@@ -18,7 +18,7 @@ import { openDeveloperApi } from '../developer-api.js';
|
|
|
18
18
|
import { GATE_ENV } from '../gate.js';
|
|
19
19
|
import { parseCallback } from '../loopback.js';
|
|
20
20
|
import { CliError, EXIT } from '../output.js';
|
|
21
|
-
import { ENV_FILE, ENV_KEYS, mergeEnvFile, mergeGitignore, readProject, writeProject, } from '../project.js';
|
|
21
|
+
import { ENV_FILE, ENV_KEYS, FRAMEWORKS, isFramework, mergeEnvFile, mergeGitignore, readProject, VITE_ENV_KEYS, writeProject, } from '../project.js';
|
|
22
22
|
import { createRemote, docsUrl, GATE_HINT } from '../remote.js';
|
|
23
23
|
import { reportSetupEvent } from '../setup-events.js';
|
|
24
24
|
import { scaffoldFiles } from '../templates.js';
|
|
@@ -66,10 +66,18 @@ export async function runInit(args, out, deps = {}) {
|
|
|
66
66
|
const remote = deps.remote ?? createRemote(env);
|
|
67
67
|
const now = (deps.now ?? (() => new Date()))();
|
|
68
68
|
const det = detectProject(dir);
|
|
69
|
+
// --framework: detection can be wrong (monorepos, unusual layouts) — the developer/agent may
|
|
70
|
+
// name the target; the value must be one we have templates for.
|
|
71
|
+
const forced = flagString(args.flags, 'framework');
|
|
72
|
+
if (forced !== undefined && forced !== null) {
|
|
73
|
+
if (!isFramework(forced))
|
|
74
|
+
throw new CliError('usage', `--framework must be one of ${FRAMEWORKS.join(', ')} (got "${forced}")`, EXIT.usage);
|
|
75
|
+
det.framework = forced;
|
|
76
|
+
}
|
|
69
77
|
out.line('info', `project: ${dir}`);
|
|
70
78
|
out.kv('package.json', det.hasPackageJson ? 'found' : 'none (plain directory)');
|
|
71
79
|
out.kv('manager', det.packageManager ?? '—');
|
|
72
|
-
out.kv('framework', det.framework ?? '—');
|
|
80
|
+
out.kv('framework', `${det.framework ?? '—'}${forced ? ' (from --framework)' : ''}`);
|
|
73
81
|
out.kv('language', det.typescript ? 'TypeScript' : 'JavaScript');
|
|
74
82
|
out.kv('callback', callback);
|
|
75
83
|
const plan = [];
|
|
@@ -96,6 +104,15 @@ export async function runInit(args, out, deps = {}) {
|
|
|
96
104
|
[ENV_KEYS.authOrigin]: authOrigin,
|
|
97
105
|
[ENV_KEYS.apiBase]: apiBase,
|
|
98
106
|
[ENV_KEYS.callback]: callback,
|
|
107
|
+
// Vite exposes only VITE_-prefixed values to the browser: same four public values, aliased.
|
|
108
|
+
...(det.framework === 'vite'
|
|
109
|
+
? {
|
|
110
|
+
[VITE_ENV_KEYS.clientId]: clientId,
|
|
111
|
+
[VITE_ENV_KEYS.authOrigin]: authOrigin,
|
|
112
|
+
[VITE_ENV_KEYS.apiBase]: apiBase,
|
|
113
|
+
[VITE_ENV_KEYS.callback]: callback,
|
|
114
|
+
}
|
|
115
|
+
: {}),
|
|
99
116
|
});
|
|
100
117
|
if (envMerged.changed.length) {
|
|
101
118
|
plan.push({
|
package/dist/commands/login.d.ts
CHANGED
|
@@ -6,11 +6,20 @@ export interface CliConfig {
|
|
|
6
6
|
scopes: string[];
|
|
7
7
|
redirect_path: string;
|
|
8
8
|
audience: string;
|
|
9
|
+
/** A2: the server allows the device grant for the CLI (older APIs omit it = false). */
|
|
10
|
+
device_authorization: boolean;
|
|
9
11
|
}
|
|
10
12
|
export interface LoginDeps {
|
|
11
13
|
env?: NodeJS.ProcessEnv;
|
|
12
14
|
open?: (url: string) => boolean;
|
|
13
15
|
onAuthorizeUrl?: (url: string) => void;
|
|
16
|
+
/** A2: called with the verification URL and the user code (tests / drivers). */
|
|
17
|
+
onDeviceCode?: (info: {
|
|
18
|
+
url: string;
|
|
19
|
+
userCode: string;
|
|
20
|
+
}) => void;
|
|
21
|
+
fetchImpl?: typeof fetch;
|
|
22
|
+
sleep?: (ms: number) => Promise<void>;
|
|
14
23
|
}
|
|
15
24
|
export declare function fetchCliConfig(apiBase: string, env: NodeJS.ProcessEnv): Promise<CliConfig>;
|
|
16
25
|
/** Resolve the API base: flag → env → linked project → error. */
|
package/dist/commands/login.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `aipay login [--api <base>] [--no-open] [--timeout <s>] [--json]`
|
|
2
|
+
* `aipay login [--api <base>] [--device] [--no-open] [--timeout <s>] [--json]`
|
|
3
3
|
*
|
|
4
|
-
* Developer sign-in for the CLI itself (consultant §10.2, auth model A
|
|
5
|
-
*
|
|
6
|
-
* against the authorization server
|
|
7
|
-
*
|
|
4
|
+
* Developer sign-in for the CLI itself (consultant §10.2, auth model A): the CLI is a
|
|
5
|
+
* first-party public client. It asks the API for its client config, then either
|
|
6
|
+
* A1 — runs loopback PKCE against the authorization server (a browser on this machine), or
|
|
7
|
+
* A2 — `--device`: RFC 8628 device authorization (no browser here: SSH boxes, containers,
|
|
8
|
+
* CI) — prints a URL + short code, the developer approves on any device, the CLI polls.
|
|
9
|
+
* Both use audience aipay-developer-api and the management scope, and keep the tokens in
|
|
10
|
+
* the OS keychain (or a 0600 file). No app secrets exist.
|
|
8
11
|
*/
|
|
9
12
|
import { createHash, randomBytes } from 'node:crypto';
|
|
10
13
|
import { exchangeCode, getUserInfo } from '@coswic/aipay-sdk';
|
|
11
14
|
import { flagBool, flagString } from '../args.js';
|
|
12
15
|
import { createAuthStore } from '../auth-store.js';
|
|
13
16
|
import { openBrowser } from '../browser.js';
|
|
17
|
+
import { pollDeviceToken, requestDeviceCode } from '../device.js';
|
|
14
18
|
import { GATE_ENV, gatedFetch, looksGated } from '../gate.js';
|
|
15
19
|
import { startLoopback } from '../loopback.js';
|
|
16
20
|
import { CliError, EXIT } from '../output.js';
|
|
@@ -43,6 +47,7 @@ export async function fetchCliConfig(apiBase, env) {
|
|
|
43
47
|
scopes: cfg.scopes.filter((s) => typeof s === 'string'),
|
|
44
48
|
redirect_path: typeof cfg.redirect_path === 'string' ? cfg.redirect_path : '/cli/callback',
|
|
45
49
|
audience: typeof cfg.audience === 'string' ? cfg.audience : 'aipay-developer-api',
|
|
50
|
+
device_authorization: cfg.device_authorization === true,
|
|
46
51
|
};
|
|
47
52
|
}
|
|
48
53
|
/** Resolve the API base: flag → env → linked project → error. */
|
|
@@ -88,16 +93,20 @@ export async function runLogin(args, out, deps = {}) {
|
|
|
88
93
|
const apiBase = resolveApiBase(args, env, dir);
|
|
89
94
|
const cfg = await fetchCliConfig(apiBase, env);
|
|
90
95
|
const timeoutS = Number(flagString(args.flags, 'timeout') ?? '300');
|
|
96
|
+
const fetchImpl = deps.fetchImpl ?? gatedFetch(env);
|
|
97
|
+
if (flagBool(args.flags, 'device', false))
|
|
98
|
+
return deviceLogin(cfg, apiBase, timeoutS, fetchImpl, env, out, deps);
|
|
91
99
|
const loop = await startLoopback({ host: '127.0.0.1', port: 0, path: cfg.redirect_path }, { timeoutMs: timeoutS * 1000 });
|
|
92
100
|
const redirectUri = `http://127.0.0.1:${loop.port}${cfg.redirect_path}`;
|
|
93
101
|
const { url, state, codeVerifier } = managementAuthorizeUrl(cfg, redirectUri);
|
|
94
102
|
out.line('info', `signing in to ${apiBase} as a developer — approve "AI-Pay CLI" in your browser:\n\n ${url}\n`);
|
|
103
|
+
if (cfg.device_authorization)
|
|
104
|
+
out.line('info', 'no browser on this machine? run `aipay login --device` instead');
|
|
95
105
|
if (out.json)
|
|
96
106
|
out.debug(`authorize_url ${url}`);
|
|
97
107
|
deps.onAuthorizeUrl?.(url);
|
|
98
108
|
if (flagBool(args.flags, 'open', true))
|
|
99
109
|
(deps.open ?? openBrowser)(url);
|
|
100
|
-
const fetchImpl = gatedFetch(env);
|
|
101
110
|
try {
|
|
102
111
|
const cb = await loop.waitForCallback;
|
|
103
112
|
const u = new URL(cb.url);
|
|
@@ -110,40 +119,71 @@ export async function runLogin(args, out, deps = {}) {
|
|
|
110
119
|
if (!code)
|
|
111
120
|
throw new CliError('remote', 'callback without a code', EXIT.remote);
|
|
112
121
|
const tokens = await exchangeCode({ hydraPublicUrl: cfg.auth_origin, clientId: cfg.client_id, redirectUri }, { code, codeVerifier, fetchImpl });
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const auth = {
|
|
118
|
-
api_base: apiBase,
|
|
119
|
-
auth_origin: cfg.auth_origin,
|
|
120
|
-
client_id: cfg.client_id,
|
|
121
|
-
access_token: tokens.accessToken,
|
|
122
|
-
refresh_token: tokens.refreshToken,
|
|
123
|
-
expires_at: Date.now() + tokens.expiresInSeconds * 1000,
|
|
122
|
+
return finishLogin({
|
|
123
|
+
accessToken: tokens.accessToken,
|
|
124
|
+
refreshToken: tokens.refreshToken,
|
|
125
|
+
expiresInSeconds: tokens.expiresInSeconds,
|
|
124
126
|
scope: tokens.scope,
|
|
125
|
-
|
|
126
|
-
sub: user.sub,
|
|
127
|
-
};
|
|
128
|
-
store.save(auth);
|
|
129
|
-
out.line('ok', `signed in as ${auth.email ?? auth.sub}`);
|
|
130
|
-
out.line('info', `tokens kept in ${store.where}${store.kind === 'file' ? ' — no keychain tool found; the file is readable only by you' : ''}`);
|
|
131
|
-
if (!tokens.refreshToken)
|
|
132
|
-
out.line('warn', 'no refresh token was granted; you will need to sign in again when this one expires');
|
|
133
|
-
if (!env[GATE_ENV])
|
|
134
|
-
out.line('info', `pilot: if the API answers 404 with an empty body later, export ${GATE_ENV}`);
|
|
135
|
-
out.result({
|
|
136
|
-
ok: true,
|
|
137
|
-
api_base: apiBase,
|
|
138
|
-
email: auth.email,
|
|
139
|
-
sub: auth.sub,
|
|
140
|
-
scope: auth.scope,
|
|
141
|
-
store: store.kind,
|
|
142
|
-
expires_at: auth.expires_at,
|
|
143
|
-
});
|
|
144
|
-
return EXIT.ok;
|
|
127
|
+
}, cfg, apiBase, fetchImpl, env, out);
|
|
145
128
|
}
|
|
146
129
|
finally {
|
|
147
130
|
await loop.close();
|
|
148
131
|
}
|
|
149
132
|
}
|
|
133
|
+
/** A2 (RFC 8628): print URL + code, poll until approved on another device. */
|
|
134
|
+
async function deviceLogin(cfg, apiBase, timeoutS, fetchImpl, env, out, deps) {
|
|
135
|
+
if (!cfg.device_authorization)
|
|
136
|
+
throw new CliError('device_unsupported', `${apiBase} does not offer device sign-in yet — run \`aipay login\` on a machine with a browser`, EXIT.remote);
|
|
137
|
+
const client = {
|
|
138
|
+
authOrigin: cfg.auth_origin,
|
|
139
|
+
clientId: cfg.client_id,
|
|
140
|
+
scopes: cfg.scopes,
|
|
141
|
+
audience: cfg.audience,
|
|
142
|
+
};
|
|
143
|
+
const auth = await requestDeviceCode(client, fetchImpl);
|
|
144
|
+
const url = auth.verificationUriComplete ?? auth.verificationUri;
|
|
145
|
+
out.line('info', `signing in to ${apiBase} as a developer — on any device with a browser, open:\n\n ${url}\n\n and enter the code: ${auth.userCode}\n`);
|
|
146
|
+
out.line('info', `waiting for approval (the code expires in ${Math.round(auth.expiresInSeconds / 60)} min)…`);
|
|
147
|
+
if (out.json) {
|
|
148
|
+
out.debug(`device_url ${url}`);
|
|
149
|
+
out.debug(`user_code ${auth.userCode}`);
|
|
150
|
+
}
|
|
151
|
+
deps.onDeviceCode?.({ url, userCode: auth.userCode });
|
|
152
|
+
const tokens = await pollDeviceToken(client, { ...auth, expiresInSeconds: Math.min(auth.expiresInSeconds, timeoutS) }, { fetchImpl, ...(deps.sleep ? { sleep: deps.sleep } : {}) });
|
|
153
|
+
return finishLogin(tokens, cfg, apiBase, fetchImpl, env, out);
|
|
154
|
+
}
|
|
155
|
+
/** Shared tail of both flows: identity → keychain/file → summary. */
|
|
156
|
+
async function finishLogin(tokens, cfg, apiBase, fetchImpl, env, out) {
|
|
157
|
+
const user = await getUserInfo({ hydraPublicUrl: cfg.auth_origin }, tokens.accessToken, {
|
|
158
|
+
fetchImpl,
|
|
159
|
+
});
|
|
160
|
+
const store = createAuthStore(env);
|
|
161
|
+
const auth = {
|
|
162
|
+
api_base: apiBase,
|
|
163
|
+
auth_origin: cfg.auth_origin,
|
|
164
|
+
client_id: cfg.client_id,
|
|
165
|
+
access_token: tokens.accessToken,
|
|
166
|
+
refresh_token: tokens.refreshToken,
|
|
167
|
+
expires_at: Date.now() + tokens.expiresInSeconds * 1000,
|
|
168
|
+
scope: tokens.scope,
|
|
169
|
+
email: user.email ?? null,
|
|
170
|
+
sub: user.sub,
|
|
171
|
+
};
|
|
172
|
+
store.save(auth);
|
|
173
|
+
out.line('ok', `signed in as ${auth.email ?? auth.sub}`);
|
|
174
|
+
out.line('info', `tokens kept in ${store.where}${store.kind === 'file' ? ' — no keychain tool found; the file is readable only by you' : ''}`);
|
|
175
|
+
if (!tokens.refreshToken)
|
|
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
|
+
out.result({
|
|
180
|
+
ok: true,
|
|
181
|
+
api_base: apiBase,
|
|
182
|
+
email: auth.email,
|
|
183
|
+
sub: auth.sub,
|
|
184
|
+
scope: auth.scope,
|
|
185
|
+
store: store.kind,
|
|
186
|
+
expires_at: auth.expires_at,
|
|
187
|
+
});
|
|
188
|
+
return EXIT.ok;
|
|
189
|
+
}
|
package/dist/commands/test.js
CHANGED
|
@@ -114,6 +114,7 @@ export async function runTest(args, out, deps = {}) {
|
|
|
114
114
|
if (e instanceof InsufficientBalanceError) {
|
|
115
115
|
result.completion = { error: 'insufficient_balance' };
|
|
116
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);
|
|
117
118
|
out.result(result);
|
|
118
119
|
return EXIT.problem;
|
|
119
120
|
}
|
|
@@ -128,6 +129,8 @@ export async function runTest(args, out, deps = {}) {
|
|
|
128
129
|
if (e instanceof CliError) {
|
|
129
130
|
result.error = { code: e.code, message: e.message };
|
|
130
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);
|
|
131
134
|
out.result(result);
|
|
132
135
|
return e.exitCode;
|
|
133
136
|
}
|
package/dist/commands/unlink.js
CHANGED
|
@@ -7,7 +7,7 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
7
7
|
import { join, resolve } from 'node:path';
|
|
8
8
|
import { flagBool, flagString } from '../args.js';
|
|
9
9
|
import { EXIT } from '../output.js';
|
|
10
|
-
import { ENV_FILE, ENV_KEYS, PROJECT_DIR, projectPath, readProject } from '../project.js';
|
|
10
|
+
import { ENV_FILE, ENV_KEYS, PROJECT_DIR, projectPath, readProject, VITE_ENV_KEYS, } from '../project.js';
|
|
11
11
|
export function runUnlink(args, out) {
|
|
12
12
|
const dir = resolve(flagString(args.flags, 'dir') ?? '.');
|
|
13
13
|
const dryRun = flagBool(args.flags, 'dry-run');
|
|
@@ -22,7 +22,10 @@ export function runUnlink(args, out) {
|
|
|
22
22
|
let envChanged = false;
|
|
23
23
|
if (existsSync(envPath)) {
|
|
24
24
|
const before = readFileSync(envPath, 'utf8');
|
|
25
|
-
const after = stripEnvKeys(before,
|
|
25
|
+
const after = stripEnvKeys(before, [
|
|
26
|
+
...Object.values(ENV_KEYS),
|
|
27
|
+
...Object.values(VITE_ENV_KEYS),
|
|
28
|
+
]);
|
|
26
29
|
envChanged = after !== before;
|
|
27
30
|
if (envChanged && !dryRun)
|
|
28
31
|
writeFileSync(envPath, after);
|
package/dist/detect.js
CHANGED
|
@@ -34,6 +34,10 @@ export function detectFramework(pkg) {
|
|
|
34
34
|
return 'fastify';
|
|
35
35
|
if ('express' in deps)
|
|
36
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';
|
|
37
41
|
return 'node';
|
|
38
42
|
}
|
|
39
43
|
export function detectProject(dir) {
|
package/dist/device.d.ts
ADDED
|
@@ -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/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>] [--
|
|
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";
|
|
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
|
@@ -12,11 +12,12 @@ import { cliVersion } from './version.js';
|
|
|
12
12
|
export const HELP = `aipay — AI-Pay CLI
|
|
13
13
|
|
|
14
14
|
Usage
|
|
15
|
-
aipay login [--api <base>] [--
|
|
15
|
+
aipay login [--api <base>] [--device] [--json] developer sign-in for the CLI itself
|
|
16
16
|
aipay whoami [--json] · aipay logout [--json]
|
|
17
17
|
aipay apps list [--json]
|
|
18
18
|
aipay apps create --name <name> [--callback <url>] [--json]
|
|
19
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
|
|
20
21
|
[--dir <path>] [--dry-run] [--no-install] [--json]
|
|
21
22
|
aipay init --create <app name> [--api <base>] ... create the app (needs login) and link it
|
|
22
23
|
aipay test [--model <id>] [--port <n>] [--no-open] [--timeout <seconds>] [--dir <path>] [--json]
|
package/dist/project.d.ts
CHANGED
|
@@ -19,7 +19,10 @@ export interface ProjectConfig {
|
|
|
19
19
|
};
|
|
20
20
|
linked_at: string;
|
|
21
21
|
}
|
|
22
|
-
export type Framework = 'next' | 'express' | 'fastify' | 'hono' | 'node';
|
|
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;
|
|
23
26
|
export declare const PROJECT_DIR = ".aipay";
|
|
24
27
|
export declare const PROJECT_FILE = "project.json";
|
|
25
28
|
export declare const ENV_FILE = ".env.local";
|
|
@@ -34,6 +37,14 @@ export declare const ENV_KEYS: {
|
|
|
34
37
|
readonly apiBase: 'AIPAY_API_BASE';
|
|
35
38
|
readonly callback: 'AIPAY_CALLBACK_URL';
|
|
36
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
|
+
};
|
|
37
48
|
/**
|
|
38
49
|
* Merge our keys into `.env.local` without touching anything else in it: existing lines
|
|
39
50
|
* for our keys are replaced in place, missing ones appended under a marker comment.
|
package/dist/project.js
CHANGED
|
@@ -9,6 +9,18 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
|
+
/** Accepted by `--framework`; detection picks server frameworks first, then Vite, then plain Node. */
|
|
13
|
+
export const FRAMEWORKS = [
|
|
14
|
+
'next',
|
|
15
|
+
'express',
|
|
16
|
+
'fastify',
|
|
17
|
+
'hono',
|
|
18
|
+
'vite',
|
|
19
|
+
'node',
|
|
20
|
+
];
|
|
21
|
+
export function isFramework(v) {
|
|
22
|
+
return typeof v === 'string' && FRAMEWORKS.includes(v);
|
|
23
|
+
}
|
|
12
24
|
export const PROJECT_DIR = '.aipay';
|
|
13
25
|
export const PROJECT_FILE = 'project.json';
|
|
14
26
|
export const ENV_FILE = '.env.local';
|
|
@@ -50,6 +62,14 @@ export const ENV_KEYS = {
|
|
|
50
62
|
apiBase: 'AIPAY_API_BASE',
|
|
51
63
|
callback: 'AIPAY_CALLBACK_URL',
|
|
52
64
|
};
|
|
65
|
+
/** Vite only exposes VITE_-prefixed values to browser code, so a Vite project gets the same four
|
|
66
|
+
* public values twice: the plain names (for any server-side tooling) and these aliases. */
|
|
67
|
+
export const VITE_ENV_KEYS = {
|
|
68
|
+
clientId: 'VITE_AIPAY_CLIENT_ID',
|
|
69
|
+
authOrigin: 'VITE_AIPAY_AUTH_ORIGIN',
|
|
70
|
+
apiBase: 'VITE_AIPAY_API_BASE',
|
|
71
|
+
callback: 'VITE_AIPAY_CALLBACK_URL',
|
|
72
|
+
};
|
|
53
73
|
/**
|
|
54
74
|
* Merge our keys into `.env.local` without touching anything else in it: existing lines
|
|
55
75
|
* for our keys are replaced in place, missing ones appended under a marker comment.
|
package/dist/templates.js
CHANGED
|
@@ -39,6 +39,13 @@ export function scaffoldFiles(input) {
|
|
|
39
39
|
content: honoRoutes(input, startPath),
|
|
40
40
|
},
|
|
41
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
|
+
];
|
|
42
49
|
default:
|
|
43
50
|
return [
|
|
44
51
|
core,
|
|
@@ -224,3 +231,103 @@ export async function handleAIPay(req${ts ? ': IncomingMessage' : ''}, res${ts ?
|
|
|
224
231
|
}
|
|
225
232
|
`;
|
|
226
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coswic/aipay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|