@aswless_854771076/ai_short_studio_cli 0.1.44 → 0.1.45

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
@@ -28,7 +28,7 @@ Agent 代操作时,用户已在当前会话明确提供的密码只能直接
28
28
 
29
29
  注册确认链接使用当前 profile 的 `baseUrl` 和 `--locale`,指向同域的 `/{locale}/auth/callback`。API 与网页需保持同域,并在 Supabase Redirect URLs 中允许各环境的 `/**`;Site URL 只作为未显式传入回调时的生产兜底。
30
30
 
31
- CLI 只把 refresh token 保存到系统钥匙串,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`,避免复用旧认证。
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`,避免复用旧认证。
32
32
 
33
33
  创作前运行 `ai-short-studio preflight --json`。CLI 会通过公开的 `/api/v1/bootstrap` 检查服务连通性、取得 Supabase 公共配置、读取 npm `latest` 稳定版本并默认更新当前全局包,再检查登录态和文本、人物、场景、故事版、视频模型配置。npm registry 暂时不可用时服务端使用配置版本降级。更新后必须按输出重跑;明确不允许全局写入时使用 `--no-update`。
34
34
 
@@ -37,12 +37,12 @@ CLI 只把 refresh token 保存到系统钥匙串,access token 仅驻留内存
37
37
  - `ai-short-studio preflight`:创作前版本、连通性、认证和模型配置检查。
38
38
  - `ai-short-studio project`:无限画布项目增删改查。
39
39
  - `ai-short-studio canvas settings get|set`:读取或原子更新项目配置。
40
- - `ai-short-studio canvas get|apply`:读取画布或执行低层图补丁。
41
- - `ai-short-studio canvas node`:节点类型/schema 查询、节点读写、执行。
40
+ - `ai-short-studio canvas get|inspect|apply`:读取画布、一次聚合查询常用状态,或执行低层图补丁。
41
+ - `ai-short-studio canvas node`:节点类型/schema 查询、字段级或批量节点更新、批量执行。
42
42
  - `ai-short-studio canvas edge`:连线读写。
43
43
  - `ai-short-studio canvas continuity analyze`:视频生成前分析镜头依赖,可修复已具备源视频的首尾帧连线。
44
44
  - `ai-short-studio canvas asset`:资产列表、上传、版本选择和下载。
45
- - `ai-short-studio task`:异步任务读取、等待和取消。
45
+ - `ai-short-studio task`:异步任务读取、批量等待和取消。
46
46
  - `ai-short-studio config`:模型、provider、默认模型、能力默认值和并发配置。
47
47
 
48
48
  ```bash
@@ -80,7 +80,20 @@ ai-short-studio canvas settings set --project "$PROJECT_ID" --file settings.json
80
80
  }
81
81
  ```
82
82
 
83
- `null` 或空字符串会清除项目覆盖并恢复继承。设置 `artStyle` 会把当前关联的 `visualBible` 保存为项目快照,画风库后续变化不会反向更新项目;同一请求显式传入 `visualBible` 时以显式值为准。所有者和管理员可写,协作者只读;409 冲突后重新 `settings get` 并携带新 `expectedVersion` 重试。`canvas apply` 仍用于节点、连线、删除和视口等低层画布图补丁;同阶段有多个可合并图变更时默认一次批量提交并统一回读,批量入口不可用或合法批量请求仍因粒度失败时才回退单条命令。Pexels 继续使用 `project provider get|set|test`。
83
+ `null` 或空字符串会清除项目覆盖并恢复继承。设置 `artStyle` 会把当前关联的 `visualBible` 保存为项目快照,画风库后续变化不会反向更新项目;同一请求显式传入 `visualBible` 时以显式值为准。所有者和管理员可写,协作者只读;409 冲突后重新 `settings get` 并携带新 `expectedVersion` 重试。`canvas apply` 仍用于节点、连线、删除和视口等低层画布图补丁;同阶段有多个可合并图变更时默认一次批量提交并统一回读。已经读取画布时传入 `--canvas-id` 与 `--expected-version`,可避免保存前再请求一次全图。批量入口不可用或合法批量请求仍因粒度失败时才回退单条命令。Pexels 继续使用 `project provider get|set|test`。
84
+
85
+ 常用审计优先使用一次聚合查询,不必临时编写脚本或逐条请求:
86
+
87
+ ```bash
88
+ ai-short-studio canvas inspect --project "$PROJECT_ID" --include summary --include nodes --include edges --include assets --include settings --include node-types --json
89
+ ai-short-studio canvas inspect --project "$PROJECT_ID" --kind storyboard-image --status stale --operation image-resource --json
90
+ ai-short-studio canvas node update <nodeId> --project "$PROJECT_ID" --set 'gridSize=9' --unset legacyField --json
91
+ ai-short-studio canvas node update-many --project "$PROJECT_ID" --file updates.json --json
92
+ ai-short-studio canvas node run --project "$PROJECT_ID" --node <node1> --node <node2> --concurrency 4 --wait --json
93
+ ai-short-studio task wait --task <task1> --task <task2> --concurrency 4 --json
94
+ ```
95
+
96
+ `canvas node update` 的 `--set key=<JSON>`、`--unset key`、`--title`、`--x` 和 `--y` 只修改指定字段;旧 `--config` 仍兼容。`update-many` 在一次画布版本校验中原子更新多个节点。结构化生成资源仍必须使用 `edit-resource` 或 `edit-shot`,不能通过字段更新改写 artifact。批量执行与等待默认并发为 4、上限为 16;Agent 应优先单进程批处理,避免为每个节点启动一个 CLI 进程。
84
97
 
85
98
  使用 `canvas node edit-shot <nodeId> --project <projectId> --file shot.json` 编辑镜头。命令会追加并选中不可变的人工 DOCUMENT 版本,不允许修改 `shotKey`、`shotIndex` 或来源身份,旧版本继续可选。
86
99
 
@@ -116,6 +129,17 @@ CLI 通过 `/api/v1/bootstrap` 完成版本、连通性与 Supabase 公共配置
116
129
 
117
130
  配套 Agent Skill 位于 `skills/using-vvicat-ai-short-studio-cli`,随 npm 包发布。安装或更新 CLI 时会自动安装到 Codex Skill 目录;已被用户修改或非 CLI 托管的目录不会被覆盖。
118
131
 
132
+ ## 本地真实验收
133
+
134
+ CLI 开发完成后运行以下命令:
135
+
136
+ ```bash
137
+ cd packages/ai_short_studio_cli
138
+ npm run test:local-e2e
139
+ ```
140
+
141
+ 该命令先构建真实 CLI,再启动隔离的本地 mock API,通过多个 `bin/run.js` 子进程验证聚合查询、字段级与多节点更新、`canvas apply` 免预读、批量执行/等待和旧位置参数兼容;同时启动 8 个独立认证进程,确认共享 session 缓存只触发一次模拟 Keychain 读取和一次刷新。测试不连接生产服务、不调用模型,所有配置、令牌、画布和任务数据均在系统临时目录中创建并在结束时清理。
142
+
119
143
  标准创作中,用户未指定时角色、道具、场景/背景和分镜图统一使用 `16:9`;视频按镜头目标时长向上取 5、10、15 秒生成档位,为剪辑保留余量。每批生成后 Agent 必须重新读取任务与资产、核对最终配置并实际检查预览,完成自我审计后才能继续或交付。
120
144
 
