@aspruyt/xfg 1.10.9 → 2.0.0

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.
package/README.md CHANGED
@@ -52,6 +52,7 @@ xfg --config ./config.yaml
52
52
 
53
53
  ```yaml
54
54
  # sync-config.yaml
55
+ id: my-org-prettier-config
55
56
  files:
56
57
  .prettierrc.json:
57
58
  content:
@@ -147,6 +147,7 @@ export function normalizeConfig(raw) {
147
147
  }
148
148
  }
149
149
  return {
150
+ id: raw.id,
150
151
  repos: expandedRepos,
151
152
  prTemplate: raw.prTemplate,
152
153
  githubHosts: raw.githubHosts,
@@ -21,11 +21,24 @@ function isStructuredFileExtension(fileName) {
21
21
  const ext = extname(fileName).toLowerCase();
22
22
  return (ext === ".json" || ext === ".json5" || ext === ".yaml" || ext === ".yml");
23
23
  }
24
+ // Pattern for valid config ID: alphanumeric, hyphens, underscores
25
+ const CONFIG_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
26
+ const CONFIG_ID_MAX_LENGTH = 64;
24
27
  /**
25
28
  * Validates raw config structure before normalization.
26
29
  * @throws Error if validation fails
27
30
  */
28
31
  export function validateRawConfig(config) {
32
+ // Validate required id field
33
+ if (!config.id || typeof config.id !== "string") {
34
+ throw new Error("Config requires an 'id' field. This unique identifier is used to namespace managed files in .xfg.json");
35
+ }
36
+ if (!CONFIG_ID_PATTERN.test(config.id)) {
37
+ throw new Error(`Config 'id' contains invalid characters: '${config.id}'. Use only alphanumeric characters, hyphens, and underscores.`);
38
+ }
39
+ if (config.id.length > CONFIG_ID_MAX_LENGTH) {
40
+ throw new Error(`Config 'id' exceeds maximum length of ${CONFIG_ID_MAX_LENGTH} characters`);
41
+ }
29
42
  if (!config.files || typeof config.files !== "object") {
30
43
  throw new Error("Config missing required field: files (must be an object)");
31
44
  }
package/dist/config.d.ts CHANGED
@@ -37,6 +37,7 @@ export interface RawRepoConfig {
37
37
  prOptions?: PRMergeOptions;
38
38
  }
39
39
  export interface RawConfig {
40
+ id: string;
40
41
  files: Record<string, RawFileConfig>;
41
42
  repos: RawRepoConfig[];
42
43
  prOptions?: PRMergeOptions;
@@ -61,6 +62,7 @@ export interface RepoConfig {
61
62
  prOptions?: PRMergeOptions;
62
63
  }
63
64
  export interface Config {
65
+ id: string;
64
66
  repos: RepoConfig[];
65
67
  prTemplate?: string;
66
68
  githubHosts?: string[];
package/dist/index.js CHANGED
@@ -132,6 +132,7 @@ async function main() {
132
132
  const result = await processor.process(repoConfig, repoInfo, {
133
133
  branchName,
134
134
  workDir,
135
+ configId: config.id,
135
136
  dryRun: options.dryRun,
136
137
  retries: options.retries,
137
138
  prTemplate: config.prTemplate,
@@ -1,7 +1,7 @@
1
1
  export declare const MANIFEST_FILENAME = ".xfg.json";
2
2
  export interface XfgManifest {
3
- version: 1;
4
- managedFiles: string[];
3
+ version: 2;
4
+ configs: Record<string, string[]>;
5
5
  }
6
6
  /**
7
7
  * Creates an empty manifest with the current version.
@@ -9,10 +9,14 @@ export interface XfgManifest {
9
9
  export declare function createEmptyManifest(): XfgManifest;
10
10
  /**
11
11
  * Loads the xfg manifest from a repository's working directory.
12
- * Returns null if the manifest file doesn't exist.
12
+ * Returns null if the manifest file doesn't exist or is v1 format.
13
+ *
14
+ * V1 manifests are treated as non-existent because they lack the config ID
15
+ * namespace required for multi-config support. The next run will create
16
+ * a fresh v2 manifest.
13
17
  *
14
18
  * @param workDir - The repository working directory
15
- * @returns The manifest or null if not found
19
+ * @returns The manifest or null if not found or incompatible
16
20
  */
17
21
  export declare function loadManifest(workDir: string): XfgManifest | null;
18
22
  /**
@@ -23,24 +27,28 @@ export declare function loadManifest(workDir: string): XfgManifest | null;
23
27
  */
24
28
  export declare function saveManifest(workDir: string, manifest: XfgManifest): void;
25
29
  /**
26
- * Gets the list of managed files from a manifest.
27
- * Returns an empty array if the manifest is null.
30
+ * Gets the list of managed files for a specific config from a manifest.
31
+ * Returns an empty array if the manifest is null or the config isn't found.
28
32
  *
29
33
  * @param manifest - The manifest or null
30
- * @returns Array of managed file names
34
+ * @param configId - The config ID to get files for
35
+ * @returns Array of managed file names for the given config
31
36
  */
32
- export declare function getManagedFiles(manifest: XfgManifest | null): string[];
37
+ export declare function getManagedFiles(manifest: XfgManifest | null, configId: string): string[];
33
38
  /**
34
- * Updates the manifest with the current set of files that have deleteOrphaned enabled.
39
+ * Updates the manifest with the current set of files that have deleteOrphaned enabled
40
+ * for a specific config. Only modifies that config's namespace - other configs are untouched.
41
+ *
35
42
  * Files with deleteOrphaned: true are added to managedFiles.
36
43
  * Files with deleteOrphaned: false (explicit) are removed from managedFiles.
37
- * Files not in the config but in managedFiles are candidates for deletion.
44
+ * Files not in the config but in managedFiles for this configId are candidates for deletion.
38
45
  *
39
46
  * @param manifest - The existing manifest (or null for new repos)
47
+ * @param configId - The config ID to update
40
48
  * @param filesWithDeleteOrphaned - Map of fileName to deleteOrphaned value (true/false/undefined)
41
49
  * @returns Updated manifest and list of files to delete
42
50
  */
43
- export declare function updateManifest(manifest: XfgManifest | null, filesWithDeleteOrphaned: Map<string, boolean | undefined>): {
51
+ export declare function updateManifest(manifest: XfgManifest | null, configId: string, filesWithDeleteOrphaned: Map<string, boolean | undefined>): {
44
52
  manifest: XfgManifest;
45
53
  filesToDelete: string[];
46
54
  };
package/dist/manifest.js CHANGED
@@ -1,21 +1,44 @@
1
1
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  export const MANIFEST_FILENAME = ".xfg.json";
4
+ /**
5
+ * Type guard to check if a manifest is v1 format.
6
+ */
7
+ function isV1Manifest(manifest) {
8
+ return (typeof manifest === "object" &&
9
+ manifest !== null &&
10
+ manifest.version === 1 &&
11
+ Array.isArray(manifest.managedFiles));
12
+ }
13
+ /**
14
+ * Type guard to check if a manifest is v2 format.
15
+ */
16
+ function isV2Manifest(manifest) {
17
+ return (typeof manifest === "object" &&
18
+ manifest !== null &&
19
+ manifest.version === 2 &&
20
+ typeof manifest.configs === "object" &&
21
+ manifest.configs !== null);
22
+ }
4
23
  /**
5
24
  * Creates an empty manifest with the current version.
6
25
  */
7
26
  export function createEmptyManifest() {
8
27
  return {
9
- version: 1,
10
- managedFiles: [],
28
+ version: 2,
29
+ configs: {},
11
30
  };
12
31
  }
13
32
  /**
14
33
  * Loads the xfg manifest from a repository's working directory.
15
- * Returns null if the manifest file doesn't exist.
34
+ * Returns null if the manifest file doesn't exist or is v1 format.
35
+ *
36
+ * V1 manifests are treated as non-existent because they lack the config ID
37
+ * namespace required for multi-config support. The next run will create
38
+ * a fresh v2 manifest.
16
39
  *
17
40
  * @param workDir - The repository working directory
18
- * @returns The manifest or null if not found
41
+ * @returns The manifest or null if not found or incompatible
19
42
  */
20
43
  export function loadManifest(workDir) {
21
44
  const manifestPath = join(workDir, MANIFEST_FILENAME);
@@ -25,17 +48,16 @@ export function loadManifest(workDir) {
25
48
  try {
26
49
  const content = readFileSync(manifestPath, "utf-8");
27
50
  const parsed = JSON.parse(content);
28
- // Validate the manifest structure
29
- if (typeof parsed !== "object" || parsed === null) {
30
- return null;
51
+ // V2 manifest - return as-is
52
+ if (isV2Manifest(parsed)) {
53
+ return parsed;
31
54
  }
32
- if (parsed.version !== 1) {
55
+ // V1 manifest - treat as no manifest (will be overwritten with v2)
56
+ if (isV1Manifest(parsed)) {
33
57
  return null;
34
58
  }
35
- if (!Array.isArray(parsed.managedFiles)) {
36
- return null;
37
- }
38
- return parsed;
59
+ // Unknown format - treat as no manifest
60
+ return null;
39
61
  }
40
62
  catch {
41
63
  return null;
@@ -53,30 +75,35 @@ export function saveManifest(workDir, manifest) {
53
75
  writeFileSync(manifestPath, content, "utf-8");
54
76
  }
55
77
  /**
56
- * Gets the list of managed files from a manifest.
57
- * Returns an empty array if the manifest is null.
78
+ * Gets the list of managed files for a specific config from a manifest.
79
+ * Returns an empty array if the manifest is null or the config isn't found.
58
80
  *
59
81
  * @param manifest - The manifest or null
60
- * @returns Array of managed file names
82
+ * @param configId - The config ID to get files for
83
+ * @returns Array of managed file names for the given config
61
84
  */
62
- export function getManagedFiles(manifest) {
85
+ export function getManagedFiles(manifest, configId) {
63
86
  if (!manifest) {
64
87
  return [];
65
88
  }
66
- return [...manifest.managedFiles];
89
+ return [...(manifest.configs[configId] ?? [])];
67
90
  }
68
91
  /**
69
- * Updates the manifest with the current set of files that have deleteOrphaned enabled.
92
+ * Updates the manifest with the current set of files that have deleteOrphaned enabled
93
+ * for a specific config. Only modifies that config's namespace - other configs are untouched.
94
+ *
70
95
  * Files with deleteOrphaned: true are added to managedFiles.
71
96
  * Files with deleteOrphaned: false (explicit) are removed from managedFiles.
72
- * Files not in the config but in managedFiles are candidates for deletion.
97
+ * Files not in the config but in managedFiles for this configId are candidates for deletion.
73
98
  *
74
99
  * @param manifest - The existing manifest (or null for new repos)
100
+ * @param configId - The config ID to update
75
101
  * @param filesWithDeleteOrphaned - Map of fileName to deleteOrphaned value (true/false/undefined)
76
102
  * @returns Updated manifest and list of files to delete
77
103
  */
78
- export function updateManifest(manifest, filesWithDeleteOrphaned) {
79
- const existingManaged = new Set(getManagedFiles(manifest));
104
+ export function updateManifest(manifest, configId, filesWithDeleteOrphaned) {
105
+ // Get existing managed files for this config only
106
+ const existingManaged = new Set(getManagedFiles(manifest, configId));
80
107
  const newManaged = new Set();
81
108
  const filesToDelete = [];
82
109
  // Process current config files
@@ -88,17 +115,30 @@ export function updateManifest(manifest, filesWithDeleteOrphaned) {
88
115
  // If deleteOrphaned is false or undefined, don't add to managed set
89
116
  // (explicitly setting false removes from tracking)
90
117
  }
91
- // Find orphaned files: in old manifest but not in current config
118
+ // Find orphaned files: in old manifest for this config but not in current config
92
119
  for (const fileName of existingManaged) {
93
120
  if (!filesWithDeleteOrphaned.has(fileName)) {
94
121
  // File was managed before but is no longer in config - delete it
95
122
  filesToDelete.push(fileName);
96
123
  }
97
124
  }
125
+ // Build updated manifest, preserving other configs
126
+ const updatedConfigs = {
127
+ ...(manifest?.configs ?? {}),
128
+ };
129
+ // Update this config's managed files
130
+ const sortedManaged = Array.from(newManaged).sort();
131
+ if (sortedManaged.length > 0) {
132
+ updatedConfigs[configId] = sortedManaged;
133
+ }
134
+ else {
135
+ // Remove config entry if no managed files
136
+ delete updatedConfigs[configId];
137
+ }
98
138
  return {
99
139
  manifest: {
100
- version: 1,
101
- managedFiles: Array.from(newManaged).sort(),
140
+ version: 2,
141
+ configs: updatedConfigs,
102
142
  },
103
143
  filesToDelete,
104
144
  };
@@ -6,6 +6,8 @@ import { CommandExecutor } from "./command-executor.js";
6
6
  export interface ProcessorOptions {
7
7
  branchName: string;
8
8
  workDir: string;
9
+ /** Config ID for manifest namespacing */
10
+ configId: string;
9
11
  dryRun?: boolean;
10
12
  /** Number of retries for network operations (default: 3) */
11
13
  retries?: number;
@@ -174,7 +174,7 @@ export class RepositoryProcessor {
174
174
  filesWithDeleteOrphaned.set(file.fileName, file.deleteOrphaned);
175
175
  }
176
176
  // Update manifest and get list of files to delete
177
- const { manifest: newManifest, filesToDelete } = updateManifest(existingManifest, filesWithDeleteOrphaned);
177
+ const { manifest: newManifest, filesToDelete } = updateManifest(existingManifest, options.configId, filesWithDeleteOrphaned);
178
178
  // Delete orphaned files (unless --no-delete flag is set)
179
179
  if (filesToDelete.length > 0 && !options.noDelete) {
180
180
  for (const fileName of filesToDelete) {
@@ -197,18 +197,18 @@ export class RepositoryProcessor {
197
197
  this.log.info(`Skipping deletion of ${filesToDelete.length} orphaned file(s) (--no-delete flag)`);
198
198
  }
199
199
  // Save updated manifest (tracks files with deleteOrphaned: true)
200
- // Only save if there are managed files or if we had a previous manifest
201
- if (newManifest.managedFiles.length > 0 || existingManifest !== null) {
200
+ // Only save if there are managed files for any config, or if we had a previous manifest
201
+ const hasAnyManagedFiles = Object.keys(newManifest.configs).length > 0;
202
+ if (hasAnyManagedFiles || existingManifest !== null) {
202
203
  if (!dryRun) {
203
204
  saveManifest(workDir, newManifest);
204
205
  }
205
206
  // Track manifest file as changed if it would be different
206
- const existingManifestFiles = existingManifest?.managedFiles ?? [];
207
- const newManifestFiles = newManifest.managedFiles;
208
- const manifestChanged = JSON.stringify(existingManifestFiles) !==
209
- JSON.stringify(newManifestFiles);
207
+ const existingConfigs = existingManifest?.configs ?? {};
208
+ const manifestChanged = JSON.stringify(existingConfigs) !==
209
+ JSON.stringify(newManifest.configs);
210
210
  if (manifestChanged) {
211
- const manifestExisted = existingManifest !== null;
211
+ const manifestExisted = existsSync(join(workDir, MANIFEST_FILENAME));
212
212
  changedFiles.push({
213
213
  fileName: MANIFEST_FILENAME,
214
214
  action: manifestExisted ? "update" : "create",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "1.10.9",
3
+ "version": "2.0.0",
4
4
  "description": "CLI tool to sync JSON, JSON5, YAML, or text configuration files across multiple Git repositories via pull requests or direct push",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",