@tenonhq/dovetail-servicenow 0.0.34 → 0.0.35
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 +61 -1
- package/dist/choices.d.ts +53 -1
- package/dist/choices.js +312 -27
- package/dist/cli.js +110 -15
- package/dist/executionContext.d.ts +84 -0
- package/dist/executionContext.js +105 -0
- package/dist/formatter.d.ts +7 -1
- package/dist/formatter.js +65 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +10 -2
- package/dist/mcp/registry.d.ts +1 -1
- package/dist/mcp/registry.js +20 -0
- package/dist/mcp/schemas.d.ts +19 -0
- package/dist/mcp/schemas.js +8 -1
- package/dist/types.d.ts +70 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -93,6 +93,13 @@ npx dove-sn add-choices \
|
|
|
93
93
|
|
|
94
94
|
# JSON payload form (recommended for >5 choices)
|
|
95
95
|
npx dove-sn add-choices --from-json ./choices.json
|
|
96
|
+
|
|
97
|
+
# Retire values — a SOFT delete (sets inactive=true), never a row drop
|
|
98
|
+
npx dove-sn remove-choices \
|
|
99
|
+
--table x_cadso_core_event \
|
|
100
|
+
--column state \
|
|
101
|
+
--update-set 0083c3bb33d003507b18bc534d5c7b6d \
|
|
102
|
+
--values "expired,failed"
|
|
96
103
|
```
|
|
97
104
|
|
|
98
105
|
JSON payload shape:
|
|
@@ -110,10 +117,37 @@ JSON payload shape:
|
|
|
110
117
|
}
|
|
111
118
|
```
|
|
112
119
|
|
|
120
|
+
`remove-choices` takes the same `--from-json` form, with a `values` array in
|
|
121
|
+
place of `choices`.
|
|
122
|
+
|
|
123
|
+
### Removal semantics
|
|
124
|
+
|
|
125
|
+
- **Soft delete.** Sets `sys_choice.inactive = true`; the row is retained and
|
|
126
|
+
the operation is reversible by re-adding the value. `sys_dictionary.choice` is
|
|
127
|
+
left alone — retiring values does not un-make the column a choice field.
|
|
128
|
+
- **Idempotent.** `deactivated` (a live row was flipped) / `unchanged` (already
|
|
129
|
+
inactive) / `missing` (no such value on the field). Re-running writes nothing.
|
|
130
|
+
- **Duplicates.** `sys_choice` has no uniqueness constraint on
|
|
131
|
+
`(name, element, value, language)`, so a field can hold several live rows for
|
|
132
|
+
one value. Every live row is deactivated, and `sysIds` lists all of them — a
|
|
133
|
+
length above 1 is your signal the field needs cleaning up.
|
|
134
|
+
- **Inherited choices are not covered.** Matching is scoped to
|
|
135
|
+
`sys_choice.name = <table>`, so a value defined on a *parent* table reports
|
|
136
|
+
`missing` rather than being deactivated. Hiding one on a child table needs an
|
|
137
|
+
override row this verb does not write.
|
|
138
|
+
- **Promotion.** ServiceNow captures the change as a single `Choice list` record
|
|
139
|
+
for the whole column, not one per value — so promoting the update set moves
|
|
140
|
+
the entire choice-list state for that column.
|
|
141
|
+
|
|
113
142
|
## Programmatic
|
|
114
143
|
|
|
115
144
|
```ts
|
|
116
|
-
import {
|
|
145
|
+
import {
|
|
146
|
+
createClient,
|
|
147
|
+
addChoicesToField,
|
|
148
|
+
removeChoicesFromField,
|
|
149
|
+
ChoiceWriteError,
|
|
150
|
+
} from "@tenonhq/dovetail-servicenow";
|
|
117
151
|
|
|
118
152
|
var client = createClient({});
|
|
119
153
|
var result = await addChoicesToField(client, { /* ... */ });
|
|
@@ -123,6 +157,32 @@ console.log(result.choices);
|
|
|
123
157
|
// { value: "delivered", label: "Delivered", sysId: "...", action: "created" },
|
|
124
158
|
// { value: "failed", label: "Failed", sysId: "...", action: "created" }
|
|
125
159
|
// ]
|
|
160
|
+
|
|
161
|
+
var removed = await removeChoicesFromField(client, {
|
|
162
|
+
table: "x_cadso_core_event",
|
|
163
|
+
column: "state",
|
|
164
|
+
updateSetSysId: "0083c3bb33d003507b18bc534d5c7b6d",
|
|
165
|
+
values: ["expired"],
|
|
166
|
+
});
|
|
167
|
+
// removed.choices[0] -> { value: "expired", sysId: "...", sysIds: ["..."], action: "deactivated" }
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Both verbs write one value at a time. If a write fails partway through they
|
|
171
|
+
throw a **`ChoiceWriteError`** carrying `completed` (the values that already
|
|
172
|
+
landed, in result shape), `failedValue` (the one that threw — its state on the
|
|
173
|
+
instance is unknown), and `cause` (the original error). Catch it rather than
|
|
174
|
+
re-querying the instance to work out how far the run got:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
try {
|
|
178
|
+
await removeChoicesFromField(client, params);
|
|
179
|
+
} catch (e) {
|
|
180
|
+
if (e instanceof ChoiceWriteError) {
|
|
181
|
+
console.error("already deactivated:", e.completed.map((r) => r.value));
|
|
182
|
+
console.error("verify by hand:", e.failedValue);
|
|
183
|
+
}
|
|
184
|
+
throw e;
|
|
185
|
+
}
|
|
126
186
|
```
|
|
127
187
|
|
|
128
188
|
## Form, list & view layouts
|
package/dist/choices.d.ts
CHANGED
|
@@ -8,7 +8,29 @@
|
|
|
8
8
|
* so choices stay in the same application as the field.
|
|
9
9
|
*/
|
|
10
10
|
import type { ServiceNowClient } from "./client";
|
|
11
|
-
import type { AddChoicesParams, AddChoicesResult } from "./types";
|
|
11
|
+
import type { AddChoicesParams, AddChoicesResult, ChoiceActionResult, ChoiceRemovalResult, RemoveChoicesParams, RemoveChoicesResult } from "./types";
|
|
12
|
+
/**
|
|
13
|
+
* Thrown when a choices write fails partway through the value loop.
|
|
14
|
+
*
|
|
15
|
+
* Both verbs write one value at a time. Without this, a throw on value 3 of 5
|
|
16
|
+
* unwound the whole call and took the local `results` array with it — the
|
|
17
|
+
* caller could not tell which values had already been deactivated (or which
|
|
18
|
+
* sys_choice rows had already been created on the add path) without going and
|
|
19
|
+
* querying the instance. That is the worst possible moment to lose the record:
|
|
20
|
+
* the writes already landed and are already captured in the update set.
|
|
21
|
+
*
|
|
22
|
+
* `completed` holds the values that finished before the failure, in order, in
|
|
23
|
+
* exactly the shape they would have had in a successful result. `failedValue`
|
|
24
|
+
* is the value being processed when the write threw, and is NOT in `completed`
|
|
25
|
+
* — its state on the instance is unknown (the row may or may not have been
|
|
26
|
+
* written before the error), so it needs checking by hand.
|
|
27
|
+
*/
|
|
28
|
+
export declare class ChoiceWriteError extends Error {
|
|
29
|
+
readonly completed: Array<ChoiceActionResult | ChoiceRemovalResult>;
|
|
30
|
+
readonly failedValue: string;
|
|
31
|
+
readonly cause: unknown;
|
|
32
|
+
constructor(verb: string, failedValue: string, completed: Array<ChoiceActionResult | ChoiceRemovalResult>, cause: unknown);
|
|
33
|
+
}
|
|
12
34
|
export declare function encodeQueryValue(v: string): string;
|
|
13
35
|
/**
|
|
14
36
|
* Upsert choices for a field and (optionally) toggle sys_dictionary.choice.
|
|
@@ -16,3 +38,33 @@ export declare function encodeQueryValue(v: string): string;
|
|
|
16
38
|
* for every row and skips the dictionary write when no change is required.
|
|
17
39
|
*/
|
|
18
40
|
export declare function addChoicesToField(client: ServiceNowClient, params: AddChoicesParams): Promise<AddChoicesResult>;
|
|
41
|
+
/**
|
|
42
|
+
* Soft-delete choice values for a field: set `inactive=true` on each matching
|
|
43
|
+
* sys_choice row via pushWithUpdateSet. NEVER a hard delete — the row stays, so the
|
|
44
|
+
* change is reversible and the historical value still resolves on records that
|
|
45
|
+
* already hold it. (A hard drop of sys_choice is deliberately deferred; see DEV-511.)
|
|
46
|
+
*
|
|
47
|
+
* Idempotent:
|
|
48
|
+
* - active value -> "deactivated" (every live row for it flipped to inactive —
|
|
49
|
+
* usually one write, but more when the field holds duplicates)
|
|
50
|
+
* - already inactive -> "unchanged" (no write)
|
|
51
|
+
* - value not found -> "missing" (no write)
|
|
52
|
+
*
|
|
53
|
+
* The dictionary row is fetched only to PROVE the field exists — without that guard a
|
|
54
|
+
* mistyped column reports every value as "missing", the silent-failure this family
|
|
55
|
+
* exists to catch. sys_dictionary.choice is left alone on purpose: removing values
|
|
56
|
+
* does not un-make the column a choice field.
|
|
57
|
+
*
|
|
58
|
+
* Repeated values are collapsed before the loop, so `["a", "a"]` costs no more than
|
|
59
|
+
* `["a"]` and yields one result row — an idempotent verb must not depend on the caller
|
|
60
|
+
* de-duplicating first. (That is "no EXTRA writes from repeats", not "exactly one
|
|
61
|
+
* write": a single value still takes one write per live duplicate row on the field.)
|
|
62
|
+
*
|
|
63
|
+
* LIMITATION — inherited choices. Matching is scoped to `sys_choice.name = <table>`, so
|
|
64
|
+
* a value defined on a PARENT table (task.state inherited by a child) reports "missing"
|
|
65
|
+
* rather than being deactivated. Hiding an inherited choice on a child table needs an
|
|
66
|
+
* override row on the child, which this verb does not write. For the x_cadso_* tables
|
|
67
|
+
* this family targets that case does not arise; on extended OOB tables, treat a
|
|
68
|
+
* surprising "missing" as a signal to check the parent.
|
|
69
|
+
*/
|
|
70
|
+
export declare function removeChoicesFromField(client: ServiceNowClient, params: RemoveChoicesParams): Promise<RemoveChoicesResult>;
|
package/dist/choices.js
CHANGED
|
@@ -9,8 +9,59 @@
|
|
|
9
9
|
* so choices stay in the same application as the field.
|
|
10
10
|
*/
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.ChoiceWriteError = void 0;
|
|
12
13
|
exports.encodeQueryValue = encodeQueryValue;
|
|
13
14
|
exports.addChoicesToField = addChoicesToField;
|
|
15
|
+
exports.removeChoicesFromField = removeChoicesFromField;
|
|
16
|
+
/**
|
|
17
|
+
* Thrown when a choices write fails partway through the value loop.
|
|
18
|
+
*
|
|
19
|
+
* Both verbs write one value at a time. Without this, a throw on value 3 of 5
|
|
20
|
+
* unwound the whole call and took the local `results` array with it — the
|
|
21
|
+
* caller could not tell which values had already been deactivated (or which
|
|
22
|
+
* sys_choice rows had already been created on the add path) without going and
|
|
23
|
+
* querying the instance. That is the worst possible moment to lose the record:
|
|
24
|
+
* the writes already landed and are already captured in the update set.
|
|
25
|
+
*
|
|
26
|
+
* `completed` holds the values that finished before the failure, in order, in
|
|
27
|
+
* exactly the shape they would have had in a successful result. `failedValue`
|
|
28
|
+
* is the value being processed when the write threw, and is NOT in `completed`
|
|
29
|
+
* — its state on the instance is unknown (the row may or may not have been
|
|
30
|
+
* written before the error), so it needs checking by hand.
|
|
31
|
+
*/
|
|
32
|
+
class ChoiceWriteError extends Error {
|
|
33
|
+
completed;
|
|
34
|
+
failedValue;
|
|
35
|
+
cause;
|
|
36
|
+
constructor(verb, failedValue, completed, cause) {
|
|
37
|
+
var detail = cause instanceof Error
|
|
38
|
+
? cause.message
|
|
39
|
+
: String(cause == null ? "" : cause);
|
|
40
|
+
super(verb +
|
|
41
|
+
' failed on value "' +
|
|
42
|
+
failedValue +
|
|
43
|
+
'" after completing ' +
|
|
44
|
+
completed.length +
|
|
45
|
+
" value(s): " +
|
|
46
|
+
detail +
|
|
47
|
+
"\nAlready written (captured in the update set): " +
|
|
48
|
+
(completed.length === 0
|
|
49
|
+
? "none"
|
|
50
|
+
: completed
|
|
51
|
+
.map(function (r) {
|
|
52
|
+
return r.value + " [" + r.action + "]";
|
|
53
|
+
})
|
|
54
|
+
.join(", ")) +
|
|
55
|
+
'\nState of "' +
|
|
56
|
+
failedValue +
|
|
57
|
+
'" on the instance is unknown — verify it directly.');
|
|
58
|
+
this.name = "ChoiceWriteError";
|
|
59
|
+
this.completed = completed;
|
|
60
|
+
this.failedValue = failedValue;
|
|
61
|
+
this.cause = cause;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
exports.ChoiceWriteError = ChoiceWriteError;
|
|
14
65
|
function encodeQueryValue(v) {
|
|
15
66
|
// ServiceNow encoded-query values: commas/carets/equals are special. We
|
|
16
67
|
// don't expect them in table/column/value/language inputs, but keep this
|
|
@@ -75,8 +126,110 @@ async function fetchUpdateSet(client, sysId) {
|
|
|
75
126
|
}
|
|
76
127
|
return row;
|
|
77
128
|
}
|
|
78
|
-
|
|
79
|
-
|
|
129
|
+
var CHOICE_PAGE_LIMIT = 1000;
|
|
130
|
+
/**
|
|
131
|
+
* Values per encoded-query batch. Keeps `valueIN a,b,c…` comfortably inside practical
|
|
132
|
+
* URL limits when a caller passes a long list.
|
|
133
|
+
*/
|
|
134
|
+
var CHOICE_QUERY_BATCH = 50;
|
|
135
|
+
/**
|
|
136
|
+
* Read the existing sys_choice rows for JUST the values being acted on.
|
|
137
|
+
*
|
|
138
|
+
* Scoped rather than whole-field on purpose. Reading every row of the field means the
|
|
139
|
+
* result set grows with the field, not with the request, and `client.table.query` has no
|
|
140
|
+
* `sysparm_offset` — so a field with more choices than one page could only be handled by
|
|
141
|
+
* guessing at a truncated read (silently wrong: the add path inserts a duplicate, the
|
|
142
|
+
* remove path reports a live value as "missing") or by refusing outright, which would
|
|
143
|
+
* make both verbs unusable on large choice sets. Asking only for the requested values
|
|
144
|
+
* bounds the read by the request, so large fields stay editable and there is nothing to
|
|
145
|
+
* truncate.
|
|
146
|
+
*
|
|
147
|
+
* Note that values are interpolated into an encoded query, so `encodeQueryValue` rejects
|
|
148
|
+
* ones carrying `,` `^` `=`. Both CLI surfaces already split on those characters, so such
|
|
149
|
+
* values were never expressible there; the library paths now refuse them loudly rather
|
|
150
|
+
* than building a malformed query.
|
|
151
|
+
*/
|
|
152
|
+
async function fetchExistingChoices(client, table, column, values) {
|
|
153
|
+
var base = "name=" + encodeQueryValue(table) + "^element=" + encodeQueryValue(column);
|
|
154
|
+
var out = [];
|
|
155
|
+
for (var i = 0; i < values.length; i += CHOICE_QUERY_BATCH) {
|
|
156
|
+
var batch = values.slice(i, i + CHOICE_QUERY_BATCH).map(encodeQueryValue);
|
|
157
|
+
// Ask for one MORE than the ceiling: requesting exactly it cannot distinguish "there
|
|
158
|
+
// are exactly that many" from "there are more and this is page 1".
|
|
159
|
+
var rows = await client.table.query("sys_choice", base + "^valueIN" + batch.join(","), CHOICE_PAGE_LIMIT + 1);
|
|
160
|
+
// A backstop, not an everyday path: this batch asked for at most CHOICE_QUERY_BATCH
|
|
161
|
+
// values, so overflowing a page means one value has an absurd number of duplicate
|
|
162
|
+
// rows. Refuse rather than act on a read that may be incomplete.
|
|
163
|
+
if (rows.length > CHOICE_PAGE_LIMIT) {
|
|
164
|
+
throw new Error("Refusing to act on a truncated choice list: " +
|
|
165
|
+
table +
|
|
166
|
+
"." +
|
|
167
|
+
column +
|
|
168
|
+
" returned more than " +
|
|
169
|
+
CHOICE_PAGE_LIMIT +
|
|
170
|
+
" sys_choice rows for a single batch of requested values, so the read cannot " +
|
|
171
|
+
"be trusted to be complete.");
|
|
172
|
+
}
|
|
173
|
+
out = out.concat(rows);
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Group the instance's rows by language::value, keeping EVERY row per key.
|
|
179
|
+
*
|
|
180
|
+
* sys_choice has no uniqueness constraint on (name, element, value, language), so a
|
|
181
|
+
* field can genuinely hold two live rows for one value. Indexing to a single row would
|
|
182
|
+
* act on one and silently leave the other — the tool reports success while the choice
|
|
183
|
+
* still renders in the dropdown.
|
|
184
|
+
*/
|
|
185
|
+
function groupExistingByKey(rows) {
|
|
186
|
+
// Null-prototype: keys derive from record data, and Object.prototype members must not
|
|
187
|
+
// masquerade as existing entries. See dedupe() for the failure this avoids.
|
|
188
|
+
var byKey = Object.create(null);
|
|
189
|
+
rows.forEach(function (row) {
|
|
190
|
+
var key = (row.language || "en") + "::" + row.value;
|
|
191
|
+
if (!byKey[key])
|
|
192
|
+
byKey[key] = [];
|
|
193
|
+
byKey[key].push(row);
|
|
194
|
+
});
|
|
195
|
+
return byKey;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Collapse choices that target the same language::value. Position is first-seen, but
|
|
199
|
+
* the LAST spec wins, so `[{a,"Old"},{a,"New"}]` upserts the label the caller asked for
|
|
200
|
+
* most recently rather than silently writing both.
|
|
201
|
+
*/
|
|
202
|
+
function dedupeChoices(choices) {
|
|
203
|
+
var byKey = Object.create(null);
|
|
204
|
+
var order = [];
|
|
205
|
+
choices.forEach(function (c) {
|
|
206
|
+
var key = (c.language || "en") + "::" + c.value;
|
|
207
|
+
if (!byKey[key])
|
|
208
|
+
order.push(key);
|
|
209
|
+
byKey[key] = c;
|
|
210
|
+
});
|
|
211
|
+
return order.map(function (key) {
|
|
212
|
+
return byKey[key];
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Preserve first-seen order while dropping repeats.
|
|
217
|
+
*
|
|
218
|
+
* The lookup is a null-prototype map because the keys are raw choice values: on a plain
|
|
219
|
+
* `{}`, `seen["constructor"]` / `["toString"]` / `["__proto__"]` are already truthy via
|
|
220
|
+
* Object.prototype, so a value with one of those names would be treated as a repeat and
|
|
221
|
+
* silently dropped from the request — never written, never even reported.
|
|
222
|
+
*/
|
|
223
|
+
function dedupe(values) {
|
|
224
|
+
var seen = Object.create(null);
|
|
225
|
+
var out = [];
|
|
226
|
+
values.forEach(function (v) {
|
|
227
|
+
if (seen[v])
|
|
228
|
+
return;
|
|
229
|
+
seen[v] = true;
|
|
230
|
+
out.push(v);
|
|
231
|
+
});
|
|
232
|
+
return out;
|
|
80
233
|
}
|
|
81
234
|
function buildChoiceFields(table, column, choice, scope) {
|
|
82
235
|
var fields = {
|
|
@@ -134,27 +287,40 @@ async function addChoicesToField(client, params) {
|
|
|
134
287
|
});
|
|
135
288
|
choiceNow = targetChoiceType;
|
|
136
289
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
290
|
+
// Collapse repeats on language::value, last spec wins. Without this a value listed
|
|
291
|
+
// twice is created twice — the cache is read once up front, so the second pass still
|
|
292
|
+
// sees "not present" and inserts a genuine duplicate sys_choice row.
|
|
293
|
+
var choices = dedupeChoices(params.choices);
|
|
294
|
+
// Read only the values being upserted — see fetchExistingChoices on why the read is
|
|
295
|
+
// scoped to the request rather than the whole field.
|
|
296
|
+
var existing = await fetchExistingChoices(client, params.table, params.column, dedupe(choices.map(function (c) {
|
|
297
|
+
return c.value;
|
|
298
|
+
})));
|
|
299
|
+
var existingByValue = groupExistingByKey(existing);
|
|
143
300
|
var results = [];
|
|
144
|
-
for (var i = 0; i <
|
|
145
|
-
var choice =
|
|
301
|
+
for (var i = 0; i < choices.length; i += 1) {
|
|
302
|
+
var choice = choices[i];
|
|
146
303
|
var key = (choice.language || "en") + "::" + choice.value;
|
|
147
|
-
var
|
|
148
|
-
|
|
304
|
+
var matches = existingByValue[key] || [];
|
|
305
|
+
var matchSysIds = matches.map(function (row) {
|
|
306
|
+
return row.sys_id;
|
|
307
|
+
});
|
|
308
|
+
// Every live row for this value, not just one: a field can already hold duplicates,
|
|
309
|
+
// and updating one would leave the other showing the old label in the dropdown.
|
|
310
|
+
var stale = matches.filter(function (row) {
|
|
311
|
+
return !isUnchanged(row, choice);
|
|
312
|
+
});
|
|
313
|
+
if (matches.length > 0 && stale.length === 0) {
|
|
149
314
|
results.push({
|
|
150
315
|
value: choice.value,
|
|
151
316
|
label: choice.label,
|
|
152
|
-
sysId:
|
|
317
|
+
sysId: matchSysIds[0],
|
|
318
|
+
sysIds: matchSysIds,
|
|
153
319
|
action: "unchanged",
|
|
154
320
|
});
|
|
155
321
|
continue;
|
|
156
322
|
}
|
|
157
|
-
if (
|
|
323
|
+
if (matches.length > 0) {
|
|
158
324
|
var updFields = {
|
|
159
325
|
label: choice.label,
|
|
160
326
|
language: choice.language || "en",
|
|
@@ -163,30 +329,45 @@ async function addChoicesToField(client, params) {
|
|
|
163
329
|
if (choice.sequence != null) {
|
|
164
330
|
updFields.sequence = String(choice.sequence);
|
|
165
331
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
332
|
+
try {
|
|
333
|
+
for (var s = 0; s < stale.length; s += 1) {
|
|
334
|
+
await client.claude.pushWithUpdateSet({
|
|
335
|
+
update_set_sys_id: params.updateSetSysId,
|
|
336
|
+
table: "sys_choice",
|
|
337
|
+
record_sys_id: stale[s].sys_id,
|
|
338
|
+
fields: updFields,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
catch (e) {
|
|
343
|
+
throw new ChoiceWriteError("add-choices", choice.value, results, e);
|
|
344
|
+
}
|
|
172
345
|
results.push({
|
|
173
346
|
value: choice.value,
|
|
174
347
|
label: choice.label,
|
|
175
|
-
sysId:
|
|
348
|
+
sysId: matchSysIds[0],
|
|
349
|
+
sysIds: matchSysIds,
|
|
176
350
|
action: "updated",
|
|
177
351
|
});
|
|
178
352
|
continue;
|
|
179
353
|
}
|
|
180
|
-
var created
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
354
|
+
var created;
|
|
355
|
+
try {
|
|
356
|
+
created = await client.claude.createRecord({
|
|
357
|
+
table: "sys_choice",
|
|
358
|
+
fields: buildChoiceFields(params.table, params.column, choice, dict.sys_scope),
|
|
359
|
+
scope: scopeName,
|
|
360
|
+
update_set_sys_id: params.updateSetSysId,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
catch (e) {
|
|
364
|
+
throw new ChoiceWriteError("add-choices", choice.value, results, e);
|
|
365
|
+
}
|
|
186
366
|
results.push({
|
|
187
367
|
value: choice.value,
|
|
188
368
|
label: choice.label,
|
|
189
369
|
sysId: created.sys_id,
|
|
370
|
+
sysIds: [created.sys_id],
|
|
190
371
|
action: "created",
|
|
191
372
|
});
|
|
192
373
|
}
|
|
@@ -201,3 +382,107 @@ async function addChoicesToField(client, params) {
|
|
|
201
382
|
choices: results,
|
|
202
383
|
};
|
|
203
384
|
}
|
|
385
|
+
/**
|
|
386
|
+
* Soft-delete choice values for a field: set `inactive=true` on each matching
|
|
387
|
+
* sys_choice row via pushWithUpdateSet. NEVER a hard delete — the row stays, so the
|
|
388
|
+
* change is reversible and the historical value still resolves on records that
|
|
389
|
+
* already hold it. (A hard drop of sys_choice is deliberately deferred; see DEV-511.)
|
|
390
|
+
*
|
|
391
|
+
* Idempotent:
|
|
392
|
+
* - active value -> "deactivated" (every live row for it flipped to inactive —
|
|
393
|
+
* usually one write, but more when the field holds duplicates)
|
|
394
|
+
* - already inactive -> "unchanged" (no write)
|
|
395
|
+
* - value not found -> "missing" (no write)
|
|
396
|
+
*
|
|
397
|
+
* The dictionary row is fetched only to PROVE the field exists — without that guard a
|
|
398
|
+
* mistyped column reports every value as "missing", the silent-failure this family
|
|
399
|
+
* exists to catch. sys_dictionary.choice is left alone on purpose: removing values
|
|
400
|
+
* does not un-make the column a choice field.
|
|
401
|
+
*
|
|
402
|
+
* Repeated values are collapsed before the loop, so `["a", "a"]` costs no more than
|
|
403
|
+
* `["a"]` and yields one result row — an idempotent verb must not depend on the caller
|
|
404
|
+
* de-duplicating first. (That is "no EXTRA writes from repeats", not "exactly one
|
|
405
|
+
* write": a single value still takes one write per live duplicate row on the field.)
|
|
406
|
+
*
|
|
407
|
+
* LIMITATION — inherited choices. Matching is scoped to `sys_choice.name = <table>`, so
|
|
408
|
+
* a value defined on a PARENT table (task.state inherited by a child) reports "missing"
|
|
409
|
+
* rather than being deactivated. Hiding an inherited choice on a child table needs an
|
|
410
|
+
* override row on the child, which this verb does not write. For the x_cadso_* tables
|
|
411
|
+
* this family targets that case does not arise; on extended OOB tables, treat a
|
|
412
|
+
* surprising "missing" as a signal to check the parent.
|
|
413
|
+
*/
|
|
414
|
+
async function removeChoicesFromField(client, params) {
|
|
415
|
+
if (!params.updateSetSysId) {
|
|
416
|
+
throw new Error("updateSetSysId is required — every write must be captured in a named update set.");
|
|
417
|
+
}
|
|
418
|
+
if (!params.values || params.values.length === 0) {
|
|
419
|
+
throw new Error("values must be a non-empty array.");
|
|
420
|
+
}
|
|
421
|
+
var language = params.language || "en";
|
|
422
|
+
// fetchDictionary throws a clear error when the field does not exist; fetchUpdateSet
|
|
423
|
+
// throws unless the set is in progress. Both mirror the add path's guards.
|
|
424
|
+
var dict = await fetchDictionary(client, params.table, params.column);
|
|
425
|
+
var updateSet = await fetchUpdateSet(client, params.updateSetSysId);
|
|
426
|
+
// Collapse repeats: without this a duplicated value writes twice and is counted
|
|
427
|
+
// twice in the summary, which contradicts the idempotency contract.
|
|
428
|
+
var values = dedupe(params.values);
|
|
429
|
+
// Read only the values being removed — see fetchExistingChoices on why the read is
|
|
430
|
+
// scoped to the request rather than the whole field.
|
|
431
|
+
var existing = await fetchExistingChoices(client, params.table, params.column, values);
|
|
432
|
+
var existingByValue = groupExistingByKey(existing);
|
|
433
|
+
var results = [];
|
|
434
|
+
for (var i = 0; i < values.length; i += 1) {
|
|
435
|
+
var value = values[i];
|
|
436
|
+
var matches = existingByValue[language + "::" + value] || [];
|
|
437
|
+
if (matches.length === 0) {
|
|
438
|
+
results.push({ value: value, sysId: "", sysIds: [], action: "missing" });
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
var sysIds = matches.map(function (row) {
|
|
442
|
+
return row.sys_id;
|
|
443
|
+
});
|
|
444
|
+
// Deactivate EVERY live row for this value. A field can hold more than one, and
|
|
445
|
+
// stopping at the first leaves the choice selectable while reporting success.
|
|
446
|
+
var active = matches.filter(function (row) {
|
|
447
|
+
return row.inactive !== "true";
|
|
448
|
+
});
|
|
449
|
+
if (active.length === 0) {
|
|
450
|
+
results.push({
|
|
451
|
+
value: value,
|
|
452
|
+
sysId: sysIds[0],
|
|
453
|
+
sysIds: sysIds,
|
|
454
|
+
action: "unchanged",
|
|
455
|
+
});
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
for (var a = 0; a < active.length; a += 1) {
|
|
460
|
+
await client.claude.pushWithUpdateSet({
|
|
461
|
+
update_set_sys_id: params.updateSetSysId,
|
|
462
|
+
table: "sys_choice",
|
|
463
|
+
record_sys_id: active[a].sys_id,
|
|
464
|
+
fields: { inactive: "true" },
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
catch (e) {
|
|
469
|
+
throw new ChoiceWriteError("remove-choices", value, results, e);
|
|
470
|
+
}
|
|
471
|
+
results.push({
|
|
472
|
+
value: value,
|
|
473
|
+
sysId: sysIds[0],
|
|
474
|
+
sysIds: sysIds,
|
|
475
|
+
action: "deactivated",
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
field: {
|
|
480
|
+
table: params.table,
|
|
481
|
+
column: params.column,
|
|
482
|
+
language: language,
|
|
483
|
+
dictionarySysId: dict.sys_id,
|
|
484
|
+
},
|
|
485
|
+
updateSet: { sysId: updateSet.sys_id, name: updateSet.name },
|
|
486
|
+
choices: results,
|
|
487
|
+
};
|
|
488
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -69,6 +69,7 @@ const formLayout_1 = require("./layout/formLayout");
|
|
|
69
69
|
const relatedLists_1 = require("./layout/relatedLists");
|
|
70
70
|
const formatter_2 = require("./layout/formatter");
|
|
71
71
|
const server_1 = require("./mcp/server");
|
|
72
|
+
const schemas_1 = require("./mcp/schemas");
|
|
72
73
|
const buildFlowOrchestrator_1 = require("./flowDesigner/buildFlowOrchestrator");
|
|
73
74
|
const flowDesigner_formatter_1 = require("./flowDesigner-formatter");
|
|
74
75
|
const readFlow_1 = require("./flowDesigner/readFlow");
|
|
@@ -147,15 +148,104 @@ function paramsFromFlags(flags) {
|
|
|
147
148
|
}
|
|
148
149
|
return params;
|
|
149
150
|
}
|
|
150
|
-
|
|
151
|
+
/**
|
|
152
|
+
* A string flag whose value was forgotten arrives as the literal "true" (see
|
|
153
|
+
* ParsedArgs.bare). Booleans legitimately do that, strings never do — so refuse
|
|
154
|
+
* rather than write nonsense. Returns the message to print, or null when clean.
|
|
155
|
+
*/
|
|
156
|
+
function bareStringFlagError(verb, bare, stringFlags) {
|
|
157
|
+
for (var f = 0; f < stringFlags.length; f += 1) {
|
|
158
|
+
if (bare[stringFlags[f]]) {
|
|
159
|
+
return (verb + ": --" + stringFlags[f] + " needs a value (it was given none).\n");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
async function runAddChoices(flags, bare) {
|
|
165
|
+
// `updateSetSysId` is guarded alongside `update-set` because paramsFromFlags accepts
|
|
166
|
+
// both spellings — guarding only the dashed one leaves the alias as a way in.
|
|
167
|
+
var bareErr = bareStringFlagError("add-choices", bare, [
|
|
168
|
+
"table",
|
|
169
|
+
"column",
|
|
170
|
+
"update-set",
|
|
171
|
+
"updateSetSysId",
|
|
172
|
+
"choices",
|
|
173
|
+
"from-json",
|
|
174
|
+
"choice-type",
|
|
175
|
+
]);
|
|
176
|
+
if (bareErr) {
|
|
177
|
+
process.stderr.write(bareErr);
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
151
180
|
var params = paramsFromFlags(flags);
|
|
152
181
|
var client = (0, client_1.createClient)({});
|
|
153
182
|
var result = await (0, choices_1.addChoicesToField)(client, params);
|
|
154
183
|
if (flags.json === "true") {
|
|
155
184
|
process.stdout.write(JSON.stringify({ params: params, result: result }, null, 2) + "\n");
|
|
156
|
-
return;
|
|
185
|
+
return 0;
|
|
157
186
|
}
|
|
158
187
|
process.stdout.write((0, formatter_1.formatAddChoicesResult)(params.table, params.column, result) + "\n");
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
function removeParamsFromFlags(flags) {
|
|
191
|
+
if (flags["from-json"]) {
|
|
192
|
+
var raw = fs.readFileSync(flags["from-json"], "utf8");
|
|
193
|
+
// Validate rather than cast: the same zod schema the MCP tool parses with, so a
|
|
194
|
+
// malformed spec fails here with a field-level message instead of somewhere
|
|
195
|
+
// downstream as an undefined table name.
|
|
196
|
+
return schemas_1.removeChoicesFromFieldSchema.parse(JSON.parse(raw));
|
|
197
|
+
}
|
|
198
|
+
var table = flags.table;
|
|
199
|
+
var column = flags.column;
|
|
200
|
+
var updateSetSysId = flags["update-set"] || flags.updateSetSysId;
|
|
201
|
+
var valuesInline = flags.values;
|
|
202
|
+
if (!table || !column || !updateSetSysId || !valuesInline) {
|
|
203
|
+
throw new Error("Missing required flags: --table, --column, --update-set, --values");
|
|
204
|
+
}
|
|
205
|
+
var params = {
|
|
206
|
+
table: table,
|
|
207
|
+
column: column,
|
|
208
|
+
updateSetSysId: updateSetSysId,
|
|
209
|
+
values: valuesInline
|
|
210
|
+
.split(",")
|
|
211
|
+
.map(function (v) {
|
|
212
|
+
return v.trim();
|
|
213
|
+
})
|
|
214
|
+
.filter(function (v) {
|
|
215
|
+
return v.length > 0;
|
|
216
|
+
}),
|
|
217
|
+
};
|
|
218
|
+
if (flags.language) {
|
|
219
|
+
params.language = flags.language;
|
|
220
|
+
}
|
|
221
|
+
return params;
|
|
222
|
+
}
|
|
223
|
+
async function runRemoveChoices(flags, bare) {
|
|
224
|
+
// --language matters most here: a bare one makes every lookup key "true::<value>",
|
|
225
|
+
// so every value reports "missing", nothing is written, and the summary still reads
|
|
226
|
+
// like a clean run. That is the silent failure this verb family exists to catch.
|
|
227
|
+
var bareErr = bareStringFlagError("remove-choices", bare, [
|
|
228
|
+
"table",
|
|
229
|
+
"column",
|
|
230
|
+
"update-set",
|
|
231
|
+
"updateSetSysId",
|
|
232
|
+
"values",
|
|
233
|
+
"language",
|
|
234
|
+
"from-json",
|
|
235
|
+
]);
|
|
236
|
+
if (bareErr) {
|
|
237
|
+
process.stderr.write(bareErr);
|
|
238
|
+
return 1;
|
|
239
|
+
}
|
|
240
|
+
var params = removeParamsFromFlags(flags);
|
|
241
|
+
var client = (0, client_1.createClient)({});
|
|
242
|
+
var result = await (0, choices_1.removeChoicesFromField)(client, params);
|
|
243
|
+
if (flags.json === "true") {
|
|
244
|
+
process.stdout.write(JSON.stringify({ params: params, result: result }, null, 2) + "\n");
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
process.stdout.write((0, formatter_1.formatRemoveChoicesResult)(params.table, params.column, result) + "\n");
|
|
248
|
+
return 0;
|
|
159
249
|
}
|
|
160
250
|
/**
|
|
161
251
|
* dove-sn build-flow:
|
|
@@ -850,6 +940,9 @@ function printHelp() {
|
|
|
850
940
|
process.stdout.write("dove-sn — ServiceNow platform helpers\n\n" +
|
|
851
941
|
"Commands:\n" +
|
|
852
942
|
" add-choices Upsert sys_choice rows for a table.column\n" +
|
|
943
|
+
" remove-choices Soft-delete (inactive=true) sys_choice values for a table.column\n" +
|
|
944
|
+
" (--table <t> --column <c> --values a,b,c --update-set <sys_id>\n" +
|
|
945
|
+
" [--language en] [--from-json <path>] [--json])\n" +
|
|
853
946
|
" create-view Create a custom view (sys_ui_view)\n" +
|
|
854
947
|
" (--name <n> --update-set <sys_id> [--title <t>] [--scope <s>] [--dry-run] [--json])\n" +
|
|
855
948
|
" set-list-layout Set the columns of a list layout\n" +
|
|
@@ -1149,17 +1242,17 @@ function parseBoolFlag(name, raw, verb = "set-column") {
|
|
|
1149
1242
|
* to set a RECORD's value, use set-field.
|
|
1150
1243
|
*/
|
|
1151
1244
|
async function runSetColumn(flags, bare) {
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1245
|
+
var bareErr = bareStringFlagError("set-column", bare, [
|
|
1246
|
+
"label",
|
|
1247
|
+
"default",
|
|
1248
|
+
"table",
|
|
1249
|
+
"column",
|
|
1250
|
+
"update-set",
|
|
1251
|
+
"updateSetSysId",
|
|
1252
|
+
]);
|
|
1253
|
+
if (bareErr) {
|
|
1254
|
+
process.stderr.write(bareErr);
|
|
1255
|
+
return 1;
|
|
1163
1256
|
}
|
|
1164
1257
|
var table = flags.table;
|
|
1165
1258
|
var column = flags.column;
|
|
@@ -1726,8 +1819,10 @@ async function main() {
|
|
|
1726
1819
|
// target multiple instances; otherwise the cwd `.env` is used.
|
|
1727
1820
|
(0, loadEnv_1.loadEnvFile)(parsed.flags.env || parsed.flags["env-file"]);
|
|
1728
1821
|
if (parsed.command === "add-choices") {
|
|
1729
|
-
await runAddChoices(parsed.flags);
|
|
1730
|
-
|
|
1822
|
+
return await runAddChoices(parsed.flags, parsed.bare);
|
|
1823
|
+
}
|
|
1824
|
+
if (parsed.command === "remove-choices") {
|
|
1825
|
+
return await runRemoveChoices(parsed.flags, parsed.bare);
|
|
1731
1826
|
}
|
|
1732
1827
|
if (parsed.command === "build-flow") {
|
|
1733
1828
|
return await runBuildFlowCmd(parsed.flags);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S1 — Execution-context detection + two-phase gate (schema-CRUD RFC §7).
|
|
3
|
+
*
|
|
4
|
+
* The reusable gate every schema-mutating verb runs through before it writes. It
|
|
5
|
+
* answers one question, given HOW the command was invoked:
|
|
6
|
+
*
|
|
7
|
+
* - Local / interactive → two-phase. A schema mutation writes only on explicit
|
|
8
|
+
* confirmation, after the caller has seen the dry-run plan.
|
|
9
|
+
* - Automation / CI → a DESTRUCTIVE change is allowed only if it arrived
|
|
10
|
+
* through a merged pull request: the workflow passes a merge ref AND proof the
|
|
11
|
+
* change is present in that merged diff. Missing either → refuse. This is the
|
|
12
|
+
* §7.1 rule: an unattended run may not ORIGINATE a destructive schema change.
|
|
13
|
+
*
|
|
14
|
+
* This is NOT a security boundary — a caller can construct any input it likes. It is
|
|
15
|
+
* a guard rail that makes the safe path the default and the dangerous path explicit,
|
|
16
|
+
* legibly, in one place instead of re-derived per verb. It FAILS CLOSED: when the
|
|
17
|
+
* context is ambiguous it resolves to `local` (confirm-required), and an automation
|
|
18
|
+
* destructive write with no valid merge signal is refused (RFC R4 — the gate is only
|
|
19
|
+
* as good as the signal the workflow passes, so absence or falsity both refuse).
|
|
20
|
+
*
|
|
21
|
+
* ES6 only; no optional chaining. Pure logic — no ServiceNow I/O — so it is unit
|
|
22
|
+
* tested exhaustively and carries no live-instance risk.
|
|
23
|
+
*/
|
|
24
|
+
export type ExecutionContext = "local" | "automation";
|
|
25
|
+
export interface ResolveContextInput {
|
|
26
|
+
/** Explicit override (e.g. from a `--context` flag). Wins over auto-detection. */
|
|
27
|
+
override?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Environment to read the CI signal from. Injected for tests; defaults to
|
|
30
|
+
* process.env. Kept explicit so detection is deterministic and testable.
|
|
31
|
+
*/
|
|
32
|
+
env?: Record<string, string | undefined>;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Whether we appear to be running in a CI/automation environment. Reads the standard
|
|
36
|
+
* signals — GitHub Actions sets GITHUB_ACTIONS=true; CI=true is the generic marker
|
|
37
|
+
* most runners set. Necessary but NOT sufficient to allow a destructive write: "ran
|
|
38
|
+
* in CI" ≠ "came from a merged PR" (that is the merge-signal check in the gate).
|
|
39
|
+
*/
|
|
40
|
+
export declare function isCiEnvironment(env: Record<string, string | undefined>): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Resolve local-vs-automation. An explicit override always wins (and an override that
|
|
43
|
+
* is neither 'local' nor 'automation' is a hard error, not a silent fallback). With
|
|
44
|
+
* no override, auto-detect CI; default to `local` — the safer, confirm-required
|
|
45
|
+
* branch — when there is no CI signal.
|
|
46
|
+
*/
|
|
47
|
+
export declare function resolveExecutionContext(input?: ResolveContextInput): ExecutionContext;
|
|
48
|
+
/**
|
|
49
|
+
* The merged-PR signal an automation workflow passes for a destructive change.
|
|
50
|
+
*
|
|
51
|
+
* `changePresent` is the load-bearing field: the workflow is responsible for proving
|
|
52
|
+
* the destructive change is actually in the diff at `mergeRef`, and the gate fails
|
|
53
|
+
* closed unless it is explicitly true. Passing a ref alone is not enough — that would
|
|
54
|
+
* let any CI run assert "a PR merged somewhere", which is exactly the loophole §7.1
|
|
55
|
+
* closes.
|
|
56
|
+
*/
|
|
57
|
+
export interface MergeSignal {
|
|
58
|
+
/** The merge commit SHA or PR ref the automation ran from. */
|
|
59
|
+
mergeRef: string;
|
|
60
|
+
/** Proof the destructive change is present in the diff at `mergeRef`. */
|
|
61
|
+
changePresent: boolean;
|
|
62
|
+
}
|
|
63
|
+
export interface WriteGateInput {
|
|
64
|
+
context: ExecutionContext;
|
|
65
|
+
/** True for a WRITE_DESTRUCTIVE operation (drop table/column). */
|
|
66
|
+
destructive: boolean;
|
|
67
|
+
/** Local: explicit confirmation that the dry-run plan was reviewed and accepted. */
|
|
68
|
+
confirmed?: boolean;
|
|
69
|
+
/** Automation: the merged-PR signal, required for a destructive change. */
|
|
70
|
+
mergeSignal?: MergeSignal;
|
|
71
|
+
}
|
|
72
|
+
export interface GateDecision {
|
|
73
|
+
allowed: boolean;
|
|
74
|
+
/** Why the write was refused, and what to do — empty string when allowed. */
|
|
75
|
+
reason: string;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Decide whether a schema write may proceed. Returns a decision rather than throwing,
|
|
79
|
+
* so a verb can fold `reason` into its own structured result; `assertWriteAllowed` is
|
|
80
|
+
* the throwing wrapper for callers that prefer to abort.
|
|
81
|
+
*/
|
|
82
|
+
export declare function evaluateWriteGate(input: WriteGateInput): GateDecision;
|
|
83
|
+
/** Throwing wrapper around evaluateWriteGate for verbs that prefer to abort on refusal. */
|
|
84
|
+
export declare function assertWriteAllowed(input: WriteGateInput): void;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* S1 — Execution-context detection + two-phase gate (schema-CRUD RFC §7).
|
|
4
|
+
*
|
|
5
|
+
* The reusable gate every schema-mutating verb runs through before it writes. It
|
|
6
|
+
* answers one question, given HOW the command was invoked:
|
|
7
|
+
*
|
|
8
|
+
* - Local / interactive → two-phase. A schema mutation writes only on explicit
|
|
9
|
+
* confirmation, after the caller has seen the dry-run plan.
|
|
10
|
+
* - Automation / CI → a DESTRUCTIVE change is allowed only if it arrived
|
|
11
|
+
* through a merged pull request: the workflow passes a merge ref AND proof the
|
|
12
|
+
* change is present in that merged diff. Missing either → refuse. This is the
|
|
13
|
+
* §7.1 rule: an unattended run may not ORIGINATE a destructive schema change.
|
|
14
|
+
*
|
|
15
|
+
* This is NOT a security boundary — a caller can construct any input it likes. It is
|
|
16
|
+
* a guard rail that makes the safe path the default and the dangerous path explicit,
|
|
17
|
+
* legibly, in one place instead of re-derived per verb. It FAILS CLOSED: when the
|
|
18
|
+
* context is ambiguous it resolves to `local` (confirm-required), and an automation
|
|
19
|
+
* destructive write with no valid merge signal is refused (RFC R4 — the gate is only
|
|
20
|
+
* as good as the signal the workflow passes, so absence or falsity both refuse).
|
|
21
|
+
*
|
|
22
|
+
* ES6 only; no optional chaining. Pure logic — no ServiceNow I/O — so it is unit
|
|
23
|
+
* tested exhaustively and carries no live-instance risk.
|
|
24
|
+
*/
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.isCiEnvironment = isCiEnvironment;
|
|
27
|
+
exports.resolveExecutionContext = resolveExecutionContext;
|
|
28
|
+
exports.evaluateWriteGate = evaluateWriteGate;
|
|
29
|
+
exports.assertWriteAllowed = assertWriteAllowed;
|
|
30
|
+
/**
|
|
31
|
+
* Whether we appear to be running in a CI/automation environment. Reads the standard
|
|
32
|
+
* signals — GitHub Actions sets GITHUB_ACTIONS=true; CI=true is the generic marker
|
|
33
|
+
* most runners set. Necessary but NOT sufficient to allow a destructive write: "ran
|
|
34
|
+
* in CI" ≠ "came from a merged PR" (that is the merge-signal check in the gate).
|
|
35
|
+
*/
|
|
36
|
+
function isCiEnvironment(env) {
|
|
37
|
+
return env.GITHUB_ACTIONS === "true" || env.CI === "true" || env.CI === "1";
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Resolve local-vs-automation. An explicit override always wins (and an override that
|
|
41
|
+
* is neither 'local' nor 'automation' is a hard error, not a silent fallback). With
|
|
42
|
+
* no override, auto-detect CI; default to `local` — the safer, confirm-required
|
|
43
|
+
* branch — when there is no CI signal.
|
|
44
|
+
*/
|
|
45
|
+
function resolveExecutionContext(input = {}) {
|
|
46
|
+
var override = input.override
|
|
47
|
+
? String(input.override).trim().toLowerCase()
|
|
48
|
+
: "";
|
|
49
|
+
if (override === "local" || override === "automation") {
|
|
50
|
+
return override;
|
|
51
|
+
}
|
|
52
|
+
if (override) {
|
|
53
|
+
throw new Error("context must be 'local' or 'automation', got '" + input.override + "'.");
|
|
54
|
+
}
|
|
55
|
+
var env = input.env || process.env;
|
|
56
|
+
return isCiEnvironment(env) ? "automation" : "local";
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Decide whether a schema write may proceed. Returns a decision rather than throwing,
|
|
60
|
+
* so a verb can fold `reason` into its own structured result; `assertWriteAllowed` is
|
|
61
|
+
* the throwing wrapper for callers that prefer to abort.
|
|
62
|
+
*/
|
|
63
|
+
function evaluateWriteGate(input) {
|
|
64
|
+
if (input.context === "local") {
|
|
65
|
+
// Every schema mutation is two-phase locally — destructive or not.
|
|
66
|
+
if (!input.confirmed) {
|
|
67
|
+
return {
|
|
68
|
+
allowed: false,
|
|
69
|
+
reason: "Local schema writes are two-phase: review the dry-run plan, then re-run " +
|
|
70
|
+
"with confirmation. Nothing was written.",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return { allowed: true, reason: "" };
|
|
74
|
+
}
|
|
75
|
+
// Automation. A non-destructive write may run unattended; a destructive one may not
|
|
76
|
+
// ORIGINATE here — it must have arrived through a merged PR.
|
|
77
|
+
if (!input.destructive) {
|
|
78
|
+
return { allowed: true, reason: "" };
|
|
79
|
+
}
|
|
80
|
+
if (!input.mergeSignal || !input.mergeSignal.mergeRef) {
|
|
81
|
+
return {
|
|
82
|
+
allowed: false,
|
|
83
|
+
reason: "Automation refuses a destructive schema change without a merged-PR signal " +
|
|
84
|
+
"— an unattended run may not originate one (fail closed).",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (!input.mergeSignal.changePresent) {
|
|
88
|
+
return {
|
|
89
|
+
allowed: false,
|
|
90
|
+
reason: "The destructive change is not present in the merged diff '" +
|
|
91
|
+
input.mergeSignal.mergeRef +
|
|
92
|
+
"' — refusing (fail closed).",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return { allowed: true, reason: "" };
|
|
96
|
+
}
|
|
97
|
+
/** Throwing wrapper around evaluateWriteGate for verbs that prefer to abort on refusal. */
|
|
98
|
+
function assertWriteAllowed(input) {
|
|
99
|
+
var decision = evaluateWriteGate(input);
|
|
100
|
+
if (!decision.allowed) {
|
|
101
|
+
// No verb prefix — the reason is self-contained, and this gate is shared by every
|
|
102
|
+
// schema verb (remove-field/remove-table/…), none of which owns the message.
|
|
103
|
+
throw new Error(decision.reason);
|
|
104
|
+
}
|
|
105
|
+
}
|
package/dist/formatter.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import type { AddChoicesResult } from "./types";
|
|
1
|
+
import type { AddChoicesResult, RemoveChoicesResult } from "./types";
|
|
2
2
|
/**
|
|
3
3
|
* Human-readable one-page summary of an addChoicesToField result.
|
|
4
4
|
* Used by the CLI and by Claude skills when surfacing outcomes back to the user.
|
|
5
5
|
*/
|
|
6
6
|
export declare function formatAddChoicesResult(table: string, column: string, result: AddChoicesResult): string;
|
|
7
|
+
/**
|
|
8
|
+
* Human-readable one-page summary of a removeChoicesFromField result.
|
|
9
|
+
* Soft-delete semantics: "deactivated" set inactive=true; "unchanged" was already
|
|
10
|
+
* inactive; "missing" was not found on the field.
|
|
11
|
+
*/
|
|
12
|
+
export declare function formatRemoveChoicesResult(table: string, column: string, result: RemoveChoicesResult): string;
|
package/dist/formatter.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.formatAddChoicesResult = formatAddChoicesResult;
|
|
4
|
+
exports.formatRemoveChoicesResult = formatRemoveChoicesResult;
|
|
4
5
|
/**
|
|
5
6
|
* Human-readable one-page summary of an addChoicesToField result.
|
|
6
7
|
* Used by the CLI and by Claude skills when surfacing outcomes back to the user.
|
|
@@ -36,3 +37,67 @@ function formatAddChoicesResult(table, column, result) {
|
|
|
36
37
|
lines.push("Summary: " + created + " created, " + updated + " updated, " + unchanged + " unchanged.");
|
|
37
38
|
return lines.join("\n");
|
|
38
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Human-readable one-page summary of a removeChoicesFromField result.
|
|
42
|
+
* Soft-delete semantics: "deactivated" set inactive=true; "unchanged" was already
|
|
43
|
+
* inactive; "missing" was not found on the field.
|
|
44
|
+
*/
|
|
45
|
+
function formatRemoveChoicesResult(table, column, result) {
|
|
46
|
+
var lines = [];
|
|
47
|
+
lines.push("ServiceNow choice soft-delete — " +
|
|
48
|
+
table +
|
|
49
|
+
"." +
|
|
50
|
+
column +
|
|
51
|
+
" [" +
|
|
52
|
+
result.field.language +
|
|
53
|
+
"]");
|
|
54
|
+
lines.push("");
|
|
55
|
+
lines.push("Update set: " +
|
|
56
|
+
result.updateSet.name +
|
|
57
|
+
" (" +
|
|
58
|
+
result.updateSet.sysId +
|
|
59
|
+
")");
|
|
60
|
+
lines.push("Dictionary: " + result.field.dictionarySysId);
|
|
61
|
+
lines.push("");
|
|
62
|
+
var deactivated = 0;
|
|
63
|
+
var unchanged = 0;
|
|
64
|
+
var missing = 0;
|
|
65
|
+
lines.push("Choices:");
|
|
66
|
+
result.choices.forEach(function (row) {
|
|
67
|
+
if (row.action === "deactivated")
|
|
68
|
+
deactivated += 1;
|
|
69
|
+
else if (row.action === "unchanged")
|
|
70
|
+
unchanged += 1;
|
|
71
|
+
else
|
|
72
|
+
missing += 1;
|
|
73
|
+
// A value with more than one row is worth saying out loud — the field carries
|
|
74
|
+
// duplicates. The wording has to follow the action: on "unchanged" nothing was
|
|
75
|
+
// written, so claiming they were deactivated would contradict the summary below.
|
|
76
|
+
// "all now inactive" rather than "all deactivated": on a mixed set — one duplicate
|
|
77
|
+
// already inactive, one live — only the live row is written, so claiming both were
|
|
78
|
+
// deactivated overstates it. End state is what the reader needs, and it is true in
|
|
79
|
+
// both cases.
|
|
80
|
+
var note = "";
|
|
81
|
+
if (row.sysIds.length > 1) {
|
|
82
|
+
note =
|
|
83
|
+
row.action === "deactivated"
|
|
84
|
+
? " [" + row.sysIds.length + " duplicate rows, all now inactive]"
|
|
85
|
+
: " [" + row.sysIds.length + " duplicate rows, all already inactive]";
|
|
86
|
+
}
|
|
87
|
+
lines.push(" [" +
|
|
88
|
+
row.action.padEnd(11) +
|
|
89
|
+
"] " +
|
|
90
|
+
row.value +
|
|
91
|
+
(row.sysId ? " (" + row.sysId + ")" : "") +
|
|
92
|
+
note);
|
|
93
|
+
});
|
|
94
|
+
lines.push("");
|
|
95
|
+
lines.push("Summary: " +
|
|
96
|
+
deactivated +
|
|
97
|
+
" deactivated, " +
|
|
98
|
+
unchanged +
|
|
99
|
+
" unchanged, " +
|
|
100
|
+
missing +
|
|
101
|
+
" missing.");
|
|
102
|
+
return lines.join("\n");
|
|
103
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
* REST API so every change lands in the target update set and scope.
|
|
6
6
|
*/
|
|
7
7
|
export { createClient } from "./client";
|
|
8
|
+
export { resolveExecutionContext, isCiEnvironment, evaluateWriteGate, assertWriteAllowed, } from "./executionContext";
|
|
9
|
+
export type { ExecutionContext, ResolveContextInput, MergeSignal, WriteGateInput, GateDecision, } from "./executionContext";
|
|
8
10
|
export { createClientFromEnvFile, resolveConfigFromEnvFile, } from "./createClientFromEnvFile";
|
|
9
11
|
export type { ServiceNowClient, TableQueryOptions, TableSchema, TableSchemaField, AttachmentMeta, NowInvokeMethod, NowInvokeParams, NowInvokeResponse, } from "./client";
|
|
10
|
-
export { addChoicesToField } from "./choices";
|
|
12
|
+
export { addChoicesToField, removeChoicesFromField, ChoiceWriteError, } from "./choices";
|
|
11
13
|
export { hostAssets, classifyChunks, formatHostAssetsResult, } from "./hostAssets";
|
|
12
|
-
export { formatAddChoicesResult } from "./formatter";
|
|
14
|
+
export { formatAddChoicesResult, formatRemoveChoicesResult } from "./formatter";
|
|
13
15
|
export { createView } from "./layout/views";
|
|
14
16
|
export { setListLayout } from "./layout/listLayout";
|
|
15
17
|
export { setFormLayout } from "./layout/formLayout";
|
|
@@ -18,7 +20,7 @@ export { formatLayoutResult, formatCreateViewResult } from "./layout/formatter";
|
|
|
18
20
|
export { sincPlugin } from "./plugin";
|
|
19
21
|
export { listTemplates, verifyArtifact, cloneSubflow, cloneActionType, triggerPublication, publishActionType, editActionType, applyStepOps, verifySteps, summarizeSteps, formatStepPill, readFlow, readActionType, publishFlow, copyFlow, createFlow, buildPublishModel, editFlow, testFlow, DEFAULT_RUN_FLOW_PATH, generateSysId, topoSort, executeWritePlan, WriteOrderError, } from "./flowDesigner";
|
|
20
22
|
export type { TemplateRef, ListTemplatesParams, FlowKind, VerifyExpect, VerifyFound, VerifyFailure, VerifyReport, VerifyArtifactParams, CloneSubflowParams, CloneSubflowResult, CloneActionTypeParams, CloneActionTypeResult, TriggerPublicationParams, TriggerPublicationResult, PublishActionTypeParams, PublishActionTypeResult, EditActionTypeParams, EditActionTypeResult, EditActionTypeOps, StepOps, StepRecord, StepSummary, StepIoSummary, PatchStepScriptOp, AddStepOutputOp, AddStepInputOp, ApplyStepOpsResult, VerifyStepsResult, ReadFlowParams, ReadFlowResult, FlowStep, FlowVariable, ReadActionTypeParams, ReadActionTypeResult, ActionIo, PublishFlowParams, PublishFlowResult, CopyFlowParams, CopyFlowResult, CreateFlowParams, CreateFlowResult, EditFlowParams, EditFlowResult, EditFlowOps, StepInputPatch, TestFlowParams, TestFlowResult, WriteOp, WriteOpResult, } from "./flowDesigner";
|
|
21
|
-
export type { ServiceNowClientConfig, ChoiceValue, ChoiceType, AddChoicesParams, AddChoicesResult, ChoiceActionResult, DictionaryRecord, UpdateSetRecord, LayoutAction, LayoutRecordResult, LayoutResult, CreateViewParams, CreateViewResult, FormSectionSpec, SetFormLayoutParams, SetListLayoutParams, SetRelatedListsParams, ChunkRole, ChunkInfo, ChunkResult, PrunedResult, HostAssetsParams, HostAssetsResult, } from "./types";
|
|
23
|
+
export type { ServiceNowClientConfig, ChoiceValue, ChoiceType, AddChoicesParams, AddChoicesResult, ChoiceActionResult, RemoveChoicesParams, RemoveChoicesResult, ChoiceRemovalResult, DictionaryRecord, UpdateSetRecord, LayoutAction, LayoutRecordResult, LayoutResult, CreateViewParams, CreateViewResult, FormSectionSpec, SetFormLayoutParams, SetListLayoutParams, SetRelatedListsParams, ChunkRole, ChunkInfo, ChunkResult, PrunedResult, HostAssetsParams, HostAssetsResult, } from "./types";
|
|
22
24
|
export { createTable, projectTableGraph, buildColumnXml, normalizeColumns, resolveType, applyTableSaveOverlay, defaultAccessFlags, TYPE_MAP, DEFAULT_SUPER_CLASS, DEFAULT_SAVE_ACTION, addColumn, deriveElement, setColumn, resolveAttributes, toStoredValue, setTable, resolveTableAttributes, } from "./table";
|
|
23
25
|
export type { CreateTableParams, CreateTableResult, AddColumnParams, AddColumnResult, SetColumnParams, SetColumnResult, ColumnAttributes, AttributeChange, SetTableParams, SetTableResult, TableAttributes, TableGraph, NormalizedColumn, ColumnSpec, AccessFlags, OverlaySpec, } from "./table";
|
|
24
26
|
export { setField } from "./setField";
|
package/dist/index.js
CHANGED
|
@@ -6,21 +6,29 @@
|
|
|
6
6
|
* REST API so every change lands in the target update set and scope.
|
|
7
7
|
*/
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
-
exports.
|
|
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.resolveTableAttributes = exports.setTable = exports.toStoredValue = exports.resolveAttributes = exports.setColumn = exports.deriveElement = void 0;
|
|
9
|
+
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.formatRemoveChoicesResult = exports.formatAddChoicesResult = exports.formatHostAssetsResult = exports.classifyChunks = exports.hostAssets = exports.ChoiceWriteError = exports.removeChoicesFromField = exports.addChoicesToField = exports.resolveConfigFromEnvFile = exports.createClientFromEnvFile = exports.assertWriteAllowed = exports.evaluateWriteGate = exports.isCiEnvironment = exports.resolveExecutionContext = exports.createClient = 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.resolveTableAttributes = exports.setTable = exports.toStoredValue = exports.resolveAttributes = exports.setColumn = exports.deriveElement = exports.addColumn = exports.DEFAULT_SAVE_ACTION = exports.DEFAULT_SUPER_CLASS = exports.TYPE_MAP = exports.defaultAccessFlags = exports.applyTableSaveOverlay = exports.resolveType = void 0;
|
|
11
11
|
var client_1 = require("./client");
|
|
12
12
|
Object.defineProperty(exports, "createClient", { enumerable: true, get: function () { return client_1.createClient; } });
|
|
13
|
+
var executionContext_1 = require("./executionContext");
|
|
14
|
+
Object.defineProperty(exports, "resolveExecutionContext", { enumerable: true, get: function () { return executionContext_1.resolveExecutionContext; } });
|
|
15
|
+
Object.defineProperty(exports, "isCiEnvironment", { enumerable: true, get: function () { return executionContext_1.isCiEnvironment; } });
|
|
16
|
+
Object.defineProperty(exports, "evaluateWriteGate", { enumerable: true, get: function () { return executionContext_1.evaluateWriteGate; } });
|
|
17
|
+
Object.defineProperty(exports, "assertWriteAllowed", { enumerable: true, get: function () { return executionContext_1.assertWriteAllowed; } });
|
|
13
18
|
var createClientFromEnvFile_1 = require("./createClientFromEnvFile");
|
|
14
19
|
Object.defineProperty(exports, "createClientFromEnvFile", { enumerable: true, get: function () { return createClientFromEnvFile_1.createClientFromEnvFile; } });
|
|
15
20
|
Object.defineProperty(exports, "resolveConfigFromEnvFile", { enumerable: true, get: function () { return createClientFromEnvFile_1.resolveConfigFromEnvFile; } });
|
|
16
21
|
var choices_1 = require("./choices");
|
|
17
22
|
Object.defineProperty(exports, "addChoicesToField", { enumerable: true, get: function () { return choices_1.addChoicesToField; } });
|
|
23
|
+
Object.defineProperty(exports, "removeChoicesFromField", { enumerable: true, get: function () { return choices_1.removeChoicesFromField; } });
|
|
24
|
+
Object.defineProperty(exports, "ChoiceWriteError", { enumerable: true, get: function () { return choices_1.ChoiceWriteError; } });
|
|
18
25
|
var hostAssets_1 = require("./hostAssets");
|
|
19
26
|
Object.defineProperty(exports, "hostAssets", { enumerable: true, get: function () { return hostAssets_1.hostAssets; } });
|
|
20
27
|
Object.defineProperty(exports, "classifyChunks", { enumerable: true, get: function () { return hostAssets_1.classifyChunks; } });
|
|
21
28
|
Object.defineProperty(exports, "formatHostAssetsResult", { enumerable: true, get: function () { return hostAssets_1.formatHostAssetsResult; } });
|
|
22
29
|
var formatter_1 = require("./formatter");
|
|
23
30
|
Object.defineProperty(exports, "formatAddChoicesResult", { enumerable: true, get: function () { return formatter_1.formatAddChoicesResult; } });
|
|
31
|
+
Object.defineProperty(exports, "formatRemoveChoicesResult", { enumerable: true, get: function () { return formatter_1.formatRemoveChoicesResult; } });
|
|
24
32
|
var views_1 = require("./layout/views");
|
|
25
33
|
Object.defineProperty(exports, "createView", { enumerable: true, get: function () { return views_1.createView; } });
|
|
26
34
|
var listLayout_1 = require("./layout/listLayout");
|
package/dist/mcp/registry.d.ts
CHANGED
|
@@ -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_table", "set_field", "create_record", "host_assets", "invoke_rest", "app_publish"];
|
|
13
|
+
export declare var TOOL_NAMES: readonly ["create_view", "set_list_layout", "set_form_layout", "set_related_lists", "add_choices_to_field", "remove_choices_from_field", "flow_view", "action_view", "action_edit", "flow_publish", "flow_copy", "flow_create", "flow_test", "flow_edit", "create_table", "add_column", "set_column", "set_table", "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({}). */
|
package/dist/mcp/registry.js
CHANGED
|
@@ -39,6 +39,7 @@ exports.TOOL_NAMES = [
|
|
|
39
39
|
"set_form_layout",
|
|
40
40
|
"set_related_lists",
|
|
41
41
|
"add_choices_to_field",
|
|
42
|
+
"remove_choices_from_field",
|
|
42
43
|
"flow_view",
|
|
43
44
|
"action_view",
|
|
44
45
|
"action_edit",
|
|
@@ -120,6 +121,25 @@ function buildDescriptors(deps = {}) {
|
|
|
120
121
|
return (0, choices_1.addChoicesToField)(client(), schemas_1.addChoicesToFieldSchema.parse(args));
|
|
121
122
|
},
|
|
122
123
|
},
|
|
124
|
+
{
|
|
125
|
+
name: "remove_choices_from_field",
|
|
126
|
+
annotations: dovetail_mcp_kit_1.WRITE_OVERWRITE,
|
|
127
|
+
description: "Soft-delete sys_choice values for a ServiceNow table.column by setting inactive=true " +
|
|
128
|
+
"(the row is kept, so it is reversible and historical values still resolve). Never a " +
|
|
129
|
+
"hard delete. Idempotent: an already-inactive value is 'unchanged', an absent value is " +
|
|
130
|
+
"'missing', and a value repeated in the request causes no extra writes — it collapses " +
|
|
131
|
+
"to one result row. A single value CAN write more than once when the field holds " +
|
|
132
|
+
"duplicate rows for it; every live one is deactivated. sys_dictionary.choice is left " +
|
|
133
|
+
"alone. Matching is scoped by LANGUAGE (defaults to 'en'), so a value that exists only " +
|
|
134
|
+
"in another language reports 'missing' and is left untouched — pass `language` to target " +
|
|
135
|
+
"it. Matching is also scoped to this table, so a choice INHERITED from a parent table " +
|
|
136
|
+
"reports 'missing' rather than being deactivated. Writes are captured in the supplied " +
|
|
137
|
+
"update set.",
|
|
138
|
+
shape: schemas_1.removeChoicesFromFieldSchema.shape,
|
|
139
|
+
handler: async function (args) {
|
|
140
|
+
return (0, choices_1.removeChoicesFromField)(client(), schemas_1.removeChoicesFromFieldSchema.parse(args));
|
|
141
|
+
},
|
|
142
|
+
},
|
|
123
143
|
{
|
|
124
144
|
name: "flow_view",
|
|
125
145
|
annotations: dovetail_mcp_kit_1.READ_ONLY,
|
package/dist/mcp/schemas.d.ts
CHANGED
|
@@ -185,6 +185,25 @@ export declare var addChoicesToFieldSchema: z.ZodObject<{
|
|
|
185
185
|
column: string;
|
|
186
186
|
choiceType?: 0 | 1 | 3 | null | undefined;
|
|
187
187
|
}>;
|
|
188
|
+
export declare var removeChoicesFromFieldSchema: z.ZodObject<{
|
|
189
|
+
table: z.ZodString;
|
|
190
|
+
column: z.ZodString;
|
|
191
|
+
values: z.ZodArray<z.ZodString, "many">;
|
|
192
|
+
language: z.ZodOptional<z.ZodString>;
|
|
193
|
+
updateSetSysId: z.ZodString;
|
|
194
|
+
}, "strip", z.ZodTypeAny, {
|
|
195
|
+
values: string[];
|
|
196
|
+
table: string;
|
|
197
|
+
updateSetSysId: string;
|
|
198
|
+
column: string;
|
|
199
|
+
language?: string | undefined;
|
|
200
|
+
}, {
|
|
201
|
+
values: string[];
|
|
202
|
+
table: string;
|
|
203
|
+
updateSetSysId: string;
|
|
204
|
+
column: string;
|
|
205
|
+
language?: string | undefined;
|
|
206
|
+
}>;
|
|
188
207
|
export declare var viewFlowSchema: z.ZodObject<{
|
|
189
208
|
sysId: z.ZodString;
|
|
190
209
|
raw: z.ZodOptional<z.ZodBoolean>;
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -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.publishAppSchema = exports.createRecordSchema = exports.setFieldSchema = exports.setTableSchema = exports.tableAttributesSchema = 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.setTableSchema = exports.tableAttributesSchema = 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.removeChoicesFromFieldSchema = 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),
|
|
@@ -61,6 +61,13 @@ exports.addChoicesToFieldSchema = zod_1.z.object({
|
|
|
61
61
|
.nullable()
|
|
62
62
|
.optional(),
|
|
63
63
|
});
|
|
64
|
+
exports.removeChoicesFromFieldSchema = zod_1.z.object({
|
|
65
|
+
table: zod_1.z.string().min(1),
|
|
66
|
+
column: zod_1.z.string().min(1),
|
|
67
|
+
values: zod_1.z.array(zod_1.z.string().min(1)).min(1),
|
|
68
|
+
language: zod_1.z.string().min(1).optional(),
|
|
69
|
+
updateSetSysId: zod_1.z.string().min(1),
|
|
70
|
+
});
|
|
64
71
|
exports.viewFlowSchema = zod_1.z.object({
|
|
65
72
|
sysId: zod_1.z.string().min(1),
|
|
66
73
|
raw: zod_1.z.boolean().optional(),
|
package/dist/types.d.ts
CHANGED
|
@@ -54,7 +54,25 @@ export interface UpdateSetRecord {
|
|
|
54
54
|
export interface ChoiceActionResult {
|
|
55
55
|
value: string;
|
|
56
56
|
label: string;
|
|
57
|
+
/**
|
|
58
|
+
* The FIRST row matching this value — `sysIds[0]`. On "created" it is the new row;
|
|
59
|
+
* on "updated" a duplicate that was already correct is left untouched, so this is
|
|
60
|
+
* not necessarily a row that was written. `sysIds` is the complete picture.
|
|
61
|
+
*/
|
|
57
62
|
sysId: string;
|
|
63
|
+
/**
|
|
64
|
+
* EVERY sys_choice row that matched this language::value — not only the ones written.
|
|
65
|
+
* Normally one, but sys_choice has no uniqueness constraint, so a field can hold
|
|
66
|
+
* duplicates; on "updated" only the rows that actually differ are written, and any
|
|
67
|
+
* duplicate that already matched the spec is left alone. A length > 1 is the caller's
|
|
68
|
+
* signal that the field needs cleaning up.
|
|
69
|
+
*
|
|
70
|
+
* OPTIONAL only for backwards compatibility: this type ships in a published package,
|
|
71
|
+
* and a required field would break any consumer that constructs or mocks one. The
|
|
72
|
+
* runtime always populates it — treat a missing value as "an older build produced
|
|
73
|
+
* this", not as a state addChoicesToField can return.
|
|
74
|
+
*/
|
|
75
|
+
sysIds?: Array<string>;
|
|
58
76
|
action: "created" | "updated" | "unchanged";
|
|
59
77
|
}
|
|
60
78
|
export interface AddChoicesResult {
|
|
@@ -70,6 +88,58 @@ export interface AddChoicesResult {
|
|
|
70
88
|
};
|
|
71
89
|
choices: Array<ChoiceActionResult>;
|
|
72
90
|
}
|
|
91
|
+
export interface RemoveChoicesParams {
|
|
92
|
+
/** Target table, e.g. "x_cadso_core_event". */
|
|
93
|
+
table: string;
|
|
94
|
+
/** Target column, e.g. "state". */
|
|
95
|
+
column: string;
|
|
96
|
+
/** Choice values to soft-delete (deactivate). */
|
|
97
|
+
values: Array<string>;
|
|
98
|
+
/** Language of the choices to deactivate. Defaults to "en". */
|
|
99
|
+
language?: string;
|
|
100
|
+
/** Update set sys_id that will capture every write. Required — no default. */
|
|
101
|
+
updateSetSysId: string;
|
|
102
|
+
}
|
|
103
|
+
export interface ChoiceRemovalResult {
|
|
104
|
+
value: string;
|
|
105
|
+
/**
|
|
106
|
+
* The FIRST row matching this value — `sysIds[0]`, or "" when the value was not
|
|
107
|
+
* found. Not necessarily a row that was written: when one duplicate is already
|
|
108
|
+
* inactive and another is live, only the live one is touched. `sysIds` is the
|
|
109
|
+
* complete picture.
|
|
110
|
+
*/
|
|
111
|
+
sysId: string;
|
|
112
|
+
/**
|
|
113
|
+
* EVERY sys_choice row that matched this language::value; [] when missing. A field
|
|
114
|
+
* can hold more than one row for a value (sys_choice has no uniqueness constraint),
|
|
115
|
+
* and all live ones are deactivated — acting on just the first would leave the
|
|
116
|
+
* choice selectable while reporting success.
|
|
117
|
+
*
|
|
118
|
+
* Required, unlike its counterpart on ChoiceActionResult: this type is new in the
|
|
119
|
+
* remove-choices verb and has never shipped, so there is no consumer to break, and
|
|
120
|
+
* the formatter can read it without an existence guard.
|
|
121
|
+
*/
|
|
122
|
+
sysIds: Array<string>;
|
|
123
|
+
/**
|
|
124
|
+
* deactivated — was active, now inactive=true.
|
|
125
|
+
* unchanged — already inactive; nothing written (idempotent).
|
|
126
|
+
* missing — no such value on this field.language; nothing written.
|
|
127
|
+
*/
|
|
128
|
+
action: "deactivated" | "unchanged" | "missing";
|
|
129
|
+
}
|
|
130
|
+
export interface RemoveChoicesResult {
|
|
131
|
+
field: {
|
|
132
|
+
table: string;
|
|
133
|
+
column: string;
|
|
134
|
+
language: string;
|
|
135
|
+
dictionarySysId: string;
|
|
136
|
+
};
|
|
137
|
+
updateSet: {
|
|
138
|
+
sysId: string;
|
|
139
|
+
name: string;
|
|
140
|
+
};
|
|
141
|
+
choices: Array<ChoiceRemovalResult>;
|
|
142
|
+
}
|
|
73
143
|
/** Outcome of a single sys_ui_* record within a layout reconcile. */
|
|
74
144
|
export type LayoutAction = "created" | "updated" | "deleted" | "unchanged";
|
|
75
145
|
/** Per-record result row — one ServiceNow sys_ui_* record touched (or planned). */
|