Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .changeset/add-api-control-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,11 @@
# OWOX Data Marts API access

Add the OWOX Data Marts API access layer for external tools and automation.
This includes [`owox-ctl`](../docs/api/owox-ctl.md), a remote-control CLI for existing OWOX Data Marts instances, and [`@owox/api-client`](../docs/api/api-client.md), a TypeScript/JavaScript API client for custom integrations.
See the new [API documentation section](../docs/api/index.md) for API keys, CLI usage, API client usage, and OpenAPI/Swagger UI discovery.
This includes [`owox-ctl`](../docs/api/owox-ctl.md), the OWOX Data Marts Control CLI for automation-first JSON commands against existing OWOX Data Marts deployments, and [`@owox/api-client`](../docs/api/api-client.md), a TypeScript/JavaScript API client for custom integrations.

`owox-ctl` resolves credentials from environment variables, loads `.env` or `--env-file`, reports the absolute env file path when one is loaded, defaults to OWOX Data Marts Cloud at `https://app.owox.com`, and supports:

- `owox-ctl status`
- `owox-ctl data-marts list`
- `owox-ctl storages list`
- `owox-ctl destinations list`
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ dist/
/out
/owox-bundle.js
/owox-test-bundle.js
*.tsbuildinfo

# Logs
logs
Expand Down
36 changes: 9 additions & 27 deletions apps/ctl/README.md
Original file line number Diff line number Diff line change
@@ -1,47 +1,29 @@
# @owox/ctl

OWOX Data Marts Control CLI.

`owox` runs and manages a local or self-managed OWOX Data Marts runtime.
`owox-ctl` controls an existing OWOX Data Marts instance through the HTTP API.

