dataiku-sdk 1.1.0 → 2.0.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.
package/README.md CHANGED
@@ -4,7 +4,7 @@ Agent-only TypeScript SDK and `dss` CLI for Dataiku DSS automation.
4
4
 
5
5
  ## Platform support
6
6
 
7
- The published `dss` CLI supports Linux, macOS, and Windows under Bun >= 1.3.14 or Node.js >= 22.15.0. The release gate runs Bun 1.3.14 with Node 24 on all three operating systems, plus a lightweight minimum-Node 22.15 package smoke on Linux. The runtime dependency is pure JavaScript, so the same package also runs on runtime-supported x64 and ARM64 systems.
7
+ The published `dss` CLI requires Bun >= 1.4.0 and supports Linux, macOS, and Windows. The release gate runs Bun 1.4.0 on all three operating systems. The runtime dependency is pure JavaScript, so the same package also runs on every Bun-supported x64 and ARM64 system.
8
8
 
9
9
  Run directly with Bun:
10
10
 
@@ -17,10 +17,11 @@ Or install the npm binary:
17
17
  ```text
18
18
  npm install --global dataiku-sdk
19
19
  ```
20
+ The installed `dss` bin bootstraps Bun with automatic `.env` preloading disabled, so Bun >= 1.4.0 must be on `PATH`; the CLI's own `.env` handling stays authoritative. `bunx --bun` forces Bun's Node-compatibility mode, which provides the same no-preload guarantee across operating systems.
20
21
 
21
- Bun is the primary development runtime and package manager. Examples below assume an installed `dss` binary. From a checkout, use `bun --no-env-file src/cli.ts ...` or the cross-runtime `bun --no-env-file ./bin/dss.js ...` launcher. Node users can invoke the same launcher as `node ./bin/dss.js ...`; if `dist/` is absent, it delegates to Bun. From another working directory, pass the checkout's absolute `bin/dss.js` path to Bun or Node.
22
+ Bun is the only supported runtime and the package manager. Examples below assume an installed `dss` binary. From a checkout, use `bun --no-env-file src/cli.ts ...` or the packaged launcher `bun --no-env-file ./bin/dss.js ...`. From another working directory, pass the checkout's absolute `bin/dss.js` path to Bun.
22
23
  `--no-env-file` disables Bun's automatic preloading only; the CLI still applies its documented `.env` handling unless `DATAIKU_DISABLE_ENV=1` is set.
23
- `dss version` reports whether the running code came from `source` or `dist`, the Node/Bun runtime, the packaged build revision when available, and `staleBuild` when that revision differs from the checkout. Release builds generate `dist/build-metadata.json`; source snapshots without Git metadata still build but report no build revision.
24
+ `dss version` reports whether the running code came from `source` or `dist`, the runtime (always `bun`), the packaged build revision when available, and `staleBuild` when that revision differs from the checkout. Release builds generate `dist/build-metadata.json`; source snapshots without Git metadata still build but report no build revision.
24
25
 
25
26
  ## CLI contract
26
27
 
@@ -135,7 +136,8 @@ dss app validate-manifest --project-key MYAPP_INSTANCE
135
136
  dss app compare-manifest my-app --project-key MYAPP_INSTANCE
136
137
  dss app create-instance my-app --data '{"targetProjectKey":"MYAPP_INSTANCE"}' --wait --record-cleanup cleanup.jsonl
137
138
  dss app manifest-version --project-key MYAPP_TEMPLATE
138
- dss app set-manifest-version --manifest-version 1.4.0 --project-key MYAPP_TEMPLATE
139
+ dss app successor-preflight my-app --from MYAPP_INSTANCE --to MYAPP_INSTANCE_V2 --copy-permissions
140
+ dss app set-manifest-version --manifest-version 1.4.0 --expect-hash PREFLIGHT_TEMPLATE_MANIFEST_HASH --project-key MYAPP_TEMPLATE
139
141
  dss app create-successor-instance my-app --from MYAPP_INSTANCE --to MYAPP_INSTANCE_V2 --copy-permissions --record-cleanup cleanup.jsonl
140
142
  dss app verify-instance my-app --project-key MYAPP_INSTANCE_V2 --expect-version 1.4.0
141
143
  dss app permissions-snapshot --project-key MYAPP_INSTANCE --output permissions.json
@@ -182,6 +184,12 @@ dss app compare-manifest APP_ID --project-key RELEASE_INSTANCE
182
184
  # Read the raw `version` from the public app manifest (string fields only; never appVersion).
183
185
  dss app manifest-version --project-key APP_TEMPLATE
184
186
 
187
+ # Run every read-only successor gate before changing the template version. This validates the
188
+ # template references, verifies the predecessor, proves target absence, and optionally snapshots
189
+ # the predecessor ACL. Keep template.manifestHash from the result for the next command.
190
+ dss app successor-preflight APP_ID \
191
+ --from RELEASE_INSTANCE --to RELEASE_INSTANCE_V2 --copy-permissions
192
+
185
193
  # Write version/versionNotes through the public app-manifest endpoint. This is NOT a publish
186
194
  # transaction: it only changes the raw manifest metadata. --expect-hash SHA256 is a non-atomic
187
195
  # stale-read guard, not a conditional write: it refuses the write when the manifest already
@@ -189,7 +197,8 @@ dss app manifest-version --project-key APP_TEMPLATE
189
197
  # overwrite a write landing between its read and PUT, and the final read cannot detect that lost
190
198
  # update when this command's payload wins. Every result reports
191
199
  # concurrencyControl: "client-side-non-atomic-stale-read-check".
192
- dss app set-manifest-version --manifest-version 1.4.0 --project-key APP_TEMPLATE
200
+ dss app set-manifest-version --manifest-version 1.4.0 \
201
+ --expect-hash PREFLIGHT_TEMPLATE_MANIFEST_HASH --project-key APP_TEMPLATE
193
202
 
194
203
  # Create an instance only after its target key is confirmed absent, wait on the DSS future, and
195
204
  # record deterministic cleanup.
@@ -200,7 +209,8 @@ dss app create-instance APP_ID \
200
209
  # Roll out a new template version to an existing instance as an additive successor: the old
201
210
  # instance is never modified or deleted, and the command always waits on the DSS future
202
211
  # (there is no --wait flag). The recorded cleanup entry targets only the new project key. If DSS
203
- # accepts creation but returns no future ID, cleanup stops unresolved instead of racing creation.
212
+ # may have accepted creation but no future ID is returned, the outcome is indeterminate and no
213
+ # unbound cleanup entry is written.
204
214
  dss app create-successor-instance APP_ID \
205
215
  --from RELEASE_INSTANCE --to RELEASE_INSTANCE_V2 \
206
216
  --name "Release instance v2" --copy-permissions --record-cleanup cleanup.jsonl
@@ -233,12 +243,24 @@ managed-folder IDs, and runtime-form parameter names against supported public pr
233
243
 
234
244
  Some DSS deployments hide unknown project keys behind `403` instead of returning `404`.
235
245
  Instance creation requires confirmed target absence before POST: an inaccessible target not present
236
- in the visible project list is still unconfirmed and is rejected rather than risking cleanup
237
- against a pre-existing project. A definitive create rejection never produces a cleanup entry.
246
+ in the visible project and app-instance lists is still unconfirmed and is rejected with
247
+ `target_absence_unverifiable` / `permission_or_environment` rather than risking cleanup against a
248
+ pre-existing project. Choosing another key does not solve deployments that mask every unknown key.
249
+ DSS exposes no permission-independent public availability endpoint, and its published app-instance
250
+ API does not guarantee duplicate-key rejection before any write or non-overwrite behavior, so
251
+ there is no unsafe force or unconfirmed-target bypass. Use an identity with global project
252
+ visibility. A definitive create rejection never produces a cleanup entry; an ambiguous POST
253
+ without a future ID or verified
254
+ incarnation also produces no cleanup entry.
255
+
256
+ Static create plans describe, but do not execute, these reads:
257
+ `preflightExecuted:false` and `preflightWillRunDuringApply:true`. Run `successor-preflight` when
258
+ live read evidence is required before changing the template version.
238
259
 
239
260
  Every new cleanup entry records the canonical DSS URL. App-instance cleanup records a
240
- `creationTag` hash observed after the DSS future identifies the target key; unconfirmed creation
241
- stops unresolved. The future target and later `creationTag` are independent, non-atomic
261
+ `creationTag` hash observed after the DSS future identifies the target key; a future-addressable
262
+ entry stops unresolved until the future names that key and its incarnation can be bound. An
263
+ ambiguous POST with no future ID produces no entry. The future target and later `creationTag` are independent, non-atomic
242
264
  observations: the public API exposes neither an immutable project ID joined to the future nor a
243
265
  conditional DELETE. Cleanup rechecks type and `creationTag` immediately before deletion, rejecting
244
266
  detected key reuse, but cannot eliminate replacement in the remaining check-to-DELETE gap.
package/bin/dss.js CHANGED
@@ -1,117 +1,87 @@
1
1
  #!/usr/bin/env node
2
+ // npm bin for the dss CLI. Bun is the only runtime that executes CLI code.
3
+ // A Node-launched npm shim only bootstraps Bun with automatic .env loading
4
+ // disabled; `bunx --bun` enters Bun's Node-compatibility mode, which disables
5
+ // that loading before this module runs.
2
6
  import { spawnSync, } from "node:child_process";
