dataiku-sdk 0.1.0 → 0.1.2

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/bin/dss.js CHANGED
@@ -1,2 +1,4 @@
1
1
  #!/usr/bin/env bun
2
- import "../dist/src/cli.js";
2
+ import * as cli from "../dist/src/cli.js";
3
+
4
+ void cli;
@@ -298,8 +298,8 @@ export declare const JupyterNotebookSummarySchema: import("@sinclair/typebox").T
298
298
  language: import("@sinclair/typebox").TString;
299
299
  kernelSpec: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
300
300
  name: import("@sinclair/typebox").TString;
301
- display_name: import("@sinclair/typebox").TString;
302
- language: import("@sinclair/typebox").TString;
301
+ display_name: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
302
+ language: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
303
303
  }>>;
304
304
  }>;
305
305
  export type JupyterNotebookSummary = Static<typeof JupyterNotebookSummarySchema>;
@@ -438,8 +438,8 @@ export declare const JupyterNotebookSummaryArraySchema: import("@sinclair/typebo
438
438
  language: import("@sinclair/typebox").TString;
439
439
  kernelSpec: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
440
440
  name: import("@sinclair/typebox").TString;
441
- display_name: import("@sinclair/typebox").TString;
442
- language: import("@sinclair/typebox").TString;
441
+ display_name: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
442
+ language: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
443
443
  }>>;
444
444
  }>>;
445
445
  export declare const SqlNotebookSummaryArraySchema: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
@@ -306,8 +306,8 @@ export const JupyterNotebookSummarySchema = Type.Object({
306
306
  language: Type.String(),
307
307
  kernelSpec: Type.Optional(Type.Object({
308
308
  name: Type.String(),
309
- display_name: Type.String(),
310
- language: Type.String(),
309
+ display_name: Type.Optional(Type.String()),
310
+ language: Type.Optional(Type.String()),
311
311
  }, { additionalProperties: true, })),
312
312
  }, { additionalProperties: true, });
