@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 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) throw new Error(body.error || `TenderLead API failed: ${response.status}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenderlead/cli",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "description": "Run TenderLead tender, profile, pipeline, insight and bid-workspace workflows from the terminal",
6
6
  "homepage": "https://tenderlead.co.uk",