shiply-cli 0.17.0 → 0.18.1

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/bundle.js ADDED
@@ -0,0 +1,64 @@
1
+ import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { build } from 'esbuild';
5
+ const BUNDLE_COMPAT_DATE = '2025-05-05';
6
+ /** Cloudflare runtime built-ins (`cloudflare:workers`, …) and `node:*` (provided
7
+ * by `nodejs_compat`) resolve at the edge — they must stay external. Everything
8
+ * else, including the framework server code that an adapter's `_worker.js`
9
+ * imports from OUTSIDE its output dir, gets inlined. */
10
+ const WORKER_EXTERNALS = ['cloudflare:*', 'node:*'];
11
+ /** esbuild-bundle a worker entry into one self-contained ESM module. */
12
+ async function bundleWorker(entryAbs) {
13
+ const result = await build({
14
+ entryPoints: [entryAbs],
15
+ bundle: true,
16
+ format: 'esm',
17
+ platform: 'neutral',
18
+ target: 'es2022',
19
+ external: WORKER_EXTERNALS,
20
+ write: false,
21
+ logLevel: 'silent',
22
+ });
23
+ return result.outputFiles[0].text;
24
+ }
25
+ /** Stage a temp publish dir: a single bundled worker under `.shiply/bundle/`,
26
+ * static assets at the root, plus `.shiply/bundle.json`. Returns the dir; the
27
+ * caller removes it after publish. The existing publish pipeline handles the rest.
28
+ *
29
+ * BOTH tiers esbuild-bundle the entry. Adapter output (SvelteKit, Astro) is NOT
30
+ * self-contained — `_worker.js` imports server code from sibling dirs
31
+ * (`../output/server`, `../cloudflare-tmp`) — so we bundle it exactly as
32
+ * `wrangler deploy` would, rather than upload it as-is. */
33
+ export async function prepareBundle(sourceDir, ssr) {
34
+ const out = await mkdtemp(join(tmpdir(), 'shiply-bundle-'));
35
+ const bundleDir = join(out, '.shiply', 'bundle');
36
+ await mkdir(bundleDir, { recursive: true });
37
+ if (ssr.tier0) {
38
+ // Tier 0: bundle the wrangler `main` entry; optional static assets dir.
39
+ await writeFile(join(bundleDir, 'index.js'), await bundleWorker(join(ssr.assetsDir, ssr.workerMain)));
40
+ if (ssr.staticDir) {
41
+ await cp(join(sourceDir, ssr.staticDir), out, { recursive: true });
42
+ }
43
+ }
44
+ else {
45
+ // Tier 1: bundle the adapter's worker entry into one module, then copy the
46
+ // rest of the output dir as static assets (minus the now-bundled entry).
47
+ await writeFile(join(bundleDir, 'index.js'), await bundleWorker(join(ssr.assetsDir, ssr.entry)));
48
+ await cp(ssr.assetsDir, out, { recursive: true });
49
+ // Drop the original worker entry (file, or Astro's `_worker.js/` dir) so it
50
+ // isn't double-served as a static asset.
51
+ await rm(join(out, ssr.entry.split('/')[0]), { recursive: true, force: true });
52
+ }
53
+ await writeManifest(out, {
54
+ framework: ssr.framework,
55
+ entry: 'index.js',
56
+ modules: ['index.js'],
57
+ compatibilityDate: ssr.compatibilityDate ?? BUNDLE_COMPAT_DATE,
58
+ compatibilityFlags: ssr.compatibilityFlags,
59
+ });
60
+ return out;
61
+ }
62
+ async function writeManifest(out, m) {
63
+ await writeFile(join(out, '.shiply', 'bundle.json'), JSON.stringify({ v: 1, ...m }, null, 2));
64
+ }
package/dist/index.js CHANGED
@@ -4,9 +4,12 @@ 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 { rm } from 'node:fs/promises';
7
8
  import { confetti, shouldCelebrate } from './confetti.js';
8
9
  import { runDetect } from './detect.js';
9
10
  import { resolvePayloadDir } from './framework.js';
11
+ import { detectSsr } from './ssr-adapters.js';
12
+ import { prepareBundle } from './bundle.js';
10
13
  import { runClaimVerify } from './claim.js';
11
14
  import { dbAttach, dbBranch, dbBranches, dbCreate, dbDelete, dbDeleteBranch, dbLs, dbMerge, dbMigrate, dbSql, } from './db.js';
12
15
  import { driveGet, driveLs, drivePublish, drivePut, driveRm } from './drive.js';
