dataiku-sdk 1.0.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.
Files changed (43) hide show
  1. package/README.md +64 -10
  2. package/bin/dss.js +67 -78
  3. package/dist/build-metadata.json +1 -0
  4. package/dist/src/cli/commands/api-service.js +5 -4
  5. package/dist/src/cli/commands/app.js +45 -12
  6. package/dist/src/cli/commands/bundle.js +5 -4
  7. package/dist/src/cli/commands/data-quality.js +42 -0
  8. package/dist/src/cli/commands/dataset.d.ts +23 -0
  9. package/dist/src/cli/commands/dataset.js +187 -6
  10. package/dist/src/cli/commands/job.js +15 -2
  11. package/dist/src/cli/commands/project-library.js +4 -4
  12. package/dist/src/cli/commands/project.js +116 -12
  13. package/dist/src/cli/contract.d.ts +1 -0
  14. package/dist/src/cli/contract.js +194 -15
  15. package/dist/src/cli/doctor.d.ts +12 -2
  16. package/dist/src/cli/doctor.js +44 -13
  17. package/dist/src/cli/flags.js +2 -0
  18. package/dist/src/cli/helpers/app-successor.d.ts +15 -3
  19. package/dist/src/cli/helpers/app-successor.js +155 -15
  20. package/dist/src/cli/helpers/cleanup.js +30 -1
  21. package/dist/src/cli/output.d.ts +3 -1
  22. package/dist/src/cli/output.js +11 -6
  23. package/dist/src/cli/types.d.ts +2 -0
  24. package/dist/src/cli/version.d.ts +47 -4
  25. package/dist/src/cli/version.js +176 -9
  26. package/dist/src/cli.js +55 -14
  27. package/dist/src/client.d.ts +28 -1
  28. package/dist/src/client.js +34 -7
  29. package/dist/src/errors.d.ts +1 -1
  30. package/dist/src/index.d.ts +3 -2
  31. package/dist/src/index.js +1 -0
  32. package/dist/src/resources/datasets.d.ts +43 -0
  33. package/dist/src/resources/datasets.js +199 -28
  34. package/dist/src/resources/folders.js +6 -8
  35. package/dist/src/resources/projects.d.ts +36 -3
  36. package/dist/src/resources/projects.js +122 -20
  37. package/dist/src/skill.js +1 -18
  38. package/dist/src/utils/project-archive.d.ts +32 -0
  39. package/dist/src/utils/project-archive.js +623 -0
  40. package/dist/src/utils/stable-hash.js +1 -2
  41. package/package.json +10 -10
  42. package/plugin.json +1 -1
  43. package/skills/dataiku-dss/SKILL.md +27 -35
@@ -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 {
@@ -4,7 +4,7 @@ export function projectArg(projectKey) {
4
4
  }
5
5
  export function cleanupLedgerEntry(resource, action, args, flags, result, projectKey) {
6
6
  if (!(action.startsWith("create") || action === "clone" || action === "duplicate"
7
- || action === "upload"))
7
+ || action === "import" || action === "upload"))
8
8
  return undefined;
9
9
  const record = resultRecord(result);
10
10
  if (record.skipped !== undefined)
@@ -190,6 +190,35 @@ export function cleanupLedgerEntry(resource, action, args, flags, result, projec
190
190
  path: args[1],
191
191
  cleanup: { argv: ["folder", "delete-file", args[0], args[1], ...withProject,], },
192
192
  };
193
+ case "project.import": {
194
+ const usedProjectKey = stringField(record, ["usedProjectKey",]);
195
+ const recordedIncarnation = stringField(record, ["projectIncarnationHash",]);
196
+ const boundIncarnation = recordedIncarnation
197
+ && /^[0-9a-f]{64}$/.test(recordedIncarnation)
198
+ ? recordedIncarnation
199
+ : undefined;
200
+ // Only a verified success may produce a cleanup entry, and it must
201
+ // bind the concrete landed project incarnation: replaying an
202
+ // unbound delete could remove a replacement project that reused the
203
+ // key. Never emit legacy/unbound project entries.
204
+ if (!usedProjectKey || !boundIncarnation)
205
+ return undefined;
206
+ return {
207
+ ...base,
208
+ projectKey: usedProjectKey,
209
+ name: usedProjectKey,
210
+ cleanup: {
211
+ argv: [
212
+ "project",
213
+ "delete",
214
+ usedProjectKey,
215
+ "--if-exists",
216
+ "--expect-project-incarnation",
217
+ boundIncarnation,
218
+ ],
219
+ },
220
+ };
221
+ }
193
222
  case "app.create-instance":
