@freecodecamp/universe-cli 0.5.1 → 0.6.0

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/dist/index.js CHANGED
@@ -440,6 +440,13 @@ var ProxyError = class extends CliError {
440
440
  this.exitCode = mapExitCode(status);
441
441
  }
442
442
  };
443
+ var AliasDriftError = class extends ProxyError {
444
+ current;
445
+ constructor(message, current) {
446
+ super(409, "alias_drift", message);
447
+ this.current = current;
448
+ }
449
+ };
443
450
  function mapExitCode(status) {
444
451
  if (status === 401 || status === 403) return EXIT_CREDENTIALS;
445
452
  if (status === 422 || status === 0 || status >= 500) return EXIT_STORAGE;
@@ -475,9 +482,11 @@ async function readErrorEnvelope(response) {
475
482
  };
476
483
  }
477
484
  if (isProxyErrorEnvelope(raw) && raw.error) {
485
+ const current = typeof raw.current === "string" ? raw.current : void 0;
478
486
  return {
479
487
  code: raw.error.code ?? `http_${status}`,
480
- message: raw.error.message ?? response.statusText ?? "request failed"
488
+ message: raw.error.message ?? response.statusText ?? "request failed",
489
+ ...current === void 0 ? {} : { current }
481
490
  };
482
491
  }
483
492
  return {
@@ -485,6 +494,12 @@ async function readErrorEnvelope(response) {
485
494
  message: response.statusText || "request failed"
486
495
  };
487
496
  }
497
+ function throwProxyError(status, env) {
498
+ if (status === 409 && env.code === "alias_drift") {
499
+ throw new AliasDriftError(env.message, env.current ?? "");
500
+ }
501
+ throw new ProxyError(status, env.code, env.message);
502
+ }
488
503
  function stripTrailingSlash(url) {
489
504
  return url.endsWith("/") ? url.slice(0, -1) : url;
490
505
  }
@@ -505,7 +520,7 @@ function createProxyClient(cfg) {
505
520
  }
506
521
  if (!response.ok) {
507
522
  const env = await readErrorEnvelope(response);
508
- throw new ProxyError(response.status, env.code, env.message);
523
+ throwProxyError(response.status, env);
509
524
  }
510
525
  if (response.status === 204) {
511
526
  return void 0;
@@ -569,18 +584,55 @@ function createProxyClient(cfg) {
569
584
  }
570
585
  });
571
586
  },
587
+ async getAlias(req) {
588
+ const url = `${base}/api/site/${encodeURIComponent(req.site)}/alias/${encodeURIComponent(req.mode)}`;
589
+ let response;
590
+ try {
591
+ response = await fetchImpl(url, {
592
+ method: "GET",
593
+ headers: {
594
+ Authorization: await userBearer(),
595
+ Accept: "application/json"
596
+ }
597
+ });
598
+ } catch (err) {
599
+ const message = err instanceof Error ? err.message : String(err);
600
+ throw new ProxyError(
601
+ 0,
602
+ "network_error",
603
+ `proxy unreachable: ${message}`
604
+ );
605
+ }
606
+ if (response.status === 404) return null;
607
+ if (!response.ok) {
608
+ const env = await readErrorEnvelope(response);
609
+ throwProxyError(response.status, env);
610
+ }
611
+ return await response.json();
612
+ },
572
613
  async sitePromote(req) {
573
614
  const url = `${base}/api/site/${encodeURIComponent(req.site)}/promote`;
615
+ const headers = {
616
+ Authorization: await userBearer(),
617
+ Accept: "application/json"
618
+ };
619
+ const body = {};
620
+ if (req.deployId !== void 0) body.deployId = req.deployId;
621
+ if (req.expectedCurrent !== void 0)
622
+ body.expectedCurrent = req.expectedCurrent;
623
+ const hasBody = Object.keys(body).length > 0;
624
+ if (hasBody) headers["Content-Type"] = "application/json";
574
625
  return call(url, {
575
626
  method: "POST",
576
- headers: {
577
- Authorization: await userBearer(),
578
- Accept: "application/json"
579
- }
627
+ headers,
628
+ ...hasBody ? { body: JSON.stringify(body) } : {}
580
629
  });
581
630
  },
