Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/developer-registration.md
Original file line number Diff line number Diff line change
@@ -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`.
18 changes: 17 additions & 1 deletion docs/reference/accounts.md
Original file line number Diff line number Diff line change
@@ -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`

Expand Down Expand Up @@ -209,6 +209,22 @@ gmc accounts autofeed update 123456789 --enable-products true
`--enable-products <true|false>` 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 <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`,
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/doctor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions docs/reference/issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
44 changes: 44 additions & 0 deletions packages/api/src/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,18 @@ export interface AutofeedSettings {
/** The writable subset of AutofeedSettings accepted on patch. */
export type AutofeedSettingsInput = Pick<AutofeedSettings, "enableProducts">;

/**
* 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`),
Expand Down Expand Up @@ -525,6 +537,38 @@ export class AccountsService {
);
}

/** Fetch the account's developer registration (registered Cloud project numbers). */
getDeveloperRegistration(account: string): Promise<DeveloperRegistration> {
return this.client.get<DeveloperRegistration>(
"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<void> {
await this.client.request<undefined>(
"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<void> {
await this.client.request<undefined>(
"accounts",
"POST",
`${this.base(account)}/developerRegistration:unregisterGcp`,
);
}

/** Fetch an account's shipping settings (the singleton). */
getShippingSettings(account: string): Promise<ShippingSettings> {
return this.client.get<ShippingSettings>("accounts", `${this.base(account)}/shippingSettings`);
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export type {
IdentityAttribute,
AutofeedSettings,
AutofeedSettingsInput,
DeveloperRegistration,
ShippingSettings,
OnlineReturnPolicy,
PostalAddress,
Expand Down
9 changes: 9 additions & 0 deletions packages/api/src/probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <you@example.com>`, 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.",
Expand Down
31 changes: 31 additions & 0 deletions packages/api/tests/accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
19 changes: 18 additions & 1 deletion packages/api/tests/probe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
82 changes: 82 additions & 0 deletions packages/cli/src/commands/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type IdentityAttribute,
type AutofeedSettings,
type AutofeedSettingsInput,
type DeveloperRegistration,
type ShippingSettings,
type OnlineReturnPolicy,
type PostalAddress,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 <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");
Expand Down
Loading