121
145
  ```bash
@@ -0,0 +1,20 @@
1
+ export interface CachedAccessToken {
2
+ accessToken: string;
3
+ expiresAt: number;
4
+ }
5
+ export interface AccessTokenCache {
6
+ delete(profile: string): Promise<void>;
7
+ get(profile: string): Promise<CachedAccessToken | null>;
8
+ set(profile: string, session: CachedAccessToken): Promise<void>;
9
+ withRefreshLock<T>(profile: string, action: () => Promise<T>): Promise<T>;
10
+ }
11
+ export declare class FileAccessTokenCache implements AccessTokenCache {
12
+ private readonly directory;
13
+ constructor(directory: string);
14
+ delete(profile: string): Promise<void>;
15
+ get(profile: string): Promise<CachedAccessToken | null>;
16
+ set(profile: string, session: CachedAccessToken): Promise<void>;
17
+ withRefreshLock<T>(profile: string, action: () => Promise<T>): Promise<T>;
18
+ private tokenPath;
19
+ private lockPath;
20
+ }
@@ -0,0 +1,76 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ const LOCK_STALE_MS = 30_000;
5
+ const LOCK_WAIT_MS = 50;
6
+ export class FileAccessTokenCache {
7
+ directory;
8
+ constructor(directory) {
9
+ this.directory = directory;
10
+ }
11
+ async delete(profile) {
12
+ await rm(this.tokenPath(profile), { force: true });
13
+ }
14
+ async get(profile) {
15
+ try {
16
+ const value = JSON.parse(await readFile(this.tokenPath(profile), 'utf8'));
17
+ if (typeof value.accessToken !== 'string' || typeof value.expiresAt !== 'number')
18
+ return null;
19
+ return { accessToken: value.accessToken, expiresAt: value.expiresAt };
20
+ }
21
+ catch (error) {
22
+ if (error.code === 'ENOENT')
23
+ return null;
24
+ throw error;
25
+ }
26
+ }
27
+ async set(profile, session) {
28
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
29
+ await chmod(this.directory, 0o700);
30
+ const path = this.tokenPath(profile);
31
+ const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
32
+ try {
33
+ await writeFile(temporaryPath, `${JSON.stringify(session)}\n`, { mode: 0o600 });
34
+ await rename(temporaryPath, path);
35
+ await chmod(path, 0o600);
36
+ }
37
+ finally {
38
+ await rm(temporaryPath, { force: true });
39
+ }
40
+ }
41
+ async withRefreshLock(profile, action) {
42
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
43
+ await chmod(this.directory, 0o700);
44
+ const path = this.lockPath(profile);
45
+ while (true) {
46
+ try {
47
+ await mkdir(path, { mode: 0o700 });
48
+ try {
49
+ return await action();
50
+ }
51
+ finally {
52
+ await rm(path, { recursive: true, force: true });
53
+ }
54
+ }
55
+ catch (error) {
56
+ if (error.code !== 'EEXIST')
57
+ throw error;
58
+ const lockStat = await stat(path).catch(() => null);
59
+ if (lockStat && Date.now() - lockStat.mtimeMs > LOCK_STALE_MS) {
60
+ await rm(path, { recursive: true, force: true });
61
+ continue;
62
+ }
63
+ await new Promise((resolve) => setTimeout(resolve, LOCK_WAIT_MS));
64
+ }
65
+ }
66
+ }
67
+ tokenPath(profile) {
68
+ return join(this.directory, `${profileKey(profile)}.json`);
69
+ }
70
+ lockPath(profile) {
71
+ return join(this.directory, `${profileKey(profile)}.lock`);
72
+ }
73
+ }
74
+ function profileKey(profile) {
75
+ return createHash('sha256').update(profile).digest('hex').slice(0, 24);
76
+ }
@@ -1,3 +1,4 @@
1
+ import type { AccessTokenCache } from './session-cache.js';
1
2
  export interface SessionTokens {
2
3
  accessToken: string;
3
4
  refreshToken: string;
@@ -26,7 +27,9 @@ export declare class SupabaseAuthProvider implements AuthProvider {
26
27
  }
27
28
  export declare class SessionManager {
28
29
  private current;
30
+ private lastReturnedAccessToken;
29
31
  private refreshPromise;
32
+ private readonly cache?;
30
33
  private readonly credentials;
31
34
  private readonly environment;
32
35
  private readonly profile;
@@ -35,6 +38,7 @@ export declare class SessionManager {
35
38
  profile: string;
36
39
  credentials: CredentialStore;
37
40
  provider: AuthProvider;
41
+ cache?: AccessTokenCache;
38
42
  environment?: NodeJS.ProcessEnv;
39
43
  });
40
44
  accessToken(): Promise<string>;
@@ -44,4 +48,5 @@ export declare class SessionManager {
44
48
  refreshAccessToken(): Promise<string>;
45
49
  logout(): Promise<void>;
46
50
  private refresh;
51
+ private remember;
47
52
  }
@@ -49,7 +49,9 @@ function sessionTokens(session) {
49
49
  }
50
50
  export class SessionManager {
51
51
  current = null;
52
+ lastReturnedAccessToken = null;
52
53
  refreshPromise = null;
54
+ cache;
53
55
  credentials;
54
56
  environment;
55
57
  profile;
@@ -58,15 +60,19 @@ export class SessionManager {
58
60
  this.profile = input.profile;
59
61
  this.credentials = input.credentials;
60
62
  this.provider = input.provider;
63
+ this.cache = input.cache;
61
64
  this.environment = input.environment ?? process.env;
62
65
  }
63
66
  async accessToken() {
64
67
  if (this.environment.VVICAT_ACCESS_TOKEN)
65
- return this.environment.VVICAT_ACCESS_TOKEN;
66
- if (this.current && this.current.expiresAt > Date.now() + 60_000)
67
- return this.current.accessToken;
68
+ return this.remember(this.environment.VVICAT_ACCESS_TOKEN);
69
+ if (this.current && isFresh(this.current))
70
+ return this.remember(this.current.accessToken);
71
+ const cached = await this.cache?.get(this.profile);
72
+ if (cached && isFresh(cached))
73
+ return this.remember(cached.accessToken);
68
74
  if (!this.refreshPromise) {
69
- this.refreshPromise = this.refresh().finally(() => {
75
+ this.refreshPromise = this.refresh(false).finally(() => {
70
76
  this.refreshPromise = null;
71
77
  });
72
78
  }
@@ -75,23 +81,26 @@ export class SessionManager {
75
81
  async login(email, password) {
76
82
  this.current = await this.provider.login(email, password);
77
83
  await this.credentials.set(this.profile, this.current.refreshToken);
84
+ await this.cache?.set(this.profile, this.current);
78
85
  }
79
86
  async loginWithGoogle() {
80
87
  this.current = await this.provider.loginWithGoogle();
81
88
  await this.credentials.set(this.profile, this.current.refreshToken);
89
+ await this.cache?.set(this.profile, this.current);
82
90
  }
83
91
  async signup(email, password, emailRedirectTo) {
84
92
  this.current = await this.provider.signup(email, password, emailRedirectTo);
85
93
  if (!this.current)
86
94
  return false;
87
95
  await this.credentials.set(this.profile, this.current.refreshToken);
96
+ await this.cache?.set(this.profile, this.current);
88
97
  return true;
89
98
  }
90
99
  async refreshAccessToken() {
91
100
  if (this.environment.VVICAT_ACCESS_TOKEN)
92
- return this.environment.VVICAT_ACCESS_TOKEN;
101
+ return this.remember(this.environment.VVICAT_ACCESS_TOKEN);
93
102
  if (!this.refreshPromise) {
94
- this.refreshPromise = this.refresh().finally(() => {
103
+ this.refreshPromise = this.refresh(true).finally(() => {
95
104
  this.refreshPromise = null;
96
105
  });
97
106
  }
@@ -103,15 +112,33 @@ export class SessionManager {
103
112
  if (accessToken)
104
113
  await this.provider.logout(accessToken);
105
114
  await this.credentials.delete(this.profile);
115
+ await this.cache?.delete(this.profile);
106
116
  }
107
117
  this.current = null;
108
118
  }
109
- async refresh() {
110
- const refreshToken = await this.credentials.get(this.profile);
111
- if (!refreshToken)
112
- throw new Error('尚未登录,请先运行 ai-short-studio auth login');
113
- this.current = await this.provider.refresh(refreshToken);
114
- await this.credentials.set(this.profile, this.current.refreshToken);
115
- return this.current.accessToken;
119
+ async refresh(force) {
120
+ const action = async () => {
121
+ const cached = await this.cache?.get(this.profile);
122
+ if (cached && isFresh(cached) && (!force || cached.accessToken !== this.lastReturnedAccessToken)) {
123
+ return this.remember(cached.accessToken);
124
+ }
125
+ const refreshToken = await this.credentials.get(this.profile);
126
+ if (!refreshToken)
127
+ throw new Error('尚未登录,请先运行 ai-short-studio auth login');
128
+ this.current = await this.provider.refresh(refreshToken);
129
+ if (this.current.refreshToken !== refreshToken) {
130
+ await this.credentials.set(this.profile, this.current.refreshToken);
131
+ }
132
+ await this.cache?.set(this.profile, this.current);
133
+ return this.remember(this.current.accessToken);
134
+ };
135
+ return this.cache ? this.cache.withRefreshLock(this.profile, action) : action();
136
+ }
137
+ remember(accessToken) {
138
+ this.lastReturnedAccessToken = accessToken;
139
+ return accessToken;
116
140
  }
117
141
  }
142
+ function isFresh(session) {
143
+ return session.expiresAt > Date.now() + 60_000;
144
+ }
@@ -8,3 +8,4 @@ export declare function commandApi(flags: Parameters<typeof createRuntime>[0]):
8
8
  client: import("./client/api-client.js").ApiClient;
9
9
  }>;
10
10
  export declare function readJsonInput(file?: string): Promise<Record<string, unknown>>;
11
+ export declare function mapConcurrent<T, R>(values: T[], concurrency: number, transform: (value: T, index: number) => Promise<R>): Promise<R[]>;
@@ -14,6 +14,18 @@ export async function readJsonInput(file) {
14
14
  throw new Error('输入必须是 JSON 对象');
15
15
  return value;
16
16
  }
17
+ export async function mapConcurrent(values, concurrency, transform) {
18
+ const results = new Array(values.length);
19
+ let cursor = 0;
20
+ const workers = Array.from({ length: Math.min(Math.max(1, concurrency), values.length) }, async () => {
21
+ while (cursor < values.length) {
22
+ const index = cursor++;
23
+ results[index] = await transform(values[index], index);
24
+ }
25
+ });
26
+ await Promise.all(workers);
27
+ return results;
28
+ }
17
29
  async function readStdin() {
18
30
  if (process.stdin.isTTY)
19
31
  throw new Error('请使用 --file 或通过 stdin 传入 JSON');
@@ -3,6 +3,8 @@ export default class CanvasApply extends BaseCommand {
3
3
  static flags: {
4
4
  project: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
5
5
  file: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
6
+ 'canvas-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
+ 'expected-version': import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
6
8
  'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
9
  json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
8
10
  locale: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
@@ -2,6 +2,24 @@ import { Flags } from '@oclif/core';
2
2
  import { BaseCommand } from '../../base-command.js';
3
3
  import { commandApi, projectFlag, readJsonInput } from '../../command-helpers.js';
4
4
  export default class CanvasApply extends BaseCommand {
5
- static flags = { ...BaseCommand.baseFlags, project: projectFlag, file: Flags.string({ description: 'patch JSON 文件;省略时读取 stdin' }) };
6
- async run() { const { flags } = await this.parse(CanvasApply); const { api } = await commandApi(flags); const canvas = await api.canvas(flags.project); this.print(await api.patchCanvas(flags.project, { ...(await readJsonInput(flags.file)), canvasId: canvas.id, expectedVersion: canvas.version })); }
5
+ static flags = {
6
+ ...BaseCommand.baseFlags,
7
+ project: projectFlag,
8
+ file: Flags.string({ description: 'patch JSON 文件;省略时读取 stdin' }),
9
+ 'canvas-id': Flags.string({ description: '已知画布 ID;与 expected-version 同时提供时跳过预读' }),
10
+ 'expected-version': Flags.integer({ description: '最近一次读取的画布版本;与 canvas-id 同时提供时跳过预读' }),
11
+ };
12
+ async run() {
13
+ const { flags } = await this.parse(CanvasApply);
14
+ const { api } = await commandApi(flags);
15
+ const input = await readJsonInput(flags.file);
16
+ const canvasId = flags['canvas-id'] ?? (typeof input.canvasId === 'string' ? input.canvasId : undefined);
17
+ const expectedVersion = flags['expected-version'] ?? (typeof input.expectedVersion === 'number' ? input.expectedVersion : undefined);
18
+ const canvas = canvasId && expectedVersion !== undefined ? null : await api.canvas(flags.project);
19
+ this.print(await api.patchCanvas(flags.project, {
20
+ ...input,
21
+ canvasId: canvasId ?? canvas.id,
22
+ expectedVersion: expectedVersion ?? canvas.version,
23
+ }));
24
+ }
7
25
  }
@@ -0,0 +1,19 @@
1
+ import { BaseCommand } from '../../base-command.js';
2
+ export default class CanvasInspect extends BaseCommand {
3
+ static description: string;
4
+ static flags: {
5
+ project: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
6
+ include: import("@oclif/core/interfaces").OptionFlag<string[], import("@oclif/core/interfaces").CustomOptions>;
7
+ kind: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ status: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
+ search: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ operation: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ 'resource-type': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ 'producer-node': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ 'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
+ locale: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
16
+ profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
+ };
18
+ run(): Promise<void>;
19
+ }
@@ -0,0 +1,36 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { BaseCommand } from '../../base-command.js';
3
+ import { commandApi, projectFlag } from '../../command-helpers.js';
4
+ const includeOptions = ['summary', 'nodes', 'edges', 'assets', 'settings', 'node-types'];
5
+ export default class CanvasInspect extends BaseCommand {
6
+ static description = '一次读取并筛选画布节点、连线、资产、设置与节点目录';
7
+ static flags = {
8
+ ...BaseCommand.baseFlags,
9
+ project: projectFlag,
10
+ include: Flags.string({
11
+ description: '返回内容,可重复指定',
12
+ multiple: true,
13
+ options: [...includeOptions],
14
+ default: ['summary', 'nodes', 'edges'],
15
+ }),
16
+ kind: Flags.string({ description: '节点 kind,可重复指定', multiple: true }),
17
+ status: Flags.string({ description: '节点状态,可重复指定', multiple: true }),
18
+ search: Flags.string({ description: '按节点 ID、标题、资源名称或 resourceKey 搜索' }),
19
+ operation: Flags.string({ description: '按 artifact.operation 筛选' }),
20
+ 'resource-type': Flags.string({ description: '按 artifact.resourceType 筛选' }),
21
+ 'producer-node': Flags.string({ description: '按 artifact.producerNodeId 筛选' }),
22
+ };
23
+ async run() {
24
+ const { flags } = await this.parse(CanvasInspect);
25
+ const { api } = await commandApi(flags);
26
+ this.print(await api.inspectCanvas(flags.project, {
27
+ includes: flags.include,
28
+ kinds: flags.kind,
29
+ statuses: flags.status,
30
+ search: flags.search,
31
+ operation: flags.operation,
32
+ resourceType: flags['resource-type'],
33
+ producerNodeId: flags['producer-node'],
34
+ }));
35
+ }
36
+ }
@@ -1,10 +1,12 @@
1
1
  import { BaseCommand } from '../../../base-command.js';
2
2
  export default class CanvasNodeRun extends BaseCommand {
3
3
  static args: {
4
- nodeId: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
4
+ nodeId: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
5
  };
6
6
  static flags: {
7
7
  project: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
+ node: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
+ concurrency: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
8
10
  wait: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
11
  interval: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
10
12
  'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
@@ -1,11 +1,37 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import { BaseCommand } from '../../../base-command.js';
3
- import { commandApi, projectFlag } from '../../../command-helpers.js';
3
+ import { commandApi, mapConcurrent, projectFlag } from '../../../command-helpers.js';
4
4
  export default class CanvasNodeRun extends BaseCommand {
5
- static args = { nodeId: Args.string({ required: true }) };
6
- static flags = { ...BaseCommand.baseFlags, project: projectFlag, wait: Flags.boolean({ default: false }), interval: Flags.integer({ default: 1000 }) };
7
- async run() { const { args, flags } = await this.parse(CanvasNodeRun); const { api } = await commandApi(flags); const submitted = await api.runNode(flags.project, args.nodeId); if (flags.wait && submitted.task?.id)
8
- this.print(await api.waitTask(submitted.task.id, flags.interval));
9
- else
10
- this.print(submitted); }
5
+ static args = { nodeId: Args.string({ required: false }) };
6
+ static flags = {
7
+ ...BaseCommand.baseFlags,
8
+ project: projectFlag,
9
+ node: Flags.string({ description: '节点 ID,可重复指定以批量运行', multiple: true }),
10
+ concurrency: Flags.integer({ description: '批量提交并发数', default: 4, min: 1, max: 16 }),
11
+ wait: Flags.boolean({ default: false }),
12
+ interval: Flags.integer({ default: 1000 }),
13
+ };
14
+ async run() {
15
+ const { args, flags } = await this.parse(CanvasNodeRun);
16
+ const nodeIds = [...new Set([...(args.nodeId ? [args.nodeId] : []), ...(flags.node ?? [])])];
17
+ if (!nodeIds.length)
18
+ throw new Error('请提供 nodeId 或至少一个 --node');
19
+ const { api } = await commandApi(flags);
20
+ const submissions = await mapConcurrent(nodeIds, flags.concurrency, async (nodeId) => ({
21
+ nodeId,
22
+ result: await api.runNode(flags.project, nodeId),
23
+ }));
24
+ if (!flags.wait) {
25
+ this.print(nodeIds.length === 1 ? submissions[0].result : { submissions });
26
+ return;
27
+ }
28
+ const tasks = submissions.filter((item) => item.result.task?.id);
29
+ const completed = await mapConcurrent(tasks, flags.concurrency, async (item) => ({
30
+ nodeId: item.nodeId,
31
+ task: await api.waitTask(item.result.task.id, flags.interval),
32
+ }));
33
+ this.print(nodeIds.length === 1 ? completed[0]?.task ?? submissions[0].result : { submissions, tasks: completed });
34
+ if (completed.some((item) => item.task.status !== 'completed'))
35
+ this.error('部分任务未成功完成', { exit: 8 });
36
+ }
11
37
  }
@@ -0,0 +1,13 @@
1
+ import { BaseCommand } from '../../../base-command.js';
2
+ export default class CanvasNodeUpdateMany extends BaseCommand {
3
+ static description: string;
4
+ static flags: {
5
+ project: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
6
+ file: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
+ 'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
+ locale: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
+ profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ };
12
+ run(): Promise<void>;
13
+ }
@@ -0,0 +1,48 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { BaseCommand } from '../../../base-command.js';
3
+ import { commandApi, projectFlag, readJsonInput } from '../../../command-helpers.js';
4
+ export default class CanvasNodeUpdateMany extends BaseCommand {
5
+ static description = '一次读取 schema 并原子更新多个节点字段';
6
+ static flags = {
7
+ ...BaseCommand.baseFlags,
8
+ project: projectFlag,
9
+ file: Flags.string({ description: '包含 updates 数组的 JSON 文件;省略时读取 stdin' }),
10
+ };
11
+ async run() {
12
+ const { flags } = await this.parse(CanvasNodeUpdateMany);
13
+ const input = await readJsonInput(flags.file);
14
+ if (!Array.isArray(input.updates))
15
+ throw new Error('输入必须包含 updates 数组');
16
+ const updates = input.updates.map(readUpdate);
17
+ const { api } = await commandApi(flags);
18
+ this.print(await api.updateNodes(flags.project, updates));
19
+ }
20
+ }
21
+ function readUpdate(value) {
22
+ if (!value || typeof value !== 'object' || Array.isArray(value))
23
+ throw new Error('updates 每一项必须是对象');
24
+ const update = value;
25
+ if (typeof update.nodeId !== 'string' || !update.nodeId)
26
+ throw new Error('updates.nodeId 不能为空');
27
+ if (update.set !== undefined && (!update.set || typeof update.set !== 'object' || Array.isArray(update.set))) {
28
+ throw new Error(`节点 ${update.nodeId} 的 set 必须是对象`);
29
+ }
30
+ if (update.unset !== undefined && (!Array.isArray(update.unset) || update.unset.some((field) => typeof field !== 'string'))) {
31
+ throw new Error(`节点 ${update.nodeId} 的 unset 必须是字符串数组`);
32
+ }
33
+ for (const coordinate of ['x', 'y']) {
34
+ if (update[coordinate] !== undefined && (typeof update[coordinate] !== 'number' || !Number.isFinite(update[coordinate]))) {
35
+ throw new Error(`节点 ${update.nodeId} 的 ${coordinate} 必须是数字`);
36
+ }
37
+ }
38
+ if (update.title !== undefined && typeof update.title !== 'string')
39
+ throw new Error(`节点 ${update.nodeId} 的 title 必须是字符串`);
40
+ return {
41
+ nodeId: update.nodeId,
42
+ set: update.set,
43
+ unset: update.unset,
44
+ title: update.title,
45
+ x: update.x,
46
+ y: update.y,
47
+ };
48
+ }
@@ -6,6 +6,11 @@ export default class CanvasNodeUpdate extends BaseCommand {
6
6
  static flags: {
7
7
  project: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
8
  config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
+ set: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ unset: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
+ title: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ x: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ y: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
14
  'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
15
  json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
16
  locale: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
@@ -3,6 +3,50 @@ import { BaseCommand } from '../../../base-command.js';
3
3
  import { commandApi, projectFlag, readJsonInput } from '../../../command-helpers.js';
4
4
  export default class CanvasNodeUpdate extends BaseCommand {
5
5
  static args = { nodeId: Args.string({ required: true }) };
6
- static flags = { ...BaseCommand.baseFlags, project: projectFlag, config: Flags.string({ description: '配置 JSON 文件;省略时读取 stdin' }) };
7
- async run() { const { args, flags } = await this.parse(CanvasNodeUpdate); const { api } = await commandApi(flags); this.print(await api.updateNode(flags.project, args.nodeId, await readJsonInput(flags.config))); }
6
+ static flags = {
7
+ ...BaseCommand.baseFlags,
8
+ project: projectFlag,
9
+ config: Flags.string({ description: '配置 patch JSON 文件;无字段参数时省略可读取 stdin' }),
10
+ set: Flags.string({ description: '设置配置字段:key=<JSON>,可重复指定', multiple: true }),
11
+ unset: Flags.string({ description: '删除可选配置字段,可重复指定', multiple: true }),
12
+ title: Flags.string({ description: '修改节点标题' }),
13
+ x: Flags.integer({ description: '修改节点横坐标' }),
14
+ y: Flags.integer({ description: '修改节点纵坐标' }),
15
+ };
16
+ async run() {
17
+ const { args, flags } = await this.parse(CanvasNodeUpdate);
18
+ const hasFieldFlags = Boolean(flags.set?.length || flags.unset?.length || flags.title !== undefined || flags.x !== undefined || flags.y !== undefined);
19
+ const filePatch = flags.config || !hasFieldFlags ? await readJsonInput(flags.config) : {};
20
+ const set = { ...filePatch, ...parseAssignments(flags.set ?? []) };
21
+ const unset = flags.unset ?? [];
22
+ const overlap = unset.find((key) => Object.prototype.hasOwnProperty.call(set, key));
23
+ if (overlap)
24
+ throw new Error(`字段不能同时 set 和 unset:${overlap}`);
25
+ const { api } = await commandApi(flags);
26
+ this.print(await api.updateNodeFields(flags.project, {
27
+ nodeId: args.nodeId,
28
+ set,
29
+ unset,
30
+ title: flags.title,
31
+ x: flags.x,
32
+ y: flags.y,
33
+ }));
34
+ }
35
+ }
36
+ function parseAssignments(assignments) {
37
+ return Object.fromEntries(assignments.map((assignment) => {
38
+ const separator = assignment.indexOf('=');
39
+ if (separator < 1)
40
+ throw new Error(`--set 必须使用 key=<JSON>:${assignment}`);
41
+ const key = assignment.slice(0, separator).trim();
42
+ const source = assignment.slice(separator + 1);
43
+ if (!key)
44
+ throw new Error('--set 字段名不能为空');
45
+ try {
46
+ return [key, JSON.parse(source)];
47
+ }
48
+ catch {
49
+ return [key, source];
50
+ }
51
+ }));
8
52
  }
@@ -1,9 +1,11 @@
1
1
  import { BaseCommand } from '../../base-command.js';
2
2
  export default class TaskWait extends BaseCommand {
3
3
  static args: {
4
- taskId: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
4
+ taskId: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
5
  };
6
6
  static flags: {
7
+ task: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ concurrency: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
7
9
  interval: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
8
10
  'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
11
  json: import("@oclif/core/interfaces").BooleanFlag<boolean>;
@@ -1,9 +1,23 @@
1
1
  import { Args, Flags } from '@oclif/core';
2
2
  import { BaseCommand } from '../../base-command.js';
3
- import { commandApi } from '../../command-helpers.js';
3
+ import { commandApi, mapConcurrent } from '../../command-helpers.js';
4
4
  export default class TaskWait extends BaseCommand {
5
- static args = { taskId: Args.string({ required: true }) };
6
- static flags = { ...BaseCommand.baseFlags, interval: Flags.integer({ default: 1000 }) };
7
- async run() { const { args, flags } = await this.parse(TaskWait); const { api } = await commandApi(flags); const task = await api.waitTask(args.taskId, flags.interval); this.print(task); if (task.status !== 'completed')
8
- this.error(`任务结束:${task.status}`, { exit: 8 }); }
5
+ static args = { taskId: Args.string({ required: false }) };
6
+ static flags = {
7
+ ...BaseCommand.baseFlags,
8
+ task: Flags.string({ description: '任务 ID,可重复指定以批量等待', multiple: true }),
9
+ concurrency: Flags.integer({ description: '批量轮询并发数', default: 4, min: 1, max: 16 }),
10
+ interval: Flags.integer({ default: 1000 }),
11
+ };
12
+ async run() {
13
+ const { args, flags } = await this.parse(TaskWait);
14
+ const taskIds = [...new Set([...(args.taskId ? [args.taskId] : []), ...(flags.task ?? [])])];
15
+ if (!taskIds.length)
16
+ throw new Error('请提供 taskId 或至少一个 --task');
17
+ const { api } = await commandApi(flags);
18
+ const tasks = await mapConcurrent(taskIds, flags.concurrency, async (taskId) => await api.waitTask(taskId, flags.interval));
19
+ this.print(tasks.length === 1 ? tasks[0] : { tasks });
20
+ if (tasks.some((task) => task.status !== 'completed'))
21
+ this.error('部分任务未成功完成', { exit: 8 });
22
+ }
9
23
  }
@@ -5,6 +5,7 @@ export interface CliProfile {
5
5
  supabaseUrl?: string;
6
6
  supabasePublishableKey?: string;
7
7
  }
8
+ export declare function defaultConfigDirectory(environment: NodeJS.ProcessEnv): string;
8
9
  export declare class ProfileStore {
9
10
  readonly path: string;
10
11
  constructor(directory?: string);
@@ -1,7 +1,7 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
4
- function defaultConfigDirectory(environment) {
4
+ export function defaultConfigDirectory(environment) {
5
5
  if (environment.VVICAT_CONFIG_DIR)
6
6
  return environment.VVICAT_CONFIG_DIR;
7
7
  if (process.platform === 'win32')
@@ -28,6 +28,23 @@ interface NodeType extends JsonObject {
28
28
  defaultConfig: JsonObject;
29
29
  configSchema: JsonObject;
30
30
  }
31
+ export interface CanvasInspectOptions {
32
+ includes: Array<'summary' | 'nodes' | 'edges' | 'assets' | 'settings' | 'node-types'>;
33
+ kinds?: string[];
34
+ statuses?: string[];
35
+ search?: string;
36
+ operation?: string;
37
+ resourceType?: string;
38
+ producerNodeId?: string;
39
+ }
40
+ export interface NodeFieldUpdate {
41
+ nodeId: string;
42
+ set?: JsonObject;
43
+ unset?: string[];
44
+ title?: string;
45
+ x?: number;
46
+ y?: number;
47
+ }
31
48
  export declare class VvicatApi {
32
49
  private readonly client;
33
50
  private readonly sleep;
@@ -55,6 +72,7 @@ export declare class VvicatApi {
55
72
  listNodes(projectId: string): Promise<CanvasNode[]>;
56
73
  node(projectId: string, nodeId: string): Promise<CanvasNode>;
57
74
  listEdges(projectId: string): Promise<JsonObject[]>;
75
+ inspectCanvas(projectId: string, options: CanvasInspectOptions): Promise<JsonObject>;
58
76
  continuityAnalysis(projectId: string): Promise<JsonObject>;
59
77
  applyContinuity(projectId: string, input: {
60
78
  expectedVersion: number;
@@ -77,6 +95,8 @@ export declare class VvicatApi {
77
95
  config?: JsonObject;
78
96
  }): Promise<JsonObject>;
79
97
  updateNode(projectId: string, nodeId: string, config: JsonObject): Promise<JsonObject>;
98
+ updateNodeFields(projectId: string, update: NodeFieldUpdate): Promise<JsonObject>;
99
+ updateNodes(projectId: string, updates: NodeFieldUpdate[]): Promise<JsonObject>;
80
100
  deleteNode(projectId: string, nodeId: string): Promise<JsonObject>;
81
101
  connect(projectId: string, input: {
82
102
  sourceNodeId: string;
@@ -49,6 +49,15 @@ function validateConfigSchema(nodeType, config, requiredValues = config ?? {}) {
49
49
  }
50
50
  }
51
51
  }
52
+ function validateUnsetConfigFields(nodeType, fields) {
53
+ const properties = nodeType.configSchema?.properties;
54
+ if (!properties || typeof properties !== 'object' || Array.isArray(properties))
55
+ return;
56
+ for (const field of fields) {
57
+ if (!(field in properties))
58
+ throw new Error(`节点 ${nodeType.kind} 的 ${field} 不在实时 schema 中`);
59
+ }
60
+ }
52
61
  export class VvicatApi {
53
62
  client;
54
63
  sleep;
@@ -111,6 +120,29 @@ export class VvicatApi {
111
120
  async listEdges(projectId) {
112
121
  return (await this.canvas(projectId)).edges;
113
122
  }
123
+ async inspectCanvas(projectId, options) {
124
+ const canvas = await this.canvas(projectId);
125
+ const nodes = canvas.nodes.filter((node) => matchesInspectNode(node, options));
126
+ const nodeIds = new Set(nodes.map((node) => node.id));
127
+ const filtered = hasInspectFilter(options);
128
+ const edges = filtered
129
+ ? canvas.edges.filter((edge) => nodeIds.has(String(edge.sourceNodeId)) || nodeIds.has(String(edge.targetNodeId)))
130
+ : canvas.edges;
131
+ const [assets, settings, nodeTypes] = await Promise.all([
132
+ options.includes.includes('assets') ? this.listAssets(projectId) : undefined,
133
+ options.includes.includes('settings') ? this.canvasSettings(projectId) : undefined,
134
+ options.includes.includes('node-types') ? this.nodeTypes() : undefined,
135
+ ]);
136
+ return {
137
+ canvas: { id: canvas.id, version: canvas.version, viewport: canvas.viewport },
138
+ ...(options.includes.includes('summary') ? { summary: canvasSummary(canvas, nodes) } : {}),
139
+ ...(options.includes.includes('nodes') ? { nodes } : {}),
140
+ ...(options.includes.includes('edges') ? { edges } : {}),
141
+ ...(assets === undefined ? {} : { assets }),
142
+ ...(settings === undefined ? {} : { settings }),
143
+ ...(nodeTypes === undefined ? {} : { nodeTypes }),
144
+ };
145
+ }
114
146
  async continuityAnalysis(projectId) {
115
147
  const payload = await this.client.request(`/api/projects/${projectId}/canvas/continuity`);
116
148
  return payload.data.analysis;
@@ -163,22 +195,48 @@ export class VvicatApi {
163
195
  });
164
196
  }
165
197
  async updateNode(projectId, nodeId, config) {
166
- const canvas = await this.canvas(projectId);
167
- const node = canvas.nodes.find((item) => item.id === nodeId);
168
- if (!node)
169
- throw new Error(`节点不存在:${nodeId}`);
170
- const catalog = await this.nodeTypes();
171
- const definitionVersionId = typeof node.data.definitionVersionId === 'string' ? node.data.definitionVersionId : null;
172
- const definition = catalog.nodeTypes.find((item) => item.kind === node.kind)
173
- ?? (definitionVersionId ? canvas.nodeTypesByVersionId?.[definitionVersionId] : undefined);
174
- if (!definition)
175
- throw new Error(`未知节点类型:${node.kind}`);
176
- const nextConfig = { ...node.data.config, ...config };
177
- validateConfigSchema(definition, config, nextConfig);
198
+ return this.updateNodeFields(projectId, { nodeId, set: config });
199
+ }
200
+ async updateNodeFields(projectId, update) {
201
+ return this.updateNodes(projectId, [update]);
202
+ }
203
+ async updateNodes(projectId, updates) {
204
+ if (!updates.length)
205
+ throw new Error('updates 不能为空');
206
+ if (new Set(updates.map((update) => update.nodeId)).size !== updates.length)
207
+ throw new Error('同一节点不能在一个批次中重复更新');
208
+ const [canvas, catalog] = await Promise.all([this.canvas(projectId), this.nodeTypes()]);
209
+ const upsertNodes = updates.map((update) => {
210
+ const node = canvas.nodes.find((item) => item.id === update.nodeId);
211
+ if (!node)
212
+ throw new Error(`节点不存在:${update.nodeId}`);
213
+ const definitionVersionId = typeof node.data.definitionVersionId === 'string' ? node.data.definitionVersionId : null;
214
+ const definition = catalog.nodeTypes.find((item) => item.kind === node.kind)
215
+ ?? (definitionVersionId ? canvas.nodeTypesByVersionId?.[definitionVersionId] : undefined);
216
+ if (!definition)
217
+ throw new Error(`未知节点类型:${node.kind}`);
218
+ const configPatch = update.set ?? {};
219
+ const unsetFields = update.unset ?? [];
220
+ const overlappingFields = unsetFields.filter((key) => Object.prototype.hasOwnProperty.call(configPatch, key));
221
+ if (overlappingFields.length)
222
+ throw new Error(`字段不能同时设置和移除:${overlappingFields.join(', ')}`);
223
+ const nextConfig = { ...node.data.config, ...configPatch };
224
+ for (const key of unsetFields)
225
+ delete nextConfig[key];
226
+ validateUnsetConfigFields(definition, unsetFields);
227
+ validateConfigSchema(definition, configPatch, nextConfig);
228
+ return {
229
+ ...node,
230
+ ...(update.title === undefined ? {} : { title: update.title }),
231
+ ...(update.x === undefined ? {} : { x: update.x }),
232
+ ...(update.y === undefined ? {} : { y: update.y }),
233
+ data: { ...node.data, config: nextConfig },
234
+ };
235
+ });
178
236
  return this.patchCanvas(projectId, {
179
237
  canvasId: canvas.id,
180
238
  expectedVersion: canvas.version,
181
- upsertNodes: [{ ...node, data: { ...node.data, config: nextConfig } }],
239
+ upsertNodes,
182
240
  });
183
241
  }
184
242
  async deleteNode(projectId, nodeId) {
@@ -247,3 +305,48 @@ export class VvicatApi {
247
305
  return this.client.request(path, options);
248
306
  }
249
307
  }
308
+ function matchesInspectNode(node, options) {
309
+ const artifact = node.data.artifact && typeof node.data.artifact === 'object' && !Array.isArray(node.data.artifact)
310
+ ? node.data.artifact
311
+ : {};
312
+ if (options.kinds?.length && !options.kinds.includes(node.kind))
313
+ return false;
314
+ if (options.statuses?.length && !options.statuses.includes(String(node.status ?? 'idle')))
315
+ return false;
316
+ if (options.operation && artifact.operation !== options.operation)
317
+ return false;
318
+ if (options.resourceType && artifact.resourceType !== options.resourceType)
319
+ return false;
320
+ if (options.producerNodeId && artifact.producerNodeId !== options.producerNodeId)
321
+ return false;
322
+ if (options.search) {
323
+ const query = options.search.toLocaleLowerCase();
324
+ const candidates = [node.id, node.title, artifact.name, artifact.resourceKey];
325
+ if (!candidates.some((value) => typeof value === 'string' && value.toLocaleLowerCase().includes(query)))
326
+ return false;
327
+ }
328
+ return true;
329
+ }
330
+ function hasInspectFilter(options) {
331
+ return Boolean(options.kinds?.length || options.statuses?.length || options.search || options.operation || options.resourceType || options.producerNodeId);
332
+ }
333
+ function canvasSummary(canvas, matchedNodes) {
334
+ const nodesByKind = {};
335
+ const nodesByStatus = {};
336
+ for (const node of canvas.nodes) {
337
+ nodesByKind[node.kind] = (nodesByKind[node.kind] ?? 0) + 1;
338
+ const status = String(node.status ?? 'idle');
339
+ nodesByStatus[status] = (nodesByStatus[status] ?? 0) + 1;
340
+ }
341
+ const nodeIds = new Set(canvas.nodes.map((node) => node.id));
342
+ return {
343
+ nodeCount: canvas.nodes.length,
344
+ matchedNodeCount: matchedNodes.length,
345
+ edgeCount: canvas.edges.length,
346
+ danglingEdgeIds: canvas.edges
347
+ .filter((edge) => !nodeIds.has(String(edge.sourceNodeId)) || !nodeIds.has(String(edge.targetNodeId)))
348
+ .map((edge) => edge.id),
349
+ nodesByKind,
350
+ nodesByStatus,
351
+ };
352
+ }
package/dist/runtime.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { ApiClient } from './client/api-client.js';
2
+ import { join } from 'node:path';
2
3
  import { KeyringCredentialStore } from './auth/credential-store.js';
3
4
  import { SessionManager, SupabaseAuthProvider } from './auth/session-manager.js';
4
- import { ProfileStore } from './config/profiles.js';
5
+ import { FileAccessTokenCache } from './auth/session-cache.js';
6
+ import { defaultConfigDirectory, ProfileStore } from './config/profiles.js';
5
7
  import { fetchBootstrap } from './preflight.js';
6
8
  export const DEFAULT_BASE_URL = 'https://ai-short-studio.vvicat.dev';
7
9
  export async function createRuntime(flags) {
@@ -28,6 +30,7 @@ export async function createRuntime(flags) {
28
30
  const session = new SessionManager({
29
31
  profile: profile.name,
30
32
  credentials: new KeyringCredentialStore(),
33
+ cache: new FileAccessTokenCache(join(defaultConfigDirectory(process.env), 'sessions')),
31
34
  provider: new SupabaseAuthProvider(profile.supabaseUrl, profile.supabasePublishableKey),
32
35
  });
33
36
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aswless_854771076/ai_short_studio_cli",
3
- "version": "0.1.44",
3
+ "version": "0.1.45",
4
4
  "description": "VVICAT 无限画布项目命令行工具",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -37,6 +37,7 @@
37
37
  "release:ci": "npm test && npm run typecheck && node scripts/publish.mjs --tag latest --verify",
38
38
  "release": "node scripts/publish.mjs --tag latest",
39
39
  "test": "vitest run",
40
+ "test:local-e2e": "npm run build && node scripts/local-e2e.mjs",
40
41
  "typecheck": "tsc -p tsconfig.json --noEmit"
41
42
  },
42
43
  "dependencies": {
@@ -9,7 +9,9 @@ description: Use when an agent needs to inspect or operate VVICAT infinite-canva
9
9
 
10
10
  把 `ai-short-studio` 当作机器可读的远程控制接口。创作全程只要目标、素材含义、平台、语言、画幅、时长、风格、人物、场景、道具、模型、节点 schema、候选版本、付费范围或覆盖影响存在未知、缺失或矛盾,就合并列出最少必要问题并询问用户;得到答复前只做只读检查,不用示例值或自行推断替用户做创作决定。本文明确规定的 `16:9` 素材画幅和 `5/10/15` 秒视频生成时长是标准流程默认值,用户未另行指定时可直接采用并在执行摘要中说明,不必为这两项单独阻塞。新建或继续任何无限画布项目时,项目配置是创作节点之前的硬门禁:配置写入并回读验证通过前,不得创建或执行任何创作节点。
11
11
 
12
- 同一阶段存在两个及以上可合并的写操作时,默认先使用 CLI 已提供的批量入口:项目配置用一次 `canvas settings set --file` 或 stdin,节点、连线、删除和视口等同一画布补丁用一次 `canvas apply --file` 或 stdin。批量提交前读取一次最新画布与实时 schema,构造完整预期差异;成功后统一回读审计。批量命令不存在、当前服务不支持,或合法批量请求在处理批量粒度时失败,才回退到对应的 `canvas node` / `canvas edge` 单条命令,并只处理尚未生效的项。409 先重读、合并并确认后继续重试批量;401/403、参数、schema、业务门禁或付费确认错误必须停下修正,不得用单条命令绕过。`canvas node run`、`task wait`、资产下载等没有批量入口的操作直接使用单条命令,其中互不依赖的任务并发提交或等待。
12
+ 同一阶段存在两个及以上可合并的操作时,默认使用一个 CLI 进程完成批处理:项目配置用一次 `canvas settings set --file` 或 stdin,节点字段更新用 `canvas node update-many`,节点、连线、删除和视口等同一画布补丁用一次 `canvas apply --file` 或 stdin,互不依赖的节点执行用一次 `canvas node run --node … --node …`,多个任务等待用一次 `task wait --task … --task …`。批量执行与等待默认并发 4、上限 16;不要由 Agent 并发启动多个 CLI 进程。这样可复用同一会话、降低请求开销,并避免频繁触发 macOS Keychain。批量提交前读取一次最新画布与实时 schema,构造完整预期差异;`canvas apply` 同时传入已读到的 `--canvas-id` 和 `--expected-version`,避免保存前再次读取全图。成功后统一回读审计。批量命令不存在、当前服务不支持,或合法批量请求在处理批量粒度时失败,才回退到对应的单条命令,并只处理尚未生效的项。409 先重读、合并并确认后继续重试批量;401/403、参数、schema、业务门禁或付费确认错误必须停下修正,不得用单条命令绕过。
13
+
14
+ 常用画布查询优先使用一次 `canvas inspect`,按需重复 `--include summary|nodes|edges|assets|settings|node-types`,并通过 `--kind`、`--status`、`--search`、`--operation`、`--resource-type`、`--producer-node` 过滤。能由这些参数表达的查询不得临时编写 TypeScript、Python、jq 或 shell 数据处理脚本;只有 `inspect` 无法表达且现有专用命令也不满足时才允许临时脚本,并在结束后清理。
13
15
 
14
16
  ## 初始化检查
15
17
 
@@ -19,7 +21,7 @@ description: Use when an agent needs to inspect or operate VVICAT infinite-canva
19
21
  2. 未登录且已有账号时执行 `ai-short-studio auth login --google` 或邮箱登录;没有账号且用户明确要求注册时,先核对 profile 的 `baseUrl` 确实指向目标环境,再使用 `auth signup --email <email> --password-stdin`。CLI 会从服务端取得 Supabase 公共配置,并把同域的 `/{locale}/auth/callback` 显式设为注册确认回调。注册返回 `confirmationRequired: true` 时,等待用户完成邮件验证后再登录。登录后重跑 preflight。`ready` 为 false 时,把 `missing` 一次性告知用户并询问如何补齐;新配置模型首选公司的 [StarRouter](https://starrouter.io/),账号开通联系邱茂。
20
22
  3. 运行账户级只读命令 `ai-short-studio config get --json`,取得账户默认模型及可用 provider。创建项目至少要求已配置 `defaultModels.analysisModel`;缺失时 `project create` 会在发送创建请求前停止,需先由用户在设置中心完成配置。此步骤只用于计算继承后的有效模型,不得用 `config set` 改账户配置来完成项目配置。
21
23
  4. 运行 `ai-short-studio canvas node types --json`,按实时 `inputSchema`、`outputSchema`、`configSchema` 和 `defaultConfig` 确定能力与必填项。
22
- 5. 运行 `project list` 判断目标是新项目还是既有项目。新项目先确认账户默认分析模型已配置,再按“确认名称和用途 → `project create` → `project get` + `canvas get` + `canvas settings get`”继续;未通过模型审查时不得尝试创建。既有项目直接运行这三个读取命令。它们只用于确认 `INFINITE_CANVAS`、项目配置、现有节点、资产 selected version 和本次创作参数;不得要求尚未创建的新项目先读取项目或画布。任何缺口仍按核心原则询问。
24
+ 5. 运行 `project list` 判断目标是新项目还是既有项目。新项目先确认账户默认分析模型已配置,再按“确认名称和用途 → `project create` → `project get` + 一次 `canvas inspect --include summary --include nodes --include edges --include assets --include settings --include node-types`”继续;未通过模型审查时不得尝试创建。既有项目同样用 `project get` 加一次聚合查询取得画布、项目配置、节点目录和资产。只有聚合结果缺少专用命令独有字段时才补充单项读取,不得把这些查询拆成一串 CLI 进程;也不得要求尚未创建的新项目先读取项目或画布。任何缺口仍按核心原则询问。
23
25
  6. 运行 `ai-short-studio skill status --target codex --json`;非 current 时询问后更新,用户修改或非托管目录不得擅自 `--force` 覆盖。
24
26
  7. Meme 流程需要外部背景时,运行 `project provider get <projectId> --json` 检查 Pexels 状态;只在用户明确提供 Key 并授权写入后,才通过安全 stdin 执行 `project provider set`,随后运行 `project provider test`。
25
27
 
@@ -62,7 +64,7 @@ description: Use when an agent needs to inspect or operate VVICAT infinite-canva
62
64
  用户明确要求专业 Markdown 剧本拆镜测试时,给 `storyboard-breakdown` 显式配置 `storyboardPipelineVersion: 2`、`sourceFormat: screenplay-markdown.v1`、目标集的 `targetLanguage: en|ar`、`platform: TikTok` 和 `aspectRatio: 9:16`,且只能连接一条剧本。该模式只验收分镜文本和镜头资产:声音只记录音色设定,不执行 `voice-design`;不执行 TTS、图片、音频或视频节点。任务成功后确认来源与逐字校验通过且没有新增 IMAGE/AUDIO/VIDEO 版本;任务失败时确认没有新增分镜版本或镜头。
63
65
  5. 生成分镜图前,必须先通过“分镜素材连线门禁”。再用 `canvas asset get/list` 取得人物、场景、道具等已选版本,必要时下载,并把实际图片或可访问预览展示给用户;只提供资产 ID、文件路径或文字说明不算展示。用户明确确认素材与选版后,读取 `storyboard-image` 的实时 `configSchema`,用户未指定画幅且 schema 支持时将 `aspectRatio: 16:9` 显式写入节点,再分析每个镜头的动作阶段、运镜变化、多人调度和连续性难度,选择 schema 允许的最小够用格数:简单镜头建议 4 格,常规镜头使用默认 6 格,复杂镜头建议 9 格,16/25 格只用于用户明确需要的长动作或高密度关键帧。向用户展示逐镜建议并确认后,将各自 `gridSize` 写入对应节点;旧服务 schema 未暴露字段时不得虚假写入,应说明限制并回退到项目有效值,`gridSize` 未另行确认时为 6 格。连续叙事优先建议 `imageLayout: storyboard`,同时说明 `grid` 与 `single` 可选,最终由用户确认布局、镜头范围、参考资产、模型与费用范围。未展示、未确认或连线门禁未通过时禁止生成分镜图。
64
66
  6. 执行分镜图后按“生成后自我审计”检查真实产物,并展示给用户确认。新分镜直接读取 `independent` / `previous-tail`,但该字段只作为建议:服务端不自动创建尾帧节点或连线,也不阻止后镜独立生成。需要严格连续性时,在用户确认后显式执行 `--apply-ready`。之后复用自动创建的 `storyboard-video-prompt`,按实际布局重写视频提示词并确认后执行 `video-generate`。镜头建议时长按正常剧情节拍、口播和动作确定,首尾保留约 1–2 秒剪辑余量;不为命中全片总时长机械扩镜。执行时按当前模型合法时长档位换算,保留原建议时长供剪辑裁切。未展示或未确认分镜图时禁止生成视频。
65
- 7. 配置门禁固定用一次 `canvas settings set` 写入确认字段;通过门禁后的同阶段节点与连线变更优先合并为一次 `canvas apply`,批量不可用或按“核心原则”处理后仍失败时才回退专用 node/edge 单条命令。每次执行都读取真实任务 ID `task wait`,不用固定 sleep
67
+ 7. 配置门禁固定用一次 `canvas settings set` 写入确认字段;通过门禁后的同阶段节点与连线变更优先合并为一次 `canvas apply`。只改节点 config、标题或坐标时,单节点使用 `canvas node update --set/--unset/--title/--x/--y`,多节点使用一次 `canvas node update-many`;不能借此编辑结构化 artifact。互不依赖的节点通过一次 `canvas node run` 重复传 `--node`,需要立即等待时加 `--wait`;已经取得多个任务 ID 时通过一次 `task wait` 重复传 `--task`,不用固定 sleep。批量不可用或按“核心原则”处理后仍失败时才回退专用单条命令。
66
68
  8. 每批图片、视频或音频生成完成后必须先通过“生成后自我审计”,才能报告成功、请求选版或进入下一付费阶段。在进入视频生成前,必须额外依次执行:(a)“镜头级素材审计”全量清单,核对 reference 集合覆盖镜中人物/场景/道具、无跨类型错边、成对资源引用正确版本、stale 清零、道具词扫 description 兜底、资源文字设定贯穿到 shot/image;(b)“上下镜连续性审计”,按时序逐对相邻镜核对人物位置、道具状态、光影色调、动势视线、景别节奏、叙事桥接,并给有动作连续关系的镜组互加 reference、给极端特写/物证镜补入必要的人物 reference;(c)“分镜逻辑审计”,对照叙事期望逐项扫掉关键词误命中、真假道具互串、跨段同名实体误连、黑场/空镜多余连线、多版本角色(成年/童年)误连、龙套角色引用主角资源等问题,输出逐镜审计表并精确删/加边直到 wired=plan;(d) 把图像模型的已知先验 bias(数字、具象动物、实体面具、中文字样等)列入视频阶段 prompt 的 negative 约束。交付前再次确认 selected version 与下载目录,记录项目、节点、任务和资产 ID、审计结论、失败项与未决项。临时项目在所有结束路径删除;既有项目绝不擅自删除。
67
69
 
68
70
  ## 分镜素材连线门禁
@@ -195,6 +197,7 @@ description: Use when an agent needs to inspect or operate VVICAT infinite-canva
195
197
  ## 安全边界
196
198
 
197
199
  - 不把密码、access token、refresh token 或 API Key 放入参数、日志、Skill 或仓库。
200
+ - refresh token 只保存在系统钥匙串;CLI 自动维护权限为 `0600` 的短期 access-token session 缓存和跨进程刷新锁。不要手工读取、复制或删除缓存来规避认证;正常退出使用 `auth logout`,CI 使用秘密管理器注入 `VVICAT_ACCESS_TOKEN`。批量操作保持单进程,避免无意义地竞争钥匙串。
198
201
  - 密码登录使用 `printf`/管道以外的安全 stdin 来源;自动化环境使用秘密管理器注入 stdin。
199
202
  - 邮箱注册同样只通过交互式隐藏输入或安全 stdin 传递密码,不把密码放入命令参数、日志或仓库。
200
203
  - 用户已在当前会话明确提供密码时,可将其直接写入正在等待的交互式隐藏密码提示;不得复述密码、拼入 shell 命令、写入临时文件或跨会话保存。
@@ -202,7 +205,7 @@ description: Use when an agent needs to inspect or operate VVICAT infinite-canva
202
205
  - `config get` 只应返回 `hasApiKey`,不得依赖或要求服务端回显密钥。
203
206
  - `project provider get` 只返回配置状态。`project provider set` 的 JSON 必须来自安全 stdin 或不入库的临时秘密文件,禁止把 Key 写进命令参数、仓库文件或会回显的 shell 历史。
204
207
  - 删除用户既有项目、覆盖完整画布或修改模型配置前,先明确目标和影响;没有授权就停在读取或生成变更计划。
205
- - `canvas apply` 只用于节点、连线、删除、视口等低层图补丁,不能代替 `canvas settings set`。同阶段有多个图变更时优先一次批量提交;批量失败按“核心原则”分类处理,不能通过逐条重试绕过权限、schema、业务门禁或付费确认。CLI 保存显式请求完整画布响应;正常保存不额外预读全图,遇到 409 才重新读取、合并并确认后重试。项目配置遇到 409 时也必须重新读取和确认。
208
+ - `canvas apply` 只用于节点、连线、删除、视口等低层图补丁,不能代替 `canvas settings set`。同阶段有多个图变更时优先一次批量提交;已有画布快照时必须传 `--canvas-id` 和 `--expected-version`,不让 CLI 再预读全图。批量失败按“核心原则”分类处理,不能通过逐条重试绕过权限、schema、业务门禁或付费确认。遇到 409 才重新读取、合并并确认后重试。项目配置遇到 409 时也必须重新读取和确认。
206
209
  - 编辑 `storyboard-shot` 必须使用 `canvas node edit-shot`。完整 JSON 可包含 `photographyPlan` 对象和 `actingNotes` 对象或数组;它们与其他镜头字段一起追加不可变版本、固定选版并把下游标记为 stale。命令不允许修改 `shotKey`、`shotIndex` 或来源身份;409 后重新读取镜头版本再确认,不能覆盖历史版本。
207
210
  - 编辑结构化生成资源必须使用 `canvas node edit-resource`,不得用 `canvas node update` 或 `canvas apply` 改写服务端 artifact。名称、资源键、ID 和所属关系等身份字段不可修改;缺少稳定 `operation/resourceKey/resourceType` 的旧节点保持只读。资源版本写入具有独立并发基线,409 后重新读取画布与当前选版并确认;兄弟资源节点不得被同步切版。
208
211
  - 手工提取视频首帧或尾帧时先从实时目录确认 `video-frame-extract`,配置 `{"position":"first"}` 或 `{"position":"last"}`。新规划镜头的连续拓扑由服务端自动协调;没有 `continuity` 的历史镜头仍可使用只读启发式分析。创建连续拓扑不执行模型、不付费,运行视频前仍须确认。
@@ -218,7 +221,12 @@ ai-short-studio canvas settings set --project "$PROJECT_ID" --field artStyle --v
218
221
  ai-short-studio canvas settings set --project "$PROJECT_ID" --field imageResolution --value 2K --json
219
222
  ai-short-studio canvas settings set --project "$PROJECT_ID" --file settings.json --json
220
223
  ai-short-studio canvas node add text --project "$PROJECT_ID" --config ./node.json --json
221
- ai-short-studio canvas node list --project "$PROJECT_ID" --json
224
+ ai-short-studio canvas inspect --project "$PROJECT_ID" --include summary --include nodes --include edges --include assets --include settings --include node-types --json
225
+ ai-short-studio canvas inspect --project "$PROJECT_ID" --kind storyboard-image --status stale --json
226
+ ai-short-studio canvas node update "$NODE_ID" --project "$PROJECT_ID" --set 'gridSize=9' --unset legacyField --json
227
+ ai-short-studio canvas node update-many --project "$PROJECT_ID" --file updates.json --json
228
+ ai-short-studio canvas node run --project "$PROJECT_ID" --node "$NODE_1" --node "$NODE_2" --concurrency 4 --wait --json
229
+ ai-short-studio task wait --task "$TASK_1" --task "$TASK_2" --concurrency 4 --json
222
230
  ```
