Skip to content

Commit 04c2ebb

Browse files
authored
Merge pull request #1547 from tailor-platform/feat/improve-setup
feat(cli): route deploy --dry-run output to stdout and add --json support
2 parents 5b6f9f8 + 15c1df6 commit 04c2ebb

24 files changed

Lines changed: 489 additions & 243 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tailor-platform/sdk": minor
3+
---
4+
5+
Route `deploy --dry-run` plan diff output to stdout so CI pipelines can capture it cleanly without `2>&1`. Add `--json` / `-j` support to `deploy`: dry-run outputs `{ summary, changes, warnings, conflicts }` and apply outputs `{ summary, status: "applied" }` to stdout for machine-readable consumption. The `warnings` array includes unmanaged resources and skipped secrets; the `conflicts` array lists owner conflicts.

packages/sdk/docs/cli/application.md

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,45 @@ Before applying changes, `deploy` shows a preview of the planned resource change
110110
After the detailed list, a summary line is printed:
111111

112112
```text
113-
Plan: 5 to create, 3 to update, 1 to delete, 25 unchanged
113+
Plan: 5 to create, 3 to update, 1 to delete
114114
```
115115

116-
Use `--dry-run` to preview the plan without applying anything.
116+
Use `--dry-run` to preview the plan without applying anything. In dry-run mode the plan is written to **stdout**, so it can be captured in CI without `2>&1`:
117+
118+
```bash
119+
tailor-sdk deploy --dry-run > plan.txt
120+
```
121+
122+
In apply mode, the plan is printed to stderr so it does not interfere with piped output.
123+
124+
**JSON Output:**
125+
126+
Pass the global `--json` / `-j` flag to get machine-readable output.
127+
128+
**Dry-run** (`--dry-run --json`): writes a JSON object to stdout:
129+
130+
```json
131+
{
132+
"summary": { "create": 2, "update": 1, "delete": 0, "replace": 0 },
133+
"changes": [{ "action": "create", "name": "Order", "labels": ["type"], "namespace": "tailordb" }],
134+
"warnings": [
135+
{ "type": "unmanaged", "resourceType": "tailorDB", "name": "LegacyType" },
136+
{ "type": "skippedSecret", "resourceType": "secret", "name": "DB_PASSWORD" }
137+
],
138+
"conflicts": [{ "resourceType": "tailorDB", "name": "User", "currentOwner": "other-app" }]
139+
}
140+
```
141+
142+
- `summary` — counts of each change type.
143+
- `changes` — planned resource changes, each with `action`, `name`, and optional `labels` / `namespace`.
144+
- `warnings` — resources not in config (`type: "unmanaged"`) or secrets with missing values (`type: "skippedSecret"`). Unmanaged resources require confirmation in apply mode (apply is cancelled if declined); skipped secrets are non-blocking.
145+
- `conflicts` — resources owned by another application that conflict with the current config. Require confirmation in apply mode; apply is cancelled if declined.
146+
147+
**Apply** (`--json`): writes a JSON object to stdout:
148+
149+
```json
150+
{ "summary": { "create": 1, "update": 2, "delete": 0, "replace": 0 }, "status": "applied" }
151+
```
117152

118153
## remove
119154

packages/sdk/docs/cli/application.template.md

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,45 @@ Before applying changes, `deploy` shows a preview of the planned resource change
5050
After the detailed list, a summary line is printed:
5151

5252
```text
53-
Plan: 5 to create, 3 to update, 1 to delete, 25 unchanged
53+
Plan: 5 to create, 3 to update, 1 to delete
5454
```
5555

56-
Use `--dry-run` to preview the plan without applying anything.
56+
Use `--dry-run` to preview the plan without applying anything. In dry-run mode the plan is written to **stdout**, so it can be captured in CI without `2>&1`:
57+
58+
```bash
59+
tailor-sdk deploy --dry-run > plan.txt
60+
```
61+
62+
In apply mode, the plan is printed to stderr so it does not interfere with piped output.
63+
64+
**JSON Output:**
65+
66+
Pass the global `--json` / `-j` flag to get machine-readable output.
67+
68+
**Dry-run** (`--dry-run --json`): writes a JSON object to stdout:
69+
70+
```json
71+
{
72+
"summary": { "create": 2, "update": 1, "delete": 0, "replace": 0 },
73+
"changes": [{ "action": "create", "name": "Order", "labels": ["type"], "namespace": "tailordb" }],
74+
"warnings": [
75+
{ "type": "unmanaged", "resourceType": "tailorDB", "name": "LegacyType" },
76+
{ "type": "skippedSecret", "resourceType": "secret", "name": "DB_PASSWORD" }
77+
],
78+
"conflicts": [{ "resourceType": "tailorDB", "name": "User", "currentOwner": "other-app" }]
79+
}
80+
```
81+
82+
- `summary` — counts of each change type.
83+
- `changes` — planned resource changes, each with `action`, `name`, and optional `labels` / `namespace`.
84+
- `warnings` — resources not in config (`type: "unmanaged"`) or secrets with missing values (`type: "skippedSecret"`). Unmanaged resources require confirmation in apply mode (apply is cancelled if declined); skipped secrets are non-blocking.
85+
- `conflicts` — resources owned by another application that conflict with the current config. Require confirmation in apply mode; apply is cancelled if declined.
86+
87+
**Apply** (`--json`): writes a JSON object to stdout:
88+
89+
```json
90+
{ "summary": { "create": 1, "update": 2, "delete": 0, "replace": 0 }, "status": "applied" }
91+
```
5792

