Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ Typescript style guide:
- Use immutable data structures as much as possible.
- Do not use abbreviations in variable (class, function, ...) names. It's fine for names to be somewhat verbose.
- Omit docstrings if they don't add any value beyond what can be obviously inferred from the function signature / class name.
- Avoid locale dependent selectors in Playwright code.
- Do not throw builtin errors; always replace them with dedicated error subclasses.
- When done, validate your changes by running `npm lint` and `npm test`.
6 changes: 6 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,9 @@ use Playwright's codegen functionality, for example:
```
npx playwright codegen --target=javascript https://login-page.example.com/
```


## Style guidelines

- Try to make new code look as similar to existing code as possible.
- See CLAUDE.md for additional details.
11 changes: 11 additions & 0 deletions src/playwrightUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Playwright utility functions for browser automation.
*/

import { randomUUID } from 'node:crypto';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
Expand All @@ -21,6 +22,16 @@ export interface BrowserLaunchOptions {
browserStatePath?: string;
}

/**
* Generate a random Latchkey-prefixed app name.
* Used for creating unique names when registering API keys, apps, or tokens.
*/
export function generateLatchkeyAppName(): string {
const date = new Date().toISOString().slice(0, 10);
const randomSuffix = randomUUID().slice(0, 4);
return `Latchkey-${date}-${randomSuffix}`;
}

/**
* Run a callback with a browser context initialized from encrypted storage state.
* After the callback completes, persists browser state back to encrypted storage.
Expand Down
7 changes: 3 additions & 4 deletions src/services/dropbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@
* Dropbox service implementation.
*/

import { randomUUID } from 'node:crypto';
import type { Response, BrowserContext } from 'playwright';
import { ApiCredentialStatus, ApiCredentials, AuthorizationBearer } from '../apiCredentials.js';
import { runCaptured } from '../curl.js';
import { typeLikeHuman } from '../playwrightUtils.js';
import { generateLatchkeyAppName, typeLikeHuman } from '../playwrightUtils.js';
import { Service, BrowserFollowupServiceSession, LoginFailedError } from './base.js';

const DEFAULT_TIMEOUT_MS = 8000;
Expand Down Expand Up @@ -60,12 +59,12 @@ class DropboxServiceSession extends BrowserFollowupServiceSession {
await fullPermissionsInput.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
await fullPermissionsInput.click();

const appName = `Latchkey-${randomUUID().slice(0, 8)}`;
const appName = generateLatchkeyAppName();
const appNameInput = page.locator('input#app-name');
await appNameInput.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
await typeLikeHuman(page, appNameInput, appName);

const createButton = page.getByRole('button', { name: 'Create app' });
const createButton = page.locator('//*[@id="create-button" and not(@disabled)]');
await createButton.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
await createButton.click();

Expand Down
23 changes: 14 additions & 9 deletions src/services/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@
* GitHub service implementation.
*/

import { randomUUID } from 'node:crypto';
import type { Response, BrowserContext } from 'playwright';
import { ApiCredentialStatus, ApiCredentials, AuthorizationBearer } from '../apiCredentials.js';
import { runCaptured } from '../curl.js';
import { typeLikeHuman } from '../playwrightUtils.js';
import { generateLatchkeyAppName, typeLikeHuman } from '../playwrightUtils.js';
import { Service, BrowserFollowupServiceSession, LoginFailedError } from './base.js';

const DEFAULT_TIMEOUT_MS = 8000;
Expand Down Expand Up @@ -46,11 +45,18 @@ class GithubServiceSession extends BrowserFollowupServiceSession {

const request = response.request();
// Detect login (and github's sudo) by seeing if github allows us to access the new token page.
if (request.url() === GITHUB_NEW_TOKEN_URL) {
if (response.status() === 200) {
if (request.url() != GITHUB_NEW_TOKEN_URL) {
return;
}
if (response.status() != 200) {
return;
}
// Make sure the content returned is actually the correct page, not just the sudo page.
void response.text().then((text) => {
if (text.includes('<p id="settings_user_tokens_note">')) {
this.isLoggedIn = true;
}
}
});
}

protected isLoginComplete(): boolean {
Expand All @@ -68,7 +74,7 @@ class GithubServiceSession extends BrowserFollowupServiceSession {
// Add a note for the token
const noteInput = page.locator('//*[@id="oauth_access_description"]');
await noteInput.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
await typeLikeHuman(page, noteInput, `Latchkey-${randomUUID().slice(0, 8)}`);
await typeLikeHuman(page, noteInput, generateLatchkeyAppName());

// Enable all necessary scopes
for (const scope of GITHUB_TOKEN_SCOPES) {
Expand All @@ -79,9 +85,8 @@ class GithubServiceSession extends BrowserFollowupServiceSession {
}

// Click the Generate Token button
const generateButton = page.locator(
'button[type="submit"].btn-primary:has-text("Generate token")'
);
// Get me button with type="submit" that's somewhere under a form with id="new_oauth_access".
const generateButton = page.locator('form#new_oauth_access button[type="submit"]');
await generateButton.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
await generateButton.click();

Expand Down
9 changes: 4 additions & 5 deletions src/services/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@
* Linear service implementation.
*/

import { randomUUID } from 'node:crypto';
import type { Response, BrowserContext } from 'playwright';
import { ApiCredentialStatus, ApiCredentials, AuthorizationBare } from '../apiCredentials.js';
import { runCaptured } from '../curl.js';
import { typeLikeHuman } from '../playwrightUtils.js';
import { generateLatchkeyAppName, typeLikeHuman } from '../playwrightUtils.js';
import { Service, BrowserFollowupServiceSession, LoginFailedError } from './base.js';

const DEFAULT_TIMEOUT_MS = 8000;
Expand Down Expand Up @@ -61,13 +60,13 @@ class LinearServiceSession extends BrowserFollowupServiceSession {
await page.goto(LINEAR_NEW_API_KEY_URL);

// Fill in the key name
const keyName = `Latchkey-${randomUUID().slice(0, 8)}`;
const keyNameInput = page.getByRole('textbox', { name: 'Key name' });
const keyName = generateLatchkeyAppName();
const keyNameInput = page.locator('//*[@id="label"]');
await keyNameInput.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
await typeLikeHuman(page, keyNameInput, keyName);

// Click the Create button
const createButton = page.getByRole('button', { name: 'Create' });
const createButton = page.locator('button[type="submit"]');
await createButton.waitFor({ timeout: DEFAULT_TIMEOUT_MS });
await createButton.click();

Expand Down