shiply-cli 0.24.0 → 0.25.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,189 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
6
+ /** The public MCP server package. `npx shiply-mcp` boots a local stdio server
7
+ * that talks to shiply's REST API: anonymous publish works with no key; a
8
+ * saved SHIPLY_API_KEY (shp_…) unlocks owned site/db/domain ops. */
9
+ export const MCP_SERVER_NAME = 'shiply';
10
+ export const MCP_COMMAND = 'npx';
11
+ /** `-y` so the first run installs shiply-mcp without an interactive prompt —
12
+ * the agent spawns this non-interactively. */
13
+ export const MCP_ARGS = ['-y', 'shiply-mcp'];
14
+ /** Where each agent keeps its (global) MCP config. Kept in one place so the
15
+ * detector and the writer never drift. Project-local variants (./.cursor,
16
+ * ./.mcp.json) are intentionally out of scope — `connect` wires the agent
17
+ * once for the whole machine, matching `shiply skill`'s global default. */
18
+ export function knownTargets(home = homedir()) {
19
+ const claudeFile = join(home, '.claude.json');
20
+ const cursorFile = join(home, '.cursor', 'mcp.json');
21
+ const codexFile = join(home, '.codex', 'config.toml');
22
+ return [
23
+ {
24
+ id: 'claude',
25
+ label: 'Claude Code',
26
+ file: claudeFile,
27
+ format: 'claude-json',
28
+ // ~/.claude.json is created on first Claude Code run; ~/.claude/ is the
29
+ // adjacent skills/data dir. Either presence means "Claude Code is here".
30
+ detected: existsSync(claudeFile) || existsSync(join(home, '.claude')),
31
+ },
32
+ {
33
+ id: 'cursor',
34
+ label: 'Cursor',
35
+ file: cursorFile,
36
+ format: 'mcp-json',
37
+ detected: existsSync(cursorFile) || existsSync(join(home, '.cursor')),
38
+ },
39
+ {
40
+ id: 'codex',
41
+ label: 'Codex',
42
+ file: codexFile,
43
+ format: 'codex-toml',
44
+ detected: existsSync(codexFile) || existsSync(join(home, '.codex')),
45
+ },
46
+ ];
47
+ }
48
+ /** Decide which targets to act on given the flags. Exported for testing. */
49
+ export function selectTargets(targets, opts) {
50
+ let chosen = targets;
51
+ if (opts.only && opts.only.length > 0) {
52
+ const want = new Set(opts.only.map((s) => s.trim().toLowerCase()));
53
+ chosen = chosen.filter((t) => want.has(t.id));
54
+ }
55
+ else if (!opts.all) {
56
+ chosen = chosen.filter((t) => t.detected);
57
+ }
58
+ return chosen;
59
+ }
60
+ /** The server entry we want present, in the JSON agents' shape. */
61
+ function desiredJsonEntry() {
62
+ return { command: MCP_COMMAND, args: [...MCP_ARGS] };
63
+ }
64
+ /** Shallow structural equality for a server entry we previously wrote. Only
65
+ * compares the fields we manage (command + args) so a user who hand-added
66
+ * `env` on top of our entry isn't treated as "different" and re-clobbered. */
67
+ function entryMatches(existing) {
68
+ if (!existing || typeof existing !== 'object')
69
+ return false;
70
+ const e = existing;
71
+ if (e.command !== MCP_COMMAND)
72
+ return false;
73
+ if (!Array.isArray(e.args))
74
+ return false;
75
+ return e.args.length === MCP_ARGS.length && e.args.every((a, i) => a === MCP_ARGS[i]);
76
+ }
77
+ /** Read a file if present; undefined if missing. Any other error propagates. */
78
+ async function readIfExists(file) {
79
+ try {
80
+ return await readFile(file, 'utf8');
81
+ }
82
+ catch (e) {
83
+ if (e.code === 'ENOENT')
84
+ return undefined;
85
+ throw e;
86
+ }
87
+ }
88
+ /** Compute + optionally apply the change for a single JSON-shaped agent
89
+ * (Claude Code / Cursor). Both use `{ mcpServers: { <name>: entry } }`. */
90
+ async function applyJsonTarget(t, opts) {
91
+ const raw = await readIfExists(t.file);
92
+ let doc = {};
93
+ if (raw && raw.trim()) {
94
+ try {
95
+ const parsed = JSON.parse(raw);
96
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
97
+ doc = parsed;
98
+ }
99
+ else {
100
+ return { ...base(t), action: 'error', detail: 'config is not a JSON object — left untouched' };
101
+ }
102
+ }
103
+ catch {
104
+ return { ...base(t), action: 'error', detail: 'config is not valid JSON — left untouched' };
105
+ }
106
+ }
107
+ const servers = doc.mcpServers && typeof doc.mcpServers === 'object' && !Array.isArray(doc.mcpServers)
108
+ ? doc.mcpServers
109
+ : undefined;
110
+ const existing = servers?.[MCP_SERVER_NAME];
111
+ if (existing !== undefined) {
112
+ if (entryMatches(existing))
113
+ return { ...base(t), action: 'unchanged', detail: 'already configured' };
114
+ if (!opts.force)
115
+ return {
116
+ ...base(t),
117
+ action: 'skipped-exists',
118
+ detail: 'a different `shiply` MCP entry exists — re-run with --force to overwrite',
119
+ };
120
+ }
121
+ const nextServers = { ...(servers ?? {}), [MCP_SERVER_NAME]: desiredJsonEntry() };
122
+ const nextDoc = { ...doc, mcpServers: nextServers };
123
+ const action = existing !== undefined ? 'updated' : 'added';
124
+ if (!opts.dryRun) {
125
+ await mkdir(dirname(t.file), { recursive: true });
126
+ // Preserve 2-space indentation like the rest of the CLI's JSON writers.
127
+ await writeFile(t.file, `${JSON.stringify(nextDoc, null, 2)}\n`);
128
+ }
129
+ return { ...base(t), action };
130
+ }
131
+ /** Codex reads TOML: `[mcp_servers.shiply]\ncommand=…\nargs=[…]`. We parse the
132
+ * whole doc, merge our table, and re-stringify — smol-toml round-trips the
133
+ * rest of the user's config unharmed. */
134
+ async function applyCodexTarget(t, opts) {
135
+ const raw = await readIfExists(t.file);
136
+ let doc = {};
137
+ if (raw && raw.trim()) {
138
+ try {
139
+ doc = parseToml(raw);
140
+ }
141
+ catch {
142
+ return { ...base(t), action: 'error', detail: 'config is not valid TOML — left untouched' };
143
+ }
144
+ }
145
+ const servers = doc.mcp_servers && typeof doc.mcp_servers === 'object' && !Array.isArray(doc.mcp_servers)
146
+ ? doc.mcp_servers
147
+ : undefined;
148
+ const existing = servers?.[MCP_SERVER_NAME];
149
+ if (existing !== undefined) {
150
+ if (entryMatches(existing))
151
+ return { ...base(t), action: 'unchanged', detail: 'already configured' };
152
+ if (!opts.force)
153
+ return {
154
+ ...base(t),
155
+ action: 'skipped-exists',
156
+ detail: 'a different `shiply` MCP entry exists — re-run with --force to overwrite',
157
+ };
158
+ }
159
+ const nextServers = { ...(servers ?? {}), [MCP_SERVER_NAME]: desiredJsonEntry() };
160
+ const nextDoc = { ...doc, mcp_servers: nextServers };
161
+ const action = existing !== undefined ? 'updated' : 'added';
162
+ if (!opts.dryRun) {
163
+ await mkdir(dirname(t.file), { recursive: true });
164
+ await writeFile(t.file, stringifyToml(nextDoc));
165
+ }
166
+ return { ...base(t), action };
167
+ }
168
+ function base(t) {
169
+ return { id: t.id, label: t.label, file: t.file };
170
+ }
171
+ async function applyTarget(t, opts) {
172
+ try {
173
+ return t.format === 'codex-toml' ? await applyCodexTarget(t, opts) : await applyJsonTarget(t, opts);
174
+ }
175
+ catch (e) {
176
+ return { ...base(t), action: 'error', detail: e.message };
177
+ }
178
+ }
179
+ /** Install the shiply MCP entry into every selected agent config. Idempotent:
180
+ * an identical entry is left as-is; a foreign `shiply` entry is preserved
181
+ * unless --force. Returns one result per selected target (empty if none). */
182
+ export async function runConnect(opts = {}) {
183
+ const targets = knownTargets(opts.home);
184
+ const selected = selectTargets(targets, opts);
185
+ const results = [];
186
+ for (const t of selected)
187
+ results.push(await applyTarget(t, opts));
188
+ return { results, targets };
189
+ }
package/dist/data.js CHANGED
@@ -49,10 +49,24 @@ export async function dataQuery(ctx, slug, collection, opts) {
49
49
  console.log(`\n more — re-run with --cursor=${page.nextCursor}`);
50
50
  }