5893
{{politty:command:remove}}
5994
{{politty:command:show}}

packages/sdk/src/cli/commands/deploy/aigateway.test.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,7 @@ vi.mock("./label", async (importOriginal) => {
1818
};
1919
});
2020

21-
vi.mock("./change-set", async (importOriginal) => {
22-
const original = (await importOriginal()) as Record<string, unknown>;
23-
const createChangeSet = original.createChangeSet as (title: string) => {
24-
print: () => void;
25-
};
26-
return {
27-
...original,
28-
createChangeSet: (title: string) => ({
29-
...createChangeSet(title),
30-
print: () => {},
31-
}),
32-
};
33-
});
21+
vi.mock("./change-set", async (importOriginal) => importOriginal());
3422

3523
const workspaceId = "test-workspace";
3624
const appName = "test-app";

packages/sdk/src/cli/commands/deploy/application.test.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,7 @@ vi.mock("./label", async (importOriginal) => {
2626
};
2727
});
2828

29-
vi.mock("./change-set", async (importOriginal) => {
30-
const original = (await importOriginal()) as Record<string, unknown>;
31-
const createChangeSet = original.createChangeSet as (title: string) => {
32-
print: () => void;
33-
};
34-
return {
35-
...original,
36-
createChangeSet: (title: string) => ({
37-
...createChangeSet(title),
38-
print: () => {},
39-
}),
40-
};
41-
});
29+
vi.mock("./change-set", async (importOriginal) => importOriginal());
4230

4331
const workspaceId = "test-workspace";
4432
const appName = "test-app";

packages/sdk/src/cli/commands/deploy/auth.plan.test.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,19 +30,7 @@ vi.mock("./label", async (importOriginal) => {
3030
};
3131
});
3232

33-
vi.mock("./change-set", async (importOriginal) => {
34-
const original = (await importOriginal()) as Record<string, unknown>;
35-
const createChangeSet = original.createChangeSet as (title: string) => {
36-
print: () => void;
37-
};
38-
return {
39-
...original,
40-
createChangeSet: (title: string) => ({
41-
...createChangeSet(title),
42-
print: () => {},
43-
}),
44-
};
45-
});
33+
vi.mock("./change-set", async (importOriginal) => importOriginal());
4634

4735
const workspaceId = "test-workspace";
4836
const appName = "test-app";

