Skip to content

Commit 0bbc62b

Browse files
authored
Merge pull request #34 from imbue-ai/dev
Version 2.2.1.
2 parents 39bdfbe + ebf02b3 commit 0bbc62b

6 files changed

Lines changed: 226 additions & 10 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "latchkey",
3-
"version": "2.2.0",
3+
"version": "2.2.1",
44
"description": "A CLI tool that injects API credentials into curl requests to third-party services",
55
"author": "Imbue <hynek@imbue.com>",
66
"repository": {

src/cli.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import { existsSync } from 'node:fs';
88
import { program } from 'commander';
99
import { registerCommands, createDefaultDependencies } from './cliCommands.js';
1010
import { CurlNotFoundError, InsecureFilePermissionsError } from './config.js';
11-
import { EncryptedStorage, EncryptionKeyLostError } from './encryptedStorage.js';
11+
import {
12+
EncryptedStorage,
13+
EncryptedStorageError,
14+
EncryptionKeyLostError,
15+
} from './encryptedStorage.js';
1216
import { MigrationError, runMigrations } from './migrations.js';
1317
import { loadRegisteredServicesIntoRegistry } from './registry.js';
1418
import packageJson from '../package.json' with { type: 'json' };
@@ -42,6 +46,17 @@ try {
4246
console.error(`Error: ${error.message}`);
4347
process.exit(1);
4448
}
49+
if (error instanceof EncryptedStorageError) {
50+
console.error(
51+
'No encryption key available.\n\n' +
52+
'Latchkey needs an encryption key to store credentials securely.\n' +
53+
'Either ensure your system keychain is accessible, or set the\n' +
54+
'LATCHKEY_ENCRYPTION_KEY environment variable. For example:\n\n' +
55+
' export LATCHKEY_ENCRYPTION_KEY="$(openssl rand -base64 32)"\n\n' +
56+
'Add this to your shell profile to persist it across sessions.'
57+
);
58+
process.exit(1);
59+
}
4560
throw error;
4661
}
4762

src/cliCommands.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ import {
2020
loadBrowserConfig,
2121
saveRegisteredService,
2222
} from './configDataStore.js';
23-
import { BrowserDisabledError } from './playwrightUtils.js';
23+
import {
24+
BrowserDisabledError,
25+
GraphicalEnvironmentNotFoundError,
26+
hasGraphicalEnvironment,
27+
} from './playwrightUtils.js';
2428
import type { CurlResult } from './curl.js';
2529
import { EncryptedStorage } from './encryptedStorage.js';
2630
import {
@@ -202,15 +206,28 @@ function checkBrowserNotDisabledOrExit(deps: CliDependencies): void {
202206
}
203207
}
204208

