@remixmate/cli 0.1.2 → 0.9.1

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 (50) hide show
  1. package/README.md +46 -0
  2. package/README.zh-CN.md +22 -0
  3. package/dist/auth/auth-lock.d.ts +26 -0
  4. package/dist/auth/auth-lock.js +100 -0
  5. package/dist/auth/auto-login.d.ts +20 -0
  6. package/dist/auth/auto-login.js +66 -0
  7. package/dist/auth/commands.d.ts +8 -0
  8. package/dist/auth/commands.js +130 -0
  9. package/dist/auth/credential-store.d.ts +44 -0
  10. package/dist/auth/credential-store.js +126 -0
  11. package/dist/auth/device-flow-runner.d.ts +43 -0
  12. package/dist/auth/device-flow-runner.js +62 -0
  13. package/dist/auth/device-flow.d.ts +52 -0
  14. package/dist/auth/device-flow.js +115 -0
  15. package/dist/auth/environment.d.ts +25 -0
  16. package/dist/auth/environment.js +48 -0
  17. package/dist/auth/resolve.d.ts +30 -0
  18. package/dist/auth/resolve.js +44 -0
  19. package/dist/cli.js +11 -0
  20. package/dist/handlers/gen-digital-human.js +1 -1
  21. package/dist/handlers/gen-image.js +1 -1
  22. package/dist/handlers/gen-video.js +1 -1
  23. package/dist/handlers/gen-voice.js +2 -2
  24. package/dist/http.d.ts +9 -5
  25. package/dist/http.js +24 -10
  26. package/dist/manifest.json +3 -3
  27. package/dist/runner.d.ts +1 -1
  28. package/package.json +1 -1
  29. package/skills/gen-digital-human/SKILL.md +11 -11
  30. package/skills/gen-digital-human/version.json +1 -1
  31. package/skills/gen-image/SKILL.md +6 -6
  32. package/skills/gen-image/version.json +1 -1
  33. package/skills/gen-script/scripts/gen_script.py +66 -31
  34. package/skills/gen-script/version.json +1 -1
  35. package/skills/gen-video/SKILL.md +6 -6
  36. package/skills/gen-video/version.json +1 -1
  37. package/skills/gen-voice/SKILL.md +6 -6
  38. package/skills/gen-voice/version.json +1 -1
  39. package/skills/render-video/scripts/remote_renderer_client.py +9 -7
  40. package/skills/render-video/scripts/render_video.py +72 -15
  41. package/skills/render-video/version.json +1 -1
  42. package/skills/template-registry/README.md +12 -13
  43. package/skills/template-registry/SKILL.md +11 -12
  44. package/skills/template-registry/scripts/registry_loader.py +117 -96
  45. package/skills/template-registry/scripts/render_job_client.py +12 -0
  46. package/skills/template-registry/skill.json +1 -1
  47. package/skills/template-registry/version.json +1 -1
  48. package/skills/template-registry/video_dsl/runtime/dsl_validator.py +2 -2
  49. package/skills/web-capture/skill.json +0 -1
  50. package/skills/web-capture/version.json +1 -1
package/README.md CHANGED
@@ -38,6 +38,52 @@ Codex does not load `SKILL.md` natively. Tell Codex about the CLI in your system
38
38
 
39
39
  See the full intro page (Claude Code / Codex setup, env vars, end-to-end workflow examples) at the project homepage.
40
40
 
