@stravica-ai/digital-operator-pa 0.1.17 → 0.1.19

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.
@@ -6,8 +6,9 @@ domain whose DNS lives on Cloudflare in an account the operator controls.
6
6
  ## Status
7
7
 
8
8
  Implemented: `add-property`, `verify-via-cf-dns`, `submit-sitemap`,
9
- `pull-analytics`, `list-properties`. Auth via Google service-account JSON
10
- loaded from the SOPS vault. Cloudflare planting via the CF v4 REST API.
9
+ `sitemap-status`, `pull-analytics`, `list-properties`. Auth via Google
10
+ service-account JSON loaded from the SOPS vault. Cloudflare planting via
11
+ the CF v4 REST API.
11
12
 
12
13
  ## Dependencies
13
14
 
@@ -37,10 +38,20 @@ Two npm packages (declared in `@stravica-ai/digital-operator-pa`):
37
38
 
38
39
  5. **Store the SA key in the SOPS vault**:
39
40
  ```bash
41
+ # Legacy single-file vault:
40
42
  pnpm exec secrets set GSC_SERVICE_ACCOUNT_KEY -
43
+ # Scoped-vault estate (workspace or canonical layout):
44
+ pnpm exec secrets set --scope <scope> GSC_SERVICE_ACCOUNT_KEY -
41
45
  # paste the entire JSON contents, then Ctrl-D
42
46
  ```
43
47
 
48
+ On scoped-vault hosts, `pa gsc` reads the key from whichever pot
49
+ `secrets get` would use — i.e. it honours (in order) `SECRETS_DEFAULT_SCOPE`,
50
+ the `defaultScope` in `~/.config/stravica-pa/defaults.json`, or the
51
+ calling PA's `identity.json` — before falling back to the legacy
52
+ single-file vault. Store the key in the pot the host is already
53
+ configured to read; no `pa gsc`-specific configuration is needed.
54
+
44
55
  6. **Store Cloudflare credentials** (also one-time). Two key shapes are
45
56
  supported; pick whichever fits your setup:
46
57
 
@@ -70,6 +81,10 @@ Two npm packages (declared in `@stravica-ai/digital-operator-pa`):
70
81
  no `--account` flag is passed and the `CLOUDFLARE_PAGES_*` unsuffixed
71
82
  pair is absent. Don't rely on it — set the `CLOUDFLARE_PAGES_*` keys.
72
83
 
84
+ Same scoped-vault note as the SA key: on scoped hosts these live in
85
+ whichever pot `secrets get` reads (see step 5); use `secrets set --scope
86
+ <scope> ...` to store them there.
87
+
73
88
  ## Per-domain setup (each time you onboard a new property)
74
89
 
75
90
  7. **Grant the service-account access to the GSC property.** Until the
@@ -104,6 +119,11 @@ pnpm exec pa gsc verify-via-cf-dns besafetraining.co.uk --account clive
104
119
  # Submit a sitemap
105
120
  pnpm exec pa gsc submit-sitemap stravica.ai https://stravica.ai/sitemap.xml
106
121
 
122
+ # Read sitemap state (list every sitemap the property has)
123
+ pnpm exec pa gsc sitemap-status stravica.ai
124
+ # Read state for one named sitemap
125
+ pnpm exec pa gsc sitemap-status stravica.ai https://stravica.ai/sitemap.xml
126
+
107
127
  # Pull search analytics (default 28d, top queries, 100 rows)
108
128
  pnpm exec pa gsc pull-analytics stravica.ai
109
129
  pnpm exec pa gsc pull-analytics stravica.ai --days 90 --dimensions query,page --rows 500
@@ -8,7 +8,7 @@
8
8
  // override and never touch the network. The SA-key read also accepts an
9
9
  // override so tests can pass in a synthetic key without going through SOPS.
10
10
 
11
- import { defaultPaths, checkVault, secretsFileExists, sopsDecrypt, VaultError } from '../secrets/vault.mjs';
11
+ import { resolveCommandPaths, checkVault, secretsFileExists, sopsDecrypt, VaultError } from '../secrets/vault.mjs';
12
12
 
13
13
  // Read-only scope: searchanalytics.query + sites.list + sitemaps.list.
14
14
  // Read-write scope: sites.add, sitemaps.submit, site verification.
