@tenonhq/dovetail-servicenow 0.0.22 → 0.0.24

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
@@ -79,6 +79,9 @@ const editFlow_1 = require("./flowDesigner/editFlow");
79
79
  const editActionType_1 = require("./flowDesigner/editActionType");
80
80
  const testFlow_1 = require("./flowDesigner/testFlow");
81
81
  const table_1 = require("./table");
82
+ const setField_1 = require("./setField");
83
+ const createRecord_1 = require("./createRecord");
84
+ const hostAssets_1 = require("./hostAssets");
82
85
  const flowDesigner_formatter_2 = require("./flowDesigner-formatter");
83
86
  function parseArgs(argv) {
84
87
  var command = argv[0] || "";
@@ -710,6 +713,15 @@ function printHelp() {
710
713
  " (--table <name|sys_id> --label <l> --type <t>\n" +
711
714
  " [--name <element>] [--max-length <n>] [--reference <table>]\n" +
712
715
  " [--scope <s>] [--update-set <sys_id>] [--dry-run] [--json])\n" +
716
+ " set-field Set scalar field value(s) on an EXISTING record, into an update set, then verify\n" +
717
+ " (--table <t> --sys-id <id>|--query <q> --fields \"k=v,k2=v2\"\n" +
718
+ " --update-set <sys_id> [--dry-run] [--json])\n" +
719
+ " create-record Create ONE NEW record in a data table, into an update set, then verify\n" +
720
+ " (--table <t> --fields \"k=v,k2=v2\" --scope <s> --update-set <sys_id>\n" +
721
+ " [--if-absent <encoded-query>] [--dry-run] [--json])\n" +
722
+ " host-assets Deploy a built dist/ to ServiceNow (carrier sys_ui_script + attachment + m2m)\n" +
723
+ " (--dir <dist> --app <sys_id> --scope <namespace>\n" +
724
+ " [--update-set <sys_id>] [--max-bytes <n>] [--allow-oversize] [--dry-run] [--json])\n" +
713
725
  " test-flow Validate (default) or run a flow/subflow\n" +
714
726
  " (--sys-id <sys_id> [--execute --confirm] [--inputs <json>] [--json])\n" +
715
727
  " edit-flow Patch a flow/subflow (rename, description, step inputs)\n" +
@@ -871,6 +883,153 @@ async function runAddColumn(flags) {
871
883
  return 2;
872
884
  return 0;
873
885
  }
886
+ /** Parse inline `--fields "k=v, k2=v2"` into a field map. */
887
+ function parseFieldsInline(input) {
888
+ var out = {};
889
+ if (!input)
890
+ return out;
891
+ var parts = input.split(",");
892
+ for (var i = 0; i < parts.length; i += 1) {
893
+ var piece = parts[i].trim();
894
+ if (!piece)
895
+ continue;
896
+ var eq = piece.indexOf("=");
897
+ if (eq === -1)
898
+ continue;
899
+ var key = piece.slice(0, eq).trim();
900
+ if (key)
901
+ out[key] = piece.slice(eq + 1).trim();
902
+ }
903
+ return out;
904
+ }
905
+ /**
906
+ * dove-sn set-field:
907
+ * --table x_cadso_core_metric_point_type
908
+ * --sys-id <id> | --query "name=send_size" (query must resolve to exactly 1 row)
909
+ * --fields "order=20" (comma-separated key=value pairs)
910
+ * --update-set <sys_id> (required — the change is captured here)
911
+ * [--dry-run] [--json]
912
+ * Exit codes: 0 applied/dry-run, 1 bad args, 2 write landed but read-back unverified.
913
+ */
914
+ async function runSetField(flags) {
915
+ var table = flags.table;
916
+ var fields = parseFieldsInline(flags.fields || "");
917
+ var hasTarget = Boolean(flags["sys-id"] || flags.query);
918
+ if (!table || Object.keys(fields).length === 0 || !hasTarget || !flags["update-set"]) {
919
+ process.stderr.write("set-field: --table, --fields \"k=v\", one of --sys-id/--query, and --update-set are required\n");
920
+ return 1;
921
+ }
922
+ var params = {
923
+ client: (0, client_1.createClient)({}),
924
+ table: table,
925
+ fields: fields,
926
+ updateSetSysId: flags["update-set"]
927
+ };
928
+ if (flags["sys-id"])
929
+ params.sysId = flags["sys-id"];
930
+ if (flags.query)
931
+ params.query = flags.query;
932
+ if (flags["dry-run"] === "true")
933
+ params.dryRun = true;
934
+ var result = await (0, setField_1.setField)(params);
935
+ if (flags.json === "true") {
936
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
937
+ }
938
+ else {
939
+ process.stdout.write("[" + result.status + "] " + result.table + "/" + result.sysId + " "
940
+ + JSON.stringify(result.fields) + (result.verified ? " — verified" : "")
941
+ + "\n" + result.note + "\n");
942
+ }
943
+ if (result.status === "failed")
944
+ return 2;
945
+ return 0;
946
+ }
947
+ /**
948
+ * dove-sn create-record:
949
+ * --table x_cadso_core_metric_point_type
950
+ * --fields "name=avg_message_parts,label=Avg. Message Parts,order=35"
951
+ * --scope x_cadso_core (the app that owns the new record)
952
+ * --update-set <sys_id> (required — the insert is captured here)
953
+ * [--if-absent "name=avg_message_parts"] (skip the insert when this query already matches)
954
+ * [--dry-run] [--json]
955
+ * Exit codes: 0 created/skipped-in-sync/dry-run, 1 bad args, 2 write landed but read-back unverified
956
+ * (or skipped with drift).
957
+ */
958
+ async function runCreateRecord(flags) {
959
+ var table = flags.table;
960
+ var fields = parseFieldsInline(flags.fields || "");
961
+ if (!table || Object.keys(fields).length === 0 || !flags.scope || !flags["update-set"]) {
962
+ process.stderr.write("create-record: --table, --fields \"k=v\", --scope and --update-set are required\n");
963
+ return 1;
964
+ }
965
+ var params = {
966
+ client: (0, client_1.createClient)({}),
967
+ table: table,
968
+ fields: fields,
969
+ scope: flags.scope,
970
+ updateSetSysId: flags["update-set"]
971
+ };
972
+ if (flags["if-absent"])
973
+ params.ifAbsentQuery = flags["if-absent"];
974
+ if (flags["dry-run"] === "true")
975
+ params.dryRun = true;
976
+ var result = await (0, createRecord_1.createRecord)(params);
977
+ if (flags.json === "true") {
978
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
979
+ }
980
+ else {
981
+ process.stdout.write("[" + result.status + "] " + result.table + "/" + (result.sysId || "(new)") + " "
982
+ + JSON.stringify(result.fields) + (result.verified ? " — verified" : "")
983
+ + "\n" + result.note + "\n");
984
+ }
985
+ if (result.status === "failed")
986
+ return 2;
987
+ if (result.status === "skipped" && !result.verified)
988
+ return 2;
989
+ return 0;
990
+ }
991
+ /**
992
+ * dove-sn host-assets:
993
+ * --dir <dist> Required. Path to the pre-built dist/ directory.
994
+ * --app <sys_id> Required. Application record sys_id (m2m `application`).
995
+ * --scope <namespace> Required. Carrier scope, e.g. x_cadso_app_shell.
996
+ * --update-set <sys_id> Optional. Defaults to the scope's current update set.
997
+ * --max-bytes <n> Optional. Per-chunk serve cap (default ~5 MB).
998
+ * --allow-oversize Optional. Warn instead of failing on an oversize chunk.
999
+ * --dry-run Optional. Plan only; no writes/uploads/prunes.
1000
+ * --json Optional. Emit the structured HostAssetsResult.
1001
+ *
1002
+ * Exit codes: 0 done/dry-run, 1 bad args, 2 a write landed but read-back is unverified.
1003
+ */
1004
+ async function runHostAssets(flags) {
1005
+ var dir = flags.dir;
1006
+ var app = flags.app;
1007
+ var scope = flags.scope;
1008
+ if (!dir || !app || !scope) {
1009
+ process.stderr.write("host-assets: --dir, --app and --scope are required\n");
1010
+ return 1;
1011
+ }
1012
+ var params = { dir: path.resolve(dir), app: app, scope: scope };
1013
+ var us = flags["update-set"] || flags.updateSetSysId;
1014
+ if (us)
1015
+ params.updateSetSysId = us;
1016
+ if (flags["max-bytes"])
1017
+ params.maxBytes = Number(flags["max-bytes"]);
1018
+ if (flags["allow-oversize"] === "true")
1019
+ params.allowOversize = true;
1020
+ if (flags["dry-run"] === "true")
1021
+ params.dryRun = true;
1022
+ var client = (0, client_1.createClient)({});
1023
+ var result = await (0, hostAssets_1.hostAssets)(client, params);
1024
+ if (flags.json === "true") {
1025
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1026
+ }
1027
+ else {
1028
+ process.stdout.write((0, hostAssets_1.formatHostAssetsResult)(result) + "\n");
1029
+ }
1030
+ var unverified = !result.dryRun && result.chunks.some(function (c) { return !c.verified; });
1031
+ return unverified ? 2 : 0;
1032
+ }
874
1033
  async function main() {
875
1034
  var parsed = parseArgs(process.argv.slice(2));
876
1035
  // Load credentials before any command runs. `--env`/`--env-file` (or the
@@ -905,6 +1064,15 @@ async function main() {
905
1064
  if (parsed.command === "add-column") {
906
1065
  return await runAddColumn(parsed.flags);
907
1066
  }
1067
+ if (parsed.command === "set-field") {
1068
+ return await runSetField(parsed.flags);
1069
+ }
1070
+ if (parsed.command === "create-record") {
1071
+ return await runCreateRecord(parsed.flags);
1072
+ }
1073
+ if (parsed.command === "host-assets") {
1074
+ return await runHostAssets(parsed.flags);
1075
+ }
908
1076
  if (parsed.command === "test-flow") {
909
1077
  return await runTestFlow(parsed.flags);
910
1078
  }
package/dist/client.d.ts CHANGED
@@ -34,6 +34,15 @@ export interface TableSchema {
34
34
  fields: Array<TableSchemaField>;
35
35
  primary_key: string;
36
36
  }
37
+ /** A sys_attachment row, as returned by the native Attachment API. */
38
+ export interface AttachmentMeta {
39
+ sys_id: string;
40
+ file_name: string;
41
+ content_type: string;
42
+ /** SHA-256 hex of the file content, computed by ServiceNow. Absent on older instances. */
43
+ hash?: string;
44
+ size_bytes?: string;
45
+ }
37
46
  export interface ServiceNowClient {
38
47
  table: {
39
48
  /** GET /api/now/table/<t>?sysparm_query=...&sysparm_limit=N — returns result array. */
@@ -113,5 +122,31 @@ export interface ServiceNowClient {
113
122
  /** POST an arbitrary native ServiceNow REST path with a JSON body. See `get`. */
114
123
  post: <T = any>(path: string, body: any) => Promise<T>;
115
124
  };
125
+ attachment: {
126
+ /**
127
+ * GET /api/now/attachment?sysparm_query=table_name=<t>^table_sys_id=<id> —
128
+ * list the sys_attachment rows on a record. Read-only.
129
+ */
130
+ listFor: (params: {
131
+ table: string;
132
+ sysId: string;
133
+ }) => Promise<Array<AttachmentMeta>>;
134
+ /**
135
+ * POST /api/now/attachment/file — upload raw bytes as a sys_attachment on a record.
136
+ * The Buffer is sent verbatim with `contentType` as the request Content-Type (binary,
137
+ * not JSON). Reuses the shared auth/retry/throttle transport — not a bespoke HTTP path.
138
+ */
139
+ upload: (params: {
140
+ table: string;
141
+ sysId: string;
142
+ fileName: string;
143
+ contentType: string;
144
+ data: Buffer;
145
+ }) => Promise<AttachmentMeta>;
146
+ /** DELETE /api/now/attachment/<sysId> — remove a single attachment. */
147
+ remove: (params: {
148
+ sysId: string;
149
+ }) => Promise<void>;
150
+ };
116
151
  }
117
152
  export declare function createClient(config?: ServiceNowClientConfig): ServiceNowClient;
package/dist/client.js CHANGED
@@ -316,6 +316,37 @@ function createClient(config = {}) {
316
316
  post: function (path, body) {
317
317
  return request({ method: "POST", url: path, data: body }, "now.post(" + path + ")");
318
318
  }
319
+ },
320
+ attachment: {
321
+ listFor: async function (params) {
322
+ var data = await request({
323
+ method: "GET",
324
+ url: "/api/now/attachment",
325
+ params: {
326
+ sysparm_query: "table_name=" + params.table + "^table_sys_id=" + params.sysId,
327
+ sysparm_fields: "sys_id,file_name,content_type,hash,size_bytes"
328
+ }
329
+ }, "attachment.listFor(" + params.table + ")");
330
+ return (data && data.result) || [];
331
+ },
332
+ upload: async function (params) {
333
+ var data = await request({
334
+ method: "POST",
335
+ url: "/api/now/attachment/file",
336
+ params: {
337
+ table_name: params.table,
338
+ table_sys_id: params.sysId,
339
+ file_name: params.fileName
340
+ },
341
+ data: params.data,
342
+ // Binary upload: override the client's default application/json.
343
+ headers: { "content-type": params.contentType }
344
+ }, "attachment.upload(" + params.fileName + ")");
345
+ return (data && data.result) || data;
346
+ },
347
+ remove: async function (params) {
348
+ await request({ method: "DELETE", url: "/api/now/attachment/" + params.sysId }, "attachment.remove(" + params.sysId + ")");
349
+ }
319
350
  }
320
351
  };
321
352
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * dove-sn create-record — create ONE new record in a data table, captured
3
+ * into a specified update set, then read back and verify.
4
+ *
5
+ * Wraps the Dovetail core Scripted REST `createRecord` op, which switches the
6
+ * executing user's app scope and update set server-side, inserts, and
7
+ * restores both — so the record is owned by the right app and the insert is
8
+ * captured in the right update set without touching sys_user_preference.
9
+ * The INSERT counterpart to `set-field`.
10
+ *
11
+ * NOT for schema tables (sys_db_object / sys_dictionary) — that's
12
+ * create-table / add-column. To UPDATE an existing record, use `set-field`.
13
+ */
14
+ import type { ServiceNowClient } from "./client";
15
+ export interface CreateRecordParams {
16
+ client?: ServiceNowClient;
17
+ table: string;
18
+ /** Field name -> value for the new record. Values are sent as strings; ServiceNow coerces. */
19
+ fields: Record<string, string>;
20
+ /** App scope that will own the record. Required — session-scope stamping is the #1 wrong-scope cause. */
21
+ scope?: string;
22
+ /** Update set to capture the insert into. Required for a tracked write. */
23
+ updateSetSysId?: string;
24
+ /** Encoded query; when it already matches a row the insert is skipped (idempotent re-runs). */
25
+ ifAbsentQuery?: string;
26
+ dryRun?: boolean;
27
+ }
28
+ export interface CreateRecordResult {
29
+ status: "dry-run" | "created" | "skipped" | "failed";
30
+ table: string;
31
+ sysId: string;
32
+ scope: string;
33
+ updateSetSysId: string;
34
+ fields: Record<string, string>;
35
+ after: Record<string, string>;
36
+ verified: boolean;
37
+ note: string;
38
+ }
39
+ export declare function createRecord(params: CreateRecordParams): Promise<CreateRecordResult>;
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ /**
3
+ * dove-sn create-record — create ONE new record in a data table, captured
4
+ * into a specified update set, then read back and verify.
5
+ *
6
+ * Wraps the Dovetail core Scripted REST `createRecord` op, which switches the
7
+ * executing user's app scope and update set server-side, inserts, and
8
+ * restores both — so the record is owned by the right app and the insert is
9
+ * captured in the right update set without touching sys_user_preference.
10
+ * The INSERT counterpart to `set-field`.
11
+ *
12
+ * NOT for schema tables (sys_db_object / sys_dictionary) — that's
13
+ * create-table / add-column. To UPDATE an existing record, use `set-field`.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.createRecord = createRecord;
17
+ const client_1 = require("./client");
18
+ const setField_1 = require("./setField");
19
+ // Same guard as set-field: schema tables are never written as data — routed to
20
+ // the dedicated schema verbs instead so we never orphan or corrupt metadata.
21
+ var REFUSED_TABLES = ["sys_db_object", "sys_dictionary"];
22
+ async function createRecord(params) {
23
+ var client = params.client || (0, client_1.createClient)({});
24
+ var table = params.table;
25
+ if (!table) {
26
+ throw new Error("create-record: --table is required.");
27
+ }
28
+ if (REFUSED_TABLES.indexOf(table) !== -1) {
29
+ throw new Error("create-record: refusing to write " + table + " as data — it is a schema table. "
30
+ + "Use add-column / create-table for schema changes.");
31
+ }
32
+ var fieldNames = params.fields ? Object.keys(params.fields) : [];
33
+ if (fieldNames.length === 0) {
34
+ throw new Error("create-record: at least one field (--fields key=value) is required.");
35
+ }
36
+ if (!params.scope) {
37
+ throw new Error("create-record: --scope is required so the record is owned by the right app.");
38
+ }
39
+ if (!params.updateSetSysId) {
40
+ throw new Error("create-record: --update-set <sys_id> is required so the insert is captured.");
41
+ }
42
+ var readFields = ["sys_id"].concat(fieldNames);
43
+ // Idempotent re-runs: skip the insert when the guard query already matches.
44
+ if (params.ifAbsentQuery) {
45
+ var existing = await client.table.query(table, params.ifAbsentQuery, {
46
+ limit: 1,
47
+ fields: readFields
48
+ });
49
+ if (existing.length > 0) {
50
+ var existingSysId = (0, setField_1.fieldToString)(existing[0].sys_id);
51
+ var existingValues = (0, setField_1.pickFields)(existing[0], fieldNames);
52
+ var matches = true;
53
+ for (var i = 0; i < fieldNames.length; i += 1) {
54
+ if (existingValues[fieldNames[i]] !== (0, setField_1.fieldToString)(params.fields[fieldNames[i]])) {
55
+ matches = false;
56
+ }
57
+ }
58
+ return {
59
+ status: "skipped",
60
+ table: table,
61
+ sysId: existingSysId,
62
+ scope: params.scope,
63
+ updateSetSysId: params.updateSetSysId,
64
+ fields: params.fields,
65
+ after: existingValues,
66
+ verified: matches,
67
+ note: matches
68
+ ? "skipped: --if-absent matched " + table + "/" + existingSysId
69
+ + " and its values already match."
70
+ : "skipped: --if-absent matched " + table + "/" + existingSysId
71
+ + " but its values differ from the requested fields — use set-field to update it."
72
+ };
73
+ }
74
+ }
75
+ if (params.dryRun) {
76
+ return {
77
+ status: "dry-run",
78
+ table: table,
79
+ sysId: "",
80
+ scope: params.scope,
81
+ updateSetSysId: params.updateSetSysId,
82
+ fields: params.fields,
83
+ after: {},
84
+ verified: false,
85
+ note: "dry-run: no write. Would create a " + table + " record with "
86
+ + JSON.stringify(params.fields) + " in scope " + params.scope
87
+ + ", captured into update set " + params.updateSetSysId + "."
88
+ };
89
+ }
90
+ // Insert via the scope- and update-set-aware core REST op.
91
+ var created = await client.claude.createRecord({
92
+ table: table,
93
+ fields: params.fields,
94
+ scope: params.scope,
95
+ update_set_sys_id: params.updateSetSysId
96
+ });
97
+ var sysId = (0, setField_1.fieldToString)(created && created.sys_id);
98
+ if (!sysId) {
99
+ return {
100
+ status: "failed",
101
+ table: table,
102
+ sysId: "",
103
+ scope: params.scope,
104
+ updateSetSysId: params.updateSetSysId,
105
+ fields: params.fields,
106
+ after: {},
107
+ verified: false,
108
+ note: "createRecord returned no sys_id — the insert may not have landed; check the instance."
109
+ };
110
+ }
111
+ // Read back and verify each field equals what we sent.
112
+ var afterRows = await client.table.query(table, "sys_id=" + sysId, {
113
+ limit: 1,
114
+ fields: readFields
115
+ });
116
+ var after = (0, setField_1.pickFields)(afterRows[0] || {}, fieldNames);
117
+ var verified = afterRows.length > 0;
118
+ for (var j = 0; j < fieldNames.length; j += 1) {
119
+ if (after[fieldNames[j]] !== (0, setField_1.fieldToString)(params.fields[fieldNames[j]])) {
120
+ verified = false;
121
+ }
122
+ }
123
+ return {
124
+ status: verified ? "created" : "failed",
125
+ table: table,
126
+ sysId: sysId,
127
+ scope: params.scope,
128
+ updateSetSysId: params.updateSetSysId,
129
+ fields: params.fields,
130
+ after: after,
131
+ verified: verified,
132
+ note: verified
133
+ ? "Created " + table + "/" + sysId + " and verified via read-back."
134
+ : "Insert landed as " + sysId
135
+ + " but read-back does not match the requested values — check field types / ACLs."
136
+ };
137
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * hostAssets — deploy a pre-built front-end bundle to ServiceNow.
3
+ *
4
+ * For each chunk in a built dist/ (index.html + assets/*.{js,css}) this:
5
+ * 1. Upserts a carrier sys_ui_script named `app_shell_asset:<vite-relative-path>`.
6
+ * The name carries the rotating hash on purpose — the Scripted REST serving
7
+ * resource resolves an asset request to its carrier by this exact name, so the
8
+ * verb's naming MUST match the path the built index.html references, or the
9
+ * asset 404s on serve.
10
+ * 2. Stores the chunk's bytes as a sys_attachment on that record (the script field
11
+ * caps at 65 KB; real chunks are far larger). Identical bytes are detected by
12
+ * SHA-256 and left in place.
13
+ * 3. Wires an x_cadso_app_shell_m2m_app_script row (application, script, chunk_role,
14
+ * order) so the app shell loads the chunk.
15
+ * 4. Prunes carriers + m2m rows for chunks no longer in the build — hashes rotate
16
+ * every build, so last build's records would otherwise pile up.
17
+ *
18
+ * The sys_ui_script + m2m writes route through the Dovetail Scripted REST API and are
19
+ * captured in the target update set. Attachments are data, not customizations, so they
20
+ * are not part of the update set. Re-runnable per build, per instance; idempotent for
21
+ * an identical dist/.
22
+ *
23
+ * The serving layer streams each chunk via GlideSysAttachment.getContentStream(), capped
24
+ * at ~5 MB (glide.scriptable.excel.max_file_size). A chunk at/over the cap truncates on
25
+ * serve, so the verb fails fast on an oversize chunk unless allowOversize is set.
26
+ */
27
+ import type { ServiceNowClient } from "./client";
28
+ import type { ChunkInfo, HostAssetsParams, HostAssetsResult } from "./types";
29
+ /**
30
+ * Classify build files into ordered ChunkInfo. Pure — no filesystem access — so the
31
+ * naming + role + order logic is unit-testable in isolation.
32
+ */
33
+ export declare function classifyChunks(files: Array<{
34
+ viteRelPath: string;
35
+ ext: string;
36
+ isIndexHtml: boolean;
37
+ }>): Array<ChunkInfo>;
38
+ /**
39
+ * Deploy a built dist/ to ServiceNow as app-shell carrier scripts + attachments + m2m
40
+ * wiring. Idempotent; re-running an identical dist/ reports every chunk unchanged.
41
+ */
42
+ export declare function hostAssets(client: ServiceNowClient, params: HostAssetsParams): Promise<HostAssetsResult>;
43
+ /** Human-readable one-line-per-chunk summary for the CLI. */
44
+ export declare function formatHostAssetsResult(r: HostAssetsResult): string;