@freecodecamp/universe-cli 0.5.0 → 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) {
@@ -920,7 +972,7 @@ async function deploy(options, deps = {}) {
920
972
  );
921
973
  }
922
974
  const git = gitState();
923
- if (git.dirty) {
975
+ if (git.dirty && !options.json) {
924
976
  warn(
925
977
  "git working tree is dirty \u2014 uncommitted changes will not be reflected."
926
978
  );
@@ -932,7 +984,7 @@ async function deploy(options, deps = {}) {
932
984
  cwd,
933
985
  outputDir
934
986
  });
935
- if (buildResult.skipped) {
987
+ if (buildResult.skipped && !options.json) {
936
988
  info("build.command not set \u2014 using pre-built output.");
937
989
  }
938
990
  const resolvedOutputDir = buildResult.outputDir;
@@ -991,7 +1043,7 @@ async function deploy(options, deps = {}) {
991
1043
  );
992
1044
  } else {
993
1045
  const sizeKB = (uploadResult.totalSize / 1024).toFixed(1);
994
- const nextLine = mode === "preview" ? "Next: universe static promote" : "Promoted to production.";
1046
+ const nextLine = mode === "preview" ? `Next: universe static promote --from ${finalizeResult.deployId}` : "Promoted to production.\nPreview alias unchanged.";
995
1047
  success(
996
1048
  [
997
1049
  `Deployed ${finalizeResult.deployId}`,
@@ -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 };
@@ -1294,7 +1346,10 @@ async function ls(options, deps = {}) {
1294
1346
  getAuthToken: () => identity.token
1295
1347
  });
1296
1348
  const raw = await client.siteDeploys({ site });
1297
- const deploys = raw.map((d) => parseDeployId(d.deployId));
1349
+ const sorted = [...raw].sort(
1350
+ (a, b) => b.deployId.localeCompare(a.deployId)
1351
+ );
1352
+ const deploys = sorted.map((d) => parseDeployId(d.deployId));
1298
1353
  if (options.json) {
1299
1354
  emitJson4(
1300
1355
  buildEnvelope("ls", true, {
@@ -1324,7 +1379,12 @@ async function ls(options, deps = {}) {
1324
1379
  // src/commands/promote.ts
1325
1380
  import { readFile as readFile4 } from "fs/promises";
1326
1381
  import { resolve as resolve4 } from "path";
1327
- 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
+ };
1328
1388
  var defaultReadPlatformYaml3 = async (cwd) => {
1329
1389
  return readFile4(resolve4(cwd, "platform.yaml"), "utf-8");
1330
1390
  };
@@ -1356,6 +1416,7 @@ async function promote(options, deps = {}) {
1356
1416
  const success = deps.logSuccess ?? ((s) => log5.success(s));
1357
1417
  const error = deps.logError ?? ((s) => log5.error(s));
1358
1418
  const exit = deps.exit ?? exitWithCode;
1419
+ const promptConfirm = deps.promptConfirm ?? defaultPromptConfirm;
1359
1420
  try {
1360
1421
  const identity = await resolveId({ env });
1361
1422
  if (!identity) {
@@ -1371,12 +1432,75 @@ async function promote(options, deps = {}) {
1371
1432
  });
1372
1433
  let result;
1373
1434
  if (options.from) {
1374
- result = await client.siteRollback({
1435
+ const prod = await client.getAlias({
1375
1436
  site: config.site,
1376
- to: options.from
1437
+ mode: "production"
1377
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
+ }
1378
1462
  } else {
1379
- 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
+ }
1380
1504
  }
1381
1505
  if (options.json) {
1382
1506
  emitJson5(
@@ -1388,20 +1512,27 @@ async function promote(options, deps = {}) {
1388
1512
  })
1389
1513
  );
1390
1514
  } else {
1391
- success(
1392
- [
1393
- `Promoted ${result.deployId} to production`,
1394
- ``,
1395
- ` Site: ${config.site}`,
1396
- ` Deploy: ${result.deployId}`,
1397
- ` Production: ${result.url}`
1398
- ].join("\n")
1399
- );
1515
+ const lines = [
1516
+ `Promoted ${result.deployId} to production`,
1517
+ ``,
1518
+ ` Site: ${config.site}`,
1519
+ ` Deploy: ${result.deployId}`,
1520
+ ` Production: ${result.url}`
1521
+ ];
1522
+ if (options.from) {
1523
+ lines.push(``, "Preview alias unchanged.");
1524
+ }
1525
+ success(lines.join("\n"));
1400
1526
  }
1401
1527
  } catch (err) {
1402
1528
  const { code, message } = wrapProxyError("promote", err);
1403
1529
  if (options.json) {
1404
- 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
+ }
1405
1536
  } else {
1406
1537
  error(message);
1407
1538
  }
@@ -1412,7 +1543,12 @@ async function promote(options, deps = {}) {
1412
1543
  // src/commands/rollback.ts
1413
1544
  import { readFile as readFile5 } from "fs/promises";
1414
1545
  import { resolve as resolve5 } from "path";
1415
- 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
+ };
1416
1552
  var defaultReadPlatformYaml4 = async (cwd) => {
1417
1553
  return readFile5(resolve5(cwd, "platform.yaml"), "utf-8");
1418
1554
  };
@@ -1444,6 +1580,7 @@ async function rollback(options, deps = {}) {
1444
1580
  const success = deps.logSuccess ?? ((s) => log6.success(s));
1445
1581
  const error = deps.logError ?? ((s) => log6.error(s));
1446
1582
  const exit = deps.exit ?? exitWithCode;
1583
+ const promptConfirm = deps.promptConfirm ?? defaultPromptConfirm2;
1447
1584
  try {
1448
1585
  if (!options.to || options.to.trim().length === 0) {
1449
1586
  throw new UsageError(
@@ -1462,10 +1599,35 @@ async function rollback(options, deps = {}) {
1462
1599
  baseUrl,
1463
1600
  getAuthToken: () => identity.token
1464
1601
  });
1465
- const result = await client.siteRollback({
1602
+ const to = options.to.trim();
1603
+ const prod = await client.getAlias({
1466
1604
  site: config.site,
1467
- to: options.to.trim()
1605
+ mode: "production"
1468
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
+ }
1469
1631
  if (options.json) {
1470
1632
  emitJson6(
1471
1633
  buildEnvelope("rollback", true, {
@@ -1489,7 +1651,12 @@ async function rollback(options, deps = {}) {
1489
1651
  } catch (err) {
1490
1652
  const { code, message } = wrapProxyError("rollback", err);
1491
1653
  if (options.json) {
1492
- 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
+ }
1493
1660
  } else {
1494
1661
  error(message);
1495
1662
  }
@@ -1849,7 +2016,7 @@ function outputError(ctx, code, message, issues) {
1849
2016
  }
1850
2017
 
1851
2018
  // src/cli.ts
1852
- var version = true ? "0.5.0" : "0.0.0";
2019
+ var version = true ? "0.6.0" : "0.0.0";
1853
2020
  function handleActionError(command, json, err) {
1854
2021
  const ctx = { json, command };
1855
2022
  const message = err instanceof Error ? err.message : "unknown error";
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@freecodecamp/universe-cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "main": "dist/index.js",
5
5
  "scripts": {
6
6
  "test": "vitest run",
7
+ "test:smoke": "UNIVERSE_E2E_REAL=1 vitest run tests/e2e/smoke-real-artemis.test.ts",
7
8
  "build": "tsup",
8
9
  "lint": "oxlint src tests",
9
10
  "typecheck": "tsc --noEmit",