223
231
 
224
232
  从 JSON 输出中取得真实 ID;示例中的环境变量仅表示调用方已安全解析并保存结果。
@@ -233,6 +241,9 @@ ai-short-studio canvas node list --project "$PROJECT_ID" --json
233
241
  - 注册成功就假定已登录:`confirmationRequired: true` 表示需要先完成邮件验证,再执行 `auth login`。
234
242
  - 自动创建的下游节点重复添加:任务完成后先刷新画布并复用真实节点 ID。
235
243
  - 多项画布写操作逐条执行:同一阶段可合并的节点、连线和删除应先用一次 `canvas apply`;只有批量入口不可用或合法批量请求仍因批量粒度失败时才回退单条命令,不能把权限、schema 或业务错误当作回退理由。
244
+ - 为常见查询写临时脚本:节点、边、资产、设置、目录、状态和资源来源审计先用一次 `canvas inspect` 与过滤参数;只有命令无法表达时才写临时脚本,并在结束后清理。
245
+ - 为每个节点启动一个 CLI 进程:互不依赖的执行和等待分别用一个 `canvas node run --node …` 或 `task wait --task …` 批次;不要用 shell 并发制造 Keychain 竞争。
246
+ - 为改一个配置字段发送完整 config:单节点使用 `canvas node update --set/--unset`,多节点使用 `update-many`;结构化资源内容仍走 `edit-resource`/`edit-shot`。
236
247
  - 素材未完成或未连线就拆分镜:人物、场景、道具等相关素材必须任务成功、已确认 selected version,并逐项连接到 `storyboard-breakdown` 对应输入后才能执行。