packages/sdk/src/cli/commands/deploy/auth.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ describe("applyAuth phase separation", () => {
5353
replaces: [] as OAuth2ClientReplace[],
5454
title: "",
5555
isEmpty: () => false,
56-
print: () => {},
56+
lines: () => [],
5757
};
5858

5959
return {

packages/sdk/src/cli/commands/deploy/change-set.test.ts

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,32 @@
1-
import { describe, expect, test, vi } from "vitest";
2-
import { logger } from "#/cli/shared/logger";
1+
import { describe, expect, test } from "vitest";
32
import { createChangeSet, formatPlanSummary, summarizeChangeSets } from "./change-set";
43
import type { HasName } from "./change-set";
54

65
function createNamedChangeSet(title: string) {
76
return createChangeSet<HasName, HasName, HasName, HasName>(title);
87
}
98

10-
describe("ChangeSet.print", () => {
9+
describe("ChangeSet.lines", () => {
1110
test("renders an item's optional details indented beneath it", () => {
1211
const changeSet = createNamedChangeSet("Applications");
1312
changeSet.updates.push({
1413
name: "my-app",
1514
details: ["~ get-user (httpAdapter)", "+ echo (httpAdapter)"],
1615
});
1716

18-
using logSpy = vi.spyOn(logger, "log").mockImplementation(() => {});
19-
changeSet.print();
20-
21-
const lines = logSpy.mock.calls.map((call) => String(call[0]));
22-
// The application line is present (symbol is color-wrapped, so match loosely),
23-
// followed by its detail lines indented by four spaces.
17+
const lines = changeSet.lines();
2418
expect(lines.some((line) => line.includes("my-app"))).toBe(true);
2519
expect(lines).toContain(" ~ get-user (httpAdapter)");
2620
expect(lines).toContain(" + echo (httpAdapter)");
2721
});
22+
23+
test("returns empty array when change set is empty", () => {
24+
expect(createNamedChangeSet("Applications").lines()).toEqual([]);
25+
});
2826
});
2927

3028
describe("summarizeChangeSets", () => {
3129
test("summarizes resource counts for plan output", () => {
32-
const unchanged = createNamedChangeSet("Applications");
33-
unchanged.unchanged.push({ name: "app-a" }, { name: "app-b" });
34-
3530
const create = createNamedChangeSet("Executors");
3631
create.creates.push({ name: "executor-a" }, { name: "executor-b" });
3732

@@ -44,12 +39,11 @@ describe("summarizeChangeSets", () => {
4439
const replace = createNamedChangeSet("OAuth2 clients");
4540
replace.replaces.push({ name: "client-a" }, { name: "client-b" });
4641

47-
expect(summarizeChangeSets([unchanged, create, update, deleteSet, replace])).toEqual({
42+
expect(summarizeChangeSets([create, update, deleteSet, replace])).toEqual({
4843
create: 2,
4944
update: 3,
5045
delete: 1,
5146
replace: 2,
52-
unchanged: 2,
5347
});
5448
});
5549
});
@@ -62,7 +56,6 @@ describe("formatPlanSummary", () => {
6256
update: 2,
6357
delete: 0,
6458
replace: 0,
65-
unchanged: 15,
6659
}),
6760
).toBe("Plan: 1 to create, 2 to update, 0 to delete");
6861
});
@@ -74,7 +67,6 @@ describe("formatPlanSummary", () => {
7467
update: 2,
7568
delete: 0,
7669
replace: 3,
77-
unchanged: 15,
7870
}),
7971
).toBe("Plan: 1 to create, 2 to update, 0 to delete, 3 to replace");
8072
});

packages/sdk/src/cli/commands/deploy/change-set.ts

Lines changed: 19 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { logger, styles, symbols } from "#/cli/shared/logger";
1+
import { styles, symbols } from "#/cli/shared/logger";
22

33
export interface HasName {
44
name: string;
55
/**
66
* Optional pre-formatted lines rendered indented beneath the item by
7-
* `ChangeSet.print()` (e.g. per-sub-resource diffs embedded in a single
7+
* `ChangeSet.lines()` (e.g. per-sub-resource diffs embedded in a single
88
* resource).
99
*/
1010
details?: readonly string[];
@@ -24,21 +24,20 @@ export type ChangeSet<
2424
readonly replaces: R[];
2525
readonly unchanged: Un[];
2626
isEmpty: () => boolean;
27-
print: () => void;
27+
lines: () => string[];
2828
};
2929

3030
export interface PlanSummary {
3131
create: number;
3232
update: number;
3333
delete: number;
3434
replace: number;
35-
unchanged: number;
3635
}
3736

3837
/**
3938
* Create a new ChangeSet for tracking resource changes.
4039
* @param title - Title for the change set
41-
* @returns Empty ChangeSet instance with isEmpty() and print() methods
40+
* @returns Empty ChangeSet instance with isEmpty() and lines() methods
4241
*/
4342
export function createChangeSet<
4443
C extends HasName,
@@ -64,21 +63,19 @@ export function createChangeSet<
6463
replaces,
6564
unchanged,
6665
isEmpty,
67-
print: () => {
68-
if (isEmpty()) {
69-
return;
70-
}
71-
logger.log(styles.bold(`${title}:`));
72-
const printItem = (symbol: string, item: HasName) => {
73-
logger.log(` ${symbol} ${item.name}`);
74-
for (const detail of item.details ?? []) {
75-
logger.log(` ${detail}`);
76-
}
77-
};
78-
creates.forEach((item) => printItem(symbols.create, item));
79-
deletes.forEach((item) => printItem(symbols.delete, item));
80-
updates.forEach((item) => printItem(symbols.update, item));
81-
replaces.forEach((item) => printItem(symbols.replace, item));
66+
lines: () => {
67+
if (isEmpty()) return [];
68+
const itemLines = (symbol: string) => (item: HasName) => [
69+
` ${symbol} ${item.name}`,
70+
...(item.details ?? []).map((d) => ` ${d}`),
71+
];
72+
return [
73+
styles.bold(`${title}:`),
74+
...creates.flatMap(itemLines(symbols.create)),
75+
...deletes.flatMap(itemLines(symbols.delete)),
76+
...updates.flatMap(itemLines(symbols.update)),
77+
...replaces.flatMap(itemLines(symbols.replace)),
78+
];
8279
},
8380
};
8481
}
@@ -92,24 +89,17 @@ export function summarizeChangeSets(
9289
changeSets: Array<
9390
Pick<
9491
ChangeSet<HasName, HasName, HasName, HasName>,
95-
"creates" | "updates" | "deletes" | "replaces" | "unchanged"
92+
"creates" | "updates" | "deletes" | "replaces"
9693
>
9794
>,
9895
): PlanSummary {
99-
const summary: PlanSummary = {
100-
create: 0,
101-
update: 0,
102-
delete: 0,
103-
replace: 0,
104-
unchanged: 0,
105-
};
96+
const summary: PlanSummary = { create: 0, update: 0, delete: 0, replace: 0 };
10697

10798
for (const changeSet of changeSets) {
10899
summary.create += changeSet.creates.length;
109100
summary.update += changeSet.updates.length;
110101
summary.delete += changeSet.deletes.length;
111102
summary.replace += changeSet.replaces.length;
112-
summary.unchanged += changeSet.unchanged.length;
113103
}
114104

115105
return summary;

0 commit comments

Comments
 (0)