@playcraft/cli 0.0.48 → 0.0.51

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.
@@ -0,0 +1,132 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { loadGlobalConfig } from '../config.js';
3
+ import { DEFAULT_PLAYCRAFT_CLOUD_BASE } from '../commands/login.js';
4
+ export class PlayCraftApiError extends Error {
5
+ code;
6
+ status;
7
+ details;
8
+ retryable;
9
+ constructor(options) {
10
+ super(options.message);
11
+ this.name = 'PlayCraftApiError';
12
+ this.code = options.code;
13
+ this.status = options.status;
14
+ this.details = options.details;
15
+ this.retryable = options.retryable ?? false;
16
+ }
17
+ }
18
+ function resolveBaseUrl(url) {
19
+ const globalConfig = loadGlobalConfig();
20
+ return (url ||
21
+ process.env.PLAYCRAFT_API_URL ||
22
+ process.env.PLAYCRAFT_URL ||
23
+ globalConfig.backendUrl ||
24
+ globalConfig.url ||
25
+ DEFAULT_PLAYCRAFT_CLOUD_BASE).replace(/\/+$/, '');
26
+ }
27
+ function resolveToken(token) {
28
+ const globalConfig = loadGlobalConfig();
29
+ const resolved = token || process.env.PLAYCRAFT_TOKEN || globalConfig.token;
30
+ if (!resolved) {
31
+ throw new PlayCraftApiError({
32
+ code: 'AUTH_REQUIRED',
33
+ message: 'Authentication required. Run `playcraft login` or provide PLAYCRAFT_TOKEN.',
34
+ details: { loginCommand: 'playcraft login' },
35
+ });
36
+ }
37
+ return resolved;
38
+ }
39
+ function statusCode(status) {
40
+ if (status === 401)
41
+ return 'AUTH_EXPIRED';
42
+ if (status === 403)
43
+ return 'FORBIDDEN';
44
+ if (status === 404)
45
+ return 'PROJECT_NOT_FOUND';
46
+ return 'AGENT_REQUEST_FAILED';
47
+ }
48
+ export class PlayCraftHttpClient {
49
+ baseUrl;
50
+ token;
51
+ requestId;
52
+ constructor(options = {}) {
53
+ this.baseUrl = resolveBaseUrl(options.url);
54
+ this.token = resolveToken(options.token);
55
+ this.requestId = options.requestId || randomUUID();
56
+ }
57
+ url(path) {
58
+ const normalized = path.startsWith('/') ? path : `/${path}`;
59
+ const apiPath = normalized === '/api' || normalized.startsWith('/api/')
60
+ ? normalized
61
+ : `/api${normalized}`;
62
+ return new URL(apiPath, this.baseUrl).toString();
63
+ }
64
+ async request(path, init = {}) {
65
+ const headers = new Headers(init.headers);
66
+ headers.set('Authorization', `Bearer ${this.token}`);
67
+ headers.set('X-Request-Id', this.requestId);
68
+ if (init.body && !headers.has('Content-Type'))
69
+ headers.set('Content-Type', 'application/json');
70
+ let response;
71
+ try {
72
+ response = await fetch(this.url(path), { ...init, headers });
73
+ }
74
+ catch (error) {
75
+ throw new PlayCraftApiError({
76
+ code: 'AGENT_REQUEST_FAILED',
77
+ message: error instanceof Error ? error.message : String(error),
78
+ retryable: true,
79
+ });
80
+ }
81
+ const contentType = response.headers.get('content-type') || '';
82
+ const raw = await response.text();
83
+ let payload = undefined;
84
+ if (raw) {
85
+ try {
86
+ payload = contentType.includes('json') ? JSON.parse(raw) : raw;
87
+ }
88
+ catch {
89
+ payload = raw;
90
+ }
91
+ }
92
+ if (!response.ok) {
93
+ const error = payload?.error;
94
+ const code = typeof error?.code === 'string' ? error.code : statusCode(response.status);
95
+ const message = typeof error?.message === 'string'
96
+ ? error.message
97
+ : typeof payload?.message === 'string'
98
+ ? payload.message
99
+ : typeof payload === 'string'
100
+ ? payload
101
+ : response.statusText || 'PlayCraft request failed';
102
+ throw new PlayCraftApiError({
103
+ code,
104
+ message,
105
+ status: response.status,
106
+ details: error?.details ?? payload,
107
+ retryable: response.status >= 500,
108
+ });
109
+ }
110
+ if (!raw)
111
+ return undefined;
112
+ return payload;
113
+ }
114
+ get(path, params, init = {}) {
115
+ const url = new URL(this.url(path));
116
+ for (const [key, value] of Object.entries(params || {})) {
117
+ if (value !== undefined && value !== '')
118
+ url.searchParams.set(key, value);
119
+ }
120
+ return this.request(url.pathname + url.search, { ...init, method: 'GET' });
121
+ }
122
+ post(path, body, init = {}) {
123
+ return this.request(path, {
124
+ ...init,
125
+ method: 'POST',
126
+ body: body === undefined ? undefined : JSON.stringify(body),
127
+ });
128
+ }
129
+ }
130
+ export function isPlayCraftApiError(error) {
131
+ return error instanceof PlayCraftApiError;
132
+ }
@@ -0,0 +1,125 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { basename, extname } from 'node:path';
3
+ import { PlayCraftApiError } from './playcraft-http-client.js';
4
+ const MAX_IMAGES = 4;
5
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
6
+ const IMAGE_MIME_BY_EXTENSION = {
7
+ '.gif': 'image/gif',
8
+ '.jpeg': 'image/jpeg',
9
+ '.jpg': 'image/jpeg',
10
+ '.png': 'image/png',
11
+ '.webp': 'image/webp',
12
+ };
13
+ function attachmentError(code, message, details, retryable = false) {
14
+ return new PlayCraftApiError({ code, message, details, retryable });
15
+ }
16
+ export function readImageAttachments(paths) {
17
+ if (!paths)
18
+ return [];
19
+ const candidates = Array.isArray(paths) ? paths : [paths];
20
+ if (candidates.length > MAX_IMAGES) {
21
+ throw attachmentError('ATTACHMENT_LIMIT_EXCEEDED', `最多只能传输 ${MAX_IMAGES} 张图片。`, { maxImages: MAX_IMAGES });
22
+ }
23
+ return candidates.map((sourcePath) => {
24
+ const extension = extname(sourcePath).toLowerCase();
25
+ const mimeType = IMAGE_MIME_BY_EXTENSION[extension];
26
+ if (!mimeType) {
27
+ throw attachmentError('ATTACHMENT_TYPE_UNSUPPORTED', `不支持的图片格式:${sourcePath}。仅支持 JPEG (Joint Photographic Experts Group, 联合图像专家组)、PNG (Portable Network Graphics, 便携式网络图形)、WebP 和 GIF (Graphics Interchange Format, 图形交换格式)。`, { path: sourcePath, supportedExtensions: Object.keys(IMAGE_MIME_BY_EXTENSION) });
28
+ }
29
+ let size;
30
+ try {
31
+ size = statSync(sourcePath).size;
32
+ }
33
+ catch {
34
+ throw attachmentError('ATTACHMENT_NOT_FOUND', `图片文件不存在或无法读取:${sourcePath}`, { path: sourcePath });
35
+ }
36
+ if (size <= 0) {
37
+ throw attachmentError('ATTACHMENT_INVALID', `图片文件为空:${sourcePath}`, { path: sourcePath });
38
+ }
39
+ if (size > MAX_IMAGE_BYTES) {
40
+ throw attachmentError('ATTACHMENT_TOO_LARGE', `图片超过 5 MB (Megabyte, 兆字节) 限制:${sourcePath}`, { path: sourcePath, maxBytes: MAX_IMAGE_BYTES, actualBytes: size });
41
+ }
42
+ try {
43
+ return {
44
+ sourcePath,
45
+ fileName: basename(sourcePath),
46
+ mimeType,
47
+ bytes: readFileSync(sourcePath),
48
+ };
49
+ }
50
+ catch {
51
+ throw attachmentError('ATTACHMENT_NOT_FOUND', `图片文件不存在或无法读取:${sourcePath}`, { path: sourcePath });
52
+ }
53
+ });
54
+ }
55
+ async function serializeFormData(form, url) {
56
+ const request = new Request(url, { method: 'POST', body: form });
57
+ const contentType = request.headers.get('content-type');
58
+ if (!contentType) {
59
+ throw attachmentError('ATTACHMENT_UPLOAD_FAILED', '无法构造图片上传请求。');
60
+ }
61
+ return {
62
+ body: await request.arrayBuffer(),
63
+ contentType,
64
+ };
65
+ }
66
+ export async function uploadImageAttachments(client, projectId, branch, attachments) {
67
+ const uploaded = [];
68
+ for (const attachment of attachments) {
69
+ const form = new FormData();
70
+ form.append('file', new Blob([new Uint8Array(attachment.bytes)], { type: attachment.mimeType }), attachment.fileName);
71
+ const path = `/projects/${encodeURIComponent(projectId)}/sandbox/upload-image`;
72
+ const url = new URL(client.url(path));
73
+ url.searchParams.set('branch', branch);
74
+ const multipart = await serializeFormData(form, url.toString());
75
+ let response;
76
+ try {
77
+ response = await client.request(`${path}?${url.searchParams.toString()}`, {
78
+ method: 'POST',
79
+ body: multipart.body,
80
+ headers: { 'Content-Type': multipart.contentType },
81
+ });
82
+ }
83
+ catch (error) {
84
+ const upstream = error instanceof PlayCraftApiError
85
+ ? { code: error.code, status: error.status, details: error.details }
86
+ : { message: error instanceof Error ? error.message : String(error) };
87
+ throw attachmentError('ATTACHMENT_UPLOAD_FAILED', `图片上传失败:${attachment.sourcePath}${error instanceof Error ? `:${error.message}` : ''}`, { path: attachment.sourcePath, upstream }, error instanceof PlayCraftApiError ? error.retryable : false);
88
+ }
89
+ if (!response?.path) {
90
+ throw attachmentError('ATTACHMENT_UPLOAD_FAILED', `图片上传成功但服务端未返回沙箱路径:${attachment.sourcePath}`, { path: attachment.sourcePath });
91
+ }
92
+ uploaded.push({
93
+ sourcePath: attachment.sourcePath,
94
+ fileName: attachment.fileName,
95
+ sandboxPath: response.path,
96
+ });
97
+ }
98
+ return uploaded;
99
+ }
100
+ export function buildReferenceImagesPrompt(prompt, attachments) {
101
+ if (attachments.length === 0)
102
+ return prompt;
103
+ const pathList = attachments.map((attachment) => `- ${attachment.sandboxPath}`).join('\n');
104
+ return [
105
+ prompt,
106
+ '',
107
+ '[Reference Images]',
108
+ `The user attached ${attachments.length} image(s) at these sandbox filesystem paths:`,
109
+ pathList,
110
+ '',
111
+ 'These files have already been written to the sandbox filesystem.',
112
+ '',
113
+ '**IMPORTANT:** Do NOT read, open, or inspect any of these image files. Do NOT use tools like read_file, cat, or any other file reader on them. The paths themselves are all you need.',
114
+ '',
115
+ "Infer each image's purpose from the user's message (e.g. reference for style/composition, UI mockup, screenshot illustrating an issue, asset to import, etc.). For any image you judge to be a visual reference, pass its path directly to `playcraft-generate-image` — do NOT ask the user to confirm, and do NOT attempt to examine the image content first.",
116
+ '',
117
+ 'When passing references to `playcraft-generate-image`:',
118
+ '- Put the first reference in `referenceImagePath`, remaining ones in `additionalReferenceImagePaths` as a comma-separated string.',
119
+ '- Preserve the original order.',
120
+ '- Up to 8 references total; paths must not contain commas.',
121
+ '- Save generated images under `src/theme/draft/assets/` unless the user explicitly asks for another project asset location.',
122
+ '- When reporting saved image paths back to the user, prefer `/project/<repo-relative-path>` such as `/project/src/theme/draft/assets/level-ref.png`, not the full `/projects/<projectId>/<branch>/...` path.',
123
+ ].join('\n');
124
+ }
125
+ export const IMAGE_OPTION_HELP = '一个或多个图片路径(最多 4 张;JPEG (Joint Photographic Experts Group, 联合图像专家组)、PNG (Portable Network Graphics, 便携式网络图形)、WebP、GIF (Graphics Interchange Format, 图形交换格式),每张不超过 5 MB (Megabyte, 兆字节))';
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.0.48",
3
+ "version": "0.0.51",
4
4
  "private": false,
