dataiku-sdk 0.12.3 → 0.12.5

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 (84) hide show
  1. package/README.md +118 -7
  2. package/dist/packages/types/src/index.d.ts +5 -0
  3. package/dist/packages/types/src/index.js +5 -0
  4. package/dist/src/cli/coerce.d.ts +1 -3
  5. package/dist/src/cli/coerce.js +1 -17
  6. package/dist/src/cli/commands/app.js +709 -14
  7. package/dist/src/cli/commands/code-env.js +2 -2
  8. package/dist/src/cli/commands/dashboard.js +1 -1
  9. package/dist/src/cli/commands/dataset.js +2 -2
  10. package/dist/src/cli/commands/folder.js +1 -1
  11. package/dist/src/cli/commands/insight.js +2 -2
  12. package/dist/src/cli/commands/notebook.js +2 -2
  13. package/dist/src/cli/commands/recipe.js +3 -3
  14. package/dist/src/cli/commands/scenario.js +1 -1
  15. package/dist/src/cli/commands/sql.js +1 -1
  16. package/dist/src/cli/commands/variable.js +1 -1
  17. package/dist/src/cli/commands/wiki.js +1 -1
  18. package/dist/src/cli/contract.d.ts +12 -3
  19. package/dist/src/cli/contract.js +805 -49
  20. package/dist/src/cli/flags.js +8 -0
  21. package/dist/src/cli/helpers/app-permissions.d.ts +63 -0
  22. package/dist/src/cli/helpers/app-permissions.js +239 -0
  23. package/dist/src/cli/helpers/app-successor.d.ts +106 -0
  24. package/dist/src/cli/helpers/app-successor.js +625 -0
  25. package/dist/src/cli/helpers/cleanup.js +46 -12
  26. package/dist/src/cli/output.d.ts +12 -1
  27. package/dist/src/cli/output.js +53 -6
  28. package/dist/src/cli/usage.d.ts +1 -1
  29. package/dist/src/cli/usage.js +5 -2
  30. package/dist/src/cli.js +329 -32
  31. package/dist/src/client.d.ts +6 -0
  32. package/dist/src/client.js +28 -1
  33. package/dist/src/errors.d.ts +1 -1
  34. package/dist/src/resources/applications.d.ts +191 -1
  35. package/dist/src/resources/applications.js +549 -10
  36. package/dist/src/resources/datasets.js +8 -0
  37. package/dist/src/resources/futures.js +7 -2
  38. package/dist/src/skill.js +80 -15
  39. package/dist/src/utils/cleanup-ledger.d.ts +34 -1
  40. package/dist/src/utils/cleanup-ledger.js +118 -7
  41. package/dist/src/utils/dss-url.d.ts +6 -0
  42. package/dist/src/utils/dss-url.js +17 -0
  43. package/dist/src/utils/project-incarnation.d.ts +6 -0
  44. package/dist/src/utils/project-incarnation.js +27 -0
  45. package/dist/src/utils/stable-hash.d.ts +11 -0
  46. package/dist/src/utils/stable-hash.js +27 -0
  47. package/node_modules/@sinclair/typebox/build/cjs/compiler/compiler.js +26 -46
  48. package/node_modules/@sinclair/typebox/build/cjs/parser/runtime/guard.d.ts +1 -1
  49. package/node_modules/@sinclair/typebox/build/cjs/parser/runtime/token.d.ts +2 -2
  50. package/node_modules/@sinclair/typebox/build/cjs/syntax/mapping.d.ts +1 -1
  51. package/node_modules/@sinclair/typebox/build/cjs/system/evaluate.d.ts +5 -0
  52. package/node_modules/@sinclair/typebox/build/cjs/system/evaluate.js +11 -0
  53. package/node_modules/@sinclair/typebox/build/cjs/system/index.d.ts +1 -0
  54. package/node_modules/@sinclair/typebox/build/cjs/system/index.js +1 -0
  55. package/node_modules/@sinclair/typebox/build/cjs/type/object/object.d.ts +2 -2
  56. package/node_modules/@sinclair/typebox/build/cjs/type/object/object.js +18 -2
  57. package/node_modules/@sinclair/typebox/build/cjs/value/parse/parse.d.ts +1 -1
  58. package/node_modules/@sinclair/typebox/build/esm/compiler/compiler.mjs +27 -47
  59. package/node_modules/@sinclair/typebox/build/esm/parser/runtime/guard.d.mts +1 -1
  60. package/node_modules/@sinclair/typebox/build/esm/parser/runtime/token.d.mts +2 -2
  61. package/node_modules/@sinclair/typebox/build/esm/syntax/mapping.d.mts +1 -1
  62. package/node_modules/@sinclair/typebox/build/esm/system/evaluate.d.mts +5 -0
  63. package/node_modules/@sinclair/typebox/build/esm/system/evaluate.mjs +7 -0
  64. package/node_modules/@sinclair/typebox/build/esm/system/index.d.mts +1 -0
  65. package/node_modules/@sinclair/typebox/build/esm/system/index.mjs +1 -0
  66. package/node_modules/@sinclair/typebox/build/esm/type/composite/composite.mjs +2 -2
  67. package/node_modules/@sinclair/typebox/build/esm/type/const/const.mjs +3 -3
  68. package/node_modules/@sinclair/typebox/build/esm/type/instantiate/instantiate.mjs +2 -2
  69. package/node_modules/@sinclair/typebox/build/esm/type/mapped/mapped.mjs +3 -3
  70. package/node_modules/@sinclair/typebox/build/esm/type/module/compute.mjs +2 -2
  71. package/node_modules/@sinclair/typebox/build/esm/type/object/object.d.mts +2 -2
  72. package/node_modules/@sinclair/typebox/build/esm/type/object/object.mjs +18 -2
  73. package/node_modules/@sinclair/typebox/build/esm/type/omit/omit.mjs +3 -3
  74. package/node_modules/@sinclair/typebox/build/esm/type/partial/partial.mjs +3 -3
  75. package/node_modules/@sinclair/typebox/build/esm/type/pick/pick.mjs +3 -3
  76. package/node_modules/@sinclair/typebox/build/esm/type/record/record.mjs +3 -3
  77. package/node_modules/@sinclair/typebox/build/esm/type/required/required.mjs +3 -3
  78. package/node_modules/@sinclair/typebox/build/esm/type/type/json.mjs +2 -2
  79. package/node_modules/@sinclair/typebox/build/esm/value/delta/delta.mjs +4 -4
  80. package/node_modules/@sinclair/typebox/build/esm/value/parse/parse.d.mts +1 -1
  81. package/node_modules/@sinclair/typebox/package.json +1 -1
  82. package/package.json +7 -5
  83. package/packages/types/dist/index.d.ts +5 -0
  84. package/packages/types/dist/index.js +5 -0
