shiply-cli 0.14.5 → 0.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/dist/contract.js +143 -0
- package/dist/email.js +147 -0
- package/dist/index.js +126 -0
- package/dist/mailbox.js +199 -0
- package/package.json +1 -1
- package/skill/SKILL.md +150 -4
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/email.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/** `shiply email <subcommand>` — agent email send + inbox.
|
|
2
|
+
*
|
|
3
|
+
* Pattern mirrors functions.ts: readFlags helper, exported runEmail
|
|
4
|
+
* entry point, individual async command functions. Top-level flags
|
|
5
|
+
* (--base / --key) are forwarded from index.ts via InheritedFlags. */
|
|
6
|
+
import { loadApiKey } from './config.js';
|
|
7
|
+
import { api, resolveBase } from './publish.js';
|
|
8
|
+
function readFlags(argv, inherited = {}) {
|
|
9
|
+
const raw = {};
|
|
10
|
+
const rest = [];
|
|
11
|
+
for (let i = 0; i < argv.length; i++) {
|
|
12
|
+
const a = argv[i];
|
|
13
|
+
if (a.startsWith('--')) {
|
|
14
|
+
const eq = a.indexOf('=');
|
|
15
|
+
if (eq >= 0) {
|
|
16
|
+
raw[a.slice(2, eq)] = a.slice(eq + 1);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
const next = argv[i + 1];
|
|
20
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
21
|
+
raw[a.slice(2)] = next;
|
|
22
|
+
i++;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
raw[a.slice(2)] = true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
rest.push(a);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// For each field: local argv flag takes precedence, then fall back to the
|
|
34
|
+
// value forwarded from index.ts parseArgs via inherited (the top-level
|
|
35
|
+
// parseArgs consumed these flags before argv reached us).
|
|
36
|
+
const flags = {
|
|
37
|
+
base: (typeof raw.base === 'string' ? raw.base : undefined) ?? inherited.base,
|
|
38
|
+
key: (typeof raw.key === 'string' ? raw.key : undefined) ?? inherited.key,
|
|
39
|
+
site: (typeof raw.site === 'string' ? raw.site : undefined) ?? inherited.site,
|
|
40
|
+
to: (typeof raw.to === 'string' ? raw.to : undefined) ?? inherited.to,
|
|
41
|
+
subject: (typeof raw.subject === 'string' ? raw.subject : undefined) ?? inherited.subject,
|
|
42
|
+
html: (typeof raw.html === 'string' ? raw.html : undefined) ?? inherited.html,
|
|
43
|
+
text: (typeof raw.text === 'string' ? raw.text : undefined) ?? inherited.text,
|
|
44
|
+
};
|
|
45
|
+
return { rest, flags };
|
|
46
|
+
}
|
|
47
|
+
const headers = (apiKey) => ({
|
|
48
|
+
'content-type': 'application/json',
|
|
49
|
+
authorization: `Bearer ${apiKey}`,
|
|
50
|
+
});
|
|
51
|
+
async function getApiKey(flags, what) {
|
|
52
|
+
const k = flags.key ?? (await loadApiKey());
|
|
53
|
+
if (!k)
|
|
54
|
+
throw new Error(`${what} commands need an API key — run \`shiply login\` first`);
|
|
55
|
+
return k;
|
|
56
|
+
}
|
|
57
|
+
const EMAIL_USAGE = [
|
|
58
|
+
'Usage: shiply email <send|inbox>',
|
|
59
|
+
' shiply email send --site <slug> --to <addr> --subject <s> --html <h> [--text <t>]',
|
|
60
|
+
' shiply email inbox [--site <slug>]',
|
|
61
|
+
].join('\n');
|
|
62
|
+
export async function runEmail(argv, inherited = {}) {
|
|
63
|
+
const action = argv[0];
|
|
64
|
+
const rest = argv.slice(1);
|
|
65
|
+
switch (action) {
|
|
66
|
+
case 'send':
|
|
67
|
+
return emailSendCmd(rest, inherited);
|
|
68
|
+
case 'inbox':
|
|
69
|
+
return emailInboxCmd(rest, inherited);
|
|
70
|
+
default:
|
|
71
|
+
console.error(EMAIL_USAGE);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function emailSendCmd(argv, inherited) {
|
|
76
|
+
const { flags } = readFlags(argv, inherited);
|
|
77
|
+
if (!flags.site) {
|
|
78
|
+
console.error('--site <slug> is required');
|
|
79
|
+
console.error(EMAIL_USAGE);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
if (!flags.to) {
|
|
83
|
+
console.error('--to <addr> is required');
|
|
84
|
+
console.error(EMAIL_USAGE);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
if (!flags.subject) {
|
|
88
|
+
console.error('--subject <s> is required');
|
|
89
|
+
console.error(EMAIL_USAGE);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
if (!flags.html) {
|
|
93
|
+
console.error('--html <h> is required');
|
|
94
|
+
console.error(EMAIL_USAGE);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
const apiKey = await getApiKey(flags, 'email');
|
|
98
|
+
const base = resolveBase(flags.base);
|
|
99
|
+
const body = {
|
|
100
|
+
slug: flags.site,
|
|
101
|
+
to: flags.to,
|
|
102
|
+
subject: flags.subject,
|
|
103
|
+
html: flags.html,
|
|
104
|
+
};
|
|
105
|
+
if (flags.text)
|
|
106
|
+
body.text = flags.text;
|
|
107
|
+
const r = await api(`${base}/api/v1/email/send`, {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: headers(apiKey),
|
|
110
|
+
body: JSON.stringify(body),
|
|
111
|
+
});
|
|
112
|
+
console.log(`✔ sent`);
|
|
113
|
+
console.log(` messageId: ${r.messageId}`);
|
|
114
|
+
}
|
|
115
|
+
async function emailInboxCmd(argv, inherited) {
|
|
116
|
+
const { flags } = readFlags(argv, inherited);
|
|
117
|
+
const apiKey = await getApiKey(flags, 'email');
|
|
118
|
+
const base = resolveBase(flags.base);
|
|
119
|
+
const qs = flags.site ? `?slug=${encodeURIComponent(flags.site)}` : '';
|
|
120
|
+
const r = await api(`${base}/api/v1/email/inbox${qs}`, {
|
|
121
|
+
headers: headers(apiKey),
|
|
122
|
+
});
|
|
123
|
+
if (!r.threads.length) {
|
|
124
|
+
console.log('No threads yet.');
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// Compact table: ID | SITE | FROM | SUBJECT | LAST
|
|
128
|
+
const cols = r.threads.map((t) => ({
|
|
129
|
+
id: t.id,
|
|
130
|
+
site: t.siteSlug ?? '',
|
|
131
|
+
from: t.from ?? '',
|
|
132
|
+
subject: t.subject ?? '',
|
|
133
|
+
last: t.lastAt ?? '',
|
|
134
|
+
count: String(t.messageCount ?? 1),
|
|
135
|
+
}));
|
|
136
|
+
const w = {
|
|
137
|
+
id: Math.max('ID'.length, ...cols.map((c) => c.id.length)),
|
|
138
|
+
site: Math.max('SITE'.length, ...cols.map((c) => c.site.length)),
|
|
139
|
+
from: Math.max('FROM'.length, ...cols.map((c) => c.from.length)),
|
|
140
|
+
subject: Math.max('SUBJECT'.length, ...cols.map((c) => c.subject.length)),
|
|
141
|
+
count: Math.max('#'.length, ...cols.map((c) => c.count.length)),
|
|
142
|
+
};
|
|
143
|
+
console.log(`${'ID'.padEnd(w.id)} ${'SITE'.padEnd(w.site)} ${'FROM'.padEnd(w.from)} ${'SUBJECT'.padEnd(w.subject)} ${'#'.padEnd(w.count)} LAST`);
|
|
144
|
+
for (const c of cols) {
|
|
145
|
+
console.log(`${c.id.padEnd(w.id)} ${c.site.padEnd(w.site)} ${c.from.padEnd(w.from)} ${c.subject.padEnd(w.subject)} ${c.count.padEnd(w.count)} ${c.last}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,10 @@ 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';
|
|
19
|
+
import { runEmail } from './email.js';
|
|
20
|
+
import { runMailbox } from './mailbox.js';
|
|
18
21
|
import { loadApiKey, saveApiKey } from './config.js';
|
|
19
22
|
import { loginViaDeviceFlow } from './login.js';
|
|
20
23
|
import { api, ApiError, DEFAULT_BASE, publish, resolveBase } from './publish.js';
|
|
@@ -84,6 +87,25 @@ Usage:
|
|
|
84
87
|
in your worker on next deploy/restart.
|
|
85
88
|
shiply cron <ls|set|rm> <slug> Manage cron triggers on the deployed worker
|
|
86
89
|
shiply cron set <slug> /api/cron/foo "0 9 * * *"
|
|
90
|
+
shiply contract <list|draft|show|send|pdf|amend|retract>
|
|
91
|
+
Customer contracts — full lifecycle CLI (B14).
|
|
92
|
+
list <project-id> — parent + amendments table
|
|
93
|
+
draft <project-id> — creates a draft you can edit
|
|
94
|
+
show <contract-id> — prints the full row as JSON
|
|
95
|
+
send <contract-id> — emails it to the customer
|
|
96
|
+
pdf <contract-id> -o file.pdf — signed PDF download
|
|
97
|
+
amend <id> --scope "…" [--fee-delta N] [--target-date YYYY-MM-DD]
|
|
98
|
+
retract <id> [--keep-as-draft] — undo a sent contract
|
|
99
|
+
shiply email send --site <slug> --to <addr> --subject <s> --html <h> [--text <t>]
|
|
100
|
+
Send a transactional email from a site's inbox address
|
|
101
|
+
shiply email inbox [--site <slug>] List email threads (optionally filtered by site)
|
|
102
|
+
shiply mailbox set <slug> <collection> [--confirm] [--no-confirm] [--notify] [--no-notify]
|
|
103
|
+
[--notify-to <email>] [--from <sendingDomainId>]
|
|
104
|
+
Update mailbox settings (double-opt-in, owner notifications…)
|
|
105
|
+
shiply mailbox broadcast <slug> <collection> --subject <s> --html <h> [--text <t>]
|
|
106
|
+
Send a broadcast to all contacts in a mailbox collection
|
|
107
|
+
shiply mailbox contacts <slug> <collection> [--status <signed_up|confirmed|unsubscribed>]
|
|
108
|
+
List contacts in a mailbox collection
|
|
87
109
|
shiply data init [dir] Scaffold a starter .shiply/data.json in <dir> (default cwd)
|
|
88
110
|
shiply data list <slug> List collections on a site with counts
|
|
89
111
|
shiply data query <slug> <coll> [--limit N] [--cursor C] [--where '<json>']
|
|
@@ -197,6 +219,21 @@ async function main() {
|
|
|
197
219
|
limit: { type: 'string' },
|
|
198
220
|
cursor: { type: 'string' },
|
|
199
221
|
where: { type: 'string' },
|
|
222
|
+
scope: { type: 'string' },
|
|
223
|
+
'fee-delta': { type: 'string' },
|
|
224
|
+
'target-date': { type: 'string' },
|
|
225
|
+
'keep-as-draft': { type: 'boolean' },
|
|
226
|
+
to: { type: 'string' },
|
|
227
|
+
subject: { type: 'string' },
|
|
228
|
+
html: { type: 'string' },
|
|
229
|
+
text: { type: 'string' },
|
|
230
|
+
status: { type: 'string' },
|
|
231
|
+
confirm: { type: 'boolean' },
|
|
232
|
+
'no-confirm': { type: 'boolean' },
|
|
233
|
+
notify: { type: 'boolean' },
|
|
234
|
+
'no-notify': { type: 'boolean' },
|
|
235
|
+
'notify-to': { type: 'string' },
|
|
236
|
+
from: { type: 'string' },
|
|
200
237
|
help: { type: 'boolean', short: 'h' },
|
|
201
238
|
version: { type: 'boolean', short: 'v' },
|
|
202
239
|
},
|
|
@@ -601,6 +638,95 @@ async function main() {
|
|
|
601
638
|
});
|
|
602
639
|
break;
|
|
603
640
|
}
|
|
641
|
+
case 'contract': {
|
|
642
|
+
const apiKey = values.key ?? (await loadApiKey());
|
|
643
|
+
if (!apiKey)
|
|
644
|
+
throw new Error('contract commands need an API key — run `shiply login` first');
|
|
645
|
+
const cctx = { base: values.base, apiKey };
|
|
646
|
+
const sub = dir; // positionals[1]
|
|
647
|
+
const arg = positionals[2]; // contract or project id
|
|
648
|
+
switch (sub) {
|
|
649
|
+
case 'list':
|
|
650
|
+
if (!arg)
|
|
651
|
+
throw new Error('usage: shiply contract list <project-id>');
|
|
652
|
+
await contractList(cctx, arg);
|
|
653
|
+
return;
|
|
654
|
+
case 'draft':
|
|
655
|
+
if (!arg)
|
|
656
|
+
throw new Error('usage: shiply contract draft <project-id>');
|
|
657
|
+
await contractDraft(cctx, arg);
|
|
658
|
+
return;
|
|
659
|
+
case 'show':
|
|
660
|
+
if (!arg)
|
|
661
|
+
throw new Error('usage: shiply contract show <contract-id>');
|
|
662
|
+
await contractShow(cctx, arg);
|
|
663
|
+
return;
|
|
664
|
+
case 'send':
|
|
665
|
+
if (!arg)
|
|
666
|
+
throw new Error('usage: shiply contract send <contract-id>');
|
|
667
|
+
await contractSend(cctx, arg);
|
|
668
|
+
return;
|
|
669
|
+
case 'pdf':
|
|
670
|
+
if (!arg)
|
|
671
|
+
throw new Error('usage: shiply contract pdf <contract-id> -o file.pdf');
|
|
672
|
+
await contractPdf(cctx, arg, values.out);
|
|
673
|
+
return;
|
|
674
|
+
case 'amend':
|
|
675
|
+
if (!arg)
|
|
676
|
+
throw new Error('usage: shiply contract amend <contract-id> --scope "…" [--fee-delta N] [--target-date YYYY-MM-DD]');
|
|
677
|
+
await contractAmend(cctx, arg, {
|
|
678
|
+
scope: values.scope,
|
|
679
|
+
feeDelta: values['fee-delta'],
|
|
680
|
+
targetDate: values['target-date'],
|
|
681
|
+
});
|
|
682
|
+
return;
|
|
683
|
+
case 'retract':
|
|
684
|
+
if (!arg)
|
|
685
|
+
throw new Error('usage: shiply contract retract <contract-id> [--keep-as-draft]');
|
|
686
|
+
await contractRetract(cctx, arg, { keepAsDraft: Boolean(values['keep-as-draft']) });
|
|
687
|
+
return;
|
|
688
|
+
default:
|
|
689
|
+
throw new Error('usage: shiply contract <list|draft|show|send|pdf|amend|retract>');
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
case 'email': {
|
|
693
|
+
await runEmail(positionals.slice(1), {
|
|
694
|
+
base: values.base,
|
|
695
|
+
key: values.key,
|
|
696
|
+
site: values.site,
|
|
697
|
+
to: values.to,
|
|
698
|
+
subject: values.subject,
|
|
699
|
+
html: values.html,
|
|
700
|
+
text: values.text,
|
|
701
|
+
});
|
|
702
|
+
break;
|
|
703
|
+
}
|
|
704
|
+
case 'mailbox': {
|
|
705
|
+
// Resolve tri-state for confirm/notify:
|
|
706
|
+
// --confirm → true (values.confirm === true)
|
|
707
|
+
// --no-confirm → false (values['no-confirm'] === true)
|
|
708
|
+
// neither provided → undefined (omit from PATCH body)
|
|
709
|
+
const confirmValue = values.confirm === true ? true :
|
|
710
|
+
values['no-confirm'] === true ? false :
|
|
711
|
+
undefined;
|
|
712
|
+
const notifyValue = values.notify === true ? true :
|
|
713
|
+
values['no-notify'] === true ? false :
|
|
714
|
+
undefined;
|
|
715
|
+
await runMailbox(positionals.slice(1), {
|
|
716
|
+
base: values.base,
|
|
717
|
+
key: values.key,
|
|
718
|
+
site: values.site,
|
|
719
|
+
subject: values.subject,
|
|
720
|
+
html: values.html,
|
|
721
|
+
text: values.text,
|
|
722
|
+
status: values.status,
|
|
723
|
+
...(confirmValue !== undefined ? { confirm: confirmValue } : {}),
|
|
724
|
+
...(notifyValue !== undefined ? { notify: notifyValue } : {}),
|
|
725
|
+
'notify-to': values['notify-to'],
|
|
726
|
+
from: values.from,
|
|
727
|
+
});
|
|
728
|
+
break;
|
|
729
|
+
}
|
|
604
730
|
case 'claim': {
|
|
605
731
|
if (dir !== 'verify') {
|
|
606
732
|
console.error('Usage: shiply claim verify <SHIPLY-XXXXXXXX>');
|
package/dist/mailbox.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/** `shiply mailbox <subcommand>` — mailbox settings, broadcasts, contacts.
|
|
2
|
+
*
|
|
3
|
+
* Pattern mirrors functions.ts: readFlags helper, exported runMailbox
|
|
4
|
+
* entry point, individual async command functions. Top-level flags
|
|
5
|
+
* (--base / --key) are forwarded from index.ts via InheritedFlags. */
|
|
6
|
+
import { loadApiKey } from './config.js';
|
|
7
|
+
import { api, resolveBase } from './publish.js';
|
|
8
|
+
function readFlags(argv, inherited = {}) {
|
|
9
|
+
const raw = {};
|
|
10
|
+
const rest = [];
|
|
11
|
+
for (let i = 0; i < argv.length; i++) {
|
|
12
|
+
const a = argv[i];
|
|
13
|
+
if (a.startsWith('--no-')) {
|
|
14
|
+
// --no-confirm, --no-notify → store as false
|
|
15
|
+
raw[a.slice(5)] = false;
|
|
16
|
+
}
|
|
17
|
+
else if (a.startsWith('--')) {
|
|
18
|
+
const eq = a.indexOf('=');
|
|
19
|
+
if (eq >= 0) {
|
|
20
|
+
raw[a.slice(2, eq)] = a.slice(eq + 1);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
const next = argv[i + 1];
|
|
24
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
25
|
+
raw[a.slice(2)] = next;
|
|
26
|
+
i++;
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
raw[a.slice(2)] = true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
rest.push(a);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// For each field: local argv flag takes precedence, then fall back to the
|
|
38
|
+
// value forwarded from index.ts parseArgs via inherited (the top-level
|
|
39
|
+
// parseArgs consumed these flags before argv reached us).
|
|
40
|
+
const flags = {
|
|
41
|
+
base: (typeof raw.base === 'string' ? raw.base : undefined) ?? inherited.base,
|
|
42
|
+
key: (typeof raw.key === 'string' ? raw.key : undefined) ?? inherited.key,
|
|
43
|
+
from: (typeof raw.from === 'string' ? raw.from : undefined) ?? inherited.from,
|
|
44
|
+
'notify-to': (typeof raw['notify-to'] === 'string' ? raw['notify-to'] : undefined) ?? inherited['notify-to'],
|
|
45
|
+
subject: (typeof raw.subject === 'string' ? raw.subject : undefined) ?? inherited.subject,
|
|
46
|
+
html: (typeof raw.html === 'string' ? raw.html : undefined) ?? inherited.html,
|
|
47
|
+
text: (typeof raw.text === 'string' ? raw.text : undefined) ?? inherited.text,
|
|
48
|
+
status: (typeof raw.status === 'string' ? raw.status : undefined) ?? inherited.status,
|
|
49
|
+
};
|
|
50
|
+
// Boolean toggles — only set when the flag was explicitly provided (local
|
|
51
|
+
// argv), or when index.ts forwarded a parsed value via inherited.
|
|
52
|
+
if ('confirm' in raw) {
|
|
53
|
+
flags.confirm = Boolean(raw.confirm);
|
|
54
|
+
}
|
|
55
|
+
else if (inherited.confirm !== undefined) {
|
|
56
|
+
flags.confirm = inherited.confirm;
|
|
57
|
+
}
|
|
58
|
+
if ('notify' in raw) {
|
|
59
|
+
flags.notify = Boolean(raw.notify);
|
|
60
|
+
}
|
|
61
|
+
else if (inherited.notify !== undefined) {
|
|
62
|
+
flags.notify = inherited.notify;
|
|
63
|
+
}
|
|
64
|
+
return { rest, flags };
|
|
65
|
+
}
|
|
66
|
+
const headers = (apiKey) => ({
|
|
67
|
+
'content-type': 'application/json',
|
|
68
|
+
authorization: `Bearer ${apiKey}`,
|
|
69
|
+
});
|
|
70
|
+
async function getApiKey(flags, what) {
|
|
71
|
+
const k = flags.key ?? (await loadApiKey());
|
|
72
|
+
if (!k)
|
|
73
|
+
throw new Error(`${what} commands need an API key — run \`shiply login\` first`);
|
|
74
|
+
return k;
|
|
75
|
+
}
|
|
76
|
+
const MAILBOX_USAGE = [
|
|
77
|
+
'Usage: shiply mailbox <set|broadcast|contacts>',
|
|
78
|
+
' shiply mailbox set <slug> <collection> [--confirm] [--no-confirm] [--notify] [--no-notify] [--notify-to <email>] [--from <sendingDomainId>]',
|
|
79
|
+
' shiply mailbox broadcast <slug> <collection> --subject <s> --html <h> [--text <t>]',
|
|
80
|
+
' shiply mailbox contacts <slug> <collection> [--status <signed_up|confirmed|unsubscribed>]',
|
|
81
|
+
].join('\n');
|
|
82
|
+
export async function runMailbox(argv, inherited = {}) {
|
|
83
|
+
const action = argv[0];
|
|
84
|
+
const rest = argv.slice(1);
|
|
85
|
+
switch (action) {
|
|
86
|
+
case 'set':
|
|
87
|
+
return mailboxSetCmd(rest, inherited);
|
|
88
|
+
case 'broadcast':
|
|
89
|
+
return mailboxBroadcastCmd(rest, inherited);
|
|
90
|
+
case 'contacts':
|
|
91
|
+
return mailboxContactsCmd(rest, inherited);
|
|
92
|
+
default:
|
|
93
|
+
console.error(MAILBOX_USAGE);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function mailboxSetCmd(argv, inherited) {
|
|
98
|
+
const { rest, flags } = readFlags(argv, inherited);
|
|
99
|
+
const [slug, collection] = rest;
|
|
100
|
+
if (!slug || !collection) {
|
|
101
|
+
console.error('usage: shiply mailbox set <slug> <collection> [options]');
|
|
102
|
+
console.error(MAILBOX_USAGE);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
const apiKey = await getApiKey(flags, 'mailbox');
|
|
106
|
+
const base = resolveBase(flags.base);
|
|
107
|
+
// Only include fields that were explicitly provided
|
|
108
|
+
const body = {};
|
|
109
|
+
if (flags.confirm !== undefined)
|
|
110
|
+
body.doubleOptIn = flags.confirm;
|
|
111
|
+
if (flags.notify !== undefined)
|
|
112
|
+
body.notifyOwner = flags.notify;
|
|
113
|
+
if (flags['notify-to'] !== undefined)
|
|
114
|
+
body.notifyTo = flags['notify-to'];
|
|
115
|
+
if (flags.from !== undefined)
|
|
116
|
+
body.sendingDomainId = flags.from;
|
|
117
|
+
const r = await api(`${base}/api/v1/publishes/${encodeURIComponent(slug)}/mailboxes/${encodeURIComponent(collection)}`, {
|
|
118
|
+
method: 'PATCH',
|
|
119
|
+
headers: headers(apiKey),
|
|
120
|
+
body: JSON.stringify(body),
|
|
121
|
+
});
|
|
122
|
+
console.log(`✔ mailbox updated for ${slug}/${collection}`);
|
|
123
|
+
if (r.doubleOptIn !== undefined)
|
|
124
|
+
console.log(` doubleOptIn: ${r.doubleOptIn}`);
|
|
125
|
+
if (r.notifyOwner !== undefined)
|
|
126
|
+
console.log(` notifyOwner: ${r.notifyOwner}`);
|
|
127
|
+
if (r.notifyTo !== undefined && r.notifyTo !== null)
|
|
128
|
+
console.log(` notifyTo: ${r.notifyTo}`);
|
|
129
|
+
if (r.sendingDomainId !== undefined && r.sendingDomainId !== null)
|
|
130
|
+
console.log(` sendingDomainId: ${r.sendingDomainId}`);
|
|
131
|
+
}
|
|
132
|
+
async function mailboxBroadcastCmd(argv, inherited) {
|
|
133
|
+
const { rest, flags } = readFlags(argv, inherited);
|
|
134
|
+
const [slug, collection] = rest;
|
|
135
|
+
if (!slug || !collection) {
|
|
136
|
+
console.error('usage: shiply mailbox broadcast <slug> <collection> --subject <s> --html <h>');
|
|
137
|
+
console.error(MAILBOX_USAGE);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
if (!flags.subject) {
|
|
141
|
+
console.error('--subject <s> is required');
|
|
142
|
+
console.error(MAILBOX_USAGE);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
if (!flags.html) {
|
|
146
|
+
console.error('--html <h> is required');
|
|
147
|
+
console.error(MAILBOX_USAGE);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
const apiKey = await getApiKey(flags, 'mailbox');
|
|
151
|
+
const base = resolveBase(flags.base);
|
|
152
|
+
const body = {
|
|
153
|
+
subject: flags.subject,
|
|
154
|
+
html: flags.html,
|
|
155
|
+
};
|
|
156
|
+
if (flags.text)
|
|
157
|
+
body.text = flags.text;
|
|
158
|
+
const r = await api(`${base}/api/v1/publishes/${encodeURIComponent(slug)}/mailboxes/${encodeURIComponent(collection)}/broadcast`, {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers: headers(apiKey),
|
|
161
|
+
body: JSON.stringify(body),
|
|
162
|
+
});
|
|
163
|
+
console.log(`✔ broadcast queued`);
|
|
164
|
+
console.log(` broadcastId: ${r.broadcastId}`);
|
|
165
|
+
console.log(` recipientCount: ${r.recipientCount}`);
|
|
166
|
+
}
|
|
167
|
+
async function mailboxContactsCmd(argv, inherited) {
|
|
168
|
+
const { rest, flags } = readFlags(argv, inherited);
|
|
169
|
+
const [slug, collection] = rest;
|
|
170
|
+
if (!slug || !collection) {
|
|
171
|
+
console.error('usage: shiply mailbox contacts <slug> <collection> [--status <...>]');
|
|
172
|
+
console.error(MAILBOX_USAGE);
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
const apiKey = await getApiKey(flags, 'mailbox');
|
|
176
|
+
const base = resolveBase(flags.base);
|
|
177
|
+
const qs = flags.status ? `?status=${encodeURIComponent(flags.status)}` : '';
|
|
178
|
+
const r = await api(`${base}/api/v1/publishes/${encodeURIComponent(slug)}/mailboxes/${encodeURIComponent(collection)}/contacts${qs}`, { headers: headers(apiKey) });
|
|
179
|
+
if (!r.contacts.length) {
|
|
180
|
+
console.log(`No contacts in ${slug}/${collection}${flags.status ? ` (status: ${flags.status})` : ''} yet.`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
// Compact table: EMAIL | STATUS | CONFIRMED | CREATED
|
|
184
|
+
const cols = r.contacts.map((c) => ({
|
|
185
|
+
email: c.email,
|
|
186
|
+
status: c.status ?? '',
|
|
187
|
+
confirmed: c.confirmedAt ?? '',
|
|
188
|
+
created: c.createdAt ?? '',
|
|
189
|
+
}));
|
|
190
|
+
const w = {
|
|
191
|
+
email: Math.max('EMAIL'.length, ...cols.map((c) => c.email.length)),
|
|
192
|
+
status: Math.max('STATUS'.length, ...cols.map((c) => c.status.length)),
|
|
193
|
+
confirmed: Math.max('CONFIRMED'.length, ...cols.map((c) => c.confirmed.length)),
|
|
194
|
+
};
|
|
195
|
+
console.log(`${'EMAIL'.padEnd(w.email)} ${'STATUS'.padEnd(w.status)} ${'CONFIRMED'.padEnd(w.confirmed)} CREATED`);
|
|
196
|
+
for (const c of cols) {
|
|
197
|
+
console.log(`${c.email.padEnd(w.email)} ${c.status.padEnd(w.status)} ${c.confirmed.padEnd(w.confirmed)} ${c.created}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -18,7 +18,7 @@ description: Publish static sites to the web instantly with shiply.now and manag
|
|
|
18
18
|
> fresh — your cached copy may be missing features like databases, projects,
|
|
19
19
|
> marketplace, or sending domains.
|
|
20
20
|
>
|
|
21
|
-
> **Last updated: 2026-06-
|
|
21
|
+
> **Last updated: 2026-06-22**
|
|
22
22
|
|
|
23
23
|
---
|
|
24
24
|
|
|
@@ -154,6 +154,8 @@ lines for automation; exit code 0 = ready.
|
|
|
154
154
|
(+ "Authorization: Bearer shp_…" for permanent owned sites)
|
|
155
155
|
2. PUT each file's bytes to response upload.uploads[].url
|
|
156
156
|
3. POST upload.finalizeUrl with {"versionId":"..."}
|
|
157
|
+
→ both the publish and finalize responses include a "toUpdate" string: the
|
|
158
|
+
exact call to update THIS site next time. Follow it — never create a new one.
|
|
157
159
|
```
|
|
158
160
|
|
|
159
161
|
**ALWAYS include `agentName` on anonymous publishes.** The response then
|
|
@@ -164,9 +166,11 @@ poll `poll_url` every `interval` seconds with the `device_code`, and on
|
|
|
164
166
|
is claimed in the same Allow click. Without `agentName` the user has to
|
|
165
167
|
do a separate manual claim step every time.
|
|
166
168
|
|
|
167
|
-
**Updates
|
|
168
|
-
|
|
169
|
-
|
|
169
|
+
**Updates (the response tells you how):** every publish/finalize response
|
|
170
|
+
includes a `toUpdate` string with the exact call — follow it verbatim. In short:
|
|
171
|
+
anonymous → include `"claimToken":"..."` (returned ONCE by the first publish —
|
|
172
|
+
save it); owned → include `"slug":"..."`. Hashes make updates cheap: unchanged
|
|
173
|
+
files are skipped server-side.
|
|
170
174
|
|
|
171
175
|
## The lifecycle to explain to users
|
|
172
176
|
- Anonymous site: live instantly, expires in 24 h. Give the user the
|
|
@@ -265,6 +269,98 @@ validated server-side; the owner reads them in the dashboard (Data section,
|
|
|
265
269
|
CSV export) or GET /api/v1/publishes/<slug>/data/<collection> with a Bearer
|
|
266
270
|
key. Owned sites only. Docs: /docs/site-data
|
|
267
271
|
|
|
272
|
+
## Agent Email — inbox, send, capture, broadcast (built into every owned site)
|
|
273
|
+
|
|
274
|
+
Every owned site has a real email address and can send, receive, capture signups,
|
|
275
|
+
and broadcast — in one line. Like AgentMail, built into the site.
|
|
276
|
+
|
|
277
|
+
### Capture (public, zero-config)
|
|
278
|
+
```
|
|
279
|
+
POST /.shiply/email { "email": "user@example.com", ...anyExtraFields }
|
|
280
|
+
```
|
|
281
|
+
No manifest needed. Works on the **relative path** from the published page.
|
|
282
|
+
On success: record lands in the site inbox + owner gets a notification ping +
|
|
283
|
+
a double-opt-in confirmation email goes to the visitor (all on by default).
|
|
284
|
+
**Owned sites only** — anonymous sites get `403 email_requires_account` (claim
|
|
285
|
+
the site first). A non-empty `company` field is treated as a honeypot (bot
|
|
286
|
+
submissions are silently dropped). Caps: 16 KB body, 30 fields max.
|
|
287
|
+
|
|
288
|
+
### Send (authenticated — Bearer key only)
|
|
289
|
+
The public page can NEVER send; only authenticated calls can.
|
|
290
|
+
```
|
|
291
|
+
POST https://shiply.now/api/v1/email/send
|
|
292
|
+
Authorization: Bearer shp_…
|
|
293
|
+
{ "slug": "my-site", "to": "user@example.com", "subject": "Hi", "html": "<p>Hi</p>", "text": "Hi" }
|
|
294
|
+
→ { "messageId": "..." }
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### Read inbox
|
|
298
|
+
```
|
|
299
|
+
GET https://shiply.now/api/v1/email/inbox # all sites
|
|
300
|
+
GET https://shiply.now/api/v1/email/inbox?slug=my-site
|
|
301
|
+
Authorization: Bearer shp_…
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### Typed upgrade (optional)
|
|
305
|
+
Declare an `email` block on a `.shiply/data.json` collection for typed fields +
|
|
306
|
+
fine-grained control:
|
|
307
|
+
```json
|
|
308
|
+
{
|
|
309
|
+
"collections": {
|
|
310
|
+
"signups": {
|
|
311
|
+
"fields": { "email": { "type": "email", "required": true } },
|
|
312
|
+
"email": {
|
|
313
|
+
"emailField": "email",
|
|
314
|
+
"confirm": true,
|
|
315
|
+
"notify": true,
|
|
316
|
+
"audience": true
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
```
|
|
322
|
+
All three flags default to `true`. With this manifest, POST to
|
|
323
|
+
`/.shiply/data/signups` as usual — the `email` block activates the email
|
|
324
|
+
layer on that collection.
|
|
325
|
+
|
|
326
|
+
### Confirm / unsubscribe (public — in the confirmation email)
|
|
327
|
+
```
|
|
328
|
+
GET /api/email/<slug>/<collection>/confirm?token=<token>
|
|
329
|
+
GET /api/email/<slug>/<collection>/unsubscribe?email=<address>
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### Audience + broadcast
|
|
333
|
+
Confirmed (double-opt-in) signups form a list. Broadcast to them:
|
|
334
|
+
```
|
|
335
|
+
POST /api/v1/publishes/<slug>/mailboxes/<collection>/broadcast
|
|
336
|
+
Authorization: Bearer shp_…
|
|
337
|
+
{ "subject": "Launch day", "html": "<p>We're live!</p>" }
|
|
338
|
+
```
|
|
339
|
+
Spam-checked before send; unsubscribe footer is auto-added; requires ≥1
|
|
340
|
+
confirmed subscriber.
|
|
341
|
+
|
|
342
|
+
Configure a mailbox: `GET/PATCH /api/v1/publishes/<slug>/mailboxes/<collection>`
|
|
343
|
+
List contacts: `GET /api/v1/publishes/<slug>/mailboxes/<collection>/contacts?status=confirmed`
|
|
344
|
+
|
|
345
|
+
### MCP tools
|
|
346
|
+
| Tool | Purpose |
|
|
347
|
+
|---|---|
|
|
348
|
+
| `send_email` | Send transactional email from a site (Bearer) |
|
|
349
|
+
| `list_site_inbox` | Read the inbox (all threads or filter by slug) |
|
|
350
|
+
| `set_mailbox` | Configure a collection's mailbox settings |
|
|
351
|
+
| `list_mailbox_contacts` | List audience contacts (filter by status) |
|
|
352
|
+
| `send_mailbox_broadcast` | Broadcast to confirmed audience (spam-checked) |
|
|
353
|
+
|
|
354
|
+
### CLI
|
|
355
|
+
```bash
|
|
356
|
+
shiply email send --site <slug> --to <address> --subject <subject> --html <html>
|
|
357
|
+
shiply email inbox [--site <slug>]
|
|
358
|
+
shiply mailbox set <slug> <collection> [--confirm] [--notify] [--from <domain>]
|
|
359
|
+
shiply mailbox broadcast <slug> <collection> --subject <subject> --html <html>
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
Docs: https://shiply.now/docs/agent-email
|
|
363
|
+
|
|
268
364
|
## SQL databases (Cloudflare D1 + Neon Postgres)
|
|
269
365
|
Two engines, same CLI/REST/MCP surface. **D1 (SQLite at the edge)** is
|
|
270
366
|
free on every plan and queryable from the browser via a built-in fetch
|
|
@@ -493,6 +589,56 @@ CLI: `shiply project ls · shiply project create <label> [--customer-email <e>]
|
|
|
493
589
|
|
|
494
590
|
Docs: https://shiply.now/docs/projects
|
|
495
591
|
|
|
592
|
+
## Contracts — draft, sign, amend (extends Projects)
|
|
593
|
+
|
|
594
|
+
Once a project reaches `brief_ready`, the dev drafts a signed contract from
|
|
595
|
+
the brief, the customer e-signs in the portal (typed-name + checkbox), and
|
|
596
|
+
the dev amends it later as scope evolves. A signed contract is immutable
|
|
597
|
+
— any change is a new amendment row chained off the parent. The portal
|
|
598
|
+
sends the customer; the dev can resend / remind / retract. A 7-day
|
|
599
|
+
reminder cron nudges unsigned contracts automatically.
|
|
600
|
+
|
|
601
|
+
MCP tools:
|
|
602
|
+
```
|
|
603
|
+
contract_draft — draft a contract from a brief_ready project (returns the new contract id)
|
|
604
|
+
contract_send — flip status='sent', fire customer email (parent OR amendment)
|
|
605
|
+
contract_amend — create an amendment draft on a SIGNED parent (scopeDelta required, fee + date optional)
|
|
606
|
+
contract_status — read state + amendments (poll this to see status='signed')
|
|
607
|
+
contract_pdf — base64-encoded signed PDF (parent + cert + signed amendments)
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
Edit-before-send today is REST-only: `PATCH /api/v1/contracts/{id}` lets
|
|
611
|
+
the dev tweak the 8 fields (scopeSummary, feeCents, currency, dates,
|
|
612
|
+
revisionCount, revisionOverageCents, jurisdiction). After PATCH, call
|
|
613
|
+
`contract_send` to fire it. Amendments are sent the same way (call
|
|
614
|
+
`contract_send` with the amendment id returned by `contract_amend`).
|
|
615
|
+
|
|
616
|
+
Example agent flow (draft → tweak fee → send → poll → amend):
|
|
617
|
+
1. `contract_draft({projectId})` → returns `{id, scopeSummary, feeCents:null, ...}`
|
|
618
|
+
2. `PATCH /api/v1/contracts/{id}` `{feeCents: 450000}` (Bearer key)
|
|
619
|
+
3. `contract_send({contractId: id})` → project flips to `contract_sent`
|
|
620
|
+
4. Poll `contract_status({contractId: id})` until `contract.status === 'signed'`
|
|
621
|
+
5. `contract_amend({parentContractId: id, scopeDelta: "Add login flow", feeDeltaCents: 50000})` → returns the amendment row
|
|
622
|
+
6. `contract_send({contractId: amendment.id})` (no PATCH needed if the amend fields are right)
|
|
623
|
+
|
|
624
|
+
REST (Bearer for dev, portal cookie for customer):
|
|
625
|
+
`POST /api/v1/projects/{id}/contracts` (draft) ·
|
|
626
|
+
`GET/PATCH /api/v1/contracts/{id}` (read either-party / dev edit) ·
|
|
627
|
+
`POST /api/v1/contracts/{id}/send` (dev) ·
|
|
628
|
+
`POST /api/v1/contracts/{id}/view` (customer ping) ·
|
|
629
|
+
`POST /api/v1/contracts/{id}/sign` (customer e-sign) ·
|
|
630
|
+
`POST /api/v1/contracts/{id}/retract` (dev; `?recoverAsDraft=true` to
|
|
631
|
+
recover the draft instead of voiding) ·
|
|
632
|
+
`POST /api/v1/contracts/{id}/amend` (dev; signed parents only) ·
|
|
633
|
+
`POST /api/v1/contracts/{id}/remind` ·
|
|
634
|
+
`POST /api/v1/contracts/{id}/resend` (manual nudges, rate-limited 3/24h) ·
|
|
635
|
+
`GET /api/v1/contracts/{id}/pdf` (binary, signed only).
|
|
636
|
+
|
|
637
|
+
`contract_pdf` returns `{filename, contentType:"application/pdf", base64,
|
|
638
|
+
byteLength}` so the MCP client can decode and save the bytes.
|
|
639
|
+
|
|
640
|
+
Docs: https://shiply.now/docs/contracts
|
|
641
|
+
|
|
496
642
|
## Marketplace — sell built sites
|
|
497
643
|
|
|
498
644
|
Devs can list any owned site for sale: set a price + short pitch, choose
|