diff --git a/.changeset/npm-credentials-cli.md b/.changeset/npm-credentials-cli.md new file mode 100644 index 0000000..b08d6e2 --- /dev/null +++ b/.changeset/npm-credentials-cli.md @@ -0,0 +1,11 @@ +--- +"@thatopen/services": minor +--- + +CLI: auto-configure `.npmrc` for private beta packages. + +`thatopen create --beta` (and `thatopen login` inside a beta project) now fetch +read-only npm credentials from the platform and write a project `.npmrc`, so +`npm install` of the private `@thatopen-platform/*-beta` packages just works for +Founding members — no manual token setup. Adds +`EngineServicesClient.getNpmCredentials()` and exports the `NpmCredentials` type. diff --git a/README.md b/README.md index c71e15e..47875d8 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,19 @@ To use beta engine libraries instead of the stable ones, see: Once scaffolded, open `AGENTS.md` in the scaffolded project root — it has everything needed to start building. +## Beta engine libraries (Founding Members) + +Founding Members get early access to the private beta engine libraries (`@thatopen-platform/*-beta`). The CLI configures access automatically — no npm account or manual token needed. + +```bash +thatopen login --token # API token from the dashboard → Data → API Tokens +thatopen create my-app --beta # new project on the beta libraries +# or, in an existing project: +thatopen swap --beta # toggle the current project to beta +``` + +On `--beta`, the CLI fetches your read-only beta npm credentials and writes them to the project's `.npmrc`, so `npm install` resolves the private packages. The `.npmrc` is gitignored — it carries a credential, so don't commit or share it. Access is tied to your membership; non-Founding accounts get a clear message and the project is still created. + ## What's in this repository - **Library** — `EngineServicesClient` and `PlatformClient` for interacting with the That Open API (files, folders, apps, cloud components, executions, permissions). diff --git a/src/cli/commands/create.ts b/src/cli/commands/create.ts index 7464aa3..9dccb64 100644 --- a/src/cli/commands/create.ts +++ b/src/cli/commands/create.ts @@ -4,6 +4,7 @@ import { basename, join, resolve } from 'node:path'; import { execSync } from 'node:child_process'; import { updateLocalConfig } from '../lib/config'; import { BETA_ALIASES } from '../lib/beta'; +import { configureBetaNpmrc } from '../lib/npmrc'; const TEMPLATES = ['app', 'cloud-component'] as const; type Template = (typeof TEMPLATES)[number]; @@ -93,12 +94,14 @@ export const createCommand = new Command('create') updateLocalConfig({ beta: true }, targetDir); } + // ── Beta: authenticate private installs via .npmrc ─────────── + if (opts.beta) { + await configureBetaNpmrc(targetDir); + } + // Install dependencies automatically console.log(''); console.log('Installing dependencies...'); - if (opts.beta) { - console.log('(Beta packages are private — if this fails with 401/403, configure your beta npm token.)'); - } try { execSync('npm install', { cwd: targetDir, stdio: 'inherit' }); } catch { diff --git a/src/cli/commands/login.ts b/src/cli/commands/login.ts index d666110..6f5f81d 100644 --- a/src/cli/commands/login.ts +++ b/src/cli/commands/login.ts @@ -1,6 +1,7 @@ import { Command } from 'commander'; -import { writeConfig, updateLocalConfig } from '../lib/config'; +import { writeConfig, updateLocalConfig, readLocalConfig } from '../lib/config'; import { EngineServicesClient } from '../../core/client'; +import { setupNpmrc } from '../lib/npmrc'; export const loginCommand = new Command('login') .description('Authenticate with the ThatOpen platform') @@ -37,8 +38,9 @@ export const loginCommand = new Command('login') console.log('Validating token...'); + const client = new EngineServicesClient(opts.token, apiUrl); + try { - const client = new EngineServicesClient(opts.token, apiUrl); await client.listApps(); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); @@ -56,4 +58,13 @@ export const loginCommand = new Command('login') 'Logged in successfully. Config saved to ~/.thatopen/config.json', ); } + + // In a beta project, refresh .npmrc so a rotated Founders token propagates + // on the next login. Best-effort — never blocks login. + if (readLocalConfig()?.beta) { + const result = await setupNpmrc(client, process.cwd()); + if (result.status === 'written') { + console.log(`Beta access refreshed — updated .npmrc for ${result.scope}.`); + } + } }); diff --git a/src/cli/commands/swap.ts b/src/cli/commands/swap.ts index 10abf48..eee92ee 100644 --- a/src/cli/commands/swap.ts +++ b/src/cli/commands/swap.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { execSync } from 'node:child_process'; import { readLocalConfig, updateLocalConfig } from '../lib/config'; import { BETA_ALIASES } from '../lib/beta'; +import { configureBetaNpmrc } from '../lib/npmrc'; export const swapCommand = new Command('swap') .description('Toggle between stable public and beta engine libraries') @@ -63,10 +64,10 @@ export const swapCommand = new Command('swap') updateLocalConfig({ beta: targetBeta }, cwd); console.log(`Switched to ${targetBeta ? 'beta' : 'stable'} libraries.`); + + // Beta packages are private — write an authenticated .npmrc before install. if (targetBeta) { - console.log( - 'Beta packages are private — make sure your beta npm token is configured.', - ); + await configureBetaNpmrc(cwd); } console.log(''); diff --git a/src/cli/lib/npmrc.test.ts b/src/cli/lib/npmrc.test.ts new file mode 100644 index 0000000..5bcfbe9 --- /dev/null +++ b/src/cli/lib/npmrc.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + mkdtempSync, + rmSync, + existsSync, + readFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { setupNpmrc } from './npmrc'; +import { RequestError } from '../../core/request-error'; +import type { EngineServicesClient } from '../../core/client'; + +function fakeClient(getNpmCredentials: () => Promise): EngineServicesClient { + return { getNpmCredentials } as unknown as EngineServicesClient; +} + +describe('setupNpmrc', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'npmrc-test-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('writes .npmrc and returns written on success', async () => { + const npmrc = + '@thatopen-platform:registry=https://registry.npmjs.org/\n' + + '//registry.npmjs.org/:_authToken=npm_ro\n'; + const client = fakeClient(async () => ({ + registry: 'https://registry.npmjs.org/', + scope: '@thatopen-platform', + token: 'npm_ro', + npmrc, + })); + + const result = await setupNpmrc(client, dir); + + expect(result).toEqual({ status: 'written', scope: '@thatopen-platform' }); + expect(readFileSync(join(dir, '.npmrc'), 'utf-8')).toBe(npmrc); + }); + + it('returns forbidden and writes nothing on a 403', async () => { + const client = fakeClient(async () => { + throw new RequestError( + 403, + 'Forbidden', + JSON.stringify({ message: 'Community membership required' }), + ); + }); + + const result = await setupNpmrc(client, dir); + + expect(result).toEqual({ status: 'forbidden' }); + expect(existsSync(join(dir, '.npmrc'))).toBe(false); + }); + + it('returns error (and writes nothing) on any other failure', async () => { + const client = fakeClient(async () => { + throw new Error('network down'); + }); + + const result = await setupNpmrc(client, dir); + + expect(result.status).toBe('error'); + expect(existsSync(join(dir, '.npmrc'))).toBe(false); + }); +}); diff --git a/src/cli/lib/npmrc.ts b/src/cli/lib/npmrc.ts new file mode 100644 index 0000000..acfc075 --- /dev/null +++ b/src/cli/lib/npmrc.ts @@ -0,0 +1,70 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { EngineServicesClient } from '../../core/client'; +import { RequestError } from '../../core/request-error'; +import { resolveConfig } from './config'; + +export type NpmrcResult = + | { status: 'written'; scope: string } + | { status: 'forbidden' } + | { status: 'error'; message: string }; + +/** + * Fetches the Founders npm credentials and writes them to `/.npmrc`, so + * `npm install` can resolve the private `@thatopen-platform` beta packages. + * + * Best-effort by design — it never throws, so scaffolding and login keep + * flowing: + * - `forbidden`: the account isn't a FOUNDING member (backend 403); no file. + * - `error`: any other failure (network, misconfig); no file. + * - `written`: `.npmrc` created (mode 0600, it carries a credential). + */ +export async function setupNpmrc( + client: EngineServicesClient, + dir: string, +): Promise { + try { + const creds = await client.getNpmCredentials(); + writeFileSync(join(dir, '.npmrc'), creds.npmrc, { mode: 0o600 }); + return { status: 'written', scope: creds.scope }; + } catch (err) { + if (err instanceof RequestError && err.status === 403) { + return { status: 'forbidden' }; + } + const message = err instanceof Error ? err.message : String(err); + return { status: 'error', message }; + } +} + +/** + * CLI glue for the `--beta` flows (`create` and `swap`): resolves the logged-in + * config, writes an authenticated `.npmrc` into `dir`, and prints a + * human-readable status. Best-effort — never throws, so the install still runs. + */ +export async function configureBetaNpmrc(dir: string): Promise { + const config = resolveConfig(dir); + if (!config) { + console.log( + ' Beta libraries are private. Run `thatopen login --token `,', + ); + console.log( + ' then `npm install`, or add your beta npm token to .npmrc manually.', + ); + return; + } + const client = new EngineServicesClient(config.accessToken, config.apiUrl); + const result = await setupNpmrc(client, dir); + if (result.status === 'written') { + console.log(` Beta access configured — wrote .npmrc for ${result.scope}.`); + } else if (result.status === 'forbidden') { + console.log( + ' Your account is not a Founding member — beta libraries need Founding', + ); + console.log(' access, so the install will fail until you have it.'); + } else { + console.log( + ` Could not fetch beta npm credentials (${result.message}). Set your`, + ); + console.log(' token in .npmrc manually if the install fails.'); + } +} diff --git a/src/cli/templates/shared/_gitignore b/src/cli/templates/shared/_gitignore index 0b7ad7d..ffe3509 100644 --- a/src/cli/templates/shared/_gitignore +++ b/src/cli/templates/shared/_gitignore @@ -2,3 +2,4 @@ node_modules dist *.zip .thatopen +.npmrc diff --git a/src/core/client.test.ts b/src/core/client.test.ts index d228e1e..b6f0c18 100644 --- a/src/core/client.test.ts +++ b/src/core/client.test.ts @@ -407,4 +407,34 @@ describe('EngineServicesClient — HTTP contract', () => { ); }); }); + + describe('getNpmCredentials', () => { + it('GETs /api/npm-registry/credentials with the access token', async () => { + fetchMock.mockResolvedValue( + okResponse({ + registry: 'https://registry.npmjs.org/', + scope: '@thatopen-platform', + token: 'npm_ro', + npmrc: '@thatopen-platform:registry=https://registry.npmjs.org/\n', + }), + ); + const client = new EngineServicesClient(TOKEN, API); + const creds = await client.getNpmCredentials(); + const { url } = getCall(fetchMock); + const { pathname, params } = parseUrl(url); + expect(pathname).toBe('/api/npm-registry/credentials'); + expect(params.get('accessToken')).toBe(TOKEN); + expect(creds.scope).toBe('@thatopen-platform'); + }); + + it('throws a RequestError with status 403 for non-Founding accounts', async () => { + fetchMock.mockResolvedValue( + errorResponse(403, 'Community membership required'), + ); + const client = new EngineServicesClient(TOKEN, API); + await expect(client.getNpmCredentials()).rejects.toMatchObject({ + status: 403, + }); + }); + }); }); diff --git a/src/core/client.ts b/src/core/client.ts index 277ca12..9ea6e27 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -22,6 +22,7 @@ import { Metadata, } from '../types/files'; import { ThatOpenContext } from '../types/context'; +import { NpmCredentials } from '../types/npm'; import { RequestError } from './request-error'; declare global { @@ -34,6 +35,7 @@ declare global { const FOLDER_PATH = 'item/folder'; const ITEM_PATH = 'item'; const PROCESS_PATH = 'processor'; +const NPM_REGISTRY_PATH = 'npm-registry'; const HIDDEN_PATH = 'hidden'; const ITEM_TYPE_FILE = 'FILE'; const ITEM_TYPE_COMPONENT = 'TOOL'; @@ -1011,6 +1013,23 @@ export class EngineServicesClient { return await this.#requestApi('DELETE', `${ITEM_PATH}/${appId}`); } + // ─── NPM Registry ──────────────────────────────────────────────── + + /** + * Fetches read-only npm credentials for the private `@thatopen` Founders + * beta packages. Gated server-side on the token owner's membership tier: + * FOUNDING members (and admins) get the token; everyone else gets a 403 + * (`RequestError` with `status === 403`). The returned `npmrc` is a + * ready-to-write `.npmrc` body the CLI drops into a scaffolded project. + * @returns The registry, scope, read-only token, and assembled `.npmrc`. + */ + async getNpmCredentials() { + return await this.#requestApi( + 'GET', + `${NPM_REGISTRY_PATH}/credentials`, + ); + } + // ─── Execution ─────────────────────────────────────────────────── /** diff --git a/src/index.ts b/src/index.ts index 3e76f8e..ef82edf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,4 +9,5 @@ export * from './types/response'; export * from './types/item.dto'; export * from './types/projects'; export * from './types/context'; +export * from './types/npm'; export * from './built-in'; diff --git a/src/types/npm.ts b/src/types/npm.ts new file mode 100644 index 0000000..e3ab1d7 --- /dev/null +++ b/src/types/npm.ts @@ -0,0 +1,14 @@ +/** + * Read-only npm credentials for the private `@thatopen` Founders beta packages. + * Returned by `GET /api/npm-registry/credentials` to FOUNDING members only. + */ +export interface NpmCredentials { + /** Registry the scope is pinned to (e.g. `https://registry.npmjs.org/`). */ + registry: string; + /** Package scope the token grants read access to (e.g. `@thatopen`). */ + scope: string; + /** The read-only npm token. */ + token: string; + /** Ready-to-write `.npmrc` file body (scope pin + auth line). */ + npmrc: string; +}