mnfst-publish 0.1.0 → 0.1.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/manifest.publish.mjs +68 -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,32 @@ 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 (base === '.mnfst-render-complete') return true; // internal render sentinel — not part of the site
|
|
169
|
+
if (/\.(pem|key|p12|pfx)$/i.test(base)) return true;
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function collectFiles(root) {
|
|
146
174
|
const git = spawnSync('git', ['ls-files', '-co', '--exclude-standard'], { cwd: root, encoding: 'utf8' });
|
|
147
175
|
let rels;
|
|
148
176
|
if (git.status === 0) {
|
|
149
177
|
rels = git.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
150
178
|
} else {
|
|
151
|
-
// Not a git repo — walk, skipping
|
|
152
|
-
const skip = new Set(['.git', 'node_modules', '.claude']);
|
|
179
|
+
// Not a git repo — walk, skipping excluded dirs as we go.
|
|
153
180
|
rels = [];
|
|
154
181
|
const walk = (dir) => {
|
|
155
182
|
for (const name of readdirSync(dir)) {
|
|
156
|
-
if (
|
|
183
|
+
if (EXCLUDED_DIRS.has(name)) continue;
|
|
157
184
|
const abs = join(dir, name);
|
|
158
185
|
const st = statSync(abs);
|
|
159
186
|
if (st.isDirectory()) walk(abs);
|
|
@@ -162,8 +189,8 @@ function collectFiles(root) {
|
|
|
162
189
|
};
|
|
163
190
|
walk(root);
|
|
164
191
|
}
|
|
165
|
-
//
|
|
166
|
-
return rels.filter((r) => !
|
|
192
|
+
// Final guard — drops nested secret files the git/walk lists may include.
|
|
193
|
+
return rels.filter((r) => !isExcludedPath(r));
|
|
167
194
|
}
|
|
168
195
|
|
|
169
196
|
// --- Minimal ZIP writer (DEFLATE), pure Node, no deps ----------------------
|
|
@@ -178,10 +205,19 @@ function crc32(buf) {
|
|
|
178
205
|
}
|
|
179
206
|
|
|
180
207
|
function buildZip(root, rels) {
|
|
208
|
+
// This packer writes classic (non-Zip64) ZIP records: file count is a uint16
|
|
209
|
+
// and offsets are uint32. Fail loudly rather than emit a silently-corrupt
|
|
210
|
+
// archive past those limits.
|
|
211
|
+
if (rels.length > 0xffff) {
|
|
212
|
+
fail(`too many files to package (${rels.length} > 65535). Split the project or contact support.`);
|
|
213
|
+
}
|
|
181
214
|
const chunks = [];
|
|
182
215
|
const central = [];
|
|
183
216
|
let offset = 0;
|
|
184
217
|
for (const rel of rels) {
|
|
218
|
+
if (offset > 0xffffffff) {
|
|
219
|
+
fail('project is too large to package (>4 GB). Contact support.');
|
|
220
|
+
}
|
|
185
221
|
const data = readFileSync(join(root, rel));
|
|
186
222
|
const nameBuf = Buffer.from(rel, 'utf8');
|
|
187
223
|
const crc = crc32(data);
|
|
@@ -262,11 +298,37 @@ export async function main() {
|
|
|
262
298
|
if (r.status !== 0) fail('render failed — fix the errors above and try again.');
|
|
263
299
|
}
|
|
264
300
|
|
|
301
|
+
// For a render project, confirm the output is a COMPLETE render before shipping
|
|
302
|
+
// it. mnfst-render writes a .mnfst-render-complete sentinel as its final step
|
|
303
|
+
// (atomic swap), so its absence means the dir is stale or half-built — refuse
|
|
304
|
+
// rather than publish a broken/empty site.
|
|
305
|
+
if (source === 'render') {
|
|
306
|
+
const outDir = prerenderOutputDir(root);
|
|
307
|
+
if (!existsSync(join(root, outDir, '.mnfst-render-complete'))) {
|
|
308
|
+
fail(
|
|
309
|
+
`the "${outDir}" directory isn't a complete render` +
|
|
310
|
+
(opts.render === false ? ' — re-run without --no-render.' : ' — run `npx mnfst-render` and try again.'),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
265
315
|
log(`Preparing ${opts.env} deploy…`);
|
|
266
316
|
const handshake = await callTool(url, key, 'manifest_publish', { env: opts.env, source, via_cli: true });
|
|
267
317
|
if (handshake.already_pro) { /* not applicable */ }
|
|
268
318
|
const uploadUrl = handshake.upload_url;
|
|
269
319
|
if (!uploadUrl) fail(handshake._text || 'could not start the publish (no upload URL returned).');
|
|
320
|
+
// The upload carries the whole project. Only POST it to an HTTPS endpoint on
|
|
321
|
+
// the SAME host as the MCP server — never to an arbitrary URL a tampered
|
|
322
|
+
// response or misconfigured .mcp.json could inject.
|
|
323
|
+
try {
|
|
324
|
+
const u = new URL(uploadUrl);
|
|
325
|
+
const mcpHost = new URL(url).host;
|
|
326
|
+
if (u.protocol !== 'https:' || u.host !== mcpHost) {
|
|
327
|
+
fail(`refusing to upload to an unexpected endpoint (${u.protocol}//${u.host}); expected https://${mcpHost}.`);
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
fail('the upload URL returned by the server was malformed.');
|
|
331
|
+
}
|
|
270
332
|
|
|
271
333
|
const rels = collectFiles(root);
|
|
272
334
|
if (!rels.length) fail('nothing to publish (no files found).');
|