@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,145 @@
1
+ /**
2
+ * `aipay doctor [--dir] [--json] [--offline]` — checks the link, the local files, the
3
+ * SDK install, and (unless --offline) reachability of the authorization server and the
4
+ * API. Exit 0 = nothing to fix, 1 = at least one problem.
5
+ */
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { join, resolve } from 'node:path';
8
+ import { flagBool, flagString } from '../args.js';
9
+ import { detectProject } from '../detect.js';
10
+ import { parseCallback } from '../loopback.js';
11
+ import { EXIT } from '../output.js';
12
+ import { ENV_FILE, ENV_KEYS, readProject } from '../project.js';
13
+ import { createRemote, docsUrl, EMPTY_404_HINT } from '../remote.js';
14
+ export async function runDoctor(args, out, deps = {}) {
15
+ const dir = resolve(flagString(args.flags, 'dir') ?? '.');
16
+ const checks = await collectChecks(dir, { offline: flagBool(args.flags, 'offline') }, deps);
17
+ for (const c of checks)
18
+ out.line(c.level, c.text);
19
+ const problems = checks.filter((c) => c.level === 'bad');
20
+ out.blank();
21
+ out.line(problems.length ? 'bad' : 'ok', problems.length ? `${problems.length} problem(s)` : 'all good');
22
+ out.result({ ok: problems.length === 0, dir, checks });
23
+ return problems.length ? EXIT.problem : EXIT.ok;
24
+ }
25
+ export async function collectChecks(dir, opts, deps) {
26
+ const checks = [];
27
+ const major = Number((deps.nodeVersion ?? process.versions.node).split('.')[0]);
28
+ checks.push(major >= 20
29
+ ? { id: 'node', level: 'ok', text: `Node ${deps.nodeVersion ?? process.versions.node}` }
30
+ : {
31
+ id: 'node',
32
+ level: 'bad',
33
+ text: `Node ${deps.nodeVersion ?? process.versions.node} — the SDK needs 20+`,
34
+ });
35
+ let project = null;
36
+ try {
37
+ project = readProject(dir);
38
+ }
39
+ catch (e) {
40
+ checks.push({ id: 'project', level: 'bad', text: e.message });
41
+ return checks;
42
+ }
43
+ if (!project) {
44
+ checks.push({
45
+ id: 'project',
46
+ level: 'bad',
47
+ text: 'not linked — run `aipay init --app <client_id> --auth <origin> --api <base>`',
48
+ });
49
+ return checks;
50
+ }
51
+ checks.push({
52
+ id: 'project',
53
+ level: 'ok',
54
+ text: `linked to ${project.app.name ?? project.app.client_id} (.aipay/project.json)`,
55
+ });
56
+ try {
57
+ const t = parseCallback(project.callback);
58
+ checks.push({
59
+ id: 'callback',
60
+ level: 'ok',
61
+ text: `callback ${project.callback} (loopback, port ${t.port})`,
62
+ });
63
+ }
64
+ catch (e) {
65
+ checks.push({ id: 'callback', level: 'bad', text: `callback: ${e.message}` });
66
+ }
67
+ const envPath = join(dir, ENV_FILE);
68
+ if (!existsSync(envPath))
69
+ checks.push({ id: 'env', level: 'bad', text: `${ENV_FILE} missing — rerun \`aipay init\`` });
70
+ else {
71
+ const content = readFileSync(envPath, 'utf8');
72
+ const want = {
73
+ [ENV_KEYS.clientId]: project.app.client_id,
74
+ [ENV_KEYS.authOrigin]: project.auth_origin,
75
+ [ENV_KEYS.apiBase]: project.api_base,
76
+ [ENV_KEYS.callback]: project.callback,
77
+ };
78
+ const wrong = Object.entries(want)
79
+ .filter(([k, v]) => !new RegExp(`^\\s*(?:export\\s+)?${k}\\s*=\\s*["']?${escapeRe(v)}["']?\\s*$`, 'm').test(content))
80
+ .map(([k]) => k);
81
+ checks.push(wrong.length
82
+ ? {
83
+ id: 'env',
84
+ level: 'bad',
85
+ text: `${ENV_FILE} out of sync: ${wrong.join(', ')} — rerun \`aipay init\``,
86
+ }
87
+ : { id: 'env', level: 'ok', text: `${ENV_FILE} has the four AIPAY_* values` });
88
+ }
89
+ const gi = join(dir, '.gitignore');
90
+ if (existsSync(gi) &&
91
+ !readFileSync(gi, 'utf8')
92
+ .split(/\r?\n/)
93
+ .some((l) => l.trim() === ENV_FILE))
94
+ checks.push({ id: 'gitignore', level: 'warn', text: `.gitignore does not list ${ENV_FILE}` });
95
+ const det = detectProject(dir);
96
+ if (det.hasPackageJson) {
97
+ const installed = det.sdkInstalled && existsSync(join(dir, 'node_modules', '@coswic', 'aipay-sdk'));
98
+ checks.push(installed
99
+ ? { id: 'sdk', level: 'ok', text: '@coswic/aipay-sdk installed' }
100
+ : det.sdkInstalled
101
+ ? {
102
+ id: 'sdk',
103
+ level: 'bad',
104
+ text: '@coswic/aipay-sdk is in package.json but not installed — run your package manager install',
105
+ }
106
+ : {
107
+ id: 'sdk',
108
+ level: 'bad',
109
+ text: '@coswic/aipay-sdk not a dependency — rerun `aipay init` (or add it yourself)',
110
+ });
111
+ for (const f of project.scaffold.files)
112
+ if (!existsSync(join(dir, f)))
113
+ checks.push({ id: 'scaffold', level: 'warn', text: `scaffolded file removed: ${f}` });
114
+ }
115
+ if (!opts.offline) {
116
+ const remote = deps.remote ?? createRemote();
117
+ const oidc = await remote.probe(new URL('/.well-known/openid-configuration', project.auth_origin).toString());
118
+ checks.push(oidc.ok
119
+ ? {
120
+ id: 'auth',
121
+ level: 'ok',
122
+ text: `authorization server reachable (${project.auth_origin})`,
123
+ }
124
+ : {
125
+ id: 'auth',
126
+ level: 'bad',
127
+ text: `authorization server ${project.auth_origin}: ${oidc.error ?? `HTTP ${oidc.status}`}`,
128
+ });
129
+ const api = await remote.probe(docsUrl(project.api_base, 'en'));
130
+ if (api.ok)
131
+ checks.push({ id: 'api', level: 'ok', text: `API reachable (${project.api_base})` });
132
+ else if (api.empty404)
133
+ checks.push({ id: 'api', level: 'bad', text: `API ${project.api_base}: ${EMPTY_404_HINT}` });
134
+ else
135
+ checks.push({
136
+ id: 'api',
137
+ level: 'bad',
138
+ text: `API ${project.api_base}: ${api.error ?? `HTTP ${api.status}`}`,
139
+ });
140
+ }
141
+ return checks;
142
+ }
143
+ function escapeRe(s) {
144
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
145
+ }
@@ -0,0 +1,18 @@
1
+ import { type ParsedArgs } from '../args.js';
2
+ import { type StoredAuth } from '../auth-store.js';
3
+ import { type DeveloperApi } from '../developer-api.js';
4
+ import { type Reporter } from '../output.js';
5
+ import { type Remote } from '../remote.js';
6
+ export declare const DEFAULT_CALLBACK = "http://127.0.0.1:3000/auth/aipay/callback";
7
+ export declare const SDK_PACKAGE = "@coswic/aipay-sdk";
8
+ export interface InitDeps {
9
+ remote?: Remote;
10
+ /** Injected for tests: the developer login (null = signed out). */
11
+ login?: StoredAuth | null;
12
+ developerApi?: DeveloperApi;
13
+ now?: () => Date;
14
+ /** Runs the package-manager install; returns exit code. Injected for tests. */
15
+ run?: (cmd: string[], cwd: string, quiet: boolean) => number;
16
+ env?: NodeJS.ProcessEnv;
17
+ }
18
+ export declare function runInit(args: ParsedArgs, out: Reporter, deps?: InitDeps): Promise<number>;
@@ -0,0 +1,265 @@
1
+ /**
2
+ * `aipay init --app <client_id> --auth <origin> --api <base> [--dir] [--callback]
3
+ * [--name] [--json] [--dry-run] [--no-install]`
4
+ *
5
+ * Links the project to an app and scaffolds the integration. Idempotent: rerunning
6
+ * updates .aipay/project.json + .env.local and never overwrites files it did not write
7
+ * this run. Linking an existing app uses public client info.
8
+ */
9
+ import { spawnSync } from 'node:child_process';
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
+ import { dirname, join, resolve } from 'node:path';
12
+ import { AGENT_GUIDE_FILE, agentGuide } from '../agent-guide.js';
13
+ import { flagBool, flagString } from '../args.js';
14
+ import { createAuthStore } from '../auth-store.js';
15
+ import { detectProject, installCommand } from '../detect.js';
16
+ import { openDeveloperApi } from '../developer-api.js';
17
+ import { parseCallback } from '../loopback.js';
18
+ import { CliError, EXIT } from '../output.js';
19
+ import { ENV_FILE, ENV_KEYS, FRAMEWORKS, isFramework, mergeEnvFile, mergeGitignore, readProject, VITE_ENV_KEYS, writeProject, } from '../project.js';
20
+ import { createRemote, docsUrl, EMPTY_404_HINT } from '../remote.js';
21
+ import { reportSetupEvent } from '../setup-events.js';
22
+ import { scaffoldFiles } from '../templates.js';
23
+ import { cliVersion } from '../version.js';
24
+ import { createApp } from './apps.js';
25
+ export const DEFAULT_CALLBACK = 'http://127.0.0.1:3000/auth/aipay/callback';
26
+ export const SDK_PACKAGE = '@coswic/aipay-sdk';
27
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
28
+ export async function runInit(args, out, deps = {}) {
29
+ const env = deps.env ?? process.env;
30
+ const dir = resolve(flagString(args.flags, 'dir') ?? '.');
31
+ const existing = safeReadProject(dir);
32
+ const callback = flagString(args.flags, 'callback') ?? existing?.callback ?? DEFAULT_CALLBACK;
33
+ // Signed in as a developer? Then --auth/--api can come from the login, and --create can
34
+ // make the app first (Idempotency-Key: rerunning never makes a second app).
35
+ const login = deps.login === undefined ? createAuthStore(env).load() : deps.login;
36
+ let clientId = flagString(args.flags, 'app');
37
+ let createdName = null;
38
+ const createName = flagString(args.flags, 'create');
39
+ if (createName) {
40
+ if (clientId)
41
+ throw new CliError('usage', 'pass either --app <client_id> or --create <name>, not both', EXIT.usage);
42
+ if (!login)
43
+ throw new CliError('not_logged_in', '--create needs a developer sign-in first: `aipay login --api <base>`', EXIT.problem);
44
+ const api = deps.developerApi ?? (await openDeveloperApi(createAuthStore(env)));
45
+ const created = await createApp(api, {
46
+ name: createName,
47
+ callback,
48
+ key: flagString(args.flags, 'key'),
49
+ });
50
+ clientId = created.client_id;
51
+ createdName = createName;
52
+ out.line('ok', `${created.replayed ? 'app already existed' : 'created app'} "${createName}" — client_id ${clientId}`);
53
+ }
54
+ if (!clientId)
55
+ throw new CliError('usage', 'missing --app <client_id> (from the Console, after creating the app) or --create <name>', EXIT.usage);
56
+ if (!UUID_RE.test(clientId))
57
+ throw new CliError('usage', `--app must be the app's client_id (a UUID), got "${clientId}"`, EXIT.usage);
58
+ const authOrigin = originOf(flagString(args.flags, 'auth') ?? existing?.auth_origin ?? login?.auth_origin ?? null, '--auth');
59
+ const apiBase = originOf(flagString(args.flags, 'api') ?? existing?.api_base ?? login?.api_base ?? null, '--api');
60
+ const target = parseCallback(callback); // throws on non-loopback
61
+ const dryRun = flagBool(args.flags, 'dry-run');
62
+ const install = flagBool(args.flags, 'install', true);
63
+ const name = flagString(args.flags, 'name') ?? createdName ?? existing?.app.name ?? null;
64
+ const remote = deps.remote ?? createRemote();
65
+ const now = (deps.now ?? (() => new Date()))();
66
+ const det = detectProject(dir);
67
+ // --framework: detection can be wrong (monorepos, unusual layouts) — the developer/agent may
68
+ // name the target; the value must be one we have templates for.
69
+ const forced = flagString(args.flags, 'framework');
70
+ if (forced !== undefined && forced !== null) {
71
+ if (!isFramework(forced))
72
+ throw new CliError('usage', `--framework must be one of ${FRAMEWORKS.join(', ')} (got "${forced}")`, EXIT.usage);
73
+ det.framework = forced;
74
+ }
75
+ out.line('info', `project: ${dir}`);
76
+ out.kv('package.json', det.hasPackageJson ? 'found' : 'none (plain directory)');
77
+ out.kv('manager', det.packageManager ?? '—');
78
+ out.kv('framework', `${det.framework ?? '—'}${forced ? ' (from --framework)' : ''}`);
79
+ out.kv('language', det.typescript ? 'TypeScript' : 'JavaScript');
80
+ out.kv('callback', callback);
81
+ const plan = [];
82
+ const writes = [];
83
+ // 1. project file
84
+ const project = {
85
+ version: 1,
86
+ app: { client_id: clientId, name },
87
+ auth_origin: authOrigin,
88
+ api_base: apiBase,
89
+ callback,
90
+ scaffold: {
91
+ package_manager: det.packageManager,
92
+ framework: det.framework,
93
+ files: existing?.scaffold.files ?? [],
94
+ },
95
+ linked_at: now.toISOString(),
96
+ };
97
+ // 2. env file (merge)
98
+ const envPath = join(dir, ENV_FILE);
99
+ const envBefore = existsSync(envPath) ? readFileSync(envPath, 'utf8') : null;
100
+ const envMerged = mergeEnvFile(envBefore, {
101
+ [ENV_KEYS.clientId]: clientId,
102
+ [ENV_KEYS.authOrigin]: authOrigin,
103
+ [ENV_KEYS.apiBase]: apiBase,
104
+ [ENV_KEYS.callback]: callback,
105
+ // Vite exposes only VITE_-prefixed values to the browser: same four public values, aliased.
106
+ ...(det.framework === 'vite'
107
+ ? {
108
+ [VITE_ENV_KEYS.clientId]: clientId,
109
+ [VITE_ENV_KEYS.authOrigin]: authOrigin,
110
+ [VITE_ENV_KEYS.apiBase]: apiBase,
111
+ [VITE_ENV_KEYS.callback]: callback,
112
+ }
113
+ : {}),
114
+ });
115
+ if (envMerged.changed.length) {
116
+ plan.push({
117
+ path: ENV_FILE,
118
+ action: envBefore === null ? 'write' : 'update',
119
+ why: envMerged.changed.join(', '),
120
+ });
121
+ writes.push({ path: envPath, content: envMerged.content });
122
+ }
123
+ else
124
+ plan.push({ path: ENV_FILE, action: 'skip', why: 'up to date' });
125
+ // 3. gitignore (append)
126
+ const giPath = join(dir, '.gitignore');
127
+ const giBefore = existsSync(giPath) ? readFileSync(giPath, 'utf8') : null;
128
+ const gi = mergeGitignore(giBefore, [ENV_FILE, '.aipay/local/']);
129
+ if (gi.added.length) {
130
+ plan.push({
131
+ path: '.gitignore',
132
+ action: giBefore === null ? 'write' : 'update',
133
+ why: gi.added.join(', '),
134
+ });
135
+ writes.push({ path: giPath, content: gi.content });
136
+ }
137
+ else
138
+ plan.push({ path: '.gitignore', action: 'skip', why: 'already ignores them' });
139
+ // 4. scaffold (never overwrite)
140
+ const scaffolded = [];
141
+ if (det.hasPackageJson && det.framework) {
142
+ for (const f of scaffoldFiles({
143
+ framework: det.framework,
144
+ typescript: det.typescript,
145
+ srcDir: det.srcDir,
146
+ callbackPath: target.path,
147
+ })) {
148
+ const abs = join(dir, f.path);
149
+ if (existsSync(abs))
150
+ plan.push({ path: f.path, action: 'skip', why: 'exists' });
151
+ else {
152
+ plan.push({ path: f.path, action: 'write' });
153
+ writes.push({ path: abs, content: f.content });
154
+ scaffolded.push(f.path);
155
+ }
156
+ }
157
+ }
158
+ else
159
+ plan.push({ path: '(scaffold)', action: 'skip', why: 'no package.json — link only' });
160
+ project.scaffold.files = Array.from(new Set([...project.scaffold.files, ...scaffolded]));
161
+ // 5. docs → agent guide
162
+ const docs = await remote.fetchDocs(apiBase, 'en');
163
+ if (docs.empty404)
164
+ out.line('warn', EMPTY_404_HINT);
165
+ else if (!docs.markdown)
166
+ out.line('warn', `docs fetch failed (${docs.status}); guide written without the docs body`);
167
+ const guidePath = join(dir, AGENT_GUIDE_FILE);
168
+ writes.push({
169
+ path: guidePath,
170
+ content: agentGuide({
171
+ project,
172
+ scaffolded: project.scaffold.files,
173
+ docsMarkdown: docs.markdown,
174
+ docsUrl: docsUrl(apiBase, 'en'),
175
+ }),
176
+ });
177
+ plan.push({ path: AGENT_GUIDE_FILE, action: existsSync(guidePath) ? 'update' : 'write' });
178
+ plan.push({ path: '.aipay/project.json', action: existing ? 'update' : 'write' });
179
+ // 6. install
180
+ let installCmd = null;
181
+ if (det.hasPackageJson && !det.sdkInstalled && install && det.packageManager)
182
+ installCmd = installCommand(det.packageManager, SDK_PACKAGE);
183
+ out.blank();
184
+ for (const p of plan)
185
+ out.line(p.action === 'skip' ? 'info' : 'ok', `${p.action.padEnd(6)} ${p.path}${p.why ? ` (${p.why})` : ''}`);
186
+ if (installCmd)
187
+ out.line('info', `${dryRun ? 'would run' : 'run '} ${installCmd.join(' ')}`);
188
+ else if (det.hasPackageJson && det.sdkInstalled)
189
+ out.line('info', `skip ${SDK_PACKAGE} (already a dependency)`);
190
+ let installExit = null;
191
+ if (!dryRun) {
192
+ for (const w of writes) {
193
+ mkdirSync(dirname(w.path), { recursive: true });
194
+ writeFileSync(w.path, w.content);
195
+ }
196
+ writeProject(dir, project);
197
+ if (installCmd) {
198
+ const run = deps.run ?? defaultRun;
199
+ // --json: the install's own output must not land in stdout (it would corrupt the document)
200
+ installExit = run(installCmd, dir, out.json);
201
+ if (installExit !== 0)
202
+ out.line('bad', `install failed (exit ${installExit}); run it yourself: ${installCmd.join(' ')}`);
203
+ else
204
+ out.line('ok', `installed ${SDK_PACKAGE}`);
205
+ }
206
+ }
207
+ // C2 setup session:登入狀態下回報 init(Console 的「等待工具連線」變綠);失敗不影響結果
208
+ let reported = false;
209
+ if (!dryRun)
210
+ reported = await reportSetupEvent(clientId, 'cli.init', {
211
+ framework: det.framework ?? 'none',
212
+ package_manager: det.packageManager ?? 'none',
213
+ typescript: det.typescript,
214
+ files: project.scaffold.files,
215
+ cli_version: cliVersion(),
216
+ }, out, env);
217
+ out.blank();
218
+ out.line('ok', dryRun ? 'dry run — nothing written' : `linked to app ${name ?? clientId}`);
219
+ out.line('info', 'next: run `aipay test` — it receives the callback on a free loopback port, so your dev server can keep running');
220
+ out.result({
221
+ ok: installExit === null || installExit === 0,
222
+ dry_run: dryRun,
223
+ dir,
224
+ project,
225
+ detection: det,
226
+ plan,
227
+ install: installCmd ? { command: installCmd, exit: installExit } : null,
228
+ docs: { fetched: !!docs.markdown, empty404: docs.empty404, status: docs.status },
229
+ reported,
230
+ });
231
+ return installExit === null || installExit === 0 ? EXIT.ok : EXIT.problem;
232
+ }
233
+ function safeReadProject(dir) {
234
+ try {
235
+ return readProject(dir);
236
+ }
237
+ catch {
238
+ return null;
239
+ }
240
+ }
241
+ function originOf(v, flag) {
242
+ if (!v)
243
+ throw new CliError('usage', `missing ${flag} <url> (the Console prompt lists both the authorization server and the API base)`, EXIT.usage);
244
+ let u;
245
+ try {
246
+ u = new URL(v);
247
+ }
248
+ catch {
249
+ throw new CliError('usage', `${flag} must be a URL, got "${v}"`, EXIT.usage);
250
+ }
251
+ if (u.protocol !== 'https:' && u.protocol !== 'http:')
252
+ throw new CliError('usage', `${flag} must be http(s)`, EXIT.usage);
253
+ return u.origin;
254
+ }
255
+ function defaultRun(cmd, cwd, quiet) {
256
+ const [bin, ...rest] = cmd;
257
+ const r = spawnSync(bin, rest, {
258
+ cwd,
259
+ stdio: quiet ? ['ignore', 'pipe', 'pipe'] : 'inherit',
260
+ encoding: 'utf8',
261
+ });
262
+ if (quiet && r.status !== 0)
263
+ process.stderr.write(`${r.stdout ?? ''}${r.stderr ?? ''}`);
264
+ return r.status ?? 1;
265
+ }
@@ -0,0 +1,34 @@
1
+ import { type ParsedArgs } from '../args.js';
2
+ import { type Reporter } from '../output.js';
3
+ export interface CliConfig {
4
+ client_id: string;
5
+ auth_origin: string;
6
+ scopes: string[];
7
+ redirect_path: string;
8
+ audience: string;
9
+ /** A2: the server allows the device grant for the CLI (older APIs omit it = false). */
10
+ device_authorization: boolean;
11
+ }
12
+ export interface LoginDeps {
13
+ env?: NodeJS.ProcessEnv;
14
+ open?: (url: string) => boolean;
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>;
23
+ }
24
+ export declare function fetchCliConfig(apiBase: string): Promise<CliConfig>;
25
+ /** Resolve the API base: flag → env → linked project → error. */
26
+ export declare function resolveApiBase(args: ParsedArgs, env: NodeJS.ProcessEnv, dir: string): string;
27
+ /** Authorization URL for the management login. The SDK's createConnectUrl fixes the
28
+ * audience to aipay-api (apps), so the CLI builds its own with the developer audience. */
29
+ export declare function managementAuthorizeUrl(cfg: CliConfig, redirectUri: string): {
30
+ url: string;
31
+ state: string;
32
+ codeVerifier: string;
33
+ };
34
+ export declare function runLogin(args: ParsedArgs, out: Reporter, deps?: LoginDeps): Promise<number>;
@@ -0,0 +1,187 @@
1
+ /**
2
+ * `aipay login [--api <base>] [--device] [--no-open] [--timeout <s>] [--json]`
3
+ *
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.
11
+ */
12
+ import { createHash, randomBytes } from 'node:crypto';
13
+ import { exchangeCode, getUserInfo } from '@coswic/aipay-sdk';
14
+ import { flagBool, flagString } from '../args.js';
15
+ import { createAuthStore } from '../auth-store.js';
16
+ import { openBrowser } from '../browser.js';
17
+ import { pollDeviceToken, requestDeviceCode } from '../device.js';
18
+ import { startLoopback } from '../loopback.js';
19
+ import { isEmpty404 } from '../network.js';
20
+ import { CliError, EXIT } from '../output.js';
21
+ import { readProject } from '../project.js';
22
+ import { EMPTY_404_HINT } from '../remote.js';
23
+ export async function fetchCliConfig(apiBase) {
24
+ const f = fetch;
25
+ let res;
26
+ try {
27
+ res = await f(new URL('/v1/cli/config', apiBase));
28
+ }
29
+ catch (e) {
30
+ throw new CliError('network', `cannot reach ${apiBase}: ${e.message}. Check the endpoint URL and network connectivity, then retry.`, EXIT.remote);
31
+ }
32
+ const text = await res.text();
33
+ if (isEmpty404(res.status, text))
34
+ throw new CliError('empty_404', EMPTY_404_HINT, EXIT.remote);
35
+ if (res.status === 404)
36
+ throw new CliError('cli_disabled', `${apiBase} does not offer CLI sign-in (developer management is not enabled there)`, EXIT.remote);
37
+ if (!res.ok)
38
+ throw new CliError('remote', `GET /v1/cli/config → HTTP ${res.status}`, EXIT.remote);
39
+ const cfg = JSON.parse(text);
40
+ if (typeof cfg.client_id !== 'string' ||
41
+ typeof cfg.auth_origin !== 'string' ||
42
+ !Array.isArray(cfg.scopes))
43
+ throw new CliError('remote', 'unexpected /v1/cli/config shape', EXIT.remote);
44
+ return {
45
+ client_id: cfg.client_id,
46
+ auth_origin: cfg.auth_origin,
47
+ scopes: cfg.scopes.filter((s) => typeof s === 'string'),
48
+ redirect_path: typeof cfg.redirect_path === 'string' ? cfg.redirect_path : '/cli/callback',
49
+ audience: typeof cfg.audience === 'string' ? cfg.audience : 'aipay-developer-api',
50
+ device_authorization: cfg.device_authorization === true,
51
+ };
52
+ }
53
+ /** Resolve the API base: flag → env → linked project → error. */
54
+ export function resolveApiBase(args, env, dir) {
55
+ const v = flagString(args.flags, 'api') ?? env.AIPAY_API_BASE ?? readProjectSafe(dir)?.api_base ?? null;
56
+ if (!v)
57
+ throw new CliError('usage', 'which AI-Pay? pass --api <api base> (the Console prompt lists it) or set AIPAY_API_BASE', EXIT.usage);
58
+ try {
59
+ return new URL(v).origin;
60
+ }
61
+ catch {
62
+ throw new CliError('usage', `--api must be a URL, got "${v}"`, EXIT.usage);
63
+ }
64
+ }
65
+ function readProjectSafe(dir) {
66
+ try {
67
+ return readProject(dir);
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ /** Authorization URL for the management login. The SDK's createConnectUrl fixes the
74
+ * audience to aipay-api (apps), so the CLI builds its own with the developer audience. */
75
+ export function managementAuthorizeUrl(cfg, redirectUri) {
76
+ const codeVerifier = randomBytes(32).toString('base64url');
77
+ const state = randomBytes(16).toString('base64url');
78
+ const challenge = createHash('sha256').update(codeVerifier).digest('base64url');
79
+ const u = new URL('/oauth2/auth', cfg.auth_origin);
80
+ u.searchParams.set('client_id', cfg.client_id);
81
+ u.searchParams.set('response_type', 'code');
82
+ u.searchParams.set('scope', cfg.scopes.join(' '));
83
+ u.searchParams.set('state', state);
84
+ u.searchParams.set('redirect_uri', redirectUri);
85
+ u.searchParams.set('code_challenge', challenge);
86
+ u.searchParams.set('code_challenge_method', 'S256');
87
+ u.searchParams.set('audience', cfg.audience);
88
+ return { url: u.toString(), state, codeVerifier };
89
+ }
90
+ export async function runLogin(args, out, deps = {}) {
91
+ const env = deps.env ?? process.env;
92
+ const dir = flagString(args.flags, 'dir') ?? '.';
93
+ const apiBase = resolveApiBase(args, env, dir);
94
+ const cfg = await fetchCliConfig(apiBase);
95
+ const timeoutS = Number(flagString(args.flags, 'timeout') ?? '300');
96
+ const fetchImpl = deps.fetchImpl ?? fetch;
97
+ if (flagBool(args.flags, 'device', false))
98
+ return deviceLogin(cfg, apiBase, timeoutS, fetchImpl, env, out, deps);
99
+ const loop = await startLoopback({ host: '127.0.0.1', port: 0, path: cfg.redirect_path }, { timeoutMs: timeoutS * 1000 });
100
+ const redirectUri = `http://127.0.0.1:${loop.port}${cfg.redirect_path}`;
101
+ const { url, state, codeVerifier } = managementAuthorizeUrl(cfg, redirectUri);
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');
105
+ if (out.json)
106
+ out.debug(`authorize_url ${url}`);
107
+ deps.onAuthorizeUrl?.(url);
108
+ if (flagBool(args.flags, 'open', true))
109
+ (deps.open ?? openBrowser)(url);
110
+ try {
111
+ const cb = await loop.waitForCallback;
112
+ const u = new URL(cb.url);
113
+ const err = u.searchParams.get('error');
114
+ if (err)
115
+ throw new CliError('denied', `authorization failed: ${err} ${u.searchParams.get('error_description') ?? ''}`.trim(), EXIT.remote);
116
+ if (u.searchParams.get('state') !== state)
117
+ throw new CliError('state', 'state mismatch — restart `aipay login`', EXIT.remote);
118
+ const code = u.searchParams.get('code');
119
+ if (!code)
120
+ throw new CliError('remote', 'callback without a code', EXIT.remote);
121
+ const tokens = await exchangeCode({ hydraPublicUrl: cfg.auth_origin, clientId: cfg.client_id, redirectUri }, { code, codeVerifier, fetchImpl });
122
+ return finishLogin({
123
+ accessToken: tokens.accessToken,
124
+ refreshToken: tokens.refreshToken,
125
+ expiresInSeconds: tokens.expiresInSeconds,
126
+ scope: tokens.scope,
127
+ }, cfg, apiBase, fetchImpl, env, out);
128
+ }
129
+ finally {
130
+ await loop.close();
131
+ }
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
+ out.result({
178
+ ok: true,
179
+ api_base: apiBase,
180
+ email: auth.email,
181
+ sub: auth.sub,
182
+ scope: auth.scope,
183
+ store: store.kind,
184
+ expires_at: auth.expires_at,
185
+ });
186
+ return EXIT.ok;
187
+ }
@@ -0,0 +1,6 @@
1
+ /** `aipay whoami` and `aipay logout`. */
2
+ import type { ParsedArgs } from '../args.js';
3
+ import { type Reporter } from '../output.js';
4
+ export declare function runWhoami(_args: ParsedArgs, out: Reporter, env?: NodeJS.ProcessEnv): Promise<number>;
5
+ export declare function runLogout(_args: ParsedArgs, out: Reporter, env?: NodeJS.ProcessEnv): Promise<number>;
6
+ export declare function requireLogin(env: NodeJS.ProcessEnv): void;