Skip to content

Commit 13e57cd

Browse files
feat: Add wrangler pages project list (#757)
Add wrangler pages project list to list your Cloudflare Pages projects
1 parent c63ea3d commit 13e57cd

File tree

7 files changed

+185
-22
lines changed

7 files changed

+185
-22
lines changed

.changeset/lucky-paws-report.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"wrangler": patch
3+
---
4+
5+
feature: Add wrangler pages project list
6+
7+
Adds a new command to list your projects in Cloudflare Pages.

package-lock.json

+11
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"jest": "^27.5.1",
3131
"npm-run-all": "^4.1.5",
3232
"prettier": "^2.5.1",
33+
"timeago.js": "^4.0.2",
3334
"typescript": "^4.6.2"
3435
},
3536
"scripts": {

packages/wrangler/src/__tests__/pages.test.ts

+75
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import { mockAccountId, mockApiToken } from "./helpers/mock-account-id";
2+
import { setMockResponse, unsetAllMocks } from "./helpers/mock-cfetch";
13
import { mockConsoleMethods } from "./helpers/mock-console";
24
import { runInTempDir } from "./helpers/run-in-tmp";
35
import { runWrangler } from "./helpers/run-wrangler";
6+
import type { Project } from "../pages";
47

58
describe("subcommand implicit help ran on incomplete command execution", () => {
69
runInTempDir();
@@ -54,4 +57,76 @@ describe("subcommand implicit help ran on incomplete command execution", () => {
5457
);
5558
});
5659
});
60+
61+
describe("project list", () => {
62+
mockAccountId();
63+
mockApiToken();
64+
65+
afterEach(() => {
66+
unsetAllMocks();
67+
});
68+
function mockListRequest(projects: unknown[]) {
69+
const requests = { count: 0 };
70+
setMockResponse(
71+
"/accounts/:accountId/pages/projects",
72+
([_url, accountId], init, query) => {
73+
requests.count++;
74+
expect(accountId).toEqual("some-account-id");
75+
expect(query.get("per_page")).toEqual("10");
76+
expect(query.get("page")).toEqual(`${requests.count}`);
77+
expect(init).toEqual({});
78+
const pageSize = Number(query.get("per_page"));
79+
const page = Number(query.get("page"));
80+
return projects.slice((page - 1) * pageSize, page * pageSize);
81+
}
82+
);
83+
return requests;
84+
}
85+
86+
it("should make request to list projects", async () => {
87+
const projects: Project[] = [
88+
{
89+
name: "dogs",
90+
domains: ["dogs.pages.dev"],
91+
source: {
92+
type: "github",
93+
},
94+
latest_deployment: {
95+
modified_on: "2021-11-17T14:52:26.133835Z",
96+
},
97+
},
98+
{
99+
name: "cats",
100+
domains: ["cats.pages.dev", "kitten.pages.dev"],
101+
latest_deployment: {
102+
modified_on: "2021-11-17T14:52:26.133835Z",
103+
},
104+
},
105+
];
106+
107+
const requests = mockListRequest(projects);
108+
await runWrangler("pages project list");
109+
110+
expect(requests.count).toBe(1);
111+
});
112+
113+
it("should make multiple requests for paginated results", async () => {
114+
const projects: Project[] = [];
115+
for (let i = 0; i < 15; i++) {
116+
projects.push({
117+
name: "dogs" + i,
118+
domains: [i + "dogs.pages.dev"],
119+
source: {
120+
type: "github",
121+
},
122+
latest_deployment: {
123+
modified_on: "2021-11-17T14:52:26.133835Z",
124+
},
125+
});
126+
}
127+
const requests = mockListRequest(projects);
128+
await runWrangler("pages project list");
129+
expect(requests.count).toEqual(2);
130+
});
131+
});
57132
});

packages/wrangler/src/index.tsx

+1-22
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,8 @@ import {
4848
login,
4949
logout,
5050
listScopes,
51-
loginOrRefreshIfRequired,
52-
getAccountId,
5351
validateScopeKeys,
52+
requireAuth,
5453
} from "./user";
5554
import { whoami } from "./whoami";
5655

@@ -133,26 +132,6 @@ function getLegacyScriptName(
133132
: args.name ?? config.name;
134133
}
135134

