@tenonhq/dovetail-servicenow 0.0.37 → 0.0.38

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
@@ -555,6 +555,71 @@ Store `appLink`, and the publish's update-set sys_id where the instance reports
555
555
  one. Exit codes: `0` published or dry-run, `1` bad args/unconfirmed, `2`
556
556
  failed/timeout. Programmatic: `publishApp({ app, version, target, confirm })`.
557
557
 
558
+ ### Export an update set (or a whole app) to importable XML
559
+
560
+ Produce the `<unload>` document the implementation team imports on a customer
561
+ instance — with every secret value replaced by `__SET_DURING_INSTALL__`.
562
+
563
+ ```bash
564
+ # An update set. assemble mode is READ-ONLY: nothing on the instance changes.
565
+ npx dove-sn export-update-set --update-set 0123456789abcdef0123456789abcdef \
566
+ --out ./tenon-core.xml
567
+
568
+ # A whole app: publish into a new set, then export it. Dry-run first (default).
569
+ npx dove-sn export-app --app x_cadso_automate --out ./automate.xml
570
+ npx dove-sn export-app --app x_cadso_automate --out ./automate.xml --confirm
571
+
572
+ # A document exported some other way
573
+ npx dove-sn strip-secrets --in ./exported.xml --out ./safe.xml
574
+ npx dove-sn strip-secrets --in ./exported.xml --report # what would be stripped
575
+ ```
576
+
577
+ **Secret stripping is not optional.** There is no `--no-strip` flag on any of
578
+ these verbs, and no field in the MCP schemas that disables it. This matters
579
+ because an unload carries field *values*: on tenonworkstudio, completed update
580
+ sets hold filled-in values for `password2` system properties and
581
+ `oauth_entity.client_secret`, several of them in sets that shipped to a
582
+ customer. A field is exempted only by a reviewed entry in the rules file.
583
+
584
+ The rule is enumerable, in four layers:
585
+
586
+ | Layer | Covers |
587
+ |---|---|
588
+ | L1 type | `password` / `password2` fields, restricted to tables an update set can capture (`update_synch=true`), resolved through `super_class`. Refreshed from the live dictionary per run, with a committed baseline as the fallback. |
589
+ | L2 conditional / explicit | `sys_properties.value` when the property's `type` is a password type; named exceptions such as `x_cadso_core.google_translate_api_key`, which holds an API key in a *string* column. |
590
+ | L3 JSON | secrets nested inside a JSON blob field, stripped in place. |
591
+ | L4 heuristic | a field that merely *looks* secret. **Never stripped silently and never assumed safe** — the run fails and names it, until a human records it in the rules file as a strip rule or as `notSecret` with a reason. |
592
+
593
+ Override the rules with `--rules <file>`; the JSON is merged over the built-ins,
594
+ and the only subtractive key is `notSecret`, which requires a reason:
595
+
596
+ ```json
597
+ {
598
+ "notSecret": [
599
+ { "table": "x_cadso_core_thing", "field": "webhook_token", "reason": "public identifier, not a credential" }
600
+ ],
601
+ "fieldRules": [
602
+ { "id": "thing-signing-key", "table": "x_cadso_core_thing", "field": "signing_key", "reason": "inbound webhook HMAC key" }
603
+ ]
604
+ }
605
+ ```
606
+
607
+ Two more things refuse to produce a file rather than produce a wrong one: a
608
+ record count that does not match the set, and the documented **in-progress
609
+ empty 200** from `export_update_set.do` (the servlet streams a document only for
610
+ a *complete* set, and app-publish leaves the set in progress). After stripping,
611
+ the output is re-read and verified; a secret that somehow survived fails the run.
612
+
613
+ `export-update-set --mode complete` marks the set complete on the instance
614
+ first — a real write, so it needs `--confirm`. `export-app` publishes ~1000+
615
+ records into a new update set and is dry-run by default. Neither is the Store
616
+ publish; that is `publish-app`, which is externally visible.
617
+
618
+ Exit codes: `0` exported or dry-run, `1` bad args/unconfirmed, `2`
619
+ failed/timeout. Programmatic: `exportUpdateSet({ updateSet, mode })`,
620
+ `exportApp({ app, confirm })`, `stripSecrets(xml, rules)`. MCP:
621
+ `update_set_export` (read-only in assemble mode) and `app_export`.
622
+
558
623
  `test-flow` defaults to **validate** — a safe pre-flight (published? inputs match
559
624
  declared variables?) that never runs the flow; `--execute --confirm` runs it via
