@freecodecamp/universe-cli 0.11.0 → 0.12.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.
Files changed (2) hide show
  1. package/dist/index.cjs +357 -105
  2. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -15892,19 +15892,23 @@ const LabelsSchema = record(union([
15892
15892
  literal("service")
15893
15893
  ]), record(string(), string()));
15894
15894
  //#endregion
15895
+ //#region src/commands/create/layer-composition/assets.json
15896
+ var templateVersion = "0.2.0";
15897
+ var templateUrl = "https://github.com/freeCodeCamp-Universe/templates/releases/download/app-templates-v{{version}}/app-templates-{{version}}.tar.gz";
15898
+ //#endregion
15899
+ //#region src/commands/create/layer-composition/assets.ts
15900
+ const defaultTemplateVersion = templateVersion;
15901
+ const resolveTemplateUrl = (version) => templateUrl.replaceAll("{{version}}", version);
15902
+ //#endregion
15895
15903
  //#region src/commands/create/layer-composition/template-cache.ts
15896
- const APP_DIR$2 = "universe-cli";
15897
- const TEMPLATES_DIR = "templates";
15904
+ const APP_DIR$3 = "universe-cli";
15905
+ const TEMPLATES_DIR$1 = "templates";
15898
15906
  const cacheBase = () => {
15899
15907
  const xdg = process.env["XDG_CACHE_HOME"];
15900
15908
  if (xdg && xdg.length > 0) return xdg;
15901
15909
  return (0, node_path.join)((0, node_os.homedir)(), ".cache");
15902
15910
  };
15903
- const versionFromUrl = (url) => {
15904
- const filename = url.split("/").at(-1) ?? url;
15905
- return filename.match(/^templates-(.+)\.tar\.gz$/)?.[1] ?? filename;
15906
- };
15907
- const templateCacheDir = (url) => (0, node_path.join)(cacheBase(), APP_DIR$2, TEMPLATES_DIR, versionFromUrl(url));
15911
+ const templateCacheDir = (version) => (0, node_path.join)(cacheBase(), APP_DIR$3, TEMPLATES_DIR$1, version);
15908
15912
  //#endregion
15909
15913
  //#region src/commands/create/layer-composition/template-provider.ts
15910
15914
  const execFileAsync = (0, node_util.promisify)(node_child_process.execFile);
