@norbix.ai/cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +125 -13
  2. package/dist/base.js +92 -23
  3. package/dist/commands/api.js +15 -81
  4. package/dist/commands/configure.js +68 -0
  5. package/dist/commands/env/use.js +36 -7
  6. package/dist/commands/files/delete.js +1 -1
  7. package/dist/commands/files/download.js +1 -1
  8. package/dist/commands/files/info.js +1 -1
  9. package/dist/commands/files/list.js +1 -1
  10. package/dist/commands/files/publish.js +38 -0
  11. package/dist/commands/files/sign.js +1 -1
  12. package/dist/commands/files/unpublish.js +34 -0
  13. package/dist/commands/files/upload.js +1 -1
  14. package/dist/commands/hub.js +24 -0
  15. package/dist/commands/login.js +82 -36
  16. package/dist/commands/logout.js +11 -13
  17. package/dist/commands/profiles.js +25 -0
  18. package/dist/commands/push/archive.js +4 -1
  19. package/dist/commands/push/clone.js +4 -1
  20. package/dist/commands/push/delete.js +4 -1
  21. package/dist/commands/push/device/register.js +35 -0
  22. package/dist/commands/push/integration/default.js +19 -0
  23. package/dist/commands/push/integration/delete.js +28 -0
  24. package/dist/commands/push/integration/disable.js +19 -0
  25. package/dist/commands/push/integration/enable.js +19 -0
  26. package/dist/commands/push/integration/test.js +30 -0
  27. package/dist/commands/push/integration.js +16 -0
  28. package/dist/commands/push/integrations.js +20 -0
  29. package/dist/commands/push/preview.js +19 -0
  30. package/dist/commands/push/settings.js +16 -0
  31. package/dist/commands/push/unarchive.js +4 -1
  32. package/dist/commands/raw.js +86 -0
  33. package/dist/commands/whoami.js +25 -8
  34. package/dist/help.js +18 -0
  35. package/dist/lib/device-login.js +76 -0
  36. package/dist/lib/dispatch.js +151 -0
  37. package/dist/lib/namespace-command.js +148 -0
  38. package/dist/lib/profiles.js +138 -0
  39. package/dist/lib/publicFiles.js +63 -0
  40. package/oclif.manifest.json +3166 -725
  41. package/package.json +24 -12