package/dist/src/cli.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { num, unknownJsonInput, } from "./cli/coerce.js";
3
3
  import { commands, } from "./cli/commands/index.js";
4
- import { AGENT_CONTRACT_USAGE, AUTH_ACTIONS, BATCH_EXAMPLE_PAYLOAD, BATCH_HINT, BATCH_USAGE, buildAgentContract, buildCommandRegistry, buildMutationPlan, CLEANUP_USAGE, COMMANDS_USAGE, inferRequiresProject, isAllowedCleanupAction, supportsCleanupLedger, } from "./cli/contract.js";
4
+ import { AGENT_CONTRACT_USAGE, AUTH_ACTIONS, BATCH_EXAMPLE_PAYLOAD, BATCH_HINT, BATCH_PLAN_EXIT_CODES, BATCH_USAGE, buildAgentContract, buildCommandRegistry, buildMutationPlan, CLEANUP_USAGE, COMMANDS_USAGE, inferRequiresProject, isAllowedCleanupAction, supportsCleanupLedger, } from "./cli/contract.js";
5
5
  import { runDoctor, runFixtures, } from "./cli/doctor.js";
6
6
  import { dataikuEnvironmentEnabled, loadEnvFile, } from "./cli/env.js";
7
- import { FLAG_ALIASES, parseArgs, SHORT_FLAGS, VALUE_FLAGS, } from "./cli/flags.js";
7
+ import { FLAG_ALIASES, isNegativeNumberToken, parseArgs, SHORT_FLAGS, VALUE_FLAGS, } from "./cli/flags.js";
8
8
  import { cleanupLedgerEntry, } from "./cli/helpers/cleanup.js";
9
- import { commandFailureExitCode, CommandResultFailure, planResult, projectResultFields, setOutputFieldProjection, writeCommandResult, } from "./cli/output.js";
9
+ import { commandFailureExitCode, commandFailureMessage, CommandResultFailure, isFailedWaitResult, planResult, projectResultFields, setCompactJsonOutput, setOutputFieldProjection, writeCommandResult, } from "./cli/output.js";
10
10
  import { currentCommandContext, resolveCredentials, resolveTlsSettings, } from "./cli/runtime.js";
11
11
  import { COMMANDS_RUN_HINT, missingActionError, noCommandError, requireArgs, unknownActionError, unknownResourceError, UsageError, } from "./cli/usage.js";
12
12
  import { cliVersionResult, } from "./cli/version.js";
@@ -14,7 +14,8 @@ import { DataikuClient, } from "./client.js";
14
14
  import { getCredentialsPath, } from "./config.js";
15
15
  import { canonicalStatusText, ClientValidationError, DataikuError, dataikuErrorCode, } from "./errors.js";
16
16
  import { AGENTS, detectAgents, findWorkspaceRoot, installSkill, planSkillInstalls, } from "./skill.js";
17
- import { appendCleanupLedgerEntry, readCleanupLedger, } from "./utils/cleanup-ledger.js";
17
+ import { appendCleanupLedgerEntry, findCleanupLedgerBindingViolation, preflightCleanupLedgerPath, readCleanupLedger, reserveCleanupLedgerDssUrl, } from "./utils/cleanup-ledger.js";
18
+ import { canonicalDssUrl, } from "./utils/dss-url.js";
18
19
  // ---------------------------------------------------------------------------
19
20
  // Arg parsing
20
21
  // ---------------------------------------------------------------------------
@@ -30,6 +31,22 @@ import { appendCleanupLedgerEntry, readCleanupLedger, } from "./utils/cleanup-le
30
31
  // ---------------------------------------------------------------------------
31
32
  // Auth commands (run before client creation)
32
33
  // ---------------------------------------------------------------------------