237
248
  - 未展示产物就继续:生成分镜图前展示并确认素材,生成视频前展示并确认分镜图;只报告 ID 或路径不能代替预览与用户确认。
238
249
  - 所有镜头沿用项目格数却声称已动态配置:实时 schema 提供 `gridSize` 时,必须展示并确认逐镜难度建议,再把数值写入对应节点;只有旧服务未暴露字段时才回退到项目格数,未另行确认时为 6 格。
@@ -1,6 +1,8 @@
1
1
  # 命令参考
2
2
 
3
- 所有命令支持 `--base-url`、`--profile`、`--locale zh|en` 和 `--json`。未配置地址时默认连接 `https://ai-short-studio.vvicat.dev`;`--base-url`、`VVICAT_BASE_URL` 和当前 profile 可依次覆盖。默认档案保存服务地址与默认项目;refresh token 仅保存在系统钥匙串。
3
+ 所有命令支持 `--base-url`、`--profile`、`--locale zh|en` 和 `--json`。未配置地址时默认连接 `https://ai-short-studio.vvicat.dev`;`--base-url`、`VVICAT_BASE_URL` 和当前 profile 可依次覆盖。默认档案保存服务地址与默认项目;refresh token 仅保存在系统钥匙串,短期 access token 使用权限为 `0600` 的本机 session 缓存。同一 profile 的并发刷新由跨进程锁合并;Agent 应优先单进程批处理,不要并发启动多个 CLI 进程。
4
+
5
+ CLI 源码开发完成后,在包目录运行 `npm run test:local-e2e`。该命令使用本地 mock API 和真实 CLI 子进程覆盖聚合查询、字段更新、批量执行/等待、兼容入口及 8 进程 session 锁,不连接生产服务或调用模型,结束时自动清理临时数据。
4
6
 