@@ -0,0 +1,86 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import { BaseCommand } from '../base.js';
3
+ import { readJsonInput } from '../lib/json.js';
4
+ /**
5
+ * Raw escape hatch (like `gh api`): call ANY Norbix endpoint, including the
6
+ * ones this CLI has no dedicated command for yet. Sends the same auth and
7
+ * scope headers the SDK transport sends.
8
+ */
9
+ export default class Raw extends BaseCommand {
10
+ static description = `Low-level escape hatch: call a raw HTTP path on the hub (default) or API.
11
+
12
+ Prefer \`norbix hub ...\` / \`norbix api ...\` — they take plain words.
13
+ Use this only for endpoints the SDK does not know yet. Sends your auth token
14
+ plus project/account/env/region headers automatically. Use {version} in the
15
+ path and it becomes v2.`;
16
+ static examples = [
17
+ '<%= config.bin %> api /v2/logs/settings --hub',
18
+ `<%= config.bin %> api '/{version}/database/collections/orders/count' --query filter='{"status":"paid"}'`,
19
+ `<%= config.bin %> api /v2/scheduler/tasks --hub --method POST --body '{"name":"nightly",...}'`,
20
+ ];
21
+ static args = {
22
+ path: Args.string({ required: true, description: 'Endpoint path, e.g. /v2/logs or /{version}/logs' }),
23
+ };
24
+ static flags = {
25
+ api: Flags.boolean({ description: 'Call the data-plane API service instead of the hub (hub is the default)', default: false }),
26
+ method: Flags.string({ char: 'X', description: 'HTTP method', default: 'GET', options: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] }),
27
+ body: Flags.string({ char: 'b', description: 'JSON request body (or `-` for stdin)' }),
28
+ query: Flags.string({ char: 'q', description: 'Query parameter key=value (repeatable)', multiple: true }),
29
+ };
30
+ async run() {
31
+ const { args, flags } = await this.parse(Raw);
32
+ const ctx = this.resolveContext(flags);
33
+ this.assertEndpoints(ctx);
34
+ if (!ctx.projectId)
35
+ this.error('No project ID configured. Run `norbix login` or pass --project.');
36
+ const token = ctx.bearerToken ?? ctx.apiKey;
37
+ if (!token)
38
+ this.error('Not authenticated. Run `norbix login` or pass --api-key.');
39
+ // resolveContext already applied profile overrides, defaults (.ai) and
40
+ // the region subdomain.
41
+ const base = flags.api ? ctx.apiUrl : ctx.hubUrl;
42
+ const path = args.path.replace('{version}', 'v2');
43
+ const url = new URL(base.replace(/\/$/, '') + (path.startsWith('/') ? path : `/${path}`));
44
+ for (const pair of flags.query ?? []) {
45
+ const eq = pair.indexOf('=');
46
+ if (eq < 1)
47
+ this.error(`--query must be key=value, got "${pair}"`);
48
+ url.searchParams.append(pair.slice(0, eq), pair.slice(eq + 1));
49
+ }
50
+ const headers = {
51
+ Accept: 'application/json',
52
+ Authorization: `Bearer ${token}`,
53
+ 'norbix-project-id': ctx.projectId,
54
+ 'X-CM-ProjectId': ctx.projectId,
55
+ };
56
+ if (ctx.accountId) {
57
+ headers['norbix-account-id'] = ctx.accountId;
58
+ headers['X-CM-AccountId'] = ctx.accountId;
59
+ }
60
+ if (ctx.env && ctx.env !== 'PROD')
61
+ headers['norbix-env'] = ctx.env;
62
+ if (ctx.region)
63
+ headers['nb-region'] = ctx.region;
64
+ let body;
65
+ if (flags.body) {
66
+ body = await readJsonInput(flags.body, 'body');
67
+ headers['Content-Type'] = 'application/json';
68
+ }
69
+ const res = await fetch(url, { method: flags.method, headers, body });
70
+ const text = await res.text();
71
+ let data;
72
+ try {
73
+ data = JSON.parse(text);
74
+ }
75
+ catch {
76
+ data = text;
77
+ }
78
+ if (!res.ok) {
79
+ this.warn(`HTTP ${res.status} ${res.statusText}`);
80
+ this.print(data);
81
+ this.exit(1);
82
+ }
83
+ this.print(data);
84
+ return data;
85
+ }
86
+ }
@@ -1,17 +1,31 @@
1
1
  import { BaseCommand } from '../base.js';
2
+ import { PROFILES_PATH, SESSION_PATH } from '../lib/profiles.js';
2
3
  import { redact } from '../lib/store.js';
