shiply-cli 0.25.2 → 0.26.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 +100 -18
- package/dist/framework.js +6 -1
- package/dist/index.js +4 -0
- package/dist/manifest.js +21 -9
- package/dist/publish.js +10 -1
- package/package.json +42 -42
- package/skill/CHANGELOG.md +11 -0
- package/skill/SKILL.md +9 -2
package/dist/bundle.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { builtinModules } from 'node:module';
|
|
4
5
|
import { tmpdir } from 'node:os';
|
|
5
6
|
import { dirname, isAbsolute, join, relative } from 'node:path';
|
|
@@ -93,27 +94,109 @@ export async function prepareBundle(sourceDir, ssr, extras = {}) {
|
|
|
93
94
|
});
|
|
94
95
|
return out;
|
|
95
96
|
}
|
|
97
|
+
/** Fingerprint everything the wrangler dry-run bundle depends on: the build
|
|
98
|
+
* output tree (path/size/mtime — content changes always touch these), the
|
|
99
|
+
* wrangler config, and the project's wrangler version. Returns null when any
|
|
100
|
+
* part can't be read — callers then skip the cache, never the bundle. */
|
|
101
|
+
async function wranglerBundleCacheKey(sourceDir, ssr) {
|
|
102
|
+
try {
|
|
103
|
+
const h = createHash('sha256');
|
|
104
|
+
let wranglerVersion = '?';
|
|
105
|
+
try {
|
|
106
|
+
const pkg = JSON.parse(await readFile(join(sourceDir, 'node_modules', 'wrangler', 'package.json'), 'utf8'));
|
|
107
|
+
wranglerVersion = pkg.version ?? '?';
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null; // no local wrangler pin → can't trust a cached bundle
|
|
111
|
+
}
|
|
112
|
+
h.update(`wrangler@${wranglerVersion}\n`);
|
|
113
|
+
for (const cfg of ['wrangler.toml', 'wrangler.json', 'wrangler.jsonc']) {
|
|
114
|
+
try {
|
|
115
|
+
h.update(`${cfg}\n`).update(await readFile(join(sourceDir, cfg)));
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
/* absent config files simply don't contribute */
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Walk the build-output tree the worker entry lives in (e.g. .open-next/),
|
|
122
|
+
// excluding the static assets subtree — assets don't feed the bundle.
|
|
123
|
+
const root = join(sourceDir, dirname(ssr.workerEntry));
|
|
124
|
+
const excludeAbs = ssr.assetsDir ? join(sourceDir, ssr.assetsDir) : null;
|
|
125
|
+
const walk = async (dir) => {
|
|
126
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
127
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
128
|
+
for (const e of entries) {
|
|
129
|
+
const abs = join(dir, e.name);
|
|
130
|
+
if (excludeAbs && (abs === excludeAbs || abs.startsWith(excludeAbs + '\\') || abs.startsWith(excludeAbs + '/')))
|
|
131
|
+
continue;
|
|
132
|
+
if (e.isDirectory()) {
|
|
133
|
+
await walk(abs);
|
|
134
|
+
}
|
|
135
|
+
else if (e.isFile()) {
|
|
136
|
+
const s = await stat(abs);
|
|
137
|
+
h.update(`${relative(root, abs)}\0${s.size}\0${Math.round(s.mtimeMs)}\n`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
await walk(root);
|
|
142
|
+
return h.digest('hex');
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
96
148
|
/** Next.js (OpenNext) path: let wrangler bundle the worker (its nodejs-compat
|
|
97
149
|
* bundler converts dynamic require('node:*') to top-level imports — esbuild
|
|
98
150
|
* can't), then deploy that worker multi-module with a hand-written asset-first
|
|
99
|
-
* entry. Two modules: index.js (entry) imports ./worker.js (wrangler's bundle).
|
|
151
|
+
* entry. Two modules: index.js (entry) imports ./worker.js (wrangler's bundle).
|
|
152
|
+
*
|
|
153
|
+
* The wrangler bundle is CACHED per project (node_modules/.cache/shiply),
|
|
154
|
+
* keyed on the build-output tree + wrangler config/version: the dry-run takes
|
|
155
|
+
* ~20s on a real Next app, and a republish without a rebuild (retry, config
|
|
156
|
+
* or content-elsewhere change) shouldn't pay it again. */
|
|
100
157
|
async function stageWranglerBundle(sourceDir, ssr, out, bundleDir, extras = {}) {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
158
|
+
const cacheKey = await wranglerBundleCacheKey(sourceDir, ssr);
|
|
159
|
+
const cacheDir = join(sourceDir, 'node_modules', '.cache', 'shiply');
|
|
160
|
+
const cachedWorker = join(cacheDir, 'wrangler-worker.js');
|
|
161
|
+
const cachedKeyFile = join(cacheDir, 'wrangler-worker.key');
|
|
162
|
+
const cacheHit = cacheKey !== null &&
|
|
163
|
+
(await readFile(cachedKeyFile, 'utf8').then((k) => k === cacheKey, () => false)) &&
|
|
164
|
+
(await stat(cachedWorker).then((s) => s.isFile(), () => false));
|
|
165
|
+
if (cacheHit) {
|
|
166
|
+
await cp(cachedWorker, join(bundleDir, 'worker.js'));
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
const wtmp = await mkdtemp(join(tmpdir(), 'shiply-wrangler-'));
|
|
170
|
+
// `--dry-run` bundles without deploying (no CF auth needed). Local wrangler
|
|
171
|
+
// (OpenNext projects depend on it) is preferred via npx. shell:true → npx.cmd on Windows.
|
|
172
|
+
const res = spawnSync('npx', ['wrangler', 'deploy', '--dry-run', '--outdir', wtmp], {
|
|
173
|
+
cwd: sourceDir,
|
|
174
|
+
encoding: 'utf8',
|
|
175
|
+
shell: true,
|
|
176
|
+
env: { ...process.env, CLOUDFLARE_API_TOKEN: process.env.CLOUDFLARE_API_TOKEN ?? 'dry-run' },
|
|
177
|
+
});
|
|
178
|
+
const workerPath = join(wtmp, 'worker.js');
|
|
179
|
+
const built = await stat(workerPath).then((s) => s.isFile(), () => false);
|
|
180
|
+
if (!built) {
|
|
181
|
+
throw new Error(`wrangler could not bundle the ${ssr.framework} worker (is wrangler installed in the project?).\n` +
|
|
182
|
+
`${(res.stderr || res.stdout || '').slice(0, 1200)}`);
|
|
183
|
+
}
|
|
184
|
+
await cp(workerPath, join(bundleDir, 'worker.js'));
|
|
185
|
+
if (cacheKey !== null) {
|
|
186
|
+
// Best-effort: populate the cache for the next publish. Write the worker
|
|
187
|
+
// FIRST so a torn write can only produce a stale-key miss, never a hit
|
|
188
|
+
// on a half-written worker.
|
|
189
|
+
try {
|
|
190
|
+
await mkdir(cacheDir, { recursive: true });
|
|
191
|
+
await cp(workerPath, cachedWorker);
|
|
192
|
+
await writeFile(cachedKeyFile, cacheKey);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
/* cache is an optimization — never fail the publish over it */
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
await rm(wtmp, { recursive: true, force: true });
|
|
115
199
|
}
|
|
116
|
-
await cp(workerPath, join(bundleDir, 'worker.js'));
|
|
117
200
|
// Asset-first entry that defers to wrangler's worker. Hand-written (not
|
|
118
201
|
// esbuild) so we never re-process wrangler's correctly-bundled output.
|
|
119
202
|
// OpenNext's worker ALSO calls env.ASSETS itself and treats any non-404 as an
|
|
@@ -150,7 +233,6 @@ async function stageWranglerBundle(sourceDir, ssr, out, bundleDir, extras = {})
|
|
|
150
233
|
compatibilityFlags: ssr.compatibilityFlags,
|
|
151
234
|
isr: extras.isr,
|
|
152
235
|
});
|
|
153
|
-
await rm(wtmp, { recursive: true, force: true });
|
|
154
236
|
}
|
|
155
237
|
async function writeManifest(out, m) {
|
|
156
238
|
// Drop an undefined `isr` so static/non-Next bundles keep a clean manifest.
|
package/dist/framework.js
CHANGED
|
@@ -8,9 +8,14 @@ const SSG_SIGNATURES = [
|
|
|
8
8
|
{ files: ['mkdocs.yml', 'mkdocs.yaml'], framework: 'MkDocs', outputDir: 'site', buildCommand: 'mkdocs build' },
|
|
9
9
|
];
|
|
10
10
|
async function detectStaticSiteGenerator(dir) {
|
|
11
|
+
// Check every signature file in parallel, then pick the first match in the
|
|
12
|
+
// original priority order — same result as the serial version, faster I/O.
|
|
13
|
+
const flatFiles = SSG_SIGNATURES.flatMap((sig) => sig.files);
|
|
14
|
+
const hits = await Promise.all(flatFiles.map((f) => exists(join(dir, f))));
|
|
15
|
+
let i = 0;
|
|
11
16
|
for (const sig of SSG_SIGNATURES) {
|
|
12
17
|
for (const f of sig.files) {
|
|
13
|
-
if (
|
|
18
|
+
if (hits[i++]) {
|
|
14
19
|
return { framework: sig.framework, outputDir: sig.outputDir, spa: false, buildCommand: sig.buildCommand };
|
|
15
20
|
}
|
|
16
21
|
}
|
package/dist/index.js
CHANGED
|
@@ -422,6 +422,10 @@ async function main() {
|
|
|
422
422
|
console.error(`\n✖ function deploy FAILED — the site's worker is NOT live:`);
|
|
423
423
|
for (const err of res.function.errors)
|
|
424
424
|
console.error(` ${err}`);
|
|
425
|
+
// A worker source was present but did not deploy: the site is not what
|
|
426
|
+
// the user shipped, so exit non-zero. Static files DID go live, hence
|
|
427
|
+
// we still print the URL below — but automation/CI must see the failure.
|
|
428
|
+
process.exitCode = 1;
|
|
425
429
|
}
|
|
426
430
|
else if (res.function?.deployed) {
|
|
427
431
|
const crons = res.function.cronCount ? ` (${res.function.cronCount} cron${res.function.cronCount === 1 ? '' : 's'})` : '';
|
package/dist/manifest.js
CHANGED
|
@@ -35,14 +35,32 @@ const MIME = {
|
|
|
35
35
|
};
|
|
36
36
|
export const contentTypeFor = (path) => MIME[extname(path).toLowerCase()] ?? 'application/octet-stream';
|
|
37
37
|
const SKIP_DIRS = new Set(['node_modules']);
|
|
38
|
+
/** Reads+hashes run concurrently — hashing a large build serially left ~1-3s
|
|
39
|
+
* on the table for a few hundred files. */
|
|
40
|
+
const HASH_CONCURRENCY = 8;
|
|
38
41
|
/** Walk a directory into the publish manifest: posix-relative paths, byte
|
|
39
42
|
* sizes, sha256 hex hashes (server hash-skips unchanged files on update).
|
|
40
43
|
* Dot entries and node_modules are never published — except the .shiply/
|
|
41
44
|
* config directory (proxy.json, data.json), which the server consumes. */
|
|
42
45
|
export async function buildManifest(dir) {
|
|
43
|
-
const
|
|
46
|
+
const targets = [];
|
|
44
47
|
const ignore = await loadIgnore(dir);
|
|
45
|
-
await walk(dir, '',
|
|
48
|
+
await walk(dir, '', targets, ignore);
|
|
49
|
+
const out = new Array(targets.length);
|
|
50
|
+
let next = 0;
|
|
51
|
+
const worker = async () => {
|
|
52
|
+
while (next < targets.length) {
|
|
53
|
+
const i = next++;
|
|
54
|
+
const buf = await readFile(targets[i].abs);
|
|
55
|
+
out[i] = {
|
|
56
|
+
path: targets[i].rel,
|
|
57
|
+
size: buf.length,
|
|
58
|
+
contentType: contentTypeFor(targets[i].rel),
|
|
59
|
+
hash: createHash('sha256').update(buf).digest('hex'),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
await Promise.all(Array.from({ length: Math.min(HASH_CONCURRENCY, targets.length) }, worker));
|
|
46
64
|
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
47
65
|
}
|
|
48
66
|
async function walk(abs, rel, out, ignore) {
|
|
@@ -67,13 +85,7 @@ async function walk(abs, rel, out, ignore) {
|
|
|
67
85
|
await walk(childAbs, childRel, out, ignore);
|
|
68
86
|
}
|
|
69
87
|
else if (e.isFile()) {
|
|
70
|
-
|
|
71
|
-
out.push({
|
|
72
|
-
path: childRel,
|
|
73
|
-
size: buf.length,
|
|
74
|
-
contentType: contentTypeFor(e.name),
|
|
75
|
-
hash: createHash('sha256').update(buf).digest('hex'),
|
|
76
|
-
});
|
|
88
|
+
out.push({ abs: childAbs, rel: childRel });
|
|
77
89
|
}
|
|
78
90
|
}
|
|
79
91
|
}
|
package/dist/publish.js
CHANGED
|
@@ -92,6 +92,9 @@ export async function publish(dir, opts = {}) {
|
|
|
92
92
|
// whole publish with "already finalized" even though the deploy succeeded.
|
|
93
93
|
// Give it room, and treat that conflict as the success it is (an earlier
|
|
94
94
|
// attempt completed after the client gave up on it).
|
|
95
|
+
// Say what's happening — finalize is one server-side call (file verification
|
|
96
|
+
// + worker deploy) and used to be a silent multi-minute gap for big bundles.
|
|
97
|
+
process.stderr.write(' ⋯ finalizing — server is verifying files and deploying the worker…\n');
|
|
95
98
|
let finalized;
|
|
96
99
|
try {
|
|
97
100
|
finalized = await api(created.upload.finalizeUrl, {
|
|
@@ -129,8 +132,14 @@ async function uploadAll(dir, targets) {
|
|
|
129
132
|
const t = targets[next++];
|
|
130
133
|
const body = await readFile(join(dir, t.path));
|
|
131
134
|
const res = await fetchWithRetry(t.url, { method: t.method, headers: t.headers, body }, { label: `upload ${t.path}` });
|
|
132
|
-
if (!res.ok)
|
|
135
|
+
if (!res.ok) {
|
|
136
|
+
if (res.status === 403) {
|
|
137
|
+
throw new Error(`upload failed for ${t.path}: HTTP 403 — presigned URLs likely expired ` +
|
|
138
|
+
`(they're short-lived). Re-run \`shiply publish\` to mint fresh ones — ` +
|
|
139
|
+
`unchanged files are hash-skipped, so the retry is cheap.`);
|
|
140
|
+
}
|
|
133
141
|
throw new Error(`upload failed for ${t.path}: HTTP ${res.status}`);
|
|
142
|
+
}
|
|
134
143
|
}
|
|
135
144
|
};
|
|
136
145
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, targets.length) }, worker));
|
package/package.json
CHANGED
|
@@ -1,42 +1,42 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "shiply-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Publish static sites to shiply.now from the command line \u00e2\u20ac\u201d instant web hosting for agents.",
|
|
5
|
-
"license": "MIT",
|
|
6
|
-
"type": "module",
|
|
7
|
-
"bin": {
|
|
8
|
-
"shiply": "dist/index.js"
|
|
9
|
-
},
|
|
10
|
-
"files": [
|
|
11
|
-
"dist",
|
|
12
|
-
"skill",
|
|
13
|
-
"README.md"
|
|
14
|
-
],
|
|
15
|
-
"scripts": {
|
|
16
|
-
"build": "tsc -p tsconfig.build.json",
|
|
17
|
-
"prepublishOnly": "pnpm build",
|
|
18
|
-
"test": "vitest run",
|
|
19
|
-
"typecheck": "tsc --noEmit"
|
|
20
|
-
},
|
|
21
|
-
"engines": {
|
|
22
|
-
"node": ">=18"
|
|
23
|
-
},
|
|
24
|
-
"keywords": [
|
|
25
|
-
"shiply",
|
|
26
|
-
"hosting",
|
|
27
|
-
"static",
|
|
28
|
-
"deploy",
|
|
29
|
-
"agents",
|
|
30
|
-
"publish"
|
|
31
|
-
],
|
|
32
|
-
"homepage": "https://shiply.now",
|
|
33
|
-
"devDependencies": {
|
|
34
|
-
"@types/node": "^22.15.0",
|
|
35
|
-
"typescript": "^5.8.0",
|
|
36
|
-
"vitest": "^3.1.0"
|
|
37
|
-
},
|
|
38
|
-
"dependencies": {
|
|
39
|
-
"esbuild": "^0.25.12",
|
|
40
|
-
"smol-toml": "^1.7.0"
|
|
41
|
-
}
|
|
42
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "shiply-cli",
|
|
3
|
+
"version": "0.26.1",
|
|
4
|
+
"description": "Publish static sites to shiply.now from the command line \u00e2\u20ac\u201d instant web hosting for agents.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"shiply": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"skill",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc -p tsconfig.build.json",
|
|
17
|
+
"prepublishOnly": "pnpm build",
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"typecheck": "tsc --noEmit"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"shiply",
|
|
26
|
+
"hosting",
|
|
27
|
+
"static",
|
|
28
|
+
"deploy",
|
|
29
|
+
"agents",
|
|
30
|
+
"publish"
|
|
31
|
+
],
|
|
32
|
+
"homepage": "https://shiply.now",
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.15.0",
|
|
35
|
+
"typescript": "^5.8.0",
|
|
36
|
+
"vitest": "^3.1.0"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"esbuild": "^0.25.12",
|
|
40
|
+
"smol-toml": "^1.7.0"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/skill/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,17 @@ entry here, that capability may be missing from your copy — re-install with
|
|
|
6
6
|
(This is the agent-facing capability log; the CLI's own release notes live in
|
|
7
7
|
the npm package changelog.)
|
|
8
8
|
|
|
9
|
+
## 2026-07-08
|
|
10
|
+
|
|
11
|
+
- **Fix** — re-publishing an unchanged large SSR site (e.g. a 171-file
|
|
12
|
+
Next.js build) used to take 6+ minutes (serial hash-skip HEAD checks, a
|
|
13
|
+
serial worker-bundle re-read/re-upload even with no changes, plus a
|
|
14
|
+
finalize retry after a client-side timeout). Finalize is now: concurrent
|
|
15
|
+
hash-skip checks, a fingerprinted worker-bundle deploy that's skipped
|
|
16
|
+
entirely when nothing changed, and an idempotent replay on retry. A
|
|
17
|
+
zero-change publish now finishes in seconds. No action needed — always
|
|
18
|
+
re-publish to the same site (slug/claimToken); it's cheap.
|
|
19
|
+
|
|
9
20
|
## 2026-07-07
|
|
10
21
|
|
|
11
22
|
- **Fix** — custom domains attached to SSR/function sites no longer 404: the
|
package/skill/SKILL.md
CHANGED
|
@@ -13,7 +13,7 @@ description: "shiply is the production backend for anything an agent builds —
|
|
|
13
13
|
> the npm package name.
|
|
14
14
|
|
|
15
15
|
> **Skill freshness check.** Shiply ships new capabilities weekly.
|
|
16
|
-
> **This skill: version 0.25.0, last updated 2026-07-
|
|
16
|
+
> **This skill: version 0.25.0, last updated 2026-07-08.**
|
|
17
17
|
> To see what changed since your copy was written, fetch
|
|
18
18
|
> `https://shiply.now/changelog.md` (date-grouped, newest first) — if it lists
|
|
19
19
|
> capabilities newer than the date above, re-install with
|
|
@@ -31,7 +31,14 @@ description: "shiply is the production backend for anything an agent builds —
|
|
|
31
31
|
**How (quickstart).** `shiply publish ./dir` (CLI) — or POST /api/v1/publish → PUT each file → POST finalize — returns a live URL + claimToken.
|
|
32
32
|
|
|
33
33
|
**NEVER create a new site to update an existing one. Always re-publish to the
|
|
34
|
-
same site** — otherwise you litter subdomains and lose
|
|
34
|
+
same site** (same slug/claimToken) — otherwise you litter subdomains and lose
|
|
35
|
+
the user's URL.
|
|
36
|
+
|
|
37
|
+
**Re-publishing the same site is cheap — always prefer it over a new site.**
|
|
38
|
+
Unchanged files are hash-skipped, and an unchanged SSR worker bundle skips
|
|
39
|
+
redeploy entirely (fingerprinted against what's already live). A zero-change
|
|
40
|
+
`shiply publish` on the same site completes in seconds, even for large SSR
|
|
41
|
+
apps — so iterate freely by re-publishing, never by spinning up a fresh site.
|
|
35
42
|
|
|
36
43
|
This file covers the core path: publish → authorize → update → claim → verify.
|
|
37
44
|
Everything else (databases, domains, SSR, email, functions, client work) lives
|