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