3
4
  export default class Whoami extends BaseCommand {
4
- static description = 'Show the current CLI context and verify it against the server';
5
- static examples = ['<%= config.bin %> whoami', '<%= config.bin %> whoami --json'];
5
+ static description = 'Show the resolved context (profile, auth source, endpoints) and verify it against the server';
6
+ static examples = [
7
+ '<%= config.bin %> whoami',
8
+ '<%= config.bin %> whoami --profile fitskin-prod',
9
+ '<%= config.bin %> whoami --json',
10
+ ];
6
11
  async run() {
7
12
  const { flags } = await this.parse(Whoami);
8
13
  const ctx = this.resolveContext(flags);
9
14
  const summary = {
15
+ profile: ctx.profileName ?? '(none)',
16
+ auth: ctx.authSource === 'session' ? `session (${ctx.userName ?? 'user'})` : ctx.authSource,
17
+ apiKey: ctx.apiKey ? redact(ctx.apiKey) : undefined,
10
18
  projectId: ctx.projectId,
19
+ accountId: ctx.accountId,
11
20
  env: ctx.env ?? 'PROD',
12
- region: ctx.region ?? '(project default)',
13
- auth: ctx.bearerToken ? `login token (${ctx.stored.userName ?? ctx.stored.userId ?? 'user'})` : ctx.apiKey ? `api key ${redact(ctx.apiKey)}` : 'none',
14
- configFile: this.config.configDir,
21
+ region: ctx.region ??
22
+ (ctx.usesDefaultEndpoints
23
+ ? '(NOT SET — required with default endpoints!)'
24
+ : '(not set — optional with custom endpoints)'),
25
+ apiUrl: ctx.apiUrl,
26
+ hubUrl: ctx.hubUrl,
27
+ profilesFile: PROFILES_PATH,
28
+ sessionFile: SESSION_PATH,
15
29
  };
16
30
  let verified = false;
17
31
  let environments;
@@ -21,7 +35,7 @@ export default class Whoami extends BaseCommand {
21
35
  const client = this.client(flags);
22
36
  const res = await client.hub.environments.list();
23
37
  const item = res.item;
24
- environments = (item?.environments ?? []).map((e) => (typeof e === 'string' ? e : (e.name ?? String(e))));
38
+ environments = (item?.environments ?? []).map((e) => typeof e === 'string' ? e : (e.name ?? String(e)));
25
39
  verified = true;
26
40
  }
27
41
  catch (error) {
@@ -29,11 +43,14 @@ export default class Whoami extends BaseCommand {
29
43
  }
30
44
  }
31
45
  this.print([
46
+ `Profile: ${summary.profile}`,
47
+ `Auth: ${summary.auth}${summary.apiKey ? ` (${summary.apiKey})` : ''}`,
32
48
  `Project: ${summary.projectId ?? '(not set)'}`,
49
+ `Account: ${summary.accountId ?? '(not set)'}`,
33
50
  `Env: ${summary.env}`,
34
51
  `Region: ${summary.region}`,
35
- `Auth: ${summary.auth}`,
36
- `Config: ${summary.configFile}`,
52
+ `API: ${summary.apiUrl}`,
53
+ `Hub: ${summary.hubUrl}`,
37
54
  verified
38
55
  ? `Server: OK${environments?.length ? ` — environments: ${environments.join(', ')}` : ''}`
39
56
  : `Server: not verified${verifyError ? ` (${verifyError})` : ''}`,
package/dist/help.js ADDED
@@ -0,0 +1,18 @@
1
+ import { Help } from '@oclif/core';
2
+ /**
3
+ * Custom help router. oclif intercepts --help before a command runs, which
4
+ * would hide the scoped help of the dynamic namespaces. For
5
+ * `norbix hub <words...> --help` (and `api`), delegate back to the command
6
+ * with an internal marker so it can print help for exactly that scope.
7
+ */
8
+ export default class NorbixHelp extends Help {
9
+ async showHelp(argv) {
10
+ const words = argv.filter((a) => a !== '--help' && a !== '-h');
11
+ const [first, ...rest] = words;
12
+ if ((first === 'hub' || first === 'api') && rest.length > 0) {
13
+ await this.config.runCommand(first, [...rest, '--scope-help']);
14
+ return;
15
+ }
16
+ return super.showHelp(argv);
17
+ }
18
+ }
@@ -0,0 +1,76 @@
1
+ import { spawn } from 'node:child_process';
2
+ export class DeviceFlowUnsupportedError extends Error {
3
+ }
4
+ export async function startDeviceFlow(hubUrl, projectId) {
5
+ let res;
6
+ try {
7
+ res = await fetch(`${hubUrl.replace(/\/$/, '')}/v2/auth/device/start`, {
8
+ method: 'POST',
9
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
10
+ body: JSON.stringify({ clientName: 'norbix-cli', projectId }),
11
+ });
12
+ }
13
+ catch (error) {
14
+ throw new DeviceFlowUnsupportedError(`Could not reach ${hubUrl}: ${error instanceof Error ? error.message : String(error)}`);
15
+ }
16
+ // 404/405/501 → the hub does not implement browser login (yet).
17
+ if ([404, 405, 501].includes(res.status)) {
18
+ throw new DeviceFlowUnsupportedError(`Hub returned HTTP ${res.status} for the device flow.`);
19
+ }
20
+ if (!res.ok)
21
+ throw new Error(`Device login start failed: HTTP ${res.status}`);
22
+ const data = (await res.json());
23
+ if (!data.deviceCode || !data.userCode || !data.verificationUri) {
24
+ throw new Error('Device login start returned an unexpected response.');
25
+ }
26
+ return data;
27
+ }
28
+ /** Poll until the user approves in the browser (or the code expires). */
29
+ export async function pollDeviceToken(hubUrl, start, log) {
30
+ const deadline = Date.now() + (start.expiresIn ?? 600) * 1000;
31
+ let intervalMs = (start.interval ?? 5) * 1000;
32
+ while (Date.now() < deadline) {
33
+ await sleep(intervalMs);
34
+ const res = await fetch(`${hubUrl.replace(/\/$/, '')}/v2/auth/device/token`, {
35
+ method: 'POST',
36
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
37
+ body: JSON.stringify({ deviceCode: start.deviceCode }),
38
+ });
39
+ if (res.status === 428)
40
+ continue; // still pending
41
+ const data = (await res.json().catch(() => ({})));
42
+ if (data.error === 'authorization_pending')
43
+ continue;
44
+ if (data.error === 'slow_down') {
45
+ intervalMs += 5000;
46
+ continue;
47
+ }
48
+ if (data.error === 'access_denied')
49
+ throw new Error('Login was denied in the browser.');
50
+ if (data.error === 'expired_token')
51
+ break;
52
+ if (!res.ok)
53
+ throw new Error(`Device login failed: HTTP ${res.status}`);
54
+ if (data.bearerToken)
55
+ return data;
56
+ log('Unexpected response while waiting — retrying...');
57
+ }
58
+ throw new Error('The login code expired. Run `norbix login` again.');
59
+ }
60
+ /** Open a URL in the default browser — macOS, Linux, Windows. */
61
+ export function openBrowser(url) {
62
+ const [cmd, args] = process.platform === 'darwin'
63
+ ? ['open', [url]]
64
+ : process.platform === 'win32'
65
+ ? ['cmd', ['/c', 'start', '', url]]
66
+ : ['xdg-open', [url]];
67
+ try {
68
+ spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
69
+ }
70
+ catch {
71
+ // Browser could not be opened — the URL is printed anyway.
72
+ }
73
+ }
74
+ function sleep(ms) {
75
+ return new Promise((resolve) => setTimeout(resolve, ms));
76
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Dynamic dispatcher: turns
3
+ *
4
+ * norbix hub database aggregates delete maggr_123 --schemaId sch_456
5
+ *
6
+ * into `client.hub.database.deleteDatabaseAggregate({id, schemaId})`.
7
+ *
8
+ * Grammar: norbix {hub|api} {module} {words...} {positional id} {--field value}
9
+ *
10
+ * The SDK namespaces are auto-generated and uniform (every method takes one
11
+ * request object), so the CLI can discover modules and methods at runtime —
12
+ * every current AND future SDK method is callable without new command files.
13
+ */
14
+ /** Split leftover argv into words, positional values and --field values. */
15
+ export function parseArgv(argv) {
16
+ const words = [];
17
+ const positionals = [];
18
+ const fields = {};
19
+ let yes = false;
20
+ let dryRun = false;
21
+ for (let i = 0; i < argv.length; i++) {
22
+ const token = argv[i];
23
+ if (token === '--yes' || token === '-y') {
24
+ yes = true;
25
+ }
26
+ else if (token === '--dry-run') {
27
+ dryRun = true;
28
+ }
29
+ else if (token.startsWith('--')) {
30
+ const body = token.slice(2);
31
+ const eq = body.indexOf('=');
32
+ if (eq > 0) {
33
+ // --field=value syntax
34
+ fields[body.slice(0, eq)] = coerce(body.slice(eq + 1));
35
+ }
36
+ else {
37
+ const next = argv[i + 1];
38
+ if (next !== undefined && !next.startsWith('--')) {
39
+ fields[body] = coerce(next);
40
+ i++;
41
+ }
42
+ else {
43
+ fields[body] = true;
44
+ }
45
+ }
46
+ }
47
+ else if (/^[a-z][a-z-]*$/i.test(token) && !looksLikeId(token)) {
48
+ words.push(token);
49
+ }
50
+ else {
51
+ positionals.push(token);
52
+ }
53
+ }
54
+ return { words, positionals, fields, yes, dryRun };
55
+ }
56
+ /** IDs look like maggr_..., sch_..., 66b2f0a1..., UPPER env names, etc. */
57
+ function looksLikeId(token) {
58
+ return /[_0-9]/.test(token) || token === token.toUpperCase();
59
+ }
60
+ function coerce(value) {
61
+ if (value === 'true')
62
+ return true;
63
+ if (value === 'false')
64
+ return false;
65
+ if (/^-?\d+$/.test(value))
66
+ return Number(value);
67
+ return value;
68
+ }
69
+ function camelSplit(name) {
70
+ const tokens = name
71
+ .replaceAll(/([a-z])([A-Z])/g, '$1 $2')
72
+ .toLowerCase()
73
+ .split(' ');
74
+ // unArchive → "unarchive", one verb.
75
+ const merged = [];
76
+ for (let i = 0; i < tokens.length; i++) {
77
+ if (tokens[i] === 'un' && tokens[i + 1]) {
78
+ merged.push('un' + tokens[++i]);
79
+ }
80
+ else {
81
+ merged.push(tokens[i]);
82
+ }
83
+ }
84
+ return merged;
85
+ }
86
+ export { camelSplit };
87
+ function singular(word) {
88
+ if (word.endsWith('ies'))
89
+ return word.slice(0, -3) + 'y';
90
+ if (word.endsWith('s') && !word.endsWith('ss'))
91
+ return word.slice(0, -1);
92
+ return word;
93
+ }
94
+ /**
95
+ * Match user words against SDK method names. "aggregates get" prefers
96
+ * getDatabaseAggregates (plural = list); "aggregate get" (or a positional id)
97
+ * prefers getDatabaseAggregate.
98
+ */
99
+ export function matchMethods(methods, words, moduleName) {
100
+ const userWords = words.map((w) => w.toLowerCase().replaceAll('-', ''));
101
+ const moduleTokens = new Set(camelSplit(moduleName));
102
+ const matches = [];
103
+ for (const method of methods) {
104
+ // Exact camelCase method name typed by the user.
105
+ if (words.length === 1 && words[0] === method)
106
+ return [{ method, exact: 99, extra: 0 }];
107
+ const tokens = camelSplit(method);
108
+ let exact = 0;
109
+ let ok = true;
110
+ for (const w of userWords) {
111
+ if (tokens.includes(w)) {
112
+ exact++;
113
+ }
114
+ else if (tokens.some((t) => singular(t) === singular(w))) {
115
+ // singular/plural tolerant match
116
+ }
117
+ else {
118
+ ok = false;
119
+ break;
120
+ }
121
+ }
122
+ if (!ok)
123
+ continue;
124
+ const extra = tokens.filter((t) => !moduleTokens.has(t) && !userWords.some((w) => singular(w) === singular(t))).length;
125
+ matches.push({ method, exact, extra });
126
+ }
127
+ matches.sort((a, b) => b.exact - a.exact || a.extra - b.extra || a.method.localeCompare(b.method));
128
+ // Keep only the best-scoring group so callers can detect ambiguity.
129
+ if (matches.length > 1) {
130
+ const best = matches[0];
131
+ return matches.filter((m) => m.exact === best.exact && m.extra === best.extra);
132
+ }
133
+ return matches;
134
+ }
135
+ const DESTRUCTIVE_VERBS = new Set(['delete', 'remove', 'clean', 'regenerate', 'rotate', 'stop']);
136
+ export function isDestructive(method) {
137
+ return DESTRUCTIVE_VERBS.has(camelSplit(method)[0]);
138
+ }
139
+ /**
140
+ * Module aliases so daily language works: `norbix hub db ...`, and
141
+ * `norbix hub email templates get` routes into the notifications module
142
+ * with "email" injected as a matching word.
143
+ */
144
+ export const MODULE_ALIASES = {
145
+ db: { module: 'database' },
146
+ email: { module: 'notifications', injectWord: 'email' },
147
+ envs: { module: 'environments' },
148
+ fs: { module: 'files' },
149
+ push: { module: 'notifications', injectWord: 'push' },
150
+ sms: { module: 'notifications', injectWord: 'sms' },
151
+ };
@@ -0,0 +1,148 @@
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { Norbix } from '@norbix.ai/ts';
3
+ import { BaseCommand } from '../base.js';
4
+ import { MODULE_ALIASES, camelSplit, isDestructive, matchMethods, parseArgv } from './dispatch.js';
5
+ /**
6
+ * Shared engine for `norbix hub ...` and `norbix api ...` — resolves the
7
+ * module and method from plain words and calls the SDK.
8
+ */
9
+ export class NamespaceCommand extends BaseCommand {
10
+ /**
11
+ * oclif rejects unknown flags even in non-strict mode, but here unknown
12
+ * flags ARE the request fields (--schemaId ...). Split the raw argv:
13
+ * known global flags go to oclif, everything else goes to the dispatcher.
14
+ */
15
+ splitArgv() {
16
+ const VALUE_FLAGS = new Set(['--project', '--env', '--region', '--api-key', '--account', '--profile']);
17
+ const BOOL_FLAGS = new Set(['--json']);
18
+ const flagArgv = [];
19
+ const rest = [];
20
+ let help = false;
21
+ const raw = this.argv;
22
+ for (let i = 0; i < raw.length; i++) {
23
+ const token = raw[i];
24
+ const name = token.split('=')[0];
25
+ if (name === '--help' || name === '-h' || name === '--scope-help') {
26
+ help = true;
27
+ }
28
+ else if (VALUE_FLAGS.has(name)) {
29
+ flagArgv.push(token);
30
+ if (!token.includes('=') && raw[i + 1] !== undefined)
31
+ flagArgv.push(raw[++i]);
32
+ }
33
+ else if (BOOL_FLAGS.has(name)) {
34
+ flagArgv.push(token);
35
+ }
36
+ else {
37
+ rest.push(token);
38
+ }
39
+ }
40
+ return { flagArgv, rest, help };
41
+ }
42
+ /** camelCase method → the plain-word form of this CLI (noun first, verb last). */
43
+ wordForm(method, moduleName, hide = new Set()) {
44
+ const tokens = camelSplit(method);
45
+ const moduleTokens = new Set(camelSplit(moduleName));
46
+ const [verb, ...rest] = tokens;
47
+ const nouns = rest.filter((t) => !moduleTokens.has(t) && !hide.has(t));
48
+ return [...nouns, verb].join(' ');
49
+ }
50
+ /** Scoped help: everything callable under `norbix {target} {module} {words...}`. */
51
+ scopeHelp(moduleWord, moduleName, methods, words, labelWords = words, hideWord) {
52
+ const scoped = words.length === 0
53
+ ? methods
54
+ : methods.filter((m) => matchMethods([m], words, moduleName).length > 0);
55
+ // The alias word (email/sms/push) is implied by the module word — hide it
56
+ // from the printed forms so `hub email ...` reads naturally.
57
+ const hide = new Set(hideWord ? [hideWord] : []);
58
+ const list = scoped.length > 0 ? scoped : methods;
59
+ const rows = list
60
+ .map((m) => {
61
+ const form = this.wordForm(m, moduleName, hide);
62
+ const marker = isDestructive(m) ? ' (asks confirmation)' : '';
63
+ return ` norbix ${this.target} ${moduleWord} ${form}${marker}`;
64
+ })
65
+ .join('\n');
66
+ this.print(`Commands in scope "${this.target} ${moduleWord}${labelWords.length > 0 ? ' ' + labelWords.join(' ') : ''}"` +
67
+ `${scoped.length === 0 ? ' (no exact scope match — showing the whole module)' : ''}:\n\n${rows}\n\n` +
68
+ `Add an ID as a plain value and request fields as flags:\n` +
69
+ ` norbix ${this.target} ${moduleWord} ${this.wordForm(list[0], moduleName, hide)} <id> --someField value\n\n` +
70
+ `Useful extras: --dry-run (preview the call), --yes (skip confirmation),\n` +
71
+ `--json (machine output), --profile/--env/--region/--project (context).`);
72
+ return { scope: `${this.target}.${moduleName}`, commands: list.map((m) => this.wordForm(m, moduleName, hide)) };
73
+ }
74
+ async dispatch(argv, flags, help = false) {
75
+ const parsed = parseArgv(argv);
76
+ const [moduleWord, ...restWords] = parsed.words;
77
+ // Introspection client — never used for requests.
78
+ const probe = new Norbix({ projectId: 'introspect', apiKey: 'introspect' }, { envSource: {} });
79
+ const namespace = probe[this.target];
80
+ const moduleNames = Object.keys(namespace).sort();
81
+ if (!moduleWord) {
82
+ this.print(`Usage: norbix ${this.target} <module> <words...> [id] [--field value]\n\nModules:\n ${moduleNames.join('\n ')}\n\n` +
83
+ `Get help at any level:\n norbix ${this.target} <module> --help\n norbix ${this.target} <module> <words...> --help\n\n` +
84
+ `Example: norbix ${this.target} database aggregates delete maggr_123 --schemaId sch_456`);
85
+ return { modules: moduleNames };
86
+ }
87
+ const alias = MODULE_ALIASES[moduleWord];
88
+ const moduleName = alias && namespace[alias.module] ? alias.module : moduleWord;
89
+ const sdkModule = namespace[moduleName];
90
+ if (!sdkModule) {
91
+ this.error(`Unknown module "${moduleWord}" on ${this.target}.\nAvailable: ${moduleNames.join(', ')}`);
92
+ }
93
+ // Only real SDK methods — internals like `transport` are not callable.
94
+ const methods = Object.keys(sdkModule)
95
+ .filter((k) => typeof sdkModule[k] === 'function')
96
+ .sort();
97
+ if (help) {
98
+ const injected = alias?.injectWord && moduleName !== moduleWord ? alias.injectWord : undefined;
99
+ const helpWords = injected ? [injected, ...restWords] : [...restWords];
100
+ return this.scopeHelp(moduleWord, moduleName, methods, helpWords, restWords, injected);
101
+ }
102
+ if (restWords.length === 0 && parsed.positionals.length === 0) {
103
+ this.print(`Methods of ${this.target}.${moduleName}:\n ${methods.join('\n ')}\n\nCall one with plain words, e.g.:\n norbix ${this.target} ${moduleWord} aggregates get\n norbix ${this.target} ${moduleWord} aggregates delete <id>`);
104
+ return { module: moduleName, methods };
105
+ }
106
+ const words = [...restWords];
107
+ if (alias?.injectWord && moduleName !== moduleWord)
108
+ words.unshift(alias.injectWord);
109
+ const matches = matchMethods(methods, words, moduleName);
110
+ if (matches.length === 0) {
111
+ // Show only methods sharing at least one word — the full list can be 100+.
112
+ const lower = words.map((w) => w.toLowerCase().replace(/s$/, ''));
113
+ const related = methods.filter((m) => lower.some((w) => m.toLowerCase().includes(w)));
114
+ const shown = (related.length > 0 ? related : methods).slice(0, 25);
115
+ this.error(`No ${this.target}.${moduleName} method matches "${words.join(' ')}".\n` +
116
+ `${related.length > 0 ? 'Close methods' : 'Methods'}:\n ${shown.join('\n ')}` +
117
+ `${shown.length < methods.length ? `\n … run \`norbix ${this.target} ${moduleWord}\` for the full list` : ''}`);
118
+ }
119
+ if (matches.length > 1) {
120
+ this.error(`"${words.join(' ')}" is ambiguous — did you mean:\n ${matches.map((m) => m.method).join('\n ')}\nAdd a word (e.g. the verb) to be specific.`);
121
+ }
122
+ const method = matches[0].method;
123
+ const request = { ...parsed.fields };
124
+ if (parsed.positionals.length > 0 && request.id === undefined) {
125
+ request.id = parsed.positionals[0];
126
+ if (parsed.positionals.length > 1) {
127
+ this.error(`Only one positional value is allowed (became "id"). Pass the rest as flags, e.g. --schemaId ${parsed.positionals[1]}`);
128
+ }
129
+ }
130
+ if (parsed.dryRun) {
131
+ this.print({ wouldCall: `${this.target}.${moduleName}.${method}`, request });
132
+ return { method: `${this.target}.${moduleName}.${method}`, request, dryRun: true };
133
+ }
134
+ if (isDestructive(method) && !parsed.yes && process.stdout.isTTY) {
135
+ const ok = await confirm({
136
+ message: `Run ${method}(${JSON.stringify(request)})?`,
137
+ default: false,
138
+ });
139
+ if (!ok)
140
+ return this.print('Cancelled.');
141
+ }
142
+ const client = this.client(flags);
143
+ const liveModule = client[this.target][moduleName];
144
+ const res = await liveModule[method](request);
145
+ this.print(res);
146
+ return res;
147
+ }
148
+ }
@@ -0,0 +1,138 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ /**
5
+ * Profile storage — one INI file, AWS-style but simpler:
6
+ *
7
+ * ~/.norbix/config (mode 600, secrets + settings together)
8
+ * ---------------------------------------------------------------
9
+ * [default]
10
+ * api_key = nbk_live_...
11
+ * project_id = 5f1a...
12
+ *
13
+ * [fitskin-prod]
14
+ * api_key = nbk_live_...
15
+ * project_id = 64ff...
16
+ * account_id = ...
17
+ * env = TEST
18
+ * api_url = https://api.norbix.ai
19
+ * hub_url = https://hub.norbix.ai
20
+ *
21
+ * Browser/password login sessions live in ~/.norbix/session.json —
22
+ * separate on purpose: sessions rotate and are machine-managed, the
23
+ * config file is edited by people.
24
+ */
25
+ export const NORBIX_DIR = join(homedir(), '.norbix');
26
+ export const PROFILES_PATH = join(NORBIX_DIR, 'config');
27
+ export const SESSION_PATH = join(NORBIX_DIR, 'session.json');
28
+ export const DEFAULT_API_URL = 'https://api.norbix.ai';
29
+ export const DEFAULT_HUB_URL = 'https://hub.norbix.ai';
30
+ export const PROFILE_KEYS = [
31
+ 'api_key',
32
+ 'project_id',
33
+ 'account_id',
34
+ 'env',
35
+ 'region',
36
+ 'api_url',
37
+ 'hub_url',
38
+ 'files_integration_id',
39
+ ];
40
+ // ---------- tiny INI reader/writer (no dependency) ----------
41
+ export function parseIni(text) {
42
+ const out = {};
43
+ let section = 'default';
44
+ for (const rawLine of text.split(/\r?\n/)) {
45
+ const line = rawLine.trim();
46
+ if (!line || line.startsWith('#') || line.startsWith(';'))
47
+ continue;
48
+ const sec = line.match(/^\[(.+)\]$/);
49
+ if (sec) {
50
+ section = sec[1].trim();
51
+ out[section] ??= {};
52
+ continue;
53
+ }
54
+ const eq = line.indexOf('=');
55
+ if (eq > 0) {
56
+ out[section] ??= {};
57
+ out[section][line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+ export function stringifyIni(data) {
63
+ const parts = [];
64
+ for (const [section, values] of Object.entries(data)) {
65
+ parts.push(`[${section}]`);
66
+ for (const [k, v] of Object.entries(values)) {
67
+ if (v !== undefined && v !== '')
68
+ parts.push(`${k} = ${v}`);
69
+ }
70
+ parts.push('');
71
+ }
72
+ return parts.join('\n');
73
+ }
74
+ // ---------- profiles ----------
75
+ export function readProfiles() {
76
+ try {
77
+ return parseIni(readFileSync(PROFILES_PATH, 'utf8'));
78
+ }
79
+ catch {
80
+ return {};
81
+ }
82
+ }
83
+ export function writeProfile(name, profile) {
84
+ mkdirSync(NORBIX_DIR, { recursive: true });
85
+ const all = readProfiles();
86
+ const clean = Object.fromEntries(Object.entries(profile).filter(([, v]) => v !== undefined && v !== ''));
87
+ all[name] = clean;
88
+ writeFileSync(PROFILES_PATH, stringifyIni(all), { mode: 0o600 });
89
+ try {
90
+ chmodSync(PROFILES_PATH, 0o600); // enforce even when the file pre-existed
91
+ }
92
+ catch {
93
+ /* windows: no-op */
94
+ }
95
+ }
96
+ export function deleteProfile(name) {
97
+ const all = readProfiles();
98
+ if (!(name in all))
99
+ return false;
100
+ delete all[name];
101
+ mkdirSync(NORBIX_DIR, { recursive: true });
102
+ writeFileSync(PROFILES_PATH, stringifyIni(all), { mode: 0o600 });
103
+ return true;
104
+ }
105
+ export function readSession() {
106
+ try {
107
+ const s = JSON.parse(readFileSync(SESSION_PATH, 'utf8'));
108
+ return s.bearerToken ? s : undefined;
109
+ }
110
+ catch {
111
+ return undefined;
112
+ }
113
+ }
114
+ export function writeSession(session) {
115
+ mkdirSync(NORBIX_DIR, { recursive: true });
116
+ writeFileSync(SESSION_PATH, JSON.stringify(session, null, 2) + '\n', { mode: 0o600 });
117
+ }
118
+ export function clearSession() {
119
+ if (existsSync(SESSION_PATH))
120
+ unlinkSync(SESSION_PATH);
121
+ }
122
+ /** Decode a JWT `exp` claim (ms). Returns undefined when not decodable. */
123
+ export function jwtExpiryMs(token) {
124
+ try {
125
+ const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
126
+ return typeof payload.exp === 'number' ? payload.exp * 1000 : undefined;
127
+ }
128
+ catch {
129
+ return undefined;
130
+ }
131
+ }
132
+ /** A session is usable when it has a token that is not (provably) expired. */
133
+ export function isSessionValid(session) {
134
+ if (!session?.bearerToken)
135
+ return false;
136
+ const exp = jwtExpiryMs(session.bearerToken);
137
+ return exp === undefined || exp > Date.now() + 30_000;
138
+ }