560
625
  the server-side FlowAPI runner (deploy `resources/runFlow.md` first).
package/dist/cli.js CHANGED
@@ -69,6 +69,10 @@ const formLayout_1 = require("./layout/formLayout");
69
69
  const relatedLists_1 = require("./layout/relatedLists");
70
70
  const formatter_2 = require("./layout/formatter");
71
71
  const server_1 = require("./mcp/server");
72
+ const exportUpdateSet_1 = require("./exportUpdateSet");
73
+ const exportApp_1 = require("./exportApp");
74
+ const stripSecrets_1 = require("./secrets/stripSecrets");
75
+ const secretRules_1 = require("./secrets/secretRules");
72
76
  const schemas_1 = require("./mcp/schemas");
73
77
  const buildFlowOrchestrator_1 = require("./flowDesigner/buildFlowOrchestrator");
74
78
  const flowDesigner_formatter_1 = require("./flowDesigner-formatter");
@@ -1048,6 +1052,22 @@ function printHelp() {
1048
1052
  " [--dry-run] [--json] [--confirm])\n" +
1049
1053
  " Store creds: SN_STORE_USERNAME/SN_STORE_PASSWORD in the --env file;\n" +
1050
1054
  " the password is never a flag. Repo publish needs the sn_cicd role.\n" +
1055
+ " export-update-set Export an update set to importable <unload> XML, with secret\n" +
1056
+ " values replaced by __SET_DURING_INSTALL__ (no opt-out).\n" +
1057
+ " assemble mode is READ-ONLY; complete mode marks the set\n" +
1058
+ " complete on the instance and needs --confirm.\n" +
1059
+ " (--update-set <sys_id|name> --out <file>\n" +
1060
+ " [--mode assemble|complete]\n" +
1061
+ " [--rules <file>] [--page-size <n>] [--max-rows <n>]\n" +
1062
+ " [--dry-run] [--json] [--confirm])\n" +
1063
+ " export-app Publish a scoped app into a new update set and export it.\n" +
1064
+ " PUBLISHING IS A REAL INSTANCE WRITE (~1000+ records).\n" +
1065
+ " DRY-RUN BY DEFAULT — nothing is published without --confirm\n" +
1066
+ " (--app <scope|sys_id|name> --out <file> [--version <v>]\n" +
1067
+ " [--description <text>] [--include-data] [--rules <file>]\n" +
1068
+ " [--timeout-ms <n>] [--dry-run] [--json] [--confirm])\n" +
1069
+ " strip-secrets Strip secret values from an unload XML exported elsewhere\n" +
1070
+ " (--in <file> [--out <file>] [--rules <file>] [--report] [--json])\n" +
1051
1071
  " mcp Run the MCP stdio server (--smoke lists tools and exits)\n" +
1052
1072
  "\nGlobal flags:\n" +
1053
1073
  " --env <path> Load credentials from a specific .env file (also --env-file,\n" +
@@ -1439,7 +1459,9 @@ async function runSetTable(flags, bare) {
1439
1459
  var stringFlags = ["table", "update-set", "updateSetSysId"];
1440
1460
  for (var f = 0; f < stringFlags.length; f += 1) {
1441
1461
  if (bare[stringFlags[f]]) {
1442
- process.stderr.write("set-table: --" + stringFlags[f] + " needs a value (it was given none).\n");
1462
+ process.stderr.write("set-table: --" +
1463
+ stringFlags[f] +
1464
+ " needs a value (it was given none).\n");
1443
1465
  return 1;
1444
1466
  }
1445
1467
  }
@@ -1902,6 +1924,232 @@ async function runPublishApp(flags) {
1902
1924
  }
1903
1925
  return exitCode;
1904
1926
  }
1927
+ /**
1928
+ * dove-sn export-update-set:
1929
+ * --update-set <sys_id|name> Required. The set to export.
1930
+ * [--mode assemble|complete] assemble (default) is READ-ONLY; complete marks
1931
+ * the set complete on the instance first, which is
1932
+ * a real write and needs --confirm.
1933
+ * [--out <file>] Write the XML here (default: stdout is NOT used —
1934
+ * a document this size belongs in a file).
1935
+ * [--rules <file>] JSON overrides for the secret rules.
1936
+ * [--page-size <n>] [--max-rows <n>]
1937
+ * [--dry-run] [--json] [--confirm]
1938
+ *
1939
+ * Secret values are ALWAYS replaced with __SET_DURING_INSTALL__; there is no
1940
+ * opt-out flag. A field that looks secret and is covered by no rule fails the
1941
+ * run, and nothing is written.
1942
+ * Exit codes: 0 exported/dry-run, 1 bad args/unconfirmed, 2 failed.
1943
+ */
1944
+ async function runExportUpdateSet(flags) {
1945
+ var selector = flags["update-set"];
1946
+ if (!selector) {
1947
+ process.stderr.write("export-update-set: --update-set <sys_id|name> is required\n");
1948
+ return 1;
1949
+ }
1950
+ var mode = flags.mode || "assemble";
1951
+ if (mode !== "assemble" && mode !== "complete") {
1952
+ process.stderr.write("export-update-set: --mode must be assemble or complete\n");
1953
+ return 1;
1954
+ }
1955
+ var outPath = flags.out;
1956
+ if (!outPath && flags["dry-run"] !== "true") {
1957
+ process.stderr.write("export-update-set: --out <file> is required for a real export\n");
1958
+ return 1;
1959
+ }
1960
+ var pageSize = undefined;
1961
+ if (flags["page-size"]) {
1962
+ pageSize = Number(flags["page-size"]);
1963
+ if (!Number.isInteger(pageSize) || pageSize < 1) {
1964
+ process.stderr.write("export-update-set: --page-size must be a positive integer\n");
1965
+ return 1;
1966
+ }
1967
+ }
1968
+ var maxRows = undefined;
1969
+ if (flags["max-rows"]) {
1970
+ maxRows = Number(flags["max-rows"]);
1971
+ if (!Number.isInteger(maxRows) || maxRows < 1) {
1972
+ process.stderr.write("export-update-set: --max-rows must be a positive integer\n");
1973
+ return 1;
1974
+ }
1975
+ }
1976
+ var result = await (0, exportUpdateSet_1.exportUpdateSet)({
1977
+ updateSet: selector,
1978
+ mode: mode,
1979
+ confirm: flags.confirm === "true",
1980
+ dryRun: flags["dry-run"] === "true",
1981
+ rulesPath: flags.rules,
1982
+ pageSize: pageSize,
1983
+ maxRows: maxRows,
1984
+ });
1985
+ if (result.status === "exported" && result.xml && outPath) {
1986
+ fs.writeFileSync(path.resolve(outPath), result.xml, "utf8");
1987
+ }
1988
+ writeExportReceipt(flags, result, outPath);
1989
+ if (result.status === "failed") {
1990
+ return 2;
1991
+ }
1992
+ if (result.status === "dry-run" && flags["dry-run"] !== "true") {
1993
+ return 1;
1994
+ }
1995
+ return 0;
1996
+ }
1997
+ /** Shared receipt for both export verbs. */
1998
+ function writeExportReceipt(flags, result, outPath) {
1999
+ if (flags.json === "true") {
2000
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
2001
+ return;
2002
+ }
2003
+ process.stdout.write("[" +
2004
+ result.status +
2005
+ "]" +
2006
+ (outPath && result.status === "exported"
2007
+ ? " " + path.resolve(outPath)
2008
+ : "") +
2009
+ "\n" +
2010
+ result.note +
2011
+ "\n");
2012
+ if (result.secretFields.length > 0) {
2013
+ process.stdout.write("Set these after loading the package:\n");
2014
+ for (var i = 0; i < result.secretFields.length; i += 1) {
2015
+ process.stdout.write(" " +
2016
+ result.secretFields[i].table +
2017
+ "." +
2018
+ result.secretFields[i].field +
2019
+ "\n");
2020
+ }
2021
+ }
2022
+ }
2023
+ /**
2024
+ * dove-sn export-app:
2025
+ * --app <scope|sys_id|name> Required. The sys_app to publish and export.
2026
+ * [--version <v>] Publish version (default: the app's current one).
2027
+ * [--description <text>] Recorded on the update set.
2028
+ * [--include-data] Ship table DATA as well as schema (off by default).
2029
+ * --out <file> Where to write the XML.
2030
+ * [--rules <file>] [--timeout-ms <n>]
2031
+ * [--dry-run] [--json] [--confirm]
2032
+ *
2033
+ * PUBLISHING IS A REAL INSTANCE WRITE — a new update set and ~1000+ records.
2034
+ * DRY-RUN BY DEFAULT. Secret values are always stripped before the file lands.
2035
+ * Exit codes: 0 exported/dry-run, 1 bad args/unconfirmed, 2 failed/timeout.
2036
+ */
2037
+ async function runExportApp(flags) {
2038
+ var app = flags.app;
2039
+ if (!app) {
2040
+ process.stderr.write("export-app: --app <scope|sys_id|name> is required\n");
2041
+ return 1;
2042
+ }
2043
+ var outPath = flags.out;
2044
+ if (!outPath && flags["dry-run"] !== "true") {
2045
+ process.stderr.write("export-app: --out <file> is required for a real export\n");
2046
+ return 1;
2047
+ }
2048
+ var timeoutMs = undefined;
2049
+ if (flags["timeout-ms"]) {
2050
+ timeoutMs = Number(flags["timeout-ms"]);
2051
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
2052
+ process.stderr.write("export-app: --timeout-ms must be a positive integer\n");
2053
+ return 1;
2054
+ }
2055
+ }
2056
+ var result = await (0, exportApp_1.exportApp)({
2057
+ app: app,
2058
+ version: flags.version,
2059
+ description: flags.description,
2060
+ includeData: flags["include-data"] === "true",
2061
+ keepSet: flags["keep-set"] !== "false",
2062
+ confirm: flags.confirm === "true",
2063
+ dryRun: flags["dry-run"] === "true",
2064
+ rulesPath: flags.rules,
2065
+ timeoutMs: timeoutMs,
2066
+ });
2067
+ if (result.status === "exported" && result.xml && outPath) {
2068
+ fs.writeFileSync(path.resolve(outPath), result.xml, "utf8");
2069
+ }
2070
+ writeExportReceipt(flags, result, outPath);
2071
+ if (result.status === "failed" || result.status === "timeout") {
2072
+ return 2;
2073
+ }
2074
+ if (result.status === "dry-run" && flags["dry-run"] !== "true") {
2075
+ return 1;
2076
+ }
2077
+ return 0;
2078
+ }
2079
+ /**
2080
+ * dove-sn strip-secrets:
2081
+ * --in <file> Required. An unload XML exported earlier.
2082
+ * --out <file> Required unless --report.
2083
+ * [--rules <file>] JSON overrides for the secret rules.
2084
+ * [--report] List what WOULD be stripped and what needs review;
2085
+ * writes nothing.
2086
+ * [--json]
2087
+ *
2088
+ * Exists for documents produced outside these verbs. Exit codes: 0 clean,
2089
+ * 1 bad args, 2 blocked (a field needs review, or a secret survived).
2090
+ */
2091
+ async function runStripSecrets(flags) {
2092
+ var inPath = flags.in;
2093
+ if (!inPath) {
2094
+ process.stderr.write("strip-secrets: --in <file> is required\n");
2095
+ return 1;
2096
+ }
2097
+ var report = flags.report === "true";
2098
+ var outPath = flags.out;
2099
+ if (!report && !outPath) {
2100
+ process.stderr.write("strip-secrets: --out <file> is required (or pass --report)\n");
2101
+ return 1;
2102
+ }
2103
+ var xml = "";
2104
+ try {
2105
+ xml = fs.readFileSync(path.resolve(inPath), "utf8");
2106
+ }
2107
+ catch (e) {
2108
+ process.stderr.write("strip-secrets: cannot read " + inPath + "\n");
2109
+ return 1;
2110
+ }
2111
+ var rules = (0, secretRules_1.loadSecretRules)(flags.rules);
2112
+ var result;
2113
+ try {
2114
+ result = (0, stripSecrets_1.stripSecrets)(xml, rules, { allowUnreviewed: report });
2115
+ }
2116
+ catch (e) {
2117
+ process.stderr.write((e instanceof Error ? e.message : String(e)) + "\n");
2118
+ return 2;
2119
+ }
2120
+ if (!report && outPath) {
2121
+ fs.writeFileSync(path.resolve(outPath), result.xml, "utf8");
2122
+ }
2123
+ if (flags.json === "true") {
2124
+ process.stdout.write(JSON.stringify({
2125
+ recordsScanned: result.recordsScanned,
2126
+ secretFields: result.secretFields,
2127
+ reviewFindings: result.reviewFindings,
2128
+ written: report ? null : path.resolve(outPath),
2129
+ }, null, 2) + "\n");
2130
+ }
2131
+ else {
2132
+ process.stdout.write("[" +
2133
+ (report ? "report" : "stripped") +
2134
+ "] " +
2135
+ result.recordsScanned +
2136
+ " record(s), " +
2137
+ result.secretFields.length +
2138
+ " secret value(s)" +
2139
+ (report ? "" : " → " + path.resolve(outPath)) +
2140
+ "\n");
2141
+ for (var i = 0; i < result.reviewFindings.length; i += 1) {
2142
+ process.stdout.write(" NEEDS REVIEW " +
2143
+ result.reviewFindings[i].table +
2144
+ "." +
2145
+ result.reviewFindings[i].field +
2146
+ " (matched '" +
2147
+ result.reviewFindings[i].matched +
2148
+ "')\n");
2149
+ }
2150
+ }
2151
+ return report && result.reviewFindings.length > 0 ? 2 : 0;
2152
+ }
1905
2153
  async function main() {
1906
2154
  var parsed = parseArgs(process.argv.slice(2));
1907
2155
  // Load credentials before any command runs. `--env`/`--env-file` (or the
@@ -1987,6 +2235,15 @@ async function main() {
1987
2235
  if (parsed.command === "publish-app") {
1988
2236
  return await runPublishApp(parsed.flags);
1989
2237
  }
2238
+ if (parsed.command === "export-update-set") {
2239
+ return await runExportUpdateSet(parsed.flags);
2240
+ }
2241
+ if (parsed.command === "export-app") {
2242
+ return await runExportApp(parsed.flags);
2243
+ }
2244
+ if (parsed.command === "strip-secrets") {
2245
+ return await runStripSecrets(parsed.flags);
2246
+ }
1990
2247
  if (parsed.command === "mcp") {
1991
2248
  return await runMcp(parsed.flags);
1992
2249
  }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Export a scoped application to an importable `<unload>` XML document — the
3
+ * headless equivalent of the classic UI's **Publish to Update Set → Export to
4
+ * XML**, with secret values stripped before the document is returned.
5
+ *
6
+ * Ground truth: `brainstorms/sn-export-app-flow.md` (HAR capture, tenonworkshop
7
+ * 2026-06-08) and the live run recorded there. "Export an app" is two operations
8
+ * chained, with no single button behind it:
9
+ *
10
+ * 1. `POST /xmlhttp.do` com.snc.apps.AppsAjaxProcessor `createUpdateSet` →
11
+ * the new set's sys_id arrives in the answer attribute.
12
+ * 2. `POST /xmlhttp.do` AppsAjaxProcessor `publishToUpdateSet` → a worker id;
13
+ * poll AJAXProgressStatusChecker/getStatus until "Successfully published"
14
+ * (the HAR took 48 polls / ~44s for 1,088 files).
15
+ * 3. export that update set — delegated to exportUpdateSet, which owns the
16
+ * in-progress empty-200 gotcha and the secret stripping.
17
+ *
18
+ * PUBLISHING IS A REAL SHARED-INSTANCE WRITE: it creates an update set and
19
+ * ~1000+ sys_update_xml rows. DRY-RUN BY DEFAULT — without confirm:true this
20
+ * resolves the app and returns the plan, having written nothing.
21
+ *
22
+ * This is NOT the Store publish. That is publishApp, which is externally
23
+ * visible; this one stays inside the instance.
24
+ *
25
+ * ES6 only, no optional chaining.
26
+ */
27
+ import type { ServiceNowClient } from "./client";
28
+ import type { FormAuth, FormSession, PostResult } from "./table";
29
+ import type { ExportTransport } from "./exportUpdateSet";
30
+ import type { SecretField } from "./secrets/stripSecrets";
31
+ /** Injectable transport so tests never touch the network. */
32
+ export interface ExportAppTransport extends ExportTransport {
33
+ post?: (auth: FormAuth, session: FormSession, path: string, fields: Record<string, string>) => Promise<PostResult>;
34
+ sleep?: (ms: number) => Promise<void>;
35
+ }
36
+ /** Inputs for exportApp. */
37
+ export interface ExportAppParams {
38
+ /** App sys_id, scope, or name. */
39
+ app: string;
40
+ /** Publish version. Defaults to the app's current version. */
41
+ version?: string;
42
+ /** Description recorded on the update set. */
43
+ description?: string;
44
+ /** Include table DATA as well as schema. Off by default. */
45
+ includeData?: boolean;
46
+ /** Leave the published update set behind. Default true — deleting is not ours to do. */
47
+ keepSet?: boolean;
48
+ client?: ServiceNowClient;
49
+ instance?: string;
50
+ user?: string;
51
+ password?: string;
52
+ /** Required: publishing writes to the instance. */
53
+ confirm?: boolean;
54
+ /** Force a plan-only run. Wins over confirm. */
55
+ dryRun?: boolean;
56
+ /** Optional JSON secret-rules file. */
57
+ rulesPath?: string;
58
+ /** Milliseconds to wait for the publish worker. Default 300000. */
59
+ timeoutMs?: number;
60
+ transport?: ExportAppTransport;
61
+ }
62
+ /** Outcome of an app export. */
63
+ export interface ExportAppResult {
64
+ status: "dry-run" | "exported" | "failed" | "timeout";
65
+ appName: string;
66
+ appScope: string;
67
+ appSysId: string;
68
+ version: string;
69
+ /** The update set the publish produced. */
70
+ updateSetSysId: string;
71
+ /** getStatus polls performed. */
72
+ polls: number;
73
+ recordCount: number;
74
+ xml?: string;
75
+ secretFields: Array<SecretField>;
76
+ message?: string;
77
+ note: string;
78
+ }
79
+ /** Publishing a large app is slow — the HAR run took ~44s for 1,088 files. */
80
+ export declare var DEFAULT_EXPORT_APP_TIMEOUT_MS: number;
81
+ interface ResolvedApp {
82
+ sysId: string;
83
+ scope: string;
84
+ name: string;
85
+ version: string;
86
+ }
87
+ /**
88
+ * Fields for the AppsAjaxProcessor createUpdateSet call. The UI sends a single
89
+ * space when the description box is empty — mirrored here so the processor sees
90
+ * what it sees from a browser.
91
+ */
92
+ export declare function buildCreateSetFields(app: ResolvedApp, description: string): Record<string, string>;
93
+ /** Fields for the AppsAjaxProcessor publishToUpdateSet call. */
94
+ export declare function buildPublishFields(app: ResolvedApp, updateSetSysId: string, version: string, description: string, includeData: boolean): Record<string, string>;
95
+ /**
96
+ * Publish an application into a fresh update set and export that set.
97
+ *
98
+ * Remote failures are RETURNED as a failed/timeout result; only caller errors
99
+ * (bad selector, bad timeout) throw.
100
+ */
101
+ export declare function exportApp(params: ExportAppParams): Promise<ExportAppResult>;
102
+ export {};