5
7
  ## 创作前预检
6
8
 
@@ -96,19 +98,27 @@ ai-short-studio canvas settings set --project "$PROJECT_ID" --file settings.json
96
98
 
97
99
  ```bash
98
100
  ai-short-studio canvas get --project <projectId> --json
99
- ai-short-studio canvas apply --project <projectId> --file <patch.json> --json
101
+ ai-short-studio canvas inspect --project <projectId> --include summary --include nodes --include edges --include assets --include settings --include node-types --json
102
+ ai-short-studio canvas inspect --project <projectId> --kind storyboard-image --status stale --operation image-resource --json
103
+ ai-short-studio canvas apply --project <projectId> --canvas-id <canvasId> --expected-version <version> --file <patch.json> --json
100
104
  ai-short-studio canvas node types --json
101
105
  ai-short-studio canvas node list --project <projectId> --json
102
106
  ai-short-studio canvas node get <nodeId> --project <projectId> --json
103
107
  ai-short-studio canvas node add <kind> --project <projectId> [--x N] [--y N] [--title <text>] [--config <config.json>] --json
104
108
  ai-short-studio canvas node update <nodeId> --project <projectId> --config <config.json> --json
109
+ ai-short-studio canvas node update <nodeId> --project <projectId> --set 'gridSize=9' --unset legacyField --title <text> --json
110
+ ai-short-studio canvas node update-many --project <projectId> --file <updates.json> --json
105
111
  ai-short-studio canvas node edit-shot <nodeId> --project <projectId> [--file <shot.json>] --json
106
112
  ai-short-studio canvas node edit-resource <nodeId> --project <projectId> [--file <resource.json>] --json
107
113
  ai-short-studio canvas node delete <nodeId> --project <projectId> --yes --json
108
- ai-short-studio canvas node run <nodeId> --project <projectId> --json
114
+ ai-short-studio canvas node run --project <projectId> --node <node1> --node <node2> --concurrency 4 --wait --json
109
115
  ```
