From 8f0b94fef757188dfae3595b4e76970995971cc8 Mon Sep 17 00:00:00 2001 From: Yasser's studio Date: Sun, 14 Jun 2026 18:05:48 +0100 Subject: [PATCH] feat(accounts): add developer-registration commands and a doctor registration hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `gmc accounts developer-registration` (register / get / unregister) for the Merchant API accounts/v1 developerRegistration resource — the one-time step that registers the calling Cloud project with a Merchant Center account. Until it is done the API returns a "GCP project is not registered with the merchant account" 401 even though the token is valid; gmc previously had no command for it, so the fix required a raw API call. gmc doctor now recognizes that not-registered 401 and points at the new register command instead of suggesting re-authentication. register accepts an optional --developer-email; unregister requires --yes. Field names and RPC paths verified against the accounts_v1 discovery doc. --- .changeset/developer-registration.md | 19 +++++++ docs/reference/accounts.md | 18 +++++- docs/reference/doctor.md | 2 +- docs/reference/issues.md | 4 +- packages/api/src/accounts.ts | 44 ++++++++++++++ packages/api/src/index.ts | 1 + packages/api/src/probe.ts | 9 +++ packages/api/tests/accounts.test.ts | 31 ++++++++++ packages/api/tests/probe.test.ts | 19 ++++++- packages/cli/src/commands/accounts.ts | 82 +++++++++++++++++++++++++++ packages/cli/tests/accounts.test.ts | 63 ++++++++++++++++++++ 11 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 .changeset/developer-registration.md diff --git a/.changeset/developer-registration.md b/.changeset/developer-registration.md new file mode 100644 index 0000000..5ccdef9 --- /dev/null +++ b/.changeset/developer-registration.md @@ -0,0 +1,19 @@ +--- +"@gmc-cli/api": patch +"@gmc-cli/cli": patch +--- + +feat(accounts): add `developer-registration` commands and a doctor registration hint + +Adds `gmc accounts developer-registration` (`register` / `get` / `unregister`) for +the Merchant API `accounts/v1` `developerRegistration` resource — the one-time step +that registers the calling Cloud project with a Merchant Center account. Until it is +done the API returns a `GCP project … is not registered with the merchant account` +**401** even though the token is valid; previously gmc had no command for it, so the +fix required a raw API call. + +`gmc doctor` now recognizes that "not registered" 401 and points at +`gmc accounts developer-registration register` instead of suggesting +re-authentication (the token is fine — the project just isn't registered). + +`register` accepts an optional `--developer-email`; `unregister` requires `--yes`. diff --git a/docs/reference/accounts.md b/docs/reference/accounts.md index de42f7b..41f9748 100644 --- a/docs/reference/accounts.md +++ b/docs/reference/accounts.md @@ -1,6 +1,6 @@ # gmc accounts -Inspect **and manage** Merchant Center accounts. Every command targets the account given as an argument, or the one resolved from `--account` / `GMC_ACCOUNT_ID` / your profile. Reads: `list` / `get` / `info` (+ `business-info`/`homepage` `get`). Profile writes: `update`, `business-info update`, and `homepage set` / `claim` / `unclaim`. Access: `users list` / `get` / `add` / `update` / `remove`. Lifecycle: `create` / `delete`. Settings: `business-identity`, `autofeed`, `shipping`, `return-policies`. +Inspect **and manage** Merchant Center accounts. Every command targets the account given as an argument, or the one resolved from `--account` / `GMC_ACCOUNT_ID` / your profile. Reads: `list` / `get` / `info` (+ `business-info`/`homepage` `get`). Profile writes: `update`, `business-info update`, and `homepage set` / `claim` / `unclaim`. Access: `users list` / `get` / `add` / `update` / `remove`. Lifecycle: `create` / `delete`. Settings: `business-identity`, `autofeed`, `developer-registration`, `shipping`, `return-policies`. ## `gmc accounts list` @@ -209,6 +209,22 @@ gmc accounts autofeed update 123456789 --enable-products true `--enable-products ` toggles autofeed product crawling (the writable field; `eligible` is output-only). `--json` emits the `AutofeedSettings`. +## `gmc accounts developer-registration` — `register` / `get` / `unregister` + +Register the **Cloud project** that calls the API with this Merchant Center account — the one-time +setup that clears the `GCP project … is not registered with the merchant account` **401**. `gmc doctor` +detects that trap and points here. + +```sh +gmc accounts developer-registration register 123456789 --developer-email you@example.com +gmc accounts developer-registration get 123456789 +gmc accounts developer-registration unregister 123456789 --yes +``` + +`--developer-email ` (on `register`) is an optional contact; it defaults to the authenticated +principal. `get` shows the registered Cloud project number(s) (`gcpIds`). `unregister` revokes the +project's access and requires `--yes`. + ## `gmc accounts shipping` — `get` / `set` Read or replace the account's shipping settings (a singleton). `set` performs the API's `insert`, diff --git a/docs/reference/doctor.md b/docs/reference/doctor.md index 4ac284e..eff4526 100644 --- a/docs/reference/doctor.md +++ b/docs/reference/doctor.md @@ -12,7 +12,7 @@ gmc doctor --json 1. **Credentials resolved** — a credential is found and an identity read (offline). 2. **Access token acquired** — the credential mints a valid token (network). 3. **Account configured** — informational; warns if no account id is set. -4. **Merchant API access** — probes the API and interprets the result, including the `SERVICE_DISABLED` ("API not enabled") and empty-result ("not linked") traps. +4. **Merchant API access** — probes the API and interprets the result, including the `SERVICE_DISABLED` ("API not enabled"), empty-result ("not linked"), and "Cloud project not registered" (401) traps. The last points at [`gmc accounts developer-registration register`](/reference/accounts). ## Exit codes diff --git a/docs/reference/issues.md b/docs/reference/issues.md index d241a58..197c0f3 100644 --- a/docs/reference/issues.md +++ b/docs/reference/issues.md @@ -17,11 +17,11 @@ gmc issues account --json | jq '.issues[] | { title, severity: .impact.severity ``` 2 issue(s): - [DISAPPROVED] Misrepresentation of self or product + [ERROR] Misrepresentation of self or product Your account was flagged for a policy violation. • United States — Shopping ads - [DEMOTED] Missing shipping information + [ERROR] Missing shipping information • United States, United Kingdom — Free listings ``` diff --git a/packages/api/src/accounts.ts b/packages/api/src/accounts.ts index d837e78..2195a0b 100644 --- a/packages/api/src/accounts.ts +++ b/packages/api/src/accounts.ts @@ -213,6 +213,18 @@ export interface AutofeedSettings { /** The writable subset of AutofeedSettings accepted on patch. */ export type AutofeedSettingsInput = Pick; +/** + * An account's developer registration (`accounts/{account}/developerRegistration`) — the + * link between this Merchant Center account and the Cloud project(s) calling the API. A + * fresh project must be registered before the API stops returning a "not registered" 401; + * `gmc doctor` detects that trap. `name` is output-only; `gcpIds` lists the registered + * Cloud project numbers. + */ +export interface DeveloperRegistration { + name?: string; + gcpIds?: string[]; +} + /** * An account's shipping settings (`accounts/{account}/shippingSettings`) — a singleton * replaced wholesale by `insert`. The body is deeply nested (`services` / `warehouses`), @@ -525,6 +537,38 @@ export class AccountsService { ); } + /** Fetch the account's developer registration (registered Cloud project numbers). */ + getDeveloperRegistration(account: string): Promise { + return this.client.get( + "accounts", + `${this.base(account)}/developerRegistration`, + ); + } + + /** + * Register the calling Cloud project with this Merchant Center account + * (`developerRegistration:registerGcp`) — the one-time setup that clears the + * "GCP project not registered" 401. `developerEmail` (optional) defaults to the + * authenticated principal. Returns nothing (Empty). + */ + async registerGcp(account: string, opts: { developerEmail?: string } = {}): Promise { + await this.client.request( + "accounts", + "POST", + `${this.base(account)}/developerRegistration:registerGcp`, + opts.developerEmail ? { body: { developerEmail: opts.developerEmail } } : {}, + ); + } + + /** Unregister the calling Cloud project (`developerRegistration:unregisterGcp`). */ + async unregisterGcp(account: string): Promise { + await this.client.request( + "accounts", + "POST", + `${this.base(account)}/developerRegistration:unregisterGcp`, + ); + } + /** Fetch an account's shipping settings (the singleton). */ getShippingSettings(account: string): Promise { return this.client.get("accounts", `${this.base(account)}/shippingSettings`); diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index d4cd7c9..fcf164a 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -33,6 +33,7 @@ export type { IdentityAttribute, AutofeedSettings, AutofeedSettingsInput, + DeveloperRegistration, ShippingSettings, OnlineReturnPolicy, PostalAddress, diff --git a/packages/api/src/probe.ts b/packages/api/src/probe.ts index fb7c088..4e12aa0 100644 --- a/packages/api/src/probe.ts +++ b/packages/api/src/probe.ts @@ -106,6 +106,15 @@ export async function probeMerchantApi( }); if (res.status === 401) { + // A 401 on an unregistered Cloud project carries a "not registered" message + // even though the token is perfectly valid — pointing at re-auth sends the + // user the wrong way. Detect that case and steer them to registration. + if (/not registered/i.test(body?.error?.message ?? "")) { + return failResult( + "Authenticated, but the Cloud project is not registered with this Merchant Center account (401).", + "Register it once with `gmc accounts developer-registration register --developer-email `, then retry.", + ); + } return failResult( "The Merchant API rejected the access token (401 Unauthorized).", "Re-authenticate (`gmc auth login`, or refresh your service-account key) and try again.", diff --git a/packages/api/tests/accounts.test.ts b/packages/api/tests/accounts.test.ts index efedc8d..b4266a8 100644 --- a/packages/api/tests/accounts.test.ts +++ b/packages/api/tests/accounts.test.ts @@ -278,6 +278,37 @@ describe("AccountsService", () => { expect(upd.calls[0]?.body).toEqual({ enableProducts: false }); }); + it("getDeveloperRegistration GETs developerRegistration and parses gcpIds", async () => { + const get = capturing({ name: "accounts/123/developerRegistration", gcpIds: ["999"] }); + const reg = await get.service.getDeveloperRegistration("123"); + expect(get.calls[0]?.method).toBe("GET"); + expect(get.calls[0]?.url).toBe(`${ACCT}/developerRegistration`); + expect(reg.gcpIds).toEqual(["999"]); + }); + + it("registerGcp POSTs :registerGcp with the developerEmail when given", async () => { + const { service, calls } = capturing({}); + await service.registerGcp("123", { developerEmail: "dev@x.com" }); + expect(calls[0]?.method).toBe("POST"); + expect(calls[0]?.url).toBe(`${ACCT}/developerRegistration:registerGcp`); + expect(calls[0]?.body).toEqual({ developerEmail: "dev@x.com" }); + }); + + it("registerGcp sends no body when no developerEmail is given", async () => { + const { service, calls } = capturing({}); + await service.registerGcp("123"); + expect(calls[0]?.method).toBe("POST"); + expect(calls[0]?.body).toBeUndefined(); + }); + + it("unregisterGcp POSTs :unregisterGcp with no body", async () => { + const { service, calls } = capturing({}); + await service.unregisterGcp("123"); + expect(calls[0]?.method).toBe("POST"); + expect(calls[0]?.url).toBe(`${ACCT}/developerRegistration:unregisterGcp`); + expect(calls[0]?.body).toBeUndefined(); + }); + it("getShippingSettings GETs; insertShippingSettings POSTs :insert with the body (incl. etag)", async () => { const get = capturing({ name: "accounts/123/shippingSettings", etag: "abc", services: [] }); await get.service.getShippingSettings("123"); diff --git a/packages/api/tests/probe.test.ts b/packages/api/tests/probe.test.ts index 9e64e75..45a7025 100644 --- a/packages/api/tests/probe.test.ts +++ b/packages/api/tests/probe.test.ts @@ -34,12 +34,29 @@ describe("probeMerchantApi", () => { expect(r.message).toContain("123"); }); - it("fails on a 401 (token rejected)", async () => { + it("fails on a 401 (token rejected) and points at re-authentication", async () => { const r = await probeMerchantApi("tok", { fetchImpl: fetchReturning(401, { error: { code: 401 } }), }); expect(r.status).toBe("fail"); expect(r.httpStatus).toBe(401); + expect(r.suggestion).toMatch(/re-authenticate/i); + }); + + it("maps a 401 'not registered' message to the registration remedy, not re-auth", async () => { + const r = await probeMerchantApi("tok", { + fetchImpl: fetchReturning(401, { + error: { + code: 401, + message: "GCP project 999 is not registered with the merchant account.", + }, + }), + }); + expect(r.status).toBe("fail"); + expect(r.httpStatus).toBe(401); + expect(r.message).toMatch(/not registered/i); + expect(r.suggestion).toMatch(/developer-registration register/); + expect(r.suggestion).not.toMatch(/re-authenticate/i); }); it("detects SERVICE_DISABLED and surfaces the activation URL and project", async () => { diff --git a/packages/cli/src/commands/accounts.ts b/packages/cli/src/commands/accounts.ts index e397b3f..b5ca565 100644 --- a/packages/cli/src/commands/accounts.ts +++ b/packages/cli/src/commands/accounts.ts @@ -19,6 +19,7 @@ import { type IdentityAttribute, type AutofeedSettings, type AutofeedSettingsInput, + type DeveloperRegistration, type ShippingSettings, type OnlineReturnPolicy, type PostalAddress, @@ -287,6 +288,16 @@ function renderAutofeed(af: AutofeedSettings): void { if (af.eligible !== undefined) line("Eligible", af.eligible ? "yes" : "no"); } +function renderDeveloperRegistration(reg: DeveloperRegistration): void { + const ids = reg.gcpIds ?? []; + if (ids.length === 0) { + process.stdout.write("No Cloud project is registered with this account.\n"); + return; + } + process.stdout.write(`${ids.length} registered Cloud project(s):\n`); + for (const id of ids) process.stdout.write(` ${id}\n`); +} + /** The bare return-policy id, preferring the resource `name` / `returnPolicyId`. */ function returnPolicyIdOf(p: OnlineReturnPolicy): string { if (p.name) return returnPolicySegment(p.name); @@ -879,6 +890,77 @@ export function registerAccountsCommands(program: Command): void { }, ); + const devReg = accounts + .command("developer-registration") + .description("Register the Cloud project that calls the API with this account"); + + devReg + .command("get") + .argument("[accountId]", "Account id (defaults to --account / profile)") + .description("Show the registered Cloud project number(s)") + .action(async (accountId: string | undefined) => { + const json = wantsJson(program); + try { + const ctx = contextFrom(program); + const account = resolveAccount(accountId, ctx); + const service = new AccountsService(await clientFor(ctx)); + const result = await service.getDeveloperRegistration(account); + if (ctx.json) emitJson(result); + else renderDeveloperRegistration(result); + } catch (err) { + reportError(err, { json }, "gmc accounts developer-registration get"); + } + }); + + devReg + .command("register") + .argument("[accountId]", "Account id (defaults to --account / profile)") + .option( + "--developer-email ", + "Developer contact email (defaults to the authenticated principal)", + ) + .description('Register the calling Cloud project (clears the "not registered" 401)') + .action(async (accountId: string | undefined, opts: { developerEmail?: string }) => { + const json = wantsJson(program); + try { + const ctx = contextFrom(program); + const account = resolveAccount(accountId, ctx); + const service = new AccountsService(await clientFor(ctx)); + await service.registerGcp(account, { + ...(opts.developerEmail ? { developerEmail: opts.developerEmail } : {}), + }); + if (ctx.json) emitJson({ registered: account }); + else process.stdout.write(`Registered the Cloud project with account ${account}.\n`); + } catch (err) { + reportError(err, { json }, "gmc accounts developer-registration register"); + } + }); + + devReg + .command("unregister") + .argument("[accountId]", "Account id (defaults to --account / profile)") + .option("--yes", "Confirm unregistering the Cloud project (required)") + .description("Unregister the calling Cloud project from this account") + .action(async (accountId: string | undefined, opts: { yes?: boolean }) => { + const json = wantsJson(program); + try { + const ctx = contextFrom(program); + const account = resolveAccount(accountId, ctx); + if (!opts.yes) { + throw new UsageError( + `Refusing to unregister the Cloud project from account ${account} without --yes.`, + "Unregistering revokes this project's API access to the account — pass --yes to confirm.", + ); + } + const service = new AccountsService(await clientFor(ctx)); + await service.unregisterGcp(account); + if (ctx.json) emitJson({ unregistered: account }); + else process.stdout.write(`Unregistered the Cloud project from account ${account}.\n`); + } catch (err) { + reportError(err, { json }, "gmc accounts developer-registration unregister"); + } + }); + const shipping = accounts .command("shipping") .description("Read or replace the account's shipping settings"); diff --git a/packages/cli/tests/accounts.test.ts b/packages/cli/tests/accounts.test.ts index 87cca33..c224d34 100644 --- a/packages/cli/tests/accounts.test.ts +++ b/packages/cli/tests/accounts.test.ts @@ -24,6 +24,9 @@ const getBusinessIdentity = vi.fn(); const updateBusinessIdentity = vi.fn(); const getAutofeedSettings = vi.fn(); const updateAutofeedSettings = vi.fn(); +const getDeveloperRegistration = vi.fn(); +const registerGcp = vi.fn(); +const unregisterGcp = vi.fn(); const getShippingSettings = vi.fn(); const insertShippingSettings = vi.fn(); const listReturnPolicies = vi.fn(); @@ -71,6 +74,9 @@ vi.mock("@gmc-cli/api", async (importActual) => { updateBusinessIdentity = updateBusinessIdentity; getAutofeedSettings = getAutofeedSettings; updateAutofeedSettings = updateAutofeedSettings; + getDeveloperRegistration = getDeveloperRegistration; + registerGcp = registerGcp; + unregisterGcp = unregisterGcp; getShippingSettings = getShippingSettings; insertShippingSettings = insertShippingSettings; listReturnPolicies = listReturnPolicies; @@ -605,6 +611,63 @@ describe("gmc accounts", () => { expect(out).toContain("Eligible"); }); + it("developer-registration register passes --developer-email; omits it otherwise", async () => { + registerGcp.mockResolvedValue(undefined); + + await run([ + "accounts", + "developer-registration", + "register", + "123", + "--developer-email", + "dev@x.com", + ]); + expect(registerGcp).toHaveBeenCalledWith("123", { developerEmail: "dev@x.com" }); + expect(writes.join("")).toContain("Registered the Cloud project with account 123."); + + await run(["accounts", "developer-registration", "register", "123"]); + expect(registerGcp).toHaveBeenLastCalledWith("123", {}); + }); + + it("developer-registration get renders the registered project ids", async () => { + getDeveloperRegistration.mockResolvedValue({ + name: "accounts/123/developerRegistration", + gcpIds: ["999"], + }); + + await run(["accounts", "developer-registration", "get", "123"]); + + const out = writes.join(""); + expect(out).toContain("1 registered Cloud project(s):"); + expect(out).toContain("999"); + }); + + it("developer-registration get --json emits the resource", async () => { + getDeveloperRegistration.mockResolvedValue({ + name: "accounts/123/developerRegistration", + gcpIds: ["999"], + }); + + await run(["accounts", "developer-registration", "get", "123", "--json"]); + + expect(JSON.parse(writes.join(""))).toEqual({ + name: "accounts/123/developerRegistration", + gcpIds: ["999"], + }); + }); + + it("developer-registration unregister refuses without --yes, then calls the service", async () => { + await run(["accounts", "developer-registration", "unregister", "123"]); + expect(unregisterGcp).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(2); + + process.exitCode = 0; + unregisterGcp.mockResolvedValue(undefined); + await run(["accounts", "developer-registration", "unregister", "123", "--yes"]); + expect(unregisterGcp).toHaveBeenCalledWith("123"); + expect(writes.join("")).toContain("Unregistered the Cloud project from account 123."); + }); + it("shipping set sends the --file body whole (etag preserved)", async () => { const file = join(dir, "ship.json"); writeFileSync(file, JSON.stringify({ etag: "abc", services: [{ serviceName: "s" }] }));