@norbix.ai/cli 0.3.0 → 1.1.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.
@@ -1,4 +1,4 @@
1
- import { NorbixAuthError, NorbixError, NorbixValidationError } from '@norbix.ai/ts';
1
+ import { gatewayError } from './gatewayError.js';
2
2
  /** The API version the SDK talks by default. Kept in one place. */
3
3
  const API_VERSION = 'v2';
4
4
  export async function callTestFilesIntegration(ctx, filesIntegrationId) {
@@ -32,23 +32,9 @@ export async function callTestFilesIntegration(ctx, filesIntegrationId) {
32
32
  }
33
33
  }
34
34
  if (!response.ok)
35
- throw errorFrom(response.status, raw, url);
35
+ throw gatewayError(response.status, raw, url);
36
36
  return (raw && typeof raw === 'object' ? raw : {});
37
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
38
  /** `Failed` / `NotTested` / `NOT_TESTED` → `FAILED` / `NOT_TESTED`. */
53
39
  export function normaliseResult(result) {
54
40
  if (!result)
@@ -0,0 +1,58 @@
1
+ import { NorbixAuthError, NorbixError, NorbixValidationError } from '@norbix.ai/ts';
2
+ function isRecord(value) {
3
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
4
+ }
5
+ /** The `responseStatus` block of a body, whatever the casing of the key. */
6
+ function responseStatusOf(raw) {
7
+ if (!isRecord(raw))
8
+ return undefined;
9
+ for (const key of Object.keys(raw)) {
10
+ if (key.toLowerCase() === 'responsestatus') {
11
+ const value = raw[key];
12
+ return isRecord(value) ? value : undefined;
13
+ }
14
+ }
15
+ return undefined;
16
+ }
17
+ function text(value) {
18
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
19
+ }
20
+ function itemsOf(value) {
21
+ if (!Array.isArray(value))
22
+ return [];
23
+ return value.filter((entry) => isRecord(entry)).map((entry) => entry);
24
+ }
25
+ /**
26
+ * True when the gateway said the call failed inside the body.
27
+ *
28
+ * A business refusal — an unknown id, a rule that says no — comes back as HTTP
29
+ * 200 with `responseStatus.isSuccess = false` (10b-files, issue #67).
30
+ */
31
+ export function saysItFailed(raw) {
32
+ const status = responseStatusOf(raw);
33
+ return isRecord(status) && status.isSuccess === false;
34
+ }
35
+ /** The message and the error code a gateway answer carries. */
36
+ export function readGatewayError(raw, status) {
37
+ const responseStatus = responseStatusOf(raw);
38
+ // `source` is responseStatus when the body has one, the body itself when it
39
+ // has none — so the top-level fields are read only in the second case.
40
+ const source = responseStatus ?? (isRecord(raw) ? raw : undefined);
41
+ const errors = itemsOf(source?.errors);
42
+ const first = errors.find((e) => e.message !== undefined || e.errorCode !== undefined);
43
+ return {
44
+ code: first?.errorCode ?? text(source?.errorCode),
45
+ errors,
46
+ message: first?.message ?? text(source?.message) ?? `Request failed (HTTP ${status})`,
47
+ };
48
+ }
49
+ /** Build the right NorbixError for a status and an already-parsed body. */
50
+ export function gatewayError(status, raw, url) {
51
+ const { code, message } = readGatewayError(raw, status);
52
+ const opts = { code, message, raw, status, url };
53
+ if (status === 401 || status === 403)
54
+ return new NorbixAuthError(opts);
55
+ if (status === 400)
56
+ return new NorbixValidationError(opts);
57
+ return new NorbixError(opts);
58
+ }
@@ -1,4 +1,4 @@
1
- import { NorbixError } from '@norbix.ai/ts';
1
+ import { gatewayError, saysItFailed } from './gatewayError.js';
2
2
  const ROUTES = {
3
3
  makeFilePrivate: 'files/item/private',
4
4
  makeFilePublic: 'files/item/public',
@@ -27,23 +27,23 @@ export async function callPublicFiles(ctx, operation, body) {
27
27
  const url = `${base}/${HUB_VERSION}/${ROUTES[operation]}`;
28
28
  const response = await fetch(url, { body: JSON.stringify(body), headers, method: 'POST' });
29
29
  const text = await response.text();
30
- let payload = {};
30
+ let raw;
31
31
  if (text) {
32
32
  try {
33
- payload = JSON.parse(text);
33
+ raw = JSON.parse(text);
34
34
  }
35
35
  catch {
36
- payload = {};
36
+ raw = text;
37
37
  }
38
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;
39
+ if (!response.ok)
40
+ throw gatewayError(response.status, raw, url);
41
+ // A 2xx does not mean the call worked: the gateway answers a business
42
+ // refusal with HTTP 200 and responseStatus.isSuccess = false, and the
43
+ // command must exit non-zero for it (10b-files, issue #67).
44
+ if (saysItFailed(raw))
45
+ throw gatewayError(response.status, raw, url);
46
+ return (raw && typeof raw === 'object' ? raw : {});
47
47
  }
48
48
  /**
49
49
  * The address anyone can open. The gateway builds the same one and puts it on
@@ -0,0 +1,70 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { Flags } from '@oclif/core';
3
+ import { readJsonInput } from './json.js';
4
+ /**
5
+ * Read a JSON object from a flag: inline JSON, `-` for stdin, or `@path` for a
6
+ * file. Used by the push commands whose body is too rich for flags alone
7
+ * (template translations, provider credentials, campaign audiences).
8
+ */
9
+ export async function readJsonObject(value, flagName) {
10
+ const raw = value.startsWith('@') ? readFileSync(value.slice(1), 'utf8') : value;
11
+ const parsed = JSON.parse(await readJsonInput(raw, flagName));
12
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
13
+ throw new Error(`--${flagName} must be a JSON object`);
14
+ }
15
+ return parsed;
16
+ }
17
+ /** Turn repeated `--token key=value` flags into token mappings with the Custom resolver. */
18
+ export function parseTokens(values) {
19
+ if (!values?.length)
20
+ return undefined;
21
+ return values.map((pair) => {
22
+ const at = pair.indexOf('=');
23
+ if (at < 1)
24
+ throw new Error(`--token must look like key=value, got "${pair}"`);
25
+ return { key: pair.slice(0, at), value: pair.slice(at + 1), resolver: 'Custom' };
26
+ });
27
+ }
28
+ /**
29
+ * Build the template body. Either --translations (a JSON array, @file or -)
30
+ * for several languages, or --title + --body for one language.
31
+ */
32
+ export async function templateBody(flags) {
33
+ let translations;
34
+ if (flags.translations) {
35
+ const raw = flags.translations.startsWith('@') ? readFileSync(flags.translations.slice(1), 'utf8') : flags.translations;
36
+ translations = JSON.parse(await readJsonInput(raw, 'translations'));
37
+ if (!Array.isArray(translations))
38
+ throw new Error('--translations must be a JSON array');
39
+ }
40
+ else {
41
+ if (!flags.title || !flags.body) {
42
+ throw new Error('Pass --title and --body, or --translations for several languages.');
43
+ }
44
+ translations = [{ language: flags.language, content: { title: flags.title, body: flags.body } }];
45
+ }
46
+ return {
47
+ templateName: flags.name,
48
+ description: flags.description,
49
+ communicationChannel: flags.channel,
50
+ tags: flags.tag,
51
+ translations,
52
+ };
53
+ }
54
+ export const templateFlags = {
55
+ name: Flags.string({ required: true, description: 'Template name' }),
56
+ description: Flags.string({ description: 'What the template is for' }),
57
+ channel: Flags.string({
58
+ description: 'Communication channel',
59
+ options: ['Transactional', 'Marketing', 'System'],
60
+ default: 'Transactional',
61
+ }),
62
+ tag: Flags.string({ description: 'Tag (repeat for several)', multiple: true }),
63
+ language: Flags.string({ description: 'Language of --title / --body', default: 'en' }),
64
+ title: Flags.string({ description: 'Push title (Razor allowed)' }),
65
+ body: Flags.string({ description: 'Push body (Razor allowed)' }),
66
+ translations: Flags.string({
67
+ description: 'All languages as a JSON array of {language, content: {title, body}} — inline, @file or -',
68
+ exclusive: ['title', 'body'],
69
+ }),
70
+ };