@tenonhq/dovetail-servicenow 0.0.36 → 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
@@ -367,6 +367,56 @@ adds diagnostics (app-switch status, resolved column key, assigned sys_id) to th
367
367
  result note. Ground truth (the HAR dissection) lives in the CTO repo's create-table
368
368
  docs.
369
369
 
370
+ ### Add a unique index
371
+
372
+ Create a **single-column UNIQUE index** on an existing table, then read it back.
373
+
374
+ ```bash
375
+ # Dry-run (the DEFAULT) - prints the plan, writes nothing and reads nothing
376
+ npx dove-sn add-index \
377
+ --table x_cadso_journey_instance --columns occurrence_key --unique \
378
+ --update-set <sys_id> --json
379
+
380
+ # Send it
381
+ npx dove-sn add-index \
382
+ --table x_cadso_journey_instance --columns occurrence_key --unique \
383
+ --update-set <sys_id> --confirm
384
+ ```
385
+
386
+ The **only** headless lever for an index is `sys_dictionary.unique`. `sys_index` fails an
387
+ API-LEVEL ACL (HTTP 403) for every identity - and an ACL that refuses `GET` refuses `POST` -
388
+ while `sys_index_column` does not exist at all (HTTP 400 `Invalid table`). So `add-index`
389
+ patches the column's dictionary row through the update-set-aware write path and lets the
390
+ platform build the physical index off that flag.
391
+
392
+ Three consequences, each reported rather than hidden:
393
+
394
+ - **One column, unique only.** `unique` is a per-COLUMN flag, so a composite index has no
395
+ dictionary lever. A multi-column request is **refused**, never narrowed to its first
396
+ column - building a different index than the one asked for is the worst available
397
+ outcome. `--unique` is required for the same reason. Composite and plain indexes stay
398
+ platform-UI work.
399
+ - **Duplicates abort the run BEFORE it writes.** A unique index cannot build over repeated
400
+ values, and ServiceNow fails that ALTER *silently* - leaving a dictionary row claiming
401
+ `unique=true` with no index behind it (which is exactly what
402
+ `x_cadso_core_metric_point.idempotency_key` looks like today). **EMPTY counts as a
403
+ value**: a freshly added column that is empty on every existing row is one collision per
404
+ row. Backfill first, index second. The scan is paged and capped, and a scan that hits
405
+ that cap **aborts the same way** - an UNPROVEN scan is treated exactly like a proven
406
+ collision, because writing on a column that was only read part-way is how this verb
407
+ would manufacture that trap on a table too big for anyone to have checked.
408
+ - **Success is read back; uniqueness never is.** `status` is `created` only when a matching
409
+ row was read back from the `v_db_index` view; a flag with no index is `failed`.
410
+ `verified.indexPresent` is `null` - UNKNOWN, not `false` - when the view could not be
411
+ read, because a blind instrument is not evidence of absence. And `v_db_index` carries no
412
+ uniqueness field (every row reads `btree`, unique or not), so **`uniqueness-enforced` is
413
+ listed in `unverified` on every status, success included**: only a duplicate-insert test
414
+ proves enforcement.
415
+
416
+ `--update-set` is required on the live path and is checked before a client is built or a
417
+ single request goes out. Exit codes: `0` created / skipped / dry-run, `1` bad args, `2`
418
+ failed (the lying-row case included).
419
+
370
420
  ### Set a field on a record
371
421
 
372
422
  Set scalar field value(s) on an **existing** data record, capture the change into
@@ -505,6 +555,71 @@ Store `appLink`, and the publish's update-set sys_id where the instance reports
505
555
  one. Exit codes: `0` published or dry-run, `1` bad args/unconfirmed, `2`
506
556
  failed/timeout. Programmatic: `publishApp({ app, version, target, confirm })`.
507
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
+
508
623
  `test-flow` defaults to **validate** — a safe pre-flight (published? inputs match
509
624
  declared variables?) that never runs the flow; `--execute --confirm` runs it via
510
625
  the server-side FlowAPI runner (deploy `resources/runFlow.md` first).
@@ -576,7 +691,9 @@ console.log(formatLayoutResult("form layout", result));
576
691
  `dove-sn mcp` runs a self-contained MCP stdio server exposing the tools to
