@pagebridge/cli 0.0.2 → 0.1.1

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.
@@ -0,0 +1,182 @@
1
+ import { Command } from "commander";
2
+ import { createClient as createSanityClient } from "@sanity/client";
3
+ import { GSCClient } from "@pagebridge/core";
4
+ import { createDb, sql } from "@pagebridge/db";
5
+ import { log } from "../logger.js";
6
+ import { existsSync, writeFileSync } from "fs";
7
+ import { resolve as resolvePath } from "path";
8
+ import * as readline from "readline/promises";
9
+ import { stdin as input, stdout as output } from "process";
10
+ const rl = readline.createInterface({ input, output });
11
+ async function prompt(question, defaultValue) {
12
+ const displayQuestion = defaultValue
13
+ ? `${question} (${defaultValue}): `
14
+ : `${question}: `;
15
+ const answer = await rl.question(displayQuestion);
16
+ return answer.trim() || defaultValue || "";
17
+ }
18
+ async function confirmPrompt(question, defaultYes = true) {
19
+ const suffix = defaultYes ? " (Y/n): " : " (y/N): ";
20
+ const answer = await rl.question(question + suffix);
21
+ const normalized = answer.trim().toLowerCase();
22
+ if (!normalized)
23
+ return defaultYes;
24
+ return normalized === "y" || normalized === "yes";
25
+ }
26
+ export const initCommand = new Command("init")
27
+ .description("Interactive setup wizard for PageBridge")
28
+ .option("--skip-db-check", "Skip database connection test")
29
+ .option("--skip-sanity-check", "Skip Sanity API test")
30
+ .option("--skip-gsc-check", "Skip Google Search Console API test")
31
+ .action(async (options) => {
32
+ log.info("🚀 PageBridge Interactive Setup\n");
33
+ const envPath = resolvePath(process.cwd(), ".env");
34
+ // Check if .env already exists
35
+ if (existsSync(envPath)) {
36
+ const overwrite = await confirmPrompt(".env file already exists. Do you want to overwrite it?", false);
37
+ if (!overwrite) {
38
+ log.info("Skipping .env creation. Exiting...");
39
+ rl.close();
40
+ return;
41
+ }
42
+ }
43
+ log.info("Let's configure your environment variables.\n");
44
+ // Database URL
45
+ log.info("📦 Database Configuration");
46
+ const dbUrl = await prompt("PostgreSQL connection string", "postgresql://postgres:postgres@localhost:5432/gsc_sanity");
47
+ if (!options.skipDbCheck && dbUrl) {
48
+ try {
49
+ log.info("Testing database connection...");
50
+ const { db, close } = createDb(dbUrl);
51
+ await db.execute(sql `SELECT 1`);
52
+ await close();
53
+ log.info("✅ Database connection successful\n");
54
+ }
55
+ catch (error) {
56
+ log.error(`❌ Database connection failed: ${error instanceof Error ? error.message : String(error)}`);
57
+ log.warn("You can continue anyway, but you'll need to fix this before syncing.\n");
58
+ }
59
+ }
60
+ // Sanity Configuration
61
+ log.info("🎨 Sanity Configuration");
62
+ const sanityProjectId = await prompt("Sanity Project ID");
63
+ const sanityDataset = await prompt("Sanity Dataset", "production");
64
+ const sanityToken = await prompt("Sanity API Token (with Editor permissions)");
65
+ if (!options.skipSanityCheck && sanityProjectId && sanityToken) {
66
+ try {
67
+ log.info("Testing Sanity API connection...");
68
+ const sanity = createSanityClient({
69
+ projectId: sanityProjectId,
70
+ dataset: sanityDataset,
71
+ token: sanityToken,
72
+ apiVersion: "2024-01-01",
73
+ useCdn: false,
74
+ });
75
+ await sanity.fetch('*[_type == "gscSite"][0]{ _id }');
76
+ log.info("✅ Sanity connection successful\n");
77
+ }
78
+ catch (error) {
79
+ log.error(`❌ Sanity connection failed: ${error instanceof Error ? error.message : String(error)}`);
80
+ log.warn("Check your project ID, dataset, and token permissions.\n");
81
+ }
82
+ }
83
+ // Google Service Account
84
+ log.info("🔐 Google Search Console Configuration");
85
+ log.info("You need a Google Cloud service account with Search Console API access.");
86
+ log.info('Paste the entire service account JSON (it should start with {"type":"service_account"...):\n');
87
+ const googleServiceAccount = await prompt("Google Service Account JSON");
88
+ // Validate JSON
89
+ let credentials = null;
90
+ try {
91
+ credentials = JSON.parse(googleServiceAccount);
92
+ if (!credentials) {
93
+ throw new Error("Parsed credentials is null");
94
+ }
95
+ log.info(`✅ Valid JSON for service account: ${credentials.client_email}\n`);
96
+ }
97
+ catch {
98
+ log.error("❌ Invalid JSON format for service account");
99
+ log.warn("You'll need to fix this in .env before syncing.\n");
100
+ }
101
+ // Test GSC API
102
+ if (!options.skipGscCheck && credentials) {
103
+ try {
104
+ log.info("Testing Google Search Console API access...");
105
+ const gsc = new GSCClient({ credentials });
106
+ const sites = await gsc.listSites();
107
+ log.info(`✅ GSC API access successful. Found ${sites.length} sites:`);
108
+ sites.forEach((site) => log.info(` - ${site}`));
109
+ log.info("");
110
+ }
111
+ catch (error) {
112
+ log.error(`❌ GSC API access failed: ${error instanceof Error ? error.message : String(error)}`);
113
+ log.warn("Make sure the service account has access to your Search Console properties.\n");
114
+ }
115
+ }
116
+ // Site URL
117
+ log.info("🌐 Site Configuration");
118
+ const siteUrl = await prompt("Your website base URL (e.g., https://example.com)");
119
+ // Write .env file
120
+ const envContent = `# PageBridge Environment Variables
121
+ # Generated by 'pagebridge init' on ${new Date().toISOString()}
122
+ # Uses PAGEBRIDGE_ prefix to avoid conflicts with your project's env vars.
123
+
124
+ # Google Service Account JSON (stringified)
125
+ PAGEBRIDGE_GOOGLE_SERVICE_ACCOUNT='${googleServiceAccount}'
126
+
127
+ # PostgreSQL connection string
128
+ PAGEBRIDGE_DATABASE_URL='${dbUrl}'
129
+
130
+ # Sanity Studio configuration
131
+ PAGEBRIDGE_SANITY_PROJECT_ID='${sanityProjectId}'
132
+ PAGEBRIDGE_SANITY_DATASET='${sanityDataset}'
133
+ PAGEBRIDGE_SANITY_TOKEN='${sanityToken}'
134
+
135
+ # Your site URL
136
+ PAGEBRIDGE_SITE_URL='${siteUrl}'
137
+ `;
138
+ writeFileSync(envPath, envContent, "utf-8");
139
+ log.info("✅ .env file created successfully!\n");
140
+ // Offer to run migrations
141
+ const runMigrations = await confirmPrompt("Would you like to run database migrations now?", true);
142
+ if (runMigrations && dbUrl) {
143
+ log.info("Running database migrations...");
144
+ try {
145
+ // Import migrate function
146
+ const { migrateIfRequested } = await import("../migrate.js");
147
+ await migrateIfRequested(true, dbUrl);
148
+ log.info("✅ Migrations completed\n");
149
+ }
150
+ catch (error) {
151
+ log.error(`❌ Migration failed: ${error instanceof Error ? error.message : String(error)}\n`);
152
+ }
153
+ }
154
+ // Offer to run first sync
155
+ if (credentials) {
156
+ const runSync = await confirmPrompt("Would you like to run your first sync now?", false);
157
+ if (runSync) {
158
+ const sites = await new GSCClient({ credentials }).listSites();
159
+ if (sites.length === 0) {
160
+ log.warn("No sites found in your Search Console account.");
161
+ }
162
+ else if (sites.length === 1) {
163
+ log.info(`\nUsing site: ${sites[0]}`);
164
+ // Note: You'd import and call the sync command here
165
+ log.info(`Run: pagebridge sync --site "${sites[0]}" --migrate`);
166
+ }
167
+ else {
168
+ log.info("\nAvailable sites:");
169
+ sites.forEach((site, i) => log.info(` ${i + 1}. ${site}`));
170
+ const siteChoice = await prompt("\nEnter site number or full URL");
171
+ const selectedSite = sites[parseInt(siteChoice) - 1] || siteChoice;
172
+ log.info(`\nRun: pagebridge sync --site "${selectedSite}" --migrate`);
173
+ }
174
+ }
175
+ }
176
+ log.info("\n🎉 Setup complete!");
177
+ log.info("\nNext steps:");
178
+ log.info(" 1. Review your .env file");
179
+ log.info(" 2. Run: pagebridge sync --site <your-site-url> --migrate");
180
+ log.info(" 3. Open Sanity Studio to see your performance data\n");
181
+ rl.close();
182
+ });
package/dist/index.js CHANGED
@@ -1,12 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { program } from "commander";
3
+ import { initCommand } from "./commands/init.js";
4
+ import { doctorCommand } from "./commands/doctor.js";
3
5
  import { syncCommand } from "./commands/sync.js";
