ns-gm 1.0.3

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Luis Simosa
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 ADDED
@@ -0,0 +1,127 @@
1
+ # ns-gm
2
+
3
+ NetSuite CLI for running SuiteScript snippets and fetching logs through a local proxy + RESTlet.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install
9
+ npm link
10
+ ```
11
+
12
+ Then use:
13
+
14
+ ```bash
15
+ ns-gm --help
16
+ ```
17
+
18
+ ## NetSuite Setup (OAuth 2.0 M2M)
19
+
20
+ ### 1) Integration Record
21
+
22
+ Create/update your integration record and enable only what is needed:
23
+
24
+ - `Client Credentials (Machine To Machine) Grant`: enabled
25
+ - Scopes: `RESTlets` and `REST Web Services`
26
+ - Nothing else required on that page for this CLI flow
27
+
28
+ ### 2) M2M Mapping
29
+
30
+ In NetSuite OAuth 2.0 Client Credentials (M2M) setup, map:
31
+
32
+ - Entity
33
+ - Role
34
+ - Application (integration record above)
35
+ - Public certificate
36
+
37
+ After saving, copy:
38
+
39
+ - `Client ID` (from integration record)
40
+ - `Certificate ID` (kid from M2M mapping)
41
+
42
+ ## Certificates
43
+
44
+ Generate keypair (PowerShell example):
45
+
46
+ ```powershell
47
+ $dir = "C:\Users\simos\Documents\ns-gm-certs"
48
+ New-Item -ItemType Directory -Force -Path $dir | Out-Null
49
+ openssl req -new -x509 -nodes -days 365 -newkey rsa:4096 -keyout "$dir\private_key.pem" -out "$dir\public_key.pem" -subj "/CN=ns-gm-oauth"
50
+ ```
51
+
52
+ - Upload `public_key.pem` to NetSuite M2M mapping
53
+ - Use `private_key.pem` in `ns-gm setup`
54
+
55
+ ## RESTlet
56
+
57
+ Deploy `ns_gm_restlet.js` to NetSuite and copy the deployment URL:
58
+
59
+ `https://<account>.restlets.api.netsuite.com/app/site/hosting/restlet.nl?script=<id>&deploy=<id>`
60
+
61
+ ## CLI Setup
62
+
63
+ Run:
64
+
65
+ ```bash
66
+ ns-gm setup
67
+ ```
68
+
69
+ Setup is alias-based. It lets you pick an existing alias or create `new`.
70
+
71
+ ### Example values to enter in setup prompts
72
+
73
+ - `alias`: `prod-main`
74
+ - `accountId`: `1234567_SB1`
75
+ - `clientId`: `6f8d...` (OAuth 2.0 Client ID from integration record)
76
+ - `certificateId`: `custcertificate_oauth2_prod` (kid from M2M mapping)
77
+ - `privateKeyPath`: `C:\Users\simos\Documents\ns-gm-certs\private_key.pem`
78
+ - `restletUrl`: `https://1234567-sb1.restlets.api.netsuite.com/app/site/hosting/restlet.nl?script=customscript_ns_gm_restlet&deploy=1`
79
+ - `scope`: `restlets`
80
+
81
+ Show active profile/config:
82
+
83
+ ```bash
84
+ ns-gm setup --show
85
+ ```
86
+
87
+ Credentials are stored at:
88
+
89
+ `~/.ns-gm/credentials.json`
90
+
91
+ ## Usage
92
+
93
+ Start proxy:
94
+
95
+ ```bash
96
+ ns-gm init
97
+ ```
98
+
99
+ Run inline code:
100
+
101
+ ```bash
102
+ ns-gm run --code "return 2 + 2"
103
+ ```
104
+
105
+ Run from file:
106
+
107
+ ```bash
108
+ ns-gm run --file script.js
109
+ ```
110
+
111
+ Get environment type:
112
+
113
+ ```bash
114
+ ns-gm env
115
+ ```
116
+
117
+ Get logs:
118
+
119
+ ```bash
120
+ ns-gm logs --type error --page-size 10
121
+ ```
122
+
123
+ Stop proxy:
124
+
125
+ ```bash
126
+ ns-gm stop
127
+ ```
@@ -0,0 +1,452 @@
1
+ /**
2
+ * @NApiVersion 2.1
3
+ * @NScriptType Restlet
4
+ * @NModuleScope Public
5
+ * @description RESTlet for NetSuite GM - Execute code and retrieve logs with all modules
6
+ */
7
+ define([
8
+ 'N/runtime', 'N/search', 'N/query', 'N/record', 'N/format', 'N/transaction',
9
+ 'N/log', 'N/file', 'N/https', 'N/http', 'N/email', 'N/error', 'N/url',
10
+ 'N/encode', 'N/crypto', 'N/currency', 'N/render', 'N/xml', 'N/config',
11
+ 'N/task', 'N/redirect', 'N/cache', 'N/certificateControl', 'N/workflow'
12
+ ],
13
+ function (runtime, search, query, record, format, transaction,
14
+ log, file, https, http, email, error, url,
15
+ encode, crypto, currency, render, xml, config,
16
+ task, redirect, cache, certificateControl, workflow) {
17
+
18
+ /**
19
+ * POST handler
20
+ * @param {Object} requestBody - Request payload
21
+ * @returns {Object} Response object
22
+ */
23
+ function post(requestBody) {
24
+ const action = requestBody.action;
25
+
26
+ if (action === 'run') {
27
+ return handleRunAction(requestBody);
28
+ }
29
+ else if (action === 'getscriptexecutionlogs') {
30
+ return handleGetLogsAction(requestBody);
31
+ }
32
+ else {
33
+ return {
34
+ success: false,
35
+ error: 'Unknown action: ' + action
36
+ };
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Handle 'run' action - Execute arbitrary SuiteScript code
42
+ * @param {Object} requestBody
43
+ * @returns {Object}
44
+ */
45
+ function handleRunAction(requestBody) {
46
+ // Track governance before execution
47
+ const currentScript = runtime.getCurrentScript();
48
+ const initialGovernance = currentScript.getRemainingUsage();
49
+
50
+ // Validate payload
51
+ if (!requestBody.data || !requestBody.data.code) {
52
+ return {
53
+ execution: 'Failure',
54
+ error: 'Missing code in data payload for action "run".',
55
+ governance: {
56
+ initial: initialGovernance,
57
+ remaining: currentScript.getRemainingUsage()
58
+ }
59
+ };
60
+ }
61
+
62
+ const userCode = requestBody.data.code;
63
+ const requestedModules = requestBody.data.modules || [
64
+ 'log', 'search', 'record', 'runtime', 'format', 'query', 'transaction',
65
+ 'file', 'https', 'http', 'email', 'error', 'url', 'encode', 'crypto',
66
+ 'currency', 'render', 'xml', 'config', 'task', 'redirect', 'cache',
67
+ 'certificateControl', 'workflow'
68
+ ];
69
+
70
+ // Map of all available NetSuite modules (all pre-loaded)
71
+ const moduleMap = {
72
+ 'log': log,
73
+ 'search': search,
74
+ 'record': record,
75
+ 'runtime': runtime,
76
+ 'format': format,
77
+ 'query': query,
78
+ 'transaction': transaction,
79
+ 'file': file,
80
+ 'https': https,
81
+ 'http': http,
82
+ 'email': email,
83
+ 'error': error,
84
+ 'url': url,
85
+ 'encode': encode,
86
+ 'crypto': crypto,
87
+ 'currency': currency,
88
+ 'render': render,
89
+ 'xml': xml,
90
+ 'config': config,
91
+ 'task': task,
92
+ 'redirect': redirect,
93
+ 'cache': cache,
94
+ 'certificateControl': certificateControl,
95
+ 'workflow': workflow
96
+ };
97
+
98
+ // Dynamically load modules if not already loaded
99
+ const loadModule = function (moduleName) {
100
+ try {
101
+ if (moduleMap[moduleName] === null) {
102
+ moduleMap[moduleName] = require('N/' + moduleName);
103
+ }
104
+ return moduleMap[moduleName];
105
+ } catch (e) {
106
+ log.error({
107
+ title: 'MODULE LOAD ERROR',
108
+ details: 'Failed to load module N/' + moduleName + ': ' + e.toString()
109
+ });
110
+ return null;
111
+ }
112
+ };
113
+
114
+ // Build execution scope with requested modules
115
+ const moduleParamNames = [];
116
+ const moduleParamValues = [];
117
+
118
+ for (var i = 0; i < requestedModules.length; i++) {
119
+ var moduleName = requestedModules[i];
120
+ var moduleObj = loadModule(moduleName);
121
+ if (moduleObj) {
122
+ moduleParamNames.push(moduleName);
123
+ moduleParamValues.push(moduleObj);
124
+ }
125
+ }
126
+
127
+ // Track execution time
128
+ const executionStartTime = new Date().getTime();
129
+
130
+ // Execute the code
131
+ const executionOutput = safeExecute(userCode, moduleParamNames, moduleParamValues);
132
+
133
+ const executionEndTime = new Date().getTime();
134
+ const executionTimeMs = executionEndTime - executionStartTime;
135
+
136
+ // Get final governance
137
+ const finalGovernance = currentScript.getRemainingUsage();
138
+
139
+ // Return response
140
+ return {
141
+ execution: executionOutput.success ? 'Success' : 'Error',
142
+ result: executionOutput.success ? executionOutput.result : null,
143
+ error: executionOutput.success ? null : executionOutput.error,
144
+ executionTime: {
145
+ netsuiteMs: executionTimeMs
146
+ },
147
+ governance: {
148
+ initial: initialGovernance,
149
+ remaining: finalGovernance,
150
+ used: initialGovernance - finalGovernance
151
+ },
152
+ logs: []
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Safely execute user code with injected modules
158
+ * @param {string} userCode
159
+ * @param {Array} moduleNames
160
+ * @param {Array} moduleValues
161
+ * @returns {Object}
162
+ */
163
+ function safeExecute(userCode, moduleNames, moduleValues) {
164
+ try {
165
+ // Create function with variable number of parameters
166
+ const executionFunction = new Function(...moduleNames, userCode);
167
+
168
+ // Execute the function, passing the required modules as arguments
169
+ const finalResult = executionFunction(...moduleValues);
170
+
171
+ // Stringify the result for JSON transport
172
+ var resultString = typeof finalResult !== 'undefined' ? JSON.stringify(finalResult) : 'undefined';
173
+
174
+ return {
175
+ success: true,
176
+ result: resultString
177
+ };
178
+ } catch (e) {
179
+ log.error({
180
+ title: 'EXECUTION ERROR',
181
+ details: e.toString()
182
+ });
183
+
184
+ return {
185
+ success: false,
186
+ error: e.toString()
187
+ };
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Handle 'getscriptexecutionlogs' action - Retrieve script execution logs with filters
193
+ * @param {Object} requestBody
194
+ * @returns {Object}
195
+ */
196
+ function handleGetLogsAction(requestBody) {
197
+ // Track governance before execution
198
+ const currentScript = runtime.getCurrentScript();
199
+ const initialGovernance = currentScript.getRemainingUsage();
200
+
201
+ try {
202
+ // Get scriptId parameter, default to current script's ID if not provided
203
+ var scriptId = requestBody.data && requestBody.data.scriptId ? requestBody.data.scriptId : currentScript.id;
204
+
205
+ // Convert script ID to internal ID if needed
206
+ // scriptnote.scripttype expects internal numeric ID, not string ID
207
+ var scriptInternalId = scriptId;
208
+
209
+ // If scriptId looks like a string ID (starts with 'customscript_'), convert it
210
+ if (scriptId.toString().indexOf('customscript_') === 0) {
211
+ try {
212
+ var scriptLookup = search.create({
213
+ type: 'script',
214
+ filters: [['scriptid', 'is', scriptId]],
215
+ columns: ['internalid']
216
+ });
217
+
218
+ var scriptResult = scriptLookup.run().getRange({ start: 0, end: 1 });
219
+
220
+ if (scriptResult && scriptResult.length > 0) {
221
+ scriptInternalId = scriptResult[0].id;
222
+ log.debug('Script ID conversion', {
223
+ stringId: scriptId,
224
+ internalId: scriptInternalId
225
+ });
226
+ } else {
227
+ return {
228
+ success: false,
229
+ error: 'Script not found: ' + scriptId,
230
+ governance: {
231
+ initial: initialGovernance,
232
+ remaining: currentScript.getRemainingUsage()
233
+ }
234
+ };
235
+ }
236
+ } catch (lookupError) {
237
+ log.error('Script ID lookup failed', lookupError);
238
+ return {
239
+ success: false,
240
+ error: 'Failed to lookup script: ' + lookupError.toString(),
241
+ governance: {
242
+ initial: initialGovernance,
243
+ remaining: currentScript.getRemainingUsage()
244
+ }
245
+ };
246
+ }
247
+ }
248
+
249
+ // Get optional parameters
250
+ const dateFrom = requestBody.data && requestBody.data.dateFrom ? requestBody.data.dateFrom : null;
251
+ const dateTo = requestBody.data && requestBody.data.dateTo ? requestBody.data.dateTo : null;
252
+ const logType = requestBody.data && requestBody.data.logType ? requestBody.data.logType : null;
253
+ const pageIndex = requestBody.data && requestBody.data.pageIndex !== undefined ? requestBody.data.pageIndex : 0;
254
+ const pageSize = requestBody.data && requestBody.data.pageSize !== undefined ? requestBody.data.pageSize : 20;
255
+
256
+ // Execute logs query with internal ID
257
+ const logsData = getScriptExecutionLogs(scriptInternalId, dateFrom, dateTo, logType, pageIndex, pageSize);
258
+
259
+ // Get final governance
260
+ const finalGovernance = currentScript.getRemainingUsage();
261
+
262
+ // Return response with governance tracking
263
+ return {
264
+ success: true,
265
+ results: logsData.results,
266
+ governance: {
267
+ initial: initialGovernance,
268
+ remaining: finalGovernance,
269
+ used: initialGovernance - finalGovernance
270
+ },
271
+ pageIndex: logsData.pageIndex,
272
+ pageSize: logsData.pageSize,
273
+ totalRecords: logsData.totalRecords,
274
+ totalPages: logsData.totalPages
275
+ };
276
+
277
+ } catch (error) {
278
+ log.error({
279
+ title: 'Error getting script execution logs',
280
+ details: error.toString()
281
+ });
282
+
283
+ return {
284
+ success: false,
285
+ error: error.toString(),
286
+ governance: {
287
+ initial: initialGovernance,
288
+ remaining: currentScript.getRemainingUsage()
289
+ }
290
+ };
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Query script execution logs with filters
296
+ * @param {string} scriptId - Script internal ID
297
+ * @param {string|null} dateFrom - Start date (YYYY-MM-DD)
298
+ * @param {string|null} dateTo - End date (YYYY-MM-DD)
299
+ * @param {string|null} logType - Log type filter (DEBUG, AUDIT, ERROR, EMERGENCY)
300
+ * @param {number} pageIndex - Page number (0-based)
301
+ * @param {number} pageSize - Number of results per page
302
+ * @returns {Object}
303
+ */
304
+ function getScriptExecutionLogs(scriptId, dateFrom, dateTo, logType, pageIndex, pageSize) {
305
+ // Calculate offset for pagination
306
+ var offset = pageIndex * pageSize;
307
+
308
+ // Build SuiteQL query with dynamic date range
309
+ var sqlQuery = `
310
+ SELECT
311
+ scriptnote.internalid,
312
+ scriptnote.date,
313
+ scriptnote.type,
314
+ scriptnote.title,
315
+ scriptnote.detail,
316
+ scriptnote.scripttype
317
+ FROM
318
+ scriptnote
319
+ WHERE
320
+ scriptnote.scripttype = ?
321
+ `;
322
+
323
+ var params = [scriptId];
324
+
325
+ // Add date range filter if provided
326
+ if (dateFrom && dateTo) {
327
+ sqlQuery += " AND scriptnote.date BETWEEN TO_DATE(?, 'YYYY-MM-DD') AND TO_DATE(?, 'YYYY-MM-DD')";
328
+ params.push(dateFrom);
329
+ params.push(dateTo);
330
+ } else if (dateFrom) {
331
+ sqlQuery += " AND scriptnote.date >= TO_DATE(?, 'YYYY-MM-DD')";
332
+ params.push(dateFrom);
333
+ } else if (dateTo) {
334
+ sqlQuery += " AND scriptnote.date <= TO_DATE(?, 'YYYY-MM-DD')";
335
+ params.push(dateTo);
336
+ } else {
337
+ // Default: last 7 days if no date range specified
338
+ sqlQuery += " AND scriptnote.date >= BUILTIN.RELATIVE_RANGES('DAGO7', 'START')";
339
+ }
340
+
341
+ // Add log type filter if provided
342
+ if (logType) {
343
+ sqlQuery += " AND UPPER(scriptnote.type) = ?";
344
+ params.push(logType.toUpperCase());
345
+ }
346
+
347
+ // Add ordering and pagination
348
+ sqlQuery += `
349
+ ORDER BY
350
+ scriptnote.date DESC
351
+ OFFSET ${offset} ROWS
352
+ FETCH FIRST ${pageSize} ROWS ONLY
353
+ `;
354
+
355
+ log.debug({
356
+ title: 'Executing SuiteQL for Script Logs',
357
+ details: {
358
+ scriptId: scriptId,
359
+ dateFrom: dateFrom,
360
+ dateTo: dateTo,
361
+ logType: logType,
362
+ pageIndex: pageIndex,
363
+ pageSize: pageSize,
364
+ offset: offset
365
+ }
366
+ });
367
+
368
+ // Execute query
369
+ const resultSet = query.runSuiteQL({
370
+ query: sqlQuery,
371
+ params: params
372
+ });
373
+
374
+ const results = resultSet.asMappedResults();
375
+
376
+ // Get total count for pagination
377
+ var countQuery = `
378
+ SELECT
379
+ COUNT(*) as total
380
+ FROM
381
+ scriptnote
382
+ WHERE
383
+ scriptnote.scripttype = ?
384
+ `;
385
+
386
+ var countParams = [scriptId];
387
+
388
+ if (dateFrom && dateTo) {
389
+ countQuery += " AND scriptnote.date BETWEEN TO_DATE(?, 'YYYY-MM-DD') AND TO_DATE(?, 'YYYY-MM-DD')";
390
+ countParams.push(dateFrom);
391
+ countParams.push(dateTo);
392
+ } else if (dateFrom) {
393
+ countQuery += " AND scriptnote.date >= TO_DATE(?, 'YYYY-MM-DD')";
394
+ countParams.push(dateFrom);
395
+ } else if (dateTo) {
396
+ countQuery += " AND scriptnote.date <= TO_DATE(?, 'YYYY-MM-DD')";
397
+ countParams.push(dateTo);
398
+ } else {
399
+ countQuery += " AND scriptnote.date >= BUILTIN.RELATIVE_RANGES('DAGO7', 'START')";
400
+ }
401
+
402
+ if (logType) {
403
+ countQuery += " AND UPPER(scriptnote.type) = ?";
404
+ countParams.push(logType.toUpperCase());
405
+ }
406
+
407
+ const countResultSet = query.runSuiteQL({
408
+ query: countQuery,
409
+ params: countParams
410
+ });
411
+
412
+ const countResults = countResultSet.asMappedResults();
413
+ const totalRecords = countResults[0].total;
414
+
415
+ log.audit({
416
+ title: 'Script Execution Logs Retrieved',
417
+ details: {
418
+ scriptId: scriptId,
419
+ pageIndex: pageIndex,
420
+ pageSize: pageSize,
421
+ recordCount: results.length,
422
+ totalRecords: totalRecords
423
+ }
424
+ });
425
+
426
+ // Map results to clean format
427
+ var cleanResults = [];
428
+ results.forEach(function (logEntry) {
429
+ cleanResults.push({
430
+ internalId: logEntry.internalid,
431
+ title: logEntry.title,
432
+ type: logEntry.type,
433
+ date: logEntry.date,
434
+ scriptType: logEntry.scripttype,
435
+ details: logEntry.detail
436
+ });
437
+ });
438
+
439
+ return {
440
+ results: cleanResults,
441
+ pageIndex: pageIndex,
442
+ pageSize: pageSize,
443
+ totalRecords: totalRecords,
444
+ totalPages: Math.ceil(totalRecords / pageSize)
445
+ };
446
+ }
447
+
448
+ return {
449
+ post: post
450
+ };
451
+ }
452
+ );
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "ns-gm",
3
+ "version": "1.0.3",
4
+ "description": "CLI tool for executing SuiteScript code against NetSuite",
5
+ "main": "src/cli.js",
6
+ "bin": {
7
+ "ns-gm": "./src/cli.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node src/cli.js",
11
+ "dev": "node src/cli.js",
12
+ "link": "npm link"
13
+ },
14
+ "keywords": [
15
+ "netsuite",
16
+ "cli",
17
+ "suitescript",
18
+ "automation"
19
+ ],
20
+ "author": "Luis Simosa <simosa37@gmail.com>",
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/Project-X-Innovation/ns-gm"
25
+ },
26
+ "homepage": "https://github.com/Project-X-Innovation/ns-gm#readme",
27
+ "bugs": {
28
+ "url": "https://github.com/Project-X-Innovation/ns-gm/issues"
29
+ },
30
+ "files": [
31
+ "src/",
32
+ "server/",
33
+ "ns_gm_restlet.js",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "dependencies": {
38
+ "axios": "^1.6.0",
39
+ "commander": "^12.0.0",
40
+ "cors": "^2.8.5",
41
+ "dotenv": "^16.0.0",
42
+ "express": "^4.18.0",
43
+ "jose": "^5.10.0",
44
+ "prompts": "^2.4.2"
45
+ },
46
+ "devDependencies": {
47
+ "eslint": "^8.0.0",
48
+ "prettier": "^3.0.0"
49
+ },
50
+ "engines": {
51
+ "node": ">=24.0.0"
52
+ }
53
+ }