Skip to content

Commit 2bcb447

Browse files
authored
explicitly check home dir for user config (#558)
1 parent 708a64f commit 2bcb447

7 files changed

+170
-64
lines changed

src/commandInstruction.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {getObservableUiOrigin} from "./observableApiClient.js";
12
import type {TtyColor} from "./tty.js";
23
import {bold, magenta} from "./tty.js";
34

@@ -21,3 +22,7 @@ export function commandInstruction(
2122

2223
return color(`${prefix} ${command}`);
2324
}
25+
26+
export const commandRequiresAuthenticationMessage = `You need to be authenticated to ${
27+
getObservableUiOrigin().hostname
28+
} to run this command. Please run ${commandInstruction("login")}.`;

src/deploy.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import type {Config} from "./config.js";
66
import {CliError, isHttpError} from "./error.js";
77
import type {Logger, Writer} from "./logger.js";
88
import {ObservableApiClient} from "./observableApiClient.js";
9+
import type {ConfigEffects} from "./observableApiConfig.js";
910
import {
1011
type ApiKey,
1112
type DeployConfig,
13+
defaultEffects as defaultConfigEffects,
1214
getDeployConfig,
1315
getObservableApiKey,
1416
setDeployConfig
@@ -20,8 +22,8 @@ export interface DeployOptions {
2022
config: Config;
2123
}
2224

23-
export interface DeployEffects {
24-
getObservableApiKey: (logger: Logger) => Promise<ApiKey>;
25+
export interface DeployEffects extends ConfigEffects {
26+
getObservableApiKey: (effects?: DeployEffects) => Promise<ApiKey>;
2527
getDeployConfig: (sourceRoot: string) => Promise<DeployConfig | null>;
2628
setDeployConfig: (sourceRoot: string, config: DeployConfig) => Promise<void>;
2729
isTty: boolean;
@@ -32,6 +34,7 @@ export interface DeployEffects {
3234
}
3335

3436
const defaultEffects: DeployEffects = {
37+
...defaultConfigEffects,
3538
getObservableApiKey,
3639
getDeployConfig,
3740
setDeployConfig,
@@ -46,7 +49,7 @@ const defaultEffects: DeployEffects = {
4649
export async function deploy({config}: DeployOptions, effects = defaultEffects): Promise<void> {
4750
Telemetry.record({event: "deploy", step: "start"});
4851
const {logger} = effects;
49-
const apiKey = await effects.getObservableApiKey(logger);
52+
const apiKey = await effects.getObservableApiKey(effects);
5053
const apiClient = new ObservableApiClient({apiKey});
5154

5255
// Check configuration

src/observableApiAuth.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,26 +9,29 @@ import {commandInstruction} from "./commandInstruction.js";
99
import {HttpError, isHttpError} from "./error.js";
1010
import type {Logger} from "./logger.js";
1111
import {ObservableApiClient, getObservableUiOrigin} from "./observableApiClient.js";
12-
import {type ApiKey, getObservableApiKey, setObservableApiKey} from "./observableApiConfig.js";
12+
import type {ConfigEffects} from "./observableApiConfig.js";
13+
import {
14+
type ApiKey,
15+
defaultEffects as defaultConfigEffects,
16+
getObservableApiKey,
17+
setObservableApiKey
18+
} from "./observableApiConfig.js";
1319

1420
const OBSERVABLE_UI_ORIGIN = getObservableUiOrigin();
1521

16-
export const commandRequiresAuthenticationMessage = `You need to be authenticated to ${
17-
getObservableUiOrigin().hostname
18-
} to run this command. Please run ${commandInstruction("login")}.`;
19-
2022
/** Actions this command needs to take wrt its environment that may need mocked out. */
21-
export interface CommandEffects {
23+
export interface AuthEffects extends ConfigEffects {
2224
openUrlInBrowser: (url: string) => Promise<void>;
2325
logger: Logger;
2426
isTty: boolean;
2527
waitForEnter: () => Promise<void>;
26-
getObservableApiKey: (logger: Logger) => Promise<ApiKey>;
28+
getObservableApiKey: (effects: AuthEffects) => Promise<ApiKey>;
2729
setObservableApiKey: (info: {id: string; key: string} | null) => Promise<void>;
2830
exitSuccess: () => void;
2931
}
3032

31-
const defaultEffects: CommandEffects = {
33+
const defaultEffects: AuthEffects = {
34+
...defaultConfigEffects,
3235
openUrlInBrowser: async (target) => void (await open(target)),
3336
logger: console,
3437
isTty: isatty(process.stdin.fd),
@@ -73,7 +76,7 @@ export async function logout(effects = defaultEffects) {
7376

7477
export async function whoami(effects = defaultEffects) {
7578
const {logger} = effects;
76-
const apiKey = await effects.getObservableApiKey(logger);
79+
const apiKey = await effects.getObservableApiKey(effects);
7780
const apiClient = new ObservableApiClient({apiKey});
7881

7982
try {
@@ -104,11 +107,11 @@ export async function whoami(effects = defaultEffects) {
104107
class LoginServer {
105108
private _server: ReturnType<typeof createServer>;
106109
private _nonce: string;
107-
private _effects: CommandEffects;
110+
private _effects: AuthEffects;
108111
isRunning: boolean;
109112
private _sockets: Set<Socket>;
110113

111-
constructor({nonce, effects}: {nonce: string; effects: CommandEffects}) {
114+
constructor({nonce, effects}: {nonce: string; effects: AuthEffects}) {
112115
this._nonce = nonce;
113116
this._effects = effects;
114117
this._server = createServer();

src/observableApiConfig.ts

Lines changed: 66 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,28 @@
11
import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
4-
import {isEnoent} from "./error.js";
5-
import type {Logger} from "./logger.js";
6-
import {commandRequiresAuthenticationMessage} from "./observableApiAuth.js";
4+
import {commandRequiresAuthenticationMessage} from "./commandInstruction.js";
5+
import {CliError, isEnoent} from "./error.js";
6+
7+
export interface ConfigEffects {
8+
readFile: (path: string, encoding: "utf8") => Promise<string>;
9+
writeFile: (path: string, contents: string) => Promise<void>;
10+
env: typeof process.env;
11+
cwd: typeof process.cwd;
12+
mkdir: (path: string, options?: {recursive?: boolean}) => Promise<void>;
13+
homedir: typeof os.homedir;
14+
}
15+
16+
export const defaultEffects: ConfigEffects = {
17+
readFile: (path, encoding) => fs.readFile(path, encoding),
18+
writeFile: fs.writeFile,
19+
mkdir: async (path, options) => {
20+
await fs.mkdir(path, options);
21+
},
22+
env: process.env,
23+
cwd: process.cwd,
24+
homedir: os.homedir
25+
};
726

827
const userConfigName = ".observablehq";
928
interface UserConfig {
@@ -22,17 +41,16 @@ export type ApiKey =
2241
| {source: "env"; envVar: string; key: string}
2342
| {source: "test"; key: string};
2443

25-
export async function getObservableApiKey(logger: Logger = console): Promise<ApiKey> {
44+
export async function getObservableApiKey(effects: ConfigEffects = defaultEffects): Promise<ApiKey> {
2645
const envVar = "OBSERVABLE_TOKEN";
27-
if (process.env[envVar]) {
28-
return {source: "env", envVar, key: process.env[envVar]};
46+
if (effects.env[envVar]) {
47+
return {source: "env", envVar, key: effects.env[envVar]};
2948
}
3049
const {config, configPath} = await loadUserConfig();
3150
if (config.auth?.key) {
3251
return {source: "file", filePath: configPath, key: config.auth.key};
3352
}
34-
logger.log(commandRequiresAuthenticationMessage);
35-
process.exit(1);
53+
throw new CliError(commandRequiresAuthenticationMessage);
3654
}
3755

3856
export async function setObservableApiKey(info: null | {id: string; key: string}): Promise<void> {
@@ -45,11 +63,14 @@ export async function setObservableApiKey(info: null | {id: string; key: string}
4563
await writeUserConfig({config, configPath});
4664
}
4765

48-
export async function getDeployConfig(sourceRoot: string): Promise<DeployConfig | null> {
49-
const deployConfigPath = path.join(process.cwd(), sourceRoot, ".observablehq", "deploy.json");
66+
export async function getDeployConfig(
67+
sourceRoot: string,
68+
effects: ConfigEffects = defaultEffects
69+
): Promise<DeployConfig | null> {
70+
const deployConfigPath = path.join(effects.cwd(), sourceRoot, ".observablehq", "deploy.json");
5071
let content: string | null = null;
5172
try {
52-
content = await fs.readFile(deployConfigPath, "utf8");
73+
content = await effects.readFile(deployConfigPath, "utf8");
5374
} catch (error) {
5475
content = "{}";
5576
}
@@ -59,41 +80,58 @@ export async function getDeployConfig(sourceRoot: string): Promise<DeployConfig
5980
return {projectId};
6081
}
6182

62-
export async function setDeployConfig(sourceRoot: string, newConfig: DeployConfig): Promise<void> {
63-
const dir = path.join(process.cwd(), sourceRoot, ".observablehq");
83+
export async function setDeployConfig(
84+
sourceRoot: string,
85+
newConfig: DeployConfig,
86+
effects: ConfigEffects = defaultEffects
87+
): Promise<void> {
88+
const dir = path.join(effects.cwd(), sourceRoot, ".observablehq");
6489
const deployConfigPath = path.join(dir, "deploy.json");
6590
const oldConfig = (await getDeployConfig(sourceRoot)) || {};
6691
const merged = {...oldConfig, ...newConfig};
67-
await fs.mkdir(dir, {recursive: true});
68-
await fs.writeFile(deployConfigPath, JSON.stringify(merged, null, 2) + "\n");
92+
await effects.mkdir(dir, {recursive: true});
93+
await effects.writeFile(deployConfigPath, JSON.stringify(merged, null, 2) + "\n");
6994
}
7095

71-
async function loadUserConfig(): Promise<{configPath: string; config: UserConfig}> {
72-
let cursor = path.resolve(process.cwd());
73-
while (true) {
74-
const configPath = path.join(cursor, userConfigName);
96+
export async function loadUserConfig(
97+
effects: ConfigEffects = defaultEffects
98+
): Promise<{configPath: string; config: UserConfig}> {
99+
const homeConfigPath = path.join(effects.homedir(), userConfigName);
100+
101+
function* pathsToTry(): Generator<string> {
102+
let cursor = path.resolve(effects.cwd());
103+
while (true) {
104+
yield path.join(cursor, userConfigName);
105+
const nextCursor = path.dirname(cursor);
106+
if (nextCursor === cursor) break;
107+
cursor = nextCursor;
108+
}
109+
yield homeConfigPath;
110+
}
111+
112+
for (const configPath of pathsToTry()) {
75113
let content: string | null = null;
76114
try {
77-
content = await fs.readFile(configPath, "utf8");
115+
content = await effects.readFile(configPath, "utf8");
78116
} catch (error) {
79117
if (!isEnoent(error)) throw error;
80-
const nextCursor = path.dirname(cursor);
81-
if (nextCursor === cursor) break;
82-
cursor = nextCursor;
83118
}
84119

85120
if (content !== null) {
86121
try {
87122
return {config: JSON.parse(content), configPath};
88-
} catch (err) {
89-
console.error(`Problem parsing config file at ${configPath}: ${err}`);
123+
} catch (error) {
124+
throw new CliError(`Problem parsing config file at ${configPath}: ${error}`, {cause: error});
90125
}
91126
}
92127
}
93128

94-
return {config: {}, configPath: path.join(os.homedir(), userConfigName)};
129+
return {config: {}, configPath: homeConfigPath};
95130
}
96131

97-
async function writeUserConfig({configPath, config}: {configPath: string; config: UserConfig}): Promise<void> {
98-
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
132+
async function writeUserConfig(
133+
{configPath, config}: {configPath: string; config: UserConfig},
134+
effects: ConfigEffects = defaultEffects
135+
): Promise<void> {
136+
await effects.writeFile(configPath, JSON.stringify(config, null, 2));
99137
}

test/deploy-test.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import assert, {fail} from "node:assert";
22
import {Readable, Writable} from "node:stream";
3+
import {commandRequiresAuthenticationMessage} from "../src/commandInstruction.js";
34
import {normalizeConfig} from "../src/config.js";
45
import type {DeployEffects} from "../src/deploy.js";
56
import {deploy, promptConfirm} from "../src/deploy.js";
67
import {CliError, isHttpError} from "../src/error.js";
7-
import type {Logger} from "../src/logger.js";
8-
import {commandRequiresAuthenticationMessage} from "../src/observableApiAuth.js";
98
import type {DeployConfig} from "../src/observableApiConfig.js";
109
import {MockLogger} from "./mocks/logger.js";
1110
import {getCurentObservableApi, mockObservableApi} from "./mocks/observableApi.js";
1211
import {invalidApiKey, validApiKey} from "./mocks/observableApi.js";
12+
import {MockConfigEffects} from "./observableApiConfig-test.js";
1313

1414
// These files are implicitly generated by the CLI. This may change over time,
1515
// so they’re enumerated here for clarity. TODO We should enforce that these
@@ -39,7 +39,7 @@ interface MockDeployEffectsOptions {
3939
debug?: boolean;
4040
}
4141

42-
class MockDeployEffects implements DeployEffects {
42+
class MockDeployEffects extends MockConfigEffects implements DeployEffects {
4343
public logger = new MockLogger();
4444
public input = new Readable();
4545
public output: NodeJS.WritableStream;
@@ -59,6 +59,7 @@ class MockDeployEffects implements DeployEffects {
5959
outputColumns = 80,
6060
debug = false
6161
}: MockDeployEffectsOptions = {}) {
62+
super();
6263
this.observableApiKey = apiKey;
6364
this.deployConfig = deployConfig;
6465
this.isTty = isTty;
@@ -88,9 +89,10 @@ class MockDeployEffects implements DeployEffects {
8889
});
8990
}
9091

91-
async getObservableApiKey(logger: Logger) {
92+
async getObservableApiKey(effects: DeployEffects = this) {
93+
if (effects !== this) throw new Error("don't pass unrelated effects to mock effects methods");
9294
if (!this.observableApiKey) {
93-
logger.log(commandRequiresAuthenticationMessage);
95+
effects?.logger.log(commandRequiresAuthenticationMessage);
9496
throw new Error("no key available in this test");
9597
}
9698
return {source: "test" as const, key: this.observableApiKey};

0 commit comments

Comments
 (0)