@jacebenson/jsn 1.0.7 → 2.0.1

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 (45) hide show
  1. package/README.md +196 -243
  2. package/bin/jsn.js +2 -57
  3. package/package.json +35 -32
  4. package/skills/servicenow/SKILL.md +137 -0
  5. package/src/app.js +172 -0
  6. package/src/auth.js +517 -0
  7. package/src/cli.js +174 -0
  8. package/src/commands/_ticket.js +260 -0
  9. package/src/commands/auth.js +227 -0
  10. package/src/commands/catalog.js +325 -0
  11. package/src/commands/changes.js +7 -0
  12. package/src/commands/dev/_generic.js +342 -0
  13. package/src/commands/dev/_simple.js +89 -0
  14. package/src/commands/dev/eval.js +38 -0
  15. package/src/commands/dev/flows.js +573 -0
  16. package/src/commands/dev/forms.js +313 -0
  17. package/src/commands/dev/lists.js +233 -0
  18. package/src/commands/dev/logs.js +64 -0
  19. package/src/commands/dev/rest.js +64 -0
  20. package/src/commands/dev/scopes.js +156 -0
  21. package/src/commands/dev/updatesets.js +184 -0
  22. package/src/commands/dev.js +53 -0
  23. package/src/commands/groupmembers.js +152 -0
  24. package/src/commands/grouproles.js +152 -0
  25. package/src/commands/groups.js +141 -0
  26. package/src/commands/incidents.js +7 -0
  27. package/src/commands/profiles.js +91 -0
  28. package/src/commands/records.js +171 -0
  29. package/src/commands/requests.js +59 -0
  30. package/src/commands/setup.js +39 -0
  31. package/src/commands/skill.js +144 -0
  32. package/src/commands/tasks.js +7 -0
  33. package/src/commands/tickets.js +121 -0
  34. package/src/commands/users.js +144 -0
  35. package/src/commands/version.js +81 -0
  36. package/src/config.js +154 -0
  37. package/src/context.js +62 -0
  38. package/src/errors.js +101 -0
  39. package/src/help.js +85 -0
  40. package/src/helpers.js +182 -0
  41. package/src/output.js +440 -0
  42. package/src/sdk.js +615 -0
  43. package/scripts/install.sh +0 -227
  44. package/scripts/npm-install.js +0 -235
  45. package/scripts/pre-commit-check.sh +0 -61