@@ -15919,7 +15923,7 @@ const EXPECTED_LAYER_FILES = [
15919
15923
  const EXPECTED_ROOT_ENTRIES = ["labels.json", "layers"];
15920
15924
  const readEnv = () => ({
15921
15925
  UNIVERSE_TEMPLATES_DIR: process.env["UNIVERSE_TEMPLATES_DIR"],
15922
- UNIVERSE_TEMPLATES_URL: process.env["UNIVERSE_TEMPLATES_URL"]
15926
+ UNIVERSE_TEMPLATES_VERSION: process.env["UNIVERSE_TEMPLATES_VERSION"]
15923
15927
  });
15924
15928
  const validateEntries = (dir, expected, actual) => {
15925
15929
  const expectedSet = new Set(expected);
@@ -15996,7 +16000,7 @@ const fetchTarball = async (url, fetchImpl) => {
15996
16000
  } catch {
15997
16001
  throw new ConfigError("Templates not cached. Run `universe templates fetch` or check network.");
15998
16002
  }
15999
- if (response.status === 404) throw new ConfigError(`Template not found at ${url}. Check UNIVERSE_TEMPLATES_URL.`);
16003
+ if (response.status === 404) throw new ConfigError(`Template not found at ${url}. Check UNIVERSE_TEMPLATES_VERSION.`);
16000
16004
  if (!response.ok) throw new ConfigError(`Failed to fetch templates from ${url}: HTTP ${String(response.status)}`);
16001
16005
  const arrayBuffer = await response.arrayBuffer();
16002
16006
  return Buffer.from(arrayBuffer);
@@ -16045,24 +16049,169 @@ var RemoteTemplateProvider = class {
16045
16049
  this.cacheBaseOverride = cacheBaseOverride;
16046
16050
  this.fetchImpl = fetchImpl;
16047
16051
  }
16048
- resolveCacheDir(url) {
16049
- if (this.cacheBaseOverride !== void 0) return (0, node_path.join)(this.cacheBaseOverride(), versionFromUrl(url));
16050
- return templateCacheDir(url);
16052
+ resolveCacheDir(version) {
16053
+ if (this.cacheBaseOverride !== void 0) return (0, node_path.join)(this.cacheBaseOverride, version);
16054
+ return templateCacheDir(version);
16051
16055
  }
16052
16056
  async loadLayers(options) {
16053
- const { UNIVERSE_TEMPLATES_DIR, UNIVERSE_TEMPLATES_URL } = this.env();
16057
+ const { UNIVERSE_TEMPLATES_DIR, UNIVERSE_TEMPLATES_VERSION } = this.env();
16054
16058
  if (UNIVERSE_TEMPLATES_DIR !== void 0 && UNIVERSE_TEMPLATES_DIR.length > 0) return loadFromDir(UNIVERSE_TEMPLATES_DIR);
16055
- if (UNIVERSE_TEMPLATES_URL === void 0 || UNIVERSE_TEMPLATES_URL.length === 0) throw new ConfigError("UNIVERSE_TEMPLATES_URL is not set. Set it to the URL of a universe-templates release asset.");
16056
- const cacheDir = this.resolveCacheDir(UNIVERSE_TEMPLATES_URL);
16059
+ const version = UNIVERSE_TEMPLATES_VERSION && UNIVERSE_TEMPLATES_VERSION.length > 0 ? UNIVERSE_TEMPLATES_VERSION : defaultTemplateVersion;
16060
+ const url = resolveTemplateUrl(version);
16061
+ const cacheDir = this.resolveCacheDir(version);
16057
16062
  if (options?.forceFetch) await (0, node_fs_promises.rm)(cacheDir, {
16058
16063
  force: true,
16059
16064
  recursive: true
16060
16065
  });
16061
16066
  if (await cacheHit(cacheDir)) return loadFromDir(cacheDir);
16062
- return fetchAndCache(UNIVERSE_TEMPLATES_URL, cacheDir, this.fetchImpl);
16067
+ return fetchAndCache(url, cacheDir, this.fetchImpl);
16063
16068
  }
16064
16069
  };
16065
16070
  //#endregion
16071
+ //#region src/lib/version-utils.ts
16072
+ const DEFAULT_TTL_MS = 3600 * 1e3;
16073
+ function ttlMs() {
16074
+ const raw = process.env["UNIVERSE_UPDATE_TTL_MS"];
16075
+ if (raw !== void 0) {
16076
+ const n = Number.parseInt(raw, 10);
16077
+ if (Number.isFinite(n) && n >= 0) return n;
16078
+ }
16079
+ return DEFAULT_TTL_MS;
16080
+ }
16081
+ function isDisabled() {
16082
+ const v = process.env["UNIVERSE_NO_UPDATE_CHECK"];
16083
+ return v === "1" || v === "true";
16084
+ }
16085
+ function parseCache(raw) {
16086
+ let parsed;
16087
+ try {
16088
+ parsed = JSON.parse(raw);
16089
+ } catch {
16090
+ return null;
16091
+ }
16092
+ if (typeof parsed !== "object" || parsed === null || !("latest" in parsed) || !("lastCheck" in parsed)) return null;
16093
+ const { latest, lastCheck } = parsed;
16094
+ if (typeof latest !== "string" || typeof lastCheck !== "number") return null;
16095
+ return {
16096
+ latest,
16097
+ lastCheck
16098
+ };
16099
+ }
16100
+ function compareVersions(a, b) {
16101
+ const pa = parseVersion(a);
16102
+ const pb = parseVersion(b);
16103
+ if (pa === null || pb === null) return 0;
16104
+ for (let i = 0; i < 3; i += 1) {
16105
+ const ai = pa[i] ?? 0;
16106
+ const bi = pb[i] ?? 0;
16107
+ if (ai < bi) return -1;
16108
+ if (ai > bi) return 1;
16109
+ }
16110
+ return 0;
16111
+ }
16112
+ function parseVersion(s) {
16113
+ const parts = (s.split("-")[0] ?? "").split(".");
16114
+ if (parts.length !== 3) return null;
16115
+ const nums = parts.map((p) => Number.parseInt(p, 10));
16116
+ if (nums.some((n) => Number.isNaN(n))) return null;
16117
+ return [
16118
+ nums[0],
16119
+ nums[1],
16120
+ nums[2]
16121
+ ];
16122
+ }
16123
+ function useColor() {
16124
+ if (process.env["NO_COLOR"] && process.env["NO_COLOR"].length > 0) return false;
16125
+ return process.stderr.isTTY === true;
16126
+ }
16127
+ function paint(s, code, color) {
16128
+ if (!color) return s;
16129
+ return `\x1b[${code}m${s}\x1b[0m`;
16130
+ }
16131
+ //#endregion
16132
+ //#region src/lib/template-version-check.ts
16133
+ const APP_DIR$2 = "universe-cli";
16134
+ const TEMPLATES_DIR = "templates";
16135
+ const CACHE_FILE$1 = "template-version-check.json";
16136
+ const GITHUB_LATEST_URL = "https://api.github.com/repos/freeCodeCamp-Universe/templates/releases/latest";
16137
+ const FETCH_TIMEOUT_MS$1 = 3e3;
16138
+ const TAG_PREFIX = "app-templates-v";
16139
+ function templateCheckCachePath() {
16140
+ return (0, node_path.join)(cacheBase(), APP_DIR$2, TEMPLATES_DIR, CACHE_FILE$1);
16141
+ }
16142
+ async function readTemplateCache() {
16143
+ try {
16144
+ return parseCache(await (0, node_fs_promises.readFile)(templateCheckCachePath(), "utf-8"));
16145
+ } catch {
16146
+ return null;
16147
+ }
16148
+ }
16149
+ async function writeTemplateCache(c) {
16150
+ const path = templateCheckCachePath();
16151
+ await (0, node_fs_promises.mkdir)((0, node_path.dirname)(path), {
16152
+ recursive: true,
16153
+ mode: 448
16154
+ });
16155
+ await (0, node_fs_promises.writeFile)(path, JSON.stringify(c), { mode: 420 });
16156
+ }
16157
+ async function fetchLatestTemplateVersion() {
16158
+ const ctl = new AbortController();
16159
+ const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS$1);
16160
+ try {
16161
+ const res = await fetch(GITHUB_LATEST_URL, {
16162
+ signal: ctl.signal,
16163
+ headers: { accept: "application/vnd.github+json" }
16164
+ });
16165
+ if (!res.ok) return null;
16166
+ const body = await res.json();
16167
+ if (typeof body.tag_name !== "string") return null;
16168
+ if (!body.tag_name.startsWith(TAG_PREFIX)) return null;
16169
+ return body.tag_name.slice(15);
16170
+ } catch {
16171
+ return null;
16172
+ } finally {
16173
+ clearTimeout(timer);
16174
+ }
16175
+ }
16176
+ async function checkTemplateVersion(currentVersion, now = Date.now()) {
16177
+ if (isDisabled()) return null;
16178
+ const cache = await readTemplateCache();
16179
+ if (cache !== null && now - cache.lastCheck < ttlMs()) {
16180
+ if (compareVersions(currentVersion, cache.latest) < 0) return {
16181
+ current: currentVersion,
16182
+ latest: cache.latest
16183
+ };
16184
+ return null;
16185
+ }
16186
+ const latest = await fetchLatestTemplateVersion();
16187
+ if (latest === null) return null;
16188
+ try {
16189
+ await writeTemplateCache({
16190
+ latest,
16191
+ lastCheck: now
16192
+ });
16193
+ } catch {}
16194
+ if (compareVersions(currentVersion, latest) < 0) return {
16195
+ current: currentVersion,
16196
+ latest
16197
+ };
16198
+ return null;
16199
+ }
16200
+ function formatTemplateNotice(n, color = useColor()) {
16201
+ const dim = (s) => paint(s, "2", color);
16202
+ const yellow = (s) => paint(s, "33", color);
16203
+ const cyan = (s) => paint(s, "36", color);
16204
+ const bar = dim("│");
16205
+ return [
16206
+ "",
16207
+ bar,
16208
+ `${yellow("▲")} Newer templates available: ${dim(n.current)} → ${cyan(n.latest)}`,
16209
+ `${bar} Set ${cyan(`UNIVERSE_TEMPLATES_VERSION=${n.latest}`)} to use them.`,
16210
+ dim("└"),
16211
+ ""
16212
+ ].join("\n");
16213
+ }
16214
+ //#endregion
16066
16215
  //#region src/commands/create/index.ts
16067
16216
  const defaultFilesystemWriter = new LocalProjectWriter();
16068
16217
  const create$1 = async (options, deps = {}) => {
@@ -16077,6 +16226,15 @@ const create$1 = async (options, deps = {}) => {
16077
16226
  const platformManifestGenerator = deps.platformManifestGenerator ?? new PlatformManifestService();
16078
16227
  const repoInitialiser = deps.repoInitialiser ?? new GitRepoInitialiser();
16079
16228
  try {
16229
+ const templatesDir = process.env["UNIVERSE_TEMPLATES_DIR"];
16230
+ if (!(templatesDir && templatesDir.length > 0)) {
16231
+ const envVersion = process.env["UNIVERSE_TEMPLATES_VERSION"];
16232
+ const effectiveVersion = envVersion && envVersion.length > 0 ? envVersion : defaultTemplateVersion;
16233
+ try {
16234
+ const notice = await checkTemplateVersion(effectiveVersion);
16235
+ if (notice) process.stderr.write(formatTemplateNotice(notice));
16236
+ } catch {}
16237
+ }
16080
16238
  const templateProvider = deps.templateProvider ?? new RemoteTemplateProvider();
16081
16239
  const { labels, registry } = await templateProvider.loadLayers({ forceFetch: options.forceFetch });
16082
16240
  const interactive = (deps.isTTY ?? Boolean(process.stdin.isTTY)) && !options.yes && !options.json;
@@ -16566,6 +16724,17 @@ function suggest(target, candidates, threshold = 2) {
16566
16724
  }
16567
16725
  return bestD <= threshold ? best : null;
16568
16726
  }
16727
+ const auditRowArraySchema = array(object({
16728
+ id: number(),
16729
+ occurredAt: string(),
16730
+ actor: string(),
16731
+ action: string(),
16732
+ site: string().optional(),
16733
+ deployId: string().optional(),
16734
+ outcome: string(),
16735
+ requestId: string().optional(),
16736
+ detail: record(string(), unknown()).optional()
16737
+ }));
16569
16738
  const repoName = string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}$/, "must start with a letter or digit, then letters, digits, '.', '_' or '-' (max 100 chars)");
16570
16739
  const visibilitySchema = _enum(["public", "private"]);
16571
16740
  const repoStatusSchema = _enum([
@@ -16968,6 +17137,23 @@ function createProxyClient(cfg) {
16968
17137
  }
16969
17138
  }, (raw) => repoRowArraySchema.parse(raw));
16970
17139
  },