110
116
 
111
- `canvas apply` 的 JSON 可以通过 `--file` 读取;省略文件时从 stdin 读取。CLI 会在提交前读取当前画布版本并携带 `canvasId` 与 `expectedVersion`。该命令保留给节点、连线、删除、视口等低层画布图补丁;常规项目配置使用 `canvas settings get|set`。同一阶段存在两个及以上可合并的图变更时,默认用一次 `canvas apply` 提交 `upsertNodes`、`upsertEdges`、`deleteNodeIds` 和 `deleteEdgeIds` 等完整差异,成功后统一回读;批量入口不存在、服务不支持,或合法请求仍因批量粒度失败时,才回退对应的 node/edge 单条命令并只处理尚未生效项。409 应重读、合并并确认后继续重试批量;401/403、参数、schema、业务门禁或付费确认错误必须先修正,不能通过单条命令绕过。节点执行、任务等待和资产下载没有批量入口,互不依赖时使用单条命令并发处理。`storyboardGridSize` 仅在节点级 `gridSize` 无法使用的异常回退中经用户确认后写入;正常流程继续按镜头复杂度设置各节点的 `gridSize`。
117
+ `canvas inspect` 一次读取画布并聚合常见审计结果。`--include` 可重复指定 summary、nodes、edges、assets、settingsnode-types;`--kind`、`--status`、`--search`、`--operation`、`--resource-type`、`--producer-node` 用于筛选。覆盖这些条件的查询不再编写临时脚本。
118
+
119
+ `canvas apply` 的 JSON 可以通过 `--file` 读取;省略文件时从 stdin 读取。若已有画布快照,传入 `--canvas-id` 和 `--expected-version` 后不会额外预读全图;缺少任一参数时保持兼容,CLI 自动读取版本。该命令保留给节点、连线、删除、视口等低层画布图补丁;常规项目配置使用 `canvas settings get|set`。同一阶段存在两个及以上可合并的图变更时,默认用一次 `canvas apply` 提交 `upsertNodes`、`upsertEdges`、`deleteNodeIds` 和 `deleteEdgeIds` 等完整差异,成功后统一回读;批量入口不存在、服务不支持,或合法请求仍因批量粒度失败时,才回退对应的 node/edge 单条命令并只处理尚未生效项。409 应重读、合并并确认后继续重试批量;401/403、参数、schema、业务门禁或付费确认错误必须先修正,不能通过单条命令绕过。
120
+
121
+ `canvas node update --set key=<JSON> --unset key` 只修改指定 config 字段,也可同时更新标题与坐标;`--config` 保持兼容。`update-many` 输入 `{ "updates": [...] }`,一次读取画布和 schema 后原子更新多个节点。结构化生成资源仍必须走 `edit-resource` 或 `edit-shot`。批量节点执行和任务等待默认并发 4、上限 16,优先在一个进程内运行。`storyboardGridSize` 仅在节点级 `gridSize` 无法使用的异常回退中经用户确认后写入;正常流程继续按镜头复杂度设置各节点的 `gridSize`。
112
122
 
