@jacebenson/jsn 4.3.6 → 4.3.7-dev.13
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/LICENSE +21 -0
- package/README.md +7 -2
- package/package.json +2 -5
- package/skills/servicenow/SKILL.md +9 -11
- package/src/cli.js +2 -0
- package/src/commands/catalog/enrich.js +1 -3
- package/src/commands/diff.js +205 -0
- package/src/commands/perf.js +6 -0
- package/src/commands/transactions.js +78 -0
- package/scripts/pre-commit-check.sh +0 -61
- package/scripts/release.js +0 -62
- package/scripts/tag-reminder.js +0 -30
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jace Benson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -72,6 +72,11 @@ jsn records bulk --table incident --query "priority=1" --set '{"state":"3"}' #
|
|
|
72
72
|
jsn records get --table incident --sys-id 8a1234abcd5678 --attachments # record + its files
|
|
73
73
|
jsn records list --table incident --limit 10 --csv # output CSV
|
|
74
74
|
|
|
75
|
+
# Read-only cross-profile record diff
|
|
76
|
+
jsn diff record --table incident --sys-id <sys_id> --profile-a dev --profile-b prod
|
|
77
|
+
jsn diff record --table incident --sys-id <sys_id> --profile-a dev --profile-b prod --ignore state
|
|
78
|
+
jsn diff record --table incident --sys-id <sys_id> --profile-a dev --profile-b prod --no-ignore
|
|
79
|
+
|
|
75
80
|
# Script execution
|
|
76
81
|
jsn eval "gs.info('Hello World')"
|
|
77
82
|
cat script.js | jsn eval --stdin # pipe a script in (no shell escaping)
|
|
@@ -110,7 +115,7 @@ Run `jsn` for the full grouped list. Commands are organized by ServiceNow domain
|
|
|
110
115
|
|
|
111
116
|
**User Experience** — `forms`, `lists`, `clientscripts`, `uipolicies`, `uiactions`
|
|
112
117
|
|
|
113
|
-
**Data** — `records` (list/get/create/update/delete/count/bulk/attachments), `tables`, `columns`, `includes`, `import`, `logs` (list/show/follow), `snippets` (save/run), `users`, `groups`
|
|
118
|
+
**Data** — `records` (list/get/create/update/delete/count/bulk/attachments), `diff record` (cross-profile read-only comparison), `tables`, `columns`, `includes`, `import`, `logs` (list/show/follow), `snippets` (save/run), `users`, `groups`
|
|
114
119
|
|
|
115
120
|
Every command supports `--json`, `--query`, and `--help`.
|
|
116
121
|
|
|
@@ -193,7 +198,7 @@ npm install
|
|
|
193
198
|
npm test
|
|
194
199
|
```
|
|
195
200
|
|
|
196
|
-
Releases
|
|
201
|
+
Releases run from the GitHub Actions workflow. Dispatch `Release production` with `patch`, `minor`, or `major`. The workflow tests and lints the repo, bumps the version, pushes the commit and tag, dispatches the npm publish workflow, and creates the GitHub release.
|
|
197
202
|
|
|
198
203
|
## License
|
|
199
204
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jacebenson/jsn",
|
|
3
|
-
"version": "4.3.
|
|
3
|
+
"version": "4.3.7-dev.13",
|
|
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": {
|
|
@@ -12,9 +12,7 @@
|
|
|
12
12
|
"lint": "npx eslint src/ bin/ test/",
|
|
13
13
|
"start": "node bin/jsn.js",
|
|
14
14
|
"docs": "npm --prefix docs run build",
|
|
15
|
-
"docs:serve": "npm --prefix docs run serve"
|
|
16
|
-
"tag-reminder": "node scripts/tag-reminder.js",
|
|
17
|
-
"release": "node scripts/release.js"
|
|
15
|
+
"docs:serve": "npm --prefix docs run serve"
|
|
18
16
|
},
|
|
19
17
|
"keywords": [
|
|
20
18
|
"servicenow",
|
|
@@ -43,7 +41,6 @@
|
|
|
43
41
|
"bin/jsn.js",
|
|
44
42
|
"src/",
|
|
45
43
|
"skills/",
|
|
46
|
-
"scripts/",
|
|
47
44
|
"README.md",
|
|
48
45
|
"LICENSE"
|
|
49
46
|
]
|
|
@@ -14,7 +14,7 @@ compatibility: |
|
|
|
14
14
|
Works with Claude Code, OpenCode, Cursor, and agentskills-compatible tools.
|
|
15
15
|
metadata:
|
|
16
16
|
author: jacebenson
|
|
17
|
-
version: "4.
|
|
17
|
+
version: "4.3.6"
|
|
18
18
|
repository: https://github.com/jacebenson/jsn
|
|
19
19
|
---
|
|
20
20
|
|
|
@@ -65,7 +65,7 @@ Pick the most specific tool for the job. **Never default to eval** — it's the
|
|
|
65
65
|
|--------|---------|
|
|
66
66
|
| **Core** | `incidents`, `changes`, `requests`, `tasks` |
|
|
67
67
|
| **Catalog** | `catalogitems list/show/create` — items with variables and flow info |
|
|
68
|
-
| **Automation** | `flows`, `actions`, `rules`, `
|
|
68
|
+
| **Automation** | `flows`, `actions`, `rules`, `triggers`, `scheduledjobs` |
|
|
69
69
|
| **Access** | `acls`, `b4rules`, `roles`, `groups`, `users` |
|
|
70
70
|
| **UX** | `forms` (section/element layout), `lists`, `clientscripts`, `uipolicies` |
|
|
71
71
|
| **Data** | `tables`, `columns`, `includes`, `logs`, `properties`, `records` |
|
|
@@ -73,16 +73,16 @@ Pick the most specific tool for the job. **Never default to eval** — it's the
|
|
|
73
73
|
| **Docs** | `docs sync`, `docs status`, `docs search`, `docs serve` |
|
|
74
74
|
| **Developer** | `eval`, `rest`, `skill`, `version` |
|
|
75
75
|
|
|
76
|
-
###
|
|
76
|
+
### Table and raw REST examples
|
|
77
77
|
|
|
78
78
|
```bash
|
|
79
|
-
# Query
|
|
80
|
-
jsn
|
|
79
|
+
# Query a table with the normal records command
|
|
80
|
+
jsn records list --table incident --query "active=true" --limit 5 --json
|
|
81
81
|
|
|
82
|
-
#
|
|
83
|
-
jsn
|
|
82
|
+
# Get a single record
|
|
83
|
+
jsn records get --table incident --sys-id abc123... --json
|
|
84
84
|
|
|
85
|
-
# Raw endpoint access
|
|
85
|
+
# Raw endpoint access when no specific command fits
|
|
86
86
|
jsn rest "/api/now/table/incident?sysparm_limit=3" --json
|
|
87
87
|
```
|
|
88
88
|
|
|
@@ -106,8 +106,6 @@ jsn docs search "REST API" --bundle it-service-management --json
|
|
|
106
106
|
# Start the web UI
|
|
107
107
|
jsn docs serve
|
|
108
108
|
|
|
109
|
-
# Share on your network
|
|
110
|
-
jsn docs serve --expose
|
|
111
109
|
```
|
|
112
110
|
|
|
113
111
|
**Re-running `jsn docs sync`** does a smart incremental refresh — pulls latest docs and only rebuilds changed files (seconds, not minutes).
|
|
@@ -129,5 +127,5 @@ Check order (priority):
|
|
|
129
127
|
```
|
|
130
128
|
~/.config/servicenow/
|
|
131
129
|
├── config.json # Profiles and settings
|
|
132
|
-
└── credentials
|
|
130
|
+
└── credentials/ # Auth tokens (fallback, one file per key)
|
|
133
131
|
```
|
package/src/cli.js
CHANGED
|
@@ -69,6 +69,7 @@ import { b4rulesCmd } from './commands/b4rules.js';
|
|
|
69
69
|
import { atfCmd } from './commands/atf.js';
|
|
70
70
|
import { approvalsCmd } from './commands/approvals.js';
|
|
71
71
|
import { perfCmd } from './commands/perf.js';
|
|
72
|
+
import { diffCmd } from './commands/diff.js';
|
|
72
73
|
|
|
73
74
|
function wrap(handler) {
|
|
74
75
|
return async (argv) => {
|
|
@@ -371,6 +372,7 @@ export function buildCLI() {
|
|
|
371
372
|
.command(atfCmd(wrap))
|
|
372
373
|
.command(approvalsCmd(wrap))
|
|
373
374
|
.command(perfCmd(wrap))
|
|
375
|
+
.command(diffCmd(wrap))
|
|
374
376
|
.command(evalCmd(wrap))
|
|
375
377
|
.command(restCmd(wrap))
|
|
376
378
|
.command(skillCmd(wrap))
|
|
@@ -68,7 +68,6 @@ export async function enrichCatalogItem({ sdk, instanceUrl, sysID }) {
|
|
|
68
68
|
flowCmd = `jsn flows show ${flowVal.value}`;
|
|
69
69
|
}
|
|
70
70
|
const wfName = getStringField(item, 'workflow') || '';
|
|
71
|
-
const wfID = item.workflow?.value || '';
|
|
72
71
|
const planName = getStringField(item, 'delivery_plan') || getStringField(item, 'execution_plan') || '';
|
|
73
72
|
|
|
74
73
|
const data = {
|
|
@@ -80,7 +79,7 @@ export async function enrichCatalogItem({ sdk, instanceUrl, sysID }) {
|
|
|
80
79
|
flow: flowName || undefined,
|
|
81
80
|
flow_cmd: flowCmd || undefined,
|
|
82
81
|
workflow: wfName || undefined,
|
|
83
|
-
|
|
82
|
+
|
|
84
83
|
execution_plan: planName || undefined,
|
|
85
84
|
variables: totalVars > 0 ? {
|
|
86
85
|
count: totalVars,
|
|
@@ -117,7 +116,6 @@ export function buildCatalogFormatted(data, standalone, setVars, totalVars) {
|
|
|
117
116
|
if (data.workflow) {
|
|
118
117
|
lines.push('─ Workflow ─');
|
|
119
118
|
lines.push(` ${data.workflow}`);
|
|
120
|
-
if (data.workflow_cmd) lines.push(` → ${data.workflow_cmd}`);
|
|
121
119
|
lines.push('');
|
|
122
120
|
}
|
|
123
121
|
if (totalVars > 0) {
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
2
|
+
import { errUsage, errUsageHint } from '../errors.js';
|
|
3
|
+
import { resolveSession } from '../session.js';
|
|
4
|
+
import { resolveRecord } from '../resolve-record.js';
|
|
5
|
+
|
|
6
|
+
declareCapabilities('diff', {});
|
|
7
|
+
|
|
8
|
+
const DEFAULT_IGNORED_FIELDS = [
|
|
9
|
+
'sys_created_by',
|
|
10
|
+
'sys_created_on',
|
|
11
|
+
'sys_mod_count',
|
|
12
|
+
'sys_updated_by',
|
|
13
|
+
'sys_updated_on',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function profileTarget(app, name, label) {
|
|
17
|
+
const session = resolveSession({ profile: name }, app.config);
|
|
18
|
+
if (session.unknownProfile || !session.profileName) {
|
|
19
|
+
throw errUsageHint(`Unknown ${label} profile "${name}"`, 'Run "jsn auth status" to list configured profiles.');
|
|
20
|
+
}
|
|
21
|
+
if (!session.instance || !session.profile) {
|
|
22
|
+
throw errUsage(`Profile "${name}" has no configured instance URL`);
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
name: session.profileName,
|
|
26
|
+
instance: session.instance,
|
|
27
|
+
profile: session.profile,
|
|
28
|
+
username: session.username || '',
|
|
29
|
+
domain: session.domain || '',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sdkForProfile(app, target) {
|
|
34
|
+
return app.getSDKForProfile(target.instance, {
|
|
35
|
+
profile: target.profile,
|
|
36
|
+
profileName: target.name,
|
|
37
|
+
username: target.username,
|
|
38
|
+
authMethod: target.profile.auth_method,
|
|
39
|
+
domain: target.domain,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function fetchRecord(app, target, table, identifier) {
|
|
44
|
+
try {
|
|
45
|
+
const record = await resolveRecord(sdkForProfile(app, target), {
|
|
46
|
+
table,
|
|
47
|
+
identifier,
|
|
48
|
+
matchField: 'sys_id',
|
|
49
|
+
resource: 'Record',
|
|
50
|
+
});
|
|
51
|
+
return { status: 'found', record };
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error?.code === 'not_found') return { status: 'missing', record: null };
|
|
54
|
+
if (error?.code === 'forbidden') return { status: 'forbidden', record: null, error: error.code };
|
|
55
|
+
if (error?.code === 'auth_error') return { status: 'auth_error', record: null, error: error.code };
|
|
56
|
+
return { status: 'error', record: null, error: error?.code || 'error' };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function stableValue(value) {
|
|
61
|
+
if (value && typeof value === 'object' && Object.hasOwn(value, 'value')) return value.value;
|
|
62
|
+
if (Array.isArray(value)) return value.map(stableValue);
|
|
63
|
+
if (value && typeof value === 'object') {
|
|
64
|
+
return Object.fromEntries(Object.keys(value).sort().map(key => [key, stableValue(value[key])]));
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function equalValue(a, b) {
|
|
70
|
+
return JSON.stringify(stableValue(a)) === JSON.stringify(stableValue(b));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function displayValue(value) {
|
|
74
|
+
if (value && typeof value === 'object' && value.display_value != null) return String(value.display_value);
|
|
75
|
+
if (value == null) return 'null';
|
|
76
|
+
if (typeof value === 'object') return JSON.stringify(value);
|
|
77
|
+
return String(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function fieldDifference(field, status, a, b) {
|
|
81
|
+
return {
|
|
82
|
+
field,
|
|
83
|
+
status,
|
|
84
|
+
...(a !== undefined ? { a } : {}),
|
|
85
|
+
...(b !== undefined ? { b } : {}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function compareRecords(recordA, recordB, ignoredFields = DEFAULT_IGNORED_FIELDS) {
|
|
90
|
+
const ignored = new Set(ignoredFields);
|
|
91
|
+
const fields = [...new Set([
|
|
92
|
+
...Object.keys(recordA || {}),
|
|
93
|
+
...Object.keys(recordB || {}),
|
|
94
|
+
])].filter(field => !ignored.has(field)).sort();
|
|
95
|
+
const differences = [];
|
|
96
|
+
for (const field of fields) {
|
|
97
|
+
const hasA = Object.hasOwn(recordA || {}, field);
|
|
98
|
+
const hasB = Object.hasOwn(recordB || {}, field);
|
|
99
|
+
if (!hasA && hasB) differences.push(fieldDifference(field, 'added', undefined, recordB[field]));
|
|
100
|
+
else if (hasA && !hasB) differences.push(fieldDifference(field, 'missing', recordA[field], undefined));
|
|
101
|
+
else if (!equalValue(recordA[field], recordB[field])) differences.push(fieldDifference(field, 'changed', recordA[field], recordB[field]));
|
|
102
|
+
}
|
|
103
|
+
return differences;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function formatRecordDiff(data) {
|
|
107
|
+
const lines = [
|
|
108
|
+
`Record diff: ${data.table} ${data.identifiers.a}`,
|
|
109
|
+
`A: ${data.profiles.a.name} (${data.existence.a})`,
|
|
110
|
+
`B: ${data.profiles.b.name} (${data.existence.b})`,
|
|
111
|
+
`Status: ${data.status}`,
|
|
112
|
+
];
|
|
113
|
+
if (data.status === 'error' || data.status === 'forbidden' || data.status === 'auth_error' || data.status === 'missing' || data.status === 'both_missing') return `${lines.join('\n')}\n`;
|
|
114
|
+
lines.push(`Ignored: ${data.ignored_fields.join(', ') || '(none)'}`);
|
|
115
|
+
if (data.differences.length === 0) lines.push('No field differences');
|
|
116
|
+
for (const difference of data.differences) {
|
|
117
|
+
const left = difference.a === undefined ? '(missing)' : displayValue(difference.a);
|
|
118
|
+
const right = difference.b === undefined ? '(missing)' : displayValue(difference.b);
|
|
119
|
+
lines.push(`${difference.status.toUpperCase()} ${difference.field}: ${left} -> ${right}`);
|
|
120
|
+
}
|
|
121
|
+
return `${lines.join('\n')}\n`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function ignoredFields(argv) {
|
|
125
|
+
if (argv['no-ignore']) return [];
|
|
126
|
+
return [...new Set([...DEFAULT_IGNORED_FIELDS, ...(argv.ignore || [])])].sort();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function diffCmd(wrap) {
|
|
130
|
+
return {
|
|
131
|
+
command: 'diff [subcommand]',
|
|
132
|
+
describe: 'Compare records across configured profiles (read-only)',
|
|
133
|
+
builder: (y) => y.command({
|
|
134
|
+
command: 'record',
|
|
135
|
+
describe: 'Compare one record across two profiles',
|
|
136
|
+
builder: (b) => b
|
|
137
|
+
.option('table', { type: 'string', demandOption: true, describe: 'ServiceNow table' })
|
|
138
|
+
.option('sys-id', { type: 'string', demandOption: true, describe: 'Record sys_id or exact identifier' })
|
|
139
|
+
.option('profile-a', { type: 'string', demandOption: true, describe: 'Profile for side A' })
|
|
140
|
+
.option('profile-b', { type: 'string', demandOption: true, describe: 'Profile for side B' })
|
|
141
|
+
.option('ignore', { type: 'string', array: true, default: [], describe: 'Additional field to ignore (repeatable)' })
|
|
142
|
+
.option('no-ignore', { type: 'boolean', default: false, describe: 'Do not ignore system noise fields' }),
|
|
143
|
+
handler: wrap(async (argv, app) => {
|
|
144
|
+
const table = String(argv.table || '').trim();
|
|
145
|
+
const identifier = String(argv['sys-id'] || '').trim();
|
|
146
|
+
if (!table || !identifier) throw errUsage('diff record requires --table and --sys-id');
|
|
147
|
+
if (argv['profile-a'] === argv['profile-b']) throw errUsage('Profile A and profile B must be different');
|
|
148
|
+
const targetA = profileTarget(app, argv['profile-a'], 'profile-a');
|
|
149
|
+
const targetB = profileTarget(app, argv['profile-b'], 'profile-b');
|
|
150
|
+
const [sideA, sideB] = await Promise.all([
|
|
151
|
+
fetchRecord(app, targetA, table, identifier),
|
|
152
|
+
fetchRecord(app, targetB, table, identifier),
|
|
153
|
+
]);
|
|
154
|
+
const ignored = ignoredFields(argv);
|
|
155
|
+
let status = 'error';
|
|
156
|
+
let differences = [];
|
|
157
|
+
if (sideA.status === 'found' && sideB.status === 'found') {
|
|
158
|
+
differences = compareRecords(sideA.record, sideB.record, ignored);
|
|
159
|
+
status = differences.length ? 'different' : 'equal';
|
|
160
|
+
} else if (sideA.status === 'missing' && sideB.status === 'missing') {
|
|
161
|
+
status = 'both_missing';
|
|
162
|
+
} else if (sideA.status === 'missing' || sideB.status === 'missing') {
|
|
163
|
+
status = 'missing';
|
|
164
|
+
} else if (sideA.status === 'forbidden' || sideB.status === 'forbidden') {
|
|
165
|
+
status = 'forbidden';
|
|
166
|
+
} else if (sideA.status === 'auth_error' || sideB.status === 'auth_error') {
|
|
167
|
+
status = 'auth_error';
|
|
168
|
+
}
|
|
169
|
+
const data = {
|
|
170
|
+
table,
|
|
171
|
+
identifiers: { a: identifier, b: identifier },
|
|
172
|
+
profiles: {
|
|
173
|
+
a: { name: targetA.name, instance: targetA.instance },
|
|
174
|
+
b: { name: targetB.name, instance: targetB.instance },
|
|
175
|
+
},
|
|
176
|
+
existence: { a: sideA.status, b: sideB.status },
|
|
177
|
+
errors: { a: sideA.error || null, b: sideB.error || null },
|
|
178
|
+
ignored_fields: ignored,
|
|
179
|
+
status,
|
|
180
|
+
differences,
|
|
181
|
+
_formatted: formatRecordDiff({
|
|
182
|
+
table,
|
|
183
|
+
identifiers: { a: identifier },
|
|
184
|
+
profiles: dataProfiles(targetA, targetB),
|
|
185
|
+
existence: { a: sideA.status, b: sideB.status },
|
|
186
|
+
ignored_fields: ignored,
|
|
187
|
+
status,
|
|
188
|
+
differences,
|
|
189
|
+
}),
|
|
190
|
+
};
|
|
191
|
+
app.ok(data, { summary: `Record diff ${status}: ${table} ${identifier}` });
|
|
192
|
+
}),
|
|
193
|
+
}),
|
|
194
|
+
handler: wrap(async (_argv, app) => {
|
|
195
|
+
app.ok({ command: 'diff', subcommands: ['record'] }, { summary: 'Use `jsn diff record --help` for details.' });
|
|
196
|
+
}),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function dataProfiles(targetA, targetB) {
|
|
201
|
+
return {
|
|
202
|
+
a: { name: targetA.name, instance: targetA.instance },
|
|
203
|
+
b: { name: targetB.name, instance: targetB.instance },
|
|
204
|
+
};
|
|
205
|
+
}
|
package/src/commands/perf.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { errUsageHint, errUsage } from '../errors.js';
|
|
2
2
|
import { captureRun, listRuns, getRun, compareRuns, formatRunDetailed, formatRunList, formatComparisonDetailed } from '../perf.js';
|
|
3
|
+
import { topTransactionsCmd } from './transactions.js';
|
|
3
4
|
import { paginatedSearch } from '../paginated-search.js';
|
|
4
5
|
|
|
5
6
|
function pickerEnabled(app) {
|
|
@@ -107,6 +108,11 @@ export function perfCmd(wrap) {
|
|
|
107
108
|
});
|
|
108
109
|
}),
|
|
109
110
|
})
|
|
111
|
+
.command({
|
|
112
|
+
command: 'transactions [subcommand]',
|
|
113
|
+
describe: 'Inspect Client Transaction Timings',
|
|
114
|
+
builder: (b) => b.command(topTransactionsCmd(wrap)),
|
|
115
|
+
})
|
|
110
116
|
.command({
|
|
111
117
|
command: 'compare [baseline] [new]',
|
|
112
118
|
describe: 'Compare exactly two saved captures',
|
|
@@ -39,6 +39,7 @@ export function classifyTransactionType(type, url = '') {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
function numericValue(value) {
|
|
42
|
+
if (value == null || String(value).trim() === '') return null;
|
|
42
43
|
const n = Number(String(value ?? '').replaceAll(',', ''));
|
|
43
44
|
return Number.isFinite(n) ? n : null;
|
|
44
45
|
}
|
|
@@ -65,6 +66,83 @@ function summarizeGroup(group) {
|
|
|
65
66
|
};
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
function topQuery(argv = {}) {
|
|
70
|
+
const parts = [];
|
|
71
|
+
if (argv.query) parts.push(String(argv.query));
|
|
72
|
+
for (const field of ['type', 'table', 'view']) if (argv[field]) parts.push(`${field}=${argv[field]}`);
|
|
73
|
+
if (argv.window != null && String(argv.window) !== '') {
|
|
74
|
+
const days = Math.max(1, Math.min(30, Number(argv.window) || 1));
|
|
75
|
+
parts.push(`sys_created_on>=javascript:gs.daysAgoStart(${days})`);
|
|
76
|
+
}
|
|
77
|
+
if (argv.since) parts.push(`sys_created_on>=${argv.since}`);
|
|
78
|
+
if (argv.until) parts.push(`sys_created_on<=${argv.until}`);
|
|
79
|
+
return normalizeTransactionQuery(parts.join('^'));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function topGroup(group) {
|
|
83
|
+
const stats = group?.stats || {};
|
|
84
|
+
return {
|
|
85
|
+
type: groupValue(group, 'type') || 'unavailable',
|
|
86
|
+
table: groupValue(group, 'table') || 'unavailable',
|
|
87
|
+
view: groupValue(group, 'view') || 'unavailable',
|
|
88
|
+
count: numericValue(stats.count) ?? 0,
|
|
89
|
+
avg_response_time_ms: metricValue(stats, 'avg', 'response_time'),
|
|
90
|
+
min_response_time_ms: metricValue(stats, 'min', 'response_time'),
|
|
91
|
+
max_response_time_ms: metricValue(stats, 'max', 'response_time'),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function formatTopTransactions(groups) {
|
|
96
|
+
const value = metric => metric == null ? 'unavailable' : `${Math.round(metric * 100) / 100} ms`;
|
|
97
|
+
const lines = [
|
|
98
|
+
'TYPE TABLE VIEW COUNT AVG MIN MAX',
|
|
99
|
+
'----------- ----------------- --------------- ----- ----------- ----------- -----------',
|
|
100
|
+
];
|
|
101
|
+
for (const row of groups) lines.push(`${String(row.type).slice(0, 11).padEnd(11)} ${String(row.table).slice(0, 17).padEnd(17)} ${String(row.view).slice(0, 15).padEnd(15)} ${String(row.count).padStart(5)} ${value(row.avg_response_time_ms).padEnd(11)} ${value(row.min_response_time_ms).padEnd(11)} ${value(row.max_response_time_ms)}`);
|
|
102
|
+
if (groups.length === 0) lines.push('(no transaction groups)');
|
|
103
|
+
return `${lines.join('\\n')}\\n`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function topTransactionsCmd(wrap) {
|
|
107
|
+
return {
|
|
108
|
+
command: 'top',
|
|
109
|
+
describe: 'Rank Client Transaction Timings by type, table, and view',
|
|
110
|
+
builder: (y) => y
|
|
111
|
+
.option('type', { type: 'string', describe: 'Filter transaction type' })
|
|
112
|
+
.option('table', { type: 'string', describe: 'Filter transaction table' })
|
|
113
|
+
.option('view', { type: 'string', describe: 'Filter transaction view' })
|
|
114
|
+
.option('query', { type: 'string', describe: 'Additional encoded query' })
|
|
115
|
+
.option('window', { type: 'number', default: 1, describe: 'Look back this many days (max 30)' })
|
|
116
|
+
.option('since', { type: 'string', describe: 'Only records on or after this timestamp' })
|
|
117
|
+
.option('until', { type: 'string', describe: 'Only records on or before this timestamp' })
|
|
118
|
+
.option('limit', { alias: 'l', type: 'number', default: 20, describe: 'Maximum groups to return' }),
|
|
119
|
+
handler: wrap(async (argv, app) => {
|
|
120
|
+
app.requireInstance();
|
|
121
|
+
const query = topQuery(argv);
|
|
122
|
+
const limit = Math.max(1, Math.min(100, Number(argv.limit) || 20));
|
|
123
|
+
const result = await app.sdk.aggregate('syslog_transaction', {
|
|
124
|
+
query,
|
|
125
|
+
groupBy: ['type', 'table', 'view'],
|
|
126
|
+
count: true,
|
|
127
|
+
averageFields: ['response_time'],
|
|
128
|
+
minimumFields: ['response_time'],
|
|
129
|
+
maximumFields: ['response_time'],
|
|
130
|
+
orderBy: 'countDESC',
|
|
131
|
+
});
|
|
132
|
+
const groups = (Array.isArray(result.groups) ? result.groups : []).map(topGroup).slice(0, limit);
|
|
133
|
+
app.ok({
|
|
134
|
+
table: 'syslog_transaction',
|
|
135
|
+
query,
|
|
136
|
+
group_by: ['type', 'table', 'view'],
|
|
137
|
+
response_time_unit: 'ms',
|
|
138
|
+
groups,
|
|
139
|
+
_formatted: formatTopTransactions(groups),
|
|
140
|
+
context: { instance_url: app.getEffectiveInstance() },
|
|
141
|
+
}, { summary: `Top transaction groups: ${groups.length}` });
|
|
142
|
+
}),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
68
146
|
export function transactionsCmd(wrap) {
|
|
69
147
|
return {
|
|
70
148
|
command: 'transactions',
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# pre-commit-check.sh - Run this before committing to check architecture compliance
|
|
3
|
-
|
|
4
|
-
echo "🔍 Checking architecture patterns..."
|
|
5
|
-
|
|
6
|
-
# Check for forbidden SDK patterns
|
|
7
|
-
FORBIDDEN_PATTERNS=(
|
|
8
|
-
"func.*Client.*ListForm"
|
|
9
|
-
"func.*Client.*ListList"
|
|
10
|
-
"func.*Client.*GetSP"
|
|
11
|
-
"func.*Client.*ListSP"
|
|
12
|
-
)
|
|
13
|
-
|
|
14
|
-
ERRORS=0
|
|
15
|
-
|
|
16
|
-
for pattern in "${FORBIDDEN_PATTERNS[@]}"; do
|
|
17
|
-
matches=$(grep -r "$pattern" internal/sdk/*.go 2>/dev/null | grep -v "_test.go" | grep -v "^Binary")
|
|
18
|
-
if [ ! -z "$matches" ]; then
|
|
19
|
-
echo "❌ Found forbidden SDK pattern: $pattern"
|
|
20
|
-
echo "$matches"
|
|
21
|
-
ERRORS=$((ERRORS + 1))
|
|
22
|
-
fi
|
|
23
|
-
done
|
|
24
|
-
|
|
25
|
-
# Check that commands don't import old SDK types
|
|
26
|
-
BAD_IMPORTS=(
|
|
27
|
-
"sdk.FormSection"
|
|
28
|
-
"sdk.FormElement"
|
|
29
|
-
"sdk.ListLayout"
|
|
30
|
-
"sdk.ListElement"
|
|
31
|
-
"sdk.SPPage"
|
|
32
|
-
"sdk.SPWidgetInstance"
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
for pattern in "${BAD_IMPORTS[@]}"; do
|
|
36
|
-
matches=$(grep -r "$pattern" internal/commands/**/*.go 2>/dev/null)
|
|
37
|
-
if [ ! -z "$matches" ]; then
|
|
38
|
-
echo "❌ Command using SDK type instead of local type: $pattern"
|
|
39
|
-
echo "$matches"
|
|
40
|
-
ERRORS=$((ERRORS + 1))
|
|
41
|
-
fi
|
|
42
|
-
done
|
|
43
|
-
|
|
44
|
-
# Run architecture tests
|
|
45
|
-
echo "🧪 Running architecture tests..."
|
|
46
|
-
if ! go test ./internal/sdk -run TestNoSDKHelperMethods -run TestCommandsUseDirectSDKList > /dev/null 2>&1; then
|
|
47
|
-
echo "❌ Architecture tests failed"
|
|
48
|
-
go test ./internal/sdk -v -run "TestNoSDKHelperMethods|TestCommandsUseDirectSDKList"
|
|
49
|
-
ERRORS=$((ERRORS + 1))
|
|
50
|
-
fi
|
|
51
|
-
|
|
52
|
-
if [ $ERRORS -eq 0 ]; then
|
|
53
|
-
echo "✅ All architecture checks passed!"
|
|
54
|
-
exit 0
|
|
55
|
-
else
|
|
56
|
-
echo ""
|
|
57
|
-
echo "⚠️ Architecture violations found!"
|
|
58
|
-
echo " Remember: Commands should call app.SDK.List() directly with local types."
|
|
59
|
-
echo " See internal/commands/forms.go for the correct pattern."
|
|
60
|
-
exit 1
|
|
61
|
-
fi
|
package/scripts/release.js
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { execSync } from 'child_process';
|
|
3
|
-
import { readFileSync } from 'fs';
|
|
4
|
-
import { fileURLToPath } from 'url';
|
|
5
|
-
import { dirname, join } from 'path';
|
|
6
|
-
|
|
7
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
-
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
9
|
-
|
|
10
|
-
const type = process.argv[2];
|
|
11
|
-
if (!type || !['patch', 'minor', 'major'].includes(type)) {
|
|
12
|
-
console.error('Usage: npm run release -- <patch|minor|major>');
|
|
13
|
-
process.exit(1);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
let branch;
|
|
17
|
-
try {
|
|
18
|
-
branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();
|
|
19
|
-
} catch {
|
|
20
|
-
console.error('Error: Not in a git repository');
|
|
21
|
-
process.exit(1);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
if (branch !== 'main') {
|
|
25
|
-
console.error(`Error: Releases can only be made from 'main' branch (currently on '${branch}')`);
|
|
26
|
-
process.exit(1);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const prefix = 'v';
|
|
30
|
-
|
|
31
|
-
console.log('');
|
|
32
|
-
console.log(`🚀 Releasing from '${branch}'`);
|
|
33
|
-
console.log(` Bump: ${type}`);
|
|
34
|
-
console.log('');
|
|
35
|
-
|
|
36
|
-
// Run tests
|
|
37
|
-
console.log('⏳ Running tests...');
|
|
38
|
-
try {
|
|
39
|
-
execSync('npm test', { stdio: 'inherit' });
|
|
40
|
-
} catch {
|
|
41
|
-
console.error('');
|
|
42
|
-
console.error('❌ Tests failed. Release aborted.');
|
|
43
|
-
process.exit(1);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Bump version with correct tag prefix
|
|
47
|
-
console.log('');
|
|
48
|
-
console.log(`⏳ Bumping version (${type})...`);
|
|
49
|
-
execSync(`npm version ${type} --tag-version-prefix=${prefix}`, { stdio: 'inherit' });
|
|
50
|
-
|
|
51
|
-
// Push commits and tags
|
|
52
|
-
console.log('');
|
|
53
|
-
console.log('⏳ Pushing to origin...');
|
|
54
|
-
execSync('git push && git push --tags', { stdio: 'inherit' });
|
|
55
|
-
|
|
56
|
-
// Show final state
|
|
57
|
-
const newPkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
58
|
-
console.log('');
|
|
59
|
-
console.log('✅ Release complete!');
|
|
60
|
-
console.log(` Version: ${newPkg.version}`);
|
|
61
|
-
console.log(` Tag: ${prefix}${newPkg.version}`);
|
|
62
|
-
console.log('');
|
package/scripts/tag-reminder.js
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { execSync } from 'child_process';
|
|
3
|
-
import { readFileSync } from 'fs';
|
|
4
|
-
import { fileURLToPath } from 'url';
|
|
5
|
-
import { dirname, join } from 'path';
|
|
6
|
-
|
|
7
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
-
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
9
|
-
|
|
10
|
-
let branch;
|
|
11
|
-
try {
|
|
12
|
-
branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();
|
|
13
|
-
} catch {
|
|
14
|
-
branch = 'unknown';
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const version = pkg.version;
|
|
18
|
-
const prefix = 'v';
|
|
19
|
-
|
|
20
|
-
console.log('');
|
|
21
|
-
console.log('📦 Release Reminder');
|
|
22
|
-
console.log('');
|
|
23
|
-
console.log(` Current version: ${version}`);
|
|
24
|
-
console.log(` Current branch: ${branch}`);
|
|
25
|
-
console.log('');
|
|
26
|
-
console.log(' To create a release tag, run:');
|
|
27
|
-
console.log('');
|
|
28
|
-
console.log(` git tag ${prefix}${version}`);
|
|
29
|
-
console.log(' git push origin --tags');
|
|
30
|
-
console.log('');
|