diff --git a/.gitignore b/.gitignore index 4b7afc6..d93307d 100644 --- a/.gitignore +++ b/.gitignore @@ -180,5 +180,5 @@ prescient-sdk-ts/tsconfig.json *.env # Smoke test build artifacts -smoke-tests/dotnet/obj/ -smoke-tests/dotnet/bin/ +prescient-sdk-ts/smoke-tests/dotnet/obj/ +prescient-sdk-ts/smoke-tests/dotnet/bin/ diff --git a/prescient-sdk-ts/JSII_MIGRATION_PLAN.md b/prescient-sdk-ts/JSII_MIGRATION_PLAN.md index bbce888..89713b5 100644 --- a/prescient-sdk-ts/JSII_MIGRATION_PLAN.md +++ b/prescient-sdk-ts/JSII_MIGRATION_PLAN.md @@ -150,21 +150,22 @@ prescient-sdk/ │ │ ├── settings.ts ← env var loading + validation │ │ ├── client.ts ← PrescientClient class │ │ └── upload.ts ← upload() function -│ │ └── __tests__/ ← Jest unit tests (64 tests) +│ │ └── __tests__/ ← Jest unit tests (80 tests) +│ ├── smoke-tests/ ← Docker-based multi-language smoke tests +│ │ ├── docker-compose.yml +│ │ ├── docker/ ← Dockerfiles (python, go, dotnet, java) +│ │ ├── config.env ← fake values for smoke tests +│ │ ├── js/smoke.js +│ │ ├── python/smoke.py +│ │ ├── go/main.go +│ │ ├── dotnet/smoke.csproj + src/ +│ │ └── java/pom.xml + src/ │ ├── dist/ ← compiled JS (gitignored) │ ├── targets/ ← jsii-pacmak output (gitignored) -│ ├── justfile ← docker smoke-test runner +│ ├── justfile ← smoke-test runner (just docker) │ ├── package.json │ ├── pnpm-workspace.yaml ← nodeLinker: hoisted (required for bundling) │ └── .gitignore -├── smoke-tests/ ← Docker-based multi-language smoke tests -│ ├── docker-compose.yml -│ ├── docker/ ← Dockerfiles (python, go, dotnet, java) -│ ├── js/smoke.js -│ ├── python/smoke.py -│ ├── go/main.go -│ ├── dotnet/smoke.csproj + src/ -│ └── java/pom.xml + src/ └── (original Python SDK files — kept, not deleted) ``` @@ -223,7 +224,7 @@ prescient-sdk/ ### Phase 3 — Settings ✅ -- [x] `src/settings.ts`: read `process.env` + optional `.env` via `dotenv` +- [x] `src/settings.ts`: read `process.env` + explicit `PrescientClientOptions` (file loading added in Phase 8) - [x] `_googleClientSecret` loaded from `PRESCIENT_GOOGLE_CLIENT_SECRET` only (never in Options) - [x] Validate: MICROSOFT requires `tenantId`; GOOGLE requires `PRESCIENT_GOOGLE_CLIENT_SECRET` - [x] Unit tests: missing required fields throw, env vars override `.env` @@ -296,68 +297,56 @@ Docker-based multi-language smoke tests. No local toolchain required beyond Dock - [x] `src/__tests__/upload.test.ts` — aws-sdk-client-mock for S3, file scan logic - [x] 64 tests, 4 suites, all passing -### Phase 7 — CI/CD Pipeline - -Update `.github/workflows/ci.yaml`: - -```yaml -jobs: - build-and-test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: { node-version: '22' } - - uses: pnpm/action-setup@v4 - with: { version: '11' } - - run: pnpm install - - run: pnpm run build # jsii - - run: pnpm test # jest - - package: - needs: build-and-test - runs-on: ubuntu-latest - steps: - - run: pnpm run package # jsii-pacmak → targets/ - - publish-npm: - if: startsWith(github.ref, 'refs/tags/v') - needs: package - steps: - - run: pnpm publish - - publish-pypi: - if: startsWith(github.ref, 'refs/tags/v') - needs: package - steps: - - uses: actions/setup-python@v5 - - run: pip install twine && twine upload targets/python/dist/* - - publish-nuget: - if: startsWith(github.ref, 'refs/tags/v') - needs: package - steps: - - uses: actions/setup-dotnet@v4 - - run: dotnet nuget push targets/dotnet/*.nupkg - - publish-maven: - if: startsWith(github.ref, 'refs/tags/v') - needs: package - steps: - - uses: actions/setup-java@v4 - - run: mvn deploy -f targets/java/pom.xml - - publish-go: - if: startsWith(github.ref, 'refs/tags/v') - needs: package - steps: - - run: | - cd targets/go - git tag && git push # go modules published via git tags - # Requires sparkgeo/prescient-sdk-go repo to exist on GitHub +### Phase 7 — CI/CD Pipeline ✅ + +Two GitHub Actions workflows: + +**`.github/workflows/ci.yaml`** — extended with `typescript-sdk` job (build + test + package all 5 language targets on every push/PR to `jsii-migration`). + +**`.github/workflows/publish-sdk.yaml`** — NEW. Triggers on `v*.*.*` tags. Five publish jobs, all `needs: build`: + +| Job | Tool | Key details | +|---|---|---| +| `publish-npm` | `npm publish targets/js/*.tgz` | Pre-built tgz from jsii-pacmak; no source checkout needed | +| `publish-pypi` | `twine upload targets/python/*` | Uploads wheel + sdist (`*` not `*.whl`) | +| `publish-nuget` | `dotnet nuget push targets/dotnet/*.nupkg` | API key via `env:` not interpolated into command | +| `publish-maven` | `publib-maven` | Handles jsii local-repo layout, injects ``, GPG-signs, promotes OSSRH staging | +| `publish-go` | `rsync` + `git tag prescientsdk/vX.Y.Z` | Syncs to `sparkgeo/prescient-sdk-go` repo; guards against duplicate remote tags for idempotent re-runs | + +Security hardening applied: all actions SHA-pinned, `permissions: {}` at top level with per-job minimums, `NUGET_API_KEY` via env var block, per-registry GitHub Actions environments with protection rules. + +> **Infrastructure prerequisites (not yet done):** +> - Create `sparkgeo/prescient-sdk-go` GitHub repo +> - Configure GitHub Actions environments: `npm-production`, `pypi-jsii-production`, `nuget-production`, `maven-production`, `go-production` +> - Add secrets: `NPM_TOKEN`, `PYPI_JSII_API_TOKEN`, `NUGET_API_KEY`, `MAVEN_USERNAME`, `MAVEN_PASSWORD`, `MAVEN_GPG_PRIVATE_KEY`, `MAVEN_GPG_PASSPHRASE`, `MAVEN_STAGING_PROFILE_ID`, `GO_DEPLOY_TOKEN` +> - Register package namespaces: npm (`prescient-sdk`), PyPI (`prescient-sdk-sparkgeo`), NuGet (`Sparkgeo.PrescientSdk`), OSSRH (`com.sparkgeo`) + +### Phase 8 — Multi-language Configuration Experience + +**Problem:** The original Python SDK automatically loaded `config.env` from the CWD via `pydantic_settings`. The jsii TypeScript `Settings` class reads only from `process.env` — users of the jsii packages (Python/Java/.NET/Go) have no file-based config loading unless we add it in the TypeScript layer (which all languages funnel through). + +**Solution:** Add `envFile?: string` to `PrescientClientOptions`. When provided, Settings parses the file as `KEY=VALUE` pairs (pure TypeScript, no extra dependency) and applies values at lowest priority: + +``` +explicit options > process.env > envFile values > built-in defaults ``` -> **Infrastructure prerequisite:** Create `sparkgeo/prescient-sdk-go` GitHub repo for Go module publishing before running publish-go. +`PRESCIENT_GOOGLE_CLIENT_SECRET` from the file flows into `_googleClientSecret` only — same as the env-var path. Never exposed in any public struct. + +**Language usage after this change:** + +| Language | Usage | +|---|---| +| TypeScript | `new PrescientClient({ envFile: 'config.env' })` | +| Python (module: `prescient_sdk`) | `PrescientClient(env_file="config.env")` | +| .NET | `new PrescientClient(new PrescientClientOptions { EnvFile = "config.env" })` | +| Java | `new PrescientClient(PrescientClientOptions.builder().envFile("config.env").build())` | +| Go | `prescientsdk.NewPrescientClient(&prescientsdk.PrescientClientOptions{EnvFile: jsii.String("config.env")})` | + +**Files changed:** +- `src/types.ts` — add `readonly envFile?: string` to `PrescientClientOptions` +- `src/settings.ts` — `parseEnvFile()` private static + constructor overlay +- `src/__tests__/settings.test.ts` — new test cases (file loading, override order, secret from file, missing file error) --- @@ -372,8 +361,9 @@ jobs: | 5 — Upload | 1.0 | ✅ Done | | 5.5 — Smoke Test Infrastructure | 1.0 | ✅ Done | | 6 — Tests | 1.5 | ✅ Done | -| 7 — CI/CD | 1.0 | Not started | -| **Total** | **~10 days** | | +| 7 — CI/CD | 1.0 | ✅ Done (PR #50 open, infrastructure prerequisites pending) | +| 8 — Multi-language Config Experience | 0.5 | Not started | +| **Total** | **~10.5 days** | | --- diff --git a/prescient-sdk-ts/README.md b/prescient-sdk-ts/README.md index 600d990..6d43016 100644 --- a/prescient-sdk-ts/README.md +++ b/prescient-sdk-ts/README.md @@ -12,6 +12,64 @@ Polyglot SDK for integrating with Prescient services. Generated from a single Ty | Java | Maven Central | | Go | GitHub | +## Configuration + +Copy `config.env` from the repo root and fill in your credentials. The SDK reads it at lowest priority — environment variables always win. + +```env +# config.env — see https://sparkgeo.github.io/prescient-sdk/config.html +PRESCIENT_ENDPOINT_URL=https://sparkgeo.prescient.earth +PRESCIENT_AUTH_URL=https://sparkgeo.prescient.earth/oauth2/auth +PRESCIENT_CLIENT_ID= +PRESCIENT_AUTH_PROVIDER=microsoft # or google +PRESCIENT_TENANT_ID= # required for microsoft +# PRESCIENT_GOOGLE_CLIENT_SECRET=... # required for google (env var only) +``` + +Three ways to configure the client — all equivalent at runtime: + +### File-based (recommended for local development) + +| Language | Code | +| --- | --- | +| TypeScript | `new PrescientClient({ envFile: 'config.env' })` | +| Python (jsii) | `PrescientClient(env_file="config.env")` | +| C# / .NET | `new PrescientClient(new PrescientClientOptions { EnvFile = "config.env" })` | +| Java | `new PrescientClient(PrescientClientOptions.builder().envFile("config.env").build())` | +| Go | `prescientsdk.NewPrescientClient(&prescientsdk.PrescientClientOptions{EnvFile: jsii.String("config.env")})` | + +All languages funnel through the Node.js jsii runtime, so the file is always read in the same place — no per-language dotenv library needed. + +### Environment variables (recommended for CI / containers) + +Set `PRESCIENT_*` variables in the process environment, then call the default constructor: + +```typescript +// TypeScript — same default-constructor pattern in all languages +const client = new PrescientClient(); +``` + +### Explicit options (useful for testing / multiple clients) + +```typescript +const client = new PrescientClient({ + endpointUrl: 'https://sparkgeo.prescient.earth', + clientId: 'my-client-id', + authUrl: 'https://sparkgeo.prescient.earth/oauth2/auth', + tenantId: 'my-tenant-id', +}); +``` + +### Priority order + +`explicit options` > `environment variables` > `envFile` > built-in defaults. + +### Security note — Google client secret + +`PRESCIENT_GOOGLE_CLIENT_SECRET` is intentionally absent from `PrescientClientOptions`. It must be supplied via environment variable or `envFile` — never as a constructor argument — to prevent it from appearing in jsii IPC logs when `JSII_DEBUG=1` is set. + +--- + ## Development See [JSII_MIGRATION_PLAN.md](JSII_MIGRATION_PLAN.md) for the full migration plan. diff --git a/prescient-sdk-ts/justfile b/prescient-sdk-ts/justfile index 1018ec3..66cd01b 100644 --- a/prescient-sdk-ts/justfile +++ b/prescient-sdk-ts/justfile @@ -1,4 +1,4 @@ -compose := "docker compose -f " + justfile_directory() + "/../smoke-tests/docker-compose.yml" +compose := "docker compose -f " + justfile_directory() + "/smoke-tests/docker-compose.yml" default: build diff --git a/smoke-tests/docker-compose.yml b/prescient-sdk-ts/smoke-tests/docker-compose.yml similarity index 75% rename from smoke-tests/docker-compose.yml rename to prescient-sdk-ts/smoke-tests/docker-compose.yml index dc9f1ae..6cad859 100644 --- a/smoke-tests/docker-compose.yml +++ b/prescient-sdk-ts/smoke-tests/docker-compose.yml @@ -1,5 +1,5 @@ # Mount layout mirrors the repo tree under /workspace/ so that relative paths -# already embedded in go.mod (../../prescient-sdk-ts/...), nuget.config, and +# already embedded in go.mod (../../...), nuget.config, and # pom.xml all resolve without modification. # # All non-JS language runtimes are jsii-based and spawn a `node` subprocess, @@ -16,8 +16,9 @@ services: working_dir: /workspace/smoke-tests/js volumes: - ./js:/workspace/smoke-tests/js:ro - - ../prescient-sdk-ts/dist:/workspace/prescient-sdk-ts/dist:ro - - ../prescient-sdk-ts/node_modules:/workspace/prescient-sdk-ts/node_modules:ro + - ../../local.env:/workspace/smoke-tests/config.env:ro + - ../dist:/workspace/prescient-sdk-ts/dist:ro + - ../node_modules:/workspace/prescient-sdk-ts/node_modules:ro command: node smoke.js python: @@ -28,7 +29,8 @@ services: working_dir: /workspace/smoke-tests/python volumes: - ./python:/workspace/smoke-tests/python:ro - - ../prescient-sdk-ts/targets/python:/workspace/prescient-sdk-ts/targets/python:ro + - ../../local.env:/workspace/smoke-tests/config.env:ro + - ../targets/python:/workspace/prescient-sdk-ts/targets/python:ro - pip-cache:/root/.cache/pip command: - sh @@ -47,7 +49,8 @@ services: working_dir: /workspace/smoke-tests/go volumes: - ./go:/workspace/smoke-tests/go:ro - - ../prescient-sdk-ts/targets/go:/workspace/prescient-sdk-ts/targets/go:ro + - ../../local.env:/workspace/smoke-tests/config.env:ro + - ../targets/go:/workspace/prescient-sdk-ts/targets/go:ro - go-mod-cache:/root/go/pkg/mod - go-build-cache:/root/.cache/go-build environment: @@ -62,7 +65,8 @@ services: working_dir: /workspace/smoke-tests/dotnet volumes: - ./dotnet:/workspace/smoke-tests/dotnet:ro - - ../prescient-sdk-ts/targets/dotnet:/workspace/prescient-sdk-ts/targets/dotnet:ro + - ../../local.env:/workspace/smoke-tests/config.env:ro + - ../targets/dotnet:/workspace/prescient-sdk-ts/targets/dotnet:ro - nuget-cache:/root/.nuget - dotnet-obj:/workspace/smoke-tests/dotnet/obj - dotnet-bin:/workspace/smoke-tests/dotnet/bin @@ -76,7 +80,8 @@ services: working_dir: /workspace/smoke-tests/java volumes: - ./java:/workspace/smoke-tests/java:ro - - ../prescient-sdk-ts/targets/java:/workspace/prescient-sdk-ts/targets/java:ro + - ../../local.env:/workspace/smoke-tests/config.env:ro + - ../targets/java:/workspace/prescient-sdk-ts/targets/java:ro - maven-cache:/root/.m2 - maven-target:/workspace/smoke-tests/java/target command: mvn --no-transfer-progress compile exec:java diff --git a/smoke-tests/docker/Dockerfile.dotnet b/prescient-sdk-ts/smoke-tests/docker/Dockerfile.dotnet similarity index 100% rename from smoke-tests/docker/Dockerfile.dotnet rename to prescient-sdk-ts/smoke-tests/docker/Dockerfile.dotnet diff --git a/smoke-tests/docker/Dockerfile.go b/prescient-sdk-ts/smoke-tests/docker/Dockerfile.go similarity index 100% rename from smoke-tests/docker/Dockerfile.go rename to prescient-sdk-ts/smoke-tests/docker/Dockerfile.go diff --git a/smoke-tests/docker/Dockerfile.java b/prescient-sdk-ts/smoke-tests/docker/Dockerfile.java similarity index 100% rename from smoke-tests/docker/Dockerfile.java rename to prescient-sdk-ts/smoke-tests/docker/Dockerfile.java diff --git a/smoke-tests/docker/Dockerfile.python b/prescient-sdk-ts/smoke-tests/docker/Dockerfile.python similarity index 100% rename from smoke-tests/docker/Dockerfile.python rename to prescient-sdk-ts/smoke-tests/docker/Dockerfile.python diff --git a/smoke-tests/dotnet/nuget.config b/prescient-sdk-ts/smoke-tests/dotnet/nuget.config similarity index 100% rename from smoke-tests/dotnet/nuget.config rename to prescient-sdk-ts/smoke-tests/dotnet/nuget.config diff --git a/smoke-tests/dotnet/smoke.csproj b/prescient-sdk-ts/smoke-tests/dotnet/smoke.csproj similarity index 100% rename from smoke-tests/dotnet/smoke.csproj rename to prescient-sdk-ts/smoke-tests/dotnet/smoke.csproj diff --git a/smoke-tests/dotnet/src/Program.cs b/prescient-sdk-ts/smoke-tests/dotnet/src/Program.cs similarity index 63% rename from smoke-tests/dotnet/src/Program.cs rename to prescient-sdk-ts/smoke-tests/dotnet/src/Program.cs index 9c1232f..669f470 100644 --- a/smoke-tests/dotnet/src/Program.cs +++ b/prescient-sdk-ts/smoke-tests/dotnet/src/Program.cs @@ -2,10 +2,7 @@ var client = new PrescientClient(new PrescientClientOptions { - EndpointUrl = "https://api.example.com", - ClientId = "test-client-id", - AuthUrl = "https://login.microsoftonline.com", - TenantId = "test-tenant-id", + EnvFile = "/workspace/smoke-tests/config.env", }); Console.WriteLine($"EndpointUrl : {client.Settings.EndpointUrl}"); diff --git a/smoke-tests/go/go.mod b/prescient-sdk-ts/smoke-tests/go/go.mod similarity index 100% rename from smoke-tests/go/go.mod rename to prescient-sdk-ts/smoke-tests/go/go.mod diff --git a/smoke-tests/go/go.sum b/prescient-sdk-ts/smoke-tests/go/go.sum similarity index 100% rename from smoke-tests/go/go.sum rename to prescient-sdk-ts/smoke-tests/go/go.sum diff --git a/smoke-tests/go/main.go b/prescient-sdk-ts/smoke-tests/go/main.go similarity index 66% rename from smoke-tests/go/main.go rename to prescient-sdk-ts/smoke-tests/go/main.go index 64e9ec0..42f2d9f 100644 --- a/smoke-tests/go/main.go +++ b/prescient-sdk-ts/smoke-tests/go/main.go @@ -11,10 +11,7 @@ func main() { defer jsii.Close() client := prescientsdk.NewPrescientClient(&prescientsdk.PrescientClientOptions{ - EndpointUrl: jsii.String("https://api.example.com"), - ClientId: jsii.String("test-client-id"), - AuthUrl: jsii.String("https://login.microsoftonline.com"), - TenantId: jsii.String("test-tenant-id"), + EnvFile: jsii.String("/workspace/smoke-tests/config.env"), }) fmt.Println("endpointUrl :", *client.Settings().EndpointUrl()) diff --git a/smoke-tests/java/pom.xml b/prescient-sdk-ts/smoke-tests/java/pom.xml similarity index 100% rename from smoke-tests/java/pom.xml rename to prescient-sdk-ts/smoke-tests/java/pom.xml diff --git a/smoke-tests/java/src/main/java/smoke/SmokeTest.java b/prescient-sdk-ts/smoke-tests/java/src/main/java/smoke/SmokeTest.java similarity index 73% rename from smoke-tests/java/src/main/java/smoke/SmokeTest.java rename to prescient-sdk-ts/smoke-tests/java/src/main/java/smoke/SmokeTest.java index 8a448f4..cbebe3c 100644 --- a/smoke-tests/java/src/main/java/smoke/SmokeTest.java +++ b/prescient-sdk-ts/smoke-tests/java/src/main/java/smoke/SmokeTest.java @@ -7,10 +7,7 @@ public class SmokeTest { public static void main(String[] args) { PrescientClient client = new PrescientClient( PrescientClientOptions.builder() - .endpointUrl("https://api.example.com") - .clientId("test-client-id") - .authUrl("https://login.microsoftonline.com") - .tenantId("test-tenant-id") + .envFile("/workspace/smoke-tests/config.env") .build() ); diff --git a/smoke-tests/js/smoke.js b/prescient-sdk-ts/smoke-tests/js/smoke.js similarity index 75% rename from smoke-tests/js/smoke.js rename to prescient-sdk-ts/smoke-tests/js/smoke.js index 24257c0..04fb1eb 100644 --- a/smoke-tests/js/smoke.js +++ b/prescient-sdk-ts/smoke-tests/js/smoke.js @@ -5,12 +5,7 @@ // which resolves the full dep tree from the registry. const { PrescientClient } = require('../../prescient-sdk-ts/dist/index'); -const client = new PrescientClient({ - endpointUrl: 'https://api.example.com', - clientId: 'test-client-id', - authUrl: 'https://login.microsoftonline.com', - tenantId: 'test-tenant-id', -}); +const client = new PrescientClient({ envFile: '/workspace/smoke-tests/config.env' }); console.log('endpointUrl :', client.settings.endpointUrl); console.log('stacCatalogUrl :', client.stacCatalogUrl); diff --git a/prescient-sdk-ts/smoke-tests/python/smoke.py b/prescient-sdk-ts/smoke-tests/python/smoke.py new file mode 100644 index 0000000..72007b1 --- /dev/null +++ b/prescient-sdk-ts/smoke-tests/python/smoke.py @@ -0,0 +1,7 @@ +from prescient_sdk import PrescientClient + +client = PrescientClient(env_file="/workspace/smoke-tests/config.env") + +print("endpoint_url :", client.settings.endpoint_url) +print("stac_catalog_url:", client.stac_catalog_url) +print("✓ Python smoke test passed") diff --git a/prescient-sdk-ts/src/__tests__/settings.test.ts b/prescient-sdk-ts/src/__tests__/settings.test.ts index 833ea21..ef29cb3 100644 --- a/prescient-sdk-ts/src/__tests__/settings.test.ts +++ b/prescient-sdk-ts/src/__tests__/settings.test.ts @@ -1,3 +1,6 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import { Settings } from '../settings'; import { AuthProvider } from '../types'; @@ -222,6 +225,134 @@ describe('Settings — toJSON', () => { }); }); +describe('Settings — envFile loading', () => { + let tmpFile: string; + + function writeTmpEnvFile(contents: string): string { + tmpFile = path.join(os.tmpdir(), `prescient-test-${process.pid}.env`); + fs.writeFileSync(tmpFile, contents, 'utf-8'); + return tmpFile; + } + + afterEach(() => { + if (tmpFile) { + try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } + } + }); + + it('loads required fields from envFile', () => { + const f = writeTmpEnvFile([ + 'PRESCIENT_ENDPOINT_URL=https://api.example.com', + 'PRESCIENT_CLIENT_ID=file-client', + 'PRESCIENT_AUTH_URL=https://auth.example.com', + 'PRESCIENT_TENANT_ID=file-tenant', + ].join('\n')); + const s = new Settings({ envFile: f }); + expect(s.endpointUrl).toBe('https://api.example.com'); + expect(s.clientId).toBe('file-client'); + expect(s.tenantId).toBe('file-tenant'); + }); + + it('process env overrides envFile values', () => { + const f = writeTmpEnvFile([ + 'PRESCIENT_ENDPOINT_URL=https://file.example.com', + 'PRESCIENT_CLIENT_ID=file-client', + 'PRESCIENT_AUTH_URL=https://auth.example.com', + 'PRESCIENT_TENANT_ID=file-tenant', + ].join('\n')); + withEnv({ PRESCIENT_ENDPOINT_URL: 'https://env.example.com' }, () => { + const s = new Settings({ envFile: f }); + expect(s.endpointUrl).toBe('https://env.example.com'); + }); + }); + + it('explicit opts override envFile and process env', () => { + const f = writeTmpEnvFile([ + 'PRESCIENT_ENDPOINT_URL=https://file.example.com', + 'PRESCIENT_CLIENT_ID=file-client', + 'PRESCIENT_AUTH_URL=https://auth.example.com', + 'PRESCIENT_TENANT_ID=file-tenant', + ].join('\n')); + withEnv({ PRESCIENT_ENDPOINT_URL: 'https://env.example.com' }, () => { + const s = new Settings({ ...MICROSOFT_OPTS, envFile: f }); + expect(s.endpointUrl).toBe('https://api.example.com'); // from MICROSOFT_OPTS + expect(s.clientId).toBe('client-id'); // from MICROSOFT_OPTS + }); + }); + + it('loads PRESCIENT_GOOGLE_CLIENT_SECRET from envFile into _googleClientSecret', () => { + const f = writeTmpEnvFile([ + 'PRESCIENT_ENDPOINT_URL=https://api.example.com', + 'PRESCIENT_CLIENT_ID=file-client', + 'PRESCIENT_AUTH_URL=https://auth.example.com', + 'PRESCIENT_AUTH_PROVIDER=google', + 'PRESCIENT_GOOGLE_CLIENT_SECRET=file-secret', // gitleaks:allow + ].join('\n')); + const s = new Settings({ envFile: f }); + expect(s._googleClientSecret).toBe('file-secret'); + expect(s.authProvider).toBe(AuthProvider.GOOGLE); + }); + + it('process env PRESCIENT_GOOGLE_CLIENT_SECRET overrides envFile secret', () => { + const f = writeTmpEnvFile([ + 'PRESCIENT_ENDPOINT_URL=https://api.example.com', + 'PRESCIENT_CLIENT_ID=file-client', + 'PRESCIENT_AUTH_URL=https://auth.example.com', + 'PRESCIENT_AUTH_PROVIDER=google', + 'PRESCIENT_GOOGLE_CLIENT_SECRET=file-secret', // gitleaks:allow + ].join('\n')); + withEnv({ PRESCIENT_GOOGLE_CLIENT_SECRET: 'env-secret' }, () => { // gitleaks:allow + const s = new Settings({ envFile: f }); + expect(s._googleClientSecret).toBe('env-secret'); + }); + }); + + it('skips comments and blank lines in envFile', () => { + const f = writeTmpEnvFile([ + '# This is a comment', + '', + 'PRESCIENT_ENDPOINT_URL=https://api.example.com', + ' # Indented comment', + 'PRESCIENT_CLIENT_ID=file-client', + 'PRESCIENT_AUTH_URL=https://auth.example.com', + 'PRESCIENT_TENANT_ID=file-tenant', + ].join('\n')); + const s = new Settings({ envFile: f }); + expect(s.clientId).toBe('file-client'); + }); + + it('strips surrounding quotes from envFile values', () => { + const f = writeTmpEnvFile([ + 'PRESCIENT_ENDPOINT_URL="https://api.example.com"', + "PRESCIENT_CLIENT_ID='quoted-client'", + 'PRESCIENT_AUTH_URL=https://auth.example.com', + 'PRESCIENT_TENANT_ID=file-tenant', + ].join('\n')); + const s = new Settings({ envFile: f }); + expect(s.endpointUrl).toBe('https://api.example.com'); + expect(s.clientId).toBe('quoted-client'); + }); + + it('throws on nonexistent envFile path', () => { + expect(() => + new Settings({ envFile: '/nonexistent/path/config.env' }), + ).toThrow('envFile not found or unreadable'); + }); + + it('loads googleRedirectPort from envFile', () => { + const f = writeTmpEnvFile([ + 'PRESCIENT_ENDPOINT_URL=https://api.example.com', + 'PRESCIENT_CLIENT_ID=file-client', + 'PRESCIENT_AUTH_URL=https://auth.example.com', + 'PRESCIENT_AUTH_PROVIDER=google', + 'PRESCIENT_GOOGLE_CLIENT_SECRET=file-secret', // gitleaks:allow + 'PRESCIENT_GOOGLE_REDIRECT_PORT=9999', + ].join('\n')); + const s = new Settings({ envFile: f }); + expect(s.googleRedirectPort).toBe(9999); + }); +}); + describe('Settings — awsRole validation', () => { it('accepts a valid ARN', () => { const s = new Settings({ ...MICROSOFT_OPTS, awsRole: 'arn:aws:iam::123456789012:role/MyRole' }); diff --git a/prescient-sdk-ts/src/settings.ts b/prescient-sdk-ts/src/settings.ts index 96c9a3f..087dd30 100644 --- a/prescient-sdk-ts/src/settings.ts +++ b/prescient-sdk-ts/src/settings.ts @@ -1,12 +1,18 @@ +import * as fs from 'fs'; import { AuthProvider, PrescientClientOptions } from './types'; /** * Resolved, validated settings for PrescientClient. * - * Constructed either from a {@link PrescientClientOptions} struct or from - * `PRESCIENT_*` environment variables (falling back to a `config.env` file - * if `dotenv` is loaded by the consumer). Validation enforces HTTPS on all - * URL fields and requires provider-specific fields. + * Constructed from {@link PrescientClientOptions}, `PRESCIENT_*` environment + * variables, and/or a `config.env` file (via `opts.envFile`). Validation + * enforces HTTPS on all URL fields and requires provider-specific fields. + * + * Resolution priority (highest first): + * 1. Explicit `PrescientClientOptions` fields + * 2. `PRESCIENT_*` environment variables + * 3. `envFile` values (if `opts.envFile` is set) + * 4. Built-in defaults * * Environment variable → field mapping: * ``` @@ -39,28 +45,72 @@ export class Settings { constructor(opts?: PrescientClientOptions) { const env = process.env; + const fileEnv: Record = opts?.envFile + ? Settings.parseEnvFile(opts.envFile) + : {}; - this.endpointUrl = opts?.endpointUrl ?? env['PRESCIENT_ENDPOINT_URL'] ?? ''; + this.endpointUrl = + opts?.endpointUrl ?? env['PRESCIENT_ENDPOINT_URL'] ?? fileEnv['PRESCIENT_ENDPOINT_URL'] ?? ''; this.authProvider = resolveAuthProvider( opts?.authProvider, env['PRESCIENT_AUTH_PROVIDER'], + fileEnv['PRESCIENT_AUTH_PROVIDER'], ); - this.clientId = opts?.clientId ?? env['PRESCIENT_CLIENT_ID'] ?? ''; - this.authUrl = opts?.authUrl ?? env['PRESCIENT_AUTH_URL'] ?? ''; - this.tenantId = opts?.tenantId ?? env['PRESCIENT_TENANT_ID']; - this._googleClientSecret = env['PRESCIENT_GOOGLE_CLIENT_SECRET']; + this.clientId = + opts?.clientId ?? env['PRESCIENT_CLIENT_ID'] ?? fileEnv['PRESCIENT_CLIENT_ID'] ?? ''; + this.authUrl = + opts?.authUrl ?? env['PRESCIENT_AUTH_URL'] ?? fileEnv['PRESCIENT_AUTH_URL'] ?? ''; + this.tenantId = + opts?.tenantId ?? env['PRESCIENT_TENANT_ID'] ?? fileEnv['PRESCIENT_TENANT_ID']; + this._googleClientSecret = + env['PRESCIENT_GOOGLE_CLIENT_SECRET'] ?? fileEnv['PRESCIENT_GOOGLE_CLIENT_SECRET']; this.googleRedirectPort = resolvePort( opts?.googleRedirectPort, env['PRESCIENT_GOOGLE_REDIRECT_PORT'], + fileEnv['PRESCIENT_GOOGLE_REDIRECT_PORT'], ); - this.awsRole = opts?.awsRole ?? env['PRESCIENT_AWS_ROLE']; - this.awsRegion = opts?.awsRegion ?? env['PRESCIENT_AWS_REGION']; - this.uploadRole = opts?.uploadRole ?? env['PRESCIENT_UPLOAD_ROLE']; - this.uploadBucket = opts?.uploadBucket ?? env['PRESCIENT_UPLOAD_BUCKET']; + this.awsRole = opts?.awsRole ?? env['PRESCIENT_AWS_ROLE'] ?? fileEnv['PRESCIENT_AWS_ROLE']; + this.awsRegion = + opts?.awsRegion ?? env['PRESCIENT_AWS_REGION'] ?? fileEnv['PRESCIENT_AWS_REGION']; + this.uploadRole = + opts?.uploadRole ?? env['PRESCIENT_UPLOAD_ROLE'] ?? fileEnv['PRESCIENT_UPLOAD_ROLE']; + this.uploadBucket = + opts?.uploadBucket ?? env['PRESCIENT_UPLOAD_BUCKET'] ?? fileEnv['PRESCIENT_UPLOAD_BUCKET']; this.validate(); } + /** + * Parses a `KEY=VALUE` env file. Lines starting with `#` and blank lines + * are skipped. Values may optionally be wrapped in single or double quotes. + */ + private static parseEnvFile(filePath: string): Record { + let content: string; + try { + content = fs.readFileSync(filePath, 'utf-8'); + } catch { + throw new Error(`envFile not found or unreadable: "${filePath}". Check the path and try again.`); + } + const result: Record = {}; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + if (!key) continue; + let value = trimmed.slice(eqIdx + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + result[key] = value; + } + return result; + } + private validate(): void { if (!this.endpointUrl) { throw new Error( @@ -130,29 +180,36 @@ export class Settings { function resolveAuthProvider( fromOpts: AuthProvider | undefined, fromEnv: string | undefined, + fromFile: string | undefined, ): AuthProvider { if (fromOpts !== undefined) return fromOpts; - if (fromEnv === undefined) return AuthProvider.MICROSOFT; - const lower = fromEnv.toLowerCase(); + const raw = fromEnv ?? fromFile; + if (raw === undefined) return AuthProvider.MICROSOFT; + const lower = raw.toLowerCase(); if (lower === 'google') return AuthProvider.GOOGLE; if (lower === 'microsoft') return AuthProvider.MICROSOFT; throw new Error( - `Invalid PRESCIENT_AUTH_PROVIDER value "${fromEnv}". Must be "microsoft" or "google".`, + `Invalid PRESCIENT_AUTH_PROVIDER value "${raw}". Must be "microsoft" or "google".`, ); } -function resolvePort(fromOpts: number | undefined, fromEnv: string | undefined): number { +function resolvePort( + fromOpts: number | undefined, + fromEnv: string | undefined, + fromFile: string | undefined, +): number { if (fromOpts !== undefined) { if (!Number.isInteger(fromOpts) || fromOpts < 1 || fromOpts > 65535) { throw new Error(`googleRedirectPort must be an integer 1–65535. Received: ${fromOpts}.`); } return fromOpts; } - if (fromEnv === undefined) return 8765; - const n = parseInt(fromEnv, 10); - if (isNaN(n) || n < 1 || n > 65535 || String(n) !== fromEnv.trim()) { + const raw = fromEnv ?? fromFile; + if (raw === undefined) return 8765; + const n = parseInt(raw, 10); + if (isNaN(n) || n < 1 || n > 65535 || String(n) !== raw.trim()) { throw new Error( - `Invalid PRESCIENT_GOOGLE_REDIRECT_PORT value "${fromEnv}". Must be an integer 1–65535.`, + `Invalid PRESCIENT_GOOGLE_REDIRECT_PORT value "${raw}". Must be an integer 1–65535.`, ); } return n; diff --git a/prescient-sdk-ts/src/types.ts b/prescient-sdk-ts/src/types.ts index 383351f..1924c50 100644 --- a/prescient-sdk-ts/src/types.ts +++ b/prescient-sdk-ts/src/types.ts @@ -16,22 +16,27 @@ export interface PrescientClientOptions { /** * Base URL of the Prescient API endpoint. * @remarks Must be an HTTPS URL. HTTP is not supported and will be rejected - * at client initialization. + * at client initialization. Required unless supplied via environment variable + * or `envFile`. */ - readonly endpointUrl: string; + readonly endpointUrl?: string; /** OAuth2 authentication provider. Defaults to MICROSOFT. */ readonly authProvider?: AuthProvider; - /** OAuth2 client ID issued by the selected authentication provider. */ - readonly clientId: string; + /** + * OAuth2 client ID issued by the selected authentication provider. + * Required unless supplied via environment variable or `envFile`. + */ + readonly clientId?: string; /** * OAuth2 token endpoint base URL. * @remarks Must be an HTTPS URL. HTTP is not supported and will be rejected - * at client initialization. + * at client initialization. Required unless supplied via environment variable + * or `envFile`. */ - readonly authUrl: string; + readonly authUrl?: string; /** Microsoft Entra tenant ID. Required when authProvider is MICROSOFT. */ readonly tenantId?: string; @@ -56,6 +61,23 @@ export interface PrescientClientOptions { /** AWS S3 bucket name targeted by the upload helpers. */ readonly uploadBucket?: string; + + /** + * Path to a `config.env` file to load. + * + * Values from the file are applied at the lowest priority — they are + * overridden by `PRESCIENT_*` environment variables, which in turn are + * overridden by explicit options fields. + * + * `PRESCIENT_GOOGLE_CLIENT_SECRET` in the file is handled securely: it flows + * into the internal `_googleClientSecret` field only and is never surfaced in + * any public struct. + * + * @example + * // TypeScript / JavaScript + * const client = new PrescientClient({ envFile: 'config.env' }); + */ + readonly envFile?: string; } /** diff --git a/smoke-tests/python/smoke.py b/smoke-tests/python/smoke.py deleted file mode 100644 index aca0cbf..0000000 --- a/smoke-tests/python/smoke.py +++ /dev/null @@ -1,12 +0,0 @@ -import prescient_sdk - -client = prescient_sdk.PrescientClient( - endpoint_url='https://api.example.com', - client_id='test-client-id', - auth_url='https://login.microsoftonline.com', - tenant_id='test-tenant-id', -) - -print('endpoint_url :', client.settings.endpoint_url) -print('stac_catalog_url:', client.stac_catalog_url) -print('✓ Python smoke test passed')