@norbix.ai/cli 1.0.0 → 1.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.
package/README.md CHANGED
@@ -48,7 +48,8 @@ norbix scheduler list
48
48
  | `norbix apikeys list/regenerate` | Show or regenerate project API keys |
49
49
  | `norbix webhooks show/secret/enable/disable/remove` | Inspect the webhook integration |
50
50
  | `norbix email templates/template/clone/archive/unarchive/delete/campaigns/campaign/stop` | Email templates and campaigns |
51
- | `norbix push ...` / `norbix sms ...` | Push and SMS — same commands as email |
51
+ | `norbix push ...` | Push module, integrations, devices, templates, campaigns — every push endpoint, see [docs/push.md](docs/push.md) |
52
+ | `norbix sms ...` | SMS — same commands as email |
52
53
  | `norbix account profile/status/usage/projects/team/regions/billing-portal` | Account-level info |
53
54
  | `norbix payments integrations/triggers/trigger/enable/disable` | Payment integrations and triggers |
54
55
  | `norbix integrations <module>` | List integrations of any module |
@@ -148,6 +149,15 @@ came back negative (`norbix files integrations test`).
148
149
  not before — `norbix files publish` / `unpublish` were the two commands that
149
150
  used to treat it as a success.
150
151
 
152
+ ## Versioning
153
+
154
+ The major version is frozen at **v1** until the public launch.
155
+
156
+ - A breaking change is released as a **minor** (for example v1.2.0 → v1.3.0), never as a new major.
157
+ - Write it as `feat(<scope>): <what>` and add a line `Breaking: <what changed and what callers must do>` in plain words, in the pull-request body and in the commit message.
158
+ - Never mark it the conventional-commits way: no `!` in the title (`feat!:`), no BREAKING CHANGE footer. The `PR title` check fails a pull request that does.
159
+ - As a safety net, the release config (`.releaserc.json` → `releaseRules`) maps breaking commits to a minor, so one that slips through still does not bump the major.
160
+
151
161
  ## Development
152
162
 
