@aswless_854771076/ai_short_studio_cli 0.1.88 → 0.1.90

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 CHANGED
@@ -20,6 +20,8 @@ npm 包内置 `using-vvicat-ai-short-studio-cli`、`short-drama` 和 `humanizer`
20
20
  ```bash
21
21
  ai-short-studio auth signup --email user@example.com --password-stdin
22
22
  ai-short-studio auth login --email user@example.com --password-stdin
23
+ # 或复用网页端 MemHub 身份(会自动打开浏览器)
24
+ ai-short-studio auth login --provider memhub
23
25
  ```
24
26
 
25
27
  Agent 代操作时,用户已在当前会话明确提供的密码只能直接写入正在等待的隐藏密码提示,不得复述、拼入 shell 命令、落盘或跨会话保存。
@@ -28,7 +30,7 @@ Agent 代操作时,用户已在当前会话明确提供的密码只能直接
28
30
 
29
31
  注册确认链接使用当前 profile 的 `baseUrl` 和 `--locale`,指向同域的 `/{locale}/auth/callback`。API 与网页需保持同域,并在 Supabase Redirect URLs 中允许各环境的 `/**`;Site URL 只作为未显式传入回调时的生产兜底。
30
32
 
31
- CLI 只把 refresh token 保存到系统钥匙串。短期 access token 会写入权限为 `0600` 的本机 session 缓存;同一 profile 的并发进程通过跨进程锁共享刷新结果,只有缓存缺失或确实需要刷新时才访问钥匙串,从而避免 Agent 批量操作反复触发 macOS Keychain。退出登录会清除该缓存,CI 可用 `VVICAT_ACCESS_TOKEN` 直接注入临时令牌。未配置服务地址时默认连接 `https://ai-short-studio.vvicat.dev`,`--base-url`、`VVICAT_BASE_URL` 和当前 profile 可依次覆盖。首次登录会从服务端 bootstrap 取得 Supabase 公共配置,也可用 `VVICAT_SUPABASE_URL`、`VVICAT_SUPABASE_PUBLISHABLE_KEY` 显式覆盖。Google 登录在 Windows 上直接调用系统 URL Handler,确保带 `&` 的 PKCE 授权参数完整传给浏览器。服务地址和非敏感 profile 写入系统配置目录;切换环境时必须使用独立的 `--profile`,避免复用旧认证。
33
+ Supabase 登录只把 refresh token 保存到系统钥匙串;MemHub 登录保存由服务端签发的 `cli:access` 专用凭据,不保存 MemHub access/refresh/ID token。MemHub 使用浏览器 OIDC、loopback 回调、PKCE 和一次性授权码交换,长期凭据不会进入浏览器 URL。短期 access token 会写入权限为 `0600` 的本机 session 缓存;同一 profile 的并发进程通过跨进程锁共享刷新结果。`auth logout` 会清除缓存,并撤销 MemHub CLI 凭据。CI 可用 `VVICAT_ACCESS_TOKEN` 直接注入临时令牌。未配置服务地址时默认连接 `https://ai-short-studio.vvicat.dev`,`--base-url`、`VVICAT_BASE_URL` 和当前 profile 可依次覆盖。Supabase 登录会从 bootstrap 取得公共配置;MemHub 登录不依赖 Supabase 公共配置。服务地址、认证提供方和其他非敏感 profile 数据写入系统配置目录;切换环境时必须使用独立的 `--profile`。
32
34
 
33
35
  创作前运行 `ai-short-studio preflight --json`。CLI 会通过公开的 `/api/v1/bootstrap` 检查服务连通性、取得 Supabase 公共配置、读取 npm `latest` 稳定版本并默认更新当前全局包,再检查登录态和文本、人物、场景、故事版、视频模型配置。npm registry 暂时不可用时服务端使用配置版本降级。更新后必须按输出重跑;明确不允许全局写入时使用 `--no-update`。
34
36
 
