@dataparade/cli 0.0.7 → 0.0.8

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/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dataparade/cli",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "description": "dataPARADE CLI — scan codebases for privacy and security data flows",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "license": "GPL-3.0-or-later",
10
10
  "author": "DataParade",
11
- "homepage": "https://app.dataparade.com/docs/cli-import-and-zip",
11
+ "homepage": "https://dataparade.io",
12
12
  "repository": {
13
13
  "type": "git",
14
14
  "url": "git+https://github.com/DataParade-io/dataparade-cli.git"
@@ -103,7 +103,10 @@ class ChatCompletionsFamilyProvider {
103
103
  const body = {
104
104
  model,
105
105
  messages: [
106
- { role: "system", content: provider_enrichment_prompts_1.AI_PROVIDER_SYSTEM_PROMPT },
106
+ {
107
+ role: "system",
108
+ content: request.systemPrompt ?? provider_enrichment_prompts_1.AI_PROVIDER_SYSTEM_PROMPT,
109
+ },
107
110
  { role: "user", content: request.prompt },
108
111
  ],
109
112
  temperature: request.temperature ?? 0.1,
@@ -34,7 +34,9 @@ class GenerateContentFamilyProvider {
34
34
  "content-type": "application/json",
35
35
  },
36
36
  body: JSON.stringify({
37
- systemInstruction: { parts: [{ text: provider_enrichment_prompts_1.AI_PROVIDER_SYSTEM_PROMPT }] },
37
+ systemInstruction: {
38
+ parts: [{ text: request.systemPrompt ?? provider_enrichment_prompts_1.AI_PROVIDER_SYSTEM_PROMPT }],
39
+ },
38
40
  contents: [
39
41
  {
40
42
  role: "user",
@@ -33,7 +33,7 @@ class MessagesFamilyProvider {
33
33
  model,
34
34
  max_tokens: maxTokens,
35
35
  temperature: request.temperature ?? 0.1,
36
- system: (0, provider_enrichment_prompts_1.buildAnthropicEnrichmentSystemPrompt)(),
36
+ system: request.systemPrompt ?? (0, provider_enrichment_prompts_1.buildAnthropicEnrichmentSystemPrompt)(),
37
37
  messages: [{ role: "user", content: request.prompt }],
38
38
  }),
39
39
  });
@@ -23,7 +23,7 @@ class OllamaGenerateFamilyProvider {
23
23
  },
24
24
  body: JSON.stringify({
25
25
  model,
26
- prompt: `${provider_enrichment_prompts_1.AI_PROVIDER_SYSTEM_PROMPT}\n\nUser payload:\n${request.prompt}`,
26
+ prompt: `${request.systemPrompt ?? provider_enrichment_prompts_1.AI_PROVIDER_SYSTEM_PROMPT}\n\nUser payload:\n${request.prompt}`,
27
27
  stream: false,
28
28
  options: {
29
29
  temperature: request.temperature ?? 0.1,
@@ -6,6 +6,12 @@ export interface AiProviderRequest {
6
6
  endpoint?: string;
7
7
  temperature?: number;
8
8
  maxTokens?: number;
9
+ /**
10
+ * Overrides the bundled enrichment system prompt (DP-P0-CLI-3813).
11
+ * Set by the DataParade backend on the Platform AI path so the server owns
12
+ * the system role. Unset for BYOK, which keeps the CLI-bundled prompt.
13
+ */
14
+ systemPrompt?: string;
9
15
  }
10
16
  export interface AiProviderUsage {
11
17
  inputTokens: number;
package/dist/src/cli.js CHANGED
@@ -155,6 +155,7 @@ function createProgram() {
155
155
  let sentryScanRoot;
156
156
  let sentryAiMode;
157
157
  let sentryAiProvider;
158
+ const isInteractive = process.stdout.isTTY;
158
159
  const workspaceApiKey = options.workspaceApiKey?.trim() ||
159
160
  options.apiKey?.trim() ||
160
161
  (0, scan_env_1.resolveWorkspaceApiKey)(process.env);
@@ -182,7 +183,6 @@ function createProgram() {
182
183
  process.exitCode = 2;
183
184
  return;
184
185
  }
185
- const isInteractive = process.stdout.isTTY;
186
186
  const [{ scan, createDefaultScanConfiguration }, { buildDiagramGraphFromScanResult }] = await Promise.all([
187
187
  Promise.resolve().then(() => __importStar(require("./core/pipeline/orchestrator"))),
188
188
  Promise.resolve().then(() => __importStar(require("./core/pipeline/graph-mapping"))),
@@ -482,26 +482,37 @@ function createProgram() {
482
482
  failureMessage: exitFailed ? "CLI scan did not complete successfully" : undefined,
483
483
  });
484
484
  quotaCompletionReported = true;
485
+ if (isInteractive) {
486
+ // eslint-disable-next-line no-console
487
+ console.log("[scan] Scan finished — you can start a new scan now.");
488
+ }
485
489
  }
486
490
  }
487
491
  catch (err) {
488
492
  const message = err instanceof Error ? err.message : "Unknown error during scan.";
489
493
  fallbackFailureMessage = message;
490
- const { CliScanQuotaExceededError } = await Promise.resolve().then(() => __importStar(require("./platform-api/scan-quota-client")));
494
+ const { CliScanQuotaExceededError, CliScanAlreadyRunningError } = await Promise.resolve().then(() => __importStar(require("./platform-api/scan-quota-client")));
491
495
  const quotaBlocked = err instanceof CliScanQuotaExceededError;
496
+ const scanInProgress = err instanceof CliScanAlreadyRunningError;
492
497
  await (0, scan_sentry_1.reportScanCliError)({
493
498
  error: err,
494
499
  scanRoot: sentryScanRoot,
495
500
  jobId: cliQuotaJobId,
496
501
  aiMode: sentryAiMode,
497
502
  aiProvider: sentryAiProvider,
498
- failurePhase: quotaBlocked ? "preflight" : "scan_command",
499
- failureCode: quotaBlocked ? "scan_quota_exceeded" : "scan_exception",
503
+ failurePhase: quotaBlocked || scanInProgress ? "preflight" : "scan_command",
504
+ failureCode: quotaBlocked
505
+ ? "scan_quota_exceeded"
506
+ : scanInProgress
507
+ ? "scan_already_running"
508
+ : "scan_exception",
500
509
  });
501
510
  // eslint-disable-next-line no-console
502
511
  console.error(quotaBlocked
503
512
  ? `[scan] workspace quota: ${message}`
504
- : `Scan failed: ${message}`);
513
+ : scanInProgress
514
+ ? `[scan] scan in progress: ${message}`
515
+ : `Scan failed: ${message}`);
505
516
  process.exitCode = 1;
506
517
  }
507
518
  finally {
@@ -516,6 +527,10 @@ function createProgram() {
516
527
  failureMessage: fallbackFailureMessage,
517
528
  });
518
529
  quotaCompletionReported = true;
530
+ if (isInteractive) {
531
+ // eslint-disable-next-line no-console
532
+ console.log("[scan] Scan finished — you can start a new scan now.");
533
+ }
519
534
  }
520
535
  catch {
521
536
  // Quota report failure must not mask the original scan error.
@@ -0,0 +1,18 @@
1
+ type NestedApiError = {
2
+ code?: string;
3
+ message?: string;
4
+ };
5
+ export type CliApiErrorBody = {
6
+ code?: string;
7
+ message?: string | string[] | NestedApiError;
8
+ statusCode?: number;
9
+ };
10
+ export declare function parseCliApiErrorBody(body: unknown): {
11
+ code?: string;
12
+ message?: string;
13
+ };
14
+ export declare function readCliApiErrorMessage(body: unknown, fallback: string): {
15
+ code?: string;
16
+ message: string;
17
+ };
18
+ export {};
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseCliApiErrorBody = parseCliApiErrorBody;
4
+ exports.readCliApiErrorMessage = readCliApiErrorMessage;
5
+ function parseCliApiErrorBody(body) {
6
+ if (!body || typeof body !== "object") {
7
+ return {};
8
+ }
9
+ const parsed = body;
10
+ if (typeof parsed.message === "string") {
11
+ return { code: parsed.code, message: parsed.message };
12
+ }
13
+ if (Array.isArray(parsed.message)) {
14
+ return { code: parsed.code, message: parsed.message.join(", ") };
15
+ }
16
+ if (parsed.message && typeof parsed.message === "object") {
17
+ const nested = parsed.message;
18
+ return {
19
+ code: nested.code ?? parsed.code,
20
+ message: typeof nested.message === "string" ? nested.message : undefined,
21
+ };
22
+ }
23
+ return { code: parsed.code };
24
+ }
25
+ function readCliApiErrorMessage(body, fallback) {
26
+ const parsed = parseCliApiErrorBody(body);
27
+ return {
28
+ code: parsed.code,
29
+ message: parsed.message ?? fallback,
30
+ };
31
+ }
@@ -3,6 +3,11 @@ export declare class CliScanQuotaExceededError extends Error {
3
3
  readonly code: "scan_quota_exceeded";
4
4
  constructor(message: string);
5
5
  }
6
+ /** Thrown when preflight is rejected with HTTP 409 scan_already_running. */
7
+ export declare class CliScanAlreadyRunningError extends Error {
8
+ readonly code: "scan_already_running";
9
+ constructor(message: string);
10
+ }
6
11
  export type CliScanPreflightResponse = {
7
12
  allowed: boolean;
8
13
  jobId: string;
@@ -1,9 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.CliScanQuotaExceededError = void 0;
3
+ exports.CliScanAlreadyRunningError = exports.CliScanQuotaExceededError = void 0;
4
4
  exports.cliScanPreflight = cliScanPreflight;
5
5
  exports.cliScanComplete = cliScanComplete;
6
6
  const dataparade_api_base_url_1 = require("./dataparade-api-base-url");
7
+ const read_cli_api_error_message_1 = require("./read-cli-api-error-message");
7
8
  /** Thrown when preflight/complete is rejected with HTTP 403 scan_quota_exceeded. */
8
9
  class CliScanQuotaExceededError extends Error {
9
10
  constructor(message) {
@@ -13,6 +14,15 @@ class CliScanQuotaExceededError extends Error {
13
14
  }
14
15
  }
15
16
  exports.CliScanQuotaExceededError = CliScanQuotaExceededError;
17
+ /** Thrown when preflight is rejected with HTTP 409 scan_already_running. */
18
+ class CliScanAlreadyRunningError extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.code = "scan_already_running";
22
+ this.name = "CliScanAlreadyRunningError";
23
+ }
24
+ }
25
+ exports.CliScanAlreadyRunningError = CliScanAlreadyRunningError;
16
26
  function buildHeaders(apiKey) {
17
27
  return {
18
28
  Authorization: `Bearer ${apiKey}`,
@@ -31,15 +41,14 @@ async function cliScanPreflight(input) {
31
41
  }),
32
42
  });
33
43
  if (!res.ok) {
34
- let message = `Scan preflight failed (${res.status})`;
44
+ const fallback = `Scan preflight failed (${res.status})`;
35
45
  let code;
46
+ let message = fallback;
36
47
  try {
37
- const body = (await res.json());
38
- code = body.code;
39
- if (typeof body.message === "string")
40
- message = body.message;
41
- else if (Array.isArray(body.message))
42
- message = body.message.join(", ");
48
+ const body = await res.json();
49
+ const parsed = (0, read_cli_api_error_message_1.readCliApiErrorMessage)(body, fallback);
50
+ code = parsed.code;
51
+ message = parsed.message;
43
52
  }
44
53
  catch {
45
54
  // ignore
@@ -47,6 +56,9 @@ async function cliScanPreflight(input) {
47
56
  if (res.status === 403 && code === "scan_quota_exceeded") {
48
57
  throw new CliScanQuotaExceededError(message);
49
58
  }
59
+ if (res.status === 409 && code === "scan_already_running") {
60
+ throw new CliScanAlreadyRunningError(message);
61
+ }
50
62
  throw new Error(message);
51
63
  }
52
64
  return (await res.json());
@@ -63,13 +75,11 @@ async function cliScanComplete(input) {
63
75
  }),
64
76
  });
65
77
  if (!res.ok) {
66
- let message = `Scan complete report failed (${res.status})`;
78
+ const fallback = `Scan complete report failed (${res.status})`;
79
+ let message = fallback;
67
80
  try {
68
- const body = (await res.json());
69
- if (typeof body.message === "string")
70
- message = body.message;
71
- else if (Array.isArray(body.message))
72
- message = body.message.join(", ");
81
+ const body = await res.json();
82
+ message = (0, read_cli_api_error_message_1.readCliApiErrorMessage)(body, fallback).message;
73
83
  }
74
84
  catch {
75
85
  // ignore
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const provider_prompt_1 = require("../../../src/ai-enrichment/provider-prompt");
4
+ /**
5
+ * Backend contract guard (DP-P0-CLI-3810).
6
+ *
7
+ * The backend validates every platform infer prompt against
8
+ * backend/src/scans/scan-cli-infer-prompt.schema.ts:
9
+ * - instructions: non-empty string
10
+ * - agent: tpAgent | propertyAgent | directionAgent | interactionAgent
11
+ * - candidates: non-empty array, each with id (string) + candidateType (enum)
12
+ * - componentContext: object
13
+ * - canonicalComponentIds: string[]
14
+ *
15
+ * If buildProviderPromptPayload stops satisfying this shape, platform AI scans
16
+ * will be rejected with 400 invalid_infer_prompt. Keep both sides in sync.
17
+ */
18
+ const AGENTS = [
19
+ "tpAgent",
20
+ "propertyAgent",
21
+ "directionAgent",
22
+ "interactionAgent",
23
+ ];
24
+ const CANDIDATE_TYPES = new Set([
25
+ "third_party",
26
+ "node_property",
27
+ "flow_direction",
28
+ "missing_interaction",
29
+ ]);
30
+ function makeComponent() {
31
+ return {
32
+ id: "tp_1",
33
+ name: "Acme API",
34
+ type: "third_party",
35
+ confidence: 0.9,
36
+ detectedFrom: [],
37
+ sourceLocations: [{ filePath: "src/lib/acme.ts", startLine: 1, endLine: 5 }],
38
+ properties: { vendor: null },
39
+ };
40
+ }
41
+ function makeCandidate() {
42
+ return {
43
+ id: "cand-1",
44
+ candidateType: "node_property",
45
+ priority: 1,
46
+ componentId: "tp_1",
47
+ missingFields: ["vendor"],
48
+ rationale: "sparse properties",
49
+ hints: [],
50
+ };
51
+ }
52
+ function assertMatchesBackendContract(payload) {
53
+ expect(typeof payload.instructions).toBe("string");
54
+ expect(payload.instructions.length).toBeGreaterThan(0);
55
+ expect(AGENTS).toContain(payload.agent);
56
+ expect(Array.isArray(payload.candidates)).toBe(true);
57
+ const candidates = payload.candidates;
58
+ expect(candidates.length).toBeGreaterThan(0);
59
+ for (const cand of candidates) {
60
+ expect(typeof cand.id).toBe("string");
61
+ expect(cand.id.length).toBeGreaterThan(0);
62
+ expect(CANDIDATE_TYPES.has(cand.candidateType)).toBe(true);
63
+ }
64
+ expect(payload.componentContext).toBeDefined();
65
+ expect(typeof payload.componentContext).toBe("object");
66
+ expect(Array.isArray(payload.componentContext)).toBe(false);
67
+ expect(Array.isArray(payload.canonicalComponentIds)).toBe(true);
68
+ for (const id of payload.canonicalComponentIds) {
69
+ expect(typeof id).toBe("string");
70
+ }
71
+ if (payload.relevantFileContents !== undefined) {
72
+ for (const value of Object.values(payload.relevantFileContents)) {
73
+ expect(typeof value).toBe("string");
74
+ }
75
+ }
76
+ // The wire format is JSON.stringify(payload); it must round-trip.
77
+ expect(() => JSON.parse(JSON.stringify(payload))).not.toThrow();
78
+ }
79
+ describe("buildProviderPromptPayload backend contract (DP-P0-CLI-3810)", () => {
80
+ it.each(AGENTS)("output for %s satisfies the backend infer schema", (agent) => {
81
+ const payload = (0, provider_prompt_1.buildProviderPromptPayload)({
82
+ agent,
83
+ queue: [makeCandidate()],
84
+ components: [makeComponent()],
85
+ dataFlows: [],
86
+ });
87
+ assertMatchesBackendContract(payload);
88
+ });
89
+ it("output with file excerpts still satisfies the contract", () => {
90
+ const payload = (0, provider_prompt_1.buildProviderPromptPayload)({
91
+ agent: "propertyAgent",
92
+ queue: [makeCandidate()],
93
+ components: [makeComponent()],
94
+ dataFlows: [],
95
+ files: [
96
+ {
97
+ path: "src/lib/acme.ts",
98
+ name: "acme.ts",
99
+ content: 'import { acme } from "acme";',
100
+ language: "typescript",
101
+ size: 30,
102
+ },
103
+ ],
104
+ });
105
+ assertMatchesBackendContract(payload);
106
+ });
107
+ it("flow-only candidates (empty componentContext) still satisfy the contract", () => {
108
+ const payload = (0, provider_prompt_1.buildProviderPromptPayload)({
109
+ agent: "directionAgent",
110
+ queue: [
111
+ {
112
+ id: "cand-flow",
113
+ candidateType: "flow_direction",
114
+ priority: 1,
115
+ flowId: "flow_1",
116
+ missingFields: [],
117
+ rationale: "ambiguous direction",
118
+ hints: [],
119
+ },
120
+ ],
121
+ components: [makeComponent()],
122
+ dataFlows: [
123
+ {
124
+ id: "flow_1",
125
+ type: "api_call",
126
+ confidence: 0.8,
127
+ sourceComponentId: "tp_1",
128
+ targetComponentId: "tp_1",
129
+ },
130
+ ],
131
+ });
132
+ assertMatchesBackendContract(payload);
133
+ });
134
+ });
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const chat_completions_family_1 = require("../../../../src/ai-enrichment/providers/families/chat-completions-family");
4
+ const messages_family_1 = require("../../../../src/ai-enrichment/providers/families/messages-family");
5
+ const resolve_provider_1 = require("../../../../src/ai-enrichment/providers/resolve-provider");
6
+ const provider_enrichment_prompts_1 = require("../../../../src/ai-enrichment/prompts/provider-enrichment-prompts");
7
+ /**
8
+ * DP-P0-CLI-3813: provider families must honor a caller-supplied `systemPrompt`
9
+ * (the DataParade backend owns it on the Platform AI path) and fall back to the
10
+ * bundled prompt when none is given (BYOK path unchanged).
11
+ */
12
+ const OVERRIDE = "SERVER-OWNED SYSTEM PROMPT (override)";
13
+ function jsonOk(body) {
14
+ return {
15
+ ok: true,
16
+ status: 200,
17
+ json: async () => body,
18
+ text: async () => JSON.stringify(body),
19
+ };
20
+ }
21
+ function lastRequestBody(fetchMock) {
22
+ const init = fetchMock.mock.calls[0][1];
23
+ return JSON.parse(String(init.body));
24
+ }
25
+ describe("provider system prompt override (DP-P0-CLI-3813)", () => {
26
+ const originalFetch = global.fetch;
27
+ const userPrompt = JSON.stringify({ agent: "propertyAgent", candidates: [] });
28
+ afterEach(() => {
29
+ global.fetch = originalFetch;
30
+ jest.resetAllMocks();
31
+ });
32
+ describe("OpenAI chat-completions", () => {
33
+ it("uses the override when systemPrompt is set", async () => {
34
+ const fetchMock = jest.fn(async () => jsonOk({ choices: [{ message: { content: '{"proposals":[]}' } }] }));
35
+ global.fetch = fetchMock;
36
+ const provider = new chat_completions_family_1.ChatCompletionsFamilyProvider((0, resolve_provider_1.resolveProviderConfig)("openai", { apiKey: "sk-test" }));
37
+ await provider.infer({
38
+ prompt: userPrompt,
39
+ model: "gpt-4o-mini",
40
+ apiKey: "sk-test",
41
+ systemPrompt: OVERRIDE,
42
+ });
43
+ const body = lastRequestBody(fetchMock);
44
+ const messages = body.messages;
45
+ expect(messages[0]).toEqual({ role: "system", content: OVERRIDE });
46
+ });
47
+ it("falls back to the bundled prompt when systemPrompt is absent (BYOK)", async () => {
48
+ const fetchMock = jest.fn(async () => jsonOk({ choices: [{ message: { content: '{"proposals":[]}' } }] }));
49
+ global.fetch = fetchMock;
50
+ const provider = new chat_completions_family_1.ChatCompletionsFamilyProvider((0, resolve_provider_1.resolveProviderConfig)("openai", { apiKey: "sk-test" }));
51
+ await provider.infer({
52
+ prompt: userPrompt,
53
+ model: "gpt-4o-mini",
54
+ apiKey: "sk-test",
55
+ });
56
+ const body = lastRequestBody(fetchMock);
57
+ const messages = body.messages;
58
+ expect(messages[0]).toEqual({
59
+ role: "system",
60
+ content: provider_enrichment_prompts_1.AI_PROVIDER_SYSTEM_PROMPT,
61
+ });
62
+ });
63
+ });
64
+ describe("Anthropic messages", () => {
65
+ it("uses the override when systemPrompt is set", async () => {
66
+ const fetchMock = jest.fn(async () => jsonOk({ content: [{ type: "text", text: '{"proposals":[]}' }] }));
67
+ global.fetch = fetchMock;
68
+ const provider = new messages_family_1.MessagesFamilyProvider((0, resolve_provider_1.resolveProviderConfig)("anthropic", { apiKey: "sk-ant" }));
69
+ await provider.infer({
70
+ prompt: userPrompt,
71
+ model: "claude-haiku-4-5",
72
+ apiKey: "sk-ant",
73
+ systemPrompt: OVERRIDE,
74
+ });
75
+ const body = lastRequestBody(fetchMock);
76
+ expect(body.system).toBe(OVERRIDE);
77
+ });
78
+ it("falls back to the bundled Anthropic prompt when absent (BYOK)", async () => {
79
+ const fetchMock = jest.fn(async () => jsonOk({ content: [{ type: "text", text: '{"proposals":[]}' }] }));
80
+ global.fetch = fetchMock;
81
+ const provider = new messages_family_1.MessagesFamilyProvider((0, resolve_provider_1.resolveProviderConfig)("anthropic", { apiKey: "sk-ant" }));
82
+ await provider.infer({
83
+ prompt: userPrompt,
84
+ model: "claude-haiku-4-5",
85
+ apiKey: "sk-ant",
86
+ });
87
+ const body = lastRequestBody(fetchMock);
88
+ expect(body.system).toBe((0, provider_enrichment_prompts_1.buildAnthropicEnrichmentSystemPrompt)());
89
+ });
90
+ });
91
+ });
@@ -12,7 +12,16 @@ describe("cli scan command - DP-P0-CLI-402 integration", () => {
12
12
  it("writes a valid dataflow.json wrapper to the output path", async () => {
13
13
  const fixturesRoot = path_1.default.join(__dirname, "..", "..", "fixtures", "typescript-basic");
14
14
  const outputPath = path_1.default.join(os_1.default.tmpdir(), `dataparade-scan-${Date.now()}.json`);
15
- await (0, cli_1.run)(["node", "cli", "scan", fixturesRoot, "--output", outputPath]);
15
+ await (0, cli_1.run)([
16
+ "node",
17
+ "cli",
18
+ "scan",
19
+ fixturesRoot,
20
+ "--output",
21
+ outputPath,
22
+ "--no-ai-inference",
23
+ "--skip-auto-upload",
24
+ ]);
16
25
  const contents = fs_1.default.readFileSync(outputPath, "utf8");
17
26
  const parsed = JSON.parse(contents);
18
27
  const validation = (0, dataflow_wrapper_schema_1.validateDataflowJson)(parsed);
@@ -107,6 +107,7 @@ describe("cli scan command - exit codes", () => {
107
107
  fixturesRoot,
108
108
  "--output",
109
109
  tmpOutputPath(),
110
+ "--no-ai-inference",
110
111
  ]);
111
112
  expect(process.exitCode).toBe(1);
112
113
  expect(outputJson.writeDataflowJson).toHaveBeenCalled();
@@ -144,6 +145,7 @@ describe("cli scan command - exit codes", () => {
144
145
  fixturesRoot,
145
146
  "--output",
146
147
  outputPath,
148
+ "--no-ai-inference",
147
149
  ]);
148
150
  expect(process.exitCode).toBe(1);
149
151
  expect(outputJson.writeDataflowJson).not.toHaveBeenCalled();
@@ -49,6 +49,20 @@ describe("scan quota flow", () => {
49
49
  expect(errorSpy).toHaveBeenCalledWith("[scan] workspace quota: No scan slots remaining in this workspace.");
50
50
  errorSpy.mockRestore();
51
51
  });
52
+ it("prints scan in progress message when preflight is blocked by running job", async () => {
53
+ process.env.DATAPARADE_WORKSPACE_API_KEY = "dp_live_test";
54
+ const errorSpy = jest.spyOn(console, "error").mockImplementation(() => { });
55
+ fetchMock.mockResolvedValueOnce(jsonResponse({
56
+ statusCode: 409,
57
+ message: {
58
+ code: "scan_already_running",
59
+ message: "Scan in progress — start a new scan after the current one completes.",
60
+ },
61
+ }, 409));
62
+ await (0, cli_1.run)(["node", "cli", "scan", tempRoot, "--ai-inference"]);
63
+ expect(errorSpy).toHaveBeenCalledWith("[scan] scan in progress: Scan in progress — start a new scan after the current one completes.");
64
+ errorSpy.mockRestore();
65
+ });
52
66
  it("reports failed completion when preflight succeeds but config validation fails", async () => {
53
67
  process.env.DATAPARADE_WORKSPACE_API_KEY = "dp_live_test";
54
68
  fetchMock
@@ -75,4 +89,33 @@ describe("scan quota flow", () => {
75
89
  expect(String(fetchMock.mock.calls[1][0])).toContain("/api/scans/cli/job-123/complete");
76
90
  expect(JSON.parse(String(fetchMock.mock.calls[1][1]?.body))).toEqual(expect.objectContaining({ status: "failed", failureCode: "scan_failed" }));
77
91
  });
92
+ it("prints ready-for-new-scan message after platform scan completes", async () => {
93
+ process.env.DATAPARADE_WORKSPACE_API_KEY = "dp_live_test";
94
+ process.env.DATAPARADE_SKIP_AUTO_UPLOAD = "true";
95
+ const logSpy = jest.spyOn(console, "log").mockImplementation(() => { });
96
+ const isTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
97
+ Object.defineProperty(process.stdout, "isTTY", {
98
+ value: true,
99
+ configurable: true,
100
+ });
101
+ fetchMock
102
+ .mockResolvedValueOnce(jsonResponse({
103
+ allowed: true,
104
+ jobId: "job-456",
105
+ scansRemaining: 2,
106
+ aiTokensRemaining: 1000,
107
+ suggestedAiBudgetTokens: 100,
108
+ aiDelivery: "platform_proxy",
109
+ }))
110
+ .mockResolvedValueOnce(jsonResponse({ ok: true }));
111
+ await (0, cli_1.run)(["node", "cli", "scan", tempRoot, "--ai-inference"]);
112
+ expect(logSpy).toHaveBeenCalledWith("[scan] Scan finished — you can start a new scan now.");
113
+ if (isTty) {
114
+ Object.defineProperty(process.stdout, "isTTY", isTty);
115
+ }
116
+ else {
117
+ delete process.stdout.isTTY;
118
+ }
119
+ logSpy.mockRestore();
120
+ });
78
121
  });
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const read_cli_api_error_message_1 = require("../../../src/platform-api/read-cli-api-error-message");
4
+ describe("readCliApiErrorMessage", () => {
5
+ it("reads flat quota error body", () => {
6
+ expect((0, read_cli_api_error_message_1.readCliApiErrorMessage)({
7
+ code: "scan_quota_exceeded",
8
+ message: "No scan slots remaining in this workspace.",
9
+ }, "fallback")).toEqual({
10
+ code: "scan_quota_exceeded",
11
+ message: "No scan slots remaining in this workspace.",
12
+ });
13
+ });
14
+ it("reads nested Nest conflict error body", () => {
15
+ expect((0, read_cli_api_error_message_1.readCliApiErrorMessage)({
16
+ statusCode: 409,
17
+ message: {
18
+ code: "scan_already_running",
19
+ message: "Scan in progress — start a new scan after the current one completes.",
20
+ },
21
+ }, "fallback")).toEqual({
22
+ code: "scan_already_running",
23
+ message: "Scan in progress — start a new scan after the current one completes.",
24
+ });
25
+ });
26
+ it("joins validation message arrays", () => {
27
+ expect((0, read_cli_api_error_message_1.parseCliApiErrorBody)({
28
+ message: ["field is required", "field must be a string"],
29
+ })).toEqual({
30
+ message: "field is required, field must be a string",
31
+ });
32
+ });
33
+ it("falls back when message is missing", () => {
34
+ expect((0, read_cli_api_error_message_1.readCliApiErrorMessage)({}, "Scan preflight failed (500)")).toEqual({
35
+ message: "Scan preflight failed (500)",
36
+ });
37
+ });
38
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dataparade/cli",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "description": "dataPARADE CLI — scan codebases for privacy and security data flows",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "license": "GPL-3.0-or-later",
10
10
  "author": "DataParade",
11
- "homepage": "https://app.dataparade.com/docs/cli-import-and-zip",
11
+ "homepage": "https://dataparade.io",
12
12
  "repository": {
13
13
  "type": "git",
14
14
  "url": "git+https://github.com/DataParade-io/dataparade-cli.git"