@forwardimpact/libcodegen 0.1.32 → 0.1.37

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/base.js CHANGED
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  * Implements dependency injection pattern with explicit validation
7
7
  */
8
8
  export class CodegenBase {
9
+ #protoDirs;
9
10
  #projectRoot;
10
11
  #path;
11
12
  #mustache;
@@ -14,19 +15,24 @@ export class CodegenBase {
14
15
 
15
16
  /**
16
17
  * Creates a new codegen base instance with dependency injection
17
- * @param {string} projectRoot - Project root directory path
18
+ * @param {string[]} protoDirs - Array of proto directory paths to scan
19
+ * @param {string} projectRoot - Project root directory path (for tools/ discovery)
18
20
  * @param {object} path - Path module for file operations
19
21
  * @param {object} mustache - Mustache template rendering module
20
22
  * @param {object} protoLoader - Protocol buffer loader module
21
23
  * @param {object} fs - File system module (sync operations only)
22
24
  */
23
- constructor(projectRoot, path, mustache, protoLoader, fs) {
25
+ constructor(protoDirs, projectRoot, path, mustache, protoLoader, fs) {
26
+ if (!protoDirs || !Array.isArray(protoDirs) || protoDirs.length === 0) {
27
+ throw new Error("protoDirs must be a non-empty array");
28
+ }
24
29
  if (!projectRoot) throw new Error("projectRoot is required");
25
30
  if (!path) throw new Error("path module is required");
26
31
  if (!mustache) throw new Error("mustache module is required");
27
32
  if (!protoLoader) throw new Error("protoLoader module is required");
28
33
  if (!fs) throw new Error("fs module is required");
29
34
 
35
+ this.#protoDirs = protoDirs;
30
36
  this.#projectRoot = projectRoot;
31
37
  this.#path = path;
32
38
  this.#mustache = mustache;
@@ -35,36 +41,46 @@ export class CodegenBase {
35
41
  }
36
42
 
37
43
  /**
38
- * Collect all proto files from project proto directory and tools directory
44
+ * Collect all proto files from discovered proto directories and tools directory.
45
+ * Deduplicates by filename (last occurrence wins), maintains common.proto-first
46
+ * ordering, and includes all discovered directories as proto include paths.
39
47
  * @param {object} opts - Collection options
40
48
  * @param {boolean} opts.includeTools - Whether to include tool proto files
41
49
  * @returns {string[]} Array of absolute proto file paths
42
50
  */
43
51
  collectProtoFiles(opts = {}) {
44
52
  const { includeTools = true } = opts;
45
- const protoDir = this.#path.join(this.#projectRoot, "proto");
46
53
 
47
- const discovered = this.#fs
48
- .readdirSync(protoDir)
49
- .filter((f) => f.endsWith(".proto"))
50
- .sort();
54
+ // Collect from all proto directories, dedup by filename (last wins)
55
+ const byName = new Map();
56
+ for (const dir of this.#protoDirs) {
57
+ if (!this.#fs.existsSync(dir)) continue;
58
+ for (const file of this.#fs.readdirSync(dir)) {
59
+ if (file.endsWith(".proto")) {
60
+ byName.set(file, this.#path.join(dir, file));
61
+ }
62
+ }
63
+ }
51
64
 
52
- const ordered = discovered.includes("common.proto")
65
+ // Sort and ensure common.proto comes first
66
+ const sorted = Array.from(byName.keys()).sort();
67
+ const ordered = sorted.includes("common.proto")
53
68
  ? [
54
- this.#path.join(protoDir, "common.proto"),
55
- ...discovered
69
+ byName.get("common.proto"),
70
+ ...sorted
56
71
  .filter((f) => f !== "common.proto")
57
- .map((f) => this.#path.join(protoDir, f)),
72
+ .map((f) => byName.get(f)),
58
73
  ]
59
- : discovered.map((f) => this.#path.join(protoDir, f));
74
+ : sorted.map((f) => byName.get(f));
60
75
 
61
76
  if (includeTools) {
62
77
  try {
78
+ const toolsDir = this.#path.join(this.#projectRoot, "tools");
63
79
  ordered.push(
64
80
  ...this.#fs
65
- .readdirSync(this.#path.join(this.#projectRoot, "tools"))
81
+ .readdirSync(toolsDir)
66
82
  .filter((f) => f.endsWith(".proto"))
67
- .map((f) => this.#path.join(this.#projectRoot, "tools", f)),
83
+ .map((f) => this.#path.join(toolsDir, f)),
68
84
  );
69
85
  } catch {
70
86
  // tools directory may not exist; ignore
@@ -74,6 +90,14 @@ export class CodegenBase {
74
90
  return ordered;
75
91
  }
76
92
 
93
+ /**
94
+ * Get all proto include directories for cross-file import resolution
95
+ * @returns {string[]} Array of absolute paths to proto directories
96
+ */
97
+ get includeDirs() {
98
+ return this.#protoDirs.filter((dir) => this.#fs.existsSync(dir));
99
+ }
100
+
77
101
  /**
78
102
  * Load mustache template for given kind
79
103
  * @param {"service"|"client"|"exports"|"definition"|"definitions-exports"|"services-exports"} kind - Template kind
@@ -145,7 +169,7 @@ export class CodegenBase {
145
169
  */
146
170
  parseProtoFile(protoPath) {
147
171
  const def = this.#protoLoader.loadSync(protoPath, {
148
- includeDirs: [this.#path.dirname(protoPath)],
172
+ includeDirs: this.includeDirs,
149
173
  keepCase: true,
150
174
  });
151
175
 
@@ -3,7 +3,6 @@
3
3
  import fs from "node:fs";
4
4
  import fsAsync from "node:fs/promises";
5
5
  import path from "node:path";
6
- import { fileURLToPath } from "node:url";
7
6
  import { execFileSync } from "node:child_process";
8
7
  import { parseArgs } from "node:util";
9
8
 
@@ -20,9 +19,6 @@ import {
20
19
  } from "@forwardimpact/libcodegen";
21
20
  import { createStorage } from "@forwardimpact/libstorage";
22
21
 
23
- const __filename = fileURLToPath(import.meta.url);
24
- const __dirname = path.dirname(__filename);
25
-
26
22
  /**
27
23
  * Create tar.gz bundle of all directories inside sourcePath
28
24
  * @param {string} sourcePath - Path containing directories to bundle
@@ -63,11 +59,11 @@ function printUsage() {
63
59
  process.stdout.write(
64
60
  [
65
61
  "Usage:",
66
- ` bunx fit-codegen --all # Generate all code`,
67
- ` bunx fit-codegen --type # Generate protobuf types only`,
68
- ` bunx fit-codegen --service # Generate service bases only`,
69
- ` bunx fit-codegen --client # Generate clients only`,
70
- ` bunx fit-codegen --definition # Generate service definitions only`,
62
+ ` npx fit-codegen --all # Generate all code`,
63
+ ` npx fit-codegen --type # Generate protobuf types only`,
64
+ ` npx fit-codegen --service # Generate service bases only`,
65
+ ` npx fit-codegen --client # Generate clients only`,
66
+ ` npx fit-codegen --definition # Generate service definitions only`,
71
67
  ].join("\n") + "\n",
72
68
  );
73
69
  }
@@ -117,16 +113,65 @@ function parseFlags() {
117
113
  }
118
114
 
119
115
  /**
120
- * Create codegen instances
116
+ * Discover proto directories from installed @forwardimpact/* packages
117
+ * and the project root. Scans node_modules for packages that include
118
+ * a proto/ subdirectory, plus the project's own proto/ if present.
121
119
  * @param {string} projectRoot - Project root directory path
120
+ * @returns {string[]} Array of absolute paths to proto directories
121
+ */
122
+ function discoverProtoDirs(projectRoot) {
123
+ const protoDirs = [];
124
+
125
+ // Scan node_modules/@forwardimpact/*/proto/ for package-owned protos
126
+ // Use fs.statSync to follow workspace symlinks (entry.isDirectory() is false for symlinks)
127
+ const scopeDir = path.join(projectRoot, "node_modules", "@forwardimpact");
128
+ if (fs.existsSync(scopeDir)) {
129
+ for (const name of fs.readdirSync(scopeDir)) {
130
+ const protoDir = path.join(scopeDir, name, "proto");
131
+ if (fs.existsSync(protoDir) && fs.statSync(protoDir).isDirectory()) {
132
+ protoDirs.push(fs.realpathSync(protoDir));
133
+ }
134
+ }
135
+ }
136
+
137
+ // Also check workspace-linked packages (monorepo with symlinked node_modules)
138
+ // The loop above handles this since workspace packages appear in node_modules
139
+
140
+ // Include the project's own proto/ directory for custom protos
141
+ const projectProtoDir = path.join(projectRoot, "proto");
142
+ if (fs.existsSync(projectProtoDir)) {
143
+ protoDirs.push(projectProtoDir);
144
+ }
145
+
146
+ return protoDirs;
147
+ }
148
+
149
+ /**
150
+ * Create codegen instances
151
+ * @param {string[]} protoDirs - Array of proto directory paths
152
+ * @param {string} projectRoot - Project root for tools/ discovery
122
153
  * @param {object} path - Path module
123
154
  * @param {object} mustache - Mustache module
124
155
  * @param {object} protoLoader - Proto loader module
125
156
  * @param {object} fs - File system module
126
157
  * @returns {object} Codegen instances
127
158
  */
128
- function createCodegen(projectRoot, path, mustache, protoLoader, fs) {
129
- const base = new CodegenBase(projectRoot, path, mustache, protoLoader, fs);
159
+ function createCodegen(
160
+ protoDirs,
161
+ projectRoot,
162
+ path,
163
+ mustache,
164
+ protoLoader,
165
+ fs,
166
+ ) {
167
+ const base = new CodegenBase(
168
+ protoDirs,
169
+ projectRoot,
170
+ path,
171
+ mustache,
172
+ protoLoader,
173
+ fs,
174
+ );
130
175
  return {
131
176
  types: new CodegenTypes(base),
132
177
  services: new CodegenServices(base),
@@ -175,11 +220,12 @@ async function executeGeneration(codegens, sourcePath, flags) {
175
220
  }
176
221
 
177
222
  /**
178
- * Simplified main function
223
+ * Run code generation pipeline
224
+ * @param {string[]} protoDirs - Discovered proto directories
179
225
  * @param {string} projectRoot - Project root directory path
180
226
  * @param {object} finder - Finder instance for path management
181
227
  */
182
- async function runCodegen(projectRoot, finder) {
228
+ async function runCodegen(protoDirs, projectRoot, finder) {
183
229
  const parsedFlags = parseFlags();
184
230
 
185
231
  if (!parsedFlags.hasGenerationFlags()) {
@@ -193,36 +239,20 @@ async function runCodegen(projectRoot, finder) {
193
239
 
194
240
  await generatedStorage.ensureBucket();
195
241
 
196
- const codegens = createCodegen(projectRoot, path, mustache, protoLoader, fs);
242
+ const codegens = createCodegen(
243
+ protoDirs,
244
+ projectRoot,
245
+ path,
246
+ mustache,
247
+ protoLoader,
248
+ fs,
249
+ );
197
250
  await executeGeneration(codegens, sourcePath, parsedFlags);
198
251
 
199
252
  await finder.createPackageSymlinks(sourcePath);
200
253
  await createBundle(sourcePath);
201
254
  }
202
255
 
203
- /**
204
- * Find the monorepo root directory (the one with workspaces)
205
- * @param {string} startPath - Starting directory path
206
- * @returns {string} Project root directory path
207
- */
208
- function findMonorepoRoot(startPath) {
209
- let current = startPath;
210
- for (let depth = 0; depth < 10; depth++) {
211
- const packageJsonPath = path.join(current, "package.json");
212
- if (fs.existsSync(packageJsonPath)) {
213
- const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
214
- // Check if this package.json has workspaces (indicates monorepo root)
215
- if (packageJson.workspaces) {
216
- return current;
217
- }
218
- }
219
- const parent = path.dirname(current);
220
- if (parent === current) break;
221
- current = parent;
222
- }
223
- throw new Error("Could not find monorepo root");
224
- }
225
-
226
256
  /**
227
257
  * CLI entry point
228
258
  */
@@ -230,9 +260,18 @@ async function main() {
230
260
  try {
231
261
  const logger = new Logger("codegen");
232
262
  const finder = new Finder(fsAsync, logger, process);
233
- const projectRoot = findMonorepoRoot(__dirname);
263
+ const projectRoot = finder.findProjectRoot(process.cwd());
264
+
265
+ const protoDirs = discoverProtoDirs(projectRoot);
266
+ if (protoDirs.length === 0) {
267
+ throw new Error(
268
+ "No proto directories found. Ensure @forwardimpact packages " +
269
+ "with proto/ directories are installed, or add proto files " +
270
+ "to your project's proto/ directory.",
271
+ );
272
+ }
234
273
 
235
- await runCodegen(projectRoot, finder);
274
+ await runCodegen(protoDirs, projectRoot, finder);
236
275
  } catch (err) {
237
276
  process.stderr.write(`Error: ${err.message}\n`);
238
277
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libcodegen",
3
- "version": "0.1.32",
3
+ "version": "0.1.37",
4
4
  "description": "Protocol Buffer code generation utilities for Guide",
5
5
  "license": "Apache-2.0",
6
6
  "author": "D. Olsson <hi@senzilla.io>",
@@ -10,20 +10,24 @@
10
10
  "fit-codegen": "./bin/fit-codegen.js"
11
11
  },
12
12
  "engines": {
13
- "bun": ">=1.2.0"
13
+ "bun": ">=1.2.0",
14
+ "node": ">=18.0.0"
14
15
  },
15
16
  "scripts": {
16
17
  "test": "bun run node --test test/*.test.js"
17
18
  },
18
19
  "dependencies": {
20
+ "@forwardimpact/libstorage": "^0.1.53",
21
+ "@forwardimpact/libtelemetry": "^0.1.22",
22
+ "@forwardimpact/libutil": "^0.1.64",
19
23
  "@grpc/proto-loader": "^0.8.0",
20
- "mustache": "^4.2.0"
21
- },
22
- "devDependencies": {
23
- "@forwardimpact/libharness": "^0.1.5",
24
+ "mustache": "^4.2.0",
24
25
  "protobufjs": "^7.5.4",
25
26
  "protobufjs-cli": "^2.0.0"
26
27
  },
28
+ "devDependencies": {
29
+ "@forwardimpact/libharness": "^0.1.5"
30
+ },
27
31
  "publishConfig": {
28
32
  "access": "public"
29
33
  }
package/types.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { createRequire } from "node:module";
2
+
1
3
  /**
2
4
  * Handles JavaScript type generation from protobuf files
3
5
  * Specializes in Protocol Buffer to JavaScript type conversion
@@ -70,7 +72,15 @@ export class CodegenTypes {
70
72
  * @returns {Promise<void>}
71
73
  */
72
74
  async generateJavaScriptTypes(protoFiles, outFile) {
75
+ // Resolve pbjs binary from protobufjs-cli package — works in both Bun and Node
76
+ const require = createRequire(import.meta.url);
77
+ const pbjsBin = require.resolve("protobufjs-cli/bin/pbjs");
78
+
79
+ // Pass all proto directories as include paths so cross-file imports resolve
80
+ const includePaths = this.#base.includeDirs.flatMap((dir) => ["-p", dir]);
81
+
73
82
  const args = [
83
+ pbjsBin,
74
84
  "-t",
75
85
  "static-module",
76
86
  "-w",
@@ -80,12 +90,13 @@ export class CodegenTypes {
80
90
  "--no-service",
81
91
  "--force-message",
82
92
  "--keep-case",
93
+ ...includePaths,
83
94
  "-o",
84
95
  outFile,
85
96
  ...protoFiles,
86
97
  ];
87
98
 
88
- await this.#base.run("bunx", ["pbjs", ...args], {
99
+ await this.#base.run(process.execPath, args, {
89
100
  cwd: this.#base.projectRoot,
90
101
  });
91
102
  }