shiply-cli 0.7.0 → 0.12.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/README.md CHANGED
@@ -1,47 +1,104 @@
1
- # shiply-cli
2
-
3
- Publish static sites to [shiply.now](https://shiply.now) from the command line —
4
- instant web hosting built for agents.
5
-
6
- ```bash
7
- npm install -g shiply-cli
8
- # or
9
- curl -fsSL https://shiply.now/install.sh | bash
10
- ```
11
-
12
- ## Usage
13
-
14
- ```bash
15
- shiply publish ./dist # publish a directory, print the live URL
16
- shiply publish ./dist # run it AGAIN → updates the SAME site (no new subdomain)
17
- shiply publish ./dist --spa # single-page app mode
18
- shiply login # email a 6-digit code, mint + save an API key
19
- shiply status <slug-or-domain> [--wait] # SSL + readiness check — confetti when live 🎉
20
- ```
21
-
22
- shiply remembers each directory's site in `.shiply.json` (slug + update token),
23
- so repeat publishes always hit the same URL. Use `--new-site` to start fresh;
24
- gitignore `.shiply.json` in public repos.
25
-
26
- `shiply status` prints stable `SSL_READY` / `SITE_READY` markers for agents and
27
- exits 0 only when the certificate is valid and the site serves. `--wait` polls
28
- until ready (great while a custom domain's certificate issues).
29
-
30
- Without an API key, sites are anonymous: live immediately at
31
- `https://<slug>.shiply.now/`, expire after 24 hours, and print a one-time
32
- `claimToken`/`claimUrl` so they can be updated or claimed into an account.
33
-
34
- With an API key (`shiply login`, `$SHIPLY_API_KEY`, or `--key`), sites are
35
- permanent and owned by your account.
36
-
37
- Unchanged files are hash-skipped on updates — only diffs are uploaded.
38
-
39
- ## For agents
40
-
41
- - Machine guide: <https://shiply.now/llms.txt>
42
- - OpenAPI spec: <https://shiply.now/openapi.json>
43
- - Docs: <https://shiply.now/docs>
44
-
45
- ## License
46
-
47
- MIT
1
+ # shiply-cli
2
+
3
+ Publish static sites to [shiply.now](https://shiply.now) from the command line —
4
+ instant web hosting built for agents.
5
+
6
+ ```bash
7
+ npm install -g shiply-cli
8
+ # or
9
+ curl -fsSL https://shiply.now/install.sh | bash
10
+ ```
11
+
12
+ ## Usage
13
+
14
+ ```bash
15
+ shiply publish ./dist # publish a directory, print the live URL
16
+ shiply publish ./dist # run it AGAIN → updates the SAME site (no new subdomain)
17
+ shiply publish ./dist --spa # single-page app mode
18
+ shiply login # email a 6-digit code, mint + save an API key
19
+ shiply status <slug-or-domain> [--wait] # SSL + readiness check — confetti when live 🎉
20
+ ```
21
+
22
+ shiply remembers each directory's site in `.shiply.json` (slug + update token),
23
+ so repeat publishes always hit the same URL. Use `--new-site` to start fresh;
24
+ gitignore `.shiply.json` in public repos.
25
+
26
+ `shiply status` prints stable `SSL_READY` / `SITE_READY` markers for agents and
27
+ exits 0 only when the certificate is valid and the site serves. `--wait` polls
28
+ until ready (great while a custom domain's certificate issues).
29
+
30
+ Without an API key, sites are anonymous: live immediately at
31
+ `https://<slug>.shiply.now/`, expire after 24 hours, and print a one-time
32
+ `claimToken`/`claimUrl` so they can be updated or claimed into an account.
33
+
34
+ With an API key (`shiply login`, `$SHIPLY_API_KEY`, or `--key`), sites are
35
+ permanent and owned by your account.
36
+
37
+ Unchanged files are hash-skipped on updates — only diffs are uploaded.
38
+
39
+ ### shiply db
40
+
41
+ Per-site SQL databases. Pick the engine that fits the job: **Cloudflare
42
+ D1** (SQLite at the edge, free on every plan) or **Neon Postgres** (with
43
+ copy-on-write branches, developer plan). Needs an API key.
44
+
45
+ ```bash
46
+ shiply db create <name> # D1 by default (binding <NAME>_DB)
47
+ shiply db create <name> --postgres # Neon Postgres (binding DATABASE_URL) — developer plan
48
+ shiply db ls # list databases (name, provider, size, attached site)
49
+ shiply db sql <name-or-id> "<query>" # one-shot query; --params '[1,"a"]' for binds (D1 + Neon)
50
+ shiply db migrate <name-or-id> <dir> # apply every *.sql in dir, sorted
51
+ shiply db attach <name-or-id> --site <slug> # bind an existing DB to a site
52
+ shiply db delete <name-or-id> --yes # drop the database
53
+
54
+ # Neon only — branching off main:
55
+ shiply db branch <db> <branch-name> # cheap, fast, copy-on-write branch
56
+ shiply db branches <db> # list branches of a Neon database
57
+ shiply db delete-branch <name-or-id> --yes # drop a branch + its compute endpoint
58
+ shiply db merge <branch-name> # no-op alias — prints the pg_dump migration tip
59
+ ```
60
+
61
+ `shiply db create` inside a directory that already has a `.shiply.json`
62
+ records the new `databaseId` there, so the next `shiply publish`
63
+ auto-attaches it.
64
+
65
+ - **D1**: pages query it through a built-in fetch shim at
66
+ `/_shiply/db/<binding>/query` — no API key in the browser.
67
+ - **Neon**: the connection URI is AES-256-GCM encrypted at rest and
68
+ surfaced to your serving Worker as `env.DATABASE_URL`. Use it with
69
+ `@neondatabase/serverless`, `pg`, drizzle, etc.
70
+
71
+ To bind a publish to a specific Neon branch instead of `main`, pass
72
+ `shiply publish --preview-branch=<branchDbId>` — handy for preview
73
+ deploys and safe schema rollouts.
74
+
75
+ See <https://shiply.now/docs/databases> for the full reference (REST
76
+ routes, per-DB MCP URL, plan caps, D1 caveats, the Neon branch flow).
77
+
78
+ ## Frameworks
79
+
80
+ `shiply publish .` auto-detects the framework, runs the build if needed, and
81
+ publishes the right output folder with the right flags. Supported:
82
+
83
+ - **JS app frameworks** — Next.js (static export), Astro, SvelteKit, Vite
84
+ (React/Vue/Svelte/Solid/Qwik), Create React App, Nuxt (`nuxt generate`),
85
+ Remix (client build), SolidStart (static), Qwik City (static adapter).
86
+ - **Doc sites** — Docusaurus, MkDocs.
87
+ - **Static site generators** — Hugo, Jekyll, Eleventy, Hexo.
88
+ - **Anything else** — generic fallback for any project that builds to
89
+ `dist/`, `build/`, `out/`, `_site/`, `public/`, or `.output/public/`.
90
+ - **Plain HTML** — publishes as-is.
91
+
92
+ Use `shiply detect` to preview which framework was matched, which build
93
+ command will run, and which folder will be uploaded — without uploading
94
+ anything. Override auto-detection with `--framework=<name>`.
95
+
96
+ ## For agents
97
+
98
+ - Machine guide: <https://shiply.now/llms.txt>
99
+ - OpenAPI spec: <https://shiply.now/openapi.json>
100
+ - Docs: <https://shiply.now/docs>
101
+
102
+ ## License
103
+
104
+ MIT
package/dist/claim.js ADDED
@@ -0,0 +1,32 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ export async function runClaimVerify(args) {
4
+ const read = args.readManifest ?? (() => JSON.parse(readFileSync(join(process.cwd(), '.shiply.json'), 'utf8')));
5
+ const origin = args.origin ?? 'https://shiply.now';
6
+ let mf;
7
+ try {
8
+ mf = read();
9
+ }
10
+ catch {
11
+ return {
12
+ ok: false,
13
+ message: 'No .shiply.json in current directory. Run from the directory where you published, or rerun `shiply publish`.',
14
+ };
15
+ }
16
+ if (!mf.siteId || !mf.claimToken) {
17
+ return { ok: false, message: '.shiply.json is missing siteId or claimToken.' };
18
+ }
19
+ if (!/^SHIPLY-[A-Z2-7]{8}$/.test(args.code)) {
20
+ return { ok: false, message: 'Invalid code format. Expected SHIPLY-XXXXXXXX.' };
21
+ }
22
+ const res = await fetch(`${origin}/api/v1/claim/pair/${encodeURIComponent(args.code)}/verify`, {
23
+ method: 'POST',
24
+ headers: { 'content-type': 'application/json' },
25
+ body: JSON.stringify({ siteId: mf.siteId, claimToken: mf.claimToken }),
26
+ });
27
+ if (!res.ok) {
28
+ const body = (await res.json().catch(() => ({})));
29
+ return { ok: false, message: `verify failed: ${body.error?.message ?? res.status}` };
30
+ }
31
+ return { ok: true, message: 'Verified — switch back to your browser to finish claiming.' };
32
+ }
@@ -0,0 +1,33 @@
1
+ import { access, mkdir, writeFile } from 'node:fs/promises';
2
+ import { resolve, join } from 'node:path';
3
+ const STARTER_MANIFEST = {
4
+ collections: {
5
+ messages: {
6
+ fields: {
7
+ name: { type: 'string', maxLength: 200 },
8
+ email: { type: 'email', required: true },
9
+ message: { type: 'string', maxLength: 2000, required: true },
10
+ },
11
+ access: { insert: 'public', read: 'owner' },
12
+ rateLimit: '10/hour/ip',
13
+ },
14
+ },
15
+ };
16
+ /** Write a starter .shiply/data.json into `dir`. Returns the absolute path
17
+ * written. Throws if the file already exists. */
18
+ export async function dataInit(dir) {
19
+ const base = resolve(dir);
20
+ const target = join(base, '.shiply', 'data.json');
21
+ try {
22
+ await access(target);
23
+ throw new Error(`.shiply/data.json already exists at ${target}`);
24
+ }
25
+ catch (e) {
26
+ // ENOENT is expected here — anything else (including our explicit throw above) re-throws.
27
+ if (e.code !== 'ENOENT')
28
+ throw e;
29
+ }
30
+ await mkdir(join(base, '.shiply'), { recursive: true });
31
+ await writeFile(target, JSON.stringify(STARTER_MANIFEST, null, 2) + '\n', 'utf8');
32
+ return target;
33
+ }
package/dist/data.js ADDED
@@ -0,0 +1,99 @@
1
+ import { createWriteStream } from 'node:fs';
2
+ import { Readable } from 'node:stream';
3
+ import { pipeline } from 'node:stream/promises';
4
+ import { api, resolveBase } from './publish.js';
5
+ const headers = (apiKey) => ({
6
+ 'content-type': 'application/json',
7
+ authorization: `Bearer ${apiKey}`,
8
+ });
9
+ export async function dataList(ctx, slug) {
10
+ const base = resolveBase(ctx.base);
11
+ const res = await api(`${base}/api/v1/publishes/${slug}/data`, {
12
+ headers: headers(ctx.apiKey),
13
+ });
14
+ if (res.collections.length === 0) {
15
+ console.log(' (no collections yet — add `.shiply/data.json` to your site and run `shiply data init`)');
16
+ return;
17
+ }
18
+ for (const c of res.collections) {
19
+ console.log(` ${c.collection} (${c.count})`);
20
+ }
21
+ }
22
+ export async function dataQuery(ctx, slug, collection, opts) {
23
+ const base = resolveBase(ctx.base);
24
+ const params = new URLSearchParams();
25
+ if (opts.limit)
26
+ params.set('limit', String(opts.limit));
27
+ if (opts.cursor)
28
+ params.set('cursor', opts.cursor);
29
+ const q = params.toString() ? `?${params.toString()}` : '';
30
+ const page = await api(`${base}/api/v1/publishes/${slug}/data/${encodeURIComponent(collection)}${q}`, { headers: headers(ctx.apiKey) });
31
+ let records = page.records;
32
+ if (opts.where) {
33
+ let filter;
34
+ try {
35
+ filter = JSON.parse(opts.where);
36
+ }
37
+ catch {
38
+ throw new Error('--where must be valid JSON, e.g. \'{"email":"a@b.co"}\'');
39
+ }
40
+ records = records.filter((r) => Object.entries(filter).every(([k, v]) => r[k] === v));
41
+ }
42
+ if (records.length === 0) {
43
+ console.log(' (no records)');
44
+ return;
45
+ }
46
+ for (const r of records)
47
+ console.log(JSON.stringify(r, null, 2));
48
+ if (page.nextCursor) {
49
+ console.log(`\n more — re-run with --cursor=${page.nextCursor}`);
50
+ }
51
+ }
52
+ export async function dataInsert(ctx, slug, collection, body, sitesDomain = 'shiply.now') {
53
+ // Inserts hit the PUBLIC visitor endpoint at the live site host. We don't
54
+ // send the Bearer key here — manifest's `access.insert` decides.
55
+ const url = `https://${slug}.${sitesDomain}/.shiply/data/${encodeURIComponent(collection)}`;
56
+ const res = await fetch(url, {
57
+ method: 'POST',
58
+ headers: { 'content-type': 'application/json' },
59
+ body: JSON.stringify(body),
60
+ });
61
+ const json = (await res.json().catch(() => ({})));
62
+ if (!res.ok)
63
+ throw new Error(json.message ?? `HTTP ${res.status}`);
64
+ console.log(`✔ inserted ${json.id ?? ''} at ${json.createdAt ?? ''}`);
65
+ }
66
+ export async function dataExport(ctx, slug, collection, opts) {
67
+ const base = resolveBase(ctx.base);
68
+ const url = `${base}/api/v1/publishes/${slug}/data/${encodeURIComponent(collection)}/export`;
69
+ const res = await fetch(url, { headers: headers(ctx.apiKey) });
70
+ if (!res.ok) {
71
+ const body = (await res.json().catch(() => ({})));
72
+ throw new Error(body.error?.message ?? `HTTP ${res.status}`);
73
+ }
74
+ if (!res.body)
75
+ throw new Error('empty response body');
76
+ if (opts.out) {
77
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(opts.out));
78
+ console.log(`✔ saved ${opts.out}`);
79
+ }
80
+ else {
81
+ // stream to stdout (one record per line, already \n-terminated by the server)
82
+ const reader = res.body.getReader();
83
+ for (;;) {
84
+ const { value, done } = await reader.read();
85
+ if (done)
86
+ break;
87
+ process.stdout.write(value);
88
+ }
89
+ }
90
+ }
91
+ export async function dataWipeCollection(ctx, slug, collection, opts) {
92
+ if (!opts.yes) {
93
+ console.error(` refusing — wipe-collection deletes EVERY record in "${collection}" on ${slug}. Re-run with --yes to confirm.`);
94
+ return;
95
+ }
96
+ const base = resolveBase(ctx.base);
97
+ const res = await api(`${base}/api/v1/publishes/${slug}/data/${encodeURIComponent(collection)}`, { method: 'DELETE', headers: headers(ctx.apiKey) });
98
+ console.log(`✔ wiped ${res.deleted} records from ${collection}`);
99
+ }
package/dist/db.js ADDED
@@ -0,0 +1,225 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { api, resolveBase } from './publish.js';
4
+ import { readState, writeState } from './state.js';
5
+ const headers = (apiKey) => ({
6
+ 'content-type': 'application/json',
7
+ authorization: `Bearer ${apiKey}`,
8
+ });
9
+ // A UUID can be used directly; anything else is treated as a name and resolved
10
+ // via /databases (the table is capped per-owner so the round-trip is cheap).
11
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
+ async function resolveId(ctx, idOrName) {
13
+ if (UUID_RE.test(idOrName))
14
+ return idOrName;
15
+ const base = resolveBase(ctx.base);
16
+ const r = await api(`${base}/api/v1/databases`, { headers: headers(ctx.apiKey) });
17
+ const row = r.databases.find((d) => d.name === idOrName);
18
+ if (!row)
19
+ throw new Error(`no database "${idOrName}" — try \`shiply db ls\``);
20
+ return row.id;
21
+ }
22
+ export async function dbCreate(ctx, name, opts) {
23
+ const base = resolveBase(ctx.base);
24
+ const body = {
25
+ name,
26
+ siteSlug: opts.site,
27
+ binding: opts.binding,
28
+ };
29
+ if (opts.postgres)
30
+ body.provider = 'neon';
31
+ const r = await api(`${base}/api/v1/databases`, {
32
+ method: 'POST',
33
+ headers: headers(ctx.apiKey),
34
+ body: JSON.stringify(body),
35
+ });
36
+ const provider = r.provider ?? (opts.postgres ? 'neon' : 'd1');
37
+ console.log(`✔ created ${provider} database "${r.name}" (binding: ${r.binding})`);
38
+ console.log(` id: ${r.id}`);
39
+ if (provider === 'neon') {
40
+ console.log(` → published sites that select this database get env.${r.binding}`);
41
+ console.log(` (Postgres connection string — use with \`pg\`, \`postgres\`, drizzle, etc.)`);
42
+ console.log(` branch it: shiply db branch ${r.name} <branch-name>`);
43
+ }
44
+ else {
45
+ console.log(` query it: shiply db sql ${r.name} "SELECT 1"`);
46
+ console.log(` MCP url: https://shiply.now/api/mcp/db/${r.id}/mcp`);
47
+ }
48
+ // If we're inside a publish directory, record the new databaseId so the
49
+ // next `shiply publish` auto-attaches it. We don't create state when none
50
+ // exists — only enrich what's already there.
51
+ const cwd = opts.cwd ?? process.cwd();
52
+ const existing = await readState(cwd);
53
+ if (existing) {
54
+ await writeState(cwd, { ...existing, databaseId: r.id });
55
+ console.log(` recorded in .shiply.json — next publish will attach to ${existing.slug}`);
56
+ }
57
+ }
58
+ export async function dbLs(ctx) {
59
+ const base = resolveBase(ctx.base);
60
+ const r = await api(`${base}/api/v1/databases`, {
61
+ headers: headers(ctx.apiKey),
62
+ });
63
+ if (!r.databases.length) {
64
+ console.log('No databases yet. Create one: shiply db create <name>');
65
+ return;
66
+ }
67
+ for (const d of r.databases) {
68
+ const size = d.sizeBytes < 1024 * 1024
69
+ ? `${(d.sizeBytes / 1024).toFixed(1)} KB`
70
+ : `${(d.sizeBytes / 1024 / 1024).toFixed(1)} MB`;
71
+ console.log(`${d.name} [${d.provider}] ${size}${d.siteId ? '' : ' (unbound)'}`);
72
+ }
73
+ }
74
+ export async function dbSql(ctx, idOrName, sql, paramsJson) {
75
+ const id = await resolveId(ctx, idOrName);
76
+ const base = resolveBase(ctx.base);
77
+ let params = [];
78
+ if (paramsJson) {
79
+ try {
80
+ params = JSON.parse(paramsJson);
81
+ }
82
+ catch {
83
+ throw new Error('--params must be valid JSON, e.g. \'[1,"a"]\'');
84
+ }
85
+ }
86
+ const out = await api(`${base}/api/v1/databases/${id}/query`, {
87
+ method: 'POST',
88
+ headers: headers(ctx.apiKey),
89
+ body: JSON.stringify({ sql, params }),
90
+ });
91
+ console.log(JSON.stringify(out.results, null, 2));
92
+ console.log(`-- ${out.meta.rows_read} read, ${out.meta.rows_written} written, ${out.meta.duration}ms`);
93
+ }
94
+ export async function dbMigrate(ctx, idOrName, dir) {
95
+ const id = await resolveId(ctx, idOrName);
96
+ const base = resolveBase(ctx.base);
97
+ const entries = (await readdir(dir)).filter((f) => f.endsWith('.sql')).sort();
98
+ if (entries.length === 0) {
99
+ console.log(`no .sql files in ${dir}`);
100
+ return;
101
+ }
102
+ for (const file of entries) {
103
+ const sql = await readFile(join(dir, file), 'utf8');
104
+ process.stdout.write(`→ ${file} `);
105
+ try {
106
+ await api(`${base}/api/v1/databases/${id}/query`, {
107
+ method: 'POST',
108
+ headers: headers(ctx.apiKey),
109
+ body: JSON.stringify({ sql }),
110
+ });
111
+ console.log('ok');
112
+ }
113
+ catch (e) {
114
+ console.log(`✖ ${e.message}`);
115
+ throw e;
116
+ }
117
+ }
118
+ console.log(`✔ ${entries.length} migrations applied`);
119
+ }
120
+ export async function dbDelete(ctx, idOrName, opts) {
121
+ if (!opts.yes) {
122
+ console.log(` refusing — \`db delete\` drops the database "${idOrName}" and every row in it. Re-run with --yes to confirm.`);
123
+ return;
124
+ }
125
+ const id = await resolveId(ctx, idOrName);
126
+ const base = resolveBase(ctx.base);
127
+ await api(`${base}/api/v1/databases/${id}`, {
128
+ method: 'DELETE',
129
+ headers: headers(ctx.apiKey),
130
+ });
131
+ console.log(`✔ deleted database "${idOrName}"`);
132
+ }
133
+ export async function dbAttach(ctx, idOrName, siteSlug) {
134
+ const id = await resolveId(ctx, idOrName);
135
+ const base = resolveBase(ctx.base);
136
+ await api(`${base}/api/v1/databases/${id}/attach`, {
137
+ method: 'POST',
138
+ headers: headers(ctx.apiKey),
139
+ body: JSON.stringify({ siteSlug }),
140
+ });
141
+ console.log(`✔ ${idOrName} attached to ${siteSlug}`);
142
+ }
143
+ export async function dbBranch(ctx, parentNameOrId, branchName) {
144
+ const parentId = await resolveId(ctx, parentNameOrId);
145
+ const base = resolveBase(ctx.base);
146
+ const r = await api(`${base}/api/v1/databases/${parentId}/branches`, {
147
+ method: 'POST',
148
+ headers: headers(ctx.apiKey),
149
+ body: JSON.stringify({ name: branchName }),
150
+ });
151
+ const b = r.database;
152
+ console.log(`✔ branched ${parentNameOrId} → ${b.name} (binding: ${b.binding})`);
153
+ console.log(` id: ${b.id}`);
154
+ console.log(` → published sites that select this branch get env.${b.binding}`);
155
+ console.log(` delete it: shiply db delete-branch ${b.name} --yes`);
156
+ }
157
+ export async function dbBranches(ctx, parentNameOrId) {
158
+ const parentId = await resolveId(ctx, parentNameOrId);
159
+ const base = resolveBase(ctx.base);
160
+ const r = await api(`${base}/api/v1/databases/${parentId}/branches`, { headers: headers(ctx.apiKey) });
161
+ if (!r.branches.length) {
162
+ console.log(`no branches on "${parentNameOrId}" yet`);
163
+ console.log(` create one: shiply db branch ${parentNameOrId} <branch-name>`);
164
+ return;
165
+ }
166
+ for (const b of r.branches) {
167
+ const pg = b.meta?.pg_version ? ` pg${b.meta.pg_version}` : '';
168
+ console.log(`${b.name} [${b.id}]${pg}`);
169
+ }
170
+ }
171
+ export async function dbDeleteBranch(ctx, branchIdOrName, opts) {
172
+ if (!opts.yes) {
173
+ console.log(` refusing — \`db delete-branch\` drops the branch "${branchIdOrName}" and its Neon endpoint. Re-run with --yes to confirm.`);
174
+ return;
175
+ }
176
+ const base = resolveBase(ctx.base);
177
+ // Resolve to (parentId, branchId). UUID input: route by branchId in both
178
+ // URL segments — the server only consults :branchId for the actual delete.
179
+ // Name input: walk owned Neon parents and find the matching branch row.
180
+ let parentId;
181
+ let branchId;
182
+ if (UUID_RE.test(branchIdOrName)) {
183
+ parentId = branchIdOrName;
184
+ branchId = branchIdOrName;
185
+ }
186
+ else {
187
+ const list = await api(`${base}/api/v1/databases`, { headers: headers(ctx.apiKey) });
188
+ const neonParents = list.databases.filter((d) => (d.provider ?? 'd1') === 'neon');
189
+ let found = null;
190
+ for (const p of neonParents) {
191
+ const r = await api(`${base}/api/v1/databases/${p.id}/branches`, { headers: headers(ctx.apiKey) });
192
+ const hit = r.branches.find((b) => b.name === branchIdOrName || b.id === branchIdOrName);
193
+ if (hit) {
194
+ found = {
195
+ parentId: hit.meta?.parent_database_id ?? p.id,
196
+ branchId: hit.id,
197
+ };
198
+ break;
199
+ }
200
+ }
201
+ if (!found) {
202
+ throw new Error(`no branch "${branchIdOrName}" — try \`shiply db branches <db>\``);
203
+ }
204
+ parentId = found.parentId;
205
+ branchId = found.branchId;
206
+ }
207
+ await api(`${base}/api/v1/databases/${parentId}/branches/${branchId}`, {
208
+ method: 'DELETE',
209
+ headers: headers(ctx.apiKey),
210
+ });
211
+ console.log(`✔ deleted branch "${branchIdOrName}"`);
212
+ }
213
+ export async function dbMerge(_ctx, branchName) {
214
+ // Neon branches don't have a server-side "merge" — they're independent
215
+ // Postgres endpoints. The supported workflow is `pg_dump` from the branch
216
+ // and `psql` into main, or run schema migrations on both. We keep this
217
+ // command as a discoverable alias so agents reaching for `db merge` get
218
+ // pointed at the right pattern instead of a hard error.
219
+ console.log(`ℹ shiply db merge is a no-op — Neon branches don't merge.`);
220
+ console.log(` branch: ${branchName}`);
221
+ console.log(` to promote a branch's data into the parent:`);
222
+ console.log(` pg_dump "$BRANCH_DATABASE_URL" | psql "$PARENT_DATABASE_URL"`);
223
+ console.log(` to keep schemas aligned, run your migrations against both branches.`);
224
+ console.log(` fetch the conn URI: bind the branch on a publish and read env.DATABASE_URL.`);
225
+ }
package/dist/detect.js ADDED
@@ -0,0 +1,35 @@
1
+ import { resolve } from 'node:path';
2
+ import { detectFramework, detectBuiltOutput, resolvePayloadDir } from './framework.js';
3
+ export async function runDetect(sourceDir, options = {}) {
4
+ const dir = resolve(sourceDir);
5
+ console.log(`source: ${dir}`);
6
+ if (options.framework) {
7
+ try {
8
+ const { dir: payload, hint } = await resolvePayloadDir(dir, options);
9
+ console.log(`override: --framework=${options.framework}`);
10
+ console.log(`framework: ${hint?.framework}`);
11
+ console.log(`upload: ${payload}`);
12
+ }
13
+ catch (e) {
14
+ console.log(`error: ${e.message}`);
15
+ process.exitCode = 1;
16
+ }
17
+ return;
18
+ }
19
+ const fw = await detectFramework(dir);
20
+ if (fw)
21
+ console.log(`framework: ${fw.framework} → outputDir: ${fw.outputDir} (build: ${fw.buildCommand})`);
22
+ else
23
+ console.log('framework: (none detected)');
24
+ const builtFallback = await detectBuiltOutput(dir);
25
+ if (builtFallback)
26
+ console.log(`pre-built fallback: ${builtFallback.outputDir}/index.html ✓`);
27
+ try {
28
+ const { dir: payload, hint } = await resolvePayloadDir(dir);
29
+ console.log(`would upload: ${payload} (${hint ? hint.framework : 'source as-is'})`);
30
+ }
31
+ catch (e) {
32
+ console.log(`would NOT upload: ${e.message}`);
33
+ process.exitCode = 1;
34
+ }
35
+ }
package/dist/domain.js ADDED
@@ -0,0 +1,56 @@
1
+ import { api, resolveBase } from './publish.js';
2
+ const headers = (apiKey) => ({ 'content-type': 'application/json', authorization: `Bearer ${apiKey}` });
3
+ const enc = (d) => encodeURIComponent(d);
4
+ export async function domainLs(ctx) {
5
+ const base = resolveBase(ctx.base);
6
+ const r = await api(`${base}/api/v1/custom-domains`, { headers: headers(ctx.apiKey) });
7
+ if (!r.domains.length) {
8
+ console.log('No custom domains yet. Add one: shiply domain add <domain>');
9
+ return;
10
+ }
11
+ for (const d of r.domains) {
12
+ console.log(`${d.name} [${d.provider}${d.connected ? ' · connected' : ''}] ${d.status}`);
13
+ for (const s of d.subdomains)
14
+ console.log(` ${s.hostname} → ${s.slug} (${s.status})`);
15
+ }
16
+ }
17
+ export async function domainAdd(ctx, domain) {
18
+ const base = resolveBase(ctx.base);
19
+ const r = await api(`${base}/api/v1/custom-domains`, { method: 'POST', headers: headers(ctx.apiKey), body: JSON.stringify({ domain }) });
20
+ console.log(`✔ ${r.domain} registered — provider: ${r.provider}${r.canAutoConnect ? ` (one-click: shiply domain connect ${r.domain})` : ' (manual DNS)'}`);
21
+ }
22
+ export async function domainConnect(ctx, domain) {
23
+ const base = resolveBase(ctx.base);
24
+ const r = await api(`${base}/api/v1/custom-domains/${enc(domain)}/connect`, { method: 'POST', headers: headers(ctx.apiKey), body: '{}' });
25
+ console.log(`Open this link in your browser to connect ${domain}:\n ${r.url}`);
26
+ }
27
+ export async function domainSubAdd(ctx, hostname, slug) {
28
+ const parts = hostname.split('.');
29
+ // NOTE: naive registrable-domain split (last two labels) — wrong for multi-label TLDs like co.uk; the dashboard avoids this by passing the parent domain explicitly.
30
+ const domain = parts.slice(-2).join('.');
31
+ const subdomain = parts.slice(0, -2).join('.') || '@';
32
+ const base = resolveBase(ctx.base);
33
+ const r = await api(`${base}/api/v1/custom-domains/${enc(domain)}/subdomains`, { method: 'POST', headers: headers(ctx.apiKey), body: JSON.stringify({ subdomain, slug }) });
34
+ if (r.autoConnected) {
35
+ console.log(`✔ ${r.hostname} → ${slug} (DNS written automatically)`);
36
+ }
37
+ else {
38
+ console.log(`✔ ${r.hostname} → ${slug}. Add this DNS record:`);
39
+ for (const d of r.dns)
40
+ console.log(` ${d.type} ${d.host} → ${d.value}`);
41
+ }
42
+ }
43
+ export async function domainSync(ctx, domain) {
44
+ const base = resolveBase(ctx.base);
45
+ const r = await api(`${base}/api/v1/custom-domains/${enc(domain)}/sync-dns`, { method: 'POST', headers: headers(ctx.apiKey), body: '{}' });
46
+ if (r.connected) {
47
+ console.log('✔ synced:');
48
+ for (const x of r.report ?? [])
49
+ console.log(` ${x.host}: ${x.action}`);
50
+ }
51
+ else {
52
+ console.log('Not connected — add these records manually:');
53
+ for (const d of r.records ?? [])
54
+ console.log(` ${d.type} ${d.host} → ${d.value}`);
55
+ }
56
+ }