@tenonhq/dovetail-servicenow 0.0.30 → 0.0.31

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
@@ -406,6 +406,45 @@ Programmatic: `invokeRest({ method, path, body, confirm })` is exported, and the
406
406
  client gained `now.put` / `now.delete` / `now.invoke` (the latter returns
407
407
  `{ status, body }` verbatim) alongside the existing `now.get` / `now.post`.
408
408
 
409
+ ### Publish an app to the Store / application repository
410
+
411
+ Publish a scoped application to the **ServiceNow Store**, the **company
412
+ application repository**, or both — headlessly, with the publish's progress
413
+ tracker polled to completion.
414
+
415
+ ```bash
416
+ # Dry-run — the DEFAULT: resolves the app, prints the plan, publishes NOTHING
417
+ npx dove-sn publish-app --app x_cadso_filter --version 6.0.20260716 --target both
418
+
419
+ # Publish for real (store, then repo, same version)
420
+ npx dove-sn publish-app --app x_cadso_filter --version 6.0.20260716 \
421
+ --target both --dev-notes "July release" --confirm --json
422
+ ```
423
+
424
+ **Store publish is EXTERNALLY VISIBLE on the ServiceNow Store — treat
425
+ `--target store --confirm` as a release.** `publish-app` is dry-run by default:
426
+ without `--confirm` it prints the resolved plan and exits `1` (a deliberate
427
+ refusal). `--target both` runs store then repo sequentially and short-circuits
428
+ if the store leg fails.
429
+
430
+ The two targets ride different transports:
431
+
432
+ - **store** replays the `sys_app` form's upload flow (`xmlhttp.do` +
433
+ `sn_appauthor.ScopedAppUploaderAJAX`) over a form-login session — basic auth
434
+ alone no-ops there. It needs the Store account credentials in the loaded env
435
+ file as `SN_STORE_USERNAME` / `SN_STORE_PASSWORD`. The password is **never
436
+ accepted as a flag** and never appears in output, dry-run previews included.
437
+ - **repo** uses the supported CI/CD REST API (`POST /api/sn_cicd/app_repo/publish`
438
+ + `GET /api/sn_cicd/progress/{id}`) over basic auth. The API user needs the
439
+ `sn_cicd` role (or admin).
440
+
441
+ `--app` accepts a scope name, `sys_app` sys_id, or app name; `--version` must be
442
+ above the currently published version. The result carries the progress-tracker
443
+ id, per-step states ("Packaging application", "Uploading application"), the
444
+ Store `appLink`, and the publish's update-set sys_id where the instance reports
445
+ one. Exit codes: `0` published or dry-run, `1` bad args/unconfirmed, `2`
446
+ failed/timeout. Programmatic: `publishApp({ app, version, target, confirm })`.
447
+
409
448
  `test-flow` defaults to **validate** — a safe pre-flight (published? inputs match
410
449
  declared variables?) that never runs the flow; `--execute --confirm` runs it via
411
450
  the server-side FlowAPI runner (deploy `resources/runFlow.md` first).
@@ -488,8 +527,10 @@ publish is read back and verified), `flow_publish` (compile a flow/subflow snaps
488
527
  publish, grafting a template), `flow_test` (validate or run a flow), and
489
528
  `flow_edit` (patch a flow), plus `invoke_rest` (invoke an arbitrary authenticated
490
529
  REST operation — Scripted REST included — with GET/POST/PUT/DELETE; dry-run by
491
- default, response passed through verbatim, bodies never logged). It reads
492
- ServiceNow credentials from the same env vars as the CLI.
530
+ default, response passed through verbatim, bodies never logged) and `app_publish`
531
+ (publish a scoped app to the ServiceNow Store and/or the application repository;
532
+ dry-run by default, Store credentials env-only — they never transit tool
533
+ arguments). It reads ServiceNow credentials from the same env vars as the CLI.
493
534
 
