@go-labs-sg/bb 1.20.0 → 2.0.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.
package/dist/index.js CHANGED
@@ -1,11 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import "./load-env.js";
3
- import { requireApiKey } from "./api-client.js";
3
+ import { readFileSync, realpathSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { BUDGET_BUILDER_API_BASE_URL, configureBudgetBuilderApiBaseUrl, DEFAULT_BUDGET_BUILDER_API_BASE_URL, requireApiKey, } from "./api-client.js";
4
7
  import { consumeCliQuietFlags, logCliAction, sanitizeFlagsForTrace, setCliQuiet, shouldLogCliActions, } from "./cli-trace.js";
5
- import { addBudgetItems, approveBill, approveBudget, approveCustomerInvoice, approveQuotation, approveSupplier, checkCustomerInvoiceReadiness, checkProjectReconciliation, cleanupStagedBillAttachments, cleanupStagedQuotationAttachments, completeProject, createApiKeyForUser, createBillApproval, createBillFromPayload, createBudgetApproval, createBudgetCategory, createBudgetFromPayload, createCompany, createContactPersonFromPayload, createCustomerInvoice, createEstimate, createItemCategory, createItemFromPayload, createPlaceholderBillForBudgetItem, createProject, createQuotationFromPayload, createSupplierCertification, createSupplierFromPayload, createSupplierPaymentMethod, createSupplierRoleOption, createSupplierTagOption, createUser, deleteBillById, deleteBudgetById, deleteBudgetCategory, deleteBudgetCommission, deleteBudgetDiscount, deleteCompanyById, deleteCustomerInvoice, deleteItemById, deleteItemCategoriesByIds, deleteProjectById, deleteQuotationById, deleteSuppliersByIds, discardCreatingCustomerInvoice, downloadCustomerInvoicePdf, downloadQuotationPdf, getApprovedBudgets, getBillAttachments, getBillDetails, getBudget, getBudgetCategories, getBudgetCategoryBenchmarks, getBudgetDetails, getBudgetItemsOnly, getBudgetVersions, getCompany, getCustomerInvoice, getDashboard, getErrorMetrics, getEstimatePerformance, getFinancialOverview, getItem, getItemPricingHistory, getMonthlyMetrics, getProject, getQuotationDetails, getRecentErrors, getSupplierAnalytics, getSupplierDetails, getSupplierPricingHistory, getSystemOverview, getUserPerformance, importQuickBooksProjectId, listApiKeysForUser, listApprovals, listBills, listBudgets, listCompanies, listContacts, listCustomerInvoices, listIntegrationOperations, listItemCategories, listItems, listProjects, listQuotations, listSuppliers, listUsers, markBudgetWonWithProof, patchBillInvoiceNumber, patchBillPayment, reactivateSuppliersByIds, reconcileProject, rejectBill, rejectBudget, rejectCustomerInvoice, rejectQuotation, rejectSupplier, removeBudgetItem, renameBudgetVersion, reorderBudgetItemsCli, restoreBudgetVersion, retryIntegrationOperation, revokeApiKeyForUser, sendEstimateToContactPersonFromPayload, setBudgetItemsNotUtilized, stageBillAttachmentsFromPaths, submitQuotation, syncCustomerInvoice, updateBillFromPayload, updateBillPaymentEvidenceFromPayload, updateBillStatus, updateBudgetCategory, updateBudgetCommissionFromPayload, updateBudgetDiscountFromPayload, updateBudgetFromPayload, updateBudgetItem, updateBudgetItemSupplierCli, updateBudgetStatus, updateCompanyFromPayload, updateContactPersonFromPayload, updateItemCategory, updateItemFromPayload, updateProjectFromPayload, updateProjectStatus, updateQuotationFromPayload, updateSupplierFromPayload, uploadBillAttachmentFromPath, uploadBillAttachmentsFromPaths, uploadBillDocumentsFromPaths, uploadBudgetAttachmentFromPath, uploadQuotationAttachmentFromPath, validateBillSelectionFromPayload, voidCustomerInvoice, } from "./commands.js";
8
+ import { addBudgetItems, approveBill, approveBudget, approveCustomerInvoice, approveQuotation, approveSupplier, checkCustomerInvoiceReadiness, checkProjectReconciliation, cleanupStagedBillAttachments, cleanupStagedQuotationAttachments, completeProject, createApiKeyForUser, createBillApproval, createBillFromPayload, createBudgetApproval, createBudgetCategory, createBudgetFromPayload, createCompany, createContactPersonFromPayload, createCustomerInvoice, createEstimate, createItemCategory, createItemFromPayload, createPlaceholderBillForBudgetItem, createProject, createQuotationFromPayload, createSupplierCertification, createSupplierFromPayload, createSupplierPaymentMethod, createSupplierRoleOption, createSupplierTagOption, createUser, deleteBillById, deleteBudgetById, deleteBudgetCategory, deleteBudgetCommission, deleteBudgetDiscount, deleteCompanyById, deleteCustomerInvoice, deleteItemById, deleteItemCategoriesByIds, deleteProjectById, deleteQuotationById, deleteSuppliersByIds, discardCreatingCustomerInvoice, downloadCustomerInvoicePaymentProof, downloadCustomerInvoicePdf, downloadQuotationPdf, getApprovedBudgets, getBillAttachments, getBillDetails, getBudget, getBudgetCategories, getBudgetCategoryBenchmarks, getBudgetDetails, getBudgetItemsOnly, getBudgetVersions, getCompany, getCustomerInvoice, getCustomerInvoiceEmailContext, getDashboard, getErrorMetrics, getEstimatePerformance, getFinancialOverview, getItem, getItemPricingHistory, getMonthlyMetrics, getProject, getQuotationDetails, getRecentErrors, getSupplierAnalytics, getSupplierDetails, getSupplierPricingHistory, getSystemOverview, getUserPerformance, importQuickBooksProjectId, listApiKeysForUser, listApprovals, listBills, listBudgets, listCompanies, listContacts, listCustomerInvoices, listEligibleCustomerInvoiceBudgets, listIntegrationOperations, listItemCategories, listItems, listProjects, listQuotations, listSuppliers, listUsers, markBudgetWonWithProof, markCustomerInvoicePaidFromPath, patchBillInvoiceNumber, patchBillPayment, reactivateSuppliersByIds, reconcileProject, rejectBill, rejectBudget, rejectCustomerInvoice, rejectQuotation, rejectSupplier, removeBudgetItem, renameBudgetVersion, reorderBudgetItemsCli, restoreBudgetVersion, retryIntegrationOperation, revokeApiKeyForUser, sendCustomerInvoiceToContactPersonFromPayload, sendEstimateToContactPersonFromPayload, setBudgetItemsNotUtilized, stageBillAttachmentsFromPaths, submitQuotation, syncCustomerInvoice, updateBillFromPayload, updateBillPaymentEvidenceFromPayload, updateBillStatus, updateBudgetCategory, updateBudgetCommissionFromPayload, updateBudgetDiscountFromPayload, updateBudgetFromPayload, updateBudgetItem, updateBudgetItemSupplierCli, updateBudgetStatus, updateCompanyFromPayload, updateContactPersonFromPayload, updateItemCategory, updateItemFromPayload, updateProjectFromPayload, updateProjectStatus, updateQuotationFromPayload, updateSupplierFromPayload, uploadBillAttachmentFromPath, uploadBillAttachmentsFromPaths, uploadBillDocumentsFromPaths, uploadBudgetAttachmentFromPath, uploadQuotationAttachmentFromPath, validateBillSelectionFromPayload, voidCustomerInvoice, whoAmI, } from "./commands.js";
6
9
  import { getFlag, parseArgs } from "./parse-args.js";
7
10
  import { billStatusesForUpdateHelp, budgetStatusesForHelp, isBudgetStatusUpdate, parseApprovalTypeFlag, parseBillStatusForUpdate, parseCommaSeparatedBillStatuses, parseCommaSeparatedBudgetStatuses, parseCommaSeparatedCustomerInvoiceStatuses, parseCommaSeparatedIds, parseCommaSeparatedQuotationStatuses, parseCommaSeparatedSupplierStatuses, parseOptionalBillListSortBy, parseOptionalBillListSortDir, parseOptionalDashboardRole, parseOptionalDeals, parseOptionalErrorSeverity, parseOptionalErrorStatus, parseOptionalExtendedProjectStatus, parseOptionalFinancialRole, parseOptionalSupplierAnalyticsTimeFrame, parseOptionalTimeFrame, parseProjectStatusForUpdate, parseUserRole, projectStatusesForHelp, userRolesForHelp, } from "./parse-cli-enums.js";
8
11
  import { parseJsonFlag, parseOptionalNumber as parseOptNum, } from "./parse-json-flag.js";
12
+ import { createCompletionScript, createHumanHelp, resolveCommand, } from "./registry/index.js";
13
+ import { CliRuntimeError, clearActiveCliSession, confirmCurrentCommand, createProcessRuntime, emitCommandError, emitCommandResult, setActiveCliSession, } from "./runtime/index.js";
9
14
  function parsePositiveIntFlag(value, flagName) {
10
15
  if (value === undefined)
11
16
  return undefined;
@@ -138,15 +143,24 @@ function parseDashboardQueryFlags(flags) {
138
143
  endDate: getFlag(flags, "endDate"),
139
144
  };
140
145
  }
141
- function printHelp() {
146
+ function printLegacyHelp(runtime) {
142
147
  const help = `
143
148
  bb — Budget Builder CLI for AI agents (parity with MCP tools)
144
149
 
145
150
  Usage: bb <command> [options] [args]
146
151
 
152
+ Identity: bb version — print the installed CLI package version and API target as JSON.
153
+
147
154
  Global: --quiet | -q | BB_CLI_QUIET=1 — hide action logs (default: log command + each tRPC call to stderr; stdout JSON unchanged).
148
155
 
149
- Auth: Set BB_API_KEY in the environment (same API key as MCP / Goracle).
156
+ Auth: Create a key under API Keys in Budget Builder and set BB_API_KEY in the environment.
157
+
158
+ Authorization: Run bb whoami before choosing commands. bb help lists the full
159
+ catalog for every role; it does not prove the active key may call a command.
160
+ The API evaluates whoami.role (USER, INSIDE_SALES, LEAD, ACCOUNTING_TEAM, or
161
+ ADMIN) together with resource ownership/project assignment, current state, and
162
+ pending-approver assignment. On FORBIDDEN, stop and report the identity, role,
163
+ command, and target; do not switch keys or try a lower-level procedure.
150
164
 
151
165
  Sensitive workflow changes: approval decisions/requests, entity status updates,
152
166
  estimate emails, customer invoices, integration retries, mark-budget-won,
@@ -170,7 +184,7 @@ Budgets
170
184
  ESTIMATE_ACCEPTED requires win proof, auto-marks PITCH or LOST projects WON, requires --projectManagerId when the project has no PM, and auto-creates the Asana project/section when enabled.
171
185
  ESTIMATE_REJECTED requires --projectStatusOnCommercialRejection when rejecting the only accepted/closed budget on a commercial project.
172
186
  status: ${budgetStatusesForHelp.join(", ")}
173
- mark-budget-won <budgetId> <filePath> [--projectManagerId <id>] [--markProjectWon true|false] Upload signed quote/PO proof and set status to ESTIMATE_ACCEPTED; PITCH or LOST projects become WON automatically and require PM when none is assigned
187
+ mark-budget-won <budgetId> <filePath> [--projectManagerId <id>] [--invoiceSettings <json>] Upload signed quote/PO proof and set status to ESTIMATE_ACCEPTED; first acceptance requires invoice settings when customer invoicing is enabled
174
188
  create-budget --payload '<json>' (budget.createBudget; Asana deal card is on the project)
175
189
  update-budget --payload '<json>' (budget.updateBudget; must include id)
176
190
  delete-budget <budgetId>
@@ -198,10 +212,10 @@ Bills
198
212
  list-bills [--projectId] [--budgetId] [--status CSV] [--search <text>] [--isClaimable true|false] [--createdByIds <csv>] [--sortBy createdAt|amount|status] [--sortDir asc|desc] [--page] [--pageSize]
199
213
  list-claims same flags as list-bills; only reimbursable claims (ignores --isClaimable)
200
214
  isClaimable differentiates the shared bill/claim records: false = bill, true = claim.
201
- New supplier bills from 1 Jul 2026 00:00 SGT require approved quotation coverage for each non-legacy line item.
215
+ Non-legacy supplier bills from 1 Jul 2026 00:00 SGT require approved quotation coverage for every selected line item, including already-paid bills.
202
216
  create-bill-approval <billId> (also sends approval request emails)
203
217
  create-bill --payload '<json>' (bill.create; payload.isClaimable false = bill, true = claim)
204
- validate-bill-selection --payload '<json>' Check projectId, supplierId, budgetItemIds, and optional alreadyPaid before creation.
218
+ validate-bill-selection --payload '<json>' Check projectId, supplierId, and budgetItemIds before creation; alreadyPaid never bypasses quotation checks.
205
219
  stage-bill-attachment <projectId> <filePath...> [--file <path>] [--files <csv>] Upload files before create-bill; returns attachment JSON for attachments/paymentProofAttachments
206
220
  cleanup-staged-bill-attachments <projectId> --keys <csv> Delete unattached staged bill/claim uploads.
207
221
  update-bill --payload '<json>' (bill.update; must include id)
@@ -235,16 +249,23 @@ Quotations
235
249
 
236
250
  Customer invoices
237
251
  check-customer-invoice-readiness <budgetId>
238
- list-customer-invoices <budgetId> [--status CSV] [--search] [--sortBy createdAt|status|totalNetAmount|approvalExpiresAt] [--sortDir asc|desc] [--page] [--perPage]
252
+ list-eligible-customer-invoice-budgets <projectId> Accepted budgets that are ready and still have invoice coverage remaining.
253
+ list-customer-invoices [budgetId] [--projectId <id>] [--companyIds CSV] [--projectIds CSV] [--budgetIds CSV] [--createdByIds CSV] [--status CSV] [--search] [--sortBy createdAt|status|totalNetAmount|approvalExpiresAt] [--sortDir asc|desc] [--page] [--perPage]
254
+ No scope flag lists invoices globally. --projectId uses the project scope. A positional budgetId is a backward-compatible shortcut for --budgetIds.
239
255
  get-customer-invoice <batchId>
256
+ get-customer-invoice-email-context <batchId> Contact, required CC, budget, and project context for composing an invoice email.
240
257
  create-customer-invoice --payload '<json>' budgetId + one split with label, percentage, and dueDate.
241
258
  discard-customer-invoice <batchId> Discard a reserved CREATING batch with no created QBO invoices.
242
259
  delete-customer-invoice <batchId> Delete QBO invoices and the local batch.
243
260
  void-customer-invoice <batchId> Void the invoice batch in QuickBooks.
244
- approve-customer-invoice <batchId> Admin approval; notifies the creator.
261
+ approve-customer-invoice <batchId> Admin approval; rejects voided invoices, notifies the creator, and closes the estimate at cumulative 100% coverage.
245
262
  reject-customer-invoice <batchId> --reason <text> Admin rejection; voids QBO invoices and notifies the creator.
263
+ send-customer-invoice-to-contact-person --payload '<json>' Same composer workflow as web; requires invoiceId, to, cc, replyTo, subject, HTML content, and HTML signature.
264
+ mark-customer-invoice-paid <invoiceId> --paymentDate YYYY-MM-DD --paymentProof <path> [--paymentReference <text>]
265
+ Admin-only. Uploads proof, creates a QBO Payment for the live balance when needed, attaches proof in QBO when possible, and records payment history in BB.
266
+ download-customer-invoice-payment-proof <invoiceId> [--output <path>]
246
267
  download-customer-invoice-pdf <invoiceId> [--output <path>]
247
- sync-customer-invoice <invoiceId> Refresh local status and balance from QuickBooks.
268
+ sync-customer-invoice <invoiceId> Refresh local status and balance from QuickBooks; paid invoices become PAID, reversals restore delivery/approval, and QBO voids can reopen an estimate.
248
269
 
249
270
  Approvals
250
271
  list-approvals | get-pending-approvals [--type budget|supplier|bill|quotation|customer_invoice|all]
@@ -298,30 +319,31 @@ Suppliers & items
298
319
  update-item --payload '<json>' (item.updateItem; must include id)
299
320
  delete-item <id>
300
321
  get-supplier-details <supplierId>
301
- get-supplier-analytics [--name] [--page] [--perPage] [--timeFrame ALL|LAST_YEAR|…]
322
+ get-supplier-analytics [--name] [--page] [--perPage] [--timeFrame ALL|LAST_YEAR|…] (admin)
302
323
  list-items [--name] [--page] [--perPage]
303
324
  get-item <id>
304
325
  list-item-categories [--page] [--perPage]
305
- create-item-category --name
306
- update-item-category --id --name
326
+ create-item-category --name (admin)
327
+ update-item-category --id --name (admin)
307
328
  delete-item-categories --ids <csv> (itemCategory.deleteItemCategories; admin; empty categories only)
308
329
 
309
330
  Dashboard & users
331
+ whoami Show the user identity and role for the active API key.
310
332
  list-users
311
333
  create-user --email <email> [--name <name>] [--role ${userRolesForHelp.join("|")}] (admin; API-only service identity with no Google sign-in)
312
- create-api-key --userId <userId> --name <label> (admin; raw key is shown once)
313
- list-api-keys [--userId <userId>] (admin; defaults to the caller)
314
- revoke-api-key <apiKeyId> [--userId <userId>] (admin; --userId is required for another user's key)
315
- get-user-performance [--userId]
316
- get-dashboard [--userId] [--role BD|CREATOR|INSIDE_SALES|ALL] [--deals ALL|SUCCESSFUL|LOST] [--timeFrame] [--startDate] [--endDate]
317
- get-monthly-metrics [same optional flags as get-dashboard] (dashboard.getMonthlyMetrics)
318
- get-system-overview [same optional flags as get-dashboard] (dashboard.getSystemOverview)
319
- get-estimate-performance [same optional flags as get-dashboard] (dashboard.getEstimatePerformance)
320
- get-financial-overview [same optional flags as dashboard; role uses BudgetRole enum]
334
+ create-api-key --name <label> [--userId <userId>] (defaults to the caller; admin required for another user; raw key is shown once)
335
+ list-api-keys [--userId <userId>] (defaults to the caller; admin required for another user)
336
+ revoke-api-key <apiKeyId> [--userId <userId>] (defaults to the caller; admin required for another user)
337
+ get-user-performance [--userId] (admin)
338
+ get-dashboard [--userId] [--role BD|CREATOR|INSIDE_SALES|ALL] [--deals ALL|SUCCESSFUL|LOST] [--timeFrame] [--startDate] [--endDate] (admin)
339
+ get-monthly-metrics [same optional flags as get-dashboard] (admin; dashboard.getMonthlyMetrics)
340
+ get-system-overview [same optional flags as get-dashboard] (admin; dashboard.getSystemOverview)
341
+ get-estimate-performance [same optional flags as get-dashboard] (admin; dashboard.getEstimatePerformance)
342
+ get-financial-overview [same optional flags as dashboard; role uses BudgetRole enum] (admin)
321
343
 
322
344
  Errors
323
- get-recent-errors [--page] [--perPage] [--severity] [--status]
324
- get-error-metrics
345
+ get-recent-errors [--page] [--perPage] [--severity] [--status] (admin)
346
+ get-error-metrics (admin)
325
347
 
326
348
  Automation (admin)
327
349
  list-integration-operations [--destination] [--status PENDING|PROCESSING|FAILED|COMPLETED] [--page] [--perPage]
@@ -343,21 +365,251 @@ Note: MCP also exposes a Prisma schema resource; the CLI has no equivalent.
343
365
 
344
366
  Not covered vs MCP get_budget: "get-budget" also fetches line items in one call.
345
367
  `.trim();
346
- console.log(help);
368
+ runtime.stdout.write(`${help}\n`);
347
369
  }
370
+ const getCliPackageMetadata = () => {
371
+ const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
372
+ if (!packageJson.name || !packageJson.version) {
373
+ throw new Error("Could not read Budget Builder CLI package metadata.");
374
+ }
375
+ return { name: packageJson.name, version: packageJson.version };
376
+ };
377
+ const getVersionData = () => {
378
+ const metadata = getCliPackageMetadata();
379
+ return {
380
+ ...metadata,
381
+ apiBaseUrl: BUDGET_BUILDER_API_BASE_URL,
382
+ };
383
+ };
348
384
  /** MCP registers tools with snake_case; CLI commands are kebab-case. */
349
385
  function normalizeCliCommand(raw) {
350
386
  return raw.replace(/_/g, "-");
351
387
  }
352
- async function main() {
353
- const { command, positional, flags } = parseArgs(process.argv);
354
- setCliQuiet(consumeCliQuietFlags(flags));
355
- const cmd = normalizeCliCommand(command ?? "");
356
- if (!cmd || cmd === "help" || flags.help === true) {
357
- printHelp();
358
- process.exit(0);
388
+ const booleanFlagValues = new Set(["true", "false", "1", "0"]);
389
+ const isEnabledFlag = (argv, flag) => {
390
+ for (let index = 0; index < argv.length; index++) {
391
+ const value = argv[index];
392
+ if (value === `${flag}=true` || value === `${flag}=1`)
393
+ return true;
394
+ if (value?.startsWith(`${flag}=`))
395
+ return false;
396
+ if (value !== flag)
397
+ continue;
398
+ const next = argv[index + 1];
399
+ if (next !== undefined && booleanFlagValues.has(next)) {
400
+ return next === "true" || next === "1";
401
+ }
402
+ return true;
359
403
  }
360
- requireApiKey();
404
+ return false;
405
+ };
406
+ const runtimeOnlyFlags = [
407
+ "--allow-state-change",
408
+ "--allow-email",
409
+ "--allow-external-write",
410
+ "--allow-delete",
411
+ "--allow-financial-write",
412
+ "--debug",
413
+ "--quiet",
414
+ "--api-url",
415
+ ];
416
+ const isRuntimeOnlyArgument = (value) => runtimeOnlyFlags.some((flag) => value === flag || value.startsWith(`${flag}=`)) || value === "-q";
417
+ const parseRootArguments = (argv) => {
418
+ const commandArgv = [];
419
+ let apiBaseUrl;
420
+ for (let index = 0; index < argv.length; index++) {
421
+ const value = argv[index];
422
+ if (value === undefined)
423
+ continue;
424
+ if (value.startsWith("--api-url=")) {
425
+ apiBaseUrl = value.slice("--api-url=".length);
426
+ continue;
427
+ }
428
+ if (value === "--api-url") {
429
+ const next = argv[index + 1];
430
+ if (next === undefined || next.startsWith("-")) {
431
+ throw new CliRuntimeError("USAGE", "--api-url requires a URL.");
432
+ }
433
+ apiBaseUrl = next;
434
+ index++;
435
+ continue;
436
+ }
437
+ if (isRuntimeOnlyArgument(value)) {
438
+ const next = argv[index + 1];
439
+ if (next !== undefined && booleanFlagValues.has(next))
440
+ index++;
441
+ continue;
442
+ }
443
+ commandArgv.push(value);
444
+ }
445
+ return { apiBaseUrl, commandArgv };
446
+ };
447
+ const effectPermissionsFromArgv = (argv) => ({
448
+ allowStateChange: isEnabledFlag(argv, "--allow-state-change"),
449
+ allowEmail: isEnabledFlag(argv, "--allow-email"),
450
+ allowExternalWrite: isEnabledFlag(argv, "--allow-external-write"),
451
+ allowDelete: isEnabledFlag(argv, "--allow-delete"),
452
+ allowFinancialWrite: isEnabledFlag(argv, "--allow-financial-write"),
453
+ });
454
+ const isCompletionShell = (value) => value === "bash" ||
455
+ value === "zsh" ||
456
+ value === "fish" ||
457
+ value === "powershell";
458
+ const legacyArgvForCommand = (argv, resolvedCommand) => {
459
+ const legacyTarget = resolvedCommand.command.legacyTarget;
460
+ if (legacyTarget === undefined) {
461
+ throw new CliRuntimeError("INTERNAL", `Command ${resolvedCommand.command.path.join(" ")} has no dispatcher target.`);
462
+ }
463
+ return [
464
+ argv[0] ?? "node",
465
+ argv[1] ?? "bb",
466
+ legacyTarget,
467
+ ...resolvedCommand.argv.filter((value) => !isRuntimeOnlyArgument(value)),
468
+ ];
469
+ };
470
+ const normalizeDispatcherError = (error) => {
471
+ if (!(error instanceof Error))
472
+ return error;
473
+ if (/\brequires\b|\bmust be\b|\bmust include\b|^Invalid\b|^Missing\b|^--/.test(error.message)) {
474
+ return new CliRuntimeError("VALIDATION", error.message);
475
+ }
476
+ return error;
477
+ };
478
+ const commandConfirmationTarget = (resolvedCommand) => {
479
+ const positionalTarget = resolvedCommand.argv.find((value) => value !== "--" && !value.startsWith("-"));
480
+ if (positionalTarget !== undefined &&
481
+ !positionalTarget.startsWith("{") &&
482
+ !positionalTarget.startsWith("[")) {
483
+ return positionalTarget;
484
+ }
485
+ return resolvedCommand.command.path.join(" ");
486
+ };
487
+ export async function runCli(argv = process.argv, runtime = createProcessRuntime()) {
488
+ const rawArgv = argv.slice(2);
489
+ const metadata = getCliPackageMetadata();
490
+ const quiet = isEnabledFlag(rawArgv, "--quiet") ||
491
+ rawArgv.includes("-q") ||
492
+ runtime.env.BB_CLI_QUIET === "1";
493
+ const debug = isEnabledFlag(rawArgv, "--debug");
494
+ let rootArguments;
495
+ try {
496
+ rootArguments = parseRootArguments(rawArgv);
497
+ const apiBaseUrl = rootArguments.apiBaseUrl ?? runtime.env.BB_API_URL?.trim();
498
+ configureBudgetBuilderApiBaseUrl(apiBaseUrl || DEFAULT_BUDGET_BUILDER_API_BASE_URL);
499
+ }
500
+ catch (error) {
501
+ setActiveCliSession({
502
+ runtime,
503
+ invocation: {
504
+ command: "startup",
505
+ cliVersion: metadata.version,
506
+ apiBaseUrl: BUDGET_BUILDER_API_BASE_URL,
507
+ quiet,
508
+ debug,
509
+ },
510
+ effects: [],
511
+ permissions: {},
512
+ });
513
+ const message = error instanceof Error ? error.message : String(error);
514
+ const exitCode = emitCommandError(error instanceof CliRuntimeError
515
+ ? error
516
+ : new CliRuntimeError("USAGE", `Invalid API URL: ${message}`));
517
+ clearActiveCliSession();
518
+ return exitCode;
519
+ }
520
+ const { commandArgv } = rootArguments;
521
+ if (commandArgv.length === 0 ||
522
+ commandArgv[0] === "help" ||
523
+ isEnabledFlag(commandArgv, "--help") ||
524
+ commandArgv.includes("-h")) {
525
+ if (commandArgv.includes("--legacy")) {
526
+ printLegacyHelp(runtime);
527
+ }
528
+ else {
529
+ runtime.stdout.write(`${createHumanHelp()}\n`);
530
+ }
531
+ return 0;
532
+ }
533
+ if (commandArgv[0] === "completion") {
534
+ const shell = commandArgv[1];
535
+ if (shell === undefined || !isCompletionShell(shell)) {
536
+ setActiveCliSession({
537
+ runtime,
538
+ invocation: {
539
+ command: "completion",
540
+ cliVersion: metadata.version,
541
+ apiBaseUrl: BUDGET_BUILDER_API_BASE_URL,
542
+ quiet,
543
+ debug,
544
+ },
545
+ effects: [],
546
+ permissions: {},
547
+ });
548
+ const exitCode = emitCommandError(new CliRuntimeError("USAGE", "completion requires bash, zsh, fish, or powershell."));
549
+ clearActiveCliSession();
550
+ return exitCode;
551
+ }
552
+ runtime.stdout.write(createCompletionScript(shell));
553
+ return 0;
554
+ }
555
+ if (commandArgv[0] === "version" ||
556
+ isEnabledFlag(commandArgv, "--version") ||
557
+ commandArgv.includes("-v")) {
558
+ setActiveCliSession({
559
+ runtime,
560
+ invocation: {
561
+ command: "version",
562
+ cliVersion: metadata.version,
563
+ apiBaseUrl: BUDGET_BUILDER_API_BASE_URL,
564
+ quiet,
565
+ debug,
566
+ },
567
+ effects: [],
568
+ permissions: {},
569
+ });
570
+ emitCommandResult(getVersionData());
571
+ clearActiveCliSession();
572
+ return 0;
573
+ }
574
+ const resolvedCommand = resolveCommand(commandArgv);
575
+ if (resolvedCommand === undefined) {
576
+ setActiveCliSession({
577
+ runtime,
578
+ invocation: {
579
+ command: commandArgv[0] ?? "unknown",
580
+ cliVersion: metadata.version,
581
+ apiBaseUrl: BUDGET_BUILDER_API_BASE_URL,
582
+ quiet,
583
+ debug,
584
+ },
585
+ effects: [],
586
+ permissions: {},
587
+ });
588
+ const exitCode = emitCommandError(new CliRuntimeError("USAGE", `Unknown command: ${commandArgv.join(" ")}. Run 'bb help'.`));
589
+ clearActiveCliSession();
590
+ return exitCode;
591
+ }
592
+ const effectiveArgv = legacyArgvForCommand(argv, resolvedCommand);
593
+ const { command, positional, flags } = parseArgs(effectiveArgv);
594
+ consumeCliQuietFlags(flags);
595
+ setCliQuiet(resolvedCommand.isLegacyAlias ? quiet : quiet || !debug);
596
+ setActiveCliSession({
597
+ runtime,
598
+ invocation: {
599
+ command: resolvedCommand.command.path.join(" "),
600
+ cliVersion: metadata.version,
601
+ apiBaseUrl: BUDGET_BUILDER_API_BASE_URL,
602
+ legacy: resolvedCommand.isLegacyAlias,
603
+ quiet,
604
+ debug,
605
+ },
606
+ effects: resolvedCommand.command.effects,
607
+ permissions: effectPermissionsFromArgv(rawArgv),
608
+ });
609
+ if (resolvedCommand.isLegacyAlias && !quiet) {
610
+ runtime.stderr.write(`[bb] Deprecated command "${resolvedCommand.invokedAs.join(" ")}"; use "bb ${resolvedCommand.command.path.join(" ")}".\n`);
611
+ }
612
+ const cmd = normalizeCliCommand(command ?? "");
361
613
  const t0 = Date.now();
362
614
  if (shouldLogCliActions()) {
363
615
  logCliAction({
@@ -369,7 +621,20 @@ async function main() {
369
621
  });
370
622
  }
371
623
  try {
624
+ requireApiKey();
625
+ if (!resolvedCommand.isLegacyAlias &&
626
+ resolvedCommand.command.effects.length > 0) {
627
+ await confirmCurrentCommand({
628
+ action: resolvedCommand.command.path.join(" "),
629
+ target: commandConfirmationTarget(resolvedCommand),
630
+ details: "Canonical v2 mutation. Supply every effect-specific --allow-* flag for non-interactive execution.",
631
+ });
632
+ }
372
633
  switch (cmd) {
634
+ case "whoami": {
635
+ await whoAmI();
636
+ break;
637
+ }
373
638
  case "list-budgets": {
374
639
  const statusStr = getFlag(flags, "status");
375
640
  const statuses = parseCommaSeparatedBudgetStatuses(statusStr !== undefined ? String(statusStr) : undefined);
@@ -415,6 +680,9 @@ async function main() {
415
680
  ? String(getFlag(flags, "projectManagerId"))
416
681
  : undefined,
417
682
  markProjectWon: parseOptionalBoolFlag(flags, "markProjectWon"),
683
+ projectInvoiceSettings: getFlag(flags, "invoiceSettings") !== undefined
684
+ ? parseJsonFlag(String(getFlag(flags, "invoiceSettings")), "--invoiceSettings")
685
+ : undefined,
418
686
  });
419
687
  break;
420
688
  }
@@ -474,6 +742,9 @@ async function main() {
474
742
  : undefined,
475
743
  markProjectWon: parseOptionalBoolFlag(flags, "markProjectWon"),
476
744
  projectStatusOnCommercialRejection: parseCommercialRejectionProjectStatus(flags),
745
+ projectInvoiceSettings: getFlag(flags, "invoiceSettings") !== undefined
746
+ ? parseJsonFlag(String(getFlag(flags, "invoiceSettings")), "--invoiceSettings")
747
+ : undefined,
477
748
  });
478
749
  break;
479
750
  }
@@ -1070,19 +1341,39 @@ async function main() {
1070
1341
  await checkCustomerInvoiceReadiness(budgetId);
1071
1342
  break;
1072
1343
  }
1073
- case "list-customer-invoices": {
1074
- const budgetId = positional[0];
1075
- if (!budgetId) {
1076
- throw new Error("list-customer-invoices requires <budgetId>");
1344
+ case "list-eligible-customer-invoice-budgets": {
1345
+ const projectId = positional[0];
1346
+ if (!projectId) {
1347
+ throw new Error("list-eligible-customer-invoice-budgets requires <projectId>");
1077
1348
  }
1349
+ await listEligibleCustomerInvoiceBudgets(projectId);
1350
+ break;
1351
+ }
1352
+ case "list-customer-invoices": {
1353
+ const positionalBudgetId = positional[0];
1354
+ const projectId = getFlag(flags, "projectId");
1355
+ const projectIds = parseCommaSeparatedIds(getFlag(flags, "projectIds"));
1356
+ if (projectId && projectIds) {
1357
+ throw new Error("Use either --projectId for project scope or --projectIds for a global filter, not both.");
1358
+ }
1359
+ const budgetIds = [
1360
+ ...(positionalBudgetId ? [positionalBudgetId] : []),
1361
+ ...(parseCommaSeparatedIds(getFlag(flags, "budgetIds")) ?? []),
1362
+ ];
1078
1363
  const statusRaw = getFlag(flags, "status");
1079
1364
  const sortDir = parseOptionalBillListSortDir(getFlag(flags, "sortDir"));
1080
1365
  await listCustomerInvoices({
1081
- budgetId,
1366
+ scope: projectId
1367
+ ? { type: "project", projectId }
1368
+ : { type: "global" },
1082
1369
  page: parsePositiveIntFlag(getFlag(flags, "page"), "--page") ?? 1,
1083
- perPage: parsePositiveIntFlag(getFlag(flags, "perPage"), "--perPage") ?? 12,
1370
+ perPage: parsePositiveIntFlag(getFlag(flags, "perPage"), "--perPage") ?? 20,
1084
1371
  q: getFlag(flags, "search") ?? "",
1085
1372
  statuses: parseCommaSeparatedCustomerInvoiceStatuses(statusRaw !== undefined ? String(statusRaw) : undefined),
1373
+ companyIds: parseCommaSeparatedIds(getFlag(flags, "companyIds")),
1374
+ projectIds,
1375
+ budgetIds: budgetIds.length > 0 ? [...new Set(budgetIds)] : undefined,
1376
+ createdByIds: parseCommaSeparatedIds(getFlag(flags, "createdByIds")),
1086
1377
  sortBy: parseCustomerInvoiceSortBy(getFlag(flags, "sortBy")),
1087
1378
  sortDir: sortDir ?? "desc",
1088
1379
  });
@@ -1096,6 +1387,14 @@ async function main() {
1096
1387
  await getCustomerInvoice(batchId);
1097
1388
  break;
1098
1389
  }
1390
+ case "get-customer-invoice-email-context": {
1391
+ const batchId = positional[0];
1392
+ if (!batchId) {
1393
+ throw new Error("get-customer-invoice-email-context requires <batchId>");
1394
+ }
1395
+ await getCustomerInvoiceEmailContext(batchId);
1396
+ break;
1397
+ }
1099
1398
  case "create-customer-invoice": {
1100
1399
  const payloadRaw = getFlag(flags, "payload");
1101
1400
  if (!payloadRaw) {
@@ -1145,6 +1444,37 @@ async function main() {
1145
1444
  await rejectCustomerInvoice(batchId, String(reason));
1146
1445
  break;
1147
1446
  }
1447
+ case "send-customer-invoice-to-contact-person": {
1448
+ const payloadRaw = getFlag(flags, "payload");
1449
+ if (!payloadRaw) {
1450
+ throw new Error("send-customer-invoice-to-contact-person requires --payload '<json>'");
1451
+ }
1452
+ await sendCustomerInvoiceToContactPersonFromPayload(parseJsonFlag(String(payloadRaw), "--payload"));
1453
+ break;
1454
+ }
1455
+ case "mark-customer-invoice-paid": {
1456
+ const invoiceId = positional[0];
1457
+ const paymentDate = getFlag(flags, "paymentDate");
1458
+ const paymentProofPath = getFlag(flags, "paymentProof");
1459
+ if (!invoiceId || !paymentDate || !paymentProofPath) {
1460
+ throw new Error("mark-customer-invoice-paid requires <invoiceId> --paymentDate YYYY-MM-DD --paymentProof <path> [--paymentReference <text>]");
1461
+ }
1462
+ await markCustomerInvoicePaidFromPath({
1463
+ invoiceId,
1464
+ paymentDate,
1465
+ paymentProofPath,
1466
+ paymentReference: getFlag(flags, "paymentReference"),
1467
+ });
1468
+ break;
1469
+ }
1470
+ case "download-customer-invoice-payment-proof": {
1471
+ const invoiceId = positional[0];
1472
+ if (!invoiceId) {
1473
+ throw new Error("download-customer-invoice-payment-proof requires <invoiceId> [--output <path>]");
1474
+ }
1475
+ await downloadCustomerInvoicePaymentProof(invoiceId, getFlag(flags, "output"));
1476
+ break;
1477
+ }
1148
1478
  case "download-customer-invoice-pdf": {
1149
1479
  const invoiceId = positional[0];
1150
1480
  if (!invoiceId) {
@@ -1601,8 +1931,8 @@ async function main() {
1601
1931
  case "create-api-key": {
1602
1932
  const userId = getFlag(flags, "userId");
1603
1933
  const name = getFlag(flags, "name");
1604
- if (!userId || !name) {
1605
- throw new Error("create-api-key requires --userId <userId> --name <label>");
1934
+ if (!name) {
1935
+ throw new Error("create-api-key requires --name <label> [--userId <userId>]");
1606
1936
  }
1607
1937
  await createApiKeyForUser({ userId, name });
1608
1938
  break;
@@ -1737,6 +2067,8 @@ async function main() {
1737
2067
  durationMs: Date.now() - t0,
1738
2068
  });
1739
2069
  }
2070
+ clearActiveCliSession();
2071
+ return 0;
1740
2072
  }
1741
2073
  catch (err) {
1742
2074
  const message = err instanceof Error ? err.message : String(err);
@@ -1749,8 +2081,21 @@ async function main() {
1749
2081
  error: message,
1750
2082
  });
1751
2083
  }
1752
- console.error(JSON.stringify({ error: message }));
1753
- process.exit(1);
2084
+ const exitCode = emitCommandError(normalizeDispatcherError(err));
2085
+ clearActiveCliSession();
2086
+ return exitCode;
2087
+ }
2088
+ }
2089
+ const resolveExecutablePath = (value) => {
2090
+ try {
2091
+ return realpathSync(value);
2092
+ }
2093
+ catch {
2094
+ return resolve(value);
1754
2095
  }
2096
+ };
2097
+ const isDirectExecution = process.argv[1] !== undefined &&
2098
+ resolveExecutablePath(process.argv[1]) === fileURLToPath(import.meta.url);
2099
+ if (isDirectExecution) {
2100
+ process.exitCode = await runCli();
1755
2101
  }
1756
- main();
package/dist/load-env.js CHANGED
@@ -3,4 +3,4 @@
3
3
  */
4
4
  import path from "node:path";
5
5
  import { config } from "dotenv";
6
- config({ path: path.resolve(process.cwd(), ".env") });
6
+ config({ path: path.resolve(process.cwd(), ".env"), quiet: true });
@@ -42,24 +42,37 @@ const requiredEmail = (value, fieldLabel) => {
42
42
  }
43
43
  return email;
44
44
  };
45
+ const parseContactPersonEmailPayload = (input, commandName) => {
46
+ if (!Array.isArray(input.cc)) {
47
+ throw new Error(`${commandName} payload.cc must be an email array.`);
48
+ }
49
+ return {
50
+ to: requiredEmail(input.to, `${commandName} payload.to`),
51
+ cc: input.cc.map((email, index) => requiredEmail(email, `${commandName} payload.cc[${index}]`)),
52
+ replyTo: requiredEmail(input.replyTo, `${commandName} payload.replyTo`),
53
+ subject: requiredString(input.subject, `${commandName} payload.subject`),
54
+ content: requiredString(input.content, `${commandName} payload.content`),
55
+ signature: requiredString(input.signature, `${commandName} payload.signature`),
56
+ };
57
+ };
45
58
  /** Matches the web estimate composer (`email.sendEstimateToContactPerson`). */
46
59
  export const parseSendEstimateToContactPersonPayload = (raw) => {
47
60
  const input = requireObject(raw, "send-estimate-to-contact-person payload");
48
- if (!Array.isArray(input.cc)) {
49
- throw new Error("send-estimate-to-contact-person payload.cc must be an email array.");
50
- }
51
61
  return {
52
- to: requiredEmail(input.to, "send-estimate-to-contact-person payload.to"),
53
- cc: input.cc.map((email, index) => requiredEmail(email, `send-estimate-to-contact-person payload.cc[${index}]`)),
54
- replyTo: requiredEmail(input.replyTo, "send-estimate-to-contact-person payload.replyTo"),
55
- subject: requiredString(input.subject, "send-estimate-to-contact-person payload.subject"),
56
- content: requiredString(input.content, "send-estimate-to-contact-person payload.content"),
57
- signature: requiredString(input.signature, "send-estimate-to-contact-person payload.signature"),
62
+ ...parseContactPersonEmailPayload(input, "send-estimate-to-contact-person"),
58
63
  estimateId: requiredString(input.estimateId, "send-estimate-to-contact-person payload.estimateId"),
59
64
  estimateDocNumber: optionalString(input.estimateDocNumber),
60
65
  budgetId: requiredString(input.budgetId, "send-estimate-to-contact-person payload.budgetId"),
61
66
  };
62
67
  };
68
+ /** Matches the web customer-invoice email composer. */
69
+ export const parseSendCustomerInvoiceToContactPersonPayload = (raw) => {
70
+ const input = requireObject(raw, "send-customer-invoice-to-contact-person payload");
71
+ return {
72
+ ...parseContactPersonEmailPayload(input, "send-customer-invoice-to-contact-person"),
73
+ invoiceId: requiredString(input.invoiceId, "send-customer-invoice-to-contact-person payload.invoiceId"),
74
+ };
75
+ };
63
76
  /** Matches budget.createBudget — optional pipedriveDealId; Asana deal is project.asanaTaskId. */
64
77
  export function parseCreateBudgetPayload(raw) {
65
78
  const o = requireObject(raw, "create-budget payload");