@@ -0,0 +1,12 @@
1
+ import type { AuthProvider, SessionTokens } from './session-manager.js';
2
+ export declare class ApiKeyAuthProvider implements AuthProvider {
3
+ private readonly baseUrl;
4
+ private readonly fetcher;
5
+ constructor(baseUrl: string, fetcher?: typeof fetch);
6
+ login(): Promise<SessionTokens>;
7
+ loginWithGoogle(): Promise<SessionTokens>;
8
+ signup(): Promise<SessionTokens | null>;
9
+ refresh(apiKey: string): Promise<SessionTokens>;
10
+ logout(apiKey: string): Promise<void>;
11
+ private request;
12
+ }
@@ -0,0 +1,36 @@
1
+ export class ApiKeyAuthProvider {
2
+ baseUrl;
3
+ fetcher;
4
+ constructor(baseUrl, fetcher = fetch) {
5
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
6
+ this.fetcher = fetcher;
7
+ }
8
+ async login() {
9
+ throw new Error('API Key 会话不支持邮箱密码登录');
10
+ }
11
+ async loginWithGoogle() {
12
+ throw new Error('API Key 会话不支持 Google 登录');
13
+ }
14
+ async signup() {
15
+ throw new Error('API Key 会话不支持注册');
16
+ }
17
+ async refresh(apiKey) {
18
+ const response = await this.request(apiKey, 'POST');
19
+ const payload = await response.json();
20
+ if (typeof payload.expiresAt !== 'number')
21
+ throw new Error('CLI 会话续期响应字段不完整');
22
+ return { accessToken: apiKey, refreshToken: apiKey, expiresAt: payload.expiresAt };
23
+ }
24
+ async logout(apiKey) {
25
+ await this.request(apiKey, 'DELETE');
26
+ }
27
+ async request(apiKey, method) {
28
+ const response = await this.fetcher(`${this.baseUrl}/api/v1/auth/cli/session`, {
29
+ method,
30
+ headers: { authorization: `Bearer ${apiKey}`, accept: 'application/json' },
31
+ });
32
+ if (!response.ok)
33
+ throw new Error(`CLI 会话请求失败(HTTP ${response.status})`);
34
+ return response;
35
+ }
36
+ }
@@ -0,0 +1,18 @@
1
+ import type { SessionTokens } from './session-manager.js';
2
+ interface CallbackResult {
3
+ code: string;
4
+ }
5
+ interface CallbackHandle {
6
+ redirectUri: string;
7
+ waitForResult(): Promise<CallbackResult>;
8
+ cancel?(): void;
9
+ }
10
+ interface MemHubLoginDependencies {
11
+ state?: string;
12
+ codeVerifier?: string;
13
+ callback?: (state: string) => Promise<CallbackHandle>;
14
+ openBrowser?: (url: string) => Promise<void>;
15
+ fetcher?: typeof fetch;
16
+ }
17
+ export declare function loginWithMemHubPkce(baseUrl: string, dependencies?: MemHubLoginDependencies): Promise<SessionTokens>;
18
+ export {};
@@ -0,0 +1,113 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { execFile } from 'node:child_process';
3
+ import { createServer } from 'node:http';
4
+ function challenge(verifier) {
5
+ return createHash('sha256').update(verifier).digest('base64url');
6
+ }
7
+ function randomParameter(bytes) {
8
+ return randomBytes(bytes).toString('base64url');
9
+ }
10
+ async function openSystemBrowser(url) {
11
+ await new Promise((resolve, reject) => {
12
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32.exe' : 'xdg-open';
13
+ const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
14
+ execFile(command, args, (error) => error ? reject(error) : resolve());
15
+ });
16
+ }
17
+ async function createLoopbackCallback(expectedState) {
18
+ let settle;
19
+ let fail;
20
+ const result = new Promise((resolve, reject) => {
21
+ settle = resolve;
22
+ fail = reject;
23
+ });
24
+ const server = createServer((request, response) => {
25
+ const url = new URL(request.url || '/', 'http://127.0.0.1');
26
+ if (url.pathname !== '/callback') {
27
+ response.writeHead(404).end('Not Found');
28
+ return;
29
+ }
30
+ const state = url.searchParams.get('state');
31
+ const code = url.searchParams.get('code');
32
+ const error = url.searchParams.get('error');
33
+ response.writeHead(error || !code || state !== expectedState ? 400 : 200, {
34
+ 'content-type': 'text/html; charset=utf-8',
35
+ });
36
+ response.end(error || !code || state !== expectedState
37
+ ? '<h1>登录失败</h1><p>请返回终端重试。</p>'
38
+ : '<h1>登录成功</h1><p>可以关闭此窗口并返回终端。</p>');
39
+ server.close();
40
+ if (error)
41
+ fail?.(new Error(`MemHub 登录失败:${error}`));
42
+ else if (state !== expectedState)
43
+ fail?.(new Error('MemHub CLI 回调 state 不匹配'));
44
+ else if (!code)
45
+ fail?.(new Error('MemHub CLI 回调缺少授权码'));
46
+ else
47
+ settle?.({ code });
48
+ });
49
+ await new Promise((resolve, reject) => {
50
+ server.once('error', reject);
51
+ server.listen(0, '127.0.0.1', resolve);
52
+ });
53
+ const address = server.address();
54
+ const timeout = setTimeout(() => {
55
+ server.close();
56
+ fail?.(new Error('MemHub CLI 登录等待超时'));
57
+ }, 5 * 60 * 1000);
58
+ timeout.unref();
59
+ return {
60
+ redirectUri: `http://127.0.0.1:${address.port}/callback`,
61
+ cancel: () => {
62
+ clearTimeout(timeout);
63
+ server.close();
64
+ },
65
+ waitForResult: async () => {
66
+ try {
67
+ return await result;
68
+ }
69
+ finally {
70
+ clearTimeout(timeout);
71
+ }
72
+ },
73
+ };
74
+ }
75
+ export async function loginWithMemHubPkce(baseUrl, dependencies = {}) {
76
+ const state = dependencies.state ?? randomParameter(32);
77
+ const codeVerifier = dependencies.codeVerifier ?? randomParameter(48);
78
+ const callback = await (dependencies.callback ?? createLoopbackCallback)(state);
79
+ const authorizationUrl = new URL('/api/sso/memhub/start', baseUrl);
80
+ authorizationUrl.searchParams.set('mode', 'cli');
81
+ authorizationUrl.searchParams.set('redirect_uri', callback.redirectUri);
82
+ authorizationUrl.searchParams.set('state', state);
83
+ authorizationUrl.searchParams.set('code_challenge', challenge(codeVerifier));
84
+ try {
85
+ await (dependencies.openBrowser ?? openSystemBrowser)(authorizationUrl.toString());
86
+ }
87
+ catch (error) {
88
+ // 浏览器启动失败时立即释放本机端口,避免 CLI 进程因 HTTP server 残留而挂起。
89
+ callback.cancel?.();
90
+ throw error;
91
+ }
92
+ const { code } = await callback.waitForResult();
93
+ const response = await (dependencies.fetcher ?? fetch)(new URL('/api/v1/auth/cli/token', baseUrl).toString(), {
94
+ method: 'POST',
95
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
96
+ body: JSON.stringify({ code, codeVerifier }),
97
+ });
98
+ if (!response.ok) {
99
+ const body = await response.text().catch(() => '');
100
+ throw new Error(`MemHub CLI 凭据交换失败(HTTP ${response.status})${body ? `:${body}` : ''}`);
101
+ }
102
+ const payload = await response.json();
103
+ if (typeof payload.accessToken !== 'string'
104
+ || typeof payload.refreshToken !== 'string'
105
+ || typeof payload.expiresAt !== 'number') {
106
+ throw new Error('MemHub CLI 凭据响应字段不完整');
107
+ }
108
+ return {
109
+ accessToken: payload.accessToken,
110
+ refreshToken: payload.refreshToken,
111
+ expiresAt: payload.expiresAt,
112
+ };
113
+ }
@@ -44,6 +44,7 @@ export declare class SessionManager {
44
44
  accessToken(): Promise<string>;
45
45
  login(email: string, password: string): Promise<void>;
46
46
  loginWithGoogle(): Promise<void>;
47
+ acceptSession(tokens: SessionTokens): Promise<void>;
47
48
  signup(email: string, password: string, emailRedirectTo: string): Promise<boolean>;
48
49
  refreshAccessToken(): Promise<string>;
49
50
  logout(): Promise<void>;
@@ -88,6 +88,11 @@ export class SessionManager {
88
88
  await this.credentials.set(this.profile, this.current.refreshToken);
89
89
  await this.cache?.set(this.profile, this.current);
90
90
  }
91
+ async acceptSession(tokens) {
92
+ this.current = tokens;
93
+ await this.credentials.set(this.profile, tokens.refreshToken);
94
+ await this.cache?.set(this.profile, tokens);
95
+ }
91
96
  async signup(email, password, emailRedirectTo) {
92
97
  this.current = await this.provider.signup(email, password, emailRedirectTo);
93
98
  if (!this.current)
@@ -4,6 +4,7 @@ export default class AuthLogin extends BaseCommand {
4
4
  static flags: {
5
5
  email: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
6
6
  google: import("@oclif/core/interfaces").BooleanFlag<boolean>;
7
+ provider: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
7
8
  'password-stdin': import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
9
  'supabase-publishable-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
10
  'supabase-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
@@ -2,20 +2,33 @@ import { Flags } from '@oclif/core';
2
2
  import { BaseCommand } from '../../base-command.js';
3
3
  import { createRuntime } from '../../runtime.js';
4
4
  import { promptLine, promptPassword, readStdinText } from '../../terminal/prompt.js';
5
+ import { loginWithMemHubPkce } from '../../auth/memhub-oauth.js';
5
6
  export default class AuthLogin extends BaseCommand {
6
- static description = '登录并将 refresh token 保存到系统钥匙串';
7
+ static description = '通过 Supabase 或 MemHub 登录,并将长期凭据保存到系统钥匙串';
7
8
  static flags = {
8
9
  ...BaseCommand.baseFlags,
9
10
  email: Flags.string({ description: '登录邮箱' }),
10
11
  google: Flags.boolean({ description: '通过浏览器使用 Google 登录', default: false }),
12
+ provider: Flags.string({
13
+ description: '登录提供方',
14
+ options: ['supabase', 'memhub'],
15
+ default: 'supabase',
16
+ }),
11
17
  'password-stdin': Flags.boolean({ description: '从标准输入读取密码', default: false }),
12
18
  'supabase-publishable-key': Flags.string({ description: 'Supabase publishable key', env: 'VVICAT_SUPABASE_PUBLISHABLE_KEY' }),
13
19
  'supabase-url': Flags.string({ description: 'Supabase URL', env: 'VVICAT_SUPABASE_URL' }),
14
20
  };
15
21
  async run() {
16
22
  const { flags } = await this.parse(AuthLogin);
17
- const runtime = await createRuntime(flags);
18
- if (flags.google) {
23
+ const authProvider = flags.provider;
24
+ if (authProvider === 'memhub' && (flags.google || flags.email || flags['password-stdin'])) {
25
+ throw new Error('MemHub 登录使用浏览器授权,不能同时传入邮箱、Google 或密码参数');
26
+ }
27
+ const runtime = await createRuntime({ ...flags, 'auth-provider': authProvider });
28
+ if (authProvider === 'memhub') {
29
+ await runtime.session.acceptSession(await loginWithMemHubPkce(runtime.profile.baseUrl));
30
+ }
31
+ else if (flags.google) {
19
32
  await runtime.session.loginWithGoogle();
20
33
  }
21
34
  else {
@@ -23,6 +36,6 @@ export default class AuthLogin extends BaseCommand {
23
36
  const password = flags['password-stdin'] ? await readStdinText() : await promptPassword('密码: ');
24
37
  await runtime.session.login(email, password.trimEnd());
25
38
  }
26
- this.print({ authenticated: true, profile: runtime.profile.name });
39
+ this.print({ authenticated: true, profile: runtime.profile.name, provider: authProvider });
27
40
  }
28
41
  }
@@ -16,7 +16,7 @@ export default class AuthSignup extends BaseCommand {
16
16
  };
17
17
  async run() {
18
18
  const { flags } = await this.parse(AuthSignup);
19
- const runtime = await createRuntime(flags);
19
+ const runtime = await createRuntime({ ...flags, 'auth-provider': 'supabase' });
20
20
  const email = (flags.email || await promptLine('邮箱: ')).trim();
21
21
  const password = flags['password-stdin'] ? await readStdinText() : await promptPassword('密码: ');
22
22
  const normalizedPassword = password.trimEnd();
@@ -1,6 +1,7 @@
1
1
  export interface CliProfile {
2
2
  name: string;
3
3
  baseUrl: string;
4
+ authProvider?: 'supabase' | 'memhub';
4
5
  defaultProjectId?: string;
5
6
  supabaseUrl?: string;
6
7
  supabasePublishableKey?: string;
package/dist/runtime.d.ts CHANGED
@@ -2,6 +2,7 @@ import { ApiClient } from './client/api-client.js';
2
2
  import { SessionManager } from './auth/session-manager.js';
3
3
  import { type CliProfile } from './config/profiles.js';
4
4
  interface RuntimeFlags {
5
+ 'auth-provider'?: 'supabase' | 'memhub';
5
6
  'base-url'?: string;
6
7
  locale?: string;
7
8
  profile?: string;
package/dist/runtime.js CHANGED
@@ -2,6 +2,7 @@ import { ApiClient } from './client/api-client.js';
2
2
  import { join } from 'node:path';
3
3
  import { KeyringCredentialStore } from './auth/credential-store.js';
4
4
  import { SessionManager, SupabaseAuthProvider } from './auth/session-manager.js';
5
+ import { ApiKeyAuthProvider } from './auth/api-key-auth-provider.js';
5
6
  import { FileAccessTokenCache } from './auth/session-cache.js';
6
7
  import { defaultConfigDirectory, ProfileStore } from './config/profiles.js';
7
8
  import { fetchBootstrap } from './preflight.js';
@@ -17,8 +18,9 @@ export async function createRuntime(flags) {
17
18
  ...selected,
18
19
  name: flags.profile || selected?.name || 'default',
19
20
  baseUrl,
21
+ authProvider: flags['auth-provider'] || selected?.authProvider || 'supabase',
20
22
  }, flags);
21
- if (!profile.supabaseUrl || !profile.supabasePublishableKey) {
23
+ if (profile.authProvider === 'supabase' && (!profile.supabaseUrl || !profile.supabasePublishableKey)) {
22
24
  const bootstrap = await fetchBootstrap(baseUrl);
23
25
  profile = {
24
26
  ...profile,
@@ -31,7 +33,9 @@ export async function createRuntime(flags) {
31
33
  profile: profile.name,
32
34
  credentials: new KeyringCredentialStore(),
33
35
  cache: new FileAccessTokenCache(join(defaultConfigDirectory(process.env), 'sessions')),
34
- provider: new SupabaseAuthProvider(profile.supabaseUrl, profile.supabasePublishableKey),
36
+ provider: profile.authProvider === 'memhub'
37
+ ? new ApiKeyAuthProvider(baseUrl)
38
+ : new SupabaseAuthProvider(profile.supabaseUrl, profile.supabasePublishableKey),
35
39
  });
36
40
  return {
37
41
  profile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aswless_854771076/ai_short_studio_cli",
3
- "version": "0.1.88",
3
+ "version": "0.1.90",
4
4
  "description": "VVICAT 无限画布项目命令行工具",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -8,11 +8,12 @@ ai-short-studio skill status --target codex --json
8
8
  ai-short-studio auth signup --email user@example.com --password-stdin
9
9
  ai-short-studio auth login --google
10
10
  ai-short-studio auth login --email user@example.com --password-stdin
11
+ ai-short-studio auth login --provider memhub
11
12
  ai-short-studio auth status --json
12
13
  ai-short-studio auth logout
13
14
  ```
14
15
 
15
- 更新 CLI 后重跑 preflight。邮箱密码只从隐藏输入或安全 stdin 读取;`confirmationRequired: true` 时先完成邮件确认。
16
+ 更新 CLI 后重跑 preflight。邮箱密码只从隐藏输入或安全 stdin 读取;`confirmationRequired: true` 时先完成邮件确认。MemHub 登录会打开系统浏览器并使用 loopback + PKCE,不能同时传 `--email`、`--google` 或 `--password-stdin`。
16
17
 
17
18
  ## 项目与供应商
18
19