Repository: firebase/firebase-tools (ts)

Issue:
Can't deploy project due to ...res.body.versions undefined
<!-- DO NOT DELETE
validate_template=true
template_path=.github/ISSUE_TEMPLATE/bug_report.md
-->

### [REQUIRED] Environment info

**firebase-tools:**
"13.0.2"

**Platform:**
Windows

### [REQUIRED] Test case

I just run `firebase deploy` on my project

### [REQUIRED] Steps to reproduce

- build a project
- deploy a project

### [REQUIRED] Expected behavior

the project is deployed

### [REQUIRED] Actual behavior

```
[debug] [2024-01-23T10:24:51.358Z] TypeError: res.body.versions is not iterable (cannot read property undefined)
    at Object.listVersions (C:\Users\feder\Repository\anyx_mono_frontend\node_modules\firebase-tools\lib\hosting\api.js:113:18)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async Promise.all (index 3)
    at async Object.gcTagsForServices (C:\Users\feder\Repository\anyx_mono_frontend\node_modules\firebase-tools\lib\hosting\runTags.js:14:31)
    at async Object.setRewriteTags (C:\Users\feder\Repository\anyx_mono_frontend\node_modules\firebase-tools\lib\hosting\runTags.js:77:9)
    at async convertConfig (C:\Users\feder\Repository\anyx_mono_frontend\node_modules\firebase-tools\lib\deploy\hosting\convertConfig.js:160:9)
    at async C:\Users\feder\Repository\anyx_mono_frontend\node_modules\firebase-tools\lib\deploy\hosting\release.js:21:21
    at async Promise.all (index 0)
    at async release (C:\Users\feder\Repository\anyx_mono_frontend\node_modules\firebase-tools\lib\deploy\hosting\release.js:14:5)
    at async chain (C:\Users\feder\Repository\anyx_mono_frontend\node_modules\firebase-tools\lib\deploy\index.js:38:9)
    ```
    
    so here
    
    ```
    async function listVersions(site) {
    let pageToken = undefined;
    const versions = [];
    do {
        const queryParams = {};
        if (pageToken) {
            queryParams.pageToken = pageToken;
        }
        const res = await apiClient.get(`projects/-/sites/${site}/versions`, {
            queryParams,
        });
        versions.push(...res.body.versions);
        pageToken = res.body.nextPageToken;
    } while (pageToken);
    return versions;
}
exports.listVersions = listVersions;
```


This issue appeared overnight.


Pull request:
Fixing unsafe array spreads on Hosting deploys

