shiply-cli 0.12.0 → 0.14.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.
@@ -0,0 +1,342 @@
1
+ /** CLI for shiply Workers Lite — function deploy, secrets, crons.
2
+ *
3
+ * Reads --base / --key / --json / --ts from the argv tail the way
4
+ * projects.ts and listings.ts do (ad-hoc parser) so the top-level
5
+ * `parseArgs` in index.ts doesn't have to know about each one. */
6
+ import { readFile } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ import { loadApiKey } from './config.js';
9
+ import { api, resolveBase } from './publish.js';
10
+ const headers = (apiKey) => ({
11
+ 'content-type': 'application/json',
12
+ authorization: `Bearer ${apiKey}`,
13
+ });
14
+ /** Strip --flag / --flag=value / --flag value out of argv, returning the
15
+ * remaining positionals and a typed flag bag. Mirrors the helper in
16
+ * projects.ts so the dispatch pattern stays consistent across subcommands.
17
+ * Top-level flags (consumed by index.ts parseArgs before we got here)
18
+ * are merged in via the `inherited` arg. */
19
+ function readFlags(argv, inherited = {}) {
20
+ const raw = {};
21
+ const rest = [];
22
+ for (let i = 0; i < argv.length; i++) {
23
+ const a = argv[i];
24
+ if (a.startsWith('--')) {
25
+ const eq = a.indexOf('=');
26
+ if (eq >= 0) {
27
+ raw[a.slice(2, eq)] = a.slice(eq + 1);
28
+ }
29
+ else {
30
+ const next = argv[i + 1];
31
+ if (next && !next.startsWith('--')) {
32
+ raw[a.slice(2)] = next;
33
+ i++;
34
+ }
35
+ else {
36
+ raw[a.slice(2)] = true;
37
+ }
38
+ }
39
+ }
40
+ else {
41
+ rest.push(a);
42
+ }
43
+ }
44
+ // `--ts` and `--json` are flags; --base/--key carry values.
45
+ // If the user passes `--ts something`, our naive parser captures
46
+ // `something` as the value — re-classify so it stays a positional.
47
+ const flags = {
48
+ base: (typeof raw.base === 'string' ? raw.base : undefined) ?? inherited.base,
49
+ key: (typeof raw.key === 'string' ? raw.key : undefined) ?? inherited.key,
50
+ json: Boolean(raw.json) || Boolean(inherited.json),
51
+ ts: Boolean(raw.ts) || Boolean(inherited.ts),
52
+ };
53
+ if (typeof raw.ts === 'string')
54
+ rest.unshift(raw.ts);
55
+ if (typeof raw.json === 'string')
56
+ rest.unshift(raw.json);
57
+ return { rest, flags };
58
+ }
59
+ async function getApiKey(flags, what) {
60
+ const k = flags.key ?? (await loadApiKey());
61
+ if (!k)
62
+ throw new Error(`${what} commands need an API key — run \`shiply login\` first`);
63
+ return k;
64
+ }
65
+ // --- shiply function --------------------------------------------------------
66
+ const FUNCTION_USAGE = [
67
+ 'Usage: shiply function <deploy|get|rm> <slug> [--ts] [--json]',
68
+ ' shiply function deploy <slug> # uploads worker.js from CWD',
69
+ ' shiply function deploy <slug> --ts # uploads worker.ts from CWD',
70
+ ' shiply function get <slug>',
71
+ ' shiply function rm <slug>',
72
+ ].join('\n');
73
+ export async function runFunction(argv, inherited = {}) {
74
+ const action = argv[0];
75
+ const rest = argv.slice(1);
76
+ switch (action) {
77
+ case 'deploy':
78
+ return deployCmd(rest, inherited);
79
+ case 'get':
80
+ return getFnCmd(rest, inherited);
81
+ case 'rm':
82
+ case 'remove':
83
+ case 'delete':
84
+ return removeFnCmd(rest, inherited);
85
+ default:
86
+ console.error(FUNCTION_USAGE);
87
+ process.exit(1);
88
+ }
89
+ }
90
+ async function deployCmd(argv, inherited) {
91
+ const { rest, flags } = readFlags(argv, inherited);
92
+ const slug = rest[0];
93
+ if (!slug) {
94
+ console.error(FUNCTION_USAGE);
95
+ process.exit(1);
96
+ }
97
+ const apiKey = await getApiKey(flags, 'function');
98
+ const base = resolveBase(flags.base);
99
+ const lang = flags.ts ? 'ts' : 'js';
100
+ const filename = lang === 'ts' ? 'worker.ts' : 'worker.js';
101
+ let source;
102
+ try {
103
+ source = await readFile(join(process.cwd(), filename), 'utf8');
104
+ }
105
+ catch (e) {
106
+ throw new Error(`couldn't read ${filename} from ${process.cwd()} — ${e.message}\n hint: cd into the directory containing your ${filename}, or pass --ts if the file is worker.ts`);
107
+ }
108
+ const row = await api(`${base}/api/v1/sites/${slug}/function`, {
109
+ method: 'POST',
110
+ headers: headers(apiKey),
111
+ body: JSON.stringify({ source, lang }),
112
+ });
113
+ if (flags.json) {
114
+ console.log(JSON.stringify(row, null, 2));
115
+ return;
116
+ }
117
+ console.log(`✔ deployed worker for ${slug}`);
118
+ console.log(` script: ${row.cfScriptName}`);
119
+ console.log(` version: ${row.version.slice(0, 12)}`);
120
+ console.log(` source: ${filename} (${lang})`);
121
+ console.log(` routes: requests to https://${slug}.shiply.now/* now hit your worker first`);
122
+ }
123
+ async function getFnCmd(argv, inherited) {
124
+ const { rest, flags } = readFlags(argv, inherited);
125
+ const slug = rest[0];
126
+ if (!slug) {
127
+ console.error(FUNCTION_USAGE);
128
+ process.exit(1);
129
+ }
130
+ const apiKey = await getApiKey(flags, 'function');
131
+ const base = resolveBase(flags.base);
132
+ const row = await api(`${base}/api/v1/sites/${slug}/function`, {
133
+ headers: headers(apiKey),
134
+ });
135
+ if (flags.json) {
136
+ console.log(JSON.stringify(row, null, 2));
137
+ return;
138
+ }
139
+ console.log(`${slug} [${row.sourceLang}] version ${row.version.slice(0, 12)}`);
140
+ console.log(` script: ${row.cfScriptName}`);
141
+ console.log(` deployed: ${row.deployedAt}`);
142
+ console.log(` updated: ${row.updatedAt}`);
143
+ console.log(` source: ${row.source.length} bytes`);
144
+ }
145
+ async function removeFnCmd(argv, inherited) {
146
+ const { rest, flags } = readFlags(argv, inherited);
147
+ const slug = rest[0];
148
+ if (!slug) {
149
+ console.error(FUNCTION_USAGE);
150
+ process.exit(1);
151
+ }
152
+ const apiKey = await getApiKey(flags, 'function');
153
+ const base = resolveBase(flags.base);
154
+ await api(`${base}/api/v1/sites/${slug}/function`, {
155
+ method: 'DELETE',
156
+ headers: headers(apiKey),
157
+ });
158
+ if (flags.json) {
159
+ console.log(JSON.stringify({ ok: true }));
160
+ return;
161
+ }
162
+ console.log(`✔ removed worker for ${slug} — static-only routing restored`);
163
+ }
164
+ // --- shiply secret ----------------------------------------------------------
165
+ const SECRET_USAGE = [
166
+ 'Usage: shiply secret <set|ls|rm> <slug> [args] [--json]',
167
+ ' shiply secret set <slug> <NAME> <VALUE>',
168
+ ' shiply secret ls <slug>',
169
+ ' shiply secret rm <slug> <NAME>',
170
+ ].join('\n');
171
+ export async function runSecret(argv, inherited = {}) {
172
+ const action = argv[0];
173
+ const rest = argv.slice(1);
174
+ switch (action) {
175
+ case 'set':
176
+ return setSecretCmd(rest, inherited);
177
+ case 'ls':
178
+ case 'list':
179
+ return lsSecretsCmd(rest, inherited);
180
+ case 'rm':
181
+ case 'remove':
182
+ case 'delete':
183
+ return rmSecretCmd(rest, inherited);
184
+ default:
185
+ console.error(SECRET_USAGE);
186
+ process.exit(1);
187
+ }
188
+ }
189
+ async function setSecretCmd(argv, inherited) {
190
+ const { rest, flags } = readFlags(argv, inherited);
191
+ const [slug, name, value] = rest;
192
+ if (!slug || !name || !value) {
193
+ console.error(SECRET_USAGE);
194
+ process.exit(1);
195
+ }
196
+ const apiKey = await getApiKey(flags, 'secret');
197
+ const base = resolveBase(flags.base);
198
+ await api(`${base}/api/v1/sites/${slug}/secrets`, {
199
+ method: 'POST',
200
+ headers: headers(apiKey),
201
+ body: JSON.stringify({ name, value }),
202
+ });
203
+ if (flags.json) {
204
+ console.log(JSON.stringify({ ok: true, name }));
205
+ return;
206
+ }
207
+ console.log(`✔ set ${name} on ${slug}`);
208
+ console.log(` available as env.${name} in your worker on next deploy/restart`);
209
+ }
210
+ async function lsSecretsCmd(argv, inherited) {
211
+ const { rest, flags } = readFlags(argv, inherited);
212
+ const slug = rest[0];
213
+ if (!slug) {
214
+ console.error(SECRET_USAGE);
215
+ process.exit(1);
216
+ }
217
+ const apiKey = await getApiKey(flags, 'secret');
218
+ const base = resolveBase(flags.base);
219
+ const r = await api(`${base}/api/v1/sites/${slug}/secrets`, { headers: headers(apiKey) });
220
+ if (flags.json) {
221
+ console.log(JSON.stringify(r, null, 2));
222
+ return;
223
+ }
224
+ if (!r.secrets.length) {
225
+ console.log(`no secrets on ${slug} yet`);
226
+ console.log(` set one: shiply secret set ${slug} STRIPE_SECRET_KEY sk_test_…`);
227
+ return;
228
+ }
229
+ for (const s of r.secrets) {
230
+ console.log(`${s.name}${s.updatedAt ? ` (updated ${s.updatedAt})` : ''}`);
231
+ }
232
+ }
233
+ async function rmSecretCmd(argv, inherited) {
234
+ const { rest, flags } = readFlags(argv, inherited);
235
+ const [slug, name] = rest;
236
+ if (!slug || !name) {
237
+ console.error(SECRET_USAGE);
238
+ process.exit(1);
239
+ }
240
+ const apiKey = await getApiKey(flags, 'secret');
241
+ const base = resolveBase(flags.base);
242
+ await api(`${base}/api/v1/sites/${slug}/secrets/${encodeURIComponent(name)}`, {
243
+ method: 'DELETE',
244
+ headers: headers(apiKey),
245
+ });
246
+ if (flags.json) {
247
+ console.log(JSON.stringify({ ok: true, name }));
248
+ return;
249
+ }
250
+ console.log(`✔ removed secret ${name} from ${slug}`);
251
+ }
252
+ // --- shiply cron ------------------------------------------------------------
253
+ const CRON_USAGE = [
254
+ 'Usage: shiply cron <ls|set|rm> <slug> [args] [--json]',
255
+ ' shiply cron ls <slug>',
256
+ ' shiply cron set <slug> <path> "<schedule>"',
257
+ ' e.g. shiply cron set my-site /api/cron/digest "0 9 * * *"',
258
+ ' shiply cron rm <slug> <path>',
259
+ ].join('\n');
260
+ export async function runCron(argv, inherited = {}) {
261
+ const action = argv[0];
262
+ const rest = argv.slice(1);
263
+ switch (action) {
264
+ case 'ls':
265
+ case 'list':
266
+ return lsCronsCmd(rest, inherited);
267
+ case 'set':
268
+ case 'add':
269
+ return setCronCmd(rest, inherited);
270
+ case 'rm':
271
+ case 'remove':
272
+ case 'delete':
273
+ return rmCronCmd(rest, inherited);
274
+ default:
275
+ console.error(CRON_USAGE);
276
+ process.exit(1);
277
+ }
278
+ }
279
+ async function lsCronsCmd(argv, inherited) {
280
+ const { rest, flags } = readFlags(argv, inherited);
281
+ const slug = rest[0];
282
+ if (!slug) {
283
+ console.error(CRON_USAGE);
284
+ process.exit(1);
285
+ }
286
+ const apiKey = await getApiKey(flags, 'cron');
287
+ const base = resolveBase(flags.base);
288
+ const r = await api(`${base}/api/v1/sites/${slug}/crons`, { headers: headers(apiKey) });
289
+ if (flags.json) {
290
+ console.log(JSON.stringify(r, null, 2));
291
+ return;
292
+ }
293
+ if (!r.crons.length) {
294
+ console.log(`no crons on ${slug} yet`);
295
+ console.log(` add one: shiply cron set ${slug} /api/cron/digest "0 9 * * *"`);
296
+ return;
297
+ }
298
+ for (const c of r.crons) {
299
+ console.log(`${c.schedule} → ${c.path}`);
300
+ }
301
+ }
302
+ async function setCronCmd(argv, inherited) {
303
+ const { rest, flags } = readFlags(argv, inherited);
304
+ const [slug, path, schedule] = rest;
305
+ if (!slug || !path || !schedule) {
306
+ console.error(CRON_USAGE);
307
+ process.exit(1);
308
+ }
309
+ const apiKey = await getApiKey(flags, 'cron');
310
+ const base = resolveBase(flags.base);
311
+ await api(`${base}/api/v1/sites/${slug}/crons`, {
312
+ method: 'POST',
313
+ headers: headers(apiKey),
314
+ body: JSON.stringify({ path, schedule }),
315
+ });
316
+ if (flags.json) {
317
+ console.log(JSON.stringify({ ok: true, path, schedule }));
318
+ return;
319
+ }
320
+ console.log(`✔ scheduled "${schedule}" → ${path} on ${slug}`);
321
+ console.log(` Cloudflare will POST to your worker at this path on the schedule`);
322
+ }
323
+ async function rmCronCmd(argv, inherited) {
324
+ const { rest, flags } = readFlags(argv, inherited);
325
+ const [slug, path] = rest;
326
+ if (!slug || !path) {
327
+ console.error(CRON_USAGE);
328
+ process.exit(1);
329
+ }
330
+ const apiKey = await getApiKey(flags, 'cron');
331
+ const base = resolveBase(flags.base);
332
+ await api(`${base}/api/v1/sites/${slug}/crons`, {
333
+ method: 'DELETE',
334
+ headers: headers(apiKey),
335
+ body: JSON.stringify({ path }),
336
+ });
337
+ if (flags.json) {
338
+ console.log(JSON.stringify({ ok: true, path }));
339
+ return;
340
+ }
341
+ console.log(`✔ removed cron ${path} from ${slug}`);
342
+ }
package/dist/index.js CHANGED
@@ -8,6 +8,10 @@ import { runClaimVerify } from './claim.js';
8
8
  import { dbAttach, dbBranch, dbBranches, dbCreate, dbDelete, dbDeleteBranch, dbLs, dbMerge, dbMigrate, dbSql, } from './db.js';
