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.
- package/manifest.publish.mjs +132 -13
- package/package.json +1 -1
package/manifest.publish.mjs
CHANGED
|
@@ -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
|
|
225
|
-
|
|
281
|
+
// Final guards — drop 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
|
-
//
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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(
|