@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 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; } });
@@ -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({}). */
@@ -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
  }
@@ -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
+ }>;
@@ -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 the new sys_db_object form and harvest every hidden/input field the browser
57
- * would submit (sysparm_ck, sysparm_encoded_record, the dynamic `<sysid>_text`
58
- * field, every sys_original.* default). The caller overlays capability params onto
59
- * `fields` before POSTing. NOTE: the new-record (sys_id=-1) form does NOT render
60
- * related lists, so `listEditKey` is normally empty — the caller falls back to the
61
- * constant "Table Columns" relId. Validated live 2026-06-13.
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
- || process.env.SN_INSTANCE
28
- || process.env.SN_DEV_INSTANCE
29
- || process.env.SN_PROD_INSTANCE
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
- var host = rawHost.replace(/^https?:\/\//, "").replace(/\/+$/, "");
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 || process.env.SN_USER || process.env.SN_DEV_USERNAME || process.env.SN_PROD_USERNAME || "";
38
- var password = c.password || process.env.SN_PASSWORD || process.env.SN_DEV_PASSWORD || process.env.SN_PROD_PASSWORD || "";
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) { return "https://" + auth.host; }
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" ? anyHeaders.getSetCookie() : [];
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).map(function (k) { return k + "=" + jar[k]; }).join("; ");
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
- || html.match(/window\.g_ck = ['"]([0-9a-f]{40,})['"]/)
63
- || html.match(/name="sysparm_ck"\s+value="([0-9a-f]{40,})"/);
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", { headers: { Accept: "text/html" }, redirect: "manual" });
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, user_password: auth.password, sysparm_ck: formCk,
75
- not_important: "", sys_action: "sysverb_login"
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: { "Content-Type": "application/x-www-form-urlencoded", Cookie: cookieHeader(jar), Accept: "text/html" },
80
- body: body, redirect: "manual"
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 ? loc : B + (loc.indexOf("/") === 0 ? loc : "/" + loc);
86
- res = await fetch(url, { headers: { Cookie: cookieHeader(jar), Accept: "text/html" }, redirect: "manual" });
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 the new sys_db_object form and harvest every hidden/input field the browser
137
- * would submit (sysparm_ck, sysparm_encoded_record, the dynamic `<sysid>_text`
138
- * field, every sys_original.* default). The caller overlays capability params onto
139
- * `fields` before POSTing. NOTE: the new-record (sys_id=-1) form does NOT render
140
- * related lists, so `listEditKey` is normally empty — the caller falls back to the
141
- * constant "Table Columns" relId. Validated live 2026-06-13.
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 getNewRecordForm(auth, session) {
188
+ async function getRecordForm(auth, session, sysId) {
144
189
  var B = base(auth);
145
- var res = await fetch(B + "/sys_db_object.do?sys_id=-1&sysparm_stack=no", {
146
- headers: { Cookie: cookieHeader(session.jar), Accept: "text/html" }
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 &amp; LAST. Doing it first double-unescapes inputs like "&amp;lt;"
251
+ // ("&amp;lt;" -> "&lt;" -> "<"), the js/double-escaping vulnerability. With the
252
+ // ampersand handled last, each entity is unescaped exactly once.
192
253
  return s
193
- .replace(/&amp;/g, "&")
194
254
  .replace(/&lt;/g, "<")
195
255
  .replace(/&gt;/g, ">")
196
256
  .replace(/&quot;/g, '"')
197
- .replace(/&#39;/g, "'");
257
+ .replace(/&#39;/g, "'")
258
+ .replace(/&amp;/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") || "";
@@ -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";
@@ -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; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenonhq/dovetail-servicenow",
3
- "version": "0.0.20",
3
+ "version": "0.0.22",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },