@go-labs-sg/bb 1.14.0 → 1.15.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/README.md +7 -1
- package/dist/commands.js +175 -3
- package/dist/index.js +12 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ For AI agents: bills and claims are stored as the same underlying bill records.
|
|
|
6
6
|
|
|
7
7
|
For AI agents using this CLI: always check whether a newer `@go-labs-sg/bb` version is available before running Budget Builder commands, then update the CLI if needed so command flags and API behavior stay current.
|
|
8
8
|
|
|
9
|
+
For AI agents using this CLI: do not execute approval decisions, approval requests, status updates, `mark-budget-won`, `submit-quotation`, or supplier approval request side effects until the user explicitly confirms the exact entity, target state, and side effects. Inspect the entity first, summarize what will change and whether emails/integrations will run, then wait for a clear confirmation from the user. These sensitive workflow commands also require an interactive `CONFIRM` prompt at runtime and abort in non-interactive shells.
|
|
10
|
+
|
|
9
11
|
**Registry:** [`@go-labs-sg/bb`](https://www.npmjs.com/package/@go-labs-sg/bb)
|
|
10
12
|
|
|
11
13
|
## Requirements
|
|
@@ -56,6 +58,10 @@ Global options and flags use `--key=value` or `--key value` (see `bb help`).
|
|
|
56
58
|
|
|
57
59
|
**Budget status automation:** Setting a budget to `ESTIMATE_ACCEPTED` requires a confirmed win-proof attachment. If the parent project is `PITCH` or `LOST`, the API now marks it `WON` automatically and, when the `asana-tasks` feature flag is enabled, creates the Asana project/section/tasks automatically. When rejecting the only accepted/closed budget on a commercial project, pass `--projectStatusOnCommercialRejection PITCH|LOST`.
|
|
58
60
|
|
|
61
|
+
**Sensitive workflow changes:** Approval decisions/requests, entity status updates, `mark-budget-won`, `submit-quotation`, and supplier approval request side effects require an interactive `CONFIRM` prompt. Non-interactive runs abort before the guarded workflow mutation. Agents must get user confirmation in chat before attempting the command; the prompt is a final runtime guard, not a replacement for user approval.
|
|
62
|
+
|
|
63
|
+
**Locked estimate budget changes:** Budget-changing CLI commands require an interactive `yes` confirmation when the current budget status is `ESTIMATE_CREATED`, `ESTIMATE_SENT`, `ESTIMATE_ACCEPTED`, `ESTIMATE_REJECTED`, or `ESTIMATE_CLOSED`. There is no force/override flag; non-interactive runs abort before mutating locked budgets.
|
|
64
|
+
|
|
59
65
|
**Mutations with `--payload`:** Commands such as `create-budget`, `create-bill`, `update-supplier`, etc. take a single JSON object (`--payload '<json>'`) matching the corresponding tRPC procedure input. Use ISO strings for date/datetime fields; the CLI coerces them where needed. The API still validates the full shape. Plain `description` fields for item create/update are converted to `descriptionRichText`; pass `descriptionRichText` directly when formatted Tiptap JSON is required. For **`update-project`**, the project window is `dateRange.from` and `dateRange.to` (optional end); there are no separate event-date fields on the project payload. **`create-budget` / `update-budget`** do not accept `asanaTaskId`; configure the deal card on the project (`update-project` / project settings).
|
|
60
66
|
|
|
61
67
|
### Command overview
|
|
@@ -66,7 +72,7 @@ Global options and flags use `--key=value` or `--key value` (see `bb help`).
|
|
|
66
72
|
| **Bills / claims** | `list-bills` (`--isClaimable false` for bills, `--isClaimable true` for claims, omit for both), `list-claims` (claims only), `create-bill` (`--payload`; set `isClaimable=false` for a bill or `isClaimable=true` for a claim; non-legacy supplier bills from 1 Jul 2026 00:00 SGT require approved quotation coverage), `update-bill` (`--payload`), `delete-bill`, `create-bill-approval` (also sends approval request emails), `update-bill-status`, `patch-bill-payment` (PAID bills: `--paymentTrackingUrl`, `--paymentReference`, `--quickbooksBillId`, `--paymentDate` ISO; clear with `--clearPaymentTrackingUrl` / `--clearPaymentReference` / `--clearQuickbooksBillId` / `--clearPaymentDate` `true`), `patch-bill-invoice-number`, `get-bill-attachments`, `upload-bill-attachment` (`<billId>` + local path), `get-bill-details` |
|
|
67
73
|
| **Quotations** | `list-quotations`, `get-quotation`, `upload-quotation-attachment` (`<projectId>` + local PDF path; returns attachment JSON for payload use), `create-quotation` (`--payload` for `quotation.createDraft`), `submit-quotation`, `approve-quotation`, `reject-quotation`, `download-quotation-pdf` (`original`, `staff`, or `final`) |
|
|
68
74
|
| **Approvals** | `list-approvals` / `get-pending-approvals` (`--type budget\|supplier\|bill\|quotation\|all`), `approve-bill` / `reject-bill` (send reply email), `approve-budget` / `reject-budget` (send reply email), `approve-supplier` / `reject-supplier` (send reply email), `approve-quotation` / `reject-quotation` |
|
|
69
|
-
| **Companies & projects** | `list-companies`, `get-company`, `create-company`, `update-company` (`--payload`), `delete-company`, `list-projects`, `get-project`, `create-project` (required: `--name`, `--companyId`, `--contactPersonId`, `--insideSalesId`, `--businessDevelopmentId`, `--venue`, `--asanaTaskId`, `--slackChannelId`, `--slackChannelUrl`, `--slackChannelName`, `--startDate` as ISO datetime for project/window start; optional `--pax`; optional `--endDate`; optional `--description`;
|
|
75
|
+
| **Companies & projects** | `list-companies`, `get-company`, `create-company`, `update-company` (`--payload`), `delete-company`, `list-projects`, `get-project`, `create-project` (required: `--name`, `--companyId`, `--contactPersonId`, `--insideSalesId`, `--businessDevelopmentId`, `--venue`, `--asanaTaskId`, `--slackChannelId`, `--slackChannelUrl`, `--slackChannelName`, `--startDate` as ISO datetime for project/window start; optional `--pax`; optional `--endDate`; optional `--description`; always requests QBO project import like the web app), `update-project` (`--payload` with `dateRange.from` / `dateRange.to` for the project window; optional `requestQboAccountantNotification` in JSON), `delete-project`, `update-project-status` (`<id>` `<status>`: `PITCH` \| `WON` \| `COMPLETED` \| `LOST`; for `PITCH` → `WON` also pass `--projectManagerId` or `--projectManagerName`; when marking `WON` without an accepted/closed budget or proof, pass `--wonOverrideReason`) |
|
|
70
76
|
| **Contacts** | `list-contacts`, `create-contact-person` (`--payload`), `update-contact-person` (`--payload`) |
|
|
71
77
|
| **Suppliers & items** | `list-suppliers` (defaults to active suppliers, `--perPage 10`, sorted by `createdAt` desc; supports `--name`, `--sortBy` for scalar supplier fields, `--sortDir`, `--createdBy`, `--gstRegistered`, `--status`, `--supplierTags`, `--active false` for archived suppliers), `create-supplier` (`--payload`; when supplier status is `PENDING_APPROVAL`, also runs `supplier.createSupplierApproval` and `email.sendSupplierApprovalRequestEmail`), `update-supplier` (`--payload`; when supplier status is `PENDING_APPROVAL`, also runs `supplier.createSupplierApproval` and `email.sendSupplierApprovalRequestEmail`), `delete-suppliers` (`--ids` CSV; admin; archives/deactivates related items), `reactivate-suppliers` (`--ids` CSV; admin; reactivates related items), `create-certification` / `create-payment-method` / `create-supplier-role` / `create-supplier-tag` (`--name`), `get-supplier-details` (includes `supplierApprovalSummary` for pending approvers, superseded approvers, and the actual responder/respondedAt metadata), `get-supplier-analytics`, `list-items`, `create-item` (`--payload`), `update-item` (`--payload`), `delete-item`, `get-item`, `list-item-categories`, `create-item-category`, `update-item-category`, `delete-item-categories` (`--ids` CSV; admin) |
|
|
72
78
|
| **Dashboard & users** | `list-users`, `get-user-performance`, `get-dashboard`, `get-monthly-metrics`, `get-system-overview`, `get-estimate-performance`, `get-financial-overview` |
|
package/dist/commands.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { basename } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
4
5
|
import { contentType } from "mime-types";
|
|
5
6
|
import { api } from "./api-client.js";
|
|
6
7
|
import { BudgetRole, Deals, ExtendedApprovalStatus, ExtendedApprovalType, ExtendedBudgetStatus, TimeFrame, } from "./filter-enums.js";
|
|
7
8
|
import { billStatusesForApi, } from "./parse-cli-enums.js";
|
|
8
9
|
import { parseBudgetDiscountPayload, parseCompanyUpdatePayload, parseContactCreatePayload, parseContactUpdatePayload, parseCreateBillPayload, parseCreateBudgetPayload, parseItemCreatePayload, parseItemUpdatePayload, parseSupplierCreatePayload, parseSupplierUpdatePayload, parseUpdateBillPayload, parseUpdateBudgetCommissionPayload, parseUpdateBudgetPayload, parseUpdateProjectPayload, } from "./parse-mutation-payload.js";
|
|
10
|
+
import { BudgetStatus, } from "./prisma-enums.js";
|
|
9
11
|
import { createRichTextFromPlainText } from "./rich-text.js";
|
|
10
12
|
const BUDGET = "BUDGET";
|
|
11
13
|
const BILL = "BILL";
|
|
@@ -27,6 +29,82 @@ const contentTypeHeaderForFileName = (fileName) => {
|
|
|
27
29
|
const ct = contentType(fileName);
|
|
28
30
|
return typeof ct === "string" ? ct : "application/octet-stream";
|
|
29
31
|
};
|
|
32
|
+
const LOCKED_BUDGET_STATUSES_REQUIRING_CONFIRMATION = new Set([
|
|
33
|
+
BudgetStatus.ESTIMATE_CREATED,
|
|
34
|
+
BudgetStatus.ESTIMATE_SENT,
|
|
35
|
+
BudgetStatus.ESTIMATE_ACCEPTED,
|
|
36
|
+
BudgetStatus.ESTIMATE_REJECTED,
|
|
37
|
+
BudgetStatus.ESTIMATE_CLOSED,
|
|
38
|
+
]);
|
|
39
|
+
const lockedBudgetStatusLabels = {
|
|
40
|
+
ESTIMATE_CREATED: "Estimate Created",
|
|
41
|
+
ESTIMATE_SENT: "Estimate Sent",
|
|
42
|
+
ESTIMATE_ACCEPTED: "Estimate Accepted",
|
|
43
|
+
ESTIMATE_REJECTED: "Estimate Rejected",
|
|
44
|
+
ESTIMATE_CLOSED: "Estimate Closed",
|
|
45
|
+
};
|
|
46
|
+
const lockedBudgetStatusLabel = (status) => status in lockedBudgetStatusLabels
|
|
47
|
+
? lockedBudgetStatusLabels[status]
|
|
48
|
+
: status;
|
|
49
|
+
const assertSensitiveWorkflowConfirmed = async ({ action, entity, details, }) => {
|
|
50
|
+
const expected = "CONFIRM";
|
|
51
|
+
const detailText = details ? ` ${details}` : "";
|
|
52
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
53
|
+
throw new Error(`${action} on ${entity}${detailText} is a sensitive Budget Builder workflow change. Run this command in an interactive terminal after the user explicitly confirms the exact action, then type "${expected}" to continue.`);
|
|
54
|
+
}
|
|
55
|
+
const rl = createInterface({
|
|
56
|
+
input: process.stdin,
|
|
57
|
+
output: process.stderr,
|
|
58
|
+
});
|
|
59
|
+
try {
|
|
60
|
+
const answer = await rl.question(`${action} on ${entity}${detailText} may change workflow status, approvals, or send notification emails. Confirm the user explicitly approved this exact action, then type "${expected}" to continue: `);
|
|
61
|
+
if (answer.trim() !== expected) {
|
|
62
|
+
throw new Error("Aborted.");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
rl.close();
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const assertLockedBudgetChangeConfirmed = async ({ budget, action, }) => {
|
|
70
|
+
if (!LOCKED_BUDGET_STATUSES_REQUIRING_CONFIRMATION.has(budget.status)) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const statusLabel = lockedBudgetStatusLabel(budget.status);
|
|
74
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
75
|
+
throw new Error(`${action} would change locked budget "${budget.name}" (${budget.id}) with status ${statusLabel}. Run this command in an interactive terminal and type "yes" to confirm.`);
|
|
76
|
+
}
|
|
77
|
+
const rl = createInterface({
|
|
78
|
+
input: process.stdin,
|
|
79
|
+
output: process.stderr,
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
const answer = await rl.question(`${action} will change locked budget "${budget.name}" (${budget.id}) with status ${statusLabel}. Type "yes" to continue: `);
|
|
83
|
+
if (answer.trim().toLowerCase() !== "yes") {
|
|
84
|
+
throw new Error("Aborted.");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
rl.close();
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const confirmLockedBudgetChangeByBudgetId = async (budgetId, action) => {
|
|
92
|
+
const budget = await api.budget.getBudget.query({ id: budgetId });
|
|
93
|
+
await assertLockedBudgetChangeConfirmed({
|
|
94
|
+
budget: {
|
|
95
|
+
id: budget.id,
|
|
96
|
+
name: budget.name,
|
|
97
|
+
status: budget.status,
|
|
98
|
+
},
|
|
99
|
+
action,
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
const confirmLockedBudgetChangeByBudgetItemId = async (budgetItemId, action) => {
|
|
103
|
+
const { budget } = await api.budgetItem.getBudgetItemContext.query({
|
|
104
|
+
budgetItemId,
|
|
105
|
+
});
|
|
106
|
+
await assertLockedBudgetChangeConfirmed({ budget, action });
|
|
107
|
+
};
|
|
30
108
|
export async function listBudgets(opts) {
|
|
31
109
|
const input = {
|
|
32
110
|
projectId: opts.projectId,
|
|
@@ -52,6 +130,12 @@ export async function getBudget(id) {
|
|
|
52
130
|
out({ budget, items });
|
|
53
131
|
}
|
|
54
132
|
export async function updateBudgetStatus(budgetId, status, opts) {
|
|
133
|
+
await assertSensitiveWorkflowConfirmed({
|
|
134
|
+
action: "Update budget status",
|
|
135
|
+
entity: `budget ${budgetId}`,
|
|
136
|
+
details: `to ${status}`,
|
|
137
|
+
});
|
|
138
|
+
await confirmLockedBudgetChangeByBudgetId(budgetId, "Update budget status");
|
|
55
139
|
const result = await api.budget.updateBudgetStatus.mutate({
|
|
56
140
|
id: budgetId,
|
|
57
141
|
status,
|
|
@@ -149,6 +233,11 @@ export async function listBills(opts) {
|
|
|
149
233
|
out(result);
|
|
150
234
|
}
|
|
151
235
|
export async function approveBill(billId) {
|
|
236
|
+
await assertSensitiveWorkflowConfirmed({
|
|
237
|
+
action: "Approve bill",
|
|
238
|
+
entity: `bill ${billId}`,
|
|
239
|
+
details: "and send requester reply email",
|
|
240
|
+
});
|
|
152
241
|
const notifications = await api.budget.getNotifications.query();
|
|
153
242
|
const pending = notifications.filter((n) => isPendingRequest(n) &&
|
|
154
243
|
n.type === BILL &&
|
|
@@ -240,6 +329,11 @@ export async function listSuppliers(opts) {
|
|
|
240
329
|
}
|
|
241
330
|
// --- Bills (MCP parity) ---
|
|
242
331
|
export async function createBillApproval(billId) {
|
|
332
|
+
await assertSensitiveWorkflowConfirmed({
|
|
333
|
+
action: "Create bill approval request",
|
|
334
|
+
entity: `bill ${billId}`,
|
|
335
|
+
details: "and send approval request emails",
|
|
336
|
+
});
|
|
243
337
|
const result = await api.bill.createApproval.mutate({ billId });
|
|
244
338
|
const approvalIds = (result.results ?? []).map((item) => ({ id: item.id }));
|
|
245
339
|
let email;
|
|
@@ -265,6 +359,11 @@ export async function createBillApproval(billId) {
|
|
|
265
359
|
});
|
|
266
360
|
}
|
|
267
361
|
export async function updateBillStatus(opts) {
|
|
362
|
+
await assertSensitiveWorkflowConfirmed({
|
|
363
|
+
action: "Update bill status",
|
|
364
|
+
entity: `bill ${opts.id}`,
|
|
365
|
+
details: `to ${opts.status}`,
|
|
366
|
+
});
|
|
268
367
|
const result = await api.bill.updateStatus.mutate({
|
|
269
368
|
id: opts.id,
|
|
270
369
|
status: opts.status,
|
|
@@ -357,6 +456,7 @@ export async function uploadQuotationAttachmentFromPath(projectId, filePath) {
|
|
|
357
456
|
});
|
|
358
457
|
}
|
|
359
458
|
export async function uploadBudgetAttachmentFromPath(budgetId, filePath) {
|
|
459
|
+
await confirmLockedBudgetChangeByBudgetId(budgetId, "Upload budget attachment");
|
|
360
460
|
const buf = await readFile(filePath);
|
|
361
461
|
const fileName = basename(filePath);
|
|
362
462
|
const size = buf.byteLength;
|
|
@@ -382,6 +482,12 @@ export async function uploadBudgetAttachmentFromPath(budgetId, filePath) {
|
|
|
382
482
|
out(confirmed);
|
|
383
483
|
}
|
|
384
484
|
export async function uploadBudgetWinProofFromPath(budgetId, filePath, opts) {
|
|
485
|
+
await assertSensitiveWorkflowConfirmed({
|
|
486
|
+
action: "Mark budget won",
|
|
487
|
+
entity: `budget ${budgetId}`,
|
|
488
|
+
details: "by uploading proof and setting status to ESTIMATE_ACCEPTED",
|
|
489
|
+
});
|
|
490
|
+
await confirmLockedBudgetChangeByBudgetId(budgetId, "Mark budget won");
|
|
385
491
|
const buf = await readFile(filePath);
|
|
386
492
|
const fileName = basename(filePath);
|
|
387
493
|
const size = buf.byteLength;
|
|
@@ -426,10 +532,12 @@ export async function getBudgetItemsOnly(budgetId) {
|
|
|
426
532
|
out(items);
|
|
427
533
|
}
|
|
428
534
|
export async function addBudgetItems(opts) {
|
|
535
|
+
await confirmLockedBudgetChangeByBudgetId(opts.budgetId, "Add budget items");
|
|
429
536
|
const result = await api.budgetItem.addBudgetItems.mutate(opts);
|
|
430
537
|
out(result);
|
|
431
538
|
}
|
|
432
539
|
export async function updateBudgetItem(input) {
|
|
540
|
+
await confirmLockedBudgetChangeByBudgetItemId(input.id, "Update budget item");
|
|
433
541
|
const result = await api.budgetItem.updateBudgetItem.mutate({
|
|
434
542
|
...input,
|
|
435
543
|
...(input.description !== undefined &&
|
|
@@ -444,6 +552,7 @@ export async function updateBudgetItem(input) {
|
|
|
444
552
|
out(result);
|
|
445
553
|
}
|
|
446
554
|
export async function removeBudgetItem(budgetItemId) {
|
|
555
|
+
await confirmLockedBudgetChangeByBudgetItemId(budgetItemId, "Remove budget item");
|
|
447
556
|
const result = await api.budgetItem.removeBudgetItem.mutate({
|
|
448
557
|
budgetItemId,
|
|
449
558
|
});
|
|
@@ -460,6 +569,12 @@ export async function getBudgetVersions(budgetId) {
|
|
|
460
569
|
out(versions);
|
|
461
570
|
}
|
|
462
571
|
export async function createBudgetApproval(budgetId, preflight) {
|
|
572
|
+
await assertSensitiveWorkflowConfirmed({
|
|
573
|
+
action: "Create budget approval request",
|
|
574
|
+
entity: `budget ${budgetId}`,
|
|
575
|
+
details: "and send approval request emails",
|
|
576
|
+
});
|
|
577
|
+
await confirmLockedBudgetChangeByBudgetId(budgetId, "Create budget approval");
|
|
463
578
|
await runBudgetPreflight(budgetId, preflight);
|
|
464
579
|
await assertBudgetActionReadiness({
|
|
465
580
|
budgetId,
|
|
@@ -491,6 +606,7 @@ export async function createBudgetApproval(budgetId, preflight) {
|
|
|
491
606
|
});
|
|
492
607
|
}
|
|
493
608
|
export async function createEstimate(budgetId, preflight) {
|
|
609
|
+
await confirmLockedBudgetChangeByBudgetId(budgetId, "Create estimate");
|
|
494
610
|
await runBudgetPreflight(budgetId, preflight);
|
|
495
611
|
await assertBudgetActionReadiness({
|
|
496
612
|
budgetId,
|
|
@@ -511,10 +627,12 @@ export async function createBudgetFromPayload(raw) {
|
|
|
511
627
|
}
|
|
512
628
|
export async function updateBudgetFromPayload(raw) {
|
|
513
629
|
const input = parseUpdateBudgetPayload(raw);
|
|
630
|
+
await confirmLockedBudgetChangeByBudgetId(input.id, "Update budget");
|
|
514
631
|
const result = await api.budget.updateBudget.mutate(input);
|
|
515
632
|
out(result);
|
|
516
633
|
}
|
|
517
634
|
export async function deleteBudgetById(budgetId) {
|
|
635
|
+
await confirmLockedBudgetChangeByBudgetId(budgetId, "Delete budget");
|
|
518
636
|
const result = await api.budget.deleteBudget.mutate({ id: budgetId });
|
|
519
637
|
out(result);
|
|
520
638
|
}
|
|
@@ -527,10 +645,12 @@ export async function deleteCompanyById(companyId) {
|
|
|
527
645
|
out(result);
|
|
528
646
|
}
|
|
529
647
|
export async function reorderBudgetItemsCli(opts) {
|
|
648
|
+
await confirmLockedBudgetChangeByBudgetId(opts.budgetId, "Reorder budget items");
|
|
530
649
|
const result = await api.budgetItem.reorderBudgetItems.mutate(opts);
|
|
531
650
|
out(result);
|
|
532
651
|
}
|
|
533
652
|
export async function updateBudgetItemSupplierCli(opts) {
|
|
653
|
+
await confirmLockedBudgetChangeByBudgetItemId(opts.budgetItemId, "Update budget item supplier");
|
|
534
654
|
const result = await api.budgetItem.updateBudgetItemSupplier.mutate(opts);
|
|
535
655
|
out(result);
|
|
536
656
|
}
|
|
@@ -548,11 +668,13 @@ export async function deleteBudgetCategory(id) {
|
|
|
548
668
|
}
|
|
549
669
|
export async function updateBudgetCommissionFromPayload(raw) {
|
|
550
670
|
const input = parseUpdateBudgetCommissionPayload(raw);
|
|
671
|
+
await confirmLockedBudgetChangeByBudgetId(input.budgetId, "Update budget commission");
|
|
551
672
|
const result = await api.budget.updateBudgetCommission.mutate(input);
|
|
552
673
|
out(result);
|
|
553
674
|
}
|
|
554
675
|
export async function updateBudgetDiscountFromPayload(raw) {
|
|
555
676
|
const input = parseBudgetDiscountPayload(raw);
|
|
677
|
+
await confirmLockedBudgetChangeByBudgetId(input.budgetId, "Update budget discount");
|
|
556
678
|
const result = await api.budget.updateBudgetDiscount.mutate(input);
|
|
557
679
|
out(result);
|
|
558
680
|
}
|
|
@@ -614,6 +736,10 @@ export async function createQuotationFromPayload(raw) {
|
|
|
614
736
|
out(result);
|
|
615
737
|
}
|
|
616
738
|
export async function submitQuotation(id) {
|
|
739
|
+
await assertSensitiveWorkflowConfirmed({
|
|
740
|
+
action: "Submit quotation for approval",
|
|
741
|
+
entity: `quotation ${id}`,
|
|
742
|
+
});
|
|
617
743
|
const result = await api.quotation.submitForApproval.mutate({ id });
|
|
618
744
|
out(result);
|
|
619
745
|
}
|
|
@@ -641,6 +767,11 @@ export async function createSupplierFromPayload(raw) {
|
|
|
641
767
|
out(created);
|
|
642
768
|
return;
|
|
643
769
|
}
|
|
770
|
+
await assertSensitiveWorkflowConfirmed({
|
|
771
|
+
action: "Create supplier approval request",
|
|
772
|
+
entity: `supplier ${supplier.id}`,
|
|
773
|
+
details: "and send approval request emails",
|
|
774
|
+
});
|
|
644
775
|
const approval = await api.supplier.createSupplierApproval.mutate({
|
|
645
776
|
supplierId: supplier.id,
|
|
646
777
|
});
|
|
@@ -679,6 +810,11 @@ export async function updateSupplierFromPayload(raw) {
|
|
|
679
810
|
out(updated);
|
|
680
811
|
return;
|
|
681
812
|
}
|
|
813
|
+
await assertSensitiveWorkflowConfirmed({
|
|
814
|
+
action: "Create supplier approval request",
|
|
815
|
+
entity: `supplier ${supplier.id}`,
|
|
816
|
+
details: "and send approval request emails",
|
|
817
|
+
});
|
|
682
818
|
let approvalResult;
|
|
683
819
|
try {
|
|
684
820
|
const approval = await api.supplier.createSupplierApproval.mutate({
|
|
@@ -759,6 +895,11 @@ export async function updateCompanyFromPayload(raw) {
|
|
|
759
895
|
}
|
|
760
896
|
// --- Approvals: budgets / suppliers / bills (MCP parity) ---
|
|
761
897
|
export async function approveBudget(budgetId) {
|
|
898
|
+
await assertSensitiveWorkflowConfirmed({
|
|
899
|
+
action: "Approve budget",
|
|
900
|
+
entity: `budget ${budgetId}`,
|
|
901
|
+
details: "and send requester reply email",
|
|
902
|
+
});
|
|
762
903
|
const notifications = await api.budget.getNotifications.query();
|
|
763
904
|
const pending = notifications.filter((n) => n.notificationType === "request" &&
|
|
764
905
|
n.status === ExtendedApprovalStatus.PENDING_APPROVAL &&
|
|
@@ -790,6 +931,11 @@ export async function approveBudget(budgetId) {
|
|
|
790
931
|
});
|
|
791
932
|
}
|
|
792
933
|
export async function rejectBudget(budgetId, reason) {
|
|
934
|
+
await assertSensitiveWorkflowConfirmed({
|
|
935
|
+
action: "Reject budget",
|
|
936
|
+
entity: `budget ${budgetId}`,
|
|
937
|
+
details: `with reason "${reason}" and send requester reply email`,
|
|
938
|
+
});
|
|
793
939
|
const notifications = await api.budget.getNotifications.query();
|
|
794
940
|
const pending = notifications.filter((n) => n.notificationType === "request" &&
|
|
795
941
|
n.status === ExtendedApprovalStatus.PENDING_APPROVAL &&
|
|
@@ -883,6 +1029,11 @@ export async function getSupplierDetails(supplierId) {
|
|
|
883
1029
|
});
|
|
884
1030
|
}
|
|
885
1031
|
export async function approveSupplier(supplierId) {
|
|
1032
|
+
await assertSensitiveWorkflowConfirmed({
|
|
1033
|
+
action: "Approve supplier",
|
|
1034
|
+
entity: `supplier ${supplierId}`,
|
|
1035
|
+
details: "and send requester reply email",
|
|
1036
|
+
});
|
|
886
1037
|
const notifications = await api.budget.getNotifications.query();
|
|
887
1038
|
const pending = notifications.filter((n) => n.notificationType === "request" &&
|
|
888
1039
|
n.status === ExtendedApprovalStatus.PENDING_APPROVAL &&
|
|
@@ -916,6 +1067,11 @@ export async function approveSupplier(supplierId) {
|
|
|
916
1067
|
});
|
|
917
1068
|
}
|
|
918
1069
|
export async function rejectSupplier(supplierId, reason) {
|
|
1070
|
+
await assertSensitiveWorkflowConfirmed({
|
|
1071
|
+
action: "Reject supplier",
|
|
1072
|
+
entity: `supplier ${supplierId}`,
|
|
1073
|
+
details: `with reason "${reason}" and send requester reply email`,
|
|
1074
|
+
});
|
|
919
1075
|
const notifications = await api.budget.getNotifications.query();
|
|
920
1076
|
const pending = notifications.filter((n) => n.notificationType === "request" &&
|
|
921
1077
|
n.status === ExtendedApprovalStatus.PENDING_APPROVAL &&
|
|
@@ -950,6 +1106,11 @@ export async function rejectSupplier(supplierId, reason) {
|
|
|
950
1106
|
});
|
|
951
1107
|
}
|
|
952
1108
|
export async function rejectBill(billId, reason) {
|
|
1109
|
+
await assertSensitiveWorkflowConfirmed({
|
|
1110
|
+
action: "Reject bill",
|
|
1111
|
+
entity: `bill ${billId}`,
|
|
1112
|
+
details: `with reason "${reason}" and send requester reply email`,
|
|
1113
|
+
});
|
|
953
1114
|
const notifications = await api.budget.getNotifications.query();
|
|
954
1115
|
const pending = notifications.filter((n) => n.notificationType === "request" &&
|
|
955
1116
|
n.status === ExtendedApprovalStatus.PENDING_APPROVAL &&
|
|
@@ -981,6 +1142,10 @@ export async function rejectBill(billId, reason) {
|
|
|
981
1142
|
});
|
|
982
1143
|
}
|
|
983
1144
|
export async function approveQuotation(quotationId) {
|
|
1145
|
+
await assertSensitiveWorkflowConfirmed({
|
|
1146
|
+
action: "Approve quotation",
|
|
1147
|
+
entity: `quotation ${quotationId}`,
|
|
1148
|
+
});
|
|
984
1149
|
const notifications = await api.budget.getNotifications.query();
|
|
985
1150
|
const pending = notifications.filter((n) => isPendingRequest(n) &&
|
|
986
1151
|
n.type === QUOTATION &&
|
|
@@ -996,6 +1161,11 @@ export async function approveQuotation(quotationId) {
|
|
|
996
1161
|
out(result);
|
|
997
1162
|
}
|
|
998
1163
|
export async function rejectQuotation(quotationId, reason) {
|
|
1164
|
+
await assertSensitiveWorkflowConfirmed({
|
|
1165
|
+
action: "Reject quotation",
|
|
1166
|
+
entity: `quotation ${quotationId}`,
|
|
1167
|
+
details: `with reason "${reason}"`,
|
|
1168
|
+
});
|
|
999
1169
|
const notifications = await api.budget.getNotifications.query();
|
|
1000
1170
|
const pending = notifications.filter((n) => isPendingRequest(n) &&
|
|
1001
1171
|
n.type === QUOTATION &&
|
|
@@ -1067,13 +1237,15 @@ export async function createProject(opts) {
|
|
|
1067
1237
|
from: new Date(opts.startDate),
|
|
1068
1238
|
to: opts.endDate ? new Date(opts.endDate) : undefined,
|
|
1069
1239
|
},
|
|
1070
|
-
...(opts.requestQboAccountantNotification !== undefined && {
|
|
1071
|
-
requestQboAccountantNotification: opts.requestQboAccountantNotification,
|
|
1072
|
-
}),
|
|
1073
1240
|
});
|
|
1074
1241
|
out(result);
|
|
1075
1242
|
}
|
|
1076
1243
|
export async function updateProjectStatus(id, status, opts) {
|
|
1244
|
+
await assertSensitiveWorkflowConfirmed({
|
|
1245
|
+
action: "Update project status",
|
|
1246
|
+
entity: `project ${id}`,
|
|
1247
|
+
details: `to ${status}`,
|
|
1248
|
+
});
|
|
1077
1249
|
const result = await api.project.updateProjectStatus.mutate({
|
|
1078
1250
|
id,
|
|
1079
1251
|
status,
|
package/dist/index.js
CHANGED
|
@@ -119,6 +119,13 @@ Global: --quiet | -q | BB_CLI_QUIET=1 — hide action logs (default: log command
|
|
|
119
119
|
|
|
120
120
|
Auth: Set BB_API_KEY in the environment (same API key as MCP / Goracle).
|
|
121
121
|
|
|
122
|
+
Sensitive workflow changes: approval decisions/requests, entity status updates,
|
|
123
|
+
mark-budget-won, submit-quotation, and supplier approval request side effects
|
|
124
|
+
require an interactive CONFIRM prompt and abort in non-interactive shells.
|
|
125
|
+
AI agents must inspect the entity first, summarize the exact entity, target
|
|
126
|
+
state, and side effects, then wait for explicit user confirmation before running
|
|
127
|
+
the sensitive command.
|
|
128
|
+
|
|
122
129
|
Budgets
|
|
123
130
|
list-budgets [--projectId] [--name] [--status CSV] [--createdBy] [--dateFrom] [--dateTo] [--sortBy] [--sortDir] [--page] [--perPage] [--summary]
|
|
124
131
|
get-budget <id> Budget + line items
|
|
@@ -127,6 +134,7 @@ Budgets
|
|
|
127
134
|
get-budget-categories
|
|
128
135
|
get-budget-versions <budgetId>
|
|
129
136
|
update-budget-status <id> <status> [--markProjectWon true|false] [--projectStatusOnCommercialRejection PITCH|LOST]
|
|
137
|
+
Budget-changing commands require interactive "yes" confirmation when the current budget is ESTIMATE_CREATED, ESTIMATE_SENT, ESTIMATE_ACCEPTED, ESTIMATE_REJECTED, or ESTIMATE_CLOSED.
|
|
130
138
|
ESTIMATE_ACCEPTED requires win proof, auto-marks PITCH or LOST projects WON, and auto-creates the Asana project/section when enabled.
|
|
131
139
|
ESTIMATE_REJECTED requires --projectStatusOnCommercialRejection when rejecting the only accepted/closed budget on a commercial project.
|
|
132
140
|
status: ${budgetStatusesForHelp.join(", ")}
|
|
@@ -195,7 +203,7 @@ Companies & projects
|
|
|
195
203
|
list-projects [--companyId] [--name] [--status] [--active true|false] [--page] [--perPage]
|
|
196
204
|
Omit --active to include both active and inactive projects.
|
|
197
205
|
get-project <id>
|
|
198
|
-
create-project --name --companyId --contactPersonId --insideSalesId --businessDevelopmentId --venue --asanaTaskId --slackChannelId --slackChannelUrl --slackChannelName --startDate <ISO> [--pax] [--endDate <ISO>] [--description]
|
|
206
|
+
create-project --name --companyId --contactPersonId --insideSalesId --businessDevelopmentId --venue --asanaTaskId --slackChannelId --slackChannelUrl --slackChannelName --startDate <ISO> [--pax] [--endDate <ISO>] [--description]
|
|
199
207
|
update-project --payload '<json>' (project.updateProject; optional requestQboAccountantNotification; default notify like web)
|
|
200
208
|
delete-project <id>
|
|
201
209
|
update-project-status <id> <status> [--projectManagerId <id>] [--projectManagerName <name>] [--wonOverrideReason <text>]
|
|
@@ -904,6 +912,9 @@ async function main() {
|
|
|
904
912
|
const slackChannelUrl = getFlag(flags, "slackChannelUrl");
|
|
905
913
|
const slackChannelName = getFlag(flags, "slackChannelName");
|
|
906
914
|
const startDate = getFlag(flags, "startDate");
|
|
915
|
+
if (getFlag(flags, "requestQboAccountantNotification") !== undefined) {
|
|
916
|
+
throw new Error("create-project does not allow --requestQboAccountantNotification; CLI project creation always requests the QuickBooks project import like the web app.");
|
|
917
|
+
}
|
|
907
918
|
if (!name ||
|
|
908
919
|
!companyId ||
|
|
909
920
|
!contactPersonId ||
|
|
@@ -932,7 +943,6 @@ async function main() {
|
|
|
932
943
|
startDate,
|
|
933
944
|
endDate: getFlag(flags, "endDate"),
|
|
934
945
|
description: getFlag(flags, "description"),
|
|
935
|
-
requestQboAccountantNotification: parseOptionalBoolFlag(flags, "requestQboAccountantNotification"),
|
|
936
946
|
});
|
|
937
947
|
break;
|
|
938
948
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@go-labs-sg/bb",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.0",
|
|
4
4
|
"description": "Budget Builder CLI for AI agents — manage budgets, bills, and claims; bill records use isClaimable=false for bills and isClaimable=true for claims.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|