9
9
  import { driveGet, driveLs, drivePublish, drivePut, driveRm } from './drive.js';
10
10
  import { domainAdd, domainConnect, domainLs, domainSubAdd, domainSync } from './domain.js';
11
+ import { runProject } from './projects.js';
12
+ import { runListing } from './listings.js';
13
+ import { runSendingDomain } from './sending-domains.js';
14
+ import { runCron, runFunction, runSecret } from './functions.js';
11
15
  import { loadApiKey, saveApiKey } from './config.js';
12
16
  import { loginViaDeviceFlow } from './login.js';
13
17
  import { api, ApiError, DEFAULT_BASE, publish, resolveBase } from './publish.js';
@@ -38,6 +42,25 @@ Usage:
38
42
  shiply domain connect <domain> One-click OAuth DNS setup (Cloudflare/Domain Connect)
39
43
  shiply domain sub add <host> --site <slug> Map a subdomain (e.g. app.example.com) to a slug
40
44
  shiply domain sync <domain> Re-sync DNS records for a connected domain
45
+ shiply project <ls|create|get|archive|restore>
46
+ Customer-intake projects + AI brief generator
47
+ shiply project create <label> [--customer-email <e>] [--customer-name <n>]
48
+ Returns the intake URL to share with the customer
49
+ shiply listing <ls|create|rm> Marketplace listings (sell built sites)
50
+ shiply listing create <site-slug> --price <cents> --jurisdiction "<region>"
51
+ Requires Stripe Connect onboarding (see MCP get_connect_status)
52
+ shiply sending-domain <ls|add|verify|rm> Outbound email on a verified domain (Resend-backed)
53
+ shiply sending-domain add <domain> Returns DNS records to add at your registrar
54
+ shiply sending-domain verify <id> Re-check DNS after adding records
55
+ shiply function <deploy|get|rm> <slug> Deploy a Cloudflare Worker for your site (Workers Lite).
56
+ Reads worker.js from CWD; pass --ts for worker.ts.
57
+ Deploys + binds routes so https://<slug>.shiply.now/*
58
+ hits your code BEFORE static fallback. Developer plan.
59
+ shiply secret <set|ls|rm> <slug> Manage worker secrets (Stripe keys, API tokens, etc.)
60
+ shiply secret set <slug> NAME VALUE — becomes env.NAME
61
+ in your worker on next deploy/restart.
62
+ shiply cron <ls|set|rm> <slug> Manage cron triggers on the deployed worker
63
+ shiply cron set <slug> /api/cron/foo "0 9 * * *"
41
64
  shiply data init [dir] Scaffold a starter .shiply/data.json in <dir> (default cwd)
