shiply-cli 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/confetti.js CHANGED
@@ -26,3 +26,8 @@ export function confetti(message = '⛵ SITE IS LIVE ⛵') {
26
26
  }
27
27
  return out;
28
28
  }
29
+ /** Celebrate only on an interactive terminal and only when not opted out.
30
+ * Piped / redirected stdout (agents, CI, scrapers) never gets ANSI confetti. */
31
+ export function shouldCelebrate(noConfetti, isTty = process.stdout.isTTY) {
32
+ return !noConfetti && isTty === true;
33
+ }
package/dist/ignore.js ADDED
@@ -0,0 +1,65 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ function globBody(glob, anchored) {
4
+ // Escape regex specials, but keep * and / for glob expansion.
5
+ let g = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
6
+ g = g
7
+ .replace(/\*\*\//g, '(?:.+/)?') // **/ → any number of leading dirs
8
+ .replace(/\*\*/g, '.*') // ** → anything, crosses slashes
9
+ .replace(/\*/g, '[^/]*'); // * → anything but a slash
10
+ return anchored ? `^${g}` : `(?:^|/)${g}`;
11
+ }
12
+ function parseRule(raw) {
13
+ let pattern = raw.trim();
14
+ const negate = pattern.startsWith('!');
15
+ if (negate)
16
+ pattern = pattern.slice(1);
17
+ const dirOnly = pattern.endsWith('/');
18
+ if (dirOnly)
19
+ pattern = pattern.slice(0, -1);
20
+ const anchored = pattern.startsWith('/');
21
+ if (anchored)
22
+ pattern = pattern.slice(1);
23
+ const body = globBody(pattern, anchored);
24
+ // re: matches the path itself OR anything beneath it.
25
+ // descendantRe: matches ONLY things strictly beneath the named path. A
26
+ // dir-only rule uses re for directories (and their subtrees) but descendantRe
27
+ // for files, so a FILE that merely shares the dir's name is kept.
28
+ return {
29
+ re: new RegExp(`${body}(?:/|$)`),
30
+ descendantRe: new RegExp(`${body}/`),
31
+ negate,
32
+ dirOnly,
33
+ };
34
+ }
35
+ /** Compile .shiplyignore lines (a documented gitignore subset) into a matcher.
36
+ * Supported: # comments, blank lines, trailing-slash dir-only rules,
37
+ * leading-slash root anchoring, * and ** globs, and ! negation. Last match wins. */
38
+ export function compileIgnore(lines) {
39
+ const rules = lines
40
+ .map((l) => l.replace(/\r$/, ''))
41
+ .filter((l) => l.trim() !== '' && !l.trimStart().startsWith('#'))
42
+ .map(parseRule);
43
+ return (relPath, isDir) => {
44
+ let excluded = false;
45
+ for (const r of rules) {
46
+ // A dir-only rule excludes the directory itself (and its subtree) but, for
47
+ // a plain file, only when that file lives BENEATH the named directory —
48
+ // never a file that merely shares the directory's name.
49
+ const re = r.dirOnly && !isDir ? r.descendantRe : r.re;
50
+ if (re.test(relPath))
51
+ excluded = !r.negate;
52
+ }
53
+ return excluded;
54
+ };
55
+ }
56
+ /** Load `.shiplyignore` from the publish root; null when absent. */
57
+ export async function loadIgnore(dir) {
58
+ try {
59
+ const raw = await readFile(join(dir, '.shiplyignore'), 'utf8');
60
+ return compileIgnore(raw.split('\n'));
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { dirname, join } from 'node:path';
4
4
  import { createInterface } from 'node:readline/promises';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { parseArgs } from 'node:util';
7
- import { confetti } from './confetti.js';
7
+ import { confetti, shouldCelebrate } from './confetti.js';
8
8
  import { runDetect } from './detect.js';
9
9
  import { resolvePayloadDir } from './framework.js';
10
10
  import { runClaimVerify } from './claim.js';
@@ -23,8 +23,11 @@ import { loginViaDeviceFlow } from './login.js';
23
23
  import { api, ApiError, DEFAULT_BASE, publish, resolveBase } from './publish.js';
24
24
  import { installSkill } from './skill.js';
25
25
  import { readState, writeState } from './state.js';
26
+ import { readPreview, writePreview } from './previews.js';
26
27
  import { checkReadiness, targetToHostname } from './status.js';
27
28
  import { runStatus } from './status-cmd.js';
29
+ import { sitesLs, sitesPromote, sitesRm, sitesRollback } from './sites.js';
30
+ import { verifySite } from './verify.js';
28
31
  // Resolve the package.json beside the compiled dist/ so `shiply --version`
29
32
  // can print the running CLI version (works in dev tsc output + npm install).
30
33
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -47,10 +50,13 @@ const HELP = `shiply — instant static hosting for agents (https://shiply.now)
47
50
  Usage:
48
51
  shiply publish <dir> [options] Publish a directory, print the live URL.
49
52
  Re-running UPDATES the same site (state in .shiply.json)
53
+ shiply publish <dir> --as <name> Stable preview: reuse the same URL across iterations
50
54
  shiply update <dir> Same as publish when .shiply.json exists
51
55
  shiply status <slug-or-domain> [--wait] SSL + readiness check (agent-friendly output)
52
56
  (no arg → account/plan status, see below)
53
57
  shiply duplicate <slug> Server-side copy of an owned site → new URL
58
+ shiply promote <preview-slug> --to <dest-slug>
59
+ Copy the exact previewed bytes into a production site (no rebuild)
54
60
  shiply detect [dir] Diagnose: print framework + dir that would upload
55
61
  (no upload — use --framework=<name> to test an override)
56
62
  shiply drive <ls|put|get|rm|publish> Private cloud storage (drive publish → live site)
@@ -140,11 +146,13 @@ Options:
140
146
  --base <url> API origin (default: ${DEFAULT_BASE})
141
147
  --wait (status) poll until the site is ready
142
148
  --timeout <seconds> (status --wait) give up after this long (default 300)
149
+ --json (publish/update) print one JSON line {slug,siteUrl,…}, no decoration
143
150
  --json <body> (data insert) JSON object to insert
144
151
  --limit <N> (data query) max records to return (default 50)
145
152
  --cursor <iso> (data query) pagination cursor from previous response
146
153
  --where <json> (data query) client-side filter, e.g. '{"email":"a@b.co"}'
147
154
  --yes (data wipe-collection) skip the safety prompt
155
+ --as <name> (publish) stable named preview — same URL every iteration
148
156
  --no-confetti Celebrate quietly
149
157
 
150
158
  \`shiply status\` prints stable machine-readable markers for agents:
@@ -173,7 +181,7 @@ async function reportReadiness(host, celebrate) {
173
181
  else {
174
182
  console.log(`✖ site: not ready${r.http.status ? ` (HTTP ${r.http.status})` : r.http.error ? ` — ${r.http.error}` : ''}`);
175
183
  }
176
- if (r.ready && celebrate)
184
+ if (r.ready && celebrate && shouldCelebrate(false))
177
185
  console.log(confetti());
178
186
  return r.ready;
179
187
  }
@@ -183,12 +191,17 @@ async function main() {
183
191
  // Detect the bare flag in raw argv BEFORE parseArgs sees it; strip the
184
192
  // token so the parser doesn't complain about a missing string value.
185
193
  const rawArgv = process.argv.slice(2);
186
- const statusJsonFlag = rawArgv[0] === 'status' && rawArgv.includes('--json') &&
194
+ // `--json` is a STRING option (data-insert body). For commands where it's a
195
+ // boolean flag (status/publish/update), detect a BARE --json (no following
196
+ // value) in raw argv and strip it before parseArgs sees it.
197
+ const BARE_JSON_CMDS = new Set(['status', 'publish', 'update']);
198
+ const bareJsonFlag = BARE_JSON_CMDS.has(rawArgv[0]) && rawArgv.includes('--json') &&
187
199
  !rawArgv.some((a, i) => a === '--json' && typeof rawArgv[i + 1] === 'string' && !rawArgv[i + 1].startsWith('-'));
188
- if (statusJsonFlag) {
200
+ if (bareJsonFlag) {
189
201
  const idx = rawArgv.indexOf('--json');
190
202
  rawArgv.splice(idx, 1);
191
203
  }
204
+ const statusJsonFlag = bareJsonFlag && rawArgv[0] === 'status';
192
205
  const { values, positionals } = parseArgs({
193
206
  args: rawArgv,
194
207
  allowPositionals: true,
@@ -201,6 +214,7 @@ async function main() {
201
214
  base: { type: 'string' },
202
215
  email: { type: 'string' },
203
216
  name: { type: 'string' },
217
+ as: { type: 'string' },
204
218
  wait: { type: 'boolean' },
205
219
  'new-site': { type: 'boolean' },
206
220
  project: { type: 'boolean' },
@@ -259,13 +273,18 @@ async function main() {
259
273
  // pre-built fallback like dist/).
260
274
  const { dir: publishDir, hint } = await resolvePayloadDir(dir, { framework: values.framework });
261
275
  const spa = values.spa ?? (hint?.spa ? true : undefined);
262
- if (hint && publishDir !== dir) {
276
+ if (hint && publishDir !== dir && !bareJsonFlag) {
263
277
  console.log(`✔ ${hint.framework} project detected — publishing ${publishDir}${hint.spa ? ' with SPA mode' : ''}`);
264
278
  }
265
279
  // same command, same URL: reuse this directory's site automatically
266
280
  const state = await readState(publishDir);
267
- let claimToken = values['claim-token'] ?? state?.claimToken;
268
- let slug = state?.owned && apiKey ? state.slug : undefined;
281
+ // --as <name>: a stable named preview. The alias registry remembers which
282
+ // slug this name last published to, so the URL is reused every iteration —
283
+ // independent of which directory the bytes came from.
284
+ const aliasName = values.as;
285
+ const alias = aliasName ? await readPreview(aliasName) : null;
286
+ let claimToken = values['claim-token'] ?? alias?.claimToken ?? state?.claimToken;
287
+ let slug = (alias?.owned && apiKey ? alias.slug : undefined) ?? (state?.owned && apiKey ? state.slug : undefined);
269
288
  if (values['new-site']) {
270
289
  claimToken = values['claim-token'];
271
290
  slug = undefined;
@@ -300,6 +319,28 @@ async function main() {
300
319
  owned: !res.anonymous,
301
320
  ...(state?.databaseId ? { databaseId: state.databaseId } : {}),
302
321
  });
322
+ if (aliasName) {
323
+ await writePreview(aliasName, {
324
+ slug: res.slug,
325
+ owned: !res.anonymous,
326
+ ...(res.siteId ? { siteId: res.siteId } : {}),
327
+ ...(res.claimToken ? { claimToken: res.claimToken } : alias?.claimToken ? { claimToken: alias.claimToken } : {}),
328
+ });
329
+ }
330
+ if (bareJsonFlag) {
331
+ // Single-line machine output — no banners, no confetti, no readiness chatter.
332
+ process.stdout.write(`${JSON.stringify({
333
+ slug: res.slug,
334
+ siteUrl: res.siteUrl,
335
+ siteId: res.siteId ?? null,
336
+ uploaded: res.uploaded,
337
+ skipped: res.skipped,
338
+ anonymous: res.anonymous,
339
+ expiresAt: res.expiresAt,
340
+ updated: updating,
341
+ })}\n`);
342
+ return;
343
+ }
303
344
  const skipped = res.skipped > 0 ? ` (${res.skipped} unchanged, skipped)` : '';
304
345
  console.log(`✔ ${updating ? `updated ${res.slug} in place` : 'published'} — ${res.uploaded} file${res.uploaded === 1 ? '' : 's'}${skipped}`);
305
346
  console.log(`\n ${res.siteUrl}\n`);
@@ -310,7 +351,7 @@ async function main() {
310
351
  void live.body?.cancel();
311
352
  if (live.status < 400) {
312
353
  console.log(`SITE_READY url=${res.siteUrl} status=${live.status}`);
313
- if (!values['no-confetti'])
354
+ if (shouldCelebrate(Boolean(values['no-confetti'])))
314
355
  console.log(confetti(`⛵ ${host} IS LIVE ⛵`));
315
356
  }
316
357
  }
@@ -378,6 +419,50 @@ async function main() {
378
419
  console.log(`\n ${out.siteUrl}\n`);
379
420
  return;
380
421
  }
422
+ case 'ls': {
423
+ const apiKey = values.key ?? (await loadApiKey());
424
+ if (!apiKey)
425
+ throw new Error('ls needs an API key — run `shiply login` first');
426
+ await sitesLs({ base: values.base, apiKey });
427
+ return;
428
+ }
429
+ case 'rm': {
430
+ if (!dir)
431
+ throw new Error('usage: shiply rm <slug> --yes');
432
+ const apiKey = values.key ?? (await loadApiKey());
433
+ if (!apiKey)
434
+ throw new Error('rm needs an API key — run `shiply login` first');
435
+ await sitesRm({ base: values.base, apiKey }, dir, { yes: Boolean(values.yes) });
436
+ return;
437
+ }
438
+ case 'rollback': {
439
+ if (!dir)
440
+ throw new Error('usage: shiply rollback <slug> [versionId] (omit versionId to list versions)');
441
+ const apiKey = values.key ?? (await loadApiKey());
442
+ if (!apiKey)
443
+ throw new Error('rollback needs an API key — run `shiply login` first');
444
+ await sitesRollback({ base: values.base, apiKey }, dir, positionals[2]);
445
+ return;
446
+ }
447
+ case 'verify': {
448
+ if (!dir)
449
+ throw new Error('usage: shiply verify <slug>');
450
+ // exit 0 when LIVE, 1 when PENDING — same contract as `status`, so an agent
451
+ // can chain `shiply verify <slug> && <next step>` safely.
452
+ const ready = await verifySite({ base: values.base }, dir);
453
+ process.exitCode = ready ? 0 : 1;
454
+ return;
455
+ }
456
+ case 'promote': {
457
+ const dest = values.to;
458
+ if (!dir || !dest)
459
+ throw new Error('usage: shiply promote <preview-slug> --to <dest-slug>');
460
+ const apiKey = values.key ?? (await loadApiKey());
461
+ if (!apiKey)
462
+ throw new Error('promote needs an API key — run `shiply login` first');
463
+ await sitesPromote({ base: values.base, apiKey }, dir, dest);
464
+ return;
465
+ }
381
466
  case 'detect': {
382
467
  await runDetect(dir ?? '.', { framework: values.framework });
383
468
  return;
package/dist/manifest.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { readdir, readFile } from 'node:fs/promises';
3
3
  import { extname, join } from 'node:path';
4
+ import { loadIgnore } from './ignore.js';
4
5
  const MIME = {
5
6
  '.html': 'text/html',
6
7
  '.htm': 'text/html',
@@ -40,22 +41,30 @@ const SKIP_DIRS = new Set(['node_modules']);
40
41
  * config directory (proxy.json, data.json), which the server consumes. */
41
42
  export async function buildManifest(dir) {
42
43
  const out = [];
43
- await walk(dir, '', out);
44
+ const ignore = await loadIgnore(dir);
45
+ await walk(dir, '', out, ignore);
44
46
  return out.sort((a, b) => a.path.localeCompare(b.path));
45
47
  }
46
- async function walk(abs, rel, out) {
48
+ async function walk(abs, rel, out, ignore) {
47
49
  const entries = await readdir(abs, { withFileTypes: true });
48
50
  for (const e of entries) {
49
51
  if (e.name === '.shiply' && e.isDirectory() && rel === '') {
50
- await walk(join(abs, e.name), '.shiply', out);
52
+ // The .shiply/ config dir (proxy.json, data.json) is fully exempt from
53
+ // .shiplyignore — the server needs it, and a broad user rule like
54
+ // `*.json` must not be able to silently drop the site's own config.
55
+ // Pass a null matcher so nothing under .shiply/ is ignore-filtered.
56
+ await walk(join(abs, e.name), '.shiply', out, null);
51
57
  continue;
52
58
  }
53
59
  if (e.name.startsWith('.') || SKIP_DIRS.has(e.name))
54
60
  continue;
55
61
  const childAbs = join(abs, e.name);
56
62
  const childRel = rel ? `${rel}/${e.name}` : e.name;
63
+ // .shiplyignore: skip matched paths (and whole subtrees for dir matches).
64
+ if (ignore?.(childRel, e.isDirectory()))
65
+ continue;
57
66
  if (e.isDirectory()) {
58
- await walk(childAbs, childRel, out);
67
+ await walk(childAbs, childRel, out, ignore);
59
68
  }
60
69
  else if (e.isFile()) {
61
70
  const buf = await readFile(childAbs);
@@ -0,0 +1,26 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ const registryPath = (home) => join(home, '.shiply', 'previews.json');
5
+ async function readAll(home) {
6
+ try {
7
+ return JSON.parse(await readFile(registryPath(home), 'utf8'));
8
+ }
9
+ catch {
10
+ return {};
11
+ }
12
+ }
13
+ /** Look up the slug/credentials previously bound to a `--as <name>` alias. */
14
+ export async function readPreview(name, home = homedir()) {
15
+ const all = await readAll(home);
16
+ return all[name] ?? null;
17
+ }
18
+ /** Remember the slug a `--as <name>` alias published to, so the next publish
19
+ * with the same name UPDATES the same URL (stable preview). */
20
+ export async function writePreview(name, entry, home = homedir()) {
21
+ const all = await readAll(home);
22
+ all[name] = entry;
23
+ const path = registryPath(home);
24
+ await mkdir(dirname(path), { recursive: true });
25
+ await writeFile(path, `${JSON.stringify(all, null, 2)}\n`);
26
+ }
package/dist/sites.js ADDED
@@ -0,0 +1,70 @@
1
+ import { api, resolveBase } from './publish.js';
2
+ const headers = (apiKey) => ({
3
+ 'content-type': 'application/json',
4
+ authorization: `Bearer ${apiKey}`,
5
+ });
6
+ export async function sitesLs(ctx) {
7
+ const base = resolveBase(ctx.base);
8
+ const { sites } = await api(`${base}/api/v1/publishes`, {
9
+ headers: headers(ctx.apiKey),
10
+ });
11
+ if (sites.length === 0) {
12
+ console.log(' no sites yet — run `shiply publish <dir>` to create one');
13
+ return;
14
+ }
15
+ for (const s of sites) {
16
+ const title = s.title ? ` ${s.title}` : '';
17
+ console.log(` ${s.slug} [${s.status}] https://${s.slug}.shiply.now${title}`);
18
+ }
19
+ }
20
+ export async function sitesRm(ctx, slug, opts) {
21
+ if (!opts.yes) {
22
+ console.error(` refusing — \`shiply rm\` PERMANENTLY deletes ${slug} and its files. Re-run with --yes to confirm.`);
23
+ return;
24
+ }
25
+ const base = resolveBase(ctx.base);
26
+ await api(`${base}/api/v1/publish/${slug}`, {
27
+ method: 'DELETE',
28
+ headers: headers(ctx.apiKey),
29
+ });
30
+ console.log(`✔ deleted ${slug}`);
31
+ }
32
+ export async function sitesRollback(ctx, slug, versionId) {
33
+ const base = resolveBase(ctx.base);
34
+ // No versionId → print the version history so the agent can pick one.
35
+ if (!versionId) {
36
+ const detail = await api(`${base}/api/v1/publish/${slug}`, { headers: headers(ctx.apiKey) });
37
+ const finalized = detail.versions.filter((v) => v.status === 'finalized');
38
+ if (finalized.length === 0) {
39
+ console.log(' no finalized versions to roll back to');
40
+ return;
41
+ }
42
+ console.log(` versions for ${slug} (newest first):`);
43
+ for (const v of finalized) {
44
+ const mark = v.id === detail.currentVersionId ? ' ← current (live)' : '';
45
+ console.log(` ${v.id} ${v.fileCount} files ${v.finalizedAt ?? ''}${mark}`);
46
+ }
47
+ console.log(`\n roll back with: shiply rollback ${slug} <versionId>`);
48
+ return;
49
+ }
50
+ const res = await api(`${base}/api/v1/publish/${slug}/rollback`, {
51
+ method: 'POST',
52
+ headers: headers(ctx.apiKey),
53
+ body: JSON.stringify({ versionId }),
54
+ });
55
+ if (!res.changed) {
56
+ console.log(` ${slug} is already serving ${versionId} — nothing to do`);
57
+ return;
58
+ }
59
+ console.log(`✔ rolled ${res.slug} to ${res.currentVersionId} — live now`);
60
+ }
61
+ export async function sitesPromote(ctx, srcSlug, destSlug) {
62
+ const base = resolveBase(ctx.base);
63
+ const r = await api(`${base}/api/v1/publish/${srcSlug}/promote`, {
64
+ method: 'POST',
65
+ headers: headers(ctx.apiKey),
66
+ body: JSON.stringify({ destSlug }),
67
+ });
68
+ console.log(`✔ promoted ${r.sourceSlug} → ${r.slug} (${r.filesCount} files) — live now`);
69
+ console.log(`\n https://${r.slug}.shiply.now\n`);
70
+ }
package/dist/verify.js ADDED
@@ -0,0 +1,26 @@
1
+ // packages/cli/src/verify.ts
2
+ import { api, resolveBase } from './publish.js';
3
+ /** Edge-check a shiply slug: SSL, HTTP reachability, and a rendered thumbnail.
4
+ * Headless agents use this to confirm what they shipped is actually serving.
5
+ * Returns true when LIVE, false when PENDING — the caller sets the exit code
6
+ * (0 / 1) so `shiply verify <slug> && <next>` only proceeds once it's serving. */
7
+ export async function verifySite(ctx, slug) {
8
+ if (slug.includes('.')) {
9
+ throw new Error(`verify takes a shiply slug, not a hostname — for custom domains use \`shiply status ${slug}\``);
10
+ }
11
+ const base = resolveBase(ctx.base);
12
+ const r = await api(`${base}/api/v1/sites/${slug}/verify`, {
13
+ headers: { 'content-type': 'application/json' },
14
+ });
15
+ const sslLine = r.ssl.valid
16
+ ? `valid${r.ssl.issuer ? ` (${r.ssl.issuer}` : ''}${r.ssl.daysLeft !== undefined ? `, ${r.ssl.daysLeft}d left)` : r.ssl.issuer ? ')' : ''}`
17
+ : `not ready${r.ssl.error ? ` — ${r.ssl.error}` : ''}`;
18
+ console.log(`${r.status === 'LIVE' ? '✔' : '…'} ${slug}.shiply.now — ${r.status}`);
19
+ console.log(` ssl: ${sslLine}`);
20
+ console.log(` http: ${r.http.ok ? `ok (HTTP ${r.http.status})` : `not ready${r.http.status ? ` (HTTP ${r.http.status})` : r.http.error ? ` — ${r.http.error}` : ''}`}`);
21
+ if (r.thumbnailUrl)
22
+ console.log(` shot: ${r.thumbnailUrl}`);
23
+ // Stable machine marker for agents/scrapers (mirrors SITE_READY in `status`).
24
+ console.log(`VERIFY status=${r.status} http=${r.http.status ?? 0} ssl=${r.ssl.valid ? 'valid' : 'pending'}${r.thumbnailUrl ? ` thumb=${r.thumbnailUrl}` : ''}`);
25
+ return r.status === 'LIVE';
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shiply-cli",
3
- "version": "0.16.0",
3
+ "version": "0.17.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
@@ -18,7 +18,7 @@ description: Publish static sites to the web instantly with shiply.now and manag
18
18
  > fresh — your cached copy may be missing features like databases, projects,
19
19
  > marketplace, or sending domains.
20
20
  >
21
- > **Last updated: 2026-06-22**
21
+ > **Last updated: 2026-06-23**
22
22
 
23
23
  ---
24
24
 
@@ -144,6 +144,47 @@ so repeat publishes reuse the URL automatically. `--new-site` opts out.
144
144
  Gitignore `.shiply.json` in public repos. Parse `SITE_READY` / `SSL_READY`
145
145
  lines for automation; exit code 0 = ready.
146
146
 
147
+ ## Manage & verify sites (CLI)
148
+
149
+ List, delete, roll back, verify, and promote owned sites — all over the same
150
+ Bearer API key. Great for agents that ship iteratively and need to keep their
151
+ subdomains tidy.
152
+
153
+ ```bash
154
+ shiply ls # list your sites: slug, status, URL
155
+ shiply rm <slug> --yes # PERMANENTLY delete a site + its files (--yes required)
156
+ shiply rollback <slug> # list finalized versions (current one marked)
157
+ shiply rollback <slug> <versionId> # re-point the site to that version — live instantly
158
+ shiply verify <slug> # edge SSL + HTTP + thumbnail readiness check
159
+ shiply publish <dir> --as <name> # STABLE preview: same URL every iteration (local alias)
160
+ shiply promote <preview-slug> --to <dest-slug> # copy the preview's exact live bytes onto a prod site
161
+ shiply publish <dir> --json # one machine-readable JSON line, no confetti/banners
162
+ ```
163
+
164
+ - **`shiply verify <slug>`** prints a human report PLUS a stable machine marker
165
+ line `VERIFY status=LIVE http=200 ssl=valid thumb=…` (or `status=PENDING`).
166
+ Headless agents should parse that `VERIFY status=…` line — it's the
167
+ contract, like `SITE_READY` in `status`.
168
+ - **`shiply publish <dir> --as <name>`** gives a stable preview URL that the
169
+ next publish with the same `--as <name>` reuses — no more changing URLs or
170
+ orphaned scratch sites across iterations, independent of the source dir.
171
+ - **`shiply promote <preview-slug> --to <dest-slug>`** copies the *exact bytes*
172
+ you previewed onto an existing owned destination site (keeps its slug,
173
+ domains, access) — atomic preview→production, no rebuild.
174
+ - **`--json`** on `publish`/`update` emits a single JSON line
175
+ `{slug,siteUrl,siteId,uploaded,skipped,anonymous,expiresAt,updated}` and
176
+ suppresses all human output + confetti — use it in scripts/agents.
177
+ - **`.shiplyignore`** in the publish root excludes files from upload
178
+ (documented gitignore subset: `# comments`, `secret.txt`, `internal/`,
179
+ `*.log`, `/anchored`, `**` globs, `!negation`). Keep `.env`, notes, and
180
+ internal files out of the published bytes.
181
+
182
+ To verify captured signups/form data without the dashboard, the data
183
+ subcommands already exist: `shiply data list <slug>` (collections + counts),
184
+ `shiply data query <slug> <coll> [--limit N] [--where '<json>']`, and
185
+ `shiply data export <slug> <coll> [--out file.ndjson]`. Confetti is
186
+ suppressed automatically on non-TTY stdout; `--no-confetti` forces it off.
187
+
147
188
  ### 3. Raw HTTP (no installs)
148
189
  ```
149
190
  1. POST https://shiply.now/api/v1/publish