mnfst-publish 0.1.7 → 0.1.9
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/bin/mnfst-publish.js +0 -0
- package/manifest.publish.mjs +211 -16
- package/package.json +1 -1
package/bin/mnfst-publish.js
CHANGED
|
File without changes
|
package/manifest.publish.mjs
CHANGED
|
@@ -30,6 +30,7 @@ function parseArgs(argv) {
|
|
|
30
30
|
else if (a === '--production' || a === '--prod') out.env = 'production';
|
|
31
31
|
else if (a === '--env') out.env = argv[++i];
|
|
32
32
|
else if (a === '--source') out.source = argv[++i];
|
|
33
|
+
else if (a === '--output-dir') out.outputDir = argv[++i];
|
|
33
34
|
else if (a === '--no-render') out.render = false;
|
|
34
35
|
else if (a === '--render') out.render = true;
|
|
35
36
|
else if (a === '--promote') out.promote = true;
|
|
@@ -259,11 +260,29 @@ export function loadPublishIgnore(root) {
|
|
|
259
260
|
return makePublishIgnore(patterns);
|
|
260
261
|
}
|
|
261
262
|
|
|
262
|
-
export function collectFiles(root) {
|
|
263
|
+
export function collectFiles(root, outputDir) {
|
|
263
264
|
const git = spawnSync('git', ['ls-files', '-co', '--exclude-standard'], { cwd: root, encoding: 'utf8' });
|
|
264
265
|
let rels;
|
|
265
266
|
if (git.status === 0) {
|
|
266
267
|
rels = git.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
268
|
+
// Non-Manifest builds usually write to a GITIGNORED folder (dist/, build/…)
|
|
269
|
+
// — force-include the declared output dir or the publish ships no site.
|
|
270
|
+
if (outputDir && existsSync(join(root, outputDir))) {
|
|
271
|
+
const seen = new Set(rels);
|
|
272
|
+
const walkOut = (dir) => {
|
|
273
|
+
for (const name of readdirSync(dir)) {
|
|
274
|
+
if (EXCLUDED_DIRS.has(name)) continue;
|
|
275
|
+
const abs = join(dir, name);
|
|
276
|
+
const st = statSync(abs);
|
|
277
|
+
if (st.isDirectory()) walkOut(abs);
|
|
278
|
+
else {
|
|
279
|
+
const rel = relative(root, abs).split(sep).join('/');
|
|
280
|
+
if (!seen.has(rel)) { seen.add(rel); rels.push(rel); }
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
walkOut(join(root, outputDir));
|
|
285
|
+
}
|
|
267
286
|
} else {
|
|
268
287
|
// Not a git repo — walk, skipping excluded dirs as we go.
|
|
269
288
|
rels = [];
|
|
@@ -285,6 +304,188 @@ export function collectFiles(root) {
|
|
|
285
304
|
return rels.filter((r) => !isExcludedPath(r) && !publishIgnored(r));
|
|
286
305
|
}
|
|
287
306
|
|
|
307
|
+
// --- Upload + publish status ------------------------------------------------
|
|
308
|
+
|
|
309
|
+
// We ask the upload endpoint (via ASYNC_HEADER) to acknowledge with 202
|
|
310
|
+
// `status:"ingesting"` as soon as the bundle lands and finish the writes in the
|
|
311
|
+
// background, rather than holding the connection — that hold is what used to
|
|
312
|
+
// time out on a distant link. The outcome then arrives on <upload-url>/status.
|
|
313
|
+
// A server that predates this ignores the header and answers synchronously with
|
|
314
|
+
// {ok:true}; both are handled below.
|
|
315
|
+
const POLL_FIRST_MS = 1000;
|
|
316
|
+
const POLL_MAX_MS = 5000;
|
|
317
|
+
const POLL_TOTAL_MS = 10 * 60 * 1000;
|
|
318
|
+
// A status read that stays "pending" this long means the bundle never actually
|
|
319
|
+
// arrived. The grace matters because the server's token record is eventually
|
|
320
|
+
// consistent, so a brief stale "pending" right after an upload is normal.
|
|
321
|
+
const PENDING_GRACE_MS = 15_000;
|
|
322
|
+
const PROGRESS_EVERY_MS = 15_000;
|
|
323
|
+
|
|
324
|
+
// Tells the server we know how to poll, so it can hand back a 202 instead of
|
|
325
|
+
// holding the connection. A server that doesn't know the header just ignores it
|
|
326
|
+
// and answers synchronously — which is exactly the fallback handled below, so
|
|
327
|
+
// CLI and server can ship in either order.
|
|
328
|
+
const ASYNC_HEADER = 'x-mnfst-async';
|
|
329
|
+
|
|
330
|
+
// Same origin, same one-time token. Derived from the upload URL we already
|
|
331
|
+
// origin-checked — never from a URL the server hands back.
|
|
332
|
+
export function statusUrlFor(uploadUrl) {
|
|
333
|
+
return uploadUrl.replace(/\/+$/, '') + '/status';
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Poll <upload-url>/status until the publish settles. Resolves `{state, body}`:
|
|
338
|
+
* done | error the publish finished (body is the server's payload)
|
|
339
|
+
* unknown server says the token is gone (expired / never existed)
|
|
340
|
+
* unsupported no status route at all — server predates it
|
|
341
|
+
* pending stayed "pending" past the grace: the bundle never landed
|
|
342
|
+
* timeout still running when the overall cap ran out
|
|
343
|
+
*/
|
|
344
|
+
export async function pollPublishStatus(statusUrl, opts = {}) {
|
|
345
|
+
const {
|
|
346
|
+
fetchImpl = fetch,
|
|
347
|
+
sleepImpl = sleep,
|
|
348
|
+
log: logFn = log,
|
|
349
|
+
now = Date.now,
|
|
350
|
+
totalMs = POLL_TOTAL_MS,
|
|
351
|
+
pendingGraceMs = Infinity,
|
|
352
|
+
} = opts;
|
|
353
|
+
const started = now();
|
|
354
|
+
let delay = POLL_FIRST_MS;
|
|
355
|
+
let lastProgress = started;
|
|
356
|
+
|
|
357
|
+
for (;;) {
|
|
358
|
+
let status = 0;
|
|
359
|
+
let json = null;
|
|
360
|
+
try {
|
|
361
|
+
const res = await fetchImpl(statusUrl, { method: 'GET', headers: { accept: 'application/json' } });
|
|
362
|
+
status = res.status;
|
|
363
|
+
const text = await res.text();
|
|
364
|
+
try {
|
|
365
|
+
json = text ? JSON.parse(text) : null;
|
|
366
|
+
} catch {
|
|
367
|
+
/* not JSON — treated as an untagged response below */
|
|
368
|
+
}
|
|
369
|
+
} catch {
|
|
370
|
+
/* blip while polling — fall through and try again */
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (status === 404) {
|
|
374
|
+
// Tagged → this server knows the route and says the token is gone.
|
|
375
|
+
// Untagged (Hono's plain-text 404) → the server has no status route.
|
|
376
|
+
return json && json.status === 'unknown' ? { state: 'unknown', body: json } : { state: 'unsupported' };
|
|
377
|
+
}
|
|
378
|
+
if (json && json.status === 'done') return { state: 'done', body: json };
|
|
379
|
+
if (json && json.status === 'error') return { state: 'error', body: json };
|
|
380
|
+
if (json && json.status === 'pending' && now() - started >= pendingGraceMs) {
|
|
381
|
+
return { state: 'pending', body: json };
|
|
382
|
+
}
|
|
383
|
+
// Anything else (ingesting, pending inside the grace, 429, 5xx, a blip) —
|
|
384
|
+
// keep waiting.
|
|
385
|
+
|
|
386
|
+
const elapsed = now() - started;
|
|
387
|
+
if (elapsed >= totalMs) return { state: 'timeout' };
|
|
388
|
+
if (elapsed - (lastProgress - started) >= PROGRESS_EVERY_MS) {
|
|
389
|
+
lastProgress = now();
|
|
390
|
+
logFn(` still publishing… (${Math.round(elapsed / 1000)}s)`);
|
|
391
|
+
}
|
|
392
|
+
await sleepImpl(delay);
|
|
393
|
+
delay = Math.min(delay * 2, POLL_MAX_MS);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Turn a settled poll result into the success payload, or throw a plain-language error. */
|
|
398
|
+
function settledPayload(res) {
|
|
399
|
+
if (res.state === 'done') return res.body;
|
|
400
|
+
if (res.state === 'error') {
|
|
401
|
+
const b = res.body || {};
|
|
402
|
+
throw new Error(`publish failed${b.error ? ` (${b.error})` : ''}: ${b.message || 'the server rejected the upload.'}`);
|
|
403
|
+
}
|
|
404
|
+
if (res.state === 'unknown') {
|
|
405
|
+
throw new Error('this publish link expired before the upload finished. Run the publish again.');
|
|
406
|
+
}
|
|
407
|
+
if (res.state === 'timeout') {
|
|
408
|
+
throw new Error(
|
|
409
|
+
'the publish is still running after 10 minutes. It may yet finish — check the deployment in Claude ' +
|
|
410
|
+
'with the Manifest connector before publishing again.',
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
throw new Error("couldn't determine whether the publish finished. Check the deployment before publishing again.");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* POST the bundle and return the final success payload, whichever protocol the
|
|
418
|
+
* server speaks — synchronous {ok:true} (older servers) or 202 + status polling.
|
|
419
|
+
* Throws on a real failure.
|
|
420
|
+
*/
|
|
421
|
+
export async function uploadBundle(uploadUrl, zip, opts = {}) {
|
|
422
|
+
const { fetchImpl = fetch, sleepImpl = sleep, log: logFn = log, tries = 4, ...pollOpts } = opts;
|
|
423
|
+
const statusUrl = statusUrlFor(uploadUrl);
|
|
424
|
+
const poll = (extra) =>
|
|
425
|
+
pollPublishStatus(statusUrl, { fetchImpl, sleepImpl, log: logFn, ...pollOpts, ...extra });
|
|
426
|
+
|
|
427
|
+
for (let attempt = 1; attempt <= tries; attempt++) {
|
|
428
|
+
let res = null;
|
|
429
|
+
try {
|
|
430
|
+
res = await fetchImpl(uploadUrl, {
|
|
431
|
+
method: 'POST',
|
|
432
|
+
headers: { 'content-type': 'application/zip', [ASYNC_HEADER]: '1' },
|
|
433
|
+
body: zip,
|
|
434
|
+
});
|
|
435
|
+
} catch {
|
|
436
|
+
// The connection died — but the bundle may well have landed and be
|
|
437
|
+
// ingesting right now. Re-uploading blind re-sends the whole archive (and
|
|
438
|
+
// would only 409 anyway), so ask the status endpoint what happened.
|
|
439
|
+
logFn(' lost the connection during upload — checking whether it landed…');
|
|
440
|
+
const s = await poll({ pendingGraceMs: PENDING_GRACE_MS });
|
|
441
|
+
// It never arrived, or this server has no status route — either way,
|
|
442
|
+
// fall back to retrying the upload exactly as before.
|
|
443
|
+
if (s.state === 'pending' || s.state === 'unsupported') {
|
|
444
|
+
if (attempt < tries) {
|
|
445
|
+
logFn(` couldn't reach the upload server — retrying (${attempt}/${tries - 1})…`);
|
|
446
|
+
await sleepImpl(500 * attempt);
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
throw new Error('could not reach the upload server.');
|
|
450
|
+
}
|
|
451
|
+
return settledPayload(s);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const text = await res.text();
|
|
455
|
+
let json = null;
|
|
456
|
+
try {
|
|
457
|
+
json = text ? JSON.parse(text) : null;
|
|
458
|
+
} catch {
|
|
459
|
+
/* leave null */
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Synchronous server: the upload response IS the success payload.
|
|
463
|
+
if (res.ok && json && json.ok === true) return json;
|
|
464
|
+
|
|
465
|
+
// Async server: acknowledged, ingest running in the background.
|
|
466
|
+
if (res.status === 202 && json && json.status === 'ingesting') {
|
|
467
|
+
logFn(' upload received — finishing the publish on the server…');
|
|
468
|
+
return settledPayload(await poll({}));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// A retry met an ingest already in flight (ours, from an attempt whose
|
|
472
|
+
// connection died). Wait it out rather than re-uploading.
|
|
473
|
+
if (res.status === 409 && json && json.error === 'ingest_in_progress') {
|
|
474
|
+
logFn(' an earlier attempt is still publishing — waiting for it to finish…');
|
|
475
|
+
return settledPayload(await poll({}));
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (res.status >= 500 && attempt < tries) {
|
|
479
|
+
logFn(` the upload server had a hiccup (HTTP ${res.status}) — retrying (${attempt}/${tries - 1})…`);
|
|
480
|
+
await sleepImpl(500 * attempt);
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
throw new Error(`upload failed (HTTP ${res.status}): ${text.slice(0, 300)}`);
|
|
485
|
+
}
|
|
486
|
+
throw new Error('could not reach the upload server.');
|
|
487
|
+
}
|
|
488
|
+
|
|
288
489
|
// --- Component version stamp ------------------------------------------------
|
|
289
490
|
|
|
290
491
|
// Stamp each shipped manifest.json (project root, and the prerender output copy)
|
|
@@ -403,7 +604,7 @@ function buildZip(root, rels, overrides = new Map()) {
|
|
|
403
604
|
export async function main() {
|
|
404
605
|
const opts = parseArgs(process.argv.slice(2));
|
|
405
606
|
if (opts.help) {
|
|
406
|
-
log('Usage: npx mnfst-publish [--staging|--production] [--no-render] [--promote]');
|
|
607
|
+
log('Usage: npx mnfst-publish [--staging|--production] [--source spa|render] [--output-dir <dir>] [--no-render] [--promote]');
|
|
407
608
|
log('Publishes the current Manifest project to managed hosting and prints the live URL.');
|
|
408
609
|
return;
|
|
409
610
|
}
|
|
@@ -484,25 +685,19 @@ export async function main() {
|
|
|
484
685
|
fail('the upload URL returned by the server was malformed.');
|
|
485
686
|
}
|
|
486
687
|
|
|
487
|
-
const rels = collectFiles(root);
|
|
688
|
+
const rels = collectFiles(root, opts.outputDir);
|
|
488
689
|
if (!rels.length) fail('nothing to publish (no files found).');
|
|
690
|
+
if (opts.outputDir && !existsSync(join(root, opts.outputDir))) {
|
|
691
|
+
fail(`the output folder "${opts.outputDir}" doesn't exist — run the project's build first, then publish again.`);
|
|
692
|
+
}
|
|
489
693
|
const zip = buildZip(root, rels, stampManifests(root, rels));
|
|
490
694
|
log(`Uploading ${rels.length} files (${(zip.length / 1048576).toFixed(1)} MB)…`);
|
|
491
695
|
|
|
492
|
-
|
|
493
|
-
uploadUrl,
|
|
494
|
-
{ method: 'POST', headers: { 'content-type': 'application/zip' }, body: zip },
|
|
495
|
-
{ label: 'the upload server' },
|
|
496
|
-
);
|
|
497
|
-
const upText = await up.text();
|
|
498
|
-
let upJson = null;
|
|
696
|
+
let upJson;
|
|
499
697
|
try {
|
|
500
|
-
upJson =
|
|
501
|
-
} catch {
|
|
502
|
-
|
|
503
|
-
}
|
|
504
|
-
if (!up.ok || !upJson || upJson.ok !== true) {
|
|
505
|
-
fail(`upload failed (HTTP ${up.status}): ${upText.slice(0, 300)}`);
|
|
698
|
+
upJson = await uploadBundle(uploadUrl, zip);
|
|
699
|
+
} catch (e) {
|
|
700
|
+
fail(e.message);
|
|
506
701
|
}
|
|
507
702
|
|
|
508
703
|
log('');
|