@rentaltide/cli 0.2.0 → 0.3.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
@@ -39,7 +39,7 @@ installed points all of them at your laptop.
39
39
  ## Embedded insurance
40
40
 
41
41
  An underwriter builds no iframe and holds no OAuth token — RentalTide calls
42
- *them*. Different world, same CLI:
42
+ _them_. Different world, same CLI:
43
43
 
44
44
  ```bash
45
45
  rentaltide init --underwriter acme-cover
@@ -47,13 +47,25 @@ cd acme-cover
47
47
  export SIGNING_SECRET=whsec_… # shown once when you created the offering
48
48
  npm run dev # six endpoints on :8787
49
49
 
50
- rentaltide coverage link
51
- rentaltide coverage secret
50
+ rentaltide coverage create # register it, secret saved automatically
52
51
  rentaltide coverage test --url http://localhost:8787
53
52
  ```
54
53
 
54
+ Or fire one call at a time while you are writing the thing — signed exactly the
55
+ way we sign it:
56
+
57
+ ```bash
58
+ rentaltide coverage quote --subtotal 1200 --currency CAD
59
+ rentaltide coverage requote --quote-id q_… --answers '{"prior_claims_3y":true}'
60
+ rentaltide coverage bind --quote-id q_…
61
+ rentaltide coverage cancel --policy-id POL-…
62
+ ```
63
+
64
+ Each prints the signed request, the status, the timing and the body, and exits
65
+ non-zero on anything but a 200.
66
+
55
67
  `coverage test` signs its requests exactly as the platform does and checks the
56
- rules that fail *quietly*:
68
+ rules that fail _quietly_:
57
69
 
