@progressive-development/pd-provider-firebase-functions 1.3.1 → 1.4.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.
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Makes a Firebase Functions package.json cloud-ready.
4
+ *
5
+ * The cloud build installs with npm, and npm does not know the `workspace:`
6
+ * protocol — a single `workspace:*` ends the deploy with EUNSUPPORTEDPROTOCOL
7
+ * before anything is uploaded. So every workspace dependency is replaced by
8
+ * something npm can resolve. Two cases, and the difference is whether the
9
+ * package is visible to the registry:
10
+ *
11
+ * PUBLISHED → `^<local version>`. The cloud fetches it from the registry.
12
+ * So that this never silently deploys an old build: publish
13
+ * first, then deploy.
14
+ * PRIVATE → `file:vendor/<name>.tgz`. A private package exists in no
15
+ * registry; it is packed here and lands in the functions folder,
16
+ * which is uploaded anyway. Packed is what `files` names — i.e.
17
+ * `dist`, which must be built BEFORE (see the predeploy chain in
18
+ * firebase.json).
19
+ *
20
+ * No configuration: pnpm links every workspace dependency into the functions
21
+ * package's node_modules as a symlink, and its real path is the source folder.
22
+ *
23
+ * Usage (firebase.json → functions.predeploy, run inside the functions package):
24
+ * pnpm --filter <functions-package> exec pd-prepare-functions-deploy [--dry-run] [dir]
25
+ *
26
+ * CAUTION: the script rewrites package.json for good. To continue working:
27
+ * git checkout -- packages/functions/package.json && pnpm install
28
+ */
29
+ import { execFileSync } from "node:child_process";
30
+ import {
31
+ existsSync,
32
+ mkdirSync,
33
+ readFileSync,
34
+ realpathSync,
35
+ renameSync,
36
+ writeFileSync,
37
+ } from "node:fs";
38
+ import { join, resolve } from "node:path";
39
+ import { pathToFileURL } from "node:url";
40
+
41
+ /** Fixed tarball name per package — otherwise vendor/ collects one corpse per version. */
42
+ export function vendorFileName(name) {
43
+ return `${name.replace(/[@/]/g, "-").replace(/^-/, "")}.tgz`;
44
+ }
45
+
46
+ /**
47
+ * Where a workspace dependency really lives: follow pnpm's symlink.
48
+ * @returns {{ dir: string, version: string, private: boolean, main: string }}
49
+ */
50
+ export function findWorkspacePackage(functionsDir, name) {
51
+ const link = join(functionsDir, "node_modules", ...name.split("/"));
52
+ if (!existsSync(link)) {
53
+ throw new Error(`${name}: nicht in node_modules — erst pnpm install.`);
54
+ }
55
+ const dir = realpathSync(link);
56
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
57
+ return {
58
+ dir,
59
+ version: pkg.version,
60
+ private: pkg.private === true,
61
+ main: pkg.main ?? "dist/index.js",
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Pure part: the rewritten dependency map plus what changed.
67
+ * `lookup(name)` answers like findWorkspacePackage.
68
+ */
69
+ export function rewriteDependencies(dependencies, lookup) {
70
+ const out = { ...(dependencies ?? {}) };
71
+ const changes = [];
72
+ for (const [name, spec] of Object.entries(out)) {
73
+ if (!String(spec).startsWith("workspace:")) continue;
74
+ const source = lookup(name);
75
+ const to = source.private
76
+ ? `file:vendor/${vendorFileName(name)}`
77
+ : `^${source.version}`;
78
+ out[name] = to;
79
+ changes.push({ name, from: spec, to, private: source.private, source });
80
+ }
81
+ return { dependencies: out, changes };
82
+ }
83
+
84
+ /** Packs a private package into vendor/ and returns the tarball name. */
85
+ export function packPrivate(functionsDir, name, source) {
86
+ const entry = join(source.dir, source.main);
87
+ if (!existsSync(entry)) {
88
+ // Without dist the tarball would be empty and the failure would surface in the cloud.
89
+ throw new Error(`${name}: ${entry} fehlt — erst bauen, dann deployen.`);
90
+ }
91
+ const vendor = join(functionsDir, "vendor");
92
+ mkdirSync(vendor, { recursive: true });
93
+ const produced = execFileSync("npm", ["pack", "--pack-destination", vendor], {
94
+ cwd: source.dir,
95
+ encoding: "utf8",
96
+ })
97
+ .trim()
98
+ .split("\n")
99
+ .pop();
100
+ const target = vendorFileName(name);
101
+ renameSync(join(vendor, produced), join(vendor, target));
102
+ return target;
103
+ }
104
+
105
+ /**
106
+ * Runs the whole thing for one functions directory.
107
+ * @returns the list of changes (empty when nothing was a workspace dependency)
108
+ */
109
+ export function prepare(functionsDir, { dryRun = false, log = console.log } = {}) {
110
+ const pkgPath = join(functionsDir, "package.json");
111
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
112
+ const { dependencies, changes } = rewriteDependencies(pkg.dependencies, (name) =>
113
+ findWorkspacePackage(functionsDir, name)
114
+ );
115
+
116
+ for (const change of changes) {
117
+ const kind = change.private ? `privat, ${change.source.version}` : "veröffentlicht";
118
+ log(` ${change.name}: ${change.from} -> ${change.to} (${kind})`);
119
+ if (change.private && !dryRun) packPrivate(functionsDir, change.name, change.source);
120
+ }
121
+
122
+ if (changes.length === 0) {
123
+ log("Keine Workspace-Abhängigkeiten offen.");
124
+ } else if (dryRun) {
125
+ log(`Dry-Run: ${pkgPath} bleibt unverändert.`);
126
+ } else {
127
+ writeFileSync(pkgPath, JSON.stringify({ ...pkg, dependencies }, null, 2) + "\n");
128
+ log(`${pkgPath} für den Deploy umgeschrieben.`);
129
+ }
130
+ return changes;
131
+ }
132
+
133
+ // CLI — argv[1] may be the symlinked path of a pnpm bin shim while
134
+ // import.meta.url is the real one; compare real paths.
135
+ const invokedAs = process.argv[1] && pathToFileURL(realpathSync(process.argv[1])).href;
136
+ if (invokedAs === import.meta.url) {
137
+ const args = process.argv.slice(2);
138
+ const dryRun = args.includes("--dry-run");
139
+ const dir = resolve(args.find((a) => !a.startsWith("--")) ?? process.cwd());
140
+ prepare(dir, { dryRun });
141
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * prepare-functions-deploy: veröffentlicht → ^version, privat → vendor-Tarball,
3
+ * Auffinden über pnpm-Symlinks, Dry-Run schreibt nichts.
4
+ */
5
+ import { describe, expect, it } from "vitest";
6
+ import { mkdtempSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ import {
11
+ findWorkspacePackage,
12
+ prepare,
13
+ rewriteDependencies,
14
+ vendorFileName,
15
+ } from "./prepare-functions-deploy.mjs";
16
+
17
+ describe("vendorFileName", () => {
18
+ it("macht aus einem Scope-Namen einen Dateinamen", () => {
19
+ expect(vendorFileName("@scope/pkg")).toBe("scope-pkg.tgz");
20
+ expect(vendorFileName("plain")).toBe("plain.tgz");
21
+ });
22
+ });
23
+
24
+ describe("rewriteDependencies", () => {
25
+ const lookup = (name) =>
26
+ name === "private-model"
27
+ ? { dir: "/x", version: "0.1.0", private: true, main: "dist/index.js" }
28
+ : { dir: "/y", version: "1.3.1", private: false, main: "dist/index.js" };
29
+
30
+ it("ersetzt nur workspace:-Abhängigkeiten", () => {
31
+ const { dependencies, changes } = rewriteDependencies(
32
+ { "@pd/functions": "workspace:*", "private-model": "workspace:^", express: "^4.0.0" },
33
+ lookup
34
+ );
35
+ expect(dependencies).toEqual({
36
+ "@pd/functions": "^1.3.1",
37
+ "private-model": "file:vendor/private-model.tgz",
38
+ express: "^4.0.0",
39
+ });
40
+ expect(changes.map((c) => c.name)).toEqual(["@pd/functions", "private-model"]);
41
+ });
42
+
43
+ it("ohne dependencies passiert nichts", () => {
44
+ expect(rewriteDependencies(undefined, lookup)).toEqual({ dependencies: {}, changes: [] });
45
+ });
46
+ });
47
+
48
+ /** Ein Mini-Workspace: functions/ mit pnpm-artigen Symlinks auf zwei Pakete. */
49
+ function fakeWorkspace() {
50
+ const root = mkdtempSync(join(tmpdir(), "pd-prepare-"));
51
+ const functions = join(root, "functions");
52
+ const model = join(root, "model");
53
+ const published = join(root, "published");
54
+ mkdirSync(join(functions, "node_modules", "@pd"), { recursive: true });
55
+ mkdirSync(model);
56
+ mkdirSync(published);
57
+ writeFileSync(
58
+ join(model, "package.json"),
59
+ JSON.stringify({ name: "app-model", version: "0.2.0", private: true, main: "dist/index.js" })
60
+ );
61
+ writeFileSync(
62
+ join(published, "package.json"),
63
+ JSON.stringify({ name: "@pd/functions", version: "1.3.1" })
64
+ );
65
+ symlinkSync(model, join(functions, "node_modules", "app-model"), "dir");
66
+ symlinkSync(published, join(functions, "node_modules", "@pd", "functions"), "dir");
67
+ writeFileSync(
68
+ join(functions, "package.json"),
69
+ JSON.stringify({
70
+ name: "functions",
71
+ dependencies: { "app-model": "workspace:*", "@pd/functions": "workspace:*", "firebase-admin": "^13.0.0" },
72
+ })
73
+ );
74
+ return { functions, model };
75
+ }
76
+
77
+ describe("findWorkspacePackage", () => {
78
+ it("folgt dem Symlink und liest die package.json der Quelle", () => {
79
+ const { functions } = fakeWorkspace();
80
+ const src = findWorkspacePackage(functions, "@pd/functions");
81
+ expect(src).toMatchObject({ version: "1.3.1", private: false });
82
+ expect(findWorkspacePackage(functions, "app-model").private).toBe(true);
83
+ });
84
+
85
+ it("meldet ein fehlendes Paket verständlich", () => {
86
+ const { functions } = fakeWorkspace();
87
+ expect(() => findWorkspacePackage(functions, "nope")).toThrow(/pnpm install/);
88
+ });
89
+ });
90
+
91
+ describe("prepare", () => {
92
+ it("dry-run: meldet die Änderungen, schreibt nichts, packt nichts", () => {
93
+ const { functions } = fakeWorkspace();
94
+ const before = readFileSync(join(functions, "package.json"), "utf8");
95
+ const lines = [];
96
+ const changes = prepare(functions, { dryRun: true, log: (l) => lines.push(l) });
97
+ expect(changes.map((c) => c.to)).toEqual(["file:vendor/app-model.tgz", "^1.3.1"]);
98
+ expect(readFileSync(join(functions, "package.json"), "utf8")).toBe(before);
99
+ expect(lines.at(-1)).toMatch(/Dry-Run/);
100
+ });
101
+
102
+ it("scharf: privates Paket ohne dist bricht ab, bevor geschrieben wird", () => {
103
+ const { functions } = fakeWorkspace();
104
+ const before = readFileSync(join(functions, "package.json"), "utf8");
105
+ expect(() => prepare(functions, { log: () => {} })).toThrow(/erst bauen/);
106
+ expect(readFileSync(join(functions, "package.json"), "utf8")).toBe(before);
107
+ });
108
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@progressive-development/pd-provider-firebase-functions",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "Firebase Functions v2 utilities for pd-spa-helper backend",
5
5
  "author": "PD Progressive Development",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -11,8 +11,12 @@
11
11
  "exports": {
12
12
  ".": "./dist/index.js"
13
13
  },
14
+ "bin": {
15
+ "pd-prepare-functions-deploy": "./bin/prepare-functions-deploy.mjs"
16
+ },
14
17
  "files": [
15
18
  "dist/",
19
+ "bin/",
16
20
  "package.json",
17
21
  "README.md",
18
22
  "LICENSE"