@tenonhq/dovetail-servicenow 0.0.20 → 0.0.22
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/dist/cli.js +68 -0
- package/dist/createClientFromEnvFile.d.ts +29 -0
- package/dist/createClientFromEnvFile.js +63 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +7 -1
- package/dist/mcp/registry.d.ts +1 -1
- package/dist/mcp/registry.js +31 -1
- package/dist/mcp/schemas.d.ts +58 -0
- package/dist/mcp/schemas.js +11 -1
- package/dist/table/addColumn.d.ts +98 -0
- package/dist/table/addColumn.js +384 -0
- package/dist/table/formSession.d.ts +18 -6
- package/dist/table/formSession.js +98 -37
- package/dist/table/index.d.ts +3 -1
- package/dist/table/index.js +6 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -706,6 +706,10 @@ function printHelp() {
|
|
|
706
706
|
" --columns \"Label:type:max, ...\" OR --from-json <spec.json>\n" +
|
|
707
707
|
" [--extends <t>] [--number-prefix <p>] [--user-role <r>]\n" +
|
|
708
708
|
" [--no-acls] [--no-menu] [--update-set <sys_id>] [--dry-run] [--json])\n" +
|
|
709
|
+
" add-column Add ONE column to an EXISTING table via the Studio form save, then verify\n" +
|
|
710
|
+
" (--table <name|sys_id> --label <l> --type <t>\n" +
|
|
711
|
+
" [--name <element>] [--max-length <n>] [--reference <table>]\n" +
|
|
712
|
+
" [--scope <s>] [--update-set <sys_id>] [--dry-run] [--json])\n" +
|
|
709
713
|
" test-flow Validate (default) or run a flow/subflow\n" +
|
|
710
714
|
" (--sys-id <sys_id> [--execute --confirm] [--inputs <json>] [--json])\n" +
|
|
711
715
|
" edit-flow Patch a flow/subflow (rename, description, step inputs)\n" +
|
|
@@ -806,6 +810,67 @@ async function runCreateTable(flags) {
|
|
|
806
810
|
return 2;
|
|
807
811
|
return 0;
|
|
808
812
|
}
|
|
813
|
+
/**
|
|
814
|
+
* dove-sn add-column:
|
|
815
|
+
* --table x_cadso_journey --label URL --type url
|
|
816
|
+
* [--name url] [--max-length 1024] [--reference <table>]
|
|
817
|
+
* [--scope x_cadso_journey] [--update-set <sys_id>] [--save-action <sys_id>]
|
|
818
|
+
* [--columns-rel-id <sys_id>] [--from-json <spec.json>] [--dry-run] [--debug] [--json]
|
|
819
|
+
*/
|
|
820
|
+
async function runAddColumn(flags) {
|
|
821
|
+
var spec = {};
|
|
822
|
+
if (flags["from-json"]) {
|
|
823
|
+
spec = JSON.parse(fs.readFileSync(path.resolve(flags["from-json"]), "utf8"));
|
|
824
|
+
}
|
|
825
|
+
var table = flags.table || spec.table;
|
|
826
|
+
var column = spec.column;
|
|
827
|
+
if (flags.label || flags.type) {
|
|
828
|
+
column = { label: flags.label || "", type: flags.type || "string" };
|
|
829
|
+
if (flags.name)
|
|
830
|
+
column.name = flags.name;
|
|
831
|
+
if (flags["max-length"])
|
|
832
|
+
column.max_length = flags["max-length"];
|
|
833
|
+
if (flags.reference)
|
|
834
|
+
column.reference = flags.reference;
|
|
835
|
+
}
|
|
836
|
+
if (!table || !column || !column.label) {
|
|
837
|
+
process.stderr.write("add-column: --table and --label (with --type) are required (or --from-json)\n");
|
|
838
|
+
return 1;
|
|
839
|
+
}
|
|
840
|
+
var params = {
|
|
841
|
+
client: (0, client_1.createClient)({}),
|
|
842
|
+
table: table,
|
|
843
|
+
column: column
|
|
844
|
+
};
|
|
845
|
+
var scope = flags.scope || spec.scope;
|
|
846
|
+
if (scope)
|
|
847
|
+
params.scope = scope;
|
|
848
|
+
var us = flags["update-set"] || spec.updateSetSysId;
|
|
849
|
+
if (us)
|
|
850
|
+
params.updateSetSysId = us;
|
|
851
|
+
var sa = flags["save-action"] || spec.saveActionSysId;
|
|
852
|
+
if (sa)
|
|
853
|
+
params.saveActionSysId = sa;
|
|
854
|
+
var relId = flags["columns-rel-id"] || spec.columnsRelId;
|
|
855
|
+
if (relId)
|
|
856
|
+
params.columnsRelId = relId;
|
|
857
|
+
if (flags["dry-run"] === "true" || spec.dryRun === true)
|
|
858
|
+
params.dryRun = true;
|
|
859
|
+
if (flags.debug === "true" || spec.debug === true)
|
|
860
|
+
params.debug = true;
|
|
861
|
+
var result = await (0, table_1.addColumn)(params);
|
|
862
|
+
if (flags.json === "true") {
|
|
863
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
864
|
+
}
|
|
865
|
+
else {
|
|
866
|
+
process.stdout.write("[" + result.status + "] " + result.table + "." + result.element + " (" + result.internalType + ")"
|
|
867
|
+
+ (result.verified ? " — verified" : "")
|
|
868
|
+
+ "\n" + result.note + "\n");
|
|
869
|
+
}
|
|
870
|
+
if (result.status === "failed")
|
|
871
|
+
return 2;
|
|
872
|
+
return 0;
|
|
873
|
+
}
|
|
809
874
|
async function main() {
|
|
810
875
|
var parsed = parseArgs(process.argv.slice(2));
|
|
811
876
|
// Load credentials before any command runs. `--env`/`--env-file` (or the
|
|
@@ -837,6 +902,9 @@ async function main() {
|
|
|
837
902
|
if (parsed.command === "create-table") {
|
|
838
903
|
return await runCreateTable(parsed.flags);
|
|
839
904
|
}
|
|
905
|
+
if (parsed.command === "add-column") {
|
|
906
|
+
return await runAddColumn(parsed.flags);
|
|
907
|
+
}
|
|
840
908
|
if (parsed.command === "test-flow") {
|
|
841
909
|
return await runTestFlow(parsed.flags);
|
|
842
910
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ServiceNowClient } from "./client";
|
|
2
|
+
import type { ServiceNowClientConfig } from "./types";
|
|
3
|
+
/**
|
|
4
|
+
* Build a ServiceNowClientConfig from a `.env`-style file WITHOUT mutating the
|
|
5
|
+
* global process.env.
|
|
6
|
+
*
|
|
7
|
+
* Unlike `loadEnvFile` (which calls `dotenv.config()` and is correct for a
|
|
8
|
+
* one-shot CLI), this reads the file into a local object via `dotenv.parse` so
|
|
9
|
+
* it is safe to call repeatedly inside a long-lived process — e.g. a per-call
|
|
10
|
+
* retarget in the MCP server, where a global dotenv mutation would race across
|
|
11
|
+
* concurrent reads.
|
|
12
|
+
*
|
|
13
|
+
* Instance/auth precedence within the file mirrors `createClient`'s env
|
|
14
|
+
* precedence (SN_* > SN_DEV_* > SN_PROD_*) so a file written for either the
|
|
15
|
+
* dev-style or prod-style variable names resolves the same way. The resolved
|
|
16
|
+
* values are returned as an explicit config object — they are never read from
|
|
17
|
+
* the surrounding process.env, so the file fully determines the target.
|
|
18
|
+
*
|
|
19
|
+
* Throws if the file cannot be read, or if it does not define an instance and
|
|
20
|
+
* a complete credential pair — failing loudly rather than silently falling
|
|
21
|
+
* back to whatever instance the host process happens to be pointed at.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveConfigFromEnvFile(envPath: string): ServiceNowClientConfig;
|
|
24
|
+
/**
|
|
25
|
+
* Construct a ServiceNowClient bound to the instance/credentials defined in a
|
|
26
|
+
* `.env`-style file, without touching the global process.env. See
|
|
27
|
+
* `resolveConfigFromEnvFile` for the precedence and failure rules.
|
|
28
|
+
*/
|
|
29
|
+
export declare function createClientFromEnvFile(envPath: string): ServiceNowClient;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.resolveConfigFromEnvFile = resolveConfigFromEnvFile;
|
|
7
|
+
exports.createClientFromEnvFile = createClientFromEnvFile;
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const dotenv_1 = __importDefault(require("dotenv"));
|
|
10
|
+
const client_1 = require("./client");
|
|
11
|
+
/**
|
|
12
|
+
* Build a ServiceNowClientConfig from a `.env`-style file WITHOUT mutating the
|
|
13
|
+
* global process.env.
|
|
14
|
+
*
|
|
15
|
+
* Unlike `loadEnvFile` (which calls `dotenv.config()` and is correct for a
|
|
16
|
+
* one-shot CLI), this reads the file into a local object via `dotenv.parse` so
|
|
17
|
+
* it is safe to call repeatedly inside a long-lived process — e.g. a per-call
|
|
18
|
+
* retarget in the MCP server, where a global dotenv mutation would race across
|
|
19
|
+
* concurrent reads.
|
|
20
|
+
*
|
|
21
|
+
* Instance/auth precedence within the file mirrors `createClient`'s env
|
|
22
|
+
* precedence (SN_* > SN_DEV_* > SN_PROD_*) so a file written for either the
|
|
23
|
+
* dev-style or prod-style variable names resolves the same way. The resolved
|
|
24
|
+
* values are returned as an explicit config object — they are never read from
|
|
25
|
+
* the surrounding process.env, so the file fully determines the target.
|
|
26
|
+
*
|
|
27
|
+
* Throws if the file cannot be read, or if it does not define an instance and
|
|
28
|
+
* a complete credential pair — failing loudly rather than silently falling
|
|
29
|
+
* back to whatever instance the host process happens to be pointed at.
|
|
30
|
+
*/
|
|
31
|
+
function resolveConfigFromEnvFile(envPath) {
|
|
32
|
+
if (typeof envPath !== "string" || envPath.length === 0) {
|
|
33
|
+
throw new Error("resolveConfigFromEnvFile: envPath must be a non-empty string.");
|
|
34
|
+
}
|
|
35
|
+
var raw;
|
|
36
|
+
try {
|
|
37
|
+
raw = fs_1.default.readFileSync(envPath, "utf8");
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
throw new Error("Cannot read env file '" + envPath + "': " + (e && e.message ? e.message : String(e)));
|
|
41
|
+
}
|
|
42
|
+
var parsed = dotenv_1.default.parse(raw);
|
|
43
|
+
var instance = parsed.SN_INSTANCE || parsed.SN_DEV_INSTANCE || parsed.SN_PROD_INSTANCE || "";
|
|
44
|
+
var user = parsed.SN_USER || parsed.SN_DEV_USERNAME || parsed.SN_PROD_USERNAME || "";
|
|
45
|
+
var password = parsed.SN_PASSWORD || parsed.SN_DEV_PASSWORD || parsed.SN_PROD_PASSWORD || "";
|
|
46
|
+
if (!instance) {
|
|
47
|
+
throw new Error("env file '" + envPath + "' does not define a ServiceNow instance — " +
|
|
48
|
+
"set SN_INSTANCE (preferred) or SN_DEV_INSTANCE / SN_PROD_INSTANCE.");
|
|
49
|
+
}
|
|
50
|
+
if (!user || !password) {
|
|
51
|
+
throw new Error("env file '" + envPath + "' is missing ServiceNow credentials — " +
|
|
52
|
+
"set SN_USER/SN_PASSWORD (preferred) or SN_DEV_USERNAME/SN_DEV_PASSWORD (or SN_PROD_*).");
|
|
53
|
+
}
|
|
54
|
+
return { instance: instance, user: user, password: password };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Construct a ServiceNowClient bound to the instance/credentials defined in a
|
|
58
|
+
* `.env`-style file, without touching the global process.env. See
|
|
59
|
+
* `resolveConfigFromEnvFile` for the precedence and failure rules.
|
|
60
|
+
*/
|
|
61
|
+
function createClientFromEnvFile(envPath) {
|
|
62
|
+
return (0, client_1.createClient)(resolveConfigFromEnvFile(envPath));
|
|
63
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* REST API so every change lands in the target update set and scope.
|
|
6
6
|
*/
|
|
7
7
|
export { createClient } from "./client";
|
|
8
|
+
export { createClientFromEnvFile, resolveConfigFromEnvFile } from "./createClientFromEnvFile";
|
|
8
9
|
export type { ServiceNowClient, TableQueryOptions, TableSchema, TableSchemaField } from "./client";
|
|
9
10
|
export { addChoicesToField } from "./choices";
|
|
10
11
|
export { formatAddChoicesResult } from "./formatter";
|
|
@@ -17,5 +18,5 @@ export { sincPlugin } from "./plugin";
|
|
|
17
18
|
export { listTemplates, verifyArtifact, cloneSubflow, cloneActionType, triggerPublication, publishActionType, editActionType, readFlow, readActionType, publishFlow, copyFlow, createFlow, buildPublishModel, editFlow, testFlow, DEFAULT_RUN_FLOW_PATH, generateSysId, topoSort, executeWritePlan, WriteOrderError } from "./flowDesigner";
|
|
18
19
|
export type { TemplateRef, ListTemplatesParams, FlowKind, VerifyExpect, VerifyFound, VerifyFailure, VerifyReport, VerifyArtifactParams, CloneSubflowParams, CloneSubflowResult, CloneActionTypeParams, CloneActionTypeResult, TriggerPublicationParams, TriggerPublicationResult, PublishActionTypeParams, PublishActionTypeResult, EditActionTypeParams, EditActionTypeResult, EditActionTypeOps, ReadFlowParams, ReadFlowResult, FlowStep, FlowVariable, ReadActionTypeParams, ReadActionTypeResult, ActionIo, PublishFlowParams, PublishFlowResult, CopyFlowParams, CopyFlowResult, CreateFlowParams, CreateFlowResult, EditFlowParams, EditFlowResult, EditFlowOps, StepInputPatch, TestFlowParams, TestFlowResult, WriteOp, WriteOpResult } from "./flowDesigner";
|
|
19
20
|
export type { ServiceNowClientConfig, ChoiceValue, ChoiceType, AddChoicesParams, AddChoicesResult, ChoiceActionResult, DictionaryRecord, UpdateSetRecord, LayoutAction, LayoutRecordResult, LayoutResult, CreateViewParams, CreateViewResult, FormSectionSpec, SetFormLayoutParams, SetListLayoutParams, SetRelatedListsParams } from "./types";
|
|
20
|
-
export { createTable, projectTableGraph, buildColumnXml, normalizeColumns, resolveType, applyTableSaveOverlay, defaultAccessFlags, TYPE_MAP, DEFAULT_SUPER_CLASS, DEFAULT_SAVE_ACTION } from "./table";
|
|
21
|
-
export type { CreateTableParams, CreateTableResult, TableGraph, NormalizedColumn, ColumnSpec, AccessFlags, OverlaySpec } from "./table";
|
|
21
|
+
export { createTable, projectTableGraph, buildColumnXml, normalizeColumns, resolveType, applyTableSaveOverlay, defaultAccessFlags, TYPE_MAP, DEFAULT_SUPER_CLASS, DEFAULT_SAVE_ACTION, addColumn, deriveElement, applyAddColumnOverlay } from "./table";
|
|
22
|
+
export type { CreateTableParams, CreateTableResult, AddColumnParams, AddColumnResult, TableGraph, NormalizedColumn, ColumnSpec, AccessFlags, OverlaySpec } from "./table";
|
package/dist/index.js
CHANGED
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
* REST API so every change lands in the target update set and scope.
|
|
7
7
|
*/
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
-
exports.DEFAULT_SAVE_ACTION = exports.DEFAULT_SUPER_CLASS = exports.TYPE_MAP = exports.defaultAccessFlags = exports.applyTableSaveOverlay = exports.resolveType = exports.normalizeColumns = exports.buildColumnXml = exports.projectTableGraph = exports.createTable = exports.WriteOrderError = exports.executeWritePlan = exports.topoSort = exports.generateSysId = exports.DEFAULT_RUN_FLOW_PATH = exports.testFlow = exports.editFlow = exports.buildPublishModel = exports.createFlow = exports.copyFlow = exports.publishFlow = exports.readActionType = exports.readFlow = exports.editActionType = exports.publishActionType = exports.triggerPublication = exports.cloneActionType = exports.cloneSubflow = exports.verifyArtifact = exports.listTemplates = exports.sincPlugin = exports.formatCreateViewResult = exports.formatLayoutResult = exports.setRelatedLists = exports.setFormLayout = exports.setListLayout = exports.createView = exports.formatAddChoicesResult = exports.addChoicesToField = exports.createClient = void 0;
|
|
9
|
+
exports.applyAddColumnOverlay = exports.deriveElement = exports.addColumn = exports.DEFAULT_SAVE_ACTION = exports.DEFAULT_SUPER_CLASS = exports.TYPE_MAP = exports.defaultAccessFlags = exports.applyTableSaveOverlay = exports.resolveType = exports.normalizeColumns = exports.buildColumnXml = exports.projectTableGraph = exports.createTable = exports.WriteOrderError = exports.executeWritePlan = exports.topoSort = exports.generateSysId = exports.DEFAULT_RUN_FLOW_PATH = exports.testFlow = exports.editFlow = exports.buildPublishModel = exports.createFlow = exports.copyFlow = exports.publishFlow = exports.readActionType = exports.readFlow = exports.editActionType = exports.publishActionType = exports.triggerPublication = exports.cloneActionType = exports.cloneSubflow = exports.verifyArtifact = exports.listTemplates = exports.sincPlugin = exports.formatCreateViewResult = exports.formatLayoutResult = exports.setRelatedLists = exports.setFormLayout = exports.setListLayout = exports.createView = exports.formatAddChoicesResult = exports.addChoicesToField = exports.resolveConfigFromEnvFile = exports.createClientFromEnvFile = exports.createClient = void 0;
|
|
10
10
|
var client_1 = require("./client");
|
|
11
11
|
Object.defineProperty(exports, "createClient", { enumerable: true, get: function () { return client_1.createClient; } });
|
|
12
|
+
var createClientFromEnvFile_1 = require("./createClientFromEnvFile");
|
|
13
|
+
Object.defineProperty(exports, "createClientFromEnvFile", { enumerable: true, get: function () { return createClientFromEnvFile_1.createClientFromEnvFile; } });
|
|
14
|
+
Object.defineProperty(exports, "resolveConfigFromEnvFile", { enumerable: true, get: function () { return createClientFromEnvFile_1.resolveConfigFromEnvFile; } });
|
|
12
15
|
var choices_1 = require("./choices");
|
|
13
16
|
Object.defineProperty(exports, "addChoicesToField", { enumerable: true, get: function () { return choices_1.addChoicesToField; } });
|
|
14
17
|
var formatter_1 = require("./formatter");
|
|
@@ -58,3 +61,6 @@ Object.defineProperty(exports, "defaultAccessFlags", { enumerable: true, get: fu
|
|
|
58
61
|
Object.defineProperty(exports, "TYPE_MAP", { enumerable: true, get: function () { return table_1.TYPE_MAP; } });
|
|
59
62
|
Object.defineProperty(exports, "DEFAULT_SUPER_CLASS", { enumerable: true, get: function () { return table_1.DEFAULT_SUPER_CLASS; } });
|
|
60
63
|
Object.defineProperty(exports, "DEFAULT_SAVE_ACTION", { enumerable: true, get: function () { return table_1.DEFAULT_SAVE_ACTION; } });
|
|
64
|
+
Object.defineProperty(exports, "addColumn", { enumerable: true, get: function () { return table_1.addColumn; } });
|
|
65
|
+
Object.defineProperty(exports, "deriveElement", { enumerable: true, get: function () { return table_1.deriveElement; } });
|
|
66
|
+
Object.defineProperty(exports, "applyAddColumnOverlay", { enumerable: true, get: function () { return table_1.applyAddColumnOverlay; } });
|
package/dist/mcp/registry.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
import type { ToolAnnotations } from "@tenonhq/dovetail-mcp-kit";
|
|
12
12
|
import type { ServiceNowClient } from "../client";
|
|
13
|
-
export declare var TOOL_NAMES: readonly ["create_view", "set_list_layout", "set_form_layout", "set_related_lists", "add_choices_to_field", "flow_view", "action_view", "flow_publish", "flow_copy", "flow_create", "flow_test", "flow_edit", "create_table"];
|
|
13
|
+
export declare var TOOL_NAMES: readonly ["create_view", "set_list_layout", "set_form_layout", "set_related_lists", "add_choices_to_field", "flow_view", "action_view", "flow_publish", "flow_copy", "flow_create", "flow_test", "flow_edit", "create_table", "add_column"];
|
|
14
14
|
export type ToolName = typeof TOOL_NAMES[number];
|
|
15
15
|
export interface RegistryDeps {
|
|
16
16
|
/** Optional client injection for tests; defaults to createClient({}). */
|
package/dist/mcp/registry.js
CHANGED
|
@@ -40,7 +40,8 @@ exports.TOOL_NAMES = [
|
|
|
40
40
|
"flow_create",
|
|
41
41
|
"flow_test",
|
|
42
42
|
"flow_edit",
|
|
43
|
-
"create_table"
|
|
43
|
+
"create_table",
|
|
44
|
+
"add_column"
|
|
44
45
|
];
|
|
45
46
|
// Annotation presets (READ_ONLY / WRITE_ADDITIVE_IDEMPOTENT / WRITE_CREATE /
|
|
46
47
|
// WRITE_OVERWRITE / WRITE_EXECUTE) come from @tenonhq/dovetail-mcp-kit.
|
|
@@ -263,6 +264,35 @@ function buildDescriptors(deps = {}) {
|
|
|
263
264
|
dryRun: p.dryRun
|
|
264
265
|
});
|
|
265
266
|
}
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
name: "add_column",
|
|
270
|
+
annotations: dovetail_mcp_kit_1.WRITE_CREATE,
|
|
271
|
+
description: "Add ONE column to an EXISTING ServiceNow table, headless and faithfully. Creating a column is a "
|
|
272
|
+
+ "sys_dictionary insert; a REST/createRecord insert reliably 500s for a scoped-app column. This "
|
|
273
|
+
+ "replays the Studio table-form save (POST /sys_db_object.do) against the existing table record, "
|
|
274
|
+
+ "embedding the new column as list-edit XML, then READS THE COLUMN BACK from sys_dictionary to prove "
|
|
275
|
+
+ "it landed (a 302 that did not create the field is reported failed, not created). table is the table "
|
|
276
|
+
+ "name or its sys_db_object sys_id; column is { label, type, name?, max_length?, reference? } with "
|
|
277
|
+
+ "friendly types mapped to internal types (string -> string_full_utf8); element is derived from label "
|
|
278
|
+
+ "unless column.name is given. dryRun:true returns the plan + column XML with no session and no writes. "
|
|
279
|
+
+ "NOTE: the live write path is pending a validated-live spike — prefer dryRun until confirmed, and "
|
|
280
|
+
+ "always verify the sys_update_xml landed in the intended update set.",
|
|
281
|
+
shape: schemas_1.addColumnSchema.shape,
|
|
282
|
+
handler: async function (args) {
|
|
283
|
+
var p = schemas_1.addColumnSchema.parse(args);
|
|
284
|
+
return (0, table_1.addColumn)({
|
|
285
|
+
client: client(),
|
|
286
|
+
table: p.table,
|
|
287
|
+
column: p.column,
|
|
288
|
+
scope: p.scope,
|
|
289
|
+
updateSetSysId: p.updateSetSysId,
|
|
290
|
+
saveActionSysId: p.saveActionSysId,
|
|
291
|
+
columnsRelId: p.columnsRelId,
|
|
292
|
+
dryRun: p.dryRun,
|
|
293
|
+
debug: p.debug
|
|
294
|
+
});
|
|
295
|
+
}
|
|
266
296
|
}
|
|
267
297
|
];
|
|
268
298
|
}
|
package/dist/mcp/schemas.d.ts
CHANGED
|
@@ -480,3 +480,61 @@ export declare var createTableSchema: z.ZodObject<{
|
|
|
480
480
|
saveActionSysId?: string | undefined;
|
|
481
481
|
columnsRelId?: string | undefined;
|
|
482
482
|
}>;
|
|
483
|
+
export declare var addColumnSchema: z.ZodObject<{
|
|
484
|
+
table: z.ZodString;
|
|
485
|
+
column: z.ZodObject<{
|
|
486
|
+
label: z.ZodString;
|
|
487
|
+
type: z.ZodString;
|
|
488
|
+
name: z.ZodOptional<z.ZodString>;
|
|
489
|
+
max_length: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodNumber]>>;
|
|
490
|
+
reference: z.ZodOptional<z.ZodString>;
|
|
491
|
+
}, "strip", z.ZodTypeAny, {
|
|
492
|
+
type: string;
|
|
493
|
+
label: string;
|
|
494
|
+
name?: string | undefined;
|
|
495
|
+
max_length?: string | number | undefined;
|
|
496
|
+
reference?: string | undefined;
|
|
497
|
+
}, {
|
|
498
|
+
type: string;
|
|
499
|
+
label: string;
|
|
500
|
+
name?: string | undefined;
|
|
501
|
+
max_length?: string | number | undefined;
|
|
502
|
+
reference?: string | undefined;
|
|
503
|
+
}>;
|
|
504
|
+
scope: z.ZodOptional<z.ZodString>;
|
|
505
|
+
updateSetSysId: z.ZodOptional<z.ZodString>;
|
|
506
|
+
saveActionSysId: z.ZodOptional<z.ZodString>;
|
|
507
|
+
columnsRelId: z.ZodOptional<z.ZodString>;
|
|
508
|
+
dryRun: z.ZodOptional<z.ZodBoolean>;
|
|
509
|
+
debug: z.ZodOptional<z.ZodBoolean>;
|
|
510
|
+
}, "strip", z.ZodTypeAny, {
|
|
511
|
+
table: string;
|
|
512
|
+
column: {
|
|
513
|
+
type: string;
|
|
514
|
+
label: string;
|
|
515
|
+
name?: string | undefined;
|
|
516
|
+
max_length?: string | number | undefined;
|
|
517
|
+
reference?: string | undefined;
|
|
518
|
+
};
|
|
519
|
+
dryRun?: boolean | undefined;
|
|
520
|
+
debug?: boolean | undefined;
|
|
521
|
+
scope?: string | undefined;
|
|
522
|
+
updateSetSysId?: string | undefined;
|
|
523
|
+
saveActionSysId?: string | undefined;
|
|
524
|
+
columnsRelId?: string | undefined;
|
|
525
|
+
}, {
|
|
526
|
+
table: string;
|
|
527
|
+
column: {
|
|
528
|
+
type: string;
|
|
529
|
+
label: string;
|
|
530
|
+
name?: string | undefined;
|
|
531
|
+
max_length?: string | number | undefined;
|
|
532
|
+
reference?: string | undefined;
|
|
533
|
+
};
|
|
534
|
+
dryRun?: boolean | undefined;
|
|
535
|
+
debug?: boolean | undefined;
|
|
536
|
+
scope?: string | undefined;
|
|
537
|
+
updateSetSysId?: string | undefined;
|
|
538
|
+
saveActionSysId?: string | undefined;
|
|
539
|
+
columnsRelId?: string | undefined;
|
|
540
|
+
}>;
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* own file so registry.ts stays focused on wiring.
|
|
5
5
|
*/
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
-
exports.createTableSchema = exports.columnSpecSchema = exports.editFlowSchema = exports.stepInputPatchSchema = exports.testFlowSchema = exports.createFlowSchema = exports.copyFlowSchema = exports.publishFlowSchema = exports.viewActionSchema = exports.viewFlowSchema = exports.addChoicesToFieldSchema = exports.choiceValueSchema = exports.setRelatedListsSchema = exports.setFormLayoutSchema = exports.formSectionSchema = exports.setListLayoutSchema = exports.createViewSchema = void 0;
|
|
7
|
+
exports.addColumnSchema = exports.createTableSchema = exports.columnSpecSchema = exports.editFlowSchema = exports.stepInputPatchSchema = exports.testFlowSchema = exports.createFlowSchema = exports.copyFlowSchema = exports.publishFlowSchema = exports.viewActionSchema = exports.viewFlowSchema = exports.addChoicesToFieldSchema = exports.choiceValueSchema = exports.setRelatedListsSchema = exports.setFormLayoutSchema = exports.formSectionSchema = exports.setListLayoutSchema = exports.createViewSchema = void 0;
|
|
8
8
|
const zod_1 = require("zod");
|
|
9
9
|
exports.createViewSchema = zod_1.z.object({
|
|
10
10
|
name: zod_1.z.string().min(1),
|
|
@@ -137,3 +137,13 @@ exports.createTableSchema = zod_1.z.object({
|
|
|
137
137
|
dryRun: zod_1.z.boolean().optional(),
|
|
138
138
|
debug: zod_1.z.boolean().optional()
|
|
139
139
|
});
|
|
140
|
+
exports.addColumnSchema = zod_1.z.object({
|
|
141
|
+
table: zod_1.z.string().min(1),
|
|
142
|
+
column: exports.columnSpecSchema,
|
|
143
|
+
scope: zod_1.z.string().optional(),
|
|
144
|
+
updateSetSysId: zod_1.z.string().optional(),
|
|
145
|
+
saveActionSysId: zod_1.z.string().optional(),
|
|
146
|
+
columnsRelId: zod_1.z.string().optional(),
|
|
147
|
+
dryRun: zod_1.z.boolean().optional(),
|
|
148
|
+
debug: zod_1.z.boolean().optional()
|
|
149
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Add ONE column to an EXISTING ServiceNow table — headless, the faithful way.
|
|
3
|
+
* Creating a column is inserting a `sys_dictionary` record; a REST / Dovetail
|
|
4
|
+
* createRecord insert into sys_dictionary reliably 500s for a scoped-app column
|
|
5
|
+
* (scope-policy + the dictionary engine being wired to the form transaction, not
|
|
6
|
+
* the REST insert). The faithful path is the one the Studio UI drives: a single
|
|
7
|
+
* `POST /sys_db_object.do` against the EXISTING table record whose body embeds the
|
|
8
|
+
* new column as the same list-edit XML blob create-table uses.
|
|
9
|
+
*
|
|
10
|
+
* This REUSES the create-table machinery wholesale (buildColumnXml, formSession,
|
|
11
|
+
* the list-edit key + type normalization) and differs in exactly two ways:
|
|
12
|
+
* 1. It GETs the EXISTING table form (sys_id=<tableSysId>), which renders the
|
|
13
|
+
* "Columns" related list — so the real listEditKey is harvested, not faked.
|
|
14
|
+
* 2. It overlays ONLY the column XML + the save machinery, preserving every
|
|
15
|
+
* existing table field from the harvest (name/label/scope/ACLs untouched).
|
|
16
|
+
* Then it READS THE COLUMN BACK from sys_dictionary to prove it landed — a write
|
|
17
|
+
* that 302s but didn't create the field is reported as failed, never "created".
|
|
18
|
+
*
|
|
19
|
+
* Validated live 2026-06-19 on tenonworkstudio (a smoke-test scoped table): the
|
|
20
|
+
* form save creates the column (HTTP 200 re-render, not a 302), the before/after
|
|
21
|
+
* sys_dictionary diff confirms it, and a scoped insert into the new column
|
|
22
|
+
* round-trips — proving the physical column, not just the dictionary row. ES6
|
|
23
|
+
* only, no optional chaining, no `any`.
|
|
24
|
+
*/
|
|
25
|
+
import type { ServiceNowClient } from "../client";
|
|
26
|
+
import { ColumnSpec } from "./buildTableSave";
|
|
27
|
+
export interface AddColumnParams {
|
|
28
|
+
/** REST client for table/scope resolution + the read-back verify. */
|
|
29
|
+
client: ServiceNowClient;
|
|
30
|
+
/** Existing table — its name ("x_cadso_journey") OR its sys_db_object sys_id. */
|
|
31
|
+
table: string;
|
|
32
|
+
/** The single column to add. `name` (the element) is optional; derived from label when omitted. */
|
|
33
|
+
column: ColumnSpec;
|
|
34
|
+
/** Scope name or sys_scope sys_id to run the form session in. Defaults to the table's own scope. */
|
|
35
|
+
scope?: string;
|
|
36
|
+
/** Update set sys_id to pin the writes to. Strongly recommended. */
|
|
37
|
+
updateSetSysId?: string;
|
|
38
|
+
/** Override the Save UI-action sys_id (defaults to the well-known global Save). */
|
|
39
|
+
saveActionSysId?: string;
|
|
40
|
+
/** Override the "Table Columns" relationship sys_id (fallback only — the existing form normally yields it). */
|
|
41
|
+
columnsRelId?: string;
|
|
42
|
+
/** Emit diagnostic detail in the result note. */
|
|
43
|
+
debug?: boolean;
|
|
44
|
+
/** Instance/creds for the form session (default: env, same precedence as the client). */
|
|
45
|
+
instance?: string;
|
|
46
|
+
user?: string;
|
|
47
|
+
password?: string;
|
|
48
|
+
/** Plan only — no session, no writes. Pure + deterministic. */
|
|
49
|
+
dryRun?: boolean;
|
|
50
|
+
}
|
|
51
|
+
export interface AddColumnResult {
|
|
52
|
+
status: "created" | "dry-run" | "failed";
|
|
53
|
+
/** The table's name (resolved; echoes the input on dry-run). */
|
|
54
|
+
table: string;
|
|
55
|
+
/** sys_db_object sys_id ("" on dry-run / when unresolved). */
|
|
56
|
+
tableSysId: string;
|
|
57
|
+
/** The dictionary element (column name) — derived from label or the explicit `name`. */
|
|
58
|
+
element: string;
|
|
59
|
+
/** The column's display label. */
|
|
60
|
+
label: string;
|
|
61
|
+
/** Resolved ServiceNow internal_type (friendly -> internal). */
|
|
62
|
+
internalType: string;
|
|
63
|
+
/** Client-generated sys_id sent for the new sys_dictionary row. */
|
|
64
|
+
columnSysId: string;
|
|
65
|
+
/** Update set the write was pinned to ("" if none). */
|
|
66
|
+
updateSetSysId: string;
|
|
67
|
+
httpStatus: number;
|
|
68
|
+
/** 302 Location on a successful POST. */
|
|
69
|
+
location: string;
|
|
70
|
+
/** The embedded column XML (always populated — it is pure). */
|
|
71
|
+
columnXml: string;
|
|
72
|
+
/** True only when the column was READ BACK from sys_dictionary after the POST. */
|
|
73
|
+
verified: boolean;
|
|
74
|
+
/** Human-readable note (success summary, the read-back result, or the failure body). */
|
|
75
|
+
note: string;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Derive the dictionary element (column name) from a label the way Studio does for
|
|
79
|
+
* a scoped table: lower-case, every run of non-alphanumerics -> a single
|
|
80
|
+
* underscore, trimmed. Scoped custom columns are NOT u_-prefixed (that is a
|
|
81
|
+
* global-scope-on-OOB-table behaviour), so "URL" -> "url". Pass `column.name`
|
|
82
|
+
* explicitly for anything non-trivial.
|
|
83
|
+
*/
|
|
84
|
+
export declare function deriveElement(label: string, explicit?: string): string;
|
|
85
|
+
/**
|
|
86
|
+
* Overlay ONLY the new-column list-edit XML and the form-save machinery onto the
|
|
87
|
+
* fields harvested from the EXISTING table form. Returns a NEW object; every
|
|
88
|
+
* existing table field (name, label, scope, access, super_class, …) is preserved
|
|
89
|
+
* from `base` untouched — an add-column must not re-stamp the table. Mirrors the
|
|
90
|
+
* harvested-defaults-preserved contract of applyTableSaveOverlay.
|
|
91
|
+
*/
|
|
92
|
+
export declare function applyAddColumnOverlay(base: Record<string, string>, o: {
|
|
93
|
+
tableSysId: string;
|
|
94
|
+
saveActionSysId: string;
|
|
95
|
+
listEditKey: string;
|
|
96
|
+
columnXml: string;
|
|
97
|
+
}): Record<string, string>;
|
|
98
|
+
export declare function addColumn(params: AddColumnParams): Promise<AddColumnResult>;
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Add ONE column to an EXISTING ServiceNow table — headless, the faithful way.
|
|
4
|
+
* Creating a column is inserting a `sys_dictionary` record; a REST / Dovetail
|
|
5
|
+
* createRecord insert into sys_dictionary reliably 500s for a scoped-app column
|
|
6
|
+
* (scope-policy + the dictionary engine being wired to the form transaction, not
|
|
7
|
+
* the REST insert). The faithful path is the one the Studio UI drives: a single
|
|
8
|
+
* `POST /sys_db_object.do` against the EXISTING table record whose body embeds the
|
|
9
|
+
* new column as the same list-edit XML blob create-table uses.
|
|
10
|
+
*
|
|
11
|
+
* This REUSES the create-table machinery wholesale (buildColumnXml, formSession,
|
|
12
|
+
* the list-edit key + type normalization) and differs in exactly two ways:
|
|
13
|
+
* 1. It GETs the EXISTING table form (sys_id=<tableSysId>), which renders the
|
|
14
|
+
* "Columns" related list — so the real listEditKey is harvested, not faked.
|
|
15
|
+
* 2. It overlays ONLY the column XML + the save machinery, preserving every
|
|
16
|
+
* existing table field from the harvest (name/label/scope/ACLs untouched).
|
|
17
|
+
* Then it READS THE COLUMN BACK from sys_dictionary to prove it landed — a write
|
|
18
|
+
* that 302s but didn't create the field is reported as failed, never "created".
|
|
19
|
+
*
|
|
20
|
+
* Validated live 2026-06-19 on tenonworkstudio (a smoke-test scoped table): the
|
|
21
|
+
* form save creates the column (HTTP 200 re-render, not a 302), the before/after
|
|
22
|
+
* sys_dictionary diff confirms it, and a scoped insert into the new column
|
|
23
|
+
* round-trips — proving the physical column, not just the dictionary row. ES6
|
|
24
|
+
* only, no optional chaining, no `any`.
|
|
25
|
+
*/
|
|
26
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
27
|
+
if (k2 === undefined) k2 = k;
|
|
28
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
29
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
30
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
31
|
+
}
|
|
32
|
+
Object.defineProperty(o, k2, desc);
|
|
33
|
+
}) : (function(o, m, k, k2) {
|
|
34
|
+
if (k2 === undefined) k2 = k;
|
|
35
|
+
o[k2] = m[k];
|
|
36
|
+
}));
|
|
37
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
38
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
39
|
+
}) : function(o, v) {
|
|
40
|
+
o["default"] = v;
|
|
41
|
+
});
|
|
42
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
43
|
+
var ownKeys = function(o) {
|
|
44
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
45
|
+
var ar = [];
|
|
46
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
47
|
+
return ar;
|
|
48
|
+
};
|
|
49
|
+
return ownKeys(o);
|
|
50
|
+
};
|
|
51
|
+
return function (mod) {
|
|
52
|
+
if (mod && mod.__esModule) return mod;
|
|
53
|
+
var result = {};
|
|
54
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
55
|
+
__setModuleDefault(result, mod);
|
|
56
|
+
return result;
|
|
57
|
+
};
|
|
58
|
+
})();
|
|
59
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
60
|
+
exports.deriveElement = deriveElement;
|
|
61
|
+
exports.applyAddColumnOverlay = applyAddColumnOverlay;
|
|
62
|
+
exports.addColumn = addColumn;
|
|
63
|
+
const crypto = __importStar(require("crypto"));
|
|
64
|
+
const buildColumnXml_1 = require("./buildColumnXml");
|
|
65
|
+
const buildTableSave_1 = require("./buildTableSave");
|
|
66
|
+
const formSession_1 = require("./formSession");
|
|
67
|
+
const createTable_1 = require("./createTable");
|
|
68
|
+
var SYS_ID = /^[0-9a-f]{32}$/i;
|
|
69
|
+
/**
|
|
70
|
+
* Read a ServiceNow Table API field value. Reference fields (e.g. sys_scope) come
|
|
71
|
+
* back as a { link, value } object — the API includes the reference link unless
|
|
72
|
+
* excluded — so a bare String() would yield "[object Object]". Returns the
|
|
73
|
+
* sys_id/value for a reference object, the string otherwise.
|
|
74
|
+
*/
|
|
75
|
+
function fieldValue(v) {
|
|
76
|
+
if (v && typeof v === "object") {
|
|
77
|
+
var o = v;
|
|
78
|
+
return o.value === undefined || o.value === null ? "" : String(o.value);
|
|
79
|
+
}
|
|
80
|
+
return v === undefined || v === null ? "" : String(v);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Derive the dictionary element (column name) from a label the way Studio does for
|
|
84
|
+
* a scoped table: lower-case, every run of non-alphanumerics -> a single
|
|
85
|
+
* underscore, trimmed. Scoped custom columns are NOT u_-prefixed (that is a
|
|
86
|
+
* global-scope-on-OOB-table behaviour), so "URL" -> "url". Pass `column.name`
|
|
87
|
+
* explicitly for anything non-trivial.
|
|
88
|
+
*/
|
|
89
|
+
function deriveElement(label, explicit) {
|
|
90
|
+
if (explicit && explicit.trim())
|
|
91
|
+
return explicit.trim();
|
|
92
|
+
// Single linear pass: lower-case, collapse each run of non-alphanumerics to one
|
|
93
|
+
// underscore, then trim leading/trailing underscores. Deliberately NOT a regex —
|
|
94
|
+
// an anchored-quantifier trim (/^_+|_+$/) is a polynomial-ReDoS on attacker-shaped
|
|
95
|
+
// input (CodeQL js/polynomial-redos); a char scan is O(n).
|
|
96
|
+
var lower = String(label || "").toLowerCase();
|
|
97
|
+
var collapsed = "";
|
|
98
|
+
var prevUnderscore = false;
|
|
99
|
+
for (var i = 0; i < lower.length; i += 1) {
|
|
100
|
+
var ch = lower.charAt(i);
|
|
101
|
+
var isAlnum = (ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9");
|
|
102
|
+
if (isAlnum) {
|
|
103
|
+
collapsed += ch;
|
|
104
|
+
prevUnderscore = false;
|
|
105
|
+
}
|
|
106
|
+
else if (!prevUnderscore) {
|
|
107
|
+
collapsed += "_";
|
|
108
|
+
prevUnderscore = true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
var start = 0;
|
|
112
|
+
var end = collapsed.length;
|
|
113
|
+
while (start < end && collapsed.charAt(start) === "_")
|
|
114
|
+
start += 1;
|
|
115
|
+
while (end > start && collapsed.charAt(end - 1) === "_")
|
|
116
|
+
end -= 1;
|
|
117
|
+
var e = collapsed.slice(start, end);
|
|
118
|
+
if (!e)
|
|
119
|
+
throw new Error("addColumn: cannot derive a column name from label '" +
|
|
120
|
+
label +
|
|
121
|
+
"' — pass column.name.");
|
|
122
|
+
return e;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Overlay ONLY the new-column list-edit XML and the form-save machinery onto the
|
|
126
|
+
* fields harvested from the EXISTING table form. Returns a NEW object; every
|
|
127
|
+
* existing table field (name, label, scope, access, super_class, …) is preserved
|
|
128
|
+
* from `base` untouched — an add-column must not re-stamp the table. Mirrors the
|
|
129
|
+
* harvested-defaults-preserved contract of applyTableSaveOverlay.
|
|
130
|
+
*/
|
|
131
|
+
function applyAddColumnOverlay(base, o) {
|
|
132
|
+
var f = Object.assign({}, base);
|
|
133
|
+
f["sys_target"] = "sys_db_object";
|
|
134
|
+
f["sys_uniqueName"] = "sys_id";
|
|
135
|
+
f["sys_uniqueValue"] = o.tableSysId;
|
|
136
|
+
f["sys_row"] = o.tableSysId;
|
|
137
|
+
f["sys_action"] = o.saveActionSysId;
|
|
138
|
+
f["isFormPage"] = "true";
|
|
139
|
+
f["sysparm_modify_check"] = "true";
|
|
140
|
+
f["personalizer_sys_db_object"] = "true";
|
|
141
|
+
if (o.listEditKey) {
|
|
142
|
+
f[o.listEditKey] = o.columnXml;
|
|
143
|
+
}
|
|
144
|
+
return f;
|
|
145
|
+
}
|
|
146
|
+
function newSysId() {
|
|
147
|
+
return crypto.randomBytes(16).toString("hex");
|
|
148
|
+
}
|
|
149
|
+
function validate(params) {
|
|
150
|
+
if (!params || typeof params !== "object")
|
|
151
|
+
throw new Error("addColumn: params object required.");
|
|
152
|
+
if (!params.client)
|
|
153
|
+
throw new Error("addColumn: client is required.");
|
|
154
|
+
if (!params.table || !String(params.table).trim())
|
|
155
|
+
throw new Error("addColumn: table is required.");
|
|
156
|
+
if (!params.column || typeof params.column !== "object")
|
|
157
|
+
throw new Error("addColumn: column is required.");
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Map every existing column on a table to its internal_type, via sys_dictionary.
|
|
161
|
+
* Used to snapshot before/after the save so the new column is found by diff —
|
|
162
|
+
* robust against ServiceNow's server-side element derivation (trailing-digit
|
|
163
|
+
* underscoring, cross-scope prefixing) that makes predicting the element brittle.
|
|
164
|
+
*/
|
|
165
|
+
async function elementTypeMap(client, tableName) {
|
|
166
|
+
var rows = await client.table.query("sys_dictionary", "name=" + tableName + "^element!=NULL", 1000);
|
|
167
|
+
var map = {};
|
|
168
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
169
|
+
var el = fieldValue(rows[i].element);
|
|
170
|
+
if (el)
|
|
171
|
+
map[el] = fieldValue(rows[i].internal_type);
|
|
172
|
+
}
|
|
173
|
+
return map;
|
|
174
|
+
}
|
|
175
|
+
/** Resolve the table by name or sys_id; returns its name, sys_id, and sys_scope. */
|
|
176
|
+
async function resolveTable(client, table) {
|
|
177
|
+
var query = SYS_ID.test(table) ? "sys_id=" + table : "name=" + table;
|
|
178
|
+
var rows = await client.table.query("sys_db_object", query, 1);
|
|
179
|
+
if (rows.length === 0) {
|
|
180
|
+
throw new Error("addColumn: table '" + table + "' not found in sys_db_object.");
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
name: fieldValue(rows[0].name) || table,
|
|
184
|
+
sysId: fieldValue(rows[0].sys_id),
|
|
185
|
+
scopeSysId: fieldValue(rows[0].sys_scope),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/** Resolve a scope name-or-sysid to the sys_app sys_id used by setCurrentApplication. */
|
|
189
|
+
async function resolveAppSysId(client, scope) {
|
|
190
|
+
if (!scope)
|
|
191
|
+
return "";
|
|
192
|
+
var query = SYS_ID.test(scope) ? "sys_id=" + scope : "scope=" + scope;
|
|
193
|
+
var apps = await client.table.query("sys_app", query, 1);
|
|
194
|
+
return apps.length > 0 ? fieldValue(apps[0].sys_id) : "";
|
|
195
|
+
}
|
|
196
|
+
async function addColumn(params) {
|
|
197
|
+
validate(params);
|
|
198
|
+
var client = params.client;
|
|
199
|
+
// Normalize the single column (validates type, reference target, dedup is trivial).
|
|
200
|
+
var normalized = (0, buildTableSave_1.normalizeColumns)([params.column]);
|
|
201
|
+
var col = normalized[0];
|
|
202
|
+
var element = deriveElement(col.label, params.column.name);
|
|
203
|
+
var columnSysId = newSysId();
|
|
204
|
+
// The column XML is pure — build it now (used by dry-run AND the live POST).
|
|
205
|
+
var columnXml = (0, buildColumnXml_1.buildColumnXml)(normalized, [columnSysId]);
|
|
206
|
+
if (params.dryRun) {
|
|
207
|
+
return {
|
|
208
|
+
status: "dry-run",
|
|
209
|
+
table: params.table,
|
|
210
|
+
tableSysId: SYS_ID.test(params.table) ? params.table : "",
|
|
211
|
+
element: element,
|
|
212
|
+
label: col.label,
|
|
213
|
+
internalType: col.type,
|
|
214
|
+
columnSysId: columnSysId,
|
|
215
|
+
updateSetSysId: params.updateSetSysId ? params.updateSetSysId : "",
|
|
216
|
+
httpStatus: 0,
|
|
217
|
+
location: "",
|
|
218
|
+
columnXml: columnXml,
|
|
219
|
+
verified: false,
|
|
220
|
+
note: "dry-run: no session opened, no writes. Would add column '" +
|
|
221
|
+
element +
|
|
222
|
+
"' (" +
|
|
223
|
+
col.type +
|
|
224
|
+
") to '" +
|
|
225
|
+
params.table +
|
|
226
|
+
"' via the table form save, then read it back from sys_dictionary.",
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
// ---- LIVE PATH (NOT YET VALIDATED END-TO-END) -----------------------------
|
|
230
|
+
// Resolve the existing table + its scope.
|
|
231
|
+
var resolved = await resolveTable(client, params.table);
|
|
232
|
+
var scopeForApp = params.scope && params.scope.trim()
|
|
233
|
+
? params.scope.trim()
|
|
234
|
+
: resolved.scopeSysId;
|
|
235
|
+
var appSysId = await resolveAppSysId(client, scopeForApp);
|
|
236
|
+
var saveAction = params.saveActionSysId && params.saveActionSysId.trim()
|
|
237
|
+
? params.saveActionSysId.trim()
|
|
238
|
+
: createTable_1.DEFAULT_SAVE_ACTION;
|
|
239
|
+
// Open the form session and put it in the table's scope before the save.
|
|
240
|
+
var auth = (0, formSession_1.resolveFormAuth)({
|
|
241
|
+
instance: params.instance,
|
|
242
|
+
user: params.user,
|
|
243
|
+
password: params.password,
|
|
244
|
+
});
|
|
245
|
+
var session = await (0, formSession_1.openFormSession)(auth);
|
|
246
|
+
var appSwitch = { ok: false, status: 0, body: "no app resolved" };
|
|
247
|
+
if (appSysId) {
|
|
248
|
+
appSwitch = await (0, formSession_1.setCurrentApplication)(auth, session, appSysId);
|
|
249
|
+
}
|
|
250
|
+
if (params.updateSetSysId) {
|
|
251
|
+
try {
|
|
252
|
+
await client.claude.changeUpdateSet({ sysId: params.updateSetSysId });
|
|
253
|
+
}
|
|
254
|
+
catch (e) {
|
|
255
|
+
/* best-effort */
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// Harvest the EXISTING table form — its rendered related list yields the real key.
|
|
259
|
+
var harvest = await (0, formSession_1.getRecordForm)(auth, session, resolved.sysId);
|
|
260
|
+
var relId = params.columnsRelId && params.columnsRelId.trim()
|
|
261
|
+
? params.columnsRelId.trim()
|
|
262
|
+
: createTable_1.DEFAULT_COLUMNS_REL_ID;
|
|
263
|
+
var colKey = harvest.listEditKey ? harvest.listEditKey : (0, buildTableSave_1.listEditKey)(relId);
|
|
264
|
+
var fields = applyAddColumnOverlay(harvest.fields, {
|
|
265
|
+
tableSysId: resolved.sysId,
|
|
266
|
+
saveActionSysId: saveAction,
|
|
267
|
+
listEditKey: colKey,
|
|
268
|
+
columnXml: columnXml,
|
|
269
|
+
});
|
|
270
|
+
// Snapshot the table's columns BEFORE the save so the new column is found by diff.
|
|
271
|
+
// ServiceNow derives the element from the column label server-side (the element
|
|
272
|
+
// field is sent empty), and normalizes it — a trailing digit gains an underscore,
|
|
273
|
+
// a cross-scope add gets a scope prefix — so predicting the element name is
|
|
274
|
+
// unreliable. The diff reports whatever element SN actually assigned.
|
|
275
|
+
var beforeMap = await elementTypeMap(client, resolved.name);
|
|
276
|
+
var resp = await (0, formSession_1.postForm)(auth, session, "/sys_db_object.do", fields);
|
|
277
|
+
// A form save's HTTP status does NOT prove the column landed: an EXISTING-record
|
|
278
|
+
// save re-renders the form with 200 on success (only a NEW record 302s to its
|
|
279
|
+
// assigned sys_id). So the sys_dictionary diff is the source of truth; only a hard
|
|
280
|
+
// HTTP error (4xx/5xx) skips it.
|
|
281
|
+
var hardError = resp.status >= 400;
|
|
282
|
+
var verified = false;
|
|
283
|
+
var actualElement = "";
|
|
284
|
+
var readBackType = "";
|
|
285
|
+
var added = [];
|
|
286
|
+
if (!hardError) {
|
|
287
|
+
var afterMap = await elementTypeMap(client, resolved.name);
|
|
288
|
+
added = Object.keys(afterMap).filter(function (e) {
|
|
289
|
+
return !Object.prototype.hasOwnProperty.call(beforeMap, e);
|
|
290
|
+
});
|
|
291
|
+
if (added.length > 0) {
|
|
292
|
+
verified = true;
|
|
293
|
+
// Prefer the new element that matches our derived guess; else take the first.
|
|
294
|
+
var exact = added.filter(function (e) {
|
|
295
|
+
return e === element || e.indexOf(element) !== -1;
|
|
296
|
+
});
|
|
297
|
+
actualElement = exact.length > 0 ? exact[0] : added[0];
|
|
298
|
+
readBackType = afterMap[actualElement];
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
var reportElement = actualElement || element;
|
|
302
|
+
var status = verified ? "created" : "failed";
|
|
303
|
+
var note;
|
|
304
|
+
if (status === "created") {
|
|
305
|
+
note =
|
|
306
|
+
"Added column '" +
|
|
307
|
+
reportElement +
|
|
308
|
+
"' (" +
|
|
309
|
+
col.type +
|
|
310
|
+
") to " +
|
|
311
|
+
resolved.name +
|
|
312
|
+
" — verified present in sys_dictionary (HTTP " +
|
|
313
|
+
resp.status +
|
|
314
|
+
")" +
|
|
315
|
+
(actualElement && actualElement !== element
|
|
316
|
+
? " (NOTE: ServiceNow assigned element '" +
|
|
317
|
+
actualElement +
|
|
318
|
+
"', not the requested '" +
|
|
319
|
+
element +
|
|
320
|
+
"')"
|
|
321
|
+
: "") +
|
|
322
|
+
(readBackType && readBackType !== col.type
|
|
323
|
+
? " (NOTE: internal_type read back as '" + readBackType + "')"
|
|
324
|
+
: "") +
|
|
325
|
+
(added.length > 1
|
|
326
|
+
? " (NOTE: " +
|
|
327
|
+
added.length +
|
|
328
|
+
" new columns appeared: " +
|
|
329
|
+
added.join(", ") +
|
|
330
|
+
")"
|
|
331
|
+
: "") +
|
|
332
|
+
". Confirm a scoped insert + the sys_update_xml landed in the update set.";
|
|
333
|
+
}
|
|
334
|
+
else if (!hardError) {
|
|
335
|
+
note =
|
|
336
|
+
"save POST returned " +
|
|
337
|
+
resp.status +
|
|
338
|
+
" but no new column appeared on " +
|
|
339
|
+
resolved.name +
|
|
340
|
+
" — the form likely rejected the column. " +
|
|
341
|
+
resp.body.slice(0, 300);
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
note =
|
|
345
|
+
"save POST returned " +
|
|
346
|
+
resp.status +
|
|
347
|
+
" (HTTP error). " +
|
|
348
|
+
resp.body.slice(0, 300);
|
|
349
|
+
}
|
|
350
|
+
if (params.debug) {
|
|
351
|
+
note +=
|
|
352
|
+
" [debug: appSwitch=" +
|
|
353
|
+
appSwitch.status +
|
|
354
|
+
(appSwitch.ok ? "/ok" : "/FAIL:" + appSwitch.body) +
|
|
355
|
+
" appSysId=" +
|
|
356
|
+
(appSysId || "(none)") +
|
|
357
|
+
" harvestedListEditKey=" +
|
|
358
|
+
(harvest.listEditKey ? "yes" : "no") +
|
|
359
|
+
" colKey=" +
|
|
360
|
+
colKey +
|
|
361
|
+
" fieldCount=" +
|
|
362
|
+
Object.keys(fields).length +
|
|
363
|
+
" location=" +
|
|
364
|
+
resp.location +
|
|
365
|
+
" readBackType=" +
|
|
366
|
+
(readBackType || "(none)") +
|
|
367
|
+
"]";
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
status: status,
|
|
371
|
+
table: resolved.name,
|
|
372
|
+
tableSysId: resolved.sysId,
|
|
373
|
+
element: reportElement,
|
|
374
|
+
label: col.label,
|
|
375
|
+
internalType: col.type,
|
|
376
|
+
columnSysId: columnSysId,
|
|
377
|
+
updateSetSysId: params.updateSetSysId ? params.updateSetSysId : "",
|
|
378
|
+
httpStatus: resp.status,
|
|
379
|
+
location: resp.location,
|
|
380
|
+
columnXml: columnXml,
|
|
381
|
+
verified: verified,
|
|
382
|
+
note: note,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
@@ -53,12 +53,24 @@ export interface HarvestedForm {
|
|
|
53
53
|
listEditKey: string;
|
|
54
54
|
}
|
|
55
55
|
/**
|
|
56
|
-
* GET
|
|
57
|
-
* would submit (sysparm_ck, sysparm_encoded_record, the dynamic
|
|
58
|
-
* field, every sys_original.* default)
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
56
|
+
* GET a sys_db_object form (record `sysId`) and harvest every hidden/input field
|
|
57
|
+
* the browser would submit (sysparm_ck, sysparm_encoded_record, the dynamic
|
|
58
|
+
* `<sysid>_text` field, every sys_original.* default) plus the discovered list-edit
|
|
59
|
+
* key. The caller overlays capability params onto `fields` before POSTing.
|
|
60
|
+
*
|
|
61
|
+
* `sysId = "-1"` is the new-record form (table create). An EXISTING record's form
|
|
62
|
+
* renders the "Columns" related list inline, so for add-column the real
|
|
63
|
+
* `ListEditFormatterAction[sys_db_object.REL:<relId>]` key IS present and harvested
|
|
64
|
+
* here — unlike the new-record form, which renders no related lists (listEditKey
|
|
65
|
+
* empty, the create-table caller falls back to the constant relId). Validated live
|
|
66
|
+
* 2026-06-13 for the new-record path.
|
|
67
|
+
*/
|
|
68
|
+
export declare function getRecordForm(auth: FormAuth, session: FormSession, sysId: string): Promise<HarvestedForm>;
|
|
69
|
+
/**
|
|
70
|
+
* GET the new-record (sys_id=-1) sys_db_object form. Thin wrapper over getRecordForm
|
|
71
|
+
* preserved for the create-table caller. The new-record form renders no related
|
|
72
|
+
* lists, so `listEditKey` is normally empty — the caller falls back to the constant
|
|
73
|
+
* "Table Columns" relId. Validated live 2026-06-13.
|
|
62
74
|
*/
|
|
63
75
|
export declare function getNewRecordForm(auth: FormAuth, session: FormSession): Promise<HarvestedForm>;
|
|
64
76
|
/**
|
|
@@ -17,34 +17,54 @@ exports.resolveFormAuth = resolveFormAuth;
|
|
|
17
17
|
exports.scrapeCk = scrapeCk;
|
|
18
18
|
exports.openFormSession = openFormSession;
|
|
19
19
|
exports.setCurrentApplication = setCurrentApplication;
|
|
20
|
+
exports.getRecordForm = getRecordForm;
|
|
20
21
|
exports.getNewRecordForm = getNewRecordForm;
|
|
21
22
|
exports.parseFormInputs = parseFormInputs;
|
|
22
23
|
exports.postForm = postForm;
|
|
23
24
|
/** Env precedence mirrors client.ts: explicit > SN_* > SN_DEV_* > SN_PROD_*. */
|
|
24
25
|
function resolveFormAuth(cfg) {
|
|
25
26
|
var c = cfg || {};
|
|
26
|
-
var rawHost = c.instance
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
var rawHost = c.instance ||
|
|
28
|
+
process.env.SN_INSTANCE ||
|
|
29
|
+
process.env.SN_DEV_INSTANCE ||
|
|
30
|
+
process.env.SN_PROD_INSTANCE ||
|
|
31
|
+
"";
|
|
31
32
|
if (!rawHost) {
|
|
32
33
|
throw new Error("ServiceNow instance not configured. Set SN_INSTANCE or pass { instance }.");
|
|
33
34
|
}
|
|
34
|
-
|
|
35
|
+
// Strip the scheme, then trim trailing slashes by index. NOT /\/+$/ — an
|
|
36
|
+
// anchored one-or-more quantifier is a polynomial-ReDoS on a host with many
|
|
37
|
+
// trailing slashes (CodeQL js/polynomial-redos); the index walk is O(n).
|
|
38
|
+
var host = rawHost.replace(/^https?:\/\//, "");
|
|
39
|
+
var hostEnd = host.length;
|
|
40
|
+
while (hostEnd > 0 && host.charAt(hostEnd - 1) === "/")
|
|
41
|
+
hostEnd -= 1;
|
|
42
|
+
host = host.slice(0, hostEnd);
|
|
35
43
|
if (host.indexOf(".") === -1)
|
|
36
44
|
host = host.toLowerCase() + ".service-now.com";
|
|
37
|
-
var user = c.user ||
|
|
38
|
-
|
|
45
|
+
var user = c.user ||
|
|
46
|
+
process.env.SN_USER ||
|
|
47
|
+
process.env.SN_DEV_USERNAME ||
|
|
48
|
+
process.env.SN_PROD_USERNAME ||
|
|
49
|
+
"";
|
|
50
|
+
var password = c.password ||
|
|
51
|
+
process.env.SN_PASSWORD ||
|
|
52
|
+
process.env.SN_DEV_PASSWORD ||
|
|
53
|
+
process.env.SN_PROD_PASSWORD ||
|
|
54
|
+
"";
|
|
39
55
|
if (!user || !password) {
|
|
40
56
|
throw new Error("ServiceNow credentials missing — set SN_USER/SN_PASSWORD (or SN_DEV_*/SN_PROD_*).");
|
|
41
57
|
}
|
|
42
58
|
return { host: host, user: user, password: password };
|
|
43
59
|
}
|
|
44
|
-
function base(auth) {
|
|
60
|
+
function base(auth) {
|
|
61
|
+
return "https://" + auth.host;
|
|
62
|
+
}
|
|
45
63
|
function jarFrom(res, jar) {
|
|
46
64
|
var anyHeaders = res.headers;
|
|
47
|
-
var setCookies = typeof anyHeaders.getSetCookie === "function"
|
|
65
|
+
var setCookies = typeof anyHeaders.getSetCookie === "function"
|
|
66
|
+
? anyHeaders.getSetCookie()
|
|
67
|
+
: [];
|
|
48
68
|
for (var i = 0; i < setCookies.length; i += 1) {
|
|
49
69
|
var kv = setCookies[i].split(";")[0];
|
|
50
70
|
var eq = kv.indexOf("=");
|
|
@@ -54,40 +74,60 @@ function jarFrom(res, jar) {
|
|
|
54
74
|
return jar;
|
|
55
75
|
}
|
|
56
76
|
function cookieHeader(jar) {
|
|
57
|
-
return Object.keys(jar)
|
|
77
|
+
return Object.keys(jar)
|
|
78
|
+
.map(function (k) {
|
|
79
|
+
return k + "=" + jar[k];
|
|
80
|
+
})
|
|
81
|
+
.join("; ");
|
|
58
82
|
}
|
|
59
83
|
/** Scrape an authenticated g_ck (or form sysparm_ck) out of a page's HTML. */
|
|
60
84
|
function scrapeCk(html) {
|
|
61
|
-
var m = html.match(/var g_ck = ['"]([0-9a-f]{40,})['"]/)
|
|
62
|
-
|
|
63
|
-
|
|
85
|
+
var m = html.match(/var g_ck = ['"]([0-9a-f]{40,})['"]/) ||
|
|
86
|
+
html.match(/window\.g_ck = ['"]([0-9a-f]{40,})['"]/) ||
|
|
87
|
+
html.match(/name="sysparm_ck"\s+value="([0-9a-f]{40,})"/);
|
|
64
88
|
return m ? m[1] : "";
|
|
65
89
|
}
|
|
66
90
|
/** Form-login (login.do) and return an authenticated { ck, jar }. */
|
|
67
91
|
async function openFormSession(auth) {
|
|
68
92
|
var B = base(auth);
|
|
69
93
|
var jar = {};
|
|
70
|
-
var res = await fetch(B + "/login.do", {
|
|
94
|
+
var res = await fetch(B + "/login.do", {
|
|
95
|
+
headers: { Accept: "text/html" },
|
|
96
|
+
redirect: "manual",
|
|
97
|
+
});
|
|
71
98
|
jarFrom(res, jar);
|
|
72
99
|
var formCk = scrapeCk(await res.text());
|
|
73
100
|
var body = new URLSearchParams({
|
|
74
|
-
user_name: auth.user,
|
|
75
|
-
|
|
101
|
+
user_name: auth.user,
|
|
102
|
+
user_password: auth.password,
|
|
103
|
+
sysparm_ck: formCk,
|
|
104
|
+
not_important: "",
|
|
105
|
+
sys_action: "sysverb_login",
|
|
76
106
|
}).toString();
|
|
77
107
|
res = await fetch(B + "/login.do", {
|
|
78
108
|
method: "POST",
|
|
79
|
-
headers: {
|
|
80
|
-
|
|
109
|
+
headers: {
|
|
110
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
111
|
+
Cookie: cookieHeader(jar),
|
|
112
|
+
Accept: "text/html",
|
|
113
|
+
},
|
|
114
|
+
body: body,
|
|
115
|
+
redirect: "manual",
|
|
81
116
|
});
|
|
82
117
|
jarFrom(res, jar);
|
|
83
118
|
var loc = res.headers.get("location");
|
|
84
119
|
if (loc) {
|
|
85
|
-
var url = loc.indexOf("http") === 0
|
|
86
|
-
|
|
120
|
+
var url = loc.indexOf("http") === 0
|
|
121
|
+
? loc
|
|
122
|
+
: B + (loc.indexOf("/") === 0 ? loc : "/" + loc);
|
|
123
|
+
res = await fetch(url, {
|
|
124
|
+
headers: { Cookie: cookieHeader(jar), Accept: "text/html" },
|
|
125
|
+
redirect: "manual",
|
|
126
|
+
});
|
|
87
127
|
jarFrom(res, jar);
|
|
88
128
|
}
|
|
89
129
|
res = await fetch(B + "/sys_db_object.do?sys_id=-1&sysparm_stack=no", {
|
|
90
|
-
headers: { Cookie: cookieHeader(jar), Accept: "text/html" }
|
|
130
|
+
headers: { Cookie: cookieHeader(jar), Accept: "text/html" },
|
|
91
131
|
});
|
|
92
132
|
jarFrom(res, jar);
|
|
93
133
|
var ck = scrapeCk(await res.text());
|
|
@@ -114,12 +154,12 @@ async function setCurrentApplication(auth, session, appSysId) {
|
|
|
114
154
|
"X-UserToken": session.ck,
|
|
115
155
|
Cookie: cookieHeader(session.jar),
|
|
116
156
|
"Content-Type": "application/json",
|
|
117
|
-
Accept: "application/json"
|
|
157
|
+
Accept: "application/json",
|
|
118
158
|
},
|
|
119
159
|
// The picker's ApplicationProcessor expects `app_id` (a `value` body returns
|
|
120
160
|
// 400 "Missing Application Id").
|
|
121
161
|
body: JSON.stringify({ app_id: appSysId }),
|
|
122
|
-
redirect: "manual"
|
|
162
|
+
redirect: "manual",
|
|
123
163
|
});
|
|
124
164
|
jarFrom(res, session.jar);
|
|
125
165
|
var body = "";
|
|
@@ -133,17 +173,26 @@ async function setCurrentApplication(auth, session, appSysId) {
|
|
|
133
173
|
return { ok: ok, status: res.status, body: body.slice(0, 200) };
|
|
134
174
|
}
|
|
135
175
|
/**
|
|
136
|
-
* GET
|
|
137
|
-
* would submit (sysparm_ck, sysparm_encoded_record, the dynamic
|
|
138
|
-
* field, every sys_original.* default)
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
176
|
+
* GET a sys_db_object form (record `sysId`) and harvest every hidden/input field
|
|
177
|
+
* the browser would submit (sysparm_ck, sysparm_encoded_record, the dynamic
|
|
178
|
+
* `<sysid>_text` field, every sys_original.* default) plus the discovered list-edit
|
|
179
|
+
* key. The caller overlays capability params onto `fields` before POSTing.
|
|
180
|
+
*
|
|
181
|
+
* `sysId = "-1"` is the new-record form (table create). An EXISTING record's form
|
|
182
|
+
* renders the "Columns" related list inline, so for add-column the real
|
|
183
|
+
* `ListEditFormatterAction[sys_db_object.REL:<relId>]` key IS present and harvested
|
|
184
|
+
* here — unlike the new-record form, which renders no related lists (listEditKey
|
|
185
|
+
* empty, the create-table caller falls back to the constant relId). Validated live
|
|
186
|
+
* 2026-06-13 for the new-record path.
|
|
142
187
|
*/
|
|
143
|
-
async function
|
|
188
|
+
async function getRecordForm(auth, session, sysId) {
|
|
144
189
|
var B = base(auth);
|
|
145
|
-
var
|
|
146
|
-
|
|
190
|
+
var id = sysId && String(sysId).trim() ? String(sysId).trim() : "-1";
|
|
191
|
+
var res = await fetch(B +
|
|
192
|
+
"/sys_db_object.do?sys_id=" +
|
|
193
|
+
encodeURIComponent(id) +
|
|
194
|
+
"&sysparm_stack=no", {
|
|
195
|
+
headers: { Cookie: cookieHeader(session.jar), Accept: "text/html" },
|
|
147
196
|
});
|
|
148
197
|
jarFrom(res, session.jar);
|
|
149
198
|
var html = await res.text();
|
|
@@ -161,6 +210,15 @@ async function getNewRecordForm(auth, session) {
|
|
|
161
210
|
}
|
|
162
211
|
return { fields: fields, listEditKey: listEditKey };
|
|
163
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* GET the new-record (sys_id=-1) sys_db_object form. Thin wrapper over getRecordForm
|
|
215
|
+
* preserved for the create-table caller. The new-record form renders no related
|
|
216
|
+
* lists, so `listEditKey` is normally empty — the caller falls back to the constant
|
|
217
|
+
* "Table Columns" relId. Validated live 2026-06-13.
|
|
218
|
+
*/
|
|
219
|
+
async function getNewRecordForm(auth, session) {
|
|
220
|
+
return getRecordForm(auth, session, "-1");
|
|
221
|
+
}
|
|
164
222
|
/**
|
|
165
223
|
* Parse `<input>` (and hidden) name/value pairs out of form HTML. Best-effort —
|
|
166
224
|
* handles double/single-quoted attributes in either order. Exported for testing.
|
|
@@ -189,12 +247,15 @@ function attr(tag, name) {
|
|
|
189
247
|
return null;
|
|
190
248
|
}
|
|
191
249
|
function decodeHtml(s) {
|
|
250
|
+
// Unescape & LAST. Doing it first double-unescapes inputs like "&lt;"
|
|
251
|
+
// ("&lt;" -> "<" -> "<"), the js/double-escaping vulnerability. With the
|
|
252
|
+
// ampersand handled last, each entity is unescaped exactly once.
|
|
192
253
|
return s
|
|
193
|
-
.replace(/&/g, "&")
|
|
194
254
|
.replace(/</g, "<")
|
|
195
255
|
.replace(/>/g, ">")
|
|
196
256
|
.replace(/"/g, '"')
|
|
197
|
-
.replace(/'/g, "'")
|
|
257
|
+
.replace(/'/g, "'")
|
|
258
|
+
.replace(/&/g, "&");
|
|
198
259
|
}
|
|
199
260
|
/** POST a form-urlencoded field map to a `.do` path. Follows nothing — returns the 302. */
|
|
200
261
|
async function postForm(auth, session, path, fields) {
|
|
@@ -210,10 +271,10 @@ async function postForm(auth, session, path, fields) {
|
|
|
210
271
|
"X-UserToken": session.ck,
|
|
211
272
|
Cookie: cookieHeader(session.jar),
|
|
212
273
|
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
213
|
-
Accept: "text/html"
|
|
274
|
+
Accept: "text/html",
|
|
214
275
|
},
|
|
215
276
|
body: params.toString(),
|
|
216
|
-
redirect: "manual"
|
|
277
|
+
redirect: "manual",
|
|
217
278
|
});
|
|
218
279
|
jarFrom(res, session.jar);
|
|
219
280
|
var location = res.headers.get("location") || "";
|
package/dist/table/index.d.ts
CHANGED
|
@@ -9,5 +9,7 @@ export { buildColumnXml, xmlEscape } from "./buildColumnXml";
|
|
|
9
9
|
export type { NormalizedColumn } from "./buildColumnXml";
|
|
10
10
|
export { normalizeColumns, resolveType, applyTableSaveOverlay, defaultAccessFlags, showInMenuKey, listEditKey, TYPE_MAP } from "./buildTableSave";
|
|
11
11
|
export type { ColumnSpec, AccessFlags, OverlaySpec } from "./buildTableSave";
|
|
12
|
-
export { resolveFormAuth, openFormSession, setCurrentApplication, getNewRecordForm, parseFormInputs, postForm, scrapeCk } from "./formSession";
|
|
12
|
+
export { resolveFormAuth, openFormSession, setCurrentApplication, getRecordForm, getNewRecordForm, parseFormInputs, postForm, scrapeCk } from "./formSession";
|
|
13
13
|
export type { FormAuth, FormSession, HarvestedForm, PostResult } from "./formSession";
|
|
14
|
+
export { addColumn, deriveElement, applyAddColumnOverlay } from "./addColumn";
|
|
15
|
+
export type { AddColumnParams, AddColumnResult } from "./addColumn";
|
package/dist/table/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* live-validation caveat.
|
|
6
6
|
*/
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
-
exports.scrapeCk = exports.postForm = exports.parseFormInputs = exports.getNewRecordForm = exports.setCurrentApplication = exports.openFormSession = exports.resolveFormAuth = exports.TYPE_MAP = exports.listEditKey = exports.showInMenuKey = exports.defaultAccessFlags = exports.applyTableSaveOverlay = exports.resolveType = exports.normalizeColumns = exports.xmlEscape = exports.buildColumnXml = exports.DEFAULT_COLUMNS_REL_ID = exports.DEFAULT_SAVE_ACTION = exports.DEFAULT_SUPER_CLASS = exports.parseSysIdFromLocation = exports.projectTableGraph = exports.createTable = void 0;
|
|
8
|
+
exports.applyAddColumnOverlay = exports.deriveElement = exports.addColumn = exports.scrapeCk = exports.postForm = exports.parseFormInputs = exports.getNewRecordForm = exports.getRecordForm = exports.setCurrentApplication = exports.openFormSession = exports.resolveFormAuth = exports.TYPE_MAP = exports.listEditKey = exports.showInMenuKey = exports.defaultAccessFlags = exports.applyTableSaveOverlay = exports.resolveType = exports.normalizeColumns = exports.xmlEscape = exports.buildColumnXml = exports.DEFAULT_COLUMNS_REL_ID = exports.DEFAULT_SAVE_ACTION = exports.DEFAULT_SUPER_CLASS = exports.parseSysIdFromLocation = exports.projectTableGraph = exports.createTable = void 0;
|
|
9
9
|
var createTable_1 = require("./createTable");
|
|
10
10
|
Object.defineProperty(exports, "createTable", { enumerable: true, get: function () { return createTable_1.createTable; } });
|
|
11
11
|
Object.defineProperty(exports, "projectTableGraph", { enumerable: true, get: function () { return createTable_1.projectTableGraph; } });
|
|
@@ -28,7 +28,12 @@ var formSession_1 = require("./formSession");
|
|
|
28
28
|
Object.defineProperty(exports, "resolveFormAuth", { enumerable: true, get: function () { return formSession_1.resolveFormAuth; } });
|
|
29
29
|
Object.defineProperty(exports, "openFormSession", { enumerable: true, get: function () { return formSession_1.openFormSession; } });
|
|
30
30
|
Object.defineProperty(exports, "setCurrentApplication", { enumerable: true, get: function () { return formSession_1.setCurrentApplication; } });
|
|
31
|
+
Object.defineProperty(exports, "getRecordForm", { enumerable: true, get: function () { return formSession_1.getRecordForm; } });
|
|
31
32
|
Object.defineProperty(exports, "getNewRecordForm", { enumerable: true, get: function () { return formSession_1.getNewRecordForm; } });
|
|
32
33
|
Object.defineProperty(exports, "parseFormInputs", { enumerable: true, get: function () { return formSession_1.parseFormInputs; } });
|
|
33
34
|
Object.defineProperty(exports, "postForm", { enumerable: true, get: function () { return formSession_1.postForm; } });
|
|
34
35
|
Object.defineProperty(exports, "scrapeCk", { enumerable: true, get: function () { return formSession_1.scrapeCk; } });
|
|
36
|
+
var addColumn_1 = require("./addColumn");
|
|
37
|
+
Object.defineProperty(exports, "addColumn", { enumerable: true, get: function () { return addColumn_1.addColumn; } });
|
|
38
|
+
Object.defineProperty(exports, "deriveElement", { enumerable: true, get: function () { return addColumn_1.deriveElement; } });
|
|
39
|
+
Object.defineProperty(exports, "applyAddColumnOverlay", { enumerable: true, get: function () { return addColumn_1.applyAddColumnOverlay; } });
|