Skip to content

Commit 1286b97

Browse files
committed
netlify account flow
1 parent 7fecd6d commit 1286b97

File tree

9 files changed

+344
-124
lines changed

9 files changed

+344
-124
lines changed

src/command/publish/cmd.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@
55
*
66
*/
77

8+
import { error } from "log/mod.ts";
9+
10+
import { prompt } from "cliffy/prompt/mod.ts";
811
import { Command } from "cliffy/command/mod.ts";
912
import { Select } from "cliffy/prompt/select.ts";
1013

1114
import { PublishOptions } from "./provider.ts";
1215

1316
import { ghpagesProvider } from "./ghpages.ts";
1417
import { netlifyProvider } from "./netlify.ts";
18+
import { exitWithCleanup } from "../../core/cleanup.ts";
1519

1620
const kPublishProviders = [netlifyProvider, ghpagesProvider];
1721

@@ -23,6 +27,10 @@ export const publishCommand = withProviders(
2327
"--no-render",
2428
"Do not render before publishing.",
2529
)
30+
.option(
31+
"--no-prompt",
32+
"Do not prompt to confirm publishing destination",
33+
)
2634
.description(
2735
"Publish a document or project to a variety of destinations.",
2836
// deno-lint-ignore no-explicit-any
@@ -31,21 +39,34 @@ export const publishCommand = withProviders(
3139
const publishOptions: PublishOptions = {
3240
path: path || Deno.cwd(),
3341
render: !!options.render,
42+
prompt: !!options.prompt,
3443
};
3544

45+
// can't call base publish with no prompot
46+
if (!publishOptions.prompt) {
47+
error(
48+
"You must specify an explicit provider (e.g. 'netlify') with --no-prompt",
49+
);
50+
exitWithCleanup(1);
51+
}
52+
3653
// select provider
37-
const name: string = await Select.prompt({
54+
const result = await prompt([{
55+
name: "destination",
3856
message: "Select destination:",
3957
options: kPublishProviders.map((provider) => ({
4058
name: provider.description,
4159
value: provider.name,
4260
})),
43-
});
61+
type: Select,
62+
}]);
4463

4564
// call provider
46-
const provider = findProvider(name);
47-
if (provider) {
48-
provider.configure(publishOptions);
65+
if (result.destination) {
66+
const provider = findProvider(result.destination);
67+
if (provider) {
68+
await provider.configure(publishOptions);
69+
}
4970
}
5071
}),
5172
);
@@ -63,6 +84,10 @@ function withProviders(command: Command<any>): Command<any> {
6384
.option(
6485
"--no-render",
6586
"Do not render before publishing.",
87+
)
88+
.option(
89+
"--no-prompt",
90+
"Do not prompt to confirm publishing destination",
6691
),
6792
),
6893
);

src/command/publish/ghpages.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export const ghpagesProvider: PublishProvider = {
1919
ghpagesConfigure({
2020
path: path || Deno.cwd(),
2121
render: !!options.render,
22+
prompt: !!options.prompt,
2223
});
2324
});
2425
},

src/command/publish/netlify.ts

Lines changed: 152 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,23 @@
55
*
66
*/
77

8+
import { error } from "log/mod.ts";
9+
810
import { Command } from "cliffy/command/mod.ts";
11+
import { prompt } from "cliffy/prompt/mod.ts";
12+
import { Select, SelectOption } from "cliffy/prompt/select.ts";
13+
import { Confirm } from "cliffy/prompt/confirm.ts";
14+
import {
15+
authorizeNetlifyAccessToken,
16+
kNetlifyAuthTokenVar,
17+
netlifyAccessToken,
18+
netlifyEnvironmentAuthToken,
19+
} from "../../publish/netlify/account.ts";
920
import { netlifyPublish } from "../../publish/netlify/netlify.ts";
1021

1122
import { PublishOptions, PublishProvider } from "./provider.ts";
23+
import { exitWithCleanup } from "../../core/cleanup.ts";
24+
import { AccessToken, ApiError } from "../../publish/netlify/api/index.ts";
1225

1326
export const netlifyProvider: PublishProvider = {
1427
name: "netlify",
@@ -20,14 +33,149 @@ export const netlifyProvider: PublishProvider = {
2033
await netlifyConfigure({
2134
path: path || Deno.cwd(),
2235
render: !!options.render,
36+
prompt: !!options.prompt,
2337
});
2438
});
2539
},
2640
configure: netlifyConfigure,
2741
};
2842