34
+ function findUnboundAppCleanup(entries) {
35
+ for (const [index, entry,] of entries.entries()) {
36
+ const parsed = parseArgs(entry.cleanup.argv);
37
+ const [resource, action,] = parsed.positional;
38
+ if (resource !== "app" || action !== "delete-instance")
39
+ continue;
40
+ if (parsed.flags["unconfirmed-creation"] === true)
41
+ continue;
42
+ const expected = parsed.flags["expect-project-incarnation"];
43
+ if (typeof expected !== "string")
44
+ return { index, reason: "missing", };
45
+ if (!/^[0-9a-f]{64}$/.test(expected))
46
+ return { index, reason: "invalid", };
47
+ }
48
+ return undefined;
49
+ }
33
50
  async function runCleanup(flags) {
34
51
  const filePath = flags["file"];
35
52
  if (typeof filePath !== "string" || filePath.trim().length === 0) {
@@ -53,6 +70,7 @@ async function runCleanup(flags) {
53
70
  name: entry.name,
54
71
  path: entry.path,
55
72
  projectKey: entry.projectKey,
73
+ dssUrl: typeof entry.dssUrl === "string" ? canonicalDssUrl(entry.dssUrl) : null,
56
74
  cleanup: entry.cleanup,
57
75
  }));
58
76
  if (flags["apply"] !== true) {
@@ -77,6 +95,47 @@ async function runCleanup(flags) {
77
95
  tlsRejectUnauthorized,
78
96
  caCertPath,
79
97
  });
98
+ // Refuse before any DSS call when the ledger is not entirely bound to this
99
+ // server: a mixed ledger must never partially apply.
100
+ const bindingViolation = findCleanupLedgerBindingViolation(ordered, client.getBaseUrl());
101
+ if (bindingViolation) {
102
+ return {
103
+ result: {
104
+ applied: false,
105
+ steps,
106
+ bindingError: {
107
+ entryIndex: bindingViolation.index,
108
+ resource: bindingViolation.resource,
109
+ action: bindingViolation.action,
110
+ reason: bindingViolation.reason,
111
+ expectedDssUrl: client.getBaseUrl(),
112
+ ...(bindingViolation.found === undefined
113
+ ? {}
114
+ : { foundDssUrl: bindingViolation.found, }),
115
+ },
116
+ },
117
+ exitCode: 2,
118
+ };
119
+ }
120
+ // Lifecycle binding is as load-bearing as server binding. Validate the whole
121
+ // reversed plan before step one so a later legacy app entry cannot leave an
122
+ // earlier cleanup applied and then fail on project-key reuse protection.
123
+ const lifecycleViolation = findUnboundAppCleanup(ordered);
124
+ if (lifecycleViolation) {
125
+ return {
126
+ result: {
127
+ applied: false,
128
+ steps,
129
+ lifecycleError: {
130
+ entryIndex: lifecycleViolation.index,
131
+ resource: "app",
132
+ action: "delete-instance",
133
+ reason: lifecycleViolation.reason,
134
+ },
135
+ },
136
+ exitCode: 2,
137
+ };
138
+ }
80
139
  const applied = [];
81
140
  const failures = [];
82
141
  for (const [index, entry,] of ordered.entries()) {
@@ -87,7 +146,29 @@ async function runCleanup(flags) {
87
146
  || !commands[resource]?.[action]) {
88
147
  throw new UsageError(`Invalid cleanup argv: ${entry.cleanup.argv.join(" ")}`);
89
148
  }
149
+ if (resource === "app"
150
+ && action === "delete-instance"
151
+ && parsed.flags["unconfirmed-creation"] !== true
152
+ && typeof parsed.flags["expect-project-incarnation"] !== "string") {
153
+ throw new UsageError("App cleanup entry is not bound to a project incarnation. Refusing deletion after possible project-key reuse.", "validation_failed", "Capture a new cleanup entry from the current CLI. Legacy entries cannot safely delete app projects.");
154
+ }
90
155
  const result = await commands[resource][action].handler(client, args, parsed.flags);
156
+ if (isFailedWaitResult(result)) {
157
+ const failure = {
158
+ index,
159
+ cleanup: entry.cleanup,
160
+ error: commandFailureMessage(result),
161
+ result,
162
+ };
163
+ failures.push(failure);
164
+ if (flags["continue-on-error"] !== true) {
165
+ return {
166
+ result: { applied: true, steps, results: applied, failures, },
167
+ exitCode: 2,
168
+ };
169
+ }
170
+ continue;
171
+ }
91
172
  applied.push({ index, cleanup: entry.cleanup, result, });
92
173
  }
93
174
  catch (error) {
@@ -170,6 +251,25 @@ function supportedCommandFlags(entry) {
170
251
  }
171
252
  return supported;
172
253
  }
