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
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: CI

on:
pull_request:
branches: [master]

jobs:
ci:
name: Lint, Test & Build
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20

- name: Setup pnpm
uses: pnpm/action-setup@v4

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Lint
run: pnpm lint

- name: Test
run: pnpm test

- name: Build
run: pnpm build
66 changes: 33 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,11 @@ Requires Node.js 18 or later.

## Environment Variables

| Variable | Default | Description |
|---|---|---|
| `INSIGHTA_API_URL` | `http://localhost:3000` | Backend base URL |
| `GITHUB_CLIENT_ID` | — | **Required.** GitHub OAuth app client ID for the CLI |
| `INSIGHTA_CALLBACK_PORT` | `9876` | Local port for the OAuth callback server |
| Variable | Default | Description |
| ------------------------ | ----------------------- | ---------------------------------------------------- |
| `INSIGHTA_API_URL` | `http://localhost:3000` | Backend base URL |
| `GITHUB_CLIENT_ID` | — | **Required.** GitHub OAuth app client ID for the CLI |
| `INSIGHTA_CALLBACK_PORT` | `9876` | Local port for the OAuth callback server |

Set these in your shell profile or a `.env` file before running the CLI.

Expand Down Expand Up @@ -111,10 +111,10 @@ If the browser flow is not completed within 5 minutes, the CLI times out and exi

The backend enforces all role restrictions. The CLI surfaces permission errors clearly.

| Role | Permitted commands |
|---|---|
| Role | Permitted commands |
| --------- | ----------------------------------------------------- |
| `analyst` | `list`, `get`, `search`, `export`, `whoami`, `logout` |
| `admin` | All analyst commands + `create`, `delete` |
| `admin` | All analyst commands + `create`, `delete` |

When a 403 is received, the error message includes the user's current role. The CLI never enforces roles locally.

Expand Down Expand Up @@ -177,43 +177,43 @@ insighta profiles --help

## `profiles list` Options

| Flag | Type | Description |
|---|---|---|
| `--gender` | `male` \| `female` | Filter by gender |
| `--country` | string | ISO 3166-1 alpha-2 country code (e.g. `NG`, `US`) |
| `--age-group` | `child` \| `teenager` \| `adult` \| `senior` | Filter by age group |
| `--min-age` | number | Minimum age inclusive |
| `--max-age` | number | Maximum age inclusive |
| `--sort-by` | `age` \| `created_at` \| `gender_probability` | Sort field |
| `--order` | `asc` \| `desc` | Sort direction |
| `--page` | number | Page number (default: 1) |
| `--limit` | number | Results per page (default: 10, max: 50) |
| Flag | Type | Description |
| ------------- | --------------------------------------------- | ------------------------------------------------- |
| `--gender` | `male` \| `female` | Filter by gender |
| `--country` | string | ISO 3166-1 alpha-2 country code (e.g. `NG`, `US`) |
| `--age-group` | `child` \| `teenager` \| `adult` \| `senior` | Filter by age group |
| `--min-age` | number | Minimum age inclusive |
| `--max-age` | number | Maximum age inclusive |
| `--sort-by` | `age` \| `created_at` \| `gender_probability` | Sort field |
| `--order` | `asc` \| `desc` | Sort direction |
| `--page` | number | Page number (default: 1) |
| `--limit` | number | Results per page (default: 10, max: 50) |

---

## `profiles export` Options

| Flag | Type | Description |
|---|---|---|
| `--format` | `csv` | **Required.** Export format |
| `--gender` | `male` \| `female` | Filter by gender |
| `--country` | string | ISO 3166-1 alpha-2 country code |
| Flag | Type | Description |
| ----------- | ------------------ | ------------------------------- |
| `--format` | `csv` | **Required.** Export format |
| `--gender` | `male` \| `female` | Filter by gender |
| `--country` | string | ISO 3166-1 alpha-2 country code |

The CSV file is saved to the current working directory with a timestamped filename.

---

## Error Handling

