@tenonhq/dovetail-servicenow 0.0.19 → 0.0.21

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/README.md CHANGED
@@ -237,14 +237,21 @@ insert into `sys_db_object` **orphans** the table (a metadata row with no physic
237
237
  table, no ACLs, no scope wiring). So this replays the Studio form save: a single
238
238
  `POST /sys_db_object.do` (form-login session → `g_ck`) whose body embeds every
239
239
  column as a `sys_dictionary` list-edit XML blob (one `<record operation="add">`
240
- per column) plus the Application-Navigator module — yielding the real 36-record
241
- graph + the physical table + seeded ACLs. Friendly column types are mapped to
242
- ServiceNow internal types (`string` `string_full_utf8`, Studio's real String
243
- type). `--dry-run` returns the plan + the column XML + the projected graph with no
244
- session and no writes; the pure transforms are unit-tested. **The live write path
245
- is pending a validated-live spike** — prefer `--dry-run`, and always verify the
246
- `sys_update_xml` rows landed in the intended update set. Ground truth (the HAR
247
- dissection) lives in the CTO repo's create-table docs.
240
+ per column) plus the Application-Navigator module — yielding the real platform
241
+ graph + the physical table + seeded ACLs. Before the save it switches the form
242
+ session's **current application** to the target scope (the new table's scope is
243
+ governed by the session app, not a form field), so the table, columns, ACLs, and
244
+ module all land in `--scope`. Friendly column types are mapped to ServiceNow
245
+ internal types (`string` `string_full_utf8`, Studio's real String type).
246
+
247
+ `--dry-run` returns the plan + the column XML + the projected graph with no session
248
+ and no writes. **Validated live 2026-06-13** (tenonworkstudio): a 6-column create
249
+ landed the correctly-scoped table, all columns, ACLs + role, the module, and the
250
+ full graph in the pinned update set, and a scoped insert round-tripped. Still: pin
251
+ `--update-set <sys_id>` and verify the `sys_update_xml` rows afterward. `--debug`
252
+ adds diagnostics (app-switch status, resolved column key, assigned sys_id) to the
253
+ result note. Ground truth (the HAR dissection) lives in the CTO repo's create-table
254
+ docs.
248
255
 
249
256
  `test-flow` defaults to **validate** — a safe pre-flight (published? inputs match
250
257
  declared variables?) that never runs the flow; `--execute --confirm` runs it via
package/dist/cli.js CHANGED
@@ -785,8 +785,13 @@ async function runCreateTable(flags) {
785
785
  var sa = flags["save-action"] || spec.saveActionSysId;
786
786
  if (sa)
787
787
  params.saveActionSysId = sa;
788
+ var relId = flags["columns-rel-id"] || spec.columnsRelId;
789
+ if (relId)
790
+ params.columnsRelId = relId;
788
791
  if (flags["dry-run"] === "true" || spec.dryRun === true)
789
792
  params.dryRun = true;
793
+ if (flags.debug === "true" || spec.debug === true)
794
+ params.debug = true;
790
795
  var result = await (0, table_1.createTable)(params);
791
796
  if (flags.json === "true") {
792
797
  process.stdout.write(JSON.stringify(result, null, 2) + "\n");
@@ -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";
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.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");
@@ -432,7 +432,9 @@ export declare var createTableSchema: z.ZodObject<{
432
432
  showInMenu: z.ZodOptional<z.ZodBoolean>;
433
433
  updateSetSysId: z.ZodOptional<z.ZodString>;
434
434
  saveActionSysId: z.ZodOptional<z.ZodString>;
435
+ columnsRelId: z.ZodOptional<z.ZodString>;
435
436
  dryRun: z.ZodOptional<z.ZodBoolean>;
437
+ debug: z.ZodOptional<z.ZodBoolean>;
436
438
  }, "strip", z.ZodTypeAny, {
437
439
  name: string;
438
440
  columns: {
@@ -445,6 +447,7 @@ export declare var createTableSchema: z.ZodObject<{
445
447
  label: string;
446
448
  scope: string;
447
449
  dryRun?: boolean | undefined;
450
+ debug?: boolean | undefined;
448
451
  updateSetSysId?: string | undefined;
449
452
  extendsTable?: string | undefined;
450
453
  numberPrefix?: string | undefined;
@@ -453,6 +456,7 @@ export declare var createTableSchema: z.ZodObject<{
453
456
  access?: string | undefined;
454
457
  showInMenu?: boolean | undefined;
455
458
  saveActionSysId?: string | undefined;
459
+ columnsRelId?: string | undefined;
456
460
  }, {
457
461
  name: string;
458
462
  columns: {
@@ -465,6 +469,7 @@ export declare var createTableSchema: z.ZodObject<{
465
469
  label: string;
466
470
  scope: string;
467
471
  dryRun?: boolean | undefined;
472
+ debug?: boolean | undefined;
468
473
  updateSetSysId?: string | undefined;
469
474
  extendsTable?: string | undefined;
470
475
  numberPrefix?: string | undefined;
@@ -473,4 +478,5 @@ export declare var createTableSchema: z.ZodObject<{
473
478
  access?: string | undefined;
474
479
  showInMenu?: boolean | undefined;
475
480
  saveActionSysId?: string | undefined;
481
+ columnsRelId?: string | undefined;
476
482
  }>;
@@ -133,5 +133,7 @@ exports.createTableSchema = zod_1.z.object({
133
133
  showInMenu: zod_1.z.boolean().optional(),
134
134
  updateSetSysId: zod_1.z.string().optional(),
135
135
  saveActionSysId: zod_1.z.string().optional(),
136
- dryRun: zod_1.z.boolean().optional()
136
+ columnsRelId: zod_1.z.string().optional(),
137
+ dryRun: zod_1.z.boolean().optional(),
138
+ debug: zod_1.z.boolean().optional()
137
139
  });
@@ -48,6 +48,15 @@ export interface OverlaySpec {
48
48
  superClassLabel: string;
49
49
  scopeSysId: string;
50
50
  scopeLabel: string;
51
+ /**
52
+ * Scope sys_id to stamp the transaction with. A new table's scope is governed
53
+ * by the form transaction's scope, NOT the `sys_db_object.sys_scope` field — and
54
+ * a form-login session can't reliably switch its current application. Setting
55
+ * `sysparm_transaction_scope` / `sysparm_record_scope` explicitly is the same
56
+ * lever createFlow.ts uses to scope cross-scope writes. Without it the table,
57
+ * ACLs, and module land in the session user's default app. Normally === scopeSysId.
58
+ */
59
+ transactionScopeSysId: string;
51
60
  numberPrefix: string;
52
61
  userRoleSysId: string;
53
62
  userRoleLabel: string;
@@ -128,6 +128,16 @@ function applyTableSaveOverlay(base, o) {
128
128
  f["sys_db_object.sys_scope_label"] = o.scopeLabel;
129
129
  f["sys_display.sys_db_object.sys_scope"] = o.scopeLabel;
130
130
  f["sys_display.original.sys_db_object.sys_scope"] = o.scopeLabel;
131
+ // NOTE: do NOT set sysparm_transaction_scope / sysparm_record_scope here. Setting
132
+ // them on a cross-scope POST triggers ServiceNow's scope-switch interstitial, which
133
+ // bounces the save to welcome.do (nothing created). The session's current app —
134
+ // switched via setCurrentApplication() before the POST — is what scopes the table.
135
+ // transactionScopeSysId is retained on OverlaySpec for callers that genuinely need
136
+ // it, but the orchestrator leaves it empty.
137
+ if (o.transactionScopeSysId) {
138
+ f["sysparm_transaction_scope"] = o.transactionScopeSysId;
139
+ f["sysparm_record_scope"] = o.transactionScopeSysId;
140
+ }
131
141
  f["sys_db_object.number_ref.prefix"] = o.numberPrefix;
132
142
  f["sys_db_object.create_access_controls"] = flag(o.createAccessControls);
133
143
  f["ni.sys_db_object.create_access_controls"] = flag(o.createAccessControls);
@@ -9,19 +9,35 @@
9
9
  *
10
10
  * Sequence (form-login replay, modeled on flowDesigner/createFlow.ts):
11
11
  * 1. open a form session (login.do -> authenticated g_ck)
12
+ * 1b. switch the form session's current application to the target scope, so the
13
+ * new table is scoped correctly (the session app governs scope, NOT a field)
12
14
  * 2. resolve sys_ids via the REST client (parent table, scope, role, app)
13
- * 3. GET the new-record form, harvest its fields (ck, encoded_record, REL id)
14
- * 4. overlay the table + column values, POST -> expect 302
15
- * 5. assert the resulting sys_update_xml landed in the pinned update set
15
+ * 3. GET the new-record form, harvest its fields (ck, encoded_record)
16
+ * 4. overlay the table + column values (columns ride a constant "Table Columns"
17
+ * relId the new-record form doesn't render related lists to harvest one from)
18
+ * 5. POST -> expect 302 to sys_db_object.do?sys_id=<assigned>; parse that sys_id
16
19
  *
17
- * NOT YET VALIDATED LIVE this is the B2 spike surface. dryRun is pure and
18
- * fully tested; the live write path awaits a validated-live run before the verb /
19
- * MCP tool flip from Studio. ES6 only, no optional chaining, no `any`.
20
+ * VALIDATED LIVE 2026-06-13 (tenonworkstudio): a 6-column create landed the table,
21
+ * all columns, ACLs + role, the nav module, and 25 sys_update_xml rows in the pinned
22
+ * update set; a scoped insert round-tripped (physical table, not an orphan). The
23
+ * earlier defects (wrong scope, 0 columns, fabricated sys_id) are fixed here.
24
+ * ES6 only, no optional chaining, no `any`.
20
25
  */
21
26
  import type { ServiceNowClient } from "../client";
22
27
  import { ColumnSpec, AccessFlags } from "./buildTableSave";
23
28
  /** Default parent for a custom scoped table — what Studio picks for "extends nothing". */
24
29
  export declare var DEFAULT_SUPER_CLASS: string;
30
+ /**
31
+ * The "Table Columns" sys_relationship sys_id — the related list that carries the
32
+ * column XML in the `sys_db_object.REL:<relId>` list-edit key. It is a shipped OOB
33
+ * relationship (stable across instances), so we use it as a constant: the new-record
34
+ * (sys_id=-1) form does NOT render related lists, so the relId can't be harvested
35
+ * from it — relying on harvest discovery silently dropped every column. Override via
36
+ * params.columnsRelId only if an instance customized it.
37
+ */
38
+ export declare var DEFAULT_COLUMNS_REL_ID: string;
39
+ /** Pull the assigned record sys_id out of a `...do?sys_id=<id>&...` 302 Location. */
40
+ export declare function parseSysIdFromLocation(location: string): string;
25
41
  export interface CreateTableParams {
26
42
  /** REST client for sys_id resolution + the update-set assertion. */
27
43
  client: ServiceNowClient;
@@ -51,6 +67,10 @@ export interface CreateTableParams {
51
67
  updateSetSysId?: string;
52
68
  /** Override the Save UI-action sys_id (defaults to the well-known global Save). */
53
69
  saveActionSysId?: string;
70
+ /** Override the "Table Columns" relationship sys_id (defaults to the OOB constant). */
71
+ columnsRelId?: string;
72
+ /** Emit diagnostic detail (harvested key, scope fields, response snippet) in the result note. */
73
+ debug?: boolean;
54
74
  /** Instance/creds for the form session (default: env, same precedence as the client). */
55
75
  instance?: string;
56
76
  user?: string;
@@ -92,6 +112,12 @@ export interface CreateTableResult {
92
112
  }
93
113
  /** The well-known global "Save" UI action (sys_ui_action). Override per-instance if needed. */
94
114
  export declare var DEFAULT_SAVE_ACTION: string;
95
- /** Project the record graph a real create emits (the 36-record shape for 12 cols). */
115
+ /**
116
+ * Project the record graph a real create emits (the documented 36-record shape for
117
+ * 12 cols + ACLs + role + menu). NOTE: a live create with Show-in-menu also re-emits
118
+ * the parent Application-Navigator menu into the update set, so the pinned set holds
119
+ * one MORE row than `total` (verified 2026-06-13: 6 cols → graph total 24, 25 rows
120
+ * captured). The extra row is the existing menu being touched, not a new record.
121
+ */
96
122
  export declare function projectTableGraph(columnCount: number, createAcls: boolean, hasRole: boolean): TableGraph;
97
123
  export declare function createTable(params: CreateTableParams): Promise<CreateTableResult>;
@@ -10,14 +10,19 @@
10
10
  *
11
11
  * Sequence (form-login replay, modeled on flowDesigner/createFlow.ts):
12
12
  * 1. open a form session (login.do -> authenticated g_ck)
13
+ * 1b. switch the form session's current application to the target scope, so the
14
+ * new table is scoped correctly (the session app governs scope, NOT a field)
13
15
  * 2. resolve sys_ids via the REST client (parent table, scope, role, app)
14
- * 3. GET the new-record form, harvest its fields (ck, encoded_record, REL id)
15
- * 4. overlay the table + column values, POST -> expect 302
16
- * 5. assert the resulting sys_update_xml landed in the pinned update set
16
+ * 3. GET the new-record form, harvest its fields (ck, encoded_record)
17
+ * 4. overlay the table + column values (columns ride a constant "Table Columns"
18
+ * relId the new-record form doesn't render related lists to harvest one from)
19
+ * 5. POST -> expect 302 to sys_db_object.do?sys_id=<assigned>; parse that sys_id
17
20
  *
18
- * NOT YET VALIDATED LIVE this is the B2 spike surface. dryRun is pure and
19
- * fully tested; the live write path awaits a validated-live run before the verb /
20
- * MCP tool flip from Studio. ES6 only, no optional chaining, no `any`.
21
+ * VALIDATED LIVE 2026-06-13 (tenonworkstudio): a 6-column create landed the table,
22
+ * all columns, ACLs + role, the nav module, and 25 sys_update_xml rows in the pinned
23
+ * update set; a scoped insert round-tripped (physical table, not an orphan). The
24
+ * earlier defects (wrong scope, 0 columns, fabricated sys_id) are fixed here.
25
+ * ES6 only, no optional chaining, no `any`.
21
26
  */
22
27
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
23
28
  if (k2 === undefined) k2 = k;
@@ -53,7 +58,8 @@ var __importStar = (this && this.__importStar) || (function () {
53
58
  };
54
59
  })();
55
60
  Object.defineProperty(exports, "__esModule", { value: true });
56
- exports.DEFAULT_SAVE_ACTION = exports.DEFAULT_SUPER_CLASS = void 0;
61
+ exports.DEFAULT_SAVE_ACTION = exports.DEFAULT_COLUMNS_REL_ID = exports.DEFAULT_SUPER_CLASS = void 0;
62
+ exports.parseSysIdFromLocation = parseSysIdFromLocation;
57
63
  exports.projectTableGraph = projectTableGraph;
58
64
  exports.createTable = createTable;
59
65
  const crypto = __importStar(require("crypto"));
@@ -62,12 +68,34 @@ const buildTableSave_1 = require("./buildTableSave");
62
68
  const formSession_1 = require("./formSession");
63
69
  /** Default parent for a custom scoped table — what Studio picks for "extends nothing". */
64
70
  exports.DEFAULT_SUPER_CLASS = "sys_metadata";
71
+ /**
72
+ * The "Table Columns" sys_relationship sys_id — the related list that carries the
73
+ * column XML in the `sys_db_object.REL:<relId>` list-edit key. It is a shipped OOB
74
+ * relationship (stable across instances), so we use it as a constant: the new-record
75
+ * (sys_id=-1) form does NOT render related lists, so the relId can't be harvested
76
+ * from it — relying on harvest discovery silently dropped every column. Override via
77
+ * params.columnsRelId only if an instance customized it.
78
+ */
79
+ exports.DEFAULT_COLUMNS_REL_ID = "4344f6f5bf1320001875647fcf0739ad";
80
+ /** Pull the assigned record sys_id out of a `...do?sys_id=<id>&...` 302 Location. */
81
+ function parseSysIdFromLocation(location) {
82
+ if (!location)
83
+ return "";
84
+ var m = String(location).match(/[?&]sys_id=([0-9a-f]{32})\b/i);
85
+ return m ? m[1] : "";
86
+ }
65
87
  /** The well-known global "Save" UI action (sys_ui_action). Override per-instance if needed. */
66
88
  exports.DEFAULT_SAVE_ACTION = "3dc6c898c3201100dcc2addbdfba8fe7";
67
89
  function newSysId() {
68
90
  return crypto.randomBytes(16).toString("hex");
69
91
  }
70
- /** Project the record graph a real create emits (the 36-record shape for 12 cols). */
92
+ /**
93
+ * Project the record graph a real create emits (the documented 36-record shape for
94
+ * 12 cols + ACLs + role + menu). NOTE: a live create with Show-in-menu also re-emits
95
+ * the parent Application-Navigator menu into the update set, so the pinned set holds
96
+ * one MORE row than `total` (verified 2026-06-13: 6 cols → graph total 24, 25 rows
97
+ * captured). The extra row is the existing menu being touched, not a new record.
98
+ */
71
99
  function projectTableGraph(columnCount, createAcls, hasRole) {
72
100
  var dictionary = columnCount + 1; // +1 collection row
73
101
  var labels = columnCount + 1; // table label + per-column labels
@@ -155,17 +183,24 @@ async function createTable(params) {
155
183
  roleSysId = role.sysId;
156
184
  roleLabel = role.label || String(params.userRole);
157
185
  }
186
+ // Resolve the app ALWAYS — it scopes the form session (setCurrentApplication),
187
+ // not just the optional nav module. scope may be a name or a sys_scope sys_id.
158
188
  var appSysId = "";
159
189
  var showInMenu = params.showInMenu === false ? false : true;
160
- if (showInMenu) {
161
- var apps = await client.table.query("sys_app", "scope=" + params.scope, 1);
162
- if (apps.length > 0)
163
- appSysId = String(apps[0].sys_id);
164
- }
190
+ var appQuery = SYS_ID.test(params.scope) ? "sys_id=" + params.scope : "scope=" + params.scope;
191
+ var apps = await client.table.query("sys_app", appQuery, 1);
192
+ if (apps.length > 0)
193
+ appSysId = String(apps[0].sys_id);
165
194
  var saveAction = params.saveActionSysId && params.saveActionSysId.trim() ? params.saveActionSysId.trim() : exports.DEFAULT_SAVE_ACTION;
166
195
  // 1: open the form session.
167
196
  var auth = (0, formSession_1.resolveFormAuth)({ instance: params.instance, user: params.user, password: params.password });
168
197
  var session = await (0, formSession_1.openFormSession)(auth);
198
+ // 1b: put the form session IN the target app so the new table is scoped correctly.
199
+ // Without this the table lands in the session user's default app (the #1 live defect).
200
+ var appSwitch = { ok: false, status: 0, body: "no app resolved" };
201
+ if (appSysId) {
202
+ appSwitch = await (0, formSession_1.setCurrentApplication)(auth, session, appSysId);
203
+ }
169
204
  if (params.updateSetSysId) {
170
205
  // Pin the REST session's update set; the form session inherits the user pref.
171
206
  try {
@@ -176,6 +211,11 @@ async function createTable(params) {
176
211
  // 3: harvest the new-record form.
177
212
  var harvest = await (0, formSession_1.getNewRecordForm)(auth, session);
178
213
  var tableSysId = newSysId();
214
+ // The new-record (sys_id=-1) form doesn't render related lists, so harvest.listEditKey
215
+ // is normally empty — fall back to the constant "Table Columns" relId so the columns
216
+ // always ride the POST. Relying on the harvest alone silently produced column-less tables.
217
+ var relId = params.columnsRelId && params.columnsRelId.trim() ? params.columnsRelId.trim() : exports.DEFAULT_COLUMNS_REL_ID;
218
+ var colKey = harvest.listEditKey ? harvest.listEditKey : (0, buildTableSave_1.listEditKey)(relId);
179
219
  var overlay = {
180
220
  name: params.name,
181
221
  label: params.label,
@@ -185,6 +225,9 @@ async function createTable(params) {
185
225
  superClassLabel: superClass.label,
186
226
  scopeSysId: scopeRef.sysId,
187
227
  scopeLabel: scopeRef.label,
228
+ // Left empty on purpose — the form-session app switch (1b) scopes the table.
229
+ // Setting these triggers a cross-scope interstitial that bounces to welcome.do.
230
+ transactionScopeSysId: "",
188
231
  numberPrefix: params.numberPrefix ? params.numberPrefix : "",
189
232
  userRoleSysId: roleSysId,
190
233
  userRoleLabel: roleLabel,
@@ -193,16 +236,38 @@ async function createTable(params) {
193
236
  accessFlags: params.accessFlags ? params.accessFlags : (0, buildTableSave_1.defaultAccessFlags)(),
194
237
  selectedApplicationSysId: showInMenu ? appSysId : "",
195
238
  menuName: params.label,
196
- listEditKey: harvest.listEditKey,
239
+ listEditKey: colKey,
197
240
  columnXml: columnXml
198
241
  };
199
242
  var fields = (0, buildTableSave_1.applyTableSaveOverlay)(harvest.fields, overlay);
200
243
  // 4: POST the save.
201
244
  var resp = await (0, formSession_1.postForm)(auth, session, "/sys_db_object.do", fields);
202
245
  var ok = resp.status >= 300 && resp.status < 400; // 302 on success
246
+ // The form assigns its own sys_id for a new record — the 302 Location is the truth,
247
+ // not the sys_uniqueValue we sent. Prefer the parsed id; fall back to what we sent.
248
+ var assignedSysId = parseSysIdFromLocation(resp.location);
249
+ var finalSysId = ok ? (assignedSysId || tableSysId) : "";
250
+ var note;
251
+ if (ok) {
252
+ note = "Created via form save. Verify the " + graph.total
253
+ + " records landed in scope " + scopeRef.sysId + " and the pinned update set.";
254
+ }
255
+ else {
256
+ note = "save POST returned " + resp.status + " (expected 302). " + resp.body.slice(0, 200);
257
+ }
258
+ if (params.debug) {
259
+ note += " [debug: appSwitch=" + appSwitch.status + (appSwitch.ok ? "/ok" : "/FAIL:" + appSwitch.body)
260
+ + " appSysId=" + (appSysId || "(none)")
261
+ + " harvestedListEditKey=" + (harvest.listEditKey ? "yes" : "no")
262
+ + " colKey=" + colKey
263
+ + " fieldCount=" + Object.keys(fields).length
264
+ + " location=" + resp.location
265
+ + " sentSysId=" + tableSysId
266
+ + " assignedSysId=" + (assignedSysId || "(unparsed)") + "]";
267
+ }
203
268
  return {
204
269
  status: ok ? "created" : "failed",
205
- tableSysId: ok ? tableSysId : "",
270
+ tableSysId: finalSysId,
206
271
  name: params.name,
207
272
  label: params.label,
208
273
  scopeSysId: scopeRef.sysId,
@@ -212,9 +277,6 @@ async function createTable(params) {
212
277
  httpStatus: resp.status,
213
278
  location: resp.location,
214
279
  columnXml: columnXml,
215
- note: ok
216
- ? "LIVE PATH NOT YET VALIDATED — verify the " + graph.total
217
- + " sys_update_xml rows landed in the pinned update set before trusting this result."
218
- : "save POST returned " + resp.status + " (expected 302). " + resp.body.slice(0, 200)
280
+ note: note
219
281
  };
220
282
  }
@@ -8,8 +8,8 @@
8
8
  * buildColumnXml.ts. Uses Node 18+ global fetch (the package targets Node >=22).
9
9
  * ES6 only, no optional chaining.
10
10
  *
11
- * NOT YET VALIDATED LIVE — pending the create-table spike (B2). The HTML harvest
12
- * in getNewRecordForm is the documented "112-field fidelity" risk surface.
11
+ * VALIDATED LIVE 2026-06-13 (tenonworkstudio). setCurrentApplication() puts the
12
+ * session in the target scope before the save (the scope-correctness lever).
13
13
  */
14
14
  /** Resolved instance + credentials for a form session. */
15
15
  export interface FormAuth {
@@ -32,6 +32,20 @@ export interface FormSession {
32
32
  export declare function scrapeCk(html: string): string;
33
33
  /** Form-login (login.do) and return an authenticated { ck, jar }. */
34
34
  export declare function openFormSession(auth: FormAuth): Promise<FormSession>;
35
+ /**
36
+ * Switch the form session's CURRENT APPLICATION to `appSysId`. A new table's scope
37
+ * is governed by the session's current app, not a form field — and setting
38
+ * `sysparm_transaction_scope` on the POST triggers a cross-scope interstitial that
39
+ * bounces the save to welcome.do. The faithful lever is the same app-picker the UI
40
+ * drives: PUT the Next-Experience concourse picker, which updates both the session's
41
+ * in-memory current app and the `apps.current_app` user preference. Returns true if
42
+ * the picker accepted the switch. Best-effort: a non-2xx is reported, not thrown.
43
+ */
44
+ export declare function setCurrentApplication(auth: FormAuth, session: FormSession, appSysId: string): Promise<{
45
+ ok: boolean;
46
+ status: number;
47
+ body: string;
48
+ }>;
35
49
  /** The fields harvested from the new-record form, plus the discovered list-edit key. */
36
50
  export interface HarvestedForm {
37
51
  fields: Record<string, string>;
@@ -41,11 +55,10 @@ export interface HarvestedForm {
41
55
  /**
42
56
  * GET the new sys_db_object form and harvest every hidden/input field the browser
43
57
  * would submit (sysparm_ck, sysparm_encoded_record, the dynamic `<sysid>_text`
44
- * field, every sys_original.* default, and the list-edit formatter key). The
45
- * caller overlays capability params onto `fields` before POSTing.
46
- *
47
- * NOT YET VALIDATED LIVE the HTML parse is a best-effort harvest of the
48
- * documented field set; confirm against a real form during the spike.
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.
49
62
  */
50
63
  export declare function getNewRecordForm(auth: FormAuth, session: FormSession): Promise<HarvestedForm>;
51
64
  /**
@@ -9,13 +9,14 @@
9
9
  * buildColumnXml.ts. Uses Node 18+ global fetch (the package targets Node >=22).
10
10
  * ES6 only, no optional chaining.
11
11
  *
12
- * NOT YET VALIDATED LIVE — pending the create-table spike (B2). The HTML harvest
13
- * in getNewRecordForm is the documented "112-field fidelity" risk surface.
12
+ * VALIDATED LIVE 2026-06-13 (tenonworkstudio). setCurrentApplication() puts the
13
+ * session in the target scope before the save (the scope-correctness lever).
14
14
  */
15
15
  Object.defineProperty(exports, "__esModule", { value: true });
16
16
  exports.resolveFormAuth = resolveFormAuth;
17
17
  exports.scrapeCk = scrapeCk;
18
18
  exports.openFormSession = openFormSession;
19
+ exports.setCurrentApplication = setCurrentApplication;
19
20
  exports.getNewRecordForm = getNewRecordForm;
20
21
  exports.parseFormInputs = parseFormInputs;
21
22
  exports.postForm = postForm;
@@ -94,14 +95,50 @@ async function openFormSession(auth) {
94
95
  throw new Error("form login failed (no authenticated g_ck) — check SN_USER/SN_PASSWORD.");
95
96
  return { ck: ck, jar: jar };
96
97
  }
98
+ /**
99
+ * Switch the form session's CURRENT APPLICATION to `appSysId`. A new table's scope
100
+ * is governed by the session's current app, not a form field — and setting
101
+ * `sysparm_transaction_scope` on the POST triggers a cross-scope interstitial that
102
+ * bounces the save to welcome.do. The faithful lever is the same app-picker the UI
103
+ * drives: PUT the Next-Experience concourse picker, which updates both the session's
104
+ * in-memory current app and the `apps.current_app` user preference. Returns true if
105
+ * the picker accepted the switch. Best-effort: a non-2xx is reported, not thrown.
106
+ */
107
+ async function setCurrentApplication(auth, session, appSysId) {
108
+ if (!appSysId)
109
+ return { ok: false, status: 0, body: "no appSysId" };
110
+ var B = base(auth);
111
+ var res = await fetch(B + "/api/now/ui/concoursepicker/application", {
112
+ method: "PUT",
113
+ headers: {
114
+ "X-UserToken": session.ck,
115
+ Cookie: cookieHeader(session.jar),
116
+ "Content-Type": "application/json",
117
+ Accept: "application/json"
118
+ },
119
+ // The picker's ApplicationProcessor expects `app_id` (a `value` body returns
120
+ // 400 "Missing Application Id").
121
+ body: JSON.stringify({ app_id: appSysId }),
122
+ redirect: "manual"
123
+ });
124
+ jarFrom(res, session.jar);
125
+ var body = "";
126
+ try {
127
+ body = await res.text();
128
+ }
129
+ catch (e) {
130
+ body = "";
131
+ }
132
+ var ok = res.status >= 200 && res.status < 300;
133
+ return { ok: ok, status: res.status, body: body.slice(0, 200) };
134
+ }
97
135
  /**
98
136
  * GET the new sys_db_object form and harvest every hidden/input field the browser
99
137
  * would submit (sysparm_ck, sysparm_encoded_record, the dynamic `<sysid>_text`
100
- * field, every sys_original.* default, and the list-edit formatter key). The
101
- * caller overlays capability params onto `fields` before POSTing.
102
- *
103
- * NOT YET VALIDATED LIVE the HTML parse is a best-effort harvest of the
104
- * documented field set; confirm against a real form during the spike.
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.
105
142
  */
106
143
  async function getNewRecordForm(auth, session) {
107
144
  var B = base(auth);
@@ -3,11 +3,11 @@
3
3
  * sys_db_object.do save). See createTable.ts for the full sequence + the
4
4
  * live-validation caveat.
5
5
  */
6
- export { createTable, projectTableGraph, DEFAULT_SUPER_CLASS, DEFAULT_SAVE_ACTION } from "./createTable";
6
+ export { createTable, projectTableGraph, parseSysIdFromLocation, DEFAULT_SUPER_CLASS, DEFAULT_SAVE_ACTION, DEFAULT_COLUMNS_REL_ID } from "./createTable";
7
7
  export type { CreateTableParams, CreateTableResult, TableGraph } from "./createTable";
8
8
  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, getNewRecordForm, parseFormInputs, postForm, scrapeCk } from "./formSession";
12
+ export { resolveFormAuth, openFormSession, setCurrentApplication, getNewRecordForm, parseFormInputs, postForm, scrapeCk } from "./formSession";
13
13
  export type { FormAuth, FormSession, HarvestedForm, PostResult } from "./formSession";
@@ -5,12 +5,14 @@
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.openFormSession = exports.resolveFormAuth = exports.TYPE_MAP = exports.listEditKey = exports.showInMenuKey = exports.defaultAccessFlags = exports.applyTableSaveOverlay = exports.resolveType = exports.normalizeColumns = exports.xmlEscape = exports.buildColumnXml = exports.DEFAULT_SAVE_ACTION = exports.DEFAULT_SUPER_CLASS = exports.projectTableGraph = exports.createTable = void 0;
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;
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; } });
12
+ Object.defineProperty(exports, "parseSysIdFromLocation", { enumerable: true, get: function () { return createTable_1.parseSysIdFromLocation; } });
12
13
  Object.defineProperty(exports, "DEFAULT_SUPER_CLASS", { enumerable: true, get: function () { return createTable_1.DEFAULT_SUPER_CLASS; } });
13
14
  Object.defineProperty(exports, "DEFAULT_SAVE_ACTION", { enumerable: true, get: function () { return createTable_1.DEFAULT_SAVE_ACTION; } });
15
+ Object.defineProperty(exports, "DEFAULT_COLUMNS_REL_ID", { enumerable: true, get: function () { return createTable_1.DEFAULT_COLUMNS_REL_ID; } });
14
16
  var buildColumnXml_1 = require("./buildColumnXml");
15
17
  Object.defineProperty(exports, "buildColumnXml", { enumerable: true, get: function () { return buildColumnXml_1.buildColumnXml; } });
16
18
  Object.defineProperty(exports, "xmlEscape", { enumerable: true, get: function () { return buildColumnXml_1.xmlEscape; } });
@@ -25,6 +27,7 @@ Object.defineProperty(exports, "TYPE_MAP", { enumerable: true, get: function ()
25
27
  var formSession_1 = require("./formSession");
26
28
  Object.defineProperty(exports, "resolveFormAuth", { enumerable: true, get: function () { return formSession_1.resolveFormAuth; } });
27
29
  Object.defineProperty(exports, "openFormSession", { enumerable: true, get: function () { return formSession_1.openFormSession; } });
30
+ Object.defineProperty(exports, "setCurrentApplication", { enumerable: true, get: function () { return formSession_1.setCurrentApplication; } });
28
31
  Object.defineProperty(exports, "getNewRecordForm", { enumerable: true, get: function () { return formSession_1.getNewRecordForm; } });
29
32
  Object.defineProperty(exports, "parseFormInputs", { enumerable: true, get: function () { return formSession_1.parseFormInputs; } });
30
33
  Object.defineProperty(exports, "postForm", { enumerable: true, get: function () { return formSession_1.postForm; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenonhq/dovetail-servicenow",
3
- "version": "0.0.19",
3
+ "version": "0.0.21",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },