shiply-cli 0.14.4 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contract.js +143 -0
- package/dist/domain.js +64 -31
- package/dist/index.js +65 -0
- package/dist/psl.data.js +6943 -0
- package/package.json +1 -1
- package/skill/SKILL.md +51 -1
package/dist/contract.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/** B14 — `shiply contract <subcommand>` — CLI parity with the contract REST
|
|
2
|
+
* + MCP surface shipped in B1-B13. The 7 subcommands cover the full contract
|
|
3
|
+
* lifecycle: list / draft / show / send / pdf / amend / retract.
|
|
4
|
+
*
|
|
5
|
+
* Every endpoint here is dev-only (Bearer auth via `~/.shiply/credentials`).
|
|
6
|
+
* Customer-side reads happen through the intake portal cookie, not the CLI. */
|
|
7
|
+
import { writeFile } from 'node:fs/promises';
|
|
8
|
+
import { api, ApiError, resolveBase } from './publish.js';
|
|
9
|
+
const headers = (apiKey) => ({
|
|
10
|
+
'content-type': 'application/json',
|
|
11
|
+
authorization: `Bearer ${apiKey}`,
|
|
12
|
+
});
|
|
13
|
+
function fmtMoney(cents, currency) {
|
|
14
|
+
if (cents === null || cents === undefined)
|
|
15
|
+
return '—';
|
|
16
|
+
const cur = currency ?? 'USD';
|
|
17
|
+
const sign = cents < 0 ? '-' : '';
|
|
18
|
+
const abs = Math.abs(cents);
|
|
19
|
+
const whole = Math.floor(abs / 100);
|
|
20
|
+
const frac = String(abs % 100).padStart(2, '0');
|
|
21
|
+
return `${sign}${cur} ${whole}.${frac}`;
|
|
22
|
+
}
|
|
23
|
+
function kind(c) {
|
|
24
|
+
return c.parentContractId ? 'amendment' : 'parent';
|
|
25
|
+
}
|
|
26
|
+
export async function contractList(ctx, projectId) {
|
|
27
|
+
if (!projectId)
|
|
28
|
+
throw new Error('usage: shiply contract list <project-id>');
|
|
29
|
+
const base = resolveBase(ctx.base);
|
|
30
|
+
const r = await api(`${base}/api/v1/projects/${encodeURIComponent(projectId)}/contracts`, { headers: headers(ctx.apiKey) });
|
|
31
|
+
if (!r.parent) {
|
|
32
|
+
console.log('No contract yet for this project. Draft one: shiply contract draft ' + projectId);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const p = r.parent;
|
|
36
|
+
console.log(`Parent ${p.id} [${p.status}] ${fmtMoney(p.feeCents, p.currency)} scope="${(p.scopeSummary ?? '').slice(0, 60)}${(p.scopeSummary ?? '').length > 60 ? '…' : ''}"`);
|
|
37
|
+
if (p.sentAt)
|
|
38
|
+
console.log(` sent ${p.sentAt}`);
|
|
39
|
+
if (p.signedAt)
|
|
40
|
+
console.log(` signed ${p.signedAt}`);
|
|
41
|
+
for (const a of r.amendments) {
|
|
42
|
+
console.log(`Amend ${a.id} [${a.status}] Δ${fmtMoney(a.feeDeltaCents, a.currency ?? p.currency)} scope="${(a.scopeDelta ?? '').slice(0, 60)}${(a.scopeDelta ?? '').length > 60 ? '…' : ''}"`);
|
|
43
|
+
if (a.sentAt)
|
|
44
|
+
console.log(` sent ${a.sentAt}`);
|
|
45
|
+
if (a.signedAt)
|
|
46
|
+
console.log(` signed ${a.signedAt}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export async function contractDraft(ctx, projectId) {
|
|
50
|
+
if (!projectId)
|
|
51
|
+
throw new Error('usage: shiply contract draft <project-id>');
|
|
52
|
+
const base = resolveBase(ctx.base);
|
|
53
|
+
const r = await api(`${base}/api/v1/projects/${encodeURIComponent(projectId)}/contract`, { method: 'POST', headers: headers(ctx.apiKey), body: '{}' });
|
|
54
|
+
console.log(`✔ draft contract ${r.contract.id} created`);
|
|
55
|
+
console.log(' edit fields via the dashboard, then: shiply contract send ' + r.contract.id);
|
|
56
|
+
}
|
|
57
|
+
export async function contractShow(ctx, contractId) {
|
|
58
|
+
if (!contractId)
|
|
59
|
+
throw new Error('usage: shiply contract show <contract-id>');
|
|
60
|
+
const base = resolveBase(ctx.base);
|
|
61
|
+
const r = await api(`${base}/api/v1/contracts/${encodeURIComponent(contractId)}`, { headers: headers(ctx.apiKey) });
|
|
62
|
+
console.log(JSON.stringify(r.contract, null, 2));
|
|
63
|
+
}
|
|
64
|
+
export async function contractSend(ctx, contractId) {
|
|
65
|
+
if (!contractId)
|
|
66
|
+
throw new Error('usage: shiply contract send <contract-id>');
|
|
67
|
+
const base = resolveBase(ctx.base);
|
|
68
|
+
const r = await api(`${base}/api/v1/contracts/${encodeURIComponent(contractId)}/send`, { method: 'POST', headers: headers(ctx.apiKey), body: '{}' });
|
|
69
|
+
const k = kind(r.contract);
|
|
70
|
+
console.log(`✔ ${k} ${r.contract.id} sent — status=${r.contract.status}`);
|
|
71
|
+
if (r.contract.sentAt)
|
|
72
|
+
console.log(` sent at ${r.contract.sentAt}`);
|
|
73
|
+
console.log(' customer receives an email + sees it on the intake portal');
|
|
74
|
+
}
|
|
75
|
+
export async function contractPdf(ctx, contractId, outPath) {
|
|
76
|
+
if (!contractId)
|
|
77
|
+
throw new Error('usage: shiply contract pdf <contract-id> -o file.pdf');
|
|
78
|
+
if (!outPath)
|
|
79
|
+
throw new Error('--out / -o <file.pdf> is required for shiply contract pdf');
|
|
80
|
+
const base = resolveBase(ctx.base);
|
|
81
|
+
const url = `${base}/api/v1/contracts/${encodeURIComponent(contractId)}/pdf`;
|
|
82
|
+
const res = await fetch(url, { headers: { authorization: `Bearer ${ctx.apiKey}` } });
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
// Use the same ApiError envelope the rest of the CLI does so 409
|
|
85
|
+
// contract_not_signed surfaces with a useful message + exit 1.
|
|
86
|
+
const body = await res.json().catch(() => ({}));
|
|
87
|
+
const err = body.error;
|
|
88
|
+
throw new ApiError(err?.code ?? 'http_error', err?.message ?? `HTTP ${res.status}`, res.status);
|
|
89
|
+
}
|
|
90
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
91
|
+
await writeFile(outPath, bytes);
|
|
92
|
+
// Quick magic-bytes sanity check — `%PDF-` at the start of a real PDF.
|
|
93
|
+
const head = String.fromCharCode(...bytes.subarray(0, 5));
|
|
94
|
+
const looksLikePdf = head === '%PDF-';
|
|
95
|
+
console.log(`✔ wrote ${outPath} (${bytes.byteLength} bytes${looksLikePdf ? ', %PDF- header' : ', WARNING: not a PDF header'})`);
|
|
96
|
+
}
|
|
97
|
+
export async function contractAmend(ctx, contractId, opts) {
|
|
98
|
+
if (!contractId)
|
|
99
|
+
throw new Error('usage: shiply contract amend <contract-id> --scope "…" [--fee-delta N] [--target-date YYYY-MM-DD]');
|
|
100
|
+
if (!opts.scope)
|
|
101
|
+
throw new Error('--scope "<change description>" is required');
|
|
102
|
+
const base = resolveBase(ctx.base);
|
|
103
|
+
const body = { scopeDelta: opts.scope };
|
|
104
|
+
if (opts.feeDelta !== undefined) {
|
|
105
|
+
const n = Number(opts.feeDelta);
|
|
106
|
+
if (!Number.isFinite(n) || !Number.isInteger(n)) {
|
|
107
|
+
throw new Error('--fee-delta must be an integer (cents — use a negative number to discount)');
|
|
108
|
+
}
|
|
109
|
+
body.feeDeltaCents = n;
|
|
110
|
+
}
|
|
111
|
+
if (opts.targetDate)
|
|
112
|
+
body.targetCompletionDate = opts.targetDate;
|
|
113
|
+
const r = await api(`${base}/api/v1/contracts/${encodeURIComponent(contractId)}/amend`, { method: 'POST', headers: headers(ctx.apiKey), body: JSON.stringify(body) });
|
|
114
|
+
console.log(`✔ amendment ${r.contract.id} drafted against parent ${contractId}`);
|
|
115
|
+
console.log(` Δscope : ${r.contract.scopeDelta ?? '—'}`);
|
|
116
|
+
console.log(` Δfee : ${fmtMoney(r.contract.feeDeltaCents, null)}`);
|
|
117
|
+
if (r.contract.targetCompletionDate)
|
|
118
|
+
console.log(` new target: ${r.contract.targetCompletionDate}`);
|
|
119
|
+
// Spec says: amend auto-sends unless the dev wants a beat. The default is
|
|
120
|
+
// to send immediately so the contract reaches the customer in one command;
|
|
121
|
+
// pass --keep-as-draft on `shiply contract retract` style usage if the dev
|
|
122
|
+
// wants to hold. Here we send unconditionally — amendments are a quick fix
|
|
123
|
+
// path, and the customer-portal preview is non-binding until signed.
|
|
124
|
+
const sent = await api(`${base}/api/v1/contracts/${encodeURIComponent(r.contract.id)}/send`, { method: 'POST', headers: headers(ctx.apiKey), body: '{}' });
|
|
125
|
+
console.log(`✔ amendment ${sent.contract.id} sent — status=${sent.contract.status}`);
|
|
126
|
+
}
|
|
127
|
+
export async function contractRetract(ctx, contractId, opts) {
|
|
128
|
+
if (!contractId)
|
|
129
|
+
throw new Error('usage: shiply contract retract <contract-id> [--keep-as-draft]');
|
|
130
|
+
const base = resolveBase(ctx.base);
|
|
131
|
+
const r = await api(`${base}/api/v1/contracts/${encodeURIComponent(contractId)}/retract`, {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
headers: headers(ctx.apiKey),
|
|
134
|
+
body: JSON.stringify({ recoverAsDraft: Boolean(opts.keepAsDraft) }),
|
|
135
|
+
});
|
|
136
|
+
if (opts.keepAsDraft) {
|
|
137
|
+
console.log(`✔ contract ${contractId} retracted to draft — edit + re-send when ready`);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
console.log(`✔ contract ${contractId} retracted (status=${r.contract.status})`);
|
|
141
|
+
}
|
|
142
|
+
console.log(' project state reverted to brief_ready');
|
|
143
|
+
}
|
package/dist/domain.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { api, resolveBase } from './publish.js';
|
|
2
|
+
import { PSL_RULES } from './psl.data.js';
|
|
2
3
|
const headers = (apiKey) => ({ 'content-type': 'application/json', authorization: `Bearer ${apiKey}` });
|
|
3
4
|
const enc = (d) => encodeURIComponent(d);
|
|
4
5
|
export async function domainLs(ctx) {
|
|
@@ -24,40 +25,72 @@ export async function domainConnect(ctx, domain) {
|
|
|
24
25
|
const r = await api(`${base}/api/v1/custom-domains/${enc(domain)}/connect`, { method: 'POST', headers: headers(ctx.apiKey), body: '{}' });
|
|
25
26
|
console.log(`Open this link in your browser to connect ${domain}:\n ${r.url}`);
|
|
26
27
|
}
|
|
27
|
-
/**
|
|
28
|
-
*
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
28
|
+
/** Index the PSL into three buckets for O(1) lookup. Exception rules ('!foo')
|
|
29
|
+
* win over wildcard rules ('*.foo') which win over plain rules ('foo'). */
|
|
30
|
+
const PSL_EXACT = new Set();
|
|
31
|
+
const PSL_WILDCARD = new Set();
|
|
32
|
+
const PSL_EXCEPTION = new Set();
|
|
33
|
+
for (const rule of PSL_RULES) {
|
|
34
|
+
if (rule.startsWith('!'))
|
|
35
|
+
PSL_EXCEPTION.add(rule.slice(1));
|
|
36
|
+
else if (rule.startsWith('*.'))
|
|
37
|
+
PSL_WILDCARD.add(rule.slice(2));
|
|
38
|
+
else
|
|
39
|
+
PSL_EXACT.add(rule);
|
|
40
|
+
}
|
|
41
|
+
/** Find the longest matching public suffix for a hostname, applying the full
|
|
42
|
+
* PSL algorithm: exception rules win, then wildcards, then longest exact
|
|
43
|
+
* match. Returns the suffix as a dotted string (e.g. 'com.au', 'co.uk').
|
|
44
|
+
* Returns null if no rule matches (hostname has only a single label, or it's
|
|
45
|
+
* IDN/punycode outside the ICANN list). */
|
|
46
|
+
export function findPublicSuffix(hostname) {
|
|
47
|
+
const labels = hostname.toLowerCase().split('.');
|
|
48
|
+
// Walk from most-specific (full hostname) to least-specific (TLD).
|
|
49
|
+
for (let i = 0; i < labels.length; i++) {
|
|
50
|
+
const candidate = labels.slice(i).join('.');
|
|
51
|
+
// 1. Exception rule wins → suffix is candidate minus its first label.
|
|
52
|
+
if (PSL_EXCEPTION.has(candidate)) {
|
|
53
|
+
return labels.slice(i + 1).join('.') || null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (let i = 0; i < labels.length; i++) {
|
|
57
|
+
const candidate = labels.slice(i).join('.');
|
|
58
|
+
// 2. Exact match.
|
|
59
|
+
if (PSL_EXACT.has(candidate))
|
|
60
|
+
return candidate;
|
|
61
|
+
// 3. Wildcard: parent of candidate (everything after candidate's first
|
|
62
|
+
// label) is in the wildcard set.
|
|
63
|
+
if (i + 1 < labels.length) {
|
|
64
|
+
const parent = labels.slice(i + 1).join('.');
|
|
65
|
+
if (PSL_WILDCARD.has(parent))
|
|
66
|
+
return candidate;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
/** Split hostname into (registrable domain, subdomain) using the full PSL.
|
|
72
|
+
* The registrable domain is the public suffix plus one extra label.
|
|
73
|
+
* E.g. `www.example.co.uk` → suffix `co.uk`, domain `example.co.uk`, sub `www`.
|
|
74
|
+
* `foo.city.kawasaki.jp` → exception `!city.kawasaki.jp` makes the suffix
|
|
75
|
+
* `kawasaki.jp`, so domain is `city.kawasaki.jp` and sub is `foo`.
|
|
76
|
+
* Falls back to the last-two-labels heuristic if the hostname is below the
|
|
77
|
+
* PSL coverage (no matching rule). */
|
|
50
78
|
export function splitRegistrable(hostname) {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
79
|
+
const host = hostname.toLowerCase().replace(/\.$/, '');
|
|
80
|
+
const parts = host.split('.');
|
|
81
|
+
const suffix = findPublicSuffix(host);
|
|
82
|
+
if (suffix) {
|
|
83
|
+
const suffixLabels = suffix.split('.').length;
|
|
84
|
+
// Registrable domain = public suffix + one more label.
|
|
85
|
+
if (parts.length <= suffixLabels) {
|
|
86
|
+
// Hostname IS the public suffix — not registrable on its own.
|
|
87
|
+
return { domain: host, subdomain: '@' };
|
|
59
88
|
}
|
|
89
|
+
const domain = parts.slice(-(suffixLabels + 1)).join('.');
|
|
90
|
+
const subdomain = parts.slice(0, -(suffixLabels + 1)).join('.') || '@';
|
|
91
|
+
return { domain, subdomain };
|
|
60
92
|
}
|
|
93
|
+
// No PSL hit — fall back to last-two-labels.
|
|
61
94
|
const domain = parts.slice(-2).join('.');
|
|
62
95
|
const subdomain = parts.slice(0, -2).join('.') || '@';
|
|
63
96
|
return { domain, subdomain };
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import { domainAdd, domainConnect, domainLs, domainPrimary, domainSubAdd, domain
|
|
|
14
14
|
import { runProject } from './projects.js';
|
|
15
15
|
import { runListing } from './listings.js';
|
|
16
16
|
import { runSendingDomain } from './sending-domains.js';
|
|
17
|
+
import { contractAmend, contractDraft, contractList, contractPdf, contractRetract, contractSend, contractShow, } from './contract.js';
|
|
17
18
|
import { runCron, runFunction, runSecret } from './functions.js';
|
|
18
19
|
import { loadApiKey, saveApiKey } from './config.js';
|
|
19
20
|
import { loginViaDeviceFlow } from './login.js';
|
|
@@ -84,6 +85,15 @@ Usage:
|
|
|
84
85
|
in your worker on next deploy/restart.
|
|
85
86
|
shiply cron <ls|set|rm> <slug> Manage cron triggers on the deployed worker
|
|
86
87
|
shiply cron set <slug> /api/cron/foo "0 9 * * *"
|
|
88
|
+
shiply contract <list|draft|show|send|pdf|amend|retract>
|
|
89
|
+
Customer contracts — full lifecycle CLI (B14).
|
|
90
|
+
list <project-id> — parent + amendments table
|
|
91
|
+
draft <project-id> — creates a draft you can edit
|
|
92
|
+
show <contract-id> — prints the full row as JSON
|
|
93
|
+
send <contract-id> — emails it to the customer
|
|
94
|
+
pdf <contract-id> -o file.pdf — signed PDF download
|
|
95
|
+
amend <id> --scope "…" [--fee-delta N] [--target-date YYYY-MM-DD]
|
|
96
|
+
retract <id> [--keep-as-draft] — undo a sent contract
|
|
87
97
|
shiply data init [dir] Scaffold a starter .shiply/data.json in <dir> (default cwd)
|
|
88
98
|
shiply data list <slug> List collections on a site with counts
|
|
89
99
|
shiply data query <slug> <coll> [--limit N] [--cursor C] [--where '<json>']
|
|
@@ -197,6 +207,10 @@ async function main() {
|
|
|
197
207
|
limit: { type: 'string' },
|
|
198
208
|
cursor: { type: 'string' },
|
|
199
209
|
where: { type: 'string' },
|
|
210
|
+
scope: { type: 'string' },
|
|
211
|
+
'fee-delta': { type: 'string' },
|
|
212
|
+
'target-date': { type: 'string' },
|
|
213
|
+
'keep-as-draft': { type: 'boolean' },
|
|
200
214
|
help: { type: 'boolean', short: 'h' },
|
|
201
215
|
version: { type: 'boolean', short: 'v' },
|
|
202
216
|
},
|
|
@@ -601,6 +615,57 @@ async function main() {
|
|
|
601
615
|
});
|
|
602
616
|
break;
|
|
603
617
|
}
|
|
618
|
+
case 'contract': {
|
|
619
|
+
const apiKey = values.key ?? (await loadApiKey());
|
|
620
|
+
if (!apiKey)
|
|
621
|
+
throw new Error('contract commands need an API key — run `shiply login` first');
|
|
622
|
+
const cctx = { base: values.base, apiKey };
|
|
623
|
+
const sub = dir; // positionals[1]
|
|
624
|
+
const arg = positionals[2]; // contract or project id
|
|
625
|
+
switch (sub) {
|
|
626
|
+
case 'list':
|
|
627
|
+
if (!arg)
|
|
628
|
+
throw new Error('usage: shiply contract list <project-id>');
|
|
629
|
+
await contractList(cctx, arg);
|
|
630
|
+
return;
|
|
631
|
+
case 'draft':
|
|
632
|
+
if (!arg)
|
|
633
|
+
throw new Error('usage: shiply contract draft <project-id>');
|
|
634
|
+
await contractDraft(cctx, arg);
|
|
635
|
+
return;
|
|
636
|
+
case 'show':
|
|
637
|
+
if (!arg)
|
|
638
|
+
throw new Error('usage: shiply contract show <contract-id>');
|
|
639
|
+
await contractShow(cctx, arg);
|
|
640
|
+
return;
|
|
641
|
+
case 'send':
|
|
642
|
+
if (!arg)
|
|
643
|
+
throw new Error('usage: shiply contract send <contract-id>');
|
|
644
|
+
await contractSend(cctx, arg);
|
|
645
|
+
return;
|
|
646
|
+
case 'pdf':
|
|
647
|
+
if (!arg)
|
|
648
|
+
throw new Error('usage: shiply contract pdf <contract-id> -o file.pdf');
|
|
649
|
+
await contractPdf(cctx, arg, values.out);
|
|
650
|
+
return;
|
|
651
|
+
case 'amend':
|
|
652
|
+
if (!arg)
|
|
653
|
+
throw new Error('usage: shiply contract amend <contract-id> --scope "…" [--fee-delta N] [--target-date YYYY-MM-DD]');
|
|
654
|
+
await contractAmend(cctx, arg, {
|
|
655
|
+
scope: values.scope,
|
|
656
|
+
feeDelta: values['fee-delta'],
|
|
657
|
+
targetDate: values['target-date'],
|
|
658
|
+
});
|
|
659
|
+
return;
|
|
660
|
+
case 'retract':
|
|
661
|
+
if (!arg)
|
|
662
|
+
throw new Error('usage: shiply contract retract <contract-id> [--keep-as-draft]');
|
|
663
|
+
await contractRetract(cctx, arg, { keepAsDraft: Boolean(values['keep-as-draft']) });
|
|
664
|
+
return;
|
|
665
|
+
default:
|
|
666
|
+
throw new Error('usage: shiply contract <list|draft|show|send|pdf|amend|retract>');
|
|
667
|
+
}
|
|
668
|
+
}
|
|
604
669
|
case 'claim': {
|
|
605
670
|
if (dir !== 'verify') {
|
|
606
671
|
console.error('Usage: shiply claim verify <SHIPLY-XXXXXXXX>');
|