mnfst-publish 0.1.5 → 0.1.7

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.
Files changed (2) hide show
  1. package/manifest.publish.mjs +132 -13
  2. package/package.json +1 -1
@@ -202,6 +202,63 @@ export function isExcludedPath(rel) {
202
202
  return false;
203
203
  }
204
204
 
205
+ // Publish-time exclusions, declared per-project and DECOUPLED from git — so a file
206
+ // can stay versioned yet never ship. Sourced from a `.manifestignore` file
207
+ // (gitignore-style) and/or a `publishIgnore: []` array in manifest.json. Supports
208
+ // comments (#), directory patterns (`dir/`), path-anchored patterns (`a/b`), and
209
+ // `*` / `**` / `?` globs. Returns a matcher; matches nothing when there are no rules.
210
+ export function makePublishIgnore(rawPatterns) {
211
+ const rules = [];
212
+ for (let p of rawPatterns || []) {
213
+ if (typeof p !== 'string') continue;
214
+ p = p.trim();
215
+ if (!p || p.startsWith('#')) continue;
216
+ const dirOnly = p.endsWith('/');
217
+ if (dirOnly) p = p.slice(0, -1);
218
+ const anchored = p.startsWith('/');
219
+ if (anchored) p = p.replace(/^\/+/, '');
220
+ if (!p) continue;
221
+ const pathScoped = anchored || p.includes('/');
222
+ const body = p
223
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
224
+ .replace(/\*\*/g, '')
225
+ .replace(/\*/g, '[^/]*')
226
+ .replace(//g, '.*')
227
+ .replace(/\?/g, '[^/]');
228
+ rules.push({ dirOnly, pathScoped, exact: new RegExp('^' + body + '$'), prefix: new RegExp('^' + body + '/') });
229
+ }
230
+ if (!rules.length) return () => false;
231
+ return (rel) => {
232
+ for (const r of rules) {
233
+ if (r.pathScoped) {
234
+ if (!r.dirOnly && r.exact.test(rel)) return true; // exact file/path
235
+ if (r.prefix.test(rel)) return true; // anything under the dir/prefix
236
+ } else {
237
+ // Unanchored, no slash: match any path segment (a dir name, or a basename).
238
+ const segs = rel.split('/');
239
+ for (let i = 0; i < segs.length; i++) {
240
+ if (r.dirOnly && i === segs.length - 1) continue; // a `dir/` rule can't match the file itself
241
+ if (r.exact.test(segs[i])) return true;
242
+ }
243
+ }
244
+ }
245
+ return false;
246
+ };
247
+ }
248
+
249
+ export function loadPublishIgnore(root) {
250
+ const patterns = [];
251
+ const ignoreFile = join(root, '.manifestignore');
252
+ if (existsSync(ignoreFile)) {
253
+ try { patterns.push(...readFileSync(ignoreFile, 'utf8').split(/\r?\n/)); } catch { /* ignore */ }
254
+ }
255
+ try {
256
+ const mf = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
257
+ if (Array.isArray(mf.publishIgnore)) patterns.push(...mf.publishIgnore);
258
+ } catch { /* no / invalid manifest.json — nothing to add */ }
259
+ return makePublishIgnore(patterns);
260
+ }
261
+
205
262
  export function collectFiles(root) {
206
263
  const git = spawnSync('git', ['ls-files', '-co', '--exclude-standard'], { cwd: root, encoding: 'utf8' });
207
264
  let rels;
@@ -221,8 +278,49 @@ export function collectFiles(root) {
221
278
  };
222
279
  walk(root);
223
280
  }
224
- // Final guarddrops nested secret files the git/walk lists may include.
225
- return rels.filter((r) => !isExcludedPath(r));
281
+ // Final guardsdrop nested secret files, then project-declared publish exclusions.
282
+ // .manifestignore applies ON TOP of gitignore, so a versioned file can still be
283
+ // kept out of the published bundle.
284
+ const publishIgnored = loadPublishIgnore(root);
285
+ return rels.filter((r) => !isExcludedPath(r) && !publishIgnored(r));
286
+ }
287
+
288
+ // --- Component version stamp ------------------------------------------------
289
+
290
+ // Stamp each shipped manifest.json (project root, and the prerender output copy)
291
+ // with `deployment`: a content hash of its component HTML files. The components
292
+ // plugin appends it as ?v= to component fetches, so browser caches bust exactly
293
+ // when component markup changes and persist when it doesn't. Purely publish-time:
294
+ // the on-disk manifest.json is never modified.
295
+ export function stampManifests(root, rels) {
296
+ const overrides = new Map();
297
+ const relSet = new Set(rels);
298
+ const outDir = prerenderOutputDir(root);
299
+ for (const mfRel of ['manifest.json', outDir + '/manifest.json']) {
300
+ if (!relSet.has(mfRel)) continue;
301
+ let mf;
302
+ try {
303
+ mf = JSON.parse(readFileSync(join(root, mfRel), 'utf8'));
304
+ } catch {
305
+ continue; // invalid JSON — ship as-is
306
+ }
307
+ const dir = mfRel.includes('/') ? mfRel.slice(0, mfRel.lastIndexOf('/') + 1) : '';
308
+ const paths = [...(mf.preloadedComponents || []), ...(mf.components || [])]
309
+ .filter((p) => typeof p === 'string' && !p.startsWith('http'))
310
+ .map((p) => dir + p.replace(/^\/+/, ''))
311
+ .filter((p) => relSet.has(p))
312
+ .sort();
313
+ if (!paths.length) continue;
314
+ const hash = createHash('sha256');
315
+ for (const p of paths) {
316
+ hash.update(p + '\0');
317
+ hash.update(readFileSync(join(root, p)));
318
+ hash.update('\0');
319
+ }
320
+ mf.deployment = hash.digest('hex').slice(0, 12);
321
+ overrides.set(mfRel, Buffer.from(JSON.stringify(mf, null, 2) + '\n', 'utf8'));
322
+ }
323
+ return overrides;
226
324
  }
