mnfst-publish 0.1.0 → 0.1.2
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/manifest.publish.mjs +70 -6
- package/package.json +1 -1
package/manifest.publish.mjs
CHANGED
|
@@ -94,6 +94,19 @@ function detectSource(root, explicit) {
|
|
|
94
94
|
return existsSync(join(root, 'website')) ? 'render' : 'spa';
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// The prerender output directory (where mnfst-render writes), honouring
|
|
98
|
+
// manifest.prerender.output / manifest.render.output; defaults to "website".
|
|
99
|
+
function prerenderOutputDir(root) {
|
|
100
|
+
try {
|
|
101
|
+
const mf = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
|
|
102
|
+
const out = mf?.prerender?.output ?? mf?.render?.output;
|
|
103
|
+
if (typeof out === 'string' && out.trim()) return out.trim().replace(/^\/+|\/+$/g, '');
|
|
104
|
+
} catch {
|
|
105
|
+
/* ignore */
|
|
106
|
+
}
|
|
107
|
+
return 'website';
|
|
108
|
+
}
|
|
109
|
+
|
|
97
110
|
// --- MCP JSON-RPC over Streamable HTTP -------------------------------------
|
|
98
111
|
|
|
99
112
|
async function mcp(url, key, method, params, sessionId) {
|
|
@@ -142,18 +155,31 @@ async function callTool(url, key, name, args) {
|
|
|
142
155
|
|
|
143
156
|
// --- File collection (gitignore-aware) -------------------------------------
|
|
144
157
|
|
|
145
|
-
|
|
158
|
+
// Never ship local config or secrets — matched at ANY depth (by path segment /
|
|
159
|
+
// basename), not just the project root. A nested `api/.env` or `sub/.claude/…`
|
|
160
|
+
// must be excluded too.
|
|
161
|
+
const EXCLUDED_DIRS = new Set(['.git', 'node_modules', '.claude']);
|
|
162
|
+
export function isExcludedPath(rel) {
|
|
163
|
+
const parts = rel.split('/');
|
|
164
|
+
if (parts.some((p) => EXCLUDED_DIRS.has(p))) return true;
|
|
165
|
+
const base = parts[parts.length - 1];
|
|
166
|
+
if (base === '.env' || base.startsWith('.env.')) return true; // .env, .env.local, .env.prod …
|
|
167
|
+
if (base === '.npmrc' || base === '.dev.vars' || base === 'id_rsa' || base === '.DS_Store') return true;
|
|
168
|
+
if (/\.(pem|key|p12|pfx)$/i.test(base)) return true;
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function collectFiles(root) {
|
|
146
173
|
const git = spawnSync('git', ['ls-files', '-co', '--exclude-standard'], { cwd: root, encoding: 'utf8' });
|
|
147
174
|
let rels;
|
|
148
175
|
if (git.status === 0) {
|
|
149
176
|
rels = git.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
150
177
|
} else {
|
|
151
|
-
// Not a git repo — walk, skipping
|
|
152
|
-
const skip = new Set(['.git', 'node_modules', '.claude']);
|
|
178
|
+
// Not a git repo — walk, skipping excluded dirs as we go.
|
|
153
179
|
rels = [];
|
|
154
180
|
const walk = (dir) => {
|
|
155
181
|
for (const name of readdirSync(dir)) {
|
|
156
|
-
if (
|
|
182
|
+
if (EXCLUDED_DIRS.has(name)) continue;
|
|
157
183
|
const abs = join(dir, name);
|
|
158
184
|
const st = statSync(abs);
|
|
159
185
|
if (st.isDirectory()) walk(abs);
|
|
@@ -162,8 +188,8 @@ function collectFiles(root) {
|
|
|
162
188
|
};
|
|
163
189
|
walk(root);
|
|
164
190
|
}
|
|
165
|
-
//
|
|
166
|
-
return rels.filter((r) => !
|
|
191
|
+
// Final guard — drops nested secret files the git/walk lists may include.
|
|
192
|
+
return rels.filter((r) => !isExcludedPath(r));
|
|
167
193
|
}
|
|
168
194
|
|
|
169
195
|
// --- Minimal ZIP writer (DEFLATE), pure Node, no deps ----------------------
|
|
@@ -178,10 +204,19 @@ function crc32(buf) {
|
|
|
178
204
|
}
|
|
179
205
|
|
|
180
206
|
function buildZip(root, rels) {
|
|
207
|
+
// This packer writes classic (non-Zip64) ZIP records: file count is a uint16
|
|
208
|
+
// and offsets are uint32. Fail loudly rather than emit a silently-corrupt
|
|
209
|
+
// archive past those limits.
|
|
210
|
+
if (rels.length > 0xffff) {
|
|
211
|
+
fail(`too many files to package (${rels.length} > 65535). Split the project or contact support.`);
|
|
212
|
+
}
|
|
181
213
|
const chunks = [];
|
|
182
214
|
const central = [];
|
|
183
215
|
let offset = 0;
|
|
184
216
|
for (const rel of rels) {
|
|
217
|
+
if (offset > 0xffffffff) {
|
|
218
|
+
fail('project is too large to package (>4 GB). Contact support.');
|
|
219
|
+
}
|
|
185
220
|
const data = readFileSync(join(root, rel));
|
|
186
221
|
const nameBuf = Buffer.from(rel, 'utf8');
|
|
187
222
|
const crc = crc32(data);
|
|
@@ -262,11 +297,40 @@ export async function main() {
|
|
|
262
297
|
if (r.status !== 0) fail('render failed — fix the errors above and try again.');
|
|
263
298
|
}
|
|
264
299
|
|
|
300
|
+
// For a render project, sanity-check there's actually a built site to ship —
|
|
301
|
+
// an output folder with an index.html. (When we just ran the render above, its
|
|
302
|
+
// exit code was already checked; this also covers --no-render and hand-built
|
|
303
|
+
// output.) Deliberately forgiving: no hard dependency on any marker file, so a
|
|
304
|
+
// good build always publishes regardless of which mnfst-render produced it.
|
|
305
|
+
if (source === 'render') {
|
|
306
|
+
const outDir = prerenderOutputDir(root);
|
|
307
|
+
if (!existsSync(join(root, outDir, 'index.html'))) {
|
|
308
|
+
fail(
|
|
309
|
+
`the "${outDir}" folder doesn't have a built site yet (no index.html). ` +
|
|
310
|
+
(opts.render === false
|
|
311
|
+
? 'Remove --no-render so it builds first, or run `npx mnfst-render`, then publish again.'
|
|
312
|
+
: 'Run `npx mnfst-render` first, then publish again.'),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
265
317
|
log(`Preparing ${opts.env} deploy…`);
|
|
266
318
|
const handshake = await callTool(url, key, 'manifest_publish', { env: opts.env, source, via_cli: true });
|
|
267
319
|
if (handshake.already_pro) { /* not applicable */ }
|
|
268
320
|
const uploadUrl = handshake.upload_url;
|
|
269
321
|
if (!uploadUrl) fail(handshake._text || 'could not start the publish (no upload URL returned).');
|
|
322
|
+
// The upload carries the whole project. Only POST it to an HTTPS endpoint on
|
|
323
|
+
// the SAME host as the MCP server — never to an arbitrary URL a tampered
|
|
324
|
+
// response or misconfigured .mcp.json could inject.
|
|
325
|
+
try {
|
|
326
|
+
const u = new URL(uploadUrl);
|
|
327
|
+
const mcpHost = new URL(url).host;
|
|
328
|
+
if (u.protocol !== 'https:' || u.host !== mcpHost) {
|
|
329
|
+
fail(`refusing to upload to an unexpected endpoint (${u.protocol}//${u.host}); expected https://${mcpHost}.`);
|
|
330
|
+
}
|
|
331
|
+
} catch {
|
|
332
|
+
fail('the upload URL returned by the server was malformed.');
|
|
333
|
+
}
|
|
270
334
|
|
|
271
335
|
const rels = collectFiles(root);
|
|
272
336
|
if (!rels.length) fail('nothing to publish (no files found).');
|