Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/browser-login.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@thatopen/services': minor
---

Sign in through the browser: `thatopen login` without a token, and `EngineServicesClient.signInWithBrowser`.
9 changes: 6 additions & 3 deletions docs/cli/login.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ description: "Authenticate with the ThatOpen platform by validating an access to

Validates an access token against the platform API and saves the credentials. Run this once globally before using any other command, or per-project with `--local`.

To get a token:
Without a token, it signs you in through your browser, as the Revit plugin does: it shows a code, opens the platform, and you confirm the code there. The session renews itself, and appears under **Connected apps** in your account, where you can disconnect it.

To use a token from the dashboard instead:
1. Go to [https://platform.thatopen.com](https://platform.thatopen.com)
2. In the header, click **Data**
3. Find the **Tokens** card and create a new token
Expand All @@ -15,17 +17,18 @@ To get a token:

**Usage:**
```bash
thatopen login --token <token> [flags]
thatopen login [flags]
```

**Flags:**

- `--token <token>` — Access token from the dashboard. Required.
- `--token <token>` — Access token from the dashboard. Without one, you sign in through your browser.
- `--api-url <url>` — Platform API URL. Default: `https://platform.thatopen.com` (production). Pass `https://dev.platform.thatopen.com` to target the dev environment.
- `--local` — Save credentials to `.thatopen` in the current directory instead of the global config. Use this inside a project to override the global token for that project only.

**Examples:**
```bash
thatopen login
thatopen login --token my-token
thatopen login --token my-token --local
npm run login -- --token my-token # from inside a scaffolded project
Expand Down
68 changes: 46 additions & 22 deletions src/cli/commands/login.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,50 @@
import { Command } from 'commander';
import { readFileSync } from 'node:fs';
import { hostname } from 'node:os';
import { join } from 'node:path';
import { signInWithBrowser } from '../../core/browser-sign-in';
import { openInBrowser } from '../lib/browser-session';
import { writeConfig, updateLocalConfig, readLocalConfig } from '../lib/config';
import { cliClient } from '../lib/cli-client';
import { setupNpmrc } from '../lib/npmrc';

const PACKAGE_VERSION: string = JSON.parse(
readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'),
).version;

const signIn = async (apiUrl: string) => {
try {
return await signInWithBrowser({
apiUrl,
application: 'That Open CLI',
machine: hostname(),
version: PACKAGE_VERSION,
onVerification: ({ url, userCode, manualUrl }) => {
console.log('');
console.log(` Your code: ${userCode}`);
console.log('');
console.log(' Confirm it in the browser that has just opened.');
console.log(` If none did, go to ${manualUrl} and enter the code.`);
console.log('');
openInBrowser(url);
},
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`Could not sign in: ${msg}`);
console.error(
'You can also log in with a token from the dashboard: thatopen login --token <token>',
);
process.exit(1);
}
};

export const loginCommand = new Command('login')
.description('Authenticate with the ThatOpen platform')
.option('--token <token>', 'Access token from the dashboard')
.option(
'--token <token>',
'Access token from the dashboard. Without one, you sign in through your browser',
)
.option(
'--api-url <url>',
'API URL (defaults to production; pass https://dev.platform.thatopen.com for the dev environment)',
Expand All @@ -16,29 +55,14 @@ export const loginCommand = new Command('login')
'Save credentials to local .thatopen file instead of global config',
)
.action(async (opts: { token?: string; apiUrl: string; local?: boolean }) => {
if (!opts.token) {
console.log('');
console.log(' To log in, you need an access token from the ThatOpen platform.');
console.log('');
console.log(' 1. Go to your ThatOpen dashboard');
console.log(' 2. Navigate to Data > API Tokens');
console.log(' 3. Create a new token and copy it');
console.log('');
console.log(' Then run:');
if (opts.local) {
console.log(' npm run login -- --token <your-token>');
} else {
console.log(' thatopen login --token <your-token>');
}
console.log('');
process.exit(1);
}

const apiUrl = opts.apiUrl.replace(/\/$/, '');

const session = opts.token ? undefined : await signIn(apiUrl);
const accessToken = opts.token ?? session!.accessToken;

console.log('Validating token...');

const client = cliClient(opts.token, apiUrl);
const client = cliClient(accessToken, apiUrl);

try {
await client.listApps();
Expand All @@ -50,10 +74,10 @@ export const loginCommand = new Command('login')
}

if (opts.local) {
updateLocalConfig({ accessToken: opts.token, apiUrl });
updateLocalConfig({ accessToken, apiUrl, session });
console.log('Logged in successfully. Config saved to .thatopen');
} else {
writeConfig({ accessToken: opts.token, apiUrl });
writeConfig({ accessToken, apiUrl, session });
console.log(
'Logged in successfully. Config saved to ~/.thatopen/config.json',
);
Expand Down
8 changes: 7 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { swapCommand } from './commands/swap';
import { revitCommand } from './commands/revit';
import { rhinoCommand } from './commands/rhino';
import { mcpCommand } from './commands/mcp';
import { renewStoredSession } from './lib/browser-session';

const pkg = JSON.parse(
readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'),
Expand Down Expand Up @@ -48,6 +49,11 @@ program
.description('CLI for ThatOpen Engine Services')
.version(pkg.version);

// The login that just asked for a new session has nothing to renew.
program.hook('preAction', async (_program, command) => {
if (command.name() !== 'login') await renewStoredSession();
});

program.addCommand(loginCommand);
program.addCommand(createCommand);
program.addCommand(publishCommand);
Expand All @@ -59,4 +65,4 @@ program.addCommand(revitCommand);
program.addCommand(rhinoCommand);
program.addCommand(mcpCommand);

program.parse(process.argv);
program.parseAsync(process.argv);
73 changes: 73 additions & 0 deletions src/cli/lib/browser-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { execFile } from 'node:child_process';
import {
refreshBrowserSession,
type BrowserSession,
} from '../../core/browser-sign-in';
import {
readConfig,
readLocalConfig,
updateLocalConfig,
writeConfig,
} from './config';

/** Renewed this long before it expires, so a command never starts on its last seconds. */
const RENEW_BEFORE_MS = 60_000;

/** Best effort: on a machine with no browser, the address is on the screen anyway. */
export function openInBrowser(url: string): void {
const [command, args] =
process.platform === 'win32'
? (['rundll32', ['url.dll,FileProtocolHandler', url]] as const)
: process.platform === 'darwin'
? (['open', [url]] as const)
: (['xdg-open', [url]] as const);
execFile(command, [...args], () => {});
}

/**
* Before a command runs, a browser sign-in whose access token is about to expire
* gets a new one, in the same config it came from. A login with `--token` has no
* session and is left alone.
*
* If the renewal fails the command goes on, and fails on its own call with the
* platform's answer, unless the session was cut: then it says to log in again.
*/
export async function renewStoredSession(cwd?: string): Promise<void> {
const local = readLocalConfig(cwd);
const stored = local?.session ?? readConfig()?.session;
if (!stored || stored.expiresAt - Date.now() > RENEW_BEFORE_MS) return;

let next: BrowserSession;
try {
next = await refreshBrowserSession(stored);
} catch (error) {
console.error(
`Could not renew your sign-in: ${error instanceof Error ? error.message : String(error)}`,
);
console.error('If this keeps happening, run `thatopen login` again.');
return;
}

storeSession(next, cwd);
}

/** The stored session this token belongs to, if it came from a browser sign-in. */
export function storedSessionFor(
accessToken: string,
cwd?: string,
): BrowserSession | undefined {
return [readLocalConfig(cwd)?.session, readConfig()?.session].find(
(session) => session?.accessToken === accessToken,
);
}

/** Puts a renewed session back where it came from: its refresh token has rotated. */
export function storeSession(session: BrowserSession, cwd?: string): void {
const fields = {
accessToken: session.accessToken,
apiUrl: session.apiUrl,
session,
};
if (readLocalConfig(cwd)?.session) updateLocalConfig(fields, cwd);
else writeConfig(fields);
}
9 changes: 9 additions & 0 deletions src/cli/lib/cli-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EngineServicesClient } from '../../core/client';
import { storedSessionFor, storeSession } from './browser-session';

/**
* The one way the CLI builds a client.
Expand All @@ -11,5 +12,13 @@ import { EngineServicesClient } from '../../core/client';
* case upgrading the platform comes first, not flipping this off.
*/
export function cliClient(accessToken: string, apiUrl: string) {
/* A browser sign-in's token lasts minutes, and `serve` and `mcp` run for hours.
So it renews itself here, and what was renewed is stored for the next command. */
const session = storedSessionFor(accessToken);
if (session) {
return EngineServicesClient.fromBrowserSession(session, {
onSession: storeSession,
});
}
return new EngineServicesClient(accessToken, apiUrl, { useBearer: true });
}
5 changes: 5 additions & 0 deletions src/cli/lib/config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import type { BrowserSession } from '../../core/browser-sign-in';

export interface ThatOpenConfig {
accessToken: string;
apiUrl: string;
/** Present after a login through the browser: what renews `accessToken`. */
session?: BrowserSession;
}

const CONFIG_DIR = join(homedir(), '.thatopen');
Expand Down Expand Up @@ -45,6 +48,8 @@ export type ProjectType = 'APP' | 'COMPONENT';
export interface ThatOpenLocalConfig {
accessToken: string;
apiUrl: string;
/** Present after a login through the browser: what renews `accessToken`. */
session?: BrowserSession;
appId?: string;
componentId?: string;
/** Project type marker set by `thatopen create`. */
Expand Down
Loading
Loading