backend-manager 5.0.133 → 5.0.135

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/CLAUDE.md CHANGED
@@ -467,16 +467,16 @@ npx bm test
467
467
  ```
468
468
 
469
469
  ### Log Files
470
- Both `npx bm emulator` and `npx bm test` automatically save all output to log files in the project directory while still streaming to the console:
471
- - **`emulator.log`** — Full emulator output (Firebase emulator + Cloud Functions logs)
472
- - **`test.log`** — Test runner output (when running against an existing emulator)
470
+ BEM CLI commands automatically save all output to log files in `functions/` while still streaming to the console:
471
+ - **`functions/serve.log`** — Output from `npx bm serve` (Firebase serve)
472
+ - **`functions/emulator.log`** — Full emulator output (Firebase emulator + Cloud Functions logs)
473
+ - **`functions/test.log`** — Test runner output (when running against an existing emulator)
474
+ - **`functions/logs.log`** — Cloud Function logs from `npx bm logs:read` or `npx bm logs:tail` (raw JSON for `read`, streaming text for `tail`)
473
475
 
474
476
  When `npx bm test` starts its own emulator, logs go to `emulator.log` (since it delegates to the emulator command). When running against an already-running emulator, logs go to `test.log`.
475
477
 
476
478
  These files are overwritten on each run and are gitignored (`*.log`). Use them to search for errors, debug webhook pipelines, or review full function output after a test run.
477
479
 
478
- - **`logs.log`** — Cloud Function logs from `npx bm logs:read` or `npx bm logs:tail` (raw JSON for `read`, streaming text for `tail`)
479
-
480
480
  ### Filtering Tests
481
481
  ```bash
482
482
  npx bm test rules/ # Run rules tests (both BEM and project)
@@ -630,7 +630,7 @@ npx bm logs:tail # Stream live logs
630
630
  npx bm logs:tail --fn bm_paymentsWebhookOnWrite # Stream filtered live logs