209+
/**
210+
* Check if a graphical environment is available.
211+
* Exits with error if no display server (X11 or Wayland) is detected on Linux.
212+
*/
213+
function checkGraphicalEnvironmentOrExit(deps: CliDependencies): void {
214+
if (!hasGraphicalEnvironment()) {
215+
deps.errorLog(new GraphicalEnvironmentNotFoundError().message);
216+
deps.exit(1);
217+
}
218+
}
219+
205220
/**
206221
* Get the browser launch options from configuration, handling errors with CLI output.
207-
* Exits with error if no valid browser config exists or if browser is disabled.
222+
* Exits with error if no valid browser config exists, if browser is disabled,
223+
* or if no graphical environment is available.
208224
*/
209225
function getBrowserLaunchOptionsOrExit(deps: CliDependencies): {
210226
browserStatePath: string;
211227
executablePath: string;
212228
} {
213229
checkBrowserNotDisabledOrExit(deps);
230+
checkGraphicalEnvironmentOrExit(deps);
214231

215232
const browserConfig = loadBrowserConfig(deps.config.configPath);
216233
if (!browserConfig) {

src/playwrightUtils.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,33 @@ export class BrowserDisabledError extends Error {
99
}
1010
}
1111

12+
export class GraphicalEnvironmentNotFoundError extends Error {
13+
constructor() {
14+
super(
15+
'No graphical environment detected (neither DISPLAY nor WAYLAND_DISPLAY is set). ' +
16+
'Browser-based authentication requires a graphical environment.'
17+
);
18+
this.name = 'GraphicalEnvironmentNotFoundError';
19+
}
20+
}
21+
22+
/**
23+
* Check whether a graphical environment is available.
24+
* On Linux, this requires DISPLAY or WAYLAND_DISPLAY to be set.
25+
* On other platforms (macOS, Windows), a display is assumed to be available.
26+
*/
27+
export function hasGraphicalEnvironment(): boolean {
28+
if (process.platform !== 'linux') {
29+
return true;
30+
}
31+
const display = process.env.DISPLAY;
32+
const waylandDisplay = process.env.WAYLAND_DISPLAY;
33+
return (
34+
(display !== undefined && display !== '') ||
35+
(waylandDisplay !== undefined && waylandDisplay !== '')
36+
);
37+
}
38+
1239
export class BrowserFlowsNotSupportedError extends Error {
1340
constructor(serviceName: string, authSubcommand: 'set' | 'set-nocurl' = 'set') {
1441
super(

tests/cli.test.ts

Lines changed: 161 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { execSync, ExecSyncOptionsWithStringEncoding } from 'node:child_process'
66
import { Command } from 'commander';
77
import { registerCommands, type CliDependencies } from '../src/cliCommands.js';
88
import { extractUrlFromCurlArguments } from '../src/curl.js';
9+
import { hasGraphicalEnvironment } from '../src/playwrightUtils.js';
910
import { EncryptedStorage } from '../src/encryptedStorage.js';
1011
import { Config } from '../src/config.js';
1112
import { Registry } from '../src/registry.js';
@@ -77,12 +78,20 @@ interface TestEnv {
7778
LATCHKEY_DISABLE_BROWSER?: string;
7879
}
7980

80-
function runCli(args: string[], env: TestEnv): CliResult {
81-
const options: ExecSyncOptionsWithStringEncoding = {
81+
interface RunCliOptions {
82+
removeEnvVars?: string[];
83+
}
84+
85+
function runCli(args: string[], env: TestEnv, options?: RunCliOptions): CliResult {
86+
const keysToRemove = new Set(options?.removeEnvVars ?? []);
87+
const baseEnv = Object.fromEntries(
88+
Object.entries(process.env).filter(([key]) => !keysToRemove.has(key))
89+
);
90+
const execOptions: ExecSyncOptionsWithStringEncoding = {
8291
cwd: join(__dirname, '..'),
8392
encoding: 'utf-8',
8493
env: {
85-
...process.env,
94+
...baseEnv,
8695
LATCHKEY_ENCRYPTION_KEY: TEST_ENCRYPTION_KEY,
8796
...env,
8897
},
@@ -93,7 +102,7 @@ function runCli(args: string[], env: TestEnv): CliResult {
93102
if (!cliPath) {
94103
throw new Error('CLI not built');
95104
}
96-
const stdout = execSync(`node ${cliPath} ${args.join(' ')}`, options);
105+
const stdout = execSync(`node ${cliPath} ${args.join(' ')}`, execOptions);
97106
return { exitCode: 0, stdout, stderr: '' };
98107
} catch (error) {
99108
const execError = error as ExecError;
@@ -163,6 +172,110 @@ describe('extractUrlFromCurlArguments', () => {
163172
});
164173
});
165174

175+
describe('hasGraphicalEnvironment', () => {
176+
const originalPlatform = process.platform;
177+
178+
afterEach(() => {
179+
Object.defineProperty(process, 'platform', { value: originalPlatform });
180+
});
181+
182+
it('should return true on non-linux platforms', () => {
183+
Object.defineProperty(process, 'platform', { value: 'darwin' });
184+
expect(hasGraphicalEnvironment()).toBe(true);
185+
186+
Object.defineProperty(process, 'platform', { value: 'win32' });
187+
expect(hasGraphicalEnvironment()).toBe(true);
188+
});
189+
190+
it('should return true on linux when DISPLAY is set', () => {
191+
Object.defineProperty(process, 'platform', { value: 'linux' });
192+
const originalDisplay = process.env.DISPLAY;
193+
const originalWayland = process.env.WAYLAND_DISPLAY;
194+
try {
195+
process.env.DISPLAY = ':0';
196+
delete process.env.WAYLAND_DISPLAY;
197+
expect(hasGraphicalEnvironment()).toBe(true);
198+
} finally {
199+
if (originalDisplay !== undefined) {
200+
process.env.DISPLAY = originalDisplay;
201+
} else {
202+
delete process.env.DISPLAY;
203+
}
204+
if (originalWayland !== undefined) {
205+
process.env.WAYLAND_DISPLAY = originalWayland;
206+
} else {
207+
delete process.env.WAYLAND_DISPLAY;
208+
}
209+
}
210+
});
211+
212+
it('should return true on linux when WAYLAND_DISPLAY is set', () => {
213+
Object.defineProperty(process, 'platform', { value: 'linux' });
214+
const originalDisplay = process.env.DISPLAY;
215+
const originalWayland = process.env.WAYLAND_DISPLAY;
216+
try {
217+
delete process.env.DISPLAY;
218+
process.env.WAYLAND_DISPLAY = 'wayland-0';
219+
expect(hasGraphicalEnvironment()).toBe(true);
220+
} finally {
221+
if (originalDisplay !== undefined) {
222+
process.env.DISPLAY = originalDisplay;
223+
} else {
224+
delete process.env.DISPLAY;
225+
}
226+
if (originalWayland !== undefined) {
227+
process.env.WAYLAND_DISPLAY = originalWayland;
228+
} else {
229+
delete process.env.WAYLAND_DISPLAY;
230+
}
231+
}
232+
});
233+
234+
it('should return false on linux when neither DISPLAY nor WAYLAND_DISPLAY is set', () => {
235+
Object.defineProperty(process, 'platform', { value: 'linux' });
236+
const originalDisplay = process.env.DISPLAY;
237+
const originalWayland = process.env.WAYLAND_DISPLAY;
238+
try {
239+
delete process.env.DISPLAY;
240+
delete process.env.WAYLAND_DISPLAY;
241+
expect(hasGraphicalEnvironment()).toBe(false);
242+
} finally {
243+
if (originalDisplay !== undefined) {
244+
process.env.DISPLAY = originalDisplay;
245+
} else {
246+
delete process.env.DISPLAY;
247+
}
248+
if (originalWayland !== undefined) {
249+
process.env.WAYLAND_DISPLAY = originalWayland;
250+
} else {
251+
delete process.env.WAYLAND_DISPLAY;
252+
}
253+
}
254+
});
255+
256+
it('should return false on linux when DISPLAY is empty string', () => {
257+
Object.defineProperty(process, 'platform', { value: 'linux' });
258+
const originalDisplay = process.env.DISPLAY;
259+
const originalWayland = process.env.WAYLAND_DISPLAY;
260+
try {
261+
process.env.DISPLAY = '';
262+
delete process.env.WAYLAND_DISPLAY;
263+
expect(hasGraphicalEnvironment()).toBe(false);
264+
} finally {
265+
if (originalDisplay !== undefined) {
266+
process.env.DISPLAY = originalDisplay;
267+
} else {
268+
delete process.env.DISPLAY;
269+
}
270+
if (originalWayland !== undefined) {
271+
process.env.WAYLAND_DISPLAY = originalWayland;
272+
} else {
273+
delete process.env.WAYLAND_DISPLAY;
274+
}
275+
}
276+
});
277+
});
278+
166279
describe('CLI commands with dependency injection', () => {
167280
let tempDir: string;
168281
let capturedArgs: string[];
@@ -1002,6 +1115,38 @@ describe('CLI commands with dependency injection', () => {
10021115
expect(exitCode).toBe(1);
10031116
});
10041117

1118+
it('should return error when no graphical environment is available', async () => {
1119+
const storePath = join(tempDir, 'credentials.json');
1120+
writeSecureFile(storePath, '{}');
1121+
1122+
const originalPlatform = process.platform;
1123+
const originalDisplay = process.env.DISPLAY;
1124+
const originalWayland = process.env.WAYLAND_DISPLAY;
1125+
try {
1126+
Object.defineProperty(process, 'platform', { value: 'linux' });
1127+
delete process.env.DISPLAY;
1128+
delete process.env.WAYLAND_DISPLAY;
1129+
1130+
const deps = createMockDependencies();
1131+
await runCommand(['auth', 'browser', 'slack'], deps);
1132+
1133+
expect(exitCode).toBe(1);
1134+
expect(errorLogs[0]).toContain('No graphical environment detected');
1135+
} finally {
1136+
Object.defineProperty(process, 'platform', { value: originalPlatform });
1137+
if (originalDisplay !== undefined) {
1138+
process.env.DISPLAY = originalDisplay;
1139+
} else {
1140+
delete process.env.DISPLAY;
1141+
}
1142+
if (originalWayland !== undefined) {
1143+
process.env.WAYLAND_DISPLAY = originalWayland;
1144+
} else {
1145+
delete process.env.WAYLAND_DISPLAY;
1146+
}
1147+
}
1148+
});
1149+
10051150
it('should suggest set-nocurl when service supports nocurl credentials', async () => {
10061151
const nocurlService: Service = {
10071152
name: 'nocurl-only',
@@ -1845,6 +1990,18 @@ describe.skipIf(!cliPath)('CLI integration tests (subprocess)', () => {
18451990
expect(result.exitCode).toBe(1);
18461991
});
18471992

1993+
it('should return error for auth browser when no graphical environment is available on linux', () => {
1994+
// This test only makes sense on Linux where the check is active
1995+
if (process.platform !== 'linux') {
1996+
return;
1997+
}
1998+
const result = runCli(['auth', 'browser', 'slack'], testEnv, {
1999+
removeEnvVars: ['DISPLAY', 'WAYLAND_DISPLAY'],
2000+
});
2001+
expect(result.exitCode).toBe(1);
2002+
expect(result.stderr).toContain('No graphical environment detected');
2003+
});
2004+
18482005
it('should list services as JSON', () => {
18492006
const result = runCli(['services', 'list'], testEnv);
18502007
expect(result.exitCode).toBe(0);

0 commit comments

Comments
 (0)