@duffcloudservices/cli 0.3.2 → 0.4.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/README.md +26 -2
- package/dist/index.js +497 -154
- package/dist/index.js.map +1 -1
- package/package.json +7 -2
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { Command } from "commander";
|
|
|
5
5
|
import chalk8 from "chalk";
|
|
6
6
|
|
|
7
7
|
// package.json
|
|
8
|
-
var version = "0.
|
|
8
|
+
var version = "0.4.0";
|
|
9
9
|
|
|
10
10
|
// src/commands/auth.ts
|
|
11
11
|
import chalk2 from "chalk";
|
|
@@ -17,64 +17,375 @@ import chalk from "chalk";
|
|
|
17
17
|
|
|
18
18
|
// src/auth/credentials.ts
|
|
19
19
|
import Conf from "conf";
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
20
|
+
|
|
21
|
+
// src/auth/keychain/run.ts
|
|
22
|
+
import { spawnSync } from "child_process";
|
|
23
|
+
function run(cmd, args, opts = {}) {
|
|
24
|
+
const options = {
|
|
25
|
+
encoding: "utf8",
|
|
26
|
+
env: opts.env ? { ...process.env, ...opts.env } : process.env,
|
|
27
|
+
windowsHide: true
|
|
28
|
+
};
|
|
29
|
+
if (opts.input !== void 0) options.input = opts.input;
|
|
30
|
+
const res = spawnSync(cmd, args, options);
|
|
31
|
+
if (res.error) {
|
|
32
|
+
return { ok: false, code: null, stdout: "", stderr: String(res.error), spawnError: true };
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
ok: res.status === 0,
|
|
36
|
+
code: res.status,
|
|
37
|
+
stdout: typeof res.stdout === "string" ? res.stdout : "",
|
|
38
|
+
stderr: typeof res.stderr === "string" ? res.stderr : "",
|
|
39
|
+
spawnError: false
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function commandExists(cmd, probeArgs) {
|
|
43
|
+
const res = run(cmd, probeArgs);
|
|
44
|
+
return !res.spawnError;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// src/auth/keychain/types.ts
|
|
48
|
+
var SERVICE = "dcs-cli";
|
|
49
|
+
|
|
50
|
+
// src/auth/keychain/macos.ts
|
|
51
|
+
var macosKeychain = {
|
|
52
|
+
name: "macos-keychain",
|
|
53
|
+
isAvailable() {
|
|
54
|
+
return process.platform === "darwin" && commandExists("security", ["help"]);
|
|
55
|
+
},
|
|
56
|
+
get(account) {
|
|
57
|
+
const res = run("security", [
|
|
58
|
+
"find-generic-password",
|
|
59
|
+
"-s",
|
|
60
|
+
SERVICE,
|
|
61
|
+
"-a",
|
|
62
|
+
account,
|
|
63
|
+
"-w"
|
|
64
|
+
]);
|
|
65
|
+
if (!res.ok) return null;
|
|
66
|
+
return res.stdout.replace(/\n$/, "");
|
|
67
|
+
},
|
|
68
|
+
set(account, secret) {
|
|
69
|
+
const res = run("security", [
|
|
70
|
+
"add-generic-password",
|
|
71
|
+
"-s",
|
|
72
|
+
SERVICE,
|
|
73
|
+
"-a",
|
|
74
|
+
account,
|
|
75
|
+
"-w",
|
|
76
|
+
secret,
|
|
77
|
+
"-U"
|
|
78
|
+
]);
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
throw new Error(`macOS Keychain write failed: ${res.stderr.trim() || res.code}`);
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
delete(account) {
|
|
84
|
+
run("security", ["delete-generic-password", "-s", SERVICE, "-a", account]);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// src/auth/keychain/libsecret.ts
|
|
89
|
+
var libsecretKeychain = {
|
|
90
|
+
name: "libsecret",
|
|
91
|
+
isAvailable() {
|
|
92
|
+
return process.platform === "linux" && commandExists("secret-tool", ["--version"]);
|
|
93
|
+
},
|
|
94
|
+
get(account) {
|
|
95
|
+
const res = run("secret-tool", ["lookup", "service", SERVICE, "account", account]);
|
|
96
|
+
if (!res.ok) return null;
|
|
97
|
+
return res.stdout.replace(/\n$/, "");
|
|
98
|
+
},
|
|
99
|
+
set(account, secret) {
|
|
100
|
+
const res = run(
|
|
101
|
+
"secret-tool",
|
|
102
|
+
["store", "--label", `${SERVICE}:${account}`, "service", SERVICE, "account", account],
|
|
103
|
+
{ input: secret }
|
|
104
|
+
);
|
|
105
|
+
if (!res.ok) {
|
|
106
|
+
throw new Error(`libsecret write failed: ${res.stderr.trim() || res.code}`);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
delete(account) {
|
|
110
|
+
run("secret-tool", ["clear", "service", SERVICE, "account", account]);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// src/auth/keychain/windows.ts
|
|
115
|
+
import fs3 from "fs";
|
|
116
|
+
|
|
117
|
+
// src/auth/keychain/paths.ts
|
|
118
|
+
import os from "os";
|
|
119
|
+
import path from "path";
|
|
120
|
+
import fs from "fs";
|
|
121
|
+
var APP_DIR = "dcs-cli-nodejs";
|
|
122
|
+
function configDir() {
|
|
123
|
+
const override2 = process.env.DCS_CLI_CONFIG_DIR;
|
|
124
|
+
if (override2) return override2;
|
|
125
|
+
if (process.platform === "win32") {
|
|
126
|
+
const base2 = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
|
127
|
+
return path.join(base2, APP_DIR);
|
|
128
|
+
}
|
|
129
|
+
if (process.platform === "darwin") {
|
|
130
|
+
return path.join(os.homedir(), "Library", "Application Support", APP_DIR);
|
|
131
|
+
}
|
|
132
|
+
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
133
|
+
return path.join(base, APP_DIR);
|
|
134
|
+
}
|
|
135
|
+
function secretFile(account, ext) {
|
|
136
|
+
const safe = account.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
137
|
+
return path.join(configDir(), `${safe}.${ext}`);
|
|
138
|
+
}
|
|
139
|
+
function ensureConfigDir() {
|
|
140
|
+
const dir = configDir();
|
|
141
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
142
|
+
return dir;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// src/auth/keychain/encrypted-file.ts
|
|
146
|
+
import fs2 from "fs";
|
|
147
|
+
import os2 from "os";
|
|
148
|
+
import crypto from "crypto";
|
|
149
|
+
var EXT = "enc";
|
|
150
|
+
var MAGIC = "DCSK1";
|
|
151
|
+
var STATIC_SALT = Buffer.from("dcs-cli-keychain-v1-salt", "utf8");
|
|
152
|
+
function deriveKey() {
|
|
153
|
+
let username = "unknown";
|
|
154
|
+
try {
|
|
155
|
+
username = os2.userInfo().username;
|
|
156
|
+
} catch {
|
|
157
|
+
}
|
|
158
|
+
const material = `${username}\0${os2.hostname()}\0dcs-cli`;
|
|
159
|
+
return crypto.scryptSync(material, STATIC_SALT, 32);
|
|
160
|
+
}
|
|
161
|
+
var encryptedFile = {
|
|
162
|
+
name: "encrypted-file",
|
|
163
|
+
isAvailable() {
|
|
164
|
+
return true;
|
|
165
|
+
},
|
|
166
|
+
get(account) {
|
|
167
|
+
let raw;
|
|
168
|
+
try {
|
|
169
|
+
raw = fs2.readFileSync(secretFile(account, EXT), "utf8").trim();
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
const parts = raw.split(":");
|
|
174
|
+
if (parts.length !== 4 || parts[0] !== MAGIC) return null;
|
|
175
|
+
try {
|
|
176
|
+
const iv = Buffer.from(parts[1], "base64");
|
|
177
|
+
const tag = Buffer.from(parts[2], "base64");
|
|
178
|
+
const ciphertext = Buffer.from(parts[3], "base64");
|
|
179
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", deriveKey(), iv);
|
|
180
|
+
decipher.setAuthTag(tag);
|
|
181
|
+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
182
|
+
return plaintext.toString("utf8");
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
set(account, secret) {
|
|
188
|
+
ensureConfigDir();
|
|
189
|
+
const iv = crypto.randomBytes(12);
|
|
190
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", deriveKey(), iv);
|
|
191
|
+
const ciphertext = Buffer.concat([cipher.update(secret, "utf8"), cipher.final()]);
|
|
192
|
+
const tag = cipher.getAuthTag();
|
|
193
|
+
const envelope = [
|
|
194
|
+
MAGIC,
|
|
195
|
+
iv.toString("base64"),
|
|
196
|
+
tag.toString("base64"),
|
|
197
|
+
ciphertext.toString("base64")
|
|
198
|
+
].join(":");
|
|
199
|
+
fs2.writeFileSync(secretFile(account, EXT), envelope, { encoding: "utf8", mode: 384 });
|
|
200
|
+
},
|
|
201
|
+
delete(account) {
|
|
202
|
+
try {
|
|
203
|
+
fs2.unlinkSync(secretFile(account, EXT));
|
|
204
|
+
} catch {
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// src/auth/keychain/windows.ts
|
|
210
|
+
var EXT2 = "dpapi";
|
|
211
|
+
var PROTECT = "$ErrorActionPreference='Stop'; Add-Type -AssemblyName System.Security; $b=[Text.Encoding]::UTF8.GetBytes($env:DCS_KEYCHAIN_SECRET); $p=[Security.Cryptography.ProtectedData]::Protect($b,$null,'CurrentUser'); [Convert]::ToBase64String($p)";
|
|
212
|
+
var UNPROTECT = "$ErrorActionPreference='Stop'; Add-Type -AssemblyName System.Security; $p=[Convert]::FromBase64String($env:DCS_KEYCHAIN_ENC); $b=[Security.Cryptography.ProtectedData]::Unprotect($p,$null,'CurrentUser'); [Text.Encoding]::UTF8.GetString($b)";
|
|
213
|
+
function powershell(script, env) {
|
|
214
|
+
return run("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], { env });
|
|
215
|
+
}
|
|
216
|
+
function deleteDpapiSidecar(account) {
|
|
217
|
+
try {
|
|
218
|
+
fs3.unlinkSync(secretFile(account, EXT2));
|
|
219
|
+
} catch {
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
var dpapiUnavailable = false;
|
|
223
|
+
var windowsDpapi = {
|
|
224
|
+
name: "windows-dpapi",
|
|
225
|
+
isAvailable() {
|
|
226
|
+
return process.platform === "win32" && !dpapiUnavailable;
|
|
227
|
+
},
|
|
228
|
+
get(account) {
|
|
229
|
+
if (dpapiUnavailable) return encryptedFile.get(account);
|
|
230
|
+
let enc;
|
|
231
|
+
try {
|
|
232
|
+
enc = fs3.readFileSync(secretFile(account, EXT2), "utf8").trim();
|
|
233
|
+
} catch {
|
|
234
|
+
return encryptedFile.get(account);
|
|
235
|
+
}
|
|
236
|
+
if (!enc) return null;
|
|
237
|
+
const res = powershell(UNPROTECT, { DCS_KEYCHAIN_ENC: enc });
|
|
238
|
+
if (!res.ok) {
|
|
239
|
+
if (res.spawnError) {
|
|
240
|
+
dpapiUnavailable = true;
|
|
241
|
+
return encryptedFile.get(account);
|
|
31
242
|
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
return res.stdout.replace(/\r?\n$/, "");
|
|
246
|
+
},
|
|
247
|
+
set(account, secret) {
|
|
248
|
+
if (dpapiUnavailable) {
|
|
249
|
+
encryptedFile.set(account, secret);
|
|
250
|
+
deleteDpapiSidecar(account);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
ensureConfigDir();
|
|
254
|
+
const res = powershell(PROTECT, { DCS_KEYCHAIN_SECRET: secret });
|
|
255
|
+
if (!res.ok || !res.stdout.trim()) {
|
|
256
|
+
dpapiUnavailable = true;
|
|
257
|
+
encryptedFile.set(account, secret);
|
|
258
|
+
deleteDpapiSidecar(account);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
fs3.writeFileSync(secretFile(account, EXT2), res.stdout.trim(), { encoding: "utf8", mode: 384 });
|
|
262
|
+
encryptedFile.delete(account);
|
|
263
|
+
},
|
|
264
|
+
delete(account) {
|
|
265
|
+
deleteDpapiSidecar(account);
|
|
266
|
+
encryptedFile.delete(account);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
// src/auth/keychain/index.ts
|
|
271
|
+
var CANDIDATES = [macosKeychain, libsecretKeychain, windowsDpapi];
|
|
272
|
+
var resolved;
|
|
273
|
+
var override = null;
|
|
274
|
+
function resolve() {
|
|
275
|
+
for (const backend of CANDIDATES) {
|
|
276
|
+
try {
|
|
277
|
+
if (backend.isAvailable()) return backend;
|
|
278
|
+
} catch {
|
|
32
279
|
}
|
|
33
280
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
281
|
+
return encryptedFile;
|
|
282
|
+
}
|
|
283
|
+
function getKeychain() {
|
|
284
|
+
if (override) return override;
|
|
285
|
+
if (!resolved) resolved = resolve();
|
|
286
|
+
return resolved;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// src/auth/credentials.ts
|
|
290
|
+
var ACCOUNT = "auth";
|
|
291
|
+
function defaultLegacyStore() {
|
|
292
|
+
const config = new Conf({ projectName: "dcs-cli" });
|
|
293
|
+
return {
|
|
294
|
+
read: () => config.get("auth"),
|
|
295
|
+
clear: () => {
|
|
296
|
+
if (config.has("auth")) config.delete("auth");
|
|
297
|
+
}
|
|
298
|
+
};
|
|
37
299
|
}
|
|
38
|
-
|
|
39
|
-
|
|
300
|
+
var legacyStore;
|
|
301
|
+
function getLegacyStore() {
|
|
302
|
+
if (!legacyStore) legacyStore = defaultLegacyStore();
|
|
303
|
+
return legacyStore;
|
|
304
|
+
}
|
|
305
|
+
var cache;
|
|
306
|
+
function load() {
|
|
307
|
+
if (cache !== void 0) return cache;
|
|
308
|
+
const raw = getKeychain().get(ACCOUNT);
|
|
309
|
+
if (raw) {
|
|
310
|
+
try {
|
|
311
|
+
cache = JSON.parse(raw);
|
|
312
|
+
return cache;
|
|
313
|
+
} catch {
|
|
314
|
+
getKeychain().delete(ACCOUNT);
|
|
315
|
+
cache = null;
|
|
316
|
+
return cache;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const legacy = getLegacyStore().read();
|
|
320
|
+
if (legacy) {
|
|
321
|
+
getKeychain().set(ACCOUNT, JSON.stringify(legacy));
|
|
322
|
+
getLegacyStore().clear();
|
|
323
|
+
cache = legacy;
|
|
324
|
+
return cache;
|
|
325
|
+
}
|
|
326
|
+
cache = null;
|
|
327
|
+
return cache;
|
|
328
|
+
}
|
|
329
|
+
function persist(creds) {
|
|
330
|
+
getKeychain().set(ACCOUNT, JSON.stringify(creds));
|
|
331
|
+
cache = creds;
|
|
332
|
+
getLegacyStore().clear();
|
|
333
|
+
}
|
|
334
|
+
function storeCredentials(credentials) {
|
|
335
|
+
persist(credentials);
|
|
40
336
|
}
|
|
41
337
|
function clearCredentials() {
|
|
42
|
-
|
|
338
|
+
getKeychain().delete(ACCOUNT);
|
|
339
|
+
getLegacyStore().clear();
|
|
340
|
+
cache = null;
|
|
43
341
|
}
|
|
44
342
|
function isAuthenticated() {
|
|
45
|
-
const creds =
|
|
343
|
+
const creds = load();
|
|
46
344
|
if (!creds) return false;
|
|
47
345
|
const bufferMs = 5 * 60 * 1e3;
|
|
48
346
|
return creds.expiresAt > Date.now() + bufferMs;
|
|
49
347
|
}
|
|
348
|
+
function hasStoredSession() {
|
|
349
|
+
return load() !== null;
|
|
350
|
+
}
|
|
50
351
|
function getCurrentUserEmail() {
|
|
51
|
-
return
|
|
352
|
+
return load()?.email;
|
|
52
353
|
}
|
|
53
354
|
function getAccessToken() {
|
|
54
|
-
|
|
55
|
-
if (!creds) return void 0;
|
|
56
|
-
return creds.accessToken;
|
|
355
|
+
return load()?.accessToken;
|
|
57
356
|
}
|
|
58
357
|
function getRefreshToken() {
|
|
59
|
-
return
|
|
358
|
+
return load()?.refreshToken;
|
|
60
359
|
}
|
|
61
360
|
function updateAccessToken(accessToken, expiresIn) {
|
|
62
|
-
const creds =
|
|
361
|
+
const creds = load();
|
|
63
362
|
if (!creds) return;
|
|
64
|
-
|
|
363
|
+
persist({
|
|
65
364
|
...creds,
|
|
66
365
|
accessToken,
|
|
67
366
|
expiresAt: Date.now() + expiresIn * 1e3
|
|
68
367
|
});
|
|
69
368
|
}
|
|
70
|
-
function
|
|
71
|
-
return
|
|
369
|
+
function getStorageBackendName() {
|
|
370
|
+
return getKeychain().name;
|
|
72
371
|
}
|
|
73
372
|
|
|
74
373
|
// src/auth/device-flow.ts
|
|
75
374
|
var DEFAULT_API_URL = "https://portal.duffcloudservices.com";
|
|
375
|
+
var RefreshTokenRevokedError = class extends Error {
|
|
376
|
+
constructor(message = "Refresh token was rejected (expired or revoked).") {
|
|
377
|
+
super(message);
|
|
378
|
+
this.name = "RefreshTokenRevokedError";
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
var ReauthRequiredError = class extends Error {
|
|
382
|
+
constructor(message = "Your CLI session expired or was revoked. Run `dcs login` to sign in again.") {
|
|
383
|
+
super(message);
|
|
384
|
+
this.name = "ReauthRequiredError";
|
|
385
|
+
}
|
|
386
|
+
};
|
|
76
387
|
function sleep(ms) {
|
|
77
|
-
return new Promise((
|
|
388
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
78
389
|
}
|
|
79
390
|
async function startDeviceAuth(options = {}) {
|
|
80
391
|
const apiUrl = options.apiUrl || DEFAULT_API_URL;
|
|
@@ -201,8 +512,11 @@ async function refreshAccessToken(refreshToken, options = {}) {
|
|
|
201
512
|
},
|
|
202
513
|
body: JSON.stringify({ refresh_token: refreshToken })
|
|
203
514
|
});
|
|
515
|
+
if (response.status === 401) {
|
|
516
|
+
throw new RefreshTokenRevokedError();
|
|
517
|
+
}
|
|
204
518
|
if (!response.ok) {
|
|
205
|
-
throw new Error(
|
|
519
|
+
throw new Error(`Failed to refresh access token (HTTP ${response.status}).`);
|
|
206
520
|
}
|
|
207
521
|
const data = await response.json();
|
|
208
522
|
return {
|
|
@@ -234,7 +548,7 @@ async function whoamiCommand() {
|
|
|
234
548
|
return;
|
|
235
549
|
}
|
|
236
550
|
console.log("Logged in as:", chalk2.green(email));
|
|
237
|
-
console.log(chalk2.dim("
|
|
551
|
+
console.log(chalk2.dim("Token storage:"), chalk2.dim(getStorageBackendName()));
|
|
238
552
|
}
|
|
239
553
|
|
|
240
554
|
// src/commands/sites.ts
|
|
@@ -248,36 +562,59 @@ var PortalClient = class {
|
|
|
248
562
|
constructor(options = {}) {
|
|
249
563
|
this.apiUrl = options.apiUrl || process.env.DCS_API_URL || DEFAULT_API_URL2;
|
|
250
564
|
}
|
|
565
|
+
/**
|
|
566
|
+
* Refresh the session using the stored refresh token. Exactly one attempt —
|
|
567
|
+
* never a retry loop. On a revoked/expired refresh token the dead local
|
|
568
|
+
* session is dropped and a clean re-auth is demanded; transient failures
|
|
569
|
+
* (network/5xx/429) keep the session and surface a retryable error.
|
|
570
|
+
*/
|
|
571
|
+
async refreshSession() {
|
|
572
|
+
const refreshToken = getRefreshToken();
|
|
573
|
+
try {
|
|
574
|
+
const { accessToken: newToken, expiresIn } = await refreshAccessToken(refreshToken, {
|
|
575
|
+
apiUrl: this.apiUrl
|
|
576
|
+
});
|
|
577
|
+
updateAccessToken(newToken, expiresIn);
|
|
578
|
+
return newToken;
|
|
579
|
+
} catch (error) {
|
|
580
|
+
if (error instanceof RefreshTokenRevokedError) {
|
|
581
|
+
clearCredentials();
|
|
582
|
+
throw new ReauthRequiredError();
|
|
583
|
+
}
|
|
584
|
+
throw new Error(
|
|
585
|
+
`Could not reach the DCS Portal to refresh your session: ${error instanceof Error ? error.message : String(error)}`
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
251
589
|
/**
|
|
252
590
|
* Make an authenticated API request.
|
|
253
591
|
*/
|
|
254
|
-
async request(
|
|
592
|
+
async request(path6, options = {}) {
|
|
255
593
|
let accessToken = getAccessToken();
|
|
594
|
+
let refreshedThisRequest = false;
|
|
256
595
|
if (!isAuthenticated() && getRefreshToken()) {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const { accessToken: newToken, expiresIn } = await refreshAccessToken(refreshToken, {
|
|
260
|
-
apiUrl: this.apiUrl
|
|
261
|
-
});
|
|
262
|
-
updateAccessToken(newToken, expiresIn);
|
|
263
|
-
accessToken = newToken;
|
|
264
|
-
} catch {
|
|
265
|
-
throw new Error("Session expired. Please login again with `dcs login`.");
|
|
266
|
-
}
|
|
596
|
+
accessToken = await this.refreshSession();
|
|
597
|
+
refreshedThisRequest = true;
|
|
267
598
|
}
|
|
268
599
|
if (!accessToken) {
|
|
269
|
-
throw new
|
|
600
|
+
throw new ReauthRequiredError("Not authenticated. Run `dcs login` to sign in.");
|
|
270
601
|
}
|
|
271
|
-
const
|
|
602
|
+
const send = (token) => fetch(`${this.apiUrl}${path6}`, {
|
|
272
603
|
...options,
|
|
273
604
|
headers: {
|
|
274
605
|
...options.headers,
|
|
275
|
-
Authorization: `Bearer ${
|
|
606
|
+
Authorization: `Bearer ${token}`,
|
|
276
607
|
"Content-Type": "application/json"
|
|
277
608
|
}
|
|
278
609
|
});
|
|
610
|
+
let response = await send(accessToken);
|
|
611
|
+
if (response.status === 401 && !refreshedThisRequest && getRefreshToken()) {
|
|
612
|
+
const newToken = await this.refreshSession();
|
|
613
|
+
response = await send(newToken);
|
|
614
|
+
}
|
|
279
615
|
if (response.status === 401) {
|
|
280
|
-
|
|
616
|
+
clearCredentials();
|
|
617
|
+
throw new ReauthRequiredError();
|
|
281
618
|
}
|
|
282
619
|
if (!response.ok) {
|
|
283
620
|
const error = await response.text();
|
|
@@ -340,7 +677,7 @@ function getPortalClient(options) {
|
|
|
340
677
|
|
|
341
678
|
// src/commands/sites.ts
|
|
342
679
|
async function listSitesCommand() {
|
|
343
|
-
if (!
|
|
680
|
+
if (!hasStoredSession()) {
|
|
344
681
|
console.log(chalk3.yellow("Not logged in."));
|
|
345
682
|
console.log("Run", chalk3.cyan("dcs login"), "to authenticate.");
|
|
346
683
|
process.exit(1);
|
|
@@ -387,7 +724,7 @@ async function listSitesCommand() {
|
|
|
387
724
|
}
|
|
388
725
|
}
|
|
389
726
|
async function showSiteCommand(slug) {
|
|
390
|
-
if (!
|
|
727
|
+
if (!hasStoredSession()) {
|
|
391
728
|
console.log(chalk3.yellow("Not logged in."));
|
|
392
729
|
console.log("Run", chalk3.cyan("dcs login"), "to authenticate.");
|
|
393
730
|
process.exit(1);
|
|
@@ -435,8 +772,8 @@ function displaySiteDetails(site) {
|
|
|
435
772
|
}
|
|
436
773
|
|
|
437
774
|
// src/commands/init.ts
|
|
438
|
-
import
|
|
439
|
-
import
|
|
775
|
+
import fs4 from "fs/promises";
|
|
776
|
+
import path2 from "path";
|
|
440
777
|
import chalk4 from "chalk";
|
|
441
778
|
import ora2 from "ora";
|
|
442
779
|
|
|
@@ -475,8 +812,9 @@ jobs:
|
|
|
475
812
|
deploy:
|
|
476
813
|
name: Deploy to Azure Static Web App
|
|
477
814
|
runs-on: ubuntu-latest
|
|
478
|
-
# Skip deployment on branch creation events
|
|
479
|
-
|
|
815
|
+
# Skip deployment on branch creation events, but always run manual dispatches
|
|
816
|
+
# (workflow_dispatch has no github.event.before).
|
|
817
|
+
if: github.event_name == 'workflow_dispatch' || github.event.before != '0000000000000000000000000000000000000000'
|
|
480
818
|
|
|
481
819
|
steps:
|
|
482
820
|
- name: Determine deployment environment
|
|
@@ -564,10 +902,15 @@ jobs:
|
|
|
564
902
|
SITE_SLUG="\${{ steps.config.outputs.site_slug }}"
|
|
565
903
|
PORTAL_API_URL="\${{ steps.config.outputs.portal_api_url }}"
|
|
566
904
|
|
|
905
|
+
# Deployment environment: "preview" lets the server issue the token for a
|
|
906
|
+
# dedicated preview SWA when PortalSites.PreviewStaticWebAppResourceId is set.
|
|
907
|
+
ENVIRONMENT="\${{ steps.environment.outputs.swa_environment }}"
|
|
908
|
+
ENVIRONMENT="\${ENVIRONMENT:-production}"
|
|
909
|
+
|
|
567
910
|
RESPONSE=$(curl -s -X POST "\${PORTAL_API_URL}/api/v1/sites/deployment-tokens" \\
|
|
568
911
|
-H "Authorization: Bearer $ACCESS_TOKEN" \\
|
|
569
912
|
-H "Content-Type: application/json" \\
|
|
570
|
-
-d "{\\"siteName\\": \\"$SITE_NAME\\", \\"siteSlug\\": \\"$SITE_SLUG\\"}")
|
|
913
|
+
-d "{\\"siteName\\": \\"$SITE_NAME\\", \\"siteSlug\\": \\"$SITE_SLUG\\", \\"environment\\": \\"$ENVIRONMENT\\"}")
|
|
571
914
|
|
|
572
915
|
SWA_TOKEN=$(echo $RESPONSE | jq -r .swaToken)
|
|
573
916
|
|
|
@@ -638,7 +981,7 @@ jobs:
|
|
|
638
981
|
// src/commands/init.ts
|
|
639
982
|
async function initCommand(options) {
|
|
640
983
|
const { siteSlug, siteName, framework = "vue", target = ".", dryRun = false, force = false } = options;
|
|
641
|
-
if (!
|
|
984
|
+
if (!hasStoredSession()) {
|
|
642
985
|
console.log(chalk4.yellow("Not logged in."));
|
|
643
986
|
console.log("Run", chalk4.cyan("dcs login"), "to authenticate.");
|
|
644
987
|
process.exit(1);
|
|
@@ -669,7 +1012,7 @@ async function initCommand(options) {
|
|
|
669
1012
|
}
|
|
670
1013
|
process.exit(1);
|
|
671
1014
|
}
|
|
672
|
-
const targetDir =
|
|
1015
|
+
const targetDir = path2.resolve(target);
|
|
673
1016
|
console.log(chalk4.dim(`Target directory: ${targetDir}`));
|
|
674
1017
|
const generatedFiles = [];
|
|
675
1018
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -721,21 +1064,21 @@ async function initCommand(options) {
|
|
|
721
1064
|
console.log();
|
|
722
1065
|
}
|
|
723
1066
|
async function writeFile(targetDir, relativePath, content, options) {
|
|
724
|
-
const fullPath =
|
|
1067
|
+
const fullPath = path2.join(targetDir, relativePath);
|
|
725
1068
|
if (options.dryRun) {
|
|
726
1069
|
console.log(chalk4.dim(`Would create: ${relativePath}`));
|
|
727
1070
|
return relativePath;
|
|
728
1071
|
}
|
|
729
1072
|
try {
|
|
730
|
-
await
|
|
1073
|
+
await fs4.access(fullPath);
|
|
731
1074
|
if (!options.force) {
|
|
732
1075
|
console.log(chalk4.yellow(`Skipped (exists): ${relativePath}`));
|
|
733
1076
|
return relativePath;
|
|
734
1077
|
}
|
|
735
1078
|
} catch {
|
|
736
1079
|
}
|
|
737
|
-
await
|
|
738
|
-
await
|
|
1080
|
+
await fs4.mkdir(path2.dirname(fullPath), { recursive: true });
|
|
1081
|
+
await fs4.writeFile(fullPath, content, "utf8");
|
|
739
1082
|
return relativePath;
|
|
740
1083
|
}
|
|
741
1084
|
function generateSiteYaml(data) {
|
|
@@ -1346,19 +1689,19 @@ After completing this plan:
|
|
|
1346
1689
|
}
|
|
1347
1690
|
|
|
1348
1691
|
// src/commands/validate.ts
|
|
1349
|
-
import
|
|
1350
|
-
import
|
|
1692
|
+
import fs5 from "fs/promises";
|
|
1693
|
+
import path3 from "path";
|
|
1351
1694
|
import chalk5 from "chalk";
|
|
1352
1695
|
import yaml from "js-yaml";
|
|
1353
1696
|
async function validateCommand(options) {
|
|
1354
1697
|
const { target = ".", fix = false, verbose = false } = options;
|
|
1355
|
-
const targetDir =
|
|
1356
|
-
const dcsDir =
|
|
1698
|
+
const targetDir = path3.resolve(target);
|
|
1699
|
+
const dcsDir = path3.join(targetDir, ".dcs");
|
|
1357
1700
|
console.log(chalk5.bold("Validating DCS configuration..."));
|
|
1358
1701
|
console.log(chalk5.dim(`Directory: ${targetDir}`));
|
|
1359
1702
|
console.log();
|
|
1360
1703
|
try {
|
|
1361
|
-
await
|
|
1704
|
+
await fs5.access(dcsDir);
|
|
1362
1705
|
} catch {
|
|
1363
1706
|
console.log(chalk5.red("No .dcs directory found."));
|
|
1364
1707
|
console.log("Run", chalk5.cyan("dcs init"), "to create DCS configuration.");
|
|
@@ -1404,7 +1747,7 @@ async function validateCommand(options) {
|
|
|
1404
1747
|
}
|
|
1405
1748
|
}
|
|
1406
1749
|
async function validateSiteYaml(dcsDir, verbose) {
|
|
1407
|
-
const filePath =
|
|
1750
|
+
const filePath = path3.join(dcsDir, "site.yaml");
|
|
1408
1751
|
const result = {
|
|
1409
1752
|
file: ".dcs/site.yaml",
|
|
1410
1753
|
valid: true,
|
|
@@ -1412,28 +1755,28 @@ async function validateSiteYaml(dcsDir, verbose) {
|
|
|
1412
1755
|
warnings: []
|
|
1413
1756
|
};
|
|
1414
1757
|
try {
|
|
1415
|
-
const content = await
|
|
1416
|
-
const
|
|
1417
|
-
if (!
|
|
1758
|
+
const content = await fs5.readFile(filePath, "utf8");
|
|
1759
|
+
const config = yaml.load(content);
|
|
1760
|
+
if (!config.site_name) {
|
|
1418
1761
|
result.errors.push("Missing required field: site_name");
|
|
1419
1762
|
result.valid = false;
|
|
1420
1763
|
}
|
|
1421
|
-
if (!
|
|
1764
|
+
if (!config.site_slug) {
|
|
1422
1765
|
result.errors.push("Missing required field: site_slug");
|
|
1423
1766
|
result.valid = false;
|
|
1424
|
-
} else if (!/^[a-z0-9-]+$/.test(
|
|
1767
|
+
} else if (!/^[a-z0-9-]+$/.test(config.site_slug)) {
|
|
1425
1768
|
result.errors.push("site_slug must be lowercase alphanumeric with hyphens only");
|
|
1426
1769
|
result.valid = false;
|
|
1427
1770
|
}
|
|
1428
|
-
if (!
|
|
1771
|
+
if (!config.swa_resource_id) {
|
|
1429
1772
|
result.warnings.push("swa_resource_id is not set (required for deployment)");
|
|
1430
1773
|
}
|
|
1431
|
-
if (!
|
|
1774
|
+
if (!config.production_url) {
|
|
1432
1775
|
result.warnings.push("production_url is not set");
|
|
1433
1776
|
}
|
|
1434
1777
|
if (verbose) {
|
|
1435
|
-
console.log(chalk5.dim(` site_name: ${
|
|
1436
|
-
console.log(chalk5.dim(` site_slug: ${
|
|
1778
|
+
console.log(chalk5.dim(` site_name: ${config.site_name}`));
|
|
1779
|
+
console.log(chalk5.dim(` site_slug: ${config.site_slug}`));
|
|
1437
1780
|
}
|
|
1438
1781
|
} catch (error) {
|
|
1439
1782
|
if (error.code === "ENOENT") {
|
|
@@ -1446,7 +1789,7 @@ async function validateSiteYaml(dcsDir, verbose) {
|
|
|
1446
1789
|
return result;
|
|
1447
1790
|
}
|
|
1448
1791
|
async function validatePagesYaml(dcsDir, verbose) {
|
|
1449
|
-
const filePath =
|
|
1792
|
+
const filePath = path3.join(dcsDir, "pages.yaml");
|
|
1450
1793
|
const result = {
|
|
1451
1794
|
file: ".dcs/pages.yaml",
|
|
1452
1795
|
valid: true,
|
|
@@ -1454,17 +1797,17 @@ async function validatePagesYaml(dcsDir, verbose) {
|
|
|
1454
1797
|
warnings: []
|
|
1455
1798
|
};
|
|
1456
1799
|
try {
|
|
1457
|
-
const content = await
|
|
1458
|
-
const
|
|
1459
|
-
if (
|
|
1800
|
+
const content = await fs5.readFile(filePath, "utf8");
|
|
1801
|
+
const config = yaml.load(content);
|
|
1802
|
+
if (config.version === void 0) {
|
|
1460
1803
|
result.errors.push("Missing required field: version");
|
|
1461
1804
|
result.valid = false;
|
|
1462
1805
|
}
|
|
1463
|
-
if (!
|
|
1806
|
+
if (!config.siteSlug) {
|
|
1464
1807
|
result.errors.push("Missing required field: siteSlug");
|
|
1465
1808
|
result.valid = false;
|
|
1466
1809
|
}
|
|
1467
|
-
const pages =
|
|
1810
|
+
const pages = config.pages;
|
|
1468
1811
|
if (!pages || !Array.isArray(pages)) {
|
|
1469
1812
|
result.errors.push("Missing or invalid pages array");
|
|
1470
1813
|
result.valid = false;
|
|
@@ -1502,7 +1845,7 @@ async function validatePagesYaml(dcsDir, verbose) {
|
|
|
1502
1845
|
return result;
|
|
1503
1846
|
}
|
|
1504
1847
|
async function validateContentYaml(dcsDir, verbose) {
|
|
1505
|
-
const filePath =
|
|
1848
|
+
const filePath = path3.join(dcsDir, "content.yaml");
|
|
1506
1849
|
const result = {
|
|
1507
1850
|
file: ".dcs/content.yaml",
|
|
1508
1851
|
valid: true,
|
|
@@ -1510,23 +1853,23 @@ async function validateContentYaml(dcsDir, verbose) {
|
|
|
1510
1853
|
warnings: []
|
|
1511
1854
|
};
|
|
1512
1855
|
try {
|
|
1513
|
-
const content = await
|
|
1514
|
-
const
|
|
1515
|
-
if (
|
|
1856
|
+
const content = await fs5.readFile(filePath, "utf8");
|
|
1857
|
+
const config = yaml.load(content);
|
|
1858
|
+
if (config.version === void 0) {
|
|
1516
1859
|
result.errors.push("Missing required field: version");
|
|
1517
1860
|
result.valid = false;
|
|
1518
1861
|
}
|
|
1519
|
-
if (
|
|
1862
|
+
if (config.global !== void 0 && typeof config.global !== "object") {
|
|
1520
1863
|
result.errors.push("global must be an object");
|
|
1521
1864
|
result.valid = false;
|
|
1522
1865
|
}
|
|
1523
|
-
if (
|
|
1866
|
+
if (config.pages !== void 0 && typeof config.pages !== "object") {
|
|
1524
1867
|
result.errors.push("pages must be an object");
|
|
1525
1868
|
result.valid = false;
|
|
1526
1869
|
}
|
|
1527
1870
|
if (verbose) {
|
|
1528
|
-
const globalKeys =
|
|
1529
|
-
const pageCount =
|
|
1871
|
+
const globalKeys = config.global ? Object.keys(config.global).length : 0;
|
|
1872
|
+
const pageCount = config.pages ? Object.keys(config.pages).length : 0;
|
|
1530
1873
|
console.log(chalk5.dim(` ${globalKeys} global keys, ${pageCount} page sections`));
|
|
1531
1874
|
}
|
|
1532
1875
|
} catch (error) {
|
|
@@ -1540,7 +1883,7 @@ async function validateContentYaml(dcsDir, verbose) {
|
|
|
1540
1883
|
return result;
|
|
1541
1884
|
}
|
|
1542
1885
|
async function validateSeoYaml(dcsDir, verbose) {
|
|
1543
|
-
const filePath =
|
|
1886
|
+
const filePath = path3.join(dcsDir, "seo.yaml");
|
|
1544
1887
|
const result = {
|
|
1545
1888
|
file: ".dcs/seo.yaml",
|
|
1546
1889
|
valid: true,
|
|
@@ -1548,13 +1891,13 @@ async function validateSeoYaml(dcsDir, verbose) {
|
|
|
1548
1891
|
warnings: []
|
|
1549
1892
|
};
|
|
1550
1893
|
try {
|
|
1551
|
-
const content = await
|
|
1552
|
-
const
|
|
1553
|
-
if (
|
|
1894
|
+
const content = await fs5.readFile(filePath, "utf8");
|
|
1895
|
+
const config = yaml.load(content);
|
|
1896
|
+
if (config.version === void 0) {
|
|
1554
1897
|
result.errors.push("Missing required field: version");
|
|
1555
1898
|
result.valid = false;
|
|
1556
1899
|
}
|
|
1557
|
-
const global =
|
|
1900
|
+
const global = config.global;
|
|
1558
1901
|
if (!global) {
|
|
1559
1902
|
result.errors.push("Missing required section: global");
|
|
1560
1903
|
result.valid = false;
|
|
@@ -1569,7 +1912,7 @@ async function validateSeoYaml(dcsDir, verbose) {
|
|
|
1569
1912
|
result.warnings.push("global.defaultDescription is not set");
|
|
1570
1913
|
}
|
|
1571
1914
|
}
|
|
1572
|
-
const pages =
|
|
1915
|
+
const pages = config.pages;
|
|
1573
1916
|
if (pages && typeof pages === "object") {
|
|
1574
1917
|
const pageKeys = Object.keys(pages);
|
|
1575
1918
|
if (!pageKeys.includes("home")) {
|
|
@@ -1591,20 +1934,20 @@ async function validateSeoYaml(dcsDir, verbose) {
|
|
|
1591
1934
|
}
|
|
1592
1935
|
|
|
1593
1936
|
// src/commands/plans.ts
|
|
1594
|
-
import
|
|
1595
|
-
import
|
|
1937
|
+
import fs6 from "fs/promises";
|
|
1938
|
+
import path4 from "path";
|
|
1596
1939
|
import chalk6 from "chalk";
|
|
1597
1940
|
import yaml2 from "js-yaml";
|
|
1598
1941
|
async function plansCommand(options) {
|
|
1599
1942
|
const { target = ".", force = false } = options;
|
|
1600
|
-
const targetDir =
|
|
1601
|
-
const dcsDir =
|
|
1602
|
-
const plansDir =
|
|
1943
|
+
const targetDir = path4.resolve(target);
|
|
1944
|
+
const dcsDir = path4.join(targetDir, ".dcs");
|
|
1945
|
+
const plansDir = path4.join(targetDir, ".plans");
|
|
1603
1946
|
console.log(chalk6.bold("Generating DCS integration plans..."));
|
|
1604
1947
|
console.log(chalk6.dim(`Directory: ${targetDir}`));
|
|
1605
1948
|
console.log();
|
|
1606
1949
|
try {
|
|
1607
|
-
await
|
|
1950
|
+
await fs6.access(dcsDir);
|
|
1608
1951
|
} catch {
|
|
1609
1952
|
console.log(chalk6.red("No .dcs directory found."));
|
|
1610
1953
|
console.log("Run", chalk6.cyan("dcs init"), "first to create DCS configuration.");
|
|
@@ -1613,7 +1956,7 @@ async function plansCommand(options) {
|
|
|
1613
1956
|
let siteName = "Customer Site";
|
|
1614
1957
|
let framework = "vue";
|
|
1615
1958
|
try {
|
|
1616
|
-
const siteYaml = await
|
|
1959
|
+
const siteYaml = await fs6.readFile(path4.join(dcsDir, "site.yaml"), "utf8");
|
|
1617
1960
|
const siteConfig = yaml2.load(siteYaml);
|
|
1618
1961
|
siteName = siteConfig.site_name || siteName;
|
|
1619
1962
|
const metadata = siteConfig.metadata;
|
|
@@ -1629,19 +1972,19 @@ async function plansCommand(options) {
|
|
|
1629
1972
|
{ name: "04-capture-snapshots.md", content: generateSnapshotsPlan(siteName) },
|
|
1630
1973
|
{ name: "05-verify-deployment.md", content: generateVerifyPlan(siteName) }
|
|
1631
1974
|
];
|
|
1632
|
-
await
|
|
1975
|
+
await fs6.mkdir(plansDir, { recursive: true });
|
|
1633
1976
|
const generatedFiles = [];
|
|
1634
1977
|
for (const plan of plans) {
|
|
1635
|
-
const filePath =
|
|
1978
|
+
const filePath = path4.join(plansDir, plan.name);
|
|
1636
1979
|
try {
|
|
1637
|
-
await
|
|
1980
|
+
await fs6.access(filePath);
|
|
1638
1981
|
if (!force) {
|
|
1639
1982
|
console.log(chalk6.yellow(`Skipped (exists): .plans/${plan.name}`));
|
|
1640
1983
|
continue;
|
|
1641
1984
|
}
|
|
1642
1985
|
} catch {
|
|
1643
1986
|
}
|
|
1644
|
-
await
|
|
1987
|
+
await fs6.writeFile(filePath, plan.content, "utf8");
|
|
1645
1988
|
generatedFiles.push(plan.name);
|
|
1646
1989
|
console.log(`${chalk6.green("\u2713")} .plans/${plan.name}`);
|
|
1647
1990
|
}
|
|
@@ -2100,8 +2443,8 @@ Once verified:
|
|
|
2100
2443
|
}
|
|
2101
2444
|
|
|
2102
2445
|
// src/commands/capture-snapshots.ts
|
|
2103
|
-
import
|
|
2104
|
-
import
|
|
2446
|
+
import fs7 from "fs";
|
|
2447
|
+
import path5 from "path";
|
|
2105
2448
|
import yaml3 from "js-yaml";
|
|
2106
2449
|
import chalk7 from "chalk";
|
|
2107
2450
|
import ora3 from "ora";
|
|
@@ -2133,15 +2476,15 @@ async function waitForAboveFoldAssets(page) {
|
|
|
2133
2476
|
if (img.complete && img.naturalWidth > 0) {
|
|
2134
2477
|
return Promise.resolve();
|
|
2135
2478
|
}
|
|
2136
|
-
return new Promise((
|
|
2137
|
-
const done = () =>
|
|
2479
|
+
return new Promise((resolve2) => {
|
|
2480
|
+
const done = () => resolve2();
|
|
2138
2481
|
img.addEventListener("load", done, { once: true });
|
|
2139
2482
|
img.addEventListener("error", done, { once: true });
|
|
2140
2483
|
});
|
|
2141
2484
|
}));
|
|
2142
2485
|
await Promise.race([
|
|
2143
2486
|
Promise.all([waitForFonts, waitForImages]),
|
|
2144
|
-
new Promise((
|
|
2487
|
+
new Promise((resolve2) => window.setTimeout(resolve2, timeoutMs))
|
|
2145
2488
|
]);
|
|
2146
2489
|
}, ASSET_WAIT_TIMEOUT_MS).catch((error) => {
|
|
2147
2490
|
console.warn(` Warning: could not verify above-fold assets: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -2164,7 +2507,7 @@ async function resetScrollForFullPageCapture(page) {
|
|
|
2164
2507
|
window.dispatchEvent(new Event("scroll"));
|
|
2165
2508
|
});
|
|
2166
2509
|
await page.waitForFunction(() => Math.abs(window.scrollY) < 1, void 0, { timeout: 1e3 }).catch(() => void 0);
|
|
2167
|
-
await page.evaluate(() => new Promise((
|
|
2510
|
+
await page.evaluate(() => new Promise((resolve2) => requestAnimationFrame(() => requestAnimationFrame(resolve2)))).catch(() => void 0);
|
|
2168
2511
|
await page.waitForTimeout(350);
|
|
2169
2512
|
await waitForAboveFoldAssets(page);
|
|
2170
2513
|
}
|
|
@@ -2207,7 +2550,7 @@ async function withFullPageCaptureLayout(page, capture) {
|
|
|
2207
2550
|
window.scrollTo(0, 0);
|
|
2208
2551
|
window.dispatchEvent(new Event("scroll"));
|
|
2209
2552
|
});
|
|
2210
|
-
await page.evaluate(() => new Promise((
|
|
2553
|
+
await page.evaluate(() => new Promise((resolve2) => requestAnimationFrame(() => requestAnimationFrame(resolve2)))).catch(() => void 0);
|
|
2211
2554
|
try {
|
|
2212
2555
|
return await capture();
|
|
2213
2556
|
} finally {
|
|
@@ -2246,27 +2589,27 @@ var SECTION_SELECTORS = {
|
|
|
2246
2589
|
cta: '[class*="cta"], [data-section-type="cta"], .call-to-action'
|
|
2247
2590
|
};
|
|
2248
2591
|
function loadPagesConfig(targetDir) {
|
|
2249
|
-
const configPath =
|
|
2250
|
-
if (!
|
|
2592
|
+
const configPath = path5.join(targetDir, ".dcs", "pages.yaml");
|
|
2593
|
+
if (!fs7.existsSync(configPath)) {
|
|
2251
2594
|
throw new Error(`Configuration file not found: ${configPath}`);
|
|
2252
2595
|
}
|
|
2253
|
-
const content =
|
|
2596
|
+
const content = fs7.readFileSync(configPath, "utf-8");
|
|
2254
2597
|
return yaml3.load(content);
|
|
2255
2598
|
}
|
|
2256
2599
|
function loadContentConfig(targetDir) {
|
|
2257
|
-
const contentPath =
|
|
2258
|
-
if (!
|
|
2600
|
+
const contentPath = path5.join(targetDir, ".dcs", "content.yaml");
|
|
2601
|
+
if (!fs7.existsSync(contentPath)) {
|
|
2259
2602
|
return null;
|
|
2260
2603
|
}
|
|
2261
|
-
const content =
|
|
2604
|
+
const content = fs7.readFileSync(contentPath, "utf-8");
|
|
2262
2605
|
return yaml3.load(content);
|
|
2263
2606
|
}
|
|
2264
2607
|
function loadTargetedSnapshotsConfig(targetDir) {
|
|
2265
|
-
const targetedPath =
|
|
2266
|
-
if (!
|
|
2608
|
+
const targetedPath = path5.join(targetDir, ".dcs", "targeted-snapshots.yaml");
|
|
2609
|
+
if (!fs7.existsSync(targetedPath)) {
|
|
2267
2610
|
return null;
|
|
2268
2611
|
}
|
|
2269
|
-
const content =
|
|
2612
|
+
const content = fs7.readFileSync(targetedPath, "utf-8");
|
|
2270
2613
|
return yaml3.load(content);
|
|
2271
2614
|
}
|
|
2272
2615
|
function getValidTextKeysForPage(contentConfig, pageSlug) {
|
|
@@ -2286,27 +2629,27 @@ function getValidTextKeysForPage(contentConfig, pageSlug) {
|
|
|
2286
2629
|
}
|
|
2287
2630
|
return keys;
|
|
2288
2631
|
}
|
|
2289
|
-
async function resolveAllPages(
|
|
2290
|
-
const
|
|
2291
|
-
for (const page of
|
|
2632
|
+
async function resolveAllPages(config) {
|
|
2633
|
+
const resolved2 = [];
|
|
2634
|
+
for (const page of config.pages) {
|
|
2292
2635
|
if (page.type === "dynamic" && page.instances) {
|
|
2293
2636
|
for (const instance of page.instances) {
|
|
2294
2637
|
const instancePath = page.pathTemplate ? page.pathTemplate.replace(/:[\w]+/, instance) : `${page.path}/${instance}`;
|
|
2295
|
-
|
|
2638
|
+
resolved2.push({
|
|
2296
2639
|
slug: `${page.slug}-${instance}`,
|
|
2297
2640
|
path: instancePath,
|
|
2298
2641
|
config: { ...page, slug: `${page.slug}-${instance}` }
|
|
2299
2642
|
});
|
|
2300
2643
|
}
|
|
2301
2644
|
} else {
|
|
2302
|
-
|
|
2645
|
+
resolved2.push({
|
|
2303
2646
|
slug: page.slug,
|
|
2304
2647
|
path: page.path,
|
|
2305
2648
|
config: page
|
|
2306
2649
|
});
|
|
2307
2650
|
}
|
|
2308
2651
|
}
|
|
2309
|
-
return
|
|
2652
|
+
return resolved2;
|
|
2310
2653
|
}
|
|
2311
2654
|
async function getDomPath(element) {
|
|
2312
2655
|
return element.evaluate((el) => {
|
|
@@ -2434,12 +2777,12 @@ async function capturePageSnapshot(browser, pageInfo, snapshotConfig, siteSlug,
|
|
|
2434
2777
|
}
|
|
2435
2778
|
await page.waitForTimeout(snapshotConfig.waitAfterLoad);
|
|
2436
2779
|
await waitForAboveFoldAssets(page);
|
|
2437
|
-
const pageOutputDir =
|
|
2438
|
-
|
|
2780
|
+
const pageOutputDir = path5.join(outputDir, siteSlug, pageInfo.slug);
|
|
2781
|
+
fs7.mkdirSync(path5.join(pageOutputDir, "sections"), { recursive: true });
|
|
2439
2782
|
const title = await page.title();
|
|
2440
|
-
|
|
2783
|
+
const pageHeight = await lazyLoadPage(page, snapshotConfig.viewport.height);
|
|
2441
2784
|
await resetScrollForFullPageCapture(page);
|
|
2442
|
-
const fullPagePath =
|
|
2785
|
+
const fullPagePath = path5.join(pageOutputDir, "full-page.png");
|
|
2443
2786
|
await withFullPageCaptureLayout(
|
|
2444
2787
|
page,
|
|
2445
2788
|
() => page.screenshot({
|
|
@@ -2447,7 +2790,7 @@ async function capturePageSnapshot(browser, pageInfo, snapshotConfig, siteSlug,
|
|
|
2447
2790
|
fullPage: snapshotConfig.captureFullPage
|
|
2448
2791
|
})
|
|
2449
2792
|
);
|
|
2450
|
-
const thumbnailPath =
|
|
2793
|
+
const thumbnailPath = path5.join(pageOutputDir, "thumbnail.png");
|
|
2451
2794
|
await captureHeaderThumbnail(page, thumbnailPath, snapshotConfig.viewport);
|
|
2452
2795
|
await page.evaluate(() => window.scrollTo(0, 0));
|
|
2453
2796
|
await page.waitForTimeout(100);
|
|
@@ -2501,7 +2844,7 @@ async function capturePageSnapshot(browser, pageInfo, snapshotConfig, siteSlug,
|
|
|
2501
2844
|
const bounds = await getAccurateBounds(element, resolvedType);
|
|
2502
2845
|
if (!bounds || bounds.height < 10) continue;
|
|
2503
2846
|
const sectionId = basicMetadata.sectionId || `section-${i}`;
|
|
2504
|
-
const sectionPath =
|
|
2847
|
+
const sectionPath = path5.join(pageOutputDir, "sections", `${sectionId}.png`);
|
|
2505
2848
|
try {
|
|
2506
2849
|
await element.screenshot({ path: sectionPath });
|
|
2507
2850
|
} catch {
|
|
@@ -2572,7 +2915,7 @@ async function capturePageSnapshot(browser, pageInfo, snapshotConfig, siteSlug,
|
|
|
2572
2915
|
const bounds = await getAccurateBounds(element, sectionType);
|
|
2573
2916
|
if (!bounds || bounds.height < 10) continue;
|
|
2574
2917
|
const sectionId = `${sectionType}-${i}`;
|
|
2575
|
-
const sectionPath =
|
|
2918
|
+
const sectionPath = path5.join(pageOutputDir, "sections", `${sectionId}.png`);
|
|
2576
2919
|
try {
|
|
2577
2920
|
await element.screenshot({ path: sectionPath });
|
|
2578
2921
|
} catch {
|
|
@@ -2625,7 +2968,7 @@ async function capturePageSnapshot(browser, pageInfo, snapshotConfig, siteSlug,
|
|
|
2625
2968
|
});
|
|
2626
2969
|
if (!bounds || bounds.height < 10) continue;
|
|
2627
2970
|
const sectionId = `content-fallback-${i}`;
|
|
2628
|
-
const sectionPath =
|
|
2971
|
+
const sectionPath = path5.join(pageOutputDir, "sections", `${sectionId}.png`);
|
|
2629
2972
|
try {
|
|
2630
2973
|
await element.screenshot({ path: sectionPath });
|
|
2631
2974
|
} catch {
|
|
@@ -2672,22 +3015,22 @@ async function capturePageSnapshot(browser, pageInfo, snapshotConfig, siteSlug,
|
|
|
2672
3015
|
}
|
|
2673
3016
|
async function captureSnapshotsCommand(options) {
|
|
2674
3017
|
const { target, baseUrl, dryRun, verbose, pages: targetedPages } = options;
|
|
2675
|
-
const targetDir =
|
|
3018
|
+
const targetDir = path5.resolve(target);
|
|
2676
3019
|
console.log(chalk7.blue("\u{1F4F8} Page Snapshot Capture"));
|
|
2677
3020
|
console.log(chalk7.gray("========================"));
|
|
2678
3021
|
console.log(chalk7.gray(`Target: ${targetDir}`));
|
|
2679
3022
|
console.log(chalk7.gray(`Base URL: ${baseUrl}`));
|
|
2680
3023
|
process.env.SITE_BASE_URL = baseUrl;
|
|
2681
|
-
let
|
|
3024
|
+
let config;
|
|
2682
3025
|
try {
|
|
2683
|
-
|
|
3026
|
+
config = loadPagesConfig(targetDir);
|
|
2684
3027
|
} catch (error) {
|
|
2685
3028
|
console.error(chalk7.red("Error:"), error.message);
|
|
2686
3029
|
console.log(chalk7.yellow("\nMake sure .dcs/pages.yaml exists in the target directory."));
|
|
2687
3030
|
process.exit(1);
|
|
2688
3031
|
}
|
|
2689
|
-
console.log(chalk7.gray(`Site: ${
|
|
2690
|
-
const outputDir =
|
|
3032
|
+
console.log(chalk7.gray(`Site: ${config.siteSlug}`));
|
|
3033
|
+
const outputDir = path5.join(targetDir, config.snapshot.outputDir || ".dcs/snapshots");
|
|
2691
3034
|
const contentConfig = loadContentConfig(targetDir);
|
|
2692
3035
|
if (contentConfig) {
|
|
2693
3036
|
const pageCount = Object.keys(contentConfig.pages || {}).length;
|
|
@@ -2704,19 +3047,19 @@ async function captureSnapshotsCommand(options) {
|
|
|
2704
3047
|
console.log(chalk7.gray(` Reason: ${targetedConfig.reason}`));
|
|
2705
3048
|
console.log(chalk7.gray(" No snapshots will be captured."));
|
|
2706
3049
|
if (!dryRun) {
|
|
2707
|
-
|
|
3050
|
+
fs7.mkdirSync(outputDir, { recursive: true });
|
|
2708
3051
|
const skipMarker = {
|
|
2709
3052
|
skipped: true,
|
|
2710
3053
|
reason: targetedConfig.reason,
|
|
2711
3054
|
triggeredBy: targetedConfig.triggeredBy,
|
|
2712
3055
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2713
3056
|
};
|
|
2714
|
-
|
|
3057
|
+
fs7.writeFileSync(path5.join(outputDir, "skipped.json"), JSON.stringify(skipMarker, null, 2));
|
|
2715
3058
|
}
|
|
2716
3059
|
console.log(chalk7.green("\n\u2705 Snapshot capture skipped (as requested)"));
|
|
2717
3060
|
return;
|
|
2718
3061
|
}
|
|
2719
|
-
let pagesToCapture = await resolveAllPages(
|
|
3062
|
+
let pagesToCapture = await resolveAllPages(config);
|
|
2720
3063
|
console.log(chalk7.gray(`Found ${pagesToCapture.length} total pages in configuration`));
|
|
2721
3064
|
if (targetedPages && targetedPages.length > 0) {
|
|
2722
3065
|
const targetedSlugs = new Set(targetedPages);
|
|
@@ -2746,10 +3089,10 @@ async function captureSnapshotsCommand(options) {
|
|
|
2746
3089
|
return;
|
|
2747
3090
|
}
|
|
2748
3091
|
const { chromium } = await import("playwright");
|
|
2749
|
-
if (
|
|
2750
|
-
|
|
3092
|
+
if (fs7.existsSync(outputDir)) {
|
|
3093
|
+
fs7.rmSync(outputDir, { recursive: true });
|
|
2751
3094
|
}
|
|
2752
|
-
|
|
3095
|
+
fs7.mkdirSync(outputDir, { recursive: true });
|
|
2753
3096
|
const spinner = ora3("Launching browser...").start();
|
|
2754
3097
|
const browser = await chromium.launch();
|
|
2755
3098
|
spinner.succeed("Browser launched");
|
|
@@ -2762,15 +3105,15 @@ async function captureSnapshotsCommand(options) {
|
|
|
2762
3105
|
const snapshot = await capturePageSnapshot(
|
|
2763
3106
|
browser,
|
|
2764
3107
|
pageInfo,
|
|
2765
|
-
|
|
2766
|
-
|
|
3108
|
+
config.snapshot,
|
|
3109
|
+
config.siteSlug,
|
|
2767
3110
|
validTextKeys,
|
|
2768
3111
|
outputDir,
|
|
2769
3112
|
verbose || false
|
|
2770
3113
|
);
|
|
2771
3114
|
snapshots.push(snapshot);
|
|
2772
|
-
const snapshotPath =
|
|
2773
|
-
|
|
3115
|
+
const snapshotPath = path5.join(outputDir, config.siteSlug, pageInfo.slug, "snapshot.json");
|
|
3116
|
+
fs7.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2));
|
|
2774
3117
|
pageSpinner.succeed(`${pageInfo.slug}: ${snapshot.sections.length} sections captured`);
|
|
2775
3118
|
} catch (error) {
|
|
2776
3119
|
const message = `${pageInfo.slug}: ${error.message}`;
|
|
@@ -2780,11 +3123,11 @@ async function captureSnapshotsCommand(options) {
|
|
|
2780
3123
|
}
|
|
2781
3124
|
await browser.close();
|
|
2782
3125
|
const manifest = {
|
|
2783
|
-
siteSlug:
|
|
3126
|
+
siteSlug: config.siteSlug,
|
|
2784
3127
|
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2785
3128
|
capturedVersion: process.env.GITHUB_SHA,
|
|
2786
3129
|
deploymentRef: process.env.GITHUB_REF || "local",
|
|
2787
|
-
pagesConfigVersion:
|
|
3130
|
+
pagesConfigVersion: config.version,
|
|
2788
3131
|
pages: snapshots.map((s) => ({
|
|
2789
3132
|
pageSlug: s.pageSlug,
|
|
2790
3133
|
pagePath: s.path,
|
|
@@ -2796,11 +3139,11 @@ async function captureSnapshotsCommand(options) {
|
|
|
2796
3139
|
hasSnapshot: true
|
|
2797
3140
|
}))
|
|
2798
3141
|
};
|
|
2799
|
-
const manifestPath =
|
|
3142
|
+
const manifestPath = path5.join(outputDir, config.siteSlug, "manifest.json");
|
|
2800
3143
|
if (isTargetedCapture) {
|
|
2801
3144
|
console.log(chalk7.gray(" Targeted capture: leaving the existing remote manifest intact"));
|
|
2802
3145
|
} else {
|
|
2803
|
-
|
|
3146
|
+
fs7.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
2804
3147
|
}
|
|
2805
3148
|
console.log("");
|
|
2806
3149
|
console.log(chalk7.green("\u2705 Snapshot capture complete!"));
|