58
70
  ```
59
71
  ✓ Refuses an unsigned request
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { request } from '../api.js';
10
10
  import { readSigningSecret, requireCoverageConfig, readCoverageConfig, saveSigningSecret, writeCoverageConfig, COVERAGE_CONFIG_FILENAME, } from '../config.js';
11
- import { probe } from '../coverageProbe.js';
11
+ import { call, probe } from '../coverageProbe.js';
12
12
  import { ask, bold, confirm, cyan, dim, fail, green, ok, red, say, step, warn } from '../ui.js';
13
13
  const toConfig = (provider, existing) => ({
14
14
  providerId: provider.id,
@@ -34,6 +34,8 @@ async function getProvider(providerId) {
34
34
  export async function coverage(args) {
35
35
  const [sub, ...rest] = args;
36
36
  switch (sub) {
37
+ case 'create':
38
+ return create();
37
39
  case 'link':
38
40
  return link();
39
41
  case 'pull':
@@ -51,8 +53,11 @@ export async function coverage(args) {
51
53
  case 'submit':
52
54
  return submit();
53
55
  default:
56
+ if (sub && ENDPOINTS.includes(sub))
57
+ return callOne(sub, rest);
54
58
  say(`${bold('rentaltide coverage')} <command>`);
55
59
  say();
60
+ say(' create register this offering with RentalTide');
56
61
  say(' link attach this directory to one of your coverage offerings');
57
62
  say(' pull write the offering into rentaltide.coverage.json');
58
63
  say(' push apply rentaltide.coverage.json to the offering');
@@ -61,8 +66,76 @@ export async function coverage(args) {
61
66
  say(' secret store the signing secret for this machine');
62
67
  say(' status what still has to be true before review');
63
68
  say(' submit submit the offering for review');
69
+ say();
70
+ say(` ${bold('One call at a time')} — signed the way we sign it:`);
71
+ say(' quote | schema | requote | bind | reschedule | cancel');
72
+ say(dim(' e.g. rentaltide coverage quote --subtotal 1200 --currency CAD'));
73
+ say(dim(' rentaltide coverage requote --quote-id q_… --answers \'{"prior_claims_3y":true}\''));
74
+ say(dim(' rentaltide coverage bind --data \'{"quoteId":"q_…"}\''));
64
75
  }
65
76
  }
77
+ /**
78
+ * Register the offering.
79
+ *
80
+ * The one call that used to need a hand-built curl and a bearer token dug out
81
+ * of the portal — for a partner whose first act is to read a contract, that is
82
+ * a strange first step. It also puts the signing secret straight where the rest
83
+ * of the CLI looks for it: RentalTide shows it exactly once and cannot return
84
+ * it again, so a partner who pastes it into a terminal and closes the window
85
+ * has lost it.
86
+ */
87
+ async function create() {
88
+ const existing = readCoverageConfig();
89
+ const name = existing?.name || (await ask('Offering name:'));
90
+ const baseUrl = existing?.baseUrl && existing.baseUrl !== 'https://example.com'
91
+ ? existing.baseUrl
92
+ : await ask('Base URL (https):');
93
+ const supportEmail = existing?.supportEmail || (await ask('Support email:'));
94
+ const countries = existing?.countries?.length
95
+ ? existing.countries
96
+ : (await ask('Licensed countries (ISO, comma separated):')).split(',').map((c) => c.trim().toUpperCase()).filter(Boolean);
97
+ const currencies = existing?.currencies?.length
98
+ ? existing.currencies
99
+ : (await ask('Currencies (ISO, comma separated):')).split(',').map((c) => c.trim().toUpperCase()).filter(Boolean);
100
+ if (!name || !baseUrl)
101
+ fail('A name and a base URL are required.');
102
+ if (!baseUrl.startsWith('https://')) {
103
+ fail('The base URL must be https.', 'We sign every call to it and send renter identity at bind.');
104
+ }
105
+ const result = await request('/developer/coverage-providers', {
106
+ method: 'POST',
107
+ body: {
108
+ name,
109
+ baseUrl,
110
+ supportEmail: supportEmail || undefined,
111
+ supportedCountries: countries,
112
+ supportedCurrencies: currencies,
113
+ quoteTimeoutMs: existing?.quoteTimeoutMs ?? 800,
114
+ },
115
+ });
116
+ writeCoverageConfig({
117
+ providerId: result.provider.id,
118
+ name: result.provider.name,
119
+ baseUrl,
120
+ supportEmail: supportEmail || undefined,
121
+ countries,
122
+ currencies,
123
+ quoteTimeoutMs: existing?.quoteTimeoutMs ?? 800,
124
+ dev: existing?.dev ?? { baseUrl: 'http://localhost:8787' },
125
+ });
126
+ // Stored before it is printed: if the terminal scrolls away or the window
127
+ // closes, the secret is still on this machine.
128
+ saveSigningSecret(result.provider.id, result.signingSecret);
129
+ ok(`Created ${result.provider.name} (${result.provider.slug}).`);
130
+ say();
131
+ say(` ${bold('Signing secret')} ${result.signingSecret}`);
132
+ say(` ${dim('Shown once. Saved to ~/.rentaltide/credentials.json for `coverage test`.')}`);
133
+ say(` ${dim('Put it in your own environment as SIGNING_SECRET too — we cannot show it again.')}`);
134
+ say();
135
+ say(`It works on sandbox locations now, with no approval step.`);
136
+ say(` ${cyan('rentaltide coverage test')} ${dim('prove your endpoints')}`);
137
+ say(` ${cyan('rentaltide coverage status')} ${dim('what is still missing before review')}`);
138
+ }
66
139
  async function link() {
67
140
  const providers = await listProviders();
68
141
  if (providers.length === 0) {
@@ -271,3 +344,116 @@ async function submit() {
271
344
  await request(`/developer/coverage-providers/${config.providerId}/submit`, { method: 'POST' });
272
345
  ok('Submitted for review. It keeps working in sandbox meanwhile.');
273
346
  }
347
+ // ============================================================================
348
+ // One call at a time
349
+ //
350
+ // `coverage test` runs the whole contract and judges it. Sometimes you just
351
+ // want to fire one endpoint with a payload you control and read what comes
352
+ // back — while you are writing the thing, that is most of the time.
353
+ // ============================================================================
354
+ /** A realistic default body per endpoint, so a bare command does something. */
355
+ function defaultBody(endpoint, args) {
356
+ const flag = (name) => {
357
+ const index = args.indexOf(`--${name}`);
358
+ return index >= 0 ? args[index + 1] : undefined;
359
+ };
360
+ const quoteId = flag('quote-id') || 'q_local_test';
361
+ const policyId = flag('policy-id') || 'POL-LOCAL-TEST';
362
+ const startsAt = new Date(Date.now() + 86_400_000).toISOString();
363
+ const endsAt = new Date(Date.now() + 100_800_000).toISOString();
364
+ switch (endpoint) {
365
+ case 'quote':
366
+ return {
367
+ productCode: flag('product') || 'hull-basic',
368
+ assetCategory: 'pontoon',
369
+ assetValue: 48000,
370
+ units: 1,
371
+ startsAt,
372
+ endsAt,
373
+ days: Number(flag('days') || 1),
374
+ bookingSubtotal: Number(flag('subtotal') || 850),
375
+ securityDeposit: 2000,
376
+ currency: flag('currency') || 'USD',
377
+ region: flag('region') || null,
378
+ };
379
+ case 'schema':
380
+ return { version: flag('version') || '2026-09-01' };
381
+ case 'requote':
382
+ return {
383
+ quoteId,
384
+ basePremium: Number(flag('premium') || 55.25),
385
+ answers: JSON.parse(flag('answers') || '{"date_of_birth":"1988-03-14","prior_claims_3y":false}'),
386
+ };
387
+ case 'bind':
388
+ return {
389
+ quoteId,
390
+ bookingReference: flag('booking') || 'bkg_local_test',
391
+ premiumCharged: Number(flag('premium') || 55.25),
392
+ renter: {
393
+ firstName: 'Sam',
394
+ lastName: 'Okafor',
395
+ email: 'sam@example.invalid',
396
+ phone: '+14165550142',
397
+ dateOfBirth: '1988-03-14',
398
+ },
399
+ answers: JSON.parse(flag('answers') || '{"date_of_birth":"1988-03-14"}'),
400
+ };
401
+ case 'reschedule':
402
+ return {
403
+ policyId,
404
+ bookingReference: flag('booking') || 'bkg_local_test',
405
+ previousStartsAt: startsAt,
406
+ previousEndsAt: endsAt,
407
+ startsAt: new Date(Date.now() + 8 * 86_400_000).toISOString(),
408
+ endsAt: new Date(Date.now() + 8 * 86_400_000 + 14_400_000).toISOString(),
409
+ days: 1,
410
+ };
411
+ case 'cancel':
412
+ return { policyId, reason: flag('reason') || 'booking_cancelled' };
413
+ default:
414
+ return {};
415
+ }
416
+ }
417
+ const ENDPOINTS = ['quote', 'schema', 'requote', 'bind', 'reschedule', 'cancel'];
418
+ async function callOne(endpoint, args) {
419
+ const config = requireCoverageConfig();
420
+ if (!config.providerId)
421
+ fail('Not linked yet.', 'Run `rentaltide coverage link`.');
422
+ const urlFlag = args.indexOf('--url');
423
+ const baseUrl = (urlFlag >= 0 ? args[urlFlag + 1] : undefined) || config.dev?.baseUrl || config.baseUrl;
424
+ const signingSecret = readSigningSecret(config.providerId);
425
+ if (!signingSecret) {
426
+ fail('No signing secret for this offering.', 'Run `rentaltide coverage secret`.');
427
+ }
428
+ // `--data` REPLACES the default body rather than merging into it: a merge
429
+ // would quietly leave defaults in a payload somebody wrote deliberately.
430
+ const dataFlag = args.indexOf('--data');
431
+ let body;
432
+ if (dataFlag >= 0) {
433
+ try {
434
+ body = JSON.parse(args[dataFlag + 1] || '{}');
435
+ }
436
+ catch (err) {
437
+ return fail('--data is not valid JSON.', err instanceof Error ? err.message : undefined);
438
+ }
439
+ }
440
+ else {
441
+ try {
442
+ body = defaultBody(endpoint, args);
443
+ }
444
+ catch (err) {
445
+ return fail('An option was not valid JSON.', err instanceof Error ? err.message : undefined);
446
+ }
447
+ }
448
+ say(`${bold('POST')} ${baseUrl.replace(/\/$/, '')}/${endpoint}`);
449
+ say(dim(JSON.stringify(body, null, 2)));
450
+ say();
451
+ const result = await call({ baseUrl, secret: signingSecret }, `/${endpoint}`, body, endpoint === 'bind' ? 15000 : 10000, endpoint === 'bind' ? String(body.quoteId ?? '') : undefined);
452
+ if (result.error)
453
+ fail(result.error);
454
+ const tone = result.status === 200 ? green : red;
455
+ say(`${tone(String(result.status))} ${dim(`${result.ms}ms`)}`);
456
+ say(JSON.stringify(result.body, null, 2));
457
+ if (result.status !== 200)
458
+ process.exit(1);
459
+ }
@@ -8,7 +8,7 @@
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
- import { writeAppConfig, writeCoverageConfig } from '../config.js';
11
+ import { writeAppConfig, writeCoverageConfig, } from '../config.js';
12
12
  import { ask, bold, dim, fail, ok, say } from '../ui.js';
13
13
  const here = path.dirname(fileURLToPath(import.meta.url));
14
14
  function templateDir(name) {
package/dist/config.js CHANGED
@@ -76,9 +76,7 @@ export function requireCoverageConfig(cwd = process.cwd()) {
76
76
  return config;
77
77
  }
78
78
  export function readSigningSecret(providerId) {
79
- return (process.env.RENTALTIDE_SIGNING_SECRET ||
80
- readCredentials()?.signingSecrets?.[providerId] ||
81
- null);
79
+ return (process.env.RENTALTIDE_SIGNING_SECRET || readCredentials()?.signingSecrets?.[providerId] || null);
82
80
  }
83
81
  export function saveSigningSecret(providerId, secret) {
84
82
  const credentials = readCredentials();
@@ -13,10 +13,10 @@
13
13
  * bind that writes a second policy on retry.
14
14
  */
15
15
  import crypto from 'node:crypto';
16
- function sign(secret, timestamp, body) {
16
+ export function sign(secret, timestamp, body) {
17
17
  return `v1=${crypto.createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex')}`;
18
18
  }
19
- async function call(opts, path, payload, timeoutMs, idempotencyKey) {
19
+ export async function call(opts, path, payload, timeoutMs, idempotencyKey) {
20
20
  const raw = JSON.stringify(payload);
21
21
  const timestamp = new Date().toISOString();
22
22
  const started = Date.now();
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ import { webhook } from './commands/webhook.js';
16
16
  import { open } from './commands/open.js';
17
17
  import { coverage } from './commands/coverage.js';
18
18
  import { bold, dim, say } from './ui.js';
19
- const VERSION = '0.2.0';
19
+ const VERSION = '0.3.0';
20
20
  function usage() {
21
21
  say(`${bold('rentaltide')} ${dim(VERSION)} — build apps for RentalTide`);
22
22
  say();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rentaltide/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Build RentalTide apps and coverage offerings: scaffold, run against your sandbox, push config, and test an underwriter the way RentalTide calls it.",
5
5
  "license": "MIT",
6
6
  "author": "RentalTide Inc.",
@@ -1,4 +1,4 @@
1
- # __APP_NAME__
1
+ # **APP_NAME**
2
2
 
3
3
  A RentalTide coverage underwriter. Six endpoints, no dependencies.
4
4