631
631
  ```
632
632
 
633
- Both commands save output to `logs.log` in the project directory (overwritten on each run). `logs:read` saves raw JSON; `logs:tail` streams text.
633
+ Both commands save output to `functions/logs.log` (overwritten on each run). `logs:read` saves raw JSON; `logs:tail` streams text.
634
634
 
635
635
  | Flag | Description | Default | Commands |
636
636
  |------|-------------|---------|----------|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.133",
3
+ "version": "5.0.135",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -18,11 +18,11 @@
18
18
  "node": "22"
19
19
  },
20
20
  "projectScripts": {
21
- "start": "npx bm setup && npx bm serve",
22
- "deploy": "npx bm setup && npx bm deploy",
23
- "emulator": "npx bm setup && npx bm emulator",
24
- "test": "npx bm setup && npx bm test",
25
- "setup": "npx bm setup"
21
+ "start": "npx mgr setup && npx mgr serve",
22
+ "deploy": "npx mgr setup && npx mgr deploy",
23
+ "emulator": "npx mgr setup && npx mgr emulator",
24
+ "test": "npx mgr setup && npx mgr test",
25
+ "setup": "npx mgr setup"
26
26
  },
27
27
  "repository": {
28
28
  "type": "git",
@@ -62,7 +62,7 @@ class EmulatorCommand extends BaseCommand {
62
62
  const emulatorCommand = `BEM_TESTING=true firebase emulators:exec --only functions,firestore,auth,database,hosting,pubsub --ui "${command}"`;
63
63
 
64
64
  // Set up log file in the project directory
65
- const logPath = path.join(projectDir, 'emulator.log');
65
+ const logPath = path.join(projectDir, 'functions', 'emulator.log');
66
66
  const logStream = fs.createWriteStream(logPath, { flags: 'w' });
67
67
  const stripAnsi = (str) => str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
68
68
 
@@ -73,7 +73,7 @@ class LogsCommand extends BaseCommand {
73
73
 
74
74
  // Set up log file in the project directory
75
75
  const projectDir = this.main.firebaseProjectPath;
76
- const logPath = path.join(projectDir, 'logs.log');
76
+ const logPath = path.join(projectDir, 'functions', 'logs.log');
77
77
 
78
78
  this.log(chalk.gray(` Filter: ${filter || '(none)'}`));
79
79
  this.log(chalk.gray(` Limit: ${limit}`));
@@ -127,7 +127,7 @@ class LogsCommand extends BaseCommand {
127
127
 
128
128
  // Set up log file in the project directory
129
129
  const projectDir = this.main.firebaseProjectPath;
130
- const logPath = path.join(projectDir, 'logs.log');
130
+ const logPath = path.join(projectDir, 'functions', 'logs.log');
131
131
  const logStream = fs.createWriteStream(logPath, { flags: 'w' });
132
132
 
133
133
  const filter = this.buildFilter(argv, { excludeTimestamp: true });
@@ -1,4 +1,7 @@
1
1
  const BaseCommand = require('./base-command');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+ const chalk = require('chalk');
2
5
  const powertools = require('node-powertools');
3
6
  const WatchCommand = require('./watch');
4
7
 
@@ -6,6 +9,7 @@ class ServeCommand extends BaseCommand {
6
9
  async execute() {
7
10
  const self = this.main;
8
11
  const port = self.argv.port || self.argv?._?.[1] || '5000';
12
+ const projectDir = self.firebaseProjectPath;
9
13
 
10
14
  // Start BEM watcher in background
11
15
  const watcher = new WatchCommand(self);
@@ -14,8 +18,44 @@ class ServeCommand extends BaseCommand {
14
18
  // Start Stripe webhook forwarding in background
15
19
  this.startStripeWebhookForwarding();
16
20
 
17
- // Execute
18
- await powertools.execute(`firebase serve --port ${port}`, { log: true });
21
+ // Set up log file in the project directory
22
+ const logPath = path.join(projectDir, 'functions', 'serve.log');
23
+ const logStream = fs.createWriteStream(logPath, { flags: 'w' });
24
+ const stripAnsi = (str) => str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
25
+
26
+ this.log(chalk.gray(` Logs saving to: ${logPath}\n`));
27
+
28
+ // Execute with tee to log file
29
+ try {
30
+ await powertools.execute(`firebase serve --port ${port}`, {
31
+ log: false,
32
+ cwd: projectDir,
33
+ config: {
34
+ stdio: ['inherit', 'pipe', 'pipe'],
35
+ env: { ...process.env, FORCE_COLOR: '1' },
36
+ },
37
+ }, (child) => {
38
+ // Tee stdout to both console and log file (strip ANSI codes for clean log)
39
+ child.stdout.on('data', (data) => {
40
+ process.stdout.write(data);
41
+ logStream.write(stripAnsi(data.toString()));
42
+ });
43
+
44
+ // Tee stderr to both console and log file (strip ANSI codes for clean log)
45
+ child.stderr.on('data', (data) => {
46
+ process.stderr.write(data);
47
+ logStream.write(stripAnsi(data.toString()));
48
+ });
49
+
50
+ // Clean up log stream when child exits
51
+ child.on('close', () => {
52
+ logStream.end();
53
+ });
54
+ });
55
+ } catch (error) {
56
+ // User pressed Ctrl+C - this is expected
57
+ this.log(chalk.gray('\n Server stopped.\n'));
58
+ }
19
59
  }
20
60
  }
21
61
 
@@ -185,7 +185,7 @@ class TestCommand extends BaseCommand {
185
185
  this.log(chalk.gray(` UI: http://127.0.0.1:${emulatorPorts.ui}`));
186
186
 
187
187
  // Set up log file in the project directory
188
- const logPath = path.join(projectDir, 'test.log');
188
+ const logPath = path.join(projectDir, 'functions', 'test.log');
189
189
  const logStream = fs.createWriteStream(logPath, { flags: 'w' });
190
190
  const stripAnsi = (str) => str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