494
535
  ```bash
495
536
  npx dove-sn mcp --smoke # list the registered tools and exit
package/dist/cli.js CHANGED
@@ -83,6 +83,7 @@ const table_1 = require("./table");
83
83
  const setField_1 = require("./setField");
84
84
  const createRecord_1 = require("./createRecord");
85
85
  const invokeRest_1 = require("./invokeRest");
86
+ const publishApp_1 = require("./publishApp");
86
87
  const hostAssets_1 = require("./hostAssets");
87
88
  const flowDesigner_formatter_2 = require("./flowDesigner-formatter");
88
89
  function parseArgs(argv) {
@@ -918,6 +919,15 @@ function printHelp() {
918
919
  " [--script-input <name>] [--update-set <sys_id>] [--apply] [--json])\n" +
919
920
  " edit-flow Patch a flow/subflow (rename, description, step inputs)\n" +
920
921
  " (--sys-id <sys_id> --from-json <ops.json> [--apply] [--update-set <sys_id>] [--scope <sys_id>] [--json])\n" +
922
+ " publish-app Publish a scoped app to the ServiceNow Store and/or the company\n" +
923
+ " application repository, then poll the publish to completion.\n" +
924
+ " STORE PUBLISH IS EXTERNALLY VISIBLE on the ServiceNow Store.\n" +
925
+ " DRY-RUN BY DEFAULT — nothing is published without --confirm\n" +
926
+ " (--app <scope|sys_id|name> --version <v> --target store|repo|both\n" +
927
+ " [--dev-notes <text>] [--store-user <email>] [--timeout-ms <n>]\n" +
928
+ " [--dry-run] [--json] [--confirm])\n" +
929
+ " Store creds: SN_STORE_USERNAME/SN_STORE_PASSWORD in the --env file;\n" +
930
+ " the password is never a flag. Repo publish needs the sn_cicd role.\n" +
921
931
  " mcp Run the MCP stdio server (--smoke lists tools and exits)\n" +
922
932
  "\nGlobal flags:\n" +
923
933
  " --env <path> Load credentials from a specific .env file (also --env-file,\n" +
@@ -1511,6 +1521,104 @@ async function runInvokeRest(flags) {
1511
1521
  }
1512
1522
  return 0;
1513
1523
  }
1524
+ /**
1525
+ * dove-sn publish-app:
1526
+ * --app <scope|sys_id|name> Required. The sys_app to publish.
1527
+ * --version <v> Required. Version to publish (e.g. 6.0.20260716).
1528
+ * --target store|repo|both Required. STORE PUBLISH IS EXTERNALLY VISIBLE.
1529
+ * [--dev-notes <text>] Optional developer notes.
1530
+ * [--store-user <email>] Store account email (else SN_STORE_USERNAME).
1531
+ * The password comes ONLY from SN_STORE_PASSWORD —
1532
+ * there is no flag for it, ever.
1533
+ * [--timeout-ms <n>] Progress-poll budget (default 120000).
1534
+ * [--dry-run] [--json] [--confirm]
1535
+ *
1536
+ * DRY-RUN unless --confirm: without it the resolved plan is printed and the
1537
+ * command exits 1 (a deliberate refusal, not success). --target both publishes
1538
+ * store then repo sequentially with the same version and short-circuits if the
1539
+ * store leg fails; --json emits an array of per-target results.
1540
+ * Exit codes: 0 published/dry-run, 1 bad args/unconfirmed, 2 failed/timeout.
1541
+ */
1542
+ async function runPublishApp(flags) {
1543
+ var app = flags.app;
1544
+ var version = flags.version;
1545
+ var target = flags.target;
1546
+ if (!app || !version || !target) {
1547
+ process.stderr.write("publish-app: --app, --version and --target store|repo|both are required\n");
1548
+ return 1;
1549
+ }
1550
+ if (target !== "store" && target !== "repo" && target !== "both") {
1551
+ process.stderr.write("publish-app: --target must be store, repo or both (got '" +
1552
+ target +
1553
+ "')\n");
1554
+ return 1;
1555
+ }
1556
+ var targets = target === "both" ? ["store", "repo"] : [target];
1557
+ var dryRun = flags["dry-run"] === "true";
1558
+ var confirmed = flags.confirm === "true";
1559
+ var client = (0, client_1.createClient)({});
1560
+ var results = [];
1561
+ var exitCode = 0;
1562
+ for (var i = 0; i < targets.length; i += 1) {
1563
+ var params = {
1564
+ client: client,
1565
+ app: app,
1566
+ version: version,
1567
+ target: targets[i],
1568
+ confirm: confirmed,
1569
+ dryRun: dryRun,
1570
+ };
1571
+ if (flags["dev-notes"])
1572
+ params.devNotes = flags["dev-notes"];
1573
+ if (flags["store-user"])
1574
+ params.storeUsername = flags["store-user"];
1575
+ if (flags["timeout-ms"]) {
1576
+ // A NaN timeout would make the poll-loop budget check always false —
1577
+ // an infinite loop. Validate here, exit 1 on garbage.
1578
+ var timeoutMs = Number(flags["timeout-ms"]);
1579
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
1580
+ process.stderr.write("publish-app: --timeout-ms must be a positive integer (got '" +
1581
+ flags["timeout-ms"] +
1582
+ "')\n");
1583
+ return 1;
1584
+ }
1585
+ params.timeoutMs = timeoutMs;
1586
+ }
1587
+ var result = await (0, publishApp_1.publishApp)(params);
1588
+ results.push(result);
1589
+ if (flags.json !== "true") {
1590
+ process.stdout.write("[" +
1591
+ result.status +
1592
+ "] " +
1593
+ result.target +
1594
+ " — " +
1595
+ result.appName +
1596
+ " (" +
1597
+ result.appScope +
1598
+ ") v" +
1599
+ result.version +
1600
+ (result.appLink ? " — " + result.appLink : "") +
1601
+ (result.updateSetSysId
1602
+ ? " — update set " + result.updateSetSysId
1603
+ : "") +
1604
+ "\n" +
1605
+ result.note +
1606
+ "\n");
1607
+ }
1608
+ if (result.status === "failed" || result.status === "timeout") {
1609
+ exitCode = 2;
1610
+ break; // Short-circuit: never repo-publish after a failed store leg.
1611
+ }
1612
+ }
1613
+ if (flags.json === "true") {
1614
+ process.stdout.write(JSON.stringify(results, null, 2) + "\n");
1615
+ }
1616
+ if (exitCode === 0 && !dryRun && !confirmed) {
1617
+ process.stderr.write("publish-app: refusing to publish without --confirm (the plan above is a dry-run).\n");
1618
+ return 1;
1619
+ }
1620
+ return exitCode;
1621
+ }
1514
1622
  async function main() {
1515
1623
  var parsed = parseArgs(process.argv.slice(2));
1516
1624
  // Load credentials before any command runs. `--env`/`--env-file` (or the
@@ -1585,6 +1693,9 @@ async function main() {
1585
1693
  await runSetRelatedLists(parsed.flags);
1586
1694
  return 0;
1587
1695
  }
1696
+ if (parsed.command === "publish-app") {
1697
+ return await runPublishApp(parsed.flags);
1698
+ }
1588
1699
  if (parsed.command === "mcp") {
1589
1700
  return await runMcp(parsed.flags);
1590
1701
  }
package/dist/index.d.ts CHANGED
@@ -27,3 +27,5 @@ export type { RecordWriteResult, SetFieldParams, SetFieldResult, } from "./setFi
27
27
  export type { CreateRecordParams, CreateRecordResult } from "./createRecord";
28
28
  export { invokeRest, INVOKE_REST_METHODS } from "./invokeRest";
29
29
  export type { InvokeRestParams, InvokeRestResult } from "./invokeRest";
30
+ export { publishApp, buildStartFields, parseXmlAnswer, parseProgressTree, classifyProgress, flattenSteps, harvestProgressResults, parseCicdPublishResponse, parseCicdProgress, DEFAULT_PUBLISH_TIMEOUT_MS, PUBLISH_POLL_DELAYS_MS, } from "./publishApp";
31
+ export type { PublishAppParams, PublishAppResult, PublishTarget, PublishStep, ProgressNode, CicdProgress, PublishTransport, } from "./publishApp";
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.addColumn = exports.DEFAULT_SAVE_ACTION = exports.DEFAULT_SUPER_CLASS = exports.TYPE_MAP = exports.defaultAccessFlags = exports.applyTableSaveOverlay = exports.resolveType = exports.normalizeColumns = exports.buildColumnXml = exports.projectTableGraph = exports.createTable = exports.WriteOrderError = exports.executeWritePlan = exports.topoSort = exports.generateSysId = exports.DEFAULT_RUN_FLOW_PATH = exports.testFlow = exports.editFlow = exports.buildPublishModel = exports.createFlow = exports.copyFlow = exports.publishFlow = exports.readActionType = exports.readFlow = exports.formatStepPill = exports.summarizeSteps = exports.verifySteps = exports.applyStepOps = 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.formatHostAssetsResult = exports.classifyChunks = exports.hostAssets = exports.addChoicesToField = exports.resolveConfigFromEnvFile = exports.createClientFromEnvFile = exports.createClient = void 0;
10
- exports.INVOKE_REST_METHODS = exports.invokeRest = exports.createRecord = exports.setField = exports.toStoredValue = exports.resolveAttributes = exports.setColumn = exports.deriveElement = void 0;
10
+ exports.PUBLISH_POLL_DELAYS_MS = exports.DEFAULT_PUBLISH_TIMEOUT_MS = exports.parseCicdProgress = exports.parseCicdPublishResponse = exports.harvestProgressResults = exports.flattenSteps = exports.classifyProgress = exports.parseProgressTree = exports.parseXmlAnswer = exports.buildStartFields = exports.publishApp = exports.INVOKE_REST_METHODS = exports.invokeRest = exports.createRecord = exports.setField = exports.toStoredValue = exports.resolveAttributes = exports.setColumn = exports.deriveElement = void 0;
11
11
  var client_1 = require("./client");
12
12
  Object.defineProperty(exports, "createClient", { enumerable: true, get: function () { return client_1.createClient; } });
13
13
  var createClientFromEnvFile_1 = require("./createClientFromEnvFile");
@@ -82,3 +82,15 @@ Object.defineProperty(exports, "createRecord", { enumerable: true, get: function
82
82
  var invokeRest_1 = require("./invokeRest");
83
83
  Object.defineProperty(exports, "invokeRest", { enumerable: true, get: function () { return invokeRest_1.invokeRest; } });
84
84
  Object.defineProperty(exports, "INVOKE_REST_METHODS", { enumerable: true, get: function () { return invokeRest_1.INVOKE_REST_METHODS; } });
85
+ var publishApp_1 = require("./publishApp");
86
+ Object.defineProperty(exports, "publishApp", { enumerable: true, get: function () { return publishApp_1.publishApp; } });
87
+ Object.defineProperty(exports, "buildStartFields", { enumerable: true, get: function () { return publishApp_1.buildStartFields; } });
88
+ Object.defineProperty(exports, "parseXmlAnswer", { enumerable: true, get: function () { return publishApp_1.parseXmlAnswer; } });
89
+ Object.defineProperty(exports, "parseProgressTree", { enumerable: true, get: function () { return publishApp_1.parseProgressTree; } });
90
+ Object.defineProperty(exports, "classifyProgress", { enumerable: true, get: function () { return publishApp_1.classifyProgress; } });
91
+ Object.defineProperty(exports, "flattenSteps", { enumerable: true, get: function () { return publishApp_1.flattenSteps; } });
92
+ Object.defineProperty(exports, "harvestProgressResults", { enumerable: true, get: function () { return publishApp_1.harvestProgressResults; } });
93
+ Object.defineProperty(exports, "parseCicdPublishResponse", { enumerable: true, get: function () { return publishApp_1.parseCicdPublishResponse; } });
94
+ Object.defineProperty(exports, "parseCicdProgress", { enumerable: true, get: function () { return publishApp_1.parseCicdProgress; } });
95
+ Object.defineProperty(exports, "DEFAULT_PUBLISH_TIMEOUT_MS", { enumerable: true, get: function () { return publishApp_1.DEFAULT_PUBLISH_TIMEOUT_MS; } });
96
+ Object.defineProperty(exports, "PUBLISH_POLL_DELAYS_MS", { enumerable: true, get: function () { return publishApp_1.PUBLISH_POLL_DELAYS_MS; } });
@@ -10,7 +10,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
  import { z } from "zod";
11
11
  import type { ToolAnnotations } from "@tenonhq/dovetail-mcp-kit";
12
12
  import type { ServiceNowClient } from "../client";
13
- export declare var TOOL_NAMES: readonly ["create_view", "set_list_layout", "set_form_layout", "set_related_lists", "add_choices_to_field", "flow_view", "action_view", "action_edit", "flow_publish", "flow_copy", "flow_create", "flow_test", "flow_edit", "create_table", "add_column", "set_column", "set_field", "create_record", "host_assets", "invoke_rest"];
13
+ export declare var TOOL_NAMES: readonly ["create_view", "set_list_layout", "set_form_layout", "set_related_lists", "add_choices_to_field", "flow_view", "action_view", "action_edit", "flow_publish", "flow_copy", "flow_create", "flow_test", "flow_edit", "create_table", "add_column", "set_column", "set_field", "create_record", "host_assets", "invoke_rest", "app_publish"];
14
14
  export type ToolName = (typeof TOOL_NAMES)[number];
15
15
  export interface RegistryDeps {
16
16
  /** Optional client injection for tests; defaults to createClient({}). */
@@ -31,6 +31,7 @@ const hostAssets_1 = require("../hostAssets");
31
31
  const setField_1 = require("../setField");
32
32
  const createRecord_1 = require("../createRecord");
33
33
  const invokeRest_1 = require("../invokeRest");
34
+ const publishApp_1 = require("../publishApp");
34
35
  const schemas_1 = require("./schemas");
35
36
  exports.TOOL_NAMES = [
36
37
  "create_view",
@@ -53,6 +54,7 @@ exports.TOOL_NAMES = [
53
54
  "create_record",
54
55
  "host_assets",
55
56
  "invoke_rest",
57
+ "app_publish",
56
58
  ];
57
59
  // Annotation presets (READ_ONLY / WRITE_ADDITIVE_IDEMPOTENT / WRITE_CREATE /
58
60
  // WRITE_OVERWRITE / WRITE_EXECUTE) come from @tenonhq/dovetail-mcp-kit.
@@ -491,6 +493,39 @@ function buildDescriptors(deps = {}) {
491
493
  return (0, invokeRest_1.invokeRest)(params);
492
494
  },
493
495
  },
496
+ {
497
+ name: "app_publish",
498
+ annotations: dovetail_mcp_kit_1.WRITE_EXECUTE,
499
+ description: "Publish a scoped ServiceNow application to ONE target per call — the ServiceNow Store " +
500
+ "(target 'store') or the company Application Repository (target 'repo'); call twice to " +
501
+ "publish to both — then poll the publish to completion. STORE PUBLISH IS EXTERNALLY " +
502
+ "VISIBLE on the ServiceNow Store — treat it as a release. DRY-RUN BY DEFAULT: without " +
503
+ "confirm:true the resolved plan (app, current version, target) is returned and nothing is " +
504
+ "published. target 'store' replays the sys_app form's upload flow over a form-login session " +
505
+ "and requires SN_STORE_USERNAME/SN_STORE_PASSWORD in the server's env file — credentials " +
506
+ "never transit tool arguments. target 'repo' uses the supported CI/CD REST API " +
507
+ "(/api/sn_cicd/app_repo/publish) and requires the sn_cicd role. app is a scope name, " +
508
+ "sys_app sys_id, or app name; version must be above the currently published version. The " +
509
+ "result carries the progress-tracker id, per-step states, the Store appLink, and the " +
510
+ "publish's update-set sys_id where the instance reports one.",
511
+ shape: schemas_1.publishAppSchema.shape,
512
+ handler: async function (args) {
513
+ var p = schemas_1.publishAppSchema.parse(args);
514
+ var params = {
515
+ app: p.app,
516
+ version: p.version,
517
+ target: p.target,
518
+ devNotes: p.devNotes,
519
+ confirm: p.confirm,
520
+ dryRun: p.dryRun,
521
+ timeoutMs: p.timeoutMs,
522
+ };
523
+ if (deps.client) {
524
+ params.client = deps.client;
525
+ }
526
+ return (0, publishApp_1.publishApp)(params);
527
+ },
528
+ },
494
529
  ];
495
530
  }
496
531
  function registerAllTools(server, deps = {}) {
@@ -958,6 +958,31 @@ export declare var createRecordSchema: z.ZodObject<{
958
958
  dryRun?: boolean | undefined;
959
959
  ifAbsentQuery?: string | undefined;
960
960
  }>;
961
+ export declare var publishAppSchema: z.ZodObject<{
962
+ app: z.ZodString;
963
+ version: z.ZodString;
964
+ devNotes: z.ZodOptional<z.ZodString>;
965
+ target: z.ZodUnion<[z.ZodLiteral<"store">, z.ZodLiteral<"repo">]>;
966
+ confirm: z.ZodOptional<z.ZodBoolean>;
967
+ dryRun: z.ZodOptional<z.ZodBoolean>;
968
+ timeoutMs: z.ZodOptional<z.ZodNumber>;
969
+ }, "strip", z.ZodTypeAny, {
970
+ target: "store" | "repo";
971
+ version: string;
972
+ app: string;
973
+ confirm?: boolean | undefined;
974
+ dryRun?: boolean | undefined;
975
+ devNotes?: string | undefined;
976
+ timeoutMs?: number | undefined;
977
+ }, {
978
+ target: "store" | "repo";
979
+ version: string;
980
+ app: string;
981
+ confirm?: boolean | undefined;
982
+ dryRun?: boolean | undefined;
983
+ devNotes?: string | undefined;
984
+ timeoutMs?: number | undefined;
985
+ }>;
961
986
  export declare var invokeRestSchema: z.ZodObject<{
962
987
  method: z.ZodEffects<z.ZodEnum<["GET", "POST", "PUT", "DELETE"]>, "GET" | "DELETE" | "POST" | "PUT", unknown>;
963
988
  path: z.ZodString;
@@ -4,7 +4,7 @@
4
4
  * own file so registry.ts stays focused on wiring.
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
- exports.invokeRestSchema = exports.createRecordSchema = exports.setFieldSchema = exports.setColumnSchema = exports.columnAttributesSchema = exports.addColumnSchema = exports.createTableSchema = exports.columnSpecSchema = exports.hostAssetsSchema = exports.editFlowSchema = exports.editActionSchema = exports.stepInputPatchSchema = exports.testFlowSchema = exports.createFlowSchema = exports.copyFlowSchema = exports.publishFlowSchema = exports.viewActionSchema = exports.viewFlowSchema = exports.addChoicesToFieldSchema = exports.choiceValueSchema = exports.setRelatedListsSchema = exports.setFormLayoutSchema = exports.formSectionSchema = exports.setListLayoutSchema = exports.createViewSchema = void 0;
7
+ exports.invokeRestSchema = exports.publishAppSchema = exports.createRecordSchema = exports.setFieldSchema = exports.setColumnSchema = exports.columnAttributesSchema = exports.addColumnSchema = exports.createTableSchema = exports.columnSpecSchema = exports.hostAssetsSchema = exports.editFlowSchema = exports.editActionSchema = exports.stepInputPatchSchema = exports.testFlowSchema = exports.createFlowSchema = exports.copyFlowSchema = exports.publishFlowSchema = exports.viewActionSchema = exports.viewFlowSchema = exports.addChoicesToFieldSchema = exports.choiceValueSchema = exports.setRelatedListsSchema = exports.setFormLayoutSchema = exports.formSectionSchema = exports.setListLayoutSchema = exports.createViewSchema = void 0;
8
8
  const zod_1 = require("zod");
9
9
  exports.createViewSchema = zod_1.z.object({
10
10
  name: zod_1.z.string().min(1),
@@ -261,6 +261,19 @@ exports.createRecordSchema = zod_1.z.object({
261
261
  ifAbsentQuery: zod_1.z.string().optional(),
262
262
  dryRun: zod_1.z.boolean().optional(),
263
263
  });
264
+ // app_publish: publish a scoped app to the ServiceNow Store or the company
265
+ // application repository. Deliberately NO credential fields — the Store
266
+ // account resolves from SN_STORE_USERNAME/SN_STORE_PASSWORD inside the verb,
267
+ // so credentials never transit MCP arguments or telemetry.
268
+ exports.publishAppSchema = zod_1.z.object({
269
+ app: zod_1.z.string().min(1),
270
+ version: zod_1.z.string().min(1),
271
+ devNotes: zod_1.z.string().optional(),
272
+ target: zod_1.z.union([zod_1.z.literal("store"), zod_1.z.literal("repo")]),
273
+ confirm: zod_1.z.boolean().optional(),
274
+ dryRun: zod_1.z.boolean().optional(),
275
+ timeoutMs: zod_1.z.number().int().positive().optional(),
276
+ });
264
277
  // invoke_rest: transport primitive for arbitrary authenticated REST operations
265
278
  // (Scripted REST included). The dry-run-unless-confirm gate lives in invokeRest
266
279
  // itself; the regex here rejects absolute URLs and non-/api/ paths early.
@@ -0,0 +1,173 @@
1
+ /**
2
+ * dove-sn publish-app — publish a scoped application to the ServiceNow Store
3
+ * and/or the company Application Repository, headlessly, then poll the
4
+ * publish's progress tracker until it terminates.
5
+ *
6
+ * Two engines, one result shape:
7
+ *
8
+ * STORE — replays the sys_app form's "Publish to ServiceNow Store" flow
9
+ * (ground truth: HAR capture, 2026-07-16): a form-login session
10
+ * POSTs /xmlhttp.do with sysparm_processor=
11
+ * sn_appauthor.ScopedAppUploaderAJAX & sysparm_name=start (plus the
12
+ * Store account credentials), then polls AJAXProgressStatusChecker/
13
+ * getStatus with the returned execution sys_id. Basic auth alone
14
+ * NO-OPS on xmlhttp.do — the form session + X-UserToken is required
15
+ * (proven by the sn-undo-default-push headless revert).
16
+ *
17
+ * REPO — uses the supported CI/CD REST API: POST /api/sn_cicd/app_repo/publish
18
+ * (basic auth via the shared client), then polls
19
+ * GET /api/sn_cicd/progress/{id}. Requires the sn_cicd role (or admin).
20
+ *
21
+ * STORE PUBLISH IS EXTERNALLY VISIBLE on the ServiceNow Store — the verb
22
+ * refuses to run either engine without confirm:true (dry-run is the default).
23
+ *
24
+ * Store account credentials resolve ONLY inside this module, from
25
+ * SN_STORE_USERNAME / SN_STORE_PASSWORD (or injected params for tests). The
26
+ * password is never logged, never echoed into notes or dry-run previews, and
27
+ * never accepted as a CLI flag.
28
+ */
29
+ import type { ServiceNowClient } from "./client";
30
+ import type { FormAuth, FormSession, PostResult } from "./table";
31
+ export type PublishTarget = "store" | "repo";
32
+ export interface PublishTransport {
33
+ openSession?: (auth: FormAuth) => Promise<FormSession>;
34
+ post?: (auth: FormAuth, session: FormSession, path: string, fields: Record<string, string>) => Promise<PostResult>;
35
+ sleep?: (ms: number) => Promise<void>;
36
+ }
37
+ export interface PublishAppParams {
38
+ /** Injectable for tests; defaults to createClient({}). */
39
+ client?: ServiceNowClient;
40
+ /** App selector: scope name (x_cadso_*), sys_app sys_id, or app name. */
41
+ app: string;
42
+ /** Version to publish, e.g. 6.0.20260716. Required — callers derive defaults. */
43
+ version: string;
44
+ devNotes?: string;
45
+ /** One target per call — "both" is CLI-level sequencing. */
46
+ target: PublishTarget;
47
+ /** Store account email; falls back to SN_STORE_USERNAME (store target only). */
48
+ storeUsername?: string;
49
+ /** Test seam only — the CLI never passes this; env SN_STORE_PASSWORD is the real source. */
50
+ storePassword?: string;
51
+ instance?: string;
52
+ user?: string;
53
+ password?: string;
54
+ /** The live gate: without confirm:true the result is a dry-run plan. */
55
+ confirm?: boolean;
56
+ dryRun?: boolean;
57
+ timeoutMs?: number;
58
+ /** Test seam — defaults to the real formSession functions + setTimeout sleep. */
59
+ transport?: PublishTransport;
60
+ }
61
+ export interface PublishStep {
62
+ name: string;
63
+ state: string;
64
+ percentComplete: string;
65
+ message: string;
66
+ }
67
+ /** The AJAXProgressStatusChecker getStatus answer, decoded. state "2" = success. */
68
+ export interface ProgressNode {
69
+ state: string;
70
+ percent_complete?: string;
71
+ message?: string;
72
+ name?: string;
73
+ result?: Record<string, string> | null;
74
+ children?: Array<ProgressNode>;
75
+ }
76
+ export interface PublishAppResult {
77
+ status: "dry-run" | "published" | "failed" | "timeout";
78
+ target: PublishTarget;
79
+ appSysId: string;
80
+ appScope: string;
81
+ appName: string;
82
+ /** sys_app.version at plan time (before the publish). */
83
+ versionBefore: string;
84
+ /** The version that was (or would be) published. */
85
+ version: string;
86
+ /** Store: the progress-tracker sys_id. Repo: the CI/CD progress id. */
87
+ executionSysId: string;
88
+ /** Store leg only — root result["sys_update_set.sys_id"]; "" when absent. */
89
+ updateSetSysId: string;
90
+ /** Store: tpp.servicenow.com editapplication URL. Repo: "". */
91
+ appLink: string;
92
+ steps: Array<PublishStep>;
93
+ polls: number;
94
+ /** Root progress message (failure diagnostics live here). */
95
+ message: string;
96
+ /** Dry-run only — the request that would be sent, credentials MASKED. */
97
+ requestPreview?: Record<string, string>;
98
+ note: string;
99
+ }
100
+ export declare var DEFAULT_PUBLISH_TIMEOUT_MS: number;
101
+ /** Repeat the LAST delay until the budget is spent — a publish can outrun 15s. */
102
+ export declare var PUBLISH_POLL_DELAYS_MS: ReadonlyArray<number>;
103
+ /**
104
+ * Build the exact ScopedAppUploaderAJAX start-call field map the browser sends
105
+ * (HAR ground truth). The repo variant is kept for parity/fixtures even though
106
+ * the repo leg ships on the CI/CD API: publish_to_store=false, empty username,
107
+ * and NO sysparm_password key at all.
108
+ */
109
+ export declare function buildStartFields(p: {
110
+ appSysId: string;
111
+ version: string;
112
+ devNotes: string;
113
+ target: PublishTarget;
114
+ storeUsername: string;
115
+ storePassword: string;
116
+ }): Record<string, string>;
117
+ /**
118
+ * Extract the `answer` attribute from an xmlhttp.do response envelope
119
+ * (`<xml answer="..." .../>`) and entity-decode it. A missing/empty answer is
120
+ * returned as an error, never thrown — callers fold it into a failed result.
121
+ */
122
+ export declare function parseXmlAnswer(xml: string): {
123
+ answer: string;
124
+ error: string;
125
+ };
126
+ /**
127
+ * Parse a decoded getStatus answer (a JSON progress tree). Throws a clear
128
+ * error on malformed JSON or an unexpected shape — the poll loop surfaces it
129
+ * as a failed result rather than a raw stack.
130
+ */
131
+ export declare function parseProgressTree(decodedAnswer: string): ProgressNode;
132
+ /**
133
+ * Terminal-state classification for the AJAX progress tracker.
134
+ * "2" = Succeeded (HAR ground truth). "3"/"4" = Failed/Cancelled per the
135
+ * GlideExecutionTracker state model. Anything else keeps polling until the
136
+ * timeout budget — conservative on purpose: an unknown state must not be
137
+ * reported as success OR failure.
138
+ */
139
+ export declare function classifyProgress(root: ProgressNode): {
140
+ terminal: boolean;
141
+ success: boolean;
142
+ };
143
+ /** Flatten the progress tree's children into an ordered step list. */
144
+ export declare function flattenSteps(root: ProgressNode): Array<PublishStep>;
145
+ /**
146
+ * Harvest the interesting result values from the progress tree, walking
147
+ * root-down: the FIRST non-empty appLink/appName/update-set sys_id encountered
148
+ * wins and is never overwritten. In the observed HAR trees each value appears
149
+ * on exactly one node, so first-wins is equivalent — the rule just needs to be
150
+ * deterministic.
151
+ */
152
+ export declare function harvestProgressResults(root: ProgressNode): {
153
+ appLink: string;
154
+ appName: string;
155
+ updateSetSysId: string;
156
+ };
157
+ /** CI/CD publish response: harvest the progress id (and echo status_label). */
158
+ export declare function parseCicdPublishResponse(body: unknown): {
159
+ progressId: string;
160
+ statusLabel: string;
161
+ error: string;
162
+ };
163
+ export interface CicdProgress {
164
+ /** "0" pending, "1" running, "2" successful, "3" failed, "4" cancelled. */
165
+ status: string;
166
+ statusLabel: string;
167
+ statusMessage: string;
168
+ statusDetail: string;
169
+ percentComplete: string;
170
+ }
171
+ /** CI/CD progress response → a typed snapshot. Unknown shapes become status "". */
172
+ export declare function parseCicdProgress(body: unknown): CicdProgress;
173
+ export declare function publishApp(params: PublishAppParams): Promise<PublishAppResult>;
@@ -0,0 +1,664 @@
1
+ "use strict";
2
+ /**
3
+ * dove-sn publish-app — publish a scoped application to the ServiceNow Store
4
+ * and/or the company Application Repository, headlessly, then poll the
5
+ * publish's progress tracker until it terminates.
6
+ *
7
+ * Two engines, one result shape:
8
+ *
9
+ * STORE — replays the sys_app form's "Publish to ServiceNow Store" flow
10
+ * (ground truth: HAR capture, 2026-07-16): a form-login session
11
+ * POSTs /xmlhttp.do with sysparm_processor=
12
+ * sn_appauthor.ScopedAppUploaderAJAX & sysparm_name=start (plus the
13
+ * Store account credentials), then polls AJAXProgressStatusChecker/
14
+ * getStatus with the returned execution sys_id. Basic auth alone
15
+ * NO-OPS on xmlhttp.do — the form session + X-UserToken is required
16
+ * (proven by the sn-undo-default-push headless revert).
17
+ *
18
+ * REPO — uses the supported CI/CD REST API: POST /api/sn_cicd/app_repo/publish
19
+ * (basic auth via the shared client), then polls
20
+ * GET /api/sn_cicd/progress/{id}. Requires the sn_cicd role (or admin).
21
+ *
22
+ * STORE PUBLISH IS EXTERNALLY VISIBLE on the ServiceNow Store — the verb
23
+ * refuses to run either engine without confirm:true (dry-run is the default).
24
+ *
25
+ * Store account credentials resolve ONLY inside this module, from
26
+ * SN_STORE_USERNAME / SN_STORE_PASSWORD (or injected params for tests). The
27
+ * password is never logged, never echoed into notes or dry-run previews, and
28
+ * never accepted as a CLI flag.
29
+ */
30
+ Object.defineProperty(exports, "__esModule", { value: true });
31
+ exports.PUBLISH_POLL_DELAYS_MS = exports.DEFAULT_PUBLISH_TIMEOUT_MS = void 0;
32
+ exports.buildStartFields = buildStartFields;
33
+ exports.parseXmlAnswer = parseXmlAnswer;
34
+ exports.parseProgressTree = parseProgressTree;
35
+ exports.classifyProgress = classifyProgress;
36
+ exports.flattenSteps = flattenSteps;
37
+ exports.harvestProgressResults = harvestProgressResults;
38
+ exports.parseCicdPublishResponse = parseCicdPublishResponse;
39
+ exports.parseCicdProgress = parseCicdProgress;
40
+ exports.publishApp = publishApp;
41
+ const client_1 = require("./client");
42
+ const table_1 = require("./table");
43
+ exports.DEFAULT_PUBLISH_TIMEOUT_MS = 120000;
44
+ /** Repeat the LAST delay until the budget is spent — a publish can outrun 15s. */
45
+ exports.PUBLISH_POLL_DELAYS_MS = [
46
+ 1000, 1000, 2000, 2000, 5000,
47
+ ];
48
+ var SYS_ID_RE = /^[0-9a-f]{32}$/;
49
+ var MASK = "***";
50
+ /**
51
+ * Build the exact ScopedAppUploaderAJAX start-call field map the browser sends
52
+ * (HAR ground truth). The repo variant is kept for parity/fixtures even though
53
+ * the repo leg ships on the CI/CD API: publish_to_store=false, empty username,
54
+ * and NO sysparm_password key at all.
55
+ */
56
+ function buildStartFields(p) {
57
+ var fields = {
58
+ sysparm_processor: "sn_appauthor.ScopedAppUploaderAJAX",
59
+ sysparm_name: "start",
60
+ sysparm_scope: "global",
61
+ sysparm_want_session_messages: "true",
62
+ sysparm_sys_id: p.appSysId,
63
+ // The UI sends a single space when the notes box is empty — mirror it.
64
+ sysparm_dev_notes: p.devNotes || " ",
65
+ sysparm_version: p.version,
66
+ sysparm_target_upload_URL: "",
67
+ sysparm_is_customization: "",
68
+ "ni.nolog.x_referer": "ignore",
69
+ x_referer: "sys_app.do?sys_id=" + p.appSysId,
70
+ };
71
+ if (p.target === "store") {
72
+ fields.sysparm_publish_to_store = "true";
73
+ fields.sysparm_username = p.storeUsername;
74
+ fields.sysparm_password = p.storePassword;
75
+ }
76
+ else {
77
+ fields.sysparm_publish_to_store = "false";
78
+ fields.sysparm_username = "";
79
+ }
80
+ return fields;
81
+ }
82
+ /**
83
+ * Extract the `answer` attribute from an xmlhttp.do response envelope
84
+ * (`<xml answer="..." .../>`) and entity-decode it. A missing/empty answer is
85
+ * returned as an error, never thrown — callers fold it into a failed result.
86
+ */
87
+ function parseXmlAnswer(xml) {
88
+ if (typeof xml !== "string" || xml.length === 0) {
89
+ return { answer: "", error: "empty xmlhttp.do response body" };
90
+ }
91
+ var m = /\banswer\s*=\s*"([^"]*)"/.exec(xml);
92
+ if (!m) {
93
+ return {
94
+ answer: "",
95
+ error: "no answer attribute in xmlhttp.do response: " + xml.slice(0, 200),
96
+ };
97
+ }
98
+ return { answer: (0, table_1.decodeHtmlEntities)(m[1]), error: "" };
99
+ }
100
+ function isProgressNode(v) {
101
+ return (typeof v === "object" &&
102
+ v !== null &&
103
+ typeof v.state === "string");
104
+ }
105
+ /**
106
+ * Parse a decoded getStatus answer (a JSON progress tree). Throws a clear
107
+ * error on malformed JSON or an unexpected shape — the poll loop surfaces it
108
+ * as a failed result rather than a raw stack.
109
+ */
110
+ function parseProgressTree(decodedAnswer) {
111
+ var parsed;
112
+ try {
113
+ parsed = JSON.parse(decodedAnswer);
114
+ }
115
+ catch (e) {
116
+ throw new Error("publish-app: getStatus answer is not valid JSON: " +
117
+ decodedAnswer.slice(0, 200));
118
+ }
119
+ if (!isProgressNode(parsed)) {
120
+ throw new Error("publish-app: getStatus answer is not a progress tree (no state field): " +
121
+ decodedAnswer.slice(0, 200));
122
+ }
123
+ return parsed;
124
+ }
125
+ /**
126
+ * Terminal-state classification for the AJAX progress tracker.
127
+ * "2" = Succeeded (HAR ground truth). "3"/"4" = Failed/Cancelled per the
128
+ * GlideExecutionTracker state model. Anything else keeps polling until the
129
+ * timeout budget — conservative on purpose: an unknown state must not be
130
+ * reported as success OR failure.
131
+ */
132
+ function classifyProgress(root) {
133
+ if (root.state === "2")
134
+ return { terminal: true, success: true };
135
+ if (root.state === "3" || root.state === "4") {
136
+ return { terminal: true, success: false };
137
+ }
138
+ return { terminal: false, success: false };
139
+ }
140
+ /** Flatten the progress tree's children into an ordered step list. */
141
+ function flattenSteps(root) {
142
+ var out = [];
143
+ function walk(node) {
144
+ if (node !== root && node.name) {
145
+ out.push({
146
+ name: node.name,
147
+ state: node.state,
148
+ percentComplete: node.percent_complete || "",
149
+ message: node.message || "",
150
+ });
151
+ }
152
+ var children = node.children;
153
+ if (Array.isArray(children)) {
154
+ for (var i = 0; i < children.length; i += 1) {
155
+ if (isProgressNode(children[i]))
156
+ walk(children[i]);
157
+ }
158
+ }
159
+ }
160
+ walk(root);
161
+ return out;
162
+ }
163
+ /**
164
+ * Harvest the interesting result values from the progress tree, walking
165
+ * root-down: the FIRST non-empty appLink/appName/update-set sys_id encountered
166
+ * wins and is never overwritten. In the observed HAR trees each value appears
167
+ * on exactly one node, so first-wins is equivalent — the rule just needs to be
168
+ * deterministic.
169
+ */
170
+ function harvestProgressResults(root) {
171
+ var appLink = "";
172
+ var appName = "";
173
+ var updateSetSysId = "";
174
+ function walk(node) {
175
+ var result = node.result;
176
+ if (result && typeof result === "object") {
177
+ if (typeof result.appLink === "string" && result.appLink && !appLink) {
178
+ appLink = result.appLink;
179
+ }
180
+ if (typeof result.appName === "string" && result.appName && !appName) {
181
+ appName = result.appName;
182
+ }
183
+ var us = result["sys_update_set.sys_id"];
184
+ if (typeof us === "string" && us && !updateSetSysId) {
185
+ updateSetSysId = us;
186
+ }
187
+ }
188
+ var children = node.children;
189
+ if (Array.isArray(children)) {
190
+ for (var i = 0; i < children.length; i += 1) {
191
+ if (isProgressNode(children[i]))
192
+ walk(children[i]);
193
+ }
194
+ }
195
+ }
196
+ walk(root);
197
+ return { appLink: appLink, appName: appName, updateSetSysId: updateSetSysId };
198
+ }
199
+ /** CI/CD publish response: harvest the progress id (and echo status_label). */
200
+ function parseCicdPublishResponse(body) {
201
+ if (typeof body !== "object" || body === null) {
202
+ return { progressId: "", statusLabel: "", error: "empty CI/CD response" };
203
+ }
204
+ var result = body.result;
205
+ if (typeof result !== "object" || result === null) {
206
+ return {
207
+ progressId: "",
208
+ statusLabel: "",
209
+ error: "CI/CD response has no result object",
210
+ };
211
+ }
212
+ var r = result;
213
+ var id = "";
214
+ if (r.links &&
215
+ typeof r.links === "object" &&
216
+ r.links.progress &&
217
+ typeof r.links.progress === "object" &&
218
+ typeof r.links.progress.id === "string") {
219
+ id = r.links.progress.id;
220
+ }
221
+ var label = typeof r.status_label === "string" ? r.status_label : "";
222
+ if (!id) {
223
+ return {
224
+ progressId: "",
225
+ statusLabel: label,
226
+ error: "CI/CD response carries no progress id",
227
+ };
228
+ }
229
+ return { progressId: id, statusLabel: label, error: "" };
230
+ }
231
+ /** CI/CD progress response → a typed snapshot. Unknown shapes become status "". */
232
+ function parseCicdProgress(body) {
233
+ var empty = {
234
+ status: "",
235
+ statusLabel: "",
236
+ statusMessage: "",
237
+ statusDetail: "",
238
+ percentComplete: "",
239
+ };
240
+ if (typeof body !== "object" || body === null)
241
+ return empty;
242
+ var result = body.result;
243
+ if (typeof result !== "object" || result === null)
244
+ return empty;
245
+ var r = result;
246
+ function str(v) {
247
+ if (typeof v === "string")
248
+ return v;
249
+ if (typeof v === "number")
250
+ return String(v);
251
+ return "";
252
+ }
253
+ return {
254
+ status: str(r.status),
255
+ statusLabel: str(r.status_label),
256
+ statusMessage: str(r.status_message),
257
+ statusDetail: str(r.status_detail),
258
+ percentComplete: str(r.percent_complete),
259
+ };
260
+ }
261
+ function realSleep(ms) {
262
+ return new Promise(function (resolve) {
263
+ setTimeout(resolve, ms);
264
+ });
265
+ }
266
+ async function resolveApp(client, selector) {
267
+ var fields = ["sys_id", "scope", "name", "version"];
268
+ var queries = [];
269
+ if (SYS_ID_RE.test(selector)) {
270
+ queries.push("sys_id=" + selector);
271
+ }
272
+ else {
273
+ queries.push("scope=" + selector);
274
+ queries.push("name=" + selector);
275
+ }
276
+ for (var i = 0; i < queries.length; i += 1) {
277
+ var rows = await client.table.query("sys_app", queries[i], { limit: 2, fields: fields });
278
+ if (rows.length === 1) {
279
+ return {
280
+ sysId: rows[0].sys_id || "",
281
+ scope: rows[0].scope || "",
282
+ name: rows[0].name || "",
283
+ version: rows[0].version || "",
284
+ };
285
+ }
286
+ if (rows.length > 1) {
287
+ throw new Error("publish-app: app selector '" +
288
+ selector +
289
+ "' matches more than one sys_app row — use the scope or sys_id.");
290
+ }
291
+ }
292
+ throw new Error("publish-app: no sys_app row matches '" +
293
+ selector +
294
+ "' (tried " +
295
+ queries.join(", then ") +
296
+ ").");
297
+ }
298
+ function baseResult(target, app, version) {
299
+ return {
300
+ status: "failed",
301
+ target: target,
302
+ appSysId: app.sysId,
303
+ appScope: app.scope,
304
+ appName: app.name,
305
+ versionBefore: app.version,
306
+ version: version,
307
+ executionSysId: "",
308
+ updateSetSysId: "",
309
+ appLink: "",
310
+ steps: [],
311
+ polls: 0,
312
+ message: "",
313
+ note: "",
314
+ };
315
+ }
316
+ function resolveStoreCreds(params) {
317
+ var username = params.storeUsername || process.env.SN_STORE_USERNAME || "";
318
+ var password = params.storePassword || process.env.SN_STORE_PASSWORD || "";
319
+ if (!username || !password) {
320
+ throw new Error("publish-app: Store credentials missing — set SN_STORE_USERNAME and " +
321
+ "SN_STORE_PASSWORD in the env file the verb loads (--env). The " +
322
+ "password is never accepted as a flag.");
323
+ }
324
+ return { username: username, password: password };
325
+ }
326
+ async function publishToStore(params, app) {
327
+ var result = baseResult("store", app, params.version);
328
+ var creds = resolveStoreCreds(params);
329
+ var transport = params.transport || {};
330
+ var openSession = transport.openSession || table_1.openFormSession;
331
+ var post = transport.post || table_1.postForm;
332
+ var sleepFn = transport.sleep || realSleep;
333
+ var timeoutMs = params.timeoutMs != null ? params.timeoutMs : exports.DEFAULT_PUBLISH_TIMEOUT_MS;
334
+ var auth = (0, table_1.resolveFormAuth)({
335
+ instance: params.instance,
336
+ user: params.user,
337
+ password: params.password,
338
+ });
339
+ var session = await openSession(auth);
340
+ var startFields = buildStartFields({
341
+ appSysId: app.sysId,
342
+ version: params.version,
343
+ devNotes: params.devNotes || "",
344
+ target: "store",
345
+ storeUsername: creds.username,
346
+ storePassword: creds.password,
347
+ });
348
+ var startRes = await post(auth, session, "/xmlhttp.do", startFields);
349
+ if (startRes.status < 200 || startRes.status >= 300) {
350
+ result.message = "start call returned HTTP " + startRes.status;
351
+ result.note =
352
+ "publish-app: the ScopedAppUploaderAJAX start call failed (HTTP " +
353
+ startRes.status +
354
+ "): " +
355
+ (startRes.body || "").slice(0, 200);
356
+ return result;
357
+ }
358
+ var startAnswer = parseXmlAnswer(startRes.body);
359
+ if (startAnswer.error || !SYS_ID_RE.test(startAnswer.answer)) {
360
+ result.message = startAnswer.error || "answer is not a sys_id";
361
+ result.note =
362
+ "publish-app: the start call did not return an execution sys_id — " +
363
+ (startAnswer.error ||
364
+ "got '" + startAnswer.answer.slice(0, 64) + "'") +
365
+ ". The upload never started.";
366
+ return result;
367
+ }
368
+ result.executionSysId = startAnswer.answer;
369
+ var startedAt = Date.now();
370
+ var pollIndex = 0;
371
+ while (true) {
372
+ var delay = exports.PUBLISH_POLL_DELAYS_MS[Math.min(pollIndex, exports.PUBLISH_POLL_DELAYS_MS.length - 1)];
373
+ if (Date.now() - startedAt + delay > timeoutMs) {
374
+ result.status = "timeout";
375
+ result.note =
376
+ "publish-app: progress tracker " +
377
+ result.executionSysId +
378
+ " did not terminate within " +
379
+ timeoutMs +
380
+ "ms after " +
381
+ result.polls +
382
+ " poll(s). Check /sys_execution_tracker.do?sys_id=" +
383
+ result.executionSysId +
384
+ " on the instance.";
385
+ return result;
386
+ }
387
+ await sleepFn(delay);
388
+ pollIndex += 1;
389
+ result.polls += 1;
390
+ var pollRes = await post(auth, session, "/xmlhttp.do", {
391
+ sysparm_processor: "AJAXProgressStatusChecker",
392
+ sysparm_name: "getStatus",
393
+ sysparm_scope: "global",
394
+ sysparm_want_session_messages: "true",
395
+ sysparm_execution_id: result.executionSysId,
396
+ "ni.nolog.x_referer": "ignore",
397
+ x_referer: "sys_app.do?sys_id=" + app.sysId,
398
+ });
399
+ if (pollRes.status < 200 || pollRes.status >= 300) {
400
+ result.message = "getStatus returned HTTP " + pollRes.status;
401
+ result.note =
402
+ "publish-app: progress poll failed (HTTP " +
403
+ pollRes.status +
404
+ ") — tracker " +
405
+ result.executionSysId +
406
+ " may still be running on the instance.";
407
+ return result;
408
+ }
409
+ var pollAnswer = parseXmlAnswer(pollRes.body);
410
+ if (pollAnswer.error) {
411
+ result.message = pollAnswer.error;
412
+ result.note =
413
+ "publish-app: could not read the progress answer — " + pollAnswer.error;
414
+ return result;
415
+ }
416
+ var tree;
417
+ try {
418
+ tree = parseProgressTree(pollAnswer.answer);
419
+ }
420
+ catch (e) {
421
+ result.message = e instanceof Error ? e.message : String(e);
422
+ result.note = result.message;
423
+ return result;
424
+ }
425
+ var state = classifyProgress(tree);
426
+ if (!state.terminal)
427
+ continue;
428
+ result.steps = flattenSteps(tree);
429
+ result.message = tree.message || "";
430
+ var harvested = harvestProgressResults(tree);
431
+ result.appLink = harvested.appLink;
432
+ result.updateSetSysId = harvested.updateSetSysId;
433
+ if (state.success) {
434
+ result.status = "published";
435
+ result.note =
436
+ "Published " +
437
+ app.name +
438
+ " v" +
439
+ params.version +
440
+ " to the ServiceNow Store" +
441
+ (result.appLink ? " — " + result.appLink : "") +
442
+ " (" +
443
+ result.polls +
444
+ " poll(s)).";
445
+ }
446
+ else {
447
+ result.status = "failed";
448
+ result.note =
449
+ "publish-app: store publish terminated in state " +
450
+ tree.state +
451
+ (result.message ? " — " + result.message : "") +
452
+ ". Tracker " +
453
+ result.executionSysId +
454
+ ".";
455
+ }
456
+ return result;
457
+ }
458
+ }
459
+ async function publishToRepo(params, app, client) {
460
+ var result = baseResult("repo", app, params.version);
461
+ var transport = params.transport || {};
462
+ var sleepFn = transport.sleep || realSleep;
463
+ var timeoutMs = params.timeoutMs != null ? params.timeoutMs : exports.DEFAULT_PUBLISH_TIMEOUT_MS;
464
+ var path = "/api/sn_cicd/app_repo/publish?sys_id=" +
465
+ encodeURIComponent(app.sysId) +
466
+ "&version=" +
467
+ encodeURIComponent(params.version);
468
+ if (params.devNotes) {
469
+ path += "&dev_notes=" + encodeURIComponent(params.devNotes);
470
+ }
471
+ var res = await client.now.invoke({ method: "POST", path: path });
472
+ if (res.status === 401 || res.status === 403) {
473
+ result.message = "HTTP " + res.status + " from the CI/CD API";
474
+ result.note =
475
+ "publish-app: the CI/CD publish was refused (HTTP " +
476
+ res.status +
477
+ ") — the API user needs the sn_cicd role (or admin) and the CI/CD " +
478
+ "plugin must be active.";
479
+ return result;
480
+ }
481
+ if (res.status < 200 || res.status >= 300) {
482
+ result.message = "HTTP " + res.status + " from the CI/CD API";
483
+ result.note =
484
+ "publish-app: POST /api/sn_cicd/app_repo/publish failed (HTTP " +
485
+ res.status +
486
+ "): " +
487
+ JSON.stringify(res.body).slice(0, 300) +
488
+ " — a version at or below the app's current repo version is the usual cause.";
489
+ return result;
490
+ }
491
+ var publishRes = parseCicdPublishResponse(res.body);
492
+ if (publishRes.error) {
493
+ result.message = publishRes.error;
494
+ result.note =
495
+ "publish-app: the CI/CD publish response is missing its progress link — " +
496
+ publishRes.error;
497
+ return result;
498
+ }
499
+ result.executionSysId = publishRes.progressId;
500
+ var startedAt = Date.now();
501
+ var pollIndex = 0;
502
+ while (true) {
503
+ var delay = exports.PUBLISH_POLL_DELAYS_MS[Math.min(pollIndex, exports.PUBLISH_POLL_DELAYS_MS.length - 1)];
504
+ if (Date.now() - startedAt + delay > timeoutMs) {
505
+ result.status = "timeout";
506
+ result.note =
507
+ "publish-app: CI/CD progress " +
508
+ result.executionSysId +
509
+ " did not terminate within " +
510
+ timeoutMs +
511
+ "ms after " +
512
+ result.polls +
513
+ " poll(s). Check GET /api/sn_cicd/progress/" +
514
+ result.executionSysId +
515
+ ".";
516
+ return result;
517
+ }
518
+ await sleepFn(delay);
519
+ pollIndex += 1;
520
+ result.polls += 1;
521
+ var progRes = await client.now.invoke({
522
+ method: "GET",
523
+ path: "/api/sn_cicd/progress/" + encodeURIComponent(result.executionSysId),
524
+ });
525
+ if (progRes.status < 200 || progRes.status >= 300) {
526
+ result.message = "HTTP " + progRes.status + " from the CI/CD progress API";
527
+ result.note =
528
+ "publish-app: CI/CD progress poll failed (HTTP " +
529
+ progRes.status +
530
+ ") — progress id " +
531
+ result.executionSysId +
532
+ ".";
533
+ return result;
534
+ }
535
+ var progress = parseCicdProgress(progRes.body);
536
+ result.message = progress.statusMessage || progress.statusLabel;
537
+ if (progress.status === "2") {
538
+ result.status = "published";
539
+ result.steps = [
540
+ {
541
+ name: "CI/CD app_repo publish",
542
+ state: progress.status,
543
+ percentComplete: progress.percentComplete,
544
+ message: result.message,
545
+ },
546
+ ];
547
+ result.note =
548
+ "Published " +
549
+ app.name +
550
+ " v" +
551
+ params.version +
552
+ " to the application repository (" +
553
+ result.polls +
554
+ " poll(s)).";
555
+ return result;
556
+ }
557
+ if (progress.status === "3" || progress.status === "4") {
558
+ result.status = "failed";
559
+ result.steps = [
560
+ {
561
+ name: "CI/CD app_repo publish",
562
+ state: progress.status,
563
+ percentComplete: progress.percentComplete,
564
+ message: progress.statusDetail || result.message,
565
+ },
566
+ ];
567
+ result.note =
568
+ "publish-app: CI/CD publish terminated " +
569
+ (progress.statusLabel || "in state " + progress.status) +
570
+ (progress.statusDetail ? " — " + progress.statusDetail : "") +
571
+ ".";
572
+ return result;
573
+ }
574
+ // "0" pending / "1" running / unknown — keep polling until the budget ends.
575
+ }
576
+ }
577
+ function maskedPreview(target, app, params) {
578
+ if (target === "store") {
579
+ var username = params.storeUsername || process.env.SN_STORE_USERNAME || "";
580
+ var hasPassword = Boolean(params.storePassword || process.env.SN_STORE_PASSWORD);
581
+ var fields = buildStartFields({
582
+ appSysId: app.sysId,
583
+ version: params.version,
584
+ devNotes: params.devNotes || "",
585
+ target: "store",
586
+ storeUsername: username || MASK,
587
+ storePassword: hasPassword ? MASK : "(MISSING — set SN_STORE_PASSWORD)",
588
+ });
589
+ // Belt and braces: the preview never carries a real password.
590
+ fields.sysparm_password = hasPassword
591
+ ? MASK
592
+ : "(MISSING — set SN_STORE_PASSWORD)";
593
+ return fields;
594
+ }
595
+ var preview = {
596
+ method: "POST",
597
+ path: "/api/sn_cicd/app_repo/publish?sys_id=" +
598
+ app.sysId +
599
+ "&version=" +
600
+ params.version,
601
+ };
602
+ if (params.devNotes)
603
+ preview.dev_notes = params.devNotes;
604
+ return preview;
605
+ }
606
+ async function publishApp(params) {
607
+ if (!params || typeof params !== "object") {
608
+ throw new Error("publish-app: params object is required.");
609
+ }
610
+ if (!params.app) {
611
+ throw new Error("publish-app: app (scope, sys_id, or name) is required.");
612
+ }
613
+ if (!params.version) {
614
+ throw new Error("publish-app: version is required.");
615
+ }
616
+ if (params.target !== "store" && params.target !== "repo") {
617
+ throw new Error("publish-app: target must be 'store' or 'repo' (got '" +
618
+ String(params.target) +
619
+ "').");
620
+ }
621
+ // A NaN/non-positive timeout would make the poll-loop budget check always
622
+ // false — an infinite loop. Refuse it here so every caller is covered.
623
+ if (params.timeoutMs != null &&
624
+ (typeof params.timeoutMs !== "number" ||
625
+ !isFinite(params.timeoutMs) ||
626
+ params.timeoutMs <= 0)) {
627
+ throw new Error("publish-app: timeoutMs must be a positive number (got '" +
628
+ String(params.timeoutMs) +
629
+ "').");
630
+ }
631
+ var live = params.confirm === true && params.dryRun !== true;
632
+ // Fail fast on missing Store creds BEFORE any network work — a live run that
633
+ // resolves the app and only then discovers the missing password wastes the
634
+ // caller's confirm. Dry-runs still render (with the gap flagged in the preview).
635
+ if (live && params.target === "store") {
636
+ resolveStoreCreds(params);
637
+ }
638
+ var client = params.client || (0, client_1.createClient)({});
639
+ var app = await resolveApp(client, params.app);
640
+ if (!live) {
641
+ var dry = baseResult(params.target, app, params.version);
642
+ dry.status = "dry-run";
643
+ dry.requestPreview = maskedPreview(params.target, app, params);
644
+ dry.note =
645
+ "dry-run: nothing was published. Would publish " +
646
+ app.name +
647
+ " (" +
648
+ app.scope +
649
+ ", current version " +
650
+ app.version +
651
+ ") as v" +
652
+ params.version +
653
+ " to the " +
654
+ (params.target === "store"
655
+ ? "ServiceNow Store (EXTERNALLY VISIBLE)"
656
+ : "application repository") +
657
+ ". Re-run with confirm to publish.";
658
+ return dry;
659
+ }
660
+ if (params.target === "store") {
661
+ return publishToStore(params, app);
662
+ }
663
+ return publishToRepo(params, app, client);
664
+ }
@@ -78,6 +78,14 @@ export declare function getNewRecordForm(auth: FormAuth, session: FormSession):
78
78
  * handles double/single-quoted attributes in either order. Exported for testing.
79
79
  */
80
80
  export declare function parseFormInputs(html: string): Record<string, string>;
81
+ /**
82
+ * Decode the HTML entities ServiceNow uses in attribute values. Unescape &amp;
83
+ * LAST. Doing it first double-unescapes inputs like "&amp;lt;" ("&amp;lt;" ->
84
+ * "&lt;" -> "<"), the js/double-escaping vulnerability. With the ampersand
85
+ * handled last, each entity is unescaped exactly once. Exported for the
86
+ * xmlhttp.do consumers (publishApp's progress-tree answers arrive entity-encoded).
87
+ */
88
+ export declare function decodeHtmlEntities(s: string): string;
81
89
  export interface PostResult {
82
90
  status: number;
83
91
  location: string;
@@ -20,6 +20,7 @@ exports.setCurrentApplication = setCurrentApplication;
20
20
  exports.getRecordForm = getRecordForm;
21
21
  exports.getNewRecordForm = getNewRecordForm;
22
22
  exports.parseFormInputs = parseFormInputs;
23
+ exports.decodeHtmlEntities = decodeHtmlEntities;
23
24
  exports.postForm = postForm;
24
25
  /** Env precedence mirrors client.ts: explicit > SN_* > SN_DEV_* > SN_PROD_*. */
25
26
  function resolveFormAuth(cfg) {
@@ -246,10 +247,14 @@ function attr(tag, name) {
246
247
  return decodeHtml(sq[1]);
247
248
  return null;
248
249
  }
249
- function decodeHtml(s) {
250
- // Unescape &amp; LAST. Doing it first double-unescapes inputs like "&amp;lt;"
251
- // ("&amp;lt;" -> "&lt;" -> "<"), the js/double-escaping vulnerability. With the
252
- // ampersand handled last, each entity is unescaped exactly once.
250
+ /**
251
+ * Decode the HTML entities ServiceNow uses in attribute values. Unescape &amp;
252
+ * LAST. Doing it first double-unescapes inputs like "&amp;lt;" ("&amp;lt;" ->
253
+ * "&lt;" -> "<"), the js/double-escaping vulnerability. With the ampersand
254
+ * handled last, each entity is unescaped exactly once. Exported for the
255
+ * xmlhttp.do consumers (publishApp's progress-tree answers arrive entity-encoded).
256
+ */
257
+ function decodeHtmlEntities(s) {
253
258
  return s
254
259
  .replace(/&lt;/g, "<")
255
260
  .replace(/&gt;/g, ">")
@@ -257,6 +262,9 @@ function decodeHtml(s) {
257
262
  .replace(/&#39;/g, "'")
258
263
  .replace(/&amp;/g, "&");
259
264
  }
265
+ function decodeHtml(s) {
266
+ return decodeHtmlEntities(s);
267
+ }
260
268
  /** POST a form-urlencoded field map to a `.do` path. Follows nothing — returns the 302. */
261
269
  async function postForm(auth, session, path, fields) {
262
270
  var B = base(auth);
@@ -9,7 +9,7 @@ export { buildColumnXml, xmlEscape } from "./buildColumnXml";
9
9
  export type { NormalizedColumn } from "./buildColumnXml";
10
10
  export { normalizeColumns, resolveType, applyTableSaveOverlay, defaultAccessFlags, showInMenuKey, listEditKey, TYPE_MAP, } from "./buildTableSave";
11
11
  export type { ColumnSpec, AccessFlags, OverlaySpec } from "./buildTableSave";
12
- export { resolveFormAuth, openFormSession, setCurrentApplication, getRecordForm, getNewRecordForm, parseFormInputs, postForm, scrapeCk, } from "./formSession";
12
+ export { resolveFormAuth, openFormSession, setCurrentApplication, getRecordForm, getNewRecordForm, parseFormInputs, postForm, scrapeCk, decodeHtmlEntities, } from "./formSession";
13
13
  export type { FormAuth, FormSession, HarvestedForm, PostResult, } from "./formSession";
14
14
  export { addColumn, deriveElement } from "./addColumn";
15
15
  export type { AddColumnParams, AddColumnResult } from "./addColumn";
@@ -5,7 +5,7 @@
5
5
  * live-validation caveat.
6
6
  */
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
- exports.findTruncationRisk = exports.toStoredValue = exports.resolveAttributes = exports.setColumn = exports.deriveElement = exports.addColumn = exports.scrapeCk = exports.postForm = exports.parseFormInputs = exports.getNewRecordForm = exports.getRecordForm = exports.setCurrentApplication = exports.openFormSession = exports.resolveFormAuth = exports.TYPE_MAP = exports.listEditKey = exports.showInMenuKey = exports.defaultAccessFlags = exports.applyTableSaveOverlay = exports.resolveType = exports.normalizeColumns = exports.xmlEscape = exports.buildColumnXml = exports.DEFAULT_COLUMNS_REL_ID = exports.DEFAULT_SAVE_ACTION = exports.DEFAULT_SUPER_CLASS = exports.parseSysIdFromLocation = exports.projectTableGraph = exports.createTable = void 0;
8
+ exports.findTruncationRisk = exports.toStoredValue = exports.resolveAttributes = exports.setColumn = exports.deriveElement = exports.addColumn = exports.decodeHtmlEntities = exports.scrapeCk = exports.postForm = exports.parseFormInputs = exports.getNewRecordForm = exports.getRecordForm = exports.setCurrentApplication = exports.openFormSession = exports.resolveFormAuth = exports.TYPE_MAP = exports.listEditKey = exports.showInMenuKey = exports.defaultAccessFlags = exports.applyTableSaveOverlay = exports.resolveType = exports.normalizeColumns = exports.xmlEscape = exports.buildColumnXml = exports.DEFAULT_COLUMNS_REL_ID = exports.DEFAULT_SAVE_ACTION = exports.DEFAULT_SUPER_CLASS = exports.parseSysIdFromLocation = exports.projectTableGraph = exports.createTable = void 0;
9
9
  var createTable_1 = require("./createTable");
10
10
  Object.defineProperty(exports, "createTable", { enumerable: true, get: function () { return createTable_1.createTable; } });
11
11
  Object.defineProperty(exports, "projectTableGraph", { enumerable: true, get: function () { return createTable_1.projectTableGraph; } });
@@ -33,6 +33,7 @@ Object.defineProperty(exports, "getNewRecordForm", { enumerable: true, get: func
33
33
  Object.defineProperty(exports, "parseFormInputs", { enumerable: true, get: function () { return formSession_1.parseFormInputs; } });
34
34
  Object.defineProperty(exports, "postForm", { enumerable: true, get: function () { return formSession_1.postForm; } });
35
35
  Object.defineProperty(exports, "scrapeCk", { enumerable: true, get: function () { return formSession_1.scrapeCk; } });
36
+ Object.defineProperty(exports, "decodeHtmlEntities", { enumerable: true, get: function () { return formSession_1.decodeHtmlEntities; } });
36
37
  var addColumn_1 = require("./addColumn");
37
38
  Object.defineProperty(exports, "addColumn", { enumerable: true, get: function () { return addColumn_1.addColumn; } });
38
39
  Object.defineProperty(exports, "deriveElement", { enumerable: true, get: function () { return addColumn_1.deriveElement; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenonhq/dovetail-servicenow",
3
- "version": "0.0.30",
3
+ "version": "0.0.31",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },