@jacebenson/jsn 0.1.2 → 0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/app.js CHANGED
@@ -118,6 +118,21 @@ export class App {
118
118
  const updateSetStr = `]8;;${updateSetLink}\x07${updateSet}]8;;\x07`;
119
119
 
120
120
  process.stderr.write(`${profileStr} ${userStr} ${scopeStr} ${updateSetStr}\n\n`);
121
+
122
+ // ⚠️ Warning if in the Default update set
123
+ if (updateSet && updateSet.toLowerCase().includes('default')) {
124
+ process.stderr.write(
125
+ '\x1b[33m' + // yellow
126
+ '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
127
+ ' ⚠ You are in the Default update set!\n' +
128
+ ' Create a named update set to capture your changes:\n' +
129
+ ' jsn dev updatesets create --name "My Feature"\n' +
130
+ ' jsn dev updatesets set "My Feature"\n' +
131
+ ' (Run \x1b[1mjsn updatesets yolo\x1b[22m to silence this warning)\n' +
132
+ '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
133
+ '\x1b[0m' // reset
134
+ );
135
+ }
121
136
  }
122
137
 
123
138
  ok(data, opts = {}) {
package/src/auth.js CHANGED
@@ -44,6 +44,40 @@ function removePKCEState(instance) {
44
44
  const DEFAULT_OAUTH_CLIENT_ID = '543e5655f77746a28228c6009a599dfb';
45
45
  const REDIRECT_URI = '/sdk-oauth.do';
46
46
 
47
+ // ─── Basic auth from environment variables ───
48
+ // SN_USERNAME / SN_PASSWORD — global credentials
49
+ // SN_<INSTANCE>_USERNAME / SN_<INSTANCE>_PASSWORD — instance-specific (e.g. SN_DEV328604_USERNAME)
50
+
51
+ function envVarName(instance) {
52
+ // Normalize instance URL to an env-var-safe name
53
+ // e.g. https://dev328604.service-now.com → DEV328604
54
+ const host = instance.replace(/https?:\/\//, '').replace(/\/.*$/, '').replace(/\.service-now\.com.*/, '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
55
+ return host;
56
+ }
57
+
58
+ function getBasicAuthFromEnv(instance) {
59
+ if (!instance) {
60
+ // Try global env vars
61
+ const username = process.env.SN_USERNAME;
62
+ const password = process.env.SN_PASSWORD;
63
+ if (username && password) return { auth_method: 'basic', username, password };
64
+ return null;
65
+ }
66
+
67
+ // Try instance-specific env vars first (e.g. SN_DEV328604_USERNAME)
68
+ const host = envVarName(instance);
69
+ const instanceUser = process.env[`SN_${host}_USERNAME`];
70
+ const instancePass = process.env[`SN_${host}_PASSWORD`];
71
+ if (instanceUser && instancePass) return { auth_method: 'basic', username: instanceUser, password: instancePass };
72
+
73
+ // Fall back to global env vars
74
+ const globalUser = process.env.SN_USERNAME;
75
+ const globalPass = process.env.SN_PASSWORD;
76
+ if (globalUser && globalPass) return { auth_method: 'basic', username: globalUser, password: globalPass };
77
+
78
+ return null;
79
+ }
80
+
47
81
  // Keychain constants — same as Go version (internal/auth/store.go)
48
82
  const KEYRING_SERVICE = 'servicenow-cli';
49
83
  const KEYRING_ATTR_SERVICE = 'service';
@@ -218,6 +252,7 @@ export class AuthManager {
218
252
 
219
253
  isAuthenticated() {
220
254
  if (process.env.SERVICENOW_OAUTH_TOKEN) return true;
255
+ if (getBasicAuthFromEnv()) return true;
221
256
  const instance = this.configProvider.getEffectiveInstance();
222
257
  if (!instance) return false;
223
258
  try {
@@ -230,6 +265,7 @@ export class AuthManager {
230
265
 
231
266
  isAuthenticatedFor(instance) {
232
267
  if (!instance) return false;
268
+ if (getBasicAuthFromEnv(instance)) return true;
233
269
  const creds = loadCredentials(instance);
234
270
  if (!creds) return false;
235
271
  if (creds.expires_at && Date.now() >= creds.expires_at * 1000) return false;
@@ -244,10 +280,16 @@ export class AuthManager {
244
280
  if (!instance) {
245
281
  throw errAuth('No instance configured');
246
282
  }
283
+ // Check basic auth from env vars first
284
+ const basicCreds = getBasicAuthFromEnv(instance);
285
+ if (basicCreds) return basicCreds;
247
286
  return this.getCredentialsFor(instance);
248
287
  }
249
288
 
250
289
  getCredentialsFor(instance) {
290
+ // Check basic auth from env vars first
291
+ const basicCreds = getBasicAuthFromEnv(instance);
292
+ if (basicCreds) return basicCreds;
251
293
  const creds = loadCredentials(instance);
252
294
  if (!creds) {
253
295
  throw errAuth(`Not authenticated for ${instance}`);
@@ -314,13 +356,68 @@ export class AuthManager {
314
356
  * Build an OAuth authorization URL and persist PKCE state for later use.
315
357
  * After calling this, the user can visit the URL in a browser and then
316
358
  * call loginWithCode() with the resulting authorization code.
359
+ * If waitFile is provided, the method will read the code from that file
360
+ * (waits for file to appear, polling every 2 seconds, up to 5 minutes).
317
361
  */
318
- buildAuthURL(instanceURL) {
362
+ buildAuthURL(instanceURL, waitFile) {
319
363
  instanceURL = normalizeInstanceURL(instanceURL);
320
364
  const clientID = getOAuthClientID();
321
365
  const pkce = generatePKCE();
322
366
  savePKCEState(instanceURL, pkce);
323
- return buildAuthURL(instanceURL, clientID, pkce);
367
+ const url = buildAuthURL(instanceURL, clientID, pkce);
368
+ if (waitFile) {
369
+ console.log(url);
370
+ console.log();
371
+ console.log(`Waiting for authorization code in file: ${waitFile}`);
372
+ console.log('(polling every 2 seconds — write the code to the file to complete login)');
373
+ return this._waitForCodeFile(instanceURL, clientID, pkce, waitFile);
374
+ }
375
+ return url;
376
+ }
377
+
378
+ async _waitForCodeFile(instanceURL, clientID, pkce, filePath, timeout = 300000) {
379
+ const start = Date.now();
380
+ const pollInterval = 2000;
381
+ while (Date.now() - start < timeout) {
382
+ try {
383
+ const code = fs.readFileSync(filePath, 'utf-8').trim();
384
+ if (code) {
385
+ console.log(`\nAuthorization code found in ${filePath}`);
386
+ removePKCEState(instanceURL);
387
+ const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
388
+ saveCredentials(instanceURL, newCreds);
389
+ console.log('Token exchange successful!\n');
390
+ return newCreds;
391
+ }
392
+ } catch {
393
+ // File not ready yet
394
+ }
395
+ await new Promise(r => setTimeout(r, pollInterval));
396
+ }
397
+ throw errAuth(`Timed out waiting for authorization code in ${filePath} (${timeout / 1000}s)`);
398
+ }
399
+
400
+ /**
401
+ * Authenticate with basic auth via environment variables.
402
+ * Reads SN_USERNAME/SN_PASSWORD (or SN_<INSTANCE>_USERNAME/SN_<INSTANCE>_PASSWORD).
403
+ * Stores the basic auth credentials so they persist across sessions.
404
+ */
405
+ async loginWithPassword(instanceURL) {
406
+ instanceURL = normalizeInstanceURL(instanceURL);
407
+ const creds = getBasicAuthFromEnv(instanceURL);
408
+ if (!creds) {
409
+ throw errAuth(
410
+ `No basic auth credentials found in environment.\n\n` +
411
+ `Set the following environment variables:\n` +
412
+ ` SN_USERNAME=admin (or SN_${envVarName(instanceURL)}_USERNAME)\n` +
413
+ ` SN_PASSWORD=<password> (or SN_${envVarName(instanceURL)}_PASSWORD)\n\n` +
414
+ `Then run:\n` +
415
+ ` jsn auth login --password ${instanceURL}`
416
+ );
417
+ }
418
+ saveCredentials(instanceURL, creds);
419
+ console.log(`✓ Basic auth credentials saved for ${instanceURL}`);
420
+ return creds;
324
421
  }
325
422
 
326
423
  /**
package/src/cli.js CHANGED
@@ -12,6 +12,7 @@ import { setupCmd } from './commands/setup.js';
12
12
  import { authCmd } from './commands/auth.js';
13
13
  import { profilesCmd } from './commands/profiles.js';
14
14
  import { recordsCmd } from './commands/records.js';
15
+ import { catalogCmd } from './commands/catalog.js';
15
16
  import { incidentsCmd } from './commands/incidents.js';
16
17
  import { changesCmd } from './commands/changes.js';
17
18
  import { requestsCmd } from './commands/requests.js';
@@ -125,6 +126,7 @@ export const cli = yargs(hideBin(process.argv))
125
126
  .command(authCmd(wrap))
126
127
  .command(profilesCmd(wrap))
127
128
  .command(recordsCmd(wrap))
129
+ .command(catalogCmd(wrap))
128
130
  .command(incidentsCmd(wrap))
129
131
  .command(changesCmd(wrap))
130
132
  .command(requestsCmd(wrap))
@@ -5,7 +5,7 @@ import process from 'node:process';
5
5
  export function authCmd(wrap) {
6
6
  return {
7
7
  command: 'auth <subcommand>',
8
- describe: 'Manage OAuth authentication',
8
+ describe: 'Manage authentication (OAuth or basic auth via env vars)',
9
9
  builder: (yargs) => {
10
10
  return yargs
11
11
  .command({
@@ -16,9 +16,17 @@ export function authCmd(wrap) {
16
16
  describe: 'Authorization code from browser (bypasses interactive prompt)',
17
17
  type: 'string',
18
18
  })
19
+ .option('password', {
20
+ describe: 'Authenticate with basic auth via env vars (SN_USERNAME/SN_PASSWORD)',
21
+ type: 'boolean',
22
+ })
19
23
  .option('print-url', {
20
24
  describe: 'Print the OAuth URL and exit (saves PKCE state for --code)',
21
25
  type: 'boolean',
26
+ })
27
+ .option('wait-file', {
28
+ describe: 'File path to watch for authorization code (used with --print-url)',
29
+ type: 'string',
22
30
  }),
23
31
  handler: wrap(async (argv, app) => {
24
32
  let instanceURL;
@@ -55,22 +63,29 @@ export function authCmd(wrap) {
55
63
  Examples:
56
64
  jsn auth login https://dev12345.service-now.com
57
65
  jsn auth login dev373698
58
- jsn auth login https://acme.service-now.com
66
+ jsn auth login --password https://dev328604.service-now.com
59
67
 
60
68
  Find your instance URL in your browser's address bar when logged into ServiceNow.`);
61
69
  }
62
70
  }
63
71
  }
64
72
 
73
+ // --password: authenticate with basic auth from env vars
74
+ if (argv.password) {
75
+ await app.auth.loginWithPassword(instanceURL);
76
+ }
77
+ // --print-url with --wait-file: print URL and wait for code file
78
+ else if (argv.printUrl && argv.waitFile) {
79
+ await app.auth.buildAuthURL(instanceURL, argv.waitFile);
80
+ }
65
81
  // --print-url: just print the URL and exit
66
- if (argv.printUrl) {
82
+ else if (argv.printUrl) {
67
83
  const authURL = app.auth.buildAuthURL(instanceURL);
68
84
  console.log(authURL);
69
85
  return;
70
86
  }
71
-
72
87
  // --code: exchange the provided authorization code directly
73
- if (argv.code) {
88
+ else if (argv.code) {
74
89
  await app.auth.loginWithCode(instanceURL, argv.code);
75
90
  } else {
76
91
  // Interactive: full OAuth flow with browser + prompt
@@ -0,0 +1,157 @@
1
+ // Catalog item management commands
2
+ // NOTE: This is an AI-friendly high-level command that wraps sc_cat_item + item_option_new
3
+
4
+ import { resolveItemOptionType } from '../helpers.js';
5
+
6
+ export function catalogCmd(wrap) {
7
+ return {
8
+ command: 'catalog <subcommand>',
9
+ aliases: ['cat'],
10
+ describe: 'Manage Service Catalog items and variables',
11
+ builder: (yargs) => {
12
+ return yargs
13
+ .command({
14
+ command: 'create-item',
15
+ describe: 'Create a catalog item (sc_cat_item) with variables',
16
+ builder: (y) => y
17
+ .option('name', { alias: 'n', type: 'string', demandOption: true, describe: 'Catalog item name' })
18
+ .option('short-description', { alias: 'd', type: 'string', describe: 'Short description' })
19
+ .option('description', { type: 'string', describe: 'Full description' })
20
+ .option('category', { alias: 'c', type: 'string', describe: 'Category name (created if missing)' })
21
+ .option('variable', { alias: 'v', type: 'array', describe: 'Variable definition in format "name:type:label" or "name:type" (e.g. "reason:multilinetext:Reason" or "start_date:date")' })
22
+ .option('update-set', { type: 'string', describe: 'Update set sys_id or name to capture changes' }),
23
+ handler: wrap(async (argv, app) => {
24
+ const name = argv.name;
25
+ const shortDesc = argv['short-description'] || '';
26
+ const description = argv.description || shortDesc;
27
+ let categoryID = '';
28
+
29
+ // Resolve or create category
30
+ if (argv.category) {
31
+ const catParams = new URLSearchParams();
32
+ catParams.set('sysparm_query', `title=${argv.category}`);
33
+ catParams.set('sysparm_limit', '1');
34
+ catParams.set('sysparm_fields', 'sys_id,title');
35
+ const cats = await app.sdk.list('sc_category', catParams);
36
+ if (cats.length > 0) {
37
+ categoryID = cats[0].sys_id?.value || cats[0].sys_id;
38
+ } else {
39
+ const newCat = await app.sdk.create('sc_category', {
40
+ title: argv.category,
41
+ description: `Category for ${argv.category}`,
42
+ });
43
+ categoryID = newCat.sys_id?.value || newCat.sys_id;
44
+ }
45
+ }
46
+
47
+ // Create the catalog item
48
+ const item = await app.sdk.create('sc_cat_item', {
49
+ name,
50
+ short_description: shortDesc,
51
+ description,
52
+ category: categoryID || undefined,
53
+ active: true,
54
+ type: 'item',
55
+ stage: 'requested',
56
+ });
57
+ const itemID = item.sys_id?.value || item.sys_id;
58
+
59
+ // Parse and create variables
60
+ const varDefs = [];
61
+ if (argv.variable) {
62
+ for (const v of argv.variable) {
63
+ // Parse "label:type" or "name:type" or "name:type:label" etc.
64
+ const parts = String(v).split(':');
65
+ if (parts.length >= 2) {
66
+ const typeName = parts.length >= 2 ? parts[parts.length - 2] : '';
67
+ const label = parts.length >= 3 ? parts[parts.length - 1] : parts[0];
68
+ const typeID = resolveItemOptionType(typeName);
69
+ const sysName = parts[0].replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase();
70
+ varDefs.push({
71
+ name: sysName,
72
+ question_text: label,
73
+ type: typeID,
74
+ order: varDefs.length * 100 + 100,
75
+ cat_item: itemID,
76
+ mandatory: true,
77
+ active: true,
78
+ });
79
+ }
80
+ }
81
+
82
+ for (const vd of varDefs) {
83
+ try {
84
+ await app.sdk.create('item_option_new', vd);
85
+ } catch {
86
+ // variable creation failure is non-fatal
87
+ }
88
+ }
89
+ }
90
+
91
+ app.ok({
92
+ sys_id: itemID,
93
+ name,
94
+ category: argv.category || null,
95
+ variables_created: varDefs.length,
96
+ update_set: argv['update-set'] || null,
97
+ instance_url: app.getEffectiveInstance(),
98
+ }, {
99
+ summary: `Created catalog item: ${name} (${varDefs.length} variable(s))`,
100
+ breadcrumbs: [
101
+ { action: 'view', cmd: `jsn records get --table sc_cat_item --sys-id ${itemID}`, description: 'View the catalog item' },
102
+ { action: 'edit', cmd: `jsn catalog update-item ${itemID} --name "${name}"`, description: 'Edit the catalog item' },
103
+ ],
104
+ });
105
+ }),
106
+ })
107
+ .command({
108
+ command: 'list-items',
109
+ aliases: ['ls'],
110
+ describe: 'List catalog items',
111
+ builder: (y) => y
112
+ .option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' })
113
+ .option('category', { type: 'string', describe: 'Filter by category' }),
114
+ handler: wrap(async (argv, app) => {
115
+ let query = 'ORDERBYname';
116
+ if (argv.category) query = `category.titleLIKE${argv.category}^${query}`;
117
+ const params = new URLSearchParams();
118
+ params.set('sysparm_query', query);
119
+ params.set('sysparm_limit', String(argv.limit));
120
+ params.set('sysparm_display_value', 'all');
121
+ params.set('sysparm_fields', 'sys_id,name,short_description,category,active');
122
+ const items = await app.sdk.list('sc_cat_item', params);
123
+ app.ok({
124
+ table: 'sc_cat_item',
125
+ count: items.length,
126
+ columns: ['name', 'short_description', 'category', 'active'],
127
+ records: items.map(r => ({
128
+ sys_id: r.sys_id?.value || r.sys_id,
129
+ name: r.name?.display_value || r.name,
130
+ short_description: r.short_description?.display_value || r.short_description || '',
131
+ category: r.category?.display_value || '',
132
+ active: r.active?.display_value || r.active,
133
+ })),
134
+ context: { instance_url: app.getEffectiveInstance() },
135
+ }, { summary: `${items.length} catalog item(s)` });
136
+ }),
137
+ });
138
+ },
139
+ handler: (argv) => {
140
+ if (!argv._[1]) {
141
+ console.log('Manage Service Catalog items.\n');
142
+ console.log('Commands:');
143
+ console.log(' create-item Create a catalog item (fast scaffold)');
144
+ console.log(' list-items List catalog items');
145
+ console.log('\nExamples:');
146
+ console.log(' jsn catalog create-item "Request PTO" \\');
147
+ console.log(' --category "Time Off" \\');
148
+ console.log(' --short-description "Submit a PTO request" \\');
149
+ console.log(' --variable "start_date:date:Start Date" \\');
150
+ console.log(' --variable "type:select:Type" \\');
151
+ console.log(' --variable "reason:multilinetext:Reason/Notes"');
152
+ console.log('\nVariable types: string, multilinetext, select, date, datetime, reference, checkbox, email');
153
+ console.log('\nRun "jsn catalog <command> --help" for details.');
154
+ }
155
+ },
156
+ };
157
+ }
@@ -113,13 +113,52 @@ export function updateSetsCmd(wrap) {
113
113
  description: argv.description || argv.name,
114
114
  state: 'in progress',
115
115
  });
116
+
117
+ // Auto-set as current update set
118
+ const sysID = record?.sys_id?.value || record?.sys_id;
119
+ try {
120
+ const user = await app.sdk.list('sys_user', new URLSearchParams({
121
+ sysparm_query: 'user_name=javascript:gs.getUserName()',
122
+ sysparm_limit: '1',
123
+ sysparm_fields: 'sys_id',
124
+ }));
125
+ if (user.length > 0) {
126
+ const userSysID = user[0].sys_id?.value || user[0].sys_id;
127
+ // Check for existing preference
128
+ const prefParams = new URLSearchParams();
129
+ prefParams.set('sysparm_query', `user=${userSysID}^name=sys_update_set`);
130
+ prefParams.set('sysparm_limit', '1');
131
+ const prefs = await app.sdk.list('sys_user_preference', prefParams);
132
+ if (prefs.length > 0) {
133
+ const prefID = prefs[0].sys_id?.value || prefs[0].sys_id;
134
+ await app.sdk.update('sys_user_preference', prefID, { value: sysID });
135
+ } else {
136
+ await app.sdk.create('sys_user_preference', {
137
+ user: userSysID,
138
+ name: 'sys_update_set',
139
+ value: sysID,
140
+ type: 'string',
141
+ });
142
+ }
143
+ }
144
+ } catch {
145
+ // Non-fatal — auto-set is a convenience, not mandatory
146
+ }
147
+
116
148
  app.ok(record, {
117
149
  summary: `Created update set: ${argv.name}`,
118
- breadcrumbs: [{
119
- action: 'set',
120
- cmd: `jsn dev updatesets set "${argv.name}"`,
121
- description: 'Set as current update set',
122
- }],
150
+ breadcrumbs: [
151
+ {
152
+ action: 'set',
153
+ cmd: `jsn dev updatesets set "${argv.name}"`,
154
+ description: 'Switch to this update set',
155
+ },
156
+ {
157
+ action: 'complete',
158
+ cmd: `jsn dev updatesets complete "${argv.name}"`,
159
+ description: 'Mark as complete when done',
160
+ },
161
+ ],
123
162
  });
124
163
  }),
125
164
  })
@@ -132,8 +171,13 @@ export function updateSetsCmd(wrap) {
132
171
  console.log(' list List update sets');
133
172
  console.log(' show <name> Show an update set');
134
173
  console.log(' set <name> Set the current update set');
135
- console.log(' create Create a new update set');
174
+ console.log(' create Create a new update set (auto-sets as current)');
175
+ console.log(' complete <name> Mark an update set as complete (coming soon)');
176
+ console.log(' yolo Silence the "Default update set" warning');
136
177
  console.log('\nRun "jsn dev updatesets <command> --help" for details.');
178
+ console.log('\nTip: Create an update set first:');
179
+ console.log(' jsn dev updatesets create --name "My Feature"');
180
+ console.log(' # → Auto-set as current, then record changes are captured.');
137
181
  }
138
182
  },
139
183
  };
package/src/helpers.js CHANGED
@@ -142,3 +142,41 @@ export function parseDataArg(argv) {
142
142
  throw new Error(`Invalid JSON: ${e.message}\n\nHint: On Windows PowerShell, use --data-file instead of --data to avoid quote mangling.\nRaw value: ${raw.substring(0, 200)}`, { cause: e });
143
143
  }
144
144
  }
145
+
146
+ /**
147
+ * Translate human-readable type names to ServiceNow item_option_new type IDs.
148
+ * Maps common names like "date", "select", "multilinetext" to their integer IDs.
149
+ * Passes through numeric values unchanged.
150
+ */
151
+ const ITEM_OPTION_TYPE_NAMES = {
152
+ '1': 1, 'yesno': 1, 'yes/no': 1, 'boolean': 1,
153
+ '2': 2, 'multilinetext': 2, 'textarea': 2, 'multiline': 2,
154
+ '3': 3, 'multiplechoice': 3,
155
+ '4': 4, 'numericscale': 4, 'rating': 4,
156
+ '5': 5, 'select': 5, 'dropdown': 5, 'choice': 5, 'selectbox': 5,
157
+ '6': 6, 'string': 6, 'text': 6, 'singlelinetext': 6,
158
+ '7': 7, 'checkbox': 7, 'check': 7,
159
+ '8': 8, 'reference': 8, 'lookup': 8,
160
+ '9': 9, 'date': 9,
161
+ '10': 10, 'datetime': 10, 'date/time': 10,
162
+ '11': 11, 'label': 11,
163
+ '14': 14, 'custom': 14,
164
+ '18': 18, 'lookupselect': 18, 'lookupselectbox': 18,
165
+ '20': 20, 'containerstart': 20,
166
+ '21': 21, 'listcollector': 21,
167
+ '23': 23, 'html': 23,
168
+ '26': 26, 'email': 26,
169
+ '29': 29, 'duration': 29,
170
+ '31': 31, 'requestedfor': 31,
171
+ '32': 32, 'richtextlabel': 32, 'richtext': 32,
172
+ };
173
+
174
+ export function resolveItemOptionType(type) {
175
+ if (type == null) return 6; // default: Single Line Text
176
+ if (typeof type === 'number') return type;
177
+ const lower = String(type).toLowerCase().replace(/[\s_-]/g, '');
178
+ if (ITEM_OPTION_TYPE_NAMES[lower] != null) return ITEM_OPTION_TYPE_NAMES[lower];
179
+ const asNum = parseInt(String(type), 10);
180
+ if (!isNaN(asNum) && asNum > 0 && asNum < 100) return asNum;
181
+ return 6; // fallback to Single Line Text
182
+ }