136-
/**
137-
* Ensure that a user is logged in, and a valid account_id is available.
138-
*/
139-
async function requireAuth(
140-
config: Config,
141-
isInteractive = true
142-
): Promise<string> {
143-
const loggedIn = await loginOrRefreshIfRequired(isInteractive);
144-
if (!loggedIn) {
145-
// didn't login, let's just quit
146-
throw new Error("Did not login, quitting...");
147-
}
148-
const accountId = config.account_id || (await getAccountId(isInteractive));
149-
if (!accountId) {
150-
throw new Error("No account id found, quitting...");
151-
}
152-
153-
return accountId;
154-
}
155-
156135
/**
157136
* Get a promise to the streamed input from stdin.
158137
*

packages/wrangler/src/pages.tsx

+69
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,39 @@ import { tmpdir } from "node:os";
66
import { join } from "node:path";
77
import { URL } from "node:url";
88
import { watch } from "chokidar";
9+
import { render } from "ink";
10+
import Table from "ink-table";
911
import { getType } from "mime";
12+
import React from "react";
13+
import { format } from "timeago.js";
1014
import { buildWorker } from "../pages/functions/buildWorker";
1115
import { generateConfigFromFileTree } from "../pages/functions/filepath-routing";
1216
import { writeRoutesModule } from "../pages/functions/routes";
17+
import { fetchResult } from "./cfetch";
18+
import { readConfig } from "./config";
1319
import { FatalError } from "./errors";
1420
import openInBrowser from "./open-in-browser";
1521
import { toUrlPath } from "./paths";
22+
import { requireAuth } from "./user";
1623
import type { Config } from "../pages/functions/routes";
1724
import type { Headers, Request, fetch } from "@miniflare/core";
1825
import type { BuildResult } from "esbuild";
1926
import type { MiniflareOptions } from "miniflare";
2027
import type { BuilderCallback } from "yargs";
2128

29+
type ConfigPath = string | undefined;
30+
31+
export type Project = {
32+
name: string;
33+
domains: Array<string>;
34+
source?: {
35+
type: string;
36+
};
37+
latest_deployment: {
38+
modified_on: string;
39+
};
40+
};
41+
2242
// Defer importing miniflare until we really need it. This takes ~0.5s
2343
// and also modifies some `stream/web` and `undici` prototypes, so we
2444
// don't want to do this if pages commands aren't being called.
@@ -1059,6 +1079,29 @@ export const pages: BuilderCallback<unknown, unknown> = (yargs) => {
10591079
});
10601080
}
10611081
)
1082+
)
1083+
.command("project", false, (yargs) =>
1084+
yargs.command(
1085+
"list",
1086+
"List your Cloudflare Pages projects",
1087+
() => {},
1088+
async (args) => {
1089+
const config = readConfig(args.config as ConfigPath, args);
1090+
const accountId = await requireAuth(config);
1091+
1092+
const projects: Array<Project> = await listProjects({ accountId });
1093+
1094+
const data = projects.map((project) => {
1095+
return {
1096+
"Project Name": project.name,
1097+
"Project Domains": `${project.domains.join(", ")}`,
1098+
"Git Provider": project.source ? "Yes" : "No",
1099+
"Last Modified": format(project.latest_deployment.modified_on),
1100+
};
1101+
});
1102+
render(<Table data={data}></Table>);
1103+
}
1104+
)
10621105
);
10631106
};
10641107

@@ -1067,3 +1110,29 @@ const invalidAssetsFetch: typeof fetch = () => {
10671110
"Trying to fetch assets directly when there is no `directory` option specified, and not in `local` mode."
10681111
);
10691112
};
1113+
1114+
const listProjects = async ({
1115+
accountId,
1116+
}: {
1117+
accountId: string;
1118+
}): Promise<Array<Project>> => {
1119+
const pageSize = 10;
1120+
let page = 1;
1121+
const results = [];
1122+
while (results.length % pageSize === 0) {
1123+
const json: Array<Project> = await fetchResult(
1124+
`/accounts/${accountId}/pages/projects`,
1125+
{},
1126+
new URLSearchParams({
1127+
per_page: pageSize.toString(),
1128+
page: page.toString(),
1129+
})
1130+
);
1131+
page++;
1132+
results.push(...json);
1133+
if (json.length < pageSize) {
1134+
break;
1135+
}
1136+
}
1137+
return results;
1138+
};

packages/wrangler/src/user.tsx

+21
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ import { getCloudflareApiBaseUrl } from "./cfetch";
223223
import { getEnvironmentVariableFactory } from "./environment-variables";
224224
import openInBrowser from "./open-in-browser";
225225
import { readFileSync } from "./parse";
226+
import type { Config } from "./config";
226227
import type { Item as SelectInputItem } from "ink-select-input/build/SelectInput";
227228
import type { ParsedUrlQuery } from "node:querystring";
228229
import type { Response } from "undici";
@@ -1143,3 +1144,23 @@ export function ChooseAccount(props: {
11431144
</>
11441145
);
11451146
}
1147+
1148+
/**
1149+
* Ensure that a user is logged in, and a valid account_id is available.
1150+
*/
1151+
export async function requireAuth(
1152+
config: Config,
1153+
isInteractive = true
1154+
): Promise<string> {
1155+
const loggedIn = await loginOrRefreshIfRequired(isInteractive);
1156+
if (!loggedIn) {
1157+
// didn't login, let's just quit
1158+
throw new Error("Did not login, quitting...");
1159+
}
1160+
const accountId = config.account_id || (await getAccountId(isInteractive));
1161+
if (!accountId) {
1162+
throw new Error("No account id found, quitting...");
1163+
}
1164+
1165+
return accountId;
1166+
}

0 commit comments

Comments
 (0)