42
65
  shiply data list <slug> List collections on a site with counts
43
66
  shiply data query <slug> <coll> [--limit N] [--cursor C] [--where '<json>']
@@ -50,14 +73,16 @@ Usage:
50
73
  Delete every record in a collection
51
74
  shiply claim verify <code> Confirm a pairing code from /claim/<slug>?pair=1
52
75
  (uses .shiply.json from CWD)
53
- shiply skill [--project] Install the shiply skill for your AI agent
76
+ shiply skill [--project] [--force] Install the shiply skill for your AI agent
54
77
  (global ~/.claude/skills, or ./.claude/skills with --project)
78
+ (--force overwrites an existing skill — recommended weekly)
55
79
  shiply login [--name <label>] Open a browser, click Allow → key saved
56
80
  (--email <addr> falls back to legacy 6-digit code)
57
81
  shiply help
58
82
 
59
83
  Options:
60
84
  --spa Single-page app mode (unknown paths fall back to index.html)
85
+ --ts (function deploy) read worker.ts instead of worker.js
61
86
  --framework <name> Force the detector (e.g. hugo, nuxt, vite) for non-standard layouts
62
87
  --claim-token <tok> Update a specific anonymous site (overrides .shiply.json)
63
88
  --new-site Ignore .shiply.json and create a fresh site
@@ -120,12 +145,14 @@ async function main() {
120
145
  wait: { type: 'boolean' },
121
146
  'new-site': { type: 'boolean' },
122
147
  project: { type: 'boolean' },
148
+ force: { type: 'boolean' },
123
149
  timeout: { type: 'string' },
124
150
  out: { type: 'string', short: 'o' },
125
151
  site: { type: 'string' },
126
152
  binding: { type: 'string' },
127
153
  params: { type: 'string' },
128
154
  postgres: { type: 'boolean' },
155
+ ts: { type: 'boolean' },
129
156
  'preview-branch': { type: 'string' },
130
157
  yes: { type: 'boolean' },
131
158
  'no-confetti': { type: 'boolean' },
@@ -480,6 +507,43 @@ async function main() {
480
507
  throw new Error('usage: shiply db <create|ls|sql|migrate|delete|attach|branch|branches|delete-branch|merge>');
481
508
  }
482
509
  }