254
+ function scopedCommandDiscoveryHint(resource, action) {
255
+ return `Use \`dss commands run --fields ${resource}.${action} --json\` to list the flags this command supports.`;
256
+ }
257
+ /**
258
+ * Resolve the registry field selectors for `commands run --fields`. Omitting the flag
259
+ * selects the whole registry, but an explicitly supplied value carrying no selector is a
260
+ * usage error: an agent that asked for a scoped subset must never be handed the full
261
+ * registry instead.
262
+ */
263
+ function commandRegistrySelectors(flags) {
264
+ const fieldsFlag = flags["fields"];
265
+ if (typeof fieldsFlag !== "string")
266
+ return [];
267
+ const selectors = fieldsFlag.split(",").map((field) => field.trim()).filter((field) => field.length > 0);
268
+ if (selectors.length === 0) {
269
+ throw new UsageError("--fields requires at least one selector. Expected RESOURCE or RESOURCE.ACTION[.FIELD...].", "usage_error", COMMANDS_RUN_HINT, { fields: fieldsFlag, });
270
+ }
271
+ return selectors;
272
+ }
173
273
  function validateSupportedCommandFlags(resource, action, flags) {
174
274
  const entry = cachedCommandRegistry()[resource]?.[action];
175
275
  if (!entry)
@@ -177,9 +277,14 @@ function validateSupportedCommandFlags(resource, action, flags) {
177
277
  const supported = supportedCommandFlags(entry);
178
278
  for (const flagName of Object.keys(flags)) {
179
279
  if (supported[flagName] !== true) {
180
- throw new UsageError(`Unknown flag --${flagName} for ${resource} ${action}`, "unknown_flag");
280
+ throw new UsageError(`Unknown flag --${flagName} for ${resource} ${action}`, "unknown_flag", scopedCommandDiscoveryHint(resource, action));
181
281
  }
182
282
  }
283
+ if (flags["dry-run"] === true && !entry.dryRun) {
284
+ throw new UsageError(`--dry-run is not supported for ${resource} ${action}.`, "unknown_flag", entry.flags.some((flag) => flag.name === "plan")
285
+ ? "Remove --dry-run; use --plan to preview the mutation instead."
286
+ : `Remove --dry-run; ${resource} ${action} has no dry-run mode.`);
287
+ }
183
288
  }
184
289
  function stripOptionalUsageGroupsForValidation(usage) {
185
290
  let stripped = "";
@@ -207,23 +312,30 @@ function requiredPositionalCount(usage) {
207
312
  const positionalTokens = [...requiredUsage.matchAll(/<[^>]+>/g),];
208
313
  return positionalTokens.length;
209
314
  }
210
- function flagIsProvided(flags, name) {
315
+ function flagIsProvided(flags, name, allowEmpty = false) {
211
316
  const value = flags[name];
212
317
  if (value === undefined || value === false)
213
318
  return false;
214
- return typeof value !== "string" || value.length > 0;
319
+ if (typeof value !== "string")
320
+ return true;
321
+ return allowEmpty || value.length > 0;
215
322
  }
216
323
  function validateRequiredCommandInputs(resource, action, args, flags, entry) {
217
324
  const argCount = requiredPositionalCount(entry.usage);
325
+ const allowEmptyFlags = {};
326
+ for (const flag of entry.flags) {
327
+ if (flag.allowEmptyValue === true)
328
+ allowEmptyFlags[flag.name] = true;
329
+ }
218
330
  if (args.length < argCount)
219
331
  requireArgs(args, argCount, entry.usage);
220
332
  for (const flagName of entry.requiredFlags) {
221
- if (!flagIsProvided(flags, flagName)) {
333
+ if (!flagIsProvided(flags, flagName, allowEmptyFlags[flagName] === true)) {
222
334
  throw new UsageError(`--${flagName} is required. Usage: ${entry.usage}`, "missing_required_flag");
223
335
  }
224
336
  }
225
337
  for (const choice of entry.requiredOneOf ?? []) {
226
- const satisfied = choice.oneOf.some((alternative) => alternative.every((flagName) => flagIsProvided(flags, flagName)));
338
+ const satisfied = choice.oneOf.some((alternative) => alternative.every((flagName) => flagIsProvided(flags, flagName, allowEmptyFlags[flagName] === true)));
227
339
  if (!satisfied) {
228
340
  const alternatives = choice.oneOf.map((alternative) => alternative.map((flagName) => `--${flagName}`).join(" and ")).join(" or ");
229
341
  throw new UsageError(`One of ${alternatives} is required for ${resource} ${action}. Usage: ${entry.usage}`, "missing_required_flag");
@@ -307,6 +419,7 @@ function validateMetaCommandInputs(resource, action, flags) {
307
419
  throw missingActionError("commands", ["run",], COMMANDS_USAGE);
308
420
  if (action !== "run")
309
421
  throw unknownActionError("commands", action, ["run",]);
422
+ commandRegistrySelectors(flags);
310
423
  return action;
311
424
  }
312
425
  if (resource === "version") {
@@ -365,7 +478,9 @@ async function validateBatchStep(argv) {
365
478
  }
366
479
  function batchStepNeedsClient(argv) {
367
480
  try {
368
- const { positional, } = parseArgs(argv);
481
+ const { positional, flags, } = parseArgs(argv);
482
+ if (flags["plan"] === true)
483
+ return false;
369
484
  const resource = positional[0];
370
485
  if (!resource)
371
486
  return false;
@@ -401,6 +516,79 @@ function batchStepCommandContext(argv) {
401
516
  }
402
517
  return { resource: positionals[0], action: positionals[1], };
403
518
  }
519
+ const SENSITIVE_ARGV_VALUE_FLAGS = new Set([
520
+ "api-key",
521
+ ]);
522
+ function redactArgv(argv) {
523
+ const redacted = [];
524
+ for (let index = 0; index < argv.length; index++) {
525
+ const arg = argv[index];
526
+ if (arg === "--") {
527
+ redacted.push(...argv.slice(index));
528
+ break;
529
+ }
530
+ if (arg.startsWith("--")) {
531
+ const eqIdx = arg.indexOf("=");
532
+ const rawFlagName = eqIdx === -1 ? arg.slice(2) : arg.slice(2, eqIdx);
533
+ const flagName = FLAG_ALIASES[rawFlagName] ?? rawFlagName;
534
+ if (SENSITIVE_ARGV_VALUE_FLAGS.has(flagName)) {
535
+ if (eqIdx !== -1) {
536
+ redacted.push(`${arg.slice(0, eqIdx + 1)}***`);
537
+ }
538
+ else {
539
+ redacted.push(arg);
540
+ const next = argv[index + 1];
541
+ if (next !== undefined
542
+ && (next === "-" || !next.startsWith("-") || isNegativeNumberToken(next))) {
543
+ redacted.push("***");
544
+ index++;
545
+ }
546
+ }
547
+ continue;
548
+ }
549
+ }
550
+ redacted.push(arg);
551
+ }
552
+ return redacted;
553
+ }
554
+ function validateBatchStepMode(argv, index) {
555
+ let positional;
556
+ let flags;
557
+ try {
558
+ ({ positional, flags, } = parseArgs(argv));
559
+ }
560
+ catch {
561
+ return;
562
+ }
563
+ if (flags["plan"] !== true && flags["dry-run"] !== true)
564
+ return;
565
+ const resource = positional[0];
566
+ const action = positional[1];
567
+ if (!resource)
568
+ return;
569
+ if (META_COMMAND_RESOURCES[resource] === true)
570
+ return;
571
+ const resourceActions = commands[resource];
572
+ if (!resourceActions)
573
+ return;
574
+ const meta = resourceActions[action ?? ""];
575
+ if (!meta)
576
+ return;
577
+ const entry = cachedCommandRegistry()[resource]?.[action ?? ""];
578
+ if (!entry)
579
+ return;
580
+ if (flags["plan"] === true && !entry.flags.some((flag) => flag.name === "plan")) {
581
+ throw new UsageError(`Batch step ${index} requests --plan, which is not supported for ${resource} ${action}.`, "unknown_flag", "Remove --plan from the step; only mutating commands support --plan.");
582
+ }
583
+ if (flags["dry-run"] === true && !entry.dryRun) {
584
+ throw new UsageError(`Batch step ${index} requests --dry-run, which is not supported for ${resource} ${action}.`, "unknown_flag", "Remove --dry-run from the step.");
585
+ }
586
+ }
587
+ function validateBatchStepModes(steps) {
588
+ for (let index = 0; index < steps.length; index++) {
589
+ validateBatchStepMode(steps[index], index);
590
+ }
591
+ }
404
592
  function runInstallSkill(flags) {
405
593
  const agentFilter = typeof flags["agent"] === "string" ? flags["agent"] : undefined;
406
594
  const isGlobal = flags["global"] === true;
@@ -520,13 +708,13 @@ function batchPlan(flags) {
520
708
  needsClient: steps.some((argv) => batchStepNeedsClient(argv)),
521
709
  },
522
710
  payload: {
523
- steps,
711
+ steps: steps.map((argv) => redactArgv(argv)),
524
712
  continueOnError: flags["continue-on-error"] === true,
525
713
  dryRun: flags["dry-run"] === true,
526
714
  },
527
715
  idempotency: "none",
528
716
  asyncKind: "none",
529
- exitCodesOnFailure: META_PLAN_EXIT_CODES,
717
+ exitCodesOnFailure: BATCH_PLAN_EXIT_CODES,
530
718
  plannedAndDryRun: flags["dry-run"] === true,
531
719
  });
532
720
  }
@@ -575,7 +763,22 @@ async function runMetaCommand(resource, action, flags) {
575
763
  currentCommandContext.action = action;
576
764
  if (action !== "run")
577
765
  throw unknownActionError("commands", action, ["run",]);
578
- return { action, result: buildCommandRegistry(), exitCode: 0, };
766
+ const selectors = commandRegistrySelectors(flags);
767
+ const registry = buildCommandRegistry();
768
+ for (const selector of selectors) {
769
+ const selectorParts = selector.split(".");
770
+ if (selectorParts.some((part) => part.length === 0)) {
771
+ throw new UsageError(`Invalid --fields selector: ${selector}. Expected RESOURCE or RESOURCE.ACTION[.FIELD...].`, "usage_error", COMMANDS_USAGE, { selector, });
772
+ }
773
+ const [selectedResource, selectedAction,] = selectorParts;
774
+ const resourceActions = registry[selectedResource];
775
+ if (!resourceActions)
776
+ throw unknownResourceError(selectedResource);
777
+ if (selectedAction && !resourceActions[selectedAction]) {
778
+ throw unknownActionError(selectedResource, selectedAction, Object.keys(resourceActions));
779
+ }
780
+ }
781
+ return { action, result: registry, exitCode: 0, };
579
782
  }
580
783
  if (resource === "version") {
581
784
  if (action !== undefined && action !== "run") {
@@ -615,6 +818,82 @@ async function runMetaCommand(resource, action, flags) {
615
818
  }
616
819
  return undefined;
617
820
  }
821
+ /**
822
+ * Cleanup-ledger support is checked before a handler runs so an unsupported
823
+ * `--record-cleanup` fails without mutating DSS. Direct dispatch and batch steps
824
+ * share this guard.
825
+ */
826
+ function assertCleanupLedgerSupported(resource, action, flags) {
827
+ if (typeof flags["record-cleanup"] !== "string" || flags["dry-run"] === true)
828
+ return;
829
+ if (!supportsCleanupLedger(resource, action)) {
830
+ throw new UsageError(`--record-cleanup is not supported for ${resource} ${action}.`);
831
+ }
832
+ }
833
+ /**
834
+ * Preflight a requested cleanup ledger path before any remote mutation so an
835
+ * unwritable (or unwritable-to-be) ledger fails fast with no DSS-side change.
836
+ * Shares assertCleanupLedgerSupported's placement: direct dispatch and batch
837
+ * steps both call it, and dry-run/preview runs never touch the ledger.
838
+ */
839
+ async function preflightCleanupLedgerForFlags(client, flags) {
840
+ if (flags["dry-run"] === true)
841
+ return;
842
+ const ledgerPath = flags["record-cleanup"];
843
+ if (typeof ledgerPath !== "string")
844
+ return;
845
+ if (ledgerPath.trim().length === 0) {
846
+ throw new UsageError("--record-cleanup requires a non-empty file path.", "validation_failed");
847
+ }
848
+ const assertExistingEntriesBound = async () => {
849
+ let entries;
850
+ try {
851
+ entries = await readCleanupLedger(ledgerPath);
852
+ }
853
+ catch (error) {
854
+ if (error.code === "ENOENT")
855
+ return;
856
+ throw error;
857
+ }
858
+ const violation = findCleanupLedgerBindingViolation(entries, client.getBaseUrl());
859
+ if (violation) {
860
+ throw new UsageError(`Existing cleanup ledger entry ${violation.index + 1} is not bound to the configured DSS server.`, "validation_failed", "Use a new cleanup ledger path, or run the mutation against the DSS server already bound to this ledger.", {
861
+ entryIndex: violation.index,
862
+ resource: violation.resource,
863
+ action: violation.action,
864
+ reason: violation.reason,
865
+ });
866
+ }
867
+ };
868
+ try {
869
+ await preflightCleanupLedgerPath(ledgerPath);
870
+ await assertExistingEntriesBound();
871
+ await reserveCleanupLedgerDssUrl(ledgerPath, client.getBaseUrl());
872
+ // Re-read after winning the atomic reservation. This catches an entry
873
+ // appended between the initial compatibility check and reservation.
874
+ await assertExistingEntriesBound();
875
+ }
876
+ catch (error) {
877
+ if (error instanceof UsageError)
878
+ throw error;
879
+ throw new UsageError(`Could not preflight cleanup ledger: ${error instanceof Error ? error.message : String(error)}`, "validation_failed");
880
+ }
881
+ }
882
+ /**
883
+ * Append the cleanup entry for a completed handler result. Callers must pass the
884
+ * raw, unprojected result and must call this before any failure exit code is
885
+ * raised: an indeterminate post-POST outcome stays addressable only when the
886
+ * ledger records it first. Eligibility (dry runs, skipped work, an explicit
887
+ * `cleanupEligible:false`) is decided by `cleanupLedgerEntry`, never here.
888
+ */
889
+ async function recordCleanupLedgerEntry(client, resource, action, args, flags, result, projectKey) {
890
+ const ledgerPath = flags["record-cleanup"];
891
+ if (typeof ledgerPath !== "string" || flags["dry-run"] === true)
892
+ return;
893
+ const entry = cleanupLedgerEntry(resource, action, args, flags, result, projectKey);
894
+ if (entry)
895
+ await appendCleanupLedgerEntry(ledgerPath, entry, client.getBaseUrl());
896
+ }
618
897
  async function runBatch(flags) {
619
898
  const payload = unknownJsonInput(flags);
620
899
  if (payload === undefined) {
@@ -631,6 +910,7 @@ async function runBatch(flags) {
631
910
  const context = batchStepCommandContext(argv);
632
911
  Object.assign(currentCommandContext, { ...context, projectKey: batchProjectKey, });
633
912
  try {
913
+ validateBatchStepMode(argv, index);
634
914
  const step = await validateBatchStep(argv);
635
915
  Object.assign(currentCommandContext, {
636
916
  resource: step.resource,
@@ -639,13 +919,19 @@ async function runBatch(flags) {
639
919
  ? step.flags["project-key"]
640
920
  : batchProjectKey,
641
921
  });
642
- return { index, args: argv, resource: step.resource, action: step.action, runnable: true, };
922
+ return {
923
+ index,
924
+ args: redactArgv(argv),
925
+ resource: step.resource,
926
+ action: step.action,
927
+ runnable: true,
928
+ };
643
929
  }
644
930
  catch (error) {
645
931
  const envelope = buildErrorReport(error);
646
932
  return {
647
933
  index,
648
- args: argv,
934
+ args: redactArgv(argv),
649
935
  resource: context.resource,
650
936
  action: context.action,
651
937
  runnable: false,
@@ -658,6 +944,7 @@ async function runBatch(flags) {
658
944
  exitCode: planned.every((step) => step.runnable) ? 0 : 1,
659
945
  };
660
946
  }
947
+ validateBatchStepModes(steps);
661
948
  let projectKey = typeof flags["project-key"] === "string"
662
949
  ? flags["project-key"]
663
950
  : dataikuEnvironmentEnabled()
@@ -700,7 +987,7 @@ async function runBatch(flags) {
700
987
  let resource = context.resource;
701
988
  let action = context.action;
702
989
  if (firstFailureExit !== undefined && !continueOnError) {
703
- results.push({ index, args: argv, resource, action, ok: null, skipped: true, });
990
+ results.push({ index, args: redactArgv(argv), resource, action, ok: null, skipped: true, });
704
991
  continue;
705
992
  }
706
993
  Object.assign(currentCommandContext, {
@@ -742,10 +1029,19 @@ async function runBatch(flags) {
742
1029
  if (!meta)
743
1030
  throw unknownActionError(resource, action, Object.keys(resourceActions));
744
1031
  validateSupportedCommandFlags(resource, action, stepFlags);
745
- if (!client) {
746
- throw new UsageError("Missing Dataiku URL.", "missing_required_flag", "Set DATAIKU_URL or pass --url.", { requiredFlags: ["url",], env: ["DATAIKU_URL",], });
1032
+ if (stepFlags["plan"] === true) {
1033
+ result = buildMutationPlan(resource, action, meta, positional.slice(2), stepFlags);
1034
+ }
1035
+ else {
1036
+ if (!client) {
1037
+ throw new UsageError("Missing Dataiku URL.", "missing_required_flag", "Set DATAIKU_URL or pass --url.", { requiredFlags: ["url",], env: ["DATAIKU_URL",], });
1038
+ }
1039
+ assertCleanupLedgerSupported(resource, action, stepFlags);
1040
+ await preflightCleanupLedgerForFlags(client, stepFlags);
1041
+ const stepArgs = positional.slice(2);
1042
+ result = await meta.handler(client, stepArgs, stepFlags);
1043
+ await recordCleanupLedgerEntry(client, resource, action, stepArgs, stepFlags, result, projectKey);
747
1044
  }
748
- result = await meta.handler(client, positional.slice(2), stepFlags);
749
1045
  const failureExitCode = commandFailureExitCode(result);
750
1046
  if (failureExitCode !== undefined)
751
1047
  throw new CommandResultFailure(result, failureExitCode);
@@ -755,11 +1051,18 @@ async function runBatch(flags) {
755
1051
  ? stepFieldsFlag.split(",").map((field) => field.trim()).filter((field) => field.length > 0)
756
1052
  : [];
757
1053
  const stepResult = stepFields.length > 0 ? projectResultFields(result, stepFields) : result;
758
- results.push({ index, args: argv, resource, action, ok: true, result: stepResult, });
1054
+ results.push({
1055
+ index,
1056
+ args: redactArgv(argv),
1057
+ resource,
1058
+ action,
1059
+ ok: true,
1060
+ result: stepResult,
1061
+ });
759
1062
  }
760
1063
  catch (error) {
761
1064
  const envelope = buildErrorReport(error);
762
- results.push({ index, args: argv, resource, action, ok: false, error: envelope, });
1065
+ results.push({ index, args: redactArgv(argv), resource, action, ok: false, error: envelope, });
763
1066
  if (firstFailureExit === undefined)
764
1067
  firstFailureExit = envelope.exitCode;
765
1068
  }
@@ -901,7 +1204,7 @@ function buildErrorReport(err) {
901
1204
  type: "error",
902
1205
  ok: false,
903
1206
  error: err.message,
904
- code: "long_running_failure",
1207
+ code: err.code,
905
1208
  category: "dss",
906
1209
  exitCode: err.exitCode,
907
1210
  details: { result: err.result, },
@@ -973,6 +1276,7 @@ function writeErrorReport(err) {
973
1276
  async function main() {
974
1277
  loadEnvFile();
975
1278
  const { positional, flags, } = parseArgs(process.argv.slice(2));
1279
+ setCompactJsonOutput(flags["json"] === true);
976
1280
  const fieldsFlag = flags["fields"];
977
1281
  if (typeof fieldsFlag === "string") {
978
1282
  const selected = fieldsFlag.split(",").map((field) => field.trim()).filter((field) => field.length > 0);
@@ -1040,17 +1344,10 @@ async function main() {
1040
1344
  tlsRejectUnauthorized,
1041
1345
  caCertPath,
1042
1346
  });
1043
- if (typeof flags["record-cleanup"] === "string" && flags["dry-run"] !== true) {
1044
- if (!supportsCleanupLedger(resource, action)) {
1045
- throw new UsageError(`--record-cleanup is not supported for ${resource} ${action}.`);
1046
- }
1047
- }
1347
+ assertCleanupLedgerSupported(resource, action, flags);
1348
+ await preflightCleanupLedgerForFlags(client, flags);
1048
1349
  const result = await actionMeta.handler(client, args, flags);
1049
- if (typeof flags["record-cleanup"] === "string" && flags["dry-run"] !== true) {
1050
- const entry = cleanupLedgerEntry(resource, action, args, flags, result, projectKey);
1051
- if (entry)
1052
- await appendCleanupLedgerEntry(flags["record-cleanup"], entry);
1053
- }
1350
+ await recordCleanupLedgerEntry(client, resource, action, args, flags, result, projectKey);
1054
1351
  const failureExitCode = commandFailureExitCode(result);
1055
1352
  if (failureExitCode !== undefined)
1056
1353
  throw new CommandResultFailure(result, failureExitCode);
@@ -151,6 +151,12 @@ export declare class DataikuClient {
151
151
  get savedModels(): SavedModelsResource;
152
152
  get modelEvaluationStores(): ModelEvaluationStoresResource;
153
153
  constructor(config?: DataikuClientConfig);
154
+ /**
155
+ * Canonical request base URL: the exact prefix every HTTP verb concatenates
156
+ * paths onto (trimmed, trailing slashes stripped). Callers that must bind a
157
+ * recorded artifact to its originating DSS server compare against this.
158
+ */
159
+ getBaseUrl(): string;
154
160
  getRequestTimeoutMs(): number;
155
161
  resolveProjectKey(paramValue?: string): string;
156
162
  get<T = unknown>(path: string): Promise<T>;
@@ -2,7 +2,7 @@ import { readFileSync, } from "node:fs";
2
2
  import { getCACertificates, } from "node:tls";
3
3
  import { Value, } from "@sinclair/typebox/value";
4
4
  import { safeParseSchema, } from "./schemas.js";
5
- import { classifyDataikuError, DataikuError, } from "./errors.js";
5
+ import { classifyDataikuError, ClientValidationError, DataikuError, } from "./errors.js";
6
6
  import { AnalysesResource, } from "./resources/analyses.js";
7
7
  import { ApiDeployerResource, } from "./resources/api-deployer.js";
8
8
  import { ApiServicesResource, } from "./resources/api-services.js";
@@ -135,6 +135,22 @@ function buildFetchTlsOptions(config) {
135
135
  }
136
136
  return tls;
137
137
  }
138
+ /**
139
+ * True when the URL embeds userinfo (`https://user:password@host`). Embedded
140
+ * credentials are rejected up front so they can never reach the canonical base
141
+ * URL that gets persisted in recorded artifacts (cleanup ledgers, permission
142
+ * snapshots) or echoed in diagnostics. Malformed URLs fall through to the
143
+ * existing request-time failure.
144
+ */
145
+ function hasEmbeddedUserinfo(url) {
146
+ try {
147
+ const parsed = new URL(url);
148
+ return parsed.username !== "" || parsed.password !== "";
149
+ }
150
+ catch {
151
+ return false;
152
+ }
153
+ }
138
154
  /* ------------------------------------------------------------------ */
139
155
  /* Client */
140
156
  /* ------------------------------------------------------------------ */
@@ -297,6 +313,9 @@ export class DataikuClient {
297
313
  if (!url || !apiKey) {
298
314
  throw new Error("Dataiku URL and API key are required: pass {url, apiKey} or set DATAIKU_URL/DATAIKU_API_KEY");
299
315
  }
316
+ if (hasEmbeddedUserinfo(url)) {
317
+ throw new ClientValidationError("Dataiku URL must not contain embedded credentials (userinfo). Authenticate with an API key instead.", "validation_failed", "Pass the DSS base URL without a username or password.", { urlHasEmbeddedUserinfo: true, });
318
+ }
300
319
  this.baseUrl = url.replace(/\/+$/, "");
301
320
  this.apiKey = apiKey;
302
321
  this.defaultProjectKey = config?.projectKey?.trim() || undefined;
@@ -308,6 +327,14 @@ export class DataikuClient {
308
327
  this.onTrace = config?.onTrace ?? defaultTrace;
309
328
  this.onValidationWarning = config?.onValidationWarning ?? defaultValidationWarning;
310
329
  }
330
+ /**
331
+ * Canonical request base URL: the exact prefix every HTTP verb concatenates
332
+ * paths onto (trimmed, trailing slashes stripped). Callers that must bind a
333
+ * recorded artifact to its originating DSS server compare against this.
334
+ */
335
+ getBaseUrl() {
336
+ return this.baseUrl;
337
+ }
311
338
  getRequestTimeoutMs() {
312
339
  return this.requestTimeoutMs;
313
340
  }
@@ -1,5 +1,5 @@
1
1
  export type DataikuErrorCategory = "not_found" | "forbidden" | "validation" | "transient" | "unknown";
2
- export type StableErrorCode = "usage_error" | "unknown_flag" | "missing_required_arg" | "missing_required_flag" | "invalid_enum" | "not_found" | "permission_denied" | "validation_failed" | "transient" | "long_running_failure" | "internal_error";
2
+ export type StableErrorCode = "usage_error" | "unknown_flag" | "missing_required_arg" | "missing_required_flag" | "invalid_enum" | "not_found" | "permission_denied" | "validation_failed" | "transient" | "long_running_failure" | "command_result_failure" | "assertion_failed" | "ambiguous_outcome" | "internal_error";
3
3
  export declare function dataikuErrorCode(category: DataikuErrorCategory): StableErrorCode;
4
4
  export interface DataikuErrorTaxonomy {
5
5
  category: DataikuErrorCategory;