4
6
  import { listSitesCommand } from "./commands/list-sites.js";
5
7
  import { diagnoseCommand } from "./commands/diagnose.js";
6
8
  program
7
9
  .name("pagebridge")
8
10
  .description("PageBridge - Connect Google Search Console to Sanity CMS")
9
- .version("0.0.1");
11
+ .version("0.0.2");
12
+ program.addCommand(initCommand);
13
+ program.addCommand(doctorCommand);
10
14
  program.addCommand(syncCommand);
11
15
  program.addCommand(listSitesCommand);
12
16
  program.addCommand(diagnoseCommand);
@@ -1,5 +1,8 @@
1
1
  /**
2
- * Resolves a config value from a CLI option first, then env var fallback.
2
+ * Resolves a config value from:
3
+ * 1. CLI option (highest priority)
4
+ * 2. PAGEBRIDGE_ prefixed env var (e.g. PAGEBRIDGE_DATABASE_URL)
5
+ * 3. Unprefixed env var fallback (e.g. DATABASE_URL)
3
6
  */
4
7
  export declare function resolve(optionValue: string | undefined, envVarName: string): string | undefined;
5
8
  interface ConfigEntry {
@@ -1 +1 @@
1
- {"version":3,"file":"resolve-config.d.ts","sourceRoot":"","sources":["../src/resolve-config.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,wBAAgB,OAAO,CACrB,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,UAAU,EAAE,MAAM,GACjB,MAAM,GAAG,SAAS,CAEpB;AAED,UAAU,WAAW;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;CAC3B;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,WAAW,EAAE,GAAG,IAAI,CAU1D"}
1
+ {"version":3,"file":"resolve-config.d.ts","sourceRoot":"","sources":["../src/resolve-config.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,wBAAgB,OAAO,CACrB,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,UAAU,EAAE,MAAM,GACjB,MAAM,GAAG,SAAS,CAMpB;AAED,UAAU,WAAW;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;CAC3B;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,WAAW,EAAE,GAAG,IAAI,CAY1D"}
@@ -1,8 +1,13 @@
1
1
  /**
2
- * Resolves a config value from a CLI option first, then env var fallback.
2
+ * Resolves a config value from:
3
+ * 1. CLI option (highest priority)
4
+ * 2. PAGEBRIDGE_ prefixed env var (e.g. PAGEBRIDGE_DATABASE_URL)
5
+ * 3. Unprefixed env var fallback (e.g. DATABASE_URL)
3
6
  */
4
7
  export function resolve(optionValue, envVarName) {
5
- return optionValue ?? process.env[envVarName];
8
+ return (optionValue ??
9
+ process.env[`PAGEBRIDGE_${envVarName}`] ??
10
+ process.env[envVarName]);
6
11
  }
7
12
  /**
8
13
  * Validates that all required config entries have values.
@@ -15,7 +20,7 @@ export function requireConfig(entries) {
15
20
  console.error("Error: Missing required configuration.\n");
16
21
  for (const entry of missing) {
17
22
  console.error(` ${entry.name}`);
18
- console.error(` ${entry.flag} or ${entry.envVar} env var\n`);
23
+ console.error(` ${entry.flag} or PAGEBRIDGE_${entry.envVar} / ${entry.envVar} env var\n`);
19
24
  }
20
25
  process.exit(1);
21
26
  }
package/package.json CHANGED
@@ -1,24 +1,42 @@
1
1
  {
2
2
  "name": "@pagebridge/cli",
3
- "version": "0.0.2",
3
+ "version": "0.1.1",
4
+ "description": "CLI for PageBridge — sync Google Search Console data, detect content decay, and generate refresh tasks",
4
5
  "private": false,
5
6
  "license": "MIT",
6
7
  "type": "module",
8
+ "keywords": [
9
+ "pagebridge",
10
+ "cli",
11
+ "google-search-console",
12
+ "gsc",
13
+ "seo",
14
+ "content-decay",
15
+ "sanity",
16
+ "search-analytics",
17
+ "content-refresh",
18
+ "decay-detection"
19
+ ],
7
20
  "bin": {
8
21
  "pagebridge": "./dist/index.js"
9
22
  },
23
+ "files": [
24
+ "dist",
25
+ "LICENSE",
26
+ "README.md"
27
+ ],
10
28
  "dependencies": {
11
29
  "@sanity/client": "^7.14.1",
12
30
  "commander": "^14.0.3",
13
- "@pagebridge/db": "^0.0.2",
14
- "@pagebridge/core": "^0.0.1"
31
+ "@pagebridge/core": "^0.0.3",
32
+ "@pagebridge/db": "^0.0.3"
15
33
  },
16
34
  "devDependencies": {
17
35
  "@types/node": "^22.15.3",
18
36
  "eslint": "^9.39.1",
19
37
  "typescript": "^5.9.3",
20
- "@pagebridge/typescript-config": "^0.0.0",
21
- "@pagebridge/eslint-config": "^0.0.0"
38
+ "@pagebridge/eslint-config": "^0.0.0",
39
+ "@pagebridge/typescript-config": "^0.0.0"
22
40
  },
23
41
  "scripts": {
24
42
  "build": "tsc",
@@ -1,2 +0,0 @@
1
- [?9001h[?1004h[?25l> @pagebridge/cli@0.0.1 build F:\Code\pagebridge\oss\apps\cli
2
- > tsc]0;C:\WINDOWS\system32\cmd.exe[?25h[?9001l[?1004l
@@ -1,4 +0,0 @@
1
-
2
- > @pagebridge/cli@0.0.1 check-types F:\Code\pagebridge\oss\apps\cli
3
- > tsc --noEmit
4
-
package/eslint.config.js DELETED
@@ -1,3 +0,0 @@
1
- import { config } from "@pagebridge/eslint-config/base";
2
-
3
- export default [...config];
@@ -1,149 +0,0 @@
1
- import { Command } from "commander";
2
- import {
3
- createDb,
4
- unmatchDiagnostics,
5
- eq,
6
- desc,
7
- } from "@pagebridge/db";
8
- import { resolve, requireConfig } from "../resolve-config.js";
9
- import { log } from "../logger.js";
10
- import { migrateIfRequested } from "../migrate.js";
11
-
12
- export const diagnoseCommand = new Command("diagnose")
13
- .description("View diagnostics for unmatched URLs")
14
- .requiredOption("--site <url>", "GSC site URL (e.g., sc-domain:example.com)")
15
- .option("--reason <reason>", "Filter by unmatch reason")
16
- .option("--limit <n>", "Limit number of results", "20")
17
- .option("--json", "Output as JSON")
18
- .option("--migrate", "Run database migrations before querying")
19
- .option("--db-url <url>", "PostgreSQL connection string")
20
- .action(async (options) => {
21
- const dbUrl = resolve(options.dbUrl, "DATABASE_URL");
22
-
23
- requireConfig([
24
- { name: "DATABASE_URL", flag: "--db-url <url>", envVar: "DATABASE_URL", value: dbUrl },
25
- ]);
26
-
27
- // Run migrations if requested
28
- await migrateIfRequested(!!options.migrate, dbUrl!);
29
-
30
- const { db, close } = createDb(dbUrl!);
31
-
32
- // Register shutdown handlers
33
- const shutdown = async () => {
34
- log.warn("Received shutdown signal, closing connections...");
35
- await close();
36
- process.exit(130);
37
- };
38
- process.on("SIGTERM", shutdown);
39
- process.on("SIGINT", shutdown);
40
-
41
- try {
42
- const query = db
43
- .select()
44
- .from(unmatchDiagnostics)
45
- .where(eq(unmatchDiagnostics.siteId, options.site))
46
- .orderBy(desc(unmatchDiagnostics.lastSeenAt))
47
- .limit(parseInt(options.limit));
48
-
49
- const results = await query;
50
-
51
- if (options.json) {
52
- console.log(JSON.stringify(results, null, 2));
53
- return;
54
- }
55
-
56
- if (results.length === 0) {
57
- log.info(`No unmatched URLs found for ${options.site}`);
58
- log.info(
59
- `Run 'sync --site ${options.site}' first to generate diagnostics.`,
60
- );
61
- return;
62
- }
63
-
64
- // Group by reason
65
- const byReason = new Map<string, typeof results>();
66
- for (const r of results) {
67
- const existing = byReason.get(r.unmatchReason) ?? [];
68
- existing.push(r);
69
- byReason.set(r.unmatchReason, existing);
70
- }
71
-
72
- log.info(`\nUnmatched URL Diagnostics for ${options.site}\n`);
73
- log.info(`Total: ${results.length} unmatched URLs\n`);
74
-
75
- for (const [reason, items] of byReason) {
76
- log.info(
77
- `${getReasonEmoji(reason)} ${getReasonDescription(reason)} (${items.length}):`,
78
- );
79
-
80
- for (const item of items) {
81
- log.info(` ${item.gscUrl}`);
82
- if (item.extractedSlug) {
83
- log.info(` Extracted slug: "${item.extractedSlug}"`);
84
- }
85
- if (item.similarSlugs) {
86
- try {
87
- const similar = JSON.parse(item.similarSlugs) as string[];
88
- if (similar.length > 0) {
89
- log.info(` Similar slugs in Sanity:`);
90
- for (const s of similar) {
91
- log.info(` - ${s}`);
92
- }
93
- }
94
- } catch {
95
- // Ignore parse errors
96
- }
97
- }
98
- }
99
- }
100
-
101
- log.info(`\nTo fix unmatched URLs:`);
102
- log.info(
103
- ` 1. Check if the Sanity document exists with the correct slug`,
104
- );
105
- log.info(` 2. Verify the document type is in the contentTypes list`);
106
- log.info(` 3. Ensure the slug field name matches your configuration`);
107
- log.info(
108
- ` 4. If using a path prefix, verify it matches your URL structure`,
109
- );
110
- } catch (error) {
111
- const message = error instanceof Error ? error.message : String(error);
112
- log.error(`Diagnose failed: ${message}`);
113
- process.exitCode = 1;
114
- } finally {
115
- await close();
116
- process.removeListener("SIGTERM", shutdown);
117
- process.removeListener("SIGINT", shutdown);
118
- }
119
- });
120
-
121
- function getReasonEmoji(reason: string): string {
122
- switch (reason) {
123
- case "matched":
124
- return "[OK]";
125
- case "no_slug_extracted":
126
- return "[SLUG]";
127
- case "no_matching_document":
128
- return "[DOC]";
129
- case "outside_path_prefix":
130
- return "[PREFIX]";
131
- default:
132
- return "[?]";
133
- }
134
- }
135
-
136
- function getReasonDescription(reason: string): string {
137
- switch (reason) {
138
- case "matched":
139
- return "Successfully matched";
140
- case "no_slug_extracted":
141
- return "Could not extract slug from URL";
142
- case "no_matching_document":
143
- return "No Sanity document with matching slug";
144
- case "outside_path_prefix":
145
- return "URL outside configured path prefix";
146
- default:
147
- return `Unknown reason: ${reason}`;
148
- }
149
- }
@@ -1,51 +0,0 @@
1
- import { Command } from "commander";
2
- import { GSCClient } from "@pagebridge/core";
3
- import { resolve, requireConfig } from "../resolve-config.js";
4
- import { log } from "../logger.js";
5
-
6
- export const listSitesCommand = new Command("list-sites")
7
- .description("List all sites the service account has access to")
8
- .option("--google-service-account <json>", "Google service account JSON")
9
- .action(async (options) => {
10
- const googleServiceAccount = resolve(options.googleServiceAccount, "GOOGLE_SERVICE_ACCOUNT");
11
-
12
- requireConfig([
13
- { name: "GOOGLE_SERVICE_ACCOUNT", flag: "--google-service-account <json>", envVar: "GOOGLE_SERVICE_ACCOUNT", value: googleServiceAccount },
14
- ]);
15
-
16
- let credentials;
17
- try {
18
- credentials = JSON.parse(googleServiceAccount!);
19
- } catch {
20
- log.error("Failed to parse GOOGLE_SERVICE_ACCOUNT as JSON");
21
- process.exit(1);
22
- }
23
-
24
- log.info(`Using service account: ${credentials.client_email}`);
25
-
26
- const gsc = new GSCClient({ credentials });
27
-
28
- try {
29
- const sites = await gsc.listSites();
30
-
31
- if (sites.length === 0) {
32
- log.warn("No sites found. The service account has no access to any GSC properties.");
33
- log.info("\nTo fix this:");
34
- log.info(
35
- "1. Go to Google Search Console > Settings > Users and permissions",
36
- );
37
- log.info(`2. Add user: ${credentials.client_email}`);
38
- log.info("3. Set permission level to 'Full'");
39
- } else {
40
- log.info(`Found ${sites.length} site(s):\n`);
41
- sites.forEach((site) => log.info(` ${site}`));
42
- log.info(
43
- '\nUse one of these exact values with: pnpm sync --site "<value>"',
44
- );
45
- }
46
- } catch (error) {
47
- const message = error instanceof Error ? error.message : String(error);
48
- log.error(`Failed to list sites: ${message}`);
49
- process.exit(1);
50
- }
51
- });