313
313
  export const JupyterNotebookContentSchema = Type.Object({
package/dist/src/cli.js CHANGED
@@ -18,6 +18,87 @@ function json(v) {
18
18
  return undefined;
19
19
  return JSON.parse(v);
20
20
  }
21
+ function jsonInput(flags) {
22
+ if (flags["stdin"] === true) {
23
+ return JSON.parse(readFileSync(0, "utf-8"));
24
+ }
25
+ if (typeof flags["data-file"] === "string") {
26
+ return JSON.parse(readFileSync(flags["data-file"], "utf-8"));
27
+ }
28
+ if (typeof flags["data"] === "string") {
29
+ return JSON.parse(flags["data"]);
30
+ }
31
+ return undefined;
32
+ }
33
+ async function resolveFolderId(client, nameOrId, flags) {
34
+ return client.folders.resolveId(nameOrId, flags["project-key"]);
35
+ }
36
+ function formatLineDiff(remoteName, localPath, remoteContent, localContent) {
37
+ if (localContent === remoteContent) {
38
+ return "No differences.";
39
+ }
40
+ const localLines = localContent.split("\n");
41
+ const remoteLines = remoteContent.split("\n");
42
+ const lines = [`--- remote:${remoteName}`, `+++ local:${localPath}`, "",];
43
+ const maxLen = Math.max(localLines.length, remoteLines.length);
44
+ for (let i = 0; i < maxLen; i++) {
45
+ const remoteLine = remoteLines[i];
46
+ const localLine = localLines[i];
47
+ if (remoteLine === localLine)
48
+ continue;
49
+ if (remoteLine !== undefined && localLine !== undefined) {
50
+ lines.push(`@@ line ${String(i + 1)} @@`);
51
+ lines.push(`- ${remoteLine}`);
52
+ lines.push(`+ ${localLine}`);
53
+ continue;
54
+ }
55
+ if (remoteLine !== undefined) {
56
+ lines.push(`- ${remoteLine}`);
57
+ continue;
58
+ }
59
+ lines.push(`+ ${localLine}`);
60
+ }
61
+ return lines.join("\n");
62
+ }
63
+ function parseOutputFormat(v) {
64
+ if (v === undefined)
65
+ return "json";
66
+ if (v === "json" || v === "quiet" || v === "tsv")
67
+ return v;
68
+ throw new UsageError(`Invalid --format value: ${String(v)}. Use json, tsv, or quiet.`);
69
+ }
70
+ function writeCommandResult(result, format) {
71
+ if (result === undefined || result === null) {
72
+ if (format !== "quiet") {
73
+ process.stdout.write(`${JSON.stringify({ ok: true, }, null, 2)}\n`);
74
+ }
75
+ return;
76
+ }
77
+ if (typeof result === "string") {
78
+ if (format !== "quiet") {
79
+ process.stdout.write(result);
80
+ if (!result.endsWith("\n"))
81
+ process.stdout.write("\n");
82
+ }
83
+ return;
84
+ }
85
+ if (format === "quiet")
86
+ return;
87
+ if (format === "tsv"
88
+ && Array.isArray(result)
89
+ && result.every((item) => item !== null && typeof item === "object" && !Array.isArray(item))) {
90
+ const items = result;
91
+ if (items.length === 0)
92
+ return;
93
+ const keys = Object.keys(items[0]);
94
+ process.stdout.write(`${keys.join("\t")}\n`);
95
+ for (const item of items) {
96
+ process.stdout.write(`${keys.map((key) => String(item[key] ?? "")).join("\t")}\n`);
97
+ }
98
+ return;
99
+ }
100
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
101
+ }
21
102
  function parseArgs(argv) {
22
103
  const positional = [];
23
104
  const flags = {};
@@ -142,14 +223,14 @@ const commands = {
142
223
  },
143
224
  update: {
144
225
  handler: (c, a, f) => {
145
- requireArgs(a, 1, "dss dataset update <name> --data '{...}'");
146
- const data = json(f["data"]);
226
+ requireArgs(a, 1, "dss dataset update <name> [--data '{...}' | --data-file PATH | --stdin]");
227
+ const data = jsonInput(f);
147
228
  if (!data) {
148
- throw new UsageError("--data is required. Usage: dss dataset update <name> --data '{...}'");
229
+ throw new UsageError("--data, --data-file, or --stdin is required. Usage: dss dataset update <name> [--data '{...}' | --data-file PATH | --stdin]");
149
230
  }
150
231
  return c.datasets.update(a[0], data, f["project-key"]);
151
232
  },
152
- usage: "dss dataset update <name> --data '{...}' [--project-key KEY]",
233
+ usage: "dss dataset update <name> [--data '{...}' | --data-file PATH | --stdin] [--project-key KEY]",
153
234
  },
154
235
  },
155
236
  recipe: {
@@ -178,37 +259,71 @@ const commands = {
178
259
  requireArgs(a, 1, "dss recipe download <name>");
179
260
  return c.recipes.download(a[0], {
180
261
  outputPath: f["output"],
262
+ projectKey: f["project-key"],
263
+ });
264
+ },
265
+ usage: "dss recipe download <name> [--output PATH] [--project-key KEY]",
266
+ },
267
+ "download-code": {
268
+ handler: (c, a, f) => {
269
+ requireArgs(a, 1, "dss recipe download-code <name>");
270
+ return c.recipes.downloadCode(a[0], {
271
+ outputPath: f["output"],
272
+ projectKey: f["project-key"],
181
273
  });
182
274
  },
183
- usage: "dss recipe download <name> [--output PATH]",
275
+ usage: "dss recipe download-code <name> [--output PATH] [--project-key KEY]",
184
276
  },
185
277
  create: {
186
278
  handler: (c, _a, f) => {
187
279
  const type = f["type"];
188
280
  if (!type) {
189
- throw new UsageError("--type is required. Usage: dss recipe create --type TYPE --input DS");
281
+ throw new UsageError("--type is required. Usage: dss recipe create --type TYPE --input DS --output DS");
282
+ }
283
+ const outputDataset = f["output"];
284
+ if (!outputDataset) {
285
+ throw new UsageError("--output is required. Usage: dss recipe create --type TYPE --input DS --output DS");
190
286
  }
191
287
  return c.recipes.create({
192
288
  type,
193
289
  name: f["name"],
194
290
  inputDatasets: f["input"] ? [f["input"],] : undefined,
195
- outputDataset: f["output"],
291
+ outputDataset,
196
292
  outputConnection: f["output-connection"],
197
293
  projectKey: f["project-key"],
198
294
  });
199
295
  },
200
- usage: "dss recipe create --type TYPE --input DS [--output DS] [--output-connection CONN] [--project-key KEY]",
296
+ usage: "dss recipe create --type TYPE --input DS --output DS [--output-connection CONN] [--project-key KEY]",
297
+ },
298
+ diff: {
299
+ handler: async (c, a, f) => {
300
+ requireArgs(a, 1, "dss recipe diff <name> --file PATH");
301
+ const filePath = f["file"];
302
+ if (!filePath) {
303
+ throw new UsageError("--file is required. Usage: dss recipe diff <name> --file PATH");
304
+ }
305
+ const result = await c.recipes.get(a[0], {
306
+ includePayload: true,
307
+ projectKey: f["project-key"],
308
+ });
309
+ if (!result.payload) {
310
+ throw new Error(`Recipe "${a[0]}" has no code payload to diff.`);
311
+ }
312
+ const localContent = readFileSync(filePath, "utf-8");
313
+ return formatLineDiff(a[0], filePath, result.payload, localContent);
314
+ },
315
+ usage: "dss recipe diff <name> --file PATH [--project-key KEY]",
201
316
  },
202
317
  update: {
203
318
  handler: (c, a, f) => {
204
- requireArgs(a, 1, "dss recipe update <name> --data '{...}'");
205
- const data = json(f["data"]);
319
+ requireArgs(a, 1, "dss recipe update <name> [--data '{...}' | --data-file PATH | --stdin]");
320
+ const data = jsonInput(f);
206
321
  if (!data) {
207
- throw new UsageError("--data is required. Usage: dss recipe update <name> --data '{...}'");
322
+ throw new UsageError("--data, --data-file, or --stdin is required. Usage: dss recipe update <name> [--data '{...}' | --data-file PATH | --stdin]");
208
323
  }
209
324
  return c.recipes.update(a[0], data, f["project-key"]);
210
325
  },
211
- usage: "dss recipe update <name> --data '{...}' [--project-key KEY]",
326
+ usage: "dss recipe update <name> [--data '{...}' | --data-file PATH | --stdin] [--project-key KEY]",
212
327
  },
213
328
  },
214
329
  job: {
@@ -316,14 +431,14 @@ const commands = {
316
431
  },
317
432
  update: {
318
433
  handler: (c, a, f) => {
319
- requireArgs(a, 1, "dss scenario update <id> --data '{...}'");
320
- const data = json(f["data"]);
434
+ requireArgs(a, 1, "dss scenario update <id> [--data '{...}' | --data-file PATH | --stdin]");
435
+ const data = jsonInput(f);
321
436
  if (!data) {
322
- throw new UsageError("--data is required. Usage: dss scenario update <id> --data '{...}'");
437
+ throw new UsageError("--data, --data-file, or --stdin is required. Usage: dss scenario update <id> [--data '{...}' | --data-file PATH | --stdin]");
323
438
  }
324
439
  return c.scenarios.update(a[0], data, f["project-key"]);
325
440
  },
326
- usage: "dss scenario update <id> --data '{...}' [--project-key KEY]",
441
+ usage: "dss scenario update <id> [--data '{...}' | --data-file PATH | --stdin] [--project-key KEY]",
327
442
  },
328
443
  },
329
444
  folder: {
@@ -332,41 +447,44 @@ const commands = {
332
447
  usage: "dss folder list [--project-key KEY]",
333
448
  },
334
449
  get: {
335
- handler: (c, a, f) => {
336
- requireArgs(a, 1, "dss folder get <id>");
337
- return c.folders.get(a[0], f["project-key"]);
450
+ handler: async (c, a, f) => {
451
+ requireArgs(a, 1, "dss folder get <name-or-id>");
452
+ return c.folders.get(await resolveFolderId(c, a[0], f), f["project-key"]);
338
453
  },
339
- usage: "dss folder get <id> [--project-key KEY]",
454
+ usage: "dss folder get <name-or-id> [--project-key KEY]",
340
455
  },
341
456
  contents: {
342
- handler: (c, a, _f) => {
343
- requireArgs(a, 1, "dss folder contents <id>");
344
- return c.folders.contents(a[0]);
457
+ handler: async (c, a, f) => {
458
+ requireArgs(a, 1, "dss folder contents <name-or-id>");
459
+ return c.folders.contents(await resolveFolderId(c, a[0], f), {
460
+ projectKey: f["project-key"],
461
+ });
345
462
  },
346
- usage: "dss folder contents <id>",
463
+ usage: "dss folder contents <name-or-id> [--project-key KEY]",
347
464
  },
348
465
  download: {
349
- handler: (c, a, f) => {
350
- requireArgs(a, 2, "dss folder download <id> <path>");
351
- return c.folders.download(a[0], a[1], {
466
+ handler: async (c, a, f) => {
467
+ requireArgs(a, 2, "dss folder download <name-or-id> <path>");
468
+ return c.folders.download(await resolveFolderId(c, a[0], f), a[1], {
352
469
  localPath: f["output"],
470
+ projectKey: f["project-key"],
353
471
  });
354
472
  },
355
- usage: "dss folder download <id> <path> [--output PATH]",
473
+ usage: "dss folder download <name-or-id> <path> [--output PATH] [--project-key KEY]",
356
474
  },
357
475
  upload: {
358
- handler: (c, a, f) => {
359
- requireArgs(a, 3, "dss folder upload <id> <path> <localPath>");
360
- return c.folders.upload(a[0], a[1], a[2], f["project-key"]);
476
+ handler: async (c, a, f) => {
477
+ requireArgs(a, 3, "dss folder upload <name-or-id> <path> <localPath>");
478
+ return c.folders.upload(await resolveFolderId(c, a[0], f), a[1], a[2], f["project-key"]);
361
479
  },
362
- usage: "dss folder upload <id> <path> <localPath> [--project-key KEY]",
480
+ usage: "dss folder upload <name-or-id> <path> <localPath> [--project-key KEY]",
363
481
  },
364
482
  "delete-file": {
365
- handler: (c, a, f) => {
366
- requireArgs(a, 2, "dss folder delete-file <id> <path>");
367
- return c.folders.deleteFile(a[0], a[1], f["project-key"]);
483
+ handler: async (c, a, f) => {
484
+ requireArgs(a, 2, "dss folder delete-file <name-or-id> <path>");
485
+ return c.folders.deleteFile(await resolveFolderId(c, a[0], f), a[1], f["project-key"]);
368
486
  },
369
- usage: "dss folder delete-file <id> <path> [--project-key KEY]",
487
+ usage: "dss folder delete-file <name-or-id> <path> [--project-key KEY]",
370
488
  },
371
489
  },
372
490
  variable: {
@@ -378,8 +496,10 @@ const commands = {
378
496
  handler: (c, _a, f) => c.variables.set({
379
497
  standard: json(f["standard"]),
380
498
  local: json(f["local"]),
499
+ replace: f["replace"] === true,
500
+ projectKey: f["project-key"],
381
501
  }),
382
- usage: 'dss variable set --standard \'{"k":"v"}\' --local \'{"k":"v"}\'',
502
+ usage: 'dss variable set --standard \'{"k":"v"}\' --local \'{"k":"v"}\' [--replace] [--project-key KEY]',
383
503
  },
384
504
  },
385
505
  connection: {
@@ -492,23 +612,25 @@ const commands = {
492
612
  },
493
613
  "save-jupyter": {
494
614
  handler: (c, a, f) => {
495
- requireArgs(a, 1, "dss notebook save-jupyter <name> --data '{...}'");
496
- const data = json(f["data"]);
497
- if (!data)
498
- throw new UsageError("--data is required (notebook JSON content)");
615
+ requireArgs(a, 1, "dss notebook save-jupyter <name> [--data '{...}' | --data-file PATH | --stdin]");
616
+ const data = jsonInput(f);
617
+ if (!data) {
618
+ throw new UsageError("--data, --data-file, or --stdin is required (notebook JSON content).");
619
+ }
499
620
  return c.notebooks.saveJupyter(a[0], data, f["project-key"]);
500
621
  },
501
- usage: "dss notebook save-jupyter <name> --data '{...}' [--project-key KEY]",
622
+ usage: "dss notebook save-jupyter <name> [--data '{...}' | --data-file PATH | --stdin] [--project-key KEY]",
502
623
  },
503
624
  "save-sql": {
504
625
  handler: (c, a, f) => {
505
- requireArgs(a, 1, "dss notebook save-sql <id> --data '{...}'");
506
- const data = json(f["data"]);
507
- if (!data)
508
- throw new UsageError("--data is required (SQL notebook content JSON)");
626
+ requireArgs(a, 1, "dss notebook save-sql <id> [--data '{...}' | --data-file PATH | --stdin]");
627
+ const data = jsonInput(f);
628
+ if (!data) {
629
+ throw new UsageError("--data, --data-file, or --stdin is required (SQL notebook content JSON).");
630
+ }
509
631
  return c.notebooks.saveSql(a[0], data, f["project-key"]);
510
632
  },
511
- usage: "dss notebook save-sql <id> --data '{...}' [--project-key KEY]",
633
+ usage: "dss notebook save-sql <id> [--data '{...}' | --data-file PATH | --stdin] [--project-key KEY]",
512
634
  },
513
635
  "clear-sql-history": {
514
636
  handler: (c, a, f) => {
@@ -535,6 +657,8 @@ function printTopLevelHelp() {
535
657
  " --url URL Dataiku DSS base URL (env: DATAIKU_URL)",
536
658
  " --api-key KEY API key (env: DATAIKU_API_KEY)",
537
659
  " --project-key KEY Default project key (env: DATAIKU_PROJECT_KEY)",
660
+ " --format FORMAT Output format: json|tsv|quiet",
661
+ " --verbose Log HTTP requests to stderr",
538
662
  " --help Show help",
539
663
  "",
540
664
  "Resources:",
@@ -662,10 +786,12 @@ async function main() {
662
786
  url,
663
787
  apiKey,
664
788
  projectKey: flags["project-key"] ?? process.env.DATAIKU_PROJECT_KEY,
789
+ verbose: flags["verbose"] === true,
665
790
  });
666
791
  const args = positional.slice(2);
792
+ const format = parseOutputFormat(flags["format"]);
667
793
  const result = await actionMeta.handler(client, args, flags);
668
- process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
794
+ writeCommandResult(result, format);
669
795
  }
670
796
  main().catch((err) => {
671
797
  if (err instanceof UsageError) {
@@ -681,7 +807,7 @@ main().catch((err) => {
681
807
  if (err.retryHint)
682
808
  payload.retryHint = err.retryHint;
683
809
  process.stderr.write(`${JSON.stringify(payload, null, 2)}\n`);
684
- process.exit(1);
810
+ process.exit(err.category === "transient" ? 3 : 2);
685
811
  }
686
812
  const message = err instanceof Error ? err.message : String(err);
687
813
  process.stderr.write(`${JSON.stringify({ error: message, }, null, 2)}\n`);
@@ -21,9 +21,11 @@ export interface DataikuClientConfig {
21
21
  requestTimeoutMs?: number;
22
22
  /** Max retry attempts for idempotent requests (default 4, capped at 10) */
23
23
  retryMaxAttempts?: number;
24
+ /** Emit HTTP request/response logs to stderr for CLI debugging. */
25
+ verbose?: boolean;
24
26
  /**
25
27
  * Called when an API response fails schema validation but data is still usable.
26
- * Default: logs to console.warn. Set to a throwing function for strict mode.
28
+ * Default: writes to stderr. Set to a throwing function for strict mode.
27
29
  * @param method - resource method that triggered the warning (e.g. "datasets.list")
28
30
  * @param errors - human-readable validation error strings
29
31
  */
@@ -35,6 +37,7 @@ export declare class DataikuClient {
35
37
  private readonly defaultProjectKey;
36
38
  private readonly requestTimeoutMs;
37
39
  private readonly retryMaxAttempts;
40
+ private readonly verbose;
38
41
  private readonly onValidationWarning;
39
42
  private _projects?;
40
43
  private _datasets?;
@@ -70,6 +73,7 @@ export declare class DataikuClient {
70
73
  stream(path: string): Promise<Response>;
71
74
  private getHeaders;
72
75
  private getAnyHeaders;
76
+ private logVerbose;
73
77
  /**
74
78
  * Validate raw data against a TypeBox schema, throwing on structural mismatch.
75
79
  * Resources call this instead of bare `as T` casts for validated responses.
@@ -24,7 +24,7 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
24
24
  /* Helpers */
25
25
  /* ------------------------------------------------------------------ */
26
26
  function defaultValidationWarning(method, errors) {
27
- console.warn(`[dataiku-sdk] Schema validation warning in ${method}:\n ${errors.join("\n ")}`);
27
+ process.stderr.write(`[dataiku-sdk] Schema validation warning in ${method}:\n ${errors.join("\n ")}\n`);
28
28
  }
29
29
  function sleep(ms) {
30
30
  return new Promise((r) => setTimeout(r, ms));
@@ -59,6 +59,7 @@ export class DataikuClient {
59
59
  defaultProjectKey;
60
60
  requestTimeoutMs;
61
61
  retryMaxAttempts;
62
+ verbose;
62
63
  onValidationWarning;
63
64
  /* Resource namespaces — lazily initialized to break circular imports */
64
65
  _projects;
@@ -118,6 +119,7 @@ export class DataikuClient {
118
119
  this.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
119
120
  const rawMax = config.retryMaxAttempts ?? DEFAULT_RETRY_MAX_ATTEMPTS;
120
121
  this.retryMaxAttempts = Math.min(Math.max(1, rawMax), MAX_RETRY_ATTEMPTS_CAP);
122
+ this.verbose = config.verbose === true;
121
123
  this.onValidationWarning = config.onValidationWarning ?? defaultValidationWarning;
122
124
  }
123
125
  /* ---- public: project key resolution ---- */
@@ -206,6 +208,10 @@ export class DataikuClient {
206
208
  Accept: "*/*",
207
209
  };
208
210
  }
211
+ logVerbose(message) {
212
+ if (this.verbose)
213
+ process.stderr.write(`[dss] ${message}\n`);
214
+ }
209
215
  /* ---- public: schema-validated parsing ---- */
210
216
  /**
211
217
  * Validate raw data against a TypeBox schema, throwing on structural mismatch.
@@ -235,6 +241,9 @@ export class DataikuClient {
235
241
  /* ---- private: JSON parsing ---- */
236
242
  async parseJsonResponse(res) {
237
243
  const text = await res.text();
244
+ // SAFETY: Empty 2xx responses from DSS are surfaced to callers as undefined
245
+ // cast to T. This keeps existing call sites stable, but callers that rely on
246
+ // an object shape must guard explicitly before dereferencing the result.
238
247
  if (!text)
239
248
  return undefined;
240
249
  try {
@@ -253,13 +262,16 @@ export class DataikuClient {
253
262
  const delaysMs = [];
254
263
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
255
264
  let timedOut = false;
265
+ const startedAt = Date.now();
256
266
  const controller = new AbortController();
257
267
  const timeout = setTimeout(() => {
258
268
  timedOut = true;
259
269
  controller.abort();
260
270
  }, this.requestTimeoutMs);
271
+ this.logVerbose(`${method} ${url}`);
261
272
  try {
262
273
  const res = await fetch(url, { ...init, method, signal: controller.signal, });
274
+ this.logVerbose(`${method} ${url} → ${res.status} (${Date.now() - startedAt}ms)`);
263
275
  if (!res.ok) {
264
276
  const text = await res.text();
265
277
  const canRetry = retryEnabled && attempt < maxAttempts && isTransientError(res.status, text);
@@ -288,6 +300,7 @@ export class DataikuClient {
288
300
  : error instanceof Error
289
301
  ? error.message
290
302
  : "Unknown transport error";
303
+ this.logVerbose(`${method} ${url} → ERROR (${Date.now() - startedAt}ms) ${detail}`);
291
304
  const statusText = timedOut ? "Request Timeout" : "Network Error";
292
305
  throw new DataikuError(0, statusText, detail, buildRetryMetadata(method, retryEnabled, maxAttempts, attempt, delaysMs, timedOut));
293
306
  }
@@ -39,6 +39,18 @@ export function classifyDataikuError(status, body) {
39
39
  retryHint: "Request appears invalid for this endpoint. Fix parameters/payload before retrying.",
40
40
  };
41
41
  }
42
+ const isServerPermissionLike = status >= 500
43
+ && (lowerBody.includes("not allowed to access")
44
+ || lowerBody.includes("access denied")
45
+ || (lowerBody.includes("permission")
46
+ && (lowerBody.includes("cannot use") || lowerBody.includes("not allowed"))));
47
+ if (isServerPermissionLike) {
48
+ return {
49
+ category: "forbidden",
50
+ retryable: false,
51
+ retryHint: "Check API key validity and project permissions for the requested action.",
52
+ };
53
+ }
42
54
  if (status === 404) {
43
55
  const isHtmlGatewayResponse = lowerBody.includes("<!doctype html>");
44
56
  return {
@@ -9,7 +9,7 @@ export declare class ConnectionsResource extends BaseResource {
9
9
  * Infers available connections.
10
10
  *
11
11
  * - fast (default): fetches the connection name list and maps to ConnectionSummary.
12
- * Falls back to rich mode on any failure.
12
+ * Falls back to rich mode on any failure or empty result set.
13
13
  * - rich: inspects project datasets to derive connection metadata
14
14
  * (types, managed flag, db schemas).
15
15
  */
@@ -55,7 +55,7 @@ export class ConnectionsResource extends BaseResource {
55
55
  * Infers available connections.
56
56
  *
57
57
  * - fast (default): fetches the connection name list and maps to ConnectionSummary.
58
- * Falls back to rich mode on any failure.
58
+ * Falls back to rich mode on any failure or empty result set.
59
59
  * - rich: inspects project datasets to derive connection metadata
60
60
  * (types, managed flag, db schemas).
61
61
  */
@@ -65,13 +65,16 @@ export class ConnectionsResource extends BaseResource {
65
65
  if (mode === "rich") {
66
66
  return inferRichConnectionsFromDatasets(this.client, projectEnc);
67
67
  }
68
- // fast — attempt name list, fall back to rich on any error
68
+ // fast — attempt name list, fall back to rich on any error or empty result
69
69
  try {
70
70
  const names = await this.list();
71
- return names.map((name) => ({ name, }));
71
+ if (names.length > 0) {
72
+ return names.map((name) => ({ name, }));
73
+ }
72
74
  }
73
75
  catch {
74
- return inferRichConnectionsFromDatasets(this.client, projectEnc);
76
+ // Fall through to rich inference.
75
77
  }
78
+ return inferRichConnectionsFromDatasets(this.client, projectEnc);
76
79
  }
77
80
  }
@@ -346,11 +346,17 @@ export class DatasetsResource extends BaseResource {
346
346
  }
347
347
  }
348
348
  : undefined;
349
+ const shouldGzip = filePath.endsWith(".gz");
349
350
  const nodeStream = Readable.fromWeb(res.body);
350
351
  const csvTransform = tsvToCsvTransform(downloadLimit, onHeader);
351
- const gzip = createGzip();
352
352
  const fileOut = createWriteStream(filePath);
353
- await pipeline(nodeStream, csvTransform, gzip, fileOut);
353
+ if (shouldGzip) {
354
+ const gzip = createGzip();
355
+ await pipeline(nodeStream, csvTransform, gzip, fileOut);
356
+ }
357
+ else {
358
+ await pipeline(nodeStream, csvTransform, fileOut);
359
+ }
354
360
  return filePath;
355
361
  }
356
362
  /**
@@ -2,6 +2,7 @@ import type { FolderDetails, FolderItem, FolderSummary } from "../schemas.js";
2
2
  import { BaseResource } from "./base.js";
3
3
  export declare class FoldersResource extends BaseResource {
4
4
  list(projectKey?: string): Promise<FolderSummary[]>;
5
+ resolveId(nameOrId: string, projectKey?: string): Promise<string>;
5
6
  get(folderId: string, projectKey?: string): Promise<FolderDetails>;
6
7
  contents(folderId: string, opts?: {
7
8
  projectKey?: string;
@@ -21,6 +21,14 @@ export class FoldersResource extends BaseResource {
21
21
  const raw = await this.client.get(`/public/api/projects/${this.enc(projectKey)}/managedfolders/`);
22
22
  return this.client.safeParse(FolderSummaryArraySchema, raw, "folders.list");
23
23
  }
24
+ async resolveId(nameOrId, projectKey) {
25
+ const folders = await this.list(projectKey);
26
+ if (folders.some((folder) => folder.id === nameOrId)) {
27
+ return nameOrId;
28
+ }
29
+ const match = folders.find((folder) => folder.name === nameOrId);
30
+ return match?.id ?? nameOrId;
31
+ }
24
32
  async get(folderId, projectKey) {
25
33
  const fEnc = encodeURIComponent(folderId);
26
34
  const raw = await this.client.get(`/public/api/projects/${this.enc(projectKey)}/managedfolders/${fEnc}`);
@@ -18,7 +18,8 @@ export declare class JobsResource extends BaseResource {
18
18
  get(jobId: string, projectKey?: string): Promise<Record<string, unknown>>;
19
19
  /**
20
20
  * Retrieve job log text.
21
- * Returns the last `maxLogLines` lines (default 50) from the tail.
21
+ * Returns the last `maxLogLines` lines (default 500) from the tail.
22
+ * Use `0` or `-1` to return the full log without truncation.
22
23
  */
23
24
  log(jobId: string, opts?: {
24
25
  activity?: string;
@@ -3,7 +3,7 @@ import { BaseResource, } from "./base.js";
3
3
  const DEFAULT_POLL_INTERVAL_MS = 2_000;
4
4
  const MAX_POLL_INTERVAL_MS = 10_000;
5
5
  const DEFAULT_TIMEOUT_MS = 120_000;
6
- const DEFAULT_MAX_LOG_LINES = 50;
6
+ const DEFAULT_MAX_LOG_LINES = 500;
7
7
  const TERMINAL_STATES = new Set([
8
8
  "DONE",
9
9
  "FAILED",
@@ -49,7 +49,8 @@ export class JobsResource extends BaseResource {
49
49
  }
50
50
  /**
51
51
  * Retrieve job log text.
52
- * Returns the last `maxLogLines` lines (default 50) from the tail.
52
+ * Returns the last `maxLogLines` lines (default 500) from the tail.
53
+ * Use `0` or `-1` to return the full log without truncation.
53
54
  */
54
55
  async log(jobId, opts) {
55
56
  const jobEnc = encodeURIComponent(jobId);
@@ -57,8 +58,11 @@ export class JobsResource extends BaseResource {
57
58
  const log = await this.client.getText(`/public/api/projects/${this.enc(opts?.projectKey)}/jobs/${jobEnc}/log/${query}`);
58
59
  if (!log)
59
60
  return "";
60
- const lines = log.split("\n");
61
61
  const limit = opts?.maxLogLines ?? DEFAULT_MAX_LOG_LINES;
62
+ if (limit === 0 || limit === -1) {
63
+ return log;
64
+ }
65
+ const lines = log.split("\n");
62
66
  if (lines.length > limit) {
63
67
  return lines.slice(-limit).join("\n");
64
68
  }
@@ -7,13 +7,28 @@ export declare class NotebooksResource extends BaseResource {
7
7
  getJupyter(name: string, projectKey?: string): Promise<JupyterNotebookContent>;
8
8
  /** Save (overwrite) a Jupyter notebook's content. */
9
9
  saveJupyter(name: string, content: JupyterNotebookContent, projectKey?: string): Promise<void>;
10
- /** Delete a Jupyter notebook. */
10
+ /**
11
+ * Delete a Jupyter notebook.
12
+ *
13
+ * DSS public APIs can delete notebooks but do not expose notebook creation, so
14
+ * this can only target notebooks created outside this SDK (for example in the UI).
15
+ */
11
16
  deleteJupyter(name: string, projectKey?: string): Promise<void>;
12
- /** Clear all cell outputs from a Jupyter notebook. */
17
+ /**
18
+ * Clear all cell outputs from a Jupyter notebook.
19
+ *
20
+ * DSS public APIs do not expose a dedicated clear-outputs endpoint, so this
21
+ * method fetches the notebook, strips outputs locally, and saves it back.
22
+ */
13
23
  clearJupyterOutputs(name: string, projectKey?: string): Promise<void>;
14
24
  /** List running kernel sessions for a Jupyter notebook. */
15
25
  listJupyterSessions(name: string, projectKey?: string): Promise<NotebookSession[]>;
16
- /** Unload (stop) a running Jupyter notebook session. */
26
+ /**
27
+ * Unload (stop) a running Jupyter notebook session.
28
+ *
29
+ * DSS public APIs do not expose notebook or session creation, so this only
30
+ * works for sessions started outside this SDK.
31
+ */
17
32
  unloadJupyter(name: string, sessionId: string, projectKey?: string): Promise<void>;
18
33
  /** List all SQL notebooks in a project. */
19
34
  listSql(projectKey?: string): Promise<SqlNotebookSummary[]>;
@@ -18,15 +18,30 @@ export class NotebooksResource extends BaseResource {
18
18
  const nameEnc = encodeURIComponent(name);
19
19
  await this.client.putVoid(`/public/api/projects/${this.enc(projectKey)}/jupyter-notebooks/${nameEnc}`, content);
20
20
  }
21
- /** Delete a Jupyter notebook. */
21
+ /**
22
+ * Delete a Jupyter notebook.
23
+ *
24
+ * DSS public APIs can delete notebooks but do not expose notebook creation, so
25
+ * this can only target notebooks created outside this SDK (for example in the UI).
26
+ */
22
27
  async deleteJupyter(name, projectKey) {
23
28
  const nameEnc = encodeURIComponent(name);
24
29
  await this.client.del(`/public/api/projects/${this.enc(projectKey)}/jupyter-notebooks/${nameEnc}`);
25
30
  }
26
- /** Clear all cell outputs from a Jupyter notebook. */
31
+ /**
32
+ * Clear all cell outputs from a Jupyter notebook.
33
+ *
34
+ * DSS public APIs do not expose a dedicated clear-outputs endpoint, so this
35
+ * method fetches the notebook, strips outputs locally, and saves it back.
36
+ */
27
37
  async clearJupyterOutputs(name, projectKey) {
28
- const nameEnc = encodeURIComponent(name);
29
- await this.client.del(`/public/api/projects/${this.enc(projectKey)}/jupyter-notebooks/${nameEnc}/outputs`);
38
+ const notebook = await this.getJupyter(name, projectKey);
39
+ const clearedCells = notebook.cells.map((cell) => ({
40
+ ...cell,
41
+ outputs: [],
42
+ execution_count: null,
43
+ }));
44
+ await this.saveJupyter(name, { ...notebook, cells: clearedCells, }, projectKey);
30
45
  }
31
46
  /** List running kernel sessions for a Jupyter notebook. */
32
47
  async listJupyterSessions(name, projectKey) {
@@ -34,7 +49,12 @@ export class NotebooksResource extends BaseResource {
34
49
  const raw = await this.client.get(`/public/api/projects/${this.enc(projectKey)}/jupyter-notebooks/${nameEnc}/sessions`);
35
50
  return this.client.safeParse(NotebookSessionArraySchema, raw, "notebooks.sessionsJupyter");
36
51
  }
37
- /** Unload (stop) a running Jupyter notebook session. */
52
+ /**
53
+ * Unload (stop) a running Jupyter notebook session.
54
+ *
55
+ * DSS public APIs do not expose notebook or session creation, so this only
56
+ * works for sessions started outside this SDK.
57
+ */
38
58
  async unloadJupyter(name, sessionId, projectKey) {
39
59
  const nameEnc = encodeURIComponent(name);
40
60
  const sidEnc = encodeURIComponent(sessionId);
@@ -19,9 +19,18 @@ export declare class RecipesResource extends BaseResource {
19
19
  create(opts: RecipeCreateOptions): Promise<RecipeCreateResult>;
20
20
  /**
21
21
  * Update a recipe by merging the patch into the current definition.
22
- * The `recipe` sub-object is spread-merged to preserve nested fields.
22
+ * The `recipe` sub-object is deep-merged to preserve nested fields.
23
23
  */
24
24
  update(recipeName: string, data: Record<string, unknown>, projectKey?: string): Promise<void>;
25
+ /**
26
+ * Download a recipe code payload to a local file.
27
+
28
+ * Returns the path to the written file.
29
+ */
30
+ downloadCode(recipeName: string, opts?: {
31
+ outputPath?: string;
32
+ projectKey?: string;
33
+ }): Promise<string>;
25
34
  /** Delete a recipe. */
26
35
  delete(recipeName: string, projectKey?: string): Promise<void>;
27
36
  /**
@@ -2,6 +2,7 @@ import { writeFile, } from "node:fs/promises";
2
2
  import { resolve, } from "node:path";
3
3
  import { DataikuError, } from "../errors.js";
4
4
  import { RecipeSummaryArraySchema, } from "../schemas.js";
5
+ import { deepMerge, } from "../utils/deep-merge.js";
5
6
  import { sanitizeFileName, } from "../utils/sanitize.js";
6
7
  import { BaseResource, } from "./base.js";
7
8
  // ---------------------------------------------------------------------------
@@ -21,6 +22,22 @@ function asRecord(value) {
21
22
  return undefined;
22
23
  return value;
23
24
  }
25
+ function inferRecipeCodeExtension(recipeType) {
26
+ const normalized = typeof recipeType === "string" ? recipeType.trim().toLowerCase() : "";
27
+ if (!normalized)
28
+ return ".txt";
29
+ if (normalized.includes("python") || normalized.includes("pyspark"))
30
+ return ".py";
31
+ if (normalized.includes("sql"))
32
+ return ".sql";
33
+ if (normalized === "r" || normalized.startsWith("r_"))
34
+ return ".R";
35
+ if (normalized.includes("scala"))
36
+ return ".scala";
37
+ if (normalized.includes("shell"))
38
+ return ".sh";
39
+ return ".txt";
40
+ }
24
41
  // ---------------------------------------------------------------------------
25
42
  // Helpers: retry predicate
26
43
  // ---------------------------------------------------------------------------
@@ -53,7 +70,7 @@ export class RecipesResource extends BaseResource {
53
70
  * Get a recipe definition (and optionally its payload).
54
71
  * Returns the raw API response shape: `{ recipe, payload }`.
55
72
  */
56
- get(recipeName, opts) {
73
+ async get(recipeName, opts) {
57
74
  const enc = this.enc(opts?.projectKey);
58
75
  const rnEnc = encodeURIComponent(recipeName);
59
76
  const params = new URLSearchParams();
@@ -64,7 +81,12 @@ export class RecipesResource extends BaseResource {
64
81
  params.set("payloadMaxLines", String(opts.payloadMaxLines));
65
82
  const qs = params.toString();
66
83
  const url = `/public/api/projects/${enc}/recipes/${rnEnc}${qs ? `?${qs}` : ""}`;
67
- return this.client.get(url);
84
+ const result = await this.client.get(url);
85
+ const recipe = asRecord(result?.recipe);
86
+ if (!result || !recipe) {
87
+ throw new DataikuError(404, "Not Found", `Recipe "${recipeName}" not found in project "${this.resolveProjectKey(opts?.projectKey)}" (DSS returned empty response).`);
88
+ }
89
+ return { ...result, recipe, };
68
90
  }
69
91
  /** Create a recipe, with optional output dataset provisioning and join configuration. */
70
92
  async create(opts) {
@@ -242,7 +264,7 @@ export class RecipesResource extends BaseResource {
242
264
  }
243
265
  /**
244
266
  * Update a recipe by merging the patch into the current definition.
245
- * The `recipe` sub-object is spread-merged to preserve nested fields.
267
+ * The `recipe` sub-object is deep-merged to preserve nested fields.
246
268
  */
247
269
  async update(recipeName, data, projectKey) {
248
270
  const enc = this.enc(projectKey);
@@ -252,13 +274,28 @@ export class RecipesResource extends BaseResource {
252
274
  if (!currentRecipe) {
253
275
  throw new Error(`Recipe "${recipeName}" was not found or returned an empty definition.`);
254
276
  }
255
- const mergedRecipe = {
256
- ...currentRecipe,
257
- ...data.recipe,
258
- };
277
+ const mergedRecipe = deepMerge(currentRecipe, asRecord(data.recipe) ?? {});
259
278
  const merged = { ...current, ...data, recipe: mergedRecipe, };
260
279
  await this.client.put(`/public/api/projects/${enc}/recipes/${rnEnc}`, merged);
261
280
  }
281
+ /**
282
+ * Download a recipe code payload to a local file.
283
+
284
+ * Returns the path to the written file.
285
+ */
286
+ async downloadCode(recipeName, opts) {
287
+ const result = await this.get(recipeName, {
288
+ includePayload: true,
289
+ projectKey: opts?.projectKey,
290
+ });
291
+ if (!result.payload) {
292
+ throw new Error(`Recipe "${recipeName}" has no code payload.`);
293
+ }
294
+ const safeRecipeName = sanitizeFileName(recipeName, "recipe");
295
+ const filePath = opts?.outputPath ?? resolve(process.cwd(), `${safeRecipeName}${inferRecipeCodeExtension(result.recipe.type)}`);
296
+ await writeFile(filePath, result.payload, "utf-8");
297
+ return filePath;
298
+ }
262
299
  /** Delete a recipe. */
263
300
  async delete(recipeName, projectKey) {
264
301
  const enc = this.enc(projectKey);
@@ -1,4 +1,18 @@
1
+ import { DataikuError, } from "../errors.js";
1
2
  import { BaseResource, } from "./base.js";
3
+ const UNSUPPORTED_SQL_DATASET_CONNECTION_DETAIL = "neither of sql nor hdfs type";
4
+ function isUnsupportedSqlDatasetConnectionError(error) {
5
+ if (!(error instanceof DataikuError))
6
+ return false;
7
+ const detail = `${error.statusText}\n${error.body}\n${error.message}`.toLowerCase();
8
+ return detail.includes(UNSUPPORTED_SQL_DATASET_CONNECTION_DETAIL);
9
+ }
10
+ function buildUnsupportedSqlDatasetConnectionMessage(datasetFullName) {
11
+ const subject = datasetFullName
12
+ ? `Dataset "${datasetFullName}" uses a connection that DSS does not support for direct SQL queries.`
13
+ : "This query uses a connection that DSS does not support for direct SQL queries.";
14
+ return `${subject} Use --connection with a SQL-compatible connection instead.`;
15
+ }
2
16
  export class SqlResource extends BaseResource {
3
17
  /**
4
18
  * Start a SQL query and return the queryId + schema.
@@ -6,7 +20,10 @@ export class SqlResource extends BaseResource {
6
20
  * or `datasetFullName` (run against a dataset's connection).
7
21
  */
8
22
  async startQuery(opts) {
9
- return this.client.post(`/public/api/sql/queries`, opts);
23
+ return this.client.post("/public/api/sql/queries/", {
24
+ ...opts,
25
+ type: opts.type ?? "sql",
26
+ });
10
27
  }
11
28
  /**
12
29
  * Stream results of a started query as parsed JSON (array of arrays).
@@ -32,9 +49,19 @@ export class SqlResource extends BaseResource {
32
49
  * This is the primary method most callers want.
33
50
  */
34
51
  async query(opts) {
35
- const { queryId, schema, } = await this.startQuery(opts);
36
- const rows = await this.streamResults(queryId);
37
- await this.finishStreaming(queryId);
38
- return { queryId, schema, rows, };
52
+ const queryOpts = { ...opts, type: opts.type ?? "sql", };
53
+ try {
54
+ const { queryId, schema, } = await this.startQuery(queryOpts);
55
+ const rows = await this.streamResults(queryId);
56
+ await this.finishStreaming(queryId);
57
+ return { queryId, schema, rows, };
58
+ }
59
+ catch (error) {
60
+ if (!isUnsupportedSqlDatasetConnectionError(error))
61
+ throw error;
62
+ throw new Error(buildUnsupportedSqlDatasetConnectionMessage(queryOpts.datasetFullName), {
63
+ cause: error,
64
+ });
65
+ }
39
66
  }
40
67
  }
@@ -5,6 +5,7 @@ export declare class VariablesResource extends BaseResource {
5
5
  set(opts: {
6
6
  standard?: Record<string, unknown>;
7
7
  local?: Record<string, unknown>;
8
+ replace?: boolean;
8
9
  projectKey?: string;
9
10
  }): Promise<ProjectVariables>;
10
11
  }
@@ -7,6 +7,15 @@ export class VariablesResource extends BaseResource {
7
7
  return this.client.safeParse(ProjectVariablesSchema, raw, "variables.get");
8
8
  }
9
9
  async set(opts) {
10
+ const enc = this.enc(opts.projectKey);
11
+ if (opts.replace === true) {
12
+ const replaced = {
13
+ standard: opts.standard ?? {},
14
+ local: opts.local ?? {},
15
+ };
16
+ await this.client.putVoid(`/public/api/projects/${enc}/variables/`, replaced);
17
+ return replaced;
18
+ }
10
19
  if (opts.standard === undefined && opts.local === undefined) {
11
20
  throw new Error("At least one of standard or local must be provided");
12
21
  }
@@ -15,7 +24,6 @@ export class VariablesResource extends BaseResource {
15
24
  standard: { ...existing.standard, ...opts.standard, },
16
25
  local: { ...existing.local, ...opts.local, },
17
26
  };
18
- const enc = this.enc(opts.projectKey);
19
27
  await this.client.putVoid(`/public/api/projects/${enc}/variables/`, merged);
20
28
  return merged;
21
29
  }
package/package.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "name": "dataiku-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Dataiku DSS SDK and CLI for programmatic access to DSS REST APIs",
5
5
  "type": "module",
6
- "workspaces": ["packages/*"],
6
+ "workspaces": [
7
+ "packages/*"
8
+ ],
7
9
  "main": "dist/src/index.js",
8
10
  "types": "dist/src/index.d.ts",
9
11
  "bin": {
@@ -298,8 +298,8 @@ export declare const JupyterNotebookSummarySchema: import("@sinclair/typebox").T
298
298
  language: import("@sinclair/typebox").TString;
299
299
  kernelSpec: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
300
300
  name: import("@sinclair/typebox").TString;
301
- display_name: import("@sinclair/typebox").TString;
302
- language: import("@sinclair/typebox").TString;
301
+ display_name: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
302
+ language: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
303
303
  }>>;
304
304
  }>;
305
305
  export type JupyterNotebookSummary = Static<typeof JupyterNotebookSummarySchema>;
@@ -438,8 +438,8 @@ export declare const JupyterNotebookSummaryArraySchema: import("@sinclair/typebo
438
438
  language: import("@sinclair/typebox").TString;
439
439
  kernelSpec: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TObject<{
440
440
  name: import("@sinclair/typebox").TString;
441
- display_name: import("@sinclair/typebox").TString;
442
- language: import("@sinclair/typebox").TString;
441
+ display_name: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
442
+ language: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
443
443
  }>>;
444
444
  }>>;
445
445
  export declare const SqlNotebookSummaryArraySchema: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TObject<{
@@ -306,8 +306,8 @@ export const JupyterNotebookSummarySchema = Type.Object({
306
306
  language: Type.String(),
307
307
  kernelSpec: Type.Optional(Type.Object({
308
308
  name: Type.String(),
309
- display_name: Type.String(),
310
- language: Type.String(),
309
+ display_name: Type.Optional(Type.String()),
310
+ language: Type.Optional(Type.String()),
311
311
  }, { additionalProperties: true, })),
312
312
  }, { additionalProperties: true, });
313
313
  export const JupyterNotebookContentSchema = Type.Object({