shiply-cli 0.1.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 ADDED
@@ -0,0 +1,38 @@
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 --spa # single-page app mode
17
+ shiply login # email a 6-digit code, mint + save an API key
18
+ shiply update ./dist --claim-token <token> # push a new version to an anonymous site
19
+ ```
20
+
21
+ Without an API key, sites are anonymous: live immediately at
22
+ `https://<slug>.shiply.now/`, expire after 24 hours, and print a one-time
23
+ `claimToken`/`claimUrl` so they can be updated or claimed into an account.
24
+
25
+ With an API key (`shiply login`, `$SHIPLY_API_KEY`, or `--key`), sites are
26
+ permanent and owned by your account.
27
+
28
+ Unchanged files are hash-skipped on updates — only diffs are uploaded.
29
+
30
+ ## For agents
31
+
32
+ - Machine guide: <https://shiply.now/llms.txt>
33
+ - OpenAPI spec: <https://shiply.now/openapi.json>
34
+ - Docs: <https://shiply.now/docs>
35
+
36
+ ## License
37
+
38
+ MIT
package/dist/config.js ADDED
@@ -0,0 +1,25 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ const credsDir = () => join(homedir(), '.shiply');
5
+ export const credentialsPath = () => join(credsDir(), 'credentials');
6
+ /** SHIPLY_API_KEY env wins; otherwise ~/.shiply/credentials (JSON { apiKey }). */
7
+ export async function loadApiKey() {
8
+ const env = process.env.SHIPLY_API_KEY?.trim();
9
+ if (env)
10
+ return env;
11
+ try {
12
+ const raw = await readFile(credentialsPath(), 'utf8');
13
+ const parsed = JSON.parse(raw);
14
+ return parsed.apiKey?.trim() || undefined;
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
20
+ export async function saveApiKey(apiKey) {
21
+ await mkdir(credsDir(), { recursive: true });
22
+ const path = credentialsPath();
23
+ await writeFile(path, `${JSON.stringify({ apiKey }, null, 2)}\n`, { mode: 0o600 });
24
+ return path;
25
+ }
package/dist/index.js ADDED
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ import { createInterface } from 'node:readline/promises';
3
+ import { parseArgs } from 'node:util';
4
+ import { loadApiKey, saveApiKey } from './config.js';
5
+ import { api, DEFAULT_BASE, publish, resolveBase } from './publish.js';
6
+ const HELP = `shiply — instant static hosting for agents (https://shiply.now)
7
+
8
+ Usage:
9
+ shiply publish <dir> [options] Publish a directory, print the live URL
10
+ shiply update <dir> --claim-token <token> Push a new version to an anonymous site
11
+ shiply login [--email <address>] Email a 6-digit code, mint + save an API key
12
+ shiply help
13
+
14
+ Options:
15
+ --spa Single-page app mode (unknown paths fall back to index.html)
16
+ --claim-token <tok> Update the anonymous site this token belongs to
17
+ --key <key> API key (default: $SHIPLY_API_KEY, then ~/.shiply/credentials)
18
+ --anonymous Publish without an API key even if one is saved
19
+ --base <url> API origin (default: ${DEFAULT_BASE})
20
+
21
+ With an API key the site is permanent and owned by your account. Without one
22
+ it expires in 24 hours — save the printed claimToken/claimUrl to update or
23
+ claim it later.
24
+ `;
25
+ async function main() {
26
+ const { values, positionals } = parseArgs({
27
+ allowPositionals: true,
28
+ options: {
29
+ spa: { type: 'boolean' },
30
+ 'claim-token': { type: 'string' },
31
+ key: { type: 'string' },
32
+ anonymous: { type: 'boolean' },
33
+ base: { type: 'string' },
34
+ email: { type: 'string' },
35
+ help: { type: 'boolean', short: 'h' },
36
+ },
37
+ });
38
+ const [cmd, dir] = positionals;
39
+ if (values.help || !cmd || cmd === 'help') {
40
+ process.stdout.write(HELP);
41
+ return;
42
+ }
43
+ switch (cmd) {
44
+ case 'publish':
45
+ case 'update': {
46
+ if (!dir)
47
+ throw new Error(`usage: shiply ${cmd} <dir>`);
48
+ if (cmd === 'update' && !values['claim-token']) {
49
+ throw new Error('shiply update needs --claim-token <token> (printed by the original publish)');
50
+ }
51
+ const apiKey = values.anonymous ? undefined : (values.key ?? (await loadApiKey()));
52
+ const res = await publish(dir, {
53
+ apiKey,
54
+ base: values.base,
55
+ spaMode: values.spa,
56
+ claimToken: values['claim-token'],
57
+ });
58
+ const skipped = res.skipped > 0 ? ` (${res.skipped} unchanged, skipped)` : '';
59
+ console.log(`✔ published ${res.uploaded} file${res.uploaded === 1 ? '' : 's'}${skipped}`);
60
+ console.log(`\n ${res.siteUrl}\n`);
61
+ if (res.anonymous) {
62
+ console.log(` anonymous site — expires ${res.expiresAt ?? 'in 24h'}`);
63
+ if (res.claimUrl)
64
+ console.log(` claim it (make permanent): ${res.claimUrl}`);
65
+ if (res.claimToken) {
66
+ console.log(` claimToken (SAVE THIS — shown once): ${res.claimToken}`);
67
+ console.log(` update later: shiply update <dir> --claim-token ${res.claimToken}`);
68
+ }
69
+ console.log(` tip: run \`shiply login\` first to publish permanent sites`);
70
+ }
71
+ return;
72
+ }
73
+ case 'login': {
74
+ const base = resolveBase(values.base);
75
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
76
+ try {
77
+ const email = values.email ?? (await rl.question('Email address: '));
78
+ await api(`${base}/api/auth/agent/request-code`, {
79
+ method: 'POST',
80
+ headers: { 'content-type': 'application/json' },
81
+ body: JSON.stringify({ email: email.trim() }),
82
+ });
83
+ const code = await rl.question(`6-digit code sent to ${email.trim()}: `);
84
+ const out = await api(`${base}/api/auth/agent/verify-code`, {
85
+ method: 'POST',
86
+ headers: { 'content-type': 'application/json' },
87
+ body: JSON.stringify({ email: email.trim(), code: code.trim() }),
88
+ });
89
+ const path = await saveApiKey(out.apiKey);
90
+ console.log(`✔ logged in as ${out.email} — key saved to ${path}`);
91
+ console.log(' publishes are now permanent and owned by your account');
92
+ }
93
+ finally {
94
+ rl.close();
95
+ }
96
+ return;
97
+ }
98
+ default:
99
+ throw new Error(`unknown command: ${cmd} (try \`shiply help\`)`);
100
+ }
101
+ }
102
+ main().catch((e) => {
103
+ const msg = e instanceof Error ? e.message : String(e);
104
+ console.error(`✖ ${msg}`);
105
+ process.exit(1);
106
+ });
@@ -0,0 +1,65 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readdir, readFile } from 'node:fs/promises';
3
+ import { extname, join } from 'node:path';
4
+ const MIME = {
5
+ '.html': 'text/html',
6
+ '.htm': 'text/html',
7
+ '.css': 'text/css',
8
+ '.js': 'text/javascript',
9
+ '.mjs': 'text/javascript',
10
+ '.cjs': 'text/javascript',
11
+ '.json': 'application/json',
12
+ '.map': 'application/json',
13
+ '.txt': 'text/plain',
14
+ '.md': 'text/markdown',
15
+ '.xml': 'application/xml',
16
+ '.svg': 'image/svg+xml',
17
+ '.png': 'image/png',
18
+ '.jpg': 'image/jpeg',
19
+ '.jpeg': 'image/jpeg',
20
+ '.gif': 'image/gif',
21
+ '.webp': 'image/webp',
22
+ '.avif': 'image/avif',
23
+ '.ico': 'image/x-icon',
24
+ '.woff': 'font/woff',
25
+ '.woff2': 'font/woff2',
26
+ '.ttf': 'font/ttf',
27
+ '.otf': 'font/otf',
28
+ '.wasm': 'application/wasm',
29
+ '.pdf': 'application/pdf',
30
+ '.mp4': 'video/mp4',
31
+ '.webm': 'video/webm',
32
+ '.mp3': 'audio/mpeg',
33
+ '.wav': 'audio/wav',
34
+ };
35
+ export const contentTypeFor = (path) => MIME[extname(path).toLowerCase()] ?? 'application/octet-stream';
36
+ const SKIP_DIRS = new Set(['node_modules']);
37
+ /** Walk a directory into the publish manifest: posix-relative paths, byte
38
+ * sizes, sha256 hex hashes (server hash-skips unchanged files on update).
39
+ * Dot entries and node_modules are never published. */
40
+ export async function buildManifest(dir) {
41
+ const out = [];
42
+ await walk(dir, '', out);
43
+ return out.sort((a, b) => a.path.localeCompare(b.path));
44
+ }
45
+ async function walk(abs, rel, out) {
46
+ const entries = await readdir(abs, { withFileTypes: true });
47
+ for (const e of entries) {
48
+ if (e.name.startsWith('.') || SKIP_DIRS.has(e.name))
49
+ continue;
50
+ const childAbs = join(abs, e.name);
51
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
52
+ if (e.isDirectory()) {
53
+ await walk(childAbs, childRel, out);
54
+ }
55
+ else if (e.isFile()) {
56
+ const buf = await readFile(childAbs);
57
+ out.push({
58
+ path: childRel,
59
+ size: buf.length,
60
+ contentType: contentTypeFor(e.name),
61
+ hash: createHash('sha256').update(buf).digest('hex'),
62
+ });
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,74 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { buildManifest } from './manifest.js';
4
+ export const DEFAULT_BASE = 'https://shiply.now';
5
+ export class ApiError extends Error {
6
+ code;
7
+ status;
8
+ constructor(code, message, status) {
9
+ super(message);
10
+ this.code = code;
11
+ this.status = status;
12
+ this.name = 'ApiError';
13
+ }
14
+ }
15
+ export async function api(url, init) {
16
+ const res = await fetch(url, init);
17
+ const body = await res.json().catch(() => ({}));
18
+ if (!res.ok) {
19
+ const err = body.error;
20
+ throw new ApiError(err?.code ?? 'http_error', err?.message ?? `HTTP ${res.status}`, res.status);
21
+ }
22
+ return body;
23
+ }
24
+ export const resolveBase = (base) => (base ?? process.env.SHIPLY_BASE_URL ?? DEFAULT_BASE).replace(/\/+$/, '');
25
+ /** The full publish flow: create → PUT uploads (hash-skipped ones excluded
26
+ * server-side) → finalize. Returns the live URL + claim info when anonymous. */
27
+ export async function publish(dir, opts = {}) {
28
+ const base = resolveBase(opts.base);
29
+ const files = await buildManifest(dir);
30
+ if (files.length === 0)
31
+ throw new Error(`no publishable files found in ${dir}`);
32
+ const headers = { 'content-type': 'application/json' };
33
+ if (opts.apiKey)
34
+ headers.authorization = `Bearer ${opts.apiKey}`;
35
+ const created = await api(`${base}/api/v1/publish`, {
36
+ method: 'POST',
37
+ headers,
38
+ body: JSON.stringify({
39
+ files,
40
+ ...(opts.spaMode ? { spaMode: true } : {}),
41
+ ...(opts.claimToken ? { claimToken: opts.claimToken } : {}),
42
+ }),
43
+ });
44
+ await uploadAll(dir, created.upload.uploads);
45
+ await api(created.upload.finalizeUrl, {
46
+ method: 'POST',
47
+ headers: { 'content-type': 'application/json' },
48
+ body: JSON.stringify({ versionId: created.upload.versionId }),
49
+ });
50
+ return {
51
+ slug: created.slug,
52
+ siteUrl: created.siteUrl,
53
+ claimToken: created.claimToken,
54
+ claimUrl: created.claimUrl,
55
+ expiresAt: created.expiresAt,
56
+ anonymous: created.anonymous,
57
+ uploaded: created.upload.uploads.length,
58
+ skipped: created.upload.skipped.length,
59
+ };
60
+ }
61
+ const CONCURRENCY = 8;
62
+ async function uploadAll(dir, targets) {
63
+ let next = 0;
64
+ const worker = async () => {
65
+ while (next < targets.length) {
66
+ const t = targets[next++];
67
+ const body = await readFile(join(dir, t.path));
68
+ const res = await fetch(t.url, { method: t.method, headers: t.headers, body });
69
+ if (!res.ok)
70
+ throw new Error(`upload failed for ${t.path}: HTTP ${res.status}`);
71
+ }
72
+ };
73
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, targets.length) }, worker));
74
+ }
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "shiply-cli",
3
+ "version": "0.1.0",
4
+ "description": "Publish static sites to shiply.now from the command line — instant web hosting for agents.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": { "shiply": "dist/index.js" },
8
+ "files": ["dist", "README.md"],
9
+ "scripts": {
10
+ "build": "tsc -p tsconfig.build.json",
11
+ "prepublishOnly": "pnpm build",
12
+ "test": "vitest run",
13
+ "typecheck": "tsc --noEmit"
14
+ },
15
+ "engines": { "node": ">=18" },
16
+ "keywords": ["shiply", "hosting", "static", "deploy", "agents", "publish"],
17
+ "homepage": "https://shiply.now",
18
+ "devDependencies": {
19
+ "typescript": "^5.8.0",
20
+ "vitest": "^3.1.0",
21
+ "@types/node": "^22.15.0"
22
+ }
23
+ }