@playcraft/cli 0.0.49 → 0.0.52

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.
@@ -46,16 +46,37 @@ function resolveLoginBaseUrl(options) {
46
46
  DEFAULT_PLAYCRAFT_CLOUD_BASE;
47
47
  return raw.replace(/\/+$/, '');
48
48
  }
49
- export function createTokenHash(token) {
50
- return crypto.createHash('sha256').update(token).digest('hex');
49
+ function normalizeLoginEnvironmentUrl(raw) {
50
+ try {
51
+ const url = new URL(raw);
52
+ url.hash = '';
53
+ url.search = '';
54
+ url.pathname = url.pathname.replace(/\/+$/, '');
55
+ return url.toString().replace(/\/+$/, '');
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ }
61
+ export function resolveExistingTokenForLoginTarget(globalConfig, backendUrl) {
62
+ const token = typeof globalConfig.token === 'string' ? globalConfig.token : undefined;
63
+ const storedBackendUrl = typeof globalConfig.backendUrl === 'string'
64
+ ? globalConfig.backendUrl
65
+ : typeof globalConfig.url === 'string'
66
+ ? globalConfig.url
67
+ : undefined;
68
+ if (!token || !storedBackendUrl)
69
+ return undefined;
70
+ const storedEnvironment = normalizeLoginEnvironmentUrl(storedBackendUrl);
71
+ const targetEnvironment = normalizeLoginEnvironmentUrl(backendUrl);
72
+ return storedEnvironment && targetEnvironment && storedEnvironment === targetEnvironment
73
+ ? token
74
+ : undefined;
51
75
  }
52
76
  export function buildCliAuthUrl(options) {
53
77
  const url = new URL('/cli-auth', options.backendUrl);
54
78
  url.searchParams.set('callback', options.callbackUrl);
55
79
  url.searchParams.set('state', options.state);
56
- if (options.existingToken?.startsWith(PAT_PREFIX)) {
57
- url.searchParams.set('existingTokenHash', createTokenHash(options.existingToken));
58
- }
59
80
  return url.toString();
60
81
  }
61
82
  export async function renewExistingToken(options) {
@@ -80,8 +101,9 @@ export async function renewExistingToken(options) {
80
101
  }
81
102
  export async function loginCommand(options) {
82
103
  const backendUrl = resolveLoginBaseUrl(options);
104
+ const callbackPort = options.callbackPort ?? CALLBACK_PORT;
83
105
  const globalConfig = loadGlobalConfig();
84
- const existingToken = typeof globalConfig.token === 'string' ? globalConfig.token : undefined;
106
+ const existingToken = resolveExistingTokenForLoginTarget(globalConfig, backendUrl);
85
107
  if (existingToken) {
86
108
  const renewed = await renewExistingToken({ backendUrl, token: existingToken });
87
109
  if (renewed) {
@@ -96,6 +118,7 @@ export async function loginCommand(options) {
96
118
  }
97
119
  const state = crypto.randomBytes(16).toString('hex');
98
120
  return new Promise((resolve, reject) => {
121
+ let timeout;
99
122
  const server = http.createServer((req, res) => {
100
123
  res.setHeader('Access-Control-Allow-Origin', '*');
101
124
  res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
@@ -112,34 +135,41 @@ export async function loginCommand(options) {
112
135
  try {
113
136
  const data = JSON.parse(body);
114
137
  if (data.state !== state) {
115
- res.writeHead(403, { 'Content-Type': 'text/html' });
116
- res.end('<html><body><h1>Invalid state</h1></body></html>');
117
- server.close(() => {
118
- reject(new Error('State mismatch - possible CSRF attack'));
138
+ res.writeHead(403, {
139
+ 'Content-Type': 'text/html',
140
+ Connection: 'close',
141
+ });
142
+ res.end('<html><body><h1>Invalid state</h1></body></html>', () => {
143
+ closeServer(() => {
144
+ reject(new Error('State mismatch - possible CSRF attack'));
145
+ });
119
146
  });
120
147
  return;
121
148
  }
122
149
  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) {
150
+ if (!callbackToken) {
126
151
  throw new Error('Login callback did not include a token');
127
152
  }
128
153
  saveGlobalConfig({
129
- token: tokenToSave,
154
+ token: callbackToken,
130
155
  url: backendUrl,
131
156
  backendUrl,
132
157
  });
133
- res.writeHead(200, { 'Content-Type': 'text/html' });
158
+ res.writeHead(200, {
159
+ 'Content-Type': 'text/html',
160
+ Connection: 'close',
161
+ });
134
162
  res.end('<html>' +
135
163
  '<head><meta charset="utf-8"><title>Login Success</title></head>' +
136
164
  '<body style="font-family:system-ui;text-align:center;padding:40px">' +
137
165
  '<h1>CLI Login Successful</h1>' +
138
166
  '<p>You can close this window and return to your terminal.</p>' +
139
- '</body></html>');
140
- server.close();
141
- console.log('Login successful! Token saved to ~/.playcraft/config.json');
142
- resolve();
167
+ '</body></html>', () => {
168
+ closeServer(() => {
169
+ console.log('Login successful! Token saved to ~/.playcraft/config.json');
170
+ resolve();
171
+ });
172
+ });
143
173
  }
144
174
  catch (err) {
145
175
  const cause = err instanceof Error ? err : new Error(String(err));
@@ -150,23 +180,39 @@ export async function loginCommand(options) {
150
180
  const userFacing = cause instanceof SyntaxError
151
181
  ? 'Invalid JSON in login callback.'
152
182
  : `Login callback failed: ${cause.message}`;
153
- res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
183
+ res.writeHead(500, {
184
+ 'Content-Type': 'text/html; charset=utf-8',
185
+ Connection: 'close',
186
+ });
154
187
  res.end('<html><head><meta charset="utf-8"/><title>Error</title></head>' +
155
188
  '<body style="font-family:system-ui;padding:24px">' +
156
189
  `<h1>Error</h1><p>${escapeHtml(userFacing)}</p>` +
157
- '</body></html>');
158
- server.close(() => {
159
- reject(new Error(userFacing));
190
+ '</body></html>', () => {
191
+ closeServer(() => {
192
+ reject(new Error(userFacing));
193
+ });
160
194
  });
161
195
  }
162
196
  });
163
197
  }
164
198
  });
199
+ const closeServer = (onClosed) => {
200
+ if (timeout) {
201
+ clearTimeout(timeout);
202
+ timeout = undefined;
203
+ }
204
+ if (!server.listening) {
205
+ onClosed();
206
+ return;
207
+ }
208
+ server.close(onClosed);
209
+ server.closeAllConnections();
210
+ };
165
211
  // Bind failures emit 'error' (e.g. EADDRINUSE); synchronously thrown errors from listen() are caught below.
166
212
  server.on('error', (err) => {
167
- server.close(() => {
213
+ closeServer(() => {
168
214
  if (err.code === 'EADDRINUSE') {
169
- reject(new Error(`Port ${CALLBACK_PORT} is already in use. ` +
215
+ reject(new Error(`Port ${callbackPort} is already in use. ` +
170
216
  'Please close any other playcraft login process and try again.'));
171
217
  }
172
218
  else {
@@ -175,13 +221,14 @@ export async function loginCommand(options) {
175
221
  });
176
222
  });
177
223
  try {
178
- server.listen(CALLBACK_PORT, () => {
179
- const callbackUrl = `http://localhost:${CALLBACK_PORT}/callback`;
224
+ server.listen(callbackPort, () => {
225
+ const address = server.address();
226
+ const listeningPort = typeof address === 'object' && address ? address.port : callbackPort;
227
+ const callbackUrl = `http://localhost:${listeningPort}/callback`;
180
228
  const authUrl = buildCliAuthUrl({
181
229
  backendUrl,
182
230
  callbackUrl,
183
231
  state,
184
- existingToken,
185
232
  });
186
233
  console.log('Opening browser for login...');
187
234
  console.log(`If the browser does not open, visit this URL manually:\n ${authUrl}\n`);
@@ -190,19 +237,22 @@ export async function loginCommand(options) {
190
237
  });
191
238
  }
192
239
  catch (err) {
193
- server.close(() => {
240
+ closeServer(() => {
194
241
  reject(err instanceof Error ? err : new Error(String(err)));
195
242
  });
196
243
  return;
197
244
  }
198
245
  // Timeout handler
199
- const timeout = setTimeout(() => {
200
- server.close(() => {
246
+ timeout = setTimeout(() => {
247
+ closeServer(() => {
201
248
  reject(new Error('Login timed out after 5 minutes. Please try again.'));
202
249
  });
203
250
  }, TIMEOUT_MS);
204
251
  server.on('close', () => {
205
- clearTimeout(timeout);
252
+ if (timeout) {
253
+ clearTimeout(timeout);
254
+ timeout = undefined;
255
+ }
206
256
  });
207
257
  });
208
258
  }