@norbix.ai/cli 0.1.0 → 0.3.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 (43) hide show
  1. package/README.md +148 -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/integrations/test.js +85 -0
  10. package/dist/commands/files/list.js +1 -1
  11. package/dist/commands/files/publish.js +38 -0
  12. package/dist/commands/files/sign.js +1 -1
  13. package/dist/commands/files/unpublish.js +34 -0
  14. package/dist/commands/files/upload.js +1 -1
  15. package/dist/commands/hub.js +24 -0
  16. package/dist/commands/login.js +82 -36
  17. package/dist/commands/logout.js +11 -13
  18. package/dist/commands/profiles.js +25 -0
  19. package/dist/commands/push/archive.js +4 -1
  20. package/dist/commands/push/clone.js +4 -1
  21. package/dist/commands/push/delete.js +4 -1
  22. package/dist/commands/push/device/register.js +35 -0
  23. package/dist/commands/push/integration/default.js +19 -0
  24. package/dist/commands/push/integration/delete.js +28 -0
  25. package/dist/commands/push/integration/disable.js +19 -0
  26. package/dist/commands/push/integration/enable.js +19 -0
  27. package/dist/commands/push/integration/test.js +30 -0
  28. package/dist/commands/push/integration.js +16 -0
  29. package/dist/commands/push/integrations.js +20 -0
  30. package/dist/commands/push/preview.js +19 -0
  31. package/dist/commands/push/settings.js +16 -0
  32. package/dist/commands/push/unarchive.js +4 -1
  33. package/dist/commands/raw.js +86 -0
  34. package/dist/commands/whoami.js +25 -8
  35. package/dist/help.js +18 -0
  36. package/dist/lib/device-login.js +76 -0
  37. package/dist/lib/dispatch.js +151 -0
  38. package/dist/lib/filesIntegrationTest.js +57 -0
  39. package/dist/lib/namespace-command.js +148 -0
  40. package/dist/lib/profiles.js +138 -0
  41. package/dist/lib/publicFiles.js +63 -0
  42. package/oclif.manifest.json +3272 -737
  43. package/package.json +30 -13
@@ -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
+ }
@@ -0,0 +1,63 @@
1
+ import { NorbixError } from '@norbix.ai/ts';
2
+ const ROUTES = {
3
+ makeFilePrivate: 'files/item/private',
4
+ makeFilePublic: 'files/item/public',
5
+ makeFolderPrivate: 'files/folder/private',
6
+ makeFolderPublic: 'files/folder/public',
7
+ };
8
+ /** The Hub version the SDK talks by default. Kept in one place. */
9
+ const HUB_VERSION = 'v2';
10
+ export async function callPublicFiles(ctx, operation, body) {
11
+ const token = ctx.bearerToken ?? ctx.apiKey;
12
+ const headers = new Headers({
13
+ Accept: 'application/json',
14
+ 'Content-Type': 'application/json',
15
+ });
16
+ if (token)
17
+ headers.set('Authorization', `Bearer ${token}`);
18
+ if (ctx.projectId)
19
+ headers.set('X-CM-ProjectId', ctx.projectId);
20
+ if (ctx.accountId)
21
+ headers.set('X-CM-AccountId', ctx.accountId);
22
+ if (ctx.env && ctx.env !== 'PROD')
23
+ headers.set('norbix-env', ctx.env);
24
+ if (ctx.region)
25
+ headers.set('nb-region', ctx.region);
26
+ const base = ctx.hubUrl.endsWith('/') ? ctx.hubUrl.slice(0, -1) : ctx.hubUrl;
27
+ const url = `${base}/${HUB_VERSION}/${ROUTES[operation]}`;
28
+ const response = await fetch(url, { body: JSON.stringify(body), headers, method: 'POST' });
29
+ const text = await response.text();
30
+ let payload = {};
31
+ if (text) {
32
+ try {
33
+ payload = JSON.parse(text);
34
+ }
35
+ catch {
36
+ payload = {};
37
+ }
38
+ }
39
+ if (!response.ok) {
40
+ throw new NorbixError({
41
+ code: payload.status,
42
+ message: payload.message ?? text ?? 'Request failed',
43
+ status: response.status,
44
+ });
45
+ }
46
+ return payload;
47
+ }
48
+ /**
49
+ * The address anyone can open. The gateway builds the same one and puts it on
50
+ * the file's `publicUrl`; this is what to print right after publishing, when
51
+ * the caller has only just been handed the id.
52
+ *
53
+ * `remote` is the path that was published. For a file the link ends with its
54
+ * name; for a folder it ends with a slash, and a file inside it is reached by
55
+ * appending the path inside the folder.
56
+ */
57
+ export function publicUrlFor(apiUrl, publicId, remote, isFolder) {
58
+ const base = apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl;
59
+ if (isFolder)
60
+ return `${base}/v3/files/public/${publicId}/`;
61
+ const name = remote.split('/').filter(Boolean).pop() ?? remote;
62
+ return `${base}/v3/files/public/${publicId}/${name.split('/').map(encodeURIComponent).join('/')}`;
63
+ }