17140
+ async listAudit(req) {
17141
+ const params = new URLSearchParams();
17142
+ if (req?.site) params.set("site", req.site);
17143
+ if (req?.actor) params.set("actor", req.actor);
17144
+ if (req?.action) params.set("action", req.action);
17145
+ if (req?.since) params.set("since", req.since);
17146
+ if (req?.limit !== void 0) params.set("limit", String(req.limit));
17147
+ if (req?.offset !== void 0) params.set("offset", String(req.offset));
17148
+ const qs = params.toString();
17149
+ return call(`${base}/api/audit${qs ? `?${qs}` : ""}`, {
17150
+ method: "GET",
17151
+ headers: {
17152
+ Authorization: await userBearer(),
17153
+ Accept: "application/json"
17154
+ }
17155
+ }, (raw) => auditRowArraySchema.parse(raw));
17156
+ },
16971
17157
  async getRepoRequest(id) {
16972
17158
  return call(`${base}/api/repo/${encodeURIComponent(id)}`, {
16973
17159
  method: "GET",
@@ -17869,24 +18055,26 @@ async function readSiteFromYaml(cwd, read) {
17869
18055
  if (!r.ok) throw new ConfigError(r.error);
17870
18056
  return r.value.site;
17871
18057
  }
17872
- function formatTable$1(deploys) {
18058
+ function formatTable$2(deploys) {
17873
18059
  const header = [
17874
18060
  "DEPLOY ID",
17875
18061
  "TIMESTAMP",
17876
18062
  "SHA",
17877
- "STATE"
18063
+ "STATE",
18064
+ "ACTOR"
17878
18065
  ];
17879
18066
  const rows = deploys.map((d) => [
17880
18067
  d.deployId,
17881
18068
  d.timestamp ? d.timestamp.replace("T", " ").replace("Z", "") : "—",
17882
18069
  d.sha ?? "—",
17883
- d.state ?? "—"
18070
+ d.state ?? "—",
18071
+ d.actor ?? "—"
17884
18072
  ]);
17885
18073
  const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
17886
18074
  const fmt = (cells) => cells.map((c, i) => c.padEnd(widths[i] ?? 0)).join(" ");
17887
18075
  return [fmt(header), ...rows.map(fmt)].join("\n");
17888
18076
  }
17889
- async function ls$2(options, deps = {}) {
18077
+ async function ls$3(options, deps = {}) {
17890
18078
  const cwd = deps.cwd ?? process.cwd();
17891
18079
  const env = deps.env ?? process.env;
17892
18080
  const readYaml = deps.readPlatformYaml ?? defaultReadPlatformYaml$2;
@@ -17907,7 +18095,8 @@ async function ls$2(options, deps = {}) {
17907
18095
  getAuthToken: () => identity.token,
17908
18096
  timeoutMs: parseFetchTimeoutMs(env)
17909
18097
  });
17910
- const parsed = [...await client.siteDeploys({ site })].sort((a, b) => b.deployId.localeCompare(a.deployId)).map((d) => parseDeployId(d.deployId));
18098
+ const sorted = [...await client.siteDeploys({ site })].sort((a, b) => b.deployId.localeCompare(a.deployId));
18099
+ const parsed = sorted.map((d) => parseDeployId(d.deployId));
17911
18100
  let previewId = null;
17912
18101
  let productionId = null;
17913
18102
  if (parsed.length > 0) {
@@ -17921,9 +18110,10 @@ async function ls$2(options, deps = {}) {
17921
18110
  previewId = preview?.deployId ?? null;
17922
18111
  productionId = production?.deployId ?? null;
17923
18112
  }
17924
- const deploys = parsed.map((d) => ({
18113
+ const deploys = parsed.map((d, i) => ({
17925
18114
  ...d,
17926
- state: deployState(d.deployId, previewId, productionId)
18115
+ state: deployState(d.deployId, previewId, productionId),
18116
+ actor: sorted[i]?.actor
17927
18117
  }));
17928
18118
  if (options.json) {
17929
18119
  emitJson(buildEnvelope("ls", true, {
@@ -17941,7 +18131,7 @@ async function ls$2(options, deps = {}) {
17941
18131
  info(`(no deploys for ${site})`);
17942
18132
  return;
17943
18133
  }
17944
- success(formatTable$1(deploys));
18134
+ success(formatTable$2(deploys));
17945
18135
  } catch (err) {
17946
18136
  const { code, message } = wrapProxyError("ls", err);
17947
18137
  outputError({
@@ -18241,7 +18431,7 @@ function parseTeamsFlag(raw) {
18241
18431
  * Resolve identity + construct a proxy client. Throws CredentialError
18242
18432
  * if no identity is available — caller wraps via wrapProxyError.
18243
18433
  */
18244
- async function setupClient$1(deps) {
18434
+ async function setupClient$2(deps) {
18245
18435
  const env = deps.env ?? process.env;
18246
18436
  const resolveId = deps.resolveIdentity ?? resolveIdentity;
18247
18437
  const mkClient = deps.createProxyClient ?? createProxyClient;
@@ -18258,7 +18448,7 @@ async function setupClient$1(deps) {
18258
18448
  }
18259
18449
  //#endregion
18260
18450
  //#region src/commands/sites/ls.ts
18261
- function formatTable(rows) {
18451
+ function formatTable$1(rows) {
18262
18452
  if (rows.length === 0) return "No registered sites.";
18263
18453
  const headers = [
18264
18454
  "SLUG",
@@ -18276,13 +18466,13 @@ function formatTable(rows) {
18276
18466
  const fmt = (row) => row.map((c, i) => c.padEnd(widths[i] ?? 0)).join(" ");
18277
18467
  return [fmt(headers), ...cells.map(fmt)].join("\n");
18278
18468
  }
18279
- async function ls$1(options, deps = {}) {
18469
+ async function ls$2(options, deps = {}) {
18280
18470
  const command = "sites ls";
18281
18471
  const success = deps.logSuccess ?? ((s) => R.message(s));
18282
18472
  const error = deps.logError ?? ((s) => R.error(s));
18283
18473
  const exit = deps.exit ?? exitWithCode;
18284
18474
  try {
18285
- const { client, identitySource } = await setupClient$1(deps);
18475
+ const { client, identitySource } = await setupClient$2(deps);
18286
18476
  let rows = await client.listSites();
18287
18477
  let scope = "all";
18288
18478
  if (options.mine) {
@@ -18297,7 +18487,7 @@ async function ls$1(options, deps = {}) {
18297
18487
  sites: rows,
18298
18488
  identitySource
18299
18489
  }));
18300
- else success(formatTable(rows));
18490
+ else success(formatTable$1(rows));
18301
18491
  } catch (err) {
18302
18492
  const { code, message } = wrapProxyError(command, err);
18303
18493
  outputError({
@@ -18317,7 +18507,7 @@ async function register(options, deps = {}) {
18317
18507
  try {
18318
18508
  if (!options.slug || options.slug.trim().length === 0) throw new UsageError("slug is required (positional argument)");
18319
18509
  const teams = parseTeamsFlag(options.team);
18320
- const { client, identitySource } = await setupClient$1(deps);
18510
+ const { client, identitySource } = await setupClient$2(deps);
18321
18511
  const row = await client.registerSite({
18322
18512
  slug: options.slug,
18323
18513
  teams: teams.length > 0 ? teams : void 0
@@ -18355,7 +18545,7 @@ async function rm$1(options, deps = {}) {
18355
18545
  const exit = deps.exit ?? exitWithCode;
18356
18546
  try {
18357
18547
  if (!options.slug || options.slug.trim().length === 0) throw new UsageError("slug is required (positional argument)");
18358
- const { client, identitySource } = await setupClient$1(deps);
18548
+ const { client, identitySource } = await setupClient$2(deps);
18359
18549
  await client.deleteSite({ slug: options.slug });
18360
18550
  if (options.json) emitJson(buildEnvelope(command, true, {
18361
18551
  slug: options.slug,
@@ -18388,7 +18578,7 @@ async function update(options, deps = {}) {
18388
18578
  if (!options.slug || options.slug.trim().length === 0) throw new UsageError("slug is required (positional argument)");
18389
18579
  const teams = parseTeamsFlag(options.team);
18390
18580
  if (teams.length === 0) throw new UsageError("--team is required with at least one slug; use `sites rm` to remove a site");
18391
- const { client, identitySource } = await setupClient$1(deps);
18581
+ const { client, identitySource } = await setupClient$2(deps);
18392
18582
  const row = await client.updateSite({
18393
18583
  slug: options.slug,
18394
18584
  teams
@@ -18427,7 +18617,7 @@ const defaultRepoPrompts = {
18427
18617
  * Resolve identity + construct a proxy client. Throws CredentialError
18428
18618
  * when no identity is available — the caller wraps via wrapProxyError.
18429
18619
  */
18430
- async function setupClient(deps) {
18620
+ async function setupClient$1(deps) {
18431
18621
  const env = deps.env ?? process.env;
18432
18622
  const resolveId = deps.resolveIdentity ?? resolveIdentity;
18433
18623
  const mkClient = deps.createProxyClient ?? createProxyClient;
@@ -18447,6 +18637,26 @@ async function setupClient(deps) {
18447
18637
  * Render repo-request rows as an aligned text table. Returns `emptyMsg`
18448
18638
  * when there are no rows (the caller passes a status-specific phrase).
18449
18639
  */
18640
+ function humanizeDuration(ms) {
18641
+ const s = Math.floor(ms / 1e3);
18642
+ if (s < 60) return `${s}s`;
18643
+ const m = Math.floor(s / 60);
18644
+ if (m < 60) return `${m}m`;
18645
+ const h = Math.floor(m / 60);
18646
+ if (h < 24) return `${h}h${m % 60}m`;
18647
+ return `${Math.floor(h / 24)}d${h % 24}h`;
18648
+ }
18649
+ const RESOLVED_STATUSES = new Set([
18650
+ "active",
18651
+ "rejected",
18652
+ "failed"
18653
+ ]);
18654
+ function resolveLatency(r) {
18655
+ if (!RESOLVED_STATUSES.has(r.status)) return "";
18656
+ const ms = Date.parse(r.updatedAt) - Date.parse(r.createdAt);
18657
+ if (!Number.isFinite(ms) || ms < 0) return "";
18658
+ return humanizeDuration(ms);
18659
+ }
18450
18660
  function formatRepoTable(rows, emptyMsg = "No repo requests.") {
18451
18661
  if (rows.length === 0) return emptyMsg;
18452
18662
  const headers = [
@@ -18455,7 +18665,9 @@ function formatRepoTable(rows, emptyMsg = "No repo requests.") {
18455
18665
  "VIS",
18456
18666
  "STATUS",
18457
18667
  "REQUESTED BY",
18458
- "REQUESTED AT"
18668
+ "REQUESTED AT",
18669
+ "APPROVER",
18670
+ "LATENCY"
18459
18671
  ];
18460
18672
  const cells = rows.map((r) => [
18461
18673
  r.id,
@@ -18463,7 +18675,9 @@ function formatRepoTable(rows, emptyMsg = "No repo requests.") {
18463
18675
  r.visibility,
18464
18676
  r.status,
18465
18677
  r.requestedBy,
18466
- r.createdAt
18678
+ r.createdAt,
18679
+ r.approver ?? "",
18680
+ resolveLatency(r)
18467
18681
  ]);
18468
18682
  const widths = headers.map((h, i) => Math.max(h.length, ...cells.map((row) => row[i]?.length ?? 0)));
18469
18683
  const fmt = (row) => row.map((c, i) => c.padEnd(widths[i] ?? 0)).join(" ");
@@ -18482,7 +18696,7 @@ async function approve(options, deps = {}) {
18482
18696
  let identitySource;
18483
18697
  try {
18484
18698
  if (!options.id || options.id.trim().length === 0) throw new UsageError("request id is required (positional argument)");
18485
- const setup = await setupClient(deps);
18699
+ const setup = await setupClient$1(deps);
18486
18700
  const client = setup.client;
18487
18701
  identitySource = setup.identitySource;
18488
18702
  if (!options.json && !options.yes) {
@@ -18600,7 +18814,7 @@ async function create(options, deps = {}) {
18600
18814
  let client;
18601
18815
  const ensureClient = async () => {
18602
18816
  if (!client) {
18603
- const setup = await setupClient(deps);
18817
+ const setup = await setupClient$1(deps);
18604
18818
  client = setup.client;
18605
18819
  identitySource = setup.identitySource;
18606
18820
  }
@@ -18702,7 +18916,7 @@ async function create(options, deps = {}) {
18702
18916
  //#region src/commands/repo/ls.ts
18703
18917
  /** Closed set accepted by `--status`: the row statuses plus `all`. */
18704
18918
  const LS_STATUSES = [...repoStatusSchema.options, "all"];
18705
- async function ls(options, deps = {}) {
18919
+ async function ls$1(options, deps = {}) {
18706
18920
  const command = "repo ls";
18707
18921
  const message = deps.logMessage ?? ((s) => R.message(s));
18708
18922
  const error = deps.logError ?? ((s) => R.error(s));
@@ -18711,7 +18925,7 @@ async function ls(options, deps = {}) {
18711
18925
  try {
18712
18926
  const requestedStatus = options.all ? "all" : options.status;
18713
18927
  if (requestedStatus !== void 0 && !LS_STATUSES.includes(requestedStatus)) throw new UsageError(`invalid --status "${requestedStatus}": must be one of ${LS_STATUSES.join(", ")}`);
18714
- const setup = await setupClient(deps);
18928
+ const setup = await setupClient$1(deps);
18715
18929
  const client = setup.client;
18716
18930
  identitySource = setup.identitySource;
18717
18931
  const rows = await client.listRepoRequests({
@@ -18753,7 +18967,7 @@ async function reject(options, deps = {}) {
18753
18967
  let identitySource;
18754
18968
  try {
18755
18969
  if (!options.id || options.id.trim().length === 0) throw new UsageError("request id is required (positional argument)");
18756
- const setup = await setupClient(deps);
18970
+ const setup = await setupClient$1(deps);
18757
18971
  const client = setup.client;
18758
18972
  identitySource = setup.identitySource;
18759
18973
  if (!options.json && !options.yes) {
@@ -18806,7 +19020,7 @@ async function rm(options, deps = {}) {
18806
19020
  let identitySource;
18807
19021
  try {
18808
19022
  if (!options.id || options.id.trim().length === 0) throw new UsageError("request id is required (positional argument)");
18809
- const setup = await setupClient(deps);
19023
+ const setup = await setupClient$1(deps);
18810
19024
  const client = setup.client;
18811
19025
  identitySource = setup.identitySource;
18812
19026
  if (!options.json && !options.yes) {
@@ -18864,7 +19078,7 @@ async function status(options, deps = {}) {
18864
19078
  let identitySource;
18865
19079
  try {
18866
19080
  if (!options.id || options.id.trim().length === 0) throw new UsageError("request id is required (positional argument)");
18867
- const setup = await setupClient(deps);
19081
+ const setup = await setupClient$1(deps);
18868
19082
  const client = setup.client;
18869
19083
  identitySource = setup.identitySource;
18870
19084
  const row = await client.getRepoRequest(options.id);
@@ -18888,22 +19102,93 @@ async function status(options, deps = {}) {
18888
19102
  }
18889
19103
  }
18890
19104
  //#endregion
19105
+ //#region src/commands/audit/_shared.ts
19106
+ async function setupClient(deps) {
19107
+ const env = deps.env ?? process.env;
19108
+ const resolveId = deps.resolveIdentity ?? resolveIdentity;
19109
+ const mkClient = deps.createProxyClient ?? createProxyClient;
19110
+ const identity = await resolveId({ env });
19111
+ if (!identity) throw new CredentialError("No GitHub identity available. Run `universe login`, set $GITHUB_TOKEN, or install the gh CLI.");
19112
+ return {
19113
+ client: mkClient({
19114
+ baseUrl: env["UNIVERSE_PROXY_URL"] ?? "https://uploads.freecode.camp",
19115
+ getAuthToken: () => identity.token,
19116
+ timeoutMs: parseFetchTimeoutMs(env)
19117
+ }),
19118
+ identitySource: identity.source
19119
+ };
19120
+ }
19121
+ //#endregion
19122
+ //#region src/commands/audit/ls.ts
19123
+ function formatTable(rows) {
19124
+ if (rows.length === 0) return "No audit events.";
19125
+ const headers = [
19126
+ "OCCURRED AT",
19127
+ "ACTOR",
19128
+ "ACTION",
19129
+ "TARGET",
19130
+ "OUTCOME"
19131
+ ];
19132
+ const cells = rows.map((r) => [
19133
+ r.occurredAt,
19134
+ r.actor,
19135
+ r.action,
19136
+ r.site || r.deployId || targetFromDetail(r),
19137
+ r.outcome
19138
+ ]);
19139
+ const widths = headers.map((h, i) => Math.max(h.length, ...cells.map((row) => row[i]?.length ?? 0)));
19140
+ const fmt = (row) => row.map((c, i) => c.padEnd(widths[i] ?? 0)).join(" ");
19141
+ return [fmt(headers), ...cells.map(fmt)].join("\n");
19142
+ }
19143
+ function targetFromDetail(r) {
19144
+ const name = r.detail?.["name"];
19145
+ return typeof name === "string" ? name : "";
19146
+ }
19147
+ async function ls(options, deps = {}) {
19148
+ const command = "audit ls";
19149
+ const message = deps.logMessage ?? ((s) => R.message(s));
19150
+ const error = deps.logError ?? ((s) => R.error(s));
19151
+ const exit = deps.exit ?? exitWithCode;
19152
+ let identitySource;
19153
+ try {
19154
+ if (options.limit !== void 0 && (!Number.isInteger(options.limit) || options.limit < 0)) throw new UsageError("--limit must be a non-negative integer");
19155
+ const setup = await setupClient(deps);
19156
+ identitySource = setup.identitySource;
19157
+ const rows = await setup.client.listAudit({
19158
+ site: options.site,
19159
+ actor: options.actor,
19160
+ action: options.action,
19161
+ since: options.since,
19162
+ limit: options.limit
19163
+ });
19164
+ if (options.json) emitJson(buildEnvelope(command, true, {
19165
+ count: rows.length,
19166
+ events: rows,
19167
+ identitySource
19168
+ }));
19169
+ else message(formatTable(rows));
19170
+ } catch (err) {
19171
+ const { code, message: msg, kind, requestId } = wrapProxyError(command, err);
19172
+ outputError({
19173
+ json: options.json,
19174
+ command
19175
+ }, code, msg, {
19176
+ logError: error,
19177
+ kind,
19178
+ requestId,
19179
+ extras: identitySource ? { identitySource } : void 0
19180
+ });
19181
+ exit(code);
19182
+ }
19183
+ }
19184
+ //#endregion
18891
19185
  //#region src/lib/update-notifier.ts
18892
19186
  const APP_DIR = "universe-cli";
18893
19187
  const CACHE_FILE = "update-check.json";
18894
19188
  const PKG_NAME = "@freecodecamp/universe-cli";
18895
19189
  const NPM_LATEST_URL = `https://registry.npmjs.org/${PKG_NAME}/latest`;
18896
- const DEFAULT_TTL_MS = 3600 * 1e3;
18897
19190
  const FETCH_TIMEOUT_MS = 3e3;
18898
19191
  const REFRESH_ENV = "UNIVERSE_REFRESH_WORKER";
18899
- function ttlMs() {
18900
- const raw = process.env["UNIVERSE_UPDATE_TTL_MS"];
18901
- if (raw !== void 0) {
18902
- const n = Number.parseInt(raw, 10);
18903
- if (Number.isFinite(n) && n >= 0) return n;
18904
- }
18905
- return DEFAULT_TTL_MS;
18906
- }
18907
19192
  function latestUrl() {
18908
19193
  const override = process.env["UNIVERSE_UPDATE_URL"];
18909
19194
  return override && override.length > 0 ? override : NPM_LATEST_URL;
@@ -18916,25 +19201,6 @@ function configBase() {
18916
19201
  function cachePath() {
18917
19202
  return (0, node_path.join)(configBase(), APP_DIR, CACHE_FILE);
18918
19203
  }
18919
- function isDisabled() {
18920
- const v = process.env["UNIVERSE_NO_UPDATE_CHECK"];
18921
- return v === "1" || v === "true";
18922
- }
18923
- function parseCache(raw) {
18924
- let parsed;
18925
- try {
18926
- parsed = JSON.parse(raw);
18927
- } catch {
18928
- return null;
18929
- }
18930
- if (typeof parsed !== "object" || parsed === null || !("latest" in parsed) || !("lastCheck" in parsed)) return null;
18931
- const { latest, lastCheck } = parsed;
18932
- if (typeof latest !== "string" || typeof lastCheck !== "number") return null;
18933
- return {
18934
- latest,
18935
- lastCheck
18936
- };
18937
- }
18938
19204
  async function readCache() {
18939
19205
  try {
18940
19206
  return parseCache(await (0, node_fs_promises.readFile)(cachePath(), "utf-8"));
@@ -18974,29 +19240,6 @@ async function fetchLatest() {
18974
19240
  clearTimeout(timer);
18975
19241
  }
18976
19242
  }
18977
- function compareVersions(a, b) {
18978
- const pa = parseVersion(a);
18979
- const pb = parseVersion(b);
18980
- if (pa === null || pb === null) return 0;
18981
- for (let i = 0; i < 3; i += 1) {
18982
- const ai = pa[i] ?? 0;
18983
- const bi = pb[i] ?? 0;
18984
- if (ai < bi) return -1;
18985
- if (ai > bi) return 1;
18986
- }
18987
- return 0;
18988
- }
18989
- function parseVersion(s) {
18990
- const parts = (s.split("-")[0] ?? "").split(".");
18991
- if (parts.length !== 3) return null;
18992
- const nums = parts.map((p) => Number.parseInt(p, 10));
18993
- if (nums.some((n) => Number.isNaN(n))) return null;
18994
- return [
18995
- nums[0],
18996
- nums[1],
18997
- nums[2]
18998
- ];
18999
- }
19000
19243
  async function refreshIfStale(now = Date.now(), options = {}) {
19001
19244
  if (isDisabled()) return;
19002
19245
  if (!options.force) {
@@ -19042,14 +19285,6 @@ function getNoticeSync(current) {
19042
19285
  latest: cache.latest
19043
19286
  };
19044
19287
  }
19045
- function useColor() {
19046
- if (process.env["NO_COLOR"] && process.env["NO_COLOR"].length > 0) return false;
19047
- return process.stderr.isTTY === true;
19048
- }
19049
- function paint(s, code, color) {
19050
- if (!color) return s;
19051
- return `\x1b[${code}m${s}\x1b[0m`;
19052
- }
19053
19288
  function formatNotice(n, color = useColor()) {
19054
19289
  const dim = (s) => paint(s, "2", color);
19055
19290
  const yellow = (s) => paint(s, "33", color);
@@ -19081,7 +19316,7 @@ function installExitNotice(current) {
19081
19316
  }
19082
19317
  //#endregion
19083
19318
  //#region src/cli.ts
19084
- const version = "0.11.0";
19319
+ const version = "0.12.0";
19085
19320
  function handleActionError(command, json, err) {
19086
19321
  const ctx = {
19087
19322
  json,
@@ -19125,6 +19360,7 @@ async function run(argv = process.argv) {
19125
19360
  const sitesCli = namespaceGroup("sites", "Static site registry commands");
19126
19361
  const staticCli = namespaceGroup("static", "Static site deployment commands");
19127
19362
  const repoCli = namespaceGroup("repo", "Repository creation + approval queue commands");
19363
+ const auditCli = namespaceGroup("audit", "Audit trail query commands");
19128
19364
  sitesCli.command("register <slug>").description("Register a new static site (staff only)").option("--team <name>", "GitHub team slug (repeatable, or comma-separated). Defaults to staff.").action(async (slug, _opts, cmd) => {
19129
19365
  const opts = cmd.optsWithGlobals();
19130
19366
  try {
@@ -19140,7 +19376,7 @@ async function run(argv = process.argv) {
19140
19376
  sitesCli.command("ls").description("List sites in the registry").option("--mine", "Filter to sites your GitHub identity is authorized for").action(async (_opts, cmd) => {
19141
19377
  const opts = cmd.optsWithGlobals();
19142
19378
  try {
19143
- await ls$1({
19379
+ await ls$2({
19144
19380
  json: opts.json ?? false,
19145
19381
  mine: opts.mine ?? false
19146
19382
  });
@@ -19189,7 +19425,7 @@ async function run(argv = process.argv) {
19189
19425
  repoCli.command("ls").description("List repo requests (default: pending)").option("--status <status>", "pending | approved | active | rejected | failed | all").option("--mine", "Only requests you submitted").option("--all", "Show every state (shorthand for --status all)").action(async (_opts, cmd) => {
19190
19426
  const opts = cmd.optsWithGlobals();
19191
19427
  try {
19192
- await ls({
19428
+ await ls$1({
19193
19429
  json: opts.json ?? false,
19194
19430
  status: opts.status,
19195
19431
  mine: opts.mine ?? false,
@@ -19284,7 +19520,7 @@ async function run(argv = process.argv) {
19284
19520
  staticCli.command("ls").description("List recent deploys for a site").option("--site <site>", "Override site from platform.yaml").action(async (_opts, cmd) => {
19285
19521
  const opts = cmd.optsWithGlobals();
19286
19522
  try {
19287
- await ls$2({
19523
+ await ls$3({
19288
19524
  json: opts.json ?? false,
19289
19525
  site: opts.site
19290
19526
  });
@@ -19351,9 +19587,25 @@ async function run(argv = process.argv) {
19351
19587
  handleActionError("whoami", opts.json ?? false, err);
19352
19588
  }
19353
19589
  });
19590
+ auditCli.command("ls").description("List durable audit events (who did what)").option("--actor <login>", "Filter by GitHub actor").option("--action <action>", "Filter by action (e.g. repo.approve)").option("--site <slug>", "Filter by site").option("--since <rfc3339>", "Only events at or after this timestamp").option("--limit <n>", "Max rows (default 100, max 500)", (v) => Number.parseInt(v, 10)).action(async (_opts, cmd) => {
19591
+ const opts = cmd.optsWithGlobals();
19592
+ try {
19593
+ await ls({
19594
+ json: opts.json ?? false,
19595
+ actor: opts.actor,
19596
+ action: opts.action,
19597
+ site: opts.site,
19598
+ since: opts.since,
19599
+ limit: opts.limit
19600
+ });
19601
+ } catch (err) {
19602
+ handleActionError("audit ls", opts.json ?? false, err);
19603
+ }
19604
+ });
19354
19605
  cli.addCommand(staticCli);
19355
19606
  cli.addCommand(sitesCli);
19356
19607
  cli.addCommand(repoCli);
19608
+ cli.addCommand(auditCli);
19357
19609
  if (args.length === 0) {
19358
19610
  cli.outputHelp();
19359
19611
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@freecodecamp/universe-cli",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "main": "dist/index.cjs",
5
5
  "scripts": {
6
6
  "test": "vitest run",