Skip to content

Commit 5ce3da8

Browse files
authored
Read auth information from env (#279)
* Read auth information from env * review * rename auth.ts * fix imports
1 parent 940d4b7 commit 5ce3da8

8 files changed

+97
-51
lines changed

bin/observable.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,10 @@ switch (command) {
8282
break;
8383
}
8484
case "login":
85-
await import("../src/auth.js").then((auth) => auth.login());
85+
await import("../src/observableApiAuth.js").then((auth) => auth.login());
8686
break;
8787
case "whoami":
88-
await import("../src/auth.js").then((auth) => auth.whoami());
88+
await import("../src/observableApiAuth.js").then((auth) => auth.whoami());
8989
break;
9090
default:
9191
console.error("Usage: observable <command>");

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
"test:coverage": "c8 yarn test:mocha",
3030
"test:mocha": "rm -rf test/.observablehq/cache test/input/build/*/.observablehq/cache && tsx --no-warnings=ExperimentalWarning ./node_modules/.bin/mocha 'test/**/*-test.*'",
3131
"test:lint": "eslint src test",
32-
"test:tsc": "tsc --noEmit"
32+
"test:tsc": "tsc --noEmit",
33+
"observable": "tsx ./bin/observable.ts"
3334
},
3435
"c8": {
3536
"all": true,

src/deploy.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
import readline from "node:readline/promises";
2-
import {commandRequiresAuthenticationMessage} from "./auth.js";
32
import type {BuildEffects} from "./build.js";
43
import {build} from "./build.js";
54
import type {Logger, Writer} from "./logger.js";
65
import {ObservableApiClient, getObservableUiHost} from "./observableApiClient.js";
7-
import type {DeployConfig} from "./observableApiConfig.js";
8-
import {getDeployConfig, getObservableApiKey, setDeployConfig} from "./observableApiConfig.js";
6+
import {
7+
type ApiKey,
8+
type DeployConfig,
9+
getDeployConfig,
10+
getObservableApiKey,
11+
setDeployConfig
12+
} from "./observableApiConfig.js";
913

1014
export interface DeployOptions {
1115
sourceRoot: string;
1216
}
1317

1418
export interface DeployEffects {
15-
getObservableApiKey: () => Promise<string | null>;
19+
getObservableApiKey: (logger: Logger) => Promise<ApiKey>;
1620
getDeployConfig: (sourceRoot: string) => Promise<DeployConfig | null>;
1721
setDeployConfig: (sourceRoot: string, config: DeployConfig) => Promise<void>;
1822
logger: Logger;
@@ -31,13 +35,12 @@ const defaultEffects: DeployEffects = {
3135

3236
/** Deploy a project to ObservableHQ */
3337
export async function deploy({sourceRoot}: DeployOptions, effects = defaultEffects): Promise<void> {
34-
const apiKey = await effects.getObservableApiKey();
3538
const {logger} = effects;
36-
if (!apiKey) {
37-
logger.log(commandRequiresAuthenticationMessage);
38-
return;
39-
}
40-
const apiClient = new ObservableApiClient({apiKey, logger});
39+
const apiKey = await effects.getObservableApiKey(logger);
40+
const apiClient = new ObservableApiClient({
41+
apiKey,
42+
logger
43+
});
4144

4245
// Find the existing project or create a new one.
4346
const deployConfig = await effects.getDeployConfig(sourceRoot);

src/auth.ts renamed to src/observableApiAuth.ts

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import open from "open";
88
import {HttpError, isHttpError} from "./error.js";
99
import type {Logger} from "./logger.js";
1010
import {ObservableApiClient, getObservableUiHost} from "./observableApiClient.js";
11-
import {getObservableApiKey, setObservableApiKey} from "./observableApiConfig.js";
11+
import {type ApiKey, getObservableApiKey, setObservableApiKey} from "./observableApiConfig.js";
1212

1313
const OBSERVABLEHQ_UI_HOST = getObservableUiHost();
1414

@@ -22,7 +22,7 @@ export interface CommandEffects {
2222
logger: Logger;
2323
isatty: (fd: number) => boolean;
2424
waitForEnter: () => Promise<void>;
25-
getObservableApiKey: () => Promise<string | null>;
25+
getObservableApiKey: (logger: Logger) => Promise<ApiKey>;
2626
setObservableApiKey: (id: string, key: string) => Promise<void>;
2727
exitSuccess: () => void;
2828
}
@@ -67,33 +67,36 @@ export async function login(effects = defaultEffects) {
6767
}
6868

6969
export async function whoami(effects = defaultEffects) {
70-
const apiKey = await effects.getObservableApiKey();
7170
const {logger} = effects;
72-
if (apiKey) {
73-
const apiClient = new ObservableApiClient({
74-
apiKey,
75-
logger
76-
});
71+
const apiKey = await effects.getObservableApiKey(logger);
72+
const apiClient = new ObservableApiClient({
73+
apiKey,
74+
logger
75+
});
7776

78-
try {
79-
const user = await apiClient.getCurrentUser();
80-
logger.log();
81-
logger.log(`You are logged into ${OBSERVABLEHQ_UI_HOST.hostname} as ${formatUser(user)}.`);
82-
logger.log();
83-
logger.log("You have access to the following workspaces:");
84-
for (const workspace of user.workspaces) {
85-
logger.log(` * ${formatUser(workspace)}`);
86-
}
87-
logger.log();
88-
} catch (error) {
89-
if (isHttpError(error) && error.statusCode == 401) {
77+
try {
78+
const user = await apiClient.getCurrentUser();
79+
logger.log();
80+
logger.log(`You are logged into ${OBSERVABLEHQ_UI_HOST.hostname} as ${formatUser(user)}.`);
81+
logger.log();
82+
logger.log("You have access to the following workspaces:");
83+
for (const workspace of user.workspaces) {
84+
logger.log(` * ${formatUser(workspace)}`);
85+
}
86+
logger.log();
87+
} catch (error) {
88+
console.log(error);
89+
if (isHttpError(error) && error.statusCode == 401) {
90+
if (apiKey.source === "env") {
91+
logger.log(`Your API key is invalid. Check the value of the ${apiKey.envVar} environment variable.`);
92+
} else if (apiKey.source === "file") {
9093
logger.log("Your API key is invalid. Run `observable login` to log in again.");
9194
} else {
92-
throw error;
95+
logger.log("Your API key is invalid.");
9396
}
97+
} else {
98+
throw error;
9499
}
95-
} else {
96-
logger.log(commandRequiresAuthenticationMessage);
97100
}
98101
}
99102

src/observableApiClient.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
22
import packageJson from "../package.json";
33
import {HttpError} from "./error.js";
44
import type {Logger} from "./logger.js";
5+
import type {ApiKey} from "./observableApiConfig.js";
56

67
export interface GetCurrentUserResponse {
78
id: string;
@@ -58,14 +59,14 @@ export class ObservableApiClient {
5859
logger = console
5960
}: {
6061
apiHost?: URL;
61-
apiKey: string;
62+
apiKey: ApiKey;
6263
logger: Logger;
6364
}) {
6465
this._apiHost = apiHost;
6566
this._logger = logger;
6667
this._apiHeaders = [
6768
["Accept", "application/json"],
68-
["Authorization", `apikey ${apiKey}`],
69+
["Authorization", `apikey ${apiKey.key}`],
6970
["User-Agent", `Observable CLI ${packageJson.version}`],
7071
["X-Observable-Api-Version", "2023-11-06"]
7172
];

src/observableApiConfig.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
44
import {isEnoent} from "./error.js";
5+
import type {Logger} from "./logger.js";
6+
import {commandRequiresAuthenticationMessage} from "./observableApiAuth.js";
57

68
const userConfigName = ".observablehq";
79
interface UserConfig {
@@ -19,9 +21,22 @@ export interface DeployConfig {
1921
};
2022
}
2123

22-
export async function getObservableApiKey(): Promise<string | null> {
23-
const {config} = await loadUserConfig();
24-
return config.auth?.key ?? null;
24+
export type ApiKey =
25+
| {source: "file"; filePath: string; key: string}
26+
| {source: "env"; envVar: string; key: string}
27+
| {source: "test"; key: string};
28+
29+
export async function getObservableApiKey(logger: Logger = console): Promise<ApiKey> {
30+
const envVar = "OBSERVABLEHQ_TOKEN";
31+
if (process.env[envVar]) {
32+
return {source: "env", envVar, key: process.env[envVar]};
33+
}
34+
const {config, configPath} = await loadUserConfig();
35+
if (config.auth?.key) {
36+
return {source: "file", filePath: configPath, key: config.auth.key};
37+
}
38+
logger.log(commandRequiresAuthenticationMessage);
39+
process.exit(1);
2540
}
2641

2742
export async function setObservableApiKey(id: string, key: string): Promise<void> {

test/deploy-test.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {Readable, Writable} from "node:stream";
33
import type {DeployEffects} from "../src/deploy.js";
44
import {deploy} from "../src/deploy.js";
55
import {isHttpError} from "../src/error.js";
6+
import type {Logger} from "../src/logger.js";
7+
import {commandRequiresAuthenticationMessage} from "../src/observableApiAuth.js";
68
import type {DeployConfig} from "../src/observableApiConfig.js";
79
import {MockLogger} from "./mocks/logger.js";
810
import {
@@ -48,8 +50,12 @@ class MockDeployEffects implements DeployEffects {
4850
});
4951
}
5052

51-
async getObservableApiKey() {
52-
return this._observableApiKey;
53+
async getObservableApiKey(logger: Logger) {
54+
if (!this._observableApiKey) {
55+
logger.log(commandRequiresAuthenticationMessage);
56+
throw new Error("no key available in this test");
57+
}
58+
return {source: "test" as const, key: this._observableApiKey};
5359
}
5460

5561
async getDeployConfig() {
@@ -104,10 +110,16 @@ describe("deploy", () => {
104110
const apiMock = new ObservableApiMock().start();
105111
const effects = new MockDeployEffects({apiKey: null});
106112

107-
await deploy({sourceRoot: TEST_SOURCE_ROOT}, effects);
113+
try {
114+
await deploy({sourceRoot: TEST_SOURCE_ROOT}, effects);
115+
assert.fail("expected error");
116+
} catch (err) {
117+
if (!(err instanceof Error)) throw err;
118+
assert.equal(err.message, "no key available in this test");
119+
effects.logger.assertExactLogs([/^You need to be authenticated/]);
120+
}
108121

109122
apiMock.close();
110-
effects.logger.assertExactLogs([/^You need to be authenticated/]);
111123
});
112124

113125
it("handles multiple user workspaces", async () => {

test/auth-test.ts renamed to test/observableApiAuth-test.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import assert from "node:assert";
2-
import {type CommandEffects, login, whoami} from "../src/auth.js";
2+
import type {Logger} from "../src/logger.js";
3+
import {type CommandEffects, commandRequiresAuthenticationMessage, login, whoami} from "../src/observableApiAuth.js";
34
import {MockLogger} from "./mocks/logger.js";
45
import {ObservableApiMock} from "./mocks/observableApi.js";
56

@@ -43,10 +44,16 @@ describe("login command", () => {
4344
});
4445

4546
describe("whoami command", () => {
46-
it("works when there is no API key", async () => {
47+
it("errors when there is no API key", async () => {
4748
const effects = new MockEffects({apiKey: null});
48-
await whoami(effects);
49-
effects.logger.assertExactLogs([/^You need to be authenticated/]);
49+
try {
50+
await whoami(effects);
51+
assert.fail("error expected");
52+
} catch (err) {
53+
if (!(err instanceof Error)) throw err;
54+
assert.equal(err.message, "no key available in this test");
55+
effects.logger.assertExactLogs([/^You need to be authenticated/]);
56+
}
5057
});
5158

5259
it("works when there is an API key that is invalid", async () => {
@@ -94,8 +101,12 @@ class MockEffects implements CommandEffects {
94101
this._observableApiKey = apiKey;
95102
}
96103

97-
getObservableApiKey() {
98-
return Promise.resolve(this._observableApiKey);
104+
getObservableApiKey(logger: Logger) {
105+
if (!this._observableApiKey) {
106+
logger.log(commandRequiresAuthenticationMessage);
107+
throw new Error("no key available in this test");
108+
}
109+
return Promise.resolve({source: "test" as const, key: this._observableApiKey});
99110
}
100111
isatty() {
101112
return true;

0 commit comments

Comments
 (0)