@jacebenson/jsn 4.0.3 → 4.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.
@@ -0,0 +1,292 @@
1
+ // Domain separation commands — list/show/current/set
2
+ //
3
+ // Domain separation (plugin) adds a `domain` table holding the domain
4
+ // hierarchy. Each domain-separated table's rows carry `sys_domain` +
5
+ // `sys_domain_path`, and queries are auto-filtered to the session domain
6
+ // plus its children. There is NO durable platform-side "current domain"
7
+ // preference a CLI can write (the picker sets the in-memory session only,
8
+ // and `sys_user.domain` is write-protected even for admin). So jsn stores
9
+ // the chosen domain in the active profile and sends it on every request
10
+ // via the `X-Now-Domain` header — the same scoping the platform applies
11
+ // to its own picker sessions.
12
+
13
+ import { getStringField, interactiveList, assertSafeExactMatch } from '../../helpers.js';
14
+
15
+ const DETAIL_FIELDS = 'sys_id,name,parent,sys_domain_path,description,active,sys_created_on,sys_updated_on';
16
+
17
+ /**
18
+ * Gate: domain separation must be installed. The `domain` table only
19
+ * exists on instances with the plugin. A single cheap query detects it;
20
+ * on non-DS instances the API returns "Invalid table domain".
21
+ */
22
+ export async function isDomainSeparationInstalled(app) {
23
+ try {
24
+ const params = new URLSearchParams();
25
+ params.set('sysparm_limit', '1');
26
+ params.set('sysparm_fields', 'sys_id,name');
27
+ await app.sdk.list('domain', params);
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ async function requireDomainSeparation(app) {
35
+ if (!(await isDomainSeparationInstalled(app))) {
36
+ const e = new Error('Domain separation is not installed on this instance — the `domain` table does not exist. Install the Domain Separation plugin to use these commands.');
37
+ e.name = 'NotSupported';
38
+ throw e;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Resolve a domain name or sys_id to its sys_id. Throws when not found.
44
+ */
45
+ export async function resolveDomainSysId(app, domainArg) {
46
+ assertSafeExactMatch(domainArg);
47
+ const params = new URLSearchParams();
48
+ params.set('sysparm_query', `name=${domainArg}`);
49
+ params.set('sysparm_limit', '1');
50
+ params.set('sysparm_fields', 'sys_id,name');
51
+ let records = await app.sdk.list('domain', params);
52
+ if (records.length === 0) {
53
+ const p2 = new URLSearchParams();
54
+ p2.set('sysparm_query', `sys_id=${domainArg}`);
55
+ p2.set('sysparm_limit', '1');
56
+ p2.set('sysparm_fields', 'sys_id,name');
57
+ records = await app.sdk.list('domain', p2);
58
+ }
59
+ if (records.length === 0) {
60
+ throw new Error(`Domain not found: ${domainArg}`);
61
+ }
62
+ return getStringField(records[0], 'sys_id');
63
+ }
64
+
65
+ /**
66
+ * Interactive domain picker — returns the chosen domain's sys_id, or null
67
+ * when the user cancels. Used by `domains set` and the setup/modify flows.
68
+ */
69
+ export async function pickDomain(app) {
70
+ const picked = await interactiveList({
71
+ app, table: 'domain', singular: 'domain', columns: ['name', 'sys_domain_path'], labelField: 'name',
72
+ formatLabel: formatDomainLabel,
73
+ });
74
+ if (!picked) return null;
75
+ return getStringField(picked, 'sys_id');
76
+ }
77
+
78
+ /** Format a picker label: "Name (path)". */
79
+ export function formatDomainLabel(r) {
80
+ const path = getStringField(r, 'sys_domain_path');
81
+ return `${getStringField(r, 'name')}${path ? ` (${path})` : ''}`;
82
+ }
83
+
84
+ /** Build a rich detail view for a domain record. */
85
+ export async function formatDomainDetail(app, record) {
86
+ const sysID = getStringField(record, 'sys_id');
87
+ const name = getStringField(record, 'name');
88
+ const instance = app.getEffectiveInstance();
89
+ const link = `${instance}/domain.do?sys_id=${sysID}`;
90
+
91
+ // Fetch full record for fields not present on the picked row
92
+ const params = new URLSearchParams();
93
+ params.set('sysparm_query', `sys_id=${sysID}`);
94
+ params.set('sysparm_limit', '1');
95
+ params.set('sysparm_display_value', 'all');
96
+ params.set('sysparm_fields', DETAIL_FIELDS);
97
+ const full = await app.sdk.list('domain', params);
98
+ const rec = full[0] || record;
99
+
100
+ const parent = getStringField(rec, 'parent');
101
+ const path = getStringField(rec, 'sys_domain_path');
102
+
103
+ const lines = [
104
+ `Domain: ${name}`,
105
+ `Path: ${path || '(root)'}`,
106
+ `Parent: ${parent || '(none)'}`,
107
+ `Active: ${getStringField(rec, 'active') || 'true'}`,
108
+ `Created: ${getStringField(rec, 'sys_created_on')}`,
109
+ `Updated: ${getStringField(rec, 'sys_updated_on')}`,
110
+ `Link: ${link}`,
111
+ ];
112
+
113
+ const desc = getStringField(rec, 'description');
114
+ if (desc) lines.splice(1, 0, `Desc: ${desc}`);
115
+
116
+ return {
117
+ sys_id: sysID,
118
+ name,
119
+ parent,
120
+ sys_domain_path: path,
121
+ _formatted: lines.join('\n'),
122
+ };
123
+ }
124
+
125
+ export function domainsCmd(wrap) {
126
+ return {
127
+ command: 'domains [subcommand]',
128
+ aliases: ['domain', 'dom'],
129
+ describe: 'Manage domains (domain separation)',
130
+ builder: (yargs) => {
131
+ return yargs
132
+ .command({
133
+ command: 'list',
134
+ aliases: ['ls'],
135
+ describe: 'List domains (hierarchy + paths)',
136
+ builder: (y) => y
137
+ .option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEACME")' })
138
+ .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns' })
139
+ .option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' }),
140
+ handler: wrap(async (argv, app) => {
141
+ await requireDomainSeparation(app);
142
+ const columns = argv.columns ? argv.columns.split(',') : ['name', 'parent', 'sys_domain_path'];
143
+ const query = argv.query || '';
144
+
145
+ const picked = await interactiveList({
146
+ app, table: 'domain', singular: 'domain', columns, limit: argv.limit, query, labelField: 'name',
147
+ formatLabel: formatDomainLabel,
148
+ });
149
+ if (picked === undefined) return; // user cancelled
150
+ if (picked) {
151
+ const detail = await formatDomainDetail(app, picked);
152
+ return app.ok(detail, { summary: `Domain: ${getStringField(picked, 'name')}` });
153
+ }
154
+
155
+ const params = new URLSearchParams();
156
+ params.set('sysparm_limit', String(argv.limit));
157
+ params.set('sysparm_display_value', 'all');
158
+ params.set('sysparm_fields', ['sys_id', ...columns].join(','));
159
+ const q = argv.query ? argv.query + '^ORDERBYsys_domain_path' : 'ORDERBYsys_domain_path';
160
+ params.set('sysparm_query', q);
161
+ const records = await app.sdk.list('domain', params);
162
+ app.ok({
163
+ table: 'domain',
164
+ count: records.length,
165
+ columns,
166
+ records: records.map(r => formatDomainListRecord(r, columns)),
167
+ context: { instance_url: app.getEffectiveInstance() },
168
+ }, { summary: `${records.length} domain(s)` });
169
+ }),
170
+ })
171
+ .command({
172
+ command: 'show <domain>',
173
+ aliases: ['get'],
174
+ describe: 'Show a domain by name or sys_id',
175
+ builder: (y) => y
176
+ .positional('domain', {
177
+ describe: 'Domain name (e.g. ACME) or sys_id',
178
+ type: 'string',
179
+ }),
180
+ handler: wrap(async (argv, app) => {
181
+ await requireDomainSeparation(app);
182
+ assertSafeExactMatch(argv.domain);
183
+ let records;
184
+ // Try by name first, then by sys_id
185
+ for (const queryField of ['name', 'sys_id']) {
186
+ const params = new URLSearchParams();
187
+ params.set('sysparm_query', `${queryField}=${argv.domain}`);
188
+ params.set('sysparm_limit', '1');
189
+ params.set('sysparm_display_value', 'all');
190
+ records = await app.sdk.list('domain', params);
191
+ if (records.length > 0) break;
192
+ }
193
+ if (!records || records.length === 0) {
194
+ throw new Error(`Domain not found: ${argv.domain}`);
195
+ }
196
+ const detail = await formatDomainDetail(app, records[0]);
197
+ app.ok(detail, { summary: `Domain ${argv.domain}` });
198
+ }),
199
+ })
200
+ .command({
201
+ command: 'current',
202
+ describe: 'Show the configured domain for this profile',
203
+ handler: wrap(async (argv, app) => {
204
+ await requireDomainSeparation(app);
205
+ const domain = app._profileDomain(app.config);
206
+ if (!domain) {
207
+ app.ok({ domain: '', scoping: 'none' }, {
208
+ summary: 'No domain configured — requests are not domain-scoped',
209
+ });
210
+ return;
211
+ }
212
+ // Resolve the configured value to a display name if it's a sys_id
213
+ let label = domain;
214
+ try {
215
+ const params = new URLSearchParams();
216
+ params.set('sysparm_query', `sys_id=${domain}`);
217
+ params.set('sysparm_limit', '1');
218
+ params.set('sysparm_fields', 'sys_id,name');
219
+ const records = await app.sdk.list('domain', params);
220
+ if (records.length > 0) label = `${getStringField(records[0], 'name')} (${domain})`;
221
+ } catch { /* non-fatal — show raw value */ }
222
+ app.ok({ domain, scoping: 'X-Now-Domain header' }, {
223
+ summary: `Configured domain: ${label}`,
224
+ });
225
+ }),
226
+ })
227
+ .command({
228
+ command: 'set [domain]',
229
+ describe: 'Set the configured domain for this profile (interactive picker when run bare; omit to clear)',
230
+ handler: wrap(async (argv, app) => {
231
+ await requireDomainSeparation(app);
232
+ let domainArg = argv.domain;
233
+ if (!domainArg) {
234
+ const picked = await pickDomain(app);
235
+ if (!picked) return; // cancelled or non-interactive
236
+ domainArg = picked;
237
+ } else if (domainArg.toLowerCase() === 'none' || domainArg.toLowerCase() === 'clear') {
238
+ app.setDomain('');
239
+ app.ok({ domain: '' }, { summary: 'Domain cleared — requests are no longer domain-scoped' });
240
+ return;
241
+ }
242
+
243
+ const sysID = await resolveDomainSysId(app, domainArg);
244
+ app.setDomain(sysID);
245
+ app.ok({ domain: sysID, name: domainArg }, {
246
+ summary: `Configured domain: ${domainArg} — all requests scoped to it`,
247
+ });
248
+ }),
249
+ });
250
+ },
251
+ handler: async (argv) => {
252
+ if (!argv._[1]) {
253
+ const app = argv.app;
254
+ let installed = false;
255
+ try {
256
+ if (app && app.sdk) installed = await isDomainSeparationInstalled(app);
257
+ } catch {
258
+ installed = false;
259
+ }
260
+ if (!installed) {
261
+ if (app && app.sdk) {
262
+ console.log('Domain separation is not installed on this instance.');
263
+ console.log('Install the Domain Separation plugin to manage domains.');
264
+ } else {
265
+ console.log('No instance configured — run "jsn auth login <instance>" first.');
266
+ }
267
+ return;
268
+ }
269
+ console.log('Manage ServiceNow domains (domain separation).\n');
270
+ console.log('Commands:');
271
+ console.log(' list List domains (hierarchy + paths)');
272
+ console.log(' show <domain> Show a domain');
273
+ console.log(' current Show the configured domain for this profile');
274
+ console.log(' set [domain] Configure the domain for this profile (picker when bare; "clear" to unset)');
275
+ console.log('\nDomains are applied per-request via the X-Now-Domain header —');
276
+ console.log('the platform filters every query to the domain and its children.');
277
+ console.log('Run "jsn domains <command> --help" for details.');
278
+ }
279
+ },
280
+ };
281
+ }
282
+
283
+ /** Flat display record for list mode (keeps the JSON payload small). */
284
+ function formatDomainListRecord(r, columns) {
285
+ const out = {};
286
+ for (const c of columns) {
287
+ const v = getStringField(r, c);
288
+ out[c] = v;
289
+ }
290
+ out.sys_id = getStringField(r, 'sys_id');
291
+ return out;
292
+ }
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import { getCurrentUser, getCurrentApplication } from '../../context.js';
2
3
 
3
4
  export function evalCmd(wrap) {
4
5
  return {
@@ -8,16 +9,34 @@ export function evalCmd(wrap) {
8
9
  return yargs
9
10
  .option('script', { alias: 's', type: 'string', describe: "JavaScript code to execute (single-quote the script, double quotes inside: --script 'gs.log(\"Hello\")')" })
10
11
  .option('file', { alias: 'f', type: 'string', describe: 'Read script from file (avoids shell quoting issues)' })
11
- .option('scope', { alias: null, type: 'string', describe: 'Scope name or sys_id to run the script under (default: global)' });
12
+ .option('scope', { alias: null, type: 'string', describe: 'Scope name or sys_id to run the script under (default: active scope from banner)' });
12
13
  },
13
14
  handler: wrap(async (argv, app) => {
14
15
  let script;
15
16
  let scopeSysId = null;
16
17
 
17
- if (argv.scope) {
18
- scopeSysId = await app.sdk.resolveScope(argv.scope);
18
+ // Resolve execution scope: explicit --scope wins; otherwise use the
19
+ // active scope from apps.current_app (same source as the banner);
20
+ // fall back to global only when no active scope exists.
21
+ let scope = argv.scope || '';
22
+ if (!scope) {
23
+ try {
24
+ const user = await getCurrentUser(app.sdk);
25
+ if (user && user.sys_id) {
26
+ const currentApp = await getCurrentApplication(app.sdk, user.sys_id);
27
+ if (currentApp && currentApp.scope && currentApp.scope !== 'global') {
28
+ scope = currentApp.scope;
29
+ }
30
+ }
31
+ } catch {
32
+ // ignore — fall back to global
33
+ }
34
+ }
35
+
36
+ if (scope) {
37
+ scopeSysId = await app.sdk.resolveScope(scope);
19
38
  if (!scopeSysId) {
20
- throw new Error(`Scope not found: ${argv.scope}`);
39
+ throw new Error(`Scope not found: ${scope}`);
21
40
  }
22
41
  }
23
42
 
@@ -36,10 +55,13 @@ export function evalCmd(wrap) {
36
55
  const warning = scopeSysId
37
56
  ? ` ⚠️ Records without explicit sys_scope will land in global. Use $scopeSysId variable.`
38
57
  : '';
39
- const output = await app.sdk.executeScript(script, argv.scope);
58
+ // Pass the resolved sys_id (not the name) — sys.scripts.do only
59
+ // honors selectable scope values, which are sys_ids.
60
+ const output = await app.sdk.executeScript(script, scopeSysId || '');
40
61
  app.ok({
41
62
  script,
42
63
  output,
64
+ scope: scope || 'global',
43
65
  instance: app.getEffectiveInstance(),
44
66
  }, {
45
67
  summary: 'Script executed' + warning,
@@ -1,4 +1,66 @@
1
- import { formatRecordForDisplay, getStringField, interactiveList } from '../../helpers.js';
1
+ import { formatRecordForDisplay, getStringField, interactiveList, assertSafeExactMatch } from '../../helpers.js';
2
+ import { requireCurrentUserSysId, setCurrentApplication } from '../../context.js';
3
+
4
+ /** Format a picker label for a scope: "Name [scope]". */
5
+ export function formatScopeLabel(r) {
6
+ return `${getStringField(r, 'name')} [${getStringField(r, 'scope') || '?'}]`;
7
+ }
8
+
9
+ /**
10
+ * Rich display for a scope: header fields + record link.
11
+ * Shared by `list` pick and `show` so they can't drift.
12
+ */
13
+ export async function formatScopeDetail(app, record) {
14
+ const sysID = getStringField(record, 'sys_id');
15
+ const instance = app.getEffectiveInstance();
16
+ const link = `${instance}/sys_scope.do?sys_id=${sysID}`;
17
+
18
+ let full = record;
19
+ try {
20
+ const params = new URLSearchParams();
21
+ params.set('sysparm_query', `sys_id=${sysID}`);
22
+ params.set('sysparm_limit', '1');
23
+ params.set('sysparm_display_value', 'all');
24
+ params.set('sysparm_fields', 'sys_id,name,scope,short_description,active,description,version,release_date,sys_class_name,sys_created_on,sys_updated_on');
25
+ const recs = await app.sdk.list('sys_scope', params);
26
+ if (Array.isArray(recs) && recs.length > 0) full = recs[0];
27
+ } catch {
28
+ // fall back to the passed record
29
+ }
30
+
31
+ // Store apps carry sys_class_name "Store Application" — link to the
32
+ // Store search for the scope value (no store-side app ID is available
33
+ // locally; the search URL is the reliable path).
34
+ const isStoreApp = String(getStringField(full, 'sys_class_name')).toLowerCase().includes('store');
35
+ const storeLink = isStoreApp ? `https://store.servicenow.com/store/apps?q=${encodeURIComponent(getStringField(full, 'scope'))}` : '';
36
+
37
+ const lines = [];
38
+ lines.push(`Scope: ${getStringField(full, 'name') || '?'}`);
39
+ lines.push(` Scope value: ${getStringField(full, 'scope') || '?'}`);
40
+ if (getStringField(full, 'version')) lines.push(` Version: ${getStringField(full, 'version')}`);
41
+ if (getStringField(full, 'release_date')) lines.push(` Released: ${getStringField(full, 'release_date')}`);
42
+ if (getStringField(full, 'short_description')) lines.push(` Description: ${getStringField(full, 'short_description')}`);
43
+ if (getStringField(full, 'active')) lines.push(` Active: ${getStringField(full, 'active')}`);
44
+ if (storeLink) lines.push(` Store: ${storeLink}`);
45
+ if (getStringField(full, 'sys_created_on')) lines.push(` Created: ${getStringField(full, 'sys_created_on')}`);
46
+ if (getStringField(full, 'sys_updated_on')) lines.push(` Updated: ${getStringField(full, 'sys_updated_on')}`);
47
+ lines.push(` Link: ${link}`);
48
+
49
+ return {
50
+ sys_id: sysID,
51
+ name: getStringField(full, 'name'),
52
+ scope: getStringField(full, 'scope'),
53
+ version: getStringField(full, 'version') || '',
54
+ release_date: getStringField(full, 'release_date') || '',
55
+ short_description: getStringField(full, 'short_description') || '',
56
+ active: getStringField(full, 'active') || '',
57
+ store_link: storeLink,
58
+ sys_created_on: getStringField(full, 'sys_created_on') || '',
59
+ sys_updated_on: getStringField(full, 'sys_updated_on') || '',
60
+ link,
61
+ _formatted: lines.join('\n'),
62
+ };
63
+ }
2
64
 
3
65
  export function scopesCmd(wrap) {
4
66
  return {
@@ -21,12 +83,12 @@ export function scopesCmd(wrap) {
21
83
 
22
84
  const picked = await interactiveList({
23
85
  app, table: 'sys_scope', singular: 'scope', columns, limit: argv.limit, query, labelField: 'name',
24
- formatLabel: r => `${getStringField(r, 'name')} [${getStringField(r, 'scope') || '?'}]`,
86
+ formatLabel: formatScopeLabel,
25
87
  });
26
88
  if (picked === undefined) return; // user cancelled
27
89
  if (picked) {
28
- picked._context = { instance_url: app.getEffectiveInstance(), table: 'sys_scope' };
29
- return app.ok(picked, { summary: `Scope: ${getStringField(picked, 'name')}` });
90
+ const detail = await formatScopeDetail(app, picked);
91
+ return app.ok(detail, { summary: `Scope: ${getStringField(picked, 'name')}` });
30
92
  }
31
93
 
32
94
  const params = new URLSearchParams();
@@ -55,6 +117,7 @@ export function scopesCmd(wrap) {
55
117
  type: 'string',
56
118
  }),
57
119
  handler: wrap(async (argv, app) => {
120
+ assertSafeExactMatch(argv.scope);
58
121
  let records;
59
122
  // Try by scope value first, then by name
60
123
  for (const queryField of ['scope', 'name']) {
@@ -68,47 +131,36 @@ export function scopesCmd(wrap) {
68
131
  if (!records || records.length === 0) {
69
132
  throw new Error(`Scope not found: ${argv.scope}`);
70
133
  }
71
- app.ok(records[0], { summary: `Scope ${argv.scope}` });
134
+ const detail = await formatScopeDetail(app, records[0]);
135
+ app.ok(detail, { summary: `Scope ${argv.scope}` });
72
136
  }),
73
137
  })
74
138
  .command({
75
- command: 'set <scope>',
76
- describe: 'Set the current application scope',
139
+ command: 'set [scope]',
140
+ describe: 'Set the current application scope (interactive picker when run bare)',
77
141
  handler: wrap(async (argv, app) => {
142
+ let scopeArg = argv.scope;
143
+ if (!scopeArg) {
144
+ const picked = await interactiveList({
145
+ app, table: 'sys_scope', singular: 'scope', columns: ['name', 'scope'], labelField: 'name',
146
+ formatLabel: r => `${getStringField(r, 'name')} [${getStringField(r, 'scope') || '?'}]`,
147
+ });
148
+ if (!picked) return; // cancelled or non-interactive
149
+ scopeArg = getStringField(picked, 'scope');
150
+ }
151
+ assertSafeExactMatch(scopeArg);
78
152
  const params = new URLSearchParams();
79
- params.set('sysparm_query', `scope=${argv.scope}`);
153
+ params.set('sysparm_query', `scope=${scopeArg}`);
80
154
  params.set('sysparm_limit', '1');
81
- params.set('sysparm_fields', 'sys_id,scope');
155
+ params.set('sysparm_fields', 'sys_id,scope,name');
82
156
  const records = await app.sdk.list('sys_scope', params);
83
157
  if (records.length === 0) {
84
- throw new Error(`Scope not found: ${argv.scope}`);
158
+ throw new Error(`Scope not found: ${scopeArg}`);
85
159
  }
86
160
  const scopeSysID = getStringField(records[0], 'sys_id');
87
- // Update user preference
88
- const user = await app.sdk.list('sys_user', new URLSearchParams({
89
- sysparm_query: 'user_name=javascript:gs.getUserName()',
90
- sysparm_limit: '1',
91
- sysparm_fields: 'sys_id',
92
- }));
93
- if (user.length === 0) {
94
- throw new Error('Could not determine current user');
95
- }
96
- const userSysID = getStringField(user[0], 'sys_id');
97
- const prefParams = new URLSearchParams();
98
- prefParams.set('sysparm_query', `user=${userSysID}^name=apps.current_app`);
99
- prefParams.set('sysparm_limit', '1');
100
- const prefs = await app.sdk.list('sys_user_preference', prefParams);
101
- if (prefs.length > 0) {
102
- await app.sdk.update('sys_user_preference', getStringField(prefs[0], 'sys_id'), { value: scopeSysID });
103
- } else {
104
- await app.sdk.create('sys_user_preference', {
105
- user: userSysID,
106
- name: 'apps.current_app',
107
- value: scopeSysID,
108
- type: 'string',
109
- });
110
- }
111
- app.ok({ scope: argv.scope, sys_id: scopeSysID }, { summary: `Current scope: ${argv.scope}` });
161
+ const userSysID = await requireCurrentUserSysId(app.sdk);
162
+ await setCurrentApplication(app.sdk, userSysID, scopeSysID);
163
+ app.ok({ scope: scopeArg, sys_id: scopeSysID }, { summary: `Current scope: ${scopeArg}` });
112
164
  }),
113
165
  })
114
166
  .command({
@@ -158,7 +210,7 @@ export function scopesCmd(wrap) {
158
210
  console.log('Commands:');
159
211
  console.log(' list List application scopes');
160
212
  console.log(' show <scope> Show a scope');
161
- console.log(' set <scope> Set the current application scope');
213
+ console.log(' set [scope] Set the current application scope (picker when run bare)');
162
214
  console.log(' create Create a new application scope');
163
215
  console.log('\nRun "jsn scopes <command> --help" for details.');
164
216
  }