@playcraft/cli 0.0.39 → 0.0.40

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.
@@ -2,15 +2,29 @@
2
2
  * fix-ids 命令:修复已导入项目的 ID 映射(将 Sandbox 中的 PlayCanvas ID 转换为 PlayCraft numericId)
3
3
  */
4
4
  import pc from 'picocolors';
5
+ import { loadGlobalConfig } from '../config.js';
6
+ import { DEFAULT_PLAYCRAFT_CLOUD_BASE } from './login.js';
5
7
  export async function fixIdsCommand(options) {
6
8
  const { projectId, apiUrl, token } = options;
7
- const baseUrl = (apiUrl || process.env.BACKEND_API_URL || process.env.PLAYCRAFT_URL || 'http://localhost:3001').replace(/\/+$/, '');
9
+ const globalConfig = loadGlobalConfig();
10
+ // 优先级:CLI 参数 → 环境变量 → login 保存的全局配置 → 默认云端地址
11
+ const resolvedUrl = (apiUrl ||
12
+ process.env.BACKEND_API_URL ||
13
+ process.env.PLAYCRAFT_URL ||
14
+ globalConfig.backendUrl ||
15
+ globalConfig.url ||
16
+ DEFAULT_PLAYCRAFT_CLOUD_BASE).replace(/\/+$/, '');
17
+ const baseUrl = resolvedUrl;
8
18
  const url = `${baseUrl}/api/projects/${encodeURIComponent(projectId)}/fix-ids`;
19
+ // 优先级:CLI 参数 → 环境变量 → login 保存的全局 token
20
+ const resolvedToken = token ||
21
+ process.env.PLAYCRAFT_TOKEN ||
22
+ globalConfig.token;
9
23
  const headers = {
10
24
  'Content-Type': 'application/json',
11
25
  };
12
- if (token || process.env.PLAYCRAFT_TOKEN) {
13
- headers['Authorization'] = `Bearer ${token || process.env.PLAYCRAFT_TOKEN}`;
26
+ if (resolvedToken) {
27
+ headers['Authorization'] = `Bearer ${resolvedToken}`;
14
28
  }
15
29
  if (process.env.NEXT_PUBLIC_LOCAL_AUTH_BYPASS === 'true' && process.env.NEXT_PUBLIC_LOCAL_USER) {
16
30
  headers['X-Local-User'] = process.env.NEXT_PUBLIC_LOCAL_USER;
@@ -0,0 +1,264 @@
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
+ });
@@ -6,6 +6,7 @@ import { saveGlobalConfig, loadGlobalConfig } from '../config.js';
6
6
  export const DEFAULT_PLAYCRAFT_CLOUD_BASE = 'https://playcraft.aix.intlgame.com';
7
7
  const CALLBACK_PORT = 3456;
8
8
  const TIMEOUT_MS = 5 * 60 * 1000;
9
+ const PAT_PREFIX = 'playcraft_pat_';
9
10
  function escapeHtml(text) {
10
11
  return text
11
12
  .replace(/&/g, '&')
@@ -45,8 +46,54 @@ function resolveLoginBaseUrl(options) {
45
46
  DEFAULT_PLAYCRAFT_CLOUD_BASE;
46
47
  return raw.replace(/\/+$/, '');
47
48
  }
49
+ export function createTokenHash(token) {
50
+ return crypto.createHash('sha256').update(token).digest('hex');
51
+ }
52
+ export function buildCliAuthUrl(options) {
53
+ const url = new URL('/cli-auth', options.backendUrl);
54
+ url.searchParams.set('callback', options.callbackUrl);
55
+ url.searchParams.set('state', options.state);
56
+ if (options.existingToken?.startsWith(PAT_PREFIX)) {
57
+ url.searchParams.set('existingTokenHash', createTokenHash(options.existingToken));
58
+ }
59
+ return url.toString();
60
+ }
61
+ export async function renewExistingToken(options) {
62
+ if (!options.token.startsWith(PAT_PREFIX)) {
63
+ return false;
64
+ }
65
+ const fetchFn = options.fetchFn ?? fetch;
66
+ try {
67
+ const res = await fetchFn(`${options.backendUrl}/api/settings/personal-access-tokens/current/renew`, {
68
+ method: 'PATCH',
69
+ headers: {
70
+ Authorization: `Bearer ${options.token}`,
71
+ 'Content-Type': 'application/json',
72
+ },
73
+ body: JSON.stringify({ expiresIn: '365d' }),
74
+ });
75
+ return res.ok;
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
48
81
  export async function loginCommand(options) {
49
82
  const backendUrl = resolveLoginBaseUrl(options);
83
+ const globalConfig = loadGlobalConfig();
84
+ const existingToken = typeof globalConfig.token === 'string' ? globalConfig.token : undefined;
85
+ if (existingToken) {
86
+ const renewed = await renewExistingToken({ backendUrl, token: existingToken });
87
+ if (renewed) {
88
+ saveGlobalConfig({
89
+ token: existingToken,
90
+ url: backendUrl,
91
+ backendUrl,
92
+ });
93
+ console.log('Existing CLI token renewed. Token saved to ~/.playcraft/config.json');
94
+ return;
95
+ }
96
+ }
50
97
  const state = crypto.randomBytes(16).toString('hex');
51
98
  return new Promise((resolve, reject) => {
52
99
  const server = http.createServer((req, res) => {
@@ -72,8 +119,14 @@ export async function loginCommand(options) {
72
119
  });
73
120
  return;
74
121
  }
122
+ const callbackToken = typeof data.token === 'string' ? data.token : undefined;
123
+ const renewedToken = data.renewed === true ? existingToken : undefined;
124
+ const tokenToSave = callbackToken || renewedToken;
125
+ if (!tokenToSave) {
126
+ throw new Error('Login callback did not include a token');
127
+ }
75
128
  saveGlobalConfig({
76
- token: data.token,
129
+ token: tokenToSave,
77
130
  url: backendUrl,
78
131
  backendUrl,
79
132
  });
@@ -124,7 +177,12 @@ export async function loginCommand(options) {
124
177
  try {
125
178
  server.listen(CALLBACK_PORT, () => {
126
179
  const callbackUrl = `http://localhost:${CALLBACK_PORT}/callback`;
127
- const authUrl = `${backendUrl}/cli-auth?callback=${encodeURIComponent(callbackUrl)}&state=${state}`;
180
+ const authUrl = buildCliAuthUrl({
181
+ backendUrl,
182
+ callbackUrl,
183
+ state,
184
+ existingToken,
185
+ });
128
186
  console.log('Opening browser for login...');
129
187
  console.log(`If the browser does not open, visit this URL manually:\n ${authUrl}\n`);
130
188
  openBrowser(authUrl);
package/dist/index.js CHANGED
@@ -127,7 +127,7 @@ program
127
127
  .command('fix-ids')
128
128
  .description('修复已导入项目的 ID 映射(PlayCanvas ID → PlayCraft numericId)')
129
129
  .argument('<project-id>', '项目 ID(numericId 或 UUID)')
130
- .option('--api-url <url>', '后端 API 地址', process.env.BACKEND_API_URL || 'http://localhost:3001')
130
+ .option('--api-url <url>', '后端 API 地址(默认从 login 配置读取,未登录时使用 https://playcraft.aix.intlgame.com)')
131
131
  .option('--token <token>', '认证令牌(可选)')
132
132
  .action(async (projectId, options) => {
133
133
  await fixIdsCommand({
@@ -1,27 +1,59 @@
1
1
  import { readFileSync, existsSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { homedir } from 'os';
4
+ import { loadGlobalConfig } from '../config.js';
5
+ import { DEFAULT_PLAYCRAFT_CLOUD_BASE } from '../commands/login.js';
4
6
  /**
5
- * Lightweight HTTP client for calling PlayCraft backend APIs from inside a sandbox.
6
- * Reads credentials from environment variables or .playcraft.json.
7
+ * 判断 token 是否为用户 PAT(Personal Access Token)。
8
+ * PAT "playcraft_pat_" 开头;沙箱 JWT 为三段式 base64。
9
+ */
10
+ function isPatToken(token) {
11
+ return token.startsWith('playcraft_pat_');
12
+ }
13
+ /**
14
+ * Lightweight HTTP client for calling PlayCraft backend APIs from inside a sandbox
15
+ * or from the CLI after `playcraft login`.
16
+ *
17
+ * 凭证解析优先级:
18
+ * API URL : PLAYCRAFT_API_URL → .playcraft.json → ~/.playcraft/config.json → DEFAULT_PLAYCRAFT_CLOUD_BASE
19
+ * Token : PLAYCRAFT_SANDBOX_TOKEN → .playcraft.json token → ~/.playcraft/config.json token (PAT)
20
+ *
21
+ * 鉴权 header 自动选择:
22
+ * - 沙箱 JWT (非 PAT) → X-Sandbox-Token: <token>(后端 @SandboxToken 路由的沙箱分支)
23
+ * - 用户 PAT → Authorization: Bearer <token>(后端 @SandboxToken 路由的回退 PAT 分支)
7
24
  */
8
25
  export class AgentApiClient {
9
26
  apiUrl;
10
27
  token;
28
+ isPat;
11
29
  constructor() {
12
- let apiUrl = process.env.PLAYCRAFT_API_URL || '';
13
- let token = process.env.PLAYCRAFT_SANDBOX_TOKEN || '';
14
- if (!apiUrl || !token) {
15
- const config = AgentApiClient.loadConfig();
16
- apiUrl = apiUrl || config.apiUrl;
17
- token = token || config.token;
30
+ const localConfig = AgentApiClient.loadLocalConfig();
31
+ const globalConfig = loadGlobalConfig();
32
+ // URL: 环境变量 本地 .playcraft.json → 全局 login 配置 → 公有云默认地址
33
+ const apiUrl = (process.env.PLAYCRAFT_API_URL ||
34
+ localConfig.apiUrl ||
35
+ globalConfig.backendUrl ||
36
+ globalConfig.url ||
37
+ DEFAULT_PLAYCRAFT_CLOUD_BASE).replace(/\/+$/, '');
38
+ // Token: 沙箱 token 环境变量 → 本地 .playcraft.json → 全局 login 配置(PAT)
39
+ const token = process.env.PLAYCRAFT_SANDBOX_TOKEN ||
40
+ localConfig.token ||
41
+ globalConfig.token ||
42
+ '';
43
+ if (!token) {
44
+ throw new Error('Missing PlayCraft API credentials. ' +
45
+ 'Run `playcraft login` to authenticate, or set PLAYCRAFT_SANDBOX_TOKEN / create a .playcraft.json file.');
18
46
  }
19
- if (!apiUrl || !token) {
20
- throw new Error('Missing PlayCraft API credentials. Set PLAYCRAFT_API_URL and PLAYCRAFT_SANDBOX_TOKEN, ' +
21
- 'or create a .playcraft.json file.');
22
- }
23
- this.apiUrl = apiUrl.replace(/\/+$/, '');
47
+ this.apiUrl = apiUrl;
24
48
  this.token = token;
49
+ this.isPat = isPatToken(token);
50
+ }
51
+ /** 根据 token 类型构造鉴权 headers */
52
+ authHeaders() {
53
+ if (this.isPat) {
54
+ return { 'Authorization': `Bearer ${this.token}` };
55
+ }
56
+ return { 'X-Sandbox-Token': this.token };
25
57
  }
26
58
  /** Absolute pathname under /api/agent/tools (path may omit leading slash). */
27
59
  toolsRequestUrl(path) {
@@ -34,7 +66,7 @@ export class AgentApiClient {
34
66
  method: 'POST',
35
67
  headers: {
36
68
  'Content-Type': 'application/json',
37
- 'X-Sandbox-Token': this.token,
69
+ ...this.authHeaders(),
38
70
  },
39
71
  body: JSON.stringify(body),
40
72
  });
@@ -53,7 +85,7 @@ export class AgentApiClient {
53
85
  }
54
86
  }
55
87
  const res = await fetch(url, {
56
- headers: { 'X-Sandbox-Token': this.token },
88
+ headers: this.authHeaders(),
57
89
  });
58
90
  if (!res.ok) {
59
91
  const text = await res.text().catch(() => res.statusText);
@@ -61,7 +93,11 @@ export class AgentApiClient {
61
93
  }
62
94
  return res.json();
63
95
  }
64
- static loadConfig() {
96
+ /**
97
+ * 按顺序搜索本地 .playcraft.json 文件(沙箱内或项目目录)。
98
+ * 与全局 login 配置(~/.playcraft/config.json)不同,前者专用于沙箱 token。
99
+ */
100
+ static loadLocalConfig() {
65
101
  const searchPaths = [
66
102
  join(process.cwd(), '.playcraft.json'),
67
103
  '/project/.playcraft.json',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.0.39",
3
+ "version": "0.0.40",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,8 +23,8 @@
23
23
  "release": "node scripts/release.js"
24
24
  },
25
25
  "dependencies": {
26
- "@playcraft/build": "^0.0.39",
27
- "@playcraft/common": "^0.0.27",
26
+ "@playcraft/build": "^0.0.41",
27
+ "@playcraft/common": "^0.0.29",
28
28
  "chokidar": "^4.0.3",
29
29
  "commander": "^13.1.0",
30
30
  "cors": "^2.8.6",