@playcraft/cli 0.1.15 → 0.1.17

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.
@@ -1,7 +1,7 @@
1
1
  import http from 'http';
2
- import { exec } from 'child_process';
2
+ import { execFile } from 'child_process';
3
3
  import crypto from 'crypto';
4
- import { saveGlobalConfig, loadGlobalConfig } from '../config.js';
4
+ import { loadGlobalConfig, normalizeEnvironmentBaseUrl, readEnvironmentProfile, saveEnvironmentProfile } from '../config.js';
5
5
  import { activateAccount } from '../auth/account-activation.js';
6
6
  /** 默认云端 Portal(Next.js `/cli-auth` 与 API 同源时的基址;可通过 --url 或 PLAYCRAFT_URL 覆盖) */
7
7
  export const DEFAULT_PLAYCRAFT_CLOUD_BASE = 'https://playcraft.aix.intlgame.com';
@@ -19,10 +19,11 @@ function openBrowser(url) {
19
19
  const cmd = process.platform === 'darwin'
20
20
  ? 'open'
21
21
  : process.platform === 'win32'
22
- ? 'start'
22
+ ? 'rundll32'
23
23
  : 'xdg-open';
24
24
  try {
25
- exec(`${cmd} "${url}"`, (err, _stdout, stderr) => {
25
+ const arguments_ = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
26
+ execFile(cmd, arguments_, (err, _stdout, stderr) => {
26
27
  if (err) {
27
28
  console.error('Failed to open browser automatically:', err.message);
28
29
  if (stderr?.trim()) {
@@ -45,21 +46,18 @@ function resolveLoginBaseUrl(options) {
45
46
  globalConfig.url ||
46
47
  globalConfig.backendUrl ||
47
48
  DEFAULT_PLAYCRAFT_CLOUD_BASE;
48
- return raw.replace(/\/+$/, '');
49
+ const base = normalizeEnvironmentBaseUrl(raw);
50
+ if (!base)
51
+ throw new Error('Invalid environment address');
52
+ return base;
49
53
  }
50
54
  function normalizeLoginEnvironmentUrl(raw) {
51
- try {
52
- const url = new URL(raw);
53
- url.hash = '';
54
- url.search = '';
55
- url.pathname = url.pathname.replace(/\/+$/, '');
56
- return url.toString().replace(/\/+$/, '');
57
- }
58
- catch {
59
- return undefined;
60
- }
55
+ return normalizeEnvironmentBaseUrl(raw);
61
56
  }
62
57
  export function resolveExistingTokenForLoginTarget(globalConfig, backendUrl) {
58
+ const profile = readEnvironmentProfile(globalConfig, backendUrl);
59
+ if (profile)
60
+ return profile.token;
63
61
  const token = typeof globalConfig.token === 'string' ? globalConfig.token : undefined;
64
62
  const storedBackendUrl = typeof globalConfig.backendUrl === 'string'
65
63
  ? globalConfig.backendUrl
@@ -75,9 +73,17 @@ export function resolveExistingTokenForLoginTarget(globalConfig, backendUrl) {
75
73
  : undefined;
76
74
  }
77
75
  export function buildCliAuthUrl(options) {
78
- const url = new URL('/cli-auth', options.backendUrl);
76
+ const base = normalizeEnvironmentBaseUrl(options.backendUrl);
77
+ if (!base)
78
+ throw new Error('Invalid environment address');
79
+ const url = new URL(`${base}/cli-auth`);
80
+ url.searchParams.set('callbackUrl', options.callbackUrl);
79
81
  url.searchParams.set('callback', options.callbackUrl);
80
82
  url.searchParams.set('state', options.state);
83
+ if (options.codeChallenge) {
84
+ url.searchParams.set('codeChallenge', options.codeChallenge);
85
+ url.searchParams.set('codeChallengeMethod', 'S256');
86
+ }
81
87
  return url.toString();
82
88
  }
83
89
  export async function renewExistingToken(options) {
@@ -88,12 +94,20 @@ export async function renewExistingToken(options) {
88
94
  try {
89
95
  const res = await fetchFn(`${options.backendUrl}/api/settings/personal-access-tokens/current/renew`, {
90
96
  method: 'PATCH',
97
+ redirect: 'error',
98
+ signal: AbortSignal.timeout(10_000),
91
99
  headers: {
92
100
  Authorization: `Bearer ${options.token}`,
93
101
  'Content-Type': 'application/json',
94
102
  },
95
103
  body: JSON.stringify({ expiresIn: '365d' }),
96
104
  });
105
+ if (res.ok && res.json && options.onRenewed) {
106
+ const result = await res.json();
107
+ if (typeof result.expiresAt === 'string' && Date.parse(result.expiresAt) > Date.now()) {
108
+ options.onRenewed(result.expiresAt);
109
+ }
110
+ }
97
111
  return res.ok;
98
112
  }
99
113
  catch {
@@ -110,24 +124,34 @@ export async function loginCommand(options) {
110
124
  const globalConfig = loadGlobalConfig();
111
125
  const existingToken = resolveExistingTokenForLoginTarget(globalConfig, backendUrl);
112
126
  if (existingToken) {
113
- const renewed = await renewExistingToken({ backendUrl, token: existingToken });
127
+ let expiresAt;
128
+ const renewed = await renewExistingToken({ backendUrl, token: existingToken,
129
+ onRenewed: (value) => { expiresAt = value; } });
114
130
  if (renewed) {
115
- saveGlobalConfig({
116
- token: existingToken,
117
- url: backendUrl,
118
- backendUrl,
119
- });
131
+ const previous = readEnvironmentProfile(globalConfig, backendUrl);
132
+ saveEnvironmentProfile(backendUrl, { token: existingToken,
133
+ ...(previous?.identity ? { identity: previous.identity } : {}),
134
+ ...(expiresAt ? { expiresAt } : {}) });
120
135
  console.log('Existing CLI token renewed. Token saved to ~/.playcraft/config.json');
121
136
  return;
122
137
  }
123
138
  }
124
139
  const state = crypto.randomBytes(16).toString('hex');
140
+ const codeVerifier = crypto.randomBytes(32).toString('base64url');
141
+ const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
142
+ let callbackUrl = '';
143
+ let exchanging = false;
125
144
  return new Promise((resolve, reject) => {
126
145
  let timeout;
127
146
  const server = http.createServer((req, res) => {
128
- res.setHeader('Access-Control-Allow-Origin', '*');
147
+ res.setHeader('Access-Control-Allow-Origin', new URL(backendUrl).origin);
129
148
  res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
130
149
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
150
+ if (req.headers.origin && req.headers.origin !== new URL(backendUrl).origin) {
151
+ res.writeHead(403);
152
+ res.end();
153
+ return;
154
+ }
131
155
  if (req.method === 'OPTIONS') {
132
156
  res.writeHead(204);
133
157
  res.end();
@@ -135,8 +159,9 @@ export async function loginCommand(options) {
135
159
  }
136
160
  if (req.url === '/callback' && req.method === 'POST') {
137
161
  let body = '';
138
- req.on('data', (chunk) => (body += chunk));
139
- req.on('end', () => {
162
+ req.on('data', (chunk) => { body += chunk; if (body.length > 16_384)
163
+ req.destroy(); });
164
+ req.on('end', async () => {
140
165
  try {
141
166
  const data = JSON.parse(body);
142
167
  if (data.state !== state) {
@@ -144,22 +169,36 @@ export async function loginCommand(options) {
144
169
  'Content-Type': 'text/html',
145
170
  Connection: 'close',
146
171
  });
147
- res.end('<html><body><h1>Invalid state</h1></body></html>', () => {
148
- closeServer(() => {
149
- reject(new Error('State mismatch - possible CSRF attack'));
150
- });
151
- });
172
+ res.end('<html><body><h1>Invalid state</h1></body></html>');
152
173
  return;
153
174
  }
154
- const callbackToken = typeof data.token === 'string' ? data.token : undefined;
155
- if (!callbackToken) {
156
- throw new Error('Login callback did not include a token');
175
+ if (exchanging) {
176
+ res.writeHead(409);
177
+ res.end();
178
+ return;
179
+ }
180
+ if (typeof data.code !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(data.code)) {
181
+ throw new Error('Login callback did not include an authorization code');
157
182
  }
158
- saveGlobalConfig({
159
- token: callbackToken,
160
- url: backendUrl,
161
- backendUrl,
183
+ exchanging = true;
184
+ const base = normalizeEnvironmentBaseUrl(backendUrl);
185
+ if (!base)
186
+ throw new Error('Invalid environment address');
187
+ const exchange = await fetch(`${base}/api/cloud-environments/exchange`, {
188
+ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
189
+ headers: { 'Content-Type': 'application/json' },
190
+ body: JSON.stringify({ code: data.code, state, callbackUrl, codeVerifier }),
162
191
  });
192
+ if (!exchange.ok)
193
+ throw new Error('Authorization code exchange failed');
194
+ const credential = await exchange.json();
195
+ if (!credential.token || !/^playcraft_pat_[A-Za-z0-9_-]{32}$/.test(credential.token)
196
+ || !credential.identity?.userId || !Array.isArray(credential.identity.identities)
197
+ || !credential.expiresAt || Date.parse(credential.expiresAt) <= Date.now()) {
198
+ throw new Error('Invalid environment credential response');
199
+ }
200
+ saveEnvironmentProfile(base, { token: credential.token,
201
+ identity: credential.identity, expiresAt: credential.expiresAt });
163
202
  res.writeHead(200, {
164
203
  'Content-Type': 'text/html',
165
204
  Connection: 'close',
@@ -179,9 +218,6 @@ export async function loginCommand(options) {
179
218
  catch (err) {
180
219
  const cause = err instanceof Error ? err : new Error(String(err));
181
220
  console.error('[playcraft login] Callback handling failed:', cause.message);
182
- if (cause.stack) {
183
- console.error(cause.stack);
184
- }
185
221
  const userFacing = cause instanceof SyntaxError
186
222
  ? 'Invalid JSON in login callback.'
187
223
  : `Login callback failed: ${cause.message}`;
@@ -226,14 +262,15 @@ export async function loginCommand(options) {
226
262
  });
227
263
  });
228
264
  try {
229
- server.listen(callbackPort, () => {
265
+ server.listen(callbackPort, '127.0.0.1', () => {
230
266
  const address = server.address();
231
267
  const listeningPort = typeof address === 'object' && address ? address.port : callbackPort;
232
- const callbackUrl = `http://localhost:${listeningPort}/callback`;
268
+ callbackUrl = `http://127.0.0.1:${listeningPort}/callback`;
233
269
  const authUrl = buildCliAuthUrl({
234
270
  backendUrl,
235
271
  callbackUrl,
236
272
  state,
273
+ codeChallenge,
237
274
  });
238
275
  console.log('Opening browser for login...');
239
276
  console.log(`If the browser does not open, visit this URL manually:\n ${authUrl}\n`);
package/dist/config.js CHANGED
@@ -2,7 +2,8 @@ import { cosmiconfig } from 'cosmiconfig';
2
2
  import { z } from 'zod';
3
3
  import path from 'path';
4
4
  import { homedir } from 'os';
5
- import { mkdirSync, writeFileSync, readFileSync } from 'fs';
5
+ import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync, readFileSync } from 'fs';
6
+ import { randomUUID } from 'node:crypto';
6
7
  import 'dotenv/config';
7
8
  export const ConfigSchema = z.object({
8
9
  projectId: z.string().optional(),
@@ -18,6 +19,33 @@ const explorer = cosmiconfig('playcraft', {
18
19
  });
19
20
  const GLOBAL_CONFIG_DIR = path.join(homedir(), '.playcraft');
20
21
  const GLOBAL_CONFIG_FILE = path.join(GLOBAL_CONFIG_DIR, 'config.json');
22
+ export function normalizeEnvironmentBaseUrl(raw) {
23
+ try {
24
+ const url = new URL(raw);
25
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password)
26
+ return undefined;
27
+ url.hash = '';
28
+ url.search = '';
29
+ url.pathname = url.pathname.replace(/\/+$/, '').replace(/\/api$/, '');
30
+ return url.toString().replace(/\/+$/, '');
31
+ }
32
+ catch {
33
+ return undefined;
34
+ }
35
+ }
36
+ export function readEnvironmentProfile(config, baseUrl) {
37
+ const key = normalizeEnvironmentBaseUrl(baseUrl);
38
+ const profiles = config.environmentProfiles;
39
+ if (!key || !profiles || typeof profiles !== 'object' || Array.isArray(profiles))
40
+ return undefined;
41
+ const profile = profiles[key];
42
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile))
43
+ return undefined;
44
+ const value = profile;
45
+ if (typeof value.token !== 'string' || !value.token || normalizeEnvironmentBaseUrl(value.backendUrl) !== key)
46
+ return undefined;
47
+ return value;
48
+ }
21
49
  export function loadGlobalConfig() {
22
50
  try {
23
51
  const content = readFileSync(GLOBAL_CONFIG_FILE, 'utf-8');
@@ -28,7 +56,8 @@ export function loadGlobalConfig() {
28
56
  }
29
57
  }
30
58
  export function saveGlobalConfig(partial) {
31
- mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true });
59
+ mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true, mode: 0o700 });
60
+ chmodSync(GLOBAL_CONFIG_DIR, 0o700);
32
61
  let existing = {};
33
62
  try {
34
63
  existing = JSON.parse(readFileSync(GLOBAL_CONFIG_FILE, 'utf-8'));
@@ -37,7 +66,25 @@ export function saveGlobalConfig(partial) {
37
66
  // ignore
38
67
  }
39
68
  const merged = { ...existing, ...partial };
40
- writeFileSync(GLOBAL_CONFIG_FILE, JSON.stringify(merged, null, 2), 'utf-8');
69
+ const temporary = path.join(GLOBAL_CONFIG_DIR, `.config-${randomUUID()}.tmp`);
70
+ try {
71
+ writeFileSync(temporary, JSON.stringify(merged, null, 2), { encoding: 'utf-8', flag: 'wx', mode: 0o600 });
72
+ renameSync(temporary, GLOBAL_CONFIG_FILE);
73
+ }
74
+ finally {
75
+ rmSync(temporary, { force: true });
76
+ }
77
+ }
78
+ export function saveEnvironmentProfile(baseUrl, profile) {
79
+ const key = normalizeEnvironmentBaseUrl(baseUrl);
80
+ if (!key)
81
+ throw new Error('Invalid environment address');
82
+ const current = loadGlobalConfig();
83
+ const profiles = current.environmentProfiles && typeof current.environmentProfiles === 'object'
84
+ && !Array.isArray(current.environmentProfiles) ? current.environmentProfiles : {};
85
+ saveGlobalConfig({ environmentProfiles: { ...profiles,
86
+ [key]: { ...profile, backendUrl: key, updatedAt: new Date().toISOString() } },
87
+ token: profile.token, url: key, backendUrl: key });
41
88
  }
42
89
  export async function loadConfig(cliOptions) {
43
90
  const result = await explorer.search(cliOptions.dir || process.cwd());
@@ -46,12 +93,20 @@ export async function loadConfig(cliOptions) {
46
93
  ? (fileConfig.agent || {})
47
94
  : fileConfig;
48
95
  const globalConfig = loadGlobalConfig();
96
+ const explicitTarget = cliOptions.url || process.env.PLAYCRAFT_URL || agentConfig.url;
97
+ const targetUrl = explicitTarget || globalConfig.url;
98
+ const profile = typeof targetUrl === 'string' ? readEnvironmentProfile(globalConfig, targetUrl) : undefined;
99
+ const legacyTarget = typeof globalConfig.backendUrl === 'string' ? globalConfig.backendUrl
100
+ : typeof globalConfig.url === 'string' ? globalConfig.url : undefined;
101
+ const legacyToken = !explicitTarget || (legacyTarget && normalizeEnvironmentBaseUrl(String(targetUrl)) === normalizeEnvironmentBaseUrl(legacyTarget))
102
+ ? globalConfig.token : undefined;
49
103
  const config = ConfigSchema.parse({
50
104
  ...globalConfig,
51
105
  ...agentConfig,
52
106
  ...cliOptions,
53
107
  projectId: cliOptions.projectId || process.env.PLAYCRAFT_PROJECT_ID || agentConfig.projectId || globalConfig.projectId,
54
- token: cliOptions.token || process.env.PLAYCRAFT_TOKEN || agentConfig.token || globalConfig.token,
108
+ url: targetUrl,
109
+ token: cliOptions.token || process.env.PLAYCRAFT_TOKEN || agentConfig.token || profile?.token || legacyToken,
55
110
  port: cliOptions.port || (process.env.PLAYCRAFT_PORT ? parseInt(process.env.PLAYCRAFT_PORT) : undefined) || agentConfig.port || globalConfig.port,
56
111
  mode: cliOptions.mode || process.env.PLAYCRAFT_MODE || agentConfig.mode || globalConfig.mode,
57
112
  skillsDir: cliOptions.skillsDir || process.env.AGENT_SKILLS_DIR || agentConfig.skillsDir || globalConfig.skillsDir,
@@ -5,6 +5,7 @@ import { loadGlobalConfig } from '../config.js';
5
5
  import { DEFAULT_PLAYCRAFT_CLOUD_BASE } from '../commands/login.js';
6
6
  import { AGENT_TOOL_OPERATION_PROTOCOL, AGENT_TOOL_OPERATION_HEADERS, AGENT_TOOL_OPERATION_CAPABILITIES_PATH, } from '@playcraft/common/agent-tools';
7
7
  import { beginToolOperation } from './tool-operation-journal.js';
8
+ import { businessEndpoint } from './environment-endpoints.js';
8
9
  import { AccountActivationError, parseErrorPayload, pendingAccountActivationError, } from '../auth/account-activation.js';
9
10
  /**
10
11
  * 判断 token 是否为用户 PAT(Personal Access Token)。
@@ -71,10 +72,10 @@ export class AgentApiClient {
71
72
  }
72
73
  return headers;
73
74
  }
74
- /** Absolute pathname under /api/agent/tools (path may omit leading slash). */
75
+ /** Deployment-relative pathname under /api/agent/tools (path may omit leading slash). */
75
76
  toolsRequestUrl(path) {
76
77
  const p = path.startsWith('/') ? path : `/${path}`;
77
- return new URL(`/api/agent/tools${p}`, this.apiUrl);
78
+ return businessEndpoint(this.apiUrl, `/api/agent/tools${p}`);
78
79
  }
79
80
  async post(path, body, options) {
80
81
  const url = this.toolsRequestUrl(path);
@@ -0,0 +1,17 @@
1
+ function publicBase(value) {
2
+ const base = new URL(value);
3
+ if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password || base.search || base.hash) {
4
+ throw new Error('invalid_environment_endpoint');
5
+ }
6
+ base.pathname = base.pathname.replace(/\/+$/, '');
7
+ return base;
8
+ }
9
+ function append(base, path) {
10
+ return new URL(`${base.toString().replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`);
11
+ }
12
+ /** Accept either a public deployment base or its /api endpoint, without duplicating /api. */
13
+ export function businessEndpoint(apiUrl, path) {
14
+ const base = publicBase(apiUrl);
15
+ base.pathname = base.pathname.replace(/\/api$/, '');
16
+ return append(base, path);
17
+ }