@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/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