@jskit-ai/jskit-cli 0.2.39 → 0.2.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,11 @@
1
+ import path from "node:path";
1
2
  import {
2
3
  isHelpToken,
3
4
  renderGenerateCatalogHelp,
4
5
  renderGeneratePackageHelp,
5
6
  renderGenerateSubcommandHelp
6
7
  } from "./discoverabilityHelp.js";
8
+ import { interpolateOptionValue } from "../../shared/optionInterpolation.js";
7
9
 
8
10
  function resolveGeneratorSubcommandDefinitionMetadata(packageEntry = {}, subcommandName = "") {
9
11
  const descriptor = packageEntry?.descriptor && typeof packageEntry.descriptor === "object"
@@ -25,6 +27,51 @@ function resolveGeneratorSubcommandDefinitionMetadata(packageEntry = {}, subcomm
25
27
  return definition && typeof definition === "object" ? definition : {};
26
28
  }
27
29
 
30
+ function mapDescriptorBackedSubcommandArgsToInlineOptions(
31
+ packageEntry = {},
32
+ subcommandName = "",
33
+ subcommandArgs = [],
34
+ inlineOptions = {},
35
+ createCliError
36
+ ) {
37
+ const definition = resolveGeneratorSubcommandDefinitionMetadata(packageEntry, subcommandName);
38
+ const positionalArgs = Array.isArray(definition?.positionalArgs)
39
+ ? definition.positionalArgs
40
+ : [];
41
+ const providedArgs = Array.isArray(subcommandArgs) ? subcommandArgs : [];
42
+ if (providedArgs.length > positionalArgs.length) {
43
+ throw createCliError(
44
+ `Generator command "${subcommandName}" for ${String(packageEntry?.packageId || "unknown-package")} accepts at most ${positionalArgs.length} positional argument${positionalArgs.length === 1 ? "" : "s"}.`
45
+ );
46
+ }
47
+
48
+ const mappedInlineOptions = {
49
+ ...(inlineOptions && typeof inlineOptions === "object" ? inlineOptions : {})
50
+ };
51
+ for (const [index, rawValue] of providedArgs.entries()) {
52
+ const positionalArg = positionalArgs[index];
53
+ const optionName = String(positionalArg?.name || "").trim();
54
+ if (!optionName) {
55
+ throw createCliError(
56
+ `Generator command "${subcommandName}" for ${String(packageEntry?.packageId || "unknown-package")} defines positional arg ${index + 1} without a name.`
57
+ );
58
+ }
59
+
60
+ const value = String(rawValue || "").trim();
61
+ const existingValue = Object.prototype.hasOwnProperty.call(mappedInlineOptions, optionName)
62
+ ? String(mappedInlineOptions[optionName] || "").trim()
63
+ : null;
64
+ if (existingValue != null && existingValue !== value) {
65
+ throw createCliError(
66
+ `Generator command "${subcommandName}" for ${String(packageEntry?.packageId || "unknown-package")} received both positional "${optionName}" and --${optionName} with different values.`
67
+ );
68
+ }
69
+ mappedInlineOptions[optionName] = value;
70
+ }
71
+
72
+ return mappedInlineOptions;
73
+ }
74
+
28
75
  function resolveSubcommandRequiresInput(packageEntry = {}, subcommandName = "") {
29
76
  const descriptor = packageEntry?.descriptor && typeof packageEntry.descriptor === "object"
30
77
  ? packageEntry.descriptor
@@ -62,6 +109,108 @@ function resolveSubcommandRequiresInput(packageEntry = {}, subcommandName = "")
62
109
  return false;
63
110
  }
64
111
 
112
+ function collectUnexpectedGeneratorSubcommandOptionNames(packageEntry = {}, subcommandName = "", inlineOptions = {}) {
113
+ const subcommandDefinition = resolveGeneratorSubcommandDefinitionMetadata(packageEntry, subcommandName);
114
+ if (!Array.isArray(subcommandDefinition?.optionNames)) {
115
+ return [];
116
+ }
117
+
118
+ const allowedOptionNameSet = new Set(
119
+ subcommandDefinition.optionNames
120
+ .map((optionName) => String(optionName || "").trim())
121
+ .filter(Boolean)
122
+ );
123
+ return Object.keys(inlineOptions || {})
124
+ .map((optionName) => String(optionName || "").trim())
125
+ .filter(Boolean)
126
+ .filter((optionName) => !allowedOptionNameSet.has(optionName))
127
+ .sort((left, right) => left.localeCompare(right));
128
+ }
129
+
130
+ function resolveCreateTargetPolicy(packageEntry = {}, subcommandName = "") {
131
+ const definition = resolveGeneratorSubcommandDefinitionMetadata(packageEntry, subcommandName);
132
+ const createTarget = definition?.createTarget;
133
+ return createTarget && typeof createTarget === "object" ? createTarget : {};
134
+ }
135
+
136
+ function normalizeRelativePathWithinApp(appRoot = "", targetPath = "", createCliError) {
137
+ const normalizedTargetPath = String(targetPath || "").trim().replace(/\\/g, "/").replace(/^\.\/+/, "");
138
+ if (!normalizedTargetPath) {
139
+ throw createCliError("Generator create target path cannot be empty.");
140
+ }
141
+
142
+ const absolutePath = path.resolve(appRoot, normalizedTargetPath);
143
+ const relativePath = path.relative(appRoot, absolutePath);
144
+ if (
145
+ !relativePath ||
146
+ relativePath === ".." ||
147
+ relativePath.startsWith(`..${path.sep}`) ||
148
+ path.isAbsolute(relativePath)
149
+ ) {
150
+ throw createCliError(`Generator create target must stay within app root: ${normalizedTargetPath}`);
151
+ }
152
+
153
+ return {
154
+ absolutePath,
155
+ relativePath: relativePath.split(path.sep).join("/")
156
+ };
157
+ }
158
+
159
+ async function enforceDescriptorBackedCreateTargetPolicy({
160
+ packageEntry,
161
+ subcommandName,
162
+ inlineOptions = {},
163
+ appRoot = "",
164
+ packageIdInput = "",
165
+ createCliError,
166
+ readdir
167
+ } = {}) {
168
+ const policy = resolveCreateTargetPolicy(packageEntry, subcommandName);
169
+ const pathTemplate = String(policy.pathTemplate || "").trim();
170
+ if (!pathTemplate) {
171
+ return;
172
+ }
173
+
174
+ const forceOptionName = String(policy.forceOptionName || "force").trim() || "force";
175
+ const forceOverwrite = String(inlineOptions?.[forceOptionName] || "").trim().toLowerCase() === "true";
176
+ if (forceOverwrite) {
177
+ return;
178
+ }
179
+
180
+ const interpolatedTargetPath = interpolateOptionValue(
181
+ pathTemplate,
182
+ inlineOptions,
183
+ String(packageEntry?.packageId || "unknown-package"),
184
+ `${String(subcommandName || "generator")}.createTarget.pathTemplate`
185
+ );
186
+ const resolvedTargetPath = normalizeRelativePathWithinApp(appRoot, interpolatedTargetPath, createCliError);
187
+
188
+ try {
189
+ const entries = await readdir(resolvedTargetPath.absolutePath);
190
+ if (policy.allowExistingEmptyDirectory === true && entries.length < 1) {
191
+ return;
192
+ }
193
+
194
+ const commandLabel = `${String(packageIdInput || packageEntry?.packageId || "generator").trim()} ${String(subcommandName || "").trim()}`.trim();
195
+ const targetLabel = String(policy.label || "target").trim() || "target";
196
+ throw createCliError(
197
+ `${commandLabel} will not overwrite existing ${targetLabel} ${resolvedTargetPath.relativePath}. Re-run with --force to overwrite it.`
198
+ );
199
+ } catch (error) {
200
+ if (error?.code === "ENOENT") {
201
+ return;
202
+ }
203
+ if (error?.code === "ENOTDIR") {
204
+ const commandLabel = `${String(packageIdInput || packageEntry?.packageId || "generator").trim()} ${String(subcommandName || "").trim()}`.trim();
205
+ const targetLabel = String(policy.label || "target").trim() || "target";
206
+ throw createCliError(
207
+ `${commandLabel} will not overwrite existing ${targetLabel} ${resolvedTargetPath.relativePath}. Re-run with --force to overwrite it.`
208
+ );
209
+ }
210
+ throw error;
211
+ }
212
+ }
213
+
65
214
  async function runPackageGenerateCommand(
66
215
  ctx = {},
67
216
  { positional, options, cwd, io },
@@ -79,7 +228,10 @@ async function runPackageGenerateCommand(
79
228
  resolvePackageKind,
80
229
  resolveGeneratorPrimarySubcommand,
81
230
  hasGeneratorSubcommandDefinition,
82
- runGeneratorSubcommand
231
+ readdir,
232
+ validateInlineOptionValuesForPackage,
233
+ runGeneratorSubcommand,
234
+ createCatalogFetchStatusReporter = () => () => {}
83
235
  } = ctx;
84
236
 
85
237
  const firstToken = String(positional[0] || "").trim();
@@ -93,6 +245,9 @@ async function runPackageGenerateCommand(
93
245
  const targetId = firstToken === "package" ? secondToken : firstToken;
94
246
  const subcommandName = firstToken === "package" ? thirdToken : secondToken;
95
247
  const subcommandArgs = firstToken === "package" ? positional.slice(3) : positional.slice(2);
248
+ const reportTemplateFetchStatus = createCatalogFetchStatusReporter(io, {
249
+ enabled: options.json !== true
250
+ });
96
251
 
97
252
  async function resolveGeneratorPackageEntry(packageIdInput = "") {
98
253
  const appRoot = await resolveAppRootFromCwd(cwd);
@@ -145,27 +300,12 @@ async function runPackageGenerateCommand(
145
300
  }
146
301
 
147
302
  if (isHelpToken(subcommandName)) {
148
- const helpSubcommandName = String(subcommandArgs[0] || "").trim();
149
- if (subcommandArgs.length > 1) {
150
- throw createCliError("generate help accepts at most one subcommand name.");
303
+ if (subcommandArgs.length > 0) {
304
+ throw createCliError(
305
+ `Unknown generator usage: jskit generate ${targetId} help ${subcommandArgs.join(" ")}. Use: jskit generate ${targetId} <subcommand> help`
306
+ );
151
307
  }
152
308
  const { packageEntry } = await resolveGeneratorPackageEntry(targetId);
153
- if (helpSubcommandName) {
154
- const rendered = renderGenerateSubcommandHelp({
155
- io,
156
- packageEntry,
157
- packageIdInput: targetId,
158
- subcommandName: helpSubcommandName,
159
- json: options.json
160
- });
161
- if (!rendered) {
162
- throw createCliError(
163
- `Unknown generator subcommand "${helpSubcommandName}" for ${String(packageEntry?.packageId || targetId)}.`
164
- );
165
- }
166
- return 0;
167
- }
168
-
169
309
  renderGeneratePackageHelp({
170
310
  io,
171
311
  packageEntry,
@@ -175,56 +315,111 @@ async function runPackageGenerateCommand(
175
315
  return 0;
176
316
  }
177
317
 
178
- if (subcommandName) {
179
- const {
180
- appRoot,
181
- packageEntry,
182
- resolvedPackageId
183
- } = await resolveGeneratorPackageEntry(targetId);
318
+ async function runResolvedGeneratorSubcommand({
319
+ appRoot,
320
+ packageEntry,
321
+ resolvedPackageId,
322
+ subcommandName: rawSubcommandName = "",
323
+ subcommandArgs: rawSubcommandArgs = []
324
+ } = {}) {
325
+ const normalizedSubcommandName = String(rawSubcommandName || "").trim().toLowerCase();
326
+ const normalizedSubcommandArgs = Array.isArray(rawSubcommandArgs)
327
+ ? rawSubcommandArgs
328
+ : [];
184
329
  const hasInlineOptions = Object.keys(options?.inlineOptions || {}).length > 0;
185
- const hasSubcommandArgs = subcommandArgs.length > 0;
186
- if (!hasInlineOptions && !hasSubcommandArgs && resolveSubcommandRequiresInput(packageEntry, subcommandName)) {
330
+ const hasSubcommandArgs = normalizedSubcommandArgs.length > 0;
331
+ if (!hasInlineOptions && !hasSubcommandArgs && resolveSubcommandRequiresInput(packageEntry, normalizedSubcommandName)) {
187
332
  const rendered = renderGenerateSubcommandHelp({
188
333
  io,
189
334
  packageEntry,
190
335
  packageIdInput: targetId,
191
- subcommandName,
336
+ subcommandName: normalizedSubcommandName,
192
337
  json: options.json
193
338
  });
194
339
  if (rendered) {
195
340
  return 0;
196
341
  }
197
342
  }
198
- if (subcommandArgs.length === 1 && isHelpToken(subcommandArgs[0])) {
343
+ if (normalizedSubcommandArgs.length === 1 && isHelpToken(normalizedSubcommandArgs[0])) {
199
344
  const rendered = renderGenerateSubcommandHelp({
200
345
  io,
201
346
  packageEntry,
202
347
  packageIdInput: targetId,
203
- subcommandName,
348
+ subcommandName: normalizedSubcommandName,
204
349
  json: options.json
205
350
  });
206
351
  if (!rendered) {
207
- throw createCliError(`Unknown generator subcommand "${subcommandName}" for ${resolvedPackageId}.`);
352
+ throw createCliError(`Unknown generator subcommand "${normalizedSubcommandName}" for ${resolvedPackageId}.`);
208
353
  }
209
354
  return 0;
210
355
  }
211
356
 
212
- const normalizedSubcommandName = String(subcommandName || "").trim().toLowerCase();
357
+ const subcommandDefinition = resolveGeneratorSubcommandDefinitionMetadata(packageEntry, normalizedSubcommandName);
358
+ const unexpectedOptionNames = collectUnexpectedGeneratorSubcommandOptionNames(
359
+ packageEntry,
360
+ normalizedSubcommandName,
361
+ options.inlineOptions
362
+ );
363
+ if (unexpectedOptionNames.length > 0) {
364
+ const commandLabel = String(targetId || resolvedPackageId || "").trim() || resolvedPackageId;
365
+ throw createCliError(
366
+ `Unknown option${unexpectedOptionNames.length === 1 ? "" : "s"} for generator command ${commandLabel} ${normalizedSubcommandName}: ${unexpectedOptionNames.map((optionName) => `--${optionName}`).join(", ")}.`,
367
+ {
368
+ renderUsage: options.json
369
+ ? null
370
+ : () => {
371
+ renderGenerateSubcommandHelp({
372
+ io: {
373
+ ...io,
374
+ stdout: io.stderr || io.stdout
375
+ },
376
+ packageEntry,
377
+ packageIdInput: targetId,
378
+ subcommandName: normalizedSubcommandName,
379
+ json: false
380
+ });
381
+ }
382
+ }
383
+ );
384
+ }
385
+ const validatedOptionNames = Array.isArray(subcommandDefinition?.optionNames)
386
+ ? subcommandDefinition.optionNames
387
+ : [];
388
+ await validateInlineOptionValuesForPackage(packageEntry, options.inlineOptions, {
389
+ appRoot,
390
+ optionNames: validatedOptionNames
391
+ });
392
+
213
393
  const primarySubcommand = resolveGeneratorPrimarySubcommand(packageEntry);
214
394
  if (
215
395
  normalizedSubcommandName &&
216
396
  normalizedSubcommandName === primarySubcommand &&
217
397
  !hasGeneratorSubcommandDefinition(packageEntry, normalizedSubcommandName)
218
398
  ) {
219
- if (subcommandArgs.length > 0) {
220
- throw createCliError(
221
- `Generator command "${primarySubcommand}" for ${resolvedPackageId} does not accept positional arguments.`
222
- );
223
- }
399
+ const inlineOptionsForPrimarySubcommand = mapDescriptorBackedSubcommandArgsToInlineOptions(
400
+ packageEntry,
401
+ normalizedSubcommandName,
402
+ normalizedSubcommandArgs,
403
+ options.inlineOptions,
404
+ createCliError
405
+ );
406
+ await validateInlineOptionValuesForPackage(packageEntry, inlineOptionsForPrimarySubcommand, {
407
+ appRoot
408
+ });
409
+ await enforceDescriptorBackedCreateTargetPolicy({
410
+ packageEntry,
411
+ subcommandName: normalizedSubcommandName,
412
+ inlineOptions: inlineOptionsForPrimarySubcommand,
413
+ appRoot,
414
+ packageIdInput: targetId,
415
+ createCliError,
416
+ readdir
417
+ });
224
418
  return runCommandAdd({
225
419
  positional: ["package", resolvedPackageId],
226
420
  options: {
227
421
  ...options,
422
+ inlineOptions: inlineOptionsForPrimarySubcommand,
228
423
  commandMode: "generate"
229
424
  },
230
425
  cwd,
@@ -234,7 +429,8 @@ async function runPackageGenerateCommand(
234
429
 
235
430
  const templateRoot = await resolvePackageTemplateRoot({
236
431
  packageEntry,
237
- appRoot
432
+ appRoot,
433
+ reportTemplateFetchStatus
238
434
  });
239
435
  const executablePackageEntry =
240
436
  templateRoot === packageEntry.rootDir
@@ -246,8 +442,8 @@ async function runPackageGenerateCommand(
246
442
 
247
443
  return runGeneratorSubcommand({
248
444
  packageEntry: executablePackageEntry,
249
- subcommandName,
250
- subcommandArgs,
445
+ subcommandName: normalizedSubcommandName,
446
+ subcommandArgs: normalizedSubcommandArgs,
251
447
  inlineOptions: options.inlineOptions,
252
448
  appRoot,
253
449
  io,
@@ -256,15 +452,23 @@ async function runPackageGenerateCommand(
256
452
  });
257
453
  }
258
454
 
259
- return runCommandAdd({
260
- positional: ["package", targetId],
261
- options: {
262
- ...options,
263
- commandMode: "generate"
264
- },
265
- cwd,
266
- io
455
+ if (subcommandName) {
456
+ const resolvedGeneratorPackage = await resolveGeneratorPackageEntry(targetId);
457
+ return runResolvedGeneratorSubcommand({
458
+ ...resolvedGeneratorPackage,
459
+ subcommandName,
460
+ subcommandArgs
461
+ });
462
+ }
463
+
464
+ const { packageEntry } = await resolveGeneratorPackageEntry(targetId);
465
+ renderGeneratePackageHelp({
466
+ io,
467
+ packageEntry,
468
+ packageIdInput: targetId,
469
+ json: options.json
267
470
  });
471
+ return 0;
268
472
  }
269
473
 
270
474
  export { runPackageGenerateCommand };
@@ -39,6 +39,44 @@ function createCommandHandlerShared(ctx = {}) {
39
39
  return lines.join("\n");
40
40
  }
41
41
 
42
+ function createCatalogFetchStatusReporter(io = {}, { enabled = true } = {}) {
43
+ if (enabled !== true) {
44
+ return () => {};
45
+ }
46
+
47
+ const stdout = io?.stdout;
48
+ if (!stdout || typeof stdout.write !== "function") {
49
+ return () => {};
50
+ }
51
+
52
+ const activeFetchLabels = new Set();
53
+ return ({ packageEntry, state } = {}) => {
54
+ const packageId = String(packageEntry?.packageId || "").trim();
55
+ const version = String(packageEntry?.version || "").trim();
56
+ const packageLabel = version ? `${packageId}@${version}` : packageId;
57
+ if (!packageLabel) {
58
+ return;
59
+ }
60
+
61
+ if (state === "start") {
62
+ if (activeFetchLabels.has(packageLabel)) {
63
+ return;
64
+ }
65
+ activeFetchLabels.add(packageLabel);
66
+ stdout.write(`Fetching ${packageLabel}...\n`);
67
+ return;
68
+ }
69
+
70
+ if (state === "complete") {
71
+ if (!activeFetchLabels.has(packageLabel)) {
72
+ return;
73
+ }
74
+ activeFetchLabels.delete(packageLabel);
75
+ stdout.write(`Fetching ${packageLabel}... done!\n`);
76
+ }
77
+ };
78
+ }
79
+
42
80
  async function runNpmInstall(appRoot, stderr) {
43
81
  await new Promise((resolve, reject) => {
44
82
  const child = spawn("npm", ["install"], {
@@ -298,6 +336,7 @@ function createCommandHandlerShared(ctx = {}) {
298
336
 
299
337
  return {
300
338
  renderResolvedSummary,
339
+ createCatalogFetchStatusReporter,
301
340
  runNpmInstall,
302
341
  getInstalledDependents,
303
342
  resolvePackageKind,
@@ -51,6 +51,7 @@ function parseArgs(argv, { createCliError } = {}) {
51
51
  inlineOptions: {}
52
52
  };
53
53
  const positional = [];
54
+ const allowLooseInlineOptionParsing = command === "generate";
54
55
 
55
56
  while (args.length > 0) {
56
57
  const token = String(args.shift() || "");
@@ -99,27 +100,45 @@ function parseArgs(argv, { createCliError } = {}) {
99
100
  options.help = true;
100
101
  continue;
101
102
  }
103
+ if (token === "--force") {
104
+ options.inlineOptions.force = "true";
105
+ continue;
106
+ }
102
107
 
103
108
  if (token.startsWith("--")) {
104
109
  const withoutPrefix = token.slice(2);
105
110
  const hasInlineValue = withoutPrefix.includes("=");
106
111
  const optionName = hasInlineValue ? withoutPrefix.slice(0, withoutPrefix.indexOf("=")) : withoutPrefix;
107
- const optionValueRaw = hasInlineValue
108
- ? withoutPrefix.slice(withoutPrefix.indexOf("=") + 1)
109
- : args.shift();
110
-
111
- if (!/^[a-z][a-z0-9-]*$/.test(optionName)) {
112
+ const optionNamePattern = allowLooseInlineOptionParsing
113
+ ? /^[A-Za-z][A-Za-z0-9_-]*$/
114
+ : /^[a-z][a-z0-9-]*$/;
115
+ if (!optionNamePattern.test(optionName)) {
112
116
  throw createCliError(`Unknown option: ${token}`, { showUsage: true });
113
117
  }
114
- if (typeof optionValueRaw !== "string") {
115
- throw createCliError(`--${optionName} requires a value.`, { showUsage: true });
118
+
119
+ let optionValueRaw;
120
+ if (hasInlineValue) {
121
+ optionValueRaw = withoutPrefix.slice(withoutPrefix.indexOf("=") + 1);
122
+ } else {
123
+ const nextToken = typeof args[0] === "string" ? String(args[0]) : "";
124
+ if (nextToken && !nextToken.startsWith("-")) {
125
+ optionValueRaw = args.shift();
126
+ }
116
127
  }
117
- const optionValue = optionValueRaw.trim();
118
- if (!hasInlineValue && optionValue.startsWith("-")) {
128
+
129
+ if (!allowLooseInlineOptionParsing && typeof optionValueRaw !== "string") {
119
130
  throw createCliError(`--${optionName} requires a value.`, { showUsage: true });
120
131
  }
132
+ if (typeof optionValueRaw === "string") {
133
+ const optionValue = optionValueRaw.trim();
134
+ if (!hasInlineValue && optionValue.startsWith("-")) {
135
+ throw createCliError(`--${optionName} requires a value.`, { showUsage: true });
136
+ }
137
+ options.inlineOptions[optionName] = optionValue;
138
+ continue;
139
+ }
121
140
 
122
- options.inlineOptions[optionName] = optionValue;
141
+ options.inlineOptions[optionName] = undefined;
123
142
  continue;
124
143
  }
125
144
 
@@ -18,6 +18,7 @@ function createCommandHandlerDeps(deps = {}) {
18
18
  hydratePackageRegistryFromInstalledNodeModules: deps.hydratePackageRegistryFromInstalledNodeModules,
19
19
  resolvePackageTemplateRoot: deps.resolvePackageTemplateRoot,
20
20
  validateInlineOptionsForPackage: deps.validateInlineOptionsForPackage,
21
+ validateInlineOptionValuesForPackage: deps.validateInlineOptionValuesForPackage,
21
22
  resolveLocalDependencyOrder: deps.resolveLocalDependencyOrder,
22
23
  validatePlannedCapabilityClosure: deps.validatePlannedCapabilityClosure,
23
24
  resolvePackageOptions: deps.resolvePackageOptions,
@@ -34,6 +35,7 @@ function createCommandHandlerDeps(deps = {}) {
34
35
  writeJsonFile: deps.writeJsonFile,
35
36
  writeFile: deps.writeFile,
36
37
  mkdir: deps.mkdir,
38
+ readdir: deps.readdir,
37
39
  path: deps.path,
38
40
  inspectPackageOfferings: deps.inspectPackageOfferings,
39
41
  buildFileWriteGroups: deps.buildFileWriteGroups,