29-
async function netlifyConfigure(options: PublishOptions) {
30-
console.log("netlify");
31-
console.log(options);
32-
await netlifyPublish();
43+
const kEnvToken = "env-token";
44+
const kAuthorizedToken = "authorized-token";
45+
const kAuthorize = "authorize";
46+
47+
async function netlifyConfigure(options: PublishOptions): Promise<void> {
48+
// see what tyep of token we are going to use
49+
let token: string | undefined;
50+
let tokenType: "env-token" | "authorized-token" | undefined;
51+
const envToken = netlifyEnvironmentAuthToken();
52+
const accessToken = netlifyAccessToken();
53+
const authorizedToken = accessToken?.access_token;
54+
55+
// if we aren't prompting then we need to have one at the ready
56+
if (!options.prompt) {
57+
token = envToken || authorizedToken;
58+
if (!token) {
59+
error(
60+
`No existing account available (account required for publish with --no-prompt)`,
61+
);
62+
}
63+
} else {
64+
// build list of selection options
65+
66+
const options: SelectOption[] = [];
67+
if (envToken) {
68+
options.push({
69+
name: `${kNetlifyAuthTokenVar}`,
70+
value: kEnvToken,
71+
});
72+
}
73+
if (authorizedToken) {
74+
options.push({
75+
name: `${accessToken.email!}`,
76+
value: kAuthorizedToken,
77+
});
78+
}
79+
if (options.length > 0) {
80+
options.push({
81+
name: "Use another account...",
82+
value: kAuthorize,
83+
});
84+
85+
const result = await prompt([{
86+
name: "token",
87+
message: "Netlify account:",
88+
options,
89+
type: Select,
90+
}]);
91+
switch (result.token) {
92+
case kEnvToken: {
93+
token = envToken;
94+
tokenType = kEnvToken;
95+
break;
96+
}
97+
case kAuthorizedToken: {
98+
token = authorizedToken;
99+
tokenType = kAuthorizedToken;
100+
break;
101+
}
102+
case kAuthorize: {
103+
tokenType = kAuthorizedToken;
104+
}
105+
}
106+
}
107+
108+
// if we don't have a token yet we need to authorize
109+
if (!token) {
110+
const result = await prompt([{
111+
name: "confirmed",
112+
message: "Authorize account",
113+
default: true,
114+
hint:
115+
"In order to publish to Netlify with Quarto you need to authorize your account.\n" +
116+
" Please be sure you are logged into the correct Netlify account in your default\n" +
117+
" web browser, then press Enter to authorize.",
118+
type: Confirm,
119+
}]);
120+
if (!result.confirmed) {
121+
exitWithCleanup(1);
122+
}
123+
124+
// do the authorization
125+
token = (await authorizeNetlifyAccessToken())?.access_token;
126+
}
127+
}
128+
129+
// publish if we have a token
130+
if (token) {
131+
try {
132+
await netlifyPublish({
133+
token,
134+
});
135+
} catch (error) {
136+
// attempt to recover from unauthorized
137+
if (error instanceof ApiError && error.status === 401) {
138+
await handleUnauthorized(tokenType!, options, accessToken);
139+
} else {
140+
throw error;
141+
}
142+
}
143+
}
144+
}
145+
146+
async function handleUnauthorized(
147+
tokenType: string,
148+
options: PublishOptions,
149+
accessToken?: AccessToken,
150+
) {
151+
if (tokenType === kEnvToken) {
152+
error(
153+
`Unable to authenticate with the provided ${kNetlifyAuthTokenVar}. Please be sure this token is valid.`,
154+
);
155+
exitWithCleanup(1);
156+
} else if (tokenType === kAuthorizedToken && accessToken) {
157+
const result = await prompt([{
158+
name: "confirmed",
159+
message: "Re-authorize account",
160+
default: true,
161+
hint:
162+
`The authorization saved for account ${accessToken.email} is no longer valid.\n` +
163+
" Please be sure you are logged into the correct Netlify account in your\n" +
164+
" default web browser, then press Enter to re-authorize.",
165+
type: Confirm,
166+
}]);
167+
if (!result.confirmed) {
168+
exitWithCleanup(1);
169+
}
170+
171+
// do the authorization then re-try
172+
if ((await authorizeNetlifyAccessToken())) {
173+
await netlifyConfigure(options);
174+
return;
175+
}
176+
}
177+
178+
// default error
179+
error("Unable to publish to Netlify (unauthorized)");
180+
exitWithCleanup(1);
33181
}

src/command/publish/provider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Command } from "cliffy/command/mod.ts";
1010
export interface PublishOptions {
1111
path: string;
1212
render: boolean;
13+
prompt: boolean;
1314
}
1415

