@payloadcms/figma 0.0.1-alpha.63 → 0.0.1-alpha.64

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.
@@ -673,41 +673,6 @@ async function upsert(args) {
673
673
  payload: this.payload
674
674
  });
675
675
  }
676
- // Mirrors MAX_DOCUMENT_UPDATES in the content API's updateDocuments.ts.
677
- // updateJobs may be called with larger limits (e.g. 150), so we batch sequentially.
678
- const CONTENT_API_MAX_UPDATES = 20;
679
- // Issues a single update request against payload-jobs and returns the unwrapped docs.
680
- async function updateJobsBatch({ batchSize, docData, meta, sortClause, whereQuery }) {
681
- const { data: response, error } = await this.client.POST('/api/v0/documents:update', {
682
- body: {
683
- collection: 'payload-jobs',
684
- contentSystemId: this.contentSystemId,
685
- createOnMissing: false,
686
- doc: docData,
687
- ...batchSize != null && {
688
- limit: batchSize
689
- },
690
- returning: {},
691
- sort: sortClause,
692
- where: whereQuery,
693
- ...meta
694
- }
695
- });
696
- if (error) {
697
- throw new Error(`Content API updateJobs error: ${JSON.stringify(error)}`);
698
- }
699
- if (!response) {
700
- throw new Error('No response from updateJobs');
701
- }
702
- if (!response.result || !('data' in response.result)) {
703
- return [];
704
- }
705
- return response.result.data.map((doc)=>unwrapDocument({
706
- collectionSlug: 'payload-jobs',
707
- doc,
708
- payload: this.payload
709
- }));
710
- }
711
676
  async function updateJobs(args) {
712
677
  const { id, limit, returning, sort, where } = args;
713
678
  if (id == null && where == null) {
@@ -737,49 +702,38 @@ async function updateJobs(args) {
737
702
  locale: undefined,
738
703
  where: whereClause
739
704
  });
740
- // When limit exceeds the content API's per-request cap, batch sequentially.
741
- const needsBatching = limit != null && limit > CONTENT_API_MAX_UPDATES;
742
- if (!needsBatching) {
743
- const docs = await updateJobsBatch.call(this, {
744
- batchSize: limit,
745
- docData,
746
- meta,
747
- sortClause,
748
- whereQuery
749
- });
750
- if (returning === false) {
751
- return null;
752
- }
753
- return docs;
754
- }
755
- // Batched path: issue sequential requests of ≤ CONTENT_API_MAX_UPDATES each.
756
- // This relies on the update itself causing matched jobs to no longer satisfy the where
757
- // clause on subsequent batches (e.g. setting processing: true removes them from a
758
- // "processing: false" query). If a future updateJobs call in Payload core updates jobs
759
- // in a way that does NOT change their match status, the same jobs could be updated
760
- // multiple times across batches.
761
- const allDocs = [];
762
- let remaining = limit;
763
- while(remaining > 0){
764
- const batchSize = Math.min(remaining, CONTENT_API_MAX_UPDATES);
765
- const batchDocs = await updateJobsBatch.call(this, {
766
- batchSize,
767
- docData,
768
- meta,
769
- sortClause,
770
- whereQuery
771
- });
772
- allDocs.push(...batchDocs);
773
- remaining -= batchSize;
774
- // Stop early when this batch returned fewer docs than requested — no more matching docs.
775
- if (batchDocs.length < batchSize) {
776
- break;
705
+ const { data: response, error } = await this.client.POST('/api/v0/documents:update', {
706
+ body: {
707
+ collection: 'payload-jobs',
708
+ contentSystemId: this.contentSystemId,
709
+ createOnMissing: false,
710
+ doc: docData,
711
+ ...limit != null && {
712
+ limit
713
+ },
714
+ returning: {},
715
+ sort: sortClause,
716
+ where: whereQuery,
717
+ ...meta
777
718
  }
719
+ });
720
+ if (error) {
721
+ throw new Error(`Content API updateJobs error: ${JSON.stringify(error)}`);
722
+ }
723
+ if (!response) {
724
+ throw new Error('No response from updateJobs');
778
725
  }
779
726
  if (returning === false) {
780
727
  return null;
781
728
  }
782
- return allDocs;
729
+ if (!response.result || !('data' in response.result)) {
730
+ return [];
731
+ }
732
+ return response.result.data.map((doc)=>unwrapDocument({
733
+ collectionSlug: 'payload-jobs',
734
+ doc,
735
+ payload: this.payload
736
+ }));
783
737
  }
784
738
  function createGlobal(args) {
785
739
  // Globals are singletons identified by their slug. Use upsert so concurrent calls
@@ -104,6 +104,66 @@ function missingOAuthCredential(name) {
104
104
  function messageOf(error) {
105
105
  return error instanceof Error ? error.message : 'Unknown error';
106
106
  }
107
+ /**
108
+ * Creates storage configuration for upload collections
109
+ */ function createStorageConfig(url, contentSystemId) {
110
+ const storageConfig = process.env.FIGMA_CONTENT_API_ACCESS_KEY ? {
111
+ baseUrl: url,
112
+ contentApiKey: process.env.FIGMA_CONTENT_API_ACCESS_KEY
113
+ } : process.env.FIGMA_DEV_JWT === 'true' ? {
114
+ auth: {
115
+ mode: 'devJwt'
116
+ },
117
+ baseUrl: url,
118
+ contentSystemId
119
+ } : {
120
+ auth: {
121
+ mode: 'tokenStore',
122
+ tokenStore: getTokenStore()
123
+ },
124
+ baseUrl: url,
125
+ contentSystemId
126
+ };
127
+ const adapter = contentApiStorageAdapter(storageConfig);
128
+ const storageClient = createStorageClient(storageConfig);
129
+ return {
130
+ adapter,
131
+ storageClient
132
+ };
133
+ }
134
+ /**
135
+ * Plugin that applies cloud storage configuration to all upload-enabled collections.
136
+ * Runs at the end of the plugin chain so it sees collections added by user plugins too.
137
+ */ function createStoragePlugin(url, contentSystemId) {
138
+ return (incomingConfig)=>{
139
+ const uploadCollections = (incomingConfig.collections || []).filter((c)=>c.upload);
140
+ if (uploadCollections.length === 0) {
141
+ return incomingConfig;
142
+ }
143
+ const { adapter, storageClient } = createStorageConfig(url, contentSystemId);
144
+ const collectionsMap = uploadCollections.reduce((acc, c)=>{
145
+ acc[c.slug] = {
146
+ adapter,
147
+ disableLocalStorage: true
148
+ };
149
+ return acc;
150
+ }, {});
151
+ initClientUploads({
152
+ clientHandler: '@payloadcms/figma/client#ContentApiClientUploadHandler',
153
+ collections: collectionsMap,
154
+ config: incomingConfig,
155
+ enabled: true,
156
+ serverHandler: getGenerateSignedURLHandler({
157
+ client: storageClient
158
+ }),
159
+ serverHandlerPath: '/content-api-storage-signed-url'
160
+ });
161
+ const storagePlugin = cloudStoragePlugin({
162
+ collections: collectionsMap
163
+ });
164
+ return storagePlugin(incomingConfig);
165
+ };
166
+ }
107
167
  export async function buildFigmaConfig(config) {
108
168
  const envConfig = getEnvConfig();
109
169
  // Resolve contentSystemId: config first, env var, then local store fallback (dev)
@@ -180,50 +240,6 @@ export async function buildFigmaConfig(config) {
180
240
  url
181
241
  });
182
242
  }
183
- // Build storage plugin if there are upload collections and storage is not disabled
184
- const uploadCollections = (config.collections || []).filter((c)=>c.upload);
185
- let storagePlugin;
186
- if (uploadCollections.length > 0 && config.figma.storage !== false) {
187
- const storageConfig = process.env.FIGMA_CONTENT_API_ACCESS_KEY ? {
188
- baseUrl: url,
189
- contentApiKey: process.env.FIGMA_CONTENT_API_ACCESS_KEY
190
- } : process.env.FIGMA_DEV_JWT === 'true' ? {
191
- auth: {
192
- mode: 'devJwt'
193
- },
194
- baseUrl: url,
195
- contentSystemId
196
- } : {
197
- auth: {
198
- mode: 'tokenStore',
199
- tokenStore: getTokenStore()
200
- },
201
- baseUrl: url,
202
- contentSystemId
203
- };
204
- const adapter = contentApiStorageAdapter(storageConfig);
205
- const storageClient = createStorageClient(storageConfig);
206
- const collectionsMap = uploadCollections.reduce((acc, c)=>{
207
- acc[c.slug] = {
208
- adapter,
209
- disableLocalStorage: true
210
- };
211
- return acc;
212
- }, {});
213
- initClientUploads({
214
- clientHandler: '@payloadcms/figma/client#ContentApiClientUploadHandler',
215
- collections: collectionsMap,
216
- config: config,
217
- enabled: true,
218
- serverHandler: getGenerateSignedURLHandler({
219
- client: storageClient
220
- }),
221
- serverHandlerPath: '/content-api-storage-signed-url'
222
- });
223
- storagePlugin = cloudStoragePlugin({
224
- collections: collectionsMap
225
- });
226
- }
227
243
  // Build complete config with Figma platform defaults
228
244
  const configWithFigmaDefaults = {
229
245
  ...config,
@@ -257,8 +273,8 @@ export async function buildFigmaConfig(config) {
257
273
  // Add oauth to plugins if not already present
258
274
  plugins: [
259
275
  ...config.plugins ?? [],
260
- ...storagePlugin ? [
261
- storagePlugin
276
+ ...config.figma.storage !== false ? [
277
+ createStoragePlugin(url, contentSystemId)
262
278
  ] : [],
263
279
  oAuth2Plugin({
264
280
  collections: [
@@ -14,6 +14,7 @@ export function helpMessage() {
14
14
  ${pc.cyan('logout')} Clear all stored tokens
15
15
  ${pc.cyan('list-tokens')} Show stored token information
16
16
  ${pc.cyan('init')} Initialize a Figma CMS project
17
+ ${pc.cyan('bootstrap')} Print bootstrap info (tenant IDs, OAuth creds) for a project
17
18
  ${pc.cyan('debug')} Show debug info for troubleshooting
18
19
  ${pc.cyan('env')} Switch active environment
19
20
  ${pc.cyan('deploy')} Deploy your project to Figma
@@ -39,6 +40,12 @@ export function helpMessage() {
39
40
  ${pc.dim('--name, -n <name>')} Set project directory name (skips prompt)
40
41
  ${pc.dim('--force')} Force reconfiguration of existing project
41
42
 
43
+ ${pc.bold('BOOTSTRAP COMMAND')}
44
+
45
+ ${pc.cyan('@payloadcms/figma bootstrap --id <cms-resource-id>')} Print bootstrap info
46
+ ${pc.dim('--env <environment>')} Filter to a single environment
47
+ ${pc.dim('--json')} Output JSON instead of styled note
48
+
42
49
  ${pc.bold('ENV COMMAND')}
43
50
 
44
51
  ${pc.cyan('@payloadcms/figma env')} List and switch environments
@@ -5,7 +5,7 @@ import * as log from './log.js';
5
5
  import { getAddCommand, getRunCommand } from './package-manager.js';
6
6
  import { addFigmaProperty, applyModifications, detectRequiredChanges } from './payload-config-ast.js';
7
7
  import { findPayloadConfig } from './payload-config-finder.js';
8
- import { checkPackageInstalled, getPayloadVersion, installPackage, uninstallPackage } from './payload-package-check.js';
8
+ import { checkPackageInstalled, getPayloadVersion, installPackages, uninstallPackages } from './payload-package-check.js';
9
9
  import { runTypeScriptCheck } from './typescript-validator.js';
10
10
  import { getOwnVersion } from './version-check.js';
11
11
  /**
@@ -14,10 +14,9 @@ import { getOwnVersion } from './version-check.js';
14
14
  */ export async function ensurePayloadFigmaConfig(projectPath, packageManager, figmaConfig) {
15
15
  const changes = [];
16
16
  const warnings = [];
17
- let modified = false;
17
+ let astModified = false;
18
18
  try {
19
19
  log.debug(`Starting payload config modification for: ${projectPath}`);
20
- // 1. Find config file
21
20
  const configPath = await findPayloadConfig(projectPath);
22
21
  if (!configPath) {
23
22
  log.debug(`Config file not found in: ${projectPath}`);
@@ -29,32 +28,10 @@ import { getOwnVersion } from './version-check.js';
29
28
  };
30
29
  }
31
30
  log.debug(`Found config file: ${configPath}`);
32
- // Get payload version to pin @payloadcms/* dependencies
31
+ // Pin @payloadcms/* packages to the project's payload version when possible.
33
32
  const payloadVersion = await getPayloadVersion(projectPath);
34
33
  log.debug(`Detected payload version: ${payloadVersion || 'unknown'}`);
35
- // 2. Check/install @payloadcms/figma package
36
- const isInstalled = await checkPackageInstalled(projectPath, '@payloadcms/figma');
37
- if (!isInstalled) {
38
- log.debug('@payloadcms/figma not installed, attempting installation...');
39
- try {
40
- const figmaVersion = await getOwnVersion();
41
- await installPackage(projectPath, '@payloadcms/figma', packageManager, figmaVersion);
42
- changes.push('Installed @payloadcms/figma');
43
- modified = true;
44
- log.debug('@payloadcms/figma installed successfully');
45
- } catch (error) {
46
- log.debug(`Failed to install @payloadcms/figma: ${error instanceof Error ? error.message : 'Unknown error'}`);
47
- return {
48
- changes: [],
49
- error: 'Failed to install @payloadcms/figma',
50
- modified: false,
51
- success: false
52
- };
53
- }
54
- } else {
55
- log.debug('@payloadcms/figma already installed');
56
- }
57
- // 3. Parse config with ts-morph
34
+ // Parse first so a malformed config fails fast before we touch node_modules.
58
35
  log.debug('Parsing config file with ts-morph...');
59
36
  const project = new Project({
60
37
  manipulationSettings: {
@@ -64,17 +41,15 @@ import { getOwnVersion } from './version-check.js';
64
41
  skipAddingFilesFromTsConfig: true
65
42
  });
66
43
  const sourceFile = project.addSourceFileAtPath(configPath);
67
- // 4. Detect required changes
68
44
  log.debug('Detecting required changes...');
69
45
  const detection = detectRequiredChanges(sourceFile);
70
46
  log.debug(`Detection results: needsImportChange=${detection.needsImportChange}, hasAlias=${detection.hasAlias}, hasBuildConfig=${detection.hasBuildConfig}`);
71
- // Handle failure modes
72
47
  if (detection.hasAlias) {
73
48
  log.debug('Config uses import alias (e.g., buildConfig as X)');
74
49
  return {
75
50
  changes,
76
51
  error: 'Config uses import alias - manual modification required',
77
- modified,
52
+ modified: false,
78
53
  success: false
79
54
  };
80
55
  }
@@ -83,25 +58,95 @@ import { getOwnVersion } from './version-check.js';
83
58
  return {
84
59
  changes,
85
60
  error: 'Config structure not recognized (no buildConfig call found)',
86
- modified,
61
+ modified: false,
87
62
  success: false
88
63
  };
89
64
  }
90
- // 5. Apply modifications
65
+ // Compute the full install/uninstall diff up front so each can run as a
66
+ // single package-manager call. npm leaves node_modules transient between
67
+ // back-to-back calls, which causes generate:importmap to silently miss
68
+ // subpath components — batching avoids that intermediate state.
69
+ const packagesToInstall = [];
70
+ const packagesToUninstall = [];
71
+ const figmaInstalled = await checkPackageInstalled(projectPath, '@payloadcms/figma');
72
+ if (!figmaInstalled) {
73
+ const figmaVersion = await getOwnVersion();
74
+ packagesToInstall.push({
75
+ name: '@payloadcms/figma',
76
+ changeMessage: 'Installed @payloadcms/figma',
77
+ version: figmaVersion
78
+ });
79
+ }
80
+ const editorIsDefault = !detection.editorProperty || detection.editorProperty.isDefault;
81
+ if (editorIsDefault) {
82
+ const hasLexical = await checkPackageInstalled(projectPath, '@payloadcms/richtext-lexical');
83
+ if (!hasLexical) {
84
+ packagesToInstall.push({
85
+ name: '@payloadcms/richtext-lexical',
86
+ changeMessage: 'Installed @payloadcms/richtext-lexical (default editor)',
87
+ version: payloadVersion
88
+ });
89
+ }
90
+ } else {
91
+ log.debug(`Custom editor detected (${detection.editorProperty?.importSource}) - skipping lexical installation`);
92
+ }
93
+ const hasCloudStorage = await checkPackageInstalled(projectPath, '@payloadcms/plugin-cloud-storage');
94
+ if (!hasCloudStorage) {
95
+ packagesToInstall.push({
96
+ name: '@payloadcms/plugin-cloud-storage',
97
+ changeMessage: 'Installed @payloadcms/plugin-cloud-storage',
98
+ version: payloadVersion
99
+ });
100
+ }
101
+ if (detection.dbProperty) {
102
+ packagesToUninstall.push(detection.dbProperty.importSource);
103
+ }
104
+ if (detection.sharpProperty) {
105
+ packagesToUninstall.push('sharp');
106
+ }
107
+ if (packagesToInstall.length > 0) {
108
+ log.debug(`Installing ${packagesToInstall.length} packages in a single operation: ${packagesToInstall.map((p)=>p.name).join(', ')}`);
109
+ try {
110
+ await installPackages(projectPath, packagesToInstall.map(({ name, version })=>({
111
+ name,
112
+ version
113
+ })), packageManager);
114
+ for (const { changeMessage } of packagesToInstall){
115
+ changes.push(changeMessage);
116
+ }
117
+ } catch (error) {
118
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
119
+ log.debug(`Batched install failed: ${errorMessage}`);
120
+ const figmaInBatch = packagesToInstall.some((p)=>p.name === '@payloadcms/figma');
121
+ if (figmaInBatch) {
122
+ // Without @payloadcms/figma the rewritten config would import a
123
+ // missing package, so abort before touching the file.
124
+ return {
125
+ changes: [],
126
+ error: 'Failed to install @payloadcms/figma',
127
+ modified: false,
128
+ success: false
129
+ };
130
+ }
131
+ // Lexical and cloud-storage are non-fatal — payload still loads without
132
+ // them, so warn (with package names) and continue.
133
+ const failedNames = packagesToInstall.map((p)=>p.name).join(', ');
134
+ warnings.push(`Could not install dependencies (${failedNames}) — install them manually. Details: ${errorMessage}`);
135
+ }
136
+ }
91
137
  log.debug('Applying modifications...');
92
138
  const modResult = applyModifications(sourceFile, detection);
93
139
  if (modResult.modified) {
94
140
  changes.push(...modResult.changes);
95
- modified = true;
141
+ astModified = true;
96
142
  log.debug(`Applied ${modResult.changes.length} modifications`);
97
143
  }
98
- // 6. Add figma property if config provided and doesn't exist
99
144
  if (figmaConfig && !detection.figmaObjectExists) {
100
145
  log.debug('Adding figma property to buildConfig...');
101
146
  const figmaResult = addFigmaProperty(sourceFile, figmaConfig);
102
147
  if (figmaResult.modified) {
103
148
  changes.push(...figmaResult.changes);
104
- modified = true;
149
+ astModified = true;
105
150
  log.debug(`Added figma property`);
106
151
  } else {
107
152
  log.debug('Figma property not added (may already exist)');
@@ -109,95 +154,39 @@ import { getOwnVersion } from './version-check.js';
109
154
  } else if (detection.figmaObjectExists) {
110
155
  log.debug('Figma property already exists, skipping');
111
156
  }
112
- // Save file if any modifications were made
113
- if (modified) {
157
+ if (astModified) {
114
158
  sourceFile.formatText({
115
159
  indentSize: 2,
116
160
  tabSize: 2
117
161
  });
118
162
  await sourceFile.save();
119
163
  log.debug('Config file saved');
120
- // 7. Handle package management
121
- const packagesToUninstall = [];
122
- // Uninstall db adapter (buildFigmaConfig provides its own)
123
- if (detection.dbProperty) {
124
- packagesToUninstall.push(detection.dbProperty.importSource);
125
- }
126
- // Uninstall sharp (Figma infrastructure handles image processing)
127
- if (detection.sharpProperty) {
128
- packagesToUninstall.push('sharp');
129
- }
130
- // Ensure lexical is installed if user doesn't have a custom editor
131
- // (buildFigmaConfig uses lexicalEditor() as default)
132
- if (!detection.editorProperty) {
133
- log.debug('No editor property detected - will ensure lexical is installed for default editor');
134
- } else if (detection.editorProperty.isDefault) {
135
- log.debug('Default lexicalEditor() detected - will ensure lexical is installed');
136
- } else {
137
- log.debug(`Custom editor detected (${detection.editorProperty.importSource}) - skipping lexical installation`);
138
- }
139
- if (!detection.editorProperty || detection.editorProperty.isDefault) {
140
- log.debug('Checking if @payloadcms/richtext-lexical is installed...');
141
- const hasLexical = await checkPackageInstalled(projectPath, '@payloadcms/richtext-lexical');
142
- if (!hasLexical) {
143
- log.debug('@payloadcms/richtext-lexical not found, installing...');
144
- try {
145
- await installPackage(projectPath, '@payloadcms/richtext-lexical', packageManager, payloadVersion);
146
- changes.push('Installed @payloadcms/richtext-lexical (default editor)');
147
- log.debug('Successfully installed @payloadcms/richtext-lexical');
148
- } catch (error) {
149
- log.debug(`Failed to install @payloadcms/richtext-lexical: ${error instanceof Error ? error.message : 'Unknown error'}`);
150
- // Don't fail on install error - user can install manually if needed
151
- warnings.push('Could not install @payloadcms/richtext-lexical - you may need to install it manually');
152
- }
153
- } else {
154
- log.debug('@payloadcms/richtext-lexical already installed, skipping');
155
- }
156
- }
157
- // Ensure cloud-storage is installed (required peer dependency for buildFigmaConfig)
158
- log.debug('Checking if @payloadcms/plugin-cloud-storage is installed...');
159
- const hasCloudStorage = await checkPackageInstalled(projectPath, '@payloadcms/plugin-cloud-storage');
160
- if (!hasCloudStorage) {
161
- log.debug('@payloadcms/plugin-cloud-storage not found, installing...');
162
- try {
163
- await installPackage(projectPath, '@payloadcms/plugin-cloud-storage', packageManager, payloadVersion);
164
- changes.push('Installed @payloadcms/plugin-cloud-storage');
165
- log.debug('Successfully installed @payloadcms/plugin-cloud-storage');
166
- } catch (error) {
167
- log.debug(`Failed to install @payloadcms/plugin-cloud-storage: ${error instanceof Error ? error.message : 'Unknown error'}`);
168
- warnings.push('Could not install @payloadcms/plugin-cloud-storage - you may need to install it manually');
169
- }
170
- } else {
171
- log.debug('@payloadcms/plugin-cloud-storage already installed, skipping');
172
- }
173
- if (packagesToUninstall.length > 0) {
174
- log.debug(`Uninstalling ${packagesToUninstall.length} orphaned packages...`);
175
- }
176
- for (const pkg of packagesToUninstall){
177
- try {
178
- await uninstallPackage(projectPath, pkg, packageManager);
179
- changes.push(`Uninstalled ${pkg}`);
180
- log.debug(`Uninstalled ${pkg}`);
181
- } catch (error) {
182
- log.debug(`Failed to uninstall ${pkg}: ${error instanceof Error ? error.message : 'Unknown error'}`);
183
- // Don't fail on uninstall errors - orphaned package is better than broken config
184
- }
185
- }
186
- // 8. Format file
187
164
  log.debug('Formatting config file...');
188
165
  const formatResult = await formatFile(configPath, packageManager);
189
166
  if (!formatResult.success && formatResult.warning) {
190
167
  warnings.push(formatResult.warning);
191
168
  log.debug(`Formatting warning: ${formatResult.warning}`);
192
169
  }
193
- // 9. Run TypeScript validation
194
170
  log.debug('Running TypeScript validation...');
195
171
  const tsResult = await runTypeScriptCheck(projectPath, packageManager);
196
172
  if (!tsResult.success && tsResult.errors.length > 0) {
197
173
  warnings.push(`TypeScript validation found issues - run '${getRunCommand(packageManager)} build' for details`);
198
174
  log.debug(`TypeScript validation found ${tsResult.errors.length} issues`);
199
175
  }
200
- } else {
176
+ }
177
+ if (packagesToUninstall.length > 0) {
178
+ log.debug(`Uninstalling ${packagesToUninstall.length} orphaned packages: ${packagesToUninstall.join(', ')}`);
179
+ const uninstallResult = await uninstallPackages(projectPath, packagesToUninstall, packageManager);
180
+ if (uninstallResult.success) {
181
+ for (const pkg of packagesToUninstall){
182
+ changes.push(`Uninstalled ${pkg}`);
183
+ }
184
+ } else {
185
+ warnings.push(`Could not uninstall ${uninstallResult.failedPackages.join(', ')} — they may remain as orphaned dependencies in package.json`);
186
+ }
187
+ }
188
+ const modified = astModified || changes.length > 0;
189
+ if (!modified) {
201
190
  log.debug('No modifications needed - config already correct');
202
191
  }
203
192
  log.debug('Payload config modification completed successfully');
@@ -215,7 +204,7 @@ import { getOwnVersion } from './version-check.js';
215
204
  return {
216
205
  changes,
217
206
  error: error instanceof Error ? error.message : 'Unknown error occurred',
218
- modified,
207
+ modified: astModified,
219
208
  success: false
220
209
  };
221
210
  }
@@ -1,4 +1,12 @@
1
1
  import type { PackageManager } from './package-manager.js';
2
+ export type PackageSpec = {
3
+ name: string;
4
+ version?: string;
5
+ };
6
+ export type UninstallResult = {
7
+ failedPackages: string[];
8
+ success: boolean;
9
+ };
2
10
  /**
3
11
  * Check if a package is installed in dependencies (not devDependencies)
4
12
  */
@@ -13,11 +21,23 @@ export declare function getPayloadVersion(projectPath: string): Promise<string |
13
21
  * @param version - Version to install (without caret). Defaults to 'latest'.
14
22
  */
15
23
  export declare function installPackage(projectPath: string, packageName: string, packageManager: PackageManager, version?: string): Promise<void>;
24
+ /**
25
+ * Install multiple packages as production dependencies in a single package-manager call.
26
+ * No-op when packages is empty. Throws on failure with exit code and truncated stderr.
27
+ */
28
+ export declare function installPackages(projectPath: string, packages: PackageSpec[], packageManager: PackageManager): Promise<void>;
16
29
  /**
17
30
  * Uninstall a package
18
31
  * Does not throw on failure - orphaned package is better than broken config
19
32
  */
20
- export declare function uninstallPackage(projectPath: string, packageName: string, packageManager: PackageManager): Promise<void>;
33
+ export declare function uninstallPackage(projectPath: string, packageName: string, packageManager: PackageManager): Promise<UninstallResult>;
34
+ /**
35
+ * Uninstall multiple packages in a single package-manager call.
36
+ * No-op when packageNames is empty. Does not throw — orphaned packages are
37
+ * preferable to a crash mid-config-rewrite. Returns a success flag so callers
38
+ * can avoid claiming "Uninstalled X" when the package manager actually failed.
39
+ */
40
+ export declare function uninstallPackages(projectPath: string, packageNames: string[], packageManager: PackageManager): Promise<UninstallResult>;
21
41
  /**
22
42
  * Run an npm script from package.json
23
43
  * Does not throw on failure - logs warning and continues