41
+ ## Authentication
42
+
43
+ The recommended way to authenticate (for humans installing the CLI) is a browser
44
+ device-login — no token pasting required:
45
+
46
+ ```bash
47
+ remixmate login # opens your browser to approve, then stores credentials securely
48
+ remixmate whoami # show the current identity (never prints the token)
49
+ remixmate logout # remove stored credentials from this machine
50
+ ```
51
+
52
+ `login` uses the OAuth 2.0 Device Authorization Grant (RFC 8628): the CLI shows a
53
+ device code, you approve it in a browser that is already signed in to the web app, and
54
+ the CLI receives and stores your credential in the OS keychain (or a `0600` file at
55
+ `~/.config/remixmate/credentials.json`). It only runs in an interactive terminal.
56
+
57
+ For CI and agent-embedded hosts (ab-agent / Claude Code), keep using the `PRIV_TOKEN`
58
+ environment variable. Credential resolution precedence is:
59
+
60
+ ```
61
+ --token flag > PRIV_TOKEN env var > stored credential (from `remixmate login`)
62
+ ```
63
+
64
+ When `PRIV_TOKEN` is set, `login` is skipped and the device flow is never auto-triggered.
65
+
66
+ ### Automatic browser authorization (local hosts)
67
+
68
+ When a skill that needs a credential is invoked from a **local third-party host**
69
+ (Claude Code / Codex / a terminal) and none of `--token` / `PRIV_TOKEN` / the stored
70
+ credential yields a token, the CLI automatically runs the device flow once: it opens
71
+ your browser, prints the verification URL + code, waits a short window, and on approval
72
+ stores the credential — so every later call resolves silently.
73
+
74
+ This only fires on a **browser-capable local desktop**. It never fires when `PRIV_TOKEN`
75
+ is injected (cloud hosts) or in **headless** environments (CI / no GUI / no `DISPLAY`),
76
+ which keep the existing fail-fast behavior. Controls:
77
+
78
+ ```
79
+ REMIXMATE_NO_BROWSER_AUTH=1 # force-disable auto auth (always fail-fast)
80
+ REMIXMATE_AUTH_WAIT_SECONDS=25 # short-wait window (default 25s); on timeout it
81
+ # tells you to authorize and re-run, never blocks forever
82
+ ```
83
+
84
+ > Security: if you previously pasted your `PRIV_TOKEN` into a chat box or shell history,
85
+ > rotate it after switching to `remixmate login`.
86
+
41
87
  ## Environment
42
88
 
43
89
  Most skills authenticate against ab-api with a `PRIV_TOKEN`. Without a valid token (and network access to ab-api) the template / media-generation skills are unavailable — the code is open-source, but the generation capabilities are hosted on the ab-api service. Copy `.env.example` to `.env` and source it before running:
package/README.zh-CN.md CHANGED
@@ -38,6 +38,28 @@ Codex 不会原生加载 `SKILL.md`。在系统提示里告诉 Codex 这个 CLI
38
38
 
39
39
  完整介绍页(Claude Code / Codex 配置、环境变量、端到端工作流示例)见项目主页。
40
40
 
41
+ ## 认证
42
+
43
+ 推荐方式(人类用户):浏览器设备登录,无需手动粘贴 token:
44
+
45
+ ```bash
46
+ remixmate login # 在浏览器确认授权,凭证安全保存到本机
47
+ remixmate whoami # 查看当前身份(绝不打印 token)
48
+ remixmate logout # 从本机移除已存储的凭证
49
+ ```
50
+
51
+ `login` 使用 OAuth 2.0 设备授权流程(RFC 8628):CLI 显示设备码,你在已登录 Web 的浏览器中确认,CLI 随后取回凭证并保存到系统钥匙串(或 `~/.config/remixmate/credentials.json`,权限 `0600`)。仅在交互式终端中可用。
52
+
53
+ CI 与 agent 宿主(ab-agent / Claude Code)继续使用 `PRIV_TOKEN` 环境变量。凭证解析优先级:
54
+
55
+ ```
56
+ --token 参数 > PRIV_TOKEN 环境变量 > 本地已存凭证(remixmate login 写入)
57
+ ```
58
+
59
+ 设置了 `PRIV_TOKEN` 时,`login` 会跳过,且任何命令都不会自动触发设备登录流程。
60
+
61
+ > 安全提示:若你此前曾把 `PRIV_TOKEN` 粘贴到聊天框或写进 shell 历史,切换到 `remixmate login` 后建议轮换该 token。
62
+
41
63
  ## 环境
42
64
 