### Description
Fixing some unsafe array spreads in the Hosting API client. This was inspired by #6172 - I'm not sure why the backend is not returning versions there (and we might wanna look into that more), but either way we should not blow up there.
Fixes #6712 



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fceb3a4a5..75dcbfa67 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,8 @@
 - Fixed issue where Auth emulator sign in with Google only shows default tenant. (#6683)
 - Prevent the use of pinTags + minInstances on the same function, as the features are not mutually compatible (#6684)
-- Add force flag to delete backend (#6635).
+- Added force flag to delete backend (#6635).
 - Use framework build target in Vite builds (#6643).
 - Use framework build target in NODE_ENV for production Vite builds (#6644)
 - Let framework handle public directory with emulator. (#6674)
 - Dynamically import Vite to fix deprecated CJS build warning. (#6660)
+- Fixed unsafe array spreads on Hosting deploys. (#6712)
diff --git a/src/hosting/api.ts b/src/hosting/api.ts
index c469e0af4..05ead31f3 100644
--- a/src/hosting/api.ts
+++ b/src/hosting/api.ts
@@ -312,14 +312,11 @@ export async function listChannels(
   let nextPageToken = "";
   for (;;) {
     try {
-      const res = await apiClient.get<{ nextPageToken?: string; channels: Channel[] }>(
+      const res = await apiClient.get<{ nextPageToken?: string; channels?: Channel[] }>(
         `/projects/${project}/sites/${site}/channels`,
         { queryParams: { pageToken: nextPageToken, pageSize: 10 } }
       );
-      const c = res.body.channels;
-      if (c) {
-        channels.push(...c);
-      }
+      channels.push(...(res.body.channels ?? []));
       nextPageToken = res.body.nextPageToken || "";
       if (!nextPageToken) {
         return channels;
@@ -431,7 +428,7 @@ export async function updateVersion(
 }
 
 interface ListVersionsResponse {
-  versions: Version[];
+  versions?: Version[];
   nextPageToken?: string;
 }
 
@@ -449,7 +446,7 @@ export async function listVersions(site: string): Promise<Version[]> {
     const res = await apiClient.get<ListVersionsResponse>(`projects/-/sites/${site}/versions`, {
       queryParams,
     });
-    versions.push(...res.body.versions);
+    versions.push(...(res.body.versions ?? []));
     pageToken = res.body.nextPageToken;
   } while (pageToken);
   return versions;
@@ -515,14 +512,11 @@ export async function listSites(project: string): Promise<Site[]> {
   let nextPageToken = "";
   for (;;) {
     try {
-      const res = await apiClient.get<{ sites: Site[]; nextPageToken?: string }>(
+      const res = await apiClient.get<{ sites?: Site[]; nextPageToken?: string }>(
         `/projects/${project}/sites`,
         { queryParams: { pageToken: nextPageToken, pageSize: 10 } }
       );
-      const c = res.body.sites;
-      if (c) {
-        sites.push(...c);
-      }
+      sites.push(...(res.body.sites ?? []));
       nextPageToken = res.body.nextPageToken || "";
       if (!nextPageToken) {
         return sites;


Test patch:
diff --git a/src/test/hosting/api.spec.ts b/src/test/hosting/api.spec.ts
index 8a25ede21..9e62da64d 100644
--- a/src/test/hosting/api.spec.ts
+++ b/src/test/hosting/api.spec.ts
@@ -104,6 +104,18 @@ describe("hosting", () => {
       expect(nock.isDone()).to.be.true;
     });
 
+    it("should return 0 channels if none are returned", async () => {
+      nock(hostingApiOrigin)
+        .get(`/v1beta1/projects/${PROJECT_ID}/sites/${SITE}/channels`)
+        .query({ pageToken: "", pageSize: 10 })
+        .reply(200, {});
+
+      const res = await hostingApi.listChannels(PROJECT_ID, SITE);
+
+      expect(res).to.deep.equal([]);
+      expect(nock.isDone()).to.be.true;
+    });
+
     it("should make multiple API requests to list channels", async () => {
       nock(hostingApiOrigin)
         .get(`/v1beta1/projects/${PROJECT_ID}/sites/${SITE}/channels`)
@@ -358,6 +370,15 @@ describe("hosting", () => {
       name: `projects/-/sites/${SITE}/versions/v2`,
     };
 
+    it("returns no versions if no versions are returned", async () => {
+      nock(hostingApiOrigin).get(`/v1beta1/projects/-/sites/${SITE}/versions`).reply(200, {});
+      nock(hostingApiOrigin);
+
+      const versions = await hostingApi.listVersions(SITE);
+      expect(versions).deep.equals([]);
+      expect(nock.isDone()).to.be.true;
+    });
+
     it("returns a single page of versions", async () => {
       nock(hostingApiOrigin)
         .get(`/v1beta1/projects/-/sites/${SITE}/versions`)
@@ -552,6 +573,18 @@ describe("hosting", () => {
       expect(nock.isDone()).to.be.true;
     });
 
+    it("should return no sites if none are returned", async () => {
+      nock(hostingApiOrigin)
+        .get(`/v1beta1/projects/${PROJECT_ID}/sites`)
+        .query({ pageToken: "", pageSize: 10 })
+        .reply(200, {});
+
+      const res = await hostingApi.listSites(PROJECT_ID);
+
+      expect(res).to.deep.equal([]);
+      expect(nock.isDone()).to.be.true;
+    });
+
     it("should make multiple API requests to list sites", async () => {
       nock(hostingApiOrigin)
         .get(`/v1beta1/projects/${PROJECT_ID}/sites`)