5
5
  "type": "module",
6
+ "engines": {
7
+ "node": ">=20"
8
+ },
6
9
  "bin": {
7
10
  "playcraft": "./dist/index.js",
8
11
  "playcraft-test": "./dist/index.js"
@@ -13,6 +16,7 @@
13
16
  "project-template",
14
17
  "engine-packs",
15
18
  "project-templates",
19
+ "REMIX.md",
16
20
  "README.md"
17
21
  ],
18
22
  "scripts": {
@@ -41,8 +45,8 @@
41
45
  "@gltf-transform/core": "^4.3.0",
42
46
  "@gltf-transform/extensions": "^4.3.0",
43
47
  "@gltf-transform/functions": "^4.3.0",
44
- "@playcraft/build": "^0.0.48",
45
- "@playcraft/common": "^0.0.35",
48
+ "@playcraft/build": "^0.0.51",
49
+ "@playcraft/common": "^0.0.51",
46
50
  "chokidar": "^4.0.3",
47
51
  "commander": "^13.1.0",
48
52
  "cors": "^2.8.6",
@@ -1,67 +0,0 @@
1
- import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
- import http from 'node:http';
3
- import { CloudConnectionManager } from './cloud-connection.js';
4
- describe('CloudConnectionManager', () => {
5
- let server;
6
- let baseUrl;
7
- beforeAll(async () => {
8
- server = http.createServer((req, res) => {
9
- const auth = req.headers.authorization;
10
- if (auth !== 'Bearer test-token') {
11
- res.writeHead(401);
12
- res.end(JSON.stringify({ error: 'unauthorized' }));
13
- return;
14
- }
15
- if (req.method === 'GET' && req.url === '/api/health') {
16
- res.writeHead(200, { 'Content-Type': 'application/json' });
17
- res.end(JSON.stringify({ ok: true }));
18
- return;
19
- }
20
- if (req.method === 'POST' && req.url === '/api/sync/upload') {
21
- let body = '';
22
- req.on('data', (c) => (body += c));
23
- req.on('end', () => {
24
- res.writeHead(200, { 'Content-Type': 'application/json' });
25
- res.end(JSON.stringify({ success: true }));
26
- });
27
- return;
28
- }
29
- res.writeHead(404);
30
- res.end('not found');
31
- });
32
- await new Promise((resolve) => {
33
- server.listen(0, '127.0.0.1', () => resolve());
34
- });
35
- const addr = server.address();
36
- baseUrl = `http://127.0.0.1:${addr.port}`;
37
- });
38
- afterAll(async () => {
39
- await new Promise((resolve) => server.close(() => resolve()));
40
- });
41
- it('request() sends Bearer token and returns JSON', async () => {
42
- const conn = new CloudConnectionManager({
43
- url: baseUrl,
44
- token: 'test-token',
45
- projectId: 'test-project',
46
- });
47
- const result = await conn.request('GET', '/api/health');
48
- expect(result).toEqual({ ok: true });
49
- });
50
- it('request() throws on 401', async () => {
51
- const conn = new CloudConnectionManager({
52
- url: baseUrl,
53
- token: 'wrong-token',
54
- projectId: 'test-project',
55
- });
56
- await expect(conn.request('GET', '/api/health')).rejects.toThrow(/401/);
57
- });
58
- it('connectionState is disconnected when not connected', () => {
59
- const conn = new CloudConnectionManager({
60
- url: baseUrl,
61
- token: 'test-token',
62
- projectId: 'test-project',
63
- });
64
- expect(conn.connectionState).toBe('disconnected');
65
- expect(conn.isConnected).toBe(false);
66
- });
67
- });
@@ -1,52 +0,0 @@
1
- import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
- import express from 'express';
3
- import http from 'node:http';
4
- import os from 'node:os';
5
- import path from 'node:path';
6
- import fs from 'node:fs/promises';
7
- import { createLocalBackendRouter } from './local-backend.js';
8
- describe('createLocalBackendRouter', () => {
9
- let server;
10
- let baseUrl;
11
- let tmpDir;
12
- beforeAll(async () => {
13
- tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'playcraft-agent-test-'));
14
- const dbPath = path.join(tmpDir, 'local-db.sqlite');
15
- const app = express();
16
- const projectId = 'test-project';
17
- const { router } = createLocalBackendRouter({
18
- projectId,
19
- projectDir: tmpDir,
20
- dbPath,
21
- emitSecret: 'test-secret',
22
- });
23
- app.use('/api', router);
24
- server = http.createServer(app);
25
- await new Promise((resolve) => {
26
- server.listen(0, '127.0.0.1', () => resolve());
27
- });
28
- const addr = server.address();
29
- baseUrl = `http://127.0.0.1:${addr.port}`;
30
- });
31
- afterAll(async () => {
32
- if (server) {
33
- await new Promise((resolve) => server.close(() => resolve()));
34
- }
35
- if (tmpDir) {
36
- await fs.rm(tmpDir, { recursive: true, force: true });
37
- }
38
- });
39
- it('responds health', async () => {
40
- const res = await fetch(`${baseUrl}/api/health`);
41
- expect(res.ok).toBe(true);
42
- const json = await res.json();
43
- expect(json).toHaveProperty('ok', true);
44
- });
45
- it('creates default project lazily and can fetch it', async () => {
46
- const res = await fetch(`${baseUrl}/api/projects/test-project`);
47
- expect(res.ok).toBe(true);
48
- const json = await res.json();
49
- expect(json).toHaveProperty('result');
50
- expect(json.result).toHaveProperty('uuid', 'test-project');
51
- });
52
- });
@@ -1,264 +0,0 @@
1
- /**
2
- * fix-ids 命令测试
3
- *
4
- * 覆盖:
5
- * - URL 优先级:CLI 参数 → BACKEND_API_URL → PLAYCRAFT_URL → globalConfig.backendUrl →
6
- * globalConfig.url → DEFAULT_PLAYCRAFT_CLOUD_BASE
7
- * - Token 优先级:CLI 参数 → PLAYCRAFT_TOKEN → globalConfig.token
8
- * - 成功/失败分支
9
- * - 本地开发鉴权绕过(X-Local-User)
10
- */
11
- import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi, } from 'vitest';
12
- import http from 'node:http';
13
- // ─── Mock loadGlobalConfig ────────────────────────────────────────────────────
14
- vi.mock('../config.js', () => ({
15
- loadGlobalConfig: vi.fn(() => ({})),
16
- saveGlobalConfig: vi.fn(),
17
- loadConfig: vi.fn(),
18
- }));
19
- import { loadGlobalConfig } from '../config.js';
20
- import { fixIdsCommand } from './fix-ids.js';
21
- import { DEFAULT_PLAYCRAFT_CLOUD_BASE } from './login.js';
22
- let server;
23
- let baseUrl;
24
- let lastRequest;
25
- let responseStatus = 200;
26
- let responseBody = { success: true };
27
- beforeAll(async () => {
28
- server = http.createServer((req, res) => {
29
- lastRequest = {
30
- url: req.url ?? '',
31
- method: req.method ?? '',
32
- headers: req.headers,
33
- };
34
- res.writeHead(responseStatus, { 'Content-Type': 'application/json' });
35
- res.end(JSON.stringify(responseBody));
36
- });
37
- await new Promise((resolve) => {
38
- server.listen(0, '127.0.0.1', () => resolve());
39
- });
40
- const addr = server.address();
41
- baseUrl = `http://127.0.0.1:${addr.port}`;
42
- });
43
- afterAll(async () => {
44
- await new Promise((resolve) => server.close(() => resolve()));
45
- });
46
- beforeEach(() => {
47
- lastRequest = null;
48
- responseStatus = 200;
49
- responseBody = { success: true };
50
- vi.mocked(loadGlobalConfig).mockReturnValue({});
51
- // 清理鉴权绕过相关环境变量
52
- delete process.env.BACKEND_API_URL;
53
- delete process.env.PLAYCRAFT_URL;
54
- delete process.env.PLAYCRAFT_TOKEN;
55
- delete process.env.NEXT_PUBLIC_LOCAL_AUTH_BYPASS;
56
- delete process.env.NEXT_PUBLIC_LOCAL_USER;
57
- });
58
- afterEach(() => {
59
- delete process.env.BACKEND_API_URL;
60
- delete process.env.PLAYCRAFT_URL;
61
- delete process.env.PLAYCRAFT_TOKEN;
62
- delete process.env.NEXT_PUBLIC_LOCAL_AUTH_BYPASS;
63
- delete process.env.NEXT_PUBLIC_LOCAL_USER;
64
- });
65
- // ─── URL resolution tests ─────────────────────────────────────────────────────
66
- describe('URL 优先级', () => {
67
- it('明确传入 --api-url 时优先使用 CLI 参数', async () => {
68
- vi.mocked(loadGlobalConfig).mockReturnValue({
69
- backendUrl: 'http://should-not-use.example.com',
70
- });
71
- await fixIdsCommand({ projectId: '123', apiUrl: baseUrl });
72
- expect(lastRequest?.url).toBe('/api/projects/123/fix-ids');
73
- // 确保确实打到了我们的测试服务器
74
- expect(lastRequest?.method).toBe('POST');
75
- });
76
- it('无 CLI 参数时从 BACKEND_API_URL 环境变量读取 URL', async () => {
77
- process.env.BACKEND_API_URL = baseUrl;
78
- await fixIdsCommand({ projectId: '123' });
79
- expect(lastRequest?.url).toBe('/api/projects/123/fix-ids');
80
- });
81
- it('无 BACKEND_API_URL 时从 PLAYCRAFT_URL 环境变量读取 URL', async () => {
82
- process.env.PLAYCRAFT_URL = baseUrl;
83
- await fixIdsCommand({ projectId: '123' });
84
- expect(lastRequest?.url).toBe('/api/projects/123/fix-ids');
85
- });
86
- it('环境变量均未设置时,从全局配置 backendUrl 读取', async () => {
87
- vi.mocked(loadGlobalConfig).mockReturnValue({ backendUrl: baseUrl });
88
- await fixIdsCommand({ projectId: '123' });
89
- expect(lastRequest?.url).toBe('/api/projects/123/fix-ids');
90
- });
91
- it('backendUrl 未设置时,从全局配置 url 读取', async () => {
92
- vi.mocked(loadGlobalConfig).mockReturnValue({ url: baseUrl });
93
- await fixIdsCommand({ projectId: '123' });
94
- expect(lastRequest?.url).toBe('/api/projects/123/fix-ids');
95
- });
96
- it('所有来源均未设置时,回退到 DEFAULT_PLAYCRAFT_CLOUD_BASE(不打本地服务器)', async () => {
97
- // 确认默认地址指向公有云,不含 localhost
98
- expect(DEFAULT_PLAYCRAFT_CLOUD_BASE).toMatch(/^https:\/\//);
99
- expect(DEFAULT_PLAYCRAFT_CLOUD_BASE).not.toContain('localhost');
100
- expect(DEFAULT_PLAYCRAFT_CLOUD_BASE).not.toContain('127.0.0.1');
101
- expect(DEFAULT_PLAYCRAFT_CLOUD_BASE).not.toContain(':3001');
102
- });
103
- it('CLI 参数 > 环境变量 > 全局配置(优先级链路)', async () => {
104
- process.env.BACKEND_API_URL = 'http://should-not-use-env.example.com';
105
- vi.mocked(loadGlobalConfig).mockReturnValue({
106
- backendUrl: 'http://should-not-use-config.example.com',
107
- });
108
- // CLI 参数最高优先级:打到我们的测试服务器
109
- await fixIdsCommand({ projectId: '123', apiUrl: baseUrl });
110
- expect(lastRequest?.url).toBe('/api/projects/123/fix-ids');
111
- });
112
- it('URL 尾部 slash 会被正确去除', async () => {
113
- vi.mocked(loadGlobalConfig).mockReturnValue({ url: `${baseUrl}/` });
114
- await fixIdsCommand({ projectId: '123' });
115
- expect(lastRequest?.url).toBe('/api/projects/123/fix-ids');
116
- });
117
- it('projectId 含特殊字符时被正确 encodeURIComponent', async () => {
118
- vi.mocked(loadGlobalConfig).mockReturnValue({ url: baseUrl });
119
- await fixIdsCommand({ projectId: 'abc/def' });
120
- expect(lastRequest?.url).toBe('/api/projects/abc%2Fdef/fix-ids');
121
- });
122
- });
123
- // ─── Token / auth tests ───────────────────────────────────────────────────────
124
- describe('Token 优先级', () => {
125
- it('明确传入 --token 时优先使用 CLI 参数', async () => {
126
- vi.mocked(loadGlobalConfig).mockReturnValue({
127
- url: baseUrl,
128
- token: 'global-token',
129
- });
130
- await fixIdsCommand({ projectId: '123', apiUrl: baseUrl, token: 'cli-token' });
131
- expect(lastRequest?.headers['authorization']).toBe('Bearer cli-token');
132
- });
133
- it('CLI 未传 token,从 PLAYCRAFT_TOKEN 环境变量读取', async () => {
134
- process.env.PLAYCRAFT_TOKEN = 'env-token';
135
- vi.mocked(loadGlobalConfig).mockReturnValue({
136
- url: baseUrl,
137
- token: 'global-token-should-not-use',
138
- });
139
- await fixIdsCommand({ projectId: '123', apiUrl: baseUrl });
140
- expect(lastRequest?.headers['authorization']).toBe('Bearer env-token');
141
- });
142
- it('环境变量未设置时,从全局配置 token 读取(login 保存的 PAT)', async () => {
143
- vi.mocked(loadGlobalConfig).mockReturnValue({
144
- url: baseUrl,
145
- token: 'playcraft_pat_from_login',
146
- });
147
- await fixIdsCommand({ projectId: '123', apiUrl: baseUrl });
148
- expect(lastRequest?.headers['authorization']).toBe('Bearer playcraft_pat_from_login');
149
- });
150
- it('三者均未设置时不带 Authorization header', async () => {
151
- await fixIdsCommand({ projectId: '123', apiUrl: baseUrl });
152
- expect(lastRequest?.headers['authorization']).toBeUndefined();
153
- });
154
- it('CLI 参数 > 环境变量 > 全局配置(token 优先级链路)', async () => {
155
- process.env.PLAYCRAFT_TOKEN = 'env-token-should-not-use';
156
- vi.mocked(loadGlobalConfig).mockReturnValue({
157
- url: baseUrl,
158
- token: 'global-token-should-not-use',
159
- });
160
- await fixIdsCommand({ projectId: '123', apiUrl: baseUrl, token: 'cli-wins' });
161
- expect(lastRequest?.headers['authorization']).toBe('Bearer cli-wins');
162
- });
163
- });
164
- // ─── 本地开发鉴权绕过 ────────────────────────────────────────────────────────
165
- describe('本地开发鉴权绕过(X-Local-User)', () => {
166
- it('NEXT_PUBLIC_LOCAL_AUTH_BYPASS=true 时附加 X-Local-User header', async () => {
167
- process.env.NEXT_PUBLIC_LOCAL_AUTH_BYPASS = 'true';
168
- process.env.NEXT_PUBLIC_LOCAL_USER = 'admin@example.com';
169
- await fixIdsCommand({ projectId: '123', apiUrl: baseUrl });
170
- expect(lastRequest?.headers['x-local-user']).toBe('admin@example.com');
171
- });
172
- it('NEXT_PUBLIC_LOCAL_AUTH_BYPASS 未设置时不附加 X-Local-User', async () => {
173
- await fixIdsCommand({ projectId: '123', apiUrl: baseUrl });
174
- expect(lastRequest?.headers['x-local-user']).toBeUndefined();
175
- });
176
- it('NEXT_PUBLIC_LOCAL_AUTH_BYPASS=false 时不附加 X-Local-User', async () => {
177
- process.env.NEXT_PUBLIC_LOCAL_AUTH_BYPASS = 'false';
178
- process.env.NEXT_PUBLIC_LOCAL_USER = 'user@example.com';
179
- await fixIdsCommand({ projectId: '123', apiUrl: baseUrl });
180
- expect(lastRequest?.headers['x-local-user']).toBeUndefined();
181
- });
182
- });
183
- // ─── 成功 / 失败分支 ─────────────────────────────────────────────────────────
184
- describe('成功/失败响应处理', () => {
185
- it('成功时不抛出异常', async () => {
186
- responseBody = { success: true };
187
- await expect(fixIdsCommand({ projectId: '123', apiUrl: baseUrl })).resolves.toBeUndefined();
188
- });
189
- it('HTTP 4xx 时调用 process.exit(1)', async () => {
190
- responseStatus = 401;
191
- responseBody = { message: 'Unauthorized' };
192
- const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {
193
- throw new Error('process.exit called');
194
- }));
195
- await expect(fixIdsCommand({ projectId: '123', apiUrl: baseUrl })).rejects.toThrow('process.exit called');
196
- expect(mockExit).toHaveBeenCalledWith(1);
197
- mockExit.mockRestore();
198
- });
199
- it('HTTP 500 时调用 process.exit(1)', async () => {
200
- responseStatus = 500;
201
- responseBody = { message: 'Internal Server Error' };
202
- const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {
203
- throw new Error('process.exit called');
204
- }));
205
- await expect(fixIdsCommand({ projectId: '123', apiUrl: baseUrl })).rejects.toThrow('process.exit called');
206
- expect(mockExit).toHaveBeenCalledWith(1);
207
- mockExit.mockRestore();
208
- });
209
- it('响应 success 字段为 false 时调用 process.exit(1)', async () => {
210
- responseStatus = 200;
211
- responseBody = { success: false };
212
- const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {
213
- throw new Error('process.exit called');
214
- }));
215
- await expect(fixIdsCommand({ projectId: '123', apiUrl: baseUrl })).rejects.toThrow('process.exit called');
216
- expect(mockExit).toHaveBeenCalledWith(1);
217
- mockExit.mockRestore();
218
- });
219
- it('网络错误时调用 process.exit(1)', async () => {
220
- const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {
221
- throw new Error('process.exit called');
222
- }));
223
- // 使用一个必定拒绝连接的地址
224
- await expect(fixIdsCommand({ projectId: '123', apiUrl: 'http://127.0.0.1:1' })).rejects.toThrow('process.exit called');
225
- expect(mockExit).toHaveBeenCalledWith(1);
226
- mockExit.mockRestore();
227
- });
228
- });
229
- // ─── AgentApiClient 凭证验证(无 localhost 默认)────────────────────────────
230
- describe('AgentApiClient 凭证行为', () => {
231
- it('PLAYCRAFT_API_URL / PLAYCRAFT_SANDBOX_TOKEN 均未设置时抛出 Missing credentials 错误', async () => {
232
- const origApiUrl = process.env.PLAYCRAFT_API_URL;
233
- const origSandboxToken = process.env.PLAYCRAFT_SANDBOX_TOKEN;
234
- delete process.env.PLAYCRAFT_API_URL;
235
- delete process.env.PLAYCRAFT_SANDBOX_TOKEN;
236
- const { AgentApiClient } = await import('../utils/agent-api-client.js');
237
- expect(() => new AgentApiClient()).toThrow(/Missing PlayCraft API credentials/);
238
- if (origApiUrl !== undefined)
239
- process.env.PLAYCRAFT_API_URL = origApiUrl;
240
- if (origSandboxToken !== undefined)
241
- process.env.PLAYCRAFT_SANDBOX_TOKEN = origSandboxToken;
242
- });
243
- it('AgentApiClient 不使用 localhost:3001 作为默认地址', async () => {
244
- const origApiUrl = process.env.PLAYCRAFT_API_URL;
245
- const origSandboxToken = process.env.PLAYCRAFT_SANDBOX_TOKEN;
246
- delete process.env.PLAYCRAFT_API_URL;
247
- delete process.env.PLAYCRAFT_SANDBOX_TOKEN;
248
- const { AgentApiClient } = await import('../utils/agent-api-client.js');
249
- let errorMessage = '';
250
- try {
251
- new AgentApiClient();
252
- }
253
- catch (e) {
254
- errorMessage = e instanceof Error ? e.message : String(e);
255
- }
256
- // 错误信息不应包含 localhost 地址 —— 不存在隐式降级到本地
257
- expect(errorMessage).not.toContain('localhost');
258
- expect(errorMessage).not.toContain('127.0.0.1');
259
- if (origApiUrl !== undefined)
260
- process.env.PLAYCRAFT_API_URL = origApiUrl;
261
- if (origSandboxToken !== undefined)
262
- process.env.PLAYCRAFT_SANDBOX_TOKEN = origSandboxToken;
263
- });
264
- });