shiply-cli 0.28.0 → 0.29.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/data.js CHANGED
@@ -105,6 +105,7 @@ export async function dataExport(ctx, slug, collection, opts) {
105
105
  export async function dataWipeCollection(ctx, slug, collection, opts) {
106
106
  if (!opts.yes) {
107
107
  console.error(` refusing — wipe-collection deletes EVERY record in "${collection}" on ${slug}. Re-run with --yes to confirm.`);
108
+ process.exitCode = 1; // refusal is not success — scripts must see a non-zero exit
108
109
  return;
109
110
  }
110
111
  const base = resolveBase(ctx.base);
package/dist/db.js CHANGED
@@ -144,6 +144,7 @@ export async function dbMigrate(ctx, idOrName, dir) {
144
144
  export async function dbDelete(ctx, idOrName, opts) {
145
145
  if (!opts.yes) {
146
146
  console.log(` refusing — \`db delete\` drops the database "${idOrName}" and every row in it. Re-run with --yes to confirm.`);
147
+ process.exitCode = 1; // refusal is not success — scripts must see a non-zero exit
147
148
  return;
148
149
  }
149
150
  const id = await resolveId(ctx, idOrName);
@@ -195,6 +196,7 @@ export async function dbBranches(ctx, parentNameOrId) {
195
196
  export async function dbDeleteBranch(ctx, branchIdOrName, opts) {
196
197
  if (!opts.yes) {
197
198
  console.log(` refusing — \`db delete-branch\` drops the branch "${branchIdOrName}" and its Neon endpoint. Re-run with --yes to confirm.`);
199
+ process.exitCode = 1; // refusal is not success — scripts must see a non-zero exit
198
200
  return;
199
201
  }
200
202
  const base = resolveBase(ctx.base);
package/dist/domain.js CHANGED
@@ -143,6 +143,36 @@ export async function domainPrimary(ctx, domain, hostname) {
143
143
  console.log(` ${s.hostname}`);
144
144
  }
145
145
  }
146
+ export async function domainRm(ctx, domain, opts = {}) {
147
+ if (!opts.yes) {
148
+ throw new Error(`refusing to remove ${domain} without --yes (subdomains mapped to it stop resolving)`);
149
+ }
150
+ const base = resolveBase(ctx.base);
151
+ const r = await api(`${base}/api/v1/custom-domains/${enc(domain)}`, {
152
+ method: 'DELETE',
153
+ headers: headers(ctx.apiKey),
154
+ });
155
+ console.log(`✔ removed ${r.removed}`);
156
+ }
157
+ /** Re-poll CF cert status + live TLS/HTTPS probe for all subdomains of a
158
+ * registered custom domain. Run this after `domain connect` / `domain sub add`
159
+ * until every subdomain reports ready=true. */
160
+ export async function domainCheck(ctx, domain) {
161
+ const base = resolveBase(ctx.base);
162
+ const r = await api(`${base}/api/v1/custom-domains/${enc(domain)}/check`, {
163
+ method: 'POST',
164
+ headers: headers(ctx.apiKey),
165
+ body: '{}',
166
+ });
167
+ if (!r.subdomains?.length) {
168
+ console.log(`No subdomains mapped under ${domain} yet. shiply domain sub add <host> --site <slug>`);
169
+ return;
170
+ }
171
+ for (const s of r.subdomains) {
172
+ console.log(` ${s.hostname} ${s.ready ? '✔ ready' : '✖ not ready'}`);
173
+ console.log(`DOMAIN_CHECK hostname=${s.hostname} ready=${s.ready}`);
174
+ }
175
+ }
146
176
  export async function domainSync(ctx, domain) {
147
177
  const base = resolveBase(ctx.base);
148
178
  const r = await api(`${base}/api/v1/custom-domains/${enc(domain)}/sync-dns`, { method: 'POST', headers: headers(ctx.apiKey), body: '{}' });
package/dist/drive.js CHANGED
@@ -30,8 +30,12 @@ export async function drivePut(ctx, file, asPath) {
30
30
  const stage = await api(`${base}/api/v1/drives/${id}/files/uploads`, { method: 'POST', headers: headers(ctx.apiKey), body: JSON.stringify({ files: [meta] }) });
31
31
  for (const u of stage.uploads) {
32
32
  const res = await fetch(u.url, { method: 'PUT', body: new Uint8Array(body) });
33
- if (!res.ok)
34
- throw new Error(`upload failed for ${u.path}`);
33
+ if (!res.ok) {
34
+ if (res.status === 401 || res.status === 403) {
35
+ throw new Error(`upload failed for ${u.path}: HTTP ${res.status} — the upload URL is not authorized. Re-run \`shiply drive put\` to mint a fresh one.`);
36
+ }
37
+ throw new Error(`upload failed for ${u.path}: HTTP ${res.status} — the object store rejected the file. Re-run \`shiply drive put\`; if it keeps failing, check the file size/name.`);
38
+ }
35
39
  }
36
40
  await api(`${base}/api/v1/drives/${id}/files/finalize`, {
37
41
  method: 'POST',
@@ -45,8 +49,15 @@ export async function driveGet(ctx, path, out) {
45
49
  const id = await defaultDrive(ctx);
46
50
  const { url } = await api(`${base}/api/v1/drives/${id}/files/${path.split('/').map(encodeURIComponent).join('/')}`, { headers: headers(ctx.apiKey) });
47
51
  const res = await fetch(url);
48
- if (!res.ok)
49
- throw new Error(`download failed (${res.status})`);
52
+ if (!res.ok) {
53
+ if (res.status === 404) {
54
+ throw new Error(`download failed: HTTP 404 — no file at "${path}" in this drive. Check the path with \`shiply drive ls\`.`);
55
+ }
56
+ if (res.status === 401 || res.status === 403) {
57
+ throw new Error(`download failed: HTTP ${res.status} — the download URL is not authorized. Re-run \`shiply drive get\` to mint a fresh one.`);
58
+ }
59
+ throw new Error(`download failed: HTTP ${res.status} for "${path}" — re-run \`shiply drive get\`; if it keeps failing, the object may be corrupted.`);
60
+ }
50
61
  const buf = Buffer.from(await res.arrayBuffer());
51
62
  if (out) {
52
63
  await writeFile(out, buf);
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ import { detectIsr } from './isr.js';
15
15
  import { runClaimVerify } from './claim.js';
16
16
  import { dbAttach, dbBranch, dbBranches, dbCreate, dbDelete, dbDeleteBranch, dbLs, dbMerge, dbMigrate, dbSql, } from './db.js';
17
17
  import { driveGet, driveLs, drivePublish, drivePut, driveRm } from './drive.js';
18
- import { domainAdd, domainConnect, domainLs, domainPrimary, domainSubAdd, domainSync } from './domain.js';
18
+ import { domainAdd, domainCheck, domainConnect, domainLs, domainPrimary, domainRm, domainSubAdd, domainSync } from './domain.js';
19
19
  import { runProject } from './projects.js';
20
20
  import { runClient, parseClientArg } from './clients.js';
21
21
  import { runListing } from './listings.js';
@@ -36,6 +36,7 @@ import { runStatus } from './status-cmd.js';
36
36
  import { accessSet, accessShow } from './access.js';
37
37
  import { sitesLs, sitesPromote, sitesRm, sitesRollback } from './sites.js';
38
38
  import { verifySite } from './verify.js';
39
+ import { variableAttach, variableDetach, variableLs, variableRm, variableSet } from './variable.js';
39
40
  // Resolve the package.json beside the compiled dist/ so `shiply --version`
40
41
  // can print the running CLI version (works in dev tsc output + npm install).
41
42
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -63,6 +64,10 @@ Usage:
63
64
  shiply status <slug-or-domain> [--wait] SSL + readiness check (agent-friendly output)
64
65
  (no arg → account/plan status, see below)
65
66
  shiply duplicate <slug> Server-side copy of an owned site → new URL
67
+ shiply ls List sites in your account
68
+ shiply rm <slug> --yes Permanently delete an owned site
69
+ shiply rollback <slug> [versionId] Re-point a site to a prior finalized deploy
70
+ (omit versionId to list deploys and pick one)
66
71
  shiply promote <preview-slug> --to <dest-slug>
67
72
  Copy the exact previewed bytes into a production site (no rebuild)
68
73
  shiply detect [dir] Diagnose: print framework + dir that would upload
@@ -88,6 +93,14 @@ Usage:
88
93
  shiply domain sub add <host> --site <slug> Map a subdomain (e.g. app.example.com) to a slug
89
94
  shiply domain sync <domain> Re-sync DNS records for a connected domain
90
95
  shiply domain primary <domain> <hostname> Mark hostname as primary; siblings 301-redirect (SEO)
96
+ shiply domain check <domain> Re-poll cert/DNS/HTTPS readiness for every subdomain
97
+ shiply domain rm <domain> --yes Remove a custom domain (its subdomains stop resolving)
98
+ shiply variable <ls|set|rm|attach|detach> Account-level encrypted variables (e.g. API keys for sites)
99
+ shiply variable set <NAME> <value> Save/update a variable (or: --stdin to read the value)
100
+ shiply variable ls [--reveal] List variables (masked by default)
101
+ shiply variable rm <NAME> --yes Delete a variable
102
+ shiply variable attach <NAME> --site <slug> Expose a saved variable to one site's Worker env
103
+ shiply variable detach <NAME> --site <slug> Stop exposing a variable to a site's Worker env
91
104
  shiply project <ls|create|get|archive|restore>
92
105
  Customer-intake projects + AI brief generator
93
106
  shiply project create <label> [--customer-email <e>] [--customer-name <n>]
@@ -220,7 +233,7 @@ async function main() {
220
233
  // parseArgs sees it. (A prior lookahead that cancelled the flag when a
221
234
  // positional followed made `shiply publish --json ./dist` swallow the dir as
222
235
  // the --json value, leaving no <dir>.)
223
- const BARE_JSON_CMDS = new Set(['status', 'publish', 'update', 'logs']);
236
+ const BARE_JSON_CMDS = new Set(['status', 'publish', 'update', 'logs', 'variable']);
224
237
  const bareJsonFlag = BARE_JSON_CMDS.has(rawArgv[0]) && rawArgv.includes('--json');
225
238
  if (bareJsonFlag) {
226
239
  const idx = rawArgv.indexOf('--json');
@@ -263,6 +276,8 @@ async function main() {
263
276
  ts: { type: 'boolean' },
264
277
  'preview-branch': { type: 'string' },
265
278
  yes: { type: 'boolean' },
279
+ reveal: { type: 'boolean' },
280
+ stdin: { type: 'boolean' },
266
281
  'no-confetti': { type: 'boolean' },
267
282
  json: { type: 'string' },
268
283
  limit: { type: 'string' },
@@ -832,8 +847,18 @@ async function main() {
832
847
  throw new Error('usage: shiply domain primary <domain> <hostname>');
833
848
  await domainPrimary(dctx, arg, arg2);
834
849
  return;
850
+ case 'rm':
851
+ if (!arg)
852
+ throw new Error('usage: shiply domain rm <domain> --yes');
853
+ await domainRm(dctx, arg, { yes: Boolean(values.yes) });
854
+ return;
855
+ case 'check':
856
+ if (!arg)
857
+ throw new Error('usage: shiply domain check <domain>');
858
+ await domainCheck(dctx, arg);
859
+ return;
835
860
  default:
836
- throw new Error('usage: shiply domain <ls|add|connect|sub|sync|primary>');
861
+ throw new Error('usage: shiply domain <ls|add|connect|sub|sync|primary|rm|check>');
837
862
  }
838
863
  }
839
864
  case 'db': {
@@ -904,6 +929,55 @@ async function main() {
904
929
  throw new Error('usage: shiply db <create|ls|sql|migrate|delete|attach|branch|branches|delete-branch|merge>');
905
930
  }
906
931
  }
932
+ case 'variable': {
933
+ const apiKey = values.key ?? (await loadApiKey());
934
+ if (!apiKey)
935
+ throw new Error('variable commands need an API key — run `shiply login` first');
936
+ const vctx = { base: values.base, apiKey };
937
+ const jsonOut = bareJsonFlag;
938
+ const sub = dir; // positionals[1]
939
+ const arg = positionals[2]; // name
940
+ const arg2 = positionals[3]; // value (set) — unused for others
941
+ switch (sub) {
942
+ case 'ls':
943
+ await variableLs(vctx, { reveal: Boolean(values.reveal), json: jsonOut });
944
+ return;
945
+ case 'set': {
946
+ if (!arg)
947
+ throw new Error("usage: shiply variable set <NAME> <value> (or: shiply variable set <NAME> --stdin)");
948
+ let value = arg2;
949
+ if (values.stdin) {
950
+ const { readFileSync } = await import('node:fs');
951
+ value = readFileSync(0, 'utf8').replace(/\n$/, '');
952
+ }
953
+ if (value === undefined)
954
+ throw new Error("usage: shiply variable set <NAME> <value> (or: shiply variable set <NAME> --stdin)");
955
+ await variableSet(vctx, arg, value, { json: jsonOut });
956
+ return;
957
+ }
958
+ case 'rm':
959
+ if (!arg)
960
+ throw new Error('usage: shiply variable rm <NAME> --yes');
961
+ await variableRm(vctx, arg, { yes: Boolean(values.yes), json: jsonOut });
962
+ return;
963
+ case 'attach':
964
+ if (!arg)
965
+ throw new Error('usage: shiply variable attach <NAME> --site <slug>');
966
+ if (!values.site)
967
+ throw new Error('--site <slug> is required for `shiply variable attach`');
968
+ await variableAttach(vctx, arg, values.site, { json: jsonOut });
969
+ return;
970
+ case 'detach':
971
+ if (!arg)
972
+ throw new Error('usage: shiply variable detach <NAME> --site <slug>');
973
+ if (!values.site)
974
+ throw new Error('--site <slug> is required for `shiply variable detach`');
975
+ await variableDetach(vctx, arg, values.site, { json: jsonOut });
976
+ return;
977
+ default:
978
+ throw new Error('usage: shiply variable <ls|set|rm|attach|detach>');
979
+ }
980
+ }
907
981
  case 'project': {
908
982
  await runProject(positionals.slice(1));
909
983
  break;
@@ -1047,14 +1121,17 @@ async function main() {
1047
1121
  break;
1048
1122
  }
1049
1123
  case 'claim': {
1124
+ // Normalized to exit 1 (was 2) — every other usage error in this CLI
1125
+ // exits 1, and automation that checks `exit !== 0` shouldn't need a
1126
+ // special case for this one command.
1050
1127
  if (dir !== 'verify') {
1051
1128
  console.error('Usage: shiply claim verify <SHIPLY-XXXXXXXX>');
1052
- process.exit(2);
1129
+ process.exit(1);
1053
1130
  }
1054
1131
  const code = positionals[2];
1055
1132
  if (!code) {
1056
1133
  console.error('Usage: shiply claim verify <SHIPLY-XXXXXXXX>');
1057
- process.exit(2);
1134
+ process.exit(1);
1058
1135
  }
1059
1136
  const r = await runClaimVerify({ code });
1060
1137
  console.log(r.message);
@@ -1185,6 +1262,18 @@ main().catch((e) => {
1185
1262
  console.error(` upgrade: https://shiply.now/dashboard/plan`);
1186
1263
  process.exit(1);
1187
1264
  }
1265
+ // Auth refusals (server returns 401/403): a bare "sign in" tells an agent
1266
+ // nothing about HOW, and a revoked key vs a plan/ownership limit need
1267
+ // different next actions — branch on the code instead of printing raw text.
1268
+ if (e instanceof ApiError && e.code === 'unauthorized') {
1269
+ console.error(`✖ API key missing, invalid, or revoked — run \`shiply login\` for a fresh key (or check ~/.shiply/credentials)`);
1270
+ process.exit(1);
1271
+ }
1272
+ if (e instanceof ApiError && e.code === 'forbidden') {
1273
+ console.error(`✖ ${e.message}`);
1274
+ console.error(` if this is a plan limit: https://shiply.now/dashboard/plan`);
1275
+ process.exit(1);
1276
+ }
1188
1277
  const msg = e instanceof Error ? e.message : String(e);
1189
1278
  console.error(`✖ ${msg}`);
1190
1279
  process.exit(1);
package/dist/login.js CHANGED
@@ -36,9 +36,15 @@ export async function loginViaDeviceFlow(base, agentNameOverride) {
36
36
  }
37
37
  catch (e) {
38
38
  // Transient network blip — don't kill the whole flow, the user has time.
39
- if (e instanceof ApiError && e.status >= 500)
40
- continue;
41
- throw e;
39
+ // Covers both server-side 5xx (ApiError) AND transport failures (DNS
40
+ // hiccup, connection reset, timeout) that never make it to an ApiError —
41
+ // those used to abort the whole device-flow wait on one dropped packet.
42
+ if (e instanceof ApiError) {
43
+ if (e.status >= 500)
44
+ continue;
45
+ throw e;
46
+ }
47
+ continue;
42
48
  }
43
49
  if (poll.status === 'approved' && poll.api_key) {
44
50
  return { ok: true, apiKey: poll.api_key, slug: poll.slug_claimed ?? null };
package/dist/publish.js CHANGED
@@ -251,7 +251,12 @@ async function uploadAll(dir, targets) {
251
251
  `(they're short-lived). Re-run \`shiply publish\` to mint fresh ones — ` +
252
252
  `unchanged files are hash-skipped, so the retry is cheap.`);
253
253
  }
254
- throw new Error(`upload failed for ${t.path}: HTTP ${res.status}`);
254
+ if (res.status === 401 || res.status === 403) {
255
+ throw new Error(`upload failed for ${t.path}: HTTP ${res.status} — the upload URL is not authorized. ` +
256
+ `Re-run \`shiply publish\` to mint fresh presigned URLs (unchanged files are hash-skipped, so the retry is cheap).`);
257
+ }
258
+ throw new Error(`upload failed for ${t.path}: HTTP ${res.status} — the object store rejected the file. ` +
259
+ `Re-run \`shiply publish\`; if it keeps failing on this file, check its size/name for anything unusual.`);
255
260
  }
256
261
  }
257
262
  };
@@ -104,6 +104,7 @@ async function verifyAction(ctx, id, flags) {
104
104
  async function removeAction(ctx, id, flags) {
105
105
  if (!flags.yes) {
106
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
+ process.exitCode = 1; // refusal is not success — scripts must see a non-zero exit
107
108
  return;
108
109
  }
109
110
  const base = resolveBase(ctx.base);
package/dist/sites.js CHANGED
@@ -20,6 +20,7 @@ export async function sitesLs(ctx) {
20
20
  export async function sitesRm(ctx, slug, opts) {
21
21
  if (!opts.yes) {
22
22
  console.error(` refusing — \`shiply rm\` PERMANENTLY deletes ${slug} and its files. Re-run with --yes to confirm.`);
23
+ process.exitCode = 1; // refusal is not success — scripts must see a non-zero exit
23
24
  return;
24
25
  }
25
26
  const base = resolveBase(ctx.base);
@@ -0,0 +1,80 @@
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 variableLs(ctx, opts = {}) {
7
+ const base = resolveBase(ctx.base);
8
+ const reveal = Boolean(opts.reveal);
9
+ const r = await api(`${base}/api/v1/variables${reveal ? '?reveal=1' : ''}`, { headers: headers(ctx.apiKey) });
10
+ if (opts.json) {
11
+ process.stdout.write(`${JSON.stringify(r.variables)}\n`);
12
+ return;
13
+ }
14
+ if (r.variables.length === 0) {
15
+ console.log('No variables saved. shiply variable set <NAME> <value>');
16
+ return;
17
+ }
18
+ for (const v of r.variables) {
19
+ console.log(` ${v.name} ${reveal ? (v.value ?? '') : (v.masked ?? '')}`);
20
+ console.log(`VARIABLE name=${v.name} value=${reveal ? (v.value ?? '') : (v.masked ?? '')}`);
21
+ }
22
+ }
23
+ export async function variableSet(ctx, name, value, opts = {}) {
24
+ const base = resolveBase(ctx.base);
25
+ await api(`${base}/api/v1/variables`, {
26
+ method: 'PUT',
27
+ headers: headers(ctx.apiKey),
28
+ body: JSON.stringify({ name, value }),
29
+ });
30
+ const upper = name.trim().toUpperCase();
31
+ if (opts.json) {
32
+ process.stdout.write(`${JSON.stringify({ name: upper, set: true })}\n`);
33
+ return;
34
+ }
35
+ console.log(`✔ ${upper} saved`);
36
+ console.log(' not exposed to any site yet — attach it: shiply variable attach ' + upper + ' --site <slug>');
37
+ }
38
+ export async function variableRm(ctx, name, opts = {}) {
39
+ if (!opts.yes) {
40
+ throw new Error(`refusing to delete ${name} without --yes (this cannot be undone)`);
41
+ }
42
+ const base = resolveBase(ctx.base);
43
+ await api(`${base}/api/v1/variables/${encodeURIComponent(name)}`, {
44
+ method: 'DELETE',
45
+ headers: headers(ctx.apiKey),
46
+ });
47
+ if (opts.json) {
48
+ process.stdout.write(`${JSON.stringify({ name: name.trim().toUpperCase(), removed: true })}\n`);
49
+ return;
50
+ }
51
+ console.log(`✔ ${name.trim().toUpperCase()} removed`);
52
+ }
53
+ export async function variableAttach(ctx, name, slug, opts = {}) {
54
+ const base = resolveBase(ctx.base);
55
+ await api(`${base}/api/v1/variables/${encodeURIComponent(name)}/attach`, {
56
+ method: 'POST',
57
+ headers: headers(ctx.apiKey),
58
+ body: JSON.stringify({ slug }),
59
+ });
60
+ const upper = name.trim().toUpperCase();
61
+ if (opts.json) {
62
+ process.stdout.write(`${JSON.stringify({ name: upper, slug, attached: true })}\n`);
63
+ return;
64
+ }
65
+ console.log(`✔ ${upper} attached to ${slug} — takes effect on the site's next function deploy`);
66
+ }
67
+ export async function variableDetach(ctx, name, slug, opts = {}) {
68
+ const base = resolveBase(ctx.base);
69
+ await api(`${base}/api/v1/variables/${encodeURIComponent(name)}/detach`, {
70
+ method: 'POST',
71
+ headers: headers(ctx.apiKey),
72
+ body: JSON.stringify({ slug }),
73
+ });
74
+ const upper = name.trim().toUpperCase();
75
+ if (opts.json) {
76
+ process.stdout.write(`${JSON.stringify({ name: upper, slug, detached: true })}\n`);
77
+ return;
78
+ }
79
+ console.log(`✔ ${upper} detached from ${slug} — takes effect on the site's next function deploy`);
80
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "shiply-cli",
3
- "version": "0.28.0",
4
- "description": "Publish static sites to shiply.now from the command line \u00e2\u20ac\u201d instant web hosting for agents.",
3
+ "version": "0.29.0",
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",
7
7
  "bin": {
package/skill/SKILL.md CHANGED
@@ -243,6 +243,8 @@ covers its CLI commands, MCP tools, AND REST endpoints.
243
243
  * [Client work](references/client-work.md) — freelancer delivery: customer
244
244
  intake projects + AI briefs, group work by client, e-sign contracts,
245
245
  sell sites on the marketplace.
246
+ * [Drives](references/drives.md) — private cloud storage: staged uploads,
247
+ sharing links, client assignment, publish-from-drive, REST + CLI + MCP.
246
248
  * [Authentication for the user's app](references/site-features.md) — shiply
247
249
  does NOT host end-user auth; bring the user's own Clerk/Auth.js/Supabase
248
250
  (pattern in Site features → "Bring your own auth").
@@ -31,16 +31,49 @@ list_project_files — files uploaded by the customer
31
31
  resend_intake_invite — re-email the customer their intake link (requires customerEmail on project)
32
32
  ```
33
33
 
34
- REST: `POST/GET /api/v1/projects · GET/PATCH /api/v1/projects/{id} ·
35
- POST /api/v1/projects/{id}/regenerate-brief`
36
- (archive: `PATCH /api/v1/projects/{id}` with `{"status":"archived"}` — no
37
- dedicated `/archive` route).
34
+ REST:
35
+
36
+ | Endpoint | Action |
37
+ |---|---|
38
+ | `POST/GET /api/v1/projects` | create / list |
39
+ | `GET/PATCH /api/v1/projects/{id}` | read / update brief or metadata |
40
+ | `POST /api/v1/projects/{id}/regenerate-brief` | re-run AI from current intake responses |
41
+ | `POST /api/v1/projects/{id}/archive` `{reason?}` | dedicated archive route — mirrors `archive_project` (`PATCH .../{id}` `{"status":"archived"}` still works too, back-compat) |
42
+ | `GET /api/v1/projects/{id}/files/zip` | download every customer-uploaded file as one .zip |
43
+ | `GET /api/v1/projects/{id}/files/{fileId}/download` | 302-redirect to a short-lived signed URL for one file |
44
+ | `POST /api/v1/projects/{id}/upload` | dev sends the customer a file (multipart `file` field, ≤25 MB) |
45
+ | `PATCH /api/v1/projects/{id}/status-note` `{note}` | short customer-facing status line shown in the portal header (`note:null` clears it) |
46
+ | `POST /api/v1/projects/{id}/resend-portal-link` | dev re-sends the customer's **portal** link — different from `resend_intake_invite`/`resend-invite` below, which re-sends the original **intake** invite |
38
47
 
39
48
  CLI: `shiply project ls · shiply project create <label> [--customer-email <e>]
40
49
  [--customer-name <n>] · shiply project get <id> · shiply project archive <id>`
41
50
 
42
51
  Docs: https://shiply.now/docs/projects
43
52
 
53
+ ### Intake — the customer-facing side (token auth, not Bearer)
54
+
55
+ The intake URL a customer opens is token-scoped, not account-scoped — no
56
+ Bearer key. These routes power the wizard, portal chat, and dev-side nudges:
57
+
58
+ | Endpoint | Action |
59
+ |---|---|
60
+ | `GET /api/v1/intake/{token}/state` | current status + saved wizard responses + project label |
61
+ | `PATCH /api/v1/intake/{token}` `{stepKey, data}` | save one wizard step (auto-save as the customer types) |
62
+ | `POST /api/v1/intake/{token}/submit` | run AI brief generation from the saved responses |
63
+ | `GET /api/v1/intake/{token}/contract` | the project's contract, if one has been sent to this token |
64
+ | `GET/POST /api/v1/intake/{token}/files` | list / upload customer attachments |
65
+ | `DELETE /api/v1/intake/{token}/files/{fileId}` | remove an uploaded attachment |
66
+ | `GET/POST /api/v1/intake/{token}/messages` `{body}` (POST) | portal chat thread with the dev, oldest-first, `?before=` cursor |
67
+ | `POST /api/v1/intake/{token}/nudge` | dev-triggered reminder ping to the customer |
68
+ | `POST /api/v1/intake/{token}/email-link` | re-email the customer their intake link |
69
+ | `POST /api/v1/intake/{token}/cookie` | set the portal session cookie for this token |
70
+ | `GET /api/v1/intake/{token}/activity` | activity/audit log for this intake |
71
+ | `POST /api/v1/intake/{token}/suggest` | AI suggestion helper for a wizard field |
72
+
73
+ Dev-side equivalent for re-sending the invite email is `resend_intake_invite`
74
+ (MCP) / `POST /api/v1/projects/{id}/resend-invite` — different from the
75
+ customer-token `email-link` route above.
76
+
44
77
  ## Group work by client (optional, for freelancers)
45
78
 
46
79
  Building sites for clients? Pass an optional `client` (a name or email) on any
@@ -56,7 +89,28 @@ drive/intake/contract all land under the same client. List calls take an
56
89
  optional `clientId` filter (`list_sites`/`list_drives`/`list_projects`).
57
90
  Omit `client` for personal projects; shiply works identically.
58
91
  CLI: `--client "<name-or-email>"` on publish/drive/project, plus
59
- `shiply client ls|show`.
92
+ `shiply client ls|show`. Re-assign (or clear) a client on an already-published
93
+ site directly: `PATCH /api/v1/publish/{slug}/client` `{clientId}`
94
+ (`clientId: null` clears it) — no MCP tool for this yet, REST only.
95
+
96
+ REST (Bearer; Studio plan — `assertStudioEntitled` gates `POST`):
97
+
98
+ | Endpoint | Action |
99
+ |---|---|
100
+ | `GET /api/v1/clients` | list your clients → `{clients}` |
101
+ | `POST /api/v1/clients` `{name, email?, phone?, company?, notes?}` | create a client explicitly (usually unnecessary — clients self-assemble from `client`/`customerEmail`) |
102
+ | `GET /api/v1/clients/{id}` | rollup view: sites, drives, projects, contracts, invoices under this client |
103
+ | `GET /api/v1/clients/{id}/invoices` | list invoices for this client |
104
+ | `POST /api/v1/clients/{id}/invoices` `{description, amountCents, currency?, dueDate?, projectId?}` | create an invoice |
105
+ | `POST /api/v1/invoices/{id}/send` | email the invoice to the client |
106
+ | `POST /api/v1/invoices/{id}/void` | void an unpaid invoice |
107
+ | `POST /api/v1/clients/{id}/portal` | enable the client's public portal (idempotent) → `{portalUrl}` |
108
+ | `DELETE /api/v1/clients/{id}/portal` | disable the portal — the link dies immediately |
109
+ | `POST /api/v1/clients/{id}/portal/rotate` | mint a fresh portal token (old link dies instantly) |
110
+
111
+ No `list_clients`/`get_client`/portal-management MCP tools exist yet — this
112
+ is REST + CLI (`shiply client ls|show`) only; an MCP agent reads client data
113
+ indirectly via `list_sites`/`list_drives`/`list_projects` with `clientId`.
60
114
 
61
115
  ## Contracts — draft, sign, amend (extends Projects)
62
116
 
@@ -91,7 +145,7 @@ Example agent flow (draft → tweak fee → send → poll → amend):
91
145
  6. `contract_send({contractId: amendment.id})` (no PATCH needed if the amend fields are right)
92
146
 
93
147
  REST (Bearer for dev, portal cookie for customer):
94
- `POST /api/v1/projects/{id}/contracts` (draft) ·
148
+ `POST /api/v1/projects/{id}/contract` (draft — singular; the plural `/contracts` is a GET-only read bundle) ·
95
149
  `GET/PATCH /api/v1/contracts/{id}` (read either-party / dev edit) ·
96
150
  `POST /api/v1/contracts/{id}/send` (dev) ·
97
151
  `POST /api/v1/contracts/{id}/view` (customer ping) ·
@@ -141,12 +195,13 @@ refund_order — issue Stripe refund (within refund window; goes back to buy
141
195
  get_connect_status — Stripe Connect onboarding state + actionable URL
142
196
  ```
143
197
 
144
- REST: `POST /api/v1/listings · PATCH /api/v1/listings/{id} ·
145
- POST /api/v1/listings/{id}/checkout · POST /api/v1/orders/{id}/refund ·
146
- GET /api/v1/connect/status`
147
- (no GET listing index over REST yet use the MCP `list_listings` /
148
- `list_my_sales` / `list_my_orders` tools for machine output, or the
149
- dashboard `/dashboard/sales` page for humans.)
198
+ REST: `GET/POST /api/v1/listings · PATCH /api/v1/listings/{id} ·
199
+ POST /api/v1/listings/{id}/checkout · GET /api/v1/orders ·
200
+ POST /api/v1/orders/{id}/refund · GET /api/v1/connect/status`
201
+ (`GET /api/v1/listings` and `GET /api/v1/orders` list your own listings/
202
+ orders `?limit=` supported; MCP mirrors are `list_listings` /
203
+ `list_my_orders`. Sales specifically: `GET /api/v1/listings/sales` /
204
+ `list_my_sales`.)
150
205
 
151
206
  CLI: `shiply listing ls · shiply listing create <site-slug> --price <cents>
152
207
  --jurisdiction "<region>" · shiply listing rm <slug> --id <listing-id>`
@@ -157,3 +212,29 @@ isn't `'ready'` it returns an `onboardingUrl` you should hand to the
157
212
  user as a clickable link.
158
213
 
159
214
  Docs: https://shiply.now/docs/marketplace
215
+
216
+ ## Inbox threads — reply, forward, archive, summarize, AI draft
217
+
218
+ The account-wide inbox (replies, unsubscribes, complaints, bounces across
219
+ every site) beyond the read-only MCP tools in [email.md](email.md):
220
+
221
+ MCP tools: `list_inbox` (filtered list + counts in one call — filters
222
+ `all|unread|replies|unsubscribes|complaints|bounces|archived`), `read_thread`
223
+ (marks read implicitly), `mark_thread_read`, `summarize_thread`,
224
+ `reply_to_thread`, `forward_thread`, `archive_thread`, `unarchive_thread`.
225
+
226
+ REST (Bearer):
227
+
228
+ | Endpoint | Action |
229
+ |---|---|
230
+ | `GET /api/v1/inbox/threads?filter=&limit=&offset=` | atomic snapshot: `{threads, counts}` |
231
+ | `GET /api/v1/inbox/threads/{id}` | one thread (also marks read) |
232
+ | `PATCH /api/v1/inbox/threads/{id}` `{action:"mark_read"\|"archive"\|"unarchive"}` | thread lifecycle |
233
+ | `POST /api/v1/inbox/threads/{id}/reply` `{body, subject?}` | reply from the original recipient alias, threaded via In-Reply-To |
234
+ | `POST /api/v1/inbox/threads/{id}/forward` `{to, messageId?, subject?, note?}` | forward one message to a new address |
235
+ | `POST /api/v1/inbox/threads/{id}/summarize` | AI summary of the thread |
236
+ | `POST /api/v1/inbox/threads/{id}/ai-reply` | AI-drafted reply body (review before sending — no matching MCP tool yet) |
237
+
238
+ **Gap (intentional, tracked):** the CLI has no `shiply inbox` thread-action
239
+ subcommands (reply/forward/archive/summarize) — use MCP or REST for those;
240
+ `shiply email inbox` only reads.
@@ -40,6 +40,8 @@ shiply domain sub add www.example.com --site my-site # map a subdomain → sit
40
40
  shiply domain ls # list all domains + subdomains + status
41
41
  shiply domain sync example.com # re-sync DNS records with a connected provider
42
42
  shiply domain primary <hostname> # mark hostname canonical (siblings 301)
43
+ shiply domain check example.com # re-poll cert/DNS/HTTPS readiness per subdomain
44
+ shiply domain rm example.com --yes # remove a domain (its subdomains stop resolving)
43
45
  ```
44
46
 
45
47
  For one-click connect (`shiply domain connect`), the CLI prints an
@@ -95,6 +95,11 @@ for D1 and Neon), `POST .../attach` to bind, `DELETE` to drop. Neon-only:
95
95
  `POST/GET/DELETE /api/v1/databases/{id}/branches[/{branchId}]` (400 on
96
96
  D1 rows).
97
97
 
98
+ Main MCP server (account-wide lifecycle — start here to discover a
99
+ databaseId): `list_databases`, `create_database`, `delete_database`,
100
+ `attach_database`. Then, to query/introspect one database, connect the
101
+ per-DB MCP server below with that id.
102
+
98
103
  Per-DB MCP server (so an agent can be scoped to one DB):
99
104
  `https://shiply.now/api/mcp/db/<id>/sse` (or `/mcp` for streamable-http),
100
105
  Bearer `shp_…` — tools: `db_query`, `db_list_tables`, `db_schema`. The same
@@ -0,0 +1,70 @@
1
+ ---
2
+ type: reference
3
+ title: Drives (private storage — REST, CLI, MCP)
4
+ description: Full reference for Drives — private cloud storage for agent memory, notes, and assets. Staged uploads, sharing links, client assignment, and publish-from-drive.
5
+ timestamp: 2026-07-14
6
+ ---
7
+
8
+ # Drives — private cloud storage
9
+
10
+ Part of the shiply skill (see SKILL.md for publish basics). A Drive is
11
+ private storage — files, notes, context — that is **not** served publicly
12
+ like a published site. Every account gets a `default` drive on first use;
13
+ you can also create named drives (e.g. one per client).
14
+
15
+ ## REST (Bearer `shp_...`)
16
+
17
+ | Endpoint | Action |
18
+ |---|---|
19
+ | `GET /api/v1/drives?clientId=` | list your drives (optional client filter) → `{drives}` |
20
+ | `POST /api/v1/drives` `{name, client?}` | create a named drive. `client` is the same optional `{clientId?, clientEmail?, clientName?, clientCompany?}` shape used on publish/create_project |
21
+ | `GET /api/v1/drives/default` | the account's auto-provisioned default drive (creates it on first call) |
22
+ | `GET /api/v1/drives/{id}` | drive detail |
23
+ | `DELETE /api/v1/drives/{id}` | delete a drive and its files |
24
+ | `GET /api/v1/drives/{id}/files?prefix=&cursor=` | list files (optional path-prefix filter, pagination cursor) |
25
+ | `POST /api/v1/drives/{id}/files/uploads` `{files:[{path,size,contentType?,hash}]}` | stage up to 1000 files → presigned PUT URLs (same 3-step flow as site publish) |
26
+ | `POST /api/v1/drives/{id}/files/finalize` `{files:[{path,size,contentType?,hash}]}` | confirm uploads landed — same `files` array as the uploads call |
27
+ | `GET /api/v1/drives/{id}/files/{...path}` | `{url}` — a presigned, time-limited download URL for one file |
28
+ | `DELETE /api/v1/drives/{id}/files/{...path}` | delete one file (optional `If-Match` header for optimistic concurrency) |
29
+ | `POST /api/v1/drives/{id}/assign-client` `{clientId}` | file this drive under a client (`clientId: null` clears it) |
30
+ | `POST /api/v1/drives/{id}/share` `{instructions?}` | enable a public share link for the drive |
31
+ | `DELETE /api/v1/drives/{id}/share` | disable the share link |
32
+ | `POST /api/v1/drives/{id}/share/rotate` | mint a fresh share token — the old link dies instantly |
33
+ | `POST /api/v1/drives/{id}/share/invite` `{emails:[...max 50], message?}` | email the share link to a list of addresses |
34
+
35
+ `{id}` is the drive's public id (`drv_...`), or the literal string
36
+ `default` where the endpoint supports it (only `GET /api/v1/drives/default`
37
+ resolves the alias itself — everywhere else, resolve `default` to a real
38
+ `drv_...` id first via that call).
39
+
40
+ ## CLI
41
+
42
+ The CLI only operates on the account's **default** drive — for named/
43
+ per-client drives, use REST or MCP.
44
+
45
+ ```bash
46
+ shiply drive ls [prefix] # list files (optionally under a prefix)
47
+ shiply drive put <file> [as-path] # stage + finalize a file into the default drive
48
+ shiply drive get <path> [out-file] # download; omit out-file to print to stdout
49
+ shiply drive rm <path> # delete a file
50
+ shiply drive publish [prefix] # snapshot the default drive (or a prefix) as a new live site
51
+ ```
52
+
53
+ ## MCP tools
54
+
55
+ | Tool | Purpose |
56
+ |---|---|
57
+ | `list_drives` | your drives (optional `clientId` filter) |
58
+ | `create_drive` | create a named drive (`name`, optional `client`) |
59
+ | `drive_list_files` | list files in a drive (`driveId` = `drv_...` or `"default"`, optional `prefix`) |
60
+ | `drive_put_file` | write a file inline (utf8 or base64, ≤2 MB — use the REST staged-upload flow above for bigger files) |
61
+ | `drive_delete_file` | delete a file from a drive |
62
+ | `publish_from_drive` | snapshot a drive (or a path prefix within it) into a new live site |
63
+
64
+ **Gap (intentional, tracked):** file download, drive delete, and the
65
+ share/assign-client family (`share`, `share/rotate`, `share/invite`,
66
+ `assign-client`) are REST-only today — there is no `drive_download`,
67
+ `delete_drive`, or share-management MCP tool yet. Use the REST endpoints
68
+ above from an MCP-only agent until parity lands.
69
+
70
+ Docs: https://shiply.now/docs/drives
@@ -127,6 +127,22 @@ the confirmed audience (unsubscribe link auto-added). `list_tests()` — all
127
127
  tests for this key. `resend_confirmation({ testId, email })` — re-send the
128
128
  opt-in to one address.
129
129
 
130
+ REST (Bearer, mirrors the MCP tools above):
131
+
132
+ | Endpoint | Action |
133
+ |---|---|
134
+ | `GET /api/v1/tests` | list your demand tests → `{tests}` |
135
+ | `POST /api/v1/tests` `{idea, copy:{headline, sub?, cta?}, price?, captureFields?, sendingDomainId?}` | create a test — deploys the capture page + provisions the confirmed-subscriber segment |
136
+ | `GET /api/v1/tests/{id}` | merged status object — same shape as `get_test_status` |
137
+ | `PATCH /api/v1/tests/{id}/branding` `{brandName?, logoUrl?, brandColor?, accentColor?, footerText?, replyTo?}` | customize the capture page's branding |
138
+ | `POST /api/v1/tests/{id}/broadcasts` `{subject, html, text?}` | email the confirmed audience — same as `send_broadcast` |
139
+ | `POST /api/v1/tests/suggest-copy` `{idea}` | AI-drafted `{headline, sub, cta}` from a one-line idea (MiniMax; 503 `service_unavailable` if no AI provider is configured) |
140
+
141
+ `list_tests` maps to `GET /api/v1/tests` above. `resend_confirmation` (re-send
142
+ the opt-in email to one address) is **MCP-only today** — no REST route exists
143
+ yet; use the MCP tool from a REST-only agent by proxying through an MCP
144
+ client, or ask for the confirmation link to be resent from the dashboard.
145
+
130
146
  ## Sending domains — outbound email on your domain
131
147
 
132
148
  For agents that want shiply to send emails (demand-test broadcasts, project
@@ -140,6 +156,14 @@ to add), `verify_sending_domain`, `remove_sending_domain`.
140
156
  REST: `GET/POST /api/v1/sending-domains ·
141
157
  POST /api/v1/sending-domains/{id}/verify · DELETE /api/v1/sending-domains/{id}`
142
158
 
159
+ Auto-DNS shortcuts (skip the "copy these records to your registrar" step —
160
+ user supplies a scoped, never-stored Cloudflare API token for the domain's
161
+ zone): `POST /api/v1/sending-domains/{id}/auto-dns` `{provider:"cloudflare",
162
+ token}` adds the SPF/DKIM records automatically; `POST
163
+ /api/v1/sending-domains/{id}/email-routing` `{provider:"cloudflare", token}`
164
+ additionally turns on inbound Email Routing and binds the catch-all to
165
+ shiply's inbound worker. No MCP tools for either yet — REST only.
166
+
143
167
  CLI: `shiply sending-domain ls · shiply sending-domain add <domain> ·
144
168
  shiply sending-domain verify <id> · shiply sending-domain rm <id> --yes`
145
169
 
@@ -70,7 +70,9 @@ shiply publish <dir> --json # one machine-readable JSON line, no
70
70
  - **`shiply verify <slug>`** prints a human report PLUS a stable machine marker
71
71
  line `VERIFY status=LIVE http=200 ssl=valid thumb=…` (or `status=PENDING`).
72
72
  Headless agents should parse that `VERIFY status=…` line — it's the
73
- contract, like `SITE_READY` in `status`.
73
+ contract, like `SITE_READY` in `status`. REST: `GET /api/v1/sites/{slug}/verify`
74
+ (public, no auth — mirrors `site_status`) → `{status, ssl, http, thumbnailUrl,
75
+ consoleErrorsDeepLink}`. MCP: `verify_site`.
74
76
  - **`shiply publish <dir> --as <name>`** gives a stable preview URL that the
75
77
  next publish with the same `--as <name>` reuses — no more changing URLs or
76
78
  orphaned scratch sites across iterations, independent of the source dir.
@@ -52,18 +52,28 @@ lists), or `--public` to open it up.
52
52
  REST: PATCH /api/v1/publishes/<slug>/access
53
53
  with {"mode":"password","password":"..."} or {"mode":"restricted",
54
54
  "allowedEmails":[...],"allowedDomains":[...]}, or set mode "public" to open it.
55
- GET returns the policy (never the hash). MCP tool: `set_site_access`. Enforced
55
+ GET returns the policy (never the hash). MCP tools: `get_site_access` (read),
56
+ `set_site_access` (write). Enforced
56
57
  at the edge before any content/proxy/data is served; visitors get a 7-day
57
58
  signed cookie; changing any setting signs current visitors out.
58
59
  Docs: /docs/access-control
59
60
 
60
61
  ## Variables — encrypted per-user KV
61
62
 
62
- For API keys the user's sites need: GET/PUT /api/v1/variables
63
- {"name","value"}, DELETE /api/v1/variables/{NAME}; GET ?reveal=1 returns
64
- plaintext values. MCP: `set_variable`. Referenced by proxy routes (`${VAR}`)
65
- and injected into Workers as `env.<NAME>`. Supabase can be connected from the
66
- dashboard and lands here as SUPABASE_URL + SUPABASE_ANON_KEY.
63
+ CLI (preferred): `shiply variable ls [--reveal]`, `shiply variable set <NAME>
64
+ <value>` (or `--stdin`), `shiply variable rm <NAME> --yes`.
65
+ REST: GET/PUT /api/v1/variables {"name","value"}, DELETE
66
+ /api/v1/variables/{NAME}; GET ?reveal=1 returns plaintext values.
67
+ MCP: `list_variables`, `set_variable`, `delete_variable`. Referenced by proxy
68
+ routes (`${VAR}`) and injected into Workers as `env.<NAME>`. Supabase can be
69
+ connected from the dashboard and lands here as SUPABASE_URL + SUPABASE_ANON_KEY.
70
+
71
+ Expose (or stop exposing) a saved account variable to one specific site's
72
+ Worker env, independent of proxy routes: CLI `shiply variable attach <NAME>
73
+ --site <slug>` / `shiply variable detach <NAME> --site <slug>` (both
74
+ idempotent). REST: `POST /api/v1/variables/{name}/attach` `{slug}` / `POST
75
+ /api/v1/variables/{name}/detach` `{slug}`. MCP: `attach_variable`,
76
+ `detach_variable`, `list_site_variables`.
67
77
 
68
78
  ## Drives — private cloud storage (agent memory + assets)
69
79
 
@@ -71,6 +81,8 @@ Private storage for files/notes/context you don't want on a public site.
71
81
  MCP tools: `list_drives`, `create_drive`, `drive_put_file` (driveId
72
82
  "default", utf8/base64, ≤2 MB), `drive_list_files`, `drive_delete_file`,
73
83
  `publish_from_drive` (snapshot a drive into a live site).
84
+ Full reference (REST staged uploads, sharing, client assignment, CLI):
85
+ [drives.md](drives.md).
74
86
 
75
87
  ## Path-mounting + public profile
76
88
 
@@ -78,7 +90,8 @@ MCP tools: `list_drives`, `create_drive`, `drive_put_file` (driveId
78
90
  (host/docs → target).
79
91
  - `set_profile` / `feature_site` (MCP) — stand up the user's public portfolio
80
92
  at <handle>.shiply.now and feature a public site on shiply.now/explore.
81
- - `export_account` (MCP) JSON bundle of the user's data (no secrets).
93
+ - `export_account` (MCP) / `GET /api/v1/account/export` (REST) JSON bundle
94
+ of the user's data (no secrets).
82
95
  - Site metadata: `PATCH /api/v1/publish/<slug>/metadata`
83
96
  {"title?","spaMode?","addedToProfile?"}.
84
97