577
692
  Claude Code and agents: `create_view`, `set_list_layout`, `set_form_layout`,
578
693
  `set_related_lists`, `add_choices_to_field`, the schema verbs `create_table` /
579
- `add_column`, the record-write verbs `set_field` (update scalar fields on an
694
+ `add_column` / `add_index` (a single-column unique index via `sys_dictionary.unique`,
695
+ read back from the `v_db_index` view - uniqueness enforcement is always reported
696
+ unverified), the record-write verbs `set_field` (update scalar fields on an
580
697
  existing record) and `create_record` (insert one record) — both update-set-captured
581
698
  and read-back-verified — `host_assets` (deploy a built dist/), plus the Flow Designer
582
699
  tools `flow_view` (read a flow/subflow's step graph), `action_view` (read an action
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");
@@ -977,6 +981,20 @@ function printHelp() {
977
981
  " [--name <element>] [--max-length <n>] [--reference <table>]\n" +
978
982
  " [--mandatory] [--default <v>] [--scope <s>] [--dry-run] [--json])\n" +
979
983
  " --update-set is REQUIRED on the live path (not for --dry-run).\n" +
984
+ " add-index Create a single-column UNIQUE index (sys_dictionary.unique), then verify\n" +
985
+ " DRY-RUN BY DEFAULT — nothing is written without --confirm\n" +
986
+ " (--table <name|sys_id> --columns <column> --unique --update-set <sys_id>\n" +
987
+ " [--confirm] [--scope <s>] [--dry-run] [--debug] [--json])\n" +
988
+ " ONE column only: unique is a per-COLUMN dictionary flag, so a\n" +
989
+ " composite index is REFUSED, not narrowed — that stays UI work,\n" +
990
+ " as does a plain (non-unique) index. The run ABORTS before writing\n" +
991
+ " when the column holds duplicate values (EMPTY counts): a unique\n" +
992
+ " index cannot build over them and the platform fails that ALTER\n" +
993
+ " SILENTLY, leaving unique=true with no index behind it. It aborts\n" +
994
+ " the same way when that scan hits its row cap — an UNPROVEN scan\n" +
995
+ " is treated exactly like a proven collision. Success is\n" +
996
+ " read back from v_db_index; that view has no uniqueness field, so\n" +
997
+ " ENFORCEMENT is always reported unverified.\n" +
980
998
  " set-column Update an EXISTING column's SCHEMA (label/mandatory/default/read-only/max-length),\n" +
981
999
  " into an update set, then verify against the instance\n" +
982
1000
  " (--table <t> --column <c> --update-set <sys_id>\n" +
@@ -1034,6 +1052,22 @@ function printHelp() {
1034
1052
  " [--dry-run] [--json] [--confirm])\n" +
1035
1053
  " Store creds: SN_STORE_USERNAME/SN_STORE_PASSWORD in the --env file;\n" +
1036
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" +
1037
1071
  " mcp Run the MCP stdio server (--smoke lists tools and exits)\n" +
1038
1072
  "\nGlobal flags:\n" +
1039
1073
  " --env <path> Load credentials from a specific .env file (also --env-file,\n" +
@@ -1222,6 +1256,82 @@ async function runAddColumn(flags) {
1222
1256
  return 2;
1223
1257
  return 0;
1224
1258
  }
1259
+ /**
1260
+ * dove-sn add-index:
1261
+ * --table x_cadso_journey_instance --columns occurrence_key --unique
1262
+ * --update-set <sys_id> [--confirm] [--scope x_cadso_journey] [--debug] [--json]
1263
+ *
1264
+ * DRY-RUN BY DEFAULT — nothing is written without --confirm (--dry-run forces a
1265
+ * dry-run even with it). --update-set is required on the live path and is checked
1266
+ * here, before a client is built or a single request goes out.
1267
+ *
1268
+ * Exit codes: 0 created / skipped / dry-run, 1 bad args, 2 failed (which includes
1269
+ * "the dictionary flag is set but no index was read back" — the lying-row case).
1270
+ */
1271
+ async function runAddIndex(flags) {
1272
+ var table = flags.table;
1273
+ var columns = (flags.columns || "")
1274
+ .split(",")
1275
+ .map(function (c) {
1276
+ return c.trim();
1277
+ })
1278
+ .filter(function (c) {
1279
+ return c.length > 0;
1280
+ });
1281
+ if (!table || columns.length === 0) {
1282
+ process.stderr.write("add-index: --table and --columns <column> are required " +
1283
+ "(--unique too, and --update-set unless this is a dry-run)\n");
1284
+ return 1;
1285
+ }
1286
+ // The only headless lever is sys_dictionary.unique. Refuse a non-unique request by
1287
+ // name instead of building something else and calling it done.
1288
+ if (flags.unique !== "true") {
1289
+ process.stderr.write("add-index: --unique is required — the only headless lever is " +
1290
+ "sys_dictionary.unique, which has no equivalent for a plain (non-unique) " +
1291
+ "index. Create that one in the platform UI.\n");
1292
+ return 1;
1293
+ }
1294
+ // DRY-RUN BY DEFAULT: --confirm is what sends; --dry-run forces a plan even with it.
1295
+ var dryRun = flags["dry-run"] === "true" || flags.confirm !== "true";
1296
+ if (!dryRun && !flags["update-set"]) {
1297
+ process.stderr.write("add-index: --update-set is required on the live path (only a dry-run works without one)\n");
1298
+ return 1;
1299
+ }
1300
+ var params = {
1301
+ client: (0, client_1.createClient)({}),
1302
+ table: table,
1303
+ columns: columns,
1304
+ unique: true,
1305
+ dryRun: dryRun,
1306
+ };
1307
+ if (flags.scope)
1308
+ params.scope = flags.scope;
1309
+ if (flags["update-set"])
1310
+ params.updateSetSysId = flags["update-set"];
1311
+ if (flags.debug === "true")
1312
+ params.debug = true;
1313
+ var result = await (0, table_1.addIndex)(params);
1314
+ if (flags.json === "true") {
1315
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
1316
+ }
1317
+ else {
1318
+ process.stdout.write("[" +
1319
+ result.status +
1320
+ "] " +
1321
+ result.table +
1322
+ "." +
1323
+ result.columns.join(",") +
1324
+ (result.indexName ? " -> " + result.indexName : "") +
1325
+ "\n" +
1326
+ result.note +
1327
+ "\nUNVERIFIED: " +
1328
+ result.unverified.join(", ") +
1329
+ "\n");
1330
+ }
1331
+ if (result.status === "failed")
1332
+ return 2;
1333
+ return 0;
1334
+ }
1225
1335
  /** Parse a CLI boolean flag. Bare `--mandatory` means true; `--mandatory false` means
1226
1336
  * false. Anything else is rejected rather than quietly coerced to `true`. */
1227
1337
  function parseBoolFlag(name, raw, verb = "set-column") {
@@ -1349,7 +1459,9 @@ async function runSetTable(flags, bare) {
1349
1459
  var stringFlags = ["table", "update-set", "updateSetSysId"];
1350
1460
  for (var f = 0; f < stringFlags.length; f += 1) {
1351
1461
  if (bare[stringFlags[f]]) {
1352
- 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");
1353
1465
  return 1;
1354
1466
  }
1355
1467
  }
@@ -1812,6 +1924,232 @@ async function runPublishApp(flags) {
1812
1924
  }
1813
1925
  return exitCode;
1814
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
+ }
1815
2153
  async function main() {
1816
2154
  var parsed = parseArgs(process.argv.slice(2));
1817
2155
  // Load credentials before any command runs. `--env`/`--env-file` (or the
@@ -1851,6 +2189,9 @@ async function main() {
1851
2189
  if (parsed.command === "add-column") {
1852
2190
  return await runAddColumn(parsed.flags);
1853
2191
  }
2192
+ if (parsed.command === "add-index") {
2193
+ return await runAddIndex(parsed.flags);
2194
+ }
1854
2195
  if (parsed.command === "set-column") {
1855
2196
  return await runSetColumn(parsed.flags, parsed.bare);
1856
2197
  }
@@ -1894,6 +2235,15 @@ async function main() {
1894
2235
  if (parsed.command === "publish-app") {
1895
2236
  return await runPublishApp(parsed.flags);
1896
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
+ }
1897
2247
  if (parsed.command === "mcp") {
1898
2248
  return await runMcp(parsed.flags);
1899
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 {};