| Error | Cause | CLI output |
|---|---|---|
| `NotLoggedInError` | No credentials file | `Not logged in. Run insighta login to authenticate.` |
| `ForbiddenError` | 403 from backend | `Permission denied: ... Your current role is analyst.` |
| `NotFoundError` | 404 from backend | `No profile found with ID <id>.` |
| `ValidationError` | 422 from backend | `Validation failed: <message>` |
| `RateLimitError` | 429 from backend | `Rate limited. Please wait N seconds before retrying.` |
| `NetworkError` | Fetch failed | `Network error: <operation> failed — <details>` |
| `TimeoutError` | OAuth not completed in 5 min | `Login timed out.` |
| Error | Cause | CLI output |
| ------------------ | ---------------------------- | ------------------------------------------------------ |
| `NotLoggedInError` | No credentials file | `Not logged in. Run insighta login to authenticate.` |
| `ForbiddenError` | 403 from backend | `Permission denied: ... Your current role is analyst.` |
| `NotFoundError` | 404 from backend | `No profile found with ID <id>.` |
| `ValidationError` | 422 from backend | `Validation failed: <message>` |
| `RateLimitError` | 429 from backend | `Rate limited. Please wait N seconds before retrying.` |
| `NetworkError` | Fetch failed | `Network error: <operation> failed — <details>` |
| `TimeoutError` | OAuth not completed in 5 min | `Login timed out.` |

---