The full user documentation lives in
[owox-ctl API documentation](https://docs.owox.com/docs/api/owox-ctl/).
OWOX Data Marts Control CLI for scripts, CI jobs, and AI agents.

## Install

```bash
npm install -g @owox/ctl
```

## Authentication

For human use, prefer interactive login:
## Usage

```bash
owox-ctl auth login
```

For CI and agents, use environment variables. These override stored login config
and do not write secrets to disk:

```bash
OWOX_API_ORIGIN=https://app.owox.com \
OWOX_API_KEY_ID=pmk_xxx \
OWOX_API_KEY_SECRET=xxx \
owox-ctl data-marts list --format json
owox-ctl data-marts list
```

You can also load these variables from an environment file:

```bash
owox-ctl data-marts list --env-file .env --format json
owox-ctl status --env-file .env
```

Access tokens are short-lived and kept in memory only for the current process.
They are never written to the local config file.
`owox-ctl` also loads `.env` from the current directory when it exists and reports the absolute path in `envFile`.

## Compatibility
All command output is JSON. The default API origin is `https://app.owox.com`.

CLI same version as server: supported.
CLI different version from server: best effort.
## Documentation

[owox-ctl API documentation](https://docs.owox.com/docs/api/owox-ctl/).
5 changes: 2 additions & 3 deletions apps/ctl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@
"@oclif/core": "^4",
"@oclif/plugin-help": "^6",
"@owox/api-client": "*",
"@owox/internal-helpers": "*",
"env-paths": "^3.0.0"
"@owox/internal-helpers": "*"
},
"devDependencies": {
"@owox/eslint-config": "*",
Expand All @@ -43,7 +42,7 @@
"owox",
"data-marts",
"cli",
"control"
"automation"
],
"main": "dist/index.js",
"oclif": {
Expand Down
78 changes: 72 additions & 6 deletions apps/ctl/src/base-command.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
import { jest } from '@jest/globals';
import { OWOXConfigError } from '@owox/api-client';
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';

import { setupEnvironmentFromFlags } from './base-command.js';
import DataMartsList from './commands/data-marts/list.js';

describe('base command environment setup', () => {
it('passes env-file through the shared environment manager contract', () => {
const setupEnvironment = jest.fn(() => ({ messages: [], success: true }));
const setupEnvironment = jest.fn(() => ({
envFilePath: '.env.test',
messages: [],
success: true,
}));

setupEnvironmentFromFlags({ 'env-file': '.env.test' }, setupEnvironment);
const envFile = setupEnvironmentFromFlags({ 'env-file': '.env.test' }, setupEnvironment);

expect(envFile).toBe('.env.test');
expect(setupEnvironment).toHaveBeenCalledWith({ envFile: '.env.test' });
});

it('fails when an explicit env-file cannot be loaded', () => {
const setupEnvironment = jest.fn(() => ({ messages: [], success: false }));
const setupEnvironment = jest.fn(() => ({ envFilePath: null, messages: [], success: false }));

expect(() =>
setupEnvironmentFromFlags({ 'env-file': 'missing.env' }, setupEnvironment)
Expand All @@ -23,10 +32,67 @@ describe('base command environment setup', () => {
).toThrow('Failed to load environment file: missing.env');
});

it('allows a missing implicit default env file', () => {
const setupEnvironment = jest.fn(() => ({ messages: [], success: false }));
it('allows a missing implicit default env file and returns null', () => {
const setupEnvironment = jest.fn(() => ({ envFilePath: null, messages: [], success: false }));

expect(() => setupEnvironmentFromFlags({}, setupEnvironment)).not.toThrow();
expect(setupEnvironmentFromFlags({}, setupEnvironment)).toBeNull();
expect(setupEnvironment).toHaveBeenCalledWith({ envFile: '' });
});

it('reports the env file path returned by the shared environment manager', () => {
const setupEnvironment = jest.fn(() => ({
envFilePath: '/work/.env',
messages: [],
success: true,
}));

expect(setupEnvironmentFromFlags({}, setupEnvironment)).toBe('/work/.env');
expect(setupEnvironment).toHaveBeenCalledWith({ envFile: '' });
});

it('reports explicit env-file paths as absolute paths through the shared environment manager', () => {
const previousCwd = process.cwd();
const tempDir = mkdtempSync(path.join(tmpdir(), 'owox-ctl-env-'));
const commandDir = path.join(tempDir, 'apps', 'ctl');
const envFile = path.join(tempDir, '.env');
mkdirSync(commandDir, { recursive: true });
writeFileSync(envFile, 'OWOX_CTL_ENV_MANAGER_TEST=value\n');

try {
process.chdir(commandDir);
const expectedEnvFile = path.resolve(process.cwd(), '../../.env');

expect(setupEnvironmentFromFlags({ 'env-file': '../../.env' })).toBe(expectedEnvFile);
} finally {
process.chdir(previousCwd);
delete process.env.OWOX_CTL_ENV_MANAGER_TEST;
}
});

it('renders command parse errors as JSON', async () => {
const previousExitCode = process.exitCode;
const stderrWrite = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);

try {
await expect(DataMartsList.run(['--unknown'], process.cwd())).rejects.toMatchObject({
code: 'EEXIT',
});

const stderr = stderrWrite.mock.calls.map(([chunk]) => String(chunk)).join('');
const jsonStart = stderr.indexOf('{');
const jsonEnd = stderr.lastIndexOf('}');

expect(jsonStart).toBeGreaterThanOrEqual(0);
expect(JSON.parse(stderr.slice(jsonStart, jsonEnd + 1))).toEqual({
error: expect.objectContaining({
message: expect.stringContaining('--unknown'),
name: expect.any(String),
}),
});
expect(stderr).not.toContain('USAGE');
} finally {
stderrWrite.mockRestore();
process.exitCode = previousExitCode;
}
});
});
83 changes: 15 additions & 68 deletions apps/ctl/src/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,10 @@ import {
type OWOXApiClientOptions,
} from '@owox/api-client';

import { ConfigStore, resolveAuthConfig } from './config-store.js';
import {
renderJson,
renderTable,
shouldUseColor,
type OutputFormat,
type TableColumn,
} from './output.js';
import { resolveAuthConfig } from './config-store.js';
import { renderJson } from './output.js';

type BaseFlags = {
format?: string;
'no-color'?: boolean;
'env-file'?: string;
};

Expand All @@ -28,15 +20,17 @@ type SetupEnvironment = (config?: EnvSetupConfig) => EnvSetupResult;
export function setupEnvironmentFromFlags(
flags: Pick<BaseFlags, 'env-file'>,
setupEnvironment: SetupEnvironment = EnvManager.setupEnvironment.bind(EnvManager)
): void {
): string | null {
const envFileValue = flags['env-file'];
const envFile = typeof envFileValue === 'string' ? envFileValue : '';
const hasExplicitEnvFile = envFile.trim().length > 0;
const result = setupEnvironment({ envFile });

if (hasExplicitEnvFile && !result.success) {
if (hasExplicitEnvFile && !result.envFilePath) {
throw new OWOXConfigError(`Failed to load environment file: ${envFile}`);
}

return result.envFilePath;
}

export abstract class BaseCommand extends Command {
Expand All @@ -46,70 +40,27 @@ export abstract class BaseCommand extends Command {
description: 'Path to environment file to load variables from',
helpValue: '/path/to/.env',
}),
format: Flags.string({
default: 'table',
description: 'Output format',
options: ['table', 'json'],
}),
'no-color': Flags.boolean({
description: 'Disable color output',
}),
};

protected createClient(config: OWOXApiClientOptions): OWOXApiClient {
return new OWOXApiClient(config);
}

protected loadEnvironment(flags: Pick<BaseFlags, 'env-file'>): void {
setupEnvironmentFromFlags(flags);
}

protected async getAuthenticatedClient(): Promise<OWOXApiClient> {
const resolved = await resolveAuthConfig({ store: new ConfigStore() });

if (!resolved) {
throw new OWOXConfigError(
'Not authenticated. Run owox-ctl auth login or set OWOX_API_ORIGIN, OWOX_API_KEY_ID, and OWOX_API_KEY_SECRET.'
);
}

return this.createClient(resolved.config);
protected loadEnvironment(flags: Pick<BaseFlags, 'env-file'>): string | null {
return setupEnvironmentFromFlags(flags);
}

protected writeRows<T extends Record<string, unknown>>(
rows: T[],
columns: TableColumn<T>[],
flags: BaseFlags
): void {
if (this.outputFormat(flags) === 'json') {
this.log(renderJson(rows));
return;
}

this.log(renderTable(rows, columns));
protected getAuthenticatedClient(): OWOXApiClient {
return this.createClient(resolveAuthConfig());
}

protected handleCliError(error: unknown, flags: BaseFlags): never {
const normalized = this.normalizeError(error);

if (this.outputFormat(flags) === 'json') {
process.stderr.write(`${renderJson({ error: normalized })}\n`);
this.exit(1);
}

this.error(normalized.message, {
code: normalized.code,
exit: 1,
});
protected writeJson(value: unknown): void {
this.log(renderJson(value));
}

protected colorEnabled(flags: BaseFlags): boolean {
return shouldUseColor({
format: this.outputFormat(flags),
noColor: flags['no-color'],
stream: process.stdout,
env: process.env,
});
protected handleCliError(error: unknown): never {
process.stderr.write(`${renderJson({ error: this.normalizeError(error) })}\n`);
this.exit(1);
}

private normalizeError(error: unknown): {
Expand Down Expand Up @@ -138,8 +89,4 @@ export abstract class BaseCommand extends Command {
message: String(error),
};
}

protected outputFormat(flags: BaseFlags): OutputFormat {
return flags.format === 'json' ? 'json' : 'table';
}
}
Loading
Loading