diff --git a/CHANGELOG.md b/CHANGELOG.md
index bc17d7a59b42..35501a087fb7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
 
 - Expose `ccf:http::parse_accept_header()` and `ccf::http::AcceptHeaderField` (#6706).
 - Added `ccf::cose::AbstractCOSESignaturesConfig` subsystem to expose COSE signature configuration to application handlers (#6707).
+- Package `build_bundle.ts` under `npx ccf-build-bundle` to allow javascript users to build a ccf schema bundle (#6704).
 
 ## [6.0.0-dev9]
 
diff --git a/doc/build_apps/js_app_bundle.rst b/doc/build_apps/js_app_bundle.rst
index 7100ac80f3b4..d1eb664f39e5 100644
--- a/doc/build_apps/js_app_bundle.rst
+++ b/doc/build_apps/js_app_bundle.rst
@@ -338,8 +338,7 @@ The key fields are:
 Once :ref:`submitted and accepted <governance/proposals:Submitting a New Proposal>`, a ``set_js_app`` proposal atomically (re-)deploys the complete JavaScript application.
 Any existing application endpoints and JavaScript modules are removed.
 
-If you are using ``npm`` or similar to build your app it may make sense to convert your app into a proposal-ready JSON bundle during packaging.
-For an example of how this could be done, see :ccf_repo:`tests/npm-app/build_bundle.js` from one of CCF's test applications, called by ``npm build`` from the corresponding :ccf_repo:`tests/npm-app/package.json`.
+If you are using ``npm`` to build your app, we package a `ccf-build-bundle` script alongside `ccf-app`. This can be run using `npx --package @microsoft/ccf-app ccf-build-bundle path/to/root/of/app` to package the `app.json` and all javascript modules under `src` into a proposal-ready JSON bundle.
 
 Bytecode cache
 ~~~~~~~~~~~~~~
diff --git a/js/ccf-app/.gitignore b/js/ccf-app/.gitignore
index 92780ed4fa90..f0ceb8748d0e 100644
--- a/js/ccf-app/.gitignore
+++ b/js/ccf-app/.gitignore
@@ -1,4 +1,5 @@
 /*.tgz
 /*.d.ts
 /*.js
-/html
\ No newline at end of file
+/html
+/scripts
\ No newline at end of file
diff --git a/js/ccf-app/package.json b/js/ccf-app/package.json
index e0aff0851229..dddf43ea99ca 100644
--- a/js/ccf-app/package.json
+++ b/js/ccf-app/package.json
@@ -31,5 +31,8 @@
     "ts-node": "^10.4.0",
     "typedoc": "^0.27.0",
     "typescript": "^5.7.2"
+  },
+  "bin": {
+    "ccf-build-bundle": "scripts/build_bundle.js"
   }
 }
diff --git a/js/ccf-app/src/scripts/build_bundle.ts b/js/ccf-app/src/scripts/build_bundle.ts
new file mode 100644
index 000000000000..ae84db5447fa
--- /dev/null
+++ b/js/ccf-app/src/scripts/build_bundle.ts
@@ -0,0 +1,81 @@
+#!/usr/bin/env node
+
+import {
+  readdirSync,
+  statSync,
+  readFileSync,
+  writeFileSync,
+  existsSync,
+} from "fs";
+import { join, resolve, sep } from "path";
+
+function getAllFiles(dirPath: string): string[] {
+  const toSearch = [dirPath];
+  const agg = [];
+
+  for (const filePath of toSearch) {
+    if (statSync(filePath).isDirectory()) {
+      for (const subfile of readdirSync(filePath)) {
+        toSearch.push(join(filePath, subfile));
+      }
+    } else {
+      agg.push(filePath);
+    }
+  }
+  return agg;
+}
+
+function removePrefix(s: string, prefix: string): string {
+  if (s.startsWith(prefix)) {
+    return s.slice(prefix.length).split(sep).join(sep);
+  }
+  console.log("Warn: tried to remove invalid prefix", s, prefix);
+  return s;
+}
+
+const args = process.argv.slice(2);
+
+if (args.length < 1) {
+  console.log("Usage: build_bundle <root_directory>");
+  process.exit(1);
+}
+
+function assertFileExists(path: string) {
+  if (!existsSync(path)) {
+    console.log("File not found: %s", path);
+    process.exit(1);
+  }
+}
+
+const argRootDirPath = args[0];
+assertFileExists(argRootDirPath);
+const rootDirPath = resolve(argRootDirPath);
+const metadataPath = join(rootDirPath, "app.json");
+assertFileExists(metadataPath);
+const srcDirPath = join(rootDirPath, "src");
+assertFileExists(srcDirPath);
+
+const metadata = JSON.parse(readFileSync(metadataPath, "utf-8"));
+const allFiles = getAllFiles(srcDirPath);
+
+// The trailing / is included so that it is trimmed in removePrefix.
+// This produces "foo/bar.js" rather than "/foo/bar.js"
+const toTrim = srcDirPath + "/";
+
+const modules = allFiles.map(function (filePath) {
+  return {
+    name: removePrefix(filePath, toTrim),
+    module: readFileSync(filePath, "utf-8"),
+  };
+});
+
+const bundlePath = join(args[0], "bundle.json");
+const bundle = {
+  metadata: metadata,
+  modules: modules,
+};
+
+console.log(
+  `Writing bundle containing ${modules.length} modules to ${bundlePath}`,
+);
+writeFileSync(bundlePath, JSON.stringify(bundle));
diff --git a/tests/npm-app/build_bundle.js b/tests/npm-app/build_bundle.js
deleted file mode 100644
index 83ad2c3e22f6..000000000000
--- a/tests/npm-app/build_bundle.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import { readdirSync, statSync, readFileSync, writeFileSync } from "fs";
-import { join, posix, sep } from "path";
-
-const args = process.argv.slice(2);
-
-const getAllFiles = function (dirPath, arrayOfFiles) {
-  arrayOfFiles = arrayOfFiles || [];
-
-  const files = readdirSync(dirPath);
-  for (const file of files) {
-    const filePath = join(dirPath, file);
-    if (statSync(filePath).isDirectory()) {
-      arrayOfFiles = getAllFiles(filePath, arrayOfFiles);
-    } else {
-      arrayOfFiles.push(filePath);
-    }
-  }
-
-  return arrayOfFiles;
-};
-
-const removePrefix = function (s, prefix) {
-  return s.substr(prefix.length).split(sep).join(posix.sep);
-};
-
-const rootDir = args[0];
-
-const metadataPath = join(rootDir, "app.json");
-const metadata = JSON.parse(readFileSync(metadataPath, "utf-8"));
-
-const srcDir = join(rootDir, "src");
-const allFiles = getAllFiles(srcDir);
-
-// The trailing / is included so that it is trimmed in removePrefix.
-// This produces "foo/bar.js" rather than "/foo/bar.js"
-const toTrim = srcDir + "/";
-
-const modules = allFiles.map(function (filePath) {
-  return {
-    name: removePrefix(filePath, toTrim),
-    module: readFileSync(filePath, "utf-8"),
-  };
-});
-
-const bundlePath = join(args[0], "bundle.json");
-const bundle = {
-  metadata: metadata,
-  modules: modules,
-};
-console.log(
-  `Writing bundle containing ${modules.length} modules to ${bundlePath}`,
-);
-writeFileSync(bundlePath, JSON.stringify(bundle));
diff --git a/tests/npm-app/package.json b/tests/npm-app/package.json
index b0cc8ca2fa33..35577a12540b 100644
--- a/tests/npm-app/package.json
+++ b/tests/npm-app/package.json
@@ -1,8 +1,8 @@
 {
   "private": true,
   "scripts": {
-    "build": "del-cli -f dist/ && rollup --config && cp app.json dist/ && node build_bundle.js dist/",
-    "bundle": "node build_bundle.js dist",
+    "build": "del-cli -f dist/ && rollup --config && cp app.json dist/ && npx ccf-build-bundle dist",
+    "bundle": "npx ccf-build-bundle dist",
     "test": "node --version"
   },
   "type": "module",