153
163
  ```sh
package/dist/base.js CHANGED
@@ -2,6 +2,16 @@ import { Norbix, NorbixError } from '@norbix.ai/ts';
2
2
  import { Command, Flags } from '@oclif/core';
3
3
  import { DEFAULT_API_URL, DEFAULT_HUB_URL, isSessionValid, readProfiles, readSession, } from './lib/profiles.js';
4
4
  import { readStore } from './lib/store.js';
5
+ /** Stands in for a projectId the SDK constructor insists on; never sent. */
6
+ const NO_PROJECT_PLACEHOLDER = 'no-project';
7
+ /** Removes the project headers the SDK adds for the placeholder projectId. */
8
+ export const dropProjectHeaders = async (ctx) => {
9
+ const headers = new Headers(ctx.init.headers);
10
+ headers.delete('norbix-project-id');
11
+ headers.delete('X-CM-ProjectId');
12
+ ctx.init.headers = headers;
13
+ return ctx.next();
14
+ };
5
15
  /**
6
16
  * Base class for every Norbix CLI command.
7
17
  *
@@ -127,18 +137,28 @@ export class BaseCommand extends Command {
127
137
  'or set custom api_url / hub_url in the profile for self-hosted installations.');
128
138
  }
129
139
  }
130
- /** Build an SDK client from the resolved context. Errors politely when auth is missing. */
140
+ /**
141
+ * Build an SDK client from the resolved context. Errors politely when auth is missing.
142
+ *
143
+ * `requireAuth: false` — the call may go out without a login (a signed link is the key);
144
+ * a login, when there is one, is still used.
145
+ * `requireProject: false` — the call does not need a project (a signed link carries it).
146
+ * The SDK constructor still demands a projectId, so a placeholder is passed and the
147
+ * project headers are taken off the wire again: the gateway never sees a fake project.
148
+ */
131
149
  client(flags, opts = {}) {
132
150
  const ctx = this.resolveContext(flags);
133
151
  this.assertEndpoints(ctx);
134
- if (!ctx.projectId) {
152
+ const noProject = !ctx.projectId && opts.requireProject === false;
153
+ if (!ctx.projectId && !noProject) {
135
154
  this.error('No project ID configured.\nRun `norbix configure` (or `norbix login`), or pass --project.');
136
155
  }
137
156
  if (opts.requireAuth !== false && !ctx.apiKey && !ctx.bearerToken) {
138
157
  this.error('Not authenticated.\nRun `norbix login` (browser/user session) or `norbix configure` (API key profile), or pass --api-key.');
139
158
  }
140
159
  return new Norbix({
141
- projectId: ctx.projectId,
160
+ projectId: noProject ? NO_PROJECT_PLACEHOLDER : ctx.projectId,
161
+ middleware: noProject ? [dropProjectHeaders] : [],
142
162
  accountId: ctx.accountId,
143
163
  apiKey: ctx.apiKey,
144
164
  bearerToken: ctx.bearerToken,
@@ -0,0 +1,39 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import { BaseCommand } from '../../../base.js';
3
+ export default class PushCampaignBatch extends BaseCommand {
4
+ static description = `List the notifications in one batch — or show one of them
5
+
6
+ Give a notification ID as the third argument to see just that notification.`;
7
+ static examples = [
8
+ '<%= config.bin %> push campaign batch 66b2f0a1... b7c3...',
9
+ '<%= config.bin %> push campaign batch 66b2f0a1... b7c3... n9d4...',
10
+ ];
11
+ static args = {
12
+ id: Args.string({ required: true, description: 'Campaign ID' }),
13
+ batchId: Args.string({ required: true, description: 'Batch ID (from `push campaign batches`)' }),
14
+ notificationId: Args.string({ description: 'Notification ID' }),
15
+ };
16
+ static flags = {
17
+ 'page-size': Flags.integer({ description: 'Items per page' }),
18
+ after: Flags.string({ description: 'Cursor from the previous page' }),
19
+ };
20
+ async run() {
21
+ const { args, flags } = await this.parse(PushCampaignBatch);
22
+ const client = this.client(flags);
23
+ const n = client.hub.notifications;
24
+ const res = args.notificationId
25
+ ? await n.getPushCampaignBatchNotification({
26
+ id: args.id,
27
+ batchId: args.batchId,
28
+ notificationId: args.notificationId,
29
+ })
30
+ : await n.getPushCampaignBatchNotifications({
31
+ id: args.id,
32
+ batchId: args.batchId,
33
+ pageSize: flags['page-size'],
34
+ startingAfter: flags.after,
35
+ });
36
+ this.print(res);
37
+ return res;
38
+ }
39
+ }
@@ -0,0 +1,24 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import { BaseCommand } from '../../../base.js';
3
+ export default class PushCampaignBatches extends BaseCommand {
4
+ static description = 'List the send batches of a push campaign';
5
+ static examples = ['<%= config.bin %> push campaign batches 66b2f0a1...'];
6
+ static args = {
7
+ id: Args.string({ required: true, description: 'Campaign ID' }),
8
+ };
9
+ static flags = {
10
+ 'page-size': Flags.integer({ description: 'Items per page' }),
11
+ after: Flags.string({ description: 'Cursor from the previous page' }),
12
+ };
13
+ async run() {
14
+ const { args, flags } = await this.parse(PushCampaignBatches);
15
+ const client = this.client(flags);
16
+ const res = await client.hub.notifications.getPushCampaignBatches({
17
+ id: args.id,
18
+ pageSize: flags['page-size'],
19
+ startingAfter: flags.after,
20
+ });
21
+ this.print(res);
22
+ return res;
23
+ }
24
+ }
@@ -0,0 +1,102 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { BaseCommand } from '../../../base.js';
3
+ import { parseTokens, readJsonObject } from '../../../lib/push.js';
4
+ /** --audience value → the `source` discriminator the server routes the body by. */
5
+ const AUDIENCES = {
6
+ 'all-users': 'allUsers',
7
+ users: 'specifiedUsers',
8
+ 'account-users': 'accountUsers',
9
+ collection: 'collection',
10
+ devices: 'devices',
11
+ };
12
+ export default class PushCampaignCreate extends BaseCommand {
13
+ static description = `Create a push campaign
14
+
15
+ --audience picks who receives it:
16
+ all-users every user; narrow with --role / --tag
17
+ users the users named with --user
18
+ account-users the account users named with --user
19
+ collection the users referenced by --field in the --schema collection
20
+ devices raw device tokens, --device <token>:<family> (Ios, Android, Chrome, Safari, Expo)
21
+
22
+ Without --at the campaign is sent right away. Anything the flags do not cover
23
+ goes in --config as a JSON object and is merged into the campaign.`;
24
+ static examples = [
25
+ '<%= config.bin %> push campaign create --template 66b2f0a1... --audience all-users --tag beta',
26
+ '<%= config.bin %> push campaign create --template 66b2f0a1... --audience users --user usr_1 --user usr_2',
27
+ '<%= config.bin %> push campaign create --template 66b2f0a1... --audience devices --device dGVzdA==:Ios',
28
+ ];
29
+ static flags = {
30
+ template: Flags.string({ required: true, description: 'Template ID' }),
31
+ audience: Flags.string({ required: true, description: 'Who receives it', options: Object.keys(AUDIENCES) }),
32
+ user: Flags.string({ description: 'User ID (users / account-users; repeat for several)', multiple: true }),
33
+ role: Flags.string({ description: 'Role name (all-users / collection; repeat for several)', multiple: true }),
34
+ tag: Flags.string({ description: 'User tag (all-users; repeat for several)', multiple: true }),
35
+ schema: Flags.string({ description: 'Collection name (collection)' }),
36
+ field: Flags.string({ description: 'Field that holds the recipient (collection; repeat for several)', multiple: true }),
37
+ 'field-type': Flags.string({ description: 'What --field holds (collection)', options: ['User', 'Email'] }),
38
+ device: Flags.string({ description: 'Device as <token>:<family> (devices; repeat for several)', multiple: true }),
39
+ integration: Flags.string({ description: 'Push integration ID (defaults to the project default)' }),
40
+ language: Flags.string({ description: 'Template language to send' }),
41
+ notes: Flags.string({ description: 'Internal notes' }),
42
+ at: Flags.string({ description: 'Send later: ISO 8601 date-time or Unix seconds' }),
43
+ token: Flags.string({ description: 'Token value as key=value (repeat for several)', multiple: true }),
44
+ 'database-integration': Flags.string({ description: 'Database integration ID (defaults to the project default)' }),
45
+ config: Flags.string({ description: 'Extra campaign fields as a JSON object, @file or -' }),
46
+ };
47
+ async run() {
48
+ const { flags } = await this.parse(PushCampaignCreate);
49
+ const client = this.client(flags);
50
+ const extra = flags.config ? await readJsonObject(flags.config, 'config') : {};
51
+ const campaign = {
52
+ ...extra,
53
+ source: AUDIENCES[flags.audience],
54
+ templateId: flags.template,
55
+ integrationId: flags.integration,
56
+ language: flags.language,
57
+ notes: flags.notes,
58
+ campaignTime: flags.at ? toUnixSeconds(flags.at) : undefined,
59
+ mappedTokens: parseTokens(flags.token),
60
+ ...this.audienceFields(flags),
61
+ };
62
+ const res = await client.hub.notifications.createPushCampaign({
63
+ campaign,
64
+ databaseIntegrationId: flags['database-integration'],
65
+ });
66
+ this.print(res);
67
+ return res;
68
+ }
69
+ audienceFields(flags) {
70
+ switch (flags.audience) {
71
+ case 'all-users':
72
+ return { rolesNames: flags.role, userTags: flags.tag };
73
+ case 'users':
74
+ case 'account-users':
75
+ if (!flags.user?.length)
76
+ this.error(`--audience ${flags.audience} needs at least one --user`);
77
+ return { userRecipients: flags.user };
78
+ case 'collection':
79
+ if (!flags.schema || !flags.field?.length)
80
+ this.error('--audience collection needs --schema and --field');
81
+ return { schemaName: flags.schema, fields: flags.field, fieldType: flags['field-type'], roleNames: flags.role };
82
+ default:
83
+ if (!flags.device?.length)
84
+ this.error('--audience devices needs at least one --device <token>:<family>');
85
+ return { devices: flags.device.map((d) => this.device(d)) };
86
+ }
87
+ }
88
+ device(value) {
89
+ const at = value.lastIndexOf(':');
90
+ if (at < 1)
91
+ this.error(`--device must look like <token>:<family>, got "${value}"`);
92
+ return { token: value.slice(0, at), deliveryFamily: value.slice(at + 1) };
93
+ }
94
+ }
95
+ function toUnixSeconds(value) {
96
+ if (/^\d+$/.test(value))
97
+ return Number(value);
98
+ const ms = Date.parse(value);
99
+ if (Number.isNaN(ms))
100
+ throw new Error(`--at is not a date: "${value}"`);
101
+ return Math.floor(ms / 1000);
102
+ }
@@ -0,0 +1,27 @@
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { Args, Flags } from '@oclif/core';
3
+ import { BaseCommand } from '../../../base.js';
4
+ export default class PushCampaignDelete extends BaseCommand {
5
+ static description = 'Delete a push campaign';
6
+ static examples = ['<%= config.bin %> push campaign delete 66b2f0a1... --yes'];
7
+ static args = {
8
+ id: Args.string({ required: true, description: 'Campaign ID' }),
9
+ };
10
+ static flags = {
11
+ yes: Flags.boolean({ char: 'y', description: 'Skip the confirmation prompt', default: false }),
12
+ };
13
+ async run() {
14
+ const { args, flags } = await this.parse(PushCampaignDelete);
15
+ const client = this.client(flags);
16
+ if (!flags.yes && process.stdout.isTTY) {
17
+ const ok = await confirm({ message: `Delete push campaign ${args.id}?`, default: false });
18
+ if (!ok)
19
+ return this.print('Cancelled.');
20
+ }
21
+ // The route is `/campaigns/{Id}` but the generated request type is empty,
22
+ // so the id goes in as `Id` — the transport fills tokens by exact name.
23
+ const res = await client.hub.notifications.deletePushCampaign({ Id: args.id });
24
+ this.print(`Campaign ${args.id} deleted.`);
25
+ return res;
26
+ }
27
+ }
@@ -0,0 +1,28 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import { BaseCommand } from '../../../base.js';
3
+ export default class PushCampaignMessage extends BaseCommand {
4
+ static description = 'Show one push message a campaign sent';
5
+ static examples = ['<%= config.bin %> push campaign message 66b2f0a1... n9d4... --batch b7c3...'];
6
+ static args = {
7
+ id: Args.string({ required: true, description: 'Campaign ID' }),
8
+ messageId: Args.string({ required: true, description: 'Message (notification) ID' }),
9
+ };
10
+ static flags = {
11
+ batch: Flags.string({ required: true, description: 'Batch the message belongs to' }),
12
+ };
13
+ async run() {
14
+ const { args, flags } = await this.parse(PushCampaignMessage);
15
+ const client = this.client(flags);
16
+ // The gateway route now names its token `{notificationId}`, and the
17
+ // regenerated request type has the matching field — so the workaround
18
+ // this command used to carry (an extra `id` plus a cast, because the
19
+ // token and the field had different names) is gone.
20
+ const res = await client.hub.notifications.getPushCampaignMessage({
21
+ campaignId: args.id,
22
+ notificationId: args.messageId,
23
+ campaignBatchId: flags.batch,
24
+ });
25
+ this.print(res);
26
+ return res;
27
+ }
28
+ }
@@ -0,0 +1,26 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import { BaseCommand } from '../../../base.js';
3
+ export default class PushCampaignMessages extends BaseCommand {
4
+ static description = 'List the push messages a campaign sent';
5
+ static examples = ['<%= config.bin %> push campaign messages 66b2f0a1... --batch b7c3...'];
6
+ static args = {
7
+ id: Args.string({ required: true, description: 'Campaign ID' }),
8
+ };
9
+ static flags = {
10
+ batch: Flags.string({ description: 'Only this batch (from `push campaign batches`)' }),
11
+ 'page-size': Flags.integer({ description: 'Items per page' }),
12
+ after: Flags.string({ description: 'Cursor from the previous page' }),
13
+ };
14
+ async run() {
15
+ const { args, flags } = await this.parse(PushCampaignMessages);
16
+ const client = this.client(flags);
17
+ const res = await client.hub.notifications.getPushCampaignMessages({
18
+ campaignId: args.id,
19
+ campaignBatchId: flags.batch,
20
+ pageSize: flags['page-size'],
21
+ startingAfter: flags.after,
22
+ });
23
+ this.print(res);
24
+ return res;
25
+ }
26
+ }
@@ -0,0 +1,16 @@
1
+ import { Args } from '@oclif/core';
2
+ import { BaseCommand } from '../../base.js';
3
+ export default class PushDevice extends BaseCommand {
4
+ static description = 'Show one registered push device, with the user it belongs to';
5
+ static examples = ['<%= config.bin %> push device pnd_123'];
6
+ static args = {
7
+ id: Args.string({ required: true, description: 'Device ID' }),
8
+ };
9
+ async run() {
10
+ const { args, flags } = await this.parse(PushDevice);
11
+ const client = this.client(flags);
12
+ const res = await client.hub.notifications.getPushDevice({ id: args.id });
13
+ this.print(res);
14
+ return res;
15
+ }
16
+ }
@@ -0,0 +1,33 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { BaseCommand } from '../../base.js';
3
+ export default class PushDevices extends BaseCommand {
4
+ static description = `List the devices registered for push
5
+
6
+ Narrow the list with --user, --token or --platform. Devices are stored
7
+ inside their user, so a page is a page of users carrying every matching
8
+ device they hold — follow hasMore rather than stopping at a short page.`;
9
+ static examples = [
10
+ '<%= config.bin %> push devices',
11
+ '<%= config.bin %> push devices --platform ios',
12
+ '<%= config.bin %> push devices --user usr_123',
13
+ ];
14
+ static flags = {
15
+ user: Flags.string({ description: 'Only the devices of this user' }),
16
+ token: Flags.string({ description: 'Only the device registered with this provider token' }),
17
+ platform: Flags.string({
18
+ description: 'Only devices of this platform',
19
+ options: ['ios', 'android', 'chrome', 'safari', 'expo'],
20
+ }),
21
+ };
22
+ async run() {
23
+ const { flags } = await this.parse(PushDevices);
24
+ const client = this.client(flags);
25
+ const res = await client.hub.notifications.getPushDevices({
26
+ userId: flags.user,
27
+ deviceKey: flags.token,
28
+ platform: flags.platform,
29
+ });
30
+ this.print(res);
31
+ return res;
32
+ }
33
+ }
@@ -0,0 +1,12 @@
1
+ import { BaseCommand } from '../../base.js';
2
+ export default class PushDisableDependencies extends BaseCommand {
3
+ static description = 'List what still depends on push — check this before `push disable`';
4
+ static examples = ['<%= config.bin %> push disable-dependencies'];
5
+ async run() {
6
+ const { flags } = await this.parse(PushDisableDependencies);
7
+ const client = this.client(flags);
8
+ const res = await client.hub.notifications.getPushDisableDependencies({});
9
+ this.print(res);
10
+ return res;
11
+ }
12
+ }
@@ -0,0 +1,24 @@
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { Flags } from '@oclif/core';
3
+ import { BaseCommand } from '../../base.js';
4
+ export default class PushDisable extends BaseCommand {
5
+ static description = `Turn off the push module for the project
6
+
7
+ Run \`push disable-dependencies\` first to see what still depends on push.`;
8
+ static examples = ['<%= config.bin %> push disable --yes'];
9
+ static flags = {
10
+ yes: Flags.boolean({ char: 'y', description: 'Skip the confirmation prompt', default: false }),
11
+ };
12
+ async run() {
13
+ const { flags } = await this.parse(PushDisable);
14
+ const client = this.client(flags);
15
+ if (!flags.yes && process.stdout.isTTY) {
16
+ const ok = await confirm({ message: 'Turn off push for this project?', default: false });
17
+ if (!ok)
18
+ return this.print('Cancelled.');
19
+ }
20
+ const res = await client.hub.notifications.disablePush({});
21
+ this.print('Push disabled.');
22
+ return res;
23
+ }
24
+ }
@@ -0,0 +1,12 @@
1
+ import { BaseCommand } from '../../base.js';
2
+ export default class PushEnable extends BaseCommand {
3
+ static description = 'Turn on the push module for the project';
4
+ static examples = ['<%= config.bin %> push enable'];
5
+ async run() {
6
+ const { flags } = await this.parse(PushEnable);
7
+ const client = this.client(flags);
8
+ const res = await client.hub.notifications.enablePush({});
9
+ this.print('Push enabled.');
10
+ return res;
11
+ }
12
+ }
@@ -0,0 +1,19 @@
1
+ import { Args } from '@oclif/core';
2
+ import { BaseCommand } from '../../../base.js';
3
+ export default class PushIntegrationConfirmDelivery extends BaseCommand {
4
+ static description = `Confirm that a test push reached a real person
5
+
6
+ After \`push integration test\`, run this once you have seen the push on the
7
+ device. It marks the integration as proven to deliver.`;
8
+ static examples = ['<%= config.bin %> push integration confirm-delivery 66b2f0a1...'];
9
+ static args = {
10
+ id: Args.string({ required: true, description: 'Integration ID' }),
11
+ };
12
+ async run() {
13
+ const { args, flags } = await this.parse(PushIntegrationConfirmDelivery);
14
+ const client = this.client(flags);
15
+ const res = await client.hub.notifications.confirmPushIntegrationHumanDelivery({ integrationId: args.id });
16
+ this.print(`Delivery confirmed for integration ${args.id}.`);
17
+ return res;
18
+ }
19
+ }
@@ -0,0 +1,41 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { BaseCommand } from '../../../base.js';
3
+ import { readJsonObject } from '../../../lib/push.js';
4
+ /** The provider names the server's integration converter accepts. */
5
+ const PROVIDERS = ['Fake', 'AppleApns', 'AndroidFirebase', 'SafariPush', 'ChromeWeb', 'FirefoxWeb', 'EdgeWeb', 'ChromePush'];
6
+ export default class PushIntegrationSave extends BaseCommand {
7
+ static description = `Create or update a push integration
8
+
9
+ --provider picks the body shape. Fake needs nothing else: it accepts every send
10
+ and contacts no push service, so use it while you develop. The real providers
11
+ need their credentials — put the provider-specific fields in --config (inline
12
+ JSON, @file.json, or - for stdin). Pass --id to update an existing integration.`;
13
+ static examples = [
14
+ '<%= config.bin %> push integration save --provider Fake',
15
+ '<%= config.bin %> push integration save --provider AndroidFirebase --name Android --config @firebase.json',
16
+ ];
17
+ static flags = {
18
+ provider: Flags.string({ required: true, description: 'Push provider', options: PROVIDERS }),
19
+ name: Flags.string({ description: 'Integration name (Fake ignores it and uses its own)' }),
20
+ id: Flags.string({ description: 'Integration ID — set it to update instead of create' }),
21
+ disabled: Flags.boolean({ description: 'Save it turned off', default: false }),
22
+ config: Flags.string({ description: 'Provider-specific fields as a JSON object, @file or -' }),
23
+ };
24
+ async run() {
25
+ const { flags } = await this.parse(PushIntegrationSave);
26
+ const client = this.client(flags);
27
+ const extra = flags.config ? await readJsonObject(flags.config, 'config') : {};
28
+ const integration = {
29
+ ...extra,
30
+ provider: flags.provider,
31
+ integrationId: flags.id,
32
+ integrationName: flags.name ?? '',
33
+ isEnabled: !flags.disabled,
34
+ };
35
+ // The provider-specific fields are not on the generated base type, so the
36
+ // body is cast: the server picks the shape from `provider`.
37
+ const res = await client.hub.notifications.savePushIntegration({ integration });
38
+ this.print(res);
39
+ return res;
40
+ }
41
+ }
@@ -1,19 +1,43 @@
1
- import { Args } from '@oclif/core';
1
+ import { NorbixError } from '@norbix.ai/ts';
2
+ import { Args, Flags } from '@oclif/core';
2
3
  import { BaseCommand } from '../../base.js';
