shiply-cli 0.15.0 → 0.17.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/confetti.js +5 -0
- package/dist/email.js +147 -0
- package/dist/ignore.js +65 -0
- package/dist/index.js +154 -8
- package/dist/mailbox.js +199 -0
- package/dist/manifest.js +13 -4
- package/dist/previews.js +26 -0
- package/dist/sites.js +70 -0
- package/dist/verify.js +26 -0
- package/package.json +1 -1
- package/skill/SKILL.md +141 -4
package/dist/confetti.js
CHANGED
|
@@ -26,3 +26,8 @@ export function confetti(message = '⛵ SITE IS LIVE ⛵') {
|
|
|
26
26
|
}
|
|
27
27
|
return out;
|
|
28
28
|
}
|
|
29
|
+
/** Celebrate only on an interactive terminal and only when not opted out.
|
|
30
|
+
* Piped / redirected stdout (agents, CI, scrapers) never gets ANSI confetti. */
|
|
31
|
+
export function shouldCelebrate(noConfetti, isTty = process.stdout.isTTY) {
|
|
32
|
+
return !noConfetti && isTty === true;
|
|
33
|
+
}
|
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/ignore.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
function globBody(glob, anchored) {
|
|
4
|
+
// Escape regex specials, but keep * and / for glob expansion.
|
|
5
|
+
let g = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
6
|
+
g = g
|
|
7
|
+
.replace(/\*\*\//g, '(?:.+/)?') // **/ → any number of leading dirs
|
|
8
|
+
.replace(/\*\*/g, '.*') // ** → anything, crosses slashes
|
|
9
|
+
.replace(/\*/g, '[^/]*'); // * → anything but a slash
|
|
10
|
+
return anchored ? `^${g}` : `(?:^|/)${g}`;
|
|
11
|
+
}
|
|
12
|
+
function parseRule(raw) {
|
|
13
|
+
let pattern = raw.trim();
|
|
14
|
+
const negate = pattern.startsWith('!');
|
|
15
|
+
if (negate)
|
|
16
|
+
pattern = pattern.slice(1);
|
|
17
|
+
const dirOnly = pattern.endsWith('/');
|
|
18
|
+
if (dirOnly)
|
|
19
|
+
pattern = pattern.slice(0, -1);
|
|
20
|
+
const anchored = pattern.startsWith('/');
|
|
21
|
+
if (anchored)
|
|
22
|
+
pattern = pattern.slice(1);
|
|
23
|
+
const body = globBody(pattern, anchored);
|
|
24
|
+
// re: matches the path itself OR anything beneath it.
|
|
25
|
+
// descendantRe: matches ONLY things strictly beneath the named path. A
|
|
26
|
+
// dir-only rule uses re for directories (and their subtrees) but descendantRe
|
|
27
|
+
// for files, so a FILE that merely shares the dir's name is kept.
|
|
28
|
+
return {
|
|
29
|
+
re: new RegExp(`${body}(?:/|$)`),
|
|
30
|
+
descendantRe: new RegExp(`${body}/`),
|
|
31
|
+
negate,
|
|
32
|
+
dirOnly,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Compile .shiplyignore lines (a documented gitignore subset) into a matcher.
|
|
36
|
+
* Supported: # comments, blank lines, trailing-slash dir-only rules,
|
|
37
|
+
* leading-slash root anchoring, * and ** globs, and ! negation. Last match wins. */
|
|
38
|
+
export function compileIgnore(lines) {
|
|
39
|
+
const rules = lines
|
|
40
|
+
.map((l) => l.replace(/\r$/, ''))
|
|
41
|
+
.filter((l) => l.trim() !== '' && !l.trimStart().startsWith('#'))
|
|
42
|
+
.map(parseRule);
|
|
43
|
+
return (relPath, isDir) => {
|
|
44
|
+
let excluded = false;
|
|
45
|
+
for (const r of rules) {
|
|
46
|
+
// A dir-only rule excludes the directory itself (and its subtree) but, for
|
|
47
|
+
// a plain file, only when that file lives BENEATH the named directory —
|
|
48
|
+
// never a file that merely shares the directory's name.
|
|
49
|
+
const re = r.dirOnly && !isDir ? r.descendantRe : r.re;
|
|
50
|
+
if (re.test(relPath))
|
|
51
|
+
excluded = !r.negate;
|
|
52
|
+
}
|
|
53
|
+
return excluded;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Load `.shiplyignore` from the publish root; null when absent. */
|
|
57
|
+
export async function loadIgnore(dir) {
|
|
58
|
+
try {
|
|
59
|
+
const raw = await readFile(join(dir, '.shiplyignore'), 'utf8');
|
|
60
|
+
return compileIgnore(raw.split('\n'));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { dirname, join } from 'node:path';
|
|
|
4
4
|
import { createInterface } from 'node:readline/promises';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { parseArgs } from 'node:util';
|
|
7
|
-
import { confetti } from './confetti.js';
|
|
7
|
+
import { confetti, shouldCelebrate } from './confetti.js';
|
|
8
8
|
import { runDetect } from './detect.js';
|
|
9
9
|
import { resolvePayloadDir } from './framework.js';
|
|
10
10
|
import { runClaimVerify } from './claim.js';
|
|
@@ -16,13 +16,18 @@ import { runListing } from './listings.js';
|
|
|
16
16
|
import { runSendingDomain } from './sending-domains.js';
|
|
17
17
|
import { contractAmend, contractDraft, contractList, contractPdf, contractRetract, contractSend, contractShow, } from './contract.js';
|
|
18
18
|
import { runCron, runFunction, runSecret } from './functions.js';
|
|
19
|
+
import { runEmail } from './email.js';
|
|
20
|
+
import { runMailbox } from './mailbox.js';
|
|
19
21
|
import { loadApiKey, saveApiKey } from './config.js';
|
|
20
22
|
import { loginViaDeviceFlow } from './login.js';
|
|
21
23
|
import { api, ApiError, DEFAULT_BASE, publish, resolveBase } from './publish.js';
|
|
22
24
|
import { installSkill } from './skill.js';
|
|
23
25
|
import { readState, writeState } from './state.js';
|
|
26
|
+
import { readPreview, writePreview } from './previews.js';
|
|
24
27
|
import { checkReadiness, targetToHostname } from './status.js';
|
|
25
28
|
import { runStatus } from './status-cmd.js';
|
|
29
|
+
import { sitesLs, sitesPromote, sitesRm, sitesRollback } from './sites.js';
|
|
30
|
+
import { verifySite } from './verify.js';
|
|
26
31
|
// Resolve the package.json beside the compiled dist/ so `shiply --version`
|
|
27
32
|
// can print the running CLI version (works in dev tsc output + npm install).
|
|
28
33
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -45,10 +50,13 @@ const HELP = `shiply — instant static hosting for agents (https://shiply.now)
|
|
|
45
50
|
Usage:
|
|
46
51
|
shiply publish <dir> [options] Publish a directory, print the live URL.
|
|
47
52
|
Re-running UPDATES the same site (state in .shiply.json)
|
|
53
|
+
shiply publish <dir> --as <name> Stable preview: reuse the same URL across iterations
|
|
48
54
|
shiply update <dir> Same as publish when .shiply.json exists
|
|
49
55
|
shiply status <slug-or-domain> [--wait] SSL + readiness check (agent-friendly output)
|
|
50
56
|
(no arg → account/plan status, see below)
|
|
51
57
|
shiply duplicate <slug> Server-side copy of an owned site → new URL
|
|
58
|
+
shiply promote <preview-slug> --to <dest-slug>
|
|
59
|
+
Copy the exact previewed bytes into a production site (no rebuild)
|
|
52
60
|
shiply detect [dir] Diagnose: print framework + dir that would upload
|
|
53
61
|
(no upload — use --framework=<name> to test an override)
|
|
54
62
|
shiply drive <ls|put|get|rm|publish> Private cloud storage (drive publish → live site)
|
|
@@ -94,6 +102,16 @@ Usage:
|
|
|
94
102
|
pdf <contract-id> -o file.pdf — signed PDF download
|
|
95
103
|
amend <id> --scope "…" [--fee-delta N] [--target-date YYYY-MM-DD]
|
|
96
104
|
retract <id> [--keep-as-draft] — undo a sent contract
|
|
105
|
+
shiply email send --site <slug> --to <addr> --subject <s> --html <h> [--text <t>]
|
|
106
|
+
Send a transactional email from a site's inbox address
|
|
107
|
+
shiply email inbox [--site <slug>] List email threads (optionally filtered by site)
|
|
108
|
+
shiply mailbox set <slug> <collection> [--confirm] [--no-confirm] [--notify] [--no-notify]
|
|
109
|
+
[--notify-to <email>] [--from <sendingDomainId>]
|
|
110
|
+
Update mailbox settings (double-opt-in, owner notifications…)
|
|
111
|
+
shiply mailbox broadcast <slug> <collection> --subject <s> --html <h> [--text <t>]
|
|
112
|
+
Send a broadcast to all contacts in a mailbox collection
|
|
113
|
+
shiply mailbox contacts <slug> <collection> [--status <signed_up|confirmed|unsubscribed>]
|
|
114
|
+
List contacts in a mailbox collection
|
|
97
115
|
shiply data init [dir] Scaffold a starter .shiply/data.json in <dir> (default cwd)
|
|
98
116
|
shiply data list <slug> List collections on a site with counts
|
|
99
117
|
shiply data query <slug> <coll> [--limit N] [--cursor C] [--where '<json>']
|
|
@@ -128,11 +146,13 @@ Options:
|
|
|
128
146
|
--base <url> API origin (default: ${DEFAULT_BASE})
|
|
129
147
|
--wait (status) poll until the site is ready
|
|
130
148
|
--timeout <seconds> (status --wait) give up after this long (default 300)
|
|
149
|
+
--json (publish/update) print one JSON line {slug,siteUrl,…}, no decoration
|
|
131
150
|
--json <body> (data insert) JSON object to insert
|
|
132
151
|
--limit <N> (data query) max records to return (default 50)
|
|
133
152
|
--cursor <iso> (data query) pagination cursor from previous response
|
|
134
153
|
--where <json> (data query) client-side filter, e.g. '{"email":"a@b.co"}'
|
|
135
154
|
--yes (data wipe-collection) skip the safety prompt
|
|
155
|
+
--as <name> (publish) stable named preview — same URL every iteration
|
|
136
156
|
--no-confetti Celebrate quietly
|
|
137
157
|
|
|
138
158
|
\`shiply status\` prints stable machine-readable markers for agents:
|
|
@@ -161,7 +181,7 @@ async function reportReadiness(host, celebrate) {
|
|
|
161
181
|
else {
|
|
162
182
|
console.log(`✖ site: not ready${r.http.status ? ` (HTTP ${r.http.status})` : r.http.error ? ` — ${r.http.error}` : ''}`);
|
|
163
183
|
}
|
|
164
|
-
if (r.ready && celebrate)
|
|
184
|
+
if (r.ready && celebrate && shouldCelebrate(false))
|
|
165
185
|
console.log(confetti());
|
|
166
186
|
return r.ready;
|
|
167
187
|
}
|
|
@@ -171,12 +191,17 @@ async function main() {
|
|
|
171
191
|
// Detect the bare flag in raw argv BEFORE parseArgs sees it; strip the
|
|
172
192
|
// token so the parser doesn't complain about a missing string value.
|
|
173
193
|
const rawArgv = process.argv.slice(2);
|
|
174
|
-
|
|
194
|
+
// `--json` is a STRING option (data-insert body). For commands where it's a
|
|
195
|
+
// boolean flag (status/publish/update), detect a BARE --json (no following
|
|
196
|
+
// value) in raw argv and strip it before parseArgs sees it.
|
|
197
|
+
const BARE_JSON_CMDS = new Set(['status', 'publish', 'update']);
|
|
198
|
+
const bareJsonFlag = BARE_JSON_CMDS.has(rawArgv[0]) && rawArgv.includes('--json') &&
|
|
175
199
|
!rawArgv.some((a, i) => a === '--json' && typeof rawArgv[i + 1] === 'string' && !rawArgv[i + 1].startsWith('-'));
|
|
176
|
-
if (
|
|
200
|
+
if (bareJsonFlag) {
|
|
177
201
|
const idx = rawArgv.indexOf('--json');
|
|
178
202
|
rawArgv.splice(idx, 1);
|
|
179
203
|
}
|
|
204
|
+
const statusJsonFlag = bareJsonFlag && rawArgv[0] === 'status';
|
|
180
205
|
const { values, positionals } = parseArgs({
|
|
181
206
|
args: rawArgv,
|
|
182
207
|
allowPositionals: true,
|
|
@@ -189,6 +214,7 @@ async function main() {
|
|
|
189
214
|
base: { type: 'string' },
|
|
190
215
|
email: { type: 'string' },
|
|
191
216
|
name: { type: 'string' },
|
|
217
|
+
as: { type: 'string' },
|
|
192
218
|
wait: { type: 'boolean' },
|
|
193
219
|
'new-site': { type: 'boolean' },
|
|
194
220
|
project: { type: 'boolean' },
|
|
@@ -211,6 +237,17 @@ async function main() {
|
|
|
211
237
|
'fee-delta': { type: 'string' },
|
|
212
238
|
'target-date': { type: 'string' },
|
|
213
239
|
'keep-as-draft': { type: 'boolean' },
|
|
240
|
+
to: { type: 'string' },
|
|
241
|
+
subject: { type: 'string' },
|
|
242
|
+
html: { type: 'string' },
|
|
243
|
+
text: { type: 'string' },
|
|
244
|
+
status: { type: 'string' },
|
|
245
|
+
confirm: { type: 'boolean' },
|
|
246
|
+
'no-confirm': { type: 'boolean' },
|
|
247
|
+
notify: { type: 'boolean' },
|
|
248
|
+
'no-notify': { type: 'boolean' },
|
|
249
|
+
'notify-to': { type: 'string' },
|
|
250
|
+
from: { type: 'string' },
|
|
214
251
|
help: { type: 'boolean', short: 'h' },
|
|
215
252
|
version: { type: 'boolean', short: 'v' },
|
|
216
253
|
},
|
|
@@ -236,13 +273,18 @@ async function main() {
|
|
|
236
273
|
// pre-built fallback like dist/).
|
|
237
274
|
const { dir: publishDir, hint } = await resolvePayloadDir(dir, { framework: values.framework });
|
|
238
275
|
const spa = values.spa ?? (hint?.spa ? true : undefined);
|
|
239
|
-
if (hint && publishDir !== dir) {
|
|
276
|
+
if (hint && publishDir !== dir && !bareJsonFlag) {
|
|
240
277
|
console.log(`✔ ${hint.framework} project detected — publishing ${publishDir}${hint.spa ? ' with SPA mode' : ''}`);
|
|
241
278
|
}
|
|
242
279
|
// same command, same URL: reuse this directory's site automatically
|
|
243
280
|
const state = await readState(publishDir);
|
|
244
|
-
|
|
245
|
-
|
|
281
|
+
// --as <name>: a stable named preview. The alias registry remembers which
|
|
282
|
+
// slug this name last published to, so the URL is reused every iteration —
|
|
283
|
+
// independent of which directory the bytes came from.
|
|
284
|
+
const aliasName = values.as;
|
|
285
|
+
const alias = aliasName ? await readPreview(aliasName) : null;
|
|
286
|
+
let claimToken = values['claim-token'] ?? alias?.claimToken ?? state?.claimToken;
|
|
287
|
+
let slug = (alias?.owned && apiKey ? alias.slug : undefined) ?? (state?.owned && apiKey ? state.slug : undefined);
|
|
246
288
|
if (values['new-site']) {
|
|
247
289
|
claimToken = values['claim-token'];
|
|
248
290
|
slug = undefined;
|
|
@@ -277,6 +319,28 @@ async function main() {
|
|
|
277
319
|
owned: !res.anonymous,
|
|
278
320
|
...(state?.databaseId ? { databaseId: state.databaseId } : {}),
|
|
279
321
|
});
|
|
322
|
+
if (aliasName) {
|
|
323
|
+
await writePreview(aliasName, {
|
|
324
|
+
slug: res.slug,
|
|
325
|
+
owned: !res.anonymous,
|
|
326
|
+
...(res.siteId ? { siteId: res.siteId } : {}),
|
|
327
|
+
...(res.claimToken ? { claimToken: res.claimToken } : alias?.claimToken ? { claimToken: alias.claimToken } : {}),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
if (bareJsonFlag) {
|
|
331
|
+
// Single-line machine output — no banners, no confetti, no readiness chatter.
|
|
332
|
+
process.stdout.write(`${JSON.stringify({
|
|
333
|
+
slug: res.slug,
|
|
334
|
+
siteUrl: res.siteUrl,
|
|
335
|
+
siteId: res.siteId ?? null,
|
|
336
|
+
uploaded: res.uploaded,
|
|
337
|
+
skipped: res.skipped,
|
|
338
|
+
anonymous: res.anonymous,
|
|
339
|
+
expiresAt: res.expiresAt,
|
|
340
|
+
updated: updating,
|
|
341
|
+
})}\n`);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
280
344
|
const skipped = res.skipped > 0 ? ` (${res.skipped} unchanged, skipped)` : '';
|
|
281
345
|
console.log(`✔ ${updating ? `updated ${res.slug} in place` : 'published'} — ${res.uploaded} file${res.uploaded === 1 ? '' : 's'}${skipped}`);
|
|
282
346
|
console.log(`\n ${res.siteUrl}\n`);
|
|
@@ -287,7 +351,7 @@ async function main() {
|
|
|
287
351
|
void live.body?.cancel();
|
|
288
352
|
if (live.status < 400) {
|
|
289
353
|
console.log(`SITE_READY url=${res.siteUrl} status=${live.status}`);
|
|
290
|
-
if (
|
|
354
|
+
if (shouldCelebrate(Boolean(values['no-confetti'])))
|
|
291
355
|
console.log(confetti(`⛵ ${host} IS LIVE ⛵`));
|
|
292
356
|
}
|
|
293
357
|
}
|
|
@@ -355,6 +419,50 @@ async function main() {
|
|
|
355
419
|
console.log(`\n ${out.siteUrl}\n`);
|
|
356
420
|
return;
|
|
357
421
|
}
|
|
422
|
+
case 'ls': {
|
|
423
|
+
const apiKey = values.key ?? (await loadApiKey());
|
|
424
|
+
if (!apiKey)
|
|
425
|
+
throw new Error('ls needs an API key — run `shiply login` first');
|
|
426
|
+
await sitesLs({ base: values.base, apiKey });
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
case 'rm': {
|
|
430
|
+
if (!dir)
|
|
431
|
+
throw new Error('usage: shiply rm <slug> --yes');
|
|
432
|
+
const apiKey = values.key ?? (await loadApiKey());
|
|
433
|
+
if (!apiKey)
|
|
434
|
+
throw new Error('rm needs an API key — run `shiply login` first');
|
|
435
|
+
await sitesRm({ base: values.base, apiKey }, dir, { yes: Boolean(values.yes) });
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
case 'rollback': {
|
|
439
|
+
if (!dir)
|
|
440
|
+
throw new Error('usage: shiply rollback <slug> [versionId] (omit versionId to list versions)');
|
|
441
|
+
const apiKey = values.key ?? (await loadApiKey());
|
|
442
|
+
if (!apiKey)
|
|
443
|
+
throw new Error('rollback needs an API key — run `shiply login` first');
|
|
444
|
+
await sitesRollback({ base: values.base, apiKey }, dir, positionals[2]);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
case 'verify': {
|
|
448
|
+
if (!dir)
|
|
449
|
+
throw new Error('usage: shiply verify <slug>');
|
|
450
|
+
// exit 0 when LIVE, 1 when PENDING — same contract as `status`, so an agent
|
|
451
|
+
// can chain `shiply verify <slug> && <next step>` safely.
|
|
452
|
+
const ready = await verifySite({ base: values.base }, dir);
|
|
453
|
+
process.exitCode = ready ? 0 : 1;
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
case 'promote': {
|
|
457
|
+
const dest = values.to;
|
|
458
|
+
if (!dir || !dest)
|
|
459
|
+
throw new Error('usage: shiply promote <preview-slug> --to <dest-slug>');
|
|
460
|
+
const apiKey = values.key ?? (await loadApiKey());
|
|
461
|
+
if (!apiKey)
|
|
462
|
+
throw new Error('promote needs an API key — run `shiply login` first');
|
|
463
|
+
await sitesPromote({ base: values.base, apiKey }, dir, dest);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
358
466
|
case 'detect': {
|
|
359
467
|
await runDetect(dir ?? '.', { framework: values.framework });
|
|
360
468
|
return;
|
|
@@ -666,6 +774,44 @@ async function main() {
|
|
|
666
774
|
throw new Error('usage: shiply contract <list|draft|show|send|pdf|amend|retract>');
|
|
667
775
|
}
|
|
668
776
|
}
|
|
777
|
+
case 'email': {
|
|
778
|
+
await runEmail(positionals.slice(1), {
|
|
779
|
+
base: values.base,
|
|
780
|
+
key: values.key,
|
|
781
|
+
site: values.site,
|
|
782
|
+
to: values.to,
|
|
783
|
+
subject: values.subject,
|
|
784
|
+
html: values.html,
|
|
785
|
+
text: values.text,
|
|
786
|
+
});
|
|
787
|
+
break;
|
|
788
|
+
}
|
|
789
|
+
case 'mailbox': {
|
|
790
|
+
// Resolve tri-state for confirm/notify:
|
|
791
|
+
// --confirm → true (values.confirm === true)
|
|
792
|
+
// --no-confirm → false (values['no-confirm'] === true)
|
|
793
|
+
// neither provided → undefined (omit from PATCH body)
|
|
794
|
+
const confirmValue = values.confirm === true ? true :
|
|
795
|
+
values['no-confirm'] === true ? false :
|
|
796
|
+
undefined;
|
|
797
|
+
const notifyValue = values.notify === true ? true :
|
|
798
|
+
values['no-notify'] === true ? false :
|
|
799
|
+
undefined;
|
|
800
|
+
await runMailbox(positionals.slice(1), {
|
|
801
|
+
base: values.base,
|
|
802
|
+
key: values.key,
|
|
803
|
+
site: values.site,
|
|
804
|
+
subject: values.subject,
|
|
805
|
+
html: values.html,
|
|
806
|
+
text: values.text,
|
|
807
|
+
status: values.status,
|
|
808
|
+
...(confirmValue !== undefined ? { confirm: confirmValue } : {}),
|
|
809
|
+
...(notifyValue !== undefined ? { notify: notifyValue } : {}),
|
|
810
|
+
'notify-to': values['notify-to'],
|
|
811
|
+
from: values.from,
|
|
812
|
+
});
|
|
813
|
+
break;
|
|
814
|
+
}
|
|
669
815
|
case 'claim': {
|
|
670
816
|
if (dir !== 'verify') {
|
|
671
817
|
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/dist/manifest.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { readdir, readFile } from 'node:fs/promises';
|
|
3
3
|
import { extname, join } from 'node:path';
|
|
4
|
+
import { loadIgnore } from './ignore.js';
|
|
4
5
|
const MIME = {
|
|
5
6
|
'.html': 'text/html',
|
|
6
7
|
'.htm': 'text/html',
|
|
@@ -40,22 +41,30 @@ const SKIP_DIRS = new Set(['node_modules']);
|
|
|
40
41
|
* config directory (proxy.json, data.json), which the server consumes. */
|
|
41
42
|
export async function buildManifest(dir) {
|
|
42
43
|
const out = [];
|
|
43
|
-
await
|
|
44
|
+
const ignore = await loadIgnore(dir);
|
|
45
|
+
await walk(dir, '', out, ignore);
|
|
44
46
|
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
45
47
|
}
|
|
46
|
-
async function walk(abs, rel, out) {
|
|
48
|
+
async function walk(abs, rel, out, ignore) {
|
|
47
49
|
const entries = await readdir(abs, { withFileTypes: true });
|
|
48
50
|
for (const e of entries) {
|
|
49
51
|
if (e.name === '.shiply' && e.isDirectory() && rel === '') {
|
|
50
|
-
|
|
52
|
+
// The .shiply/ config dir (proxy.json, data.json) is fully exempt from
|
|
53
|
+
// .shiplyignore — the server needs it, and a broad user rule like
|
|
54
|
+
// `*.json` must not be able to silently drop the site's own config.
|
|
55
|
+
// Pass a null matcher so nothing under .shiply/ is ignore-filtered.
|
|
56
|
+
await walk(join(abs, e.name), '.shiply', out, null);
|
|
51
57
|
continue;
|
|
52
58
|
}
|
|
53
59
|
if (e.name.startsWith('.') || SKIP_DIRS.has(e.name))
|
|
54
60
|
continue;
|
|
55
61
|
const childAbs = join(abs, e.name);
|
|
56
62
|
const childRel = rel ? `${rel}/${e.name}` : e.name;
|
|
63
|
+
// .shiplyignore: skip matched paths (and whole subtrees for dir matches).
|
|
64
|
+
if (ignore?.(childRel, e.isDirectory()))
|
|
65
|
+
continue;
|
|
57
66
|
if (e.isDirectory()) {
|
|
58
|
-
await walk(childAbs, childRel, out);
|
|
67
|
+
await walk(childAbs, childRel, out, ignore);
|
|
59
68
|
}
|
|
60
69
|
else if (e.isFile()) {
|
|
61
70
|
const buf = await readFile(childAbs);
|
package/dist/previews.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
const registryPath = (home) => join(home, '.shiply', 'previews.json');
|
|
5
|
+
async function readAll(home) {
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(await readFile(registryPath(home), 'utf8'));
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return {};
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Look up the slug/credentials previously bound to a `--as <name>` alias. */
|
|
14
|
+
export async function readPreview(name, home = homedir()) {
|
|
15
|
+
const all = await readAll(home);
|
|
16
|
+
return all[name] ?? null;
|
|
17
|
+
}
|
|
18
|
+
/** Remember the slug a `--as <name>` alias published to, so the next publish
|
|
19
|
+
* with the same name UPDATES the same URL (stable preview). */
|
|
20
|
+
export async function writePreview(name, entry, home = homedir()) {
|
|
21
|
+
const all = await readAll(home);
|
|
22
|
+
all[name] = entry;
|
|
23
|
+
const path = registryPath(home);
|
|
24
|
+
await mkdir(dirname(path), { recursive: true });
|
|
25
|
+
await writeFile(path, `${JSON.stringify(all, null, 2)}\n`);
|
|
26
|
+
}
|
package/dist/sites.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { api, resolveBase } from './publish.js';
|
|
2
|
+
const headers = (apiKey) => ({
|
|
3
|
+
'content-type': 'application/json',
|
|
4
|
+
authorization: `Bearer ${apiKey}`,
|
|
5
|
+
});
|
|
6
|
+
export async function sitesLs(ctx) {
|
|
7
|
+
const base = resolveBase(ctx.base);
|
|
8
|
+
const { sites } = await api(`${base}/api/v1/publishes`, {
|
|
9
|
+
headers: headers(ctx.apiKey),
|
|
10
|
+
});
|
|
11
|
+
if (sites.length === 0) {
|
|
12
|
+
console.log(' no sites yet — run `shiply publish <dir>` to create one');
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
for (const s of sites) {
|
|
16
|
+
const title = s.title ? ` ${s.title}` : '';
|
|
17
|
+
console.log(` ${s.slug} [${s.status}] https://${s.slug}.shiply.now${title}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function sitesRm(ctx, slug, opts) {
|
|
21
|
+
if (!opts.yes) {
|
|
22
|
+
console.error(` refusing — \`shiply rm\` PERMANENTLY deletes ${slug} and its files. Re-run with --yes to confirm.`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const base = resolveBase(ctx.base);
|
|
26
|
+
await api(`${base}/api/v1/publish/${slug}`, {
|
|
27
|
+
method: 'DELETE',
|
|
28
|
+
headers: headers(ctx.apiKey),
|
|
29
|
+
});
|
|
30
|
+
console.log(`✔ deleted ${slug}`);
|
|
31
|
+
}
|
|
32
|
+
export async function sitesRollback(ctx, slug, versionId) {
|
|
33
|
+
const base = resolveBase(ctx.base);
|
|
34
|
+
// No versionId → print the version history so the agent can pick one.
|
|
35
|
+
if (!versionId) {
|
|
36
|
+
const detail = await api(`${base}/api/v1/publish/${slug}`, { headers: headers(ctx.apiKey) });
|
|
37
|
+
const finalized = detail.versions.filter((v) => v.status === 'finalized');
|
|
38
|
+
if (finalized.length === 0) {
|
|
39
|
+
console.log(' no finalized versions to roll back to');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
console.log(` versions for ${slug} (newest first):`);
|
|
43
|
+
for (const v of finalized) {
|
|
44
|
+
const mark = v.id === detail.currentVersionId ? ' ← current (live)' : '';
|
|
45
|
+
console.log(` ${v.id} ${v.fileCount} files ${v.finalizedAt ?? ''}${mark}`);
|
|
46
|
+
}
|
|
47
|
+
console.log(`\n roll back with: shiply rollback ${slug} <versionId>`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const res = await api(`${base}/api/v1/publish/${slug}/rollback`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: headers(ctx.apiKey),
|
|
53
|
+
body: JSON.stringify({ versionId }),
|
|
54
|
+
});
|
|
55
|
+
if (!res.changed) {
|
|
56
|
+
console.log(` ${slug} is already serving ${versionId} — nothing to do`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
console.log(`✔ rolled ${res.slug} to ${res.currentVersionId} — live now`);
|
|
60
|
+
}
|
|
61
|
+
export async function sitesPromote(ctx, srcSlug, destSlug) {
|
|
62
|
+
const base = resolveBase(ctx.base);
|
|
63
|
+
const r = await api(`${base}/api/v1/publish/${srcSlug}/promote`, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: headers(ctx.apiKey),
|
|
66
|
+
body: JSON.stringify({ destSlug }),
|
|
67
|
+
});
|
|
68
|
+
console.log(`✔ promoted ${r.sourceSlug} → ${r.slug} (${r.filesCount} files) — live now`);
|
|
69
|
+
console.log(`\n https://${r.slug}.shiply.now\n`);
|
|
70
|
+
}
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// packages/cli/src/verify.ts
|
|
2
|
+
import { api, resolveBase } from './publish.js';
|
|
3
|
+
/** Edge-check a shiply slug: SSL, HTTP reachability, and a rendered thumbnail.
|
|
4
|
+
* Headless agents use this to confirm what they shipped is actually serving.
|
|
5
|
+
* Returns true when LIVE, false when PENDING — the caller sets the exit code
|
|
6
|
+
* (0 / 1) so `shiply verify <slug> && <next>` only proceeds once it's serving. */
|
|
7
|
+
export async function verifySite(ctx, slug) {
|
|
8
|
+
if (slug.includes('.')) {
|
|
9
|
+
throw new Error(`verify takes a shiply slug, not a hostname — for custom domains use \`shiply status ${slug}\``);
|
|
10
|
+
}
|
|
11
|
+
const base = resolveBase(ctx.base);
|
|
12
|
+
const r = await api(`${base}/api/v1/sites/${slug}/verify`, {
|
|
13
|
+
headers: { 'content-type': 'application/json' },
|
|
14
|
+
});
|
|
15
|
+
const sslLine = r.ssl.valid
|
|
16
|
+
? `valid${r.ssl.issuer ? ` (${r.ssl.issuer}` : ''}${r.ssl.daysLeft !== undefined ? `, ${r.ssl.daysLeft}d left)` : r.ssl.issuer ? ')' : ''}`
|
|
17
|
+
: `not ready${r.ssl.error ? ` — ${r.ssl.error}` : ''}`;
|
|
18
|
+
console.log(`${r.status === 'LIVE' ? '✔' : '…'} ${slug}.shiply.now — ${r.status}`);
|
|
19
|
+
console.log(` ssl: ${sslLine}`);
|
|
20
|
+
console.log(` http: ${r.http.ok ? `ok (HTTP ${r.http.status})` : `not ready${r.http.status ? ` (HTTP ${r.http.status})` : r.http.error ? ` — ${r.http.error}` : ''}`}`);
|
|
21
|
+
if (r.thumbnailUrl)
|
|
22
|
+
console.log(` shot: ${r.thumbnailUrl}`);
|
|
23
|
+
// Stable machine marker for agents/scrapers (mirrors SITE_READY in `status`).
|
|
24
|
+
console.log(`VERIFY status=${r.status} http=${r.http.status ?? 0} ssl=${r.ssl.valid ? 'valid' : 'pending'}${r.thumbnailUrl ? ` thumb=${r.thumbnailUrl}` : ''}`);
|
|
25
|
+
return r.status === 'LIVE';
|
|
26
|
+
}
|
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-23**
|
|
22
22
|
|
|
23
23
|
---
|
|
24
24
|
|
|
@@ -144,6 +144,47 @@ so repeat publishes reuse the URL automatically. `--new-site` opts out.
|
|
|
144
144
|
Gitignore `.shiply.json` in public repos. Parse `SITE_READY` / `SSL_READY`
|
|
145
145
|
lines for automation; exit code 0 = ready.
|
|
146
146
|
|
|
147
|
+
## Manage & verify sites (CLI)
|
|
148
|
+
|
|
149
|
+
List, delete, roll back, verify, and promote owned sites — all over the same
|
|
150
|
+
Bearer API key. Great for agents that ship iteratively and need to keep their
|
|
151
|
+
subdomains tidy.
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
shiply ls # list your sites: slug, status, URL
|
|
155
|
+
shiply rm <slug> --yes # PERMANENTLY delete a site + its files (--yes required)
|
|
156
|
+
shiply rollback <slug> # list finalized versions (current one marked)
|
|
157
|
+
shiply rollback <slug> <versionId> # re-point the site to that version — live instantly
|
|
158
|
+
shiply verify <slug> # edge SSL + HTTP + thumbnail readiness check
|
|
159
|
+
shiply publish <dir> --as <name> # STABLE preview: same URL every iteration (local alias)
|
|
160
|
+
shiply promote <preview-slug> --to <dest-slug> # copy the preview's exact live bytes onto a prod site
|
|
161
|
+
shiply publish <dir> --json # one machine-readable JSON line, no confetti/banners
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
- **`shiply verify <slug>`** prints a human report PLUS a stable machine marker
|
|
165
|
+
line `VERIFY status=LIVE http=200 ssl=valid thumb=…` (or `status=PENDING`).
|
|
166
|
+
Headless agents should parse that `VERIFY status=…` line — it's the
|
|
167
|
+
contract, like `SITE_READY` in `status`.
|
|
168
|
+
- **`shiply publish <dir> --as <name>`** gives a stable preview URL that the
|
|
169
|
+
next publish with the same `--as <name>` reuses — no more changing URLs or
|
|
170
|
+
orphaned scratch sites across iterations, independent of the source dir.
|
|
171
|
+
- **`shiply promote <preview-slug> --to <dest-slug>`** copies the *exact bytes*
|
|
172
|
+
you previewed onto an existing owned destination site (keeps its slug,
|
|
173
|
+
domains, access) — atomic preview→production, no rebuild.
|
|
174
|
+
- **`--json`** on `publish`/`update` emits a single JSON line
|
|
175
|
+
`{slug,siteUrl,siteId,uploaded,skipped,anonymous,expiresAt,updated}` and
|
|
176
|
+
suppresses all human output + confetti — use it in scripts/agents.
|
|
177
|
+
- **`.shiplyignore`** in the publish root excludes files from upload
|
|
178
|
+
(documented gitignore subset: `# comments`, `secret.txt`, `internal/`,
|
|
179
|
+
`*.log`, `/anchored`, `**` globs, `!negation`). Keep `.env`, notes, and
|
|
180
|
+
internal files out of the published bytes.
|
|
181
|
+
|
|
182
|
+
To verify captured signups/form data without the dashboard, the data
|
|
183
|
+
subcommands already exist: `shiply data list <slug>` (collections + counts),
|
|
184
|
+
`shiply data query <slug> <coll> [--limit N] [--where '<json>']`, and
|
|
185
|
+
`shiply data export <slug> <coll> [--out file.ndjson]`. Confetti is
|
|
186
|
+
suppressed automatically on non-TTY stdout; `--no-confetti` forces it off.
|
|
187
|
+
|
|
147
188
|
### 3. Raw HTTP (no installs)
|
|
148
189
|
```
|
|
149
190
|
1. POST https://shiply.now/api/v1/publish
|
|
@@ -154,6 +195,8 @@ lines for automation; exit code 0 = ready.
|
|
|
154
195
|
(+ "Authorization: Bearer shp_…" for permanent owned sites)
|
|
155
196
|
2. PUT each file's bytes to response upload.uploads[].url
|
|
156
197
|
3. POST upload.finalizeUrl with {"versionId":"..."}
|
|
198
|
+
→ both the publish and finalize responses include a "toUpdate" string: the
|
|
199
|
+
exact call to update THIS site next time. Follow it — never create a new one.
|
|
157
200
|
```
|
|
158
201
|
|
|
159
202
|
**ALWAYS include `agentName` on anonymous publishes.** The response then
|
|
@@ -164,9 +207,11 @@ poll `poll_url` every `interval` seconds with the `device_code`, and on
|
|
|
164
207
|
is claimed in the same Allow click. Without `agentName` the user has to
|
|
165
208
|
do a separate manual claim step every time.
|
|
166
209
|
|
|
167
|
-
**Updates
|
|
168
|
-
|
|
169
|
-
|
|
210
|
+
**Updates (the response tells you how):** every publish/finalize response
|
|
211
|
+
includes a `toUpdate` string with the exact call — follow it verbatim. In short:
|
|
212
|
+
anonymous → include `"claimToken":"..."` (returned ONCE by the first publish —
|
|
213
|
+
save it); owned → include `"slug":"..."`. Hashes make updates cheap: unchanged
|
|
214
|
+
files are skipped server-side.
|
|
170
215
|
|
|
171
216
|
## The lifecycle to explain to users
|
|
172
217
|
- Anonymous site: live instantly, expires in 24 h. Give the user the
|
|
@@ -265,6 +310,98 @@ validated server-side; the owner reads them in the dashboard (Data section,
|
|
|
265
310
|
CSV export) or GET /api/v1/publishes/<slug>/data/<collection> with a Bearer
|
|
266
311
|
key. Owned sites only. Docs: /docs/site-data
|
|
267
312
|
|
|
313
|
+
## Agent Email — inbox, send, capture, broadcast (built into every owned site)
|
|
314
|
+
|
|
315
|
+
Every owned site has a real email address and can send, receive, capture signups,
|
|
316
|
+
and broadcast — in one line. Like AgentMail, built into the site.
|
|
317
|
+
|
|
318
|
+
### Capture (public, zero-config)
|
|
319
|
+
```
|
|
320
|
+
POST /.shiply/email { "email": "user@example.com", ...anyExtraFields }
|
|
321
|
+
```
|
|
322
|
+
No manifest needed. Works on the **relative path** from the published page.
|
|
323
|
+
On success: record lands in the site inbox + owner gets a notification ping +
|
|
324
|
+
a double-opt-in confirmation email goes to the visitor (all on by default).
|
|
325
|
+
**Owned sites only** — anonymous sites get `403 email_requires_account` (claim
|
|
326
|
+
the site first). A non-empty `company` field is treated as a honeypot (bot
|
|
327
|
+
submissions are silently dropped). Caps: 16 KB body, 30 fields max.
|
|
328
|
+
|
|
329
|
+
### Send (authenticated — Bearer key only)
|
|
330
|
+
The public page can NEVER send; only authenticated calls can.
|
|
331
|
+
```
|
|
332
|
+
POST https://shiply.now/api/v1/email/send
|
|
333
|
+
Authorization: Bearer shp_…
|
|
334
|
+
{ "slug": "my-site", "to": "user@example.com", "subject": "Hi", "html": "<p>Hi</p>", "text": "Hi" }
|
|
335
|
+
→ { "messageId": "..." }
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### Read inbox
|
|
339
|
+
```
|
|
340
|
+
GET https://shiply.now/api/v1/email/inbox # all sites
|
|
341
|
+
GET https://shiply.now/api/v1/email/inbox?slug=my-site
|
|
342
|
+
Authorization: Bearer shp_…
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### Typed upgrade (optional)
|
|
346
|
+
Declare an `email` block on a `.shiply/data.json` collection for typed fields +
|
|
347
|
+
fine-grained control:
|
|
348
|
+
```json
|
|
349
|
+
{
|
|
350
|
+
"collections": {
|
|
351
|
+
"signups": {
|
|
352
|
+
"fields": { "email": { "type": "email", "required": true } },
|
|
353
|
+
"email": {
|
|
354
|
+
"emailField": "email",
|
|
355
|
+
"confirm": true,
|
|
356
|
+
"notify": true,
|
|
357
|
+
"audience": true
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
```
|
|
363
|
+
All three flags default to `true`. With this manifest, POST to
|
|
364
|
+
`/.shiply/data/signups` as usual — the `email` block activates the email
|
|
365
|
+
layer on that collection.
|
|
366
|
+
|
|
367
|
+
### Confirm / unsubscribe (public — in the confirmation email)
|
|
368
|
+
```
|
|
369
|
+
GET /api/email/<slug>/<collection>/confirm?token=<token>
|
|
370
|
+
GET /api/email/<slug>/<collection>/unsubscribe?email=<address>
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### Audience + broadcast
|
|
374
|
+
Confirmed (double-opt-in) signups form a list. Broadcast to them:
|
|
375
|
+
```
|
|
376
|
+
POST /api/v1/publishes/<slug>/mailboxes/<collection>/broadcast
|
|
377
|
+
Authorization: Bearer shp_…
|
|
378
|
+
{ "subject": "Launch day", "html": "<p>We're live!</p>" }
|
|
379
|
+
```
|
|
380
|
+
Spam-checked before send; unsubscribe footer is auto-added; requires ≥1
|
|
381
|
+
confirmed subscriber.
|
|
382
|
+
|
|
383
|
+
Configure a mailbox: `GET/PATCH /api/v1/publishes/<slug>/mailboxes/<collection>`
|
|
384
|
+
List contacts: `GET /api/v1/publishes/<slug>/mailboxes/<collection>/contacts?status=confirmed`
|
|
385
|
+
|
|
386
|
+
### MCP tools
|
|
387
|
+
| Tool | Purpose |
|
|
388
|
+
|---|---|
|
|
389
|
+
| `send_email` | Send transactional email from a site (Bearer) |
|
|
390
|
+
| `list_site_inbox` | Read the inbox (all threads or filter by slug) |
|
|
391
|
+
| `set_mailbox` | Configure a collection's mailbox settings |
|
|
392
|
+
| `list_mailbox_contacts` | List audience contacts (filter by status) |
|
|
393
|
+
| `send_mailbox_broadcast` | Broadcast to confirmed audience (spam-checked) |
|
|
394
|
+
|
|
395
|
+
### CLI
|
|
396
|
+
```bash
|
|
397
|
+
shiply email send --site <slug> --to <address> --subject <subject> --html <html>
|
|
398
|
+
shiply email inbox [--site <slug>]
|
|
399
|
+
shiply mailbox set <slug> <collection> [--confirm] [--notify] [--from <domain>]
|
|
400
|
+
shiply mailbox broadcast <slug> <collection> --subject <subject> --html <html>
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
Docs: https://shiply.now/docs/agent-email
|
|
404
|
+
|
|
268
405
|
## SQL databases (Cloudflare D1 + Neon Postgres)
|
|
269
406
|
Two engines, same CLI/REST/MCP surface. **D1 (SQLite at the edge)** is
|
|
270
407
|
free on every plan and queryable from the browser via a built-in fetch
|