51
51
  }
52
- export async function dataInsert(ctx, slug, collection, body, sitesDomain = 'shiply.now') {
52
+ export async function dataInsert(ctx, slug, collection, body, sitesDomain) {
53
53
  // Inserts hit the PUBLIC visitor endpoint at the live site host. We don't
54
54
  // send the Bearer key here — manifest's `access.insert` decides.
55
- const url = `https://${slug}.${sitesDomain}/.shiply/data/${encodeURIComponent(collection)}`;
55
+ //
56
+ // The site host is NOT the API base, but it lives on the same domain as it:
57
+ // for the default base https://shiply.now the site host is
58
+ // <slug>.shiply.now. Derive scheme + host from the resolved base so that
59
+ // --base / SHIPLY_BASE_URL (staging, self-host, local) is honored, matching
60
+ // every other data subcommand. An explicit sitesDomain arg still wins.
61
+ let origin;
62
+ if (sitesDomain) {
63
+ origin = `https://${slug}.${sitesDomain}`;
64
+ }
65
+ else {
66
+ const baseUrl = new URL(resolveBase(ctx.base));
67
+ origin = `${baseUrl.protocol}//${slug}.${baseUrl.host}`;
68
+ }
69
+ const url = `${origin}/.shiply/data/${encodeURIComponent(collection)}`;
56
70
  const res = await fetch(url, {
57
71
  method: 'POST',
58
72
  headers: { 'content-type': 'application/json' },
package/dist/index.js CHANGED
@@ -27,6 +27,7 @@ import { loadApiKey, saveApiKey } from './config.js';
27
27
  import { loginViaDeviceFlow } from './login.js';
28
28
  import { api, ApiError, DEFAULT_BASE, publish, resolveBase } from './publish.js';
29
29
  import { installSkill } from './skill.js';
30
+ import { runConnect } from './connect.js';
30
31
  import { readState, writeState } from './state.js';
31
32
  import { readPreview, writePreview } from './previews.js';
32
33
  import { checkReadiness, targetToHostname } from './status.js';
@@ -140,6 +141,9 @@ Usage:
140
141
  shiply skill [--project] [--force] Install the shiply skill for your AI agent
141
142
  (global ~/.claude/skills, or ./.claude/skills with --project)
142
143
  (--force overwrites an existing skill — recommended weekly)
144
+ shiply connect [--all] [--only <ids>] Wire the shiply MCP server into your agent(s)
145
+ (auto-detects Claude Code, Cursor, Codex; idempotent)
146
+ (--all writes even undetected agents; --dry-run previews)
143
147
  shiply login [--name <label>] Open a browser, click Allow → key saved
144
148
  (--email <addr> falls back to legacy 6-digit code)
145
149
  shiply help
@@ -203,11 +207,13 @@ async function main() {
203
207
  // token so the parser doesn't complain about a missing string value.
204
208
  const rawArgv = process.argv.slice(2);
205
209
  // `--json` is a STRING option (data-insert body). For commands where it's a
206
- // boolean flag (status/publish/update), detect a BARE --json (no following
207
- // value) in raw argv and strip it before parseArgs sees it.
210
+ // boolean flag (status/publish/update/logs), `--json` NEVER takes a value, so
211
+ // treat it as a bare flag regardless of what follows and strip it before
212
+ // parseArgs sees it. (A prior lookahead that cancelled the flag when a
213
+ // positional followed made `shiply publish --json ./dist` swallow the dir as
214
+ // the --json value, leaving no <dir>.)
208
215
  const BARE_JSON_CMDS = new Set(['status', 'publish', 'update', 'logs']);
209
- const bareJsonFlag = BARE_JSON_CMDS.has(rawArgv[0]) && rawArgv.includes('--json') &&
210
- !rawArgv.some((a, i) => a === '--json' && typeof rawArgv[i + 1] === 'string' && !rawArgv[i + 1].startsWith('-'));
216
+ const bareJsonFlag = BARE_JSON_CMDS.has(rawArgv[0]) && rawArgv.includes('--json');
211
217
  if (bareJsonFlag) {
212
218
  const idx = rawArgv.indexOf('--json');
213
219
  rawArgv.splice(idx, 1);
@@ -231,6 +237,9 @@ async function main() {
231
237
  'new-site': { type: 'boolean' },
232
238
  project: { type: 'boolean' },
233
239
  force: { type: 'boolean' },
240
+ all: { type: 'boolean' },
241
+ only: { type: 'string' },
242
+ 'dry-run': { type: 'boolean' },
234
243
  timeout: { type: 'string' },
235
244
  out: { type: 'string', short: 'o' },
236
245
  site: { type: 'string' },
@@ -477,7 +486,7 @@ async function main() {
477
486
  if (!apiKey)
478
487
  throw new Error('duplicate needs an API key — run `shiply login` first');
479
488
  const base = resolveBase(values.base);
480
- const out = await api(`${base}/api/v1/publishes/${dir}/duplicate`, {
489
+ const out = await api(`${base}/api/v1/publishes/${encodeURIComponent(dir)}/duplicate`, {
481
490
  method: 'POST',
482
491
  headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
483
492
  body: JSON.stringify({}),
@@ -916,6 +925,70 @@ async function main() {
916
925
  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.');
917
926
  break;
918
927
  }
928
+ case 'connect': {
929
+ const only = values.only
930
+ ? String(values.only)
931
+ .split(',')
932
+ .map((s) => s.trim())
933
+ .filter(Boolean)
934
+ : undefined;
935
+ const dryRun = Boolean(values['dry-run']);
936
+ const { results, targets } = await runConnect({
937
+ all: Boolean(values.all),
938
+ only,
939
+ force: Boolean(values.force),
940
+ dryRun,
941
+ });
942
+ if (results.length === 0) {
943
+ // Nothing selected: either no agents detected, or --only matched none.
944
+ if (only && only.length > 0) {
945
+ console.error(`✖ no known agent matched --only ${only.join(',')}`);
946
+ console.error(` known ids: ${targets.map((t) => t.id).join(', ')}`);
947
+ process.exit(2);
948
+ }
949
+ console.log('No agent config detected (looked for Claude Code, Cursor, Codex).');
950
+ console.log(' Re-run with --all to write configs for all of them anyway,');
951
+ console.log(' or --only claude,cursor,codex to target specific ones.');
952
+ break;
953
+ }
954
+ let wrote = false;
955
+ let blocked = false;
956
+ for (const r of results) {
957
+ switch (r.action) {
958
+ case 'added':
959
+ case 'updated':
960
+ wrote = true;
961
+ console.log(`✔ ${r.label} — shiply MCP ${dryRun ? 'would be ' + r.action : r.action} (${r.file})`);
962
+ break;
963
+ case 'unchanged':
964
+ console.log(`• ${r.label} — already configured (${r.file})`);
965
+ break;
966
+ case 'skipped-exists':
967
+ blocked = true;
968
+ console.log(`• ${r.label} — ${r.detail} (${r.file})`);
969
+ break;
970
+ case 'error':
971
+ blocked = true;
972
+ console.error(`✖ ${r.label} — ${r.detail} (${r.file})`);
973
+ break;
974
+ }
975
+ }
976
+ if (wrote && !dryRun) {
977
+ console.log('');
978
+ console.log('The shiply MCP is now wired in. Next steps:');
979
+ console.log(' 1. Restart your agent (or reload its MCP servers) to pick up the change.');
980
+ console.log(' 2. Ask it to "publish this to shiply" — it can now deploy, check status,');
981
+ console.log(' roll back, and manage custom domains directly.');
982
+ console.log(' 3. Optional: run `shiply login` so owned-site tools work (anonymous publish');
983
+ console.log(' already works with no key).');
984
+ }
985
+ else if (dryRun && wrote) {
986
+ console.log('\n(dry run — nothing was written; re-run without --dry-run to apply)');
987
+ }
988
+ if (blocked)
989
+ process.exitCode = 1;
990
+ break;
991
+ }
919
992
  case 'login': {
920
993
  const base = resolveBase(values.base);
921
994
  // Default path: device flow. One click in the browser, no email round-trip.
package/dist/skill.js CHANGED
@@ -1,15 +1,19 @@
1
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
1
+ import { cp, mkdir, readFile } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
- /** The SKILL.md ships inside the npm package (skill/SKILL.md next to dist/). */
6
- export async function bundledSkill() {
5
+ /** The skill bundle ships inside the npm package (skill/ next to dist/):
6
+ * SKILL.md (core) + references/*.md (per-topic) + CHANGELOG.md. */
7
+ function bundledSkillDir() {
7
8
  const here = dirname(fileURLToPath(import.meta.url)); // <pkg>/dist
8
- return readFile(join(here, '..', 'skill', 'SKILL.md'), 'utf8');
9
+ return join(here, '..', 'skill');
10
+ }
11
+ export async function bundledSkill() {
12
+ return readFile(join(bundledSkillDir(), 'SKILL.md'), 'utf8');
9
13
  }
10
14
  /** Where agents look for skills. Claude Code is the reference layout
11
- * (~/.claude/skills/<name>/SKILL.md); several other agents read the same
12
- * format from their own folders. */
15
+ * (~/.claude/skills/<name>/SKILL.md + sibling reference files); several
16
+ * other agents read the same format from their own folders. */
13
17
  export function installTargets(project) {
14
18
  if (project) {
15
19
  return [{ agent: 'this project (Claude Code & compatible)', dir: join(process.cwd(), '.claude', 'skills', 'shiply') }];
@@ -17,17 +21,16 @@ export function installTargets(project) {
17
21
  return [{ agent: 'Claude Code (all projects)', dir: join(homedir(), '.claude', 'skills', 'shiply') }];
18
22
  }
19
23
  export async function installSkill(project, force = false) {
20
- const content = await bundledSkill();
24
+ const src = bundledSkillDir();
21
25
  const written = [];
22
26
  for (const t of installTargets(project)) {
23
27
  await mkdir(t.dir, { recursive: true });
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
+ // cp always overwrites; --force is an explicit signal of intent (and
29
+ // matches what users type from the AGENT_PROMPT). A stale references/
30
+ // file that no longer ships is harmless — SKILL.md only links current ones.
28
31
  void force;
29
- await writeFile(file, content);
30
- written.push(`${t.agent}: ${file}`);
32
+ await cp(src, t.dir, { recursive: true });
33
+ written.push(`${t.agent}: ${join(t.dir, 'SKILL.md')} (+ references/ + CHANGELOG.md)`);
31
34
  }
32
35
  return written;
33
36
  }
package/package.json CHANGED
@@ -1,42 +1,42 @@
1
- {
2
- "name": "shiply-cli",
3
- "version": "0.24.0",
4
- "description": "Publish static sites to shiply.now from the command line \u00e2\u20ac\u201d instant web hosting for agents.",
5
- "license": "MIT",
6
- "type": "module",
7
- "bin": {
8
- "shiply": "dist/index.js"
9
- },
10
- "files": [
11
- "dist",
12
- "skill",
13
- "README.md"
14
- ],
15
- "scripts": {
16
- "build": "tsc -p tsconfig.build.json",
17
- "prepublishOnly": "pnpm build",
18
- "test": "vitest run",
19
- "typecheck": "tsc --noEmit"
20
- },
21
- "engines": {
22
- "node": ">=18"
23
- },
24
- "keywords": [
25
- "shiply",
26
- "hosting",
27
- "static",
28
- "deploy",
29
- "agents",
30
- "publish"
31
- ],
32
- "homepage": "https://shiply.now",
33
- "devDependencies": {
34
- "@types/node": "^22.15.0",
35
- "typescript": "^5.8.0",
36
- "vitest": "^3.1.0"
37
- },
38
- "dependencies": {
39
- "esbuild": "^0.25.12",
40
- "smol-toml": "^1.7.0"
41
- }
42
- }
1
+ {
2
+ "name": "shiply-cli",
3
+ "version": "0.25.0",
4
+ "description": "Publish static sites to shiply.now from the command line \u00e2\u20ac\u201d instant web hosting for agents.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "shiply": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "skill",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.build.json",
17
+ "prepublishOnly": "pnpm build",
18
+ "test": "vitest run",
19
+ "typecheck": "tsc --noEmit"
20
+ },
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "keywords": [
25
+ "shiply",
26
+ "hosting",
27
+ "static",
28
+ "deploy",
29
+ "agents",
30
+ "publish"
31
+ ],
32
+ "homepage": "https://shiply.now",
33
+ "devDependencies": {
34
+ "@types/node": "^22.15.0",
35
+ "typescript": "^5.8.0",
36
+ "vitest": "^3.1.0"
37
+ },
38
+ "dependencies": {
39
+ "esbuild": "^0.25.12",
40
+ "smol-toml": "^1.7.0"
41
+ }
42
+ }
@@ -0,0 +1,83 @@
1
+ # shiply — what's new for agents
2
+
3
+ Newest first. If your installed skill's "last updated" date is older than an
4
+ entry here, that capability may be missing from your copy — re-install with
5
+ `npx -y shiply-cli@latest skill --force` or read https://shiply.now/skill.md.
6
+ (This is the agent-facing capability log; the CLI's own release notes live in
7
+ the npm package changelog.)
8
+
9
+ ## 2026-07-04
10
+
11
+ - **Update** — the skill was restructured for progressive disclosure: a slim
12
+ core `SKILL.md` (publish → authorize → update → claim → verify) plus
13
+ per-topic files under `references/` (publishing, ssr-frameworks,
14
+ custom-domains, databases, functions, email, site-features, client-work).
15
+ `shiply skill` installs the whole bundle. Hosted copies:
16
+ `https://shiply.now/skill/references/<file>`. `https://shiply.now/llms.txt`
17
+ is now an index; the single-file everything version moved to
18
+ `https://shiply.now/llms-full.txt`. This log is served at
19
+ `https://shiply.now/changelog.md`.
20
+
21
+ ## 2026-06-28
22
+
23
+ - **New** — group work by client: optional `client` (name or email) on
24
+ `publish_site` / `create_drive` / `create_project`, `--client` on the CLI,
25
+ `shiply client ls|show`, and `clientId` filters on list calls. The client
26
+ record self-assembles — no CRM step. (CLI 0.21.0)
27
+
28
+ ## 2026-06-27
29
+
30
+ - **Update** — one-Allow-forever guidance: persist the device-flow API key to
31
+ `~/.shiply/credentials` (`{"apiKey":"shp_..."}`) so every future publish in
32
+ any session is owned automatically.
33
+
34
+ ## 2026-06-26
35
+
36
+ - **New** — `shiply logs <slug>` + `GET /api/v1/sites/<slug>/logs`: real
37
+ per-site Worker runtime logs (7-day retention, newest-first). The MCP
38
+ `get_function_logs` tool returns the same real logs. (CLI 0.20.0)
39
+
40
+ ## 2026-06-25
41
+
42
+ - **New** — the SSR lane is complete: SvelteKit, Astro, Qwik City, the Nitro
43
+ family (Nuxt, SolidStart, Analog, TanStack Start), React Router v7, and
44
+ Next.js (via OpenNext) all deploy server-side rendered with
45
+ `shiply publish .` after a build. (CLI 0.18.x–0.19.0)
46
+
47
+ ## 2026-06-23
48
+
49
+ - **New** — site management batch: `shiply ls` / `rm` / `rollback` /
50
+ `verify` (stable `VERIFY status=…` marker) / `promote <preview> --to
51
+ <prod>` / `publish --as <name>` stable previews / `--json` machine output /
52
+ `.shiplyignore`. (CLI 0.17.0)
53
+
54
+ ## 2026-06-22
55
+
56
+ - **New** — Agent Email: every owned site captures signups
57
+ (`POST /.shiply/email`), sends (`/api/v1/email/send`), reads its inbox, and
58
+ broadcasts to double-opt-in audiences.
59
+ - **Update** — raw HTTP publish + finalize responses now include the
60
+ `toUpdate` string (the exact next-update call), matching MCP.
61
+
62
+ ## 2026-06-21
63
+
64
+ - **New** — Contracts: draft from a project brief, customer e-signs in the
65
+ portal, amend after signing. MCP `contract_draft/send/amend/status/pdf`.
66
+
67
+ ## 2026-06-20
68
+
69
+ - **New** — Functions (Workers Lite): `worker.js`/`worker.ts` at the publish
70
+ root deploys as a per-site Worker — webhooks, crons, secrets.
71
+ - **New** — Projects (customer intake + AI brief), Marketplace (sell sites via
72
+ Stripe Connect), and Sending domains (BYO verified outbound domain) CLI +
73
+ MCP + REST surfaces.
74
+ - **New** — custom domains: primary-subdomain selection with sibling 301s
75
+ (SEO); bring-your-own-auth pattern documented (Clerk/Auth.js/Supabase).
76
+
77
+ ## Earlier
78
+
79
+ - Databases (free D1 + Neon Postgres with branching), Site Data
80
+ (zero-backend forms), proxy routes (server-side secret injection), access
81
+ control (password / invite-only), Drives (private storage), email demand
82
+ tests, device-flow auth (`agentName` on anonymous publish), anonymous
83
+ claim / pairing codes.