diff --git a/package.json b/package.json index 78bd3d6..15ba956 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cloudinary/cloud", - "version": "0.1.0", + "version": "0.1.1", "description": "One command to get a disposable Cloudinary cloud account — no signup required", "type": "module", "bin": { diff --git a/src/lib/provision.ts b/src/lib/provision.ts index 4bad568..53ac582 100644 --- a/src/lib/provision.ts +++ b/src/lib/provision.ts @@ -182,7 +182,29 @@ export async function provisionCloud( throw new ProvisionError(message, res.status, code, category); } - return (await res.json()) as CloudAccount; + return normalizeAccount(await res.json()); +} + +/** + * The API has served the cloud's environment both nested (product_environments[]) + * and flattened onto the account object itself. Normalize to the nested shape so + * every consumer reads one contract. Throwing here must include the raw response: + * the cloud already exists, so an unrecognized shape must never cost the caller + * their only copy of its credentials. + */ +function normalizeAccount(raw: unknown): CloudAccount { + const account = raw as CloudAccount & ProductEnvironment; + if (Array.isArray(account.product_environments) && account.product_environments.length > 0) { + return account; + } + if (typeof account.cloud_name === 'string' && account.cloud_name !== '') { + account.product_environments = [account]; + return account; + } + throw new ProvisionError( + `Provisioning succeeded but the response shape was not recognized. Raw response (keep it — it may contain your credentials): ${JSON.stringify(raw)}`, + 0, + ); } /** The credentials the CLI surfaces, tolerant of every response shape seen so far. */ diff --git a/test/provision.test.mjs b/test/provision.test.mjs index 5ed41ac..acdc715 100644 --- a/test/provision.test.mjs +++ b/test/provision.test.mjs @@ -127,6 +127,61 @@ test('posts the contract-shaped body and parses the response', async () => { assert.deepEqual(captured, { delivery_ips: ['requester_ip'], email: 'dev@example.com' }); }); +// Live shape since 2026-08: the environment's fields arrive flattened onto the +// account object, with no product_environments array. +const FLAT_RESPONSE = { + account_id: 'acct2', + email: 'x@cloud.cloudinary.invalid', + cloud_name: 'cloud-flat', + api_key: 'flatkey', + api_secret: 'flatsecret', + api_environment_variable: 'CLOUDINARY_URL=cloudinary://flatkey:flatsecret@cloud-flat', + claimed: false, + expires_at: '2026-01-01T00:00:00Z', + delivery_ips: ['203.0.113.7'], + claim_url: 'https://console.cloudinary.com/claim?token=t', + guidance: 'Use it before it expires.', +}; + +test('normalizes the flattened response shape', async () => { + await withStub( + (req, res) => { + req.on('data', () => {}); + req.on('end', () => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(FLAT_RESPONSE)); + }); + }, + async host => { + const account = await provisionCloud({}, { apiHost: host }); + const env = account.product_environments[0]; + assert.equal(env.cloud_name, 'cloud-flat'); + assert.equal(getCloudinaryUrl(env), 'cloudinary://flatkey:flatsecret@cloud-flat'); + assert.equal(getActiveAccessKey(env).key, 'flatkey'); + assert.equal(account.claim_url, FLAT_RESPONSE.claim_url); + assert.equal(account.expires_at, FLAT_RESPONSE.expires_at); + }, + ); +}); + +test('unrecognized success shape throws with the raw response preserved', async () => { + await withStub( + (req, res) => { + req.on('data', () => {}); + req.on('end', () => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ unexpected: true, api_secret: 'keep-me' })); + }); + }, + async host => { + await assert.rejects( + provisionCloud({}, { apiHost: host }), + err => err instanceof ProvisionError && /keep-me/.test(err.message) && /not recognized/.test(err.message), + ); + }, + ); +}); + test('omits email when not supplied', async () => { let captured; await withStub(