Skip to content

Commit 1ff2ca7

Browse files
committed
2 parents 90347b6 + 10be1e2 commit 1ff2ca7

14 files changed

Lines changed: 385 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,46 @@
11
# thatopen-services
22

3+
## 0.2.0
4+
5+
### Minor Changes
6+
7+
- 30a9034: CLI: auto-configure `.npmrc` for private beta packages.
8+
9+
`thatopen create --beta` (and `thatopen login` inside a beta project) now fetch
10+
read-only npm credentials from the platform and write a project `.npmrc`, so
11+
`npm install` of the private `@thatopen-platform/*-beta` packages just works for
12+
Founding members — no manual token setup. Adds
13+
`EngineServicesClient.getNpmCredentials()` and exports the `NpmCredentials` type.
14+
15+
## 0.1.3
16+
17+
### Patch Changes
18+
19+
- 6f845c1: Republish attempt — ship `createHiddenFilesBatch` to npm.
20+
21+
## 0.1.2
22+
23+
### Patch Changes
24+
25+
- 067b1af: Republish attempt — ship `createHiddenFilesBatch` to npm now that publish
26+
credentials are configured.
27+
28+
## 0.1.1
29+
30+
### Patch Changes
31+
32+
- 15d6c25: Republish — the 0.1.0 release (which added `createHiddenFilesBatch`) failed to
33+
publish to npm on an expired token. This ships that change.
34+
35+
## 0.1.0
36+
37+
### Minor Changes
38+
39+
- 4568b81: Add `EngineServicesClient.createHiddenFilesBatch()` to upload many hidden files
40+
in a single request, for large 3D-tile sets (point clouds / gaussian splats)
41+
without hitting the per-file upload throttle. Exports the
42+
`CreateHiddenItemsBatchResult` type.
43+
344
## 0.18.0
445

546
### Minor Changes

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).

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"access": "public"
1616
},
1717
"private": false,
18-
"version": "0.0.6",
18+
"version": "0.2.0",
1919
"main": "dist/index.cjs.js",
2020
"module": "dist/index.es.js",
2121
"types": "dist/index.d.ts",

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

0 commit comments

Comments
 (0)