1516
export interface PublishProvider {

src/publish/netlify/account.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
* accounts.ts
3+
*
4+
* Copyright (C) 2020 by RStudio, PBC
5+
*
6+
*/
7+
8+
import { join } from "path/mod.ts";
9+
import { ensureDirSync, existsSync } from "fs/mod.ts";
10+
11+
import { quartoDataDir } from "../../core/appdirs.ts";
12+
13+
import { AccessToken, NetlifyClient, Ticket } from "./api/index.ts";
14+
import { quartoConfig } from "../../core/quarto.ts";
15+
import { openUrl } from "../../core/shell.ts";
16+
import { sleep } from "../../core/wait.ts";
17+
18+
export const kNetlifyAuthTokenVar = "NETLIFY_AUTH_TOKEN";
19+
20+
export function netlifyEnvironmentAuthToken() {
21+
return Deno.env.get(kNetlifyAuthTokenVar);
22+
}
23+
24+
export function netlifyAccessToken(): AccessToken | undefined {
25+
const tokenPath = netlifyAccessTokenPath();
26+
if (existsSync(tokenPath)) {
27+
const token = JSON.parse(Deno.readTextFileSync(tokenPath)) as AccessToken;
28+
return token;
29+
} else {
30+
return undefined;
31+
}
32+
}
33+
34+
export async function authorizeNetlifyAccessToken(): Promise<
35+
AccessToken | undefined
36+
> {
37+
// create ticket for authorization
38+
const client = new NetlifyClient({});
39+
const clientId = (await quartoConfig.dotenv())["NETLIFY_APP_CLIENT_ID"];
40+
const ticket = await client.ticket.createTicket({
41+
clientId,
42+
}) as unknown as Ticket;
43+
await openUrl(
44+
`https://app.netlify.com/authorize?response_type=ticket&ticket=${ticket.id}`,
45+
);
46+
47+
// poll for ticket to be authoried
48+
let authorizedTicket: Ticket | undefined;
49+
const checkTicket = async () => {
50+
const t = await client.ticket.showTicket({ ticketId: ticket.id! });
51+
if (t.authorized) {
52+
authorizedTicket = t;
53+
}
54+
return Boolean(t.authorized);
55+
};
56+
const pollingStart = Date.now();
57+
const kPollingTimeout = 60 * 1000;
58+
const kPollingInterval = 500;
59+
while ((Date.now() - pollingStart) < kPollingTimeout) {
60+
if (await checkTicket()) {
61+
break;
62+
}
63+
await sleep(kPollingInterval);
64+
}
65+
if (authorizedTicket) {
66+
// exechange ticket for the token
67+
const accessToken = await client.accessToken
68+
.exchangeTicket({
69+
ticketId: authorizedTicket!.id!,
70+
}) as unknown as AccessToken;
71+
72+
// save the token
73+
writeNetlifyAccessToken(accessToken);
74+
75+
// return it
76+
return accessToken;
77+
} else {
78+
return undefined;
79+
}
80+
}
81+
82+
function writeNetlifyAccessToken(token: AccessToken) {
83+
Deno.writeTextFileSync(
84+
netlifyAccessTokenPath(),
85+
JSON.stringify(token, undefined, 2),
86+
);
87+
}
88+
89+
function accountsDataDir() {
90+
return quartoDataDir("accounts");
91+
}
92+
93+
function netlifyAccessTokenPath() {
94+
const dir = join(accountsDataDir(), "netlify");
95+
ensureDirSync(dir);
96+
return join(dir, "token.json");
97+
}

src/publish/netlify/accounts.ts

Whitespace-only changes.

0 commit comments

Comments
 (0)