@payloadcms/figma 0.0.1-alpha.71 → 0.0.1-alpha.73

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.
Files changed (36) hide show
  1. package/dist/api/control-plane.js +9 -2
  2. package/dist/api/error-response.d.ts +37 -0
  3. package/dist/api/error-response.js +68 -0
  4. package/dist/api/figma-api.js +10 -15
  5. package/dist/auth/oauth-flow.js +75 -11
  6. package/dist/auth/paste-code.d.ts +34 -0
  7. package/dist/auth/paste-code.js +89 -0
  8. package/dist/cli.js +2 -0
  9. package/dist/commands/deploy.d.ts +2 -0
  10. package/dist/commands/deploy.js +17 -4
  11. package/dist/commands/init.js +2 -2
  12. package/dist/oauth/components/LoginButton/index.js +4 -2
  13. package/dist/oauth/defaults.js +4 -1
  14. package/dist/oauth/endpoints/getLoginEndpoint.js +3 -1
  15. package/dist/oauth/hooks/refresh.js +2 -1
  16. package/dist/oauth/utilities/clearCookie.js +5 -1
  17. package/dist/oauth/utilities/createCookieOptions.js +9 -2
  18. package/dist/oauth/utilities/establishSession.js +4 -3
  19. package/dist/oauth/utilities/refreshTokens.js +2 -1
  20. package/dist/oauth/utilities/withPartitionedCookie.d.ts +20 -0
  21. package/dist/oauth/utilities/withPartitionedCookie.js +28 -0
  22. package/dist/utils/adapters/nextjs.d.ts +2 -2
  23. package/dist/utils/adapters/nextjs.js +1 -2
  24. package/dist/utils/adapters/nitro.d.ts +2 -2
  25. package/dist/utils/adapters/nitro.js +7 -2
  26. package/dist/utils/adapters/vite.d.ts +2 -2
  27. package/dist/utils/build-lambda-zip.d.ts +11 -1
  28. package/dist/utils/build-lambda-zip.js +14 -0
  29. package/dist/utils/deploy-adapter.d.ts +8 -2
  30. package/dist/utils/fs-utils.d.ts +7 -0
  31. package/dist/utils/fs-utils.js +30 -0
  32. package/dist/utils/messages.js +1 -0
  33. package/dist/utils/project.d.ts +1 -1
  34. package/dist/utils/project.js +2 -2
  35. package/dist/utils/s3-upload.js +5 -1
  36. package/package.json +1 -1
@@ -5,6 +5,7 @@
5
5
  */ import { getAuthHeaders } from '../auth/credentials.js';
6
6
  import { getEnvConfig } from '../constants.js';
7
7
  import * as log from '../utils/log.js';
