dataiku-sdk 4.0.0 → 4.2.0

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.
Files changed (66) hide show
  1. package/README.md +1 -0
  2. package/dist/build-metadata.json +1 -1
  3. package/dist/packages/types/src/index.d.ts +5 -1
  4. package/dist/packages/types/src/index.js +5 -1
  5. package/dist/src/cli/command-syntax.json +4136 -2756
  6. package/dist/src/cli/commands/code-env.js +5 -3
  7. package/dist/src/cli/commands/dataset.js +6 -4
  8. package/dist/src/cli/commands/future.js +7 -2
  9. package/dist/src/cli/commands/index.js +4 -0
  10. package/dist/src/cli/commands/job.js +12 -9
  11. package/dist/src/cli/commands/messaging-channel.d.ts +17 -0
  12. package/dist/src/cli/commands/messaging-channel.js +385 -0
  13. package/dist/src/cli/commands/project-deployer.js +33 -0
  14. package/dist/src/cli/commands/project.js +30 -0
  15. package/dist/src/cli/commands/recipe.js +23 -2
  16. package/dist/src/cli/commands/scenario.d.ts +6 -0
  17. package/dist/src/cli/commands/scenario.js +495 -17
  18. package/dist/src/cli/contract.js +20 -4
  19. package/dist/src/cli/env.d.ts +6 -0
  20. package/dist/src/cli/env.js +8 -0
  21. package/dist/src/cli/flags.js +34 -0
  22. package/dist/src/cli/helpers/cleanup.js +11 -0
  23. package/dist/src/cli/list-shapes.js +5 -0
  24. package/dist/src/cli/output.d.ts +2 -0
  25. package/dist/src/cli/output.js +24 -4
  26. package/dist/src/cli/plans.js +94 -2
  27. package/dist/src/cli/read-shapes.js +92 -1
  28. package/dist/src/cli/result-shapes.d.ts +19 -1
  29. package/dist/src/cli/result-shapes.js +228 -4
  30. package/dist/src/cli/selector-suggestions.d.ts +14 -0
  31. package/dist/src/cli/selector-suggestions.js +56 -0
  32. package/dist/src/cli/usage.d.ts +1 -1
  33. package/dist/src/cli/usage.js +36 -4
  34. package/dist/src/cli/version.d.ts +2 -2
  35. package/dist/src/cli/version.js +2 -2
  36. package/dist/src/cli.js +62 -13
  37. package/dist/src/client.d.ts +16 -4
  38. package/dist/src/client.js +45 -9
  39. package/dist/src/errors.d.ts +2 -0
  40. package/dist/src/errors.js +12 -2
  41. package/dist/src/generated/action-output-schemas.json +296 -0
  42. package/dist/src/index.d.ts +1 -0
  43. package/dist/src/index.js +1 -0
  44. package/dist/src/resources/bundles.d.ts +18 -0
  45. package/dist/src/resources/bundles.js +26 -0
  46. package/dist/src/resources/futures.d.ts +3 -0
  47. package/dist/src/resources/futures.js +5 -1
  48. package/dist/src/resources/messaging-channels.d.ts +83 -0
  49. package/dist/src/resources/messaging-channels.js +108 -0
  50. package/dist/src/resources/projects.d.ts +24 -0
  51. package/dist/src/resources/projects.js +36 -0
  52. package/dist/src/resources/recipe-create.d.ts +4 -1
  53. package/dist/src/resources/recipe-create.js +11 -2
  54. package/dist/src/resources/scenario-authoring.d.ts +52 -0
  55. package/dist/src/resources/scenario-authoring.js +216 -0
  56. package/dist/src/resources/scenarios.d.ts +128 -11
  57. package/dist/src/resources/scenarios.js +273 -43
  58. package/dist/src/resources/sql.js +3 -3
  59. package/dist/src/utils/messaging-channel-secrets.d.ts +17 -0
  60. package/dist/src/utils/messaging-channel-secrets.js +114 -0
  61. package/package.json +2 -1
  62. package/plugin.json +1 -1
  63. package/skills/dataiku-dss/SKILL.md +15 -16
  64. package/skills/dataiku-dss/references/discovery.md +2 -2
  65. package/skills/dataiku-dss/references/notifications.md +29 -0
  66. package/skills/dataiku-dss/references/troubleshooting.md +1 -0
