shiply-cli 0.7.1 → 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 +104 -47
- package/dist/claim.js +32 -0
- package/dist/data-init.js +33 -0
- package/dist/data.js +99 -0
- package/dist/db.js +225 -0
- package/dist/detect.js +35 -0
- package/dist/domain.js +56 -0
- package/dist/framework.js +110 -0
- package/dist/index.js +327 -55
- package/dist/login.js +60 -0
- package/dist/publish.js +2 -0
- package/package.json +38 -23
- package/skill/SKILL.md +359 -129
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
|
-
|
|
40
|
-
|
|
41
|
-
-
|
|
42
|
-
|
|
43
|
-
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
}
|