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 +38 -0
- package/dist/config.js +25 -0
- package/dist/index.js +106 -0
- package/dist/manifest.js +65 -0
- package/dist/publish.js +74 -0
- package/package.json +23 -0
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
|
+
});
|
package/dist/manifest.js
ADDED
|
@@ -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
|
+
}
|
package/dist/publish.js
ADDED
|
@@ -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
|
+
}
|