@tenonhq/dovetail-servicenow 0.0.47 → 0.0.49
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 +152 -7
- package/dist/choices.d.ts +12 -1
- package/dist/choices.js +113 -10
- package/dist/cli.d.ts +24 -16
- package/dist/cli.js +245 -308
- package/dist/cliUsage.d.ts +82 -0
- package/dist/cliUsage.js +961 -0
- package/dist/client.d.ts +16 -1
- package/dist/client.js +21 -1
- package/dist/createRecord.js +9 -15
- package/dist/deleteRecord.d.ts +60 -0
- package/dist/deleteRecord.js +165 -0
- package/dist/fieldsFromJson.d.ts +41 -0
- package/dist/fieldsFromJson.js +123 -0
- package/dist/formatter.d.ts +2 -1
- package/dist/formatter.js +116 -41
- package/dist/index.d.ts +5 -1
- package/dist/index.js +9 -2
- package/dist/mcp/registry.d.ts +1 -1
- package/dist/mcp/registry.js +58 -8
- package/dist/mcp/schemas.d.ts +121 -70
- package/dist/mcp/schemas.js +30 -1
- package/dist/setField.d.ts +23 -0
- package/dist/setField.js +97 -7
- package/dist/table/addColumn.d.ts +34 -3
- package/dist/table/addColumn.js +304 -53
- package/dist/table/buildTableSave.d.ts +7 -0
- package/dist/table/overrideColumn.d.ts +12 -4
- package/dist/table/overrideColumn.js +35 -4
- package/dist/table/setColumn.d.ts +6 -0
- package/dist/table/setColumn.js +29 -0
- package/dist/types.d.ts +60 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -110,6 +110,19 @@ file — handy for CI.
|
|
|
110
110
|
|
|
111
111
|
## CLI
|
|
112
112
|
|
|
113
|
+
### Getting help
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
npx dove-sn help # verb index
|
|
117
|
+
npx dove-sn help add-choices # one verb: required/optional flags, value formats, write gate, example
|
|
118
|
+
npx dove-sn add-choices --help # same — never loads credentials or touches the instance
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Every verb's usage is rendered from one table (`src/cliUsage.ts`), so the help
|
|
122
|
+
and the dispatcher cannot drift — a test fails if a verb is missing an entry. A
|
|
123
|
+
`Missing required flags` error prints that verb's usage block under the error
|
|
124
|
+
line; a mistyped verb lists the closest matches and exits 1.
|
|
125
|
+
|
|
113
126
|
```bash
|
|
114
127
|
# Inline form
|
|
115
128
|
npx dove-sn add-choices \
|
|
@@ -127,8 +140,19 @@ npx dove-sn remove-choices \
|
|
|
127
140
|
--column state \
|
|
128
141
|
--update-set 0083c3bb33d003507b18bc534d5c7b6d \
|
|
129
142
|
--values "expired,failed"
|
|
143
|
+
|
|
144
|
+
# Preview either verb — reads happen, NOTHING is written
|
|
145
|
+
npx dove-sn add-choices --table ... --column ... --update-set ... --choices "..." --dry-run
|
|
146
|
+
npx dove-sn remove-choices --table ... --column ... --update-set ... --values "..." --dry-run
|
|
130
147
|
```
|
|
131
148
|
|
|
149
|
+
`--dry-run` still resolves the field and the update set (so a mistyped column or a
|
|
150
|
+
closed update set fails exactly as it would live), then reports what the live run
|
|
151
|
+
would do without sending a single write. Rows are tagged `[would create]` /
|
|
152
|
+
`[would update]` / `[would deactivate]` — never `[created]` — and the header reads
|
|
153
|
+
`(DRY RUN — nothing written)`. The `--json` result carries `dryRun: true` and the
|
|
154
|
+
same `would-*` action values.
|
|
155
|
+
|
|
132
156
|
JSON payload shape:
|
|
133
157
|
|
|
134
158
|
```json
|
|
@@ -154,6 +178,14 @@ place of `choices`.
|
|
|
154
178
|
left alone — retiring values does not un-make the column a choice field.
|
|
155
179
|
- **Idempotent.** `deactivated` (a live row was flipped) / `unchanged` (already
|
|
156
180
|
inactive) / `missing` (no such value on the field). Re-running writes nothing.
|
|
181
|
+
- **Case-sensitive, with a hint.** `sys_choice.value` is case-sensitive, so
|
|
182
|
+
`--values DELIVERED` does **not** match a stored `delivered` — it reports
|
|
183
|
+
`missing`. When the field holds the same spelling in a different case, the row
|
|
184
|
+
carries `nearMatches` (the stored spelling(s)) and the CLI prints
|
|
185
|
+
`[missing] DELIVERED — no exact match; did you mean "delivered"? (choice values are case-sensitive)`.
|
|
186
|
+
Matching is never case-folded: a field holding both `delivered` and
|
|
187
|
+
`Delivered` treats them as two distinct values, and a request for either one
|
|
188
|
+
touches only its own row.
|
|
157
189
|
- **Duplicates.** `sys_choice` has no uniqueness constraint on
|
|
158
190
|
`(name, element, value, language)`, so a field can hold several live rows for
|
|
159
191
|
one value. Every live row is deactivated, and `sysIds` lists all of them — a
|
|
@@ -181,8 +213,8 @@ var result = await addChoicesToField(client, { /* ... */ });
|
|
|
181
213
|
|
|
182
214
|
console.log(result.choices);
|
|
183
215
|
// [
|
|
184
|
-
// { value: "delivered", label: "Delivered", sysId: "...", action: "created" },
|
|
185
|
-
// { value: "failed", label: "Failed", sysId: "...", action: "created" }
|
|
216
|
+
// { value: "delivered", label: "Delivered", sysId: "...", sysIds: ["..."], action: "created" },
|
|
217
|
+
// { value: "failed", label: "Failed", sysId: "...", sysIds: ["..."], action: "created" }
|
|
186
218
|
// ]
|
|
187
219
|
|
|
188
220
|
var removed = await removeChoicesFromField(client, {
|
|
@@ -192,8 +224,48 @@ var removed = await removeChoicesFromField(client, {
|
|
|
192
224
|
values: ["expired"],
|
|
193
225
|
});
|
|
194
226
|
// removed.choices[0] -> { value: "expired", sysId: "...", sysIds: ["..."], action: "deactivated" }
|
|
227
|
+
|
|
228
|
+
// Plan without writing — identical reads, zero writes, `would-*` actions
|
|
229
|
+
var plan = await addChoicesToField(client, { /* ... */ dryRun: true });
|
|
230
|
+
// plan.dryRun -> true; plan.choices[0].action -> "would-create" | "would-update" | "unchanged"
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Result shape
|
|
234
|
+
|
|
235
|
+
Both verbs return the **same `field` envelope**, so one consumer can format either:
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
interface ChoiceFieldRef {
|
|
239
|
+
table: string;
|
|
240
|
+
column: string;
|
|
241
|
+
language: string; // remove: the language matched; add: the default for choices without one
|
|
242
|
+
scope: string; // sys_scope sys_id of the dictionary record
|
|
243
|
+
dictionarySysId: string;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
interface AddChoicesResult {
|
|
247
|
+
field: ChoiceFieldRef;
|
|
248
|
+
dictionary: { choiceWas: ChoiceType; choiceNow: ChoiceType }; // add-only transition
|
|
249
|
+
updateSet: { sysId: string; name: string };
|
|
250
|
+
dryRun: boolean;
|
|
251
|
+
choices: Array<ChoiceActionResult>; // action: created | updated | unchanged | would-create | would-update
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
interface RemoveChoicesResult {
|
|
255
|
+
field: ChoiceFieldRef;
|
|
256
|
+
updateSet: { sysId: string; name: string };
|
|
257
|
+
dryRun: boolean;
|
|
258
|
+
choices: Array<ChoiceRemovalResult>; // action: deactivated | unchanged | missing | would-deactivate
|
|
259
|
+
} // + nearMatches?: string[] on a case-only "missing"
|
|
195
260
|
```
|
|
196
261
|
|
|
262
|
+
> **Breaking change (0.0.x).** Earlier releases returned
|
|
263
|
+
> `AddChoicesResult.dictionary: { sysId, scope, choiceWas, choiceNow }` and a
|
|
264
|
+
> `RemoveChoicesResult.field` without `scope`. The dictionary sys_id now lives at
|
|
265
|
+
> `field.dictionarySysId` on both verbs and `scope` at `field.scope`;
|
|
266
|
+
> `dictionary` keeps only the choice-type transition. Consumers reading
|
|
267
|
+
> `result.dictionary.sysId` or `result.dictionary.scope` must move to `result.field`.
|
|
268
|
+
|
|
197
269
|
Both verbs write one value at a time. If a write fails partway through they
|
|
198
270
|
throw a **`ChoiceWriteError`** carrying `completed` (the values that already
|
|
199
271
|
landed, in result shape), `failedValue` (the one that threw — its state on the
|
|
@@ -769,10 +841,70 @@ already matches a row). Exit codes: `0` created / skipped-in-sync / dry-run, `1`
|
|
|
769
841
|
args, `2` write landed unverified (or skipped with drift). To **update** an existing
|
|
770
842
|
record instead, use `set-field`.
|
|
771
843
|
|
|
772
|
-
|
|
844
|
+
#### Field sources (`--fields`, `--from-json`, `--from-stdin`)
|
|
845
|
+
|
|
846
|
+
Both verbs take their field map from any combination of three sources; on a shared
|
|
847
|
+
key the file / stdin value wins over the inline one.
|
|
848
|
+
|
|
849
|
+
| Source | Use it for |
|
|
850
|
+
| --- | --- |
|
|
851
|
+
| `--fields "k=v,k2=v2"` | Short scalar values. Splits on commas and trims, so it cannot carry a comma, newline or `=`. |
|
|
852
|
+
| `--from-json <path>` | A flat JSON object `{ "field": "value" }` — any character, any size (script bodies, HTML, JSON blobs). |
|
|
853
|
+
| `--from-stdin` (alias `--from-json -`) | The same JSON object, piped in: `cat fields.json \| npx dove-sn create-record ... --from-stdin`. |
|
|
854
|
+
|
|
855
|
+
**stdin is read only when you ask for it.** Without `--from-stdin` / `--from-json -`
|
|
856
|
+
no `dove-sn` verb touches stdin at all, so a process launched with an open-but-idle
|
|
857
|
+
stdin pipe (an agent harness running it in the background) returns immediately instead
|
|
858
|
+
of waiting for input that never comes; a missing field source is an immediate usage
|
|
859
|
+
error (exit `1`), never a prompt. `--from-stdin` refuses a terminal stdin and an empty
|
|
860
|
+
stream with an actionable message.
|
|
861
|
+
|
|
862
|
+
#### Read-back verification and the HTML sanitizer
|
|
863
|
+
|
|
864
|
+
After the write both verbs re-query the record and compare every requested value to
|
|
865
|
+
what was stored. ServiceNow's HTML sanitizer rewrites characters in `html` /
|
|
866
|
+
`translated_html` / `wiki` fields on save (for example `@` becomes `@`), which used
|
|
867
|
+
to surface as a false **mismatch** on a record that was in fact correct. The compare is
|
|
868
|
+
now strict byte-for-byte first, and only when the **stored** value contains an HTML
|
|
869
|
+
entity reference are both sides entity-decoded and compared again; a match found that
|
|
870
|
+
way is reported as verified with a note naming the normalized field(s). A genuinely
|
|
871
|
+
different value still decodes to something different and still reports `failed`
|
|
872
|
+
(exit `2`), and the failure note names the mismatched field(s).
|
|
873
|
+
|
|
874
|
+
### Delete a record
|
|
875
|
+
|
|
876
|
+
Delete **one** existing data record by table + sys_id, with the record read back **before** (so the dry-run shows exactly what would go,
|
|
877
|
+
and a missing record is an error rather than a "successful" delete of nothing) and
|
|
878
|
+
**after** (success is never reported until the record is confirmed gone).
|
|
879
|
+
|
|
880
|
+
```bash
|
|
881
|
+
# Dry-run (the default) — prints the record snapshot, deletes nothing
|
|
882
|
+
npx dove-sn delete-record \
|
|
883
|
+
--table x_cadso_core_metric_point_type --sys-id <32-hex sys_id> \
|
|
884
|
+
--update-set <sys_id>
|
|
885
|
+
|
|
886
|
+
# Apply — deletes, then reads back and verifies the record is gone
|
|
887
|
+
npx dove-sn delete-record \
|
|
888
|
+
--table x_cadso_core_metric_point_type --sys-id <32-hex sys_id> \
|
|
889
|
+
--update-set <sys_id> --apply --json
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
`delete-record` wraps the core `deleteRecord` op. It is **dry-run by default** —
|
|
893
|
+
nothing is deleted without `--apply` (`--dry-run` wins if both are given). `--sys-id`
|
|
894
|
+
must be a 32-character lowercase hex id and `--table` a plain table name; both are
|
|
895
|
+
validated before any network call. `--update-set` is **required** and sent with the
|
|
896
|
+
delete, but **the capture is not pinned yet**: until
|
|
897
|
+
[#297](https://github.com/TenonHQ/Dovetail/issues/297) ships server-side, the op ignores
|
|
898
|
+
`update_set_sys_id` and captures the delete into the session's **current** update set —
|
|
899
|
+
make that the set you want before `--apply`. Every result note repeats this caveat; the
|
|
900
|
+
client keeps sending the field so it takes effect the moment the server honours it. Like its siblings it **refuses** schema tables (`sys_db_object` /
|
|
901
|
+
`sys_dictionary`). Exit codes: `0` deleted / dry-run, `1` bad args or no such record,
|
|
902
|
+
`2` the delete returned but the record is **still present** on read-back.
|
|
903
|
+
|
|
904
|
+
All three verbs are exported for programmatic use:
|
|
773
905
|
|
|
774
906
|
```ts
|
|
775
|
-
import { createClient, setField, createRecord } from "@tenonhq/dovetail-servicenow";
|
|
907
|
+
import { createClient, setField, createRecord, deleteRecord } from "@tenonhq/dovetail-servicenow";
|
|
776
908
|
|
|
777
909
|
var client = createClient({});
|
|
778
910
|
var r = await setField({
|
|
@@ -1019,7 +1151,11 @@ console.log(formatLayoutResult("form layout", result));
|
|
|
1019
1151
|
`dove-sn mcp` runs a self-contained MCP stdio server exposing the tools to
|
|
1020
1152
|
Claude Code and agents: `create_view`, `set_list_layout`, `set_form_layout`,
|
|
1021
1153
|
`set_related_lists`, `add_choices_to_field`, the schema verbs `create_table` /
|
|
1022
|
-
`add_column`
|
|
1154
|
+
`add_column` (same-scope by default; `crossScope: true` with `scope` adds a column OWNED
|
|
1155
|
+
by another app — a Journey field on an Automate table, element `<scope>_<name>` —
|
|
1156
|
+
guarded by `sys_db_object.alter_access` and the update set's scope, with the stored
|
|
1157
|
+
element and `sys_scope` read back; the CLI flag is `--cross-scope`) /
|
|
1158
|
+
`add_index` (a single-column unique index via `sys_dictionary.unique`,
|
|
1023
1159
|
read back from the `v_db_index` view - uniqueness enforcement is always reported
|
|
1024
1160
|
unverified) / `index_list` (read-only: a table's database indexes from `v_db_index`,
|
|
1025
1161
|
the only index read surface - `sys_index` is API-level-ACL 403 and `sys_index_column`
|
|
@@ -1027,8 +1163,13 @@ does not exist) / `index_create` (create an index, composite and non-unique incl
|
|
|
1027
1163
|
replaying the platform index-creator form; dry-run by default, idempotent, read back from
|
|
1028
1164
|
`v_db_index` - and **not** captured in an update set, because a database index is a
|
|
1029
1165
|
physical per-instance change), the record-write verbs `set_field` (update scalar fields on an
|
|
1030
|
-
existing record)
|
|
1031
|
-
|
|
1166
|
+
existing record), `create_record` (insert one record) and `delete_record` (delete one
|
|
1167
|
+
record — dry-run by default, `confirm:true` to apply, `updateSetSysId` required, the
|
|
1168
|
+
record read back before AND after so success is only reported once it is confirmed
|
|
1169
|
+
gone) — all read-back-verified; `set_field` / `create_record` are captured in the
|
|
1170
|
+
update set you pass, while `delete_record` captures into the session's current set
|
|
1171
|
+
until [#297](https://github.com/TenonHQ/Dovetail/issues/297) ships — `host_assets` (deploy a built
|
|
1172
|
+
dist/), plus the Flow Designer
|
|
1032
1173
|
tools `flow_view` (read a flow/subflow's step graph), `action_view` (read an action
|
|
1033
1174
|
type's model), `action_edit` (structurally edit a published action type — per-step
|
|
1034
1175
|
scripts, step-level inputs/outputs, data-pill wiring — dry-run by default, and the
|
|
@@ -1060,6 +1201,10 @@ npx dove-sn mcp # run the stdio server (wire into .mcp.json)
|
|
|
1060
1201
|
|
|
1061
1202
|
This server is separate from `@tenonhq/dovetail-mcp` (the read-only cross-system
|
|
1062
1203
|
aggregator) — `dovetail-servicenow`'s server is the ServiceNow **write** surface.
|
|
1204
|
+
`dovetail-mcp` intentionally does **not** get `delete_record` (or any other ServiceNow
|
|
1205
|
+
write): record writes — create, set, delete — live on `dove-sn mcp` only, where every
|
|
1206
|
+
one takes an update set, previews before writing and is read-back-verified (the delete's
|
|
1207
|
+
update set is honoured server-side once #297 ships).
|
|
1063
1208
|
|
|
1064
1209
|
## Publishing a Custom Action Type
|
|
1065
1210
|
|
package/dist/choices.d.ts
CHANGED
|
@@ -36,6 +36,11 @@ export declare function encodeQueryValue(v: string): string;
|
|
|
36
36
|
* Upsert choices for a field and (optionally) toggle sys_dictionary.choice.
|
|
37
37
|
* Idempotent: re-running with the same inputs returns `action: "unchanged"`
|
|
38
38
|
* for every row and skips the dictionary write when no change is required.
|
|
39
|
+
*
|
|
40
|
+
* `params.dryRun` plans instead of writing: every read still happens (so a missing field
|
|
41
|
+
* or a closed update set still fails loudly), but no pushWithUpdateSet / createRecord
|
|
42
|
+
* call is made, and rows report "would-create" / "would-update" in place of
|
|
43
|
+
* "created" / "updated". The result's `dryRun` flag is true.
|
|
39
44
|
*/
|
|
40
45
|
export declare function addChoicesToField(client: ServiceNowClient, params: AddChoicesParams): Promise<AddChoicesResult>;
|
|
41
46
|
/**
|
|
@@ -48,7 +53,13 @@ export declare function addChoicesToField(client: ServiceNowClient, params: AddC
|
|
|
48
53
|
* - active value -> "deactivated" (every live row for it flipped to inactive —
|
|
49
54
|
* usually one write, but more when the field holds duplicates)
|
|
50
55
|
* - already inactive -> "unchanged" (no write)
|
|
51
|
-
* - value not found -> "missing" (no write
|
|
56
|
+
* - value not found -> "missing" (no write; `nearMatches` lists any value on the
|
|
57
|
+
* field that differs by letter case alone — matching is strictly
|
|
58
|
+
* case-sensitive, this is a hint so the caller can tell a typo'd
|
|
59
|
+
* casing from a genuinely absent value)
|
|
60
|
+
*
|
|
61
|
+
* `params.dryRun` plans instead of writing: reads still happen, no pushWithUpdateSet is
|
|
62
|
+
* sent, and a live row reports "would-deactivate" in place of "deactivated".
|
|
52
63
|
*
|
|
53
64
|
* The dictionary row is fetched only to PROVE the field exists — without that guard a
|
|
54
65
|
* mistyped column reports every value as "missing", the silent-failure this family
|
package/dist/choices.js
CHANGED
|
@@ -231,6 +231,40 @@ function dedupe(values) {
|
|
|
231
231
|
});
|
|
232
232
|
return out;
|
|
233
233
|
}
|
|
234
|
+
/**
|
|
235
|
+
* Values on the field (same language) that differ from `value` by letter case ALONE.
|
|
236
|
+
*
|
|
237
|
+
* sys_choice.value is case-sensitive, so "DELIVERED" is NOT "delivered" and the lookup
|
|
238
|
+
* must not fold case — but the two outcomes "that value is absent" and "that value is
|
|
239
|
+
* present, spelled with different casing" are otherwise indistinguishable to the caller.
|
|
240
|
+
* This surfaces the second so it can be reported as a hint next to "missing".
|
|
241
|
+
*
|
|
242
|
+
* Best-effort by design: it inspects the rows the scoped read already returned, and
|
|
243
|
+
* sends no extra request. ServiceNow's `IN` / `=` on string columns are case-insensitive
|
|
244
|
+
* at the database layer, which is exactly how a differently-cased row comes back for a
|
|
245
|
+
* `valueIN DELIVERED` query in the first place.
|
|
246
|
+
*
|
|
247
|
+
* Distinct values only, in first-seen order; a value that exactly equals `value` is
|
|
248
|
+
* excluded — that one is a real match and is handled by the caller before this runs.
|
|
249
|
+
*/
|
|
250
|
+
function findCaseOnlyMatches(rows, language, value) {
|
|
251
|
+
var wanted = value.toLowerCase();
|
|
252
|
+
var out = [];
|
|
253
|
+
rows.forEach(function (row) {
|
|
254
|
+
if ((row.language || "en") !== language)
|
|
255
|
+
return;
|
|
256
|
+
if (typeof row.value !== "string")
|
|
257
|
+
return;
|
|
258
|
+
if (row.value === value)
|
|
259
|
+
return;
|
|
260
|
+
if (row.value.toLowerCase() !== wanted)
|
|
261
|
+
return;
|
|
262
|
+
if (out.indexOf(row.value) !== -1)
|
|
263
|
+
return;
|
|
264
|
+
out.push(row.value);
|
|
265
|
+
});
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
234
268
|
function buildChoiceFields(table, column, choice, scope) {
|
|
235
269
|
var fields = {
|
|
236
270
|
name: table,
|
|
@@ -260,6 +294,11 @@ function isUnchanged(existing, choice) {
|
|
|
260
294
|
* Upsert choices for a field and (optionally) toggle sys_dictionary.choice.
|
|
261
295
|
* Idempotent: re-running with the same inputs returns `action: "unchanged"`
|
|
262
296
|
* for every row and skips the dictionary write when no change is required.
|
|
297
|
+
*
|
|
298
|
+
* `params.dryRun` plans instead of writing: every read still happens (so a missing field
|
|
299
|
+
* or a closed update set still fails loudly), but no pushWithUpdateSet / createRecord
|
|
300
|
+
* call is made, and rows report "would-create" / "would-update" in place of
|
|
301
|
+
* "created" / "updated". The result's `dryRun` flag is true.
|
|
263
302
|
*/
|
|
264
303
|
async function addChoicesToField(client, params) {
|
|
265
304
|
if (!params.updateSetSysId) {
|
|
@@ -268,6 +307,9 @@ async function addChoicesToField(client, params) {
|
|
|
268
307
|
if (!params.choices || params.choices.length === 0) {
|
|
269
308
|
throw new Error("choices must be a non-empty array.");
|
|
270
309
|
}
|
|
310
|
+
// Strict equality on purpose: a truthy-but-not-true value (the string "false" from a
|
|
311
|
+
// mis-parsed flag, say) must not silently turn a planned run into a live write.
|
|
312
|
+
var dryRun = params.dryRun === true;
|
|
271
313
|
var dict = await fetchDictionary(client, params.table, params.column);
|
|
272
314
|
var updateSet = await fetchUpdateSet(client, params.updateSetSysId);
|
|
273
315
|
var scopeName = await resolveScopeName(client, dict.sys_scope);
|
|
@@ -279,12 +321,14 @@ async function addChoicesToField(client, params) {
|
|
|
279
321
|
var choiceWas = Number(dict.choice);
|
|
280
322
|
var choiceNow = choiceWas;
|
|
281
323
|
if (params.choiceType !== null && Number(dict.choice) !== targetChoiceType) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
324
|
+
if (!dryRun) {
|
|
325
|
+
await client.claude.pushWithUpdateSet({
|
|
326
|
+
update_set_sys_id: params.updateSetSysId,
|
|
327
|
+
table: "sys_dictionary",
|
|
328
|
+
record_sys_id: dict.sys_id,
|
|
329
|
+
fields: { choice: String(targetChoiceType) },
|
|
330
|
+
});
|
|
331
|
+
}
|
|
288
332
|
choiceNow = targetChoiceType;
|
|
289
333
|
}
|
|
290
334
|
// Collapse repeats on language::value, last spec wins. Without this a value listed
|
|
@@ -329,6 +373,16 @@ async function addChoicesToField(client, params) {
|
|
|
329
373
|
if (choice.sequence != null) {
|
|
330
374
|
updFields.sequence = String(choice.sequence);
|
|
331
375
|
}
|
|
376
|
+
if (dryRun) {
|
|
377
|
+
results.push({
|
|
378
|
+
value: choice.value,
|
|
379
|
+
label: choice.label,
|
|
380
|
+
sysId: matchSysIds[0],
|
|
381
|
+
sysIds: matchSysIds,
|
|
382
|
+
action: "would-update",
|
|
383
|
+
});
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
332
386
|
try {
|
|
333
387
|
for (var s = 0; s < stale.length; s += 1) {
|
|
334
388
|
await client.claude.pushWithUpdateSet({
|
|
@@ -351,6 +405,18 @@ async function addChoicesToField(client, params) {
|
|
|
351
405
|
});
|
|
352
406
|
continue;
|
|
353
407
|
}
|
|
408
|
+
if (dryRun) {
|
|
409
|
+
// No row exists yet, so there is no sys_id to report — "" mirrors the layout
|
|
410
|
+
// verbs' convention for a create that was only planned.
|
|
411
|
+
results.push({
|
|
412
|
+
value: choice.value,
|
|
413
|
+
label: choice.label,
|
|
414
|
+
sysId: "",
|
|
415
|
+
sysIds: [],
|
|
416
|
+
action: "would-create",
|
|
417
|
+
});
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
354
420
|
var created;
|
|
355
421
|
try {
|
|
356
422
|
created = await client.claude.createRecord({
|
|
@@ -372,13 +438,19 @@ async function addChoicesToField(client, params) {
|
|
|
372
438
|
});
|
|
373
439
|
}
|
|
374
440
|
return {
|
|
375
|
-
|
|
376
|
-
|
|
441
|
+
field: {
|
|
442
|
+
table: params.table,
|
|
443
|
+
column: params.column,
|
|
444
|
+
language: "en",
|
|
377
445
|
scope: dict.sys_scope,
|
|
446
|
+
dictionarySysId: dict.sys_id,
|
|
447
|
+
},
|
|
448
|
+
dictionary: {
|
|
378
449
|
choiceWas: choiceWas,
|
|
379
450
|
choiceNow: choiceNow,
|
|
380
451
|
},
|
|
381
452
|
updateSet: { sysId: updateSet.sys_id, name: updateSet.name },
|
|
453
|
+
dryRun: dryRun,
|
|
382
454
|
choices: results,
|
|
383
455
|
};
|
|
384
456
|
}
|
|
@@ -392,7 +464,13 @@ async function addChoicesToField(client, params) {
|
|
|
392
464
|
* - active value -> "deactivated" (every live row for it flipped to inactive —
|
|
393
465
|
* usually one write, but more when the field holds duplicates)
|
|
394
466
|
* - already inactive -> "unchanged" (no write)
|
|
395
|
-
* - value not found -> "missing" (no write
|
|
467
|
+
* - value not found -> "missing" (no write; `nearMatches` lists any value on the
|
|
468
|
+
* field that differs by letter case alone — matching is strictly
|
|
469
|
+
* case-sensitive, this is a hint so the caller can tell a typo'd
|
|
470
|
+
* casing from a genuinely absent value)
|
|
471
|
+
*
|
|
472
|
+
* `params.dryRun` plans instead of writing: reads still happen, no pushWithUpdateSet is
|
|
473
|
+
* sent, and a live row reports "would-deactivate" in place of "deactivated".
|
|
396
474
|
*
|
|
397
475
|
* The dictionary row is fetched only to PROVE the field exists — without that guard a
|
|
398
476
|
* mistyped column reports every value as "missing", the silent-failure this family
|
|
@@ -419,6 +497,8 @@ async function removeChoicesFromField(client, params) {
|
|
|
419
497
|
throw new Error("values must be a non-empty array.");
|
|
420
498
|
}
|
|
421
499
|
var language = params.language || "en";
|
|
500
|
+
// Strict equality on purpose — see addChoicesToField.
|
|
501
|
+
var dryRun = params.dryRun === true;
|
|
422
502
|
// fetchDictionary throws a clear error when the field does not exist; fetchUpdateSet
|
|
423
503
|
// throws unless the set is in progress. Both mirror the add path's guards.
|
|
424
504
|
var dict = await fetchDictionary(client, params.table, params.column);
|
|
@@ -435,7 +515,19 @@ async function removeChoicesFromField(client, params) {
|
|
|
435
515
|
var value = values[i];
|
|
436
516
|
var matches = existingByValue[language + "::" + value] || [];
|
|
437
517
|
if (matches.length === 0) {
|
|
438
|
-
|
|
518
|
+
// Exact match first, hint second: nearMatches is only ever computed once the
|
|
519
|
+
// strict lookup has come up empty, so it can never shadow a real match.
|
|
520
|
+
var missing = {
|
|
521
|
+
value: value,
|
|
522
|
+
sysId: "",
|
|
523
|
+
sysIds: [],
|
|
524
|
+
action: "missing",
|
|
525
|
+
};
|
|
526
|
+
var near = findCaseOnlyMatches(existing, language, value);
|
|
527
|
+
if (near.length > 0) {
|
|
528
|
+
missing.nearMatches = near;
|
|
529
|
+
}
|
|
530
|
+
results.push(missing);
|
|
439
531
|
continue;
|
|
440
532
|
}
|
|
441
533
|
var sysIds = matches.map(function (row) {
|
|
@@ -455,6 +547,15 @@ async function removeChoicesFromField(client, params) {
|
|
|
455
547
|
});
|
|
456
548
|
continue;
|
|
457
549
|
}
|
|
550
|
+
if (dryRun) {
|
|
551
|
+
results.push({
|
|
552
|
+
value: value,
|
|
553
|
+
sysId: sysIds[0],
|
|
554
|
+
sysIds: sysIds,
|
|
555
|
+
action: "would-deactivate",
|
|
556
|
+
});
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
458
559
|
try {
|
|
459
560
|
for (var a = 0; a < active.length; a += 1) {
|
|
460
561
|
await client.claude.pushWithUpdateSet({
|
|
@@ -480,9 +581,11 @@ async function removeChoicesFromField(client, params) {
|
|
|
480
581
|
table: params.table,
|
|
481
582
|
column: params.column,
|
|
482
583
|
language: language,
|
|
584
|
+
scope: dict.sys_scope,
|
|
483
585
|
dictionarySysId: dict.sys_id,
|
|
484
586
|
},
|
|
485
587
|
updateSet: { sysId: updateSet.sys_id, name: updateSet.name },
|
|
588
|
+
dryRun: dryRun,
|
|
486
589
|
choices: results,
|
|
487
590
|
};
|
|
488
591
|
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -3,22 +3,30 @@
|
|
|
3
3
|
* dove-sn — thin CLI adapter for @tenonhq/dovetail-servicenow.
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
|
-
* dove-sn
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* --choices 'delivered=Delivered,failed=Failed,...' \
|
|
11
|
-
* [--choice-type 3] [--json]
|
|
6
|
+
* dove-sn help verb index
|
|
7
|
+
* dove-sn help <verb> one verb's flags, value formats, write gate, example
|
|
8
|
+
* dove-sn <verb> --help same — never loads an env file or builds a client
|
|
9
|
+
* dove-sn <verb> [flags]
|
|
12
10
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* JSON payload shape:
|
|
16
|
-
* {
|
|
17
|
-
* "table": "x_cadso_core_event",
|
|
18
|
-
* "column": "state",
|
|
19
|
-
* "updateSetSysId": "...",
|
|
20
|
-
* "choiceType": 3,
|
|
21
|
-
* "choices": [{ "value": "delivered", "label": "Delivered" }, ...]
|
|
22
|
-
* }
|
|
11
|
+
* Every verb's usage is rendered from VERB_USAGE in ./cliUsage — add the entry there
|
|
12
|
+
* when you add a dispatch site below; cliHelp.test.ts fails on a missing one.
|
|
23
13
|
*/
|
|
14
|
+
interface ParsedArgs {
|
|
15
|
+
command: string;
|
|
16
|
+
flags: Record<string, string>;
|
|
17
|
+
/**
|
|
18
|
+
* Flags that arrived with no value at all (`--label` followed by another flag, or by
|
|
19
|
+
* nothing). They land in `flags` as the string "true", which is right for a boolean and
|
|
20
|
+
* a trap for a string: `--label` with a forgotten value would rename a column to "true".
|
|
21
|
+
* Recorded here so a verb can tell the two apart and refuse.
|
|
22
|
+
*/
|
|
23
|
+
bare: Record<string, boolean>;
|
|
24
|
+
/**
|
|
25
|
+
* Non-flag tokens after the command that no flag consumed as its value —
|
|
26
|
+
* `dove-sn help add-choices` carries the verb here.
|
|
27
|
+
*/
|
|
28
|
+
positional: Array<string>;
|
|
29
|
+
}
|
|
30
|
+
export declare function parseArgs(argv: Array<string>): ParsedArgs;
|
|
31
|
+
export declare function main(argv: Array<string>): Promise<number>;
|
|
24
32
|
export {};
|