113
123
  ## 连线
114
124
 
@@ -170,6 +180,7 @@ ai-short-studio canvas asset download <assetId> --project <projectId> --output <
170
180
  ```bash
171
181
  ai-short-studio task get <taskId> --json
172
182
  ai-short-studio task wait <taskId> [--interval 1000] --json
183
+ ai-short-studio task wait --task <task1> --task <task2> --concurrency 4 [--interval 1000] --json
173
184
  ai-short-studio task cancel <taskId> --yes --json
174
185
  ```
175
186
 
@@ -184,13 +195,13 @@ ai-short-studio config set --file <config.json> --json
184
195
 
185
196
  ## 标准创作命令顺序
186
197
 
187
- 先只读执行 `preflight` → `config get` → `canvas node types` → `project list`。新项目随后按“确认名称/用途 → `project create` → `project get` + `canvas get` + `canvas settings get`”继续,不要求创建前读取不存在的项目或画布;既有项目读取同样的已有资源。现有节点和资产也只读检查。
198
+ 先只读执行 `preflight` → `config get` → `canvas node types` → `project list`。新项目随后按“确认名称/用途 → `project create` → `project get` + 一次 `canvas inspect` 聚合查询”继续,不要求创建前读取不存在的项目或画布;既有项目读取同样的已有资源。只有聚合结果缺少专用命令独有字段时才补充单项查询。
188
199
 
