dataiku-sdk 2.0.0 → 3.1.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 (80) hide show
  1. package/LICENSE +107 -17
  2. package/README.md +144 -17
  3. package/dist/build-metadata.json +1 -1
  4. package/dist/packages/types/src/index.d.ts +39 -4
  5. package/dist/packages/types/src/index.js +31 -2
  6. package/dist/src/cli/commands/api-service.js +62 -16
  7. package/dist/src/cli/commands/bundle.js +49 -15
  8. package/dist/src/cli/commands/code-env.js +210 -49
  9. package/dist/src/cli/commands/code.d.ts +19 -0
  10. package/dist/src/cli/commands/code.js +87 -15
  11. package/dist/src/cli/commands/dataset.js +8 -3
  12. package/dist/src/cli/commands/flow-zone.js +33 -5
  13. package/dist/src/cli/commands/notebook.js +72 -41
  14. package/dist/src/cli/commands/project-git.js +8 -5
  15. package/dist/src/cli/commands/project-library.d.ts +21 -0
  16. package/dist/src/cli/commands/project-library.js +121 -50
  17. package/dist/src/cli/commands/project.js +16 -2
  18. package/dist/src/cli/commands/recipe.js +37 -12
  19. package/dist/src/cli/commands/sql.d.ts +10 -0
  20. package/dist/src/cli/commands/sql.js +39 -17
  21. package/dist/src/cli/commands/webapp.js +67 -13
  22. package/dist/src/cli/contract.d.ts +15 -0
  23. package/dist/src/cli/contract.js +870 -107
  24. package/dist/src/cli/env.d.ts +9 -0
  25. package/dist/src/cli/env.js +20 -2
  26. package/dist/src/cli/flags.js +9 -0
  27. package/dist/src/cli/helpers/cleanup.js +104 -3
  28. package/dist/src/cli/helpers/flow-zone.d.ts +3 -0
  29. package/dist/src/cli/helpers/flow-zone.js +33 -3
  30. package/dist/src/cli/helpers/recipe.d.ts +20 -0
  31. package/dist/src/cli/helpers/recipe.js +158 -3
  32. package/dist/src/cli/output.d.ts +2 -0
  33. package/dist/src/cli/output.js +33 -6
  34. package/dist/src/cli/runtime.d.ts +35 -1
  35. package/dist/src/cli/runtime.js +125 -18
  36. package/dist/src/cli/types.d.ts +7 -0
  37. package/dist/src/cli.js +135 -28
  38. package/dist/src/client.d.ts +60 -0
  39. package/dist/src/client.js +237 -42
  40. package/dist/src/errors.d.ts +8 -2
  41. package/dist/src/errors.js +40 -0
  42. package/dist/src/index.d.ts +4 -3
  43. package/dist/src/index.js +2 -1
  44. package/dist/src/resources/api-services.d.ts +48 -6
  45. package/dist/src/resources/api-services.js +54 -8
  46. package/dist/src/resources/bundles.d.ts +51 -6
  47. package/dist/src/resources/bundles.js +39 -9
  48. package/dist/src/resources/code-envs.d.ts +25 -3
  49. package/dist/src/resources/code-envs.js +104 -2
  50. package/dist/src/resources/datasets.d.ts +20 -0
  51. package/dist/src/resources/datasets.js +232 -52
  52. package/dist/src/resources/jobs.d.ts +35 -10
  53. package/dist/src/resources/jobs.js +92 -17
  54. package/dist/src/resources/notebooks.d.ts +77 -7
  55. package/dist/src/resources/notebooks.js +143 -19
  56. package/dist/src/resources/project-git.d.ts +12 -0
  57. package/dist/src/resources/project-git.js +39 -8
  58. package/dist/src/resources/project-library.d.ts +122 -7
  59. package/dist/src/resources/project-library.js +494 -25
  60. package/dist/src/resources/projects.d.ts +5 -2
  61. package/dist/src/resources/projects.js +18 -2
  62. package/dist/src/resources/scenarios.d.ts +30 -0
  63. package/dist/src/resources/scenarios.js +73 -27
  64. package/dist/src/resources/sql.d.ts +6 -0
  65. package/dist/src/resources/sql.js +22 -2
  66. package/dist/src/resources/webapps.d.ts +35 -4
  67. package/dist/src/resources/webapps.js +54 -5
  68. package/dist/src/schemas.d.ts +2 -2
  69. package/dist/src/schemas.js +1 -1
  70. package/dist/src/skill.d.ts +9 -0
  71. package/dist/src/skill.js +37 -4
  72. package/dist/src/utils/flow-analysis.d.ts +53 -0
  73. package/dist/src/utils/flow-analysis.js +382 -0
  74. package/dist/src/utils/project-archive.d.ts +41 -2
  75. package/dist/src/utils/project-archive.js +159 -16
  76. package/package.json +5 -5
  77. package/packages/types/dist/index.d.ts +39 -4
  78. package/packages/types/dist/index.js +31 -2
  79. package/plugin.json +2 -2
  80. package/skills/dataiku-dss/SKILL.md +75 -48
@@ -1,24 +1,12 @@
1
1
  import * as fs from "node:fs";