@@ -207,6 +210,7 @@ async function main() {
207
210
  allowPositionals: true,
208
211
  options: {
209
212
  spa: { type: 'boolean' },
213
+ 'no-ssr': { type: 'boolean' },
210
214
  framework: { type: 'string' },
211
215
  'claim-token': { type: 'string' },
212
216
  key: { type: 'string' },
@@ -267,108 +271,133 @@ async function main() {
267
271
  if (!dir)
268
272
  throw new Error(`usage: shiply ${cmd} <dir>`);
269
273
  const apiKey = values.anonymous ? undefined : (values.key ?? (await loadApiKey()));
270
- // Any static site works React/Vue/Astro/Hugo/Jekyll/etc but you
271
- // publish the BUILD OUTPUT, not the source. resolvePayloadDir picks the
272
- // right dir (source as-is, a matched framework's build output, or a
273
- // pre-built fallback like dist/).
274
- const { dir: publishDir, hint } = await resolvePayloadDir(dir, { framework: values.framework });
275
- const spa = values.spa ?? (hint?.spa ? true : undefined);
276
- if (hint && publishDir !== dir && !bareJsonFlag) {
277
- console.log(`✔ ${hint.framework} project detected publishing ${publishDir}${hint.spa ? ' with SPA mode' : ''}`);
274
+ // SSR / worker-bundle lane: a built Cloudflare-adapter project (SvelteKit,
275
+ // Astro) or a wrangler worker (Hono/raw fetch) ships as a worker bundle.
276
+ // Skipped when --no-ssr or --framework (static override) is passed.
277
+ const ssr = values['no-ssr'] || values.framework ? null : await detectSsr(dir);
278
+ let stagingDir = null;
279
+ // Any static site works publish the BUILD OUTPUT, not the source.
280
+ // resolvePayloadDir picks the right dir (source as-is, a matched
281
+ // framework's build output, or a pre-built fallback like dist/).
282
+ let publishDir;
283
+ let spa;
284
+ let stateDir;
285
+ if (ssr) {
286
+ stagingDir = await prepareBundle(dir, ssr);
287
+ publishDir = stagingDir;
288
+ spa = false; // SSR: an asset miss must 404 so the worker handles the route
289
+ stateDir = dir; // persist .shiply.json in the SOURCE dir, not the temp dir
290
+ if (!bareJsonFlag)
291
+ console.log(`✔ ${ssr.framework} (SSR) detected — deploying worker bundle`);
278
292
  }
279
- // same command, same URL: reuse this directory's site automatically
280
- const state = await readState(publishDir);
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);
288
- if (values['new-site']) {
289
- claimToken = values['claim-token'];
290
- slug = undefined;
291
- }
292
- if (cmd === 'update' && !claimToken && !slug) {
293
- throw new Error('nothing to update here — publish first (shiply remembers the site in .shiply.json), or pass --claim-token');
293
+ else {
294
+ const resolved = await resolvePayloadDir(dir, { framework: values.framework });
295
+ publishDir = resolved.dir;
296
+ spa = values.spa ?? (resolved.hint?.spa ? true : undefined);
297
+ stateDir = publishDir;
298
+ if (resolved.hint && publishDir !== dir && !bareJsonFlag) {
299
+ console.log(`✔ ${resolved.hint.framework} project detected — publishing ${publishDir}${resolved.hint.spa ? ' with SPA mode' : ''}`);
300
+ }
294
301
  }
295
- const updating = Boolean(claimToken || slug);
296
- // Auto-attach a previously-created database on publish. Anonymous
297
- // publishes can't attach (no auth subject) — silently skipped server-side.
298
- const attachDatabaseId = state?.databaseId && apiKey ? state.databaseId : undefined;
299
- // Opt-in: bind THIS publish version to a Neon branch's URI. Server
300
- // validates ownership + that the branch lives under a project attached
301
- // to this site; a bogus/foreign id silently falls back to the parent URI.
302
- const previewBranchDbId = values['preview-branch'];
303
- const res = await publish(publishDir, {
304
- apiKey,
305
- base: values.base,
306
- spaMode: spa,
307
- claimToken,
308
- slug,
309
- attachDatabaseId,
310
- previewBranchDbId,
311
- });
312
- await writeState(publishDir, {
313
- slug: res.slug,
314
- siteUrl: res.siteUrl,
315
- // siteId only present on 0.14.4+ servers prefer the new response,
316
- // fall back to a prior state file so re-publishes don't drop it.
317
- ...(res.siteId ? { siteId: res.siteId } : state?.siteId ? { siteId: state.siteId } : {}),
318
- ...(res.claimToken ? { claimToken: res.claimToken } : state?.claimToken && updating ? { claimToken: state.claimToken } : {}),
319
- owned: !res.anonymous,
320
- ...(state?.databaseId ? { databaseId: state.databaseId } : {}),
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 } : {}),
302
+ try {
303
+ // same command, same URL: reuse this directory's site automatically
304
+ const state = await readState(stateDir);
305
+ // --as <name>: a stable named preview. The alias registry remembers which
306
+ // slug this name last published to, so the URL is reused every iteration —
307
+ // independent of which directory the bytes came from.
308
+ const aliasName = values.as;
309
+ const alias = aliasName ? await readPreview(aliasName) : null;
310
+ let claimToken = values['claim-token'] ?? alias?.claimToken ?? state?.claimToken;
311
+ let slug = (alias?.owned && apiKey ? alias.slug : undefined) ?? (state?.owned && apiKey ? state.slug : undefined);
312
+ if (values['new-site']) {
313
+ claimToken = values['claim-token'];
314
+ slug = undefined;
315
+ }
316
+ if (cmd === 'update' && !claimToken && !slug) {
317
+ throw new Error('nothing to update here — publish first (shiply remembers the site in .shiply.json), or pass --claim-token');
318
+ }
319
+ const updating = Boolean(claimToken || slug);
320
+ // Auto-attach a previously-created database on publish. Anonymous
321
+ // publishes can't attach (no auth subject) — silently skipped server-side.
322
+ const attachDatabaseId = state?.databaseId && apiKey ? state.databaseId : undefined;
323
+ // Opt-in: bind THIS publish version to a Neon branch's URI. Server
324
+ // validates ownership + that the branch lives under a project attached
325
+ // to this site; a bogus/foreign id silently falls back to the parent URI.
326
+ const previewBranchDbId = values['preview-branch'];
327
+ const res = await publish(publishDir, {
328
+ apiKey,
329
+ base: values.base,
330
+ spaMode: spa,
331
+ claimToken,
332
+ slug,
333
+ attachDatabaseId,
334
+ previewBranchDbId,
328
335
  });
329
- }
330
- if (bareJsonFlag) {
331
- // Single-line machine output — no banners, no confetti, no readiness chatter.
332
- process.stdout.write(`${JSON.stringify({
336
+ await writeState(stateDir, {
333
337
  slug: res.slug,
334
338
  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
- }
344
- const skipped = res.skipped > 0 ? ` (${res.skipped} unchanged, skipped)` : '';
345
- console.log(`✔ ${updating ? `updated ${res.slug} in place` : 'published'} — ${res.uploaded} file${res.uploaded === 1 ? '' : 's'}${skipped}`);
346
- console.log(`\n ${res.siteUrl}\n`);
347
- // confirm the site actually serves, then celebrate
348
- try {
349
- const host = new URL(res.siteUrl).hostname;
350
- const live = await fetch(res.siteUrl, { method: 'GET', redirect: 'follow' });
351
- void live.body?.cancel();
352
- if (live.status < 400) {
353
- console.log(`SITE_READY url=${res.siteUrl} status=${live.status}`);
354
- if (shouldCelebrate(Boolean(values['no-confetti'])))
355
- console.log(confetti(`⛵ ${host} IS LIVE ⛵`));
339
+ // siteId only present on 0.14.4+ servers — prefer the new response,
340
+ // fall back to a prior state file so re-publishes don't drop it.
341
+ ...(res.siteId ? { siteId: res.siteId } : state?.siteId ? { siteId: state.siteId } : {}),
342
+ ...(res.claimToken ? { claimToken: res.claimToken } : state?.claimToken && updating ? { claimToken: state.claimToken } : {}),
343
+ owned: !res.anonymous,
344
+ ...(state?.databaseId ? { databaseId: state.databaseId } : {}),
345
+ });
346
+ if (aliasName) {
347
+ await writePreview(aliasName, {
348
+ slug: res.slug,
349
+ owned: !res.anonymous,
350
+ ...(res.siteId ? { siteId: res.siteId } : {}),
351
+ ...(res.claimToken ? { claimToken: res.claimToken } : alias?.claimToken ? { claimToken: alias.claimToken } : {}),
352
+ });
356
353
  }
354
+ if (bareJsonFlag) {
355
+ // Single-line machine output — no banners, no confetti, no readiness chatter.
356
+ process.stdout.write(`${JSON.stringify({
357
+ slug: res.slug,
358
+ siteUrl: res.siteUrl,
359
+ siteId: res.siteId ?? null,
360
+ uploaded: res.uploaded,
361
+ skipped: res.skipped,
362
+ anonymous: res.anonymous,
363
+ expiresAt: res.expiresAt,
364
+ updated: updating,
365
+ })}\n`);
366
+ return;
367
+ }
368
+ const skipped = res.skipped > 0 ? ` (${res.skipped} unchanged, skipped)` : '';
369
+ console.log(`✔ ${updating ? `updated ${res.slug} in place` : 'published'} — ${res.uploaded} file${res.uploaded === 1 ? '' : 's'}${skipped}`);
370
+ console.log(`\n ${res.siteUrl}\n`);
371
+ // confirm the site actually serves, then celebrate
372
+ try {
373
+ const host = new URL(res.siteUrl).hostname;
374
+ const live = await fetch(res.siteUrl, { method: 'GET', redirect: 'follow' });
375
+ void live.body?.cancel();
376
+ if (live.status < 400) {
377
+ console.log(`SITE_READY url=${res.siteUrl} status=${live.status}`);
378
+ if (shouldCelebrate(Boolean(values['no-confetti'])))
379
+ console.log(confetti(`⛵ ${host} IS LIVE ⛵`));
380
+ }
381
+ }
382
+ catch {
383
+ /* serving check is best-effort */
384
+ }
385
+ if (!updating) {
386
+ console.log(` saved .shiply.json — run \`shiply publish ${dir}\` again to UPDATE this same site`);
387
+ console.log(` (gitignore .shiply.json if this folder is public — it can update the site)`);
388
+ }
389
+ if (res.anonymous) {
390
+ console.log(` anonymous site — expires ${res.expiresAt ?? 'in 24h'}`);
391
+ if (res.claimUrl)
392
+ console.log(` claim it to KEEP it (free account): ${res.claimUrl}`);
393
+ console.log(` tip: run \`shiply login\` first to publish permanent sites`);
394
+ }
395
+ return;
357
396
  }
358
- catch {
359
- /* serving check is best-effort */
360
- }
361
- if (!updating) {
362
- console.log(` saved .shiply.json — run \`shiply publish ${dir}\` again to UPDATE this same site`);
363
- console.log(` (gitignore .shiply.json if this folder is public — it can update the site)`);
364
- }
365
- if (res.anonymous) {
366
- console.log(` anonymous site — expires ${res.expiresAt ?? 'in 24h'}`);
367
- if (res.claimUrl)
368
- console.log(` claim it to KEEP it (free account): ${res.claimUrl}`);
369
- console.log(` tip: run \`shiply login\` first to publish permanent sites`);
397
+ finally {
398
+ if (stagingDir)
399
+ await rm(stagingDir, { recursive: true, force: true });
370
400
  }
371
- return;
372
401
  }
373
402
  case 'status': {
374
403
  if (!dir) {
@@ -0,0 +1,95 @@
1
+ import { readFile, readdir, stat } from 'node:fs/promises';
2
+ import { join, relative } from 'node:path';
3
+ import { parse as parseToml } from 'smol-toml';
4
+ const NODE_COMPAT = ['nodejs_compat'];
5
+ const exists = (p) => stat(p).then(() => true, () => false);
6
+ async function deps(dir) {
7
+ try {
8
+ const pkg = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8'));
9
+ return { ...pkg.dependencies, ...pkg.devDependencies };
10
+ }
11
+ catch {
12
+ return {};
13
+ }
14
+ }
15
+ /** Recursively list every file under `root`, as posix paths relative to `base`. */
16
+ async function listFiles(root, base = root) {
17
+ const out = [];
18
+ for (const e of await readdir(root, { withFileTypes: true })) {
19
+ const abs = join(root, e.name);
20
+ if (e.isDirectory())
21
+ out.push(...(await listFiles(abs, base)));
22
+ else
23
+ out.push(relative(base, abs).split('\\').join('/'));
24
+ }
25
+ return out;
26
+ }
27
+ /** Detect a built SSR project (Cloudflare-adapter output or a wrangler worker)
28
+ * in `dir`. Returns null for plain static dirs. */
29
+ export async function detectSsr(dir) {
30
+ const d = await deps(dir);
31
+ // Tier 1: SvelteKit (single _worker.js + sibling assets)
32
+ if ('@sveltejs/adapter-cloudflare' in d) {
33
+ const out = join(dir, '.svelte-kit', 'cloudflare');
34
+ if (await exists(join(out, '_worker.js'))) {
35
+ return {
36
+ framework: 'sveltekit',
37
+ assetsDir: out,
38
+ entry: '_worker.js',
39
+ modules: ['_worker.js'],
40
+ compatibilityFlags: NODE_COMPAT,
41
+ };
42
+ }
43
+ }
44
+ // Tier 1: Astro (dist/_worker.js/ multi-module + dist assets)
45
+ if ('@astrojs/cloudflare' in d) {
46
+ const out = join(dir, 'dist');
47
+ const workerDir = join(out, '_worker.js');
48
+ if (await exists(join(workerDir, 'index.js'))) {
49
+ const modules = (await listFiles(workerDir))
50
+ .filter((p) => p.endsWith('.js'))
51
+ .map((p) => `_worker.js/${p}`);
52
+ return {
53
+ framework: 'astro',
54
+ assetsDir: out,
55
+ entry: '_worker.js/index.js',
56
+ modules,
57
+ compatibilityFlags: NODE_COMPAT,
58
+ };
59
+ }
60
+ }
61
+ // Tier 0: generic Cloudflare Worker via wrangler config
62
+ return detectWrangler(dir);
63
+ }
64
+ async function detectWrangler(dir) {
65
+ let cfg = null;
66
+ if (await exists(join(dir, 'wrangler.toml'))) {
67
+ cfg = parseToml(await readFile(join(dir, 'wrangler.toml'), 'utf8'));
68
+ }
69
+ else {
70
+ for (const f of ['wrangler.json', 'wrangler.jsonc']) {
71
+ if (await exists(join(dir, f))) {
72
+ cfg = JSON.parse(stripJsonc(await readFile(join(dir, f), 'utf8')));
73
+ break;
74
+ }
75
+ }
76
+ }
77
+ if (!cfg || typeof cfg.main !== 'string')
78
+ return null;
79
+ const staticDir = cfg.assets?.directory ?? cfg.site?.bucket;
80
+ return {
81
+ framework: 'cloudflare-worker',
82
+ assetsDir: dir,
83
+ tier0: true,
84
+ workerMain: cfg.main,
85
+ staticDir: typeof staticDir === 'string' ? staticDir : undefined,
86
+ compatibilityDate: typeof cfg.compatibility_date === 'string' ? cfg.compatibility_date : undefined,
87
+ compatibilityFlags: Array.isArray(cfg.compatibility_flags) && cfg.compatibility_flags.length
88
+ ? cfg.compatibility_flags
89
+ : NODE_COMPAT,
90
+ };
91
+ }
92
+ /** Strip // and block comments so wrangler.jsonc parses with JSON.parse. */
93
+ function stripJsonc(s) {
94
+ return s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
95
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shiply-cli",
3
- "version": "0.17.0",
3
+ "version": "0.18.1",
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",
@@ -31,8 +31,12 @@
31
31
  ],
32
32
  "homepage": "https://shiply.now",
33
33
  "devDependencies": {
34
+ "@types/node": "^22.15.0",
34
35
  "typescript": "^5.8.0",
35
- "vitest": "^3.1.0",
36
- "@types/node": "^22.15.0"
36
+ "vitest": "^3.1.0"
37
+ },
38
+ "dependencies": {
39
+ "esbuild": "^0.25.12",
40
+ "smol-toml": "^1.7.0"
37
41
  }
38
42
  }
package/skill/SKILL.md CHANGED
@@ -144,6 +144,16 @@ 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
+ ### SSR / server frameworks (live: SvelteKit, Astro, Hono/raw Workers)
148
+
149
+ `shiply publish` auto-detects a built Cloudflare-adapter project (SvelteKit
150
+ `@sveltejs/adapter-cloudflare`, Astro `@astrojs/cloudflare`) or a wrangler
151
+ worker (`wrangler.toml` with `main` — Hono, itty-router, raw `fetch`) and
152
+ deploys the worker bundle with `nodejs_compat`, serving static assets from the
153
+ edge. **Build first** (`npm run build`), then `shiply publish .`. Requires
154
+ sign-in + Developer plan. Force the static path with `--framework=<name>`, or
155
+ skip SSR detection entirely with `--no-ssr`. Next.js: waitlist (not yet live).
156
+
147
157
  ## Manage & verify sites (CLI)
148
158
 
149
159
  List, delete, roll back, verify, and promote owned sites — all over the same