@tenderlead/cli 0.4.3 → 0.5.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 +97 -0
- package/bin/tenderlead.js +131 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -200,3 +200,100 @@ Provider acceptance and API responses do not prove email inbox delivery.
|
|
|
200
200
|
## Licence
|
|
201
201
|
|
|
202
202
|
MIT
|
|
203
|
+
|
|
204
|
+
## Multi-company pilot
|
|
205
|
+
|
|
206
|
+
The 0.5.0 candidate adds explicit company commands for enabled portfolio pilot
|
|
207
|
+
accounts. These commands require the shared company API to be deployed. Standard
|
|
208
|
+
Pro remains one company profile; Team access is agreed separately. Existing
|
|
209
|
+
single-profile commands keep their existing behaviour.
|
|
210
|
+
|
|
211
|
+
Start with the saved company IDs. A company name is never a selector:
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
tenderlead companies list --json
|
|
215
|
+
tenderlead companies show COMPANY_UUID --json
|
|
216
|
+
tenderlead companies brief COMPANY_UUID --json
|
|
217
|
+
tenderlead companies matches COMPANY_UUID --json
|
|
218
|
+
tenderlead companies pipeline COMPANY_UUID --json
|
|
219
|
+
tenderlead companies matches all --json
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`list` returns company metadata; `show COMPANY_UUID` returns that company's
|
|
223
|
+
full saved profile. `brief`, `matches` and `pipeline` return one page only:
|
|
224
|
+
10 items by default, with `--page-size 1` to `--page-size 25` supported.
|
|
225
|
+
Human output includes the total, whether more items exist, the next page and
|
|
226
|
+
snapshot token, plus a continuation command. `--json` preserves the same
|
|
227
|
+
`pagination` metadata unchanged. No command automatically fetches later pages.
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
tenderlead companies matches all --page-size 10 --json
|
|
231
|
+
# Use the returned pagination.snapshot for the next page:
|
|
232
|
+
tenderlead companies matches all --page 2 --page-size 10 --snapshot SNAPSHOT_TOKEN --json
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`--page`, `--page-size` and `--snapshot` apply only to those three read commands.
|
|
236
|
+
Page numbers start at 1. Pages after the first require the previous response's
|
|
237
|
+
64-character snapshot token and the same company selector, operation and page
|
|
238
|
+
size. If the server reports a changed snapshot (HTTP 409), restart at page 1
|
|
239
|
+
without `--snapshot`; do not combine pages from different snapshots. The CLI
|
|
240
|
+
reports the conflict and never retries or restarts automatically.
|
|
241
|
+
|
|
242
|
+
`all` is allowed only for `brief`, `matches` and `pipeline` reads. Combined
|
|
243
|
+
responses retain company IDs and names. Reads show saved results, revisions and
|
|
244
|
+
source state; they do not start matching or send alerts. `brief` is a saved
|
|
245
|
+
company brief, not proof of email delivery.
|
|
246
|
+
|
|
247
|
+
Create a token with the explicit company scopes. Existing tokens gain no scopes
|
|
248
|
+
automatically:
|
|
249
|
+
|
|
250
|
+
| Command | Required scopes |
|
|
251
|
+
| --- | --- |
|
|
252
|
+
| `list`, `show` | `companies:read`, `profile:read` |
|
|
253
|
+
| `brief` | `companies:read`, `briefs:read` |
|
|
254
|
+
| `matches` | `companies:read`, `tenders:read` |
|
|
255
|
+
| `pipeline` | `companies:read`, `pipeline:read` |
|
|
256
|
+
| `apply` | `companies:write`, `profile:write` |
|
|
257
|
+
| `save`, `update` | `companies:write`, `pipeline:write` |
|
|
258
|
+
| `review` | `companies:write`, `feedback:write` |
|
|
259
|
+
|
|
260
|
+
Writes first read the selected company for an identity check, so also retain
|
|
261
|
+
`companies:read` and `profile:read`. Review the saved company, notice or pipeline
|
|
262
|
+
record before preparing the JSON. Each write requires typed confirmation, or
|
|
263
|
+
`--yes` after review:
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
tenderlead companies apply COMPANY_UUID --file company-changes.json
|
|
267
|
+
tenderlead companies save COMPANY_UUID --file save-notice.json
|
|
268
|
+
tenderlead companies update COMPANY_UUID ITEM_UUID --file pipeline-changes.json
|
|
269
|
+
tenderlead companies review COMPANY_UUID --file notice-review.json
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
`apply` accepts a flat partial company profile with the saved UUID
|
|
273
|
+
`expectedRevision`. Company fields follow the portfolio schema: `name`,
|
|
274
|
+
`websites`, `capabilities`, `keywords`, `industries`, `exclusions`, `limitations`,
|
|
275
|
+
`legal_name`, `company_number`, `team_size`, `specialisms`, `region`, `size_min`,
|
|
276
|
+
`size_max`, `frameworks`, `clearances`, `no_bid_rules`, `past_work` and `geography`.
|
|
277
|
+
This schema differs from the single-profile `profile apply` schema.
|
|
278
|
+
|
|
279
|
+
`save` requires `noticeKey`, UUID `expectedProfileRevision` and the exact
|
|
280
|
+
64-character `expectedSourceFingerprint` from the saved company notice. An
|
|
281
|
+
optional `legacyId` must be a UUID for an existing legacy record. It does not
|
|
282
|
+
create another company or run an assessment.
|
|
283
|
+
|
|
284
|
+
`update` requires UUID `expectedRevision` from the saved pipeline item plus
|
|
285
|
+
`stage` or `notes`. Stages are `watching`, `preparing`, `submitted`, `won` and
|
|
286
|
+
`lost`. Notes accept up to 10,000 characters.
|
|
287
|
+
|
|
288
|
+
`review` requires `noticeKey`, UUID `expectedProfileRevision`,
|
|
289
|
+
`expectedSourceFingerprint`, `decision`, `rationale` and `expectedRevision`.
|
|
290
|
+
Use `expectedRevision: null` for a first review, or the saved review UUID for an
|
|
291
|
+
update. Optional canonical fields are `sourceQuote`, `capability`, `nextAction`
|
|
292
|
+
and `gaps`; the server validates the decision and supporting source evidence.
|
|
293
|
+
|
|
294
|
+
All writes use the company UUID in the command. JSON must not contain an owner,
|
|
295
|
+
account or profile override, `notify`, `monitoring` or `archived`. Company
|
|
296
|
+
creation, deletion, archiving, alert preferences and refreshing are not exposed
|
|
297
|
+
by these commands. On a conflict or uncertain request outcome, reload the
|
|
298
|
+
company and affected record before deciding whether to retry. The CLI never
|
|
299
|
+
automatically retries a write.
|
package/bin/tenderlead.js
CHANGED
|
@@ -17,6 +17,11 @@ function usage() {
|
|
|
17
17
|
return [
|
|
18
18
|
'Usage:',
|
|
19
19
|
' tenderlead login',
|
|
20
|
+
' tenderlead companies list [--json]',
|
|
21
|
+
' tenderlead companies show <company-id> [--json]',
|
|
22
|
+
' tenderlead companies brief|matches|pipeline <company-id|all> [--page <N>] [--page-size <1-25>] [--snapshot <token>] [--json]',
|
|
23
|
+
' tenderlead companies apply|save|review <company-id> --file <path> [--yes] [--json]',
|
|
24
|
+
' tenderlead companies update <company-id> <item-id> --file <path> [--yes] [--json]',
|
|
20
25
|
' tenderlead whoami [--json]',
|
|
21
26
|
' tenderlead account status [--json]',
|
|
22
27
|
' tenderlead brief [--json]',
|
|
@@ -118,6 +123,7 @@ function parseArgs(argv) {
|
|
|
118
123
|
}
|
|
119
124
|
return options;
|
|
120
125
|
}
|
|
126
|
+
if (command === 'companies') return parseCompanyArgs(argv);
|
|
121
127
|
if (command === 'pipeline') return parsePipelineArgs(argv);
|
|
122
128
|
if (command === 'insights') return parseJsonOnlyArgs(argv, 3, { command: 'insights', json: false });
|
|
123
129
|
if (command === 'feedback') return parseFeedbackArgs(argv);
|
|
@@ -150,6 +156,124 @@ function parseArgs(argv) {
|
|
|
150
156
|
return options;
|
|
151
157
|
}
|
|
152
158
|
|
|
159
|
+
const COMPANY_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
160
|
+
const COMPANY_PAGED_READS = ['brief', 'matches', 'pipeline'];
|
|
161
|
+
const COMPANY_WRITES = ['apply', 'save', 'update', 'review'];
|
|
162
|
+
function companyId(id, allowAll = false) {
|
|
163
|
+
if ((allowAll && id === 'all') || (typeof id === 'string' && COMPANY_UUID.test(id))) return id;
|
|
164
|
+
throw new Error(`Use an explicit company UUID${allowAll ? ' or all for this read' : ''}. Run companies list for IDs.`);
|
|
165
|
+
}
|
|
166
|
+
function parseCompanyArgs(argv) {
|
|
167
|
+
const action = argv[3];
|
|
168
|
+
if (!['list', 'show', 'brief', 'matches', 'pipeline', ...COMPANY_WRITES].includes(action)) throw new Error('Use companies list, show, brief, matches, pipeline, apply, save, update or review.');
|
|
169
|
+
const write = COMPANY_WRITES.includes(action);
|
|
170
|
+
const paged = COMPANY_PAGED_READS.includes(action);
|
|
171
|
+
const options = { command: 'companies', action, json: false, yes: false, file: null };
|
|
172
|
+
let start = 4;
|
|
173
|
+
if (action !== 'list') { options.id = companyId(argv[start++], ['brief', 'matches', 'pipeline'].includes(action)); }
|
|
174
|
+
if (action === 'update') { options.itemId = companyId(argv[start++]); }
|
|
175
|
+
for (let index = start; index < argv.length; index += 1) {
|
|
176
|
+
const arg = argv[index];
|
|
177
|
+
if (arg === '--json') options.json = true;
|
|
178
|
+
else if (paged && ['--page', '--page-size', '--snapshot'].includes(arg)) {
|
|
179
|
+
const key = { '--page': 'page', '--page-size': 'pageSize', '--snapshot': 'snapshot' }[arg];
|
|
180
|
+
if (options[key] !== undefined) throw new Error(`Duplicate argument: ${arg}`);
|
|
181
|
+
options[key] = optionValue(argv, index, arg); index += 1;
|
|
182
|
+
}
|
|
183
|
+
else if (write && arg === '--yes') options.yes = true;
|
|
184
|
+
else if (write && arg === '--file' && !options.file) { options.file = optionValue(argv, index, arg); index += 1; }
|
|
185
|
+
else throw new Error(`Unknown argument: ${arg}`);
|
|
186
|
+
}
|
|
187
|
+
if (paged) Object.assign(options, companyPagination(options));
|
|
188
|
+
if (write && !options.file) throw new Error(`companies ${action} requires --file <path>`);
|
|
189
|
+
return options;
|
|
190
|
+
}
|
|
191
|
+
function companyPagination(options) {
|
|
192
|
+
const pageText = String(options.page ?? 1), sizeText = String(options.pageSize ?? 10);
|
|
193
|
+
if (!/^[1-9][0-9]*$/.test(pageText) || !Number.isSafeInteger(Number(pageText))) throw new Error('--page must be a positive integer.');
|
|
194
|
+
if (!/^[1-9][0-9]*$/.test(sizeText) || Number(sizeText) > 25) throw new Error('--page-size must be an integer from 1 to 25.');
|
|
195
|
+
const snapshot = options.snapshot;
|
|
196
|
+
if (snapshot !== undefined && (typeof snapshot !== 'string' || !/^[0-9a-f]{64}$/.test(snapshot))) throw new Error('--snapshot must be the saved 64-character snapshot token.');
|
|
197
|
+
if (Number(pageText) > 1 && !snapshot) throw new Error('--page greater than 1 requires --snapshot from the previous response.');
|
|
198
|
+
return { page: Number(pageText), pageSize: Number(sizeText), ...(snapshot ? { snapshot } : {}) };
|
|
199
|
+
}
|
|
200
|
+
function validateCompanyBody(action, body) {
|
|
201
|
+
if (!isPlainObject(body)) throw new Error('Company JSON must be an object.');
|
|
202
|
+
const fields = {
|
|
203
|
+
apply: ['expectedRevision', 'name', 'websites', 'capabilities', 'keywords', 'industries', 'exclusions', 'limitations', 'legal_name', 'company_number', 'team_size', 'specialisms', 'region', 'size_min', 'size_max', 'frameworks', 'clearances', 'no_bid_rules', 'past_work', 'geography'],
|
|
204
|
+
save: ['noticeKey', 'expectedProfileRevision', 'expectedSourceFingerprint', 'legacyId'],
|
|
205
|
+
update: ['expectedRevision', 'stage', 'notes'],
|
|
206
|
+
review: ['noticeKey', 'expectedProfileRevision', 'expectedSourceFingerprint', 'expectedRevision', 'decision', 'rationale', 'sourceQuote', 'capability', 'nextAction', 'gaps'],
|
|
207
|
+
}[action];
|
|
208
|
+
if (!fields || Object.keys(body).some(key => !fields.includes(key))) throw new Error('Unsupported company field. Account/profile overrides, alerts and monitoring changes are not accepted.');
|
|
209
|
+
const uuid = key => { if (typeof body[key] !== 'string' || !COMPANY_UUID.test(body[key])) throw new Error(`${key} must be the saved UUID revision.`); };
|
|
210
|
+
if (['apply', 'update'].includes(action)) uuid('expectedRevision');
|
|
211
|
+
if (['save', 'review'].includes(action)) {
|
|
212
|
+
uuid('expectedProfileRevision');
|
|
213
|
+
if (typeof body.noticeKey !== 'string' || !body.noticeKey || body.noticeKey.length > 1000) throw new Error('noticeKey must identify a saved company notice.');
|
|
214
|
+
if (typeof body.expectedSourceFingerprint !== 'string' || !/^[0-9a-f]{64}$/.test(body.expectedSourceFingerprint)) throw new Error('expectedSourceFingerprint must be the saved source fingerprint.');
|
|
215
|
+
}
|
|
216
|
+
if (action === 'save' && body.legacyId !== undefined) uuid('legacyId');
|
|
217
|
+
if (action === 'review' && body.expectedRevision !== null) uuid('expectedRevision');
|
|
218
|
+
if (action === 'update') {
|
|
219
|
+
if (!('stage' in body) && !('notes' in body)) throw new Error('Include stage or notes.');
|
|
220
|
+
if ('stage' in body && !['watching', 'preparing', 'submitted', 'won', 'lost'].includes(body.stage)) throw new Error('Use a valid pipeline stage.');
|
|
221
|
+
if ('notes' in body && (typeof body.notes !== 'string' || body.notes.length > 10000)) throw new Error('notes must be text of at most 10,000 characters.');
|
|
222
|
+
}
|
|
223
|
+
return body;
|
|
224
|
+
}
|
|
225
|
+
async function companyRequest(options, body, fetchImpl = fetch) {
|
|
226
|
+
const { action } = options;
|
|
227
|
+
const id = action === 'list' ? null : companyId(options.id, ['brief', 'matches', 'pipeline'].includes(action));
|
|
228
|
+
const base = '/v1/companies' + (id ? `/${encodeURIComponent(id)}` : '');
|
|
229
|
+
if (!COMPANY_WRITES.includes(action)) {
|
|
230
|
+
const paged = COMPANY_PAGED_READS.includes(action);
|
|
231
|
+
const params = paged ? companyPagination(options) : null;
|
|
232
|
+
const query = params ? new URLSearchParams({ page: String(params.page), page_size: String(params.pageSize), ...(params.snapshot ? { snapshot: params.snapshot } : {}) }) : null;
|
|
233
|
+
try { return await apiRequest(base + (['list', 'show'].includes(action) ? '' : `/${action}`) + (query ? `?${query}` : ''), {}, fetchImpl); }
|
|
234
|
+
catch (error) {
|
|
235
|
+
if (paged && error.status === 409) throw new Error(`${error.message} Restart at --page 1 without --snapshot; do not reuse the previous pages.`);
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
validateCompanyBody(action, body);
|
|
240
|
+
const suffix = action === 'apply' ? '' : action === 'review' ? '/reviews' : '/pipeline' + (action === 'update' ? `/${companyId(options.itemId)}` : '');
|
|
241
|
+
return apiRequest(base + suffix, { method: ['apply', 'update'].includes(action) ? 'PATCH' : 'POST', body: JSON.stringify(body) }, fetchImpl);
|
|
242
|
+
}
|
|
243
|
+
function companyText(value, fallback = '') {
|
|
244
|
+
return line(String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f]/g, ' '), fallback);
|
|
245
|
+
}
|
|
246
|
+
function formatCompanies(payload, options) {
|
|
247
|
+
const profiles = payload.profiles || (payload.profile ? [payload.profile] : []);
|
|
248
|
+
const heading = options.action === 'list' ? '# TenderLead companies' : `# Company ${companyText(options.id)} · ${companyText(options.action)}`;
|
|
249
|
+
const lines = [heading];
|
|
250
|
+
for (const profile of profiles) lines.push(`Company: ${companyText(profile.name, 'Unnamed')} · ${companyText(profile.id, 'unknown')} · revision ${companyText(profile.revision, 'unknown')}`);
|
|
251
|
+
// Retain every saved field, source check and company label in text output too.
|
|
252
|
+
lines.push(JSON.stringify(payload, null, 2));
|
|
253
|
+
if (payload.pagination) {
|
|
254
|
+
const page = payload.pagination;
|
|
255
|
+
lines.push(`Page: ${companyText(page.page)} · Total: ${companyText(page.total_count)} · Has more: ${Boolean(page.has_more)} · Next page: ${companyText(page.next_page, 'none')} · Snapshot: ${companyText(page.snapshot)}`);
|
|
256
|
+
if (page.has_more && Number.isSafeInteger(page.next_page) && page.next_page > 1 && /^[0-9a-f]{64}$/.test(page.snapshot) && COMPANY_PAGED_READS.includes(options.action)) {
|
|
257
|
+
lines.push(`Continue: tenderlead companies ${options.action} ${companyId(options.id, true)} --page ${page.next_page} --page-size ${companyPagination({ pageSize: page.page_size }).pageSize} --snapshot ${page.snapshot}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return lines.join('\n');
|
|
261
|
+
}
|
|
262
|
+
async function runCompanyCommand(options, dependencies = {}) {
|
|
263
|
+
const fetchImpl = dependencies.fetch || fetch;
|
|
264
|
+
if (!COMPANY_WRITES.includes(options.action)) return companyRequest(options, undefined, fetchImpl);
|
|
265
|
+
companyId(options.id);
|
|
266
|
+
if (options.action === 'update') companyId(options.itemId);
|
|
267
|
+
const body = validateCompanyBody(options.action, (dependencies.read || readJsonFile)(options.file));
|
|
268
|
+
apiToken();
|
|
269
|
+
const current = await companyRequest({ action: 'show', id: options.id }, undefined, fetchImpl);
|
|
270
|
+
const profile = current.profile;
|
|
271
|
+
if (!profile || profile.id !== options.id) throw new Error('Company identity could not be confirmed. Reload companies list.');
|
|
272
|
+
const message = `${options.action} for company ${companyText(profile.name, 'Unnamed')} (${options.id})${options.itemId ? `, pipeline item ${options.itemId}` : ''}.`;
|
|
273
|
+
if (!(await (dependencies.confirm || requireWriteConfirmation)(options, options.action.toUpperCase(), message))) return { unchanged: true, profileId: options.id, companyName: profile.name };
|
|
274
|
+
return companyRequest(options, body, fetchImpl);
|
|
275
|
+
}
|
|
276
|
+
|
|
153
277
|
function optionValue(argv, index, flag) {
|
|
154
278
|
const value = argv[index + 1];
|
|
155
279
|
if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value`);
|
|
@@ -444,7 +568,11 @@ async function apiRequest(path, options = {}, fetchImpl = fetch) {
|
|
|
444
568
|
},
|
|
445
569
|
});
|
|
446
570
|
const body = await response.json().catch(() => ({}));
|
|
447
|
-
if (!response.ok)
|
|
571
|
+
if (!response.ok) {
|
|
572
|
+
const error = new Error(body.error || `TenderLead API failed: ${response.status}`);
|
|
573
|
+
error.status = response.status;
|
|
574
|
+
throw error;
|
|
575
|
+
}
|
|
448
576
|
return body;
|
|
449
577
|
}
|
|
450
578
|
|
|
@@ -735,6 +863,7 @@ async function main() {
|
|
|
735
863
|
console.log('TenderLead Settings opened. Sign in, create the required scoped token, then set TENDERLEAD_API_TOKEN in your shell.');
|
|
736
864
|
return;
|
|
737
865
|
}
|
|
866
|
+
if (options.command === 'companies') return printPayload(await runCompanyCommand(options), options.json, payload => formatCompanies(payload, options));
|
|
738
867
|
if (options.command === 'whoami') return printPayload(await fetchWhoami(), options.json, formatWhoami);
|
|
739
868
|
if (options.command === 'account') return printPayload(await fetchAccountStatus(), options.json, formatAccountStatus);
|
|
740
869
|
if (options.command === 'brief') {
|
|
@@ -870,6 +999,7 @@ if (require.main === module) {
|
|
|
870
999
|
}
|
|
871
1000
|
|
|
872
1001
|
module.exports = {
|
|
1002
|
+
parseCompanyArgs, validateCompanyBody, companyRequest, formatCompanies, runCompanyCommand,
|
|
873
1003
|
parseArgs, openLogin, fetchWhoami, fetchAccountStatus, formatWhoami, formatAccountStatus, formatBrief, formatTenders, formatScore, fetchBrief, searchTenders, scoreTender, fetchProfile, updateProfile,
|
|
874
1004
|
validateProfile, readProfileFile, profileTemplate, profileDiff, formatProfile,
|
|
875
1005
|
formatProfileDiff, confirmApply, confirmWord, readJsonFile, contentTypeForFile, appendFiles,
|
package/package.json
CHANGED