227
325
 
228
326
  // --- Minimal ZIP writer (DEFLATE), pure Node, no deps ----------------------
@@ -236,7 +334,7 @@ function crc32(buf) {
236
334
  return (~c) >>> 0;
237
335
  }
238
336
 
239
- function buildZip(root, rels) {
337
+ function buildZip(root, rels, overrides = new Map()) {
240
338
  // This packer writes classic (non-Zip64) ZIP records: file count is a uint16
241
339
  // and offsets are uint32. Fail loudly rather than emit a silently-corrupt
242
340
  // archive past those limits.
@@ -250,7 +348,7 @@ function buildZip(root, rels) {
250
348
  if (offset > 0xffffffff) {
251
349
  fail('project is too large to package (>4 GB). Contact support.');
252
350
  }
253
- const data = readFileSync(join(root, rel));
351
+ const data = overrides.get(rel) ?? readFileSync(join(root, rel));
254
352
  const nameBuf = Buffer.from(rel, 'utf8');
255
353
  const crc = crc32(data);
256
354
  const deflated = deflateRawSync(data);
@@ -311,19 +409,33 @@ export async function main() {
311
409
  }
312
410
 
313
411
  const root = opts.root ? opts.root : findRoot(process.cwd());
314
- const key = readApiKey(root, opts.key);
315
- if (!key) fail('no API key found. Expected MANIFEST_API_KEY in .env (this folder doesn’t look like a Manifest project, or it isn’t set up for publishing).');
316
412
  const url = readMcpUrl(root, opts.mcp);
317
413
  const source = detectSource(root, opts.source);
318
414
 
319
- // Promote a previously-staged build straight to production (no upload).
415
+ // A connector-driven publish passes a pre-authorised, one-time upload URL in
416
+ // the environment (minted by the manifest_publish tool for the signed-in
417
+ // user). When present, no API key is needed — the token IS the authorisation,
418
+ // so an invited teammate publishes without ever handling a project secret.
419
+ const injectedUpload = process.env.MNFST_PUBLISH_UPLOAD_URL || null;
420
+ const key = readApiKey(root, opts.key);
421
+
422
+ // Promote a previously-staged build straight to production (no upload). Headless
423
+ // convenience; interactive users promote via the connector's manifest_promote
424
+ // tool (no key). Needs a key.
320
425
  if (opts.promote) {
426
+ if (!key) fail('no API key found for --promote. Set MANIFEST_API_KEY in .env for headless use, or promote from Claude with the Manifest connector (no key needed).');
321
427
  log('Promoting the staged version to production…');
322
428
  const res = await callTool(url, key, 'manifest_promote', {});
323
429
  log('✓ Live: ' + (res.url || res._text || 'production updated'));
324
430
  return;
325
431
  }
326
432
 
433
+ // A normal publish needs either the injected one-time URL (connector) or a
434
+ // stored key (headless/CI). Without either, there's nothing to authorise with.
435
+ if (!injectedUpload && !key) {
436
+ fail('no API key found. Publish from Claude with the Manifest connector (no key needed), or set MANIFEST_API_KEY in .env for headless/CI use.');
437
+ }
438
+
327
439
  if (source === 'render' && opts.render !== false) {
328
440
  log('Rendering the site…');
329
441
  const r = spawnSync('npx', ['mnfst-render'], { cwd: root, stdio: 'inherit', shell: process.platform === 'win32' });
@@ -347,11 +459,18 @@ export async function main() {
347
459
  }
348
460
  }
349
461
 
350
- log(`Preparing ${opts.env} deploy…`);
351
- const handshake = await callTool(url, key, 'manifest_publish', { env: opts.env, source, via_cli: true });
352
- if (handshake.already_pro) { /* not applicable */ }
353
- const uploadUrl = handshake.upload_url;
354
- if (!uploadUrl) fail(handshake._text || 'could not start the publish (no upload URL returned).');
462
+ let uploadUrl;
463
+ if (injectedUpload) {
464
+ // Connector path: the manifest_publish tool already minted the deployment
465
+ // and a one-time upload URL for the signed-in user. No handshake, no key.
466
+ uploadUrl = injectedUpload;
467
+ } else {
468
+ // Headless/CI: authenticate the handshake with the key to get an upload URL.
469
+ log(`Preparing ${opts.env} deploy…`);
470
+ const handshake = await callTool(url, key, 'manifest_publish', { env: opts.env, source, via_cli: true });
471
+ uploadUrl = handshake.upload_url;
472
+ if (!uploadUrl) fail(handshake._text || 'could not start the publish (no upload URL returned).');
473
+ }
355
474
  // The upload carries the whole project. Only POST it to an HTTPS endpoint on
356
475
  // the SAME host as the MCP server — never to an arbitrary URL a tampered
357
476
  // response or misconfigured .mcp.json could inject.
@@ -367,7 +486,7 @@ export async function main() {
367
486
 
368
487
  const rels = collectFiles(root);
369
488
  if (!rels.length) fail('nothing to publish (no files found).');
370
- const zip = buildZip(root, rels);
489
+ const zip = buildZip(root, rels, stampManifests(root, rels));
371
490
  log(`Uploading ${rels.length} files (${(zip.length / 1048576).toFixed(1)} MB)…`);
372
491
 
373
492
  const up = await fetchRetry(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-publish",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "One-command managed publishing for Manifest projects — render, zip, upload, live URL.",
5
5
  "type": "module",
6
6
  "bin": {