@@ -0,0 +1,144 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const SKILL_REPO_PATH = path.join(
7
+ path.dirname(fileURLToPath(import.meta.url)),
8
+ '..', '..', 'skills', 'servicenow', 'SKILL.md'
9
+ );
10
+
11
+ const SKILL_RAW_URL = 'https://raw.githubusercontent.com/jacebenson/jsn/nodejs/skills/servicenow/SKILL.md';
12
+
13
+ function readBundledSkill() {
14
+ try {
15
+ return fs.readFileSync(SKILL_REPO_PATH, 'utf-8');
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ export async function checkSkill() {
22
+ const bundled = readBundledSkill();
23
+ if (!bundled) return { current: false, error: 'Skill file not found in package' };
24
+ try {
25
+ const controller = new AbortController();
26
+ const timer = setTimeout(() => controller.abort(), 10000);
27
+ const res = await fetch(SKILL_RAW_URL, { signal: controller.signal });
28
+ clearTimeout(timer);
29
+ if (!res.ok) return { current: false, error: `GitHub returned ${res.status}` };
30
+ const upstream = await res.text();
31
+ const current = bundled === upstream;
32
+ return {
33
+ current,
34
+ bundled_lines: bundled.split('\n').length,
35
+ upstream_lines: upstream.split('\n').length,
36
+ error: current ? null : 'Bundled skill is outdated — run "jsn skill install" to update',
37
+ };
38
+ } catch {
39
+ return { current: false, error: 'Could not check — GitHub unreachable' };
40
+ }
41
+ }
42
+
43
+ export function skillCmd(wrap) {
44
+ return {
45
+ command: 'skill',
46
+ describe: 'Manage the jsn AI agent skill file (for Hermes, Claude Code, Cursor, etc.)',
47
+ builder: (y) => {
48
+ return y
49
+ .command({
50
+ command: 'show',
51
+ describe: 'Show the bundled skill file content',
52
+ handler: wrap(async (_argv, app) => {
53
+ const content = readBundledSkill();
54
+ if (!content) {
55
+ throw new Error('Skill file not found in package (expected at skills/servicenow/SKILL.md)');
56
+ }
57
+ app.ok({
58
+ content,
59
+ bundled: SKILL_REPO_PATH,
60
+ }, {
61
+ summary: 'jsn AI agent skill file (bundled)',
62
+ });
63
+ }),
64
+ })
65
+ .command({
66
+ command: 'check',
67
+ describe: 'Check if the bundled skill file is up to date with GitHub',
68
+ handler: wrap(async (_argv, app) => {
69
+ const result = await checkSkill();
70
+ if (result.error && !result.current) {
71
+ app.ok(result, { summary: result.error });
72
+ } else if (result.current) {
73
+ app.ok(result, { summary: `✓ Skill is current (${result.bundled_lines} lines)` });
74
+ } else {
75
+ app.ok(result, { summary: `⚠ Skill is outdated (bundled ${result.bundled_lines} lines vs upstream ${result.upstream_lines} lines) — run "jsn skill install" to update` });
76
+ }
77
+ }),
78
+ })
79
+ .command({
80
+ command: 'fetch',
81
+ describe: 'Download the latest skill file from GitHub to stdout',
82
+ handler: wrap(async (_argv, _app) => {
83
+ const res = await fetch(SKILL_RAW_URL);
84
+ if (!res.ok) throw new Error(`Failed to fetch skill: ${res.status} ${res.statusText}`);
85
+ const content = await res.text();
86
+ process.stdout.write(content);
87
+ }),
88
+ })
89
+ .command({
90
+ command: 'path',
91
+ describe: 'Show skill file locations and install targets',
92
+ handler: wrap(async (_argv, app) => {
93
+ const hermesDir = path.join(os.homedir(), '.hermes', 'skills', 'servicenow');
94
+ const hermesPath = path.join(hermesDir, 'SKILL.md');
95
+ app.ok({
96
+ bundled: SKILL_REPO_PATH,
97
+ hermes: hermesPath,
98
+ raw_url: SKILL_RAW_URL,
99
+ }, {
100
+ summary: 'Skill file locations',
101
+ breadcrumbs: [
102
+ { action: 'view', cmd: 'jsn skill show', description: 'Show bundled skill content' },
103
+ { action: 'fetch', cmd: 'jsn skill fetch', description: 'Download latest from GitHub' },
104
+ { action: 'install', cmd: 'jsn skill install', description: 'Download + save to Hermes' },
105
+ ],
106
+ });
107
+ }),
108
+ })
109
+ .command({
110
+ command: 'install [dir]',
111
+ describe: 'Download and save the latest skill file',
112
+ builder: (y) => y
113
+ .positional('dir', {
114
+ type: 'string',
115
+ describe: 'Target directory (default: ~/.hermes/skills/servicenow/)',
116
+ }),
117
+ handler: wrap(async (argv, app) => {
118
+ const targetDir = argv.dir || path.join(os.homedir(), '.hermes', 'skills', 'servicenow');
119
+ const targetPath = path.join(targetDir, 'SKILL.md');
120
+
121
+ fs.mkdirSync(targetDir, { recursive: true });
122
+
123
+ const res = await fetch(SKILL_RAW_URL);
124
+ if (!res.ok) throw new Error(`Failed to fetch skill: ${res.status} ${res.statusText}`);
125
+ const content = await res.text();
126
+
127
+ fs.writeFileSync(targetPath, content, 'utf-8');
128
+
129
+ app.ok({
130
+ installed: targetPath,
131
+ from: SKILL_RAW_URL,
132
+ }, {
133
+ summary: `Skill installed to ${targetPath}`,
134
+ breadcrumbs: [
135
+ { action: 'verify', cmd: `head -30 ${targetPath}`, description: 'Verify the installed skill' },
136
+ { action: 'reinstall', cmd: 'jsn skill install', description: 'Re-download and reinstall' },
137
+ ],
138
+ });
139
+ }),
140
+ });
141
+ },
142
+ handler: () => {},
143
+ };
144
+ }
@@ -0,0 +1,7 @@
1
+ import { buildTicketCommands } from './_ticket.js';
2
+
3
+ const taskDefaultColumns = ['number', 'short_description', 'state', 'assigned_to'];
4
+
5
+ export function tasksCmd(wrap) {
6
+ return buildTicketCommands('sc_task', 'tasks', 'task', taskDefaultColumns, {}, null, wrap);
7
+ }
@@ -0,0 +1,121 @@
1
+ import { formatRecordForDisplay, getStringField, buildQuerySuffix } from '../helpers.js';
2
+
3
+ export function ticketsCmd(wrap) {
4
+ return {
5
+ command: 'tickets [subcommand]',
6
+ aliases: ['ticket'],
7
+ describe: 'Query generic tickets (e.g. "tickets list --query active=true")',
8
+ builder: (yargs) => {
9
+ return yargs
10
+ .command({
11
+ command: 'list',
12
+ aliases: ['ls'],
13
+ describe: 'List tickets',
14
+ builder: (y) => y
15
+ .option('query', { type: 'string', describe: 'Encoded query (e.g. "active=true^priority=1")' })
16
+ .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description,priority")' })
17
+ .option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' })
18
+ .option('offset', { alias: 'o', type: 'number', default: 0, describe: 'Offset' }),
19
+ handler: wrap(async (argv, app) => {
20
+ const columns = argv.columns ? argv.columns.split(',') : ['number', 'short_description', 'state', 'assigned_to'];
21
+ const params = new URLSearchParams();
22
+ params.set('sysparm_limit', String(argv.limit));
23
+ params.set('sysparm_offset', String(argv.offset));
24
+ params.set('sysparm_display_value', 'all');
25
+ params.set('sysparm_fields', ['sys_id', ...columns].join(','));
26
+ const q = argv.query ? argv.query + '^ORDERBYDESCsys_updated_on' : 'ORDERBYDESCsys_updated_on';
27
+ params.set('sysparm_query', q);
28
+ const records = await app.sdk.list('ticket', params);
29
+ const displayRecords = records.map(r => formatRecordForDisplay(r, columns));
30
+ const breadcrumbs = [
31
+ { action: 'create', cmd: 'jsn tickets create --data \'{...}\'', description: 'Create a new ticket' },
32
+ ];
33
+ if (records.length === argv.limit) {
34
+ breadcrumbs.push({
35
+ action: 'next',
36
+ cmd: `jsn tickets list --limit ${argv.limit} --offset ${argv.offset + argv.limit}${buildQuerySuffix(argv.query)}`,
37
+ description: `Next page`,
38
+ });
39
+ }
40
+ if (argv.offset > 0) {
41
+ breadcrumbs.push({
42
+ action: 'prev',
43
+ cmd: `jsn tickets list --limit ${argv.limit} --offset ${Math.max(0, argv.offset - argv.limit)}${buildQuerySuffix(argv.query)}`,
44
+ description: 'Previous page',
45
+ });
46
+ }
47
+ app.ok({
48
+ table: 'ticket',
49
+ count: records.length,
50
+ columns,
51
+ records: displayRecords,
52
+ pagination: { limit: argv.limit, offset: argv.offset },
53
+ context: { instance_url: app.getEffectiveInstance() },
54
+ }, { summary: `${records.length} ticket(s)`, breadcrumbs });
55
+ }),
56
+ })
57
+ .command({
58
+ command: 'show <number>',
59
+ aliases: ['get'],
60
+ describe: 'Show a ticket',
61
+ handler: wrap(async (argv, app) => {
62
+ const params = new URLSearchParams();
63
+ params.set('sysparm_query', `number=${argv.number}`);
64
+ params.set('sysparm_limit', '1');
65
+ params.set('sysparm_display_value', 'all');
66
+ const records = await app.sdk.list('ticket', params);
67
+ if (records.length === 0) {
68
+ throw new Error(`Ticket not found: ${argv.number}`);
69
+ }
70
+ app.ok(records[0], { summary: `Ticket ${argv.number}` });
71
+ }),
72
+ })
73
+ .command({
74
+ command: 'create',
75
+ describe: 'Create a new ticket',
76
+ builder: (y) => y.option('data', { type: 'string', demandOption: true, describe: 'JSON fields (e.g. \'{"state":"2","priority":"1"}\')' }),
77
+ handler: wrap(async (argv, app) => {
78
+ const recordData = JSON.parse(argv.data);
79
+ const record = await app.sdk.create('ticket', recordData);
80
+ app.ok(record, { summary: `Created ticket ${getStringField(record, 'number')}` });
81
+ }),
82
+ })
83
+ .command({
84
+ command: 'update <number>',
85
+ describe: 'Update a ticket',
86
+ builder: (y) => y.option('data', { type: 'string', demandOption: true, describe: 'JSON fields (e.g. \'{"state":"2","priority":"1"}\')' }),
87
+ handler: wrap(async (argv, app) => {
88
+ const recordData = JSON.parse(argv.data);
89
+ const findParams = new URLSearchParams();
90
+ findParams.set('sysparm_query', `number=${argv.number}`);
91
+ findParams.set('sysparm_limit', '1');
92
+ const records = await app.sdk.list('ticket', findParams);
93
+ if (records.length === 0) {
94
+ throw new Error(`Ticket not found: ${argv.number}`);
95
+ }
96
+ const sysID = getStringField(records[0], 'sys_id');
97
+ const updated = await app.sdk.update('ticket', sysID, recordData);
98
+ app.ok(updated, { summary: `Updated ticket ${argv.number}` });
99
+ }),
100
+ })
101
+ .command({
102
+ command: 'delete <number>',
103
+ describe: 'Delete a ticket',
104
+ handler: wrap(async (argv, app) => {
105
+ const findParams = new URLSearchParams();
106
+ findParams.set('sysparm_query', `number=${argv.number}`);
107
+ findParams.set('sysparm_limit', '1');
108
+ const records = await app.sdk.list('ticket', findParams);
109
+ if (records.length === 0) {
110
+ throw new Error(`Ticket not found: ${argv.number}`);
111
+ }
112
+ const sysID = getStringField(records[0], 'sys_id');
113
+ await app.sdk.delete('ticket', sysID);
114
+ app.ok({ number: argv.number, message: 'Ticket deleted' }, { summary: `Deleted ticket ${argv.number}` });
115
+ }),
116
+ })
117
+
118
+ },
119
+ handler: () => {},
120
+ };
121
+ }
@@ -0,0 +1,144 @@
1
+ import { formatRecordForDisplay, getStringField, interactiveList } from '../helpers.js';
2
+
3
+ export function usersCmd(wrap) {
4
+ return {
5
+ command: 'users [subcommand]',
6
+ aliases: ['user'],
7
+ describe: 'Manage ServiceNow users',
8
+ builder: (yargs) => {
9
+ return yargs
10
+ .command({
11
+ command: 'list',
12
+ aliases: ['ls'],
13
+ describe: 'List users',
14
+ builder: (y) => y
15
+ .option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident" or "active=true")' })
16
+ .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
17
+ .option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Max records' }),
18
+ handler: wrap(async (argv, app) => {
19
+ const columns = argv.columns ? argv.columns.split(',') : ['user_name', 'name', 'email', 'active'];
20
+ const query = argv.query || '';
21
+
22
+ const picked = await interactiveList({
23
+ app, table: 'sys_user', singular: 'user', columns, limit: argv.limit, query, labelField: 'user_name',
24
+ formatLabel: r => `${getStringField(r, 'user_name')} (${getStringField(r, 'name') || '-'})`,
25
+ });
26
+ if (picked) {
27
+ picked._context = { instance_url: app.getEffectiveInstance(), table: 'sys_user' };
28
+ return app.ok(picked, { summary: `User: ${getStringField(picked, 'user_name')}` });
29
+ }
30
+
31
+ const params = new URLSearchParams();
32
+ params.set('sysparm_limit', String(argv.limit));
33
+ params.set('sysparm_display_value', 'all');
34
+ params.set('sysparm_fields', ['sys_id', ...columns].join(','));
35
+ if (argv.query) params.set('sysparm_query', argv.query);
36
+ const records = await app.sdk.list('sys_user', params);
37
+ app.ok({
38
+ table: 'sys_user',
39
+ count: records.length,
40
+ columns,
41
+ records: records.map(r => formatRecordForDisplay(r, columns)),
42
+ context: { instance_url: app.getEffectiveInstance() },
43
+ }, { summary: `${records.length} user(s)` });
44
+ }),
45
+ })
46
+ .command({
47
+ command: 'show <identifier>',
48
+ aliases: ['get'],
49
+ describe: 'Show a user by user_name or sys_id',
50
+ handler: wrap(async (argv, app) => {
51
+ const id = argv.identifier;
52
+ const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
53
+ const params = new URLSearchParams();
54
+ params.set('sysparm_query', isSysID ? `sys_id=${id}` : `user_name=${id}`);
55
+ params.set('sysparm_limit', '1');
56
+ params.set('sysparm_display_value', 'all');
57
+ const records = await app.sdk.list('sys_user', params);
58
+ if (records.length === 0) {
59
+ throw new Error(`User not found: ${id}`);
60
+ }
61
+ app.ok(records[0], { summary: `User ${id}` });
62
+ }),
63
+ })
64
+ .command({
65
+ command: 'create',
66
+ describe: 'Create a new user',
67
+ builder: (y) => y
68
+ .option('username', { alias: 'u', type: 'string', describe: 'Username (e.g. john.doe)' })
69
+ .option('name', { alias: 'n', type: 'string', describe: 'Full name (e.g. John Doe)' })
70
+ .option('email', { alias: 'e', type: 'string', describe: 'Email address' })
71
+ .option('data', { type: 'string', describe: 'JSON data for additional fields' }),
72
+ handler: wrap(async (argv, app) => {
73
+ const recordData = {};
74
+ if (argv.data) {
75
+ Object.assign(recordData, JSON.parse(argv.data));
76
+ }
77
+ if (argv.username) recordData.user_name = argv.username;
78
+ if (argv.name) recordData.name = argv.name;
79
+ if (argv.email) recordData.email = argv.email;
80
+ if (!recordData.user_name) {
81
+ throw new Error('user_name is required (use --username or --data)');
82
+ }
83
+ const record = await app.sdk.create('sys_user', recordData);
84
+ app.ok(record, {
85
+ summary: `Created user ${getStringField(record, 'user_name')}`,
86
+ breadcrumbs: [
87
+ { action: 'view', cmd: `jsn users show ${getStringField(record, 'user_name')}`, description: `View the new user` },
88
+ ],
89
+ });
90
+ }),
91
+ })
92
+ .command({
93
+ command: 'update <identifier>',
94
+ describe: 'Update a user by user_name or sys_id',
95
+ builder: (y) => y
96
+ .option('data', { type: 'string', demandOption: true, describe: 'JSON data to update' }),
97
+ handler: wrap(async (argv, app) => {
98
+ const id = argv.identifier;
99
+ const recordData = JSON.parse(argv.data);
100
+ const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
101
+ const params = new URLSearchParams();
102
+ params.set('sysparm_query', isSysID ? `sys_id=${id}` : `user_name=${id}`);
103
+ params.set('sysparm_limit', '1');
104
+ params.set('sysparm_display_value', 'all');
105
+ params.set('sysparm_fields', 'sys_id');
106
+ const records = await app.sdk.list('sys_user', params);
107
+ if (records.length === 0) {
108
+ throw new Error(`User not found: ${id}`);
109
+ }
110
+ const sysID = getStringField(records[0], 'sys_id');
111
+ const updated = await app.sdk.update('sys_user', sysID, recordData);
112
+ app.ok(updated, {
113
+ summary: `Updated user ${id}`,
114
+ breadcrumbs: [
115
+ { action: 'view', cmd: `jsn users show ${id}`, description: `View the updated user` },
116
+ ],
117
+ });
118
+ }),
119
+ })
120
+ .command({
121
+ command: 'delete <identifier>',
122
+ describe: 'Delete a user by user_name or sys_id',
123
+ handler: wrap(async (argv, app) => {
124
+ const id = argv.identifier;
125
+ const isSysID = id.length === 32 && /^[0-9a-fA-F]+$/.test(id);
126
+ const params = new URLSearchParams();
127
+ params.set('sysparm_query', isSysID ? `sys_id=${id}` : `user_name=${id}`);
128
+ params.set('sysparm_limit', '1');
129
+ params.set('sysparm_fields', 'sys_id');
130
+ const records = await app.sdk.list('sys_user', params);
131
+ if (records.length === 0) {
132
+ throw new Error(`User not found: ${id}`);
133
+ }
134
+ const sysID = getStringField(records[0], 'sys_id');
135
+ await app.sdk.delete('sys_user', sysID);
136
+ app.ok({ identifier: id, message: 'User deleted' }, {
137
+ summary: `Deleted user ${id}`,
138
+ });
139
+ }),
140
+ });
141
+ },
142
+ handler: () => {},
143
+ };
144
+ }
@@ -0,0 +1,81 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const NPM_PKG = '@jacebenson/jsn';
6
+ const NPM_REGISTRY = `https://registry.npmjs.org/${NPM_PKG}/latest`;
7
+
8
+ function getVersion() {
9
+ try {
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ const pkgPath = path.join(__dirname, '..', '..', 'package.json');
12
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
13
+ return pkg.version;
14
+ } catch {
15
+ return '0.0.0';
16
+ }
17
+ }
18
+
19
+ async function checkLatest() {
20
+ try {
21
+ const res = await fetch(NPM_REGISTRY);
22
+ if (!res.ok) return null;
23
+ const data = await res.json();
24
+ return data.version;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ export function versionCmd(wrap) {
31
+ return {
32
+ command: 'version',
33
+ describe: 'Show version information',
34
+ builder: (y) => y
35
+ .option('check', {
36
+ alias: 'c',
37
+ type: 'boolean',
38
+ describe: 'Check npm registry for newer version',
39
+ }),
40
+ handler: wrap(async (argv, app) => {
41
+ const version = getVersion();
42
+
43
+ if (argv.check) {
44
+ const latest = await checkLatest();
45
+ if (!latest) {
46
+ app.ok({ version }, {
47
+ summary: `jsn ${version} (could not reach npm registry to check for updates)`,
48
+ notice: `Run \`npm view ${NPM_PKG}\` directly to check manually`,
49
+ });
50
+ return;
51
+ }
52
+
53
+ const isUpToDate = version === latest;
54
+
55
+ if (isUpToDate) {
56
+ app.ok({
57
+ version,
58
+ latest,
59
+ upToDate: true,
60
+ }, {
61
+ summary: `jsn ${version} — up to date`,
62
+ });
63
+ } else {
64
+ app.ok({
65
+ version,
66
+ latest,
67
+ upToDate: false,
68
+ installCmd: `npm install -g ${NPM_PKG}`,
69
+ }, {
70
+ summary: `jsn ${version} — newer version ${latest} available`,
71
+ breadcrumbs: [
72
+ { action: 'update', cmd: `npm install -g ${NPM_PKG}`, description: `Update jsn from ${version} to ${latest}` },
73
+ ],
74
+ });
75
+ }
76
+ } else {
77
+ app.ok({ version }, { summary: `jsn ${version}` });
78
+ }
79
+ }),
80
+ };
81
+ }
package/src/config.js ADDED
@@ -0,0 +1,154 @@
1
+ // Layered configuration: flags > env > local > global > defaults
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+
7
+ const APP_NAME = 'servicenow';
8
+
9
+ export function globalConfigDir() {
10
+ const xdg = process.env.XDG_CONFIG_HOME;
11
+ if (xdg) return path.join(xdg, APP_NAME);
12
+ return path.join(os.homedir(), '.config', APP_NAME);
13
+ }
14
+
15
+ export function globalConfigPath() {
16
+ return path.join(globalConfigDir(), 'config.json');
17
+ }
18
+
19
+ export function localConfigPath() {
20
+ return path.join(process.cwd(), `.${APP_NAME}`, 'config.json');
21
+ }
22
+
23
+ export function cacheDir() {
24
+ const xdg = process.env.XDG_CACHE_HOME;
25
+ if (xdg) return path.join(xdg, APP_NAME);
26
+ return path.join(os.homedir(), '.cache', APP_NAME);
27
+ }
28
+
29
+ export function normalizeInstanceURL(url) {
30
+ if (!url) return '';
31
+ url = url.trim();
32
+ url = url.replace(/\/$/, '');
33
+ if (!/^https?:\/\//i.test(url)) {
34
+ url = 'https://' + url;
35
+ }
36
+ return url;
37
+ }
38
+
39
+ function loadFromFile(cfg, filePath, source) {
40
+ try {
41
+ const data = fs.readFileSync(filePath, 'utf-8');
42
+ const fileCfg = JSON.parse(data);
43
+ if (fileCfg.instance_url) {
44
+ cfg.instanceURL = fileCfg.instance_url;
45
+ cfg.sources.instance_url = source;
46
+ }
47
+ if (fileCfg.format) {
48
+ cfg.format = fileCfg.format;
49
+ cfg.sources.format = source;
50
+ }
51
+ if (fileCfg.default_profile) {
52
+ cfg.defaultProfile = fileCfg.default_profile;
53
+ cfg.activeProfile = fileCfg.default_profile;
54
+ cfg.sources.default_profile = source;
55
+ }
56
+ if (fileCfg.profiles && typeof fileCfg.profiles === 'object') {
57
+ cfg.profiles = { ...cfg.profiles, ...fileCfg.profiles };
58
+ cfg.sources.profiles = source;
59
+ }
60
+ } catch {
61
+ // File doesn't exist or is malformed — skip
62
+ }
63
+ }
64
+
65
+ function loadFromEnv(cfg) {
66
+ if (process.env.SERVICENOW_INSTANCE_URL) {
67
+ cfg.instanceURL = process.env.SERVICENOW_INSTANCE_URL;
68
+ cfg.sources.instance_url = 'env';
69
+ }
70
+ if (process.env.SERVICENOW_FORMAT) {
71
+ cfg.format = process.env.SERVICENOW_FORMAT;
72
+ cfg.sources.format = 'env';
73
+ }
74
+ }
75
+
76
+ export function createConfig() {
77
+ return {
78
+ instanceURL: '',
79
+ profiles: {},
80
+ defaultProfile: '',
81
+ activeProfile: '',
82
+ format: 'auto',
83
+ sources: {},
84
+ };
85
+ }
86
+
87
+ export function loadConfig(overrides = {}) {
88
+ const cfg = createConfig();
89
+
90
+ loadFromFile(cfg, globalConfigPath(), 'global');
91
+ loadFromFile(cfg, localConfigPath(), 'local');
92
+ loadFromEnv(cfg);
93
+
94
+ if (overrides.instance) {
95
+ cfg.instanceURL = overrides.instance;
96
+ cfg.sources.instance_url = 'flag';
97
+ }
98
+ if (overrides.format) {
99
+ cfg.format = overrides.format;
100
+ cfg.sources.format = 'flag';
101
+ }
102
+ if (overrides.profile) {
103
+ cfg.activeProfile = overrides.profile;
104
+ if (cfg.profiles[overrides.profile] && cfg.profiles[overrides.profile].instance_url) {
105
+ cfg.instanceURL = cfg.profiles[overrides.profile].instance_url;
106
+ cfg.sources.instance_url = 'profile';
107
+ }
108
+ }
109
+
110
+ return cfg;
111
+ }
112
+
113
+ export function saveConfig(cfg) {
114
+ const dir = globalConfigDir();
115
+ fs.mkdirSync(dir, { recursive: true, mode: 0o750 });
116
+ const payload = {
117
+ instance_url: cfg.instanceURL || undefined,
118
+ profiles: Object.keys(cfg.profiles).length > 0 ? cfg.profiles : undefined,
119
+ default_profile: cfg.defaultProfile || undefined,
120
+ format: cfg.format || undefined,
121
+ };
122
+ fs.writeFileSync(globalConfigPath(), JSON.stringify(payload, null, 2), { mode: 0o600 });
123
+ }
124
+
125
+ export function saveLocalConfig(cfg) {
126
+ const dir = path.dirname(localConfigPath());
127
+ fs.mkdirSync(dir, { recursive: true, mode: 0o750 });
128
+ const payload = {
129
+ instance_url: cfg.instanceURL || undefined,
130
+ profiles: Object.keys(cfg.profiles).length > 0 ? cfg.profiles : undefined,
131
+ default_profile: cfg.defaultProfile || undefined,
132
+ format: cfg.format || undefined,
133
+ };
134
+ fs.writeFileSync(localConfigPath(), JSON.stringify(payload, null, 2), { mode: 0o600 });
135
+ }
136
+
137
+ export function getEffectiveInstance(cfg) {
138
+ const name = cfg.activeProfile || cfg.defaultProfile;
139
+ if (name && cfg.profiles[name] && cfg.profiles[name].instance_url) {
140
+ return cfg.profiles[name].instance_url;
141
+ }
142
+ return cfg.instanceURL || '';
143
+ }
144
+
145
+ export function getActiveProfile(cfg) {
146
+ const name = cfg.activeProfile || cfg.defaultProfile;
147
+ if (!name) return null;
148
+ return cfg.profiles[name] || null;
149
+ }
150
+
151
+ export function setProfile(cfg, name, profile) {
152
+ cfg.profiles[name] = profile;
153
+ return saveConfig(cfg);
154
+ }