@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,20 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { BaseCommand } from '../../base.js';
3
+ export default class PushIntegrations extends BaseCommand {
4
+ static description = 'List the push integrations set up for this project';
5
+ static examples = ['<%= config.bin %> push integrations --page-size 50'];
6
+ static flags = {
7
+ 'page-size': Flags.integer({ description: 'Records per page' }),
8
+ after: Flags.string({ description: 'Cursor: fetch the page after this item' }),
9
+ };
10
+ async run() {
11
+ const { flags } = await this.parse(PushIntegrations);
12
+ const client = this.client(flags);
13
+ const res = await client.hub.notifications.getPushIntegrations({
14
+ pageSize: flags['page-size'],
15
+ startingAfter: flags.after,
16
+ });
17
+ this.print(res);
18
+ return res;
19
+ }
20
+ }
@@ -0,0 +1,19 @@
1
+ import { Args } from '@oclif/core';
2
+ import { BaseCommand } from '../../base.js';
3
+ export default class PushPreview extends BaseCommand {
4
+ static description = `Render the title, body and subtitle behind a push preview link
5
+
6
+ The hash is the opaque value the backend puts in a preview link — you cannot
7
+ build it yourself, so copy it out of the link.`;
8
+ static examples = ['<%= config.bin %> push preview 8f2a91c4...'];
9
+ static args = {
10
+ hash: Args.string({ required: true, description: 'Preview hash from the preview link' }),
11
+ };
12
+ async run() {
13
+ const { args, flags } = await this.parse(PushPreview);
14
+ const client = this.client(flags);
15
+ const res = await client.hub.notifications.previewPushNotification({ hash: args.hash });
16
+ this.print(res);
17
+ return res;
18
+ }
19
+ }
@@ -0,0 +1,16 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { BaseCommand } from '../../base.js';
3
+ export default class PushSettings extends BaseCommand {
4
+ static description = 'Show the project push settings';
5
+ static examples = ['<%= config.bin %> push settings'];
6
+ static flags = {
7
+ id: Flags.string({ description: 'Settings ID (defaults to the project settings)' }),
8
+ };
9
+ async run() {
10
+ const { flags } = await this.parse(PushSettings);
11
+ const client = this.client(flags);
12
+ const res = await client.hub.notifications.getPushSettings(flags.id ? { id: flags.id } : {});
13
+ this.print(res);
14
+ return res;
15
+ }
16
+ }
@@ -9,7 +9,10 @@ export default class PushUnarchive extends BaseCommand {
9
9
  async run() {
10
10
  const { args, flags } = await this.parse(PushUnarchive);
11
11
  const client = this.client(flags);
12
- const res = await client.hub.notifications.unArchivePushTemplate({ id: args.id });
12
+ // The route token is `{Id}` and the transport fills tokens by exact name,
13
+ // so the request must carry `Id`. The generated type calls the field `id`,
14
+ // which throws NORBIX_MISSING_PATH_PARAM — hence the cast.
15
+ const res = await client.hub.notifications.unArchivePushTemplate({ Id: args.id });
13
16
  this.print(`Template ${args.id} restored.`);
14
17
  return res;
15
18
  }
@@ -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,57 @@
1
+ import { NorbixAuthError, NorbixError, NorbixValidationError } from '@norbix.ai/ts';
2
+ /** The API version the SDK talks by default. Kept in one place. */
3
+ const API_VERSION = 'v2';
4
+ export async function callTestFilesIntegration(ctx, filesIntegrationId) {
5
+ const token = ctx.bearerToken ?? ctx.apiKey;
6
+ const headers = new Headers({ Accept: 'application/json' });
7
+ if (token)
8
+ headers.set('Authorization', `Bearer ${token}`);
9
+ if (ctx.projectId) {
10
+ headers.set('norbix-project-id', ctx.projectId);
11
+ headers.set('X-CM-ProjectId', ctx.projectId);
12
+ }
13
+ if (ctx.accountId) {
14
+ headers.set('norbix-account-id', ctx.accountId);
15
+ headers.set('X-CM-AccountId', ctx.accountId);
16
+ }
17
+ if (ctx.env && ctx.env !== 'PROD')
18
+ headers.set('norbix-env', ctx.env);
19
+ if (ctx.region)
20
+ headers.set('nb-region', ctx.region);
21
+ const base = ctx.apiUrl.endsWith('/') ? ctx.apiUrl.slice(0, -1) : ctx.apiUrl;
22
+ const url = `${base}/${API_VERSION}/files/${encodeURIComponent(filesIntegrationId)}/test`;
23
+ const response = await fetch(url, { headers, method: 'POST' });
24
+ const text = await response.text();
25
+ let raw;
26
+ if (text) {
27
+ try {
28
+ raw = JSON.parse(text);
29
+ }
30
+ catch {
31
+ raw = text;
32
+ }
33
+ }
34
+ if (!response.ok)
35
+ throw errorFrom(response.status, raw, url);
36
+ return (raw && typeof raw === 'object' ? raw : {});
37
+ }
38
+ /** Same mapping as the SDK transport: 401/403 → auth, 400 → validation, else NorbixError. */
39
+ function errorFrom(status, raw, url) {
40
+ const body = raw && typeof raw === 'object' ? raw : undefined;
41
+ const payload = body?.responseStatus ?? body;
42
+ const message = payload?.message ??
43
+ body?.responseStatus?.errors?.find((e) => e.message)?.message ??
44
+ `Request failed with status ${status}`;
45
+ const opts = { code: payload?.errorCode, message, raw, status, url };
46
+ if (status === 401 || status === 403)
47
+ return new NorbixAuthError(opts);
48
+ if (status === 400)
49
+ return new NorbixValidationError(opts);
50
+ return new NorbixError(opts);
51
+ }
52
+ /** `Failed` / `NotTested` / `NOT_TESTED` → `FAILED` / `NOT_TESTED`. */
53
+ export function normaliseResult(result) {
54
+ if (!result)
55
+ return 'UNKNOWN';
56
+ return result.replaceAll(/([a-z])([A-Z])/g, '$1_$2').replaceAll(/[\s-]+/g, '_').toUpperCase();
57
+ }