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