582
631
  async siteRollback(req) {
583
632
  const url = `${base}/api/site/${encodeURIComponent(req.site)}/rollback`;
633
+ const body = { to: req.to };
634
+ if (req.expectedCurrent !== void 0)
635
+ body.expectedCurrent = req.expectedCurrent;
584
636
  return call(url, {
585
637
  method: "POST",
586
638
  headers: {
@@ -588,7 +640,7 @@ function createProxyClient(cfg) {
588
640
  Accept: "application/json",
589
641
  "Content-Type": "application/json"
590
642
  },
591
- body: JSON.stringify({ to: req.to })
643
+ body: JSON.stringify(body)
592
644
  });
593
645
  },
594
646
  async registerSite(req) {
@@ -1225,7 +1277,7 @@ var defaultReadPlatformYaml2 = async (cwd) => {
1225
1277
  function emitJson4(envelope) {
1226
1278
  process.stdout.write(JSON.stringify(envelope) + "\n");
1227
1279
  }
1228
- var DEPLOY_ID_RE = /^(\d{8})-(\d{6})-([a-f0-9]+)$/i;
1280
+ var DEPLOY_ID_RE = /^(\d{8})-(\d{6})-(\S+)$/;
1229
1281
  function parseDeployId(deployId) {
1230
1282
  const m = DEPLOY_ID_RE.exec(deployId);
1231
1283
  if (!m) return { deployId, timestamp: null, sha: null };
@@ -1327,7 +1379,12 @@ async function ls(options, deps = {}) {
1327
1379
  // src/commands/promote.ts
1328
1380
  import { readFile as readFile4 } from "fs/promises";
1329
1381
  import { resolve as resolve4 } from "path";
1330
- import { log as log5 } from "@clack/prompts";
1382
+ import { confirm, isCancel, log as log5 } from "@clack/prompts";
1383
+ var defaultPromptConfirm = async (msg) => {
1384
+ const r = await confirm({ message: msg, initialValue: false });
1385
+ if (isCancel(r)) return false;
1386
+ return r === true;
1387
+ };
1331
1388
  var defaultReadPlatformYaml3 = async (cwd) => {
1332
1389
  return readFile4(resolve4(cwd, "platform.yaml"), "utf-8");
1333
1390
  };
@@ -1359,6 +1416,7 @@ async function promote(options, deps = {}) {
1359
1416
  const success = deps.logSuccess ?? ((s) => log5.success(s));
1360
1417
  const error = deps.logError ?? ((s) => log5.error(s));
1361
1418
  const exit = deps.exit ?? exitWithCode;
1419
+ const promptConfirm = deps.promptConfirm ?? defaultPromptConfirm;
1362
1420
  try {
1363
1421
  const identity = await resolveId({ env });
1364
1422
  if (!identity) {
@@ -1374,12 +1432,75 @@ async function promote(options, deps = {}) {
1374
1432
  });
1375
1433
  let result;
1376
1434
  if (options.from) {
1377
- result = await client.siteRollback({
1435
+ const prod = await client.getAlias({
1378
1436
  site: config.site,
1379
- to: options.from
1437
+ mode: "production"
1380
1438
  });
1439
+ const initialExpected = prod?.deployId ?? "";
1440
+ try {
1441
+ result = await client.siteRollback({
1442
+ site: config.site,
1443
+ to: options.from,
1444
+ expectedCurrent: initialExpected
1445
+ });
1446
+ } catch (err) {
1447
+ if (!(err instanceof AliasDriftError)) throw err;
1448
+ if (options.json) throw err;
1449
+ error(
1450
+ `drift: production moved to ${err.current}, expected ${initialExpected}`
1451
+ );
1452
+ const ok = await promptConfirm(
1453
+ `Retry promote --from with expectedCurrent='${err.current}'?`
1454
+ );
1455
+ if (!ok) throw err;
1456
+ result = await client.siteRollback({
1457
+ site: config.site,
1458
+ to: options.from,
1459
+ expectedCurrent: err.current
1460
+ });
1461
+ }
1381
1462
  } else {
1382
- result = await client.sitePromote({ site: config.site });
1463
+ const preview = await client.getAlias({
1464
+ site: config.site,
1465
+ mode: "preview"
1466
+ });
1467
+ if (preview === null) {
1468
+ throw new ConfigError(
1469
+ "no preview alias to promote \u2014 run `universe static deploy` first"
1470
+ );
1471
+ }
1472
+ const prod = await client.getAlias({
1473
+ site: config.site,
1474
+ mode: "production"
1475
+ });
1476
+ if (!options.json) {
1477
+ success(
1478
+ `Promoting ${preview.deployId} \u2192 ${prod?.deployId ?? "<none>"}`
1479
+ );
1480
+ }
1481
+ const initialExpected = prod?.deployId ?? "";
1482
+ try {
1483
+ result = await client.sitePromote({
1484
+ site: config.site,
1485
+ deployId: preview.deployId,
1486
+ expectedCurrent: initialExpected
1487
+ });
1488
+ } catch (err) {
1489
+ if (!(err instanceof AliasDriftError)) throw err;
1490
+ if (options.json) throw err;
1491
+ error(
1492
+ `drift: production moved to ${err.current}, expected ${initialExpected}`
1493
+ );
1494
+ const ok = await promptConfirm(
1495
+ `Retry promote with expectedCurrent='${err.current}'?`
1496
+ );
1497
+ if (!ok) throw err;
1498
+ result = await client.sitePromote({
1499
+ site: config.site,
1500
+ deployId: preview.deployId,
1501
+ expectedCurrent: err.current
1502
+ });
1503
+ }
1383
1504
  }
1384
1505
  if (options.json) {
1385
1506
  emitJson5(
@@ -1406,7 +1527,12 @@ async function promote(options, deps = {}) {
1406
1527
  } catch (err) {
1407
1528
  const { code, message } = wrapProxyError("promote", err);
1408
1529
  if (options.json) {
1409
- emitJson5(buildErrorEnvelope("promote", code, message));
1530
+ const envelope = buildErrorEnvelope("promote", code, message);
1531
+ if (err instanceof AliasDriftError) {
1532
+ emitJson5({ ...envelope, current: err.current });
1533
+ } else {
1534
+ emitJson5(envelope);
1535
+ }
1410
1536
  } else {
1411
1537
  error(message);
1412
1538
  }
@@ -1417,7 +1543,12 @@ async function promote(options, deps = {}) {
1417
1543
  // src/commands/rollback.ts
1418
1544
  import { readFile as readFile5 } from "fs/promises";
1419
1545
  import { resolve as resolve5 } from "path";
1420
- import { log as log6 } from "@clack/prompts";
1546
+ import { confirm as confirm2, isCancel as isCancel2, log as log6 } from "@clack/prompts";
1547
+ var defaultPromptConfirm2 = async (msg) => {
1548
+ const r = await confirm2({ message: msg, initialValue: false });
1549
+ if (isCancel2(r)) return false;
1550
+ return r === true;
1551
+ };
1421
1552
  var defaultReadPlatformYaml4 = async (cwd) => {
1422
1553
  return readFile5(resolve5(cwd, "platform.yaml"), "utf-8");
1423
1554
  };
@@ -1449,6 +1580,7 @@ async function rollback(options, deps = {}) {
1449
1580
  const success = deps.logSuccess ?? ((s) => log6.success(s));
1450
1581
  const error = deps.logError ?? ((s) => log6.error(s));
1451
1582
  const exit = deps.exit ?? exitWithCode;
1583
+ const promptConfirm = deps.promptConfirm ?? defaultPromptConfirm2;
1452
1584
  try {
1453
1585
  if (!options.to || options.to.trim().length === 0) {
1454
1586
  throw new UsageError(
@@ -1467,10 +1599,35 @@ async function rollback(options, deps = {}) {
1467
1599
  baseUrl,
1468
1600
  getAuthToken: () => identity.token
1469
1601
  });
1470
- const result = await client.siteRollback({
1602
+ const to = options.to.trim();
1603
+ const prod = await client.getAlias({
1471
1604
  site: config.site,
1472
- to: options.to.trim()
1605
+ mode: "production"
1473
1606
  });
1607
+ const initialExpected = prod?.deployId ?? "";
1608
+ let result;
1609
+ try {
1610
+ result = await client.siteRollback({
1611
+ site: config.site,
1612
+ to,
1613
+ expectedCurrent: initialExpected
1614
+ });
1615
+ } catch (err) {
1616
+ if (!(err instanceof AliasDriftError)) throw err;
1617
+ if (options.json) throw err;
1618
+ error(
1619
+ `drift: production moved to ${err.current}, expected ${initialExpected}`
1620
+ );
1621
+ const ok = await promptConfirm(
1622
+ `Retry rollback with expectedCurrent='${err.current}'?`
1623
+ );
1624
+ if (!ok) throw err;
1625
+ result = await client.siteRollback({
1626
+ site: config.site,
1627
+ to,
1628
+ expectedCurrent: err.current
1629
+ });
1630
+ }
1474
1631
  if (options.json) {
1475
1632
  emitJson6(
1476
1633
  buildEnvelope("rollback", true, {
@@ -1494,7 +1651,12 @@ async function rollback(options, deps = {}) {
1494
1651
  } catch (err) {
1495
1652
  const { code, message } = wrapProxyError("rollback", err);
1496
1653
  if (options.json) {
1497
- emitJson6(buildErrorEnvelope("rollback", code, message));
1654
+ const envelope = buildErrorEnvelope("rollback", code, message);
1655
+ if (err instanceof AliasDriftError) {
1656
+ emitJson6({ ...envelope, current: err.current });
1657
+ } else {
1658
+ emitJson6(envelope);
1659
+ }
1498
1660
  } else {
1499
1661
  error(message);
1500
1662
  }
@@ -1854,7 +2016,7 @@ function outputError(ctx, code, message, issues) {
1854
2016
  }
1855
2017
 
1856
2018
  // src/cli.ts
1857
- var version = true ? "0.5.1" : "0.0.0";
2019
+ var version = true ? "0.6.0" : "0.0.0";
1858
2020
  function handleActionError(command, json, err) {
1859
2021
  const ctx = { json, command };
1860
2022
  const message = err instanceof Error ? err.message : "unknown error";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@freecodecamp/universe-cli",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "main": "dist/index.js",
5
5
  "scripts": {
6
6
  "test": "vitest run",