@duffcloudservices/cli 0.4.3 → 0.4.4

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/README.md CHANGED
@@ -42,6 +42,14 @@ dcs whoami
42
42
  dcs logout
43
43
  ```
44
44
 
45
+ The CLI talks to the production portal by default (`config.portalApiUrl` in this
46
+ package's `package.json`). Set `DCS_API_URL` to point every command - login, token
47
+ refresh, logout and the API client - at another portal, for example a staging one:
48
+
49
+ ```bash
50
+ DCS_API_URL=https://portal-staging.example.test dcs login
51
+ ```
52
+
45
53
  ### Site Management
46
54
 
47
55
  ```bash
package/dist/index.js CHANGED
@@ -5,7 +5,10 @@ import { Command } from "commander";
5
5
  import chalk8 from "chalk";
6
6
 
7
7
  // package.json
8
- var version = "0.4.3";
8
+ var version = "0.4.4";
9
+ var config = {
10
+ portalApiUrl: "https://portal.duffcloudservices.com"
11
+ };
9
12
 
10
13
  // src/commands/auth.ts
11
14
  import chalk2 from "chalk";
@@ -289,11 +292,11 @@ function getKeychain() {
289
292
  // src/auth/credentials.ts
290
293
  var ACCOUNT = "auth";
291
294
  function defaultLegacyStore() {
292
- const config = new Conf({ projectName: "dcs-cli" });
295
+ const config2 = new Conf({ projectName: "dcs-cli" });
293
296
  return {
294
- read: () => config.get("auth"),
297
+ read: () => config2.get("auth"),
295
298
  clear: () => {
296
- if (config.has("auth")) config.delete("auth");
299
+ if (config2.has("auth")) config2.delete("auth");
297
300
  }
298
301
  };
299
302
  }
@@ -370,8 +373,20 @@ function getStorageBackendName() {
370
373
  return getKeychain().name;
371
374
  }
372
375
 
376
+ // src/config/portal-api-url.ts
377
+ var PORTAL_API_URL_ENV = "DCS_API_URL";
378
+ function trimTrailingSlashes(url) {
379
+ return url.replace(/\/+$/, "");
380
+ }
381
+ function resolvePortalApiUrl(explicit) {
382
+ const fromArg = explicit?.trim();
383
+ if (fromArg) return trimTrailingSlashes(fromArg);
384
+ const fromEnv = process.env[PORTAL_API_URL_ENV]?.trim();
385
+ if (fromEnv) return trimTrailingSlashes(fromEnv);
386
+ return trimTrailingSlashes(config.portalApiUrl);
387
+ }
388
+
373
389
  // src/auth/device-flow.ts
374
- var DEFAULT_API_URL = "https://portal.duffcloudservices.com";
375
390
  var RefreshTokenRevokedError = class extends Error {
376
391
  constructor(message = "Refresh token was rejected (expired or revoked).") {
377
392
  super(message);
@@ -388,7 +403,7 @@ function sleep(ms) {
388
403
  return new Promise((resolve2) => setTimeout(resolve2, ms));
389
404
  }
390
405
  async function startDeviceAuth(options = {}) {
391
- const apiUrl = options.apiUrl || DEFAULT_API_URL;
406
+ const apiUrl = resolvePortalApiUrl(options.apiUrl);
392
407
  const clientId = options.clientId || "dcs-cli";
393
408
  const response = await fetch(`${apiUrl}/api/v1/cli/auth/device`, {
394
409
  method: "POST",
@@ -437,7 +452,7 @@ async function pollForToken(deviceCode, apiUrl) {
437
452
  };
438
453
  }
439
454
  async function login(options = {}) {
440
- const apiUrl = options.apiUrl || DEFAULT_API_URL;
455
+ const apiUrl = resolvePortalApiUrl(options.apiUrl);
441
456
  const spinner = ora("Starting authentication...").start();
442
457
  let deviceAuth;
443
458
  try {
@@ -505,7 +520,7 @@ async function login(options = {}) {
505
520
  }
506
521
  var REVOKE_TIMEOUT_MS = 1e4;
507
522
  async function revokeRefreshToken(refreshToken, options = {}) {
508
- const apiUrl = options.apiUrl || DEFAULT_API_URL;
523
+ const apiUrl = resolvePortalApiUrl(options.apiUrl);
509
524
  const response = await fetch(`${apiUrl}/api/v1/cli/auth/logout`, {
510
525
  method: "POST",
511
526
  headers: {
@@ -521,7 +536,7 @@ async function revokeRefreshToken(refreshToken, options = {}) {
521
536
  return data?.revoked === true;
522
537
  }
523
538
  async function refreshAccessToken(refreshToken, options = {}) {
524
- const apiUrl = options.apiUrl || DEFAULT_API_URL;
539
+ const apiUrl = resolvePortalApiUrl(options.apiUrl);
525
540
  const response = await fetch(`${apiUrl}/api/v1/cli/auth/refresh`, {
526
541
  method: "POST",
527
542
  headers: {
@@ -600,11 +615,10 @@ import chalk3 from "chalk";
600
615
  import Table from "cli-table3";
601
616
 
602
617
  // src/api/portal-client.ts
603
- var DEFAULT_API_URL2 = "https://portal.duffcloudservices.com";
604
618
  var PortalClient = class {
605
619
  apiUrl;
606
620
  constructor(options = {}) {
607
- this.apiUrl = options.apiUrl || process.env.DCS_API_URL || DEFAULT_API_URL2;
621
+ this.apiUrl = resolvePortalApiUrl(options.apiUrl);
608
622
  }
609
623
  /**
610
624
  * Refresh the session using the stored refresh token. Exactly one attempt —
@@ -1061,7 +1075,8 @@ async function initCommand(options) {
1061
1075
  const generatedFiles = [];
1062
1076
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
1063
1077
  try {
1064
- const siteYaml = generateSiteYaml({ siteSlug, siteName, timestamp, framework });
1078
+ const portalApiUrl = resolvePortalApiUrl();
1079
+ const siteYaml = generateSiteYaml({ siteSlug, siteName, timestamp, framework, portalApiUrl });
1065
1080
  generatedFiles.push(await writeFile(targetDir, ".dcs/site.yaml", siteYaml, { dryRun, force }));
1066
1081
  const pagesYaml = generatePagesYaml({ siteSlug, timestamp });
1067
1082
  generatedFiles.push(await writeFile(targetDir, ".dcs/pages.yaml", pagesYaml, { dryRun, force }));
@@ -1073,7 +1088,7 @@ async function initCommand(options) {
1073
1088
  generatedFiles.push(
1074
1089
  await writeFile(targetDir, ".dcs/SECTION-CONVENTIONS.md", sectionConventions, { dryRun, force })
1075
1090
  );
1076
- const copilotInstructions = generateCopilotInstructions({ siteName });
1091
+ const copilotInstructions = generateCopilotInstructions({ siteName, portalApiUrl });
1077
1092
  generatedFiles.push(
1078
1093
  await writeFile(targetDir, ".github/copilot-instructions.md", copilotInstructions, { dryRun, force })
1079
1094
  );
@@ -1152,7 +1167,7 @@ azure:
1152
1167
  subscription_id: ""
1153
1168
 
1154
1169
  # Portal API Configuration
1155
- portal_api_url: "https://portal.duffcloudservices.com"
1170
+ portal_api_url: "${data.portalApiUrl}"
1156
1171
 
1157
1172
  # Site metadata for portal
1158
1173
  metadata:
@@ -1303,7 +1318,7 @@ function generateCopilotInstructions(data) {
1303
1318
  ## Project Overview
1304
1319
 
1305
1320
  This is a **DCS-managed customer site**. Content and feature changes are managed
1306
- through the DCS Portal at https://portal.duffcloudservices.com.
1321
+ through the DCS Portal at ${data.portalApiUrl}.
1307
1322
 
1308
1323
  ## Technology Stack
1309
1324
 
@@ -1800,27 +1815,27 @@ async function validateSiteYaml(dcsDir, verbose) {
1800
1815
  };
1801
1816
  try {
1802
1817
  const content = await fs5.readFile(filePath, "utf8");
1803
- const config = yaml.load(content);
1804
- if (!config.site_name) {
1818
+ const config2 = yaml.load(content);
1819
+ if (!config2.site_name) {
1805
1820
  result.errors.push("Missing required field: site_name");
1806
1821
  result.valid = false;
1807
1822
  }
1808
- if (!config.site_slug) {
1823
+ if (!config2.site_slug) {
1809
1824
  result.errors.push("Missing required field: site_slug");
1810
1825
  result.valid = false;
1811
- } else if (!/^[a-z0-9-]+$/.test(config.site_slug)) {
1826
+ } else if (!/^[a-z0-9-]+$/.test(config2.site_slug)) {
1812
1827
  result.errors.push("site_slug must be lowercase alphanumeric with hyphens only");
1813
1828
  result.valid = false;
1814
1829
  }
1815
- if (!config.swa_resource_id) {
1830
+ if (!config2.swa_resource_id) {
1816
1831
  result.warnings.push("swa_resource_id is not set (required for deployment)");
1817
1832
  }
1818
- if (!config.production_url) {
1833
+ if (!config2.production_url) {
1819
1834
  result.warnings.push("production_url is not set");
1820
1835
  }
1821
1836
  if (verbose) {
1822
- console.log(chalk5.dim(` site_name: ${config.site_name}`));
1823
- console.log(chalk5.dim(` site_slug: ${config.site_slug}`));
1837
+ console.log(chalk5.dim(` site_name: ${config2.site_name}`));
1838
+ console.log(chalk5.dim(` site_slug: ${config2.site_slug}`));
1824
1839
  }
1825
1840
  } catch (error) {
1826
1841
  if (error.code === "ENOENT") {
@@ -1842,16 +1857,16 @@ async function validatePagesYaml(dcsDir, verbose) {
1842
1857
  };
1843
1858
  try {
1844
1859
  const content = await fs5.readFile(filePath, "utf8");
1845
- const config = yaml.load(content);
1846
- if (config.version === void 0) {
1860
+ const config2 = yaml.load(content);
1861
+ if (config2.version === void 0) {
1847
1862
  result.errors.push("Missing required field: version");
1848
1863
  result.valid = false;
1849
1864
  }
1850
- if (!config.siteSlug) {
1865
+ if (!config2.siteSlug) {
1851
1866
  result.errors.push("Missing required field: siteSlug");
1852
1867
  result.valid = false;
1853
1868
  }
1854
- const pages = config.pages;
1869
+ const pages = config2.pages;
1855
1870
  if (!pages || !Array.isArray(pages)) {
1856
1871
  result.errors.push("Missing or invalid pages array");
1857
1872
  result.valid = false;
@@ -1898,22 +1913,22 @@ async function validateContentYaml(dcsDir, verbose) {
1898
1913
  };
1899
1914
  try {
1900
1915
  const content = await fs5.readFile(filePath, "utf8");
1901
- const config = yaml.load(content);
1902
- if (config.version === void 0) {
1916
+ const config2 = yaml.load(content);
1917
+ if (config2.version === void 0) {
1903
1918
  result.errors.push("Missing required field: version");
1904
1919
  result.valid = false;
1905
1920
  }
1906
- if (config.global !== void 0 && typeof config.global !== "object") {
1921
+ if (config2.global !== void 0 && typeof config2.global !== "object") {
1907
1922
  result.errors.push("global must be an object");
1908
1923
  result.valid = false;
1909
1924
  }
1910
- if (config.pages !== void 0 && typeof config.pages !== "object") {
1925
+ if (config2.pages !== void 0 && typeof config2.pages !== "object") {
1911
1926
  result.errors.push("pages must be an object");
1912
1927
  result.valid = false;
1913
1928
  }
1914
1929
  if (verbose) {
1915
- const globalKeys = config.global ? Object.keys(config.global).length : 0;
1916
- const pageCount = config.pages ? Object.keys(config.pages).length : 0;
1930
+ const globalKeys = config2.global ? Object.keys(config2.global).length : 0;
1931
+ const pageCount = config2.pages ? Object.keys(config2.pages).length : 0;
1917
1932
  console.log(chalk5.dim(` ${globalKeys} global keys, ${pageCount} page sections`));
1918
1933
  }
1919
1934
  } catch (error) {
@@ -1936,12 +1951,12 @@ async function validateSeoYaml(dcsDir, verbose) {
1936
1951
  };
1937
1952
  try {
1938
1953
  const content = await fs5.readFile(filePath, "utf8");
1939
- const config = yaml.load(content);
1940
- if (config.version === void 0) {
1954
+ const config2 = yaml.load(content);
1955
+ if (config2.version === void 0) {
1941
1956
  result.errors.push("Missing required field: version");
1942
1957
  result.valid = false;
1943
1958
  }
1944
- const global = config.global;
1959
+ const global = config2.global;
1945
1960
  if (!global) {
1946
1961
  result.errors.push("Missing required section: global");
1947
1962
  result.valid = false;
@@ -1956,7 +1971,7 @@ async function validateSeoYaml(dcsDir, verbose) {
1956
1971
  result.warnings.push("global.defaultDescription is not set");
1957
1972
  }
1958
1973
  }
1959
- const pages = config.pages;
1974
+ const pages = config2.pages;
1960
1975
  if (pages && typeof pages === "object") {
1961
1976
  const pageKeys = Object.keys(pages);
1962
1977
  if (!pageKeys.includes("home")) {
@@ -2492,6 +2507,49 @@ import path5 from "path";
2492
2507
  import yaml3 from "js-yaml";
2493
2508
  import chalk7 from "chalk";
2494
2509
  import ora3 from "ora";
2510
+
2511
+ // src/commands/redirectedCapture.ts
2512
+ var RedirectedPageError = class extends Error {
2513
+ constructor(requested, landed) {
2514
+ super(`redirected to ${landed}`);
2515
+ this.requested = requested;
2516
+ this.landed = landed;
2517
+ this.name = "RedirectedPageError";
2518
+ }
2519
+ };
2520
+ var SYNTHETIC_CAPTURE_PARAMS = ["dcs-hide-ribbon", "dcs-no-telemetry"];
2521
+ function normalisePath(pathname) {
2522
+ const trimmed = pathname.replace(/\/+$/, "");
2523
+ return trimmed === "" ? "/" : trimmed;
2524
+ }
2525
+ function landedOffPath(requestedUrl, landedUrl) {
2526
+ let requested;
2527
+ let landed;
2528
+ try {
2529
+ requested = new URL(requestedUrl);
2530
+ } catch {
2531
+ return landedUrl;
2532
+ }
2533
+ try {
2534
+ landed = new URL(landedUrl);
2535
+ } catch {
2536
+ return landedUrl;
2537
+ }
2538
+ const sameOrigin = requested.origin === landed.origin;
2539
+ const samePath = normalisePath(requested.pathname) === normalisePath(landed.pathname);
2540
+ if (sameOrigin && samePath) return null;
2541
+ return `${landed.origin}${normalisePath(landed.pathname)}`;
2542
+ }
2543
+ function carriesSyntheticMarker(url) {
2544
+ try {
2545
+ const params = new URL(url).searchParams;
2546
+ return SYNTHETIC_CAPTURE_PARAMS.some((p) => params.has(p));
2547
+ } catch {
2548
+ return false;
2549
+ }
2550
+ }
2551
+
2552
+ // src/commands/capture-snapshots.ts
2495
2553
  var CARD_THUMBNAIL_ASPECT_RATIO = 16 / 9;
2496
2554
  var ASSET_WAIT_TIMEOUT_MS = 5e3;
2497
2555
  async function disableCaptureMotion(page) {
@@ -2673,9 +2731,9 @@ function getValidTextKeysForPage(contentConfig, pageSlug) {
2673
2731
  }
2674
2732
  return keys;
2675
2733
  }
2676
- async function resolveAllPages(config) {
2734
+ async function resolveAllPages(config2) {
2677
2735
  const resolved2 = [];
2678
- for (const page of config.pages) {
2736
+ for (const page of config2.pages) {
2679
2737
  if (page.type === "dynamic" && page.instances) {
2680
2738
  for (const instance of page.instances) {
2681
2739
  const instancePath = page.pathTemplate ? page.pathTemplate.replace(/:[\w]+/, instance) : `${page.path}/${instance}`;
@@ -2811,11 +2869,18 @@ async function capturePageSnapshot(browser, pageInfo, snapshotConfig, siteSlug,
2811
2869
  }
2812
2870
  try {
2813
2871
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
2872
+ const landedElsewhere = landedOffPath(url, page.url());
2873
+ if (landedElsewhere !== null) {
2874
+ const unmarked = !carriesSyntheticMarker(page.url());
2875
+ await context.close();
2876
+ throw new RedirectedPageError(url, `${landedElsewhere}${unmarked ? " (markers dropped)" : ""}`);
2877
+ }
2814
2878
  await disableCaptureMotion(page);
2815
2879
  await page.waitForLoadState("networkidle", { timeout: 1e4 }).catch(() => {
2816
2880
  if (verbose) console.log(` Note: networkidle timeout, continuing anyway`);
2817
2881
  });
2818
2882
  } catch (error) {
2883
+ if (error instanceof RedirectedPageError) throw error;
2819
2884
  console.error(` Failed to load: ${error}`);
2820
2885
  await context.close();
2821
2886
  throw error;
@@ -3066,16 +3131,16 @@ async function captureSnapshotsCommand(options) {
3066
3131
  console.log(chalk7.gray(`Target: ${targetDir}`));
3067
3132
  console.log(chalk7.gray(`Base URL: ${baseUrl}`));
3068
3133
  process.env.SITE_BASE_URL = baseUrl;
3069
- let config;
3134
+ let config2;
3070
3135
  try {
3071
- config = loadPagesConfig(targetDir);
3136
+ config2 = loadPagesConfig(targetDir);
3072
3137
  } catch (error) {
3073
3138
  console.error(chalk7.red("Error:"), error.message);
3074
3139
  console.log(chalk7.yellow("\nMake sure .dcs/pages.yaml exists in the target directory."));
3075
3140
  process.exit(1);
3076
3141
  }
3077
- console.log(chalk7.gray(`Site: ${config.siteSlug}`));
3078
- const outputDir = path5.join(targetDir, config.snapshot.outputDir || ".dcs/snapshots");
3142
+ console.log(chalk7.gray(`Site: ${config2.siteSlug}`));
3143
+ const outputDir = path5.join(targetDir, config2.snapshot.outputDir || ".dcs/snapshots");
3079
3144
  const contentConfig = loadContentConfig(targetDir);
3080
3145
  if (contentConfig) {
3081
3146
  const pageCount = Object.keys(contentConfig.pages || {}).length;
@@ -3104,7 +3169,7 @@ async function captureSnapshotsCommand(options) {
3104
3169
  console.log(chalk7.green("\n\u2705 Snapshot capture skipped (as requested)"));
3105
3170
  return;
3106
3171
  }
3107
- let pagesToCapture = await resolveAllPages(config);
3172
+ let pagesToCapture = await resolveAllPages(config2);
3108
3173
  console.log(chalk7.gray(`Found ${pagesToCapture.length} total pages in configuration`));
3109
3174
  if (targetedPages && targetedPages.length > 0) {
3110
3175
  const targetedSlugs = new Set(targetedPages);
@@ -3143,6 +3208,7 @@ async function captureSnapshotsCommand(options) {
3143
3208
  spinner.succeed("Browser launched");
3144
3209
  const snapshots = [];
3145
3210
  const failures = [];
3211
+ const redirectStubs = [];
3146
3212
  for (const pageInfo of pagesToCapture) {
3147
3213
  const pageSpinner = ora3(`Capturing ${pageInfo.slug}...`).start();
3148
3214
  try {
@@ -3150,17 +3216,24 @@ async function captureSnapshotsCommand(options) {
3150
3216
  const snapshot = await capturePageSnapshot(
3151
3217
  browser,
3152
3218
  pageInfo,
3153
- config.snapshot,
3154
- config.siteSlug,
3219
+ config2.snapshot,
3220
+ config2.siteSlug,
3155
3221
  validTextKeys,
3156
3222
  outputDir,
3157
3223
  verbose || false
3158
3224
  );
3159
3225
  snapshots.push(snapshot);
3160
- const snapshotPath = path5.join(outputDir, config.siteSlug, pageInfo.slug, "snapshot.json");
3226
+ const snapshotPath = path5.join(outputDir, config2.siteSlug, pageInfo.slug, "snapshot.json");
3161
3227
  fs7.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2));
3162
3228
  pageSpinner.succeed(`${pageInfo.slug}: ${snapshot.sections.length} sections captured`);
3163
3229
  } catch (error) {
3230
+ if (error instanceof RedirectedPageError) {
3231
+ redirectStubs.push(`${pageInfo.slug} -> ${error.landed}`);
3232
+ pageSpinner.warn(
3233
+ `${pageInfo.slug}: redirect stub -> ${error.landed} - skipped (C-1553: not a page of its own, and the redirect drops the synthetic markers)`
3234
+ );
3235
+ continue;
3236
+ }
3164
3237
  const message = `${pageInfo.slug}: ${error.message}`;
3165
3238
  failures.push(message);
3166
3239
  pageSpinner.fail(`${pageInfo.slug}: Failed - ${error.message}`);
@@ -3168,11 +3241,11 @@ async function captureSnapshotsCommand(options) {
3168
3241
  }
3169
3242
  await browser.close();
3170
3243
  const manifest = {
3171
- siteSlug: config.siteSlug,
3244
+ siteSlug: config2.siteSlug,
3172
3245
  capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
3173
3246
  capturedVersion: process.env.GITHUB_SHA,
3174
3247
  deploymentRef: process.env.GITHUB_REF || "local",
3175
- pagesConfigVersion: config.version,
3248
+ pagesConfigVersion: config2.version,
3176
3249
  pages: snapshots.map((s) => ({
3177
3250
  pageSlug: s.pageSlug,
3178
3251
  pagePath: s.path,
@@ -3184,7 +3257,7 @@ async function captureSnapshotsCommand(options) {
3184
3257
  hasSnapshot: true
3185
3258
  }))
3186
3259
  };
3187
- const manifestPath = path5.join(outputDir, config.siteSlug, "manifest.json");
3260
+ const manifestPath = path5.join(outputDir, config2.siteSlug, "manifest.json");
3188
3261
  if (isTargetedCapture) {
3189
3262
  console.log(chalk7.gray(" Targeted capture: leaving the existing remote manifest intact"));
3190
3263
  } else {
@@ -3193,6 +3266,9 @@ async function captureSnapshotsCommand(options) {
3193
3266
  console.log("");
3194
3267
  console.log(chalk7.green("\u2705 Snapshot capture complete!"));
3195
3268
  console.log(chalk7.gray(` Captured ${snapshots.length} pages`));
3269
+ if (redirectStubs.length > 0) {
3270
+ console.log(chalk7.yellow(` Skipped ${redirectStubs.length} redirect stub(s): ${redirectStubs.join("; ")}`));
3271
+ }
3196
3272
  console.log(chalk7.gray(` - Static: ${snapshots.filter((s) => s.type === "static").length}`));
3197
3273
  console.log(chalk7.gray(` - Index: ${snapshots.filter((s) => s.type === "index").length}`));
3198
3274
  console.log(chalk7.gray(` - Dynamic: ${snapshots.filter((s) => s.type === "dynamic").length}`));