@@ -25,9 +25,26 @@ const DEFAULT_VAULT_KEY = 'GSC_SERVICE_ACCOUNT_KEY';
25
25
  /**
26
26
  * Load the service-account JSON object from the SOPS vault.
27
27
  *
28
+ * Vault path resolution goes through `resolveCommandPaths` — the same
29
+ * resolver `secrets get`/`set` use — so this honours all the same layouts
30
+ * without duplicating logic: an explicit `overrides.paths` (from a
31
+ * pre-resolved `--scope`/`--file`) is a pass-through; otherwise the
32
+ * default-scope resolver runs (SECRETS_DEFAULT_SCOPE env, defaultScope in
33
+ * ~/.config/stravica-pa/defaults.json, or the calling PA's identity.json)
34
+ * and points at a scoped `.vault/scopes/<scope>.enc.json`; if nothing is
35
+ * configured anywhere it falls back to the legacy single-file vault.
36
+ *
37
+ * A stderr `note: writing to '<scope>' pot ...` line is printed by the
38
+ * resolver on the default-scope path — same behaviour as `secrets get`,
39
+ * kept intentionally consistent so operators see the same routing signal
40
+ * from every consumer of the vault.
41
+ *
28
42
  * @param {object} overrides
29
- * @param {object} [overrides.paths] Vault paths (from defaultPaths()).
43
+ * @param {object} [overrides.paths] Vault paths already resolved (e.g. from --scope upstream).
30
44
  * @param {string} [overrides.vaultKey] Vault key name (default GSC_SERVICE_ACCOUNT_KEY).
45
+ * @param {string} [overrides.cwd] Test seam — cwd used for workspace resolution.
46
+ * @param {object} [overrides.env] Test seam — env used for scope-default resolution.
47
+ * @param {string} [overrides.home] Test seam — home used for keyfile + defaults.json lookup.
31
48
  * @param {string} [overrides.serviceAccountKey]
32
49
  * Raw SA JSON string. If provided, skips vault entirely (testing).
33
50
  * @returns {Promise<object>} Parsed service-account JSON.
@@ -39,34 +56,39 @@ export async function loadServiceAccountKey(overrides = {}) {
39
56
  : overrides.serviceAccountKey;
40
57
  }
41
58
 
42
- const paths = overrides.paths || defaultPaths();
59
+ const paths = resolveCommandPaths(overrides);
43
60
  const vaultKey = overrides.vaultKey || DEFAULT_VAULT_KEY;
44
61
 
45
62
  const state = checkVault(paths);
46
63
  if (!state.ok) {
64
+ // Print then throw — main() suppresses messages for errors that carry
65
+ // an exitCode (the throw-site is expected to have reported), so
66
+ // omitting the console.error would exit silently through the pa gsc
67
+ // binary.
68
+ console.error(state.message);
47
69
  throw new VaultError(state.message, state.exitCode ?? 1);
48
70
  }
49
71
  if (!secretsFileExists(paths)) {
50
- throw new VaultError(
51
- `No secrets file at ${paths.secretsFile}. Run 'secrets init' first.`,
52
- 1,
53
- );
72
+ const message = `No secrets file at ${paths.secretsFile}.` + (paths.scoped
73
+ ? ` Store the service-account JSON with:\n secrets set --scope ${paths.scope} ${vaultKey} -\n(then paste the JSON, then Ctrl-D)`
74
+ : " Run 'secrets init' first.");
75
+ console.error(message);
76
+ throw new VaultError(message, 1);
54
77
  }
55
78
  const data = await sopsDecrypt(paths, paths.secretsFile);
56
79
  if (!(vaultKey in data)) {
57
- throw new VaultError(
58
- `Vault key "${vaultKey}" not found. Store the service-account JSON with:\n secrets set ${vaultKey} -\n(then paste the JSON, then Ctrl-D)`,
59
- 1,
60
- );
80
+ const scopeHint = paths.scoped ? ` --scope ${paths.scope}` : '';
81
+ const message = `Vault key "${vaultKey}" not found in ${paths.secretsFile}. Store the service-account JSON with:\n secrets set${scopeHint} ${vaultKey} -\n(then paste the JSON, then Ctrl-D)`;
82
+ console.error(message);
83
+ throw new VaultError(message, 1);
61
84
  }
62
85
  const raw = data[vaultKey];
63
86
  try {
64
87
  return typeof raw === 'string' ? JSON.parse(raw) : raw;
65
88
  } catch (e) {
66
- throw new VaultError(
67
- `Vault key "${vaultKey}" is not valid JSON: ${e.message}`,
68
- 2,
69
- );
89
+ const message = `Vault key "${vaultKey}" is not valid JSON: ${e.message}`;
90
+ console.error(message);
91
+ throw new VaultError(message, 2);
70
92
  }
71
93
  }
72
94
 
@@ -9,6 +9,7 @@
9
9
  import { runAddProperty } from './commands/add-property.mjs';
10
10
  import { runVerifyViaCfDns } from './commands/verify-via-cf-dns.mjs';
11
11
  import { runSubmitSitemap } from './commands/submit-sitemap.mjs';
12
+ import { runSitemapStatus } from './commands/sitemap-status.mjs';
12
13
  import { runPullAnalytics } from './commands/pull-analytics.mjs';
13
14
  import { runListProperties } from './commands/list-properties.mjs';
14
15
 
@@ -29,6 +30,12 @@ Usage:
29
30
  pa gsc submit-sitemap <domain> <sitemap-url>
30
31
  Submit a sitemap to GSC for the property.
31
32
 
33
+ pa gsc sitemap-status <domain> [sitemap-url]
34
+ Read sitemap state for the property (sitemaps.list) or one named
35
+ sitemap (sitemaps.get). Reports path, lastSubmitted, lastDownloaded,
36
+ isPending, warnings, errors, and per-type contents[] submitted/indexed
37
+ counts. Read-only.
38
+
32
39
  pa gsc pull-analytics <domain> [--days N] [--dimensions D1,D2,...] [--rows N]
33
40
  Pull search-analytics rows. Defaults: --days 28 --dimensions query
34
41
  --rows 100. Outputs a JSON array on stdout.
@@ -69,6 +76,7 @@ export async function run(argv, overrides = {}) {
69
76
  case 'add-property': return runAddProperty(rest, overrides);
70
77
  case 'verify-via-cf-dns': return runVerifyViaCfDns(rest, overrides);
71
78
  case 'submit-sitemap': return runSubmitSitemap(rest, overrides);
79
+ case 'sitemap-status': return runSitemapStatus(rest, overrides);
72
80
  case 'pull-analytics': return runPullAnalytics(rest, overrides);
73
81
  case 'list-properties': return runListProperties(rest, overrides);
74
82
  default: {
@@ -24,7 +24,7 @@
24
24
  //
25
25
  // `fetchImpl` and `now` are injectable for tests.
26
26
 
27
- import { defaultPaths, checkVault, secretsFileExists, sopsDecrypt, VaultError } from '../secrets/vault.mjs';
27
+ import { resolveCommandPaths, checkVault, secretsFileExists, sopsDecrypt, VaultError } from '../secrets/vault.mjs';
28
28
 
29
29
  const CF_API = 'https://api.cloudflare.com/client/v4';
30
30
 
@@ -122,14 +122,23 @@ export async function loadCloudflareCreds(overrides = {}) {
122
122
  if (overrides.vaultData) {
123
123
  data = overrides.vaultData;
124
124
  } else {
125
- const paths = overrides.paths || defaultPaths();
125
+ // resolveCommandPaths honours --scope/--file (via a pre-resolved
126
+ // overrides.paths), the SECRETS_DEFAULT_SCOPE env, defaults.json's
127
+ // defaultScope, and the calling PA's identity.json, before falling
128
+ // back to the legacy single-file vault. Same routing as `secrets get`
129
+ // and `pa gsc`'s SA-key loader — one resolver, one routing signal.
130
+ const paths = resolveCommandPaths(overrides);
126
131
  const state = checkVault(paths);
127
- if (!state.ok) throw new VaultError(state.message, state.exitCode ?? 1);
132
+ if (!state.ok) {
133
+ console.error(state.message);
134
+ throw new VaultError(state.message, state.exitCode ?? 1);
135
+ }
128
136
  if (!secretsFileExists(paths)) {
129
- throw new VaultError(
130
- `No secrets file at ${paths.secretsFile}. Run 'secrets init' first.`,
131
- 1,
132
- );
137
+ const message = `No secrets file at ${paths.secretsFile}.` + (paths.scoped
138
+ ? ` Store Cloudflare credentials with 'secrets set --scope ${paths.scope} <KEY> <value>'.`
139
+ : " Run 'secrets init' first.");
140
+ console.error(message);
141
+ throw new VaultError(message, 1);
133
142
  }
134
143
  data = await sopsDecrypt(paths, paths.secretsFile);
135
144
  }
@@ -0,0 +1,95 @@
1
+ // sitemap-status — Read sitemap state for a GSC property.
2
+ //
3
+ // Wraps webmasters.sitemaps.list (no sitemap-url) and
4
+ // webmasters.sitemaps.get (with sitemap-url). Both are read-only.
5
+ //
6
+ // Every previous "did the release-time submit take?" check was a one-off
7
+ // workaround — this verb makes it a first-class subcommand so the pattern
8
+ // is repeatable and auditable.
9
+ //
10
+ // Usage:
11
+ // pa gsc sitemap-status <domain>
12
+ // List every sitemap the property has, with lastSubmitted,
13
+ // lastDownloaded, isPending, warnings, errors, and per-type
14
+ // contents[] submitted/indexed counts.
15
+ //
16
+ // pa gsc sitemap-status <domain> <sitemap-url>
17
+ // Same shape, but only for the named sitemap (webmasters.sitemaps.get).
18
+
19
+ import { buildGscClient, toDomainProperty } from '../auth.mjs';
20
+
21
+ /**
22
+ * Normalise a Sitemap resource from the GSC API into a stable shape.
23
+ * The GSC API returns warnings/errors/submitted/indexed as strings; we
24
+ * keep them as strings (matches the wire format so operators reading the
25
+ * JSON see exactly what the API said).
26
+ */
27
+ function normaliseSitemap(s = {}) {
28
+ const contents = Array.isArray(s.contents)
29
+ ? s.contents.map((c) => ({
30
+ type: c.type ?? null,
31
+ submitted: c.submitted ?? null,
32
+ indexed: c.indexed ?? null,
33
+ }))
34
+ : [];
35
+ return {
36
+ path: s.path ?? null,
37
+ lastSubmitted: s.lastSubmitted ?? null,
38
+ lastDownloaded: s.lastDownloaded ?? null,
39
+ isPending: s.isPending ?? null,
40
+ isSitemapsIndex: s.isSitemapsIndex ?? null,
41
+ type: s.type ?? null,
42
+ warnings: s.warnings ?? null,
43
+ errors: s.errors ?? null,
44
+ contents,
45
+ };
46
+ }
47
+
48
+ export async function runSitemapStatus(args, overrides = {}) {
49
+ const domain = args[0];
50
+ const sitemapUrl = args[1];
51
+
52
+ if (!domain) {
53
+ process.stderr.write('Usage: pa gsc sitemap-status <domain> [sitemap-url]\n');
54
+ return 1;
55
+ }
56
+ if (sitemapUrl && !/^https?:\/\//i.test(sitemapUrl)) {
57
+ process.stderr.write(`[gsc sitemap-status] sitemap-url must be an absolute http(s) URL, got "${sitemapUrl}"\n`);
58
+ return 1;
59
+ }
60
+
61
+ const siteUrl = toDomainProperty(domain);
62
+
63
+ let client;
64
+ try {
65
+ client = await buildGscClient(overrides);
66
+ } catch (e) {
67
+ process.stderr.write(`[gsc sitemap-status] auth failed: ${e.message}\n`);
68
+ return e.exitCode || 2;
69
+ }
70
+
71
+ let sitemaps;
72
+ try {
73
+ if (sitemapUrl) {
74
+ const res = await client.webmasters.sitemaps.get({ siteUrl, feedpath: sitemapUrl });
75
+ // sitemaps.get returns the Sitemap resource directly (no wrapper).
76
+ sitemaps = [normaliseSitemap(res?.data || {})];
77
+ } else {
78
+ const res = await client.webmasters.sitemaps.list({ siteUrl });
79
+ sitemaps = (res?.data?.sitemap || []).map(normaliseSitemap);
80
+ }
81
+ } catch (e) {
82
+ const verb = sitemapUrl ? 'sitemaps.get' : 'sitemaps.list';
83
+ process.stderr.write(`[gsc sitemap-status] ${verb} failed: ${e.message}\n`);
84
+ return 2;
85
+ }
86
+
87
+ const out = overrides.stdout || process.stdout;
88
+ out.write(JSON.stringify({
89
+ siteUrl,
90
+ sitemapUrl: sitemapUrl || null,
91
+ count: sitemaps.length,
92
+ sitemaps,
93
+ }, null, 2) + '\n');
94
+ return 0;
95
+ }
@@ -23,7 +23,7 @@ Usage:
23
23
  pa workspace <verb> Workspace server lifecycle (start | stop | status | open)
24
24
  pa project <verb> Project state writes (create | status | doc add/rm | list)
25
25
  pa work <verb> Work-item state writes (create | status | list)
26
- pa gsc <verb> Google Search Console automation (add-property | verify-via-cf-dns | submit-sitemap | pull-analytics | list-properties)
26
+ pa gsc <verb> Google Search Console automation (add-property | verify-via-cf-dns | submit-sitemap | sitemap-status | pull-analytics | list-properties)
27
27
  pa knowledge <verb> Query the operator's knowledge graph (related | search | domain | orphans | path)
28
28
  pa research <verb> Deep-research session store (run | list | show | gates | prune | mark | paths)
29
29
  pa autonomy <verb> Per-agent per-task-type approval state (init | get | set | list)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "Digital Operator PA — single-bundled public package surface (bins + library) for the platform.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",