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.
File without changes
@@ -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
- const up = await fetchRetry(
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 = JSON.parse(upText);
501
- } catch {
502
- /* ignore */
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('');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-publish",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "One-command managed publishing for Manifest projects — render, zip, upload, live URL.",
5
5
  "type": "module",
6
6
  "bin": {