@remixmate/cli 0.9.13 → 0.9.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -1
- package/README.zh-CN.md +37 -1
- package/dist/auth/auto-login.js +10 -1
- package/dist/auth/commands.js +181 -21
- package/dist/auth/credential-store.d.ts +16 -0
- package/dist/auth/credential-store.js +17 -0
- package/dist/auth/device-flow-runner.d.ts +15 -0
- package/dist/auth/device-flow-runner.js +60 -12
- package/dist/auth/device-flow.js +24 -7
- package/dist/auth/ensure.d.ts +55 -0
- package/dist/auth/ensure.js +62 -0
- package/dist/auth/environment.d.ts +13 -0
- package/dist/auth/environment.js +21 -0
- package/dist/auth/pending-store.d.ts +33 -0
- package/dist/auth/pending-store.js +45 -0
- package/dist/cli.js +75 -8
- package/dist/doctor.d.ts +22 -0
- package/dist/doctor.js +142 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +33 -0
- package/dist/exec.d.ts +18 -0
- package/dist/exec.js +47 -0
- package/dist/handlers/gen-digital-human.js +1 -0
- package/dist/handlers/gen-image.js +1 -0
- package/dist/handlers/gen-video.js +1 -0
- package/dist/handlers/gen-voice.js +2 -0
- package/dist/handlers/index.d.ts +7 -0
- package/dist/http.d.ts +17 -6
- package/dist/http.js +39 -15
- package/dist/manifest.json +2 -2
- package/dist/registry.d.ts +4 -2
- package/dist/registry.js +2 -1
- package/dist/runner.d.ts +4 -0
- package/dist/runner.js +27 -12
- package/dist/skill-schema.d.ts +19 -0
- package/dist/skill-schema.js +18 -0
- package/dist/text.d.ts +8 -0
- package/dist/text.js +15 -0
- package/package.json +2 -2
- package/skills/export-jianying/skill.json +1 -0
- package/skills/gen-digital-human/skill.json +1 -0
- package/skills/gen-image/skill.json +1 -0
- package/skills/gen-script/skill.json +1 -0
- package/skills/gen-video/skill.json +1 -0
- package/skills/gen-voice/skill.json +1 -0
- package/skills/prepare-video-assets/skill.json +1 -0
- package/skills/render-video/skill.json +1 -0
- package/skills/template-registry/scripts/list_templates.py +16 -1
- package/skills/template-registry/scripts/registry_loader.py +63 -63
- package/skills/template-registry/skill.json +1 -0
- package/skills/video-parser/skill.json +1 -0
- package/skills/web-record/skill.json +1 -0
- package/skills/web-screenshot/skill.json +1 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single authorization entry point for every skill invocation.
|
|
3
|
+
*
|
|
4
|
+
* Before this module the CLI resolved PrivTokens in three unrelated places:
|
|
5
|
+
* `resolveHttpContext` (4 TS handlers, with credential store + auto device
|
|
6
|
+
* login), `registry_loader.py` (1 Python skill, with its own credential-file
|
|
7
|
+
* reader), and plain `os.environ["PRIV_TOKEN"]` (the other 7 Python skills,
|
|
8
|
+
* which therefore ignored `remixmate login` entirely). Auto browser auth only
|
|
9
|
+
* ever covered the first group.
|
|
10
|
+
*
|
|
11
|
+
* Now `runner.ts` calls `ensureAuth()` once, before dispatch, for every skill —
|
|
12
|
+
* so the credential store, the OS keychain and the device flow exist in exactly
|
|
13
|
+
* one place (Node), and Python scripts only ever read an injected `PRIV_TOKEN`.
|
|
14
|
+
*/
|
|
15
|
+
import { type CredentialSource } from './resolve.js';
|
|
16
|
+
/**
|
|
17
|
+
* Per-skill authorization requirement, declared as `auth` in skill.json.
|
|
18
|
+
*
|
|
19
|
+
* - `required`: no token → run the device flow; still no token → abort (exit 4).
|
|
20
|
+
* - `optional`: inject a token when one is already available, otherwise carry
|
|
21
|
+
* on silently. Never opens a browser — these skills degrade to a
|
|
22
|
+
* reduced mode (e.g. web-record keeps the file locally instead of
|
|
23
|
+
* uploading), and popping an auth window for that would be rude.
|
|
24
|
+
* - `none`: purely local, never touches ab-api (e.g. web-screenshot).
|
|
25
|
+
*/
|
|
26
|
+
export type AuthMode = 'required' | 'optional' | 'none';
|
|
27
|
+
export declare const AUTH_MODE_VALUES: readonly ["required", "optional", "none"];
|
|
28
|
+
export interface AuthPreflight {
|
|
29
|
+
/** Backend the invocation is bound to; injected so Python derives the same one. */
|
|
30
|
+
apiBaseUrl: string;
|
|
31
|
+
/** Absent only for `none`, or for `optional` with no credential available. */
|
|
32
|
+
token?: string;
|
|
33
|
+
source?: CredentialSource;
|
|
34
|
+
userLabel?: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolve (and if necessary acquire) a PrivToken for one skill invocation.
|
|
38
|
+
* Throws SkillError(exit 4) when `mode === 'required'` and no token could be
|
|
39
|
+
* obtained; the message is the shared NOT_AUTHENTICATED_HINT.
|
|
40
|
+
*/
|
|
41
|
+
export declare function ensureAuth(opts: {
|
|
42
|
+
mode: AuthMode;
|
|
43
|
+
apiBaseUrl?: string;
|
|
44
|
+
flagToken?: string;
|
|
45
|
+
}): Promise<AuthPreflight>;
|
|
46
|
+
/**
|
|
47
|
+
* Environment overrides handed to a spawned Python skill.
|
|
48
|
+
*
|
|
49
|
+
* `MM_API_BASE_URL` is always set so the child resolves the same backend the
|
|
50
|
+
* preflight authenticated against — otherwise a `--api-base-url` flag or a
|
|
51
|
+
* credential bound to a staging backend would silently disagree with the URL
|
|
52
|
+
* the Python side derives. `PRIV_TOKEN` is only set when non-empty, so an
|
|
53
|
+
* `optional` skill without a credential keeps inheriting whatever the host set.
|
|
54
|
+
*/
|
|
55
|
+
export declare function authChildEnv(pre: AuthPreflight): Record<string, string>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single authorization entry point for every skill invocation.
|
|
3
|
+
*
|
|
4
|
+
* Before this module the CLI resolved PrivTokens in three unrelated places:
|
|
5
|
+
* `resolveHttpContext` (4 TS handlers, with credential store + auto device
|
|
6
|
+
* login), `registry_loader.py` (1 Python skill, with its own credential-file
|
|
7
|
+
* reader), and plain `os.environ["PRIV_TOKEN"]` (the other 7 Python skills,
|
|
8
|
+
* which therefore ignored `remixmate login` entirely). Auto browser auth only
|
|
9
|
+
* ever covered the first group.
|
|
10
|
+
*
|
|
11
|
+
* Now `runner.ts` calls `ensureAuth()` once, before dispatch, for every skill —
|
|
12
|
+
* so the credential store, the OS keychain and the device flow exist in exactly
|
|
13
|
+
* one place (Node), and Python scripts only ever read an injected `PRIV_TOKEN`.
|
|
14
|
+
*/
|
|
15
|
+
import { resolveApiBaseUrl } from '../http.js';
|
|
16
|
+
import { EXIT, SkillError } from '../errors.js';
|
|
17
|
+
import { resolvePrivToken, NOT_AUTHENTICATED_HINT } from './resolve.js';
|
|
18
|
+
import { attemptAutoLogin } from './auto-login.js';
|
|
19
|
+
export const AUTH_MODE_VALUES = ['required', 'optional', 'none'];
|
|
20
|
+
/**
|
|
21
|
+
* Resolve (and if necessary acquire) a PrivToken for one skill invocation.
|
|
22
|
+
* Throws SkillError(exit 4) when `mode === 'required'` and no token could be
|
|
23
|
+
* obtained; the message is the shared NOT_AUTHENTICATED_HINT.
|
|
24
|
+
*/
|
|
25
|
+
export async function ensureAuth(opts) {
|
|
26
|
+
const apiBaseUrl = resolveApiBaseUrl(opts.apiBaseUrl);
|
|
27
|
+
if (opts.mode === 'none')
|
|
28
|
+
return { apiBaseUrl };
|
|
29
|
+
let resolved = await resolvePrivToken({ flag: opts.flagToken, apiBaseUrl });
|
|
30
|
+
// Browser authorization is only ever attempted for `required`. attemptAutoLogin
|
|
31
|
+
// self-gates on CI / headless / kill-switch, so the env-injected cloud path
|
|
32
|
+
// returns here untouched.
|
|
33
|
+
if (!resolved && opts.mode === 'required') {
|
|
34
|
+
resolved = await attemptAutoLogin(apiBaseUrl);
|
|
35
|
+
}
|
|
36
|
+
if (!resolved) {
|
|
37
|
+
if (opts.mode === 'optional')
|
|
38
|
+
return { apiBaseUrl };
|
|
39
|
+
throw new SkillError(NOT_AUTHENTICATED_HINT, EXIT.NOT_AUTHENTICATED);
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
apiBaseUrl,
|
|
43
|
+
token: resolved.privToken,
|
|
44
|
+
source: resolved.source,
|
|
45
|
+
userLabel: resolved.userLabel,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Environment overrides handed to a spawned Python skill.
|
|
50
|
+
*
|
|
51
|
+
* `MM_API_BASE_URL` is always set so the child resolves the same backend the
|
|
52
|
+
* preflight authenticated against — otherwise a `--api-base-url` flag or a
|
|
53
|
+
* credential bound to a staging backend would silently disagree with the URL
|
|
54
|
+
* the Python side derives. `PRIV_TOKEN` is only set when non-empty, so an
|
|
55
|
+
* `optional` skill without a credential keeps inheriting whatever the host set.
|
|
56
|
+
*/
|
|
57
|
+
export function authChildEnv(pre) {
|
|
58
|
+
const env = { MM_API_BASE_URL: pre.apiBaseUrl };
|
|
59
|
+
if (pre.token)
|
|
60
|
+
env.PRIV_TOKEN = pre.token;
|
|
61
|
+
return env;
|
|
62
|
+
}
|
|
@@ -23,3 +23,16 @@ export declare function isHeadless(): boolean;
|
|
|
23
23
|
* AND the environment can plausibly open a browser for the user.
|
|
24
24
|
*/
|
|
25
25
|
export declare function canAutoAuth(): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Whether the CLI is being driven by an agent host (Claude Code / Codex /
|
|
28
|
+
* ab-agent) rather than typed by a human at a terminal.
|
|
29
|
+
*
|
|
30
|
+
* Used to decide (a) whether to emit the machine-readable AUTH_REQUIRED block
|
|
31
|
+
* alongside the human text, and (b) whether an interactive `login` may block
|
|
32
|
+
* for the device code's full lifetime — an agent's tool call is time-bounded,
|
|
33
|
+
* so it must get a bounded, resumable wait instead.
|
|
34
|
+
*
|
|
35
|
+
* Detection is deliberately loose: a piped stdout is the reliable signal, the
|
|
36
|
+
* env markers just make the intent explicit for hosts that allocate a pty.
|
|
37
|
+
*/
|
|
38
|
+
export declare function isAgentHost(): boolean;
|
package/dist/auth/environment.js
CHANGED
|
@@ -46,3 +46,24 @@ export function isHeadless() {
|
|
|
46
46
|
export function canAutoAuth() {
|
|
47
47
|
return !autoAuthDisabled() && !isHeadless();
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Whether the CLI is being driven by an agent host (Claude Code / Codex /
|
|
51
|
+
* ab-agent) rather than typed by a human at a terminal.
|
|
52
|
+
*
|
|
53
|
+
* Used to decide (a) whether to emit the machine-readable AUTH_REQUIRED block
|
|
54
|
+
* alongside the human text, and (b) whether an interactive `login` may block
|
|
55
|
+
* for the device code's full lifetime — an agent's tool call is time-bounded,
|
|
56
|
+
* so it must get a bounded, resumable wait instead.
|
|
57
|
+
*
|
|
58
|
+
* Detection is deliberately loose: a piped stdout is the reliable signal, the
|
|
59
|
+
* env markers just make the intent explicit for hosts that allocate a pty.
|
|
60
|
+
*/
|
|
61
|
+
export function isAgentHost() {
|
|
62
|
+
const e = process.env;
|
|
63
|
+
if ((e.CLAUDECODE ?? '').trim() || (e.CLAUDE_CODE ?? '').trim() || (e.CODEX_SANDBOX ?? '').trim()) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
if ((e.AGENT_NAME ?? '').trim())
|
|
67
|
+
return true;
|
|
68
|
+
return !process.stdout.isTTY;
|
|
69
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending device-authorization state, at ~/.config/remixmate/pending.json.
|
|
3
|
+
*
|
|
4
|
+
* Exists so authorization can outlive a single process. `login --start` requests
|
|
5
|
+
* a device code, persists it and returns immediately; `login --wait` picks it up
|
|
6
|
+
* and polls with a bounded window, as many times as needed. That split is what
|
|
7
|
+
* makes authorization workable from an agent host, whose tool calls are
|
|
8
|
+
* time-bounded and cannot sit on a 10-minute blocking poll.
|
|
9
|
+
*
|
|
10
|
+
* The device_code IS a short-lived bearer secret (it can be exchanged for the
|
|
11
|
+
* PrivToken until it expires or is used), so the file is written 0600 like the
|
|
12
|
+
* credential store, and is deleted as soon as it is redeemed or found expired.
|
|
13
|
+
*/
|
|
14
|
+
export declare const PENDING_FILE: string;
|
|
15
|
+
export interface PendingAuth {
|
|
16
|
+
apiBaseUrl: string;
|
|
17
|
+
deviceCode: string;
|
|
18
|
+
userCode: string;
|
|
19
|
+
verificationUri: string;
|
|
20
|
+
verificationUriComplete?: string;
|
|
21
|
+
/** Poll interval in seconds, as instructed by the backend. */
|
|
22
|
+
interval: number;
|
|
23
|
+
/** Absolute epoch-ms deadline after which the device code is dead. */
|
|
24
|
+
expiresAt: number;
|
|
25
|
+
}
|
|
26
|
+
export declare function setPending(pending: PendingAuth): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* Read the pending authorization for `apiBaseUrl`. Returns null when absent,
|
|
29
|
+
* unreadable, bound to a different backend, or already expired — callers then
|
|
30
|
+
* tell the user to run `login --start` again.
|
|
31
|
+
*/
|
|
32
|
+
export declare function getPending(apiBaseUrl: string): Promise<PendingAuth | null>;
|
|
33
|
+
export declare function clearPending(): Promise<void>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending device-authorization state, at ~/.config/remixmate/pending.json.
|
|
3
|
+
*
|
|
4
|
+
* Exists so authorization can outlive a single process. `login --start` requests
|
|
5
|
+
* a device code, persists it and returns immediately; `login --wait` picks it up
|
|
6
|
+
* and polls with a bounded window, as many times as needed. That split is what
|
|
7
|
+
* makes authorization workable from an agent host, whose tool calls are
|
|
8
|
+
* time-bounded and cannot sit on a 10-minute blocking poll.
|
|
9
|
+
*
|
|
10
|
+
* The device_code IS a short-lived bearer secret (it can be exchanged for the
|
|
11
|
+
* PrivToken until it expires or is used), so the file is written 0600 like the
|
|
12
|
+
* credential store, and is deleted as soon as it is redeemed or found expired.
|
|
13
|
+
*/
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { promises as fs } from 'node:fs';
|
|
17
|
+
const PENDING_DIR = join(homedir(), '.config', 'remixmate');
|
|
18
|
+
export const PENDING_FILE = join(PENDING_DIR, 'pending.json');
|
|
19
|
+
export async function setPending(pending) {
|
|
20
|
+
await fs.mkdir(PENDING_DIR, { recursive: true, mode: 0o700 });
|
|
21
|
+
await fs.writeFile(PENDING_FILE, JSON.stringify(pending, null, 2), { mode: 0o600 });
|
|
22
|
+
await fs.chmod(PENDING_FILE, 0o600);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Read the pending authorization for `apiBaseUrl`. Returns null when absent,
|
|
26
|
+
* unreadable, bound to a different backend, or already expired — callers then
|
|
27
|
+
* tell the user to run `login --start` again.
|
|
28
|
+
*/
|
|
29
|
+
export async function getPending(apiBaseUrl) {
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = JSON.parse(await fs.readFile(PENDING_FILE, 'utf8'));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
if (!parsed?.deviceCode || parsed.apiBaseUrl !== apiBaseUrl)
|
|
38
|
+
return null;
|
|
39
|
+
if (!(parsed.expiresAt > Date.now()))
|
|
40
|
+
return null;
|
|
41
|
+
return parsed;
|
|
42
|
+
}
|
|
43
|
+
export async function clearPending() {
|
|
44
|
+
await fs.rm(PENDING_FILE, { force: true });
|
|
45
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -15,6 +15,10 @@ import { loadSkills, SKILLS_DIR } from './registry.js';
|
|
|
15
15
|
import { parseArgv } from './argv.js';
|
|
16
16
|
import { runSkill } from './runner.js';
|
|
17
17
|
import { runAuthCommand } from './auth/commands.js';
|
|
18
|
+
import { runExec } from './exec.js';
|
|
19
|
+
import { authStatusLine, runDoctor } from './doctor.js';
|
|
20
|
+
import { padDisplay } from './text.js';
|
|
21
|
+
import { EXIT, SkillError } from './errors.js';
|
|
18
22
|
/** Reserved auth verbs handled before skill dispatch. */
|
|
19
23
|
const AUTH_VERBS = new Set(['login', 'logout', 'whoami']);
|
|
20
24
|
/**
|
|
@@ -46,9 +50,12 @@ function printUsage() {
|
|
|
46
50
|
const skills = loadSkills(resolveBaseDir());
|
|
47
51
|
process.stdout.write('Usage:\n');
|
|
48
52
|
process.stdout.write(' remixmate --list List all skills\n');
|
|
53
|
+
process.stdout.write(' remixmate doctor Diagnose environment, credentials, backend\n');
|
|
49
54
|
process.stdout.write(' remixmate login Authorize this CLI via the browser\n');
|
|
55
|
+
process.stdout.write(' remixmate login --start | --wait Non-blocking authorization (for agent hosts)\n');
|
|
50
56
|
process.stdout.write(' remixmate logout Remove stored credentials\n');
|
|
51
57
|
process.stdout.write(' remixmate whoami Show the current identity\n');
|
|
58
|
+
process.stdout.write(' remixmate exec -- <cmd> [args...] Run a command with credentials injected\n');
|
|
52
59
|
process.stdout.write(' remixmate <name> [--flag value ...] Invoke a skill\n');
|
|
53
60
|
process.stdout.write(' remixmate <name> --help Show skill-specific help\n');
|
|
54
61
|
process.stdout.write('\nAvailable skills:\n');
|
|
@@ -57,30 +64,83 @@ function printUsage() {
|
|
|
57
64
|
process.stdout.write(` ${s.name.padEnd(22)} ${s.entry.type.padEnd(8)} ${summary}\n`);
|
|
58
65
|
}
|
|
59
66
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
67
|
+
/** Human-facing note on what a skill's auth mode means in practice. */
|
|
68
|
+
const AUTH_NOTE = {
|
|
69
|
+
required: '需登录',
|
|
70
|
+
optional: '可选',
|
|
71
|
+
none: '免登录',
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* List skills.
|
|
75
|
+
*
|
|
76
|
+
* The default output is for a person: what each skill is and whether it needs a
|
|
77
|
+
* credential. The previous tab-separated form leaked internals (toolName, entry
|
|
78
|
+
* type, script path) that only tooling cares about — that shape now lives behind
|
|
79
|
+
* `--json`, which is a stabler contract for tooling anyway.
|
|
80
|
+
*/
|
|
81
|
+
function printList(json) {
|
|
82
|
+
const skills = loadSkills(resolveBaseDir());
|
|
83
|
+
if (json) {
|
|
84
|
+
process.stdout.write(JSON.stringify(skills.map((s) => ({
|
|
85
|
+
name: s.name,
|
|
86
|
+
toolName: s.toolName,
|
|
87
|
+
runtime: s.entry.type,
|
|
88
|
+
auth: s.auth,
|
|
89
|
+
entry: describeEntryTail(s),
|
|
90
|
+
description: s.description.split('\n')[0],
|
|
91
|
+
}))) + '\n');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
for (const s of skills) {
|
|
95
|
+
const summary = s.description.split('\n')[0].slice(0, 58);
|
|
96
|
+
process.stdout.write(` ${s.name.padEnd(22)} ${padDisplay(AUTH_NOTE[s.auth], 8)}${summary}\n`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Auth status footer for the listing commands, on stderr so stdout stays clean
|
|
101
|
+
* for anything piping the list. A fresh install used to say nothing at all about
|
|
102
|
+
* credentials until a skill failed.
|
|
103
|
+
*/
|
|
104
|
+
async function printAuthFooter() {
|
|
105
|
+
try {
|
|
106
|
+
process.stderr.write(`\n${await authStatusLine()}\n`);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// A diagnostic footer must never be the reason a listing command fails.
|
|
63
110
|
}
|
|
64
111
|
}
|
|
65
112
|
async function main() {
|
|
66
113
|
const argv = process.argv.slice(2);
|
|
67
114
|
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
|
68
115
|
printUsage();
|
|
69
|
-
|
|
116
|
+
await printAuthFooter();
|
|
117
|
+
process.exit(EXIT.OK);
|
|
70
118
|
}
|
|
71
119
|
if (argv[0] === '--list' || argv[0] === 'list') {
|
|
72
|
-
|
|
73
|
-
|
|
120
|
+
const json = argv.includes('--json') || argv.includes('--json-output');
|
|
121
|
+
printList(json);
|
|
122
|
+
if (!json)
|
|
123
|
+
await printAuthFooter();
|
|
124
|
+
process.exit(EXIT.OK);
|
|
125
|
+
}
|
|
126
|
+
if (argv[0] === 'doctor') {
|
|
127
|
+
const code = await runDoctor({ skipNetwork: argv.includes('--offline') });
|
|
128
|
+
process.exit(code);
|
|
74
129
|
}
|
|
75
130
|
// Auth verbs are handled before skill dispatch so they never collide with skill names.
|
|
76
131
|
if (AUTH_VERBS.has(argv[0])) {
|
|
77
132
|
const code = await runAuthCommand(argv[0], argv.slice(1));
|
|
78
133
|
process.exit(code);
|
|
79
134
|
}
|
|
135
|
+
if (argv[0] === 'exec') {
|
|
136
|
+
// Raw argv (not parsed) — everything after `--` belongs to the child command.
|
|
137
|
+
const code = await runExec(argv.slice(1));
|
|
138
|
+
process.exit(code);
|
|
139
|
+
}
|
|
80
140
|
if (argv[0].startsWith('-')) {
|
|
81
141
|
process.stderr.write(`❌ unknown option: ${argv[0]}\n`);
|
|
82
142
|
printUsage();
|
|
83
|
-
process.exit(
|
|
143
|
+
process.exit(EXIT.USAGE);
|
|
84
144
|
}
|
|
85
145
|
const skillName = argv[0];
|
|
86
146
|
const rest = argv.slice(1);
|
|
@@ -92,6 +152,13 @@ async function main() {
|
|
|
92
152
|
process.exit(code);
|
|
93
153
|
}
|
|
94
154
|
main().catch((err) => {
|
|
155
|
+
// A SkillError is an expected, already-worded failure (most often "not
|
|
156
|
+
// authenticated", exit 4) — print the message and honor its exit code rather
|
|
157
|
+
// than dumping a stack trace the user can do nothing with.
|
|
158
|
+
if (err instanceof SkillError) {
|
|
159
|
+
process.stderr.write(err.message + '\n');
|
|
160
|
+
process.exit(err.exitCode);
|
|
161
|
+
}
|
|
95
162
|
process.stderr.write(`❌ fatal: ${err?.stack ?? err?.message ?? String(err)}\n`);
|
|
96
|
-
process.exit(
|
|
163
|
+
process.exit(EXIT.ERROR);
|
|
97
164
|
});
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `remixmate doctor` — one command that answers "why isn't this working?".
|
|
3
|
+
*
|
|
4
|
+
* The failure modes this package actually produces in the field are: no
|
|
5
|
+
* credential, a credential bound to a different backend, a missing python3, a
|
|
6
|
+
* missing Playwright, and an unreachable backend. Each of those used to surface
|
|
7
|
+
* only as a downstream error from whichever skill happened to be run first, in
|
|
8
|
+
* that skill's own wording. Checking them up front, in one place, turns a
|
|
9
|
+
* scavenger hunt into a list.
|
|
10
|
+
*
|
|
11
|
+
* Also exports the one-line status shown under `--list` / `--help`, so a first
|
|
12
|
+
* run says "you are not logged in" instead of leaving the user to find out by
|
|
13
|
+
* invoking a skill.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Short auth status suitable for a footer. Never performs network I/O — a
|
|
17
|
+
* listing command must stay instant and work offline.
|
|
18
|
+
*/
|
|
19
|
+
export declare function authStatusLine(): Promise<string>;
|
|
20
|
+
export declare function runDoctor(opts?: {
|
|
21
|
+
skipNetwork?: boolean;
|
|
22
|
+
}): Promise<number>;
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `remixmate doctor` — one command that answers "why isn't this working?".
|
|
3
|
+
*
|
|
4
|
+
* The failure modes this package actually produces in the field are: no
|
|
5
|
+
* credential, a credential bound to a different backend, a missing python3, a
|
|
6
|
+
* missing Playwright, and an unreachable backend. Each of those used to surface
|
|
7
|
+
* only as a downstream error from whichever skill happened to be run first, in
|
|
8
|
+
* that skill's own wording. Checking them up front, in one place, turns a
|
|
9
|
+
* scavenger hunt into a list.
|
|
10
|
+
*
|
|
11
|
+
* Also exports the one-line status shown under `--list` / `--help`, so a first
|
|
12
|
+
* run says "you are not logged in" instead of leaving the user to find out by
|
|
13
|
+
* invoking a skill.
|
|
14
|
+
*/
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
import { resolveApiBaseUrl } from './http.js';
|
|
17
|
+
import { EXIT } from './errors.js';
|
|
18
|
+
import { resolvePrivToken } from './auth/resolve.js';
|
|
19
|
+
import { listCredentials } from './auth/credential-store.js';
|
|
20
|
+
import { canAutoAuth, isHeadless } from './auth/environment.js';
|
|
21
|
+
import { padDisplay } from './text.js';
|
|
22
|
+
/**
|
|
23
|
+
* Short auth status suitable for a footer. Never performs network I/O — a
|
|
24
|
+
* listing command must stay instant and work offline.
|
|
25
|
+
*/
|
|
26
|
+
export async function authStatusLine() {
|
|
27
|
+
const apiBaseUrl = resolveApiBaseUrl();
|
|
28
|
+
const resolved = await resolvePrivToken({ apiBaseUrl });
|
|
29
|
+
if (resolved) {
|
|
30
|
+
const who = resolved.userLabel ? `${resolved.userLabel} ` : '';
|
|
31
|
+
return `✅ 已登录 ${who}@ ${apiBaseUrl}(来源:${resolved.source})`;
|
|
32
|
+
}
|
|
33
|
+
const others = await listCredentials();
|
|
34
|
+
if (others.length > 0) {
|
|
35
|
+
return (`⚠️ 当前后端 ${apiBaseUrl} 未登录;` +
|
|
36
|
+
`已有凭证的后端:${others.map((c) => c.apiBaseUrl).join(', ')}`);
|
|
37
|
+
}
|
|
38
|
+
return `⚠️ 未登录(${apiBaseUrl})→ 运行 remixmate login`;
|
|
39
|
+
}
|
|
40
|
+
const ICON = { ok: '✅', warn: '⚠️ ', fail: '❌' };
|
|
41
|
+
/** Probe a command's presence and version line without failing on absence. */
|
|
42
|
+
function probe(cmd, args) {
|
|
43
|
+
try {
|
|
44
|
+
const res = spawnSync(cmd, args, { encoding: 'utf8', timeout: 10_000 });
|
|
45
|
+
if (res.error || res.status !== 0)
|
|
46
|
+
return null;
|
|
47
|
+
return (res.stdout || res.stderr || '').trim().split('\n')[0] || null;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function checkBackend(apiBaseUrl) {
|
|
54
|
+
const controller = new AbortController();
|
|
55
|
+
const timer = setTimeout(() => controller.abort(), 8_000);
|
|
56
|
+
try {
|
|
57
|
+
// The device-code endpoint is public, so reachability can be probed without
|
|
58
|
+
// a credential — and without side effects, since we never redeem the code.
|
|
59
|
+
const resp = await fetch(`${apiBaseUrl}/auth/device/code`, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: { 'Content-Type': 'application/json' },
|
|
62
|
+
body: JSON.stringify({ clientLabel: 'remixmate-doctor' }),
|
|
63
|
+
signal: controller.signal,
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
name: '后端可达性',
|
|
67
|
+
status: resp.ok ? 'ok' : 'warn',
|
|
68
|
+
detail: `${apiBaseUrl} → HTTP ${resp.status}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
const reason = err instanceof Error && err.name === 'AbortError' ? '超时' : err.message;
|
|
73
|
+
return { name: '后端可达性', status: 'fail', detail: `${apiBaseUrl} 不可达(${reason})` };
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export async function runDoctor(opts = {}) {
|
|
80
|
+
const apiBaseUrl = resolveApiBaseUrl();
|
|
81
|
+
const checks = [];
|
|
82
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
83
|
+
checks.push({
|
|
84
|
+
name: 'Node',
|
|
85
|
+
status: major >= 18 ? 'ok' : 'fail',
|
|
86
|
+
detail: `v${process.versions.node}${major >= 18 ? '' : '(需要 >= 18)'}`,
|
|
87
|
+
});
|
|
88
|
+
const py = probe('python3', ['--version']);
|
|
89
|
+
checks.push({
|
|
90
|
+
name: 'python3',
|
|
91
|
+
status: py ? 'ok' : 'fail',
|
|
92
|
+
detail: py ?? '未找到 —— 12 个 skill 中的 8 个需要 python3 >= 3.10',
|
|
93
|
+
});
|
|
94
|
+
const playwright = py ? probe('python3', ['-c', 'import playwright; print(playwright.__version__)']) : null;
|
|
95
|
+
checks.push({
|
|
96
|
+
name: 'Playwright',
|
|
97
|
+
status: playwright ? 'ok' : 'warn',
|
|
98
|
+
detail: playwright ?? '当前解释器下未安装 —— 仅 web-screenshot / web-record 需要,首次运行会自动装',
|
|
99
|
+
});
|
|
100
|
+
checks.push({ name: '后端地址', status: 'ok', detail: apiBaseUrl });
|
|
101
|
+
const resolved = await resolvePrivToken({ apiBaseUrl });
|
|
102
|
+
const stored = await listCredentials();
|
|
103
|
+
if (resolved) {
|
|
104
|
+
checks.push({
|
|
105
|
+
name: '凭证',
|
|
106
|
+
status: 'ok',
|
|
107
|
+
detail: `${resolved.userLabel ?? '(已认证)'}(来源:${resolved.source})`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
else if (stored.length > 0) {
|
|
111
|
+
checks.push({
|
|
112
|
+
name: '凭证',
|
|
113
|
+
status: 'fail',
|
|
114
|
+
detail: `当前后端无凭证;已存储:${stored.map((c) => c.apiBaseUrl).join(', ')}。` +
|
|
115
|
+
' 设置 MM_API_BASE_URL 指向其中之一,或重新 remixmate login',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
checks.push({ name: '凭证', status: 'fail', detail: '未登录 → 运行 remixmate login' });
|
|
120
|
+
}
|
|
121
|
+
checks.push({
|
|
122
|
+
name: '浏览器授权',
|
|
123
|
+
status: canAutoAuth() ? 'ok' : 'warn',
|
|
124
|
+
detail: canAutoAuth()
|
|
125
|
+
? '可用(缺凭证时会自动打开授权页)'
|
|
126
|
+
: `不可用(${isHeadless() ? 'CI / 无桌面会话' : '已被 REMIXMATE_NO_BROWSER_AUTH 关闭'})→ 请用 PRIV_TOKEN`,
|
|
127
|
+
});
|
|
128
|
+
if (!opts.skipNetwork)
|
|
129
|
+
checks.push(await checkBackend(apiBaseUrl));
|
|
130
|
+
for (const c of checks) {
|
|
131
|
+
process.stdout.write(`${ICON[c.status]} ${padDisplay(c.name, 14)}${c.detail}\n`);
|
|
132
|
+
}
|
|
133
|
+
const failed = checks.filter((c) => c.status === 'fail');
|
|
134
|
+
if (failed.length === 0) {
|
|
135
|
+
process.stdout.write('\n一切正常。\n');
|
|
136
|
+
return EXIT.OK;
|
|
137
|
+
}
|
|
138
|
+
process.stdout.write(`\n${failed.length} 项需要处理:${failed.map((c) => c.name).join('、')}\n`);
|
|
139
|
+
// A missing credential is the single most common cause and has a dedicated
|
|
140
|
+
// exit code, so a host can act on it without parsing the report.
|
|
141
|
+
return failed.some((c) => c.name === '凭证') ? EXIT.NOT_AUTHENTICATED : EXIT.ERROR;
|
|
142
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-wide error type and exit-code contract.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from http.ts so the auth gateway (auth/ensure.ts) can throw the same
|
|
5
|
+
* error type without creating an import cycle (http.ts → ensure.ts → http.ts).
|
|
6
|
+
* `SkillError` is still re-exported from http.ts, so existing imports are unchanged.
|
|
7
|
+
*
|
|
8
|
+
* Exit codes are a CONTRACT with hosts (Claude Code / Codex / ab-agent): an agent
|
|
9
|
+
* reads the code to decide whether to guide the user through authorization, retry
|
|
10
|
+
* later, or surface a hard failure. Never reuse a code for a different meaning.
|
|
11
|
+
*/
|
|
12
|
+
export declare const EXIT: {
|
|
13
|
+
/** Success. */
|
|
14
|
+
readonly OK: 0;
|
|
15
|
+
/** Generic runtime failure. */
|
|
16
|
+
readonly ERROR: 1;
|
|
17
|
+
/** Usage error — unknown skill / unknown option. */
|
|
18
|
+
readonly USAGE: 2;
|
|
19
|
+
/** No usable credential, or the credential was rejected. Host should offer to authorize. */
|
|
20
|
+
readonly NOT_AUTHENTICATED: 4;
|
|
21
|
+
/** Backend unreachable / timed out. Distinct from 4 so "log in" isn't suggested for an outage. */
|
|
22
|
+
readonly BACKEND_UNREACHABLE: 5;
|
|
23
|
+
/** Failed to spawn the underlying interpreter. */
|
|
24
|
+
readonly SPAWN_FAILED: 127;
|
|
25
|
+
};
|
|
26
|
+
export declare class SkillError extends Error {
|
|
27
|
+
readonly exitCode: number;
|
|
28
|
+
constructor(message: string, exitCode?: number);
|
|
29
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-wide error type and exit-code contract.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from http.ts so the auth gateway (auth/ensure.ts) can throw the same
|
|
5
|
+
* error type without creating an import cycle (http.ts → ensure.ts → http.ts).
|
|
6
|
+
* `SkillError` is still re-exported from http.ts, so existing imports are unchanged.
|
|
7
|
+
*
|
|
8
|
+
* Exit codes are a CONTRACT with hosts (Claude Code / Codex / ab-agent): an agent
|
|
9
|
+
* reads the code to decide whether to guide the user through authorization, retry
|
|
10
|
+
* later, or surface a hard failure. Never reuse a code for a different meaning.
|
|
11
|
+
*/
|
|
12
|
+
export const EXIT = {
|
|
13
|
+
/** Success. */
|
|
14
|
+
OK: 0,
|
|
15
|
+
/** Generic runtime failure. */
|
|
16
|
+
ERROR: 1,
|
|
17
|
+
/** Usage error — unknown skill / unknown option. */
|
|
18
|
+
USAGE: 2,
|
|
19
|
+
/** No usable credential, or the credential was rejected. Host should offer to authorize. */
|
|
20
|
+
NOT_AUTHENTICATED: 4,
|
|
21
|
+
/** Backend unreachable / timed out. Distinct from 4 so "log in" isn't suggested for an outage. */
|
|
22
|
+
BACKEND_UNREACHABLE: 5,
|
|
23
|
+
/** Failed to spawn the underlying interpreter. */
|
|
24
|
+
SPAWN_FAILED: 127,
|
|
25
|
+
};
|
|
26
|
+
export class SkillError extends Error {
|
|
27
|
+
exitCode;
|
|
28
|
+
constructor(message, exitCode = EXIT.ERROR) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.exitCode = exitCode;
|
|
31
|
+
this.name = 'SkillError';
|
|
32
|
+
}
|
|
33
|
+
}
|
package/dist/exec.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `remixmate exec -- <command> [args...]`
|
|
3
|
+
*
|
|
4
|
+
* Runs an arbitrary command with the resolved credential injected into its
|
|
5
|
+
* environment (`PRIV_TOKEN` + `MM_API_BASE_URL`), using the same gateway that
|
|
6
|
+
* skill dispatch uses.
|
|
7
|
+
*
|
|
8
|
+
* This is the general form of the invariant the auth rework establishes: Node
|
|
9
|
+
* is the only thing that reads the credential store / keychain / device flow,
|
|
10
|
+
* and every child process receives the result through its environment. Without
|
|
11
|
+
* it, maintenance scripts that import `registry_loader` directly (e.g.
|
|
12
|
+
* scripts/test-template-pipeline.py) would have to re-implement credential
|
|
13
|
+
* lookup — which is exactly the duplication, and the cross-origin token leak,
|
|
14
|
+
* that was just removed.
|
|
15
|
+
*
|
|
16
|
+
* The token is never printed; it only ever reaches the child's environment.
|
|
17
|
+
*/
|
|
18
|
+
export declare function runExec(argv: string[]): Promise<number>;
|
package/dist/exec.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `remixmate exec -- <command> [args...]`
|
|
3
|
+
*
|
|
4
|
+
* Runs an arbitrary command with the resolved credential injected into its
|
|
5
|
+
* environment (`PRIV_TOKEN` + `MM_API_BASE_URL`), using the same gateway that
|
|
6
|
+
* skill dispatch uses.
|
|
7
|
+
*
|
|
8
|
+
* This is the general form of the invariant the auth rework establishes: Node
|
|
9
|
+
* is the only thing that reads the credential store / keychain / device flow,
|
|
10
|
+
* and every child process receives the result through its environment. Without
|
|
11
|
+
* it, maintenance scripts that import `registry_loader` directly (e.g.
|
|
12
|
+
* scripts/test-template-pipeline.py) would have to re-implement credential
|
|
13
|
+
* lookup — which is exactly the duplication, and the cross-origin token leak,
|
|
14
|
+
* that was just removed.
|
|
15
|
+
*
|
|
16
|
+
* The token is never printed; it only ever reaches the child's environment.
|
|
17
|
+
*/
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { parseArgv } from './argv.js';
|
|
20
|
+
import { EXIT } from './errors.js';
|
|
21
|
+
import { authChildEnv, ensureAuth } from './auth/ensure.js';
|
|
22
|
+
const USAGE = 'Usage: remixmate exec [--api-base-url URL] [--token TOKEN] -- <command> [args...]\n';
|
|
23
|
+
export async function runExec(argv) {
|
|
24
|
+
const separator = argv.indexOf('--');
|
|
25
|
+
if (separator === -1 || separator === argv.length - 1) {
|
|
26
|
+
process.stderr.write(`❌ exec 需要一个 \`--\` 分隔符与要执行的命令。\n${USAGE}`);
|
|
27
|
+
return EXIT.USAGE;
|
|
28
|
+
}
|
|
29
|
+
const flags = parseArgv(argv.slice(0, separator));
|
|
30
|
+
const [command, ...args] = argv.slice(separator + 1);
|
|
31
|
+
const auth = await ensureAuth({
|
|
32
|
+
mode: 'required',
|
|
33
|
+
apiBaseUrl: typeof flags.api_base_url === 'string' ? flags.api_base_url : undefined,
|
|
34
|
+
flagToken: typeof flags.token === 'string' ? flags.token : undefined,
|
|
35
|
+
});
|
|
36
|
+
return await new Promise((resolve) => {
|
|
37
|
+
const proc = spawn(command, args, {
|
|
38
|
+
stdio: 'inherit',
|
|
39
|
+
env: { ...process.env, ...authChildEnv(auth) },
|
|
40
|
+
});
|
|
41
|
+
proc.on('exit', (code, signal) => resolve(signal ? EXIT.ERROR : code ?? EXIT.ERROR));
|
|
42
|
+
proc.on('error', (err) => {
|
|
43
|
+
process.stderr.write(`❌ failed to spawn ${command}: ${err.message}\n`);
|
|
44
|
+
resolve(EXIT.SPAWN_FAILED);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|