43
65
  多数技能通过 `PRIV_TOKEN` 向 ab-api 鉴权。没有有效 token(以及访问 ab-api 的网络)时,模板 / 媒体生成类技能不可用——代码是开源的,但生成能力托管在 ab-api 服务上。运行前把 `.env.example` 拷为 `.env` 并 source:
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Cross-process file lock for the auto device-login flow.
3
+ *
4
+ * When several skill processes start without a token at the same time, only one
5
+ * should drive the Device Flow (= open one browser). The rest wait for the
6
+ * winner to write the credential, then read it. This lock coordinates that.
7
+ *
8
+ * The lock file lives next to the credential store, contains only non-secret
9
+ * metadata, and self-heals: a lock left behind by a crashed process is reclaimed
10
+ * once it is older than `staleMs` (Requirement 3.1/3.4/3.5).
11
+ */
12
+ export interface LockHandle {
13
+ release(): Promise<void>;
14
+ }
15
+ /**
16
+ * Try to acquire the auth lock for `apiBaseUrl`.
17
+ * - Returns a handle when acquired.
18
+ * - Returns null when another live process holds it.
19
+ * - Reclaims a stale lock (older than `staleMs`) and retries once.
20
+ */
21
+ export declare function acquireLock(apiBaseUrl: string, staleMs: number): Promise<LockHandle | null>;
22
+ /**
23
+ * Poll the credential store until a usable credential for `apiBaseUrl` appears
24
+ * or `timeoutMs` elapses. Used by processes that did NOT win the lock.
25
+ */
26
+ export declare function waitForCredential(apiBaseUrl: string, timeoutMs: number): Promise<boolean>;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Cross-process file lock for the auto device-login flow.
3
+ *
4
+ * When several skill processes start without a token at the same time, only one
5
+ * should drive the Device Flow (= open one browser). The rest wait for the
6
+ * winner to write the credential, then read it. This lock coordinates that.
7
+ *
8
+ * The lock file lives next to the credential store, contains only non-secret
9
+ * metadata, and self-heals: a lock left behind by a crashed process is reclaimed
10
+ * once it is older than `staleMs` (Requirement 3.1/3.4/3.5).
11
+ */
12
+ import { createHash } from 'node:crypto';
13
+ import { promises as fs } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { CRED_DIR, getCredential } from './credential-store.js';
16
+ /** Stable short hash so different apiBaseUrls never block each other. */
17
+ function lockPathFor(apiBaseUrl) {
18
+ const hash = createHash('sha256').update(apiBaseUrl).digest('hex').slice(0, 12);
19
+ return join(CRED_DIR, `auth-${hash}.lock`);
20
+ }
21
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
22
+ async function writeLockFile(lockPath) {
23
+ const payload = { pid: process.pid, createdAt: Date.now() };
24
+ // `wx` fails if the file already exists → atomic acquire.
25
+ await fs.writeFile(lockPath, JSON.stringify(payload), { flag: 'wx', mode: 0o600 });
26
+ }
27
+ /** Best-effort removal; never throws. */
28
+ async function safeUnlink(lockPath) {
29
+ try {
30
+ await fs.unlink(lockPath);
31
+ }
32
+ catch {
33
+ // already gone / not ours — nothing to do
34
+ }
35
+ }
36
+ /**
37
+ * Try to acquire the auth lock for `apiBaseUrl`.
38
+ * - Returns a handle when acquired.
39
+ * - Returns null when another live process holds it.
40
+ * - Reclaims a stale lock (older than `staleMs`) and retries once.
41
+ */
42
+ export async function acquireLock(apiBaseUrl, staleMs) {
43
+ await fs.mkdir(CRED_DIR, { recursive: true, mode: 0o700 });
44
+ const lockPath = lockPathFor(apiBaseUrl);
45
+ try {
46
+ await writeLockFile(lockPath);
47
+ return makeHandle(lockPath);
48
+ }
49
+ catch {
50
+ // Lock exists — decide whether it is stale.
51
+ }
52
+ let stale = false;
53
+ try {
54
+ const stat = await fs.stat(lockPath);
55
+ stale = Date.now() - stat.mtimeMs > staleMs;
56
+ }
57
+ catch {
58
+ // Disappeared between the failed create and the stat → treat as free.
59
+ stale = true;
60
+ }
61
+ if (!stale)
62
+ return null;
63
+ // Reclaim the stale lock and retry exactly once.
64
+ await safeUnlink(lockPath);
65
+ try {
66
+ await writeLockFile(lockPath);
67
+ return makeHandle(lockPath);
68
+ }
69
+ catch {
70
+ // Someone else won the race after we cleared it.
71
+ return null;
72
+ }
73
+ }
74
+ function makeHandle(lockPath) {
75
+ let released = false;
76
+ return {
77
+ async release() {
78
+ if (released)
79
+ return;
80
+ released = true;
81
+ await safeUnlink(lockPath);
82
+ },
83
+ };
84
+ }
85
+ /**
86
+ * Poll the credential store until a usable credential for `apiBaseUrl` appears
87
+ * or `timeoutMs` elapses. Used by processes that did NOT win the lock.
88
+ */
89
+ export async function waitForCredential(apiBaseUrl, timeoutMs) {
90
+ const deadline = Date.now() + timeoutMs;
91
+ const intervalMs = 1_000;
92
+ while (Date.now() < deadline) {
93
+ const cred = await getCredential(apiBaseUrl);
94
+ if (cred && cred.privToken.trim())
95
+ return true;
96
+ await sleep(intervalMs);
97
+ }
98
+ const cred = await getCredential(apiBaseUrl);
99
+ return !!(cred && cred.privToken.trim());
100
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Automatic Device Flow orchestration triggered when a skill call finds no
3
+ * token. Only ever invoked AFTER resolvePrivToken() has confirmed all three
4
+ * sources (flag / env / credential store) are empty, so the env-injected
5
+ * (cloud) path never reaches this module — guaranteeing zero regression there.
6
+ *
7
+ * Flow (see design.md):
8
+ * canAutoAuth gate → acquire lock
9
+ * ├─ won : runDeviceFlow(short wait) → on approval re-read the store
10
+ * └─ lost : wait for whoever holds the lock to write a credential
11
+ *
12
+ * Always bounded by `maxWaitMs` so it can never block a host tool call forever.
13
+ */
14
+ import { type ResolvedToken } from './resolve.js';
15
+ /**
16
+ * Attempt to obtain a token via automatic browser authorization. Returns the
17
+ * resolved token on success, or null when auto auth is not applicable / did not
18
+ * complete in time (callers then fail fast with NOT_AUTHENTICATED_HINT).
19
+ */
20
+ export declare function attemptAutoLogin(apiBaseUrl: string): Promise<ResolvedToken | null>;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Automatic Device Flow orchestration triggered when a skill call finds no
3
+ * token. Only ever invoked AFTER resolvePrivToken() has confirmed all three
4
+ * sources (flag / env / credential store) are empty, so the env-injected
5
+ * (cloud) path never reaches this module — guaranteeing zero regression there.
6
+ *
7
+ * Flow (see design.md):
8
+ * canAutoAuth gate → acquire lock
9
+ * ├─ won : runDeviceFlow(short wait) → on approval re-read the store
10
+ * └─ lost : wait for whoever holds the lock to write a credential
11
+ *
12
+ * Always bounded by `maxWaitMs` so it can never block a host tool call forever.
13
+ */
14
+ import { resolvePrivToken } from './resolve.js';
15
+ import { canAutoAuth } from './environment.js';
16
+ import { acquireLock, waitForCredential } from './auth-lock.js';
17
+ import { runDeviceFlow } from './device-flow-runner.js';
18
+ const DEFAULT_WAIT_MS = 25_000;
19
+ /** Extra slack on top of the wait window before a held lock is considered stale. */
20
+ const STALE_SLACK_MS = 10_000;
21
+ /** Resolve the short-wait window from env, falling back to the default. */
22
+ function waitMs() {
23
+ const raw = Number(process.env.REMIXMATE_AUTH_WAIT_SECONDS);
24
+ return Number.isFinite(raw) && raw > 0 ? raw * 1000 : DEFAULT_WAIT_MS;
25
+ }
26
+ /**
27
+ * Attempt to obtain a token via automatic browser authorization. Returns the
28
+ * resolved token on success, or null when auto auth is not applicable / did not
29
+ * complete in time (callers then fail fast with NOT_AUTHENTICATED_HINT).
30
+ */
31
+ export async function attemptAutoLogin(apiBaseUrl) {
32
+ if (!canAutoAuth())
33
+ return null;
34
+ const max = waitMs();
35
+ const lock = await acquireLock(apiBaseUrl, max + STALE_SLACK_MS);
36
+ if (!lock) {
37
+ // Another process is driving the Device Flow — wait for its credential.
38
+ process.stderr.write('ℹ️ 另一个进程正在授权,等待其完成…\n');
39
+ const ok = await waitForCredential(apiBaseUrl, max);
40
+ return ok ? resolvePrivToken({ apiBaseUrl }) : null;
41
+ }
42
+ try {
43
+ const outcome = await runDeviceFlow(apiBaseUrl, { maxWaitMs: max });
44
+ switch (outcome.status) {
45
+ case 'approved':
46
+ process.stderr.write(`✅ 授权成功:${outcome.userLabel ?? ''}\n`);
47
+ return resolvePrivToken({ apiBaseUrl });
48
+ case 'timeout':
49
+ process.stderr.write('⏳ 已打开授权页但未在等待窗口内完成。请在浏览器完成授权后重跑同一命令。\n');
50
+ return null;
51
+ case 'denied':
52
+ process.stderr.write('❌ 授权被拒绝。\n');
53
+ return null;
54
+ case 'expired':
55
+ process.stderr.write('❌ 设备码已过期,请重跑命令重新授权。\n');
56
+ return null;
57
+ case 'error':
58
+ default:
59
+ process.stderr.write(`❌ 自动授权失败:${'message' in outcome ? outcome.message : '未知错误'}\n`);
60
+ return null;
61
+ }
62
+ }
63
+ finally {
64
+ await lock.release();
65
+ }
66
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * `remixmate login | logout | whoami` command implementations.
3
+ *
4
+ * Security: the PrivToken value is never printed. Identity is shown by label only.
5
+ * Env/CI behavior: when PRIV_TOKEN is set, `login` does not start the device flow.
6
+ */
7
+ /** Dispatch an auth verb. Returns a process exit code. */
8
+ export declare function runAuthCommand(verb: string, rest: string[]): Promise<number>;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * `remixmate login | logout | whoami` command implementations.
3
+ *
4
+ * Security: the PrivToken value is never printed. Identity is shown by label only.
5
+ * Env/CI behavior: when PRIV_TOKEN is set, `login` does not start the device flow.
6
+ */
7
+ import { parseArgv } from '../argv.js';
8
+ import { resolveApiBaseUrl } from '../http.js';
9
+ import { getCredential, removeCredential } from './credential-store.js';
10
+ import { resolvePrivToken } from './resolve.js';
11
+ import { runDeviceFlow } from './device-flow-runner.js';
12
+ async function login(apiBaseUrl) {
13
+ // Env token short-circuits the device flow (CI / agent-embedded hosts).
14
+ if ((process.env.PRIV_TOKEN ?? '').trim()) {
15
+ process.stdout.write('ℹ️ PRIV_TOKEN 环境变量已设置,CLI 将直接使用它,无需登录。\n');
16
+ return 0;
17
+ }
18
+ // Device flow requires an interactive terminal.
19
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
20
+ process.stderr.write('❌ remixmate login 需要交互式终端;在 CI/无终端环境请改用 PRIV_TOKEN 环境变量。\n');
21
+ return 1;
22
+ }
23
+ // Already authenticated for this backend?
24
+ const existing = await getCredential(apiBaseUrl);
25
+ if (existing) {
26
+ process.stdout.write(`✅ 已登录(${existing.userLabel ?? 'unknown'})。如需切换账号请先运行 remixmate logout。\n`);
27
+ return 0;
28
+ }
29
+ // Interactive login uses the device code's full lifetime (no short-wait cap)
30
+ // and routes progress to stdout. Shared orchestration handles persistence.
31
+ const outcome = await runDeviceFlow(apiBaseUrl, {
32
+ write: (msg) => process.stdout.write(msg),
33
+ });
34
+ switch (outcome.status) {
35
+ case 'approved':
36
+ process.stdout.write(`\n✅ 登录成功:${outcome.userLabel ?? ''}\n`);
37
+ process.stdout.write('🔒 安全提示:若你此前曾把 PrivToken 粘贴到聊天框或写进 shell 历史,建议在控制台轮换该 token。\n');
38
+ return 0;
39
+ case 'denied':
40
+ process.stderr.write('❌ 授权被拒绝。\n');
41
+ return 1;
42
+ case 'error':
43
+ process.stderr.write(`❌ 登录失败:${outcome.message}\n`);
44
+ return 1;
45
+ case 'timeout':
46
+ case 'expired':
47
+ default:
48
+ process.stderr.write('❌ 设备码已过期或无效,请重新运行 remixmate login。\n');
49
+ return 1;
50
+ }
51
+ }
52
+ async function logout(apiBaseUrl) {
53
+ const res = await removeCredential(apiBaseUrl);
54
+ const localNote = 'ℹ️ logout 仅清除本地凭证,不会在服务端吊销 PrivToken;如需服务端失效请轮换 token。\n';
55
+ if (!res.removedFile && !res.removedKeychain && !res.fileError && !res.keychainError) {
56
+ process.stdout.write('ℹ️ 未找到已存储的凭证。\n');
57
+ return 0;
58
+ }
59
+ // Partial failure: keychain removed but file not cleared (Req 8.4), or vice versa.
60
+ if (res.fileError || res.keychainError) {
61
+ const which = res.fileError ? '本地文件' : '系统钥匙串';
62
+ process.stderr.write(`⚠️ 凭证未完全清除:${which}清除失败(${res.fileError ?? res.keychainError})。\n`);
63
+ process.stdout.write(localNote);
64
+ return 1;
65
+ }
66
+ process.stdout.write('✅ 已清除本地存储的凭证。\n');
67
+ process.stdout.write(localNote);
68
+ return 0;
69
+ }
70
+ /** Probe token validity against an existing authenticated endpoint (10s timeout). */
71
+ async function verifyToken(apiBaseUrl, token) {
72
+ const controller = new AbortController();
73
+ const timer = setTimeout(() => controller.abort(), 10_000);
74
+ try {
75
+ const resp = await fetch(`${apiBaseUrl}/remotionTemplate/registry`, {
76
+ method: 'POST',
77
+ headers: { 'Content-Type': 'application/json', 'X-Priv-Token': token },
78
+ body: '{}',
79
+ signal: controller.signal,
80
+ });
81
+ const parsed = JSON.parse(await resp.text());
82
+ if (parsed.code === 0)
83
+ return 'valid';
84
+ if (parsed.code === 401)
85
+ return 'invalid';
86
+ return 'invalid';
87
+ }
88
+ catch {
89
+ return 'unreachable';
90
+ }
91
+ finally {
92
+ clearTimeout(timer);
93
+ }
94
+ }
95
+ async function whoami(apiBaseUrl, flagToken) {
96
+ const resolved = await resolvePrivToken({ flag: flagToken, apiBaseUrl });
97
+ if (!resolved) {
98
+ process.stderr.write('❌ 未登录。运行 remixmate login,或设置 PRIV_TOKEN 环境变量。\n');
99
+ return 1;
100
+ }
101
+ const verdict = await verifyToken(apiBaseUrl, resolved.privToken);
102
+ if (verdict === 'unreachable') {
103
+ process.stderr.write('❌ 无法验证身份:后端不可达或超时(本地凭证未改动)。\n');
104
+ return 1;
105
+ }
106
+ if (verdict === 'invalid') {
107
+ process.stderr.write(`❌ 凭证无效或已被吊销。来源:${resolved.source}\n`);
108
+ return 1;
109
+ }
110
+ const label = resolved.userLabel ?? '(已认证)';
111
+ process.stdout.write(`已认证:${label}\n来源:${resolved.source}\n`);
112
+ return 0;
113
+ }
114
+ /** Dispatch an auth verb. Returns a process exit code. */
115
+ export async function runAuthCommand(verb, rest) {
116
+ const args = parseArgv(rest);
117
+ const apiBaseUrl = resolveApiBaseUrl(args.api_base_url);
118
+ const flagToken = args.token;
119
+ switch (verb) {
120
+ case 'login':
121
+ return login(apiBaseUrl);
122
+ case 'logout':
123
+ return logout(apiBaseUrl);
124
+ case 'whoami':
125
+ return whoami(apiBaseUrl, flagToken);
126
+ default:
127
+ process.stderr.write(`❌ unknown auth command: ${verb}\n`);
128
+ return 2;
129
+ }
130
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Local credential store for the remixmate CLI.
3
+ *
4
+ * Design (see .kiro/specs/cli-device-login/design.md):
5
+ * - File store at ~/.config/remixmate/credentials.json (mode 0600) is the
6
+ * REQUIRED, always-present implementation. Entries are keyed by API base
7
+ * URL so multiple accounts / backends coexist without overwriting.
8
+ * - The OS keychain is an OPTIONAL enhancement loaded via dynamic import().
9
+ * When a keychain module is available we keep the secret PrivToken there
10
+ * and store only non-secret metadata (+ a location marker) in the file.
11
+ * When it is absent or fails at runtime, the PrivToken is stored in the
12
+ * file instead. Either way the PrivToken value is never logged.
13
+ *
14
+ * No new runtime dependency is added: keychain support activates only if a
15
+ * compatible module (`keytar`) is already importable in the host environment.
16
+ */
17
+ export declare const CRED_DIR: string;
18
+ export declare const CRED_FILE: string;
19
+ /** A resolved credential as seen by callers (token always populated). */
20
+ export interface StoredCredential {
21
+ privToken: string;
22
+ userLabel?: string;
23
+ createdAt: number;
24
+ }
25
+ /** Result of a remove, so callers (logout) can report partial failures. */
26
+ export interface RemoveResult {
27
+ removedFile: boolean;
28
+ removedKeychain: boolean;
29
+ keychainError?: string;
30
+ fileError?: string;
31
+ }
32
+ /**
33
+ * Persist a credential for `apiBaseUrl`. Prefers the keychain for the secret;
34
+ * falls back to the file on absence or runtime failure. Returns whether the
35
+ * keychain was used so callers can surface "keychain unavailable, used file".
36
+ */
37
+ export declare function setCredential(apiBaseUrl: string, cred: StoredCredential): Promise<{
38
+ usedKeychain: boolean;
39
+ keychainError?: string;
40
+ }>;
41
+ /** Read a credential for `apiBaseUrl`, or null when none is stored. */
42
+ export declare function getCredential(apiBaseUrl: string): Promise<StoredCredential | null>;
43
+ /** Remove a credential from both stores; reports per-store outcome. */
44
+ export declare function removeCredential(apiBaseUrl: string): Promise<RemoveResult>;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Local credential store for the remixmate CLI.
3
+ *
4
+ * Design (see .kiro/specs/cli-device-login/design.md):
5
+ * - File store at ~/.config/remixmate/credentials.json (mode 0600) is the
6
+ * REQUIRED, always-present implementation. Entries are keyed by API base
7
+ * URL so multiple accounts / backends coexist without overwriting.
8
+ * - The OS keychain is an OPTIONAL enhancement loaded via dynamic import().
9
+ * When a keychain module is available we keep the secret PrivToken there
10
+ * and store only non-secret metadata (+ a location marker) in the file.
11
+ * When it is absent or fails at runtime, the PrivToken is stored in the
12
+ * file instead. Either way the PrivToken value is never logged.
13
+ *
14
+ * No new runtime dependency is added: keychain support activates only if a
15
+ * compatible module (`keytar`) is already importable in the host environment.
16
+ */
17
+ import { homedir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { promises as fs } from 'node:fs';
20
+ export const CRED_DIR = join(homedir(), '.config', 'remixmate');
21
+ export const CRED_FILE = join(CRED_DIR, 'credentials.json');
22
+ const KEYCHAIN_SERVICE = 'remixmate-cli';
23
+ let keychainProbe;
24
+ /** Lazily attempt to load an optional keychain module; cache the (null) result. */
25
+ async function loadKeychain() {
26
+ if (keychainProbe === undefined) {
27
+ keychainProbe = import('keytar')
28
+ .then((m) => (m.default ?? m))
29
+ .catch(() => null);
30
+ }
31
+ return keychainProbe;
32
+ }
33
+ async function readFileStore() {
34
+ try {
35
+ const raw = await fs.readFile(CRED_FILE, 'utf8');
36
+ const parsed = JSON.parse(raw);
37
+ if (parsed && parsed.version === 1 && parsed.credentials) {
38
+ return { version: 1, credentials: parsed.credentials };
39
+ }
40
+ }
41
+ catch {
42
+ // Missing or unreadable/corrupt file → treat as empty store.
43
+ }
44
+ return { version: 1, credentials: {} };
45
+ }
46
+ async function writeFileStore(store) {
47
+ await fs.mkdir(CRED_DIR, { recursive: true, mode: 0o700 });
48
+ // Write with 0600 from the start, then chmod defensively in case the file
49
+ // pre-existed with looser permissions.
50
+ await fs.writeFile(CRED_FILE, JSON.stringify(store, null, 2), { mode: 0o600 });
51
+ await fs.chmod(CRED_FILE, 0o600);
52
+ }
53
+ /**
54
+ * Persist a credential for `apiBaseUrl`. Prefers the keychain for the secret;
55
+ * falls back to the file on absence or runtime failure. Returns whether the
56
+ * keychain was used so callers can surface "keychain unavailable, used file".
57
+ */
58
+ export async function setCredential(apiBaseUrl, cred) {
59
+ const store = await readFileStore();
60
+ const entry = {
61
+ userLabel: cred.userLabel,
62
+ createdAt: cred.createdAt,
63
+ secret: 'file',
64
+ privToken: cred.privToken,
65
+ };
66
+ const keychain = await loadKeychain();
67
+ let usedKeychain = false;
68
+ let keychainError;
69
+ if (keychain) {
70
+ try {
71
+ await keychain.setPassword(KEYCHAIN_SERVICE, apiBaseUrl, cred.privToken);
72
+ usedKeychain = true;
73
+ entry.secret = 'keychain';
74
+ delete entry.privToken; // keep the secret out of the file
75
+ }
76
+ catch (err) {
77
+ keychainError = err.message;
78
+ }
79
+ }
80
+ store.credentials[apiBaseUrl] = entry;
81
+ await writeFileStore(store);
82
+ return { usedKeychain, keychainError };
83
+ }
84
+ /** Read a credential for `apiBaseUrl`, or null when none is stored. */
85
+ export async function getCredential(apiBaseUrl) {
86
+ const store = await readFileStore();
87
+ const entry = store.credentials[apiBaseUrl];
88
+ if (!entry)
89
+ return null;
90
+ let privToken = entry.privToken;
91
+ if (entry.secret === 'keychain') {
92
+ const keychain = await loadKeychain();
93
+ const secret = keychain ? await keychain.getPassword(KEYCHAIN_SERVICE, apiBaseUrl) : null;
94
+ if (!secret)
95
+ return null; // secret expected in keychain but unavailable
96
+ privToken = secret;
97
+ }
98
+ if (!privToken)
99
+ return null;
100
+ return { privToken, userLabel: entry.userLabel, createdAt: entry.createdAt };
101
+ }
102
+ /** Remove a credential from both stores; reports per-store outcome. */
103
+ export async function removeCredential(apiBaseUrl) {
104
+ const result = { removedFile: false, removedKeychain: false };
105
+ const keychain = await loadKeychain();
106
+ if (keychain) {
107
+ try {
108
+ result.removedKeychain = await keychain.deletePassword(KEYCHAIN_SERVICE, apiBaseUrl);
109
+ }
110
+ catch (err) {
111
+ result.keychainError = err.message;
112
+ }
113
+ }
114
+ const store = await readFileStore();
115
+ if (store.credentials[apiBaseUrl]) {
116
+ delete store.credentials[apiBaseUrl];
117
+ try {
118
+ await writeFileStore(store);
119
+ result.removedFile = true;
120
+ }
121
+ catch (err) {
122
+ result.fileError = err.message;
123
+ }
124
+ }
125
+ return result;
126
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Shared Device Flow orchestration used by BOTH `remixmate login` (interactive,
3
+ * full device-code lifetime) and the automatic browser auth that kicks in when
4
+ * a skill call finds no token (bounded by a short wait window).
5
+ *
6
+ * Centralizing the request → open browser → poll → persist sequence keeps the
7
+ * two entry points byte-for-byte consistent (Requirement 4.1/4.2) and means the
8
+ * credential written by auto-login is indistinguishable from `login`'s.
9
+ *
10
+ * Security: the PrivToken value is NEVER written to the provided sink.
11
+ */
12
+ export interface DeviceFlowOptions {
13
+ /**
14
+ * Upper bound on how long to poll for approval. When omitted the device
15
+ * code's own `expiresIn` is the only bound (used by interactive `login`).
16
+ * When set (auto-login), a stop caused by this window is reported as
17
+ * `'timeout'` rather than `'expired'`.
18
+ */
19
+ maxWaitMs?: number;
20
+ /** Where human-facing progress text goes. Defaults to stderr. */
21
+ write?: (msg: string) => void;
22
+ /** Client label sent with the device-code request. */
23
+ clientLabel?: string;
24
+ }
25
+ export type DeviceFlowOutcome = {
26
+ status: 'approved';
27
+ userLabel?: string;
28
+ } | {
29
+ status: 'timeout';
30
+ } | {
31
+ status: 'denied';
32
+ } | {
33
+ status: 'expired';
34
+ } | {
35
+ status: 'error';
36
+ message: string;
37
+ };
38
+ /**
39
+ * Run one full Device Flow against `apiBaseUrl`. On approval the PrivToken is
40
+ * persisted via setCredential (keychain or file, same as `login`) and only the
41
+ * non-secret outcome is returned.
42
+ */
43
+ export declare function runDeviceFlow(apiBaseUrl: string, opts?: DeviceFlowOptions): Promise<DeviceFlowOutcome>;