510
+ case 'project': {
511
+ await runProject(positionals.slice(1));
512
+ break;
513
+ }
514
+ case 'listing': {
515
+ await runListing(positionals.slice(1));
516
+ break;
517
+ }
518
+ case 'sending-domain': {
519
+ await runSendingDomain(positionals.slice(1));
520
+ break;
521
+ }
522
+ case 'function': {
523
+ await runFunction(positionals.slice(1), {
524
+ base: values.base,
525
+ key: values.key,
526
+ json: typeof values.json === 'string' && values.json.length > 0,
527
+ ts: Boolean(values.ts),
528
+ });
529
+ break;
530
+ }
531
+ case 'secret': {
532
+ await runSecret(positionals.slice(1), {
533
+ base: values.base,
534
+ key: values.key,
535
+ json: typeof values.json === 'string' && values.json.length > 0,
536
+ });
537
+ break;
538
+ }
539
+ case 'cron': {
540
+ await runCron(positionals.slice(1), {
541
+ base: values.base,
542
+ key: values.key,
543
+ json: typeof values.json === 'string' && values.json.length > 0,
544
+ });
545
+ break;
546
+ }
483
547
  case 'claim': {
484
548
  if (dir !== 'verify') {
485
549
  console.error('Usage: shiply claim verify <SHIPLY-XXXXXXXX>');
@@ -495,12 +559,11 @@ async function main() {
495
559
  process.exit(r.ok ? 0 : 1);
496
560
  }
497
561
  case 'skill': {
498
- const written = await installSkill(Boolean(values.project));
562
+ const written = await installSkill(Boolean(values.project), Boolean(values.force));
499
563
  for (const w of written)
500
564
  console.log(`✔ skill installed — ${w}`);
501
- console.log(' Your agent now knows how to publish, update (same URL!), check SSL, and more.');
502
- console.log(' Restart the agent session to pick it up. Re-run after CLI upgrades.');
503
- return;
565
+ console.log('\nThe skill instructs your agent how to publish, manage sites, query databases, run demand tests, drive customer-intake projects, list/sell sites in the marketplace, manage inbox + sending domains, and more.');
566
+ break;
504
567
  }
505
568
  case 'login': {
506
569
  const base = resolveBase(values.base);
@@ -0,0 +1,169 @@
1
+ import { api, resolveBase } from './publish.js';
2
+ import { loadApiKey } from './config.js';
3
+ const headers = (apiKey) => ({
4
+ 'content-type': 'application/json',
5
+ authorization: `Bearer ${apiKey}`,
6
+ });
7
+ function readFlags(argv) {
8
+ const flags = {};
9
+ const rest = [];
10
+ for (let i = 0; i < argv.length; i++) {
11
+ const a = argv[i];
12
+ if (a.startsWith('--')) {
13
+ const eq = a.indexOf('=');
14
+ if (eq >= 0) {
15
+ flags[a.slice(2, eq)] = a.slice(eq + 1);
16
+ }
17
+ else {
18
+ const next = argv[i + 1];
19
+ if (next && !next.startsWith('--')) {
20
+ flags[a.slice(2)] = next;
21
+ i++;
22
+ }
23
+ else {
24
+ flags[a.slice(2)] = true;
25
+ }
26
+ }
27
+ }
28
+ else {
29
+ rest.push(a);
30
+ }
31
+ }
32
+ return { rest, flags };
33
+ }
34
+ async function ctxFromEnv(flags) {
35
+ const apiKey = typeof flags.key === 'string' ? flags.key : await loadApiKey();
36
+ if (!apiKey) {
37
+ throw new Error('listing commands need an API key — run `shiply login` first');
38
+ }
39
+ const base = typeof flags.base === 'string' ? flags.base : undefined;
40
+ return { base, apiKey };
41
+ }
42
+ function printJson(data) {
43
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
44
+ }
45
+ function money(cents, currency = 'usd') {
46
+ return `${(cents / 100).toFixed(2)} ${currency.toUpperCase()}`;
47
+ }
48
+ async function listAction(ctx, flags) {
49
+ const base = resolveBase(ctx.base);
50
+ // The public REST surface doesn't yet expose GET /api/v1/listings. To list
51
+ // the seller's own listings without inventing an endpoint, defer to the
52
+ // dashboard URL the seller can open. Surface the same hint as the dashboard
53
+ // surfaces; agents wanting machine-readable listings should call the MCP
54
+ // `list_listings` tool.
55
+ if (flags.json) {
56
+ printJson({
57
+ note: 'GET /api/v1/listings is not exposed; use the MCP `list_listings` tool for machine-readable output.',
58
+ dashboardUrl: `${resolveBase(ctx.base)}/dashboard/sales`,
59
+ });
60
+ return;
61
+ }
62
+ console.log('No REST listing index endpoint yet — use the MCP tool `list_listings` for machine output.');
63
+ console.log(` dashboard: ${base}/dashboard/sales`);
64
+ // suppress unused-var warning while keeping the auth check active.
65
+ void headers(ctx.apiKey);
66
+ }
67
+ async function createAction(ctx, slug, flags) {
68
+ const priceFlag = flags.price;
69
+ if (typeof priceFlag !== 'string') {
70
+ throw new Error('usage: shiply listing create <site-slug> --price <cents> --jurisdiction "<region>" [--pitch "<text>"] [--terms standard|custom] [--terms-custom "<text>"] [--status draft|live]');
71
+ }
72
+ const priceCents = Number.parseInt(priceFlag, 10);
73
+ if (!Number.isFinite(priceCents) || priceCents < 100 || priceCents > 999_900) {
74
+ throw new Error('--price must be cents between 100 and 999900 (i.e. $1.00 – $9999.00)');
75
+ }
76
+ const jurisdiction = typeof flags.jurisdiction === 'string' ? flags.jurisdiction : undefined;
77
+ if (!jurisdiction || jurisdiction.length < 2) {
78
+ throw new Error('--jurisdiction is required, e.g. --jurisdiction "California, USA"');
79
+ }
80
+ const termsMode = (typeof flags.terms === 'string' ? flags.terms : 'standard');
81
+ if (termsMode !== 'standard' && termsMode !== 'custom') {
82
+ throw new Error('--terms must be "standard" or "custom"');
83
+ }
84
+ const status = typeof flags.status === 'string' ? flags.status : undefined;
85
+ if (status && status !== 'draft' && status !== 'live') {
86
+ throw new Error('--status must be "draft" or "live" on create');
87
+ }
88
+ const body = {
89
+ slug,
90
+ priceCents,
91
+ jurisdiction,
92
+ termsMode,
93
+ };
94
+ if (typeof flags.pitch === 'string')
95
+ body.pitch = flags.pitch;
96
+ if (typeof flags['terms-custom'] === 'string')
97
+ body.termsCustom = flags['terms-custom'];
98
+ if (status)
99
+ body.status = status;
100
+ const base = resolveBase(ctx.base);
101
+ const r = await api(`${base}/api/v1/listings`, {
102
+ method: 'POST',
103
+ headers: headers(ctx.apiKey),
104
+ body: JSON.stringify(body),
105
+ });
106
+ if (flags.json) {
107
+ printJson(r);
108
+ return;
109
+ }
110
+ console.log(`✔ listed ${slug} for ${money(r.priceCents, r.currency)} [${r.status}]`);
111
+ console.log(` id: ${r.id}`);
112
+ console.log(` marketplace: ${base}/marketplace/${slug}`);
113
+ console.log(` edit later: shiply listing update ${slug} --price <cents>`);
114
+ }
115
+ async function removeAction(ctx, slug, flags) {
116
+ // The REST surface for unlisting is PATCH /api/v1/listings/{id} with
117
+ // status: "draft". We don't have a slug→id resolver in REST, so the CLI
118
+ // currently requires the listing id via --id. Agents that only have the
119
+ // slug should use the MCP `delete_listing` tool which resolves slug→id
120
+ // server-side.
121
+ const id = typeof flags.id === 'string' ? flags.id : undefined;
122
+ if (!id) {
123
+ throw new Error('usage: shiply listing rm <site-slug> --id <listing-id> (use the MCP `delete_listing` tool to unlist by slug without needing the id)');
124
+ }
125
+ const base = resolveBase(ctx.base);
126
+ const r = await api(`${base}/api/v1/listings/${encodeURIComponent(id)}`, {
127
+ method: 'PATCH',
128
+ headers: headers(ctx.apiKey),
129
+ body: JSON.stringify({ status: 'draft' }),
130
+ });
131
+ if (flags.json) {
132
+ printJson(r);
133
+ return;
134
+ }
135
+ console.log(`✔ unlisted ${slug} [${r.status}]`);
136
+ }
137
+ export async function runListing(argv) {
138
+ const sub = argv[0];
139
+ const { rest, flags } = readFlags(argv.slice(1));
140
+ if (!sub || sub === 'help') {
141
+ console.log('usage: shiply listing <ls|create|rm> [args] [--json]');
142
+ return;
143
+ }
144
+ const ctx = await ctxFromEnv(flags);
145
+ switch (sub) {
146
+ case 'ls':
147
+ case 'list':
148
+ await listAction(ctx, flags);
149
+ return;
150
+ case 'create': {
151
+ const slug = rest[0];
152
+ if (!slug) {
153
+ throw new Error('usage: shiply listing create <site-slug> --price <cents> --jurisdiction "<region>"');
154
+ }
155
+ await createAction(ctx, slug, flags);
156
+ return;
157
+ }
158
+ case 'rm':
159
+ case 'delete': {
160
+ const slug = rest[0];
161
+ if (!slug)
162
+ throw new Error('usage: shiply listing rm <site-slug> --id <listing-id>');
163
+ await removeAction(ctx, slug, flags);
164
+ return;
165
+ }
166
+ default:
167
+ throw new Error(`unknown listing subcommand: ${sub} (try ls|create|rm)`);
168
+ }
169
+ }
@@ -0,0 +1,171 @@
1
+ import { api, resolveBase } from './publish.js';
2
+ import { loadApiKey } from './config.js';
3
+ const headers = (apiKey) => ({
4
+ 'content-type': 'application/json',
5
+ authorization: `Bearer ${apiKey}`,
6
+ });
7
+ function readFlags(argv) {
8
+ const flags = {};
9
+ const rest = [];
10
+ for (let i = 0; i < argv.length; i++) {
11
+ const a = argv[i];
12
+ if (a.startsWith('--')) {
13
+ const eq = a.indexOf('=');
14
+ if (eq >= 0) {
15
+ flags[a.slice(2, eq)] = a.slice(eq + 1);
16
+ }
17
+ else {
18
+ const next = argv[i + 1];
19
+ if (next && !next.startsWith('--')) {
20
+ flags[a.slice(2)] = next;
21
+ i++;
22
+ }
23
+ else {
24
+ flags[a.slice(2)] = true;
25
+ }
26
+ }
27
+ }
28
+ else {
29
+ rest.push(a);
30
+ }
31
+ }
32
+ return { rest, flags };
33
+ }
34
+ async function ctxFromEnv(flags) {
35
+ const apiKey = typeof flags.key === 'string' ? flags.key : await loadApiKey();
36
+ if (!apiKey) {
37
+ throw new Error('project commands need an API key — run `shiply login` first');
38
+ }
39
+ const base = typeof flags.base === 'string' ? flags.base : undefined;
40
+ return { base, apiKey };
41
+ }
42
+ function printJson(data) {
43
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
44
+ }
45
+ function shortRow(p) {
46
+ const customer = p.customerEmail ?? p.customerName ?? 'no customer';
47
+ return `${p.id} [${p.status}] ${p.label} (${customer})`;
48
+ }
49
+ async function listAction(ctx, flags) {
50
+ const base = resolveBase(ctx.base);
51
+ const params = new URLSearchParams();
52
+ if (typeof flags.status === 'string')
53
+ params.set('status', flags.status);
54
+ if (typeof flags.q === 'string')
55
+ params.set('q', flags.q);
56
+ const qs = params.toString();
57
+ const url = `${base}/api/v1/projects${qs ? `?${qs}` : ''}`;
58
+ const r = await api(url, { headers: headers(ctx.apiKey) });
59
+ if (flags.json) {
60
+ printJson(r.projects);
61
+ return;
62
+ }
63
+ if (r.projects.length === 0) {
64
+ console.log('No projects yet. Create one: shiply project create <label>');
65
+ return;
66
+ }
67
+ for (const p of r.projects)
68
+ console.log(shortRow(p));
69
+ }
70
+ async function createAction(ctx, label, flags) {
71
+ const base = resolveBase(ctx.base);
72
+ const body = { label };
73
+ if (typeof flags['customer-email'] === 'string')
74
+ body.customerEmail = flags['customer-email'];
75
+ if (typeof flags['customer-name'] === 'string')
76
+ body.customerName = flags['customer-name'];
77
+ const r = await api(`${base}/api/v1/projects`, { method: 'POST', headers: headers(ctx.apiKey), body: JSON.stringify(body) });
78
+ if (flags.json) {
79
+ printJson(r);
80
+ return;
81
+ }
82
+ const appBase = base.replace(/\/+$/, '');
83
+ console.log(`✔ created project ${r.id} [${r.status}] ${r.label}`);
84
+ console.log(` intake URL (share with customer):`);
85
+ console.log(` ${appBase}/intake/${r.intakeToken}`);
86
+ if (r.customerEmail)
87
+ console.log(` intake invite emailed to ${r.customerEmail}`);
88
+ }
89
+ async function getAction(ctx, id, flags) {
90
+ const base = resolveBase(ctx.base);
91
+ const r = await api(`${base}/api/v1/projects/${encodeURIComponent(id)}`, { headers: headers(ctx.apiKey) });
92
+ if (flags.json) {
93
+ printJson(r);
94
+ return;
95
+ }
96
+ console.log(JSON.stringify(r, null, 2));
97
+ }
98
+ async function archiveAction(ctx, id, flags) {
99
+ const base = resolveBase(ctx.base);
100
+ // Server archives via PATCH /api/v1/projects/{id} with { status: "archived" }.
101
+ // There is no dedicated /archive route — PATCH is the documented surface.
102
+ const body = { status: 'archived' };
103
+ if (typeof flags.reason === 'string')
104
+ body.archivedReason = flags.reason;
105
+ const r = await api(`${base}/api/v1/projects/${encodeURIComponent(id)}`, { method: 'PATCH', headers: headers(ctx.apiKey), body: JSON.stringify(body) });
106
+ if (flags.json) {
107
+ printJson(r);
108
+ return;
109
+ }
110
+ console.log(`✔ archived ${r.id} [${r.status}] ${r.label}`);
111
+ console.log(` restore later with: shiply project restore ${r.id}`);
112
+ }
113
+ async function restoreAction(ctx, id, flags) {
114
+ const base = resolveBase(ctx.base);
115
+ const r = await api(`${base}/api/v1/projects/${encodeURIComponent(id)}`, {
116
+ method: 'PATCH',
117
+ headers: headers(ctx.apiKey),
118
+ body: JSON.stringify({ status: 'draft' }),
119
+ });
120
+ if (flags.json) {
121
+ printJson(r);
122
+ return;
123
+ }
124
+ console.log(`✔ restored ${r.id} [${r.status}] ${r.label}`);
125
+ }
126
+ export async function runProject(argv) {
127
+ const sub = argv[0];
128
+ const { rest, flags } = readFlags(argv.slice(1));
129
+ if (!sub || sub === 'help') {
130
+ console.log('usage: shiply project <ls|create|get|archive|restore> [args] [--json]');
131
+ return;
132
+ }
133
+ const ctx = await ctxFromEnv(flags);
134
+ switch (sub) {
135
+ case 'ls':
136
+ case 'list':
137
+ await listAction(ctx, flags);
138
+ return;
139
+ case 'create': {
140
+ const label = rest[0];
141
+ if (!label) {
142
+ throw new Error('usage: shiply project create <label> [--customer-email <email>] [--customer-name <name>]');
143
+ }
144
+ await createAction(ctx, label, flags);
145
+ return;
146
+ }
147
+ case 'get': {
148
+ const id = rest[0];
149
+ if (!id)
150
+ throw new Error('usage: shiply project get <id>');
151
+ await getAction(ctx, id, flags);
152
+ return;
153
+ }
154
+ case 'archive': {
155
+ const id = rest[0];
156
+ if (!id)
157
+ throw new Error('usage: shiply project archive <id> [--reason "<text>"]');
158
+ await archiveAction(ctx, id, flags);
159
+ return;
160
+ }
161
+ case 'restore': {
162
+ const id = rest[0];
163
+ if (!id)
164
+ throw new Error('usage: shiply project restore <id>');
165
+ await restoreAction(ctx, id, flags);
166
+ return;
167
+ }
168
+ default:
169
+ throw new Error(`unknown project subcommand: ${sub} (try ls|create|get|archive|restore)`);
170
+ }
171
+ }
@@ -0,0 +1,159 @@
1
+ import { api, resolveBase } from './publish.js';
2
+ import { loadApiKey } from './config.js';
3
+ const headers = (apiKey) => ({
4
+ 'content-type': 'application/json',
5
+ authorization: `Bearer ${apiKey}`,
6
+ });
7
+ function readFlags(argv) {
8
+ const flags = {};
9
+ const rest = [];
10
+ for (let i = 0; i < argv.length; i++) {
11
+ const a = argv[i];
12
+ if (a.startsWith('--')) {
13
+ const eq = a.indexOf('=');
14
+ if (eq >= 0) {
15
+ flags[a.slice(2, eq)] = a.slice(eq + 1);
16
+ }
17
+ else {
18
+ const next = argv[i + 1];
19
+ if (next && !next.startsWith('--')) {
20
+ flags[a.slice(2)] = next;
21
+ i++;
22
+ }
23
+ else {
24
+ flags[a.slice(2)] = true;
25
+ }
26
+ }
27
+ }
28
+ else {
29
+ rest.push(a);
30
+ }
31
+ }
32
+ return { rest, flags };
33
+ }
34
+ async function ctxFromEnv(flags) {
35
+ const apiKey = typeof flags.key === 'string' ? flags.key : await loadApiKey();
36
+ if (!apiKey) {
37
+ throw new Error('sending-domain commands need an API key — run `shiply login` first');
38
+ }
39
+ const base = typeof flags.base === 'string' ? flags.base : undefined;
40
+ return { base, apiKey };
41
+ }
42
+ function printJson(data) {
43
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
44
+ }
45
+ function printDns(d) {
46
+ if (!d.dns || d.dns.length === 0) {
47
+ console.log(' (no DNS records returned — call `shiply sending-domain verify <id>` after Resend provisions records)');
48
+ return;
49
+ }
50
+ console.log(' Add these DNS records at your registrar:');
51
+ for (const r of d.dns) {
52
+ const status = r.status ? ` [${r.status}]` : '';
53
+ console.log(` ${r.type.padEnd(5)} ${r.name} → ${r.value}${status}`);
54
+ }
55
+ }
56
+ async function listAction(ctx, flags) {
57
+ const base = resolveBase(ctx.base);
58
+ const r = await api(`${base}/api/v1/sending-domains`, { headers: headers(ctx.apiKey) });
59
+ if (flags.json) {
60
+ printJson(r.domains);
61
+ return;
62
+ }
63
+ if (!r.domains.length) {
64
+ console.log('No sending domains yet. Add one: shiply sending-domain add <domain>');
65
+ return;
66
+ }
67
+ for (const d of r.domains) {
68
+ const from = d.fromAddress ? ` from=${d.fromAddress}` : '';
69
+ console.log(`${d.domain} [${d.status}] id=${d.id}${from}`);
70
+ }
71
+ }
72
+ async function addAction(ctx, domain, flags) {
73
+ const base = resolveBase(ctx.base);
74
+ const r = await api(`${base}/api/v1/sending-domains`, {
75
+ method: 'POST',
76
+ headers: headers(ctx.apiKey),
77
+ body: JSON.stringify({ domain }),
78
+ });
79
+ if (flags.json) {
80
+ printJson(r);
81
+ return;
82
+ }
83
+ console.log(`✔ ${r.domain} registered [${r.status}] id=${r.id}`);
84
+ printDns(r);
85
+ console.log('');
86
+ console.log(` After DNS propagates: shiply sending-domain verify ${r.id}`);
87
+ }
88
+ async function verifyAction(ctx, id, flags) {
89
+ const base = resolveBase(ctx.base);
90
+ const r = await api(`${base}/api/v1/sending-domains/${encodeURIComponent(id)}/verify`, { method: 'POST', headers: headers(ctx.apiKey), body: '{}' });
91
+ if (flags.json) {
92
+ printJson(r);
93
+ return;
94
+ }
95
+ console.log(`${r.domain} [${r.status}]`);
96
+ if (r.status === 'verified') {
97
+ console.log(' ✔ ready — shiply will send your demand-test emails from this domain');
98
+ }
99
+ else {
100
+ printDns(r);
101
+ console.log(' Re-run `shiply sending-domain verify ' + r.id + '` after DNS updates propagate.');
102
+ }
103
+ }
104
+ async function removeAction(ctx, id, flags) {
105
+ if (!flags.yes) {
106
+ console.log(` refusing — \`sending-domain rm\` deletes the domain and forces existing demand tests to fall back to the shared shiply sender. Re-run with --yes to confirm.`);
107
+ return;
108
+ }
109
+ const base = resolveBase(ctx.base);
110
+ await api(`${base}/api/v1/sending-domains/${encodeURIComponent(id)}`, {
111
+ method: 'DELETE',
112
+ headers: headers(ctx.apiKey),
113
+ });
114
+ if (flags.json) {
115
+ printJson({ removed: id });
116
+ return;
117
+ }
118
+ console.log(`✔ removed sending domain ${id}`);
119
+ }
120
+ export async function runSendingDomain(argv) {
121
+ const sub = argv[0];
122
+ const { rest, flags } = readFlags(argv.slice(1));
123
+ if (!sub || sub === 'help') {
124
+ console.log('usage: shiply sending-domain <ls|add|verify|rm> [args] [--json]');
125
+ return;
126
+ }
127
+ const ctx = await ctxFromEnv(flags);
128
+ switch (sub) {
129
+ case 'ls':
130
+ case 'list':
131
+ await listAction(ctx, flags);
132
+ return;
133
+ case 'add': {
134
+ const domain = rest[0];
135
+ if (!domain)
136
+ throw new Error('usage: shiply sending-domain add <domain>');
137
+ await addAction(ctx, domain, flags);
138
+ return;
139
+ }
140
+ case 'verify': {
141
+ const id = rest[0];
142
+ if (!id)
143
+ throw new Error('usage: shiply sending-domain verify <id>');
144
+ await verifyAction(ctx, id, flags);
145
+ return;
146
+ }
147
+ case 'rm':
148
+ case 'remove':
149
+ case 'delete': {
150
+ const id = rest[0];
151
+ if (!id)
152
+ throw new Error('usage: shiply sending-domain rm <id> --yes');
153
+ await removeAction(ctx, id, flags);
154
+ return;
155
+ }
156
+ default:
157
+ throw new Error(`unknown sending-domain subcommand: ${sub} (try ls|add|verify|rm)`);
158
+ }
159
+ }
package/dist/skill.js CHANGED
@@ -16,12 +16,16 @@ export function installTargets(project) {
16
16
  }
17
17
  return [{ agent: 'Claude Code (all projects)', dir: join(homedir(), '.claude', 'skills', 'shiply') }];
18
18
  }
19
- export async function installSkill(project) {
19
+ export async function installSkill(project, force = false) {
20
20
  const content = await bundledSkill();
21
21
  const written = [];
22
22
  for (const t of installTargets(project)) {
23
23
  await mkdir(t.dir, { recursive: true });
24
24
  const file = join(t.dir, 'SKILL.md');
25
+ // writeFile always overwrites; --force is an explicit signal of intent
26
+ // (and matches what users type from the AGENT_PROMPT). Without --force we
27
+ // still overwrite — but a future version may add a sanity check.
28
+ void force;
25
29
  await writeFile(file, content);
26
30
  written.push(`${t.agent}: ${file}`);
27
31
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shiply-cli",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Publish static sites to shiply.now from the command line — instant web hosting for agents.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/skill/SKILL.md CHANGED
@@ -3,6 +3,18 @@ name: shiply
3
3
  description: Publish static sites to the web instantly with shiply.now and manage them (updates, SSL checks, custom domains, variables). Use when the user asks to publish, host, deploy, share, or update a website/page/demo/report, or wants a live URL for generated files. Triggers - "publish this", "host this", "put this online", "give me a link", "update the site", shiply.
4
4
  ---
5
5
 
6
+ ---
7
+
8
+ > **Skill freshness check.** Shiply ships new capabilities weekly. If this
9
+ > file's "Last updated" below is more than 7 days old, re-install it with
10
+ > `npx -y shiply-cli@latest skill --force` OR fetch `https://shiply.now/skill.md`
11
+ > fresh — your cached copy may be missing features like databases, projects,
12
+ > marketplace, or sending domains.
13
+ >
14
+ > **Last updated: 2026-06-20**
15
+
16
+ ---
17
+
6
18
  # shiply — instant web hosting for agents
7
19
 
8
20
  shiply.now puts files on the web in seconds. No account needed to start:
@@ -126,11 +138,23 @@ lines for automation; exit code 0 = ready.
126
138
  ### 3. Raw HTTP (no installs)
127
139
  ```
128
140
  1. POST https://shiply.now/api/v1/publish
129
- {"files":[{"path":"index.html","size":<bytes>,"contentType":"text/html","hash":"<sha256, optional>"}]}
141
+ {
142
+ "agentName": "<your tool name, e.g. Claude Code, Cursor, Codex>",
143
+ "files": [{"path":"index.html","size":<bytes>,"contentType":"text/html","hash":"<sha256, optional>"}]
144
+ }
130
145
  (+ "Authorization: Bearer shp_…" for permanent owned sites)
131
146
  2. PUT each file's bytes to response upload.uploads[].url
132
147
  3. POST upload.finalizeUrl with {"versionId":"..."}
133
148
  ```
149
+
150
+ **ALWAYS include `agentName` on anonymous publishes.** The response then
151
+ carries `deviceAuth` ({user_code, verification_url, poll_url, device_code,
152
+ expires_in, interval}) alongside `claimUrl`. Show the user `verification_url`,
153
+ poll `poll_url` every `interval` seconds with the `device_code`, and on
154
+ `{status:"approved"}` you get a permanent `api_key` PLUS the linked site
155
+ is claimed in the same Allow click. Without `agentName` the user has to
156
+ do a separate manual claim step every time.
157
+
134
158
  **Updates:** anonymous → include `"claimToken":"..."` (returned ONCE by the
135
159
  first publish — save it); owned → include `"slug":"..."`. Hashes make updates
136
160
  cheap: unchanged files are skipped server-side.
@@ -318,6 +342,180 @@ providers; `db_schema` introspects `sqlite_master` (D1) or
318
342
  `information_schema` (Neon). Full docs:
319
343
  https://shiply.now/docs/databases
320
344
 
345
+ ## Functions (Workers Lite) — webhooks, cron, secrets, full backend (Developer plan)
346
+
347
+ shiply Workers Lite lets a published site include server-side code that runs
348
+ on every request. Use when the user needs:
349
+ - A webhook receiver (Stripe, GitHub, etc.) with raw body + signature verification
350
+ - A cron job (daily reminders, periodic sync, retention emails)
351
+ - A privileged API call using secrets (Stripe key, OpenAI key) without exposing them to the browser
352
+ - An authenticated mutation on D1 / Neon (do the auth check in the function before writing)
353
+
354
+ ### How it works
355
+
356
+ Author `worker.js` (or `worker.ts`) at the publish root. On `shiply publish`,
357
+ shiply compiles (TS) + deploys it as a per-site Cloudflare Worker bound to
358
+ the site's hostname. The worker handles every request; pass static asset
359
+ requests through `env.ASSETS.fetch(request)`.
360
+
361
+ ```ts
362
+ import { verifyStripeSig, json } from 'shiply-runtime'
363
+
364
+ export default {
365
+ async fetch(req: Request, env: Env): Promise<Response> {
366
+ const url = new URL(req.url)
367
+ if (url.pathname === '/api/webhooks/stripe' && req.method === 'POST') {
368
+ const body = await req.text()
369
+ if (!await verifyStripeSig(body, req.headers.get('stripe-signature'), env.STRIPE_WEBHOOK_SECRET)) {
370
+ return new Response('bad sig', { status: 400 })
371
+ }
372
+ // ... handle event, write to env.SITE_DB
373
+ return new Response('ok')
374
+ }
375
+ return env.ASSETS.fetch(req)
376
+ },
377
+ async scheduled(event: ScheduledEvent, env: Env) {
378
+ if (event.cron === '0 9 * * *') { /* daily job */ }
379
+ }
380
+ }
381
+ ```
382
+
383
+ `.shiply/crons.json` declares cron schedules. `.shiply/secrets.json` declares
384
+ secret names (values set via CLI/MCP, never reach the publish payload).
385
+
386
+ ### Bindings available in `env`
387
+
388
+ - `env.ASSETS` — fall through to static (`env.ASSETS.fetch(request)`)
389
+ - `env.SITE_DB` — attached D1 (if `shiply db attach` was run)
390
+ - `env.<NAME>` — each shiply Variable becomes a plain-text env var
391
+ - `env.<NAME>` — each secret set via `set_secret` is encrypted, accessible as env var
392
+
393
+ ### MCP tools
394
+
395
+ | Tool | Purpose |
396
+ |---|---|
397
+ | `deploy_function` | Deploy a Worker function (alternative to publish auto-detection) |
398
+ | `get_function` | Read the deployed source + metadata |
399
+ | `remove_function` | Strip function + secrets + crons; fall back to static |
400
+ | `set_secret` / `list_secrets` / `remove_secret` | Manage CF Worker secrets |
401
+ | `set_cron` / `list_crons` / `remove_cron` | Manage cron triggers |
402
+ | `get_function_logs` | Deep-link to CF dashboard for live tail |
403
+
404
+ ### CLI
405
+
406
+ ```bash
407
+ shiply publish . # auto-detects worker.js + crons.json + secrets.json
408
+ shiply function deploy <slug> # alternative: upload worker.js without re-publishing
409
+ shiply function deploy <slug> --ts # uploads worker.ts (server-side compile)
410
+ shiply secret set <slug> STRIPE_KEY sk_xxx # set secret value
411
+ shiply cron set <slug> /api/daily "0 9 * * *"
412
+ ```
413
+
414
+ ### REST
415
+
416
+ `POST/GET/DELETE /api/v1/sites/{slug}/function` · `POST/GET /api/v1/sites/{slug}/secrets` · `DELETE /api/v1/sites/{slug}/secrets/{name}` · `GET/POST/DELETE /api/v1/sites/{slug}/crons`
417
+
418
+ ### Limits + plan
419
+
420
+ - 1 MB compiled script size, 30s CPU per request, V8 isolate runtime (no Node APIs)
421
+ - 50 secrets, 20 cron triggers per site
422
+ - Plan-gated to Developer: Founder/Hobby see `402 payment_required`. Upgrade: shiply.now/dashboard/plan
423
+
424
+ Docs: https://shiply.now/docs/functions
425
+
426
+ ## Projects — customer intake + AI brief
427
+
428
+ When a dev needs to capture a real client brief, they spin up a **project**:
429
+ the dashboard (or `shiply project create`) mints a one-URL intake form to
430
+ share with the customer. The customer fills a 10-step wizard, attaches
431
+ files, hits submit. An AI brief generator (MiniMax-M2 with Anthropic
432
+ fallback) turns the answers into a structured brief the dev reviews and
433
+ edits inline. All files flow into a per-project drive folder.
434
+
435
+ MCP tools:
436
+ ```
437
+ list_projects — your projects (filter status/q)
438
+ create_project — start a new intake; pass customerName/customerEmail to email them the link
439
+ get_project — full project + brief
440
+ update_brief — patch brief jsonb (after AI generation)
441
+ regenerate_brief — re-run AI from current intake responses
442
+ archive_project / restore_project — lifecycle
443
+ list_project_files — files uploaded by the customer
444
+ resend_intake_invite — re-email the customer their intake link (requires customerEmail on project)
445
+ ```
446
+
447
+ REST: `POST/GET /api/v1/projects · GET/PATCH /api/v1/projects/{id} · POST /api/v1/projects/{id}/regenerate-brief`
448
+ (archive: `PATCH /api/v1/projects/{id}` with `{"status":"archived"}` — no
449
+ dedicated `/archive` route).
450
+
451
+ CLI: `shiply project ls · shiply project create <label> [--customer-email <e>] [--customer-name <n>] · shiply project get <id> · shiply project archive <id>`
452
+
453
+ Docs: https://shiply.now/docs/projects
454
+
455
+ ## Marketplace — sell built sites
456
+
457
+ Devs can list any owned site for sale: set a price + short pitch, choose
458
+ standard or custom terms, pick a jurisdiction. Stripe Connect Express
459
+ handles seller payouts — sellers complete a one-time onboarding before
460
+ listing (`get_connect_status` returns the onboarding URL when needed).
461
+ Buyers pay via Stripe Checkout; on `checkout.session.completed` the
462
+ webhook flips the order to `paid` and transfers site ownership
463
+ atomically. Refunds are available within the order's refund window
464
+ (`refundExpiresAt`); a refund reverts ownership to the seller.
465
+
466
+ Prices are passed as `priceCents` (whole-dollar cents between 100 and
467
+ 999900). `termsMode='standard'` uses shiply's template; `'custom'`
468
+ requires `termsCustom` ≥50 chars.
469
+
470
+ MCP tools:
471
+ ```
472
+ list_listings — my listings
473
+ create_listing — list a site (siteSlug, priceCents, pitch?, termsMode, jurisdiction, ...)
474
+ update_listing — change price/pitch/status (draft|live|paused)
475
+ delete_listing — pulls off marketplace (sets status=draft)
476
+ list_my_sales — orders where I'm the seller
477
+ list_my_orders — orders where I'm the buyer
478
+ refund_order — issue Stripe refund (within refund window; goes back to buyer)
479
+ get_connect_status — Stripe Connect onboarding state + actionable URL
480
+ ```
481
+
482
+ REST: `POST /api/v1/listings · PATCH /api/v1/listings/{id} · POST /api/v1/listings/{id}/checkout · POST /api/v1/orders/{id}/refund · GET /api/v1/connect/status`
483
+ (no GET listing index over REST yet — use the MCP `list_listings` /
484
+ `list_my_sales` / `list_my_orders` tools for machine output, or the
485
+ dashboard `/dashboard/sales` page for humans.)
486
+
487
+ CLI: `shiply listing ls · shiply listing create <site-slug> --price <cents> --jurisdiction "<region>" · shiply listing rm <slug> --id <listing-id>`
488
+
489
+ **Stripe Connect note:** Sellers must complete one-time Stripe Connect
490
+ onboarding before listing. Call `get_connect_status` first — when status
491
+ isn't `'ready'` it returns an `onboardingUrl` you should hand to the
492
+ user as a clickable link.
493
+
494
+ Docs: https://shiply.now/docs/marketplace
495
+
496
+ ## Sending domains — outbound email on your domain
497
+
498
+ For agents that want shiply to send emails (demand-test broadcasts,
499
+ project intake invites, transactional notifications) from a custom
500
+ verified domain instead of the shared shiply pool. Backed by Resend —
501
+ add the DNS records they return at your registrar, then call
502
+ `verify_sending_domain` to re-check.
503
+
504
+ MCP tools:
505
+ ```
506
+ list_sending_domains — all your sending domains
507
+ add_sending_domain — start: returns DNS records to add at your registrar
508
+ verify_sending_domain — re-check DNS after adding records
509
+ remove_sending_domain — tear down
510
+ ```
511
+
512
+ REST: `GET/POST /api/v1/sending-domains · POST /api/v1/sending-domains/{id}/verify · DELETE /api/v1/sending-domains/{id}`
513
+
514
+ CLI: `shiply sending-domain ls · shiply sending-domain add <domain> · shiply sending-domain verify <id> · shiply sending-domain rm <id> --yes`
515
+
516
+ Cannot be a `shiply.now` subdomain. Verified status flips once SPF +
517
+ DKIM (+ MX for inbound) all check out.
518
+
321
519
  ## Make a site private (paid plans)
322
520
  To password-protect or restrict a site: PATCH /api/v1/publishes/<slug>/access
323
521
  with {"mode":"password","password":"..."} or {"mode":"restricted",