@coswic/aipay 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +6 -10
- package/dist/agent-guide.d.ts +18 -0
- package/dist/agent-guide.js +60 -0
- package/dist/args.d.ts +15 -0
- package/dist/args.js +47 -0
- package/dist/auth-store.d.ts +47 -0
- package/dist/auth-store.js +176 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +8 -0
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +18 -0
- package/dist/commands/apps.d.ts +15 -0
- package/dist/commands/apps.js +53 -0
- package/dist/commands/doctor.d.ts +16 -0
- package/dist/commands/doctor.js +145 -0
- package/dist/commands/init.d.ts +18 -0
- package/dist/commands/init.js +265 -0
- package/dist/commands/login.d.ts +34 -0
- package/dist/commands/login.js +187 -0
- package/dist/commands/session.d.ts +6 -0
- package/dist/commands/session.js +62 -0
- package/dist/commands/status.d.ts +3 -0
- package/dist/commands/status.js +24 -0
- package/dist/commands/test.d.ts +12 -0
- package/dist/commands/test.js +159 -0
- package/dist/commands/unlink.d.ts +5 -0
- package/dist/commands/unlink.js +73 -0
- package/dist/detect.d.ts +27 -0
- package/dist/detect.js +83 -0
- package/dist/developer-api.d.ts +14 -0
- package/dist/developer-api.js +73 -0
- package/dist/device.d.ts +38 -0
- package/dist/device.js +122 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +22 -0
- package/dist/loopback.d.ts +37 -0
- package/dist/loopback.js +95 -0
- package/dist/main.d.ts +5 -0
- package/dist/main.js +90 -0
- package/dist/network.d.ts +3 -0
- package/dist/network.js +5 -0
- package/dist/output.d.ts +36 -0
- package/dist/output.js +52 -0
- package/dist/project.d.ts +61 -0
- package/dist/project.js +126 -0
- package/dist/remote.d.ts +21 -0
- package/dist/remote.js +41 -0
- package/dist/setup-events.d.ts +3 -0
- package/dist/setup-events.js +28 -0
- package/dist/templates.d.ts +24 -0
- package/dist/templates.js +333 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +10 -0
- package/package.json +4 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# @coswic/aipay
|
|
2
|
+
|
|
3
|
+
## 0.4.0 — 2026-09-08
|
|
4
|
+
|
|
5
|
+
- Simplify setup instructions around standard OAuth and HTTPS connectivity.
|
|
6
|
+
- Report empty 404 responses without inferring their cause.
|
|
7
|
+
- Remove the retired `AIPAY_GATE_TOKEN` setting and automatic `X-AIPay-Gate` header injection. OAuth tokens, PKCE, device authorization, refresh and revocation retain their existing behavior.
|
|
8
|
+
- **Programmatic compatibility:** remove the `GATE_ENV`, `gatedFetch`, `gateHeaders` and `looksGated` exports; use `isEmpty404` to identify an empty 404 response. Remove the gate-only environment arguments from `createRemote`, `fetchCliConfig`, `openDeveloperApi` and the doctor dependency interface. `Remote.fetchDocs`/`probe` results and init JSON use `empty404` instead of `gated`; the corresponding error code is `empty_404`.
|
|
9
|
+
|
|
10
|
+
## 0.3.0
|
|
11
|
+
|
|
12
|
+
- `aipay init --framework next|express|fastify|hono|vite|node` overrides framework detection.
|
|
13
|
+
- Vite (browser SPA) projects: `init` writes a browser helper (`aipay.ts`, PKCE in the page,
|
|
14
|
+
tokens in sessionStorage) plus `aipay-boot.ts`, and `VITE_AIPAY_*` aliases in `.env.local`
|
|
15
|
+
(Vite exposes only VITE_-prefixed values). Server frameworks still win when both are present.
|
|
16
|
+
- `aipay test` reports failures to the Console too (`cli.test` with `ok:false` and an error
|
|
17
|
+
code), so the verify step can show what went wrong.
|
|
18
|
+
|
|
19
|
+
## 0.2.1
|
|
20
|
+
|
|
21
|
+
Same content as 0.2.0. Version 0.2.0 on npm was published by mistake without `dist` (the
|
|
22
|
+
tarball only had README and package.json); it is deprecated and `latest` points past it.
|
|
23
|
+
Install 0.2.1 or later.
|
|
24
|
+
|
|
25
|
+
## 0.2.0
|
|
26
|
+
|
|
27
|
+
- `aipay login --device`: RFC 8628 device authorization for machines without a browser (SSH
|
|
28
|
+
boxes, containers, CI). Prints a URL and a short code; approve on any device; the CLI polls.
|
|
29
|
+
Requires an AI-Pay whose `/v1/cli/config` reports `device_authorization: true`.
|
|
30
|
+
- Windows: developer tokens are now protected with DPAPI (Windows PowerShell
|
|
31
|
+
`ConvertFrom-SecureString`) in `%APPDATA%\aipay\credentials.dpapi` instead of a plaintext
|
|
32
|
+
`credentials.json`. An existing plaintext file is read once and replaced on the next login.
|
|
33
|
+
`AIPAY_TOKEN_STORE=file` keeps the old behaviour.
|
|
34
|
+
- `aipay login` (browser flow) mentions `--device` when the server supports it.
|
|
35
|
+
|
|
36
|
+
## 0.1.0
|
|
37
|
+
|
|
38
|
+
- First release: `init`, `test`, `doctor`, `status`, `unlink`, `login`/`logout`/`whoami`,
|
|
39
|
+
`apps list`/`apps create`; loopback PKCE on any free port; tokens in the OS keychain or a
|
|
40
|
+
0600 file; setup progress reported to the Console.
|
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. |
|
|
@@ -37,16 +37,12 @@ own. Nothing is written to the project; tokens go to the keychain or the 0600 fi
|
|
|
37
37
|
like the browser flow. With `--json`, the URL and code are echoed on stderr as
|
|
38
38
|
`device_url …` / `user_code …` so an agent can show them to you.
|
|
39
39
|
|
|
40
|
-
##
|
|
40
|
+
## Connection troubleshooting
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
request
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
export AIPAY_GATE_TOKEN=<token from the Console>
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
The CLI never writes the token to any file and never prints it.
|
|
42
|
+
Check the configured API and authorization server URLs and network connectivity if a
|
|
43
|
+
request fails. An empty `404` alone does not identify the cause; it does not establish
|
|
44
|
+
whether a path is missing or why the server returned that response. Run `aipay doctor`
|
|
45
|
+
to check the configured endpoints before retrying.
|
|
50
46
|
|
|
51
47
|
## What gets written
|
|
52
48
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `aipay-agent.md`: the file a coding agent reads first. Header = the facts of THIS
|
|
3
|
+
* project (app, endpoints, what was scaffolded, what "done" means); body = the AI-Pay
|
|
4
|
+
* developer docs fetched from the API (`/docs.md?lang=en`), so the agent does not need
|
|
5
|
+
* network access to read them later.
|
|
6
|
+
*
|
|
7
|
+
* Never contains tokens. If the docs cannot be fetched, the file still gets
|
|
8
|
+
* written with the header and a pointer, and the CLI warns.
|
|
9
|
+
*/
|
|
10
|
+
import type { ProjectConfig } from './project.js';
|
|
11
|
+
export declare const AGENT_GUIDE_FILE = "aipay-agent.md";
|
|
12
|
+
export interface GuideInput {
|
|
13
|
+
project: ProjectConfig;
|
|
14
|
+
scaffolded: string[];
|
|
15
|
+
docsMarkdown: string | null;
|
|
16
|
+
docsUrl: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function agentGuide(input: GuideInput): string;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export const AGENT_GUIDE_FILE = 'aipay-agent.md';
|
|
2
|
+
export function agentGuide(input) {
|
|
3
|
+
const p = input.project;
|
|
4
|
+
const files = input.scaffolded.length
|
|
5
|
+
? input.scaffolded.map((f) => `- \`${f}\``).join('\n')
|
|
6
|
+
: '- (none — the project had them already)';
|
|
7
|
+
const head = `# AI-Pay integration guide for this project
|
|
8
|
+
|
|
9
|
+
> Written by \`aipay init\` on ${p.linked_at}. Regenerate with \`aipay init --app ${p.app.client_id}\`. Safe to commit: it contains no secrets.
|
|
10
|
+
|
|
11
|
+
## This project
|
|
12
|
+
|
|
13
|
+
| | |
|
|
14
|
+
|---|---|
|
|
15
|
+
| App | ${p.app.name ?? '(unnamed)'} |
|
|
16
|
+
| client_id | \`${p.app.client_id}\` (public client, PKCE S256, no secret) |
|
|
17
|
+
| Authorization server | ${p.auth_origin} |
|
|
18
|
+
| API base | ${p.api_base} |
|
|
19
|
+
| Registered callback | \`${p.callback}\` (for 127.0.0.1 the port is not compared, so any local port works) |
|
|
20
|
+
| Local config | \`.aipay/project.json\` (commit it), \`.env.local\` (do not commit) |
|
|
21
|
+
|
|
22
|
+
Files scaffolded by the CLI (edit freely; the CLI never overwrites existing files):
|
|
23
|
+
${files}
|
|
24
|
+
|
|
25
|
+
## Definition of done
|
|
26
|
+
|
|
27
|
+
1. \`aipay doctor\` reports no problems.
|
|
28
|
+
2. The app's dev server serves the start route and the callback route above.
|
|
29
|
+
3. \`aipay test\` completes: a real sign-in through the AI-Pay authorization server and
|
|
30
|
+
one chat completion whose response carries a receipt (\`aipay.request_id\`).
|
|
31
|
+
4. Typecheck/build pass. Do not commit automatically and do not deploy.
|
|
32
|
+
|
|
33
|
+
## Rules for agents
|
|
34
|
+
|
|
35
|
+
- Read the docs below before changing the integration; they are the source of truth.
|
|
36
|
+
- Use standard OAuth sign-in and app authorization.
|
|
37
|
+
- Keep OAuth tokens out of code, config files, and version control.
|
|
38
|
+
- Never request \`offline_access\` unless the product needs background renewal.
|
|
39
|
+
- Key user accounts on \`sub\` from the userinfo endpoint, never on email.
|
|
40
|
+
|
|
41
|
+
`;
|
|
42
|
+
const body = input.docsMarkdown
|
|
43
|
+
? `---
|
|
44
|
+
|
|
45
|
+
# AI-Pay developer documentation
|
|
46
|
+
|
|
47
|
+
_Fetched from ${input.docsUrl} by \`aipay init\`._
|
|
48
|
+
|
|
49
|
+
${input.docsMarkdown.trim()}
|
|
50
|
+
`
|
|
51
|
+
: `---
|
|
52
|
+
|
|
53
|
+
# AI-Pay developer documentation
|
|
54
|
+
|
|
55
|
+
The docs could not be fetched when this file was written (see the \`aipay init\` output).
|
|
56
|
+
Check the endpoint URL and network connectivity, then read
|
|
57
|
+
${input.docsUrl} or run \`aipay init\` again. An empty 404 alone does not identify the cause.
|
|
58
|
+
`;
|
|
59
|
+
return head + body;
|
|
60
|
+
}
|
package/dist/args.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny argv parser — zero deps on purpose (the CLI is installed by coding agents in
|
|
3
|
+
* arbitrary projects; every dependency is attack surface and install time).
|
|
4
|
+
*
|
|
5
|
+
* Supports `--flag`, `--flag value`, `--flag=value`, `--no-flag`, and positionals.
|
|
6
|
+
* Every flag is a string or boolean; commands coerce what they need.
|
|
7
|
+
*/
|
|
8
|
+
export interface ParsedArgs {
|
|
9
|
+
command: string | null;
|
|
10
|
+
positionals: string[];
|
|
11
|
+
flags: Record<string, string | boolean>;
|
|
12
|
+
}
|
|
13
|
+
export declare function parseArgs(argv: string[]): ParsedArgs;
|
|
14
|
+
export declare function flagString(flags: ParsedArgs['flags'], name: string): string | null;
|
|
15
|
+
export declare function flagBool(flags: ParsedArgs['flags'], name: string, dflt?: boolean): boolean;
|
package/dist/args.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export function parseArgs(argv) {
|
|
2
|
+
const out = { command: null, positionals: [], flags: {} };
|
|
3
|
+
for (let i = 0; i < argv.length; i++) {
|
|
4
|
+
const a = argv[i] ?? '';
|
|
5
|
+
if (a === '--') {
|
|
6
|
+
out.positionals.push(...argv.slice(i + 1));
|
|
7
|
+
break;
|
|
8
|
+
}
|
|
9
|
+
if (a.startsWith('--')) {
|
|
10
|
+
const body = a.slice(2);
|
|
11
|
+
const eq = body.indexOf('=');
|
|
12
|
+
if (eq >= 0) {
|
|
13
|
+
out.flags[body.slice(0, eq)] = body.slice(eq + 1);
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (body.startsWith('no-')) {
|
|
17
|
+
out.flags[body.slice(3)] = false;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const next = argv[i + 1];
|
|
21
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
22
|
+
out.flags[body] = next;
|
|
23
|
+
i++;
|
|
24
|
+
}
|
|
25
|
+
else
|
|
26
|
+
out.flags[body] = true;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (out.command === null)
|
|
30
|
+
out.command = a;
|
|
31
|
+
else
|
|
32
|
+
out.positionals.push(a);
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
export function flagString(flags, name) {
|
|
37
|
+
const v = flags[name];
|
|
38
|
+
return typeof v === 'string' && v !== '' ? v : null;
|
|
39
|
+
}
|
|
40
|
+
export function flagBool(flags, name, dflt = false) {
|
|
41
|
+
const v = flags[name];
|
|
42
|
+
if (typeof v === 'boolean')
|
|
43
|
+
return v;
|
|
44
|
+
if (typeof v === 'string')
|
|
45
|
+
return v !== 'false' && v !== '0';
|
|
46
|
+
return dflt;
|
|
47
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
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';
|
|
12
|
+
export interface StoredAuth {
|
|
13
|
+
api_base: string;
|
|
14
|
+
auth_origin: string;
|
|
15
|
+
client_id: string;
|
|
16
|
+
access_token: string;
|
|
17
|
+
refresh_token: string | null;
|
|
18
|
+
/** epoch ms */
|
|
19
|
+
expires_at: number;
|
|
20
|
+
scope: string;
|
|
21
|
+
email: string | null;
|
|
22
|
+
sub: string;
|
|
23
|
+
}
|
|
24
|
+
export interface AuthStore {
|
|
25
|
+
/** keychain = OS secret service; dpapi = Windows user-scoped encryption; file = plain 0600 file */
|
|
26
|
+
kind: 'keychain' | 'dpapi' | 'file';
|
|
27
|
+
load(): StoredAuth | null;
|
|
28
|
+
save(auth: StoredAuth): void;
|
|
29
|
+
clear(): void;
|
|
30
|
+
/** Human description of where tokens are kept (for `whoami`/`login` output). */
|
|
31
|
+
where: string;
|
|
32
|
+
}
|
|
33
|
+
export declare function configDir(env?: NodeJS.ProcessEnv): string;
|
|
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;
|
|
47
|
+
export declare function fileStore(dir: string): AuthStore;
|
|
@@ -0,0 +1,176 @@
|
|
|
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 { spawnSync } from 'node:child_process';
|
|
12
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
const SERVICE = 'aipay-cli';
|
|
16
|
+
export function configDir(env = process.env) {
|
|
17
|
+
if (env.AIPAY_CONFIG_DIR)
|
|
18
|
+
return env.AIPAY_CONFIG_DIR;
|
|
19
|
+
const base = process.platform === 'win32'
|
|
20
|
+
? (env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'))
|
|
21
|
+
: (env.XDG_CONFIG_HOME ?? join(homedir(), '.config'));
|
|
22
|
+
return join(base, 'aipay');
|
|
23
|
+
}
|
|
24
|
+
function has(bin) {
|
|
25
|
+
const r = spawnSync(process.platform === 'win32' ? 'where' : 'which', [bin], { stdio: 'ignore' });
|
|
26
|
+
return r.status === 0;
|
|
27
|
+
}
|
|
28
|
+
export function createAuthStore(env = process.env) {
|
|
29
|
+
if (env.AIPAY_TOKEN_STORE !== 'file') {
|
|
30
|
+
if (process.platform === 'darwin' && has('security'))
|
|
31
|
+
return macKeychain();
|
|
32
|
+
if (process.platform === 'linux' && has('secret-tool'))
|
|
33
|
+
return secretTool();
|
|
34
|
+
if (process.platform === 'win32' && has('powershell'))
|
|
35
|
+
return dpapiStore(configDir(env));
|
|
36
|
+
}
|
|
37
|
+
return fileStore(configDir(env));
|
|
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
|
+
}
|
|
97
|
+
function macKeychain() {
|
|
98
|
+
const account = 'developer';
|
|
99
|
+
return {
|
|
100
|
+
kind: 'keychain',
|
|
101
|
+
where: 'macOS Keychain (service aipay-cli)',
|
|
102
|
+
load() {
|
|
103
|
+
const r = spawnSync('security', ['find-generic-password', '-a', account, '-s', SERVICE, '-w'], { encoding: 'utf8' });
|
|
104
|
+
if (r.status !== 0)
|
|
105
|
+
return null;
|
|
106
|
+
return parse(r.stdout.trim());
|
|
107
|
+
},
|
|
108
|
+
save(auth) {
|
|
109
|
+
const r = spawnSync('security', ['add-generic-password', '-U', '-a', account, '-s', SERVICE, '-w', JSON.stringify(auth)], { stdio: 'ignore' });
|
|
110
|
+
if (r.status !== 0)
|
|
111
|
+
throw new Error('could not write to the macOS Keychain');
|
|
112
|
+
},
|
|
113
|
+
clear() {
|
|
114
|
+
spawnSync('security', ['delete-generic-password', '-a', account, '-s', SERVICE], {
|
|
115
|
+
stdio: 'ignore',
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function secretTool() {
|
|
121
|
+
const attrs = ['service', SERVICE, 'account', 'developer'];
|
|
122
|
+
return {
|
|
123
|
+
kind: 'keychain',
|
|
124
|
+
where: 'the system keyring (secret-tool, service aipay-cli)',
|
|
125
|
+
load() {
|
|
126
|
+
const r = spawnSync('secret-tool', ['lookup', ...attrs], { encoding: 'utf8' });
|
|
127
|
+
if (r.status !== 0 || !r.stdout)
|
|
128
|
+
return null;
|
|
129
|
+
return parse(r.stdout.trim());
|
|
130
|
+
},
|
|
131
|
+
save(auth) {
|
|
132
|
+
const r = spawnSync('secret-tool', ['store', '--label', 'AI-Pay CLI', ...attrs], {
|
|
133
|
+
input: JSON.stringify(auth),
|
|
134
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
135
|
+
});
|
|
136
|
+
if (r.status !== 0)
|
|
137
|
+
throw new Error('could not write to the system keyring');
|
|
138
|
+
},
|
|
139
|
+
clear() {
|
|
140
|
+
spawnSync('secret-tool', ['clear', ...attrs], { stdio: 'ignore' });
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
export function fileStore(dir) {
|
|
145
|
+
const file = join(dir, 'credentials.json');
|
|
146
|
+
return {
|
|
147
|
+
kind: 'file',
|
|
148
|
+
where: `${file} (mode 0600)`,
|
|
149
|
+
load() {
|
|
150
|
+
if (!existsSync(file))
|
|
151
|
+
return null;
|
|
152
|
+
return parse(readFileSync(file, 'utf8'));
|
|
153
|
+
},
|
|
154
|
+
save(auth) {
|
|
155
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
156
|
+
writeFileSync(file, `${JSON.stringify(auth, null, 2)}\n`, { mode: 0o600 });
|
|
157
|
+
chmodSync(file, 0o600);
|
|
158
|
+
},
|
|
159
|
+
clear() {
|
|
160
|
+
rmSync(file, { force: true });
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function parse(raw) {
|
|
165
|
+
try {
|
|
166
|
+
const v = JSON.parse(raw);
|
|
167
|
+
if (typeof v.access_token !== 'string' ||
|
|
168
|
+
typeof v.api_base !== 'string' ||
|
|
169
|
+
typeof v.client_id !== 'string')
|
|
170
|
+
return null;
|
|
171
|
+
return v;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
package/dist/bin.d.ts
ADDED
package/dist/bin.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { main } from './main.js';
|
|
3
|
+
main(process.argv.slice(2)).then((code) => {
|
|
4
|
+
process.exitCode = code;
|
|
5
|
+
}, (err) => {
|
|
6
|
+
process.stderr.write(`${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`);
|
|
7
|
+
process.exitCode = 1;
|
|
8
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function openBrowser(url: string, platform?: NodeJS.Platform): boolean;
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Open a URL in the user's default browser without depending on the `open` package. */
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
export function openBrowser(url, platform = process.platform) {
|
|
4
|
+
const [cmd, args] = platform === 'darwin'
|
|
5
|
+
? ['open', [url]]
|
|
6
|
+
: platform === 'win32'
|
|
7
|
+
? ['cmd', ['/c', 'start', '', url.replace(/&/g, '^&')]]
|
|
8
|
+
: ['xdg-open', [url]];
|
|
9
|
+
try {
|
|
10
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
11
|
+
child.on('error', () => { });
|
|
12
|
+
child.unref();
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type ParsedArgs } from '../args.js';
|
|
2
|
+
import { type DeveloperApi } from '../developer-api.js';
|
|
3
|
+
import { type Reporter } from '../output.js';
|
|
4
|
+
export interface CreatedApp {
|
|
5
|
+
app_id: string;
|
|
6
|
+
client_id: string;
|
|
7
|
+
redirect_uris: string[];
|
|
8
|
+
replayed?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function createApp(api: DeveloperApi, input: {
|
|
11
|
+
name: string;
|
|
12
|
+
callback: string;
|
|
13
|
+
key?: string | null;
|
|
14
|
+
}): Promise<CreatedApp>;
|
|
15
|
+
export declare function runApps(args: ParsedArgs, out: Reporter, env?: NodeJS.ProcessEnv): Promise<number>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `aipay apps list [--json]`
|
|
3
|
+
* `aipay apps create --name <name> [--callback <uri>] [--key <idempotency key>] [--json]`
|
|
4
|
+
*
|
|
5
|
+
* Uses the developer's management token. Creation sends an Idempotency-Key so a retried
|
|
6
|
+
* command never makes a second app.
|
|
7
|
+
*/
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { flagString } from '../args.js';
|
|
10
|
+
import { createAuthStore } from '../auth-store.js';
|
|
11
|
+
import { errorMessage, openDeveloperApi } from '../developer-api.js';
|
|
12
|
+
import { CliError, EXIT } from '../output.js';
|
|
13
|
+
import { DEFAULT_CALLBACK } from './init.js';
|
|
14
|
+
export async function createApp(api, input) {
|
|
15
|
+
const key = input.key ?? `aipay-cli-${randomUUID()}`;
|
|
16
|
+
const res = await api.request('POST', '/v1/developer/apps', {
|
|
17
|
+
body: { name: input.name, redirect_uris: [input.callback] },
|
|
18
|
+
headers: { 'idempotency-key': key },
|
|
19
|
+
});
|
|
20
|
+
if (res.status !== 201 && res.status !== 200)
|
|
21
|
+
throw new CliError('create_failed', `could not create the app: ${errorMessage(res.body, res.status)}`, EXIT.remote);
|
|
22
|
+
return res.body;
|
|
23
|
+
}
|
|
24
|
+
export async function runApps(args, out, env = process.env) {
|
|
25
|
+
const sub = args.positionals[0] ?? 'list';
|
|
26
|
+
const store = createAuthStore(env);
|
|
27
|
+
const api = await openDeveloperApi(store);
|
|
28
|
+
if (sub === 'list') {
|
|
29
|
+
const res = await api.request('GET', '/v1/developer/apps');
|
|
30
|
+
if (res.status !== 200)
|
|
31
|
+
throw new CliError('remote', `GET /v1/developer/apps → ${errorMessage(res.body, res.status)}`, EXIT.remote);
|
|
32
|
+
const apps = Array.isArray(res.body) ? res.body : (res.body.data ?? res.body.apps ?? []);
|
|
33
|
+
if (!apps.length)
|
|
34
|
+
out.line('info', 'no apps yet — `aipay apps create --name "My app"`');
|
|
35
|
+
for (const a of apps)
|
|
36
|
+
out.line('info', `${String(a.name ?? '?')} ${String(a.id ?? a.app_id ?? a.client_id ?? '')} ${String(a.status ?? '')}`);
|
|
37
|
+
out.result({ ok: true, apps });
|
|
38
|
+
return EXIT.ok;
|
|
39
|
+
}
|
|
40
|
+
if (sub === 'create') {
|
|
41
|
+
const name = flagString(args.flags, 'name');
|
|
42
|
+
if (!name)
|
|
43
|
+
throw new CliError('usage', 'missing --name <app name>', EXIT.usage);
|
|
44
|
+
const callback = flagString(args.flags, 'callback') ?? DEFAULT_CALLBACK;
|
|
45
|
+
const created = await createApp(api, { name, callback, key: flagString(args.flags, 'key') });
|
|
46
|
+
out.line('ok', `${created.replayed ? 'already created' : 'created'} "${name}" — client_id ${created.client_id}`);
|
|
47
|
+
out.kv('callback', created.redirect_uris.join(', '));
|
|
48
|
+
out.line('info', `next: aipay init --app ${created.client_id}`);
|
|
49
|
+
out.result({ ok: true, ...created, name });
|
|
50
|
+
return EXIT.ok;
|
|
51
|
+
}
|
|
52
|
+
throw new CliError('usage', `unknown apps subcommand "${sub}" (list | create)`, EXIT.usage);
|
|
53
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ParsedArgs } from '../args.js';
|
|
2
|
+
import { type Level, type Reporter } from '../output.js';
|
|
3
|
+
import { type Remote } from '../remote.js';
|
|
4
|
+
export interface Check {
|
|
5
|
+
id: string;
|
|
6
|
+
level: Level;
|
|
7
|
+
text: string;
|
|
8
|
+
}
|
|
9
|
+
export interface DoctorDeps {
|
|
10
|
+
remote?: Remote;
|
|
11
|
+
nodeVersion?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function runDoctor(args: ParsedArgs, out: Reporter, deps?: DoctorDeps): Promise<number>;
|
|
14
|
+
export declare function collectChecks(dir: string, opts: {
|
|
15
|
+
offline: boolean;
|
|
16
|
+
}, deps: DoctorDeps): Promise<Check[]>;
|