From 7ddbc1905aad3401ce7e79b5fa6a25d003a0fe8f Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 30 Jul 2026 16:21:34 -0700 Subject: [PATCH 1/2] [CLI] Auth token per host --- client/packages/cli/__tests__/auth.test.ts | 128 ++++++++++++++++++ client/packages/cli/__tests__/config.test.ts | 47 +++++++ client/packages/cli/package.json | 6 + client/packages/cli/src/auth/index.ts | 125 +++++++++++++++++ client/packages/cli/src/commands/login.ts | 4 +- client/packages/cli/src/commands/logout.ts | 35 +++-- client/packages/cli/src/context/authToken.ts | 27 +--- client/packages/cli/src/lib/config.ts | 68 ++++++++++ client/packages/cli/src/lib/http.ts | 72 +--------- client/packages/cli/src/lib/login.ts | 16 +-- client/packages/cli/src/old.js | 16 +-- client/packages/cli/src/util/getAuthPaths.ts | 12 -- .../packages/create-instant-app/package.json | 1 - .../packages/create-instant-app/src/login.ts | 31 +---- .../src/utils/fetch.test.ts | 33 +++++ .../create-instant-app/src/utils/fetch.ts | 6 +- client/pnpm-lock.yaml | 3 - 17 files changed, 453 insertions(+), 177 deletions(-) create mode 100644 client/packages/cli/__tests__/auth.test.ts create mode 100644 client/packages/cli/__tests__/config.test.ts create mode 100644 client/packages/cli/src/auth/index.ts create mode 100644 client/packages/cli/src/lib/config.ts delete mode 100644 client/packages/cli/src/util/getAuthPaths.ts create mode 100644 client/packages/create-instant-app/src/utils/fetch.test.ts diff --git a/client/packages/cli/__tests__/auth.test.ts b/client/packages/cli/__tests__/auth.test.ts new file mode 100644 index 0000000000..8972a611d6 --- /dev/null +++ b/client/packages/cli/__tests__/auth.test.ts @@ -0,0 +1,128 @@ +import { + mkdir, + mkdtemp, + readFile, + rm, + unlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const paths = vi.hoisted(() => ({ root: '' })); + +vi.mock('env-paths', () => ({ + default: (name: string) => ({ config: `${paths.root}/${name}` }), +})); + +import { + readAuthToken, + removeAuthToken, + writeAuthToken, +} from '../src/auth/index.ts'; + +beforeEach(async () => { + paths.root = await mkdtemp(join(tmpdir(), 'instant-cli-auth-')); +}); + +afterEach(async () => { + await rm(paths.root, { recursive: true, force: true }); +}); + +const writeLegacyAuthToken = async ( + configName: 'instantdb-prod' | 'instantdb-dev', + authToken: string, +) => { + const configDir = join(paths.root, configName); + await mkdir(configDir, { recursive: true }); + await writeFile(join(configDir, 'a'), authToken); +}; + +describe('backend-scoped auth tokens', () => { + it('treats equivalent backend URLs as the same credential scope', async () => { + await writeAuthToken( + 'https://EXAMPLE.com:443/backend/?ignored=true', + 'auth-token', + ); + + expect(await readAuthToken('https://example.com/backend')).toBe( + 'auth-token', + ); + }); + + it('rejects non-HTTP URLs', async () => { + await expect(readAuthToken('ftp://example.com')).rejects.toThrow( + 'Instant API URI must use http:// or https://', + ); + }); + + it('stores and removes credentials independently by backend', async () => { + const cloudApiURI = 'https://api.instantdb.com'; + const selfHostedApiURI = 'https://instant.example.com'; + + await writeAuthToken(cloudApiURI, 'cloud-token'); + await writeAuthToken(selfHostedApiURI, 'self-hosted-token'); + + expect(await readAuthToken(cloudApiURI)).toBe('cloud-token'); + expect(await readAuthToken(selfHostedApiURI)).toBe('self-hosted-token'); + + expect(await removeAuthToken(selfHostedApiURI)).toBe(true); + expect(await readAuthToken(selfHostedApiURI)).toBeNull(); + expect(await readAuthToken(cloudApiURI)).toBe('cloud-token'); + }); + + it('does not read or overwrite a legacy Cloud token for a custom backend', async () => { + await writeLegacyAuthToken('instantdb-prod', 'cloud-token'); + + const selfHostedApiURI = 'https://instant.example.com'; + expect(await readAuthToken(selfHostedApiURI)).toBeNull(); + + await writeAuthToken(selfHostedApiURI, 'self-hosted-token'); + expect( + await readFile(join(paths.root, 'instantdb-prod', 'a'), 'utf8'), + ).toBe('cloud-token'); + }); + + it('copies the legacy Cloud token into scoped storage', async () => { + const apiURI = 'https://api.instantdb.com'; + const legacyPath = join(paths.root, 'instantdb-prod', 'a'); + await writeLegacyAuthToken('instantdb-prod', 'cloud-token'); + + expect(await readAuthToken(apiURI)).toBe('cloud-token'); + await unlink(legacyPath); + expect(await readAuthToken(apiURI)).toBe('cloud-token'); + }); + + it('copies the legacy localhost token into scoped storage', async () => { + const apiURI = 'http://localhost:8888'; + const legacyPath = join(paths.root, 'instantdb-dev', 'a'); + await writeLegacyAuthToken('instantdb-dev', 'local-token'); + + expect(await readAuthToken(apiURI)).toBe('local-token'); + await unlink(legacyPath); + expect(await readAuthToken(apiURI)).toBe('local-token'); + }); + + it('keeps legacy clients logged in for known backends', async () => { + await writeAuthToken('https://api.instantdb.com', 'cloud-token'); + await writeAuthToken('http://localhost:8888', 'local-token'); + + expect( + await readFile(join(paths.root, 'instantdb-prod', 'a'), 'utf8'), + ).toBe('cloud-token'); + expect(await readFile(join(paths.root, 'instantdb-dev', 'a'), 'utf8')).toBe( + 'local-token', + ); + }); + + it('logs out legacy and scoped credentials for the current backend', async () => { + const apiURI = 'https://api.instantdb.com'; + await writeLegacyAuthToken('instantdb-prod', 'legacy-token'); + await writeAuthToken(apiURI, 'scoped-token'); + + expect(await removeAuthToken(apiURI)).toBe(true); + expect(await readAuthToken(apiURI)).toBeNull(); + expect(await removeAuthToken(apiURI)).toBe(false); + }); +}); diff --git a/client/packages/cli/__tests__/config.test.ts b/client/packages/cli/__tests__/config.test.ts new file mode 100644 index 0000000000..a25e935241 --- /dev/null +++ b/client/packages/cli/__tests__/config.test.ts @@ -0,0 +1,47 @@ +import { Effect } from 'effect'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + readInstantConfigFile: vi.fn(), +})); + +vi.mock('../src/util/instantConfig.ts', () => ({ + readInstantConfigFile: mocks.readInstantConfigFile, +})); + +import { getDashUrl } from '../src/lib/config.ts'; + +beforeEach(() => { + vi.stubEnv('INSTANT_CLI_API_URI', undefined); + vi.stubEnv('INSTANT_CLI_DASH_URI', undefined); + vi.stubEnv('INSTANT_CLI_DEV', undefined); + mocks.readInstantConfigFile.mockResolvedValue({}); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); +}); + +describe('dashboard URL configuration', () => { + it('reads dashURI from instant.config.ts', async () => { + mocks.readInstantConfigFile.mockResolvedValue({ + dashURI: 'https://dash.instant.example', + }); + + await expect(Effect.runPromise(getDashUrl)).resolves.toBe( + 'https://dash.instant.example', + ); + }); + + it('prefers INSTANT_CLI_DASH_URI over instant.config.ts', async () => { + vi.stubEnv('INSTANT_CLI_DASH_URI', 'https://dash.env.example'); + mocks.readInstantConfigFile.mockResolvedValue({ + dashURI: 'https://dash.config.example', + }); + + await expect(Effect.runPromise(getDashUrl)).resolves.toBe( + 'https://dash.env.example', + ); + }); +}); diff --git a/client/packages/cli/package.json b/client/packages/cli/package.json index 66b86de781..601eea07e9 100644 --- a/client/packages/cli/package.json +++ b/client/packages/cli/package.json @@ -11,6 +11,12 @@ "directory": "client/packages/cli" }, "exports": { + "./auth": { + "import": { + "types": "./dist/auth/index.d.ts", + "default": "./dist/auth/index.js" + } + }, "./ui": { "import": { "types": "./dist/ui/index.d.ts", diff --git a/client/packages/cli/src/auth/index.ts b/client/packages/cli/src/auth/index.ts new file mode 100644 index 0000000000..2e98932117 --- /dev/null +++ b/client/packages/cli/src/auth/index.ts @@ -0,0 +1,125 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import envPaths from 'env-paths'; + +const CLOUD_API_URI = 'https://api.instantdb.com'; +const LOCAL_API_URI = 'http://localhost:8888'; + +function normalizeApiURI(apiURI: string) { + const url = new URL(apiURI); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('Instant API URI must use http:// or https://'); + } + + url.search = ''; + url.hash = ''; + url.pathname = url.pathname.replace(/\/+$/, ''); + + return url.toString().replace(/\/$/, ''); +} + +function getAuthConfigFilePath(apiURI: string) { + const normalizedApiURI = normalizeApiURI(apiURI); + const backendKey = createHash('sha256') + .update(normalizedApiURI) + .digest('hex'); + const { config: configDir } = envPaths('instantdb-prod'); + + return join(configDir, 'auth', backendKey); +} + +function getLegacyAuthConfigFilePath(apiURI: string) { + const normalizedApiURI = normalizeApiURI(apiURI); + const legacyConfigName = + normalizedApiURI === CLOUD_API_URI + ? 'instantdb-prod' + : normalizedApiURI === LOCAL_API_URI + ? 'instantdb-dev' + : null; + + if (!legacyConfigName) { + return null; + } + + return join(envPaths(legacyConfigName).config, 'a'); +} + +async function readFileOrNull(filePath: string) { + try { + return (await readFile(filePath, 'utf8')).trim(); + } catch (error) { + if (isFileNotFoundError(error)) { + return null; + } + throw error; + } +} + +export async function readAuthToken(apiURI: string) { + const authConfigFilePath = getAuthConfigFilePath(apiURI); + const authToken = await readFileOrNull(authConfigFilePath); + if (authToken) { + return authToken; + } + + const legacyPath = getLegacyAuthConfigFilePath(apiURI); + if (!legacyPath) { + return null; + } + + const legacyAuthToken = await readFileOrNull(legacyPath); + if (!legacyAuthToken) { + return null; + } + + await writeAuthTokenFile(authConfigFilePath, legacyAuthToken).catch( + () => undefined, + ); + return legacyAuthToken; +} + +export async function writeAuthToken(apiURI: string, authToken: string) { + const authConfigFilePath = getAuthConfigFilePath(apiURI); + await writeAuthTokenFile(authConfigFilePath, authToken); + + const legacyPath = getLegacyAuthConfigFilePath(apiURI); + if (legacyPath) { + await writeAuthTokenFile(legacyPath, authToken).catch(() => undefined); + } +} + +async function writeAuthTokenFile(filePath: string, authToken: string) { + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, authToken, { encoding: 'utf8', mode: 0o600 }); +} + +export async function removeAuthToken(apiURI: string) { + const authConfigFilePath = getAuthConfigFilePath(apiURI); + const legacyPath = getLegacyAuthConfigFilePath(apiURI); + const paths = legacyPath + ? [authConfigFilePath, legacyPath] + : [authConfigFilePath]; + const removed = await Promise.all(paths.map(removeFileIfExists)); + return removed.some(Boolean); +} + +async function removeFileIfExists(filePath: string) { + try { + await unlink(filePath); + return true; + } catch (error) { + if (isFileNotFoundError(error)) { + return false; + } + throw error; + } +} + +function isFileNotFoundError(error: unknown): error is NodeJS.ErrnoException { + return ( + error instanceof Error && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/client/packages/cli/src/commands/login.ts b/client/packages/cli/src/commands/login.ts index d8082f12e7..4690211b24 100644 --- a/client/packages/cli/src/commands/login.ts +++ b/client/packages/cli/src/commands/login.ts @@ -3,7 +3,7 @@ import { Effect } from 'effect'; import openInBrowser from 'open'; import { loginDef } from '../index.ts'; import type { OptsFromCommand } from '../index.ts'; -import { getDashUrl } from '../lib/http.ts'; +import { getDashUrl } from '../lib/config.ts'; import { getLoginTicketAndSecret, saveConfigAuthToken, @@ -31,7 +31,7 @@ export const loginCommand = Effect.fn(function* ( const ok = yield* promptOk( { promptText: - 'This will open instantdb.com in your browser, OK to proceed?', + 'This will open your Instant dashboard in your browser, OK to proceed?', }, true, ); diff --git a/client/packages/cli/src/commands/logout.ts b/client/packages/cli/src/commands/logout.ts index 7d056c7073..8c2d0e484e 100644 --- a/client/packages/cli/src/commands/logout.ts +++ b/client/packages/cli/src/commands/logout.ts @@ -1,23 +1,22 @@ import { Effect } from 'effect'; -import { getAuthPaths } from '../util/getAuthPaths.ts'; -import { FileSystem } from '@effect/platform'; import chalk from 'chalk'; -import { SystemError } from '@effect/platform/Error'; +import { removeAuthToken } from '../auth/index.ts'; +import { getBaseUrl } from '../lib/config.ts'; export const logoutCommand = Effect.fn(function* () { - const { authConfigFilePath } = getAuthPaths(); - const fs = yield* FileSystem.FileSystem; - - yield* Effect.matchEffect(fs.remove(authConfigFilePath), { - onFailure: (e) => - Effect.gen(function* () { - if (e instanceof SystemError && e.reason === 'NotFound') { - yield* Effect.log(chalk.green('You were already logged out!')); - } else { - yield* Effect.logError(chalk.red('Failed to logout: ' + e.message)); - } - }), - onSuccess: () => - Effect.log(chalk.green('Successfully logged out from Instant!')), - }); + const apiURI = yield* getBaseUrl; + yield* Effect.tryPromise(() => removeAuthToken(apiURI)).pipe( + Effect.matchEffect({ + onFailure: (error) => + Effect.logError(chalk.red(`Failed to logout: ${error.message}`)), + onSuccess: (removed) => + Effect.log( + chalk.green( + removed + ? 'Successfully logged out from Instant!' + : 'You were already logged out!', + ), + ), + }), + ); }); diff --git a/client/packages/cli/src/context/authToken.ts b/client/packages/cli/src/context/authToken.ts index bbbd086be9..51b287087c 100644 --- a/client/packages/cli/src/context/authToken.ts +++ b/client/packages/cli/src/context/authToken.ts @@ -1,8 +1,7 @@ -import { FileSystem } from '@effect/platform'; import { Config, Context, Effect, Layer, Option, Ref, Schema } from 'effect'; -import envPaths from 'env-paths'; -import { join } from 'node:path'; +import { readAuthToken } from '../auth/index.ts'; import { loginCommand } from '../commands/login.ts'; +import { getBaseUrl } from '../lib/config.ts'; import { program } from '../program.ts'; type AuthTokenSource = 'admin' | 'env' | 'opt' | 'file'; @@ -64,14 +63,10 @@ export const authTokenGetEffect = (allowAdminToken: boolean = true) => }; } - const authPaths = yield* getAuthPaths; - const fs = yield* FileSystem.FileSystem; - const file = yield* fs - .readFileString(authPaths.authConfigFilePath, 'utf8') - .pipe( - // will usually fail if file not found, return null instead - Effect.orElseSucceed(() => null), - ); + const apiURI = yield* getBaseUrl; + const file = yield* Effect.tryPromise(() => readAuthToken(apiURI)).pipe( + Effect.orElseSucceed(() => null), + ); if (file) { return { authToken: file, @@ -137,13 +132,3 @@ export const AuthTokenLive = ({ ), ), ); - -const getAuthPaths = Effect.gen(function* () { - const dev = yield* Config.boolean('INSTANT_CLI_DEV').pipe( - Config.withDefault(false), - ); - const key = `instantdb-${dev ? 'dev' : 'prod'}`; - const { config: appConfigDirPath } = envPaths(key); - const authConfigFilePath = join(appConfigDirPath, 'a'); - return { authConfigFilePath, appConfigDirPath }; -}); diff --git a/client/packages/cli/src/lib/config.ts b/client/packages/cli/src/lib/config.ts new file mode 100644 index 0000000000..41c3d60dd7 --- /dev/null +++ b/client/packages/cli/src/lib/config.ts @@ -0,0 +1,68 @@ +import { Config, Effect, Option, Schema } from 'effect'; +import { BadArgsError } from '../errors.ts'; +import { readInstantConfigFile } from '../util/instantConfig.ts'; + +const HttpUrl = Schema.URL.pipe( + Schema.filter( + (url) => + url.protocol === 'http:' || + url.protocol === 'https:' || + 'Expected an HTTP(S) URL', + ), +); + +export const getBaseUrl = Effect.gen(function* () { + const setEnv = yield* Config.string('INSTANT_CLI_API_URI').pipe( + Config.option, + ); + const dev = yield* Config.boolean('INSTANT_CLI_DEV').pipe( + Config.withDefault(false), + ); + + if (Option.isSome(setEnv)) { + return setEnv.value; + } + + const instantConfig = yield* Effect.tryPromise(readInstantConfigFile); + if (instantConfig?.apiURI !== undefined) { + yield* Schema.decodeUnknown(HttpUrl)(instantConfig.apiURI).pipe( + Effect.mapError(() => + BadArgsError.make({ + message: + 'Invalid apiURI in instant.config.ts. Expected a valid HTTP(S) URL.', + }), + ), + ); + return instantConfig.apiURI; + } + + return dev ? 'http://localhost:8888' : 'https://api.instantdb.com'; +}); + +export const getDashUrl = Effect.gen(function* () { + const setEnv = yield* Config.string('INSTANT_CLI_DASH_URI').pipe( + Config.option, + ); + const dev = yield* Config.boolean('INSTANT_CLI_DEV').pipe( + Config.withDefault(false), + ); + + if (Option.isSome(setEnv)) { + return setEnv.value; + } + + const instantConfig = yield* Effect.tryPromise(readInstantConfigFile); + if (instantConfig?.dashURI !== undefined) { + yield* Schema.decodeUnknown(HttpUrl)(instantConfig.dashURI).pipe( + Effect.mapError(() => + BadArgsError.make({ + message: + 'Invalid dashURI in instant.config.ts. Expected a valid HTTP(S) URL.', + }), + ), + ); + return instantConfig.dashURI; + } + + return dev ? 'http://localhost:3000' : 'https://instantdb.com'; +}); diff --git a/client/packages/cli/src/lib/http.ts b/client/packages/cli/src/lib/http.ts index b19f943cf3..da492d2092 100644 --- a/client/packages/cli/src/lib/http.ts +++ b/client/packages/cli/src/lib/http.ts @@ -1,11 +1,12 @@ import { HttpClient, HttpClientRequest } from '@effect/platform'; import { version } from '@instantdb/version'; -import { Config, Context, Data, Effect, Layer, Option, Schema } from 'effect'; +import { Context, Data, Effect, Layer, Schema } from 'effect'; import { AuthToken } from '../context/authToken.ts'; import { TimeoutException } from 'effect/Cause'; import { RequestError } from '@effect/platform/HttpClientError'; -import { readInstantConfigFile } from '../util/instantConfig.ts'; -import { BadArgsError } from '../errors.ts'; +import { getBaseUrl } from './config.ts'; + +export { getBaseUrl, getDashUrl } from './config.ts'; export class InstantHttp extends Context.Tag( 'instant-cli/new/lib/http/InstantHttp', @@ -49,15 +50,6 @@ class InstantTypicalHttpErrorResponse extends Schema.Struct({ ), }) {} -const HttpUrl = Schema.URL.pipe( - Schema.filter( - (url) => - url.protocol === 'http:' || - url.protocol === 'https:' || - 'Expected an HTTP(S) URL', - ), -); - export const InstantHttpLive = Layer.effect( InstantHttp, Effect.gen(function* () { @@ -134,59 +126,3 @@ export const InstantHttpAuthedLive = Layer.effect( ); }), ); - -export const getBaseUrl = Effect.gen(function* () { - const setEnv = yield* Config.string('INSTANT_CLI_API_URI').pipe( - Config.option, - ); - const dev = yield* Config.boolean('INSTANT_CLI_DEV').pipe( - Config.withDefault(false), - ); - - if (Option.isSome(setEnv)) { - return setEnv.value; - } - - const instantConfig = yield* Effect.tryPromise(readInstantConfigFile); - if (instantConfig?.apiURI !== undefined) { - yield* Schema.decodeUnknown(HttpUrl)(instantConfig.apiURI).pipe( - Effect.mapError(() => - BadArgsError.make({ - message: - 'Invalid apiURI in instant.config.ts. Expected a valid HTTP(S) URL.', - }), - ), - ); - return instantConfig.apiURI; - } - - return dev ? 'http://localhost:8888' : 'https://api.instantdb.com'; -}); - -export const getDashUrl = Effect.gen(function* () { - const setEnv = yield* Config.string('INSTANT_CLI_DASH_URI').pipe( - Config.option, - ); - const dev = yield* Config.boolean('INSTANT_CLI_DEV').pipe( - Config.withDefault(false), - ); - - if (Option.isSome(setEnv)) { - return setEnv.value; - } - - const instantConfig = yield* Effect.tryPromise(readInstantConfigFile); - if (instantConfig?.dashURI !== undefined) { - yield* Schema.decodeUnknown(HttpUrl)(instantConfig.dashURI).pipe( - Effect.mapError(() => - BadArgsError.make({ - message: - 'Invalid dashURI in instant.config.ts. Expected a valid HTTP(S) URL.', - }), - ), - ); - return instantConfig.dashURI; - } - - return dev ? 'http://localhost:3000' : 'https://instantdb.com'; -}); diff --git a/client/packages/cli/src/lib/login.ts b/client/packages/cli/src/lib/login.ts index 6cc37407aa..5fbaeaaed2 100644 --- a/client/packages/cli/src/lib/login.ts +++ b/client/packages/cli/src/lib/login.ts @@ -1,11 +1,8 @@ import { Effect, Schedule, Schema } from 'effect'; import { InstantHttp, withCommand } from './http.ts'; -import { - HttpClientRequest, - HttpClientResponse, - FileSystem, -} from '@effect/platform'; -import { getAuthPaths } from '../util/getAuthPaths.ts'; +import { HttpClientRequest, HttpClientResponse } from '@effect/platform'; +import { writeAuthToken } from '../auth/index.ts'; +import { getBaseUrl } from './config.ts'; const LoginInfo = Schema.Struct({ secret: Schema.String, @@ -46,9 +43,6 @@ export const waitForAuthToken = Effect.fn(function* (secret: string) { }); export const saveConfigAuthToken = Effect.fn(function* (token: string) { - const authPaths = getAuthPaths(); - - const fs = yield* FileSystem.FileSystem; - yield* fs.makeDirectory(authPaths.appConfigDirPath, { recursive: true }); - yield* fs.writeFileString(authPaths.authConfigFilePath, token); + const apiURI = yield* getBaseUrl; + yield* Effect.tryPromise(() => writeAuthToken(apiURI, token)); }); diff --git a/client/packages/cli/src/old.js b/client/packages/cli/src/old.js index b18ce2baa2..df5893e019 100644 --- a/client/packages/cli/src/old.js +++ b/client/packages/cli/src/old.js @@ -1,8 +1,8 @@ import boxen from 'boxen'; import chalk from 'chalk'; import { program } from '@commander-js/extra-typings'; -import { readFile } from 'node:fs/promises'; import path from 'node:path'; +import { readAuthToken } from './auth/index.ts'; import { UI } from './ui/index.ts'; import { deferred, renderUnwrap } from './ui/lib.ts'; import { @@ -10,7 +10,6 @@ import { getPermsReadCandidates, getSchemaReadCandidates, } from './util/findConfigCandidates.ts'; -import { getAuthPaths } from './util/getAuthPaths.ts'; import { loadConfig } from './util/loadConfig.ts'; import { ResolveRenamePrompt } from './util/renamePrompt.ts'; import version from './version.js'; @@ -331,7 +330,7 @@ async function fetchJson({ let authToken = null; if (withAuth) { authToken = - providedAuthToken ?? (await readConfigAuthTokenWithErrorLogging()); + providedAuthToken ?? (await readConfigAuthTokenWithErrorLogging(apiURI)); if (!authToken) { return { ok: false, data: undefined }; } @@ -436,7 +435,7 @@ export async function readLocalEmailFile(emailPath) { return { path: relativePath, email: res.config }; } -async function readConfigAuthToken(allowAdminToken = true) { +async function readConfigAuthToken(apiURI, allowAdminToken = true) { const options = program.opts(); // @ts-expect-error command opts type is unknown if (typeof options.token === 'string') { @@ -458,10 +457,7 @@ async function readConfigAuthToken(allowAdminToken = true) { } } - const authToken = await readFile( - getAuthPaths().authConfigFilePath, - 'utf-8', - ).catch(() => null); + const authToken = await readAuthToken(apiURI).catch(() => null); if (authToken) { return authToken; @@ -470,8 +466,8 @@ async function readConfigAuthToken(allowAdminToken = true) { return null; } -export async function readConfigAuthTokenWithErrorLogging() { - const token = await readConfigAuthToken(); +export async function readConfigAuthTokenWithErrorLogging(apiURI) { + const token = await readConfigAuthToken(apiURI); if (!token) { error( `Looks like you are not logged in. Please log in with ${chalk.green('`instant-cli login`')}`, diff --git a/client/packages/cli/src/util/getAuthPaths.ts b/client/packages/cli/src/util/getAuthPaths.ts deleted file mode 100644 index 154573f938..0000000000 --- a/client/packages/cli/src/util/getAuthPaths.ts +++ /dev/null @@ -1,12 +0,0 @@ -import envPaths from 'env-paths'; -import { join } from 'node:path'; - -const dev = Boolean(process.env.INSTANT_CLI_DEV); - -export function getAuthPaths() { - const key = `instantdb-${dev ? 'dev' : 'prod'}`; - const { config: appConfigDirPath } = envPaths(key); - const authConfigFilePath = join(appConfigDirPath, 'a'); - - return { authConfigFilePath, appConfigDirPath }; -} diff --git a/client/packages/create-instant-app/package.json b/client/packages/create-instant-app/package.json index 9326279c7c..1711c4fcfd 100644 --- a/client/packages/create-instant-app/package.json +++ b/client/packages/create-instant-app/package.json @@ -40,7 +40,6 @@ "@instantdb/version": "workspace:*", "chalk": "5.2.0", "commander": "^10.0.1", - "env-paths": "^3.0.0", "execa": "^7.2.0", "fs-extra": "^11.3.1", "gradient-string": "^2.0.2", diff --git a/client/packages/create-instant-app/src/login.ts b/client/packages/create-instant-app/src/login.ts index feb15986a0..60cd20b086 100644 --- a/client/packages/create-instant-app/src/login.ts +++ b/client/packages/create-instant-app/src/login.ts @@ -1,7 +1,4 @@ -import envPaths from 'env-paths'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; import openInBrowser from 'open'; -import { join } from 'node:path'; import { Project } from './cli.js'; import { randomUUID } from 'node:crypto'; import { @@ -11,19 +8,11 @@ import { ScaffoldMetadata, } from './utils/fetch.js'; import { renderUnwrap, UI } from 'instant-cli/ui'; +import { readAuthToken, writeAuthToken } from 'instant-cli/auth'; import { toTitleCase } from './utils/titleCase.js'; -const dev = Boolean(process.env.INSTANT_CLI_DEV); const forceEphemeral = Boolean(process.env.INSTANT_CLI_FORCE_EPHEMERAL); -function getAuthPaths() { - const key = `instantdb-${dev ? 'dev' : 'prod'}`; - const { config: appConfigDirPath } = envPaths(key); - const authConfigFilePath = join(appConfigDirPath, 'a'); - - return { authConfigFilePath, appConfigDirPath }; -} - export const createApp = async ( title: string, authToken: string, @@ -173,11 +162,7 @@ const getAuthToken = async (): Promise => { return process.env.INSTANT_CLI_AUTH_TOKEN; } - const authToken = await readFile( - getAuthPaths().authConfigFilePath, - 'utf-8', - ).catch(() => null); - return authToken; + return readAuthToken(instantBackendOrigin).catch(() => null); }; export type AppTokenResponse = { @@ -449,20 +434,10 @@ async function waitForAuthToken({ if (authCheckRes.ok) { return authCheckRes.json(); } - - // if (authCheckRes.data?.hint.errors?.[0]?.issue === 'waiting-for-user') { - // continue; - // } } throw new Error('Timed out waiting for login'); } async function saveConfigAuthToken(authToken: string) { - const authPaths = getAuthPaths(); - - await mkdir(authPaths.appConfigDirPath, { - recursive: true, - }); - - return writeFile(authPaths.authConfigFilePath, authToken, 'utf-8'); + return writeAuthToken(instantBackendOrigin, authToken); } diff --git a/client/packages/create-instant-app/src/utils/fetch.test.ts b/client/packages/create-instant-app/src/utils/fetch.test.ts new file mode 100644 index 0000000000..3f958a49fd --- /dev/null +++ b/client/packages/create-instant-app/src/utils/fetch.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +describe('Instant origins', () => { + it('uses the configured self-hosted API and dashboard', async () => { + vi.stubEnv('INSTANT_CLI_API_URI', 'https://api.instant.example'); + vi.stubEnv('INSTANT_CLI_DASH_URI', 'https://dash.instant.example'); + + const { instantBackendOrigin, instantDashOrigin } = await import( + './fetch.js' + ); + + expect(instantBackendOrigin).toBe('https://api.instant.example'); + expect(instantDashOrigin).toBe('https://dash.instant.example'); + }); + + it('uses localhost defaults in CLI dev mode', async () => { + vi.stubEnv('INSTANT_CLI_API_URI', ''); + vi.stubEnv('INSTANT_CLI_DASH_URI', ''); + vi.stubEnv('INSTANT_CLI_DEV', '1'); + + const { instantBackendOrigin, instantDashOrigin } = await import( + './fetch.js' + ); + + expect(instantBackendOrigin).toBe('http://localhost:8888'); + expect(instantDashOrigin).toBe('http://localhost:3000'); + }); +}); diff --git a/client/packages/create-instant-app/src/utils/fetch.ts b/client/packages/create-instant-app/src/utils/fetch.ts index e24d6a218e..de559ceddc 100644 --- a/client/packages/create-instant-app/src/utils/fetch.ts +++ b/client/packages/create-instant-app/src/utils/fetch.ts @@ -3,9 +3,9 @@ import { version } from '@instantdb/version'; const dev = Boolean(process.env.INSTANT_CLI_DEV); -export const instantDashOrigin = dev - ? 'http://localhost:3000' - : 'https://instantdb.com'; +export const instantDashOrigin = + process.env.INSTANT_CLI_DASH_URI || + (dev ? 'http://localhost:3000' : 'https://instantdb.com'); export const instantBackendOrigin = process.env.INSTANT_CLI_API_URI || diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index 77c13944f8..98dcf19967 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -480,9 +480,6 @@ importers: commander: specifier: ^10.0.1 version: 10.0.1 - env-paths: - specifier: ^3.0.0 - version: 3.0.0 execa: specifier: ^7.2.0 version: 7.2.0 From 29c5f80a8e5d3603f236681005aa0dfd8f514075 Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Fri, 31 Jul 2026 09:10:36 -0700 Subject: [PATCH 2/2] Bump v1.0.57 --- client/packages/version/src/version.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/packages/version/src/version.ts b/client/packages/version/src/version.ts index 3e2af24054..f52f0f2e2c 100644 --- a/client/packages/version/src/version.ts +++ b/client/packages/version/src/version.ts @@ -2,6 +2,6 @@ // Update the version here and merge your code to main to // publish a new version of all of the packages to npm. -const version = 'v1.0.56'; +const version = 'v1.0.57'; export { version };