Skip to content

Commit 30a9034

Browse files
authored
feat: auto-configure .npmrc for private beta packages (#19)
* feat: auto-configure .npmrc for private beta packages The thatopen CLI now sets up authenticated access to the private @thatopen-platform/*-beta packages instead of asking users to configure an npm token by hand: - EngineServicesClient.getNpmCredentials() fetches the read-only Founders token from GET /api/npm-registry/credentials (gated to FOUNDING members). - setupNpmrc() writes the returned .npmrc; create --beta runs it before npm install, and login refreshes it inside a beta project (token rotation). - .npmrc added to the scaffold .gitignore (it carries a credential). Non-Founding accounts get a clear message and the scaffold still completes. * feat: configure beta .npmrc on swap too + README - swap --beta now writes an authenticated .npmrc before install (was only printing a manual-token hint), matching create --beta. - Extract the shared resolve-config + write-.npmrc glue into configureBetaNpmrc(); create and swap both use it. - Document beta libraries in the README. * fix: ensure .npmrc is gitignored before writing the token Addresses Sergio's review on #19: create covers .npmrc via the scaffold template, but swap/login write into existing projects whose .gitignore we don't own — risking a committed credential. ensureNpmrcIgnored() now adds .npmrc to the project .gitignore (creating it if absent) at the single write chokepoint, so all three flows are covered.
1 parent cd5cd75 commit 30a9034

12 files changed

Lines changed: 325 additions & 8 deletions

File tree

.changeset/npm-credentials-cli.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@thatopen/services": minor
3+
---
4+
5+
CLI: auto-configure `.npmrc` for private beta packages.
6+
7+
`thatopen create --beta` (and `thatopen login` inside a beta project) now fetch
8+
read-only npm credentials from the platform and write a project `.npmrc`, so
9+
`npm install` of the private `@thatopen-platform/*-beta` packages just works for
10+
Founding members — no manual token setup. Adds
11+
`EngineServicesClient.getNpmCredentials()` and exports the `NpmCredentials` type.

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,19 @@ To use beta engine libraries instead of the stable ones, see:
2929

3030
Once scaffolded, open `AGENTS.md` in the scaffolded project root — it has everything needed to start building.
3131

32+
## Beta engine libraries (Founding Members)
33+
34+
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.
35+
36+
```bash
37+
thatopen login --token <your-token> # API token from the dashboard → Data → API Tokens
38+
thatopen create my-app --beta # new project on the beta libraries
39+
# or, in an existing project:
40+
thatopen swap --beta # toggle the current project to beta
41+
```
42+
43+
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.
44+
3245
## What's in this repository
3346

3447
- **Library**`EngineServicesClient` and `PlatformClient` for interacting with the That Open API (files, folders, apps, cloud components, executions, permissions).

src/cli/commands/create.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { basename, join, resolve } from 'node:path';
44
import { execSync } from 'node:child_process';
55
import { updateLocalConfig } from '../lib/config';
66
import { BETA_ALIASES } from '../lib/beta';
7+
import { configureBetaNpmrc } from '../lib/npmrc';
78

89
const TEMPLATES = ['app', 'cloud-component'] as const;
910
type Template = (typeof TEMPLATES)[number];
@@ -93,12 +94,14 @@ export const createCommand = new Command('create')
9394
updateLocalConfig({ beta: true }, targetDir);
9495
}
9596

97+
// ── Beta: authenticate private installs via .npmrc ───────────
98+
if (opts.beta) {
99+
await configureBetaNpmrc(targetDir);
100+
}
101+
96102
// Install dependencies automatically
97103
console.log('');
98104
console.log('Installing dependencies...');
99-
if (opts.beta) {
100-
console.log('(Beta packages are private — if this fails with 401/403, configure your beta npm token.)');
101-
}
102105
try {
103106
execSync('npm install', { cwd: targetDir, stdio: 'inherit' });
104107
} catch {

src/cli/commands/login.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Command } from 'commander';
2-
import { writeConfig, updateLocalConfig } from '../lib/config';
2+
import { writeConfig, updateLocalConfig, readLocalConfig } from '../lib/config';
33
import { EngineServicesClient } from '../../core/client';
4+
import { setupNpmrc } from '../lib/npmrc';
45

56
export const loginCommand = new Command('login')
67
.description('Authenticate with the ThatOpen platform')
@@ -37,8 +38,9 @@ export const loginCommand = new Command('login')
3738

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

41+
const client = new EngineServicesClient(opts.token, apiUrl);
42+
4043
try {
41-
const client = new EngineServicesClient(opts.token, apiUrl);
4244
await client.listApps();
4345
} catch (err: unknown) {
4446
const msg = err instanceof Error ? err.message : String(err);
@@ -56,4 +58,13 @@ export const loginCommand = new Command('login')
5658
'Logged in successfully. Config saved to ~/.thatopen/config.json',
5759
);
5860
}
61+
62+
// In a beta project, refresh .npmrc so a rotated Founders token propagates
63+
// on the next login. Best-effort — never blocks login.
64+
if (readLocalConfig()?.beta) {
65+
const result = await setupNpmrc(client, process.cwd());
66+
if (result.status === 'written') {
67+
console.log(`Beta access refreshed — updated .npmrc for ${result.scope}.`);
68+
}
69+
}
5970
});

src/cli/commands/swap.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { join } from 'node:path';
44
import { execSync } from 'node:child_process';
55
import { readLocalConfig, updateLocalConfig } from '../lib/config';
66
import { BETA_ALIASES } from '../lib/beta';
7+
import { configureBetaNpmrc } from '../lib/npmrc';
78

89
export const swapCommand = new Command('swap')
910
.description('Toggle between stable public and beta engine libraries')
@@ -63,10 +64,10 @@ export const swapCommand = new Command('swap')
6364
updateLocalConfig({ beta: targetBeta }, cwd);
6465

6566
console.log(`Switched to ${targetBeta ? 'beta' : 'stable'} libraries.`);
67+
68+
// Beta packages are private — write an authenticated .npmrc before install.
6669
if (targetBeta) {
67-
console.log(
68-
'Beta packages are private — make sure your beta npm token is configured.',
69-
);
70+
await configureBetaNpmrc(cwd);
7071
}
7172

7273
console.log('');

src/cli/lib/npmrc.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import {
3+
mkdtempSync,
4+
rmSync,
5+
existsSync,
6+
readFileSync,
7+
writeFileSync,
8+
} from 'node:fs';
9+
import { tmpdir } from 'node:os';
10+
import { join } from 'node:path';
11+
import { setupNpmrc } from './npmrc';
12+
import { RequestError } from '../../core/request-error';
13+
import type { EngineServicesClient } from '../../core/client';
14+
15+
function fakeClient(getNpmCredentials: () => Promise<unknown>): EngineServicesClient {
16+
return { getNpmCredentials } as unknown as EngineServicesClient;
17+
}
18+
19+
describe('setupNpmrc', () => {
20+
let dir: string;
21+
22+
beforeEach(() => {
23+
dir = mkdtempSync(join(tmpdir(), 'npmrc-test-'));
24+
});
25+
26+
afterEach(() => {
27+
rmSync(dir, { recursive: true, force: true });
28+
});
29+
30+
it('writes .npmrc and returns written on success', async () => {
31+
const npmrc =
32+
'@thatopen-platform:registry=https://registry.npmjs.org/\n' +
33+
'//registry.npmjs.org/:_authToken=npm_ro\n';
34+
const client = fakeClient(async () => ({
35+
registry: 'https://registry.npmjs.org/',
36+
scope: '@thatopen-platform',
37+
token: 'npm_ro',
38+
npmrc,
39+
}));
40+
41+
const result = await setupNpmrc(client, dir);
42+
43+
expect(result).toEqual({ status: 'written', scope: '@thatopen-platform' });
44+
expect(readFileSync(join(dir, '.npmrc'), 'utf-8')).toBe(npmrc);
45+
});
46+
47+
it('returns forbidden and writes nothing on a 403', async () => {
48+
const client = fakeClient(async () => {
49+
throw new RequestError(
50+
403,
51+
'Forbidden',
52+
JSON.stringify({ message: 'Community membership required' }),
53+
);
54+
});
55+
56+
const result = await setupNpmrc(client, dir);
57+
58+
expect(result).toEqual({ status: 'forbidden' });
59+
expect(existsSync(join(dir, '.npmrc'))).toBe(false);
60+
});
61+
62+
it('returns error (and writes nothing) on any other failure', async () => {
63+
const client = fakeClient(async () => {
64+
throw new Error('network down');
65+
});
66+
67+
const result = await setupNpmrc(client, dir);
68+
69+
expect(result.status).toBe('error');
70+
expect(existsSync(join(dir, '.npmrc'))).toBe(false);
71+
});
72+
73+
describe('.gitignore protection (Sergio review #19)', () => {
74+
const okClient = () =>
75+
fakeClient(async () => ({
76+
registry: 'https://registry.npmjs.org/',
77+
scope: '@thatopen-platform',
78+
token: 'npm_ro',
79+
npmrc: '//registry.npmjs.org/:_authToken=npm_ro\n',
80+
}));
81+
82+
const gitignore = () =>
83+
readFileSync(join(dir, '.gitignore'), 'utf-8');
84+
85+
it('creates .gitignore ignoring .npmrc when none exists', async () => {
86+
await setupNpmrc(okClient(), dir);
87+
expect(gitignore()).toBe('.npmrc\n');
88+
});
89+
90+
it('appends .npmrc to an existing .gitignore that lacks it', async () => {
91+
writeFileSync(join(dir, '.gitignore'), 'node_modules\ndist\n');
92+
await setupNpmrc(okClient(), dir);
93+
expect(gitignore()).toBe('node_modules\ndist\n.npmrc\n');
94+
});
95+
96+
it('adds a newline before appending when the file has no trailing newline', async () => {
97+
writeFileSync(join(dir, '.gitignore'), 'node_modules');
98+
await setupNpmrc(okClient(), dir);
99+
expect(gitignore()).toBe('node_modules\n.npmrc\n');
100+
});
101+
102+
it('does not duplicate .npmrc when already ignored', async () => {
103+
writeFileSync(join(dir, '.gitignore'), 'node_modules\n.npmrc\ndist\n');
104+
await setupNpmrc(okClient(), dir);
105+
expect(gitignore()).toBe('node_modules\n.npmrc\ndist\n');
106+
});
107+
108+
it('does not write .gitignore when the account is forbidden', async () => {
109+
const client = fakeClient(async () => {
110+
throw new RequestError(403, 'Forbidden', '{}');
111+
});
112+
await setupNpmrc(client, dir);
113+
expect(existsSync(join(dir, '.gitignore'))).toBe(false);
114+
});
115+
});
116+
});

src/cli/lib/npmrc.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import {
2+
appendFileSync,
3+
existsSync,
4+
readFileSync,
5+
writeFileSync,
6+
} from 'node:fs';
7+
import { join } from 'node:path';
8+
import { EngineServicesClient } from '../../core/client';
9+
import { RequestError } from '../../core/request-error';
10+
import { resolveConfig } from './config';
11+
12+
export type NpmrcResult =
13+
| { status: 'written'; scope: string }
14+
| { status: 'forbidden' }
15+
| { status: 'error'; message: string };
16+
17+
/**
18+
* Make sure `<dir>/.gitignore` ignores `.npmrc` before we write a credential
19+
* into it. The scaffold template already covers this for `create`, but
20+
* `swap`/`login` run in existing projects whose `.gitignore` we don't own — so
21+
* without this the token could be committed. Creates `.gitignore` if absent.
22+
*/
23+
function ensureNpmrcIgnored(dir: string): void {
24+
const gitignorePath = join(dir, '.gitignore');
25+
if (!existsSync(gitignorePath)) {
26+
writeFileSync(gitignorePath, '.npmrc\n');
27+
return;
28+
}
29+
const content = readFileSync(gitignorePath, 'utf-8');
30+
const alreadyIgnored = content
31+
.split(/\r?\n/)
32+
.some((line) => line.trim() === '.npmrc');
33+
if (alreadyIgnored) return;
34+
const prefix = content.length === 0 || content.endsWith('\n') ? '' : '\n';
35+
appendFileSync(gitignorePath, `${prefix}.npmrc\n`);
36+
}
37+
38+
/**
39+
* Fetches the Founders npm credentials and writes them to `<dir>/.npmrc`, so
40+
* `npm install` can resolve the private `@thatopen-platform` beta packages.
41+
*
42+
* Best-effort by design — it never throws, so scaffolding and login keep
43+
* flowing:
44+
* - `forbidden`: the account isn't a FOUNDING member (backend 403); no file.
45+
* - `error`: any other failure (network, misconfig); no file.
46+
* - `written`: `.npmrc` created (mode 0600, it carries a credential).
47+
*/
48+
export async function setupNpmrc(
49+
client: EngineServicesClient,
50+
dir: string,
51+
): Promise<NpmrcResult> {
52+
try {
53+
const creds = await client.getNpmCredentials();
54+
ensureNpmrcIgnored(dir);
55+
writeFileSync(join(dir, '.npmrc'), creds.npmrc, { mode: 0o600 });
56+
return { status: 'written', scope: creds.scope };
57+
} catch (err) {
58+
if (err instanceof RequestError && err.status === 403) {
59+
return { status: 'forbidden' };
60+
}
61+
const message = err instanceof Error ? err.message : String(err);
62+
return { status: 'error', message };
63+
}
64+
}
65+
66+
/**
67+
* CLI glue for the `--beta` flows (`create` and `swap`): resolves the logged-in
68+
* config, writes an authenticated `.npmrc` into `dir`, and prints a
69+
* human-readable status. Best-effort — never throws, so the install still runs.
70+
*/
71+
export async function configureBetaNpmrc(dir: string): Promise<void> {
72+
const config = resolveConfig(dir);
73+
if (!config) {
74+
console.log(
75+
' Beta libraries are private. Run `thatopen login --token <token>`,',
76+
);
77+
console.log(
78+
' then `npm install`, or add your beta npm token to .npmrc manually.',
79+
);
80+
return;
81+
}
82+
const client = new EngineServicesClient(config.accessToken, config.apiUrl);
83+
const result = await setupNpmrc(client, dir);
84+
if (result.status === 'written') {
85+
console.log(` Beta access configured — wrote .npmrc for ${result.scope}.`);
86+
} else if (result.status === 'forbidden') {
87+
console.log(
88+
' Your account is not a Founding member — beta libraries need Founding',
89+
);
90+
console.log(' access, so the install will fail until you have it.');
91+
} else {
92+
console.log(
93+
` Could not fetch beta npm credentials (${result.message}). Set your`,
94+
);
95+
console.log(' token in .npmrc manually if the install fails.');
96+
}
97+
}

src/cli/templates/shared/_gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ node_modules
22
dist
33
*.zip
44
.thatopen
5+
.npmrc

src/core/client.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,4 +407,34 @@ describe('EngineServicesClient — HTTP contract', () => {
407407
);
408408
});
409409
});
410+
411+
describe('getNpmCredentials', () => {
412+
it('GETs /api/npm-registry/credentials with the access token', async () => {
413+
fetchMock.mockResolvedValue(
414+
okResponse({
415+
registry: 'https://registry.npmjs.org/',
416+
scope: '@thatopen-platform',
417+
token: 'npm_ro',
418+
npmrc: '@thatopen-platform:registry=https://registry.npmjs.org/\n',
419+
}),
420+
);
421+
const client = new EngineServicesClient(TOKEN, API);
422+
const creds = await client.getNpmCredentials();
423+
const { url } = getCall(fetchMock);
424+
const { pathname, params } = parseUrl(url);
425+
expect(pathname).toBe('/api/npm-registry/credentials');
426+
expect(params.get('accessToken')).toBe(TOKEN);
427+
expect(creds.scope).toBe('@thatopen-platform');
428+
});
429+
430+
it('throws a RequestError with status 403 for non-Founding accounts', async () => {
431+
fetchMock.mockResolvedValue(
432+
errorResponse(403, 'Community membership required'),
433+
);
434+
const client = new EngineServicesClient(TOKEN, API);
435+
await expect(client.getNpmCredentials()).rejects.toMatchObject({
436+
status: 403,
437+
});
438+
});
439+
});
410440
});

0 commit comments

Comments
 (0)