@go-labs-sg/bb 1.14.0 → 1.16.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 +377 -8
- package/dist/index.js +24 -10
- 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`, `--
|
|
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`, `--startDate` as ISO datetime for project/window start; when `--asanaTaskId` is omitted, the CLI searches open Asana lead tasks in the Deals project, prompts for one of the top five matches, and resolves Slack channel fields from the selected deal card; optional `--asanaSearch`, `--pax`, `--endDate`, `--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,32 +1,293 @@
|
|
|
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";
|
|
12
14
|
const SUPPLIER = "SUPPLIER";
|
|
13
15
|
const QUOTATION = "QUOTATION";
|
|
16
|
+
const ASANA_WON_LOST_SECTION_GIDS = new Set([
|
|
17
|
+
"1211678338364908",
|
|
18
|
+
"1211678338364907",
|
|
19
|
+
]);
|
|
20
|
+
const MAX_ASANA_LEAD_CANDIDATES = 5;
|
|
21
|
+
const ASANA_SEARCH_DETAIL_LIMIT = 15;
|
|
14
22
|
function isPendingRequest(n) {
|
|
15
23
|
return n.notificationType === "request" && n.status === "PENDING_APPROVAL";
|
|
16
24
|
}
|
|
17
25
|
const out = (data) => {
|
|
18
26
|
console.log(JSON.stringify(data, null, 2));
|
|
19
27
|
};
|
|
28
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
29
|
+
const stringField = (record, key) => {
|
|
30
|
+
const value = record[key];
|
|
31
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
32
|
+
};
|
|
33
|
+
const asanaSectionsForTask = (task) => {
|
|
34
|
+
if (!isRecord(task) || !Array.isArray(task.memberships))
|
|
35
|
+
return [];
|
|
36
|
+
return task.memberships.flatMap((membership) => {
|
|
37
|
+
if (!isRecord(membership) || !isRecord(membership.section))
|
|
38
|
+
return [];
|
|
39
|
+
return [
|
|
40
|
+
{
|
|
41
|
+
gid: stringField(membership.section, "gid"),
|
|
42
|
+
name: stringField(membership.section, "name"),
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
const isWonOrLostAsanaTask = (task) => asanaSectionsForTask(task).some((section) => {
|
|
48
|
+
if (section.gid && ASANA_WON_LOST_SECTION_GIDS.has(section.gid)) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
const name = section.name?.trim().toLowerCase();
|
|
52
|
+
return Boolean(name && /\b(won|lost)\b/.test(name));
|
|
53
|
+
});
|
|
54
|
+
const asanaCandidateFromTask = (task, details) => {
|
|
55
|
+
const source = details ?? task;
|
|
56
|
+
const sourceRecord = isRecord(source) ? source : undefined;
|
|
57
|
+
const gid = (sourceRecord ? stringField(sourceRecord, "gid") : undefined) ?? task.gid;
|
|
58
|
+
const name = (sourceRecord ? stringField(sourceRecord, "name") : undefined) ??
|
|
59
|
+
task.name ??
|
|
60
|
+
gid;
|
|
61
|
+
if (!gid || isWonOrLostAsanaTask(source))
|
|
62
|
+
return null;
|
|
63
|
+
return {
|
|
64
|
+
gid,
|
|
65
|
+
name,
|
|
66
|
+
modifiedAt: (sourceRecord ? stringField(sourceRecord, "modified_at") : undefined) ??
|
|
67
|
+
task.modified_at,
|
|
68
|
+
createdAt: (sourceRecord ? stringField(sourceRecord, "created_at") : undefined) ??
|
|
69
|
+
task.created_at,
|
|
70
|
+
sections: asanaSectionsForTask(source),
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
const uniqueSearchQueries = (...queries) => {
|
|
74
|
+
const seen = new Set();
|
|
75
|
+
const unique = [];
|
|
76
|
+
for (const query of queries) {
|
|
77
|
+
const normalized = query?.trim() ?? "";
|
|
78
|
+
if (!normalized)
|
|
79
|
+
continue;
|
|
80
|
+
const key = normalized.toLowerCase();
|
|
81
|
+
if (seen.has(key))
|
|
82
|
+
continue;
|
|
83
|
+
seen.add(key);
|
|
84
|
+
unique.push(normalized);
|
|
85
|
+
}
|
|
86
|
+
return unique;
|
|
87
|
+
};
|
|
88
|
+
const searchOpenAsanaLeadCandidates = async ({ projectName, searchQuery, }) => {
|
|
89
|
+
const queries = uniqueSearchQueries(projectName, searchQuery);
|
|
90
|
+
const candidates = [];
|
|
91
|
+
const seenTaskIds = new Set();
|
|
92
|
+
for (const query of queries) {
|
|
93
|
+
const results = await api.asana.searchTasks.query({ query });
|
|
94
|
+
const tasks = results.data.slice(0, ASANA_SEARCH_DETAIL_LIMIT);
|
|
95
|
+
const detailsByTaskId = new Map();
|
|
96
|
+
await Promise.all(tasks.map(async (task) => {
|
|
97
|
+
if (!task.gid)
|
|
98
|
+
return;
|
|
99
|
+
try {
|
|
100
|
+
const details = await api.asana.getTaskById.query({ id: task.gid });
|
|
101
|
+
detailsByTaskId.set(task.gid, details.data);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Keep the compact search result if detail lookup fails for one task.
|
|
105
|
+
}
|
|
106
|
+
}));
|
|
107
|
+
for (const task of tasks) {
|
|
108
|
+
if (!task.gid || seenTaskIds.has(task.gid))
|
|
109
|
+
continue;
|
|
110
|
+
const candidate = asanaCandidateFromTask(task, detailsByTaskId.get(task.gid));
|
|
111
|
+
if (!candidate)
|
|
112
|
+
continue;
|
|
113
|
+
seenTaskIds.add(candidate.gid);
|
|
114
|
+
candidates.push(candidate);
|
|
115
|
+
if (candidates.length >= MAX_ASANA_LEAD_CANDIDATES)
|
|
116
|
+
return candidates;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return candidates;
|
|
120
|
+
};
|
|
121
|
+
const formatAsanaCandidateMetadata = (candidate) => {
|
|
122
|
+
const sectionText = candidate.sections
|
|
123
|
+
.map((section) => section.name ?? section.gid)
|
|
124
|
+
.filter(Boolean)
|
|
125
|
+
.join(", ") || "stage unavailable";
|
|
126
|
+
const dateText = candidate.modifiedAt
|
|
127
|
+
? `modified ${new Date(candidate.modifiedAt).toISOString()}`
|
|
128
|
+
: candidate.createdAt
|
|
129
|
+
? `created ${new Date(candidate.createdAt).toISOString()}`
|
|
130
|
+
: "date unavailable";
|
|
131
|
+
return `${sectionText}; ${dateText}; ${candidate.gid}`;
|
|
132
|
+
};
|
|
133
|
+
const promptForAsanaLeadCandidate = async (candidates) => {
|
|
134
|
+
if (candidates.length === 0) {
|
|
135
|
+
throw new Error("No open Asana lead tasks were found in the Deals project. Pass --asanaTaskId explicitly or update the Deals project first.");
|
|
136
|
+
}
|
|
137
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
138
|
+
throw new Error("create-project requires --asanaTaskId in non-interactive runs. Interactive runs can omit it to choose from the top Asana lead task matches.");
|
|
139
|
+
}
|
|
140
|
+
console.error("Select the Asana lead task to link to this project:");
|
|
141
|
+
candidates.forEach((candidate, index) => {
|
|
142
|
+
console.error(`${index + 1}. ${candidate.name} (${formatAsanaCandidateMetadata(candidate)})`);
|
|
143
|
+
});
|
|
144
|
+
const rl = createInterface({
|
|
145
|
+
input: process.stdin,
|
|
146
|
+
output: process.stderr,
|
|
147
|
+
});
|
|
148
|
+
try {
|
|
149
|
+
while (true) {
|
|
150
|
+
const answer = await rl.question(`Choose 1-${candidates.length}, or q to abort: `);
|
|
151
|
+
const trimmed = answer.trim().toLowerCase();
|
|
152
|
+
if (trimmed === "q" || trimmed === "quit") {
|
|
153
|
+
throw new Error("Aborted.");
|
|
154
|
+
}
|
|
155
|
+
const selected = Number.parseInt(trimmed, 10);
|
|
156
|
+
if (Number.isInteger(selected) &&
|
|
157
|
+
selected >= 1 &&
|
|
158
|
+
selected <= candidates.length) {
|
|
159
|
+
const candidate = candidates[selected - 1];
|
|
160
|
+
if (candidate)
|
|
161
|
+
return candidate;
|
|
162
|
+
}
|
|
163
|
+
console.error(`Enter a number from 1 to ${candidates.length}.`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
rl.close();
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
const resolveAsanaDealSlackChannel = async (asanaTaskId) => {
|
|
171
|
+
const result = await api.asana.getDealSlackChannel.query({ id: asanaTaskId });
|
|
172
|
+
if (result.status !== "resolved") {
|
|
173
|
+
throw new Error(result.message);
|
|
174
|
+
}
|
|
175
|
+
console.error(`Resolved Slack channel from Asana deal: #${result.channel.slackChannelName} (${result.channel.slackChannelId})`);
|
|
176
|
+
return result.channel;
|
|
177
|
+
};
|
|
178
|
+
const resolveCreateProjectLinks = async (opts) => {
|
|
179
|
+
const asanaTaskId = opts.asanaTaskId?.trim() ||
|
|
180
|
+
(await promptForAsanaLeadCandidate(await searchOpenAsanaLeadCandidates({
|
|
181
|
+
projectName: opts.name,
|
|
182
|
+
searchQuery: opts.asanaSearch,
|
|
183
|
+
}))).gid;
|
|
184
|
+
const hasCompleteManualSlackChannel = Boolean(opts.slackChannelId && opts.slackChannelUrl && opts.slackChannelName);
|
|
185
|
+
if (hasCompleteManualSlackChannel) {
|
|
186
|
+
return {
|
|
187
|
+
asanaTaskId,
|
|
188
|
+
slackChannelId: opts.slackChannelId,
|
|
189
|
+
slackChannelUrl: opts.slackChannelUrl,
|
|
190
|
+
slackChannelName: opts.slackChannelName,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const slackChannel = await resolveAsanaDealSlackChannel(asanaTaskId);
|
|
194
|
+
return {
|
|
195
|
+
asanaTaskId,
|
|
196
|
+
...slackChannel,
|
|
197
|
+
};
|
|
198
|
+
};
|
|
20
199
|
const combineDateAndTime = (day, time) => {
|
|
21
200
|
const d = new Date(day);
|
|
22
201
|
d.setHours(time.getHours(), time.getMinutes(), time.getSeconds(), 0);
|
|
23
202
|
return d;
|
|
24
203
|
};
|
|
204
|
+
const formatOptionalProjectPaxInput = (pax) => {
|
|
205
|
+
if (typeof pax !== "number" || !Number.isFinite(pax) || pax <= 0) {
|
|
206
|
+
return "";
|
|
207
|
+
}
|
|
208
|
+
return String(pax);
|
|
209
|
+
};
|
|
25
210
|
/** Extension-based MIME, like browser `File.type` when the OS provides it */
|
|
26
211
|
const contentTypeHeaderForFileName = (fileName) => {
|
|
27
212
|
const ct = contentType(fileName);
|
|
28
213
|
return typeof ct === "string" ? ct : "application/octet-stream";
|
|
29
214
|
};
|
|
215
|
+
const LOCKED_BUDGET_STATUSES_REQUIRING_CONFIRMATION = new Set([
|
|
216
|
+
BudgetStatus.ESTIMATE_CREATED,
|
|
217
|
+
BudgetStatus.ESTIMATE_SENT,
|
|
218
|
+
BudgetStatus.ESTIMATE_ACCEPTED,
|
|
219
|
+
BudgetStatus.ESTIMATE_REJECTED,
|
|
220
|
+
BudgetStatus.ESTIMATE_CLOSED,
|
|
221
|
+
]);
|
|
222
|
+
const lockedBudgetStatusLabels = {
|
|
223
|
+
ESTIMATE_CREATED: "Estimate Created",
|
|
224
|
+
ESTIMATE_SENT: "Estimate Sent",
|
|
225
|
+
ESTIMATE_ACCEPTED: "Estimate Accepted",
|
|
226
|
+
ESTIMATE_REJECTED: "Estimate Rejected",
|
|
227
|
+
ESTIMATE_CLOSED: "Estimate Closed",
|
|
228
|
+
};
|
|
229
|
+
const lockedBudgetStatusLabel = (status) => status in lockedBudgetStatusLabels
|
|
230
|
+
? lockedBudgetStatusLabels[status]
|
|
231
|
+
: status;
|
|
232
|
+
const assertSensitiveWorkflowConfirmed = async ({ action, entity, details, }) => {
|
|
233
|
+
const expected = "CONFIRM";
|
|
234
|
+
const detailText = details ? ` ${details}` : "";
|
|
235
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
236
|
+
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.`);
|
|
237
|
+
}
|
|
238
|
+
const rl = createInterface({
|
|
239
|
+
input: process.stdin,
|
|
240
|
+
output: process.stderr,
|
|
241
|
+
});
|
|
242
|
+
try {
|
|
243
|
+
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: `);
|
|
244
|
+
if (answer.trim() !== expected) {
|
|
245
|
+
throw new Error("Aborted.");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
rl.close();
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
const assertLockedBudgetChangeConfirmed = async ({ budget, action, }) => {
|
|
253
|
+
if (!LOCKED_BUDGET_STATUSES_REQUIRING_CONFIRMATION.has(budget.status)) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const statusLabel = lockedBudgetStatusLabel(budget.status);
|
|
257
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
258
|
+
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.`);
|
|
259
|
+
}
|
|
260
|
+
const rl = createInterface({
|
|
261
|
+
input: process.stdin,
|
|
262
|
+
output: process.stderr,
|
|
263
|
+
});
|
|
264
|
+
try {
|
|
265
|
+
const answer = await rl.question(`${action} will change locked budget "${budget.name}" (${budget.id}) with status ${statusLabel}. Type "yes" to continue: `);
|
|
266
|
+
if (answer.trim().toLowerCase() !== "yes") {
|
|
267
|
+
throw new Error("Aborted.");
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
finally {
|
|
271
|
+
rl.close();
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
const confirmLockedBudgetChangeByBudgetId = async (budgetId, action) => {
|
|
275
|
+
const budget = await api.budget.getBudget.query({ id: budgetId });
|
|
276
|
+
await assertLockedBudgetChangeConfirmed({
|
|
277
|
+
budget: {
|
|
278
|
+
id: budget.id,
|
|
279
|
+
name: budget.name,
|
|
280
|
+
status: budget.status,
|
|
281
|
+
},
|
|
282
|
+
action,
|
|
283
|
+
});
|
|
284
|
+
};
|
|
285
|
+
const confirmLockedBudgetChangeByBudgetItemId = async (budgetItemId, action) => {
|
|
286
|
+
const { budget } = await api.budgetItem.getBudgetItemContext.query({
|
|
287
|
+
budgetItemId,
|
|
288
|
+
});
|
|
289
|
+
await assertLockedBudgetChangeConfirmed({ budget, action });
|
|
290
|
+
};
|
|
30
291
|
export async function listBudgets(opts) {
|
|
31
292
|
const input = {
|
|
32
293
|
projectId: opts.projectId,
|
|
@@ -52,9 +313,18 @@ export async function getBudget(id) {
|
|
|
52
313
|
out({ budget, items });
|
|
53
314
|
}
|
|
54
315
|
export async function updateBudgetStatus(budgetId, status, opts) {
|
|
316
|
+
await assertSensitiveWorkflowConfirmed({
|
|
317
|
+
action: "Update budget status",
|
|
318
|
+
entity: `budget ${budgetId}`,
|
|
319
|
+
details: `to ${status}`,
|
|
320
|
+
});
|
|
321
|
+
await confirmLockedBudgetChangeByBudgetId(budgetId, "Update budget status");
|
|
55
322
|
const result = await api.budget.updateBudgetStatus.mutate({
|
|
56
323
|
id: budgetId,
|
|
57
324
|
status,
|
|
325
|
+
...(opts?.projectManagerId !== undefined && {
|
|
326
|
+
projectManagerId: opts.projectManagerId,
|
|
327
|
+
}),
|
|
58
328
|
...(opts?.markProjectWon !== undefined && {
|
|
59
329
|
markProjectWon: opts.markProjectWon,
|
|
60
330
|
}),
|
|
@@ -116,7 +386,7 @@ const runBudgetPreflight = async (budgetId, preflight) => {
|
|
|
116
386
|
insideSalesId: project.insideSalesId ?? "",
|
|
117
387
|
businessDevelopmentId: project.businessDevelopmentId ?? "",
|
|
118
388
|
venue: project.venue ?? "",
|
|
119
|
-
pax:
|
|
389
|
+
pax: formatOptionalProjectPaxInput(project.pax),
|
|
120
390
|
...(project.slackChannelId &&
|
|
121
391
|
project.slackChannelUrl &&
|
|
122
392
|
project.slackChannelName
|
|
@@ -149,6 +419,11 @@ export async function listBills(opts) {
|
|
|
149
419
|
out(result);
|
|
150
420
|
}
|
|
151
421
|
export async function approveBill(billId) {
|
|
422
|
+
await assertSensitiveWorkflowConfirmed({
|
|
423
|
+
action: "Approve bill",
|
|
424
|
+
entity: `bill ${billId}`,
|
|
425
|
+
details: "and send requester reply email",
|
|
426
|
+
});
|
|
152
427
|
const notifications = await api.budget.getNotifications.query();
|
|
153
428
|
const pending = notifications.filter((n) => isPendingRequest(n) &&
|
|
154
429
|
n.type === BILL &&
|
|
@@ -240,6 +515,11 @@ export async function listSuppliers(opts) {
|
|
|
240
515
|
}
|
|
241
516
|
// --- Bills (MCP parity) ---
|
|
242
517
|
export async function createBillApproval(billId) {
|
|
518
|
+
await assertSensitiveWorkflowConfirmed({
|
|
519
|
+
action: "Create bill approval request",
|
|
520
|
+
entity: `bill ${billId}`,
|
|
521
|
+
details: "and send approval request emails",
|
|
522
|
+
});
|
|
243
523
|
const result = await api.bill.createApproval.mutate({ billId });
|
|
244
524
|
const approvalIds = (result.results ?? []).map((item) => ({ id: item.id }));
|
|
245
525
|
let email;
|
|
@@ -265,6 +545,11 @@ export async function createBillApproval(billId) {
|
|
|
265
545
|
});
|
|
266
546
|
}
|
|
267
547
|
export async function updateBillStatus(opts) {
|
|
548
|
+
await assertSensitiveWorkflowConfirmed({
|
|
549
|
+
action: "Update bill status",
|
|
550
|
+
entity: `bill ${opts.id}`,
|
|
551
|
+
details: `to ${opts.status}`,
|
|
552
|
+
});
|
|
268
553
|
const result = await api.bill.updateStatus.mutate({
|
|
269
554
|
id: opts.id,
|
|
270
555
|
status: opts.status,
|
|
@@ -357,6 +642,7 @@ export async function uploadQuotationAttachmentFromPath(projectId, filePath) {
|
|
|
357
642
|
});
|
|
358
643
|
}
|
|
359
644
|
export async function uploadBudgetAttachmentFromPath(budgetId, filePath) {
|
|
645
|
+
await confirmLockedBudgetChangeByBudgetId(budgetId, "Upload budget attachment");
|
|
360
646
|
const buf = await readFile(filePath);
|
|
361
647
|
const fileName = basename(filePath);
|
|
362
648
|
const size = buf.byteLength;
|
|
@@ -382,6 +668,12 @@ export async function uploadBudgetAttachmentFromPath(budgetId, filePath) {
|
|
|
382
668
|
out(confirmed);
|
|
383
669
|
}
|
|
384
670
|
export async function uploadBudgetWinProofFromPath(budgetId, filePath, opts) {
|
|
671
|
+
await assertSensitiveWorkflowConfirmed({
|
|
672
|
+
action: "Mark budget won",
|
|
673
|
+
entity: `budget ${budgetId}`,
|
|
674
|
+
details: "by uploading proof and setting status to ESTIMATE_ACCEPTED",
|
|
675
|
+
});
|
|
676
|
+
await confirmLockedBudgetChangeByBudgetId(budgetId, "Mark budget won");
|
|
385
677
|
const buf = await readFile(filePath);
|
|
386
678
|
const fileName = basename(filePath);
|
|
387
679
|
const size = buf.byteLength;
|
|
@@ -407,6 +699,9 @@ export async function uploadBudgetWinProofFromPath(budgetId, filePath, opts) {
|
|
|
407
699
|
const result = await api.budget.updateBudgetStatus.mutate({
|
|
408
700
|
id: budgetId,
|
|
409
701
|
status: "ESTIMATE_ACCEPTED",
|
|
702
|
+
...(opts?.projectManagerId !== undefined && {
|
|
703
|
+
projectManagerId: opts.projectManagerId,
|
|
704
|
+
}),
|
|
410
705
|
...(opts?.markProjectWon !== undefined && {
|
|
411
706
|
markProjectWon: opts.markProjectWon,
|
|
412
707
|
}),
|
|
@@ -426,10 +721,12 @@ export async function getBudgetItemsOnly(budgetId) {
|
|
|
426
721
|
out(items);
|
|
427
722
|
}
|
|
428
723
|
export async function addBudgetItems(opts) {
|
|
724
|
+
await confirmLockedBudgetChangeByBudgetId(opts.budgetId, "Add budget items");
|
|
429
725
|
const result = await api.budgetItem.addBudgetItems.mutate(opts);
|
|
430
726
|
out(result);
|
|
431
727
|
}
|
|
432
728
|
export async function updateBudgetItem(input) {
|
|
729
|
+
await confirmLockedBudgetChangeByBudgetItemId(input.id, "Update budget item");
|
|
433
730
|
const result = await api.budgetItem.updateBudgetItem.mutate({
|
|
434
731
|
...input,
|
|
435
732
|
...(input.description !== undefined &&
|
|
@@ -444,6 +741,7 @@ export async function updateBudgetItem(input) {
|
|
|
444
741
|
out(result);
|
|
445
742
|
}
|
|
446
743
|
export async function removeBudgetItem(budgetItemId) {
|
|
744
|
+
await confirmLockedBudgetChangeByBudgetItemId(budgetItemId, "Remove budget item");
|
|
447
745
|
const result = await api.budgetItem.removeBudgetItem.mutate({
|
|
448
746
|
budgetItemId,
|
|
449
747
|
});
|
|
@@ -460,6 +758,12 @@ export async function getBudgetVersions(budgetId) {
|
|
|
460
758
|
out(versions);
|
|
461
759
|
}
|
|
462
760
|
export async function createBudgetApproval(budgetId, preflight) {
|
|
761
|
+
await assertSensitiveWorkflowConfirmed({
|
|
762
|
+
action: "Create budget approval request",
|
|
763
|
+
entity: `budget ${budgetId}`,
|
|
764
|
+
details: "and send approval request emails",
|
|
765
|
+
});
|
|
766
|
+
await confirmLockedBudgetChangeByBudgetId(budgetId, "Create budget approval");
|
|
463
767
|
await runBudgetPreflight(budgetId, preflight);
|
|
464
768
|
await assertBudgetActionReadiness({
|
|
465
769
|
budgetId,
|
|
@@ -491,6 +795,7 @@ export async function createBudgetApproval(budgetId, preflight) {
|
|
|
491
795
|
});
|
|
492
796
|
}
|
|
493
797
|
export async function createEstimate(budgetId, preflight) {
|
|
798
|
+
await confirmLockedBudgetChangeByBudgetId(budgetId, "Create estimate");
|
|
494
799
|
await runBudgetPreflight(budgetId, preflight);
|
|
495
800
|
await assertBudgetActionReadiness({
|
|
496
801
|
budgetId,
|
|
@@ -511,10 +816,12 @@ export async function createBudgetFromPayload(raw) {
|
|
|
511
816
|
}
|
|
512
817
|
export async function updateBudgetFromPayload(raw) {
|
|
513
818
|
const input = parseUpdateBudgetPayload(raw);
|
|
819
|
+
await confirmLockedBudgetChangeByBudgetId(input.id, "Update budget");
|
|
514
820
|
const result = await api.budget.updateBudget.mutate(input);
|
|
515
821
|
out(result);
|
|
516
822
|
}
|
|
517
823
|
export async function deleteBudgetById(budgetId) {
|
|
824
|
+
await confirmLockedBudgetChangeByBudgetId(budgetId, "Delete budget");
|
|
518
825
|
const result = await api.budget.deleteBudget.mutate({ id: budgetId });
|
|
519
826
|
out(result);
|
|
520
827
|
}
|
|
@@ -527,10 +834,12 @@ export async function deleteCompanyById(companyId) {
|
|
|
527
834
|
out(result);
|
|
528
835
|
}
|
|
529
836
|
export async function reorderBudgetItemsCli(opts) {
|
|
837
|
+
await confirmLockedBudgetChangeByBudgetId(opts.budgetId, "Reorder budget items");
|
|
530
838
|
const result = await api.budgetItem.reorderBudgetItems.mutate(opts);
|
|
531
839
|
out(result);
|
|
532
840
|
}
|
|
533
841
|
export async function updateBudgetItemSupplierCli(opts) {
|
|
842
|
+
await confirmLockedBudgetChangeByBudgetItemId(opts.budgetItemId, "Update budget item supplier");
|
|
534
843
|
const result = await api.budgetItem.updateBudgetItemSupplier.mutate(opts);
|
|
535
844
|
out(result);
|
|
536
845
|
}
|
|
@@ -548,11 +857,13 @@ export async function deleteBudgetCategory(id) {
|
|
|
548
857
|
}
|
|
549
858
|
export async function updateBudgetCommissionFromPayload(raw) {
|
|
550
859
|
const input = parseUpdateBudgetCommissionPayload(raw);
|
|
860
|
+
await confirmLockedBudgetChangeByBudgetId(input.budgetId, "Update budget commission");
|
|
551
861
|
const result = await api.budget.updateBudgetCommission.mutate(input);
|
|
552
862
|
out(result);
|
|
553
863
|
}
|
|
554
864
|
export async function updateBudgetDiscountFromPayload(raw) {
|
|
555
865
|
const input = parseBudgetDiscountPayload(raw);
|
|
866
|
+
await confirmLockedBudgetChangeByBudgetId(input.budgetId, "Update budget discount");
|
|
556
867
|
const result = await api.budget.updateBudgetDiscount.mutate(input);
|
|
557
868
|
out(result);
|
|
558
869
|
}
|
|
@@ -614,6 +925,10 @@ export async function createQuotationFromPayload(raw) {
|
|
|
614
925
|
out(result);
|
|
615
926
|
}
|
|
616
927
|
export async function submitQuotation(id) {
|
|
928
|
+
await assertSensitiveWorkflowConfirmed({
|
|
929
|
+
action: "Submit quotation for approval",
|
|
930
|
+
entity: `quotation ${id}`,
|
|
931
|
+
});
|
|
617
932
|
const result = await api.quotation.submitForApproval.mutate({ id });
|
|
618
933
|
out(result);
|
|
619
934
|
}
|
|
@@ -641,6 +956,11 @@ export async function createSupplierFromPayload(raw) {
|
|
|
641
956
|
out(created);
|
|
642
957
|
return;
|
|
643
958
|
}
|
|
959
|
+
await assertSensitiveWorkflowConfirmed({
|
|
960
|
+
action: "Create supplier approval request",
|
|
961
|
+
entity: `supplier ${supplier.id}`,
|
|
962
|
+
details: "and send approval request emails",
|
|
963
|
+
});
|
|
644
964
|
const approval = await api.supplier.createSupplierApproval.mutate({
|
|
645
965
|
supplierId: supplier.id,
|
|
646
966
|
});
|
|
@@ -679,6 +999,11 @@ export async function updateSupplierFromPayload(raw) {
|
|
|
679
999
|
out(updated);
|
|
680
1000
|
return;
|
|
681
1001
|
}
|
|
1002
|
+
await assertSensitiveWorkflowConfirmed({
|
|
1003
|
+
action: "Create supplier approval request",
|
|
1004
|
+
entity: `supplier ${supplier.id}`,
|
|
1005
|
+
details: "and send approval request emails",
|
|
1006
|
+
});
|
|
682
1007
|
let approvalResult;
|
|
683
1008
|
try {
|
|
684
1009
|
const approval = await api.supplier.createSupplierApproval.mutate({
|
|
@@ -759,6 +1084,11 @@ export async function updateCompanyFromPayload(raw) {
|
|
|
759
1084
|
}
|
|
760
1085
|
// --- Approvals: budgets / suppliers / bills (MCP parity) ---
|
|
761
1086
|
export async function approveBudget(budgetId) {
|
|
1087
|
+
await assertSensitiveWorkflowConfirmed({
|
|
1088
|
+
action: "Approve budget",
|
|
1089
|
+
entity: `budget ${budgetId}`,
|
|
1090
|
+
details: "and send requester reply email",
|
|
1091
|
+
});
|
|
762
1092
|
const notifications = await api.budget.getNotifications.query();
|
|
763
1093
|
const pending = notifications.filter((n) => n.notificationType === "request" &&
|
|
764
1094
|
n.status === ExtendedApprovalStatus.PENDING_APPROVAL &&
|
|
@@ -790,6 +1120,11 @@ export async function approveBudget(budgetId) {
|
|
|
790
1120
|
});
|
|
791
1121
|
}
|
|
792
1122
|
export async function rejectBudget(budgetId, reason) {
|
|
1123
|
+
await assertSensitiveWorkflowConfirmed({
|
|
1124
|
+
action: "Reject budget",
|
|
1125
|
+
entity: `budget ${budgetId}`,
|
|
1126
|
+
details: `with reason "${reason}" and send requester reply email`,
|
|
1127
|
+
});
|
|
793
1128
|
const notifications = await api.budget.getNotifications.query();
|
|
794
1129
|
const pending = notifications.filter((n) => n.notificationType === "request" &&
|
|
795
1130
|
n.status === ExtendedApprovalStatus.PENDING_APPROVAL &&
|
|
@@ -883,6 +1218,11 @@ export async function getSupplierDetails(supplierId) {
|
|
|
883
1218
|
});
|
|
884
1219
|
}
|
|
885
1220
|
export async function approveSupplier(supplierId) {
|
|
1221
|
+
await assertSensitiveWorkflowConfirmed({
|
|
1222
|
+
action: "Approve supplier",
|
|
1223
|
+
entity: `supplier ${supplierId}`,
|
|
1224
|
+
details: "and send requester reply email",
|
|
1225
|
+
});
|
|
886
1226
|
const notifications = await api.budget.getNotifications.query();
|
|
887
1227
|
const pending = notifications.filter((n) => n.notificationType === "request" &&
|
|
888
1228
|
n.status === ExtendedApprovalStatus.PENDING_APPROVAL &&
|
|
@@ -916,6 +1256,11 @@ export async function approveSupplier(supplierId) {
|
|
|
916
1256
|
});
|
|
917
1257
|
}
|
|
918
1258
|
export async function rejectSupplier(supplierId, reason) {
|
|
1259
|
+
await assertSensitiveWorkflowConfirmed({
|
|
1260
|
+
action: "Reject supplier",
|
|
1261
|
+
entity: `supplier ${supplierId}`,
|
|
1262
|
+
details: `with reason "${reason}" and send requester reply email`,
|
|
1263
|
+
});
|
|
919
1264
|
const notifications = await api.budget.getNotifications.query();
|
|
920
1265
|
const pending = notifications.filter((n) => n.notificationType === "request" &&
|
|
921
1266
|
n.status === ExtendedApprovalStatus.PENDING_APPROVAL &&
|
|
@@ -950,6 +1295,11 @@ export async function rejectSupplier(supplierId, reason) {
|
|
|
950
1295
|
});
|
|
951
1296
|
}
|
|
952
1297
|
export async function rejectBill(billId, reason) {
|
|
1298
|
+
await assertSensitiveWorkflowConfirmed({
|
|
1299
|
+
action: "Reject bill",
|
|
1300
|
+
entity: `bill ${billId}`,
|
|
1301
|
+
details: `with reason "${reason}" and send requester reply email`,
|
|
1302
|
+
});
|
|
953
1303
|
const notifications = await api.budget.getNotifications.query();
|
|
954
1304
|
const pending = notifications.filter((n) => n.notificationType === "request" &&
|
|
955
1305
|
n.status === ExtendedApprovalStatus.PENDING_APPROVAL &&
|
|
@@ -981,6 +1331,10 @@ export async function rejectBill(billId, reason) {
|
|
|
981
1331
|
});
|
|
982
1332
|
}
|
|
983
1333
|
export async function approveQuotation(quotationId) {
|
|
1334
|
+
await assertSensitiveWorkflowConfirmed({
|
|
1335
|
+
action: "Approve quotation",
|
|
1336
|
+
entity: `quotation ${quotationId}`,
|
|
1337
|
+
});
|
|
984
1338
|
const notifications = await api.budget.getNotifications.query();
|
|
985
1339
|
const pending = notifications.filter((n) => isPendingRequest(n) &&
|
|
986
1340
|
n.type === QUOTATION &&
|
|
@@ -996,6 +1350,11 @@ export async function approveQuotation(quotationId) {
|
|
|
996
1350
|
out(result);
|
|
997
1351
|
}
|
|
998
1352
|
export async function rejectQuotation(quotationId, reason) {
|
|
1353
|
+
await assertSensitiveWorkflowConfirmed({
|
|
1354
|
+
action: "Reject quotation",
|
|
1355
|
+
entity: `quotation ${quotationId}`,
|
|
1356
|
+
details: `with reason "${reason}"`,
|
|
1357
|
+
});
|
|
999
1358
|
const notifications = await api.budget.getNotifications.query();
|
|
1000
1359
|
const pending = notifications.filter((n) => isPendingRequest(n) &&
|
|
1001
1360
|
n.type === QUOTATION &&
|
|
@@ -1050,6 +1409,14 @@ export async function getProject(id) {
|
|
|
1050
1409
|
out({ project, budgetsOverview, billsOverview });
|
|
1051
1410
|
}
|
|
1052
1411
|
export async function createProject(opts) {
|
|
1412
|
+
const links = await resolveCreateProjectLinks({
|
|
1413
|
+
name: opts.name,
|
|
1414
|
+
asanaTaskId: opts.asanaTaskId,
|
|
1415
|
+
asanaSearch: opts.asanaSearch,
|
|
1416
|
+
slackChannelId: opts.slackChannelId,
|
|
1417
|
+
slackChannelUrl: opts.slackChannelUrl,
|
|
1418
|
+
slackChannelName: opts.slackChannelName,
|
|
1419
|
+
});
|
|
1053
1420
|
const result = await api.project.createProject.mutate({
|
|
1054
1421
|
name: opts.name,
|
|
1055
1422
|
description: opts.description,
|
|
@@ -1059,21 +1426,23 @@ export async function createProject(opts) {
|
|
|
1059
1426
|
businessDevelopmentId: opts.businessDevelopmentId,
|
|
1060
1427
|
venue: opts.venue,
|
|
1061
1428
|
pax: opts.pax,
|
|
1062
|
-
asanaTaskId:
|
|
1063
|
-
slackChannelId:
|
|
1064
|
-
slackChannelUrl:
|
|
1065
|
-
slackChannelName:
|
|
1429
|
+
asanaTaskId: links.asanaTaskId,
|
|
1430
|
+
slackChannelId: links.slackChannelId,
|
|
1431
|
+
slackChannelUrl: links.slackChannelUrl,
|
|
1432
|
+
slackChannelName: links.slackChannelName,
|
|
1066
1433
|
dateRange: {
|
|
1067
1434
|
from: new Date(opts.startDate),
|
|
1068
1435
|
to: opts.endDate ? new Date(opts.endDate) : undefined,
|
|
1069
1436
|
},
|
|
1070
|
-
...(opts.requestQboAccountantNotification !== undefined && {
|
|
1071
|
-
requestQboAccountantNotification: opts.requestQboAccountantNotification,
|
|
1072
|
-
}),
|
|
1073
1437
|
});
|
|
1074
1438
|
out(result);
|
|
1075
1439
|
}
|
|
1076
1440
|
export async function updateProjectStatus(id, status, opts) {
|
|
1441
|
+
await assertSensitiveWorkflowConfirmed({
|
|
1442
|
+
action: "Update project status",
|
|
1443
|
+
entity: `project ${id}`,
|
|
1444
|
+
details: `to ${status}`,
|
|
1445
|
+
});
|
|
1077
1446
|
const result = await api.project.updateProjectStatus.mutate({
|
|
1078
1447
|
id,
|
|
1079
1448
|
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
|
|
@@ -126,11 +133,12 @@ Budgets
|
|
|
126
133
|
get-budget-details <id> Full detail (margins, suppliers, etc.)
|
|
127
134
|
get-budget-categories
|
|
128
135
|
get-budget-versions <budgetId>
|
|
129
|
-
update-budget-status <id> <status> [--markProjectWon true|false] [--projectStatusOnCommercialRejection PITCH|LOST]
|
|
130
|
-
|
|
136
|
+
update-budget-status <id> <status> [--projectManagerId <id>] [--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.
|
|
138
|
+
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.
|
|
131
139
|
ESTIMATE_REJECTED requires --projectStatusOnCommercialRejection when rejecting the only accepted/closed budget on a commercial project.
|
|
132
140
|
status: ${budgetStatusesForHelp.join(", ")}
|
|
133
|
-
mark-budget-won <budgetId> <filePath> [--markProjectWon true|false] Upload signed quote/PO proof and set status to ESTIMATE_ACCEPTED; PITCH or LOST projects become WON automatically
|
|
141
|
+
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
|
|
134
142
|
create-budget --payload '<json>' (budget.createBudget; Asana deal card is on the project)
|
|
135
143
|
update-budget --payload '<json>' (budget.updateBudget; must include id)
|
|
136
144
|
delete-budget <budgetId>
|
|
@@ -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
|
|
206
|
+
create-project --name --companyId --contactPersonId --insideSalesId --businessDevelopmentId --venue --startDate <ISO> [--asanaTaskId] [--asanaSearch <text>] [--slackChannelId --slackChannelUrl --slackChannelName] [--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>]
|
|
@@ -328,6 +336,9 @@ async function main() {
|
|
|
328
336
|
throw new Error("mark-budget-won requires <budgetId> <filePath>");
|
|
329
337
|
}
|
|
330
338
|
await markBudgetWonWithProof(budgetId, filePath, {
|
|
339
|
+
projectManagerId: getFlag(flags, "projectManagerId") !== undefined
|
|
340
|
+
? String(getFlag(flags, "projectManagerId"))
|
|
341
|
+
: undefined,
|
|
331
342
|
markProjectWon: parseOptionalBoolFlag(flags, "markProjectWon"),
|
|
332
343
|
});
|
|
333
344
|
break;
|
|
@@ -366,6 +377,9 @@ async function main() {
|
|
|
366
377
|
throw new Error(`Invalid status. Use one of: ${budgetStatusesForHelp.join(", ")}`);
|
|
367
378
|
}
|
|
368
379
|
await updateBudgetStatus(id, status, {
|
|
380
|
+
projectManagerId: getFlag(flags, "projectManagerId") !== undefined
|
|
381
|
+
? String(getFlag(flags, "projectManagerId"))
|
|
382
|
+
: undefined,
|
|
369
383
|
markProjectWon: parseOptionalBoolFlag(flags, "markProjectWon"),
|
|
370
384
|
projectStatusOnCommercialRejection: parseCommercialRejectionProjectStatus(flags),
|
|
371
385
|
});
|
|
@@ -900,22 +914,22 @@ async function main() {
|
|
|
900
914
|
const venue = getFlag(flags, "venue");
|
|
901
915
|
const pax = getFlag(flags, "pax");
|
|
902
916
|
const asanaTaskId = getFlag(flags, "asanaTaskId");
|
|
917
|
+
const asanaSearch = getFlag(flags, "asanaSearch");
|
|
903
918
|
const slackChannelId = getFlag(flags, "slackChannelId");
|
|
904
919
|
const slackChannelUrl = getFlag(flags, "slackChannelUrl");
|
|
905
920
|
const slackChannelName = getFlag(flags, "slackChannelName");
|
|
906
921
|
const startDate = getFlag(flags, "startDate");
|
|
922
|
+
if (getFlag(flags, "requestQboAccountantNotification") !== undefined) {
|
|
923
|
+
throw new Error("create-project does not allow --requestQboAccountantNotification; CLI project creation always requests the QuickBooks project import like the web app.");
|
|
924
|
+
}
|
|
907
925
|
if (!name ||
|
|
908
926
|
!companyId ||
|
|
909
927
|
!contactPersonId ||
|
|
910
928
|
!insideSalesId ||
|
|
911
929
|
!businessDevelopmentId ||
|
|
912
930
|
!venue ||
|
|
913
|
-
!asanaTaskId ||
|
|
914
|
-
!slackChannelId ||
|
|
915
|
-
!slackChannelUrl ||
|
|
916
|
-
!slackChannelName ||
|
|
917
931
|
!startDate) {
|
|
918
|
-
throw new Error("create-project requires --name --companyId --contactPersonId --insideSalesId --businessDevelopmentId --venue --
|
|
932
|
+
throw new Error("create-project requires --name --companyId --contactPersonId --insideSalesId --businessDevelopmentId --venue --startDate (ISO/datetime flags as appropriate); optional --asanaTaskId, --asanaSearch, --pax and --endDate");
|
|
919
933
|
}
|
|
920
934
|
await createProject({
|
|
921
935
|
name,
|
|
@@ -926,13 +940,13 @@ async function main() {
|
|
|
926
940
|
venue,
|
|
927
941
|
pax,
|
|
928
942
|
asanaTaskId,
|
|
943
|
+
asanaSearch,
|
|
929
944
|
slackChannelId,
|
|
930
945
|
slackChannelUrl,
|
|
931
946
|
slackChannelName,
|
|
932
947
|
startDate,
|
|
933
948
|
endDate: getFlag(flags, "endDate"),
|
|
934
949
|
description: getFlag(flags, "description"),
|
|
935
|
-
requestQboAccountantNotification: parseOptionalBoolFlag(flags, "requestQboAccountantNotification"),
|
|
936
950
|
});
|
|
937
951
|
break;
|
|
938
952
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@go-labs-sg/bb",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.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",
|