Expand Down
123 changes: 66 additions & 57 deletions bin/insighta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,121 +3,130 @@
// Global error handler — must be registered before any async work.
// In production: print only the message, no stack trace (Requirement 8.3).
// In development: print the full error with stack.
process.on('uncaughtException', (err: Error) => {
if (process.env.NODE_ENV === 'development') {
process.on("uncaughtException", (err: Error) => {
if (process.env.NODE_ENV === "development") {
console.error(err);
} else {
console.error(`Error: ${err.message}`);
}
process.exit(1);
});

process.on('unhandledRejection', (reason: unknown) => {
process.on("unhandledRejection", (reason: unknown) => {
const err = reason instanceof Error ? reason : new Error(String(reason));
if (process.env.NODE_ENV === 'development') {
if (process.env.NODE_ENV === "development") {
console.error(err);
} else {
console.error(`Error: ${err.message}`);
}
process.exit(1);
});

import { Command } from 'commander';
import { readFileSync } from 'fs';
import { join } from 'path';

import { login } from '../src/commands/login';
import { logout } from '../src/commands/logout';
import { whoami } from '../src/commands/whoami';
import { listProfiles, type ListProfilesOptions } from '../src/commands/profiles/list';
import { getProfile } from '../src/commands/profiles/get';
import { createProfile } from '../src/commands/profiles/create';
import { deleteProfile } from '../src/commands/profiles/delete';
import { searchProfiles } from '../src/commands/profiles/search';
import { exportProfiles, type ExportProfilesOptions } from '../src/commands/profiles/export';
import { Command } from "commander";
import { readFileSync } from "fs";
import { join } from "path";

import { login } from "../src/commands/login";
import { logout } from "../src/commands/logout";
import { whoami } from "../src/commands/whoami";
import {
listProfiles,
type ListProfilesOptions,
} from "../src/commands/profiles/list";
import { getProfile } from "../src/commands/profiles/get";
import { createProfile } from "../src/commands/profiles/create";
import { deleteProfile } from "../src/commands/profiles/delete";
import { searchProfiles } from "../src/commands/profiles/search";
import {
exportProfiles,
type ExportProfilesOptions,
} from "../src/commands/profiles/export";

// Read version from package.json (dist/bin/ → ../../package.json)
const pkg = JSON.parse(
readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')
readFileSync(join(__dirname, "..", "..", "package.json"), "utf-8"),
) as { version: string };

const program = new Command();

program
.name('insighta')
.description('CLI for the Insighta Labs+ platform')
.version(pkg.version, '-v, --version');
.name("insighta")
.description("CLI for the Insighta Labs+ platform")
.version(pkg.version, "-v, --version");

// ── Auth commands ────────────────────────────────────────────────────────────

program
.command('login')
.description('Log in with your GitHub account')
.command("login")
.description("Log in with your GitHub account")
.action(login);

program
.command('logout')
.description('Log out and revoke your session')
.command("logout")
.description("Log out and revoke your session")
.action(logout);

program
.command('whoami')
.description('Show the currently authenticated user')
.command("whoami")
.description("Show the currently authenticated user")
.action(whoami);

// ── Profiles subcommands ─────────────────────────────────────────────────────

const profiles = program
.command('profiles')
.description('Manage profiles');
const profiles = program.command("profiles").description("Manage profiles");

profiles
.command('list')
.description('List profiles with optional filters')
.option('--gender <value>', 'Filter by gender (male|female)')
.option('--country <code>', 'Filter by country code (e.g. NG)')
.option('--age-group <value>', 'Filter by age group (adult|child|teenager|senior)')
.option('--min-age <n>', 'Filter by minimum age')
.option('--max-age <n>', 'Filter by maximum age')
.option('--sort-by <field>', 'Sort results by field (e.g. age)')
.option('--order <asc|desc>', 'Sort order (asc or desc)')
.option('--page <n>', 'Page number (positive integer)')
.option('--limit <n>', 'Results per page (positive integer)')
.command("list")
.description("List profiles with optional filters")
.option("--gender <value>", "Filter by gender (male|female)")
.option("--country <code>", "Filter by country code (e.g. NG)")
.option(
"--age-group <value>",
"Filter by age group (adult|child|teenager|senior)",
)
.option("--min-age <n>", "Filter by minimum age")
.option("--max-age <n>", "Filter by maximum age")
.option("--sort-by <field>", "Sort results by field (e.g. age)")
.option("--order <asc|desc>", "Sort order (asc or desc)")
.option("--page <n>", "Page number (positive integer)")
.option("--limit <n>", "Results per page (positive integer)")
.action((opts: ListProfilesOptions) => listProfiles(opts));

profiles
.command('get <id>')
.description('Get a profile by ID')
.command("get <id>")
.description("Get a profile by ID")
.action((id: string) => getProfile(id));

profiles
.command('create')
.description('Create a new profile (admin only)')
.requiredOption('--name <name>', 'Name of the profile to create')
.command("create")
.description("Create a new profile (admin only)")
.requiredOption("--name <name>", "Name of the profile to create")
.action((opts: { name: string }) => createProfile(opts));

profiles
.command('delete <id>')
.description('Delete a profile by ID (admin only)')
.command("delete <id>")
.description("Delete a profile by ID (admin only)")
.action((id: string) => deleteProfile(id));

profiles
.command('search <query>')
.description('Search profiles using natural language')
.command("search <query>")
.description("Search profiles using natural language")
.action((query: string) => searchProfiles(query));

profiles
.command('export')
.description('Export profiles to a file')
.requiredOption('--format <format>', 'Export format (csv)')
.option('--gender <value>', 'Filter by gender (male|female)')
.option('--country <code>', 'Filter by country code (e.g. NG)')
.command("export")
.description("Export profiles to a file")
.requiredOption("--format <format>", "Export format (csv)")
.option("--gender <value>", "Filter by gender (male|female)")
.option("--country <code>", "Filter by country code (e.g. NG)")
.action((opts: ExportProfilesOptions) => exportProfiles(opts));

// ── Unrecognized commands ────────────────────────────────────────────────────

program.on('command:*', (operands: string[]) => {
console.error(`Unknown command: \`${operands[0]}\`. Run \`insighta --help\` for available commands.`);
program.on("command:*", (operands: string[]) => {
console.error(
`Unknown command: \`${operands[0]}\`. Run \`insighta --help\` for available commands.`,
);
process.exit(1);
});

Expand Down
41 changes: 31 additions & 10 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,44 @@
import tsParser from '@typescript-eslint/parser';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import tsParser from "@typescript-eslint/parser";
import tsPlugin from "@typescript-eslint/eslint-plugin";

const sharedRules = {
...tsPlugin.configs.recommended.rules,
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "warn",
};

export default [
// Source files — use strict tsconfig (excludes test files)
{
files: ['src/**/*.ts', 'bin/**/*.ts'],
files: ["src/**/*.ts", "bin/**/*.ts"],
ignores: ["src/**/*.test.ts", "src/**/*.spec.ts"],
languageOptions: {
parser: tsParser,
parserOptions: {
project: './tsconfig.json',
project: "./tsconfig.json",
},
},
plugins: {
'@typescript-eslint': tsPlugin,
"@typescript-eslint": tsPlugin,
},
rules: {
...tsPlugin.configs.recommended.rules,
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
rules: sharedRules,
},
// Test files — use tsconfig.test.json which includes test files
{
files: ["src/**/*.test.ts", "src/**/*.spec.ts"],
languageOptions: {
parser: tsParser,
parserOptions: {
project: "./tsconfig.test.json",
},
},
plugins: {
"@typescript-eslint": tsPlugin,
},
rules: sharedRules,
},
];
Loading
Loading