3
- import * as fs from "node:fs";
4
7
  import * as path from "node:path";
5
8
  import { fileURLToPath, pathToFileURL, } from "node:url";
6
9
 
7
- const args = process.argv.slice(2,);
8
- const optionArgs = args.includes("--",) ? args.slice(0, args.indexOf("--",),) : args;
9
- const here = path.dirname(fileURLToPath(import.meta.url,),);
10
- const distCliPath = path.resolve(here, "../dist/src/cli.js",);
11
- const sourceCliPath = path.resolve(here, "../src/cli.ts",);
12
- const cliPath = fs.existsSync(distCliPath,) ? distCliPath : sourceCliPath;
13
- const cliUrl = pathToFileURL(cliPath,).href;
10
+ const runningUnderBun = typeof process.versions.bun === "string";
11
+ const bunNodeCompatibilityMode = runningUnderBun
12
+ && (
13
+ path.basename(process.argv0,).toLowerCase().startsWith("node",)
14
+ || (
15
+ process.env.npm_lifecycle_event === "bunx"
16
+ && ["dss", "dataiku-sdk",].includes(process.env.npm_lifecycle_script ?? "",)
17
+ && /^bun(?:\.exe)?$/i.test(path.basename(process.env.npm_execpath ?? "",),)
18
+ )
19
+ );
20
+ const envFileAutoloadDisabled = process.execArgv.includes("--no-env-file",)
21
+ || bunNodeCompatibilityMode;
14
22
 