189
200
  取得配置后,向用户展示项目名称/用途,并逐项确认 `aspectRatio`、`artStyle`、`visualBible`、`storyboardImageType`、`analysisModel`、`imageModel`、`imageResolution`、`imageQuality`、`editModel`、`videoModel`、`videoResolution`、`audioModel` 的 `currentValue`、`effectiveValue`、建议值与 `source`;候选值和视觉圣经结构分别以实时 `options` 与 `schema` 为准。模型可项目覆盖,也可由用户明确确认继承账户默认,但必须明确最终生效且可用的具体模型。`storyboardGridSize` 仅在异常回退时确认。同一阶段的多个画布写操作默认合并到一次 `canvas apply`,批量不可用或合法批量请求仍因批量粒度失败时才回退单条 node/edge 命令。
190
201
 
191
202
  项目名称和用途先由用户确认;新项目用 `project create` 保存,既有项目需要变更时仅用 `project update` 修改用户确认的项目字段。把确认的配置用一次 `canvas settings set` 原子写入并携带 `expectedVersion`,随后再次 `settings get` 回读。只有结果与确认一致且所需模型可用,才执行 node add/edge add/node run。写入、权限、冲突、回读或模型校验失败时停在配置阶段,不触发付费任务。账户级 `config set`、项目 `description`、节点 `prompt`/`config` 和低层 `canvas apply` 都不能替代项目 settings 命令。
192
203
 
193
- 执行 `storyboard-breakdown` 前,必须先完成本次故事涉及的全部人物、场景、道具等素材节点:互不依赖的节点先同批执行并收集真实任务 ID,再并发调用 `task wait`;有上游依赖的节点按依赖顺序分批执行,不得为了等待单个任务而串行提交其他独立素材。全部任务成功后,用 `canvas asset get/list` 核对并在用户确认后设置 selected version。随后依据实时 schema,用 `canvas edge add` 将剧本节点和每个相关素材节点分别连接到 `storyboard-breakdown` 对应输入 handle,再用 `canvas get` 与 `canvas edge list` 核对无遗漏。任一素材任务未成功、selected version 为空或对应连线缺失时,都不得执行分镜拆解;素材范围不清楚时先询问用户。即使服务端 schema 把人物、场景或道具输入标为可选,标准创作流程也不能跳过本次故事实际涉及且已确认使用的素材。
204
+ 执行 `storyboard-breakdown` 前,必须先完成本次故事涉及的全部人物、场景、道具等素材节点:互不依赖的节点用一次 `canvas node run` 重复传 `--node`,需要同步等待时加 `--wait`;已有多个任务 ID 时用一次 `task wait` 重复传 `--task`。有上游依赖的节点按依赖顺序分批执行,不得为了等待单个任务而串行提交其他独立素材。全部任务成功后,用聚合查询或 `canvas asset get/list` 核对并在用户确认后设置 selected version。随后依据实时 schema 连接素材,并用一次 `canvas inspect` 核对无遗漏。任一素材任务未成功、selected version 为空或对应连线缺失时,都不得执行分镜拆解;素材范围不清楚时先询问用户。即使服务端 schema 把人物、场景或道具输入标为可选,标准创作流程也不能跳过本次故事实际涉及且已确认使用的素材。
194
205
 
195
206
  专业 Markdown 纯文本拆镜使用 `{"storyboardPipelineVersion":2,"sourceFormat":"screenplay-markdown.v1","targetLanguage":"en","platform":"TikTok","aspectRatio":"9:16"}`,阿语集把语言改为 `ar`。只连接一条剧本与该集实际涉及的文字资产。该模式不执行声音、TTS、图片、音频或视频生成;完成后用任务、画布和资产回读确认只新增分镜文本/镜头,失败时零分镜持久化。
196
207