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.
@@ -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
- function collectFiles(root) {
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 the usual heavy/secret dirs.
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 (skip.has(name)) continue;
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
- // Never ship local config or secrets.
166
- return rels.filter((r) => !r.startsWith('.claude/') && r !== '.env' && !r.startsWith('.env.') && r !== '.env');
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).');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-publish",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "One-command managed publishing for Manifest projects — render, zip, upload, live URL.",
5
5
  "type": "module",
6
6
  "bin": {