2
- import { readStdinText, } from "../coerce.js";
3
- import { encodedProjectEndpoint, planResult, } from "../output.js";
2
+ import { encodeLibraryPath, EXPECT_SHA256_PATTERN, validateLibraryDestinationPath, validateLibraryName, validateLibraryPath, } from "../../resources/project-library.js";
3
+ import { numFlag, readStdinText, sha256Hex, } from "../coerce.js";
4
+ import { encodedProjectEndpoint, planResult, skipResult, } from "../output.js";
4
5
  import { requireArgs, UsageError, } from "../usage.js";
5
6
  const PROJECT_LIBRARY_EXIT_CODES = { usage: 1, error: 2, transient: 3, };
6
- function normalizeLibraryPath(path) {
7
- const normalized = path.replace(/^\/+/, "");
8
- if (!normalized)
9
- throw new Error("Project library path is required");
10
- return normalized;
11
- }
12
- function normalizeLibraryDestinationPath(path) {
13
- if (path === "/")
14
- return "/";
15
- return `/${normalizeLibraryPath(path)}`;
16
- }
17
- function encodeLibraryPath(path) {
18
- return normalizeLibraryPath(path)
19
- .split("/")
20
- .map((segment) => encodeURIComponent(segment))
21
- .join("/");
7
+ const PUT_FLAG_ERROR_HINT = "Pass a 64-character SHA-256 hex digest (lowercase or uppercase).";
8
+ function sha256BytesHex(bytes) {
9
+ return new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
22
10
  }
23
11
  function projectLibraryContentsEndpoint(client, projectKey, path) {
24
12
  return encodedProjectEndpoint(client, projectKey, `/libraries/contents/${encodeLibraryPath(path)}`);
@@ -29,22 +17,53 @@ function projectLibraryFolderEndpoint(client, projectKey, path) {
29
17
  function projectLibraryActionEndpoint(client, projectKey, action) {
30
18
  return encodedProjectEndpoint(client, projectKey, `/libraries/contents-actions/${action}`);
31
19
  }
32
- function projectLibraryPutPayload(flags) {
33
- if (typeof flags["content"] === "string")
34
- return { contentSource: "flag", };
35
- if (typeof flags["file"] === "string")
36
- return { contentSource: "file", file: flags["file"], };
37
- return { contentSource: "stdin", };
20
+ /**
21
+ * Put-input metadata for plans and reports. Plans must never include content
22
+ * bytes: this helper reports only the source, byte count, and SHA-256 digest —
23
+ * and only when they are available without consuming stdin (dry-run keeps
24
+ * stdin unread, so the stdin source carries no size/hash).
25
+ */
26
+ export function projectLibraryPutPayload(flags) {
27
+ const expectSha256 = expectSha256FromFlags(flags);
28
+ if (typeof flags["content"] === "string") {
29
+ const text = flags["content"];
30
+ return {
31
+ contentSource: "flag",
32
+ bytes: Buffer.byteLength(text, "utf8"),
33
+ sha256: sha256Hex(text),
34
+ ...(expectSha256 ? { expectSha256, } : {}),
35
+ };
36
+ }
37
+ if (typeof flags["file"] === "string") {
38
+ const bytes = fs.readFileSync(flags["file"]);
39
+ return {
40
+ contentSource: "file",
41
+ file: flags["file"],
42
+ bytes: bytes.length,
43
+ sha256: sha256BytesHex(bytes),
44
+ ...(expectSha256 ? { expectSha256, } : {}),
45
+ };
46
+ }
47
+ return { contentSource: "stdin", ...(expectSha256 ? { expectSha256, } : {}), };
38
48
  }
39
49
  function projectLibraryPlan(action, options) {
40
50
  return planResult("project-library", action, {
41
51
  ...options,
52
+ idempotency: options.idempotency ?? "none",
42
53
  asyncKind: "none",
43
54
  exitCodesOnFailure: PROJECT_LIBRARY_EXIT_CODES,
44
- idempotency: "none",
45
55
  plannedAndDryRun: true,
46
56
  });
47
57
  }
58
+ function expectSha256FromFlags(flags) {
59
+ const value = flags["expect-sha256"];
60
+ if (value === undefined || value === false)
61
+ return undefined;
62
+ if (typeof value !== "string" || !EXPECT_SHA256_PATTERN.test(value)) {
63
+ throw new UsageError("--expect-sha256 must be a 64-character SHA-256 hex digest.", "validation_failed", PUT_FLAG_ERROR_HINT);
64
+ }
65
+ return value;
66
+ }
48
67
  export const projectLibraryCommands = {
49
68
  list: {
50
69
  handler: (c, _a, f) => c.projectLibrary.listContents(f["project-key"]),
@@ -55,6 +74,7 @@ export const projectLibraryCommands = {
55
74
  get: {
56
75
  handler: (c, a, f) => {
57
76
  requireArgs(a, 1, "dss project-library get <path> [--project-key KEY]");
77
+ validateLibraryPath(a[0]);
58
78
  return c.projectLibrary.getFile(a[0], f["project-key"]);
59
79
  },
60
80
  usage: "dss project-library get <path> [--project-key KEY]",
@@ -69,7 +89,7 @@ export const projectLibraryCommands = {
69
89
  throw new UsageError("--output PATH is required.", "missing_required_flag");
70
90
  const bytes = await c.projectLibrary.getFileBytes(a[0], f["project-key"]);
71
91
  const written = await Bun.write(out, bytes, { createPath: false, });
72
- return { path: out, bytes: written, };
92
+ return { path: out, bytes: written, sha256: sha256BytesHex(bytes), };
73
93
  },
74
94
  usage: "dss project-library get-bytes <path> --output PATH [--project-key KEY]",
75
95
  description: "Download a project library file's raw bytes to a local file.",
@@ -77,44 +97,62 @@ export const projectLibraryCommands = {
77
97
  },
78
98
  "create-file": {
79
99
  handler: async (c, a, f) => {
80
- requireArgs(a, 1, "dss project-library create-file <path> [--dry-run] [--project-key KEY]");
100
+ requireArgs(a, 1, "dss project-library create-file <path> [--if-not-exists] [--dry-run] [--project-key KEY]");
81
101
  const pk = f["project-key"];
102
+ validateLibraryPath(a[0]);
82
103
  if (f["dry-run"] === true) {
83
104
  return projectLibraryPlan("create-file", {
84
105
  method: "POST",
85
106
  endpoint: projectLibraryContentsEndpoint(c, pk, a[0]),
86
107
  identifiers: { path: a[0], },
108
+ idempotency: "if-not-exists",
87
109
  });
88
110
  }
111
+ if (f["if-not-exists"] === true) {
112
+ const exists = await c.projectLibrary.hasLibraryItem(a[0], pk);
113
+ if (exists) {
114
+ return skipResult("project-library", a[0], "exists", { kind: "file", });
115
+ }
116
+ }
89
117
  await c.projectLibrary.addFile(a[0], pk);
90
118
  return { created: a[0], };
91
119
  },
92
- usage: "dss project-library create-file <path> [--dry-run] [--project-key KEY]",
93
- description: "Create an empty file in the project library.",
94
- examples: ["dss project-library create-file python/mylib/new.py",],
120
+ usage: "dss project-library create-file <path> [--if-not-exists] [--dry-run] [--project-key KEY]",
121
+ description: "Create an empty file in the project library; refuses to overwrite an existing item.",
122
+ examples: ["dss project-library create-file python/mylib/new.py --if-not-exists",],
95
123
  },
96
124
  "create-folder": {
97
125
  handler: async (c, a, f) => {
98
- requireArgs(a, 1, "dss project-library create-folder <path> [--dry-run] [--project-key KEY]");
126
+ requireArgs(a, 1, "dss project-library create-folder <path> [--if-not-exists] [--dry-run] [--project-key KEY]");
99
127
  const pk = f["project-key"];
128
+ validateLibraryPath(a[0]);
100
129
  if (f["dry-run"] === true) {
101
130
  return projectLibraryPlan("create-folder", {
102
131
  method: "POST",
103
132
  endpoint: projectLibraryFolderEndpoint(c, pk, a[0]),
104
133
  identifiers: { path: a[0], },
134
+ idempotency: "if-not-exists",
105
135
  });
106
136
  }
137
+ if (f["if-not-exists"] === true) {
138
+ const exists = await c.projectLibrary.hasLibraryItem(a[0], pk);
139
+ if (exists) {
140
+ return skipResult("project-library", a[0], "exists", { kind: "folder", });
141
+ }
142
+ }
107
143
  await c.projectLibrary.addFolder(a[0], pk);
108
144
  return { created: a[0], };
109
145
  },
110
- usage: "dss project-library create-folder <path> [--dry-run] [--project-key KEY]",
111
- description: "Create a folder in the project library.",
112
- examples: ["dss project-library create-folder python/mylib",],
146
+ usage: "dss project-library create-folder <path> [--if-not-exists] [--dry-run] [--project-key KEY]",
147
+ description: "Create a folder in the project library; refuses to overwrite an existing item.",
148
+ examples: ["dss project-library create-folder python/mylib --if-not-exists",],
113
149
  },
114
150
  put: {
115
151
  handler: async (c, a, f) => {
116
- requireArgs(a, 1, "dss project-library put <path> (--content TEXT|--file PATH|--stdin) [--dry-run] [--project-key KEY]");
152
+ requireArgs(a, 1, "dss project-library put <path> (--content TEXT|--file PATH|--stdin) [--expect-sha256 SHA256] [--dry-run] [--project-key KEY]");
117
153
  const pk = f["project-key"];
154
+ const expectSha256 = expectSha256FromFlags(f);
155
+ validateLibraryPath(a[0]);
118
156
  if (f["dry-run"] === true) {
119
157
  return projectLibraryPlan("put", {
120
158
  method: "POST",
@@ -126,19 +164,48 @@ export const projectLibraryCommands = {
126
164
  const content = typeof f["content"] === "string"
127
165
  ? f["content"]
128
166
  : typeof f["file"] === "string"
129
- ? fs.readFileSync(f["file"], "utf-8")
167
+ ? fs.readFileSync(f["file"])
168
+ : await readStdinText();
169
+ const result = await c.projectLibrary.addOrUpdateFile(a[0], content, pk, expectSha256 !== undefined ? { expectSha256, } : undefined);
170
+ return {
171
+ updated: result.path,
172
+ bytes: result.bytes,
173
+ sha256: result.sha256,
174
+ ...(result.beforeSha256 !== undefined ? { beforeSha256: result.beforeSha256, } : {}),
175
+ };
176
+ },
177
+ usage: "dss project-library put <path> (--content TEXT|--file PATH|--stdin) [--expect-sha256 SHA256] [--dry-run] [--project-key KEY]",
178
+ description: "Create or overwrite a project library file with text or binary content; reports the written byte count and sha256.",
179
+ examples: [
180
+ "dss project-library put python/mylib/utils.py --file ./utils.py",
181
+ "dss project-library put python/mylib/utils.py --file ./utils.py --expect-sha256 <previous-sha256>",
182
+ ],
183
+ },
184
+ diff: {
185
+ handler: async (c, a, f) => {
186
+ requireArgs(a, 1, "dss project-library diff <path> (--content TEXT|--file PATH|--stdin) [--max-lines N] [--project-key KEY]");
187
+ const pk = f["project-key"];
188
+ validateLibraryPath(a[0]);
189
+ const maxLines = numFlag(f, ["max-lines",]);
190
+ const local = typeof f["content"] === "string"
191
+ ? f["content"]
192
+ : typeof f["file"] === "string"
193
+ ? fs.readFileSync(f["file"])
130
194
  : await readStdinText();
131
- await c.projectLibrary.addOrUpdateFile(a[0], content, pk);
132
- return { updated: a[0], };
195
+ return c.projectLibrary.diffFile(a[0], local, pk, maxLines !== undefined ? { maxLines, } : {});
133
196
  },
134
- usage: "dss project-library put <path> (--content TEXT|--file PATH|--stdin) [--dry-run] [--project-key KEY]",
135
- description: "Create or overwrite a project library file with raw text content.",
136
- examples: ["dss project-library put python/mylib/utils.py --file ./utils.py",],
197
+ usage: "dss project-library diff <path> (--content TEXT|--file PATH|--stdin) [--max-lines N] [--project-key KEY]",
198
+ description: "Diff a project library file against local content; capped unified text diff, binary files detected instead of dumped.",
199
+ examples: [
200
+ "dss project-library diff python/mylib/utils.py --file ./utils.py",
201
+ "dss project-library diff python/mylib/utils.py --content 'print(1)' --max-lines 50",
202
+ ],
137
203
  },
138
204
  delete: {
139
205
  handler: async (c, a, f) => {
140
206
  requireArgs(a, 1, "dss project-library delete <path> [--dry-run] [--project-key KEY]");
141
207
  const pk = f["project-key"];
208
+ validateLibraryPath(a[0]);
142
209
  if (f["dry-run"] === true) {
143
210
  return projectLibraryPlan("delete", {
144
211
  method: "DELETE",
@@ -157,16 +224,18 @@ export const projectLibraryCommands = {
157
224
  handler: async (c, a, f) => {
158
225
  requireArgs(a, 2, "dss project-library rename <path> <new-name> [--dry-run] [--project-key KEY]");
159
226
  const pk = f["project-key"];
227
+ const validPath = validateLibraryPath(a[0]);
228
+ const validName = validateLibraryName(a[1]);
160
229
  if (f["dry-run"] === true) {
161
230
  return projectLibraryPlan("rename", {
162
231
  method: "POST",
163
232
  endpoint: projectLibraryActionEndpoint(c, pk, "rename/"),
164
- identifiers: { path: a[0], newName: a[1], },
165
- payload: { oldPath: `/${normalizeLibraryPath(a[0])}`, newName: a[1], },
233
+ identifiers: { path: validPath, newName: validName, },
234
+ payload: { oldPath: `/${validPath}`, newName: validName, },
166
235
  });
167
236
  }
168
- await c.projectLibrary.rename(a[0], a[1], pk);
169
- return { renamed: a[0], to: a[1], };
237
+ await c.projectLibrary.rename(validPath, validName, pk);
238
+ return { renamed: validPath, to: validName, };
170
239
  },
171
240
  usage: "dss project-library rename <path> <new-name> [--dry-run] [--project-key KEY]",
172
241
  description: "Rename a project library file or folder within its parent.",
@@ -176,19 +245,21 @@ export const projectLibraryCommands = {
176
245
  handler: async (c, a, f) => {
177
246
  requireArgs(a, 2, "dss project-library move <path> <destination-folder> [--dry-run] [--project-key KEY]");
178
247
  const pk = f["project-key"];
248
+ const validPath = validateLibraryPath(a[0]);
249
+ const destination = validateLibraryDestinationPath(a[1]);
179
250
  if (f["dry-run"] === true) {
180
251
  return projectLibraryPlan("move", {
181
252
  method: "POST",
182
253
  endpoint: projectLibraryActionEndpoint(c, pk, "move"),
183
- identifiers: { path: a[0], destinationFolder: a[1], },
254
+ identifiers: { path: validPath, destinationFolder: destination, },
184
255
  payload: {
185
- oldPath: `/${normalizeLibraryPath(a[0])}`,
186
- newPath: normalizeLibraryDestinationPath(a[1]),
256
+ oldPath: `/${validPath}`,
257
+ newPath: destination,
187
258
  },
188
259
  });
189
260
  }
190
- await c.projectLibrary.move(a[0], a[1], pk);
191
- return { moved: a[0], to: a[1], };
261
+ await c.projectLibrary.move(validPath, destination, pk);
262
+ return { moved: validPath, to: destination, };
192
263
  },
193
264
  usage: "dss project-library move <path> <destination-folder> [--dry-run] [--project-key KEY]",
194
265
  description: "Move a project library file or folder into another folder.",
@@ -6,6 +6,13 @@ import { jsonInput, num, requiredJsonInput, requiredStringFlag, } from "../coerc
6
6
  import { CommandResultFailure, } from "../output.js";
7
7
  import { requireArgs, UsageError, } from "../usage.js";
8
8
  const INSPECT_ARCHIVE_USAGE = "dss project inspect-archive <file>";
9
+ function flowRenderFormat(value) {
10
+ if (value === undefined)
11
+ return undefined;
12
+ if (value === "ascii" || value === "mermaid")
13
+ return value;
14
+ throw new UsageError("--render must be ascii or mermaid.", "invalid_enum");
15
+ }
9
16
  /** Local, DSS-free archive inspection shared by the local handler and the command handler. */
10
17
  function inspectArchiveCommand(args) {
11
18
  requireArgs(args, 1, INSPECT_ARCHIVE_USAGE);
@@ -42,12 +49,19 @@ export const projectCommands = {
42
49
  maxNodes: num(f["max-nodes"], "--max-nodes"),
43
50
  maxEdges: num(f["max-edges"], "--max-edges"),
44
51
  includeRaw: f["include-raw"] === true,
52
+ render: flowRenderFormat(f["render"]),
45
53
  }),
46
- usage: "dss project map [--max-nodes N] [--max-edges N] [--include-raw] [--project-key KEY]",
47
- description: "Get a summarized, truncated flow map.",
54
+ validate: (_a, f) => {
55
+ flowRenderFormat(f["render"]);
56
+ num(f["max-nodes"], "--max-nodes");
57
+ num(f["max-edges"], "--max-edges");
58
+ },
59
+ usage: "dss project map [--max-nodes N] [--max-edges N] [--include-raw] [--render ascii|mermaid] [--project-key KEY]",
60
+ description: "Get an analyzed flow map with zones, layers, components, and diagnostics.",
48
61
  examples: [
49
62
  "dss project map",
50
63
  "dss project map --max-nodes 50 --max-edges 100",
64
+ "dss project map --render mermaid",
51
65
  "dss project map --include-raw",
52
66
  ],
53
67
  },
@@ -1,18 +1,42 @@
1
1
  import { readFileSync, } from "node:fs";
2
- import { mkdir, writeFile, } from "node:fs/promises";
2
+ import { writeFile, } from "node:fs/promises";
3
3
  import { join, } from "node:path";
4
4
  import { deepMerge, } from "../../utils/deep-merge.js";
5
5
  import { jobLogFilterFromFlag, jsonInput, maxLogLinesFromFlags, normalizeLineEndings, num, recipeInputDatasetsFromFlags, requiredStringFlag, rewritePairsFromFlags, sha256Hex, splitCsvFlag, stableHash, } from "../coerce.js";
6
6
  import { moveCreatedItemsToZone, resolveFlowZoneIdFromFlags, } from "../helpers/flow-zone.js";
7
- import { readRecipeBackup, recipeBackupDocument, recipeBackupPath, recipeCodeEnv, recipeGraph, recipeInputItemRef, recipeRoleInputItems, recipeRunShouldWait, } from "../helpers/recipe.js";
7
+ import { ensureRecipeBackupDir, readRecipeBackup, recipeBackupDocument, recipeBackupPath, recipeCodeEnv, recipeGraph, recipeInputItemRef, recipeRoleInputItems, recipeRunShouldWait, writeRecipeBackup, } from "../helpers/recipe.js";
8
8
  import { encodedProjectEndpoint, readIfExists, skipResult, } from "../output.js";
9
9
  import { requireArgs, UsageError, } from "../usage.js";
10
+ /** Read a local recipe file before any DSS request, mapping read failures to usage validation. */
11
+ function readRecipeFile(filePath, flagName) {
12
+ try {
13
+ return readFileSync(filePath, "utf-8");
14
+ }
15
+ catch (error) {
16
+ const reason = error instanceof Error ? error.message : String(error);
17
+ throw new UsageError(`Could not read ${flagName} ${filePath}: ${reason}.`, "not_found", "Verify the file path and that it is readable.");
18
+ }
19
+ }
20
+ /** Read a set-payload backup file before any DSS request, mapping read failures to usage validation. */
21
+ function readRecipeBackupFile(backupPath) {
22
+ try {
23
+ return readRecipeBackup(backupPath);
24
+ }
25
+ catch (error) {
26
+ const reason = error instanceof Error ? error.message : String(error);
27
+ throw new UsageError(`Could not read --backup ${backupPath}: ${reason}.`, "not_found", "Verify the backup file path and that it is readable.");
28
+ }
29
+ }
10
30
  function formatLineDiff(remoteName, localPath, remoteContent, localContent) {
11
- if (localContent === remoteContent) {
31
+ // Line-ending differences are not content differences: compare and split on
32
+ // CRLF-normalized text, mirroring assert-unchanged's normalized payload hash.
33
+ const normalizedLocal = normalizeLineEndings(localContent);
34
+ const normalizedRemote = normalizeLineEndings(remoteContent);
35
+ if (normalizedLocal === normalizedRemote) {
12
36
  return "No differences.";
13
37
  }
14
- const localLines = localContent.split("\n");
15
- const remoteLines = remoteContent.split("\n");
38
+ const localLines = normalizedLocal.split("\n");
39
+ const remoteLines = normalizedRemote.split("\n");
16
40
  const lines = [`--- remote:${remoteName}`, `+++ local:${localPath}`, "",];
17
41
  const maxLen = Math.max(localLines.length, remoteLines.length);
18
42
  for (let i = 0; i < maxLen; i++) {
@@ -104,7 +128,7 @@ export const recipeCommands = {
104
128
  return c.recipes.run(a[0], options);
105
129
  },
106
130
  usage: "dss recipe run <name> [--wait|--no-wait] [--build-mode MODE] [--include-logs] [--log-filter stdout|stderr|user|errors] [--summary] [--max-log-lines N] [--timeout MS] [--poll-interval MS] [--partition PARTITION] [--dry-run] [--project-key KEY]",
107
- description: "Run a recipe by resolving its outputs and submitting the correct dataset or managed-folder build job.",
131
+ description: "Run a recipe by resolving its outputs and submitting the correct dataset or managed-folder build job. --timeout defaults to 120000 ms and --poll-interval to 2000 ms.",
108
132
  examples: [
109
133
  "dss recipe run compute_orders --wait",
110
134
  "dss recipe run compute_exports --include-logs --log-filter stdout --summary --timeout 600000",
@@ -344,6 +368,8 @@ export const recipeCommands = {
344
368
  if (!filePath) {
345
369
  throw new UsageError("--file is required. Usage: dss recipe diff <name> --file PATH");
346
370
  }
371
+ // Read and validate the local file before any DSS request.
372
+ const localContent = readRecipeFile(filePath, "--file");
347
373
  const result = await c.recipes.get(a[0], {
348
374
  includePayload: true,
349
375
  projectKey: f["project-key"],
@@ -351,7 +377,6 @@ export const recipeCommands = {
351
377
  if (!result.payload) {
352
378
  throw new Error(`Recipe "${a[0]}" has no code payload to diff.`);
353
379
  }
354
- const localContent = readFileSync(filePath, "utf-8");
355
380
  return formatLineDiff(a[0], filePath, result.payload, localContent);
356
381
  },
357
382
  usage: "dss recipe diff <name> --file PATH [--project-key KEY]",
@@ -506,7 +531,7 @@ export const recipeCommands = {
506
531
  const filePath = f["file"];
507
532
  if (!filePath)
508
533
  throw new UsageError("--file is required.");
509
- const content = readFileSync(filePath, "utf-8");
534
+ const content = readRecipeFile(filePath, "--file");
510
535
  const pk = f["project-key"];
511
536
  const shouldBackup = f["no-backup"] !== true;
512
537
  const backupDir = shouldBackup
@@ -530,8 +555,8 @@ export const recipeCommands = {
530
555
  };
531
556
  }
532
557
  if (backupDir && backupPath) {
533
- await mkdir(backupDir, { recursive: true, });
534
- await writeFile(backupPath, `${JSON.stringify(recipeBackupDocument(a[0], pk, current), null, 2)}\n`, "utf-8");
558
+ ensureRecipeBackupDir(backupDir);
559
+ writeRecipeBackup(backupPath, `${JSON.stringify(recipeBackupDocument(a[0], pk, current), null, 2)}\n`);
535
560
  }
536
561
  await c.recipes.replace(a[0], { ...current, payload: content, }, pk);
537
562
  return {
@@ -555,7 +580,7 @@ export const recipeCommands = {
555
580
  const usage = "dss recipe restore <name> --backup FILE [--payload-only] [--dry-run] [--project-key KEY]";
556
581
  requireArgs(a, 1, usage);
557
582
  const backupPath = requiredStringFlag(f, "backup", usage);
558
- const backup = readRecipeBackup(backupPath);
583
+ const backup = readRecipeBackupFile(backupPath);
559
584
  const payload = typeof backup.payload === "string" ? backup.payload : "";
560
585
  const pk = f["project-key"];
561
586
  const current = await c.recipes.get(a[0], { includePayload: true, projectKey: pk, });
@@ -598,7 +623,7 @@ export const recipeCommands = {
598
623
  const usage = "dss recipe assert-unchanged <name> --since BACKUP [--project-key KEY]";
599
624
  requireArgs(a, 1, usage);
600
625
  const backupPath = requiredStringFlag(f, "since", usage);
601
- const backup = readRecipeBackup(backupPath);
626
+ const backup = readRecipeBackupFile(backupPath);
602
627
  const current = await c.recipes.get(a[0], {
603
628
  includePayload: true,
604
629
  projectKey: f["project-key"],
@@ -1,2 +1,12 @@
1
1
  import type { CommandMeta } from "../types.js";
2
+ export declare function resolveSqlInput(args: string[], flags: Record<string, string | boolean>): string;
3
+ export interface SqlQueryInvocation {
4
+ query: string;
5
+ connection?: string;
6
+ datasetFullName?: string;
7
+ database?: string;
8
+ projectKey?: string;
9
+ type: "sql";
10
+ }
11
+ export declare function resolveSqlQueryInvocation(args: string[], flags: Record<string, string | boolean>, projectKeyOverride?: string): SqlQueryInvocation;
2
12
  export declare const sqlCommands: Record<string, CommandMeta>;
@@ -2,6 +2,7 @@ import { readFileSync, } from "node:fs";
2
2
  import { mkdir, writeFile, } from "node:fs/promises";
3
3
  import { dirname, resolve, } from "node:path";
4
4
  import { readStdinText, stripUtf8Bom, } from "../coerce.js";
5
+ import { enqueueCliWarning, } from "../output.js";
5
6
  import { UsageError, } from "../usage.js";
6
7
  const SQL_QUERY_USAGE = "dss sql query (SQL | --sql QUERY | --sql-file PATH | --sql - | --stdin) (--connection CONN | --dataset FULL_NAME) [--database DB] [--output PATH|--output-file PATH] [--preview N] [--start-retries N] [--request-timeout MS] [--project-key KEY]";
7
8
  const DEFAULT_SQL_PREVIEW_ROWS = 5;
@@ -34,7 +35,7 @@ function parseSqlStartRetries(value) {
34
35
  }
35
36
  return parsed;
36
37
  }
37
- function resolveSqlInput(args, flags) {
38
+ export function resolveSqlInput(args, flags) {
38
39
  const sources = [];
39
40
  if (typeof flags["sql"] === "string") {
40
41
  sources.push({
@@ -69,34 +70,55 @@ function resolveSqlInput(args, flags) {
69
70
  }
70
71
  return query;
71
72
  }
73
+ export function resolveSqlQueryInvocation(args, flags, projectKeyOverride) {
74
+ const connection = flags["connection"];
75
+ const datasetFullName = flags["dataset"];
76
+ if ((connection ? 1 : 0) + (datasetFullName ? 1 : 0) !== 1) {
77
+ throw new UsageError(`Pass exactly one of --connection or --dataset. Usage: ${SQL_QUERY_USAGE}`);
78
+ }
79
+ return {
80
+ query: resolveSqlInput(args, flags),
81
+ connection,
82
+ datasetFullName,
83
+ database: flags["database"],
84
+ projectKey: projectKeyOverride ?? flags["project-key"],
85
+ type: "sql",
86
+ };
87
+ }
72
88
  export const sqlCommands = {
73
89
  query: {
74
90
  handler: async (c, a, f) => {
75
- const connection = f["connection"];
76
- const datasetFullName = f["dataset"];
77
- if ((connection ? 1 : 0) + (datasetFullName ? 1 : 0) !== 1) {
78
- throw new UsageError(`Pass exactly one of --connection or --dataset. Usage: ${SQL_QUERY_USAGE}`);
79
- }
80
91
  const outputFile = f["output"]
81
92
  ?? f["output-file"];
82
93
  const previewProvided = f["preview"] !== undefined;
83
- if (previewProvided && !outputFile) {
84
- throw new UsageError(`--preview requires --output or --output-file. Usage: ${SQL_QUERY_USAGE}`, "validation_failed");
85
- }
86
94
  const previewCount = previewProvided
87
95
  ? parseSqlPreviewCount(f["preview"])
88
96
  : DEFAULT_SQL_PREVIEW_ROWS;
89
- const query = resolveSqlInput(a, f);
90
97
  const result = await c.sql.query({
91
- query,
92
- connection,
93
- datasetFullName,
94
- database: f["database"],
95
- projectKey: f["project-key"],
98
+ ...resolveSqlQueryInvocation(a, f),
96
99
  retryMaxAttempts: parseSqlStartRetries(f["start-retries"]),
97
100
  });
98
- if (!outputFile)
101
+ if (!outputFile && !previewProvided)
99
102
  return result;
103
+ if (!outputFile) {
104
+ const truncated = result.rows.length > previewCount;
105
+ if (truncated) {
106
+ enqueueCliWarning({
107
+ code: "sql_preview_truncated",
108
+ rowCount: result.rows.length,
109
+ previewRows: previewCount,
110
+ hint: "Use --output PATH to write the full result without placing it on stdout.",
111
+ });
112
+ }
113
+ return {
114
+ queryId: result.queryId,
115
+ schema: result.schema,
116
+ columns: result.columns ?? result.schema,
117
+ rowCount: result.rows.length,
118
+ preview: result.rows.slice(0, previewCount),
119
+ truncated,
120
+ };
121
+ }
100
122
  const outputPath = resolve(outputFile);
101
123
  await mkdir(dirname(outputPath), { recursive: true, });
102
124
  await writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`, "utf-8");
@@ -111,7 +133,7 @@ export const sqlCommands = {
111
133
  };
112
134
  },
113
135
  usage: SQL_QUERY_USAGE,
114
- description: "Run a SQL query against a DSS connection or dataset. --start-retries may execute the SQL more than once; use it only when repetition is safe.",
136
+ description: "Run potentially mutating SQL against a DSS connection or dataset. Use --preview N for bounded stdout or --output PATH for full rows. --start-retries may execute SQL more than once; use it only when repetition is safe.",
115
137
  examples: [
116
138
  "dss sql query 'SELECT * FROM orders LIMIT 10' --connection my_pg",
117
139
  "dss sql query --sql-file query.sql --connection my_pg",
@@ -1,5 +1,18 @@
1
- import { requiredJsonInput, } from "../coerce.js";
1
+ import { ClientValidationError, } from "../../errors.js";
2
+ import { deepMerge, } from "../../utils/deep-merge.js";
3
+ import { stableHash, } from "../../utils/stable-hash.js";
4
+ import { num, requiredJsonInput, } from "../coerce.js";
2
5
  import { requireArgs, } from "../usage.js";
6
+ const UPDATE_SETTINGS_USAGE = "dss webapp update-settings <webappId> (--data JSON|--data-file PATH|--stdin) [--expect-hash SHA256] [--dry-run] [--project-key KEY]";
7
+ const RESTART_USAGE = "dss webapp restart-backend <webappId> [--wait] [--timeout MS] [--poll-interval MS] [--project-key KEY]";
8
+ function validateExpectHash(value) {
9
+ if (value === undefined || value === false)
10
+ return undefined;
11
+ if (typeof value !== "string" || !/^[0-9a-fA-F]{64}$/.test(value)) {
12
+ throw new ClientValidationError("Expected webapp hash must be a 64-character SHA-256 hex digest.", "validation_failed", "Run this update with --dry-run and capture currentHash before retrying.");
13
+ }
14
+ return value;
15
+ }
3
16
  export const webappCommands = {
4
17
  list: {
5
18
  handler: (c, _a, f) => c.webapps.list(f["project-key"]),
@@ -26,14 +39,45 @@ export const webappCommands = {
26
39
  examples: ["dss webapp create --data-file webapp.json",],
27
40
  },
28
41
  "update-settings": {
29
- handler: (c, a, f) => {
30
- requireArgs(a, 1, "dss webapp update-settings <webappId> (--data JSON|--data-file PATH|--stdin) [--project-key KEY]");
42
+ handler: async (c, a, f) => {
43
+ requireArgs(a, 1, UPDATE_SETTINGS_USAGE);
31
44
  const body = requiredJsonInput(f, "--data, --data-file, or --stdin is required (webapp settings).");
32
- return c.webapps.updateSettings(a[0], body, f["project-key"]);
45
+ const projectKey = f["project-key"];
46
+ const expectHash = validateExpectHash(f["expect-hash"]);
47
+ if (f["dry-run"] === true) {
48
+ const current = await c.webapps.getSettings(a[0], projectKey);
49
+ const currentHash = stableHash(current);
50
+ if (expectHash !== undefined && currentHash !== expectHash.toLowerCase()) {
51
+ throw new ClientValidationError(`The webapp ${JSON.stringify(a[0])} changed since it was read.`, "validation_failed", "Re-read the webapp settings and retry with the current hash value.", {
52
+ id: a[0],
53
+ expectedHash: expectHash.toLowerCase(),
54
+ actualHash: currentHash,
55
+ });
56
+ }
57
+ const next = deepMerge(current, body);
58
+ return {
59
+ dryRun: true,
60
+ action: "update-settings",
61
+ resource: "webapp",
62
+ id: a[0],
63
+ current,
64
+ next,
65
+ currentHash,
66
+ nextHash: stableHash(next),
67
+ ...(expectHash !== undefined
68
+ ? { expectHash: expectHash.toLowerCase(), provenanceVerified: true, }
69
+ : {}),
70
+ };
71
+ }
72
+ const updated = await c.webapps.updateSettings(a[0], body, projectKey, { expectHash, });
73
+ return { updated: a[0], ...updated, };
33
74
  },
34
- usage: "dss webapp update-settings <webappId> (--data JSON|--data-file PATH|--stdin) [--project-key KEY]",
35
- description: "Replace a webapp's settings.",
36
- examples: ["dss webapp update-settings WEBAPP_ID --data-file webapp.json",],
75
+ usage: UPDATE_SETTINGS_USAGE,
76
+ description: "Merge supplied fields into a webapp's settings (GET-merge-PUT) and PUT the full object, so fields outside the patch are never dropped. --expect-hash refuses the write when the object changed since it was fetched; --dry-run reports current, next, and both hashes without writing.",
77
+ examples: [
78
+ "dss webapp update-settings WEBAPP_ID --data-file webapp.json",
79
+ 'dss webapp update-settings WEBAPP_ID --dry-run --data=\'{"params":{"designOptions":{"namingRule":"x"}}}\'',
80
+ ],
37
81
  },
38
82
  "stop-backend": {
39
83
  handler: async (c, a, f) => {
@@ -47,13 +91,23 @@ export const webappCommands = {
47
91
  },
48
92
  "restart-backend": {
49
93
  handler: async (c, a, f) => {
50
- requireArgs(a, 1, "dss webapp restart-backend <webappId> [--project-key KEY]");
51
- await c.webapps.startOrRestartBackend(a[0], f["project-key"]);
52
- return { restarted: true, };
94
+ requireArgs(a, 1, RESTART_USAGE);
95
+ const projectKey = f["project-key"];
96
+ if (f["wait"] === true) {
97
+ return c.webapps.restartBackendAndWait(a[0], projectKey, {
98
+ pollIntervalMs: num(f["poll-interval"], "--poll-interval"),
99
+ timeoutMs: num(f["timeout"], "--timeout"),
100
+ });
101
+ }
102
+ const future = await c.webapps.startOrRestartBackend(a[0], projectKey);
103
+ return { restarted: true, future, };
53
104
  },
54
- usage: "dss webapp restart-backend <webappId> [--project-key KEY]",
55
- description: "Start or restart a webapp's backend.",
56
- examples: ["dss webapp restart-backend WEBAPP_ID",],
105
+ usage: RESTART_USAGE,
106
+ description: "Start or restart a webapp's backend. The documented endpoint returns a restart future; --wait settles it and returns the FutureWaitResult.",
107
+ examples: [
108
+ "dss webapp restart-backend WEBAPP_ID",
109
+ "dss webapp restart-backend WEBAPP_ID --wait --timeout=60000",
110
+ ],
57
111
  },
58
112
  "backend-state": {
59
113
  handler: (c, a, f) => {