@duffcloudservices/cli 0.1.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 +158 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2139 -0
- package/dist/index.js.map +1 -0
- package/package.json +62 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import chalk7 from "chalk";
|
|
6
|
+
|
|
7
|
+
// package.json
|
|
8
|
+
var version = "0.1.0";
|
|
9
|
+
|
|
10
|
+
// src/commands/auth.ts
|
|
11
|
+
import chalk2 from "chalk";
|
|
12
|
+
|
|
13
|
+
// src/auth/device-flow.ts
|
|
14
|
+
import open from "open";
|
|
15
|
+
import ora from "ora";
|
|
16
|
+
import chalk from "chalk";
|
|
17
|
+
|
|
18
|
+
// src/auth/credentials.ts
|
|
19
|
+
import Conf from "conf";
|
|
20
|
+
var config = new Conf({
|
|
21
|
+
projectName: "dcs-cli",
|
|
22
|
+
schema: {
|
|
23
|
+
auth: {
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
accessToken: { type: "string" },
|
|
27
|
+
refreshToken: { type: "string" },
|
|
28
|
+
expiresAt: { type: "number" },
|
|
29
|
+
email: { type: "string" },
|
|
30
|
+
userId: { type: "string" }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
function storeCredentials(credentials) {
|
|
36
|
+
config.set("auth", credentials);
|
|
37
|
+
}
|
|
38
|
+
function getCredentials() {
|
|
39
|
+
return config.get("auth");
|
|
40
|
+
}
|
|
41
|
+
function clearCredentials() {
|
|
42
|
+
config.delete("auth");
|
|
43
|
+
}
|
|
44
|
+
function isAuthenticated() {
|
|
45
|
+
const creds = getCredentials();
|
|
46
|
+
if (!creds) return false;
|
|
47
|
+
const bufferMs = 5 * 60 * 1e3;
|
|
48
|
+
return creds.expiresAt > Date.now() + bufferMs;
|
|
49
|
+
}
|
|
50
|
+
function getCurrentUserEmail() {
|
|
51
|
+
return getCredentials()?.email;
|
|
52
|
+
}
|
|
53
|
+
function getAccessToken() {
|
|
54
|
+
const creds = getCredentials();
|
|
55
|
+
if (!creds) return void 0;
|
|
56
|
+
return creds.accessToken;
|
|
57
|
+
}
|
|
58
|
+
function getRefreshToken() {
|
|
59
|
+
return getCredentials()?.refreshToken;
|
|
60
|
+
}
|
|
61
|
+
function updateAccessToken(accessToken, expiresIn) {
|
|
62
|
+
const creds = getCredentials();
|
|
63
|
+
if (!creds) return;
|
|
64
|
+
config.set("auth", {
|
|
65
|
+
...creds,
|
|
66
|
+
accessToken,
|
|
67
|
+
expiresAt: Date.now() + expiresIn * 1e3
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function getConfigPath() {
|
|
71
|
+
return config.path;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/auth/device-flow.ts
|
|
75
|
+
var DEFAULT_API_URL = "https://portal.duffcloudservices.com";
|
|
76
|
+
function sleep(ms) {
|
|
77
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
78
|
+
}
|
|
79
|
+
async function startDeviceAuth(options = {}) {
|
|
80
|
+
const apiUrl = options.apiUrl || DEFAULT_API_URL;
|
|
81
|
+
const clientId = options.clientId || "dcs-cli";
|
|
82
|
+
const response = await fetch(`${apiUrl}/api/v1/cli/auth/device`, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: {
|
|
85
|
+
"Content-Type": "application/json"
|
|
86
|
+
},
|
|
87
|
+
body: JSON.stringify({ client_id: clientId })
|
|
88
|
+
});
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
const error = await response.text();
|
|
91
|
+
throw new Error(`Failed to start device authorization: ${error}`);
|
|
92
|
+
}
|
|
93
|
+
const data = await response.json();
|
|
94
|
+
return {
|
|
95
|
+
deviceCode: data.device_code,
|
|
96
|
+
userCode: data.user_code,
|
|
97
|
+
verificationUri: data.verification_uri,
|
|
98
|
+
verificationUriComplete: data.verification_uri_complete,
|
|
99
|
+
expiresIn: data.expires_in,
|
|
100
|
+
interval: data.interval
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
async function pollForToken(deviceCode, apiUrl) {
|
|
104
|
+
const response = await fetch(`${apiUrl}/api/v1/cli/auth/token`, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: {
|
|
107
|
+
"Content-Type": "application/json"
|
|
108
|
+
},
|
|
109
|
+
body: JSON.stringify({
|
|
110
|
+
device_code: deviceCode,
|
|
111
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
|
112
|
+
})
|
|
113
|
+
});
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
const data2 = await response.json();
|
|
116
|
+
return { error: data2.error };
|
|
117
|
+
}
|
|
118
|
+
const data = await response.json();
|
|
119
|
+
return {
|
|
120
|
+
accessToken: data.access_token,
|
|
121
|
+
refreshToken: data.refresh_token,
|
|
122
|
+
tokenType: data.token_type,
|
|
123
|
+
expiresIn: data.expires_in,
|
|
124
|
+
email: data.email,
|
|
125
|
+
userId: data.user_id
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
async function login(options = {}) {
|
|
129
|
+
const apiUrl = options.apiUrl || DEFAULT_API_URL;
|
|
130
|
+
const spinner = ora("Starting authentication...").start();
|
|
131
|
+
let deviceAuth;
|
|
132
|
+
try {
|
|
133
|
+
deviceAuth = await startDeviceAuth(options);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
spinner.fail("Failed to start authentication");
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
spinner.stop();
|
|
139
|
+
console.log();
|
|
140
|
+
console.log(chalk.bold("Please visit:"), chalk.cyan(deviceAuth.verificationUri));
|
|
141
|
+
console.log(chalk.bold("And enter code:"), chalk.yellow.bold(deviceAuth.userCode));
|
|
142
|
+
console.log();
|
|
143
|
+
try {
|
|
144
|
+
await open(deviceAuth.verificationUriComplete);
|
|
145
|
+
console.log(chalk.dim("(Browser opened automatically)"));
|
|
146
|
+
} catch {
|
|
147
|
+
console.log(chalk.dim("(Please open the link manually)"));
|
|
148
|
+
}
|
|
149
|
+
console.log();
|
|
150
|
+
const pollSpinner = ora("Waiting for authentication...").start();
|
|
151
|
+
let interval = deviceAuth.interval;
|
|
152
|
+
const deadline = Date.now() + deviceAuth.expiresIn * 1e3;
|
|
153
|
+
while (Date.now() < deadline) {
|
|
154
|
+
await sleep(interval * 1e3);
|
|
155
|
+
try {
|
|
156
|
+
const result = await pollForToken(deviceAuth.deviceCode, apiUrl);
|
|
157
|
+
if ("error" in result) {
|
|
158
|
+
if (result.error === "authorization_pending") {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (result.error === "slow_down") {
|
|
162
|
+
interval += 5;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (result.error === "expired_token") {
|
|
166
|
+
pollSpinner.fail("Authentication timed out");
|
|
167
|
+
throw new Error("Authentication timed out. Please try again.");
|
|
168
|
+
}
|
|
169
|
+
if (result.error === "access_denied") {
|
|
170
|
+
pollSpinner.fail("Authentication denied");
|
|
171
|
+
throw new Error("Authentication was denied.");
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
const credentials = {
|
|
175
|
+
accessToken: result.accessToken,
|
|
176
|
+
refreshToken: result.refreshToken,
|
|
177
|
+
expiresAt: Date.now() + result.expiresIn * 1e3,
|
|
178
|
+
email: result.email,
|
|
179
|
+
userId: result.userId
|
|
180
|
+
};
|
|
181
|
+
storeCredentials(credentials);
|
|
182
|
+
pollSpinner.succeed(`Authenticated as ${chalk.green(result.email)}`);
|
|
183
|
+
return credentials;
|
|
184
|
+
}
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (error instanceof Error && error.message.includes("fetch")) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
pollSpinner.fail("Authentication timed out");
|
|
193
|
+
throw new Error("Authentication timed out. Please try again.");
|
|
194
|
+
}
|
|
195
|
+
async function refreshAccessToken(refreshToken, options = {}) {
|
|
196
|
+
const apiUrl = options.apiUrl || DEFAULT_API_URL;
|
|
197
|
+
const response = await fetch(`${apiUrl}/api/v1/cli/auth/refresh`, {
|
|
198
|
+
method: "POST",
|
|
199
|
+
headers: {
|
|
200
|
+
"Content-Type": "application/json"
|
|
201
|
+
},
|
|
202
|
+
body: JSON.stringify({ refresh_token: refreshToken })
|
|
203
|
+
});
|
|
204
|
+
if (!response.ok) {
|
|
205
|
+
throw new Error("Failed to refresh access token. Please login again.");
|
|
206
|
+
}
|
|
207
|
+
const data = await response.json();
|
|
208
|
+
return {
|
|
209
|
+
accessToken: data.access_token,
|
|
210
|
+
expiresIn: data.expires_in
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// src/commands/auth.ts
|
|
215
|
+
async function loginCommand(options = {}) {
|
|
216
|
+
try {
|
|
217
|
+
await login(options);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (error instanceof Error) {
|
|
220
|
+
console.error(chalk2.red("Login failed:"), error.message);
|
|
221
|
+
}
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async function logoutCommand() {
|
|
226
|
+
clearCredentials();
|
|
227
|
+
console.log(chalk2.green("\u2713"), "Logged out successfully");
|
|
228
|
+
}
|
|
229
|
+
async function whoamiCommand() {
|
|
230
|
+
const email = getCurrentUserEmail();
|
|
231
|
+
if (!email) {
|
|
232
|
+
console.log(chalk2.yellow("Not logged in."));
|
|
233
|
+
console.log("Run", chalk2.cyan("dcs login"), "to authenticate.");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
console.log("Logged in as:", chalk2.green(email));
|
|
237
|
+
console.log(chalk2.dim("Config file:"), chalk2.dim(getConfigPath()));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// src/commands/sites.ts
|
|
241
|
+
import chalk3 from "chalk";
|
|
242
|
+
import Table from "cli-table3";
|
|
243
|
+
|
|
244
|
+
// src/api/portal-client.ts
|
|
245
|
+
var DEFAULT_API_URL2 = "https://portal.duffcloudservices.com";
|
|
246
|
+
var PortalClient = class {
|
|
247
|
+
apiUrl;
|
|
248
|
+
constructor(options = {}) {
|
|
249
|
+
this.apiUrl = options.apiUrl || process.env.DCS_API_URL || DEFAULT_API_URL2;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Make an authenticated API request.
|
|
253
|
+
*/
|
|
254
|
+
async request(path4, options = {}) {
|
|
255
|
+
let accessToken = getAccessToken();
|
|
256
|
+
if (!isAuthenticated() && getRefreshToken()) {
|
|
257
|
+
const refreshToken = getRefreshToken();
|
|
258
|
+
try {
|
|
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
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (!accessToken) {
|
|
269
|
+
throw new Error("Not authenticated. Please login with `dcs login`.");
|
|
270
|
+
}
|
|
271
|
+
const response = await fetch(`${this.apiUrl}${path4}`, {
|
|
272
|
+
...options,
|
|
273
|
+
headers: {
|
|
274
|
+
...options.headers,
|
|
275
|
+
Authorization: `Bearer ${accessToken}`,
|
|
276
|
+
"Content-Type": "application/json"
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
if (response.status === 401) {
|
|
280
|
+
throw new Error("Session expired. Please login again with `dcs login`.");
|
|
281
|
+
}
|
|
282
|
+
if (!response.ok) {
|
|
283
|
+
const error = await response.text();
|
|
284
|
+
throw new Error(`API error (${response.status}): ${error}`);
|
|
285
|
+
}
|
|
286
|
+
return response.json();
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* List sites accessible to the authenticated user.
|
|
290
|
+
*/
|
|
291
|
+
async listSites() {
|
|
292
|
+
return this.request("/api/v1/cli/sites");
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Get details for a specific site.
|
|
296
|
+
*/
|
|
297
|
+
async getSite(slug) {
|
|
298
|
+
return this.request(`/api/v1/cli/sites/${slug}`);
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Validate that the user can create/modify a site.
|
|
302
|
+
*/
|
|
303
|
+
async validateSiteAccess(slug, operation) {
|
|
304
|
+
try {
|
|
305
|
+
const { sites } = await this.listSites();
|
|
306
|
+
const site = sites.find((s) => s.slug === slug);
|
|
307
|
+
if (!site) {
|
|
308
|
+
if (operation === "create") {
|
|
309
|
+
const canCreate = sites.some((s) => ["owner", "admin"].includes(s.role));
|
|
310
|
+
return canCreate ? { valid: true, message: "New site will be created" } : { valid: false, message: "You need admin access in a company to create new sites" };
|
|
311
|
+
}
|
|
312
|
+
return { valid: false, message: `Site '${slug}' not found or you don't have access` };
|
|
313
|
+
}
|
|
314
|
+
if (operation === "read") {
|
|
315
|
+
return { valid: true, role: site.role };
|
|
316
|
+
}
|
|
317
|
+
if (operation === "edit") {
|
|
318
|
+
const canEdit = ["owner", "admin", "editor"].includes(site.role);
|
|
319
|
+
return canEdit ? { valid: true, role: site.role } : { valid: false, role: site.role, message: "You need editor access to modify this site" };
|
|
320
|
+
}
|
|
321
|
+
if (operation === "create") {
|
|
322
|
+
return { valid: false, message: "Site already exists" };
|
|
323
|
+
}
|
|
324
|
+
return { valid: false };
|
|
325
|
+
} catch (error) {
|
|
326
|
+
if (error instanceof Error) {
|
|
327
|
+
return { valid: false, message: error.message };
|
|
328
|
+
}
|
|
329
|
+
return { valid: false, message: "Unknown error" };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
var clientInstance = null;
|
|
334
|
+
function getPortalClient(options) {
|
|
335
|
+
if (!clientInstance) {
|
|
336
|
+
clientInstance = new PortalClient(options);
|
|
337
|
+
}
|
|
338
|
+
return clientInstance;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// src/commands/sites.ts
|
|
342
|
+
async function listSitesCommand() {
|
|
343
|
+
if (!isAuthenticated()) {
|
|
344
|
+
console.log(chalk3.yellow("Not logged in."));
|
|
345
|
+
console.log("Run", chalk3.cyan("dcs login"), "to authenticate.");
|
|
346
|
+
process.exit(1);
|
|
347
|
+
}
|
|
348
|
+
const client = getPortalClient();
|
|
349
|
+
try {
|
|
350
|
+
const { sites } = await client.listSites();
|
|
351
|
+
if (sites.length === 0) {
|
|
352
|
+
console.log(chalk3.yellow("No sites found."));
|
|
353
|
+
console.log("Ask your company admin to grant you access to sites.");
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
console.log(chalk3.bold("\nYour Sites:\n"));
|
|
357
|
+
const table = new Table({
|
|
358
|
+
head: [
|
|
359
|
+
chalk3.cyan("Slug"),
|
|
360
|
+
chalk3.cyan("Name"),
|
|
361
|
+
chalk3.cyan("Role"),
|
|
362
|
+
chalk3.cyan("Company"),
|
|
363
|
+
chalk3.cyan("URL")
|
|
364
|
+
],
|
|
365
|
+
style: {
|
|
366
|
+
head: [],
|
|
367
|
+
border: []
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
for (const site of sites) {
|
|
371
|
+
table.push([
|
|
372
|
+
site.slug,
|
|
373
|
+
site.name,
|
|
374
|
+
formatRole(site.role),
|
|
375
|
+
site.companyName,
|
|
376
|
+
site.productionUrl || chalk3.dim("(not deployed)")
|
|
377
|
+
]);
|
|
378
|
+
}
|
|
379
|
+
console.log(table.toString());
|
|
380
|
+
console.log();
|
|
381
|
+
console.log(chalk3.dim(`Total: ${sites.length} site(s)`));
|
|
382
|
+
} catch (error) {
|
|
383
|
+
if (error instanceof Error) {
|
|
384
|
+
console.error(chalk3.red("Error:"), error.message);
|
|
385
|
+
}
|
|
386
|
+
process.exit(1);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
async function showSiteCommand(slug) {
|
|
390
|
+
if (!isAuthenticated()) {
|
|
391
|
+
console.log(chalk3.yellow("Not logged in."));
|
|
392
|
+
console.log("Run", chalk3.cyan("dcs login"), "to authenticate.");
|
|
393
|
+
process.exit(1);
|
|
394
|
+
}
|
|
395
|
+
const client = getPortalClient();
|
|
396
|
+
try {
|
|
397
|
+
const site = await client.getSite(slug);
|
|
398
|
+
displaySiteDetails(site);
|
|
399
|
+
} catch (error) {
|
|
400
|
+
if (error instanceof Error) {
|
|
401
|
+
console.error(chalk3.red("Error:"), error.message);
|
|
402
|
+
}
|
|
403
|
+
process.exit(1);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
function formatRole(role) {
|
|
407
|
+
switch (role) {
|
|
408
|
+
case "owner":
|
|
409
|
+
return chalk3.magenta(role);
|
|
410
|
+
case "admin":
|
|
411
|
+
return chalk3.red(role);
|
|
412
|
+
case "editor":
|
|
413
|
+
return chalk3.yellow(role);
|
|
414
|
+
case "viewer":
|
|
415
|
+
return chalk3.blue(role);
|
|
416
|
+
default:
|
|
417
|
+
return role;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function displaySiteDetails(site) {
|
|
421
|
+
console.log();
|
|
422
|
+
console.log(chalk3.bold("Site Details"));
|
|
423
|
+
console.log();
|
|
424
|
+
console.log(" Slug: ", chalk3.cyan(site.slug));
|
|
425
|
+
console.log(" Name: ", site.name);
|
|
426
|
+
console.log(" Role: ", formatRole(site.role));
|
|
427
|
+
console.log(" Company: ", site.companyName);
|
|
428
|
+
console.log(" Company ID: ", chalk3.dim(site.companyId));
|
|
429
|
+
if (site.productionUrl) {
|
|
430
|
+
console.log(" URL: ", chalk3.green(site.productionUrl));
|
|
431
|
+
} else {
|
|
432
|
+
console.log(" URL: ", chalk3.dim("(not deployed)"));
|
|
433
|
+
}
|
|
434
|
+
console.log();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// src/commands/init.ts
|
|
438
|
+
import fs from "fs/promises";
|
|
439
|
+
import path from "path";
|
|
440
|
+
import chalk4 from "chalk";
|
|
441
|
+
import ora2 from "ora";
|
|
442
|
+
|
|
443
|
+
// src/templates/site-deploy.yml
|
|
444
|
+
var site_deploy_default = `# Site Deployment Workflow\r
|
|
445
|
+
# Generated by @duffcloudservices/cli\r
|
|
446
|
+
# This workflow deploys the site to Azure Static Web Apps using OIDC authentication.\r
|
|
447
|
+
\r
|
|
448
|
+
name: Site Deployment\r
|
|
449
|
+
\r
|
|
450
|
+
on:\r
|
|
451
|
+
push:\r
|
|
452
|
+
branches:\r
|
|
453
|
+
- 'release/**'\r
|
|
454
|
+
- 'master'\r
|
|
455
|
+
workflow_dispatch:\r
|
|
456
|
+
inputs:\r
|
|
457
|
+
trigger_reason:\r
|
|
458
|
+
description: 'Reason for manual deployment'\r
|
|
459
|
+
required: false\r
|
|
460
|
+
type: string\r
|
|
461
|
+
default: 'Manual deployment'\r
|
|
462
|
+
\r
|
|
463
|
+
# Prevent concurrent deployments for the same branch\r
|
|
464
|
+
concurrency:\r
|
|
465
|
+
group: site-deploy-\${{ github.ref }}\r
|
|
466
|
+
cancel-in-progress: false\r
|
|
467
|
+
\r
|
|
468
|
+
permissions:\r
|
|
469
|
+
contents: read\r
|
|
470
|
+
issues: write\r
|
|
471
|
+
pull-requests: read\r
|
|
472
|
+
id-token: write # Required for Azure OIDC authentication\r
|
|
473
|
+
\r
|
|
474
|
+
jobs:\r
|
|
475
|
+
deploy:\r
|
|
476
|
+
name: Deploy to Azure Static Web App\r
|
|
477
|
+
runs-on: ubuntu-latest\r
|
|
478
|
+
# Skip deployment on branch creation events\r
|
|
479
|
+
if: github.event.before != '0000000000000000000000000000000000000000'\r
|
|
480
|
+
\r
|
|
481
|
+
steps:\r
|
|
482
|
+
- name: Determine deployment environment\r
|
|
483
|
+
id: environment\r
|
|
484
|
+
run: |\r
|
|
485
|
+
BRANCH="\${{ github.ref_name }}"\r
|
|
486
|
+
echo "Branch: $BRANCH"\r
|
|
487
|
+
\r
|
|
488
|
+
if [ "$BRANCH" == "master" ]; then\r
|
|
489
|
+
echo "environment=Production" >> $GITHUB_OUTPUT\r
|
|
490
|
+
echo "environment_name=Production" >> $GITHUB_OUTPUT\r
|
|
491
|
+
echo "swa_environment=" >> $GITHUB_OUTPUT\r
|
|
492
|
+
else\r
|
|
493
|
+
echo "environment=preview" >> $GITHUB_OUTPUT\r
|
|
494
|
+
echo "environment_name=Preview" >> $GITHUB_OUTPUT\r
|
|
495
|
+
echo "swa_environment=preview" >> $GITHUB_OUTPUT\r
|
|
496
|
+
fi\r
|
|
497
|
+
\r
|
|
498
|
+
- name: Checkout repository\r
|
|
499
|
+
uses: actions/checkout@v4\r
|
|
500
|
+
\r
|
|
501
|
+
- name: Read site configuration\r
|
|
502
|
+
id: config\r
|
|
503
|
+
run: |\r
|
|
504
|
+
CONFIG_FILE=".dcs/site.yaml"\r
|
|
505
|
+
\r
|
|
506
|
+
if [ ! -f "$CONFIG_FILE" ]; then\r
|
|
507
|
+
echo "::error::Missing DCS site configuration file: $CONFIG_FILE"\r
|
|
508
|
+
exit 1\r
|
|
509
|
+
fi\r
|
|
510
|
+
\r
|
|
511
|
+
SITE_NAME=$(grep -E '^site_name:' "$CONFIG_FILE" | sed 's/site_name:\\s*//' | tr -d '"' | tr -d "'")\r
|
|
512
|
+
SITE_SLUG=$(grep -E '^site_slug:' "$CONFIG_FILE" | sed 's/site_slug:\\s*//' | tr -d '"' | tr -d "'")\r
|
|
513
|
+
SWA_RESOURCE_ID=$(grep -E '^swa_resource_id:' "$CONFIG_FILE" | sed 's/swa_resource_id:\\s*//' | tr -d '"' | tr -d "'")\r
|
|
514
|
+
PORTAL_API_URL=$(grep -E '^portal_api_url:' "$CONFIG_FILE" | sed 's/portal_api_url:\\s*//' | tr -d '"' | tr -d "'")\r
|
|
515
|
+
PORTAL_API_URL="\${PORTAL_API_URL:-https://portal.duffcloudservices.com}"\r
|
|
516
|
+
GOOGLE_ANALYTICS_ID=$(grep -E '^google_analytics_id:' "$CONFIG_FILE" | sed 's/google_analytics_id:\\s*//' | tr -d '"' | tr -d "'")\r
|
|
517
|
+
\r
|
|
518
|
+
# Azure Authentication\r
|
|
519
|
+
AZURE_CLIENT_ID=$(grep -E '^\\s*client_id:' "$CONFIG_FILE" | sed 's/.*client_id:\\s*//' | tr -d '"' | tr -d "'")\r
|
|
520
|
+
AZURE_TENANT_ID=$(grep -E '^\\s*tenant_id:' "$CONFIG_FILE" | sed 's/.*tenant_id:\\s*//' | tr -d '"' | tr -d "'")\r
|
|
521
|
+
AZURE_SUBSCRIPTION_ID=$(grep -E '^\\s*subscription_id:' "$CONFIG_FILE" | sed 's/.*subscription_id:\\s*//' | tr -d '"' | tr -d "'")\r
|
|
522
|
+
\r
|
|
523
|
+
# Build Configuration\r
|
|
524
|
+
APP_LOCATION=$(grep -E '^\\s*app_location:' "$CONFIG_FILE" | sed 's/.*app_location:\\s*//' | tr -d '"' | tr -d "'")\r
|
|
525
|
+
OUTPUT_LOCATION=$(grep -E '^\\s*output_location:' "$CONFIG_FILE" | sed 's/.*output_location:\\s*//' | tr -d '"' | tr -d "'")\r
|
|
526
|
+
\r
|
|
527
|
+
APP_LOCATION="\${APP_LOCATION:-dist}"\r
|
|
528
|
+
OUTPUT_LOCATION="\${OUTPUT_LOCATION:-.}"\r
|
|
529
|
+
\r
|
|
530
|
+
if [ -z "$SWA_RESOURCE_ID" ]; then\r
|
|
531
|
+
echo "::error::Missing swa_resource_id in $CONFIG_FILE"\r
|
|
532
|
+
exit 1\r
|
|
533
|
+
fi\r
|
|
534
|
+
\r
|
|
535
|
+
echo "site_name=$SITE_NAME" >> $GITHUB_OUTPUT\r
|
|
536
|
+
echo "site_slug=$SITE_SLUG" >> $GITHUB_OUTPUT\r
|
|
537
|
+
echo "swa_resource_id=$SWA_RESOURCE_ID" >> $GITHUB_OUTPUT\r
|
|
538
|
+
echo "app_location=$APP_LOCATION" >> $GITHUB_OUTPUT\r
|
|
539
|
+
echo "output_location=$OUTPUT_LOCATION" >> $GITHUB_OUTPUT\r
|
|
540
|
+
echo "azure_client_id=$AZURE_CLIENT_ID" >> $GITHUB_OUTPUT\r
|
|
541
|
+
echo "azure_tenant_id=$AZURE_TENANT_ID" >> $GITHUB_OUTPUT\r
|
|
542
|
+
echo "azure_subscription_id=$AZURE_SUBSCRIPTION_ID" >> $GITHUB_OUTPUT\r
|
|
543
|
+
echo "portal_api_url=$PORTAL_API_URL" >> $GITHUB_OUTPUT\r
|
|
544
|
+
echo "google_analytics_id=$GOOGLE_ANALYTICS_ID" >> $GITHUB_OUTPUT\r
|
|
545
|
+
\r
|
|
546
|
+
- name: Azure login via OIDC\r
|
|
547
|
+
uses: azure/login@v2\r
|
|
548
|
+
with:\r
|
|
549
|
+
client-id: \${{ steps.config.outputs.azure_client_id || vars.AZURE_CLIENT_ID }}\r
|
|
550
|
+
tenant-id: \${{ steps.config.outputs.azure_tenant_id || vars.AZURE_TENANT_ID }}\r
|
|
551
|
+
subscription-id: \${{ steps.config.outputs.azure_subscription_id || vars.AZURE_SUBSCRIPTION_ID }}\r
|
|
552
|
+
\r
|
|
553
|
+
- name: Get deployment tokens\r
|
|
554
|
+
id: tokens\r
|
|
555
|
+
run: |\r
|
|
556
|
+
# Get access token for DCS API\r
|
|
557
|
+
ACCESS_TOKEN=$(az account get-access-token --resource "api://304711b9-8532-4659-beb7-c85726de9ae5" --query accessToken -o tsv)\r
|
|
558
|
+
\r
|
|
559
|
+
SITE_NAME="\${{ steps.config.outputs.site_name }}"\r
|
|
560
|
+
SITE_SLUG="\${{ steps.config.outputs.site_slug }}"\r
|
|
561
|
+
PORTAL_API_URL="\${{ steps.config.outputs.portal_api_url }}"\r
|
|
562
|
+
\r
|
|
563
|
+
RESPONSE=$(curl -s -X POST "\${PORTAL_API_URL}/api/v1/sites/deployment-tokens" \\\r
|
|
564
|
+
-H "Authorization: Bearer $ACCESS_TOKEN" \\\r
|
|
565
|
+
-H "Content-Type: application/json" \\\r
|
|
566
|
+
-d "{\\"siteName\\": \\"$SITE_NAME\\", \\"siteSlug\\": \\"$SITE_SLUG\\"}")\r
|
|
567
|
+
\r
|
|
568
|
+
SWA_TOKEN=$(echo $RESPONSE | jq -r .swaToken)\r
|
|
569
|
+
\r
|
|
570
|
+
if [ -z "$SWA_TOKEN" ] || [ "$SWA_TOKEN" == "null" ]; then\r
|
|
571
|
+
echo "::error::Failed to retrieve SWA token from DCS API"\r
|
|
572
|
+
echo "Response: $RESPONSE"\r
|
|
573
|
+
exit 1\r
|
|
574
|
+
fi\r
|
|
575
|
+
\r
|
|
576
|
+
echo "::add-mask::$SWA_TOKEN"\r
|
|
577
|
+
echo "swa_token=$SWA_TOKEN" >> $GITHUB_OUTPUT\r
|
|
578
|
+
\r
|
|
579
|
+
- name: Setup Node.js\r
|
|
580
|
+
uses: actions/setup-node@v4\r
|
|
581
|
+
with:\r
|
|
582
|
+
node-version: "22"\r
|
|
583
|
+
\r
|
|
584
|
+
- name: Install pnpm\r
|
|
585
|
+
uses: pnpm/action-setup@v4\r
|
|
586
|
+
\r
|
|
587
|
+
- name: Setup pnpm cache\r
|
|
588
|
+
uses: actions/cache@v4\r
|
|
589
|
+
with:\r
|
|
590
|
+
path: ~/.pnpm-store\r
|
|
591
|
+
key: \${{ runner.os }}-pnpm-\${{ hashFiles('**/pnpm-lock.yaml') }}\r
|
|
592
|
+
restore-keys: |\r
|
|
593
|
+
\${{ runner.os }}-pnpm-\r
|
|
594
|
+
\r
|
|
595
|
+
- name: Install dependencies\r
|
|
596
|
+
run: pnpm install --frozen-lockfile\r
|
|
597
|
+
\r
|
|
598
|
+
- name: Create environment file\r
|
|
599
|
+
run: |\r
|
|
600
|
+
cat > .env << EOF\r
|
|
601
|
+
VITE_BACKEND_URI="\${{ steps.config.outputs.portal_api_url }}"\r
|
|
602
|
+
VITE_SITE_SLUG="\${{ steps.config.outputs.site_slug }}"\r
|
|
603
|
+
VITE_GOOGLE_ANALYTICS_ID="\${{ steps.config.outputs.google_analytics_id }}"\r
|
|
604
|
+
EOF\r
|
|
605
|
+
\r
|
|
606
|
+
- name: Build site\r
|
|
607
|
+
run: pnpm run build\r
|
|
608
|
+
\r
|
|
609
|
+
- name: Deploy to Azure Static Web Apps\r
|
|
610
|
+
uses: Azure/static-web-apps-deploy@v1\r
|
|
611
|
+
with:\r
|
|
612
|
+
azure_static_web_apps_api_token: \${{ steps.tokens.outputs.swa_token }}\r
|
|
613
|
+
repo_token: \${{ secrets.GITHUB_TOKEN }}\r
|
|
614
|
+
action: "upload"\r
|
|
615
|
+
app_location: \${{ steps.config.outputs.app_location }}\r
|
|
616
|
+
output_location: \${{ steps.config.outputs.output_location }}\r
|
|
617
|
+
skip_app_build: true\r
|
|
618
|
+
deployment_environment: \${{ steps.environment.outputs.swa_environment != '' && steps.environment.outputs.swa_environment || '' }}\r
|
|
619
|
+
`;
|
|
620
|
+
|
|
621
|
+
// src/commands/init.ts
|
|
622
|
+
async function initCommand(options) {
|
|
623
|
+
const { siteSlug, siteName, framework = "vue", target = ".", dryRun = false, force = false } = options;
|
|
624
|
+
if (!isAuthenticated()) {
|
|
625
|
+
console.log(chalk4.yellow("Not logged in."));
|
|
626
|
+
console.log("Run", chalk4.cyan("dcs login"), "to authenticate.");
|
|
627
|
+
process.exit(1);
|
|
628
|
+
}
|
|
629
|
+
const spinner = ora2("Validating site access...").start();
|
|
630
|
+
const client = getPortalClient();
|
|
631
|
+
try {
|
|
632
|
+
const { sites } = await client.listSites();
|
|
633
|
+
const existingSite = sites.find((s) => s.slug === siteSlug);
|
|
634
|
+
if (existingSite) {
|
|
635
|
+
if (!["owner", "admin", "editor"].includes(existingSite.role)) {
|
|
636
|
+
spinner.fail(`You don't have edit access to site '${siteSlug}'`);
|
|
637
|
+
process.exit(1);
|
|
638
|
+
}
|
|
639
|
+
spinner.succeed(`Site '${siteSlug}' found (role: ${existingSite.role})`);
|
|
640
|
+
} else {
|
|
641
|
+
const canCreate = sites.some((s) => ["owner", "admin"].includes(s.role));
|
|
642
|
+
if (!canCreate) {
|
|
643
|
+
spinner.fail("You need admin access in a company to create new sites");
|
|
644
|
+
process.exit(1);
|
|
645
|
+
}
|
|
646
|
+
spinner.info(`Creating new site: ${siteSlug}`);
|
|
647
|
+
}
|
|
648
|
+
} catch (error) {
|
|
649
|
+
spinner.fail("Failed to validate site access");
|
|
650
|
+
if (error instanceof Error) {
|
|
651
|
+
console.error(chalk4.red("Error:"), error.message);
|
|
652
|
+
}
|
|
653
|
+
process.exit(1);
|
|
654
|
+
}
|
|
655
|
+
const targetDir = path.resolve(target);
|
|
656
|
+
console.log(chalk4.dim(`Target directory: ${targetDir}`));
|
|
657
|
+
const generatedFiles = [];
|
|
658
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
659
|
+
try {
|
|
660
|
+
const siteYaml = generateSiteYaml({ siteSlug, siteName, timestamp, framework });
|
|
661
|
+
generatedFiles.push(await writeFile(targetDir, ".dcs/site.yaml", siteYaml, { dryRun, force }));
|
|
662
|
+
const pagesYaml = generatePagesYaml({ siteSlug, timestamp });
|
|
663
|
+
generatedFiles.push(await writeFile(targetDir, ".dcs/pages.yaml", pagesYaml, { dryRun, force }));
|
|
664
|
+
const contentYaml = generateContentYaml({ timestamp });
|
|
665
|
+
generatedFiles.push(await writeFile(targetDir, ".dcs/content.yaml", contentYaml, { dryRun, force }));
|
|
666
|
+
const seoYaml = generateSeoYaml({ siteName, timestamp });
|
|
667
|
+
generatedFiles.push(await writeFile(targetDir, ".dcs/seo.yaml", seoYaml, { dryRun, force }));
|
|
668
|
+
const sectionConventions = generateSectionConventions();
|
|
669
|
+
generatedFiles.push(
|
|
670
|
+
await writeFile(targetDir, ".dcs/SECTION-CONVENTIONS.md", sectionConventions, { dryRun, force })
|
|
671
|
+
);
|
|
672
|
+
const copilotInstructions = generateCopilotInstructions({ siteName });
|
|
673
|
+
generatedFiles.push(
|
|
674
|
+
await writeFile(targetDir, ".github/copilot-instructions.md", copilotInstructions, { dryRun, force })
|
|
675
|
+
);
|
|
676
|
+
const deployWorkflow = generateDeployWorkflow();
|
|
677
|
+
generatedFiles.push(
|
|
678
|
+
await writeFile(targetDir, ".github/workflows/site-deploy.yml", deployWorkflow, { dryRun, force })
|
|
679
|
+
);
|
|
680
|
+
const plansReadme = generatePlansReadme({ siteName, framework });
|
|
681
|
+
generatedFiles.push(await writeFile(targetDir, ".plans/README.md", plansReadme, { dryRun, force }));
|
|
682
|
+
const plan00 = generatePlan00AuditSite({ framework });
|
|
683
|
+
generatedFiles.push(await writeFile(targetDir, ".plans/00-audit-site.md", plan00, { dryRun, force }));
|
|
684
|
+
const plan01 = generatePlan01IntegrateContent();
|
|
685
|
+
generatedFiles.push(await writeFile(targetDir, ".plans/01-integrate-content.md", plan01, { dryRun, force }));
|
|
686
|
+
const plan02 = generatePlan02ConfigureSeo();
|
|
687
|
+
generatedFiles.push(await writeFile(targetDir, ".plans/02-configure-seo.md", plan02, { dryRun, force }));
|
|
688
|
+
} catch (error) {
|
|
689
|
+
if (error instanceof Error) {
|
|
690
|
+
console.error(chalk4.red("Error:"), error.message);
|
|
691
|
+
}
|
|
692
|
+
process.exit(1);
|
|
693
|
+
}
|
|
694
|
+
console.log();
|
|
695
|
+
console.log(chalk4.bold("Generated files:"));
|
|
696
|
+
for (const file of generatedFiles) {
|
|
697
|
+
console.log(` ${chalk4.green("\u2713")} ${file}`);
|
|
698
|
+
}
|
|
699
|
+
console.log();
|
|
700
|
+
console.log(chalk4.bold("Next steps:"));
|
|
701
|
+
console.log(" 1. Install CMS package:", chalk4.cyan("pnpm add @duffcloudservices/cms"));
|
|
702
|
+
console.log(" 2. Configure Vite plugins in your build config");
|
|
703
|
+
console.log(" 3. Follow the plans in", chalk4.cyan(".plans/"), "directory");
|
|
704
|
+
console.log();
|
|
705
|
+
}
|
|
706
|
+
async function writeFile(targetDir, relativePath, content, options) {
|
|
707
|
+
const fullPath = path.join(targetDir, relativePath);
|
|
708
|
+
if (options.dryRun) {
|
|
709
|
+
console.log(chalk4.dim(`Would create: ${relativePath}`));
|
|
710
|
+
return relativePath;
|
|
711
|
+
}
|
|
712
|
+
try {
|
|
713
|
+
await fs.access(fullPath);
|
|
714
|
+
if (!options.force) {
|
|
715
|
+
console.log(chalk4.yellow(`Skipped (exists): ${relativePath}`));
|
|
716
|
+
return relativePath;
|
|
717
|
+
}
|
|
718
|
+
} catch {
|
|
719
|
+
}
|
|
720
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
721
|
+
await fs.writeFile(fullPath, content, "utf8");
|
|
722
|
+
return relativePath;
|
|
723
|
+
}
|
|
724
|
+
function generateSiteYaml(data) {
|
|
725
|
+
return `# DCS Site Configuration
|
|
726
|
+
# This file defines the site identity and Azure resource bindings.
|
|
727
|
+
# Generated by: @duffcloudservices/cli
|
|
728
|
+
|
|
729
|
+
site_name: ${data.siteName}
|
|
730
|
+
site_slug: ${data.siteSlug}
|
|
731
|
+
|
|
732
|
+
# Azure Static Web App resource ID
|
|
733
|
+
# Set this after provisioning the SWA resource in Azure
|
|
734
|
+
swa_resource_id: ""
|
|
735
|
+
|
|
736
|
+
# URLs are populated after first deployment
|
|
737
|
+
production_url: ""
|
|
738
|
+
preview_url: ""
|
|
739
|
+
|
|
740
|
+
# Google Analytics Configuration
|
|
741
|
+
google_analytics_id: ""
|
|
742
|
+
|
|
743
|
+
# Azure Authentication (Managed Identity)
|
|
744
|
+
# These values are used by GitHub Actions for OIDC authentication
|
|
745
|
+
azure:
|
|
746
|
+
client_id: ""
|
|
747
|
+
tenant_id: ""
|
|
748
|
+
subscription_id: ""
|
|
749
|
+
|
|
750
|
+
# Portal API Configuration
|
|
751
|
+
portal_api_url: "https://portal.duffcloudservices.com"
|
|
752
|
+
|
|
753
|
+
# Site metadata for portal
|
|
754
|
+
metadata:
|
|
755
|
+
framework: ${data.framework}
|
|
756
|
+
managed_by: dcs
|
|
757
|
+
created_at: "${data.timestamp}"
|
|
758
|
+
`;
|
|
759
|
+
}
|
|
760
|
+
function generatePagesYaml(data) {
|
|
761
|
+
return `# .dcs/pages.yaml
|
|
762
|
+
# Page registry for DCS CMS integration and snapshot capture.
|
|
763
|
+
# Generated by: @duffcloudservices/cli
|
|
764
|
+
#
|
|
765
|
+
# This file defines page metadata only. Text content is stored in content.yaml
|
|
766
|
+
# and text keys are auto-discovered during snapshot capture.
|
|
767
|
+
|
|
768
|
+
version: 3
|
|
769
|
+
siteSlug: ${data.siteSlug}
|
|
770
|
+
lastUpdated: "${data.timestamp}"
|
|
771
|
+
generatedBy: dcs-cli
|
|
772
|
+
|
|
773
|
+
pages:
|
|
774
|
+
# Home page - always protected
|
|
775
|
+
- slug: home
|
|
776
|
+
path: /
|
|
777
|
+
type: static
|
|
778
|
+
title: Home
|
|
779
|
+
deletable: false
|
|
780
|
+
|
|
781
|
+
# Excluded paths - never captured
|
|
782
|
+
excluded:
|
|
783
|
+
- /404
|
|
784
|
+
- /dev-*
|
|
785
|
+
- /_*
|
|
786
|
+
|
|
787
|
+
# Snapshot configuration
|
|
788
|
+
snapshot:
|
|
789
|
+
viewport:
|
|
790
|
+
width: 1280
|
|
791
|
+
height: 720
|
|
792
|
+
waitAfterLoad: 3000
|
|
793
|
+
captureFullPage: true
|
|
794
|
+
`;
|
|
795
|
+
}
|
|
796
|
+
function generateContentYaml(data) {
|
|
797
|
+
return `# .dcs/content.yaml
|
|
798
|
+
# Managed by DCS Portal - Do not edit manually
|
|
799
|
+
# This file contains all text content for the site.
|
|
800
|
+
#
|
|
801
|
+
# Content is organized by page slug, with each page containing
|
|
802
|
+
# key-value pairs for editable text. The \`global\` section contains
|
|
803
|
+
# text shared across all pages (navigation, footer, etc.).
|
|
804
|
+
|
|
805
|
+
version: 1
|
|
806
|
+
lastUpdated: "${data.timestamp}"
|
|
807
|
+
updatedBy: "dcs-cli"
|
|
808
|
+
|
|
809
|
+
# Global text content (shared across all pages)
|
|
810
|
+
global: {}
|
|
811
|
+
|
|
812
|
+
# Page-specific text content
|
|
813
|
+
pages: {}
|
|
814
|
+
`;
|
|
815
|
+
}
|
|
816
|
+
function generateSeoYaml(data) {
|
|
817
|
+
return `# .dcs/seo.yaml
|
|
818
|
+
# Managed by DCS Portal - Do not edit manually
|
|
819
|
+
# SEO configuration for all pages and global defaults.
|
|
820
|
+
|
|
821
|
+
version: 1
|
|
822
|
+
lastUpdated: "${data.timestamp}"
|
|
823
|
+
updatedBy: "dcs-cli"
|
|
824
|
+
|
|
825
|
+
# Global/site-wide SEO defaults
|
|
826
|
+
global:
|
|
827
|
+
siteName: "${data.siteName}"
|
|
828
|
+
siteUrl: "" # Set your production URL
|
|
829
|
+
locale: en_US
|
|
830
|
+
defaultTitle: "${data.siteName}"
|
|
831
|
+
defaultDescription: ""
|
|
832
|
+
titleTemplate: "%s | ${data.siteName}"
|
|
833
|
+
|
|
834
|
+
# Social media handles
|
|
835
|
+
social:
|
|
836
|
+
twitter: ""
|
|
837
|
+
linkedin: ""
|
|
838
|
+
|
|
839
|
+
# Default images for social sharing
|
|
840
|
+
images:
|
|
841
|
+
logo: ""
|
|
842
|
+
ogDefault: ""
|
|
843
|
+
twitterDefault: ""
|
|
844
|
+
|
|
845
|
+
# Default robots directive
|
|
846
|
+
robots: "index, follow"
|
|
847
|
+
|
|
848
|
+
# Page-specific SEO configurations
|
|
849
|
+
pages:
|
|
850
|
+
home:
|
|
851
|
+
title: Home
|
|
852
|
+
description: ""
|
|
853
|
+
noTitleTemplate: true
|
|
854
|
+
openGraph:
|
|
855
|
+
type: website
|
|
856
|
+
twitter:
|
|
857
|
+
card: summary_large_image
|
|
858
|
+
`;
|
|
859
|
+
}
|
|
860
|
+
function generateSectionConventions() {
|
|
861
|
+
return `# Section Conventions for DCS CMS Integration
|
|
862
|
+
|
|
863
|
+
This document describes the HTML attributes used by the DCS visual page editor
|
|
864
|
+
for section identification and snapshot capture.
|
|
865
|
+
|
|
866
|
+
## Required Attributes
|
|
867
|
+
|
|
868
|
+
### \`data-dcs-section\`
|
|
869
|
+
|
|
870
|
+
Marks an element as an editable section. The value should match the section's
|
|
871
|
+
identifier in content.yaml.
|
|
872
|
+
|
|
873
|
+
\`\`\`html
|
|
874
|
+
<section data-dcs-section="hero">
|
|
875
|
+
<!-- Section content -->
|
|
876
|
+
</section>
|
|
877
|
+
\`\`\`
|
|
878
|
+
|
|
879
|
+
### \`data-dcs-text\`
|
|
880
|
+
|
|
881
|
+
Marks an element's text as editable via the portal. The value should be the
|
|
882
|
+
text key from content.yaml.
|
|
883
|
+
|
|
884
|
+
\`\`\`html
|
|
885
|
+
<h1 data-dcs-text="hero.title">{{ t('hero.title') }}</h1>
|
|
886
|
+
\`\`\`
|
|
887
|
+
|
|
888
|
+
## Best Practices
|
|
889
|
+
|
|
890
|
+
1. Use semantic section names that describe the content
|
|
891
|
+
2. Keep text keys hierarchical (section.element)
|
|
892
|
+
3. Ensure all user-editable text has a data-dcs-text attribute
|
|
893
|
+
4. Don't nest sections unnecessarily
|
|
894
|
+
`;
|
|
895
|
+
}
|
|
896
|
+
function generateCopilotInstructions(data) {
|
|
897
|
+
return `# GitHub Copilot Instructions for ${data.siteName}
|
|
898
|
+
|
|
899
|
+
## Project Overview
|
|
900
|
+
|
|
901
|
+
This is a **DCS-managed customer site**. Content and feature changes are managed
|
|
902
|
+
through the DCS Portal at https://portal.duffcloudservices.com.
|
|
903
|
+
|
|
904
|
+
## Technology Stack
|
|
905
|
+
|
|
906
|
+
- Vue 3 + Composition API
|
|
907
|
+
- VitePress (or Vite)
|
|
908
|
+
- @duffcloudservices/cms for content management
|
|
909
|
+
- Azure Static Web Apps for hosting
|
|
910
|
+
|
|
911
|
+
## Content Management
|
|
912
|
+
|
|
913
|
+
### Text Content
|
|
914
|
+
|
|
915
|
+
Use the \`useTextContent\` composable for all editable text:
|
|
916
|
+
|
|
917
|
+
\`\`\`vue
|
|
918
|
+
<script setup lang="ts">
|
|
919
|
+
import { useTextContent } from '@duffcloudservices/cms'
|
|
920
|
+
|
|
921
|
+
const { t } = useTextContent({
|
|
922
|
+
pageSlug: 'home',
|
|
923
|
+
defaults: {
|
|
924
|
+
'hero.title': 'Default Title',
|
|
925
|
+
'hero.subtitle': 'Default subtitle'
|
|
926
|
+
}
|
|
927
|
+
})
|
|
928
|
+
</script>
|
|
929
|
+
|
|
930
|
+
<template>
|
|
931
|
+
<h1 data-dcs-text="hero.title">{{ t('hero.title') }}</h1>
|
|
932
|
+
<p data-dcs-text="hero.subtitle">{{ t('hero.subtitle') }}</p>
|
|
933
|
+
</template>
|
|
934
|
+
\`\`\`
|
|
935
|
+
|
|
936
|
+
### SEO
|
|
937
|
+
|
|
938
|
+
Use the \`useSEO\` composable for page SEO:
|
|
939
|
+
|
|
940
|
+
\`\`\`vue
|
|
941
|
+
<script setup lang="ts">
|
|
942
|
+
import { useSEO } from '@duffcloudservices/cms'
|
|
943
|
+
|
|
944
|
+
const { applyHead } = useSEO('home')
|
|
945
|
+
applyHead()
|
|
946
|
+
</script>
|
|
947
|
+
\`\`\`
|
|
948
|
+
|
|
949
|
+
## Key Conventions
|
|
950
|
+
|
|
951
|
+
1. All user-editable text must use \`useTextContent\`
|
|
952
|
+
2. All pages must have SEO configured via \`useSEO\`
|
|
953
|
+
3. Section elements need \`data-dcs-section\` attributes
|
|
954
|
+
4. Text elements need \`data-dcs-text\` attributes
|
|
955
|
+
5. See \`.dcs/SECTION-CONVENTIONS.md\` for details
|
|
956
|
+
`;
|
|
957
|
+
}
|
|
958
|
+
function generatePlansReadme(data) {
|
|
959
|
+
return `# DCS Integration Plans for ${data.siteName}
|
|
960
|
+
|
|
961
|
+
This directory contains step-by-step plans for integrating this site with the
|
|
962
|
+
DCS content management system.
|
|
963
|
+
|
|
964
|
+
## Overview
|
|
965
|
+
|
|
966
|
+
These plans guide AI assistants (like GitHub Copilot) through the integration
|
|
967
|
+
process. Follow them in order for best results.
|
|
968
|
+
|
|
969
|
+
## Plans
|
|
970
|
+
|
|
971
|
+
1. **00-audit-site.md** - Analyze current site structure
|
|
972
|
+
2. **01-create-use-text-content.md** - Set up the text content composable
|
|
973
|
+
3. **02-integrate-home-page.md** - Add CMS integration to home page
|
|
974
|
+
4. **03-integrate-remaining-pages.md** - Complete other pages
|
|
975
|
+
5. **04-capture-snapshots.md** - Configure visual editor snapshots
|
|
976
|
+
6. **05-verify-deployment.md** - Test and verify deployment
|
|
977
|
+
|
|
978
|
+
## Framework
|
|
979
|
+
|
|
980
|
+
This site uses: **${data.framework}**
|
|
981
|
+
|
|
982
|
+
## Getting Started
|
|
983
|
+
|
|
984
|
+
1. Open plan 00 and ask Copilot to execute it
|
|
985
|
+
2. Review the output and make any needed adjustments
|
|
986
|
+
3. Continue with subsequent plans
|
|
987
|
+
|
|
988
|
+
## Configuration Files
|
|
989
|
+
|
|
990
|
+
- \`.dcs/site.yaml\` - Site identity and Azure config
|
|
991
|
+
- \`.dcs/pages.yaml\` - Page registry for CMS
|
|
992
|
+
- \`.dcs/content.yaml\` - Text content (managed by Portal)
|
|
993
|
+
- \`.dcs/seo.yaml\` - SEO configuration (managed by Portal)
|
|
994
|
+
`;
|
|
995
|
+
}
|
|
996
|
+
function generateDeployWorkflow() {
|
|
997
|
+
return site_deploy_default;
|
|
998
|
+
}
|
|
999
|
+
function generatePlan00AuditSite(data) {
|
|
1000
|
+
return `# Plan 00: Audit Site Structure
|
|
1001
|
+
|
|
1002
|
+
## Objective
|
|
1003
|
+
|
|
1004
|
+
Analyze the current site structure to understand what pages exist, their routes,
|
|
1005
|
+
and identify all text content that should be managed via DCS.
|
|
1006
|
+
|
|
1007
|
+
## Framework
|
|
1008
|
+
|
|
1009
|
+
This site uses: **${data.framework}**
|
|
1010
|
+
|
|
1011
|
+
## Tasks
|
|
1012
|
+
|
|
1013
|
+
### 1. List All Pages
|
|
1014
|
+
|
|
1015
|
+
Identify all page components/routes in the project:
|
|
1016
|
+
|
|
1017
|
+
- For Vue/VitePress: Look in \`src/views/\`, \`src/pages/\`, or \`.vitepress/\`
|
|
1018
|
+
- For Astro: Look in \`src/pages/\`
|
|
1019
|
+
|
|
1020
|
+
Create a list of:
|
|
1021
|
+
- Route path (e.g., \`/\`, \`/about\`, \`/services\`)
|
|
1022
|
+
- Component file path
|
|
1023
|
+
- Page title (from existing meta or content)
|
|
1024
|
+
|
|
1025
|
+
### 2. Identify Text Content
|
|
1026
|
+
|
|
1027
|
+
For each page, list all user-facing text that should be editable:
|
|
1028
|
+
|
|
1029
|
+
- Headings (h1, h2, etc.)
|
|
1030
|
+
- Paragraphs and body text
|
|
1031
|
+
- Button labels
|
|
1032
|
+
- Navigation links
|
|
1033
|
+
- Footer content
|
|
1034
|
+
|
|
1035
|
+
Use this naming convention for text keys:
|
|
1036
|
+
\`\`\`
|
|
1037
|
+
{pageSlug}.{section}.{element}
|
|
1038
|
+
|
|
1039
|
+
Examples:
|
|
1040
|
+
- home.hero.title
|
|
1041
|
+
- home.hero.subtitle
|
|
1042
|
+
- home.cta.buttonText
|
|
1043
|
+
- about.intro.heading
|
|
1044
|
+
\`\`\`
|
|
1045
|
+
|
|
1046
|
+
### 3. Update pages.yaml
|
|
1047
|
+
|
|
1048
|
+
Add all discovered pages to \`.dcs/pages.yaml\`:
|
|
1049
|
+
|
|
1050
|
+
\`\`\`yaml
|
|
1051
|
+
pages:
|
|
1052
|
+
- slug: home
|
|
1053
|
+
path: /
|
|
1054
|
+
type: static
|
|
1055
|
+
title: Home
|
|
1056
|
+
deletable: false
|
|
1057
|
+
|
|
1058
|
+
- slug: about
|
|
1059
|
+
path: /about
|
|
1060
|
+
type: static
|
|
1061
|
+
title: About Us
|
|
1062
|
+
deletable: true
|
|
1063
|
+
\`\`\`
|
|
1064
|
+
|
|
1065
|
+
### 4. Document Findings
|
|
1066
|
+
|
|
1067
|
+
Create a brief summary of:
|
|
1068
|
+
- Total number of pages
|
|
1069
|
+
- Estimated number of text keys per page
|
|
1070
|
+
- Any complex sections (carousels, tabs, etc.)
|
|
1071
|
+
- Shared components that contain text (header, footer)
|
|
1072
|
+
|
|
1073
|
+
## Output
|
|
1074
|
+
|
|
1075
|
+
After completing this plan, you should have:
|
|
1076
|
+
1. Updated \`.dcs/pages.yaml\` with all pages
|
|
1077
|
+
2. A mental map of text content to migrate
|
|
1078
|
+
3. Understanding of component structure
|
|
1079
|
+
`;
|
|
1080
|
+
}
|
|
1081
|
+
function generatePlan01IntegrateContent() {
|
|
1082
|
+
return `# Plan 01: Integrate Text Content
|
|
1083
|
+
|
|
1084
|
+
## Objective
|
|
1085
|
+
|
|
1086
|
+
Set up the \`useTextContent\` composable and migrate all static text to the
|
|
1087
|
+
DCS content management system.
|
|
1088
|
+
|
|
1089
|
+
## Prerequisites
|
|
1090
|
+
|
|
1091
|
+
- Completed Plan 00 (site audit)
|
|
1092
|
+
- \`@duffcloudservices/cms\` package installed
|
|
1093
|
+
|
|
1094
|
+
## Tasks
|
|
1095
|
+
|
|
1096
|
+
### 1. Install DCS CMS Package
|
|
1097
|
+
|
|
1098
|
+
\`\`\`bash
|
|
1099
|
+
pnpm add @duffcloudservices/cms
|
|
1100
|
+
\`\`\`
|
|
1101
|
+
|
|
1102
|
+
### 2. Configure Vite Plugin
|
|
1103
|
+
|
|
1104
|
+
Add the DCS content plugin to your Vite config:
|
|
1105
|
+
|
|
1106
|
+
\`\`\`typescript
|
|
1107
|
+
// vite.config.ts
|
|
1108
|
+
import { dcsContentPlugin } from '@duffcloudservices/cms/vite'
|
|
1109
|
+
|
|
1110
|
+
export default defineConfig({
|
|
1111
|
+
plugins: [
|
|
1112
|
+
vue(),
|
|
1113
|
+
dcsContentPlugin({
|
|
1114
|
+
contentPath: '.dcs/content.yaml',
|
|
1115
|
+
}),
|
|
1116
|
+
],
|
|
1117
|
+
})
|
|
1118
|
+
\`\`\`
|
|
1119
|
+
|
|
1120
|
+
### 3. Integrate Home Page
|
|
1121
|
+
|
|
1122
|
+
Start with the home page as a template:
|
|
1123
|
+
|
|
1124
|
+
\`\`\`vue
|
|
1125
|
+
<script setup lang="ts">
|
|
1126
|
+
import { useTextContent } from '@duffcloudservices/cms'
|
|
1127
|
+
|
|
1128
|
+
const { t } = useTextContent({
|
|
1129
|
+
pageSlug: 'home',
|
|
1130
|
+
defaults: {
|
|
1131
|
+
'hero.title': 'Your Default Title',
|
|
1132
|
+
'hero.subtitle': 'Your default subtitle text',
|
|
1133
|
+
'hero.cta': 'Get Started',
|
|
1134
|
+
}
|
|
1135
|
+
})
|
|
1136
|
+
</script>
|
|
1137
|
+
|
|
1138
|
+
<template>
|
|
1139
|
+
<section data-dcs-section="hero">
|
|
1140
|
+
<h1 data-dcs-text="hero.title">{{ t('hero.title') }}</h1>
|
|
1141
|
+
<p data-dcs-text="hero.subtitle">{{ t('hero.subtitle') }}</p>
|
|
1142
|
+
<button data-dcs-text="hero.cta">{{ t('hero.cta') }}</button>
|
|
1143
|
+
</section>
|
|
1144
|
+
</template>
|
|
1145
|
+
\`\`\`
|
|
1146
|
+
|
|
1147
|
+
### 4. Key Patterns
|
|
1148
|
+
|
|
1149
|
+
**Text Keys**: Use hierarchical naming
|
|
1150
|
+
- \`{section}.{element}\` for page-specific content
|
|
1151
|
+
- Prefix with \`global.\` for shared content
|
|
1152
|
+
|
|
1153
|
+
**Default Values**: Always provide defaults that match current content
|
|
1154
|
+
- This ensures the site works before CMS content is loaded
|
|
1155
|
+
- Defaults serve as fallback if content fetch fails
|
|
1156
|
+
|
|
1157
|
+
**Data Attributes**: Required for visual editor
|
|
1158
|
+
- \`data-dcs-section\` on section containers
|
|
1159
|
+
- \`data-dcs-text\` on text elements (value = text key)
|
|
1160
|
+
|
|
1161
|
+
### 5. Migrate Remaining Pages
|
|
1162
|
+
|
|
1163
|
+
Apply the same pattern to all pages identified in Plan 00.
|
|
1164
|
+
|
|
1165
|
+
For each page:
|
|
1166
|
+
1. Import \`useTextContent\`
|
|
1167
|
+
2. Define defaults for all text content
|
|
1168
|
+
3. Replace static text with \`{{ t('key') }}\`
|
|
1169
|
+
4. Add \`data-dcs-text\` attributes
|
|
1170
|
+
|
|
1171
|
+
### 6. Handle Global Content
|
|
1172
|
+
|
|
1173
|
+
Create a shared composable for navigation/footer:
|
|
1174
|
+
|
|
1175
|
+
\`\`\`typescript
|
|
1176
|
+
// composables/useGlobalContent.ts
|
|
1177
|
+
import { useTextContent } from '@duffcloudservices/cms'
|
|
1178
|
+
|
|
1179
|
+
export function useGlobalContent() {
|
|
1180
|
+
return useTextContent({
|
|
1181
|
+
pageSlug: 'global',
|
|
1182
|
+
defaults: {
|
|
1183
|
+
'nav.home': 'Home',
|
|
1184
|
+
'nav.about': 'About',
|
|
1185
|
+
'footer.copyright': '\xA9 2025 Company Name',
|
|
1186
|
+
}
|
|
1187
|
+
})
|
|
1188
|
+
}
|
|
1189
|
+
\`\`\`
|
|
1190
|
+
|
|
1191
|
+
## Verification
|
|
1192
|
+
|
|
1193
|
+
After completing this plan:
|
|
1194
|
+
1. Run \`pnpm dev\` - site should display normally with defaults
|
|
1195
|
+
2. Check browser console for any missing text key warnings
|
|
1196
|
+
3. Verify all text elements have \`data-dcs-text\` attributes
|
|
1197
|
+
`;
|
|
1198
|
+
}
|
|
1199
|
+
function generatePlan02ConfigureSeo() {
|
|
1200
|
+
return `# Plan 02: Configure SEO
|
|
1201
|
+
|
|
1202
|
+
## Objective
|
|
1203
|
+
|
|
1204
|
+
Set up the \`useSEO\` composable to manage page metadata through DCS Portal.
|
|
1205
|
+
|
|
1206
|
+
## Prerequisites
|
|
1207
|
+
|
|
1208
|
+
- Completed Plan 01 (content integration)
|
|
1209
|
+
- \`@duffcloudservices/cms\` package installed
|
|
1210
|
+
|
|
1211
|
+
## Tasks
|
|
1212
|
+
|
|
1213
|
+
### 1. Configure Vite Plugin
|
|
1214
|
+
|
|
1215
|
+
Add the DCS SEO plugin to your Vite config:
|
|
1216
|
+
|
|
1217
|
+
\`\`\`typescript
|
|
1218
|
+
// vite.config.ts
|
|
1219
|
+
import { dcsContentPlugin, dcsSeoPlugin } from '@duffcloudservices/cms/vite'
|
|
1220
|
+
|
|
1221
|
+
export default defineConfig({
|
|
1222
|
+
plugins: [
|
|
1223
|
+
vue(),
|
|
1224
|
+
dcsContentPlugin({ contentPath: '.dcs/content.yaml' }),
|
|
1225
|
+
dcsSeoPlugin({ seoPath: '.dcs/seo.yaml' }),
|
|
1226
|
+
],
|
|
1227
|
+
})
|
|
1228
|
+
\`\`\`
|
|
1229
|
+
|
|
1230
|
+
### 2. Add SEO to Pages
|
|
1231
|
+
|
|
1232
|
+
For each page, add the \`useSEO\` composable:
|
|
1233
|
+
|
|
1234
|
+
\`\`\`vue
|
|
1235
|
+
<script setup lang="ts">
|
|
1236
|
+
import { useTextContent, useSEO } from '@duffcloudservices/cms'
|
|
1237
|
+
|
|
1238
|
+
// Content setup
|
|
1239
|
+
const { t } = useTextContent({ pageSlug: 'home', defaults: { ... } })
|
|
1240
|
+
|
|
1241
|
+
// SEO setup - applies head tags automatically
|
|
1242
|
+
const { applyHead } = useSEO('home')
|
|
1243
|
+
applyHead()
|
|
1244
|
+
</script>
|
|
1245
|
+
\`\`\`
|
|
1246
|
+
|
|
1247
|
+
### 3. Configure Default SEO
|
|
1248
|
+
|
|
1249
|
+
Update \`.dcs/seo.yaml\` with site-wide defaults:
|
|
1250
|
+
|
|
1251
|
+
\`\`\`yaml
|
|
1252
|
+
global:
|
|
1253
|
+
siteName: "Your Site Name"
|
|
1254
|
+
siteUrl: "https://yoursite.com"
|
|
1255
|
+
locale: en_US
|
|
1256
|
+
defaultTitle: "Your Site Name"
|
|
1257
|
+
defaultDescription: "Your site description for search engines"
|
|
1258
|
+
titleTemplate: "%s | Your Site Name"
|
|
1259
|
+
|
|
1260
|
+
social:
|
|
1261
|
+
twitter: "@yourhandle"
|
|
1262
|
+
linkedin: "company/yourcompany"
|
|
1263
|
+
|
|
1264
|
+
images:
|
|
1265
|
+
logo: "/images/logo.png"
|
|
1266
|
+
ogDefault: "/images/og-default.png"
|
|
1267
|
+
|
|
1268
|
+
pages:
|
|
1269
|
+
home:
|
|
1270
|
+
title: Home
|
|
1271
|
+
description: "Welcome to our site"
|
|
1272
|
+
noTitleTemplate: true # Home page often skips " | Site Name"
|
|
1273
|
+
openGraph:
|
|
1274
|
+
type: website
|
|
1275
|
+
twitter:
|
|
1276
|
+
card: summary_large_image
|
|
1277
|
+
|
|
1278
|
+
about:
|
|
1279
|
+
title: About Us
|
|
1280
|
+
description: "Learn more about our company"
|
|
1281
|
+
\`\`\`
|
|
1282
|
+
|
|
1283
|
+
### 4. Page-Specific Overrides
|
|
1284
|
+
|
|
1285
|
+
Each page can have custom SEO settings that override globals:
|
|
1286
|
+
|
|
1287
|
+
\`\`\`yaml
|
|
1288
|
+
pages:
|
|
1289
|
+
services:
|
|
1290
|
+
title: Our Services
|
|
1291
|
+
description: "Explore our professional services"
|
|
1292
|
+
canonical: "https://yoursite.com/services"
|
|
1293
|
+
openGraph:
|
|
1294
|
+
title: "Services - Custom OG Title"
|
|
1295
|
+
description: "Custom description for social sharing"
|
|
1296
|
+
image: "/images/services-og.png"
|
|
1297
|
+
\`\`\`
|
|
1298
|
+
|
|
1299
|
+
### 5. Dynamic Pages (Blogs, etc.)
|
|
1300
|
+
|
|
1301
|
+
For dynamic pages like blog posts, you'll pass SEO data dynamically:
|
|
1302
|
+
|
|
1303
|
+
\`\`\`vue
|
|
1304
|
+
<script setup lang="ts">
|
|
1305
|
+
import { useSEO } from '@duffcloudservices/cms'
|
|
1306
|
+
|
|
1307
|
+
const props = defineProps<{ post: BlogPost }>()
|
|
1308
|
+
|
|
1309
|
+
const { applyHead } = useSEO('blog-post', {
|
|
1310
|
+
title: props.post.title,
|
|
1311
|
+
description: props.post.excerpt,
|
|
1312
|
+
openGraph: {
|
|
1313
|
+
type: 'article',
|
|
1314
|
+
image: props.post.featuredImage,
|
|
1315
|
+
}
|
|
1316
|
+
})
|
|
1317
|
+
applyHead()
|
|
1318
|
+
</script>
|
|
1319
|
+
\`\`\`
|
|
1320
|
+
|
|
1321
|
+
## Verification
|
|
1322
|
+
|
|
1323
|
+
After completing this plan:
|
|
1324
|
+
1. View page source - check for meta tags
|
|
1325
|
+
2. Use browser dev tools to inspect \`<head>\`
|
|
1326
|
+
3. Test with social sharing debuggers (Facebook, Twitter, LinkedIn)
|
|
1327
|
+
4. Validate with Google's Rich Results Test
|
|
1328
|
+
`;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// src/commands/validate.ts
|
|
1332
|
+
import fs2 from "fs/promises";
|
|
1333
|
+
import path2 from "path";
|
|
1334
|
+
import chalk5 from "chalk";
|
|
1335
|
+
import yaml from "js-yaml";
|
|
1336
|
+
async function validateCommand(options) {
|
|
1337
|
+
const { target = ".", fix = false, verbose = false } = options;
|
|
1338
|
+
const targetDir = path2.resolve(target);
|
|
1339
|
+
const dcsDir = path2.join(targetDir, ".dcs");
|
|
1340
|
+
console.log(chalk5.bold("Validating DCS configuration..."));
|
|
1341
|
+
console.log(chalk5.dim(`Directory: ${targetDir}`));
|
|
1342
|
+
console.log();
|
|
1343
|
+
try {
|
|
1344
|
+
await fs2.access(dcsDir);
|
|
1345
|
+
} catch {
|
|
1346
|
+
console.log(chalk5.red("No .dcs directory found."));
|
|
1347
|
+
console.log("Run", chalk5.cyan("dcs init"), "to create DCS configuration.");
|
|
1348
|
+
process.exit(1);
|
|
1349
|
+
}
|
|
1350
|
+
const results = [];
|
|
1351
|
+
results.push(await validateSiteYaml(dcsDir, verbose));
|
|
1352
|
+
results.push(await validatePagesYaml(dcsDir, verbose));
|
|
1353
|
+
results.push(await validateContentYaml(dcsDir, verbose));
|
|
1354
|
+
results.push(await validateSeoYaml(dcsDir, verbose));
|
|
1355
|
+
console.log(chalk5.bold("Validation Results:"));
|
|
1356
|
+
console.log();
|
|
1357
|
+
let hasErrors = false;
|
|
1358
|
+
let hasWarnings = false;
|
|
1359
|
+
for (const result of results) {
|
|
1360
|
+
if (!result.valid || result.errors.length > 0) {
|
|
1361
|
+
hasErrors = true;
|
|
1362
|
+
console.log(`${chalk5.red("\u2717")} ${result.file}`);
|
|
1363
|
+
for (const error of result.errors) {
|
|
1364
|
+
console.log(` ${chalk5.red("error:")} ${error}`);
|
|
1365
|
+
}
|
|
1366
|
+
} else if (result.warnings.length > 0) {
|
|
1367
|
+
hasWarnings = true;
|
|
1368
|
+
console.log(`${chalk5.yellow("!")} ${result.file}`);
|
|
1369
|
+
} else {
|
|
1370
|
+
console.log(`${chalk5.green("\u2713")} ${result.file}`);
|
|
1371
|
+
}
|
|
1372
|
+
for (const warning of result.warnings) {
|
|
1373
|
+
console.log(` ${chalk5.yellow("warning:")} ${warning}`);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
console.log();
|
|
1377
|
+
if (hasErrors) {
|
|
1378
|
+
console.log(chalk5.red("Validation failed with errors."));
|
|
1379
|
+
if (fix) {
|
|
1380
|
+
console.log(chalk5.yellow("Some errors can be fixed automatically. Run with --fix flag."));
|
|
1381
|
+
}
|
|
1382
|
+
process.exit(1);
|
|
1383
|
+
} else if (hasWarnings) {
|
|
1384
|
+
console.log(chalk5.yellow("Validation passed with warnings."));
|
|
1385
|
+
} else {
|
|
1386
|
+
console.log(chalk5.green("All configuration files are valid."));
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
async function validateSiteYaml(dcsDir, verbose) {
|
|
1390
|
+
const filePath = path2.join(dcsDir, "site.yaml");
|
|
1391
|
+
const result = {
|
|
1392
|
+
file: ".dcs/site.yaml",
|
|
1393
|
+
valid: true,
|
|
1394
|
+
errors: [],
|
|
1395
|
+
warnings: []
|
|
1396
|
+
};
|
|
1397
|
+
try {
|
|
1398
|
+
const content = await fs2.readFile(filePath, "utf8");
|
|
1399
|
+
const config2 = yaml.load(content);
|
|
1400
|
+
if (!config2.site_name) {
|
|
1401
|
+
result.errors.push("Missing required field: site_name");
|
|
1402
|
+
result.valid = false;
|
|
1403
|
+
}
|
|
1404
|
+
if (!config2.site_slug) {
|
|
1405
|
+
result.errors.push("Missing required field: site_slug");
|
|
1406
|
+
result.valid = false;
|
|
1407
|
+
} else if (!/^[a-z0-9-]+$/.test(config2.site_slug)) {
|
|
1408
|
+
result.errors.push("site_slug must be lowercase alphanumeric with hyphens only");
|
|
1409
|
+
result.valid = false;
|
|
1410
|
+
}
|
|
1411
|
+
if (!config2.swa_resource_id) {
|
|
1412
|
+
result.warnings.push("swa_resource_id is not set (required for deployment)");
|
|
1413
|
+
}
|
|
1414
|
+
if (!config2.production_url) {
|
|
1415
|
+
result.warnings.push("production_url is not set");
|
|
1416
|
+
}
|
|
1417
|
+
if (verbose) {
|
|
1418
|
+
console.log(chalk5.dim(` site_name: ${config2.site_name}`));
|
|
1419
|
+
console.log(chalk5.dim(` site_slug: ${config2.site_slug}`));
|
|
1420
|
+
}
|
|
1421
|
+
} catch (error) {
|
|
1422
|
+
if (error.code === "ENOENT") {
|
|
1423
|
+
result.errors.push("File not found");
|
|
1424
|
+
} else {
|
|
1425
|
+
result.errors.push(`Parse error: ${error.message}`);
|
|
1426
|
+
}
|
|
1427
|
+
result.valid = false;
|
|
1428
|
+
}
|
|
1429
|
+
return result;
|
|
1430
|
+
}
|
|
1431
|
+
async function validatePagesYaml(dcsDir, verbose) {
|
|
1432
|
+
const filePath = path2.join(dcsDir, "pages.yaml");
|
|
1433
|
+
const result = {
|
|
1434
|
+
file: ".dcs/pages.yaml",
|
|
1435
|
+
valid: true,
|
|
1436
|
+
errors: [],
|
|
1437
|
+
warnings: []
|
|
1438
|
+
};
|
|
1439
|
+
try {
|
|
1440
|
+
const content = await fs2.readFile(filePath, "utf8");
|
|
1441
|
+
const config2 = yaml.load(content);
|
|
1442
|
+
if (config2.version === void 0) {
|
|
1443
|
+
result.errors.push("Missing required field: version");
|
|
1444
|
+
result.valid = false;
|
|
1445
|
+
}
|
|
1446
|
+
if (!config2.siteSlug) {
|
|
1447
|
+
result.errors.push("Missing required field: siteSlug");
|
|
1448
|
+
result.valid = false;
|
|
1449
|
+
}
|
|
1450
|
+
const pages = config2.pages;
|
|
1451
|
+
if (!pages || !Array.isArray(pages)) {
|
|
1452
|
+
result.errors.push("Missing or invalid pages array");
|
|
1453
|
+
result.valid = false;
|
|
1454
|
+
} else {
|
|
1455
|
+
const hasHome = pages.some((p) => p.slug === "home");
|
|
1456
|
+
if (!hasHome) {
|
|
1457
|
+
result.warnings.push("No home page defined (slug: home)");
|
|
1458
|
+
}
|
|
1459
|
+
for (let i = 0; i < pages.length; i++) {
|
|
1460
|
+
const page = pages[i];
|
|
1461
|
+
if (!page.slug) {
|
|
1462
|
+
result.errors.push(`Page ${i}: missing slug`);
|
|
1463
|
+
result.valid = false;
|
|
1464
|
+
}
|
|
1465
|
+
if (!page.path) {
|
|
1466
|
+
result.errors.push(`Page ${i}: missing path`);
|
|
1467
|
+
result.valid = false;
|
|
1468
|
+
}
|
|
1469
|
+
if (!page.type) {
|
|
1470
|
+
result.warnings.push(`Page '${page.slug}': missing type (defaulting to static)`);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
if (verbose) {
|
|
1474
|
+
console.log(chalk5.dim(` ${pages.length} pages defined`));
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
} catch (error) {
|
|
1478
|
+
if (error.code === "ENOENT") {
|
|
1479
|
+
result.errors.push("File not found");
|
|
1480
|
+
} else {
|
|
1481
|
+
result.errors.push(`Parse error: ${error.message}`);
|
|
1482
|
+
}
|
|
1483
|
+
result.valid = false;
|
|
1484
|
+
}
|
|
1485
|
+
return result;
|
|
1486
|
+
}
|
|
1487
|
+
async function validateContentYaml(dcsDir, verbose) {
|
|
1488
|
+
const filePath = path2.join(dcsDir, "content.yaml");
|
|
1489
|
+
const result = {
|
|
1490
|
+
file: ".dcs/content.yaml",
|
|
1491
|
+
valid: true,
|
|
1492
|
+
errors: [],
|
|
1493
|
+
warnings: []
|
|
1494
|
+
};
|
|
1495
|
+
try {
|
|
1496
|
+
const content = await fs2.readFile(filePath, "utf8");
|
|
1497
|
+
const config2 = yaml.load(content);
|
|
1498
|
+
if (config2.version === void 0) {
|
|
1499
|
+
result.errors.push("Missing required field: version");
|
|
1500
|
+
result.valid = false;
|
|
1501
|
+
}
|
|
1502
|
+
if (config2.global !== void 0 && typeof config2.global !== "object") {
|
|
1503
|
+
result.errors.push("global must be an object");
|
|
1504
|
+
result.valid = false;
|
|
1505
|
+
}
|
|
1506
|
+
if (config2.pages !== void 0 && typeof config2.pages !== "object") {
|
|
1507
|
+
result.errors.push("pages must be an object");
|
|
1508
|
+
result.valid = false;
|
|
1509
|
+
}
|
|
1510
|
+
if (verbose) {
|
|
1511
|
+
const globalKeys = config2.global ? Object.keys(config2.global).length : 0;
|
|
1512
|
+
const pageCount = config2.pages ? Object.keys(config2.pages).length : 0;
|
|
1513
|
+
console.log(chalk5.dim(` ${globalKeys} global keys, ${pageCount} page sections`));
|
|
1514
|
+
}
|
|
1515
|
+
} catch (error) {
|
|
1516
|
+
if (error.code === "ENOENT") {
|
|
1517
|
+
result.errors.push("File not found");
|
|
1518
|
+
} else {
|
|
1519
|
+
result.errors.push(`Parse error: ${error.message}`);
|
|
1520
|
+
}
|
|
1521
|
+
result.valid = false;
|
|
1522
|
+
}
|
|
1523
|
+
return result;
|
|
1524
|
+
}
|
|
1525
|
+
async function validateSeoYaml(dcsDir, verbose) {
|
|
1526
|
+
const filePath = path2.join(dcsDir, "seo.yaml");
|
|
1527
|
+
const result = {
|
|
1528
|
+
file: ".dcs/seo.yaml",
|
|
1529
|
+
valid: true,
|
|
1530
|
+
errors: [],
|
|
1531
|
+
warnings: []
|
|
1532
|
+
};
|
|
1533
|
+
try {
|
|
1534
|
+
const content = await fs2.readFile(filePath, "utf8");
|
|
1535
|
+
const config2 = yaml.load(content);
|
|
1536
|
+
if (config2.version === void 0) {
|
|
1537
|
+
result.errors.push("Missing required field: version");
|
|
1538
|
+
result.valid = false;
|
|
1539
|
+
}
|
|
1540
|
+
const global = config2.global;
|
|
1541
|
+
if (!global) {
|
|
1542
|
+
result.errors.push("Missing required section: global");
|
|
1543
|
+
result.valid = false;
|
|
1544
|
+
} else {
|
|
1545
|
+
if (!global.siteName) {
|
|
1546
|
+
result.warnings.push("global.siteName is not set");
|
|
1547
|
+
}
|
|
1548
|
+
if (!global.siteUrl) {
|
|
1549
|
+
result.warnings.push("global.siteUrl is not set (required for SEO)");
|
|
1550
|
+
}
|
|
1551
|
+
if (!global.defaultDescription) {
|
|
1552
|
+
result.warnings.push("global.defaultDescription is not set");
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
const pages = config2.pages;
|
|
1556
|
+
if (pages && typeof pages === "object") {
|
|
1557
|
+
const pageKeys = Object.keys(pages);
|
|
1558
|
+
if (!pageKeys.includes("home")) {
|
|
1559
|
+
result.warnings.push("No SEO configuration for home page");
|
|
1560
|
+
}
|
|
1561
|
+
if (verbose) {
|
|
1562
|
+
console.log(chalk5.dim(` ${pageKeys.length} page SEO configs`));
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
} catch (error) {
|
|
1566
|
+
if (error.code === "ENOENT") {
|
|
1567
|
+
result.errors.push("File not found");
|
|
1568
|
+
} else {
|
|
1569
|
+
result.errors.push(`Parse error: ${error.message}`);
|
|
1570
|
+
}
|
|
1571
|
+
result.valid = false;
|
|
1572
|
+
}
|
|
1573
|
+
return result;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// src/commands/plans.ts
|
|
1577
|
+
import fs3 from "fs/promises";
|
|
1578
|
+
import path3 from "path";
|
|
1579
|
+
import chalk6 from "chalk";
|
|
1580
|
+
import yaml2 from "js-yaml";
|
|
1581
|
+
async function plansCommand(options) {
|
|
1582
|
+
const { target = ".", force = false } = options;
|
|
1583
|
+
const targetDir = path3.resolve(target);
|
|
1584
|
+
const dcsDir = path3.join(targetDir, ".dcs");
|
|
1585
|
+
const plansDir = path3.join(targetDir, ".plans");
|
|
1586
|
+
console.log(chalk6.bold("Generating DCS integration plans..."));
|
|
1587
|
+
console.log(chalk6.dim(`Directory: ${targetDir}`));
|
|
1588
|
+
console.log();
|
|
1589
|
+
try {
|
|
1590
|
+
await fs3.access(dcsDir);
|
|
1591
|
+
} catch {
|
|
1592
|
+
console.log(chalk6.red("No .dcs directory found."));
|
|
1593
|
+
console.log("Run", chalk6.cyan("dcs init"), "first to create DCS configuration.");
|
|
1594
|
+
process.exit(1);
|
|
1595
|
+
}
|
|
1596
|
+
let siteName = "Customer Site";
|
|
1597
|
+
let framework = "vue";
|
|
1598
|
+
try {
|
|
1599
|
+
const siteYaml = await fs3.readFile(path3.join(dcsDir, "site.yaml"), "utf8");
|
|
1600
|
+
const siteConfig = yaml2.load(siteYaml);
|
|
1601
|
+
siteName = siteConfig.site_name || siteName;
|
|
1602
|
+
const metadata = siteConfig.metadata;
|
|
1603
|
+
framework = metadata?.framework || framework;
|
|
1604
|
+
} catch {
|
|
1605
|
+
console.log(chalk6.yellow("Warning: Could not read site.yaml"));
|
|
1606
|
+
}
|
|
1607
|
+
const plans = [
|
|
1608
|
+
{ name: "00-audit-site.md", content: generateAuditPlan(siteName) },
|
|
1609
|
+
{ name: "01-create-use-text-content.md", content: generateTextContentPlan(siteName, framework) },
|
|
1610
|
+
{ name: "02-integrate-home-page.md", content: generateHomePagePlan(siteName) },
|
|
1611
|
+
{ name: "03-integrate-remaining-pages.md", content: generateRemainingPagesPlan(siteName) },
|
|
1612
|
+
{ name: "04-capture-snapshots.md", content: generateSnapshotsPlan(siteName) },
|
|
1613
|
+
{ name: "05-verify-deployment.md", content: generateVerifyPlan(siteName) }
|
|
1614
|
+
];
|
|
1615
|
+
await fs3.mkdir(plansDir, { recursive: true });
|
|
1616
|
+
const generatedFiles = [];
|
|
1617
|
+
for (const plan of plans) {
|
|
1618
|
+
const filePath = path3.join(plansDir, plan.name);
|
|
1619
|
+
try {
|
|
1620
|
+
await fs3.access(filePath);
|
|
1621
|
+
if (!force) {
|
|
1622
|
+
console.log(chalk6.yellow(`Skipped (exists): .plans/${plan.name}`));
|
|
1623
|
+
continue;
|
|
1624
|
+
}
|
|
1625
|
+
} catch {
|
|
1626
|
+
}
|
|
1627
|
+
await fs3.writeFile(filePath, plan.content, "utf8");
|
|
1628
|
+
generatedFiles.push(plan.name);
|
|
1629
|
+
console.log(`${chalk6.green("\u2713")} .plans/${plan.name}`);
|
|
1630
|
+
}
|
|
1631
|
+
console.log();
|
|
1632
|
+
if (generatedFiles.length > 0) {
|
|
1633
|
+
console.log(chalk6.green(`Generated ${generatedFiles.length} plan files.`));
|
|
1634
|
+
} else {
|
|
1635
|
+
console.log(chalk6.yellow("No new files generated. Use --force to overwrite existing files."));
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
function generateAuditPlan(siteName) {
|
|
1639
|
+
return `# Plan 00: Audit ${siteName} Site Structure
|
|
1640
|
+
|
|
1641
|
+
## Objective
|
|
1642
|
+
|
|
1643
|
+
Analyze the current site structure to understand:
|
|
1644
|
+
- What pages exist and their routing structure
|
|
1645
|
+
- Current component organization
|
|
1646
|
+
- Existing content patterns
|
|
1647
|
+
- Build/deploy configuration
|
|
1648
|
+
|
|
1649
|
+
## Steps
|
|
1650
|
+
|
|
1651
|
+
### Step 1: List All Pages
|
|
1652
|
+
|
|
1653
|
+
Scan the project for page components:
|
|
1654
|
+
|
|
1655
|
+
\`\`\`
|
|
1656
|
+
# For VitePress
|
|
1657
|
+
ls docs/**/*.md
|
|
1658
|
+
|
|
1659
|
+
# For Vue/Vite
|
|
1660
|
+
ls src/pages/**/*.vue
|
|
1661
|
+
ls src/views/**/*.vue
|
|
1662
|
+
\`\`\`
|
|
1663
|
+
|
|
1664
|
+
### Step 2: Identify Content Patterns
|
|
1665
|
+
|
|
1666
|
+
Look for:
|
|
1667
|
+
- Hardcoded text strings in templates
|
|
1668
|
+
- Existing i18n or content management
|
|
1669
|
+
- Props-based content passing
|
|
1670
|
+
- API-driven content
|
|
1671
|
+
|
|
1672
|
+
### Step 3: Document Current Structure
|
|
1673
|
+
|
|
1674
|
+
Create a list of:
|
|
1675
|
+
1. All page routes
|
|
1676
|
+
2. Reusable components with text content
|
|
1677
|
+
3. Layout components (header, footer)
|
|
1678
|
+
4. Any existing SEO implementation
|
|
1679
|
+
|
|
1680
|
+
### Step 4: Update pages.yaml
|
|
1681
|
+
|
|
1682
|
+
Update \`.dcs/pages.yaml\` with all discovered pages:
|
|
1683
|
+
|
|
1684
|
+
\`\`\`yaml
|
|
1685
|
+
pages:
|
|
1686
|
+
- slug: home
|
|
1687
|
+
path: /
|
|
1688
|
+
type: static
|
|
1689
|
+
title: Home
|
|
1690
|
+
deletable: false
|
|
1691
|
+
# Add discovered pages here...
|
|
1692
|
+
\`\`\`
|
|
1693
|
+
|
|
1694
|
+
## Output
|
|
1695
|
+
|
|
1696
|
+
- Updated \`.dcs/pages.yaml\` with all pages
|
|
1697
|
+
- List of components requiring content integration
|
|
1698
|
+
- Notes on any existing content management to migrate
|
|
1699
|
+
`;
|
|
1700
|
+
}
|
|
1701
|
+
function generateTextContentPlan(siteName, framework) {
|
|
1702
|
+
return `# Plan 01: Create useTextContent Composable for ${siteName}
|
|
1703
|
+
|
|
1704
|
+
## Objective
|
|
1705
|
+
|
|
1706
|
+
Set up the text content composable for runtime/build-time content management.
|
|
1707
|
+
|
|
1708
|
+
## Prerequisites
|
|
1709
|
+
|
|
1710
|
+
- \`@duffcloudservices/cms\` package installed
|
|
1711
|
+
- Vite/VitePress project configured
|
|
1712
|
+
|
|
1713
|
+
## Steps
|
|
1714
|
+
|
|
1715
|
+
### Step 1: Install CMS Package
|
|
1716
|
+
|
|
1717
|
+
\`\`\`bash
|
|
1718
|
+
pnpm add @duffcloudservices/cms
|
|
1719
|
+
\`\`\`
|
|
1720
|
+
|
|
1721
|
+
### Step 2: Configure Vite Plugin
|
|
1722
|
+
|
|
1723
|
+
${framework === "vue" ? `
|
|
1724
|
+
In \`vite.config.ts\`:
|
|
1725
|
+
|
|
1726
|
+
\`\`\`typescript
|
|
1727
|
+
import { defineConfig } from 'vite'
|
|
1728
|
+
import vue from '@vitejs/plugin-vue'
|
|
1729
|
+
import { dcsContentPlugin } from '@duffcloudservices/cms/plugins'
|
|
1730
|
+
|
|
1731
|
+
export default defineConfig({
|
|
1732
|
+
plugins: [
|
|
1733
|
+
vue(),
|
|
1734
|
+
dcsContentPlugin({ path: '.dcs/content.yaml' }),
|
|
1735
|
+
],
|
|
1736
|
+
})
|
|
1737
|
+
\`\`\`
|
|
1738
|
+
` : `
|
|
1739
|
+
In \`.vitepress/config.ts\`:
|
|
1740
|
+
|
|
1741
|
+
\`\`\`typescript
|
|
1742
|
+
import { defineConfig } from 'vitepress'
|
|
1743
|
+
import { dcsContentPlugin } from '@duffcloudservices/cms/plugins'
|
|
1744
|
+
|
|
1745
|
+
export default defineConfig({
|
|
1746
|
+
vite: {
|
|
1747
|
+
plugins: [
|
|
1748
|
+
dcsContentPlugin({ path: '.dcs/content.yaml' }),
|
|
1749
|
+
],
|
|
1750
|
+
},
|
|
1751
|
+
})
|
|
1752
|
+
\`\`\`
|
|
1753
|
+
`}
|
|
1754
|
+
|
|
1755
|
+
### Step 3: Create Site Composable Wrapper (Optional)
|
|
1756
|
+
|
|
1757
|
+
Create a site-specific wrapper for consistent defaults:
|
|
1758
|
+
|
|
1759
|
+
\`\`\`typescript
|
|
1760
|
+
// src/composables/useContent.ts
|
|
1761
|
+
import { useTextContent as baseuseTextContent } from '@duffcloudservices/cms'
|
|
1762
|
+
|
|
1763
|
+
export function useContent(pageSlug: string, defaults: Record<string, string> = {}) {
|
|
1764
|
+
return baseuseTextContent({ pageSlug, defaults })
|
|
1765
|
+
}
|
|
1766
|
+
\`\`\`
|
|
1767
|
+
|
|
1768
|
+
## Verification
|
|
1769
|
+
|
|
1770
|
+
1. Run \`pnpm dev\` - should start without errors
|
|
1771
|
+
2. Import and use composable in a test component
|
|
1772
|
+
3. Verify content.yaml values are accessible via \`t()\` function
|
|
1773
|
+
`;
|
|
1774
|
+
}
|
|
1775
|
+
function generateHomePagePlan(siteName) {
|
|
1776
|
+
return `# Plan 02: Integrate Home Page for ${siteName}
|
|
1777
|
+
|
|
1778
|
+
## Objective
|
|
1779
|
+
|
|
1780
|
+
Add DCS CMS integration to the home page, including:
|
|
1781
|
+
- Text content management
|
|
1782
|
+
- SEO configuration
|
|
1783
|
+
- Data attributes for visual editor
|
|
1784
|
+
|
|
1785
|
+
## Steps
|
|
1786
|
+
|
|
1787
|
+
### Step 1: Identify Home Page Component
|
|
1788
|
+
|
|
1789
|
+
Find the main home page component:
|
|
1790
|
+
- VitePress: \`docs/index.md\` or \`docs/.vitepress/theme/components/Home.vue\`
|
|
1791
|
+
- Vue: \`src/pages/index.vue\` or \`src/views/Home.vue\`
|
|
1792
|
+
|
|
1793
|
+
### Step 2: Add Text Content Integration
|
|
1794
|
+
|
|
1795
|
+
Replace hardcoded text with \`t()\` function calls:
|
|
1796
|
+
|
|
1797
|
+
\`\`\`vue
|
|
1798
|
+
<script setup lang="ts">
|
|
1799
|
+
import { useTextContent, useSEO } from '@duffcloudservices/cms'
|
|
1800
|
+
|
|
1801
|
+
const { t } = useTextContent({
|
|
1802
|
+
pageSlug: 'home',
|
|
1803
|
+
defaults: {
|
|
1804
|
+
'hero.title': 'Welcome to ${siteName}',
|
|
1805
|
+
'hero.subtitle': 'Your subtitle here',
|
|
1806
|
+
'hero.cta': 'Get Started',
|
|
1807
|
+
// Add all text content...
|
|
1808
|
+
}
|
|
1809
|
+
})
|
|
1810
|
+
|
|
1811
|
+
// Apply SEO
|
|
1812
|
+
const { applyHead } = useSEO('home')
|
|
1813
|
+
applyHead()
|
|
1814
|
+
</script>
|
|
1815
|
+
|
|
1816
|
+
<template>
|
|
1817
|
+
<section data-dcs-section="hero">
|
|
1818
|
+
<h1 data-dcs-text="hero.title">{{ t('hero.title') }}</h1>
|
|
1819
|
+
<p data-dcs-text="hero.subtitle">{{ t('hero.subtitle') }}</p>
|
|
1820
|
+
<button data-dcs-text="hero.cta">{{ t('hero.cta') }}</button>
|
|
1821
|
+
</section>
|
|
1822
|
+
</template>
|
|
1823
|
+
\`\`\`
|
|
1824
|
+
|
|
1825
|
+
### Step 3: Update content.yaml
|
|
1826
|
+
|
|
1827
|
+
Add all discovered text keys to \`.dcs/content.yaml\`:
|
|
1828
|
+
|
|
1829
|
+
\`\`\`yaml
|
|
1830
|
+
pages:
|
|
1831
|
+
home:
|
|
1832
|
+
hero.title: "Welcome to ${siteName}"
|
|
1833
|
+
hero.subtitle: "Your subtitle here"
|
|
1834
|
+
hero.cta: "Get Started"
|
|
1835
|
+
\`\`\`
|
|
1836
|
+
|
|
1837
|
+
### Step 4: Update seo.yaml
|
|
1838
|
+
|
|
1839
|
+
Configure SEO for the home page in \`.dcs/seo.yaml\`:
|
|
1840
|
+
|
|
1841
|
+
\`\`\`yaml
|
|
1842
|
+
pages:
|
|
1843
|
+
home:
|
|
1844
|
+
title: Home
|
|
1845
|
+
description: "Description for ${siteName}"
|
|
1846
|
+
noTitleTemplate: true
|
|
1847
|
+
openGraph:
|
|
1848
|
+
type: website
|
|
1849
|
+
\`\`\`
|
|
1850
|
+
|
|
1851
|
+
## Verification
|
|
1852
|
+
|
|
1853
|
+
1. Run \`pnpm dev\` and verify home page renders correctly
|
|
1854
|
+
2. Check browser console for any errors
|
|
1855
|
+
3. Verify SEO meta tags in page source
|
|
1856
|
+
4. Test that data-dcs-* attributes are present
|
|
1857
|
+
`;
|
|
1858
|
+
}
|
|
1859
|
+
function generateRemainingPagesPlan(siteName) {
|
|
1860
|
+
return `# Plan 03: Integrate Remaining Pages for ${siteName}
|
|
1861
|
+
|
|
1862
|
+
## Objective
|
|
1863
|
+
|
|
1864
|
+
Apply the same integration pattern to all remaining pages identified in the audit.
|
|
1865
|
+
|
|
1866
|
+
## Steps
|
|
1867
|
+
|
|
1868
|
+
### For Each Page
|
|
1869
|
+
|
|
1870
|
+
1. **Open the page component**
|
|
1871
|
+
|
|
1872
|
+
2. **Add imports and composable setup**:
|
|
1873
|
+
|
|
1874
|
+
\`\`\`vue
|
|
1875
|
+
<script setup lang="ts">
|
|
1876
|
+
import { useTextContent, useSEO } from '@duffcloudservices/cms'
|
|
1877
|
+
|
|
1878
|
+
const { t } = useTextContent({
|
|
1879
|
+
pageSlug: 'PAGE_SLUG', // Match pages.yaml slug
|
|
1880
|
+
defaults: {
|
|
1881
|
+
// Add all text content with sensible defaults
|
|
1882
|
+
}
|
|
1883
|
+
})
|
|
1884
|
+
|
|
1885
|
+
const { applyHead } = useSEO('PAGE_SLUG')
|
|
1886
|
+
applyHead()
|
|
1887
|
+
</script>
|
|
1888
|
+
\`\`\`
|
|
1889
|
+
|
|
1890
|
+
3. **Replace all hardcoded text** with \`t()\` calls
|
|
1891
|
+
|
|
1892
|
+
4. **Add data-dcs-* attributes** to sections and text elements
|
|
1893
|
+
|
|
1894
|
+
5. **Update content.yaml** with the page's text content
|
|
1895
|
+
|
|
1896
|
+
6. **Update seo.yaml** with SEO configuration
|
|
1897
|
+
|
|
1898
|
+
### Global Components
|
|
1899
|
+
|
|
1900
|
+
Don't forget shared components like:
|
|
1901
|
+
- Header/Navigation
|
|
1902
|
+
- Footer
|
|
1903
|
+
- Sidebar
|
|
1904
|
+
|
|
1905
|
+
These should use \`pageSlug: 'global'\` for shared content.
|
|
1906
|
+
|
|
1907
|
+
## Checklist
|
|
1908
|
+
|
|
1909
|
+
- [ ] About page
|
|
1910
|
+
- [ ] Contact page
|
|
1911
|
+
- [ ] Services/Products pages
|
|
1912
|
+
- [ ] Blog index (if applicable)
|
|
1913
|
+
- [ ] Header component
|
|
1914
|
+
- [ ] Footer component
|
|
1915
|
+
- [ ] Any other pages...
|
|
1916
|
+
|
|
1917
|
+
## Verification
|
|
1918
|
+
|
|
1919
|
+
For each page:
|
|
1920
|
+
1. Page renders correctly
|
|
1921
|
+
2. No console errors
|
|
1922
|
+
3. SEO meta tags present
|
|
1923
|
+
4. Data attributes present
|
|
1924
|
+
`;
|
|
1925
|
+
}
|
|
1926
|
+
function generateSnapshotsPlan(siteName) {
|
|
1927
|
+
return `# Plan 04: Configure Snapshot Capture for ${siteName}
|
|
1928
|
+
|
|
1929
|
+
## Objective
|
|
1930
|
+
|
|
1931
|
+
Ensure the visual page editor can capture accurate snapshots of all pages.
|
|
1932
|
+
|
|
1933
|
+
## Prerequisites
|
|
1934
|
+
|
|
1935
|
+
- All pages integrated with \`useTextContent\`
|
|
1936
|
+
- All pages have \`data-dcs-section\` attributes
|
|
1937
|
+
- All editable text has \`data-dcs-text\` attributes
|
|
1938
|
+
|
|
1939
|
+
## Steps
|
|
1940
|
+
|
|
1941
|
+
### Step 1: Verify pages.yaml
|
|
1942
|
+
|
|
1943
|
+
Ensure all pages are listed in \`.dcs/pages.yaml\`:
|
|
1944
|
+
|
|
1945
|
+
\`\`\`yaml
|
|
1946
|
+
pages:
|
|
1947
|
+
- slug: home
|
|
1948
|
+
path: /
|
|
1949
|
+
type: static
|
|
1950
|
+
title: Home
|
|
1951
|
+
deletable: false
|
|
1952
|
+
# All other pages...
|
|
1953
|
+
|
|
1954
|
+
excluded:
|
|
1955
|
+
- /404
|
|
1956
|
+
- /dev-*
|
|
1957
|
+
- /_*
|
|
1958
|
+
\`\`\`
|
|
1959
|
+
|
|
1960
|
+
### Step 2: Configure Snapshot Settings
|
|
1961
|
+
|
|
1962
|
+
Review snapshot configuration:
|
|
1963
|
+
|
|
1964
|
+
\`\`\`yaml
|
|
1965
|
+
snapshot:
|
|
1966
|
+
viewport:
|
|
1967
|
+
width: 1280
|
|
1968
|
+
height: 720
|
|
1969
|
+
waitAfterLoad: 3000 # Adjust if page has animations
|
|
1970
|
+
captureFullPage: true
|
|
1971
|
+
\`\`\`
|
|
1972
|
+
|
|
1973
|
+
### Step 3: Test Local Snapshots
|
|
1974
|
+
|
|
1975
|
+
Build and preview the site:
|
|
1976
|
+
|
|
1977
|
+
\`\`\`bash
|
|
1978
|
+
pnpm build
|
|
1979
|
+
pnpm preview
|
|
1980
|
+
\`\`\`
|
|
1981
|
+
|
|
1982
|
+
Manually verify each page:
|
|
1983
|
+
1. Opens correctly
|
|
1984
|
+
2. All content is visible
|
|
1985
|
+
3. No loading spinners or placeholders
|
|
1986
|
+
|
|
1987
|
+
### Step 4: Verify Data Attributes
|
|
1988
|
+
|
|
1989
|
+
Use browser DevTools to check:
|
|
1990
|
+
- Each section has \`data-dcs-section\`
|
|
1991
|
+
- Each editable text element has \`data-dcs-text\`
|
|
1992
|
+
- Attribute values match content.yaml keys
|
|
1993
|
+
|
|
1994
|
+
## Verification
|
|
1995
|
+
|
|
1996
|
+
1. Site builds without errors
|
|
1997
|
+
2. All pages accessible at documented paths
|
|
1998
|
+
3. Data attributes present in rendered HTML
|
|
1999
|
+
4. No dynamic content that would prevent snapshot capture
|
|
2000
|
+
`;
|
|
2001
|
+
}
|
|
2002
|
+
function generateVerifyPlan(siteName) {
|
|
2003
|
+
return `# Plan 05: Verify Deployment for ${siteName}
|
|
2004
|
+
|
|
2005
|
+
## Objective
|
|
2006
|
+
|
|
2007
|
+
Test the complete integration before handing off to the DCS Portal.
|
|
2008
|
+
|
|
2009
|
+
## Steps
|
|
2010
|
+
|
|
2011
|
+
### Step 1: Run Validation
|
|
2012
|
+
|
|
2013
|
+
\`\`\`bash
|
|
2014
|
+
npx @duffcloudservices/cli validate
|
|
2015
|
+
\`\`\`
|
|
2016
|
+
|
|
2017
|
+
Fix any reported errors or warnings.
|
|
2018
|
+
|
|
2019
|
+
### Step 2: Build Test
|
|
2020
|
+
|
|
2021
|
+
\`\`\`bash
|
|
2022
|
+
pnpm build
|
|
2023
|
+
\`\`\`
|
|
2024
|
+
|
|
2025
|
+
Ensure no build errors related to:
|
|
2026
|
+
- Missing content keys
|
|
2027
|
+
- SEO configuration
|
|
2028
|
+
- Vite plugin issues
|
|
2029
|
+
|
|
2030
|
+
### Step 3: Local Preview
|
|
2031
|
+
|
|
2032
|
+
\`\`\`bash
|
|
2033
|
+
pnpm preview
|
|
2034
|
+
\`\`\`
|
|
2035
|
+
|
|
2036
|
+
Verify:
|
|
2037
|
+
- [ ] Home page loads correctly
|
|
2038
|
+
- [ ] All pages accessible
|
|
2039
|
+
- [ ] SEO meta tags correct
|
|
2040
|
+
- [ ] No console errors
|
|
2041
|
+
- [ ] Content displays correctly
|
|
2042
|
+
|
|
2043
|
+
### Step 4: Deploy to Staging
|
|
2044
|
+
|
|
2045
|
+
Deploy to Azure Static Web Apps staging environment:
|
|
2046
|
+
|
|
2047
|
+
\`\`\`bash
|
|
2048
|
+
# If using GitHub Actions, push to a PR branch
|
|
2049
|
+
git push origin feature/dcs-integration
|
|
2050
|
+
\`\`\`
|
|
2051
|
+
|
|
2052
|
+
### Step 5: Portal Registration
|
|
2053
|
+
|
|
2054
|
+
1. Log in to DCS Portal
|
|
2055
|
+
2. Navigate to Sites > Add Site
|
|
2056
|
+
3. Enter site slug and GitHub repo
|
|
2057
|
+
4. Trigger initial snapshot capture
|
|
2058
|
+
|
|
2059
|
+
### Step 6: Visual Editor Test
|
|
2060
|
+
|
|
2061
|
+
1. Open the site in DCS Portal
|
|
2062
|
+
2. Navigate to Pages
|
|
2063
|
+
3. Select a page
|
|
2064
|
+
4. Verify visual editor loads correctly
|
|
2065
|
+
5. Test editing a text field
|
|
2066
|
+
6. Save and verify changes deploy
|
|
2067
|
+
|
|
2068
|
+
## Success Criteria
|
|
2069
|
+
|
|
2070
|
+
- [ ] All validation passes
|
|
2071
|
+
- [ ] Site builds and deploys
|
|
2072
|
+
- [ ] Portal can capture snapshots
|
|
2073
|
+
- [ ] Visual editor works
|
|
2074
|
+
- [ ] Content changes deploy correctly
|
|
2075
|
+
|
|
2076
|
+
## Handoff
|
|
2077
|
+
|
|
2078
|
+
Once verified:
|
|
2079
|
+
1. Merge the integration PR
|
|
2080
|
+
2. Notify the site owner
|
|
2081
|
+
3. Provide portal access credentials
|
|
2082
|
+
`;
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
// src/index.ts
|
|
2086
|
+
var program = new Command();
|
|
2087
|
+
program.name("dcs").description("DCS (Duff Cloud Services) CLI for customer site management").version(version);
|
|
2088
|
+
program.command("login").description("Authenticate with DCS Portal using Google OAuth").action(async () => {
|
|
2089
|
+
await loginCommand();
|
|
2090
|
+
});
|
|
2091
|
+
program.command("logout").description("Clear stored credentials").action(async () => {
|
|
2092
|
+
await logoutCommand();
|
|
2093
|
+
});
|
|
2094
|
+
program.command("whoami").description("Display current authenticated user").action(async () => {
|
|
2095
|
+
await whoamiCommand();
|
|
2096
|
+
});
|
|
2097
|
+
var sitesCmd = program.command("sites").description("Manage customer sites");
|
|
2098
|
+
sitesCmd.command("list").description("List sites you have access to").action(async () => {
|
|
2099
|
+
await listSitesCommand();
|
|
2100
|
+
});
|
|
2101
|
+
sitesCmd.command("show <slug>").description("Show details for a specific site").action(async (slug) => {
|
|
2102
|
+
await showSiteCommand(slug);
|
|
2103
|
+
});
|
|
2104
|
+
program.command("init").description("Initialize DCS integration for a customer site").requiredOption("-s, --site-slug <slug>", "Site slug (lowercase, hyphens)").requiredOption("-n, --site-name <name>", "Human-readable site name").option("-f, --framework <framework>", "Site framework (vue, astro)", "vue").option("-t, --target <dir>", "Target directory", ".").option("--dry-run", "Show what would be created without writing files").option("--force", "Overwrite existing files").action(async (options) => {
|
|
2105
|
+
await initCommand({
|
|
2106
|
+
siteSlug: options.siteSlug,
|
|
2107
|
+
siteName: options.siteName,
|
|
2108
|
+
framework: options.framework,
|
|
2109
|
+
target: options.target,
|
|
2110
|
+
dryRun: options.dryRun,
|
|
2111
|
+
force: options.force
|
|
2112
|
+
});
|
|
2113
|
+
});
|
|
2114
|
+
program.command("validate").description("Validate .dcs configuration files").option("-t, --target <dir>", "Target directory", ".").option("--fix", "Attempt to fix common issues").option("-v, --verbose", "Show detailed output").action(async (options) => {
|
|
2115
|
+
await validateCommand({
|
|
2116
|
+
target: options.target,
|
|
2117
|
+
fix: options.fix,
|
|
2118
|
+
verbose: options.verbose
|
|
2119
|
+
});
|
|
2120
|
+
});
|
|
2121
|
+
program.command("plans").description("Generate DCS integration plan files for AI-assisted onboarding").option("-t, --target <dir>", "Target directory", ".").option("--force", "Overwrite existing plan files").action(async (options) => {
|
|
2122
|
+
await plansCommand({
|
|
2123
|
+
target: options.target,
|
|
2124
|
+
force: options.force
|
|
2125
|
+
});
|
|
2126
|
+
});
|
|
2127
|
+
program.exitOverride();
|
|
2128
|
+
try {
|
|
2129
|
+
await program.parseAsync(process.argv);
|
|
2130
|
+
} catch (error) {
|
|
2131
|
+
if (error instanceof Error) {
|
|
2132
|
+
if (error.code === "commander.helpDisplayed" || error.code === "commander.version") {
|
|
2133
|
+
process.exit(0);
|
|
2134
|
+
}
|
|
2135
|
+
console.error(chalk7.red("Error:"), error.message);
|
|
2136
|
+
process.exit(1);
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
//# sourceMappingURL=index.js.map
|