@@ -79,13 +79,15 @@ export const codeEnvCommands = withUsage("code-env", {
79
79
  examples: ["dss code-env list", "dss code-env list --lang PYTHON",],
80
80
  },
81
81
  get: {
82
- handler: (c, a) => {
82
+ handler: (c, a, f) => {
83
83
  const usage = commandUsage("code-env", "get");
84
84
  requireArgs(a, 2, usage);
85
85
  const envLang = requireEnvLang(a[0], usage);
86
- return c.codeEnvs.get(envLang, a[1]);
86
+ return f["full"] === true
87
+ ? c.codeEnvs.getDefinition(envLang, a[1])
88
+ : c.codeEnvs.get(envLang, a[1]);
87
89
  },
88
- description: "Get code environment details: requested vs installed packages, Python interpreter, deployment mode, and the stable definition hash (definitionHash) used by set-definition --expect-hash.",
90
+ description: "Get code environment details: requested vs installed packages, Python interpreter, deployment mode, and the stable definition hash (definitionHash) used by set-definition --expect-hash. --full returns the raw DSS definition (same as get-definition).",
89
91
  examples: ["dss code-env get PYTHON my_env",],
90
92
  },
91
93
  "get-definition": {
@@ -225,11 +225,13 @@ export const datasetCommands = withUsage("dataset", {
225
225
  handler: async (c, a, f) => {
226
226
  const previewUsage = commandUsage("dataset", "preview");
227
227
  requireArgs(a, 1, previewUsage);
228
- const maxRows = num(f["max-rows"], "--max-rows");
229
- if (maxRows !== undefined && (!Number.isSafeInteger(maxRows) || maxRows <= 0 || maxRows > 500)) {
230
- throw new UsageError(`--max-rows must be a positive integer no greater than 500 (got "${String(f["max-rows"])}"). `
231
- + `Usage: ${previewUsage}`, "validation_failed");
228
+ const requestedRows = num(f["max-rows"], "--max-rows");
229
+ if (requestedRows !== undefined && (!Number.isSafeInteger(requestedRows) || requestedRows <= 0)) {
230
+ throw new UsageError(`--max-rows must be a positive integer (got "${String(f["max-rows"])}"). Usage: ${previewUsage}`, "validation_failed");
232
231
  }
232
+ // Agents ask for "all rows" with a large number; serve the 500-row public
233
+ // maximum instead of failing, and let the truncation warning say if more exist.
234
+ const maxRows = requestedRows === undefined ? undefined : Math.min(requestedRows, 500);
233
235
  const result = await c.datasets.preview(a[0], {
234
236
  maxRows,
235
237
  projectKey: f["project-key"],
@@ -8,9 +8,14 @@ export const futureCommands = withUsage("future", {
8
8
  handler: (c, _a, f) => c.futures.list({
9
9
  allUsers: f["all-users"] === true,
10
10
  withScenarios: f["with-scenarios"] === true,
11
+ scenariosOnly: f["scenarios-only"] === true,
11
12
  }),
12
- description: "List long tasks in progress on the instance (DSS futures): your own, or every user's with --all-users; --with-scenarios adds running scenarios.",
13
- examples: ["dss future list", "dss future list --all-users --with-scenarios",],
13
+ description: "List long tasks in progress on the instance (DSS futures): your own, or every user's with --all-users; --with-scenarios adds running scenarios, --scenarios-only lists only running scenarios.",
14
+ examples: [
15
+ "dss future list",
16
+ "dss future list --all-users --with-scenarios",
17
+ "dss future list --scenarios-only",
18
+ ],
14
19
  },
15
20
  get: {
16
21
  handler: (c, a) => {
@@ -67,6 +67,10 @@ export const commands = {
67
67
  get "meaning"() {
68
68
  return require("./meaning.js").meaningCommands;
69
69
  },
70
+ get "messaging-channel"() {
71
+ return require("./messaging-channel.js")
72
+ .messagingChannelCommands;
73
+ },
70
74
  get "workspace"() {
71
75
  return require("./workspace.js").workspaceCommands;
72
76
  },
@@ -1,5 +1,5 @@
1
1
  import { resolve, } from "node:path";
2
- import { ClientValidationError, DataikuError, dataikuErrorCode, } from "../../errors.js";
2
+ import { ClientValidationError, DataikuError, dataikuErrorCode, RESPONSE_TOO_LARGE_STATUS_TEXT, } from "../../errors.js";
3
3
  import { asRecord, } from "../../utils/records.js";
4
4
  import { jobBuildTargetTypeFromFlags, jobLogFilterFromFlag, maxLogLinesFromFlags, num, } from "../coerce.js";
5
5
  import { executionMode, } from "../flags.js";
@@ -34,23 +34,26 @@ function tailJobLogForStdout(log, limit) {
34
34
  const tail = lines.slice(-shown).join("\n");
35
35
  return hasTrailingLineBreak ? `${tail}\n` : tail;
36
36
  }
37
- const RESPONSE_TOO_LARGE_STATUS_TEXT = "Response Too Large";
38
- /** POSIX-shell quoting for a resume command argument. */
37
+ /** POSIX-shell quoting for the display form of a resume command. */
39
38
  function shellArg(value) {
40
39
  return /^[\w.:@%+=,/-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
41
40
  }
42
- /** Command that resumes waiting for a job whose wait timed out. */
43
- function jobWaitResumeCommand(jobId, projectKey) {
44
- return `dss job wait ${shellArg(jobId)}${projectKey ? ` --project-key ${shellArg(projectKey)}` : ""}`;
45
- }
46
41
  /**
47
42
  * A wait that timed out only stopped polling: the job is still running in DSS.
48
- * Attach the command that resumes waiting; other results pass through.
43
+ * Attach the command that resumes waiting: `resumeArgv` (run it without a
44
+ * shell, on any OS) and `resume` (POSIX display form); other results pass through.
49
45
  */
50
46
  function withWaitResume(result, projectKey) {
51
47
  if (result.timedOut !== true)
52
48
  return result;
53
- return { ...result, resume: jobWaitResumeCommand(result.jobId, projectKey), };
49
+ const resumeArgv = [
50
+ "dss",
51
+ "job",
52
+ "wait",
53
+ result.jobId,
54
+ ...(projectKey ? ["--project-key", projectKey,] : []),
55
+ ];
56
+ return { ...result, resume: resumeArgv.map(shellArg).join(" "), resumeArgv, };
54
57
  }
55
58
  /**
56
59
  * With an empty output schema DSS ends a dataset build DONE after writing no
@@ -0,0 +1,17 @@
1
+ import type { CommandMeta } from "../types.js";
2
+ /** The offline request shape of a messaging-channel write; mirrors the fields `commandPlanShape` returns. */
3
+ export interface MessagingChannelPlanShape {
4
+ endpoint?: string;
5
+ exact?: boolean;
6
+ identifiers?: Record<string, unknown>;
7
+ method?: string;
8
+ payload?: unknown;
9
+ reason?: string;
10
+ requests?: unknown;
11
+ }
12
+ /**
13
+ * Offline request shape of a messaging-channel write, shared by `--plan` and
14
+ * `--dry-run`. Credentials are redacted: a plan never carries a secret.
15
+ */
16
+ export declare function messagingChannelPlanShape(action: string, args: string[], flags: Record<string, string | boolean>, projectKey: string | undefined): MessagingChannelPlanShape;
17
+ export declare const messagingChannelCommands: Record<string, CommandMeta>;
@@ -0,0 +1,385 @@
1
+ import { access, } from "node:fs/promises";
2
+ import { basename, } from "node:path";
3
+ import { ClientValidationError, } from "../../errors.js";
4
+ import { mailMessagePayload, mailSendQuery, messagingChannelCreateBody, } from "../../resources/messaging-channels.js";
5
+ import { deepMerge, } from "../../utils/deep-merge.js";
6
+ import { messagingChannelSecretsFromBody, messagingChannelSettingsHash, sanitizeMessagingChannelError, sanitizeMessagingChannelSecrets, } from "../../utils/messaging-channel-secrets.js";
7
+ import { isRecord, } from "../../utils/records.js";
8
+ import { SECRET_REDACTED, } from "../../utils/secret-sanitize.js";
9
+ import { SHA256_HEX_PATTERN, stableJson, } from "../../utils/stable-hash.js";
10
+ import { parseBooleanOption, parseJsonValue, readInputText, readStdinText, requiredJsonInput, requiredStringFlag, splitCsvFlag, unknownJsonInput, } from "../coerce.js";
11
+ import { executionMode, } from "../flags.js";
12
+ import { planResult, readIfExists, skipResult, } from "../output.js";
13
+ import { commandUsage, withUsage, } from "../syntax.js";
14
+ import { requireArgs, UsageError, } from "../usage.js";
15
+ const RESOURCE = "messaging-channel";
16
+ const PLAN_FAILURE_CODES = { usage: 1, error: 2, transient: 3, };
17
+ function channelEndpoint(channelId) {
18
+ return `/public/api/messaging-channels/${encodeURIComponent(channelId)}`;
19
+ }
20
+ function channelIdArg(args, usage) {
21
+ requireArgs(args, 1, usage);
22
+ const channelId = args[0].trim();
23
+ if (channelId.length === 0) {
24
+ throw new UsageError(`channel id must be a non-empty string.\nUsage: ${usage}`, "validation_failed");
25
+ }
26
+ return channelId;
27
+ }
28
+ /**
29
+ * Read commands print `[redacted]` for credentials. Writing that placeholder
30
+ * back would overwrite the stored secret with the literal marker, so it is
31
+ * refused before any request.
32
+ */
33
+ function rejectRedactedPlaceholder(value, source) {
34
+ if (value === SECRET_REDACTED) {
35
+ throw new UsageError(`${source} contains the ${SECRET_REDACTED} placeholder that read commands print instead of a credential.`, "validation_failed", "Omit unchanged credential fields (settings-set merges into the stored settings) or supply the real value.");
36
+ }
37
+ if (Array.isArray(value)) {
38
+ for (const item of value)
39
+ rejectRedactedPlaceholder(item, source);
40
+ }
41
+ else if (isRecord(value)) {
42
+ for (const item of Object.values(value))
43
+ rejectRedactedPlaceholder(item, source);
44
+ }
45
+ }
46
+ function permissionsFromValue(value, source) {
47
+ if (!Array.isArray(value) || !value.every(isRecord)) {
48
+ throw new UsageError(`${source} must be a JSON array of permission objects such as [{"group":"analysts","canUse":true},{"user":"jdoe","canUse":false}].`, "validation_failed");
49
+ }
50
+ return value;
51
+ }
52
+ function createInputFromFlags(flags, usage) {
53
+ const type = requiredStringFlag(flags, "type", usage);
54
+ const configuration = unknownJsonInput(flags) ?? {};
55
+ if (!isRecord(configuration)) {
56
+ throw new UsageError("The channel configuration must be a JSON object.", "validation_failed", `Usage: ${usage}`);
57
+ }
58
+ rejectRedactedPlaceholder(configuration, "The channel configuration");
59
+ const permissionsText = flags["permissions"];
60
+ const permissions = typeof permissionsText === "string"
61
+ ? permissionsFromValue(parseJsonValue(permissionsText, "--permissions"), "--permissions")
62
+ : undefined;
63
+ const id = typeof flags["id"] === "string" ? flags["id"].trim() : undefined;
64
+ const request = {
65
+ type,
66
+ ...(id ? { id, } : {}),
67
+ configuration,
68
+ permissions,
69
+ };
70
+ const body = messagingChannelCreateBody(request);
71
+ return { request, body, secrets: messagingChannelSecretsFromBody(body), };
72
+ }
73
+ function mailBodyFromFlags(flags, usage) {
74
+ const sources = ["body", "body-file", "stdin",].filter((name) => name === "stdin"
75
+ ? parseBooleanOption(flags[name], "--stdin") === true
76
+ : flags[name] !== undefined);
77
+ if (sources.length !== 1) {
78
+ throw new UsageError(sources.length === 0
79
+ ? `--body, --body-file, or --stdin is required (message body). Usage: ${usage}`
80
+ : `Conflicting input sources: ${sources.map((name) => `--${name}`).join(" or ")}. Provide exactly one.`, sources.length === 0 ? "missing_required_flag" : "conflicting_input_sources");
81
+ }
82
+ if (sources[0] === "stdin")
83
+ return readStdinText();
84
+ if (sources[0] === "body-file")
85
+ return readInputText(String(flags["body-file"]), "--body-file");
86
+ return String(flags["body"]);
87
+ }
88
+ function sendInputFromFlags(args, flags, usage) {
89
+ const channelId = channelIdArg(args, usage);
90
+ const to = splitCsvFlag(flags["to"]);
91
+ if (to.length === 0) {
92
+ throw new UsageError(`--to is required (comma-separated recipient addresses). Usage: ${usage}`, "missing_required_flag");
93
+ }
94
+ const subject = flags["subject"];
95
+ if (typeof subject !== "string") {
96
+ throw new UsageError(`--subject is required. Usage: ${usage}`, "missing_required_flag");
97
+ }
98
+ const cc = splitCsvFlag(flags["cc"]);
99
+ const bcc = splitCsvFlag(flags["bcc"]);
100
+ const from = flags["from"];
101
+ return {
102
+ channelId,
103
+ message: {
104
+ to,
105
+ subject,
106
+ body: mailBodyFromFlags(flags, usage),
107
+ ...(cc.length > 0 ? { cc, } : {}),
108
+ ...(bcc.length > 0 ? { bcc, } : {}),
109
+ ...(typeof from === "string" && from.trim() ? { from: from.trim(), } : {}),
110
+ plainText: flags["plain-text"] === true,
111
+ },
112
+ attachments: splitCsvFlag(flags["attachment"]).map((path) => ({ path, })),
113
+ sendAsSingleMessage: flags["single-message"] === true,
114
+ };
115
+ }
116
+ /** Dot paths whose value differs between two settings objects; values stay out of the result. */
117
+ function changedPaths(before, after, prefix = "") {
118
+ if (isRecord(before) && isRecord(after)) {
119
+ return [...new Set([...Object.keys(before), ...Object.keys(after),]),].flatMap((key) => changedPaths(before[key], after[key], prefix ? `${prefix}.${key}` : key));
120
+ }
121
+ return stableJson(before) === stableJson(after) ? [] : [prefix,];
122
+ }
123
+ function validatedExpectHash(value, usage) {
124
+ if (value === undefined)
125
+ return undefined;
126
+ if (typeof value !== "string" || !SHA256_HEX_PATTERN.test(value)) {
127
+ throw new UsageError(`--expect-hash must be a 64-character SHA-256 hex digest (the settingsHash of settings-get). Usage: ${usage}`, "invalid_flag_value");
128
+ }
129
+ return value.toLowerCase();
130
+ }
131
+ /**
132
+ * Offline request shape of a messaging-channel write, shared by `--plan` and
133
+ * `--dry-run`. Credentials are redacted: a plan never carries a secret.
134
+ */
135
+ export function messagingChannelPlanShape(action, args, flags, projectKey) {
136
+ const usage = commandUsage(RESOURCE, action);
137
+ switch (action) {
138
+ case "create": {
139
+ const { body, } = createInputFromFlags(flags, usage);
140
+ return {
141
+ method: "POST",
142
+ endpoint: "/public/api/messaging-channels/",
143
+ identifiers: typeof body["id"] === "string" && body["id"] !== "" ? { id: body["id"], } : {},
144
+ payload: sanitizeMessagingChannelSecrets(body),
145
+ };
146
+ }
147
+ case "settings-set": {
148
+ const channelId = channelIdArg(args, usage);
149
+ const patch = requiredJsonInput(flags, `--data, --data-file, or --stdin is required (settings to merge). Usage: ${usage}`);
150
+ rejectRedactedPlaceholder(patch, "The settings patch");
151
+ const expectHash = validatedExpectHash(flags["expect-hash"], usage);
152
+ const endpoint = `${channelEndpoint(channelId)}/settings`;
153
+ return {
154
+ exact: false,
155
+ reason: "A fresh GET supplies the current settings, the patch is merged into them, and the merged object is PUT back.",
156
+ endpoint,
157
+ identifiers: { id: channelId, ...(expectHash ? { expectHash, } : {}), },
158
+ requests: [
159
+ { method: "GET", endpoint, purpose: "read current settings and check --expect-hash", },
160
+ {
161
+ method: "PUT",
162
+ endpoint,
163
+ payload: "<omitted; merged settings may include credentials>",
164
+ redactedFields: ["payload",],
165
+ },
166
+ ],
167
+ };
168
+ }
169
+ case "permissions-set": {
170
+ const channelId = channelIdArg(args, usage);
171
+ const permissions = permissionsFromValue(unknownJsonInput(flags), "The permissions input");
172
+ return {
173
+ method: "PUT",
174
+ endpoint: `${channelEndpoint(channelId)}/permissions`,
175
+ identifiers: { id: channelId, },
176
+ payload: permissions,
177
+ };
178
+ }
179
+ case "delete": {
180
+ const channelId = channelIdArg(args, usage);
181
+ return {
182
+ method: "DELETE",
183
+ endpoint: channelEndpoint(channelId),
184
+ identifiers: { id: channelId, },
185
+ };
186
+ }
187
+ case "send": {
188
+ const input = sendInputFromFlags(args, flags, usage);
189
+ if (!projectKey) {
190
+ throw new UsageError(`--project-key is required for send. Usage: ${usage}`, "missing_required_flag");
191
+ }
192
+ return {
193
+ method: "POST",
194
+ endpoint: `${channelEndpoint(input.channelId)}/actions/send?${mailSendQuery(projectKey, input.sendAsSingleMessage).toString()}`,
195
+ identifiers: { id: input.channelId, },
196
+ payload: {
197
+ upload: "multipart",
198
+ message: mailMessagePayload(input.message),
199
+ attachments: input.attachments.map((attachment) => ({
200
+ path: attachment.path,
201
+ fileName: basename(attachment.path),
202
+ })),
203
+ },
204
+ };
205
+ }
206
+ default:
207
+ return {
208
+ exact: false,
209
+ reason: `No offline request shape is defined for ${RESOURCE}.${action}.`,
210
+ };
211
+ }
212
+ }
213
+ /** `--dry-run` result: the redacted plan, flagged as also being a dry run. */
214
+ function dryRunPlan(action, shape, idempotency) {
215
+ return planResult(RESOURCE, action, {
216
+ ...shape,
217
+ asyncKind: "none",
218
+ exitCodesOnFailure: PLAN_FAILURE_CODES,
219
+ idempotency,
220
+ plannedAndDryRun: true,
221
+ });
222
+ }
223
+ export const messagingChannelCommands = withUsage(RESOURCE, {
224
+ list: {
225
+ handler: async (c, _a, f) => sanitizeMessagingChannelSecrets(await c.messagingChannels.list({
226
+ channelType: f["type"],
227
+ channelFamily: f["family"],
228
+ })),
229
+ description: "List messaging channels visible to the caller (compact: id, type, family; --full returns the DSS objects). Filter with --type (e.g. smtp, aws-ses-mail, slack) or --family mail. Credentials are redacted in output.",
230
+ examples: ["dss messaging-channel list --family mail",],
231
+ },
232
+ get: {
233
+ handler: async (c, a) => sanitizeMessagingChannelSecrets(await c.messagingChannels.get(channelIdArg(a, commandUsage(RESOURCE, "get")))),
234
+ description: "Get one messaging channel (type, family, sender of a mail channel). Credentials are redacted in output.",
235
+ examples: ["dss messaging-channel get smtp-main",],
236
+ },
237
+ create: {
238
+ handler: async (c, _a, f) => {
239
+ const { request, secrets, } = createInputFromFlags(f, commandUsage(RESOURCE, "create"));
240
+ if (executionMode(f).dryRun) {
241
+ return dryRunPlan("create", messagingChannelPlanShape("create", [], f, undefined), "none");
242
+ }
243
+ try {
244
+ return sanitizeMessagingChannelSecrets(await c.messagingChannels.create(request), secrets);
245
+ }
246
+ catch (error) {
247
+ throw sanitizeMessagingChannelError(error, secrets);
248
+ }
249
+ },
250
+ description: "Create a messaging channel (admin). --type: smtp, aws-ses-mail, microsoft-graph-mail, slack, msteams, google-chat, twilio, shell; --data is its configuration; --id defaults to a DSS-allocated id; --permissions is a JSON array of {group|user, canUse} (all users when omitted). Credentials are never echoed.",
251
+ examples: [
252
+ `dss messaging-channel create --type shell --id ops-shell --data '{"type":"COMMAND","command":"echo hi"}' --dry-run`,
253
+ ],
254
+ },
255
+ "settings-get": {
256
+ handler: async (c, a) => {
257
+ const channelId = channelIdArg(a, commandUsage(RESOURCE, "settings-get"));
258
+ const settings = await c.messagingChannels.getSettings(channelId);
259
+ return {
260
+ id: channelId,
261
+ settings: sanitizeMessagingChannelSecrets(settings),
262
+ settingsHash: messagingChannelSettingsHash(settings),
263
+ };
264
+ },
265
+ description: "Get a messaging channel's settings, i.e. its configuration (admin). Credentials are redacted; settingsHash fingerprints the redacted settings for settings-set --expect-hash.",
266
+ examples: ["dss messaging-channel settings-get smtp-main",],
267
+ },
268
+ "settings-set": {
269
+ handler: async (c, a, f) => {
270
+ const usage = commandUsage(RESOURCE, "settings-set");
271
+ const channelId = channelIdArg(a, usage);
272
+ const patch = requiredJsonInput(f, `--data, --data-file, or --stdin is required (settings to merge). Usage: ${usage}`);
273
+ rejectRedactedPlaceholder(patch, "The settings patch");
274
+ const expectHash = validatedExpectHash(f["expect-hash"], usage);
275
+ const current = await c.messagingChannels.getSettings(channelId);
276
+ const currentHash = messagingChannelSettingsHash(current);
277
+ if (expectHash !== undefined && currentHash !== expectHash) {
278
+ throw new ClientValidationError(`The messaging channel ${JSON.stringify(channelId)} changed since it was read.`, "validation_failed", "Re-read the settings with `dss messaging-channel settings-get` and retry with the current settingsHash.", { id: channelId, expectedHash: expectHash, actualHash: currentHash, });
279
+ }
280
+ const next = deepMerge(current, patch);
281
+ const secrets = messagingChannelSecretsFromBody(next);
282
+ const changed = changedPaths(current, next);
283
+ const nextHash = messagingChannelSettingsHash(next);
284
+ if (executionMode(f).dryRun) {
285
+ return {
286
+ dryRun: true,
287
+ action: "settings-set",
288
+ resource: RESOURCE,
289
+ id: channelId,
290
+ method: "PUT",
291
+ endpoint: `${channelEndpoint(channelId)}/settings`,
292
+ current: sanitizeMessagingChannelSecrets(current),
293
+ next: sanitizeMessagingChannelSecrets(next),
294
+ changed,
295
+ currentHash,
296
+ nextHash,
297
+ ...(expectHash !== undefined ? { expectHash, provenanceVerified: true, } : {}),
298
+ };
299
+ }
300
+ try {
301
+ await c.messagingChannels.saveSettings(channelId, next);
302
+ }
303
+ catch (error) {
304
+ throw sanitizeMessagingChannelError(error, secrets);
305
+ }
306
+ return { updated: channelId, changed, previousHash: currentHash, settingsHash: nextHash, };
307
+ },
308
+ description: "Merge supplied fields into a messaging channel's settings (GET-merge-PUT, admin); other fields are kept. --expect-hash takes settingsHash from settings-get and refuses the write if the redacted settings changed since. --dry-run shows redacted current/next settings, changed paths, and hashes. Never paste a [redacted] placeholder back: omit unchanged credentials.",
309
+ examples: [
310
+ `dss messaging-channel settings-set smtp-main --data '{"sender":"noreply@example.com"}' --dry-run`,
311
+ ],
312
+ },
313
+ "permissions-get": {
314
+ handler: (c, a) => c.messagingChannels.getPermissions(channelIdArg(a, commandUsage(RESOURCE, "permissions-get"))),
315
+ description: 'Get the permission list of a messaging channel: [{"group":"analysts","canUse":true},{"user":"jdoe","canUse":true}].',
316
+ examples: ["dss messaging-channel permissions-get smtp-main",],
317
+ },
318
+ "permissions-set": {
319
+ handler: async (c, a, f) => {
320
+ const channelId = channelIdArg(a, commandUsage(RESOURCE, "permissions-set"));
321
+ const permissions = permissionsFromValue(unknownJsonInput(f), "The permissions input");
322
+ if (executionMode(f).dryRun) {
323
+ return dryRunPlan("permissions-set", messagingChannelPlanShape("permissions-set", a, f, undefined), "convergent");
324
+ }
325
+ await c.messagingChannels.setPermissions(channelId, permissions);
326
+ return { updated: channelId, permissions, };
327
+ },
328
+ description: "Replace a messaging channel's permission list with a JSON array of {group|user, canUse} entries; read the current list with permissions-get, edit it, and send it back.",
329
+ examples: [
330
+ `dss messaging-channel permissions-set smtp-main --data '[{"group":"analysts","canUse":true}]' --dry-run`,
331
+ ],
332
+ },
333
+ delete: {
334
+ handler: async (c, a, f) => {
335
+ const channelId = channelIdArg(a, commandUsage(RESOURCE, "delete"));
336
+ if (executionMode(f).dryRun || f["if-exists"] === true) {
337
+ const current = await readIfExists(() => c.messagingChannels.get(channelId));
338
+ if (!current)
339
+ return skipResult(RESOURCE, channelId, "missing");
340
+ if (executionMode(f).dryRun) {
341
+ return dryRunPlan("delete", messagingChannelPlanShape("delete", a, f, undefined), "if-exists");
342
+ }
343
+ }
344
+ await c.messagingChannels.delete(channelId);
345
+ return { deleted: channelId, resource: RESOURCE, };
346
+ },
347
+ description: "Delete a messaging channel (admin). Scenario reporters that point at it stop delivering.",
348
+ examples: ["dss messaging-channel delete ops-shell --if-exists",],
349
+ },
350
+ send: {
351
+ handler: async (c, a, f) => {
352
+ const usage = commandUsage(RESOURCE, "send");
353
+ const input = sendInputFromFlags(a, f, usage);
354
+ const projectKey = c.resolveProjectKey(f["project-key"]);
355
+ if (executionMode(f).dryRun) {
356
+ // Attachments are the one input a dry run can still check without sending.
357
+ for (const attachment of input.attachments) {
358
+ await access(attachment.path).catch(() => {
359
+ throw new UsageError(`Attachment could not be read: ${attachment.path}`, "validation_failed", "Verify that the path names an existing, readable file.");
360
+ });
361
+ }
362
+ return dryRunPlan("send", messagingChannelPlanShape("send", a, f, projectKey), "none");
363
+ }
364
+ await c.messagingChannels.send(input.channelId, input.message, {
365
+ projectKey,
366
+ sendAsSingleMessage: input.sendAsSingleMessage,
367
+ attachments: input.attachments,
368
+ });
369
+ return {
370
+ sent: input.channelId,
371
+ projectKey,
372
+ to: input.message.to,
373
+ cc: input.message.cc ?? [],
374
+ bcc: input.message.bcc ?? [],
375
+ subject: input.message.subject,
376
+ attachments: input.attachments.map((attachment) => basename(attachment.path)),
377
+ sendAsSingleMessage: input.sendAsSingleMessage,
378
+ };
379
+ },
380
+ description: "Send an email through a mail channel (`list --family mail`) for a project (needs Write content). Delivered mail cannot be recalled: preview with --dry-run or --plan. Body is HTML unless --plain-text; --attachment takes comma-separated or repeated paths; one message per --to address unless --single-message.",
381
+ examples: [
382
+ 'dss messaging-channel send smtp-main --to a@example.com,b@example.com --subject "Nightly build" --body "<b>done</b>" --project-key MYPROJ --dry-run',
383
+ ],
384
+ },
385
+ });
@@ -1,4 +1,6 @@
1
1
  import { requiredJsonInput, } from "../coerce.js";
2
+ import { executionMode, } from "../flags.js";
3
+ import { planResult, } from "../output.js";
2
4
  import { commandUsage, withUsage, } from "../syntax.js";
3
5
  import { requireArgs, } from "../usage.js";
4
6
  export const projectDeployerCommands = withUsage("project-deployer", {
@@ -61,6 +63,37 @@ export const projectDeployerCommands = withUsage("project-deployer", {
61
63
  description: "Get a Project Deployer deployment's full health/status.",
62
64
  examples: ["dss project-deployer deployment-status my-deployment",],
63
65
  },
66
+ "testing-status": {
67
+ handler: (c, a, f) => {
68
+ requireArgs(a, 1, commandUsage("project-deployer", "testing-status"));
69
+ return c.projectDeployer.getTestingStatus(a[0], {
70
+ bundleId: f["bundle-id"],
71
+ automationNodeId: f["automation-node-id"],
72
+ });
73
+ },
74
+ description: "Get a deployment's testing status: the combined last-run outcome of its test scenarios (worst outcome wins). --bundle-id filters the runs to one bundle; --automation-node-id is needed for multi-node deployments only.",
75
+ examples: ["dss project-deployer testing-status my-deployment",],
76
+ },
77
+ "run-test-scenarios": {
78
+ handler: async (c, a, f) => {
79
+ requireArgs(a, 1, commandUsage("project-deployer", "run-test-scenarios"));
80
+ const automationNodeId = f["automation-node-id"];
81
+ if (executionMode(f).dryRun) {
82
+ return planResult("project-deployer", "run-test-scenarios", {
83
+ asyncKind: "none",
84
+ method: "POST",
85
+ endpoint: `/public/api/project-deployer/deployments/${encodeURIComponent(a[0])}/run-test-scenarios${automationNodeId ? `?automationNodeId=${encodeURIComponent(automationNodeId)}` : ""}`,
86
+ identifiers: { deploymentId: a[0], },
87
+ idempotency: "none",
88
+ exitCodesOnFailure: { usage: 1, error: 2, transient: 3, },
89
+ plannedAndDryRun: true,
90
+ });
91
+ }
92
+ return c.projectDeployer.runTestScenarios(a[0], { automationNodeId, });
93
+ },
94
+ description: "Run every test scenario of a deployment on its Automation node and return the resulting testing status. The request stays open until the scenarios finish, so raise --request-timeout for long suites. --automation-node-id is needed for multi-node deployments only; --dry-run previews the request.",
95
+ examples: ["dss project-deployer run-test-scenarios my-deployment --dry-run",],
96
+ },
64
97
  "save-deployment-settings": {
65
98
  handler: async (c, a, f) => {
66
99
  requireArgs(a, 1, commandUsage("project-deployer", "save-deployment-settings"));
@@ -184,6 +184,36 @@ export const projectCommands = withUsage("project", {
184
184
  description: "Export a project to a local archive (.zip). The written archive is inspected locally; credential-bearing structures (apikeys.json, secret-named variables, apiKey/token/password fields) raise an export_contains_credentials warning listing member paths and key names only, and the result reports credentialFindings. The archive is not redacted.",
185
185
  examples: ["dss project export MY_PROJ --output ./my_proj.zip",],
186
186
  },
187
+ "test-report": {
188
+ handler: async (c, _a, f) => {
189
+ const requested = f["format"] ?? "junit";
190
+ const format = ["junit", "html",].find((name) => name === requested);
191
+ if (format === undefined) {
192
+ throw new UsageError("--format must be junit or html.", "invalid_enum");
193
+ }
194
+ const options = { bundleId: f["bundle-id"], format, };
195
+ const projectKey = f["project-key"];
196
+ const out = f["output"];
197
+ if (out) {
198
+ const bytes = await c.projects.downloadTestScenarioRunsReport(out, projectKey, options);
199
+ return { path: out, bytes, format, };
200
+ }
201
+ if (format === "html") {
202
+ throw new UsageError("--output PATH is required for --format html (the page is not printed to stdout).", "missing_required_flag");
203
+ }
204
+ return { format, report: await c.projects.getTestScenarioRunsReport(projectKey, options), };
205
+ },
206
+ description: "Download the report of the latest test-scenario runs (scenarios marked as test): JUnit XML by default, an HTML page with --format html. Without --output the JUnit XML is returned in the `report` field; --output PATH writes the file instead (required for html). On an Automation node --bundle-id selects the bundle, otherwise the active bundle is reported.",
207
+ examples: [
208
+ "dss project test-report --project-key MYPROJ",
209
+ "dss project test-report --format html --output ./test-report.html --project-key MYPROJ",
210
+ ],
211
+ },
212
+ "testing-status": {
213
+ handler: (c, _a, f) => c.projects.getTestingStatus(f["project-key"], f["bundle-id"]),
214
+ description: "Get the testing status of a project: the worst outcome across the last runs of its test scenarios. On an Automation node --bundle-id restricts it to runs made while that bundle was active.",
215
+ examples: ["dss project testing-status --project-key MYPROJ",],
216
+ },
187
217
  import: {
188
218
  handler: async (c, a, f) => {
189
219
  const usage = commandUsage("project", "import");
@@ -412,9 +412,30 @@ export const recipeCommands = withUsage("recipe", {
412
412
  hint: `Fix the recipe settings, then run dss recipe update-schema ${createdName}.`,
413
413
  });
414
414
  }
415
- return { created: createdName, resource: "recipe", ...created, ...moved, };
415
+ // A visual recipe whose payload came from flags is ready to run: point at the build
416
+ // (recursive, so outputs of recipes created just before are built too).
417
+ const output = f["output"];
418
+ const next = typeof output === "string" && (created.payloadConfigured || created.joinConfigured)
419
+ ? [[
420
+ "dss",
421
+ "job",
422
+ "build-and-wait",
423
+ output,
424
+ "--build-mode",
425
+ "RECURSIVE_BUILD",
426
+ "--summary",
427
+ ...(pk ? ["--project-key", pk,] : []),
428
+ ],]
429
+ : undefined;
430
+ return {
431
+ created: createdName,
432
+ resource: "recipe",
433
+ ...created,
434
+ ...moved,
435
+ ...(next ? { next, } : {}),
436
+ };
416
437
  },
417
- description: "Create a recipe with optional inputs and a dataset or managed-folder output. Missing outputs are created on --output-connection as DSS managed datasets. Join, grouping (--group-by/--aggregate) and prepare (--rename/--formula/--filter/...) payloads come from flags. Visual recipe outputs get the DSS-computed schema (outputSchemaUpdated).",
438
+ description: "Create a recipe; missing outputs become managed datasets on --output-connection. Join, grouping (--group-by/--aggregate: keys plus those aggregates, no implicit count) and prepare (--rename/--formula/--filter/...) payloads come from flags, then `next` is the build. Visual outputs get the DSS-computed schema.",
418
439
  examples: [
419
440
  "dss recipe create --type python --input raw_orders,lookup --output orders_clean",
420
441
  "dss recipe create --type grouping --input orders --output orders_by_cat --group-by category --aggregate amount:sum+avg,name:first --order-by day",
@@ -1,2 +1,8 @@
1
1
  import type { CommandMeta } from "../types.js";
2
+ /** Run parameters from `--params JSON` or `--params-file PATH` (the POST /run body). */
3
+ export declare function scenarioRunParams(flags: Record<string, string | boolean>): Record<string, unknown> | undefined;
4
+ /** The trigger `trigger-add` appends: a raw `--data` object, or a schedule built from the --frequency flags. */
5
+ export declare function scenarioTriggerFromFlags(flags: Record<string, string | boolean>): Record<string, unknown>;
6
+ /** The reporter `reporter-add` appends: a raw `--data` object, or a mail reporter built from the --channel flags. */
7
+ export declare function scenarioReporterFromFlags(flags: Record<string, string | boolean>): Record<string, unknown>;
2
8
  export declare const scenarioCommands: Record<string, CommandMeta>;