3
4
  export default class PushPreview extends BaseCommand {
4
5
  static description = `Render the title, body and subtitle behind a push preview link
5
6
 
6
7
  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...'];
8
+ build it yourself, so copy it out of the link.
9
+
10
+ The signed link is the key: no login and no project are needed. When you are
11
+ logged in, your session is still sent.`;
12
+ static examples = [
13
+ '<%= config.bin %> push preview 8f2a91c4...',
14
+ '<%= config.bin %> push preview --hash 8f2a91c4... --region nb-eu-germany',
15
+ ];
9
16
  static args = {
10
- hash: Args.string({ required: true, description: 'Preview hash from the preview link' }),
17
+ hash: Args.string({ required: false, description: 'Preview hash from the preview link' }),
18
+ };
19
+ static flags = {
20
+ hash: Flags.string({ description: 'Preview hash from the preview link (same as the argument)' }),
11
21
  };
12
22
  async run() {
13
23
  const { args, flags } = await this.parse(PushPreview);
14
- const client = this.client(flags);
15
- const res = await client.hub.notifications.previewPushNotification({ hash: args.hash });
24
+ const hash = flags.hash ?? args.hash;
25
+ if (!hash) {
26
+ this.error('Pass the preview hash: `norbix push preview <hash>` or `--hash <hash>`.');
27
+ }
28
+ // The signed link opens without sign-in, so neither a login nor a project
29
+ // is forced here. A login, when there is one, is still used.
30
+ const client = this.client(flags, { requireAuth: false, requireProject: false });
31
+ const res = await client.hub.notifications.previewPushNotification({ hash });
16
32
  this.print(res);
17
33
  return res;
18
34
  }
35
+ async catch(error) {
36
+ // A 401 here means the link is bad or expired, not that a session ran out.
37
+ if (error instanceof NorbixError && error.status === 401) {
38
+ const { code } = error;
39
+ return this.error(`${code ? `${code}: ` : ''}${error.message}\nThe preview link is invalid or has expired. Ask for a new link.`);
40
+ }
41
+ return super.catch(error);
42
+ }
19
43
  }