194
223
  case "app.create-successor-instance": {
195
224
  const targetKey = stringField(record, ["projectKey", "targetProjectKey",]);
@@ -21,7 +21,8 @@ export declare function commandFailureExitCode(result: unknown): number | undefi
21
21
  * Stable error code for a command-level failure report. A failed synchronous
22
22
  * assertion (exit 4) is a distinct outcome from a failed long-running remote
23
23
  * operation (also exit 4), so agents must never see `long_running_failure`
24
- * for a result that finished synchronously with `unchanged: false`.
24
+ * for a result that finished synchronously with `unchanged: false` or
25
+ * `satisfied: false`.
25
26
  */
26
27
  export declare function commandFailureCode(result: unknown, exitCode: number): StableErrorCode;
27
28
  export declare class CommandResultFailure extends Error {
@@ -44,6 +45,7 @@ export declare function planResult(resource: string, action: string, options: {
44
45
  payload?: unknown;
45
46
  localWrites?: unknown;
46
47
  plannedAndDryRun?: boolean;
48
+ requests?: unknown;
47
49
  wait?: unknown;
48
50
  }): Record<string, unknown>;
49
51
  export declare function encodedProjectEndpoint(client: DataikuClient, projectKey: string | undefined, suffix: string): string;
@@ -87,10 +87,10 @@ export function isFailedWaitResult(result) {
87
87
  && (typeof record.state === "string" || typeof record.outcome === "string");
88
88
  }
89
89
  export function isAssertionFailureResult(result) {
90
- return result !== null
91
- && typeof result === "object"
92
- && !Array.isArray(result)
93
- && result.unchanged === false;
90
+ if (result === null || typeof result !== "object" || Array.isArray(result))
91
+ return false;
92
+ const record = result;
93
+ return record.unchanged === false || record.satisfied === false;
94
94
  }
95
95
  function isNestedAssertionFailureResult(result) {
96
96
  if (result === null || typeof result !== "object" || Array.isArray(result))
@@ -121,7 +121,8 @@ export function commandFailureExitCode(result) {
121
121
  * Stable error code for a command-level failure report. A failed synchronous
122
122
  * assertion (exit 4) is a distinct outcome from a failed long-running remote
123
123
  * operation (also exit 4), so agents must never see `long_running_failure`
124
- * for a result that finished synchronously with `unchanged: false`.
124
+ * for a result that finished synchronously with `unchanged: false` or
125
+ * `satisfied: false`.
125
126
  */
126
127
  export function commandFailureCode(result, exitCode) {
127
128
  if (isAssertionFailureResult(result) || isNestedAssertionFailureResult(result)) {
@@ -150,7 +151,10 @@ export function commandFailureMessage(result) {
150
151
  return `Command completed with failed long-running result${state ? `: ${state}` : ""}.`;
151
152
  }
152
153
  if (isAssertionFailureResult(result)) {
153
- return "Command completed with failed assertion result.";
154
+ const record = result;
155
+ return record.unchanged === false
156
+ ? "Command completed with failed assertion result."
157
+ : "Command completed with unsatisfied assertion result.";
154
158
  }
155
159
  if (isNestedAssertionFailureResult(result)) {
156
160
  return "Command completed with nested failed assertion result.";
@@ -188,6 +192,7 @@ export function planResult(resource, action, options) {
188
192
  ...(options.endpoint ? { endpoint: options.endpoint, } : {}),
189
193
  ...(options.payload !== undefined ? { payload: options.payload, } : {}),
190
194
  ...(options.localWrites !== undefined ? { localWrites: options.localWrites, } : {}),
195
+ ...(options.requests !== undefined ? { requests: options.requests, } : {}),
191
196
  ...(options.wait !== undefined ? { wait: options.wait, } : {}),
192
197
  idempotency: options.idempotency,
193
198
  async: options.asyncKind,
@@ -1,5 +1,6 @@
1
1
  import type { DataikuClient } from "../client.js";
2
2
  export type CommandHandler = (client: DataikuClient, args: string[], flags: Record<string, string | boolean>) => Promise<unknown>;
3
+ export type LocalCommandHandler = (args: string[], flags: Record<string, string | boolean>) => Promise<unknown>;
3
4
  export interface CommandPayloadSchema {
4
5
  stdin?: boolean;
5
6
  dataFlag?: boolean;
@@ -19,6 +20,7 @@ export interface CommandRegistryOverride {
19
20
  }
20
21
  export interface CommandMeta extends CommandRegistryOverride {
21
22
  handler: CommandHandler;
23
+ localHandler?: LocalCommandHandler;
22
24
  usage: string;
23
25
  description?: string;
24
26
  examples?: string[];
@@ -1,14 +1,57 @@
1
1
  export declare function findPackageRoot(): string | undefined;
2
2
  export declare function packageVersion(packageRoot: string | undefined): string;
3
3
  export declare function gitDirectory(packageRoot: string): string;
4
+ export declare function isFullRevision(value: unknown): boolean;
5
+ /**
6
+ * Full checkout HEAD revision (40-lowercase-hex) — the basis for staleness
7
+ * comparison. Resolution follows Git's own layouts: a detached revision, then
8
+ * loose refs in the worktree git dir, then the shared store reached through
9
+ * `commondir`, then packed-refs in each. Anything that is not a full
10
+ * lowercase hexadecimal revision is rejected.
11
+ */
12
+ export declare function gitFullRevision(packageRoot: string | undefined): string | undefined;
4
13
  export declare function gitRevision(packageRoot: string | undefined): string | undefined;
14
+ export type CliLoadSource = "source" | "dist";
15
+ export type CliRuntime = "bun";
16
+ export declare function detectLoadSource(modulePath?: string): CliLoadSource;
17
+ export declare function buildMetadataRevision(packageRoot: string | undefined): string | undefined;
18
+ /**
19
+ * Whether compiled CLI output predates any source input. The build metadata
20
+ * file is written after `tsc`, so its mtime is the build-completion marker.
21
+ * Installed packages omit `src/`; without both sides there is no drift claim.
22
+ */
23
+ export declare function sourceTreeNewerThanBuild(packageRoot: string | undefined): boolean;
24
+ /**
25
+ * CLI provenance payload. `source` distinguishes a source checkout run from a
26
+ * published dist run; `runtime` names the executing runtime. `buildRevision`
27
+ * is only ever claimed by a dist build that carries build metadata — source
28
+ * execution never claims one. `staleBuild` is true only when the running dist
29
+ * build revision differs from the checkout or a source input is newer than the
30
+ * build-completion marker.
31
+ */
32
+ export interface CliVersionPayload {
33
+ version: string;
34
+ gitRevision: string | null;
35
+ source: CliLoadSource;
36
+ runtime: CliRuntime;
37
+ buildRevision: string | null;
38
+ staleBuild: boolean;
39
+ }
40
+ /** Bun is the only supported runtime; the field is retained for payload stability. */
41
+ export declare function detectRuntime(): CliRuntime;
42
+ export declare function buildVersionPayload(input: {
43
+ packageVersion: string;
44
+ checkoutRevision: string | undefined;
45
+ checkoutFullRevision: string | undefined;
46
+ loadSource: string | undefined;
47
+ buildRevision: string | undefined;
48
+ sourceNewerThanBuild?: boolean;
49
+ runtime: CliRuntime;
50
+ }): CliVersionPayload;
5
51
  export declare const PACKAGE_ROOT: string | undefined;
6
52
  export declare const CLI_VERSION: string;
7
53
  export declare const CLI_GIT_REVISION: string | undefined;
8
54
  export declare const AGENT_CONTRACT_VERSION = 2;
9
55
  export declare const JSON_SCHEMA_DRAFT = "https://json-schema.org/draft/2020-12/schema";
10
56
  export declare const AGENT_CONTRACT_SCHEMA_ID = "https://clssck.github.io/dataiku-sdk/schemas/agent-contract-v2.json";
11
- export declare function cliVersionResult(): {
12
- version: string;
13
- gitRevision: string | null;
14
- };
57
+ export declare function cliVersionResult(): CliVersionPayload;
@@ -1,4 +1,4 @@
1
- import { readFileSync, } from "node:fs";
1
+ import { readdirSync, readFileSync, statSync, } from "node:fs";
2
2
  import { dirname, resolve, } from "node:path";
3
3
  import { fileURLToPath, } from "node:url";
4
4
  export function findPackageRoot() {
@@ -36,22 +36,175 @@ export function gitDirectory(packageRoot) {
36
36
  }
37
37
  return resolve(packageRoot, ".git");
38
38
  }
39
+ /**
40
+ * A full lowercase hexadecimal revision is the only provenance claim trusted.
41
+ * Git is never invoked from runtime version reporting; revisions are resolved
42
+ * directly from the filesystem layouts Git documents.
43
+ */
44
+ const FULL_REVISION_PATTERN = /^[0-9a-f]{40}$/;
45
+ export function isFullRevision(value) {
46
+ return typeof value === "string" && FULL_REVISION_PATTERN.test(value);
47
+ }
48
+ function readGitFile(base, relative) {
49
+ try {
50
+ return readFileSync(resolve(base, relative), "utf-8").trim();
51
+ }
52
+ catch {
53
+ return undefined;
54
+ }
55
+ }
56
+ function isSafeRefName(ref) {
57
+ return (ref.length > 0
58
+ && !ref.startsWith("/")
59
+ && !ref.endsWith("/")
60
+ && !ref.includes("\\")
61
+ && !ref.split("/").includes(".."));
62
+ }
63
+ /** Resolve an entry from packed-refs; peeled `^` lines never match a ref. */
64
+ function packedRefFile(base, ref) {
65
+ let contents;
66
+ try {
67
+ contents = readFileSync(resolve(base, "packed-refs"), "utf-8");
68
+ }
69
+ catch {
70
+ return undefined;
71
+ }
72
+ for (const line of contents.split("\n")) {
73
+ const trimmed = line.trim();
74
+ if (trimmed.length === 0 || trimmed.startsWith("#") || trimmed.startsWith("^"))
75
+ continue;
76
+ const splitAt = trimmed.indexOf(" ");
77
+ if (splitAt <= 0)
78
+ continue;
79
+ const hash = trimmed.slice(0, splitAt);
80
+ if (isFullRevision(hash) && trimmed.slice(splitAt + 1).trim() === ref)
81
+ return hash;
82
+ }
83
+ return undefined;
84
+ }
85
+ /**
86
+ * The shared git dir behind a linked worktree or submodule checkout: the
87
+ * `commondir` file inside the worktree's git dir, resolved relative to that
88
+ * dir. Normal checkouts (no `commondir` file) share their own git dir.
89
+ */
90
+ function gitCommonDirectory(gitDir) {
91
+ try {
92
+ const commondir = readFileSync(resolve(gitDir, "commondir"), "utf-8").trim();
93
+ return commondir.length > 0 ? resolve(gitDir, commondir) : undefined;
94
+ }
95
+ catch {
96
+ return undefined;
97
+ }
98
+ }
99
+ /**
100
+ * Full checkout HEAD revision (40-lowercase-hex) — the basis for staleness
101
+ * comparison. Resolution follows Git's own layouts: a detached revision, then
102
+ * loose refs in the worktree git dir, then the shared store reached through
103
+ * `commondir`, then packed-refs in each. Anything that is not a full
104
+ * lowercase hexadecimal revision is rejected.
105
+ */
106
+ export function gitFullRevision(packageRoot) {
107
+ if (!packageRoot)
108
+ return undefined;
109
+ const gitDir = gitDirectory(packageRoot);
110
+ const head = readGitFile(gitDir, "HEAD");
111
+ if (!head)
112
+ return undefined;
113
+ if (isFullRevision(head))
114
+ return head;
115
+ if (!head.startsWith("ref:"))
116
+ return undefined;
117
+ const ref = head.slice("ref:".length).trim();
118
+ if (!isSafeRefName(ref))
119
+ return undefined;
120
+ for (const base of [gitDir, gitCommonDirectory(gitDir),]) {
121
+ if (!base)
122
+ continue;
123
+ const loose = readGitFile(base, ref);
124
+ if (loose && isFullRevision(loose))
125
+ return loose;
126
+ const packed = packedRefFile(base, ref);
127
+ if (packed)
128
+ return packed;
129
+ }
130
+ return undefined;
131
+ }
39
132
  export function gitRevision(packageRoot) {
133
+ const full = gitFullRevision(packageRoot);
134
+ return full ? full.slice(0, 7) : undefined;
135
+ }
136
+ export function detectLoadSource(modulePath = fileURLToPath(import.meta.url)) {
137
+ return modulePath.replaceAll("\\", "/").includes("/dist/") ? "dist" : "source";
138
+ }
139
+ export function buildMetadataRevision(packageRoot) {
40
140
  if (!packageRoot)
41
141
  return undefined;
42
142
  try {
43
- const gitDir = gitDirectory(packageRoot);
44
- const head = readFileSync(resolve(gitDir, "HEAD"), "utf-8").trim();
45
- if (!head.startsWith("ref:"))
46
- return head.slice(0, 7);
47
- const ref = head.slice("ref:".length).trim();
48
- const full = readFileSync(resolve(gitDir, ref), "utf-8").trim();
49
- return full.slice(0, 7);
143
+ const metadata = JSON.parse(readFileSync(resolve(packageRoot, "dist", "build-metadata.json"), "utf-8"));
144
+ return typeof metadata.buildRevision === "string"
145
+ && isFullRevision(metadata.buildRevision)
146
+ ? metadata.buildRevision
147
+ : undefined;
50
148
  }
51
149
  catch {
52
150
  return undefined;
53
151
  }
54
152
  }
153
+ /**
154
+ * Whether compiled CLI output predates any source input. The build metadata
155
+ * file is written after `tsc`, so its mtime is the build-completion marker.
156
+ * Installed packages omit `src/`; without both sides there is no drift claim.
157
+ */
158
+ export function sourceTreeNewerThanBuild(packageRoot) {
159
+ if (!packageRoot)
160
+ return false;
161
+ try {
162
+ const buildMtimeMs = statSync(resolve(packageRoot, "dist", "build-metadata.json")).mtimeMs;
163
+ const pending = [resolve(packageRoot, "src"),];
164
+ while (pending.length > 0) {
165
+ const path = pending.pop();
166
+ if (!path)
167
+ break;
168
+ const stat = statSync(path);
169
+ if (stat.mtimeMs > buildMtimeMs)
170
+ return true;
171
+ if (!stat.isDirectory())
172
+ continue;
173
+ for (const entry of readdirSync(path, { withFileTypes: true, })) {
174
+ if (entry.isDirectory() || entry.isFile()) {
175
+ pending.push(resolve(path, entry.name));
176
+ }
177
+ }
178
+ }
179
+ return false;
180
+ }
181
+ catch {
182
+ return false;
183
+ }
184
+ }
185
+ /** Bun is the only supported runtime; the field is retained for payload stability. */
186
+ export function detectRuntime() {
187
+ return "bun";
188
+ }
189
+ export function buildVersionPayload(input) {
190
+ const isDist = input.loadSource === "dist";
191
+ // Source execution never claims a build revision, even if the environment
192
+ // happens to carry one.
193
+ const buildRevision = isDist && input.buildRevision ? input.buildRevision : null;
194
+ const staleBuild = isDist
195
+ && ((buildRevision !== null
196
+ && input.checkoutFullRevision !== undefined
197
+ && input.checkoutFullRevision !== buildRevision)
198
+ || input.sourceNewerThanBuild === true);
199
+ return {
200
+ version: input.packageVersion,
201
+ gitRevision: input.checkoutRevision ?? null,
202
+ source: isDist ? "dist" : "source",
203
+ runtime: input.runtime,
204
+ buildRevision,
205
+ staleBuild,
206
+ };
207
+ }
55
208
  export const PACKAGE_ROOT = findPackageRoot();
56
209
  export const CLI_VERSION = packageVersion(PACKAGE_ROOT);
57
210
  export const CLI_GIT_REVISION = gitRevision(PACKAGE_ROOT);
@@ -59,5 +212,19 @@ export const AGENT_CONTRACT_VERSION = 2;
59
212
  export const JSON_SCHEMA_DRAFT = "https://json-schema.org/draft/2020-12/schema";
60
213
  export const AGENT_CONTRACT_SCHEMA_ID = "https://clssck.github.io/dataiku-sdk/schemas/agent-contract-v2.json";
61
214
  export function cliVersionResult() {
62
- return { version: CLI_VERSION, gitRevision: CLI_GIT_REVISION ?? null, };
215
+ const loadSource = process.env["DSS_LOAD_SOURCE"] ?? detectLoadSource();
216
+ const envBuildRevision = process.env["DSS_BUILD_REVISION"];
217
+ return buildVersionPayload({
218
+ packageVersion: CLI_VERSION,
219
+ checkoutRevision: CLI_GIT_REVISION,
220
+ checkoutFullRevision: gitFullRevision(PACKAGE_ROOT),
221
+ loadSource,
222
+ buildRevision: isFullRevision(envBuildRevision)
223
+ ? envBuildRevision
224
+ : (loadSource === "dist" ? buildMetadataRevision(PACKAGE_ROOT) : undefined),
225
+ sourceNewerThanBuild: loadSource === "dist"
226
+ ? sourceTreeNewerThanBuild(PACKAGE_ROOT)
227
+ : false,
228
+ runtime: detectRuntime(),
229
+ });
63
230
  }