15
- function flagValue(names,) {
16
- for (let i = 0; i < optionArgs.length; i++) {
17
- const arg = optionArgs[i];
18
- for (const name of names) {
19
- if (arg === name) return optionArgs[i + 1];
20
- if (arg.startsWith(`${name}=`,)) return arg.slice(name.length + 1,);
21
- }
22
- }
23
- return undefined;
24
- }
25
-
26
- function hasFlag(names,) {
27
- return names.some((name,) => optionArgs.includes(name,));
28
- }
29
-
30
- async function loadSavedTlsSettings() {
31
- try {
32
- const { loadCredentials, } = await import("../dist/src/config.js");
33
- const creds = loadCredentials();
34
- return {
35
- caCertPath: creds?.caCertPath,
36
- tlsRejectUnauthorized: creds?.tlsRejectUnauthorized,
37
- };
38
- } catch {
39
- return {};
40
- }
41
- }
42
-
43
- const savedTls = await loadSavedTlsSettings();
44
- const env = { ...process.env, };
45
- const explicitCaCert = flagValue(["--ca-cert", "--extra-ca-certs",],);
46
- if (explicitCaCert) {
47
- env.NODE_EXTRA_CA_CERTS = explicitCaCert;
48
- } else if (!env.NODE_EXTRA_CA_CERTS && savedTls.caCertPath) {
49
- env.NODE_EXTRA_CA_CERTS = savedTls.caCertPath;
50
- }
51
-
52
- if (hasFlag(["--insecure", "--skip-tls-verify",],)) {
53
- env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
54
- } else if (
55
- env.NODE_TLS_REJECT_UNAUTHORIZED === undefined && savedTls.tlsRejectUnauthorized === false
56
- ) {
57
- env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
58
- }
59
- const runningOnBun = Boolean(process.versions.bun,);
60
- const usesSourceCli = cliPath === sourceCliPath;
61
-
62
- // Published dist carries dist/build-metadata.json with the revision it was
63
- // built from. Forward it to the CLI so provenance can report dist source and
64
- // its build revision; only a full lowercase hexadecimal revision is accepted,
65
- // so an inherited or corrupt variable never stands in for packaged metadata.
66
- const distMetadataPath = path.resolve(here, "../dist/build-metadata.json",);
67
- if (!usesSourceCli) {
68
- env.DSS_LOAD_SOURCE = "dist";
69
- delete env.DSS_BUILD_REVISION;
70
- try {
71
- const metadata = JSON.parse(fs.readFileSync(distMetadataPath, "utf-8",),);
72
- const revision = metadata.buildRevision;
73
- if (typeof revision === "string" && /^[0-9a-f]{40}$/.test(revision,)) {
74
- env.DSS_BUILD_REVISION = revision;
75
- }
76
- } catch {
77
- // Dist without build metadata: provenance still reports dist source.
78
- }
79
- }
80
-
81
- const runtimeBin = runningOnBun ? process.execPath : usesSourceCli ? "bun" : process.execPath;
82
- const runtimeArgs = runningOnBun || usesSourceCli ? ["--no-env-file",] : ["--use-system-ca",];
83
- const result = spawnSync(
84
- runtimeBin,
85
- [...runtimeArgs, cliPath, ...args,],
86
- {
87
- stdio: "inherit",
88
- env,
89
- },
90
- );
91
-
92
- if (result.error) {
93
- const message = usesSourceCli
94
- ? `Unable to start Bun runtime for source dss CLI (${result.error.message}).`
95
- : `Unable to start ${
96
- runningOnBun ? "Bun" : "Node"
97
- } runtime for packaged dss CLI (${result.error.message}); falling back to the current runtime.`;
98
- if (usesSourceCli) {
23
+ if (!runningUnderBun) {
24
+ const result = spawnSync(
25
+ "bun",
26
+ ["--no-env-file", fileURLToPath(import.meta.url,), ...process.argv.slice(2,),],
27
+ { stdio: "inherit", env: process.env, },
28
+ );
29
+ if (result.error) {
99
30
  process.stdout.write(`${
100
31
  JSON.stringify({
101
32
  type: "error",
102
33
  ok: false,
103
- error: message,
34
+ error: "Unable to start the required Bun runtime.",
104
35
  code: "internal_error",
105
36
  category: "internal",
106
37
  exitCode: 2,
38
+ hint: "Install Bun >= 1.4.0 and ensure `bun` is on PATH.",
107
39
  },)
108
40
  }\n`,);
109
41
  process.exitCode = 2;
42
+ } else if (result.signal) {
43
+ process.kill(process.pid, result.signal,);
110
44
  } else {
111
- await import(cliUrl);
45
+ process.exitCode = result.status ?? 1;
112
46
  }
113
- } else if (result.signal) {
114
- process.kill(process.pid, result.signal,);
47
+ } else if (!envFileAutoloadDisabled) {
48
+ process.stdout.write(`${
49
+ JSON.stringify({
50
+ type: "error",
51
+ ok: false,
52
+ error: "Bun automatic .env loading must be disabled for the dss launcher.",
53
+ code: "env_autoload_enabled",
54
+ category: "usage",
55
+ exitCode: 1,
56
+ hint: "Use `bunx --bun dataiku-sdk` or pass `--no-env-file` before the script path.",
57
+ },)
58
+ }\n`,);
59
+ process.exitCode = 1;
115
60
  } else {
116
- process.exit(result.status ?? 1,);
61
+ const here = import.meta.dir;
62
+ const distCliPath = path.resolve(here, "../dist/src/cli.js",);
63
+ const sourceCliPath = path.resolve(here, "../src/cli.ts",);
64
+ const usesDistCli = await Bun.file(distCliPath,).exists();
65
+ const cliPath = usesDistCli ? distCliPath : sourceCliPath;
66
+
67
+ // Published dist carries dist/build-metadata.json with the revision it was
68
+ // built from. Hand it to the CLI so provenance can report dist source and its
69
+ // build revision; only a full lowercase hexadecimal revision is accepted, so an
70
+ // inherited or corrupt variable never stands in for packaged metadata.
71
+ if (usesDistCli) {
72
+ process.env.DSS_LOAD_SOURCE = "dist";
73
+ delete process.env.DSS_BUILD_REVISION;
74
+ try {
75
+ const metadata = await Bun.file(path.resolve(here, "../dist/build-metadata.json",),).json();
76
+ const revision = metadata.buildRevision;
77
+ if (typeof revision === "string" && /^[0-9a-f]{40}$/.test(revision,)) {
78
+ process.env.DSS_BUILD_REVISION = revision;
79
+ }
80
+ } catch {
81
+ // Dist without build metadata: provenance still reports dist source.
82
+ }
83
+ }
84
+
85
+ // Same process, same argv: the CLI sees process.argv.slice(2) exactly as before.
86
+ await import(pathToFileURL(cliPath,).href);
117
87
  }
@@ -1 +1 @@
1
- {"format":1,"buildRevision":"70487e2275a70acce6862f126a89b1bc852ebb11"}
1
+ {"format":1,"buildRevision":"730725833d165213e28d6affee90bd3c1b012ec9"}
@@ -1,4 +1,3 @@
1
- import { writeFileSync, } from "node:fs";
2
1
  import { requiredJsonInput, } from "../coerce.js";
3
2
  import { requireArgs, UsageError, } from "../usage.js";
4
3
  export const apiServiceCommands = {
@@ -100,9 +99,11 @@ export const apiServiceCommands = {
100
99
  if (!out)
101
100
  throw new UsageError("--output PATH is required.", "missing_required_flag");
102
101
  const res = await c.apiServices.downloadPackageArchive(a[0], a[1], f["project-key"]);
103
- const buf = Buffer.from(await res.arrayBuffer());
104
- writeFileSync(out, buf);
105
- return { path: out, bytes: buf.length, };
102
+ if (!res.body) {
103
+ throw new Error("apiServices.downloadPackageArchive response did not include a body");
104
+ }
105
+ const bytes = await Bun.write(out, new Response(res.body), { createPath: false, });
106
+ return { path: out, bytes, };
106
107
  },
107
108
  usage: "dss api-service download-package <serviceId> <packageId> --output PATH [--project-key KEY]",
108
109
  description: "Download an API service package archive to a local file.",
@@ -2,16 +2,17 @@ import { ClientValidationError, DataikuError, } from "../../errors.js";
2
2
  import { projectIncarnationHash, } from "../../utils/project-incarnation.js";
3
3
  import { jsonInput, num, parseBooleanOption, plainRecord, requiredJsonInput, requiredStringFlag, stringField, } from "../coerce.js";
4
4
  import { appPermissionsHash, appPermissionsVerificationError, assertAppPermissionsSnapshotBinding, buildAppPermissionsSnapshot, diffAppPermissions, readAppPermissionsSnapshot, writeAppPermissionsSnapshot, } from "../helpers/app-permissions.js";
5
- import { createSuccessorInstance, futureTargetIdentity, isDefinitiveRejection, requireAbsentProjectTarget, safeErrorSummary, verifyAppInstance, VISUAL_UI_GATE, } from "../helpers/app-successor.js";
5
+ import { createSuccessorInstance, futureTargetIdentity, isDefinitiveRejection, preflightSuccessorInstance, requireAbsentProjectTarget, safeErrorSummary, verifyAppInstance, VISUAL_UI_GATE, } from "../helpers/app-successor.js";
6
6
  import { CommandResultFailure, } from "../output.js";
7
7
  import { requireArgs, requireNoArgs, UsageError, } from "../usage.js";
8
8
  const CREATE_INSTANCE_USAGE = "dss app create-instance <appId> (--data JSON|--data-file PATH|--stdin) [--wait] [--timeout MS] [--poll-interval MS]";
9
9
  const MANIFEST_VERSION_USAGE = "dss app manifest-version [--project-key KEY]";
10
10
  const SET_MANIFEST_VERSION_USAGE = "dss app set-manifest-version (--manifest-version V|--version-notes NOTES) [--expect-hash SHA256] [--dry-run] [--project-key KEY]";
11
11
  const CREATE_SUCCESSOR_USAGE = "dss app create-successor-instance <appId> --from KEY --to KEY [--name NAME] [--copy-permissions] [--timeout MS] [--poll-interval MS] [--dry-run] [--record-cleanup PATH]";
12
+ const SUCCESSOR_PREFLIGHT_USAGE = "dss app successor-preflight <appId> --from KEY --to KEY [--name NAME] [--copy-permissions]";
12
13
  const VERIFY_INSTANCE_USAGE = "dss app verify-instance <appId> --project-key KEY [--expect-version V]";
13
14
  const DELETE_INSTANCE_USAGE = "dss app delete-instance --project-key KEY [--future-id ID] [--expect-project-incarnation SHA256] [--unconfirmed-creation] [--timeout MS] [--poll-interval MS]";
14
- const CREATE_INSTANCE_INDETERMINATE_REMEDIATION = "DSS may have accepted the creation, but no future ID proves when it settles. The recorded cleanup entry intentionally stops as unresolved; inspect `dss app instances <appId>` and retry deletion only after creation is known terminal.";
15
+ const CREATE_INSTANCE_INDETERMINATE_REMEDIATION = "DSS may have accepted the creation, but no future ID or project incarnation authorizes cleanup. Inspect `dss app instances <appId>` and DSS task history; delete only after creation is known terminal and the target identity is verified.";
15
16
  const CREATE_INSTANCE_REJECTED_REMEDIATION = "DSS refused the creation request, so no instance project was created: fix the reported problem and retry.";
16
17
  const DELETE_INSTANCE_FUTURE_REMEDIATION = "The supplied creation future could not authorize deletion. Inspect that future and the target project, then retry only with a future whose terminal result reports this project.";
17
18
  const DELETE_INSTANCE_UNCONFIRMED_REMEDIATION = "Creation may still be running and DSS returned no future ID. Inspect the app's instances and wait until creation is known terminal before running delete-instance without --unconfirmed-creation.";
@@ -116,7 +117,9 @@ export const appCommands = {
116
117
  throw new UsageError("Instance creation payload must include a non-empty targetProjectKey.", "validation_failed", `Usage: ${CREATE_INSTANCE_USAGE}`);
117
118
  }
118
119
  body.targetProjectKey = requestedProjectKey;
119
- await requireAbsentProjectTarget(c, requestedProjectKey, "targetProjectKey");
120
+ await requireAbsentProjectTarget(c, requestedProjectKey, "targetProjectKey", {
121
+ appId: a[0],
122
+ });
120
123
  let created;
121
124
  try {
122
125
  created = await c.applications.createInstance(a[0], body);
@@ -124,15 +127,19 @@ export const appCommands = {
124
127
  catch (error) {
125
128
  // A definitive rejection proves nothing was created; anything else
126
129
  // (5xx, retryable 4xx, transport failure) leaves the outcome
127
- // unknown and stays cleanup-eligible against the requested key.
130
+ // unknown. Without a returned future ID or verified incarnation,
131
+ // cleanup cannot be authorized.
128
132
  const rejected = isDefinitiveRejection(error);
129
133
  return {
130
134
  success: false,
131
- state: "CREATE_FAILED",
135
+ state: rejected ? "CREATE_FAILED" : "INDETERMINATE",
132
136
  elapsedMs: 0,
133
137
  pollCount: 0,
134
138
  projectKey: requestedProjectKey,
135
- cleanupEligible: !rejected,
139
+ outcome: rejected ? "rejected" : "indeterminate",
140
+ creationPostAttempted: true,
141
+ cleanupEligible: false,
142
+ recoveryCommands: rejected ? [] : [`dss app instances ${a[0]}`,],
136
143
  remediation: rejected
137
144
  ? CREATE_INSTANCE_REJECTED_REMEDIATION
138
145
  : CREATE_INSTANCE_INDETERMINATE_REMEDIATION,
@@ -143,11 +150,13 @@ export const appCommands = {
143
150
  if (!instance) {
144
151
  return {
145
152
  success: false,
146
- state: "UNTRACKABLE",
153
+ state: "INDETERMINATE",
147
154
  elapsedMs: 0,
148
155
  pollCount: 0,
149
156
  projectKey: requestedProjectKey,
150
- cleanupEligible: true,
157
+ outcome: "indeterminate",
158
+ creationPostAttempted: true,
159
+ cleanupEligible: false,
151
160
  responseKind: created === undefined
152
161
  ? "empty"
153
162
  : created === null
@@ -157,6 +166,7 @@ export const appCommands = {
157
166
  : "scalar",
158
167
  error: "DSS accepted instance creation without returning a structured creation state.",
159
168
  remediation: CREATE_INSTANCE_INDETERMINATE_REMEDIATION,
169
+ recoveryCommands: [`dss app instances ${a[0]}`,],
160
170
  };
161
171
  }
162
172
  // The create endpoint's `jobId` is a DSS future ID (the official client wraps it in
@@ -174,7 +184,7 @@ export const appCommands = {
174
184
  projectKey: requestedProjectKey,
175
185
  instance,
176
186
  ...(jobId !== undefined ? { futureId: jobId, jobId, } : {}),
177
- cleanupEligible: true,
187
+ cleanupEligible: jobId !== undefined,
178
188
  error: "DSS named a different target project in the creation response.",
179
189
  expected: { projectKey: requestedProjectKey, },
180
190
  actual: { projectKey: echoedTarget, },
@@ -251,14 +261,17 @@ export const appCommands = {
251
261
  if (!jobId) {
252
262
  return {
253
263
  success: false,
254
- state: "UNTRACKABLE",
264
+ state: "INDETERMINATE",
255
265
  elapsedMs: 0,
256
266
  pollCount: 0,
257
267
  projectKey: requestedProjectKey,
258
268
  instance,
259
- cleanupEligible: true,
260
- error: "DSS returned no instance-creation future ID. The instance may still have been created; inspect app instances or replay the recorded cleanup entry.",
269
+ outcome: "indeterminate",
270
+ creationPostAttempted: true,
271
+ cleanupEligible: false,
272
+ error: "DSS returned no instance-creation future ID. The instance may still have been created; inspect app instances before any recovery action.",
261
273
  remediation: CREATE_INSTANCE_INDETERMINATE_REMEDIATION,
274
+ recoveryCommands: [`dss app instances ${a[0]}`,],
262
275
  };
263
276
  }
264
277
  let waited;
@@ -278,6 +291,7 @@ export const appCommands = {
278
291
  instance,
279
292
  jobId,
280
293
  cleanupEligible: true,
294
+ recoveryCommands: [`dss future wait ${jobId}`, `dss app instances ${a[0]}`,],
281
295
  remediation: CREATE_INSTANCE_INDETERMINATE_REMEDIATION,
282
296
  ...safeErrorSummary(error),
283
297
  };
@@ -343,6 +357,25 @@ export const appCommands = {
343
357
  'dss app create-instance my-app --data \'{"targetProjectKey":"NEWPROJ"}\' --wait --timeout 120000 --poll-interval 2000',
344
358
  ],
345
359
  },
360
+ "successor-preflight": {
361
+ handler: async (c, a, f) => {
362
+ requireArgs(a, 1, SUCCESSOR_PREFLIGHT_USAGE);
363
+ return preflightSuccessorInstance(c, {
364
+ appId: a[0],
365
+ from: requiredStringFlag(f, "from", SUCCESSOR_PREFLIGHT_USAGE),
366
+ to: requiredStringFlag(f, "to", SUCCESSOR_PREFLIGHT_USAGE),
367
+ ...(f["name"] !== undefined ? { name: f["name"], } : {}),
368
+ copyPermissions: parseBooleanOption(f["copy-permissions"], "--copy-permissions") ?? false,
369
+ dryRun: true,
370
+ }, SUCCESSOR_PREFLIGHT_USAGE);
371
+ },
372
+ usage: SUCCESSOR_PREFLIGHT_USAGE,
373
+ description: "Run every read-only successor gate before changing the template version: validate the template, verify the predecessor, prove target absence, and optionally snapshot the predecessor ACL.",
374
+ examples: [
375
+ "dss app successor-preflight my-app --from OLD_INSTANCE --to NEW_INSTANCE",
376
+ "dss app successor-preflight my-app --from OLD_INSTANCE --to NEW_INSTANCE --copy-permissions",
377
+ ],
378
+ },
346
379
  "create-successor-instance": {
347
380
  handler: async (c, a, f) => {
348
381
  requireArgs(a, 1, CREATE_SUCCESSOR_USAGE);
@@ -1,4 +1,3 @@
1
- import { writeFileSync, } from "node:fs";
2
1
  import { requireArgs, UsageError, } from "../usage.js";
3
2
  export const bundleCommands = {
4
3
  "list-exported": {
@@ -34,9 +33,11 @@ export const bundleCommands = {
34
33
  if (!out)
35
34
  throw new UsageError("--output PATH is required.", "missing_required_flag");
36
35
  const res = await c.bundles.downloadExportedArchive(a[0], f["project-key"]);
37
- const buf = Buffer.from(await res.arrayBuffer());
38
- writeFileSync(out, buf);
39
- return { path: out, bytes: buf.length, };
36
+ if (!res.body) {
37
+ throw new Error("bundles.downloadExportedArchive response did not include a body");
38
+ }
39
+ const bytes = await Bun.write(out, new Response(res.body), { createPath: false, });
40
+ return { path: out, bytes, };
40
41
  },
41
42
  usage: "dss bundle download-exported <bundleId> --output PATH [--project-key KEY]",
42
43
  description: "Download an exported Design-node bundle archive to a local file.",
@@ -1,4 +1,4 @@
1
- import { readFileSync, writeFileSync, } from "node:fs";
1
+ import * as fs from "node:fs";
2
2
  import { readStdinText, } from "../coerce.js";
3
3
  import { encodedProjectEndpoint, planResult, } from "../output.js";
4
4
  import { requireArgs, UsageError, } from "../usage.js";
@@ -68,8 +68,8 @@ export const projectLibraryCommands = {
68
68
  if (!out)
69
69
  throw new UsageError("--output PATH is required.", "missing_required_flag");
70
70
  const bytes = await c.projectLibrary.getFileBytes(a[0], f["project-key"]);
71
- writeFileSync(out, bytes);
72
- return { path: out, bytes: bytes.length, };
71
+ const written = await Bun.write(out, bytes, { createPath: false, });
72
+ return { path: out, bytes: written, };
73
73
  },
74
74
  usage: "dss project-library get-bytes <path> --output PATH [--project-key KEY]",
75
75
  description: "Download a project library file's raw bytes to a local file.",
@@ -126,7 +126,7 @@ export const projectLibraryCommands = {
126
126
  const content = typeof f["content"] === "string"
127
127
  ? f["content"]
128
128
  : typeof f["file"] === "string"
129
- ? readFileSync(f["file"], "utf-8")
129
+ ? fs.readFileSync(f["file"], "utf-8")
130
130
  : await readStdinText();
131
131
  await c.projectLibrary.addOrUpdateFile(a[0], content, pk);
132
132
  return { updated: a[0], };
@@ -2315,6 +2315,8 @@ export function commandPlanShape(resource, action, args, flags, entry, projectKe
2315
2315
  identifiers: {
2316
2316
  appId: id,
2317
2317
  targetProjectKey,
2318
+ preflightExecuted: false,
2319
+ preflightWillRunDuringApply: true,
2318
2320
  incarnationControl: "client-side-non-atomic-future-target-and-creation-tag-join",
2319
2321
  incarnationObservationRequests: [
2320
2322
  {
@@ -2337,7 +2339,13 @@ export function commandPlanShape(resource, action, args, flags, entry, projectKe
2337
2339
  method: "GET",
2338
2340
  endpoint: "/public/api/projects/",
2339
2341
  when: "conditional",
2340
- intent: "Fallback list probe issued only when the direct project GET is forbidden (403) to confirm the target is absent; an unconfirmed target rejects creation.",
2342
+ intent: "Fallback list probe issued only when the direct project GET is forbidden (403). Presence proves a collision; absence cannot prove availability.",
2343
+ },
2344
+ {
2345
+ method: "GET",
2346
+ endpoint: `/public/api/apps/${encodeURIComponent(id)}/instances/`,
2347
+ when: "conditional",
2348
+ intent: "Fallback app-instance list probe issued when the direct project GET is forbidden (403). Presence proves a collision; absence still rejects creation as unverifiable.",
2341
2349
  },
2342
2350
  ],
2343
2351
  note: "Strict preflight: the instance POST runs only after the payload targetProjectKey is confirmed absent. Creation never writes into an existing or unprovable project.",
@@ -2399,6 +2407,8 @@ export function commandPlanShape(resource, action, args, flags, entry, projectKe
2399
2407
  appId: id,
2400
2408
  sourceProjectKey,
2401
2409
  targetProjectKey,
2410
+ preflightExecuted: false,
2411
+ preflightWillRunDuringApply: true,
2402
2412
  ...(targetProjectName !== undefined ? { targetProjectName, } : {}),
2403
2413
  copyPermissions,
2404
2414
  incarnationControl: "client-side-non-atomic-future-target-and-creation-tag-join",
@@ -2437,7 +2447,7 @@ export function commandPlanShape(resource, action, args, flags, entry, projectKe
2437
2447
  method: "GET",
2438
2448
  endpoint: "/public/api/projects/",
2439
2449
  when: "conditional",
2440
- intent: "Fallback list probe issued only when the direct project GET is forbidden (403) to confirm the target is absent; an unconfirmed target rejects creation.",
2450
+ intent: "Fallback list probe issued only when the direct project GET is forbidden (403). Presence proves a collision; absence cannot prove availability and rejects creation.",
2441
2451
  },
2442
2452
  ],
2443
2453
  ...(copyPermissions
@@ -87,12 +87,22 @@ export declare function futureTargetIdentity(result: unknown): {
87
87
  projectKey: string;
88
88
  field: "targetProjectKey" | "projectKey";
89
89
  } | null;
90
+ interface TargetAbsenceContext {
91
+ appId: string;
92
+ visibleAppInstanceKeys?: readonly (string | undefined)[];
93
+ }
90
94
  /**
91
95
  * Creation cleanup is safe only when the target was proven absent before POST.
92
96
  * A hidden 403 is not absence: project listing can prove a collision, but it
93
97
  * cannot prove that an inaccessible project does not exist.
94
98
  */
95
- export declare function requireAbsentProjectTarget(client: DataikuClient, targetProjectKey: string, targetFlag: "--to" | "targetProjectKey"): Promise<void>;
99
+ export declare function requireAbsentProjectTarget(client: DataikuClient, targetProjectKey: string, targetFlag: "--to" | "targetProjectKey", context: TargetAbsenceContext): Promise<void>;
100
+ /**
101
+ * Run every read-only gate used by successor creation without changing the
102
+ * template version or issuing the creation POST. Its manifest hash is suitable
103
+ * for the subsequent set-manifest-version --expect-hash guard.
104
+ */
105
+ export declare function preflightSuccessorInstance(client: DataikuClient, request: SuccessorInstanceRequest, usage: string): Promise<Record<string, unknown>>;
96
106
  /**
97
107
  * Create a new app instance from the current template version alongside an
98
108
  * existing instance. Classic app instances have no public upgrade endpoint, so
@@ -100,7 +110,9 @@ export declare function requireAbsentProjectTarget(client: DataikuClient, target
100
110
  * and deliberately retired.
101
111
  *
102
112
  * After the creation POST this function never throws. A post-POST failure
103
- * returns a failed-wait result carrying the target project key, so the CLI still
104
- * exits 4 and the cleanup ledger still records an addressable successor.
113
+ * returns a failed-wait result. Only a DSS future ID or verified project
114
+ * incarnation can authorize cleanup; an ambiguous POST with neither stays
115
+ * indeterminate and produces no cleanup entry.
105
116
  */
106
117
  export declare function createSuccessorInstance(client: DataikuClient, request: SuccessorInstanceRequest, usage: string): Promise<Record<string, unknown>>;
118
+ export {};
@@ -150,6 +150,7 @@ export async function verifyAppInstance(client, appId, projectKey, expectVersion
150
150
  }
151
151
  const SUCCESSOR_REMEDIATION = "The successor project may exist: replay the recorded cleanup entry, which future-gates deletion when DSS supplied a future ID. Without a cleanup entry, inspect the creation future and wait for it to become terminal before deleting the requested successor project. The predecessor project was not modified.";
152
152
  const CREATE_REJECTED_REMEDIATION = "DSS refused the creation request, so no successor project was created: fix the reported problem and retry. The predecessor project was not modified.";
153
+ const SUCCESSOR_UNBOUND_REMEDIATION = "DSS may have accepted the successor request, but no future ID or project incarnation is available to authorize cleanup. Inspect the app's instances and DSS task history; do not delete the requested key until creation is known terminal and its identity is verified. The predecessor project was not modified.";
153
154
  /**
154
155
  * True only when DSS definitively refused a write: a non-retryable 4xx with a
155
156
  * classified client-side taxonomy. Transport failures (status 0), 5xx, retryable
@@ -186,7 +187,19 @@ export function futureTargetIdentity(result) {
186
187
  * A hidden 403 is not absence: project listing can prove a collision, but it
187
188
  * cannot prove that an inaccessible project does not exist.
188
189
  */
189
- export async function requireAbsentProjectTarget(client, targetProjectKey, targetFlag) {
190
+ export async function requireAbsentProjectTarget(client, targetProjectKey, targetFlag, context) {
191
+ if (context.visibleAppInstanceKeys?.includes(targetProjectKey) === true) {
192
+ throw new UsageError(`Target project ${targetProjectKey} already exists as an app instance.`, "validation_failed", `Choose an unused ${targetFlag}: app instance creation never writes into an existing project.`, {
193
+ targetProjectKey,
194
+ targetFlag,
195
+ directTargetProbe: "not-executed",
196
+ targetVisibleInProjectList: null,
197
+ targetVisibleInAppInstances: true,
198
+ appInstancesProbe: 200,
199
+ preflightExecuted: true,
200
+ creationPostAttempted: false,
201
+ });
202
+ }
190
203
  try {
191
204
  const existing = await readIfExists(() => client.projects.get(targetProjectKey));
192
205
  if (existing === undefined)
@@ -195,10 +208,61 @@ export async function requireAbsentProjectTarget(client, targetProjectKey, targe
195
208
  catch (error) {
196
209
  if (!(error instanceof DataikuError) || error.status !== 403)
197
210
  throw error;
198
- const accessible = await client.projects.list();
199
- if (!accessible.some((project) => project.projectKey === targetProjectKey)) {
200
- throw new UsageError(`Could not confirm target project ${targetProjectKey} is absent.`, "validation_failed", "Grant project-list/details visibility or choose a target key whose absence DSS can confirm; creation never risks cleanup against an unproven target.", { targetProjectKey, targetFlag, targetProbe: "forbidden-and-not-listable", });
211
+ let projectListProbe = 200;
212
+ let targetVisibleInProjectList;
213
+ try {
214
+ const accessible = await client.projects.list();
215
+ targetVisibleInProjectList = accessible.some((project) => project.projectKey === targetProjectKey);
216
+ }
217
+ catch (projectListError) {
218
+ if (!(projectListError instanceof DataikuError) || projectListError.status !== 403) {
219
+ throw projectListError;
220
+ }
221
+ projectListProbe = 403;
222
+ targetVisibleInProjectList = null;
201
223
  }
224
+ let appInstancesProbe = 200;
225
+ let visibleAppInstanceKeys = context.visibleAppInstanceKeys;
226
+ if (visibleAppInstanceKeys === undefined) {
227
+ try {
228
+ visibleAppInstanceKeys = (await client.applications.listInstances(context.appId))
229
+ .map((instance) => instance.projectKey);
230
+ }
231
+ catch (appInstancesError) {
232
+ if (!(appInstancesError instanceof DataikuError) || appInstancesError.status !== 403) {
233
+ throw appInstancesError;
234
+ }
235
+ appInstancesProbe = 403;
236
+ visibleAppInstanceKeys = [];
237
+ }
238
+ }
239
+ const targetVisibleInAppInstances = appInstancesProbe === 200
240
+ ? visibleAppInstanceKeys?.includes(targetProjectKey) ?? false
241
+ : null;
242
+ const probeDetails = {
243
+ targetProjectKey,
244
+ targetFlag,
245
+ directTargetProbe: 403,
246
+ projectListProbe,
247
+ targetVisibleInProjectList,
248
+ targetVisibleInAppInstances,
249
+ appInstancesProbe,
250
+ preflightExecuted: true,
251
+ creationPostAttempted: false,
252
+ };
253
+ if (targetVisibleInProjectList !== true && targetVisibleInAppInstances !== true) {
254
+ throw new UsageError(`Could not confirm target project ${targetProjectKey} is absent.`, "target_absence_unverifiable", "DSS exposes no permission-independent project-key availability endpoint, and app-instance creation has no published atomic duplicate-key guarantee. Retry with an identity that has global project visibility; creation was not attempted.", {
255
+ ...probeDetails,
256
+ targetProbe: "forbidden-and-not-listable",
257
+ deploymentMasksUnknownProjects: "possible-but-unproven",
258
+ supportedRecoveryModes: ["grant-global-project-visibility",],
259
+ unavailableRecoveryModes: [
260
+ "use-supported-key-availability-endpoint",
261
+ "server-atomic-create",
262
+ ],
263
+ });
264
+ }
265
+ throw new UsageError(`Target project ${targetProjectKey} already exists.`, "validation_failed", `Choose an unused ${targetFlag}: app instance creation never writes into an existing project.`, probeDetails);
202
266
  }
203
267
  throw new UsageError(`Target project ${targetProjectKey} already exists.`, "validation_failed", `Choose an unused ${targetFlag}: app instance creation never writes into an existing project.`, { targetProjectKey, targetFlag, });
204
268
  }
@@ -226,12 +290,26 @@ async function preflightSuccessor(client, request, usage) {
226
290
  if (source.projectAppType !== "APP_INSTANCE") {
227
291
  throw new UsageError(`Project ${from} is a ${source.projectAppType ?? "non-app"} project, not a classic Dataiku App instance.`, "validation_failed", "--from must name an APP_INSTANCE project; app templates are versioned with `dss app set-manifest-version`.", { sourceProjectKey: from, projectAppType: source.projectAppType ?? null, });
228
292
  }
229
- await requireAbsentProjectTarget(client, to, "--to");
293
+ await requireAbsentProjectTarget(client, to, "--to", {
294
+ appId,
295
+ visibleAppInstanceKeys: instances.map((instance) => instance.projectKey),
296
+ });
230
297
  const templateManifest = await client.applications.getAppManifest(appId);
231
298
  const templateVersion = rawVersion(templateManifest);
232
299
  if (templateVersion === null) {
233
300
  throw new UsageError(`App template ${appId} has no manifest version, so a successor instance cannot be verified against it.`, "validation_failed", "Set the template version first with `dss app set-manifest-version --manifest-version V --project-key <templateProjectKey>`.", { appId, });
234
301
  }
302
+ const templateReferenceValidation = await client.applications.validateAppManifest(templateManifest, appId);
303
+ if (!templateReferenceValidation.valid) {
304
+ throw new UsageError(`App template ${appId} failed manifest reference validation.`, "validation_failed", "Fix the template manifest references before changing its version or creating a successor.", {
305
+ appId,
306
+ manifestHash: templateReferenceValidation.manifestHash,
307
+ checks: templateReferenceValidation.checks,
308
+ errors: templateReferenceValidation.errors,
309
+ preflightExecuted: true,
310
+ creationPostAttempted: false,
311
+ });
312
+ }
235
313
  const sourcePermissions = request.copyPermissions
236
314
  ? await client.projects.getPermissions(from)
237
315
  : undefined;
@@ -243,6 +321,7 @@ async function preflightSuccessor(client, request, usage) {
243
321
  targetExists: false,
244
322
  templateManifestHash: stableHash(templateManifest),
245
323
  templateVersion,
324
+ templateReferenceValidation,
246
325
  ...(sourcePermissions
247
326
  ? {
248
327
  sourcePermissions,
@@ -251,6 +330,52 @@ async function preflightSuccessor(client, request, usage) {
251
330
  : {}),
252
331
  };
253
332
  }
333
+ /**
334
+ * Run every read-only gate used by successor creation without changing the
335
+ * template version or issuing the creation POST. Its manifest hash is suitable
336
+ * for the subsequent set-manifest-version --expect-hash guard.
337
+ */
338
+ export async function preflightSuccessorInstance(client, request, usage) {
339
+ const preflight = await preflightSuccessor(client, request, usage);
340
+ return {
341
+ action: "successor-preflight",
342
+ preflight: "passed",
343
+ preflightExecuted: true,
344
+ creationPostAttempted: false,
345
+ versionMutationAttempted: false,
346
+ appId: request.appId,
347
+ source: {
348
+ projectKey: request.from,
349
+ projectAppType: preflight.sourceProjectAppType,
350
+ version: preflight.sourceVersion,
351
+ manifestHash: preflight.sourceManifestHash,
352
+ ...(preflight.sourcePermissionsHash
353
+ ? { permissionsHash: preflight.sourcePermissionsHash, }
354
+ : {}),
355
+ },
356
+ target: {
357
+ projectKey: request.to,
358
+ name: preflight.targetProjectName,
359
+ exists: false,
360
+ },
361
+ targetPreflight: "confirmed-absent",
362
+ template: {
363
+ projectKey: request.appId,
364
+ version: preflight.templateVersion,
365
+ manifestHash: preflight.templateManifestHash,
366
+ referenceValidation: {
367
+ valid: preflight.templateReferenceValidation.valid,
368
+ checks: preflight.templateReferenceValidation.checks,
369
+ errors: preflight.templateReferenceValidation.errors,
370
+ },
371
+ },
372
+ copyPermissions: request.copyPermissions,
373
+ next: {
374
+ setVersion: `dss app set-manifest-version --manifest-version <VERSION> --expect-hash ${preflight.templateManifestHash} --project-key ${request.appId}`,
375
+ createSuccessor: `dss app create-successor-instance ${request.appId} --from ${request.from} --to ${request.to}${request.name !== undefined ? ` --name ${JSON.stringify(request.name)}` : ""}${request.copyPermissions ? " --copy-permissions" : ""}`,
376
+ },
377
+ };
378
+ }
254
379
  /**
255
380
  * Create a new app instance from the current template version alongside an
256
381
  * existing instance. Classic app instances have no public upgrade endpoint, so
@@ -258,8 +383,9 @@ async function preflightSuccessor(client, request, usage) {
258
383
  * and deliberately retired.
259
384
  *
260
385
  * After the creation POST this function never throws. A post-POST failure
261
- * returns a failed-wait result carrying the target project key, so the CLI still
262
- * exits 4 and the cleanup ledger still records an addressable successor.
386
+ * returns a failed-wait result. Only a DSS future ID or verified project
387
+ * incarnation can authorize cleanup; an ambiguous POST with neither stays
388
+ * indeterminate and produces no cleanup entry.
263
389
  */
264
390
  export async function createSuccessorInstance(client, request, usage) {
265
391
  const { appId, from, to, } = request;
@@ -312,12 +438,17 @@ export async function createSuccessorInstance(client, request, usage) {
312
438
  projectKey: to,
313
439
  target: { projectKey: to, name: preflight.targetProjectName, },
314
440
  success: false,
315
- state: "CREATE_FAILED",
441
+ state: rejected ? "CREATE_FAILED" : "INDETERMINATE",
316
442
  elapsedMs: 0,
317
443
  pollCount: 0,
318
444
  stage: "create",
319
- cleanupEligible: !rejected,
320
- remediation: rejected ? CREATE_REJECTED_REMEDIATION : SUCCESSOR_REMEDIATION,
445
+ outcome: rejected ? "rejected" : "indeterminate",
446
+ creationPostAttempted: true,
447
+ cleanupEligible: false,
448
+ recoveryCommands: rejected
449
+ ? []
450
+ : [`dss app instances ${appId}`,],
451
+ remediation: rejected ? CREATE_REJECTED_REMEDIATION : SUCCESSOR_UNBOUND_REMEDIATION,
321
452
  ...safeErrorSummary(error),
322
453
  };
323
454
  }
@@ -342,11 +473,13 @@ export async function createSuccessorInstance(client, request, usage) {
342
473
  stage: "create",
343
474
  ...(instance ? { instance, } : {}),
344
475
  ...(jobId !== undefined ? { futureId: jobId, jobId, } : {}),
345
- cleanupEligible: true,
476
+ cleanupEligible: jobId !== undefined,
346
477
  error: "DSS named a different target project in the creation response.",
347
478
  expected: { projectKey: to, },
348
479
  actual: { projectKey: echoedTarget, },
349
- remediation: SUCCESSOR_REMEDIATION,
480
+ remediation: jobId !== undefined
481
+ ? SUCCESSOR_REMEDIATION
482
+ : SUCCESSOR_UNBOUND_REMEDIATION,
350
483
  };
351
484
  }
352
485
  const base = {
@@ -381,13 +514,16 @@ export async function createSuccessorInstance(client, request, usage) {
381
514
  : "scalar",
382
515
  }),
383
516
  success: false,
384
- state: "UNTRACKABLE",
517
+ state: "INDETERMINATE",
385
518
  elapsedMs: 0,
386
519
  pollCount: 0,
387
520
  stage: "future-wait",
388
- cleanupEligible: true,
521
+ outcome: "indeterminate",
522
+ creationPostAttempted: true,
523
+ cleanupEligible: false,
389
524
  error: "DSS returned no instance-creation future ID, so the successor instance cannot be verified.",
390
- remediation: SUCCESSOR_REMEDIATION,
525
+ remediation: SUCCESSOR_UNBOUND_REMEDIATION,
526
+ recoveryCommands: [`dss app instances ${appId}`,],
391
527
  };
392
528
  }
393
529
  else {
@@ -408,6 +544,7 @@ export async function createSuccessorInstance(client, request, usage) {
408
544
  pollCount: 0,
409
545
  stage: "future-wait",
410
546
  cleanupEligible: true,
547
+ recoveryCommands: [`dss future wait ${jobId}`, `dss app instances ${appId}`,],
411
548
  remediation: SUCCESSOR_REMEDIATION,
412
549
  ...safeErrorSummary(error),
413
550
  };
@@ -451,6 +588,9 @@ export async function createSuccessorInstance(client, request, usage) {
451
588
  if (!waited.success) {
452
589
  return failure(waited.state, "future-wait", {
453
590
  error: "The instance-creation future did not complete successfully.",
591
+ recoveryCommands: jobId !== undefined
592
+ ? [`dss future wait ${jobId}`, `dss app instances ${appId}`,]
593
+ : [`dss app instances ${appId}`,],
454
594
  });
455
595
  }
456
596
  try {
@@ -12,7 +12,7 @@ export declare function isFullRevision(value: unknown): boolean;
12
12
  export declare function gitFullRevision(packageRoot: string | undefined): string | undefined;
13
13
  export declare function gitRevision(packageRoot: string | undefined): string | undefined;
14
14
  export type CliLoadSource = "source" | "dist";
15
- export type CliRuntime = "bun" | "node";
15
+ export type CliRuntime = "bun";
16
16
  export declare function detectLoadSource(modulePath?: string): CliLoadSource;
17
17
  export declare function buildMetadataRevision(packageRoot: string | undefined): string | undefined;
18
18
  /**
@@ -37,6 +37,7 @@ export interface CliVersionPayload {
37
37
  buildRevision: string | null;
38
38
  staleBuild: boolean;
39
39
  }
40
+ /** Bun is the only supported runtime; the field is retained for payload stability. */
40
41
  export declare function detectRuntime(): CliRuntime;
41
42
  export declare function buildVersionPayload(input: {
42
43
  packageVersion: string;
@@ -182,8 +182,9 @@ export function sourceTreeNewerThanBuild(packageRoot) {
182
182
  return false;
183
183
  }
184
184
  }
185
+ /** Bun is the only supported runtime; the field is retained for payload stability. */
185
186
  export function detectRuntime() {
186
- return process.versions.bun ? "bun" : "node";
187
+ return "bun";
187
188
  }
188
189
  export function buildVersionPayload(input) {
189
190
  const isDist = input.loadSource === "dist";
package/dist/src/cli.js CHANGED
@@ -1241,8 +1241,11 @@ function buildErrorReport(err) {
1241
1241
  ok: false,
1242
1242
  error: err.message,
1243
1243
  code: err.code,
1244
- category: "usage",
1244
+ category: err.code === "target_absence_unverifiable"
1245
+ ? "permission_or_environment"
1246
+ : "usage",
1245
1247
  exitCode,
1248
+ ...(err.code === "target_absence_unverifiable" ? { retryable: false, } : {}),
1246
1249
  ...(err.hint ? { hint: err.hint, } : {}),
1247
1250
  ...(err.details ? { details: err.details, } : {}),
1248
1251
  ...context,
@@ -1,4 +1,4 @@
1
- import { openAsBlob, readFileSync, } from "node:fs";
1
+ import { readFileSync, } from "node:fs";
2
2
  import { basename, } from "node:path";
3
3
  import { getCACertificates, } from "node:tls";
4
4
  import { Value, } from "@sinclair/typebox/value";
@@ -479,7 +479,7 @@ export class DataikuClient {
479
479
  });
480
480
  }
481
481
  async uploadResponse(path, filePath, fileName) {
482
- const fileBlob = await openAsBlob(filePath);
482
+ const fileBlob = Bun.file(filePath);
483
483
  const formData = new FormData();
484
484
  formData.append("file", fileBlob, fileName ?? basename(filePath));
485
485
  return this.fetchWithRetry(`${this.baseUrl}${path}`, {
@@ -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_flag_value" | "conflicting_input_sources" | "invalid_enum" | "not_found" | "permission_denied" | "validation_failed" | "transient" | "long_running_failure" | "command_result_failure" | "assertion_failed" | "ambiguous_outcome" | "internal_error";
2
+ export type StableErrorCode = "usage_error" | "unknown_flag" | "missing_required_arg" | "missing_required_flag" | "invalid_flag_value" | "conflicting_input_sources" | "invalid_enum" | "not_found" | "permission_denied" | "target_absence_unverifiable" | "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;
@@ -1,7 +1,4 @@
1
- import { createWriteStream, } from "node:fs";
2
- import { resolve, } from "node:path";
3
- import { Readable, } from "node:stream";
4
- import { pipeline, } from "node:stream/promises";
1
+ import * as nodePath from "node:path";
5
2
  import { FolderDetailsSchema, FolderItemArraySchema, FolderSummaryArraySchema, } from "../schemas.js";
6
3
  import { deepMerge, } from "../utils/deep-merge.js";
7
4
  import { sanitizeFileName, } from "../utils/sanitize.js";
@@ -72,10 +69,11 @@ export class FoldersResource extends BaseResource {
72
69
  const normalizedPath = normalizeRemotePath(path);
73
70
  const pEnc = encodeURIComponent(normalizedPath);
74
71
  const res = await this.client.stream(`/public/api/projects/${this.enc(opts?.projectKey)}/managedfolders/${fEnc}/contents/${pEnc}`);
75
- const dest = opts?.localPath ?? resolve(process.cwd(), inferDownloadFileName(normalizedPath));
76
- const nodeStream = Readable.fromWeb(res.body);
77
- const fileOut = createWriteStream(dest);
78
- await pipeline(nodeStream, fileOut);
72
+ if (!res.body)
73
+ throw new Error("folders.download response did not include a body");
74
+ const dest = opts?.localPath
75
+ ?? nodePath.resolve(process.cwd(), inferDownloadFileName(normalizedPath));
76
+ await Bun.write(dest, new Response(res.body), { createPath: false, });
79
77
  return dest;
80
78
  }
81
79
  upload(folderId, path, localPath, projectKey) {
@@ -1,5 +1,3 @@
1
- import { createWriteStream, } from "node:fs";
2
- import { Writable, } from "node:stream";
3
1
  import { ClientValidationError, DataikuError, } from "../errors.js";
4
2
  import { ProjectDetailsSchema, ProjectMetadataSchema, ProjectSummaryArraySchema, } from "../schemas.js";
5
3
  import { normalizeFlowGraph, } from "../utils/flow-map.js";
@@ -159,14 +157,7 @@ export class ProjectsResource extends BaseResource {
159
157
  const res = await this.postStream(`/public/api/projects/${encodeURIComponent(projectKey)}/export`, options ?? {});
160
158
  if (!res.body)
161
159
  throw new Error("projects.exportArchive response did not include a body");
162
- const file = createWriteStream(filePath);
163
- try {
164
- await res.body.pipeTo(Writable.toWeb(file));
165
- }
166
- catch (error) {
167
- file.destroy();
168
- throw error;
169
- }
160
+ await Bun.write(filePath, new Response(res.body), { createPath: false, });
170
161
  }
171
162
  /** Upload a project archive and return its temporary import handle. */
172
163
  async prepareProjectImport(filePath) {
package/dist/src/skill.js CHANGED
@@ -1,4 +1,3 @@
1
- import { execFileSync, } from "node:child_process";
2
1
  import * as fs from "node:fs";
3
2
  import * as os from "node:os";
4
3
  import * as path from "node:path";
@@ -61,24 +60,11 @@ export const AGENTS = {
61
60
  content: skillContent,
62
61
  },
63
62
  };
64
- // ---------------------------------------------------------------------------
65
- // Agent detection
66
- // ---------------------------------------------------------------------------
67
- function binaryExists(name) {
68
- const cmd = process.platform === "win32" ? "where" : "which";
69
- try {
70
- execFileSync(cmd, [name,], { stdio: "pipe", });
71
- return true;
72
- }
73
- catch {
74
- return false;
75
- }
76
- }
77
63
  export function detectAgents() {
78
64
  const home = os.homedir();
79
65
  const found = [];
80
66
  for (const [id, def,] of Object.entries(AGENTS)) {
81
- const hasBinary = binaryExists(def.binary);
67
+ const hasBinary = Bun.which(def.binary) !== null;
82
68
  const hasConfigDir = fs.existsSync(path.join(home, def.configDir));
83
69
  if (hasBinary && (!def.configDirRequired || hasConfigDir)) {
84
70
  found.push({ id, def, via: "binary", });
@@ -90,9 +76,6 @@ export function detectAgents() {
90
76
  return found;
91
77
  }
92
78
  // ---------------------------------------------------------------------------
93
- // Skill installation
94
- // ---------------------------------------------------------------------------
95
- // ---------------------------------------------------------------------------
96
79
  // Workspace root detection
97
80
  // ---------------------------------------------------------------------------
98
81
  const WORKSPACE_MARKERS = [".git",];
@@ -17,7 +17,7 @@ export interface ProjectArchiveInspection {
17
17
  * Inspect a DSS project export ZIP archive without extracting anything to
18
18
  * disk: enumerate every central-directory record, validate member paths
19
19
  * independently, stream every file member through a size/CRC-32 check with
20
- * bounded memory (manual `node:zlib.crc32`, covering data-descriptor
20
+ * bounded memory (manual `Bun.hash.crc32`, covering data-descriptor
21
21
  * records), parse `export-manifest.json` and the source project key, and run
22
22
  * the generic dangling-reference/orphan-member graph checks. Identity,
23
23
  * history and machine-specific residue is reported only as warnings carrying
@@ -1,6 +1,5 @@
1
1
  import StreamZip from "node-stream-zip";
2
2
  import { stat, } from "node:fs/promises";
3
- import { crc32, } from "node:zlib";
4
3
  import { ClientValidationError, } from "../errors.js";
5
4
  const MANIFEST_MEMBER = "export-manifest.json";
6
5
  const DATASETS_ROOT = "project_config/datasets";
@@ -106,7 +105,7 @@ function isUnsafeMemberPath(name) {
106
105
  /**
107
106
  * Stream one member through a bounded-memory CRC-32/size check. The library's
108
107
  * own verification is bypassed for data-descriptor records (bit 3 set), so the
109
- * manual `node:zlib.crc32` computation covers every record uniformly.
108
+ * manual `Bun.hash.crc32` computation covers every record uniformly.
110
109
  *
111
110
  * When `accumulate` is set, buffered content never exceeds `maxBytes`: the
112
111
  * buffer is dropped as soon as the cap is reached while the member is still
@@ -130,7 +129,7 @@ function streamMember(zip, entry, accumulate, maxBytes = Number.POSITIVE_INFINIT
130
129
  stream.on("data", (chunk) => {
131
130
  const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
132
131
  bytes += buffer.length;
133
- crc = crc32(buffer, crc) >>> 0;
132
+ crc = Bun.hash.crc32(buffer, crc) >>> 0;
134
133
  if (!content || truncated)
135
134
  return;
136
135
  if (bytes > maxBytes) {
@@ -307,7 +306,7 @@ function residueIssue(member, counts) {
307
306
  * Inspect a DSS project export ZIP archive without extracting anything to
308
307
  * disk: enumerate every central-directory record, validate member paths
309
308
  * independently, stream every file member through a size/CRC-32 check with
310
- * bounded memory (manual `node:zlib.crc32`, covering data-descriptor
309
+ * bounded memory (manual `Bun.hash.crc32`, covering data-descriptor
311
310
  * records), parse `export-manifest.json` and the source project key, and run
312
311
  * the generic dangling-reference/orphan-member graph checks. Identity,
313
312
  * history and machine-specific residue is reported only as warnings carrying
@@ -1,6 +1,5 @@
1
- import { createHash, } from "node:crypto";
2
1
  export function sha256Hex(value) {
3
- return createHash("sha256").update(value).digest("hex");
2
+ return new Bun.CryptoHasher("sha256").update(value).digest("hex");
4
3
  }
5
4
  /** Locale-independent UTF-16 code-unit order for deterministic machine output. */
6
5
  export function compareStrings(a, b) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dataiku-sdk",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "description": "Dataiku DSS SDK and CLI for programmatic access to DSS REST APIs",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -19,15 +19,14 @@
19
19
  "packages/types/dist/"
20
20
  ],
21
21
  "engines": {
22
- "node": ">=22.15.0",
23
- "bun": ">=1.3.14"
22
+ "bun": ">=1.4.0"
24
23
  },
25
24
  "scripts": {
26
- "build": "tsc && node scripts/generate-build-metadata.mjs && tsc -p packages/types/tsconfig.json",
27
- "prepack": "node scripts/sync-plugin-version.mjs --check && tsc && node scripts/generate-build-metadata.mjs && tsc -p packages/types/tsconfig.json",
28
- "prepublishOnly": "node scripts/sync-plugin-version.mjs --check && tsc && node scripts/generate-build-metadata.mjs && tsc -p packages/types/tsconfig.json",
29
- "check": "tsc --noEmit && node scripts/sync-plugin-version.mjs --check",
30
- "version": "node scripts/sync-plugin-version.mjs && git add plugin.json",
25
+ "build": "tsc && bun scripts/generate-build-metadata.mjs && tsc -p packages/types/tsconfig.json",
26
+ "prepack": "bun scripts/sync-plugin-version.mjs --check && tsc && bun scripts/generate-build-metadata.mjs && tsc -p packages/types/tsconfig.json",
27
+ "prepublishOnly": "bun scripts/sync-plugin-version.mjs --check && tsc && bun scripts/generate-build-metadata.mjs && tsc -p packages/types/tsconfig.json",
28
+ "check": "tsc --noEmit && bun scripts/sync-plugin-version.mjs --check",
29
+ "version": "bun scripts/sync-plugin-version.mjs && git add plugin.json",
31
30
  "lint": "oxlint src/",
32
31
  "lint:fix": "oxlint --fix src/",
33
32
  "format": "dprint fmt",
@@ -41,7 +40,7 @@
41
40
  "test:integration:rigorous:mutating": "cross-env RUN_DATAIKU_INTEGRATION=1 RUN_DATAIKU_INTEGRATION_MUTATING=1 bun test tests/integration-rigorous.test.ts"
42
41
  },
43
42
  "devDependencies": {
44
- "@types/bun": "^1.3.14",
43
+ "@types/bun": "^1.4.0",
45
44
  "cross-env": "^10.1.0",
46
45
  "dprint": "^0.55.2",
47
46
  "js-tiktoken": "1.0.21",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "dataiku-sdk",
4
- "version": "1.1.0",
4
+ "version": "2.0.0",
5
5
  "description": "Dataiku DSS SDK and agent-oriented CLI for programmatic access to DSS REST APIs.",
6
6
  "homepage": "https://github.com/clssck/dataiku-sdk",
7
7
  "repository": "https://github.com/clssck/dataiku-sdk",
@@ -7,14 +7,14 @@ description: >-
7
7
  dss commands run --fields RESOURCE.ACTION.
8
8
  license: MIT
9
9
  compatibility: >-
10
- Requires the dss CLI, network access to a Dataiku DSS instance, and configured
11
- DATAIKU_URL and DATAIKU_API_KEY credentials. Source-checkout fallback requires Bun.
10
+ Requires the dss CLI, network access to a Dataiku DSS instance, configured
11
+ DATAIKU_URL and DATAIKU_API_KEY credentials, and Bun >= 1.4.0.
12
12
  ---
13
13
 
14
14
  # Dataiku DSS agent CLI
15
15
 
16
16
  Use `dss` when an agent needs to inspect or change Dataiku DSS resources: projects, datasets, recipes, jobs, scenarios, folders, notebooks, SQL, variables, code envs, and connections.
17
- If the installed `dss` binary is unavailable but the repository checkout is the current workspace, prefer `bun --no-env-file src/cli.ts ...` or `bun --no-env-file ./bin/dss.js ...`. Node users can invoke the same cross-runtime launcher as `node ./bin/dss.js ...`; when `dist/` has not been built, it delegates to Bun. From another working directory, pass the checkout's absolute `bin/dss.js` path to Bun or Node.
17
+ If the installed `dss` binary is unavailable but the repository checkout is the current workspace, prefer `bun --no-env-file src/cli.ts ...` or the packaged launcher `bun --no-env-file ./bin/dss.js ...`. From another working directory, pass the checkout's absolute `bin/dss.js` path to Bun.
18
18
  `--no-env-file` disables Bun's automatic preloading only; the CLI still applies its documented `.env` handling unless `DATAIKU_DISABLE_ENV=1` is set.
19
19
 
20
20
  ## Contract
@@ -126,7 +126,8 @@ recipient-sharing, or UI-click operations that the public DSS API does not expos
126
126
  dss app validate-manifest --project-key APP_TEMPLATE
127
127
  dss app compare-manifest APP_ID --project-key RELEASE_INSTANCE
128
128
  dss app manifest-version --project-key APP_TEMPLATE
129
- dss app set-manifest-version --manifest-version 1.4.0 --project-key APP_TEMPLATE
129
+ dss app successor-preflight APP_ID --from RELEASE_INSTANCE --to RELEASE_INSTANCE_V2 --copy-permissions
130
+ dss app set-manifest-version --manifest-version 1.4.0 --expect-hash PREFLIGHT_TEMPLATE_MANIFEST_HASH --project-key APP_TEMPLATE
130
131
  # targetProjectKey in instance.json must be confirmed absent
131
132
  dss app create-instance APP_ID --data-file instance.json --wait --record-cleanup cleanup.jsonl
132
133
  dss app create-successor-instance APP_ID --from RELEASE_INSTANCE --to RELEASE_INSTANCE_V2 --copy-permissions --record-cleanup cleanup.jsonl
@@ -137,39 +138,30 @@ dss app permissions-diff --project-key RELEASE_INSTANCE --file permissions.json
137
138
  dss app permissions-restore --project-key RELEASE_INSTANCE --file permissions.json --dry-run
138
139
  ```
139
140
 
140
- An invalid manifest exits non-zero with the validation result in the structured error details.
141
- Review the cleanup preview before `dss cleanup --file cleanup.jsonl --apply`. Every new entry is
142
- bound to the canonical DSS URL. App cleanup records a `creationTag` hash observed after the DSS
143
- future identifies its target key; unconfirmed creation stops unresolved. The future target and
144
- later `creationTag` are independent, non-atomic observations because the public API exposes
145
- neither an immutable project ID joined to the future nor a conditional DELETE. Cleanup rechecks
146
- type and `creationTag` immediately before deletion, rejecting detected key reuse but unable to
147
- eliminate replacement in the remaining check-to-DELETE gap. Apply validates the full ledger before
148
- any request and rejects legacy, mixed-server, mismatched-server, or unbound app cleanup entries.
149
- The successor's cleanup ledger entry targets only the new project key;
150
- the predecessor is never targeted. Some DSS deployments return 403 for an unknown target project:
151
- instance creation requires confirmed target absence before POST, so an inaccessible target absent
152
- from the visible project list is still rejected as unconfirmed rather than risking cleanup against
153
- a pre-existing project. A definitive create rejection never produces a cleanup entry.
141
+ Run `successor-preflight` before changing the template version. It validates the template,
142
+ predecessor, target, and optional ACL snapshot, performs no mutation, and returns the template
143
+ manifest hash for the next `set-manifest-version --expect-hash` guard.
144
+
145
+ A masked `403` absent from both visible lists is rejected as
146
+ `target_absence_unverifiable` / `permission_or_environment`; lists prove collisions, not absence.
147
+ DSS exposes no permission-independent public key-availability endpoint or guaranteed
148
+ non-overwriting duplicate-key rejection, so no force or server-atomic bypass is supported. Use
149
+ global project visibility. A definitive rejection writes no cleanup entry. An ambiguous POST
150
+ without a returned future ID or verified incarnation is `INDETERMINATE` and also produces no cleanup
151
+ entry. Future-addressable cleanup waits for target identity and `creationTag`; the predecessor is
152
+ never targeted. Ledgers bind the canonical DSS URL, project key, and concrete project incarnation,
153
+ and reject legacy, mixed-server, mismatched-server, or unbound app cleanup entries. Static plans
154
+ expose `preflightExecuted:false` and `preflightWillRunDuringApply:true`.
154
155
 
155
156
  `app set-manifest-version` reports
156
- `concurrencyControl:"client-side-non-atomic-stale-read-check"`. `--expect-hash SHA256` refuses the
157
- write when the manifest already changed, but the PUT itself stays unconditional: this command can
158
- overwrite a writer that commits inside the read-then-write window, and the post-write read cannot
159
- detect that lost update when this command's payload wins. Never treat the hash as a serializing lock.
160
- An ambiguous manifest PUT or verification read exits non-zero with `persisted:null`,
161
- `after:null`, and `outcome:"indeterminate"`; it never claims success or rejection.
162
-
163
- The API key authenticates public REST only. `app verify-instance` reports `apiReady:true`,
164
- `status:"API_VERIFIED_UI_PENDING"`, and `uiPublicationVerified:false`. Its `visual-ui` gate
165
- requires a pre-authenticated SSO browser session or dedicated UI test identity and evidence from
166
- exercising the affected tiles, forms, and actions; the CLI never marks that external gate complete.
167
- Permission snapshots have an integrity hash bound to the canonical DSS URL, project key, and
168
- observed concrete project incarnation from `creationTag`; diff and restore reject detected server,
169
- key, or incarnation mismatches. DSS exposes no conditional permission PUT, so these client-side checks
170
- narrow and detect key-reuse races but cannot serialize the final check with the write. Snapshots
171
- contain access-control identities; keep them mode `0600` and commit them only when repository
172
- policy permits.
157
+ `concurrencyControl:"client-side-non-atomic-stale-read-check"`. `--expect-hash` is a stale-read
158
+ guard; the PUT is unconditional. Never treat the hash as a serializing lock. Ambiguous writes report
159
+ `outcome:"indeterminate"`.
160
+
161
+ The API key authenticates public REST only. `app verify-instance` reports
162
+ `status:"API_VERIFIED_UI_PENDING"`; its external SSO gate requires exercising the affected tiles,
163
+ forms, and actions. Permission snapshots bind identity and permissions to the server/project
164
+ incarnation and reject mismatches.
173
165
 
174
166
  ## Project Git
175
167