8
+ import { parseControlPlaneApiError } from './error-response.js';
8
9
  /**
9
10
  * Get the Control Plane API base URL based on environment
10
11
  */ function getControlPlaneBaseUrl() {
@@ -330,9 +331,15 @@ async function controlPlaneFetch(params) {
330
331
  return response;
331
332
  }
332
333
  if (response.status < 500) {
333
- throw new ControlPlaneError(`Failed to ${context}: ${response.status} ${response.statusText}`, response.status);
334
+ throw new ControlPlaneError(await parseControlPlaneApiError({
335
+ context,
336
+ response
337
+ }), response.status);
334
338
  }
335
- lastError = new ControlPlaneError(`Failed to ${context}: ${response.status} ${response.statusText}`, response.status);
339
+ lastError = new ControlPlaneError(await parseControlPlaneApiError({
340
+ context,
341
+ response
342
+ }), response.status);
336
343
  } catch (error) {
337
344
  if (error instanceof ControlPlaneError) {
338
345
  throw error;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Extract a human-readable error message from a non-ok control-plane API Response.
3
+ *
4
+ * Sinatra's error helpers (api_error, api_reject, api_not_found, api_needs_upgrade)
5
+ * all serialize the user-facing string under a `message` key. Surfacing it lets the
6
+ * CLI show the real reason (e.g. "Deployment package is too large...") instead of a
7
+ * bare status code.
8
+ *
9
+ * Scoped to JSON `{ message }` bodies. S3 upload errors arrive as XML and need a
10
+ * separate parser.
11
+ *
12
+ * Only called on the error path, immediately before throwing — the body is never read
13
+ * again, so consuming it here is safe (no clone needed). Never throws: any parse
14
+ * failure or missing message falls back to the HTTP status line.
15
+ */
16
+ export declare function parseControlPlaneApiError(params: {
17
+ context: string;
18
+ response: Response;
19
+ }): Promise<string>;
20
+ /**
21
+ * Extract a human-readable error message from a non-ok S3 upload Response.
22
+ *
23
+ * S3 reports failures (e.g. SignatureDoesNotMatch, AccessDenied / expired signatures)
24
+ * as an XML document whose first `<Code>` and `<Message>` carry the reason — not the
25
+ * JSON shape {@link parseControlPlaneApiError} expects. The document also includes
26
+ * sibling tags (StringToSign, CanonicalRequest, RequestId), so we extract only those
27
+ * two so the CLI can show the real reason instead of a bare status code.
28
+ *
29
+ * Only called on the error path, immediately before throwing — the body is never read
30
+ * again, so consuming it here is safe (no clone needed). Never throws: any parse
31
+ * failure or missing fields falls back to the HTTP status line.
32
+ */
33
+ export declare function parseS3Error(params: {
34
+ context: string;
35
+ response: Response;
36
+ }): Promise<string>;
37
+ //# sourceMappingURL=error-response.d.ts.map
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Extract a human-readable error message from a non-ok control-plane API Response.
3
+ *
4
+ * Sinatra's error helpers (api_error, api_reject, api_not_found, api_needs_upgrade)
5
+ * all serialize the user-facing string under a `message` key. Surfacing it lets the
6
+ * CLI show the real reason (e.g. "Deployment package is too large...") instead of a
7
+ * bare status code.
8
+ *
9
+ * Scoped to JSON `{ message }` bodies. S3 upload errors arrive as XML and need a
10
+ * separate parser.
11
+ *
12
+ * Only called on the error path, immediately before throwing — the body is never read
13
+ * again, so consuming it here is safe (no clone needed). Never throws: any parse
14
+ * failure or missing message falls back to the HTTP status line.
15
+ */ export async function parseControlPlaneApiError(params) {
16
+ const { context, response } = params;
17
+ const fallback = `Failed to ${context}: ${response.status} ${response.statusText}`;
18
+ try {
19
+ const body = await response.json();
20
+ if (typeof body.message === 'string' && body.message.trim().length > 0) {
21
+ return `Failed to ${context}: ${response.status} ${body.message.trim()}`;
22
+ }
23
+ } catch {
24
+ // Non-JSON body, empty body, or no .json() — fall back to the status line.
25
+ }
26
+ return fallback;
27
+ }
28
+ /**
29
+ * Extract a human-readable error message from a non-ok S3 upload Response.
30
+ *
31
+ * S3 reports failures (e.g. SignatureDoesNotMatch, AccessDenied / expired signatures)
32
+ * as an XML document whose first `<Code>` and `<Message>` carry the reason — not the
33
+ * JSON shape {@link parseControlPlaneApiError} expects. The document also includes
34
+ * sibling tags (StringToSign, CanonicalRequest, RequestId), so we extract only those
35
+ * two so the CLI can show the real reason instead of a bare status code.
36
+ *
37
+ * Only called on the error path, immediately before throwing — the body is never read
38
+ * again, so consuming it here is safe (no clone needed). Never throws: any parse
39
+ * failure or missing fields falls back to the HTTP status line.
40
+ */ export async function parseS3Error(params) {
41
+ const { context, response } = params;
42
+ const fallback = `Failed to ${context}: ${response.status} ${response.statusText}`;
43
+ try {
44
+ const body = await response.text();
45
+ const code = extractXmlTag(body, 'Code');
46
+ const message = extractXmlTag(body, 'Message');
47
+ if (message) {
48
+ return `Failed to ${context}: ${response.status} ${code ? `${code}: ` : ''}${message}`;
49
+ }
50
+ if (code) {
51
+ return `Failed to ${context}: ${response.status} ${code}`;
52
+ }
53
+ } catch {
54
+ // Non-XML body, empty body, or no .text() — fall back to the status line.
55
+ }
56
+ return fallback;
57
+ }
58
+ /**
59
+ * Pull the trimmed text content of a single non-nested XML tag (e.g. `<Code>`).
60
+ * Returns undefined when the tag is absent or empty. S3 error documents are flat,
61
+ * so a narrow regex is sufficient — no general XML parsing needed.
62
+ */ function extractXmlTag(xml, tag) {
63
+ const match = new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(xml);
64
+ const value = match?.[1]?.trim();
65
+ return value && value.length > 0 ? value : undefined;
66
+ }
67
+
68
+ //# sourceMappingURL=error-response.js.map
@@ -6,6 +6,7 @@
6
6
  */ import { getAuthHeaders } from '../auth/credentials.js';
7
7
  import { getEnvConfig } from '../constants.js';
8
8
  import * as log from '../utils/log.js';
9
+ import { parseControlPlaneApiError } from './error-response.js';
9
10
  /**
10
11
  * Error thrown when Figma API calls fail
11
12
  */ export class FigmaApiError extends Error {
@@ -92,20 +93,11 @@ import * as log from '../utils/log.js';
92
93
  method: 'POST'
93
94
  });
94
95
  if (!response.ok) {
95
- // Handle error responses
96
- let errorMessage = `Failed to get project token: ${response.status} ${response.statusText}`;
97
- try {
98
- const errorBody = await response.json();
99
- // Only use string error messages (API may return { error: true })
100
- if (typeof errorBody.message === 'string') {
101
- errorMessage = errorBody.message;
102
- } else if (typeof errorBody.error === 'string') {
103
- errorMessage = errorBody.error;
104
- }
105
- } catch {
106
- // Ignore JSON parse errors for error body
107
- }
108
- // Provide actionable error for common cases
96
+ let errorMessage = await parseControlPlaneApiError({
97
+ context: 'get project token',
98
+ response
99
+ });
100
+ // Provide an actionable error for the common misconfiguration case.
109
101
  if (response.status === 404) {
110
102
  errorMessage = `Content System ID "${tenantId}" not found. Ensure FIGMA_CONTENT_API_CONTENT_SYSTEM_ID is set to a valid content system ID in your .env file.`;
111
103
  }
@@ -204,7 +196,10 @@ import * as log from '../utils/log.js';
204
196
  headers: getAuthHeaders(credential)
205
197
  });
206
198
  if (!response.ok) {
207
- throw new FigmaApiError(`Failed to get user info: ${response.status} ${response.statusText}`, response.status);
199
+ throw new FigmaApiError(await parseControlPlaneApiError({
200
+ context: 'get user info',
201
+ response
202
+ }), response.status);
208
203
  }
209
204
  const data = await response.json();