@@ -18,12 +18,10 @@ export default class PushStop extends BaseCommand {
18
18
  if (!ok)
19
19
  return this.print('Cancelled.');
20
20
  }
21
- const n = client.hub.notifications;
22
- if (!n.stopPushCampaign) {
23
- this.error('Your installed @norbix.ai/ts does not support stopping campaigns yet.\n' +
24
- `Update the SDK, or run: norbix api "/{version}/notifications/push/campaigns/${args.id}/stop" --hub --method POST`);
25
- }
26
- const res = await n.stopPushCampaign({ id: args.id });
21
+ // The route is `/campaigns/{Id}/stop` and the transport fills tokens by
22
+ // exact name, but the generated request type has no field at all — so the
23
+ // id goes in as `Id`. Sending `id` throws NORBIX_MISSING_PATH_PARAM.
24
+ const res = await client.hub.notifications.stopPushCampaign({ Id: args.id });
27
25
  this.print(`Campaign ${args.id} stopped.`);
28
26
  return res;
29
27
  }
@@ -0,0 +1,20 @@
1
+ import { BaseCommand } from '../../../base.js';
2
+ import { templateBody, templateFlags } from '../../../lib/push.js';
3
+ export default class PushTemplateCreate extends BaseCommand {
4
+ static description = `Create a push template
5
+
6
+ One language: --title and --body. Several languages: --translations with a
7
+ JSON array of {language, content: {title, body}}.`;
8
+ static examples = [
9
+ '<%= config.bin %> push template create --name Welcome --title "Hi @Model.Name" --body "Thanks for joining"',
10
+ '<%= config.bin %> push template create --name Welcome --translations @welcome.json',
11
+ ];
12
+ static flags = templateFlags;
13
+ async run() {
14
+ const { flags } = await this.parse(PushTemplateCreate);
15
+ const client = this.client(flags);
16
+ const res = await client.hub.notifications.createPushTemplate((await templateBody(flags)));
17
+ this.print(res);
18
+ return res;
19
+ }
20
+ }
@@ -0,0 +1,28 @@
1
+ import { Flags } from '@oclif/core';
2
+ import { BaseCommand } from '../../../base.js';
3
+ import { parseTokens } from '../../../lib/push.js';
4
+ export default class PushTemplateRender extends BaseCommand {
5
+ static description = `Render one push field (title, body or subtitle) with token values
6
+
7
+ Use it to check a Razor template before you save it. When the template needs a
8
+ token you did not pass, the answer lists the missing ones.`;
9
+ static examples = [
10
+ '<%= config.bin %> push template render --code "Hi @Model.Name" --token Name=Ada',
11
+ ];
12
+ static flags = {
13
+ code: Flags.string({ required: true, description: 'Razor source of the field' }),
14
+ token: Flags.string({ description: 'Token value as key=value (repeat for several)', multiple: true }),
15
+ preview: Flags.boolean({ description: 'Render for a preview (less strict)', default: false }),
16
+ };
17
+ async run() {
18
+ const { flags } = await this.parse(PushTemplateRender);
19
+ const client = this.client(flags);
20
+ const res = await client.hub.notifications.renderPush({
21
+ code: flags.code,
22
+ tokens: parseTokens(flags.token),
23
+ isForPreview: flags.preview,
24
+ });
25
+ this.print(res);
26
+ return res;
27
+ }
28
+ }
@@ -0,0 +1,16 @@
1
+ import { Args } from '@oclif/core';
2
+ import { BaseCommand } from '../../../base.js';
3
+ export default class PushTemplateTokens extends BaseCommand {
4
+ static description = 'List the tokens a push template uses — the values a campaign must fill';
5
+ static examples = ['<%= config.bin %> push template tokens 66b2f0a1...'];
6
+ static args = {
7
+ id: Args.string({ required: true, description: 'Template ID' }),
8
+ };
9
+ async run() {
10
+ const { args, flags } = await this.parse(PushTemplateTokens);
11
+ const client = this.client(flags);
12
+ const res = await client.hub.notifications.getPushMessageContentTokens({ id: args.id });
13
+ this.print(res);
14
+ return res;
15
+ }
16
+ }