@forwardimpact/libutil 0.1.61 → 0.1.63

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,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { spawn } from "node:child_process";
3
4
  import { createScriptConfig } from "@forwardimpact/libconfig";
4
5
  import { createStorage } from "@forwardimpact/libstorage";
6
+ import { createLogger } from "@forwardimpact/libtelemetry";
5
7
  import { createBundleDownloader, execLine } from "@forwardimpact/libutil";
6
8
 
7
9
  /**
@@ -11,11 +13,12 @@ import { createBundleDownloader, execLine } from "@forwardimpact/libutil";
11
13
  */
12
14
  async function main() {
13
15
  await createScriptConfig("download-bundle");
14
- const downloader = await createBundleDownloader(createStorage);
16
+ const logger = createLogger("generated");
17
+ const downloader = createBundleDownloader(createStorage, logger);
15
18
  await downloader.download();
16
19
 
17
20
  // If additional arguments provided, execute them after download
18
- execLine();
21
+ execLine(0, { spawn, process });
19
22
  }
20
23
 
21
24
  main().catch((error) => {
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ import { countTokens } from "@forwardimpact/libutil";
3
+
4
+ /**
5
+ * Counts tokens in the provided text
6
+ * Usage: fit-tiktoken <text>
7
+ * echo "text" | fit-tiktoken
8
+ * @returns {Promise<void>}
9
+ */
10
+ async function main() {
11
+ let text = process.argv.slice(2).join(" ");
12
+
13
+ if (!text && !process.stdin.isTTY) {
14
+ const chunks = [];
15
+ for await (const chunk of process.stdin) {
16
+ chunks.push(chunk);
17
+ }
18
+ text = Buffer.concat(chunks).toString().trim();
19
+ }
20
+
21
+ if (!text) {
22
+ console.error("Usage: fit-tiktoken <text>");
23
+ console.error(' echo "text" | fit-tiktoken');
24
+ process.exit(1);
25
+ }
26
+
27
+ console.log(countTokens(text));
28
+ }
29
+
30
+ main().catch((error) => {
31
+ console.error(error.message);
32
+ process.exit(1);
33
+ });
package/index.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import crypto from "crypto";
2
2
  import fs from "fs/promises";
3
3
  import path from "path";
4
- import { spawn } from "child_process";
5
4
 
6
5
  import { Tokenizer, ranks } from "./tokenizer.js";
7
6
  import { Finder } from "./finder.js";
@@ -10,16 +9,22 @@ import { TarExtractor } from "./extractor.js";
10
9
 
11
10
  /**
12
11
  * Updates or creates an environment variable in .env file
12
+ * @param {string} filePath - Path to .env file
13
13
  * @param {string} key - Environment variable name (e.g., "SERVICE_SECRET")
14
14
  * @param {string} value - Environment variable value
15
- * @param {string} [envPath] - Path to .env file (defaults to .env in current directory)
15
+ * @param {object} fsFns - File system functions
16
+ * @param {Function} fsFns.readFile - Async file read function
17
+ * @param {Function} fsFns.writeFile - Async file write function
16
18
  */
17
- export async function updateEnvFile(key, value, envPath = ".env") {
18
- const fullPath = path.resolve(envPath);
19
+ export async function updateEnvFile(filePath, key, value, fsFns) {
20
+ if (!fsFns?.readFile) throw new Error("fsFns.readFile is required");
21
+ if (!fsFns?.writeFile) throw new Error("fsFns.writeFile is required");
22
+
23
+ const fullPath = path.resolve(filePath);
19
24
  let content = "";
20
25
 
21
26
  try {
22
- content = await fs.readFile(fullPath, "utf8");
27
+ content = await fsFns.readFile(fullPath, "utf8");
23
28
  } catch (error) {
24
29
  // It's ok if the file doesn't exist
25
30
  if (error.code !== "ENOENT") throw error;
@@ -47,7 +52,7 @@ export async function updateEnvFile(key, value, envPath = ".env") {
47
52
  }
48
53
 
49
54
  // Write back to file
50
- await fs.writeFile(fullPath, lines.join("\n"));
55
+ await fsFns.writeFile(fullPath, lines.join("\n"));
51
56
  }
52
57
 
53
58
  /**
@@ -95,14 +100,13 @@ export function createTokenizer() {
95
100
  * Creates a BundleDownloader instance configured for generated code management.
96
101
  * Used in containerized deployments to download pre-generated code bundles.
97
102
  * @param {Function} createStorage - Storage factory function from libstorage
98
- * @returns {Promise<BundleDownloader>} Configured BundleDownloader instance
103
+ * @param {object} logger - Logger instance
104
+ * @returns {BundleDownloader} Configured BundleDownloader instance
99
105
  */
100
- export async function createBundleDownloader(createStorage) {
106
+ export function createBundleDownloader(createStorage, logger) {
101
107
  if (!createStorage) throw new Error("createStorage is required");
108
+ if (!logger) throw new Error("logger is required");
102
109
 
103
- // Dynamic import to avoid circular dependency with libtelemetry
104
- const { createLogger } = await import("@forwardimpact/libtelemetry");
105
- const logger = createLogger("generated");
106
110
  const finder = new Finder(fs, logger);
107
111
  const extractor = new TarExtractor(fs, path);
108
112
 
@@ -111,11 +115,18 @@ export async function createBundleDownloader(createStorage) {
111
115
 
112
116
  /**
113
117
  * Executes command line arguments as child process, similar to execv() in C
114
- * @param {number} [shift] - Number of arguments to skip from process.argv before extracting command
118
+ * @param {number} shift - Number of arguments to skip from process.argv before extracting command
119
+ * @param {object} deps - Required dependencies
120
+ * @param {Function} deps.spawn - Child process spawn function
121
+ * @param {object} deps.process - Process object (argv, env, on, exit)
115
122
  * @returns {void} Function does not return - exits parent process
116
123
  */
117
- export function execLine(shift = 0) {
118
- const args = process.argv.slice(2 + shift);
124
+ export function execLine(shift, deps) {
125
+ if (!deps?.spawn) throw new Error("deps.spawn is required");
126
+ if (!deps?.process) throw new Error("deps.process is required");
127
+
128
+ const { spawn: spawnFn, process: proc } = deps;
129
+ const args = proc.argv.slice(2 + (shift || 0));
119
130
  if (args.length === 0) return;
120
131
 
121
132
  // Look for '--' delimiter and use everything after it as the command
@@ -125,23 +136,23 @@ export function execLine(shift = 0) {
125
136
  if (line.length === 0) return;
126
137
 
127
138
  const [command, ...commandArgs] = line;
128
- const child = spawn(command, commandArgs, {
139
+ const child = spawnFn(command, commandArgs, {
129
140
  stdio: "inherit",
130
- env: process.env,
141
+ env: proc.env,
131
142
  });
132
143
 
133
144
  // Forward signals to child process
134
145
  ["SIGTERM", "SIGINT", "SIGQUIT"].forEach((signal) => {
135
- process.on(signal, () => child.kill(signal));
146
+ proc.on(signal, () => child.kill(signal));
136
147
  });
137
148
 
138
149
  child.on("error", (error) => {
139
150
  console.error("Error:", error);
140
- process.exit(1);
151
+ proc.exit(1);
141
152
  });
142
153
 
143
154
  child.on("exit", (code, signal) => {
144
- process.exit(signal ? 1 : code || 0);
155
+ proc.exit(signal ? 1 : code || 0);
145
156
  });
146
157
  }
147
158
 
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@forwardimpact/libutil",
3
- "version": "0.1.61",
3
+ "version": "0.1.63",
4
4
  "description": "Utility functions and utilities for Guide",
5
5
  "license": "Apache-2.0",
6
6
  "author": "D. Olsson <hi@senzilla.io>",
7
7
  "type": "module",
8
8
  "main": "index.js",
9
9
  "bin": {
10
- "fit-download-bundle": "./bin/fit-download-bundle.js"
10
+ "fit-download-bundle": "./bin/fit-download-bundle.js",
11
+ "fit-tiktoken": "./bin/fit-tiktoken.js"
11
12
  },
12
13
  "engines": {
13
14
  "node": ">=22.0.0"
@@ -4,35 +4,32 @@ import assert from "node:assert";
4
4
  // Module under test
5
5
  import { createBundleDownloader } from "../index.js";
6
6
 
7
+ const noop = () => {};
8
+ const mockLogger = { info: noop, debug: noop, warn: noop, error: noop };
9
+
7
10
  describe("libutil", () => {
8
11
  describe("createBundleDownloader", () => {
9
- test("creates BundleDownloader instance with correct dependencies", async () => {
12
+ test("creates BundleDownloader instance with correct dependencies", () => {
10
13
  const mockStorageFactory = mock.fn();
11
- const mockProcess = { env: { STORAGE_TYPE: "local" } };
12
14
 
13
- const downloader = await createBundleDownloader(
14
- mockStorageFactory,
15
- mockProcess,
16
- );
15
+ const downloader = createBundleDownloader(mockStorageFactory, mockLogger);
17
16
 
18
17
  assert.ok(downloader);
19
- // Check that it's a BundleDownloader instance by checking if it has the expected methods
20
18
  assert.ok(typeof downloader.initialize === "function");
21
19
  assert.ok(typeof downloader.download === "function");
22
20
  });
23
21
 
24
- test("validates storageFactory parameter", async () => {
25
- await assert.rejects(() => createBundleDownloader(null), {
22
+ test("validates storageFactory parameter", () => {
23
+ assert.throws(() => createBundleDownloader(null, mockLogger), {
26
24
  message: /createStorage is required/,
27
25
  });
28
26
  });
29
27
 
30
- test("uses global process when not provided", async () => {
28
+ test("validates logger parameter", () => {
31
29
  const mockStorageFactory = mock.fn();
32
-
33
- const downloader = await createBundleDownloader(mockStorageFactory);
34
-
35
- assert.ok(downloader);
30
+ assert.throws(() => createBundleDownloader(mockStorageFactory, null), {
31
+ message: /logger is required/,
32
+ });
36
33
  });
37
34
  });
38
35
  });
package/test/wait.test.js CHANGED
@@ -3,13 +3,26 @@ import assert from "node:assert";
3
3
 
4
4
  import { waitFor } from "../wait.js";
5
5
 
6
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
7
+
6
8
  describe("waitFor", () => {
9
+ test("throws if delayFn is missing", async () => {
10
+ await assert.rejects(
11
+ () => waitFor(() => Promise.resolve(true), {}),
12
+ /delayFn is required/,
13
+ );
14
+ });
15
+
7
16
  test("resolves immediately when condition is true", async () => {
8
17
  let callCount = 0;
9
- await waitFor(() => {
10
- callCount++;
11
- return Promise.resolve(true);
12
- });
18
+ await waitFor(
19
+ () => {
20
+ callCount++;
21
+ return Promise.resolve(true);
22
+ },
23
+ {},
24
+ delay,
25
+ );
13
26
 
14
27
  assert.strictEqual(callCount, 1);
15
28
  });
@@ -22,6 +35,7 @@ describe("waitFor", () => {
22
35
  return Promise.resolve(callCount >= 3);
23
36
  },
24
37
  { interval: 10, timeout: 5000 },
38
+ delay,
25
39
  );
26
40
 
27
41
  assert.strictEqual(callCount, 3);
@@ -30,10 +44,11 @@ describe("waitFor", () => {
30
44
  test("throws error on timeout", async () => {
31
45
  await assert.rejects(
32
46
  () =>
33
- waitFor(() => Promise.resolve(false), {
34
- timeout: 50,
35
- interval: 10,
36
- }),
47
+ waitFor(
48
+ () => Promise.resolve(false),
49
+ { timeout: 50, interval: 10 },
50
+ delay,
51
+ ),
37
52
  { message: "Timeout waiting for condition after 50ms" },
38
53
  );
39
54
  });
@@ -49,18 +64,22 @@ describe("waitFor", () => {
49
64
  return Promise.resolve(true);
50
65
  },
51
66
  { interval: 10, timeout: 5000 },
67
+ delay,
52
68
  );
53
69
 
54
70
  assert.strictEqual(callCount, 3);
55
71
  });
56
72
 
57
73
  test("uses default options when not provided", async () => {
58
- // Just verify it doesn't throw with defaults
59
74
  let called = false;
60
- await waitFor(() => {
61
- called = true;
62
- return Promise.resolve(true);
63
- });
75
+ await waitFor(
76
+ () => {
77
+ called = true;
78
+ return Promise.resolve(true);
79
+ },
80
+ {},
81
+ delay,
82
+ );
64
83
 
65
84
  assert.strictEqual(called, true);
66
85
  });
@@ -81,6 +100,7 @@ describe("waitFor", () => {
81
100
  return Promise.resolve(callCount >= 4);
82
101
  },
83
102
  { interval: 20, maxInterval: 100, timeout: 5000 },
103
+ delay,
84
104
  );
85
105
 
86
106
  // Intervals should generally increase (with some tolerance for timing)
package/wait.js CHANGED
@@ -1,14 +1,17 @@
1
1
  /**
2
2
  * Poll until a condition returns true with exponential backoff
3
3
  * @param {() => Promise<boolean>} checkFn - Function that returns true when ready
4
- * @param {object} [options] - Configuration options
4
+ * @param {object} options - Configuration options
5
5
  * @param {number} [options.timeout] - Maximum time to wait in ms
6
6
  * @param {number} [options.interval] - Initial polling interval in ms
7
7
  * @param {number} [options.maxInterval] - Maximum polling interval in ms
8
+ * @param {(ms: number) => Promise<void>} delayFn - Function that returns a promise resolving after ms
8
9
  * @returns {Promise<void>}
9
10
  * @throws {Error} When timeout is reached
10
11
  */
11
- export async function waitFor(checkFn, options = {}) {
12
+ export async function waitFor(checkFn, options, delayFn) {
13
+ if (!delayFn) throw new Error("delayFn is required");
14
+
12
15
  const { timeout = 30000, interval = 1000, maxInterval = 10000 } = options;
13
16
  const startTime = Date.now();
14
17
  let currentInterval = interval;
@@ -20,7 +23,7 @@ export async function waitFor(checkFn, options = {}) {
20
23
  // Ignore errors during polling - service may not be up yet
21
24
  }
22
25
 
23
- await new Promise((resolve) => setTimeout(resolve, currentInterval));
26
+ await delayFn(currentInterval);
24
27
  currentInterval = Math.min(currentInterval * 1.5, maxInterval);
25
28
  }
26
29