@payloadcms/figma 0.0.1-alpha.69 → 0.0.1-alpha.70

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.
@@ -8,7 +8,7 @@ import * as log from '../utils/log.js';
8
8
  /**
9
9
  * Get the Control Plane API base URL based on environment
10
10
  */ function getControlPlaneBaseUrl() {
11
- return process.env.FIGMA_API_BASE_URL || getEnvConfig().apiBaseUrl;
11
+ return getEnvConfig().apiBaseUrl;
12
12
  }
13
13
  /**
14
14
  * Error thrown when Control Plane API calls fail
@@ -85,7 +85,7 @@ import * as log from '../utils/log.js';
85
85
  };
86
86
  }
87
87
  // REAL API IMPLEMENTATION
88
- const baseUrl = process.env.FIGMA_API_BASE_URL || getEnvConfig().apiBaseUrl;
88
+ const baseUrl = getEnvConfig().apiBaseUrl;
89
89
  const url = `${baseUrl}/v1/cms/${tenantId}/token/`;
90
90
  const response = await fetch(url, {
91
91
  headers: getAuthHeaders(credential),
@@ -197,7 +197,7 @@ import * as log from '../utils/log.js';
197
197
  * @returns FigmaUserInfo with user profile data
198
198
  * @throws {FigmaApiError} If the API call fails
199
199
  */ export async function getUserInfo(credential) {
200
- const baseUrl = process.env.FIGMA_API_BASE_URL || getEnvConfig().apiBaseUrl;
200
+ const baseUrl = getEnvConfig().apiBaseUrl;
201
201
  const url = `${baseUrl}/v1/me`;
202
202
  try {
203
203
  const response = await fetch(url, {
@@ -1,6 +1,6 @@
1
1
  import { createRemoteJWKSet, jwtVerify } from 'jose';
2
2
  import { JWKSMultipleMatchingKeys, JWKSNoMatchingKey, JWSInvalid, JWSSignatureVerificationFailed, JWTExpired } from 'jose/errors';
3
- import { ENV_CONFIG } from '../constants.js';
3
+ import { getEnvConfig } from '../constants.js';
4
4
  /**
5
5
  * JWT validation error with specific error codes
6
6
  */ export class JWTValidationError extends Error {
@@ -28,7 +28,7 @@ import { ENV_CONFIG } from '../constants.js';
28
28
  * @param environment - Figma environment ('production' or 'staging')
29
29
  * @returns JWKS resolver function
30
30
  */ function getJWKSResolver(environment) {
31
- const jwksUri = ENV_CONFIG[environment].jwksUri;
31
+ const jwksUri = getEnvConfig(environment).jwksUri;
32
32
  if (!jwksResolvers.has(jwksUri)) {
33
33
  jwksResolvers.set(jwksUri, createRemoteJWKSet(new URL(jwksUri), {
34
34
  cacheMaxAge: 24 * 60 * 60 * 1000,
package/dist/cli.js CHANGED
@@ -46,6 +46,7 @@ class Main {
46
46
  '--json': Boolean,
47
47
  '--name': String,
48
48
  '--no-skill': Boolean,
49
+ '--payload-version': String,
49
50
  '--skip-auth': Boolean,
50
51
  '--skip-build': Boolean,
51
52
  '--template': String,
@@ -150,6 +151,7 @@ class Main {
150
151
  env: this.args['--env'],
151
152
  force: this.args['--force'],
152
153
  noSkill: this.args['--no-skill'],
154
+ payloadVersion: this.args['--payload-version'],
153
155
  skipAuth: this.args['--skip-auth'],
154
156
  template: this.args['--template']
155
157
  });
@@ -14,6 +14,8 @@ export interface InitCommandOptions {
14
14
  name?: string;
15
15
  /** Skip Payload skill installation */
16
16
  noSkill?: boolean;
17
+ /** npm dist-tag or explicit version for core payload packages (e.g. 'latest', 'canary', '3.40.0'); defaults to 'latest' */
18
+ payloadVersion?: string;
17
19
  /** Skip authentication check (for testing/development) */
18
20
  skipAuth?: boolean;
19
21
  /** GitHub template override, e.g. "v3.80.0:website" or "owner/repo#ref:template-path" */
@@ -341,7 +341,7 @@ async function maybeInstallPayloadSkill(args) {
341
341
  };
342
342
  s.start(`Downloading template "${resolvedSource.templatePath}" from ` + `${resolvedSource.owner}/${resolvedSource.repo}@${resolvedSource.ref}...`);
343
343
  try {
344
- await scaffoldProject(fullPath, projectName, packageManager, templateSource);
344
+ await scaffoldProject(fullPath, projectName, packageManager, templateSource, options.payloadVersion);
345
345
  s.stop(pc.green('✓ Template downloaded'));
346
346
  s.start('Installing dependencies...');
347
347
  await installDependencies(fullPath, packageManager);
@@ -4,14 +4,14 @@ export declare const DEFAULT_CALLBACK_PORT: number;
4
4
  /**
5
5
  * Get OAuth configuration for current environment.
6
6
  *
7
- * Honors env overrides:
8
- * FIGMA_API_BASE_URL tokenUrl, refreshUrl
9
- * FIGMA_WEB_BASE_URL authorizationUrl (appends /oauth)
7
+ * `apiBaseUrl` (FIGMA_API_BASE_URL) and `authorizationUrl` (FIGMA_WEB_BASE_URL)
8
+ * are resolved by `getEnvConfig`; this function only layers on the OAuth-client
9
+ * specifics it owns:
10
10
  * FIGMA_CLIENT_ID → clientId
11
11
  * FIGMA_REDIRECT_URI → redirectUri
12
12
  *
13
13
  * When FIGMA_INFRA_ENV=devbox, FIGMA_API_BASE_URL and FIGMA_WEB_BASE_URL are
14
- * required (ENV_CONFIG.devbox has empty URL defaults to fail-closed).
14
+ * required (the devbox preset has empty URL defaults, to fail closed).
15
15
  */
16
16
  export declare function getOAuthConfig(): OAuthConfig;
17
17
  export declare const TOKEN_EXPIRY_BUFFER_SECONDS = 300;
@@ -9,19 +9,16 @@ export const DEFAULT_CALLBACK_PORT = DEFAULT_CALLBACK_PORTS[0];
9
9
  /**
10
10
  * Get OAuth configuration for current environment.
11
11
  *
12
- * Honors env overrides:
13
- * FIGMA_API_BASE_URL tokenUrl, refreshUrl
14
- * FIGMA_WEB_BASE_URL authorizationUrl (appends /oauth)
12
+ * `apiBaseUrl` (FIGMA_API_BASE_URL) and `authorizationUrl` (FIGMA_WEB_BASE_URL)
13
+ * are resolved by `getEnvConfig`; this function only layers on the OAuth-client
14
+ * specifics it owns:
15
15
  * FIGMA_CLIENT_ID → clientId
16
16
  * FIGMA_REDIRECT_URI → redirectUri
17
17
  *
18
18
  * When FIGMA_INFRA_ENV=devbox, FIGMA_API_BASE_URL and FIGMA_WEB_BASE_URL are
19
- * required (ENV_CONFIG.devbox has empty URL defaults to fail-closed).
19
+ * required (the devbox preset has empty URL defaults, to fail closed).
20
20
  */ export function getOAuthConfig() {
21
- const { apiBaseUrl: defaultApi, authorizationUrl: defaultAuth, clientId } = getEnvConfig();
22
- const apiBaseUrl = process.env.FIGMA_API_BASE_URL || defaultApi;
23
- const webBase = process.env.FIGMA_WEB_BASE_URL;
24
- const authorizationUrl = webBase ? `${webBase}/oauth` : defaultAuth;
21
+ const { apiBaseUrl, authorizationUrl, clientId } = getEnvConfig();
25
22
  if (getInfraEnvironment() === 'devbox' && (!apiBaseUrl || !authorizationUrl)) {
26
23
  throw new Error('FIGMA_API_BASE_URL and FIGMA_WEB_BASE_URL must be set when FIGMA_INFRA_ENV=devbox');
27
24
  }
@@ -18,11 +18,6 @@ type EnvironmentConfig = {
18
18
  /** JSON Web Key Set URL for token validation */
19
19
  jwksUri: string;
20
20
  };
21
- /**
22
- * Environment-specific configuration for Figma integration.
23
- * Selected via FIGMA_INFRA_ENV ('production' | 'staging'), defaults to 'production'.
24
- */
25
- export declare const ENV_CONFIG: Record<Environment, EnvironmentConfig>;
26
21
  /**
27
22
  * User-facing error message when Content API returns 404 or 410.
28
23
  * Used by the db adapter for consistent messaging at runtime.
@@ -42,13 +37,36 @@ export declare function getProjectNotFoundMessage(params: {
42
37
  */
43
38
  export declare function setInfraEnvironment(env: Environment | undefined): void;
44
39
  /**
45
- * Get current Figma infrastructure environment
46
- * Priority: 1) setInfraEnvironment override, 2) FIGMA_INFRA_ENV env var, 3) .env file, 4) 'production' default
47
- */
48
- /**
49
- * Get the resolved EnvironmentConfig for the current infrastructure environment
40
+ * Resolve the EnvironmentConfig for an infrastructure environment, applying the
41
+ * `process.env` overrides that the Make-preview sandbox (and devbox/staging)
42
+ * depend on. This is a FUNCTION, not a populated constant, because those env
43
+ * vars may be set after module load — resolving them lazily at call time keeps
44
+ * the values current (the empty `devbox` preset is filled here, not at import).
45
+ *
46
+ * This is the single place these `process.env` overrides are parsed; callers
47
+ * (including `getOAuthConfig`) read the resolved values from here rather than
48
+ * re-parsing the env vars themselves. Three overrides, each keyed off the axis
49
+ * it belongs to:
50
+ *
51
+ * - `apiBaseUrl` ← `FIGMA_API_BASE_URL` (Control Plane / token host).
52
+ *
53
+ * - `authorizationUrl` / `identityMetadata` ← `FIGMA_WEB_BASE_URL`. The
54
+ * authorization code is minted by the SAME Figma host the editor runs on, so
55
+ * the token exchange / OIDC discovery must hit THAT host. Without this the
56
+ * plugin falls back to `www.figma.com`, the devbox-minted code is rejected
57
+ * with `invalid_grant`, and the admin login loops forever.
58
+ *
59
+ * - `jwksUri` ← `FIGMA_CONTENT_API_URL` (the content backend that signs the
60
+ * project token, independent of the OAuth host — see `deriveContentJwksUri`).
61
+ *
62
+ * In production the platform injects `FIGMA_WEB_BASE_URL=https://www.figma.com`
63
+ * and a `figmacontent.com` content URL, so the derived values equal the presets
64
+ * and behavior is unchanged.
65
+ *
66
+ * @param environment - the infrastructure environment to resolve; defaults to
67
+ * the one reported by `getInfraEnvironment()`.
50
68
  */
51
- export declare function getEnvConfig(): EnvironmentConfig;
69
+ export declare function getEnvConfig(environment?: Environment): EnvironmentConfig;
52
70
  export declare function getInfraEnvironment(): Environment;
53
71
  export {};
54
72
  //# sourceMappingURL=constants.d.ts.map
package/dist/constants.js CHANGED
@@ -2,7 +2,7 @@ import { getEnvVarSync } from './utils/env-management.js';
2
2
  /**
3
3
  * Environment-specific configuration for Figma integration.
4
4
  * Selected via FIGMA_INFRA_ENV ('production' | 'staging'), defaults to 'production'.
5
- */ export const ENV_CONFIG = {
5
+ */ const ENV_CONFIG = {
6
6
  production: {
7
7
  apiBaseUrl: 'https://api.figma.com',
8
8
  authorizationUrl: 'https://www.figma.com/oauth',
@@ -60,9 +60,78 @@ let envOverride;
60
60
  * Get current Figma infrastructure environment
61
61
  * Priority: 1) setInfraEnvironment override, 2) FIGMA_INFRA_ENV env var, 3) .env file, 4) 'production' default
62
62
  */ /**
63
- * Get the resolved EnvironmentConfig for the current infrastructure environment
64
- */ export function getEnvConfig() {
65
- return ENV_CONFIG[getInfraEnvironment()];
63
+ * Derive the project-token JWKS URI from the content/CMS API domain
64
+ * (`FIGMA_CONTENT_API_URL`), or `undefined` when it is unset or malformed.
65
+ *
66
+ * The project token is signed by the content backend, whose JWKS host tracks
67
+ * the content env — NOT the OAuth/infra env. A devbox or staging tenant runs
68
+ * on the staging content backend (`*.figmacontentstaging.com`) even when OAuth
69
+ * points at a devbox host, so its tokens are signed by the staging key and must
70
+ * validate against `static.figmacontentstaging.com`. Returning `undefined` lets
71
+ * the caller fall back to the per-env preset.
72
+ */ function deriveContentJwksUri() {
73
+ const contentApiUrl = process.env.FIGMA_CONTENT_API_URL;
74
+ if (!contentApiUrl) {
75
+ return undefined;
76
+ }
77
+ try {
78
+ const host = new URL(contentApiUrl).hostname;
79
+ const match = host.match(/(figmacontentstaging\.com|figmacontent\.com)$/);
80
+ if (match) {
81
+ return `https://static.${match[1]}/.well_known/jwks.json`;
82
+ }
83
+ } catch {
84
+ // Malformed URL — fall through to the per-env preset.
85
+ }
86
+ return undefined;
87
+ }
88
+ /**
89
+ * Resolve the EnvironmentConfig for an infrastructure environment, applying the
90
+ * `process.env` overrides that the Make-preview sandbox (and devbox/staging)
91
+ * depend on. This is a FUNCTION, not a populated constant, because those env
92
+ * vars may be set after module load — resolving them lazily at call time keeps
93
+ * the values current (the empty `devbox` preset is filled here, not at import).
94
+ *
95
+ * This is the single place these `process.env` overrides are parsed; callers
96
+ * (including `getOAuthConfig`) read the resolved values from here rather than
97
+ * re-parsing the env vars themselves. Three overrides, each keyed off the axis
98
+ * it belongs to:
99
+ *
100
+ * - `apiBaseUrl` ← `FIGMA_API_BASE_URL` (Control Plane / token host).
101
+ *
102
+ * - `authorizationUrl` / `identityMetadata` ← `FIGMA_WEB_BASE_URL`. The
103
+ * authorization code is minted by the SAME Figma host the editor runs on, so
104
+ * the token exchange / OIDC discovery must hit THAT host. Without this the
105
+ * plugin falls back to `www.figma.com`, the devbox-minted code is rejected
106
+ * with `invalid_grant`, and the admin login loops forever.
107
+ *
108
+ * - `jwksUri` ← `FIGMA_CONTENT_API_URL` (the content backend that signs the
109
+ * project token, independent of the OAuth host — see `deriveContentJwksUri`).
110
+ *
111
+ * In production the platform injects `FIGMA_WEB_BASE_URL=https://www.figma.com`
112
+ * and a `figmacontent.com` content URL, so the derived values equal the presets
113
+ * and behavior is unchanged.
114
+ *
115
+ * @param environment - the infrastructure environment to resolve; defaults to
116
+ * the one reported by `getInfraEnvironment()`.
117
+ */ export function getEnvConfig(environment = getInfraEnvironment()) {
118
+ const config = {
119
+ ...ENV_CONFIG[environment]
120
+ };
121
+ const apiBaseUrl = process.env.FIGMA_API_BASE_URL?.replace(/\/+$/, '');
122
+ if (apiBaseUrl) {
123
+ config.apiBaseUrl = apiBaseUrl;
124
+ }
125
+ const webBaseUrl = process.env.FIGMA_WEB_BASE_URL?.replace(/\/+$/, '');
126
+ if (webBaseUrl) {
127
+ config.authorizationUrl = `${webBaseUrl}/oauth`;
128
+ config.identityMetadata = `${webBaseUrl}/.well-known/openid-configuration`;
129
+ }
130
+ const jwksUri = deriveContentJwksUri();
131
+ if (jwksUri) {
132
+ config.jwksUri = jwksUri;
133
+ }
134
+ return config;
66
135
  }
67
136
  export function getInfraEnvironment() {
68
137
  if (envOverride) {
@@ -225,6 +225,13 @@ async function queryDrafts(args) {
225
225
  };
226
226
  }
227
227
  async function createVersion(args) {
228
+ const versionContent = dataToContentAPI(this.payload, args.collectionSlug, args.versionData, {
229
+ applyDefaults: true,
230
+ publishedLocale: args.publishedLocale
231
+ });
232
+ if (args.autosave) {
233
+ versionContent._payloadAutosave = true;
234
+ }
228
235
  const { data: response, error } = await this.client.POST('/api/v0/document_versions:create', {
229
236
  body: {
230
237
  collection: args.collectionSlug,
@@ -234,10 +241,7 @@ async function createVersion(args) {
234
241
  latest: true,
235
242
  parent: String(args.parent),
236
243
  updatedAt: args.updatedAt,
237
- version: dataToContentAPI(this.payload, args.collectionSlug, args.versionData, {
238
- applyDefaults: true,
239
- publishedLocale: args.publishedLocale
240
- })
244
+ version: versionContent
241
245
  }
242
246
  }
243
247
  });
@@ -262,6 +266,9 @@ async function updateVersion(args) {
262
266
  const locale = addFallbackLocale(args.locale, this.payload);
263
267
  const { publishedLocale, version, ...versionMeta } = args.versionData;
264
268
  const resolvedVersion = resolveVersionContent(version, versionMeta);
269
+ const versionContent = dataToContentAPI(this.payload, args.collection, resolvedVersion, {
270
+ publishedLocale
271
+ });
265
272
  const { data: response, error } = await this.client.POST('/api/v0/document_versions:update', {
266
273
  body: {
267
274
  collection: args.collection,
@@ -273,9 +280,7 @@ async function updateVersion(args) {
273
280
  latest: versionMeta.latest,
274
281
  parent: versionMeta.parent != null ? String(versionMeta.parent) : undefined,
275
282
  updatedAt: versionMeta.updatedAt,
276
- version: dataToContentAPI(this.payload, args.collection, resolvedVersion, {
277
- publishedLocale
278
- })
283
+ version: versionContent
279
284
  },
280
285
  where: convertPayloadWhereToContentAPI(where, {
281
286
  stripVersionPrefix: true
@@ -620,6 +625,7 @@ async function countVersions(args) {
620
625
  contentSystemId: this.contentSystemId,
621
626
  locale,
622
627
  where: convertPayloadWhereToContentAPI(args.where, {
628
+ parentToDocumentId: true,
623
629
  stripVersionPrefix: true
624
630
  }),
625
631
  ...buildMeta(this.payload, {
@@ -19,6 +19,11 @@ export function unwrapDocument(args) {
19
19
  const baseDoc = {
20
20
  ...versionDoc
21
21
  };
22
+ const versionData = baseDoc.version;
23
+ if (versionData && '_payloadAutosave' in versionData) {
24
+ baseDoc.autosave = versionData._payloadAutosave;
25
+ delete versionData._payloadAutosave;
26
+ }
22
27
  // Transform data from Content API format to Payload format
23
28
  if (collectionSlug) {
24
29
  // Only transform the nested 'version' field
@@ -9,7 +9,7 @@ const baseClass = 'oauth-login';
9
9
  // Max time we wait for the parent (figma.com) to respond with a code after
10
10
  // posting `mint-cms-oauth-code`. If we hit this without a reply, we assume
11
11
  // the parent isn't going to respond and fall back to the manual login button.
12
- const AUTO_LOGIN_PARENT_REPLY_TIMEOUT_MS = 5000;
12
+ const AUTO_LOGIN_PARENT_REPLY_TIMEOUT_MS = 10000;
13
13
  // Figma logo SVG colored version
14
14
  // const FigmaIcon: React.FC = () => (
15
15
  // <svg fill="none" height="27" viewBox="0 0 400 600" width="18">
@@ -36,7 +36,17 @@ export const getLoginEndpoint = ({ collection, collectionOptions, endpointSlug,
36
36
  }
37
37
  const stateRaw = jsonBody.state;
38
38
  const state = JSON.parse(Buffer.from(stateRaw, 'base64').toString('utf-8'));
39
- const redirectServerURL = state.serverURL || pluginOptions.redirectServerURL || config.serverURL;
39
+ // Resolve the post-login redirect base. This MUST match the precedence
40
+ // used when the OAuth `redirect_uri` is minted at `/meta`
41
+ // (`getAuthorizeURL`: redirectServerURL → config.serverURL →
42
+ // state.serverURL). `state.serverURL` is the iframe's untrusted
43
+ // `window.location.origin` (a `/meta` query param), so it must rank
44
+ // LAST: trusting it first lets the redirect target diverge from the
45
+ // server-configured origin — in the Make preview that origin is http
46
+ // (mixed content) and, more visibly, it isn't in Payload's auto-derived
47
+ // `config.csrf` allowlist (sanitizeConfig pushes `config.serverURL`),
48
+ // so the CSRF origin check below 403s the callback.
49
+ const redirectServerURL = pluginOptions.redirectServerURL || config.serverURL || state.serverURL;
40
50
  let failedRedirect = formatAdminURL({
41
51
  adminRoute,
42
52
  path: '/login',
@@ -59,11 +59,22 @@ export declare const OAUTH_STATE_CSRF_COOKIE_NAME = "__Host-payload-oauth-state_
59
59
  * /sso/login, AND so the iframe auto-login path works (the iframe is a
60
60
  * third-party context from the top-level Figma page). Path=/ is required
61
61
  * by the `__Host-` prefix.
62
+ *
63
+ * `Partitioned` (CHIPS) is required because the Make preview embeds the
64
+ * admin panel in a cross-site iframe. Under Chrome's third-party-cookie
65
+ * restrictions a `SameSite=None` cookie set in a third-party context is
66
+ * NOT sent back unless it is partitioned by the top-level site. Without it
67
+ * the `__Host-payload-oauth-state_csrf` cookie is dropped on the way back
68
+ * to /sso/login, the cookie↔state.csrf binding check fails, and login dies
69
+ * with "CSRF cookie mismatch" (intermittent sign-in loop). `__Host-` and
70
+ * `Partitioned` are mutually compatible.
62
71
  */
63
72
  export declare const buildCsrfCookieHeader: (nonce: string) => string;
64
73
  /**
65
74
  * `Set-Cookie` header to clear the CSRF cookie after a successful (or
66
75
  * failed) callback. Same attributes as the set version, plus Max-Age=0.
76
+ * Must include `Partitioned` so the clear targets the same partitioned
77
+ * cookie jar the set wrote to (an unpartitioned clear would not match).
67
78
  */
68
79
  export declare const buildCsrfCookieClearHeader: () => string;
69
80
  export {};
@@ -64,22 +64,35 @@ export const getAuthorizeURL = async ({ collection, collectionOptions, endpointS
64
64
  * /sso/login, AND so the iframe auto-login path works (the iframe is a
65
65
  * third-party context from the top-level Figma page). Path=/ is required
66
66
  * by the `__Host-` prefix.
67
+ *
68
+ * `Partitioned` (CHIPS) is required because the Make preview embeds the
69
+ * admin panel in a cross-site iframe. Under Chrome's third-party-cookie
70
+ * restrictions a `SameSite=None` cookie set in a third-party context is
71
+ * NOT sent back unless it is partitioned by the top-level site. Without it
72
+ * the `__Host-payload-oauth-state_csrf` cookie is dropped on the way back
73
+ * to /sso/login, the cookie↔state.csrf binding check fails, and login dies
74
+ * with "CSRF cookie mismatch" (intermittent sign-in loop). `__Host-` and
75
+ * `Partitioned` are mutually compatible.
67
76
  */ export const buildCsrfCookieHeader = (nonce)=>[
68
77
  `${OAUTH_STATE_CSRF_COOKIE_NAME}=${nonce}`,
69
78
  'HttpOnly',
70
79
  'Secure',
71
80
  'SameSite=None',
81
+ 'Partitioned',
72
82
  'Path=/',
73
83
  'Max-Age=600'
74
84
  ].join('; ');
75
85
  /**
76
86
  * `Set-Cookie` header to clear the CSRF cookie after a successful (or
77
87
  * failed) callback. Same attributes as the set version, plus Max-Age=0.
88
+ * Must include `Partitioned` so the clear targets the same partitioned
89
+ * cookie jar the set wrote to (an unpartitioned clear would not match).
78
90
  */ export const buildCsrfCookieClearHeader = ()=>[
79
91
  `${OAUTH_STATE_CSRF_COOKIE_NAME}=`,
80
92
  'HttpOnly',
81
93
  'Secure',
82
94
  'SameSite=None',
95
+ 'Partitioned',
83
96
  'Path=/',
84
97
  'Max-Age=0'
85
98
  ].join('; ');
@@ -41,6 +41,7 @@ export function helpMessage() {
41
41
  ${pc.dim('--force')} Force reconfiguration of existing project
42
42
  ${pc.dim('--no-skill')} Skip installing the Payload skill into .claude/skills/payload/
43
43
  ${pc.dim('--template, -t <spec>')} Override scaffold template (e.g. v3.80.0:website, owner/repo#ref:path)
44
+ ${pc.dim('--payload-version <tag|version>')} Core Payload npm dist-tag or version (default latest; e.g. canary, 3.40.0)
44
45
 
45
46
  ${pc.bold('BOOTSTRAP COMMAND')}
46
47
 
@@ -57,8 +57,11 @@ import * as log from './log.js';
57
57
  return new Promise((resolve, reject)=>{
58
58
  const command = packageManager;
59
59
  const packageSpecs = packages.map(({ name, version })=>name.endsWith('.tgz') ? name : `${name}@${version ?? 'latest'}`);
60
+ const installVerb = packageManager === 'npm' ? 'install' : 'add';
61
+ // -E pins exact version
60
62
  const args = [
61
- packageManager === 'npm' ? 'install' : 'add',
63
+ installVerb,
64
+ '-E',
62
65
  ...packageSpecs
63
66
  ];
64
67
  log.debug(`Running: ${command} ${args.join(' ')} in ${projectPath}`);
@@ -23,8 +23,11 @@ export declare function validatePayloadVersion(version: string): boolean;
23
23
  *
24
24
  * @param projectPath - Path where to create the project
25
25
  * @param projectName - Name for the project
26
+ * @param packageManager - Package manager to use
27
+ * @param templateSource - Optional template source override
28
+ * @param payloadVersion - npm dist-tag or explicit version for core payload packages (default 'latest')
26
29
  */
27
- export declare function scaffoldProject(projectPath: string, projectName: string, packageManager?: PackageManager, templateSource?: Partial<TemplateSource>): Promise<void>;
30
+ export declare function scaffoldProject(projectPath: string, projectName: string, packageManager?: PackageManager, templateSource?: Partial<TemplateSource>, payloadVersion?: string): Promise<void>;
28
31
  /**
29
32
  * Initialize git repository after all setup is complete
30
33
  *
@@ -67,27 +67,38 @@ import { getOwnVersion } from './version-check.js';
67
67
  return majorVersion === 3;
68
68
  }
69
69
  /**
70
- * Fetch the latest version of a package from npm registry
70
+ * Resolve a package version from an npm dist-tag or an explicit version.
71
71
  *
72
- * @param packageName - Package name to fetch version for
73
- * @returns Latest version string
74
- */ async function getLatestPackageVersion(packageName) {
75
- const response = await fetch(`https://registry.npmjs.org/-/package/${packageName}/dist-tags`);
76
- if (!response.ok) {
77
- throw new Error(`Failed to fetch version for ${packageName}: ${response.statusText}`);
72
+ * A value matching a published dist-tag (e.g. 'latest', 'canary') resolves to
73
+ * that tag's concrete version. Any other value is treated as an explicit version
74
+ * and verified against the registry.
75
+ *
76
+ * @param packageName - Package name to look up
77
+ * @param versionOrTag - npm dist-tag or explicit semver version; defaults to 'latest'
78
+ * @returns The concrete version string to pin
79
+ */ async function resolvePackageVersion(packageName, versionOrTag = 'latest') {
80
+ const distTagsResponse = await fetch(`https://registry.npmjs.org/-/package/${packageName}/dist-tags`);
81
+ if (!distTagsResponse.ok) {
82
+ throw new Error(`Failed to fetch versions for ${packageName}: ${distTagsResponse.statusText}`);
83
+ }
84
+ const distTags = await distTagsResponse.json();
85
+ const tagged = distTags[versionOrTag];
86
+ if (typeof tagged === 'string') {
87
+ return tagged;
78
88
  }
79
- const data = await response.json();
80
- if (typeof data.latest !== 'string') {
81
- throw new Error(`Invalid version data received for ${packageName}`);
89
+ // Not a dist-tag — treat as an explicit version and verify it's published.
90
+ const versionResponse = await fetch(`https://registry.npmjs.org/${packageName}/${versionOrTag}`);
91
+ if (versionResponse.ok) {
92
+ return versionOrTag;
82
93
  }
83
- return data.latest;
94
+ throw new Error(`No "${versionOrTag}" version or dist-tag published for ${packageName}. Available dist-tags: ${Object.keys(distTags).join(', ')}`);
84
95
  }
85
96
  /**
86
97
  * Replace workspace versions in package.json with actual versions
87
98
  *
88
99
  * @param packageJson - Package JSON object to update
89
- * @param latestVersion - Latest Payload version to use
90
- */ function replaceWorkspaceVersions(packageJson, latestVersion, figmaVersion) {
100
+ * @param coreVersion - Resolved core Payload version to use
101
+ */ function replaceWorkspaceVersions(packageJson, coreVersion, figmaVersion) {
91
102
  const deps = packageJson.dependencies;
92
103
  if (!deps) {
93
104
  return;
@@ -96,9 +107,9 @@ import { getOwnVersion } from './version-check.js';
96
107
  if (key === '@payloadcms/figma') {
97
108
  deps[key] = figmaVersion;
98
109
  } else if (typeof value === 'string' && value.startsWith('workspace:')) {
99
- deps[key] = latestVersion;
110
+ deps[key] = coreVersion;
100
111
  } else if (key === 'payload' || key.startsWith('@payloadcms/')) {
101
- deps[key] = latestVersion;
112
+ deps[key] = coreVersion;
102
113
  }
103
114
  }
104
115
  const devDeps = packageJson.devDependencies;
@@ -109,9 +120,9 @@ import { getOwnVersion } from './version-check.js';
109
120
  if (key === '@payloadcms/figma') {
110
121
  devDeps[key] = figmaVersion;
111
122
  } else if (typeof value === 'string' && value.startsWith('workspace:')) {
112
- devDeps[key] = latestVersion;
123
+ devDeps[key] = coreVersion;
113
124
  } else if (key === 'payload' || key.startsWith('@payloadcms/')) {
114
- devDeps[key] = latestVersion;
125
+ devDeps[key] = coreVersion;
115
126
  }
116
127
  }
117
128
  }
@@ -120,7 +131,10 @@ import { getOwnVersion } from './version-check.js';
120
131
  *
121
132
  * @param projectPath - Path where to create the project
122
133
  * @param projectName - Name for the project
123
- */ export async function scaffoldProject(projectPath, projectName, packageManager = 'npm', templateSource) {
134
+ * @param packageManager - Package manager to use
135
+ * @param templateSource - Optional template source override
136
+ * @param payloadVersion - npm dist-tag or explicit version for core payload packages (default 'latest')
137
+ */ export async function scaffoldProject(projectPath, projectName, packageManager = 'npm', templateSource, payloadVersion = 'latest') {
124
138
  // Create project directory if it doesn't exist
125
139
  await fs.mkdir(projectPath, {
126
140
  recursive: true
@@ -130,9 +144,9 @@ import { getOwnVersion } from './version-check.js';
130
144
  // Apply Lambda modifications BEFORE updating package.json
131
145
  // This ensures we don't overwrite version replacements
132
146
  await applyLambdaModifications(projectPath, packageManager);
133
- // Get latest Payload version from npm and CLI's own version
134
- const [latestVersion, figmaVersion] = await Promise.all([
135
- getLatestPackageVersion('payload'),
147
+ // Resolve core Payload version from the selected dist-tag or version; figma uses CLI version
148
+ const [coreVersion, figmaVersion] = await Promise.all([
149
+ resolvePackageVersion('payload', payloadVersion),
136
150
  getOwnVersion()
137
151
  ]);
138
152
  // Delete any existing lock files from template (they reference workspace:* versions)
@@ -156,7 +170,7 @@ import { getOwnVersion } from './version-check.js';
156
170
  const packageJson = JSON.parse(packageJsonContent);
157
171
  packageJson.name = projectName;
158
172
  // Replace workspace:* versions with actual versions
159
- replaceWorkspaceVersions(packageJson, latestVersion, figmaVersion);
173
+ replaceWorkspaceVersions(packageJson, coreVersion, figmaVersion);
160
174
  await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n', 'utf-8');
161
175
  // Ensure .gitignore has required entries
162
176
  // Note: .env file is created by init command with FIGMA_CONTENT_API_CONTENT_SYSTEM_ID
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payloadcms/figma",
3
- "version": "0.0.1-alpha.69",
3
+ "version": "0.0.1-alpha.70",
4
4
  "license": "SEE LICENSE IN LICENSE.md",
5
5
  "type": "module",
6
6
  "exports": {
@@ -99,6 +99,8 @@
99
99
  "test": "TEST_UNIT=true vitest run",
100
100
  "test:int": "TEST_INT=true vitest run --config vitest.config.int.ts",
101
101
  "test:int:watch": "TEST_INT=true vitest --config vitest.config.int.ts",
102
+ "test:e2e": "TEST_E2E=true vitest run --config vitest.config.e2e.ts",
103
+ "test:e2e:watch": "TEST_E2E=true vitest --config vitest.config.e2e.ts",
102
104
  "test:watch": "TEST_UNIT=true vitest"
103
105
  }
104
106
  }