191
191
 
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * POST /admin/post - Create blog post
3
3
  * Admin/blogger endpoint to create blog posts via GitHub
4
+ * Uses Git Trees API to commit all files (images + post) in a single commit
4
5
  */
5
6
  const moment = require('moment');
6
7
  const jetpack = require('fs-jetpack');
@@ -15,7 +16,6 @@ const IMAGE_PATH_SRC = `src/assets/images/blog/post-{id}/`;
15
16
  const IMAGE_REGEX = /(?:!\[(.*?)\]\((.*?)\))/img;
16
17
 
17
18
  module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
18
- const fetch = Manager.require('wonderful-fetch');
19
19
 
20
20
  // Require authentication
21
21
  if (!user.authenticated) {
@@ -92,30 +92,47 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
92
92
 
93
93
  assistant.log('main(): Creating post...', settings);
94
94
 
95
- // Extract all images
96
- const imageResult = await extractImages(assistant, octokit, settings).catch(e => e);
97
- if (imageResult instanceof Error) {
98
- return assistant.respond(imageResult.message, { code: 400 });
95
+ // Download all images and collect file data
96
+ const imageFiles = await downloadImages(assistant, settings).catch(e => e);
97
+ if (imageFiles instanceof Error) {
98
+ return assistant.respond(imageFiles.message, { code: 400 });
99
99
  }
100
100
 
101
101
  // Rewrite body to use @post/ prefix for extracted images
102
- for (const { originalUrl, localFilename } of imageResult) {
103
- settings.body = settings.body.split(originalUrl).join(`@post/${localFilename}`);
102
+ for (const file of imageFiles) {
103
+ if (file.originalUrl) {
104
+ settings.body = settings.body.split(file.originalUrl).join(`@post/${file.filename}`);
105
+ }
104
106
  }
105
107
 
106
- // Set defaults
108
+ // Generate post content from template
107
109
  const formattedContent = powertools.template(
108
110
  POST_TEMPLATE,
109
111
  formatClone(settings),
110
112
  );
111
113
 
112
- // Upload post
113
- const uploadResult = await uploadPost(assistant, octokit, settings, formattedContent).catch(e => e);
114
- if (uploadResult instanceof Error) {
115
- return assistant.respond(uploadResult.message, { code: 500 });
114
+ // Build post file entry
115
+ const postFilename = `${settings.path}/${settings.date}-${settings.url}.md`;
116
+ const allFiles = [
117
+ ...imageFiles.map(img => ({
118
+ path: img.githubPath,
119
+ content: img.base64,
120
+ encoding: 'base64',
121
+ })),
122
+ {
123
+ path: postFilename,
124
+ content: Buffer.from(formattedContent).toString('base64'),
125
+ encoding: 'base64',
126
+ },
127
+ ];
128
+
129
+ // Commit all files in a single commit
130
+ const commitResult = await commitAll(assistant, octokit, settings, allFiles).catch(e => e);
131
+ if (commitResult instanceof Error) {
132
+ return assistant.respond(commitResult.message, { code: 500 });
116
133
  }
117
134
 
118
- assistant.log('main(): uploadPost', uploadResult);
135
+ assistant.log('main(): commitAll', commitResult);
119
136
 
120
137
  // Track analytics
121
138
  analytics.event('admin/post', { action: 'create' });
@@ -123,9 +140,10 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
123
140
  return assistant.respond(settings);
124
141
  };
125
142
 
126
- // Helper: Extract and upload images
127
- async function extractImages(assistant, octokit, settings) {
128
- const urlMap = [];
143
+ // Helper: Download all images and return file data (no GitHub uploads)
144
+ async function downloadImages(assistant, settings) {
145
+ const files = [];
146
+ const assetsPath = powertools.template(IMAGE_PATH_SRC, settings);
129
147
 
130
148
  const matches = settings.body.matchAll(IMAGE_REGEX);
131
149
  const images = Array.from(matches).map(match => ({
@@ -141,10 +159,10 @@ async function extractImages(assistant, octokit, settings) {
141
159
  header: true,
142
160
  });
143
161
 
144
- assistant.log('extractImages(): images', images);
162
+ assistant.log('downloadImages(): images', images);
145
163
 
146
164
  if (!images.length) {
147
- return urlMap;
165
+ return files;
148
166
  }
149
167
 
150
168
  for (let index = 0; index < images.length; index++) {
@@ -153,38 +171,29 @@ async function extractImages(assistant, octokit, settings) {
153
171
  // Download image
154
172
  const download = await downloadImage(assistant, image.src, image.alt).catch(e => e);
155
173
 
156
- assistant.log('extractImages(): download', download);
174
+ assistant.log('downloadImages(): download', download);
157
175
 
158
176
  if (download instanceof Error) {
159
177
  if (image.header) {
160
178
  throw download;
161
179
  } else {
162
- assistant.warn('extractImages(): Skipping NON-HEADER image download due to error', download);
180
+ assistant.warn('downloadImages(): Skipping NON-HEADER image download due to error', download);
163
181
  continue;
164
182
  }
165
183
  }
166
184
 
167
- // Upload image
168
- const upload = await uploadImage(assistant, octokit, settings, download).catch(e => e);
185
+ // Read file content as base64
186
+ const base64 = jetpack.read(download.path, 'buffer').toString('base64');
169
187
 
170
- assistant.log('extractImages(): upload', upload);
171
-
172
- if (upload instanceof Error) {
173
- if (image.header) {
174
- throw upload;
175
- } else {
176
- assistant.warn('extractImages(): Skipping NON-HEADER image upload due to error', upload);
177
- continue;
178
- }
179
- }
180
-
181
- // Track successfully uploaded non-header images for body rewriting
182
- if (!image.header) {
183
- urlMap.push({ originalUrl: image.src, localFilename: download.filename });
184
- }
188
+ files.push({
189
+ githubPath: `${assetsPath}${download.filename}`,
190
+ filename: download.filename,
191
+ base64: base64,
192
+ originalUrl: image.header ? null : image.src,
193
+ });
185
194
  }
186
195
 
187
- return urlMap;
196
+ return files;
188
197
  }
189
198
 
190
199
  // Helper: Download image
@@ -211,82 +220,95 @@ async function downloadImage(assistant, src, alt) {
211
220
  return result;
212
221
  }
213
222
 
214
- // Helper: Upload image to GitHub
215
- async function uploadImage(assistant, octokit, settings, image) {
216
- const filepath = image.path;
217
- const filename = image.filename;
218
- const assetsPath = powertools.template(IMAGE_PATH_SRC, settings);
223
+ // Helper: Commit all files (images + post) in a single commit using Git Trees API
224
+ async function commitAll(assistant, octokit, settings, files) {
219
225
  const owner = settings.githubUser;
220
226
  const repo = settings.githubRepo;
221
227
 
222
- assistant.log('uploadImage(): image', image);
223
- assistant.log('uploadImage(): path', `${assetsPath}${filename}`);
228
+ assistant.log('commitAll(): Committing', files.length, 'files');
224
229
 
225
- // Get existing image
226
- const existing = await octokit.rest.repos.getContent({
230
+ // Get the latest commit SHA on the default branch
231
+ const refResult = await octokit.rest.git.getRef({
227
232
  owner: owner,
228
233
  repo: repo,
229
- path: `${assetsPath}${filename}`,
230
- }).catch(e => e);
234
+ ref: 'heads/master',
235
+ }).catch(() => {
236
+ // Try 'main' if 'master' fails
237
+ return octokit.rest.git.getRef({
238
+ owner: owner,
239
+ repo: repo,
240
+ ref: 'heads/main',
241
+ });
242
+ });
231
243
 
232
- assistant.log('uploadImage(): Existing', existing);
244
+ const latestCommitSha = refResult.data.object.sha;
245
+ const branch = refResult.data.ref;
233
246
 
234
- if (existing instanceof Error && existing?.status !== 404) {
235
- throw existing;
236
- }
247
+ assistant.log('commitAll(): Latest commit', latestCommitSha, 'on', branch);
237
248
 
238
- // Upload image
239
- const result = await octokit.rest.repos.createOrUpdateFileContents({
249
+ // Get the tree SHA of the latest commit
250
+ const commitResult = await octokit.rest.git.getCommit({
240
251
  owner: owner,
241
252
  repo: repo,
242
- path: `${assetsPath}${filename}`,
243
- sha: existing?.data?.sha || undefined,
244
- message: `📦 admin/post:upload-image ${filename}`,
245
- content: jetpack.read(filepath, 'buffer').toString('base64'),
253
+ commit_sha: latestCommitSha,
246
254
  });
247
255
 
248
- assistant.log('uploadImage(): Result', result);
256
+ const baseTreeSha = commitResult.data.tree.sha;
249
257
 
250
- return result;
251
- }
258
+ // Create blobs for each file
259
+ const treeItems = [];
252
260
 
253
- // Helper: Upload post to GitHub
254
- async function uploadPost(assistant, octokit, settings, content) {
255
- const filename = `${settings.path}/${settings.date}-${settings.url}.md`;
256
- const owner = settings.githubUser;
257
- const repo = settings.githubRepo;
261
+ for (const file of files) {
262
+ const blob = await octokit.rest.git.createBlob({
263
+ owner: owner,
264
+ repo: repo,
265
+ content: file.content,
266
+ encoding: file.encoding,
267
+ });
268
+
269
+ assistant.log('commitAll(): Created blob for', file.path, blob.data.sha);
258
270
 
259
- assistant.log('uploadPost(): filename', filename);
271
+ treeItems.push({
272
+ path: file.path,
273
+ mode: '100644',
274
+ type: 'blob',
275
+ sha: blob.data.sha,
276
+ });
277
+ }
260
278
 
261
- // Get existing post
262
- const existing = await octokit.rest.repos.getContent({
279
+ // Create a new tree with all files
280
+ const newTree = await octokit.rest.git.createTree({
263
281
  owner: owner,
264
282
  repo: repo,
265
- path: filename,
266
- }).catch(e => e);
283
+ base_tree: baseTreeSha,
284
+ tree: treeItems,
285
+ });
267
286
 
268
- assistant.log('uploadPost(): Existing', existing);
287
+ assistant.log('commitAll(): Created tree', newTree.data.sha);
269
288
 
270
- if (existing instanceof Error && existing?.status !== 404) {
271
- throw existing;
272
- }
289
+ // Create the commit
290
+ const postPath = files[files.length - 1].path;
291
+ const newCommit = await octokit.rest.git.createCommit({
292
+ owner: owner,
293
+ repo: repo,
294
+ message: `📦 admin/post:create ${postPath}`,
295
+ tree: newTree.data.sha,
296
+ parents: [latestCommitSha],
297
+ });
273
298
 
274
- // Wait for GitHub to process images
275
- await powertools.wait(30000);
299
+ assistant.log('commitAll(): Created commit', newCommit.data.sha);
276
300
 
277
- // Upload post
278
- const result = await octokit.rest.repos.createOrUpdateFileContents({
301
+ // Update the branch ref to point to the new commit
302
+ const updateResult = await octokit.rest.git.updateRef({
279
303
  owner: owner,
280
304
  repo: repo,
281
- path: filename,
282
- sha: existing?.data?.sha || undefined,
283
- message: `📦 admin/post:upload-post ${filename}`,
284
- content: Buffer.from(content).toString('base64'),
305
+ ref: branch.replace('refs/', ''),
306
+ sha: newCommit.data.sha,
285
307
  });
286
308
 
287
- assistant.log('uploadPost(): Result', result);
309
+ assistant.log('commitAll(): Updated ref', updateResult.data.object.sha);
288
310
 
289
- return result;
311
+ return updateResult;
290
312
  }
291
313
 
292
314
  // Helper: Format clone for templating