210
205
  if (!data.id || !data.email || typeof data.handle !== 'string') {
@@ -3,7 +3,8 @@ import crypto from 'crypto';
3
3
  import { getOAuthConfig } from '../config/oauth.js';
4
4
  import * as log from '../utils/log.js';
5
5
  import { buildAuthorizationUrl, openBrowser } from './browser.js';
6
- import { CallbackServer } from './callback-server.js';
6
+ import { CallbackError, CallbackServer } from './callback-server.js';
7
+ import { parsePastedCode, validatePastedCode } from './paste-code.js';
7
8
  import { generatePKCEPair } from './pkce.js';
8
9
  import { exchangeCodeForTokens, refreshAccessToken, TokenRefreshError } from './refresh.js';
9
10
  /**
@@ -87,28 +88,36 @@ import { exchangeCodeForTokens, refreshAccessToken, TokenRefreshError } from './
87
88
  // Prompt user to open browser
88
89
  const shouldOpenBrowser = await p.confirm({
89
90
  initialValue: true,
90
- message: 'Authenticate with Figma in your browser?'
91
+ message: 'Open browser to authenticate with Figma?'
91
92
  });
92
93
  if (p.isCancel(shouldOpenBrowser)) {
93
94
  throw new OAuthFlowError('Authentication cancelled by user');
94
95
  }
96
+ let browserOpened = false;
95
97
  if (shouldOpenBrowser) {
96
- // Try to open browser, but don't fail if it doesn't work
97
98
  try {
98
99
  await openBrowser(authUrl);
100
+ browserOpened = true;
99
101
  } catch (_error) {
100
- // Browser opening failed, show URL for manual opening
101
102
  log.warning('Could not open browser automatically.');
102
- log.info(`Please open this URL in your browser:`);
103
- log.info(` ${authUrl}`);
104
103
  }
104
+ }
105
+ let code;
106
+ if (browserOpened) {
107
+ // Browser launched locally; the localhost listener receives the callback.
108
+ code = (await callbackPromise).code;
105
109
  } else {
106
- // User declined automatic opening, show URL
107
- log.info(`Please open this URL manually in your browser:`);
108
- log.info(` ${authUrl}`);
110
+ // No local browser (user declined, or open() failed — e.g. a remote devbox).
111
+ // Show the URL to open manually and accept the redirected code pasted back,
112
+ // while the localhost listener keeps running in case it can still receive it.
113
+ // p.log.step directly (not log.info) to avoid bolding the URL.
114
+ p.log.step(`Open this URL to authenticate:\n\n ${authUrl}`);
115
+ code = await waitForCallbackOrPastedCode({
116
+ callbackPromise,
117
+ callbackServer,
118
+ expectedState: state
119
+ });
109
120
  }
110
- // Wait for callback
111
- const { code } = await callbackPromise;
112
121
  // Exchange authorization code for tokens
113
122
  let tokens;
114
123
  try {
@@ -215,6 +224,61 @@ import { exchangeCodeForTokens, refreshAccessToken, TokenRefreshError } from './
215
224
  return null;
216
225
  }
217
226
  }
227
+ /**
228
+ * Race the localhost callback listener against a pasted-code prompt.
229
+ *
230
+ * Used when no local browser was opened (remote/headless environments). Whichever
231
+ * settles first wins: if the listener receives the callback, the paste prompt is
232
+ * aborted; if the user pastes first, the listener is stopped. The listener's own
233
+ * timeout is non-fatal here — the user may take a while approving in the browser.
234
+ *
235
+ * @returns The authorization code from whichever source won
236
+ * @throws OAuthFlowError if the user cancels the paste prompt or the pasted value is invalid
237
+ */ async function waitForCallbackOrPastedCode({ callbackPromise, callbackServer, expectedState }) {
238
+ const abortController = new AbortController();
239
+ const listenerBranch = callbackPromise.then((result)=>{
240
+ // Listener won — tear down the paste prompt.
241
+ abortController.abort();
242
+ return result.code;
243
+ }, (error)=>{
244
+ // A timeout (or an unreachable listener on a remote box, which surfaces as a
245
+ // timeout) is non-fatal here — the user can still paste. Any other callback
246
+ // error means the browser actually reached the listener (denied consent, bad
247
+ // request, state mismatch), so surface it instead of hanging silently on the
248
+ // paste prompt.
249
+ if (error instanceof CallbackError && error.code === 'timeout') {
250
+ return new Promise(()=>{});
251
+ }
252
+ abortController.abort();
253
+ throw error;
254
+ });
255
+ const pasteBranch = (async ()=>{
256
+ // @clack/prompts TextOptions doesn't expose `signal` in its public type, but the
257
+ // underlying @clack/core PromptOptions does. Cast to reach the runtime capability.
258
+ const textOpts = {
259
+ message: 'Paste the redirect URL or code (optional):',
260
+ signal: abortController.signal,
261
+ validate: (value)=>validatePastedCode(value ?? '')
262
+ };
263
+ const pasted = await p.text(textOpts);
264
+ if (p.isCancel(pasted)) {
265
+ throw new OAuthFlowError('Authentication cancelled by user');
266
+ }
267
+ // Paste won — stop the listener before exchanging.
268
+ callbackServer.stop();
269
+ try {
270
+ return parsePastedCode(pasted, expectedState);
271
+ } catch (error) {
272
+ throw new OAuthFlowError(error instanceof Error ? error.message : 'Invalid pasted authorization code', error instanceof Error ? error : undefined);
273
+ }
274
+ })();
275
+ // Avoid an unhandled rejection when the paste prompt is aborted after the listener wins.
276
+ pasteBranch.catch(()=>{});
277
+ return Promise.race([
278
+ listenerBranch,
279
+ pasteBranch
280
+ ]);
281
+ }
218
282
  function replaceRedirectUriPort(redirectUri, port) {
219
283
  const url = new URL(redirectUri);
220
284
  url.port = String(port);
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Error parsing a pasted authorization code or redirect URL.
3
+ */
4
+ export declare class PastedCodeError extends Error {
5
+ constructor(message: string);
6
+ }
7
+ /**
8
+ * Parse a value the user pasted back after completing OAuth in their browser.
9
+ *
10
+ * Accepts either the full redirect URL (e.g.
11
+ * `http://localhost:34462/callback?code=...&state=...`) or a bare authorization
12
+ * code. When a URL is pasted, the `state` is validated against the value the CLI
13
+ * generated (CSRF protection); when a bare code is pasted, no state check is
14
+ * possible and PKCE alone gates the subsequent token exchange.
15
+ *
16
+ * @param input - Raw pasted text
17
+ * @param expectedState - The state value the CLI generated for this flow
18
+ * @returns The authorization code
19
+ * @throws PastedCodeError if the input is empty, is a URL without a code, or has a mismatched state
20
+ */
21
+ export declare function parsePastedCode(input: string, expectedState: string): string;
22
+ /**
23
+ * Structural validation for use as a `@clack/prompts` `text` validator.
24
+ *
25
+ * Returns `undefined` when the input could yield a code (so the prompt accepts it),
26
+ * or a human-readable message to re-prompt. Intentionally does NOT validate `state`
27
+ * — a state mismatch is a hard failure handled after the prompt resolves, not a
28
+ * re-prompt condition.
29
+ *
30
+ * @param input - Current prompt value
31
+ * @returns Error message string, or undefined if structurally valid
32
+ */
33
+ export declare function validatePastedCode(input: string): string | undefined;
34
+ //# sourceMappingURL=paste-code.d.ts.map
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Error parsing a pasted authorization code or redirect URL.
3
+ */ export class PastedCodeError extends Error {
4
+ constructor(message){
5
+ super(message);
6
+ this.name = 'PastedCodeError';
7
+ }
8
+ }
9
+ const NO_CODE_MESSAGE = 'That URL has no "code" parameter. Paste the full redirect URL or just the code.';
10
+ function parsePasteInput(input) {
11
+ const trimmed = input.trim();
12
+ if (!trimmed) {
13
+ return {
14
+ kind: 'empty'
15
+ };
16
+ }
17
+ let url;
18
+ try {
19
+ url = new URL(trimmed);
20
+ } catch {
21
+ return {
22
+ code: trimmed,
23
+ kind: 'bare-code'
24
+ };
25
+ }
26
+ const code = url.searchParams.get('code');
27
+ if (!code) {
28
+ return {
29
+ kind: 'url-missing-code'
30
+ };
31
+ }
32
+ return {
33
+ code,
34
+ kind: 'url',
35
+ state: url.searchParams.get('state')
36
+ };
37
+ }
38
+ /**
39
+ * Parse a value the user pasted back after completing OAuth in their browser.
40
+ *
41
+ * Accepts either the full redirect URL (e.g.
42
+ * `http://localhost:34462/callback?code=...&state=...`) or a bare authorization
43
+ * code. When a URL is pasted, the `state` is validated against the value the CLI
44
+ * generated (CSRF protection); when a bare code is pasted, no state check is
45
+ * possible and PKCE alone gates the subsequent token exchange.
46
+ *
47
+ * @param input - Raw pasted text
48
+ * @param expectedState - The state value the CLI generated for this flow
49
+ * @returns The authorization code
50
+ * @throws PastedCodeError if the input is empty, is a URL without a code, or has a mismatched state
51
+ */ export function parsePastedCode(input, expectedState) {
52
+ const parsed = parsePasteInput(input);
53
+ switch(parsed.kind){
54
+ case 'empty':
55
+ throw new PastedCodeError('No value provided.');
56
+ case 'bare-code':
57
+ return parsed.code;
58
+ case 'url-missing-code':
59
+ throw new PastedCodeError(NO_CODE_MESSAGE);
60
+ case 'url':
61
+ if (parsed.state !== null && parsed.state !== expectedState) {
62
+ throw new PastedCodeError('State mismatch — possible CSRF. Run login again.');
63
+ }
64
+ return parsed.code;
65
+ }
66
+ }
67
+ /**
68
+ * Structural validation for use as a `@clack/prompts` `text` validator.
69
+ *
70
+ * Returns `undefined` when the input could yield a code (so the prompt accepts it),
71
+ * or a human-readable message to re-prompt. Intentionally does NOT validate `state`
72
+ * — a state mismatch is a hard failure handled after the prompt resolves, not a
73
+ * re-prompt condition.
74
+ *
75
+ * @param input - Current prompt value
76
+ * @returns Error message string, or undefined if structurally valid
77
+ */ export function validatePastedCode(input) {
78
+ const parsed = parsePasteInput(input);
79
+ switch(parsed.kind){
80
+ case 'empty':
81
+ return 'Paste the redirect URL or the code.';
82
+ case 'url-missing-code':
83
+ return NO_CODE_MESSAGE;
84
+ default:
85
+ return undefined;
86
+ }
87
+ }
88
+
89
+ //# sourceMappingURL=paste-code.js.map
package/dist/cli.js CHANGED
@@ -49,6 +49,7 @@ class Main {
49
49
  '--payload-version': String,
50
50
  '--skip-auth': Boolean,
51
51
  '--skip-build': Boolean,
52
+ '--skip-size-check': Boolean,
52
53
  '--template': String,
53
54
  '--version': Boolean,
54
55
  '--yes': Boolean,
@@ -135,6 +136,7 @@ class Main {
135
136
  debug: this.args['--debug'],
136
137
  env: this.args['--env'],
137
138
  skipBuild: this.args['--skip-build'],
139
+ skipSizeCheck: this.args['--skip-size-check'],
138
140
  yes: this.args['--yes']
139
141
  });
140
142
  break;
@@ -13,6 +13,8 @@ export interface DeployCommandOptions {
13
13
  id?: string;
14
14
  /** Skip the build step (use existing build) */
15
15
  skipBuild?: boolean;
16
+ /** Skip the client-side unzipped bundle size pre-flight check */
17
+ skipSizeCheck?: boolean;
16
18
  /** Skip confirmation prompts */
17
19
  yes?: boolean;
18
20
  }
@@ -7,6 +7,7 @@ import { getTokenStore } from '../auth/token-store.js';
7
7
  import { getInfraEnvironment, getProjectNotFoundMessage } from '../constants.js';
8
8
  import { getFileSize } from '../utils/asset-collection.js';
9
9
  import { detectBuild, getBuildCommand } from '../utils/build-detection.js';
10
+ import { LAMBDA_MAX_UNZIPPED_BYTES } from '../utils/build-lambda-zip.js';
10
11
  import { detectAdapter } from '../utils/deploy-adapter.js';
11
12
  import { getEnvVar } from '../utils/env-management.js';
12
13
  import * as log from '../utils/log.js';
@@ -201,12 +202,15 @@ import { loginCommand } from './login.js';
201
202
  // Create deployment package (if server adapter)
202
203
  let zipPath = null;
203
204
  let zipSize = 0;
205
+ let unzippedBytes = 0;
204
206
  spinner.start('Creating deployment package...');
205
207
  try {
206
- zipPath = await adapter.prepareLambdaBundle(projectPath);
207
- if (zipPath) {
208
+ const bundle = await adapter.prepareLambdaBundle(projectPath);
209
+ if (bundle) {
210
+ zipPath = bundle.zipPath;
211
+ unzippedBytes = bundle.unzippedBytes;
208
212
  zipSize = await getFileSize(zipPath);
209
- spinner.stop(pc.green(`✓ Deployment package created (${formatBytes(zipSize)})`));
213
+ spinner.stop(pc.green(`✓ Deployment package created (${formatBytes(zipSize)} zipped, ${formatBytes(unzippedBytes)} unzipped)`));
210
214
  } else {
211
215
  spinner.stop(pc.green('✓ Static site — no server package needed'));
212
216
  }
@@ -214,6 +218,15 @@ import { loginCommand } from './login.js';
214
218
  spinner.stop(pc.red('✗ Failed to create deployment package'));
215
219
  throw error;
216
220
  }
221
+ // Pre-flight: fail fast before the long upload when the unzipped bundle exceeds
222
+ // AWS Lambda's limit. The server enforces this too (and now returns a clear
223
+ // message), so --skip-size-check is a safe escape hatch if this client-side
224
+ // value ever drifts below the real limit.
225
+ if (zipPath && !options.skipSizeCheck && unzippedBytes >= LAMBDA_MAX_UNZIPPED_BYTES) {
226
+ spinner.stop(pc.red(`✗ Unzipped deployment size (${formatBytes(unzippedBytes)}) is at or over the ${formatBytes(LAMBDA_MAX_UNZIPPED_BYTES)} limit.`));
227
+ p.note('Reduce your deployment size (remove unused dependencies or large assets) and try again.\n' + 'To bypass this client-side check, re-run with --skip-size-check.', 'Deployment too large');
228
+ process.exit(1);
229
+ }
217
230
  log.debug(`Adapter: ${adapter.name}`);
218
231
  if (zipPath) {
219
232
  log.debug(`Lambda zip size: ${formatBytes(zipSize)}`);
@@ -225,7 +238,7 @@ import { loginCommand } from './login.js';
225
238
  p.note([
226
239
  `${pc.cyan('Adapter:')} ${adapter.name}`,
227
240
  `${pc.cyan('Build ID:')} ${buildInfo.buildId}`,
228
- zipPath ? `${pc.cyan('Code size:')} ${formatBytes(zipSize)}` : `${pc.cyan('Code:')} No server`,
241
+ zipPath ? `${pc.cyan('Code size:')} ${formatBytes(zipSize)} zipped, ${formatBytes(unzippedBytes)} unzipped` : `${pc.cyan('Code:')} No server`,
229
242
  `${pc.cyan('Pages:')} ${pages.routes.length} routes (${pages.uploadKeys.length} files)`,
230
243
  `${pc.cyan('Assets:')} ${assets.uploadKeys.length} files`
231
244
  ].join('\n'), 'Details');
@@ -190,8 +190,8 @@ async function maybeInstallPayloadSkill(args) {
190
190
  if (projectInfo.hasPayload) {
191
191
  // Validate existing Payload version
192
192
  if (projectInfo.payloadVersion && !validatePayloadVersion(projectInfo.payloadVersion)) {
193
- p.log.warn(pc.yellow(`Payload version ${projectInfo.payloadVersion} detected. Version 3.x is required.`));
194
- p.note('Upgrade to Payload 3.x before continuing', 'Action Required');
193
+ p.log.warn(pc.yellow(`Payload version ${projectInfo.payloadVersion} detected. Version 3.x or newer is required.`));
194
+ p.note('Upgrade to Payload 3.x or newer before continuing', 'Action Required');
195
195
  process.exit(1);
196
196
  }
197
197
  p.log.success(pc.green(`✓ Payload ${projectInfo.payloadVersion || 'detected'}`));
@@ -119,8 +119,10 @@ export const DefaultLoginButton = ({ disabled, endpointSlug })=>{
119
119
  ]);
120
120
  useEffect(()=>{
121
121
  if (payloadRedirect && !disabled) {
122
- // set cookie to redirect to the original page
123
- document.cookie = `payloadRedirect=${payloadRedirect}; path=/`;
122
+ // set cookie to redirect to the original page. SameSite=None; Secure;
123
+ // Partitioned so it round-trips to /sso/login from the cross-site Make
124
+ // iframe (a Lax/unmarked cookie isn't sent, dropping the deep-link redirect).
125
+ document.cookie = `payloadRedirect=${payloadRedirect}; path=/; Secure; SameSite=None; Partitioned`;
124
126
  }
125
127
  }, [
126
128
  payloadRedirect,
@@ -44,7 +44,10 @@ export const defaultVerify = ({ collection, strategyName, userInfoCookieName = D
44
44
  // Clear the cookie after reading
45
45
  if (figmaUserInfo) {
46
46
  responseHeaders = new Headers();
47
- responseHeaders.append('Set-Cookie', `${userInfoCookieName}=; Max-Age=0; Path=/;`);
47
+ // SameSite=None; Secure; Partitioned so the clear matches the attributes
48
+ // the cookie was set with (establishSession) and actually removes it in the
49
+ // cross-site Make iframe.
50
+ responseHeaders.append('Set-Cookie', `${userInfoCookieName}=; Max-Age=0; Path=/; Secure; SameSite=None; Partitioned`);
48
51
  }
49
52
  let user = null;
50
53
  const depth = typeof collection.auth === 'object' ? collection.auth.depth : undefined;
@@ -198,7 +198,9 @@ export const getLoginEndpoint = ({ collection, collectionOptions, endpointSlug,
198
198
  } else {
199
199
  redirectToUse += payloadRedirect;
200
200
  }
201
- req.responseHeaders.append('Set-Cookie', `payloadRedirect=${redirectToUse}; Max-Age=0; Path=/;`);
201
+ req.responseHeaders.append('Set-Cookie', // Match the attributes the cookie was set with so the clear removes it
202
+ // in the cross-site Make iframe.
203
+ `payloadRedirect=${redirectToUse}; Max-Age=0; Path=/; Secure; SameSite=None; Partitioned`);
202
204
  }
203
205
  await establishSession({
204
206
  accessToken: access_token,
@@ -6,6 +6,7 @@ import { getCookieExpiration } from '../utilities/getCookieExpiration.js';
6
6
  import { getTokenExp } from '../utilities/getTokenExp.js';
7
7
  import { mergeHeaders } from '../utilities/mergeHeaders.js';
8
8
  import { refreshTokens } from '../utilities/refreshTokens.js';
9
+ import { withPartitionedCookie } from '../utilities/withPartitionedCookie.js';
9
10
  export const getRefreshHook = ({ pluginOptions, strategy })=>async ({ args, user })=>{
10
11
  if (!args.req.responseHeaders) {
11
12
  args.req.responseHeaders = new Headers();
@@ -47,7 +48,7 @@ export const getRefreshHook = ({ pluginOptions, strategy })=>async ({ args, user
47
48
  }) ?? 31_556_952),
48
49
  value: refreshedToken
49
50
  });
50
- args.req.responseHeaders.append('Set-Cookie', refreshCookie);
51
+ args.req.responseHeaders.append('Set-Cookie', withPartitionedCookie(refreshCookie));
51
52
  const newDecoded = jwt.decode(refreshedToken);
52
53
  strategy.debugLog('usePayloadJWT: true - Received new refresh token', {
53
54
  newDecoded,
@@ -1,5 +1,6 @@
1
1
  import { generateCookie } from 'payload';
2
2
  import { getCookieExpiration } from './getCookieExpiration.js';
3
+ import { withPartitionedCookie } from './withPartitionedCookie.js';
3
4
  export const clearCookie = (args)=>{
4
5
  const options = {
5
6
  ...args.cookieOptions,
@@ -15,7 +16,10 @@ export const clearCookie = (args)=>{
15
16
  if (!args.req.responseHeaders) {
16
17
  args.req.responseHeaders = new Headers();
17
18
  }
18
- args.req.responseHeaders.append('Set-Cookie', clearedCookie);
19
+ // Match the Partitioned attribute the cookie was set with — a Max-Age=0 clear
20
+ // without it targets the unpartitioned jar and won't delete the partitioned
21
+ // cookie, so logout wouldn't clear the session in the cross-site Make iframe.
22
+ args.req.responseHeaders.append('Set-Cookie', withPartitionedCookie(clearedCookie));
19
23
  };
20
24
 
21
25
  //# sourceMappingURL=clearCookie.js.map
@@ -1,10 +1,17 @@
1
1
  export const createCookieOptions = ({ collection, headers, pluginOptions })=>{
2
+ // Default to SameSite=None + Secure (and `Partitioned`, appended in
3
+ // establishSession via withPartitionedCookie) so the session cookies survive
4
+ // the cross-site Make-preview iframe — the admin panel runs on a different
5
+ // site from the top-level figma.com page, and a Lax/unmarked cookie is not
6
+ // sent back, looping the login. Mirrors the CSRF cookie (buildCsrfCookieHeader).
7
+ // Self-hosters on a same-site setup can override via collection.auth.cookies
8
+ // or pluginOptions.createCookieOptions below.
2
9
  let cookieOptions = {
3
10
  domain: undefined,
4
11
  httpOnly: true,
5
12
  path: '/',
6
- sameSite: undefined,
7
- secure: false
13
+ sameSite: 'None',
14
+ secure: true
8
15
  };
9
16
  if (typeof collection.auth === 'object' && typeof collection.auth.cookies === 'object') {
10
17
  const { domain, sameSite, secure } = collection.auth.cookies;
@@ -2,6 +2,7 @@ import { APIError, generateCookie } from 'payload';
2
2
  import { getProjectToken, getUserInfo } from '../../api/figma-api.js';
3
3
  import { createCookieOptions } from './createCookieOptions.js';
4
4
  import { getCookieExpiration } from './getCookieExpiration.js';
5
+ import { withPartitionedCookie } from './withPartitionedCookie.js';
5
6
  /**
6
7
  * Given a valid OAuth access_token: fetch user info (non-fatal), mint a
7
8
  * project token, and write the main + refresh + user-info cookies to
@@ -51,7 +52,7 @@ import { getCookieExpiration } from './getCookieExpiration.js';
51
52
  returnCookieAsObject: false,
52
53
  value: accessToken
53
54
  });
54
- req.responseHeaders.append('Set-Cookie', refreshCookie);
55
+ req.responseHeaders.append('Set-Cookie', withPartitionedCookie(refreshCookie));
55
56
  }
56
57
  const tokenExpires = cookieOptions?.expires ? getCookieExpiration(cookieOptions.expires) : new Date(projectToken.expiresAt);
57
58
  const tokenCookie = generateCookie({
@@ -61,7 +62,7 @@ import { getCookieExpiration } from './getCookieExpiration.js';
61
62
  returnCookieAsObject: false,
62
63
  value: projectToken.token
63
64
  });
64
- req.responseHeaders.append('Set-Cookie', tokenCookie);
65
+ req.responseHeaders.append('Set-Cookie', withPartitionedCookie(tokenCookie));
65
66
  // Short-lived handoff cookie consumed by defaultVerify on the next request.
66
67
  if (figmaUserInfo) {
67
68
  const userInfoValue = Buffer.from(JSON.stringify({
@@ -75,7 +76,7 @@ import { getCookieExpiration } from './getCookieExpiration.js';
75
76
  returnCookieAsObject: false,
76
77
  value: userInfoValue
77
78
  });
78
- req.responseHeaders.append('Set-Cookie', userInfoCookie);
79
+ req.responseHeaders.append('Set-Cookie', withPartitionedCookie(userInfoCookie));
79
80
  }
80
81
  };
81
82
 
@@ -5,6 +5,7 @@ import { getInfraEnvironment } from '../../constants.js';
5
5
  import { createCookieOptions } from './createCookieOptions.js';
6
6
  import { createDebugLogger } from './createDebugLogger.js';
7
7
  import { getCookieExpiration } from './getCookieExpiration.js';
8
+ import { withPartitionedCookie } from './withPartitionedCookie.js';
8
9
  export const refreshTokens = async ({ payload, refreshToken, strategy })=>{
9
10
  const debugLogger = createDebugLogger(payload, strategy.debug);
10
11
  debugLogger.info({
@@ -50,7 +51,7 @@ export const refreshTokens = async ({ payload, refreshToken, strategy })=>{
50
51
  returnCookieAsObject: false,
51
52
  value: token
52
53
  });
53
- headers.append('Set-Cookie', tokenCookie);
54
+ headers.append('Set-Cookie', withPartitionedCookie(tokenCookie));
54
55
  const decodedOauthToken = await validateProjectToken(token, getInfraEnvironment());
55
56
  return {
56
57
  decodedOauthToken,
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Append the `Partitioned` (CHIPS) attribute to a `Set-Cookie` header string.
3
+ *
4
+ * Payload's `generateCookie` cannot emit `Partitioned`, but Chrome requires it
5
+ * for a `SameSite=None` cookie set inside the cross-site Make-preview iframe
6
+ * (top-level `figma.com`, app on a different site) to be stored and sent back.
7
+ * Without it the session cookies are dropped, `/api/users/me` sees no token,
8
+ * and the admin login loops forever. The CSRF cookie avoids this by
9
+ * hand-building its header (`buildCsrfCookieHeader`); the session cookies go
10
+ * through `generateCookie`, so we add the attribute here to match.
11
+ *
12
+ * Only applied to `Secure` cookies: `Partitioned` requires `Secure`, and a
13
+ * partitioned non-secure cookie is rejected outright. Idempotent. The presence
14
+ * check matches a bare `Secure` or `generateCookie`'s `Secure=true` form, but
15
+ * deliberately NOT `Secure=false` — `generateCookie` never emits that today
16
+ * (it only writes `Secure` when truthy), but the check stays defensive so a
17
+ * future/hand-built `Secure=false` can't get `Partitioned` (which requires Secure).
18
+ */
19
+ export declare const withPartitionedCookie: (cookie: string) => string;
20
+ //# sourceMappingURL=withPartitionedCookie.d.ts.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Append the `Partitioned` (CHIPS) attribute to a `Set-Cookie` header string.
3
+ *
4
+ * Payload's `generateCookie` cannot emit `Partitioned`, but Chrome requires it
5
+ * for a `SameSite=None` cookie set inside the cross-site Make-preview iframe
6
+ * (top-level `figma.com`, app on a different site) to be stored and sent back.
7
+ * Without it the session cookies are dropped, `/api/users/me` sees no token,
8
+ * and the admin login loops forever. The CSRF cookie avoids this by
9
+ * hand-building its header (`buildCsrfCookieHeader`); the session cookies go
10
+ * through `generateCookie`, so we add the attribute here to match.
11
+ *
12
+ * Only applied to `Secure` cookies: `Partitioned` requires `Secure`, and a
13
+ * partitioned non-secure cookie is rejected outright. Idempotent. The presence
14
+ * check matches a bare `Secure` or `generateCookie`'s `Secure=true` form, but
15
+ * deliberately NOT `Secure=false` — `generateCookie` never emits that today
16
+ * (it only writes `Secure` when truthy), but the check stays defensive so a
17
+ * future/hand-built `Secure=false` can't get `Partitioned` (which requires Secure).
18
+ */ export const withPartitionedCookie = (cookie)=>{
19
+ if (!/;\s*Secure(?:=true)?(?:;|$)/i.test(cookie)) {
20
+ return cookie;
21
+ }
22
+ if (/;\s*Partitioned(?:=|;|$)/i.test(cookie)) {
23
+ return cookie;
24
+ }
25
+ return `${cookie}; Partitioned`;
26
+ };
27
+
28
+ //# sourceMappingURL=withPartitionedCookie.js.map
@@ -1,9 +1,9 @@
1
- import type { AssetCollection, DeployAdapter, PageCollection } from '../deploy-adapter.js';
1
+ import type { AssetCollection, DeployAdapter, LambdaBundle, PageCollection } from '../deploy-adapter.js';
2
2
  export declare class NextjsAdapter implements DeployAdapter {
3
3
  name: "nextjs";
4
4
  collectAssets(projectPath: string): Promise<AssetCollection>;
5
5
  collectPages(projectPath: string): Promise<PageCollection>;
6
- prepareLambdaBundle(projectPath: string): Promise<string>;
6
+ prepareLambdaBundle(projectPath: string): Promise<LambdaBundle>;
7
7
  }
8
8
  export declare const nextjsAdapter: NextjsAdapter;
9
9
  //# sourceMappingURL=nextjs.d.ts.map
@@ -55,8 +55,7 @@ export class NextjsAdapter {
55
55
  };
56
56
  }
57
57
  async prepareLambdaBundle(projectPath) {
58
- await buildLambdaZip(projectPath);
59
- return path.join(projectPath, 'lambda.zip');
58
+ return buildLambdaZip(projectPath);
60
59
  }
61
60
  }
62
61
  export const nextjsAdapter = new NextjsAdapter();
@@ -1,9 +1,9 @@
1
- import type { AssetCollection, DeployAdapter, PageCollection } from '../deploy-adapter.js';
1
+ import type { AssetCollection, DeployAdapter, LambdaBundle, PageCollection } from '../deploy-adapter.js';
2
2
  export declare class NitroAdapter implements DeployAdapter {
3
3
  name: "nitro";
4
4
  collectAssets(projectPath: string): Promise<AssetCollection>;
5
5
  collectPages(projectPath: string): Promise<PageCollection>;
6
- prepareLambdaBundle(projectPath: string): Promise<string>;
6
+ prepareLambdaBundle(projectPath: string): Promise<LambdaBundle>;
7
7
  }
8
8
  export declare const nitroAdapter: NitroAdapter;
9
9
  //# sourceMappingURL=nitro.d.ts.map
@@ -2,7 +2,7 @@ import archiver from 'archiver';
2
2
  import { createWriteStream, statSync } from 'fs';
3
3
  import fs from 'fs/promises';
4
4
  import path from 'path';
5
- import { collectFilesRecursive } from '../fs-utils.js';
5
+ import { collectFilesRecursive, getDirectorySize } from '../fs-utils.js';
6
6
  const RUN_SCRIPT = `#!/bin/bash -x
7
7
  [ ! -d '/tmp/cache' ] && mkdir -p /tmp/cache
8
8
  NODE_ENV=production exec node server/index.mjs
@@ -99,11 +99,16 @@ export class NitroAdapter {
99
99
  source: runShPath
100
100
  }
101
101
  ]);
102
+ const runShStat = await fs.stat(runShPath);
103
+ const unzippedBytes = await getDirectorySize(serverDir) + await getDirectorySize(publicDir) + runShStat.size;
102
104
  // Clean up temp run.sh
103
105
  await fs.rm(runShPath, {
104
106
  force: true
105
107
  });
106
- return zipPath;
108
+ return {
109
+ unzippedBytes,
110
+ zipPath
111
+ };
107
112
  }
108
113
  }
109
114
  export const nitroAdapter = new NitroAdapter();
@@ -1,10 +1,10 @@
1
- import type { AssetCollection, DeployAdapter, PageCollection } from '../deploy-adapter.js';
1
+ import type { AssetCollection, DeployAdapter, LambdaBundle, PageCollection } from '../deploy-adapter.js';
2
2
  export declare class ViteAdapter implements DeployAdapter {
3
3
  fallback: string;
4
4
  name: "vite";
5
5
  collectAssets(projectPath: string): Promise<AssetCollection>;
6
6
  collectPages(): Promise<PageCollection>;
7
- prepareLambdaBundle(): Promise<null>;
7
+ prepareLambdaBundle(): Promise<LambdaBundle | null>;
8
8
  }
9
9
  export declare const viteAdapter: ViteAdapter;
10
10
  //# sourceMappingURL=vite.d.ts.map
@@ -1,3 +1,12 @@
1
+ import type { LambdaBundle } from './deploy-adapter.js';
2
+ /**
3
+ * AWS Lambda's hard limit on unzipped deployment code size (250 MB). The unzipped
4
+ * total must be strictly less than this value, so a bundle exactly equal to it is
5
+ * rejected. The server enforces this too and now returns a clear message, so a
6
+ * client-side check against this value is an early-exit convenience, not the
7
+ * source of truth.
8
+ */
9
+ export declare const LAMBDA_MAX_UNZIPPED_BYTES: number;
1
10
  /**
2
11
  * Build Lambda deployment zip using pure Node.js
3
12
  *
@@ -10,7 +19,8 @@
10
19
  * as static assets and served via CDN, not from the Lambda function.
11
20
  *
12
21
  * @param projectPath - Path to project root
22
+ * @returns The built bundle: its unzipped byte size and the absolute zip path.
13
23
  * @throws Error if standalone directory missing or zip creation fails
14
24
  */
15
- export declare function buildLambdaZip(projectPath: string): Promise<void>;
25
+ export declare function buildLambdaZip(projectPath: string): Promise<LambdaBundle>;
16
26
  //# sourceMappingURL=build-lambda-zip.d.ts.map
@@ -2,6 +2,14 @@ import archiver from 'archiver';
2
2
  import { createWriteStream } from 'fs';
3
3
  import fs from 'fs/promises';
4
4
  import path from 'path';
5
+ import { getDirectorySize } from './fs-utils.js';
6
+ /**
7
+ * AWS Lambda's hard limit on unzipped deployment code size (250 MB). The unzipped
8
+ * total must be strictly less than this value, so a bundle exactly equal to it is
9
+ * rejected. The server enforces this too and now returns a clear message, so a
10
+ * client-side check against this value is an early-exit convenience, not the
11
+ * source of truth.
12
+ */ export const LAMBDA_MAX_UNZIPPED_BYTES = 250 * 1024 * 1024;
5
13
  /**
6
14
  * Build Lambda deployment zip using pure Node.js
7
15
  *
@@ -14,6 +22,7 @@ import path from 'path';
14
22
  * as static assets and served via CDN, not from the Lambda function.
15
23
  *
16
24
  * @param projectPath - Path to project root
25
+ * @returns The built bundle: its unzipped byte size and the absolute zip path.
17
26
  * @throws Error if standalone directory missing or zip creation fails
18
27
  */ export async function buildLambdaZip(projectPath) {
19
28
  const standalonePath = path.join(projectPath, '.next', 'standalone');
@@ -32,7 +41,12 @@ import path from 'path';
32
41
  } catch {
33
42
  // run.sh may not exist in all setups
34
43
  }
44
+ const unzippedBytes = await getDirectorySize(standalonePath);
35
45
  await createZip(standalonePath, zipPath);
46
+ return {
47
+ unzippedBytes,
48
+ zipPath
49
+ };
36
50
  }
37
51
  async function isDirectory(dirPath) {
38
52
  try {
@@ -17,14 +17,20 @@ export interface AssetCollection {
17
17
  uploadKeys: string[];
18
18
  }
19
19
  export type AdapterName = 'nextjs' | 'nitro' | 'vite';
20
+ export interface LambdaBundle {
21
+ /** Total size in bytes of the bundle contents before compression. */
22
+ unzippedBytes: number;
23
+ /** Absolute path to the built lambda.zip. */
24
+ zipPath: string;
25
+ }
20
26
  export interface DeployAdapter {
21
27
  collectAssets(projectPath: string): Promise<AssetCollection>;
22
28
  collectPages(projectPath: string): Promise<PageCollection>;
23
29
  /** SPA fallback path (e.g., "/index.html"). Undefined for SSR/SSG apps. */
24
30
  fallback?: string;
25
31
  name: AdapterName;
26
- /** Assembles and zips the server bundle. Returns path to zip, or null if no server (e.g., Vite SPA). */
27
- prepareLambdaBundle(projectPath: string): Promise<null | string>;
32
+ /** Assembles and zips the server bundle. Returns the bundle, or null if no server (e.g., Vite SPA). */
33
+ prepareLambdaBundle(projectPath: string): Promise<LambdaBundle | null>;
28
34
  }
29
35
  /**
30
36
  * Auto-detect the framework adapter based on build output.
@@ -3,4 +3,11 @@
3
3
  * returned as forward-slash-separated paths relative to baseDir.
4
4
  */
5
5
  export declare function collectFilesRecursive(dir: string, baseDir: string): Promise<string[]>;
6
+ /**
7
+ * Recursively sum the byte size of all files under a directory.
8
+ * Returns 0 for a missing or unreadable directory. Symlinks are not
9
+ * followed. Files that disappear or become unreadable between readdir
10
+ * and stat are skipped (treated as 0 bytes).
11
+ */
12
+ export declare function getDirectorySize(dir: string): Promise<number>;
6
13
  //# sourceMappingURL=fs-utils.d.ts.map
@@ -23,5 +23,35 @@ import path from 'path';
23
23
  }
24
24
  return files;
25
25
  }
26
+ /**
27
+ * Recursively sum the byte size of all files under a directory.
28
+ * Returns 0 for a missing or unreadable directory. Symlinks are not
29
+ * followed. Files that disappear or become unreadable between readdir
30
+ * and stat are skipped (treated as 0 bytes).
31
+ */ export async function getDirectorySize(dir) {
32
+ let entries;
33
+ try {
34
+ entries = await fs.readdir(dir, {
35
+ withFileTypes: true
36
+ });
37
+ } catch {
38
+ return 0;
39
+ }
40
+ let total = 0;
41
+ for (const entry of entries){
42
+ const fullPath = path.join(dir, entry.name);
43
+ if (entry.isDirectory()) {
44
+ total += await getDirectorySize(fullPath);
45
+ } else if (entry.isFile()) {
46
+ try {
47
+ const stats = await fs.stat(fullPath);
48
+ total += stats.size;
49
+ } catch {
50
+ // File vanished or is unreadable between readdir and stat — skip it.
51
+ }
52
+ }
53
+ }
54
+ return total;
55
+ }
26
56
 
27
57
  //# sourceMappingURL=fs-utils.js.map
@@ -61,6 +61,7 @@ export function helpMessage() {
61
61
  ${pc.dim('--env <environment>')} Override FIGMA_ENVIRONMENT_NAME
62
62
  ${pc.dim('--yes, -y')} Skip confirmation prompts
63
63
  ${pc.dim('--skip-build')} Skip building and use existing build
64
+ ${pc.dim('--skip-size-check')} Skip the client-side bundle size check
64
65
 
65
66
  ${pc.bold('UPGRADE COMMAND')}
66
67
 
@@ -12,7 +12,7 @@ import type { PackageManager } from './package-manager.js';
12
12
  */
13
13
  export declare function detectPayloadProject(projectPath?: string): Promise<ProjectInfo>;
14
14
  /**
15
- * Validate Payload version (must be 3.x)
15
+ * Validate Payload version (must be 3.x or newer)
16
16
  *
17
17
  * @param version - Version string from package.json
18
18
  * @returns True if version is valid
@@ -53,7 +53,7 @@ import { getOwnVersion } from './version-check.js';
53
53
  }
54
54
  }
55
55
  /**
56
- * Validate Payload version (must be 3.x)
56
+ * Validate Payload version (must be 3.x or newer)
57
57
  *
58
58
  * @param version - Version string from package.json
59
59
  * @returns True if version is valid
@@ -64,7 +64,7 @@ import { getOwnVersion } from './version-check.js';
64
64
  return false;
65
65
  }
66
66
  const majorVersion = parseInt(match[1], 10);
67
- return majorVersion === 3;
67
+ return majorVersion >= 3;
68
68
  }
69
69
  /**
70
70
  * Resolve a package version from an npm dist-tag or an explicit version.
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
+ import { parseS3Error } from '../api/error-response.js';
3
4
  import * as log from './log.js';
4
5
  /**
5
6
  * Retry configuration
@@ -87,7 +88,10 @@ const INITIAL_RETRY_DELAY = 1000 // 1 second
87
88
  if (response.ok) {
88
89
  return; // Success
89
90
  }
90
- lastError = new Error(`Upload failed: ${response.status} ${response.statusText}`);
91
+ lastError = new Error(await parseS3Error({
92
+ context: `upload ${path.basename(filePath)}`,
93
+ response
94
+ }));
91
95
  } catch (error) {
92
96
  lastError = error instanceof Error ? error : new Error('Unknown error');
93
97
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payloadcms/figma",
3
- "version": "0.0.1-alpha.71",
3
+ "version": "0.0.1-alpha.73",
4
4
  "license": "SEE LICENSE IN LICENSE.md",
5
5
  "type": "module",
6
6
  "exports": {