@griffin-app/griffin-cli 1.0.4 → 1.0.6

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.
@@ -1,11 +1,10 @@
1
- import { loadVariables } from "./variables.js";
2
- import { resolvePlan } from "../resolve.js";
3
1
  import { terminal } from "../utils/terminal.js";
2
+ import { withSDKErrorHandling } from "../utils/sdk-error.js";
4
3
  /**
5
4
  * Apply diff actions to the hub.
6
5
  * CLI injects both project and environment into plan payloads.
7
6
  */
8
- export async function applyDiff(diff, sdk, projectId, environment, options) {
7
+ export async function applyDiff(diff, sdk, options) {
9
8
  const applied = [];
10
9
  const errors = [];
11
10
  // Filter out noop actions
@@ -22,10 +21,10 @@ export async function applyDiff(diff, sdk, projectId, environment, options) {
22
21
  }
23
22
  switch (action.type) {
24
23
  case "create":
25
- await applyCreate(action, sdk, projectId, environment, applied);
24
+ await applyCreate(action, sdk, applied);
26
25
  break;
27
26
  case "update":
28
- await applyUpdate(action, sdk, projectId, environment, applied);
27
+ await applyUpdate(action, sdk, applied);
29
28
  break;
30
29
  case "delete":
31
30
  await applyDelete(action, sdk, applied);
@@ -55,10 +54,10 @@ export async function applyDiff(diff, sdk, projectId, environment, options) {
55
54
  /**
56
55
  * Apply a create action
57
56
  */
58
- async function applyCreate(action, sdk, projectId, environment, applied) {
59
- const plan = action.plan;
60
- const variables = await loadVariables(environment);
61
- const resolvedPlan = resolvePlan(plan, projectId, environment, variables);
57
+ async function applyCreate(action, sdk, applied) {
58
+ const resolvedPlan = action.plan;
59
+ //const variables = await loadVariables(environment);
60
+ //const resolvedPlan = resolvePlan(plan, projectId, environment, variables);
62
61
  const { data: createdPlan } = await sdk.postPlan({
63
62
  body: resolvedPlan,
64
63
  });
@@ -72,35 +71,35 @@ async function applyCreate(action, sdk, projectId, environment, applied) {
72
71
  /**
73
72
  * Apply an update action
74
73
  */
75
- async function applyUpdate(action, sdk, projectId, environment, applied) {
76
- const plan = action.plan;
77
- const remotePlan = action.remotePlan;
78
- const variables = await loadVariables(environment);
79
- const resolvedPlan = resolvePlan(plan, projectId, environment, variables);
74
+ async function applyUpdate(action, sdk, applied) {
75
+ const resolvedPlan = action.plan;
76
+ //const remotePlan = action.remotePlan!;
77
+ //const variables = await loadVariables(environment);
78
+ //const resolvedPlan = resolvePlan(plan, projectId, environment, variables);
80
79
  // Use the remote plan's ID for the update
81
80
  await sdk.putPlanById({
82
81
  path: {
83
- id: remotePlan.id,
82
+ id: action.remotePlan.id,
84
83
  },
85
84
  body: resolvedPlan,
86
85
  });
87
86
  applied.push({
88
87
  type: "update",
89
- planName: plan.name,
88
+ planName: action.remotePlan.name,
90
89
  success: true,
91
90
  });
92
- terminal.success(`Updated: ${terminal.colors.cyan(plan.name)}`);
91
+ terminal.success(`Updated: ${terminal.colors.cyan(action.remotePlan.name)}`);
93
92
  }
94
93
  /**
95
94
  * Apply a delete action
96
95
  */
97
96
  async function applyDelete(action, sdk, applied) {
98
97
  const remotePlan = action.remotePlan;
99
- await sdk.deletePlanById({
98
+ await withSDKErrorHandling(() => sdk.deletePlanById({
100
99
  path: {
101
100
  id: remotePlan.id,
102
101
  },
103
- });
102
+ }), `Failed to delete plan "${remotePlan.name}"`);
104
103
  applied.push({
105
104
  type: "delete",
106
105
  planName: remotePlan.name,
@@ -20,7 +20,7 @@ describe("applyDiff", () => {
20
20
  summary: { creates: 0, updates: 0, deletes: 0, noops: 0 },
21
21
  };
22
22
  const mockPlanApi = {};
23
- const result = await applyDiff(diff, mockPlanApi, "project-id", "test");
23
+ const result = await applyDiff(diff, mockPlanApi);
24
24
  expect(result.success).toBe(true);
25
25
  expect(result.applied).toHaveLength(0);
26
26
  expect(result.errors).toHaveLength(0);
@@ -39,7 +39,7 @@ describe("applyDiff", () => {
39
39
  summary: { creates: 0, updates: 0, deletes: 0, noops: 1 },
40
40
  };
41
41
  const mockPlanApi = {};
42
- const result = await applyDiff(diff, mockPlanApi, "project-id", "test");
42
+ const result = await applyDiff(diff, mockPlanApi);
43
43
  expect(result.success).toBe(true);
44
44
  expect(result.applied).toHaveLength(0);
45
45
  });
@@ -57,11 +57,11 @@ describe("applyDiff", () => {
57
57
  summary: { creates: 1, updates: 0, deletes: 0, noops: 0 },
58
58
  };
59
59
  const mockPlanApi = {
60
- planPost: vi.fn().mockResolvedValue({
60
+ postPlan: vi.fn().mockResolvedValue({
61
61
  data: { data: { ...plan, id: "created-id" } },
62
62
  }),
63
63
  };
64
- const result = await applyDiff(diff, mockPlanApi, "project-id", "staging");
64
+ const result = await applyDiff(diff, mockPlanApi);
65
65
  expect(result.success).toBe(true);
66
66
  expect(result.applied).toHaveLength(1);
67
67
  expect(result.applied[0].type).toBe("create");
@@ -69,9 +69,11 @@ describe("applyDiff", () => {
69
69
  expect(result.applied[0].success).toBe(true);
70
70
  // Verify the API was called with injected project and environment
71
71
  expect(mockPlanApi.postPlan).toHaveBeenCalledWith(expect.objectContaining({
72
- name: "new-plan",
73
- project: "project-id",
74
- environment: "staging",
72
+ body: expect.objectContaining({
73
+ name: "new-plan",
74
+ project: "test-project",
75
+ environment: "test",
76
+ }),
75
77
  }));
76
78
  });
77
79
  it("should apply update action", async () => {
@@ -93,17 +95,22 @@ describe("applyDiff", () => {
93
95
  data: { data: remotePlan },
94
96
  }),
95
97
  };
96
- const result = await applyDiff(diff, mockPlanApi, "project-id", "production");
98
+ const result = await applyDiff(diff, mockPlanApi);
97
99
  expect(result.success).toBe(true);
98
100
  expect(result.applied).toHaveLength(1);
99
101
  expect(result.applied[0].type).toBe("update");
100
102
  expect(result.applied[0].planName).toBe("existing-plan");
101
103
  expect(result.applied[0].success).toBe(true);
102
104
  // Verify the API was called with the remote plan's ID
103
- expect(mockPlanApi.putPlanById).toHaveBeenCalledWith("remote-id", expect.objectContaining({
104
- name: "existing-plan",
105
- project: "project-id",
106
- environment: "production",
105
+ expect(mockPlanApi.putPlanById).toHaveBeenCalledWith(expect.objectContaining({
106
+ body: expect.objectContaining({
107
+ name: "existing-plan",
108
+ project: "test-project",
109
+ environment: "test",
110
+ }),
111
+ path: {
112
+ id: "remote-id",
113
+ },
107
114
  }));
108
115
  });
109
116
  it("should apply delete action", async () => {
@@ -115,14 +122,18 @@ describe("applyDiff", () => {
115
122
  const mockPlanApi = {
116
123
  deletePlanById: vi.fn().mockResolvedValue({}),
117
124
  };
118
- const result = await applyDiff(diff, mockPlanApi, "project-id", "test");
125
+ const result = await applyDiff(diff, mockPlanApi);
119
126
  expect(result.success).toBe(true);
120
127
  expect(result.applied).toHaveLength(1);
121
128
  expect(result.applied[0].type).toBe("delete");
122
129
  expect(result.applied[0].planName).toBe("old-plan");
123
130
  expect(result.applied[0].success).toBe(true);
124
131
  // Verify the API was called with the remote plan's ID
125
- expect(mockPlanApi.deletePlanById).toHaveBeenCalledWith(remotePlan.id);
132
+ expect(mockPlanApi.deletePlanById).toHaveBeenCalledWith({
133
+ path: {
134
+ id: remotePlan.id,
135
+ },
136
+ });
126
137
  });
127
138
  it("should handle errors gracefully", async () => {
128
139
  const plan = createPlan("failing-plan");
@@ -140,7 +151,7 @@ describe("applyDiff", () => {
140
151
  const mockPlanApi = {
141
152
  postPlan: vi.fn().mockRejectedValue(new Error("API Error")),
142
153
  };
143
- const result = await applyDiff(diff, mockPlanApi, "project-id", "test");
154
+ const result = await applyDiff(diff, mockPlanApi);
144
155
  expect(result.success).toBe(false);
145
156
  expect(result.errors).toHaveLength(1);
146
157
  expect(result.applied).toHaveLength(1);
@@ -161,9 +172,9 @@ describe("applyDiff", () => {
161
172
  summary: { creates: 1, updates: 0, deletes: 0, noops: 0 },
162
173
  };
163
174
  const mockPlanApi = {
164
- planPost: vi.fn(),
175
+ postPlan: vi.fn(),
165
176
  };
166
- const result = await applyDiff(diff, mockPlanApi, "project-id", "test", {
177
+ const result = await applyDiff(diff, mockPlanApi, {
167
178
  dryRun: true,
168
179
  });
169
180
  expect(result.success).toBe(true);
@@ -0,0 +1,36 @@
1
+ import { type CredentialsFile, HubCredentials } from "../schemas/credentials.js";
2
+ export declare const CREDENTIALS_DIR = ".griffin";
3
+ export declare const CREDENTIALS_FILE = "credentials.json";
4
+ /**
5
+ * Get the user-level credentials directory path (~/.griffin)
6
+ */
7
+ export declare function getCredentialsDirPath(): string;
8
+ /**
9
+ * Get the credentials file path
10
+ */
11
+ export declare function getCredentialsFilePath(): string;
12
+ /**
13
+ * Check if credentials file exists
14
+ */
15
+ export declare function credentialsExist(): Promise<boolean>;
16
+ /**
17
+ * Load credentials file from disk
18
+ * Returns empty credentials if file doesn't exist
19
+ */
20
+ export declare function loadCredentials(): Promise<CredentialsFile>;
21
+ /**
22
+ * Save credentials file to disk with restricted permissions
23
+ */
24
+ export declare function saveCredentials(credentials: CredentialsFile): Promise<void>;
25
+ /**
26
+ * Save hub credentials (token from login or API key from connect)
27
+ */
28
+ export declare function saveHubCredentials(token: string): Promise<void>;
29
+ /**
30
+ * Get credentials for a specific hub URL
31
+ */
32
+ export declare function getHubCredentials(): Promise<HubCredentials | undefined>;
33
+ /**
34
+ * Remove credentials for a specific hub URL
35
+ */
36
+ export declare function removeHubCredentials(): Promise<void>;
@@ -0,0 +1,98 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { Value } from "typebox/value";
5
+ import { CredentialsFileSchema, createEmptyCredentials, } from "../schemas/credentials.js";
6
+ export const CREDENTIALS_DIR = ".griffin";
7
+ export const CREDENTIALS_FILE = "credentials.json";
8
+ /**
9
+ * Get the user-level credentials directory path (~/.griffin)
10
+ */
11
+ export function getCredentialsDirPath() {
12
+ return path.join(os.homedir(), CREDENTIALS_DIR);
13
+ }
14
+ /**
15
+ * Get the credentials file path
16
+ */
17
+ export function getCredentialsFilePath() {
18
+ return path.join(getCredentialsDirPath(), CREDENTIALS_FILE);
19
+ }
20
+ /**
21
+ * Check if credentials file exists
22
+ */
23
+ export async function credentialsExist() {
24
+ try {
25
+ await fs.access(getCredentialsFilePath());
26
+ return true;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ /**
33
+ * Load credentials file from disk
34
+ * Returns empty credentials if file doesn't exist
35
+ */
36
+ export async function loadCredentials() {
37
+ const credentialsFilePath = getCredentialsFilePath();
38
+ try {
39
+ const content = await fs.readFile(credentialsFilePath, "utf-8");
40
+ const data = JSON.parse(content);
41
+ // Validate schema
42
+ if (Value.Check(CredentialsFileSchema, data)) {
43
+ return data;
44
+ }
45
+ // Invalid schema
46
+ const errors = [...Value.Errors(CredentialsFileSchema, data)];
47
+ throw new Error(`Invalid credentials file schema:\n${errors.map((e) => ` - ${e.path || "unknown"}: ${e.message}`).join("\n")}`);
48
+ }
49
+ catch (error) {
50
+ if (error.code === "ENOENT") {
51
+ // File doesn't exist, return empty credentials
52
+ return createEmptyCredentials();
53
+ }
54
+ throw error;
55
+ }
56
+ }
57
+ /**
58
+ * Save credentials file to disk with restricted permissions
59
+ */
60
+ export async function saveCredentials(credentials) {
61
+ const credentialsDirPath = getCredentialsDirPath();
62
+ const credentialsFilePath = getCredentialsFilePath();
63
+ // Ensure directory exists
64
+ await fs.mkdir(credentialsDirPath, { recursive: true });
65
+ // Validate before saving
66
+ if (!Value.Check(CredentialsFileSchema, credentials)) {
67
+ const errors = [...Value.Errors(CredentialsFileSchema, credentials)];
68
+ throw new Error(`Invalid credentials data:\n${errors.map((e) => ` - ${e.path || "unknown"}: ${e.message}`).join("\n")}`);
69
+ }
70
+ // Write with pretty formatting and restricted permissions (0600 - read/write for owner only)
71
+ await fs.writeFile(credentialsFilePath, JSON.stringify(credentials, null, 2), { encoding: "utf-8", mode: 0o600 });
72
+ }
73
+ /**
74
+ * Save hub credentials (token from login or API key from connect)
75
+ */
76
+ export async function saveHubCredentials(token) {
77
+ const credentials = await loadCredentials();
78
+ credentials.hub = {
79
+ token,
80
+ updatedAt: new Date().toISOString(),
81
+ };
82
+ await saveCredentials(credentials);
83
+ }
84
+ /**
85
+ * Get credentials for a specific hub URL
86
+ */
87
+ export async function getHubCredentials() {
88
+ const credentials = await loadCredentials();
89
+ return credentials.hub;
90
+ }
91
+ /**
92
+ * Remove credentials for a specific hub URL
93
+ */
94
+ export async function removeHubCredentials() {
95
+ const credentials = await loadCredentials();
96
+ delete credentials.hub;
97
+ await saveCredentials(credentials);
98
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,137 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { loadCredentials, saveCredentials, saveHubCredentials, getHubCredentials, removeHubCredentials, } from "./credentials.js";
6
+ import { createEmptyCredentials } from "../schemas/credentials.js";
7
+ const TEST_DIR = path.join(os.tmpdir(), `griffin-test-${Date.now()}`);
8
+ const TEST_CREDS_FILE = path.join(TEST_DIR, "credentials.json");
9
+ // Mock the credentials directory to use test directory
10
+ const originalHomedir = os.homedir;
11
+ describe("Credentials Management", () => {
12
+ beforeEach(async () => {
13
+ // Create test directory
14
+ await fs.mkdir(TEST_DIR, { recursive: true });
15
+ // Mock homedir to point to test directory
16
+ os.homedir = () => TEST_DIR;
17
+ });
18
+ afterEach(async () => {
19
+ // Restore original homedir
20
+ os.homedir = originalHomedir;
21
+ // Clean up test directory
22
+ await fs.rm(TEST_DIR, { recursive: true, force: true });
23
+ });
24
+ describe("createEmptyCredentials", () => {
25
+ it("should create empty credentials structure", () => {
26
+ const creds = createEmptyCredentials();
27
+ expect(creds).toEqual({
28
+ version: 1,
29
+ });
30
+ });
31
+ });
32
+ describe("loadCredentials", () => {
33
+ it("should return empty credentials if file doesn't exist", async () => {
34
+ const creds = await loadCredentials();
35
+ expect(creds).toEqual(createEmptyCredentials());
36
+ });
37
+ it("should load existing credentials from file", async () => {
38
+ const testCreds = createEmptyCredentials();
39
+ testCreds.hub = {
40
+ token: "test-token",
41
+ updatedAt: new Date().toISOString(),
42
+ };
43
+ await saveCredentials(testCreds);
44
+ const loaded = await loadCredentials();
45
+ expect(loaded).toEqual(testCreds);
46
+ });
47
+ it("should throw error for invalid schema", async () => {
48
+ const invalidCreds = { invalid: "data" };
49
+ await fs.mkdir(path.join(TEST_DIR, ".griffin"), { recursive: true });
50
+ await fs.writeFile(path.join(TEST_DIR, ".griffin", "credentials.json"), JSON.stringify(invalidCreds));
51
+ await expect(loadCredentials()).rejects.toThrow("Invalid credentials file schema");
52
+ });
53
+ });
54
+ describe("saveHubCredentials", () => {
55
+ it("should save credentials for a hub", async () => {
56
+ const token = "test-token-123";
57
+ await saveHubCredentials(token);
58
+ const creds = await loadCredentials();
59
+ expect(creds.hub).toBeDefined();
60
+ expect(creds.hub.token).toBe(token);
61
+ expect(creds.hub.updatedAt).toBeDefined();
62
+ });
63
+ it("should normalize hub URL by removing trailing slash", async () => {
64
+ const token = "test-token";
65
+ await saveHubCredentials(token);
66
+ const creds = await loadCredentials();
67
+ expect(creds.hub).toBeDefined();
68
+ expect(creds.hub.token).toBe(token);
69
+ });
70
+ it("should update existing hub credentials", async () => {
71
+ const token1 = "token-1";
72
+ const token2 = "token-2";
73
+ await saveHubCredentials(token1);
74
+ const creds1 = await loadCredentials();
75
+ const updatedAt1 = creds1.hub.updatedAt;
76
+ // Wait a bit to ensure different timestamp
77
+ await new Promise((resolve) => setTimeout(resolve, 10));
78
+ await saveHubCredentials(token2);
79
+ const creds2 = await loadCredentials();
80
+ const updatedAt2 = creds2.hub.updatedAt;
81
+ expect(creds2.hub.token).toBe(token2);
82
+ expect(updatedAt2).not.toBe(updatedAt1);
83
+ });
84
+ });
85
+ describe("getHubCredentials", () => {
86
+ it("should return undefined for non-existent hub", async () => {
87
+ const creds = await getHubCredentials();
88
+ expect(creds).toBeUndefined();
89
+ });
90
+ it("should return credentials for existing hub", async () => {
91
+ const token = "test-token";
92
+ await saveHubCredentials(token);
93
+ const creds = await getHubCredentials();
94
+ expect(creds).toBeDefined();
95
+ expect(creds.token).toBe(token);
96
+ });
97
+ it("should normalize hub URL when retrieving", async () => {
98
+ await saveHubCredentials("test-token");
99
+ const creds = await getHubCredentials();
100
+ expect(creds).toBeDefined();
101
+ });
102
+ });
103
+ describe("removeHubCredentials", () => {
104
+ it("should remove credentials for a hub", async () => {
105
+ const hubUrl = "https://hub.test.com";
106
+ await saveHubCredentials("test-token");
107
+ await removeHubCredentials();
108
+ const creds = await loadCredentials();
109
+ expect(creds.hub).toBeUndefined();
110
+ });
111
+ it("should not affect other hub credentials", async () => {
112
+ await saveHubCredentials("token1");
113
+ await saveHubCredentials("token2");
114
+ await removeHubCredentials();
115
+ const creds = await loadCredentials();
116
+ expect(creds.hub).toBeUndefined();
117
+ });
118
+ });
119
+ describe("listHubUrls", () => {
120
+ it("should return empty array for no hubs", async () => {
121
+ const creds = await getHubCredentials();
122
+ expect(creds).toBeUndefined();
123
+ });
124
+ });
125
+ describe("file permissions", () => {
126
+ it("should create credentials file with 0600 permissions", async () => {
127
+ const token = "test-token";
128
+ await saveHubCredentials(token);
129
+ const credsPath = path.join(TEST_DIR, ".griffin", "credentials.json");
130
+ const stats = await fs.stat(credsPath);
131
+ // Check permissions (0600 = owner read/write only)
132
+ // Note: permissions are platform-dependent, so we check that it's at least restrictive
133
+ const mode = stats.mode & 0o777;
134
+ expect(mode).toBe(0o600);
135
+ });
136
+ });
137
+ });
@@ -1,10 +1,10 @@
1
1
  import type { PlanV1 } from "@griffin-app/griffin-hub-sdk";
2
- import type { PlanDSL } from "@griffin-app/griffin-ts/types";
3
2
  import { type PlanChanges } from "./plan-diff.js";
4
3
  export type DiffActionType = "create" | "update" | "delete" | "noop";
4
+ export type ResolvedPlan = Omit<PlanV1, "id">;
5
5
  export interface DiffAction {
6
6
  type: DiffActionType;
7
- plan: PlanDSL | null;
7
+ plan: ResolvedPlan | null;
8
8
  remotePlan: PlanV1 | null;
9
9
  reason: string;
10
10
  changes?: PlanChanges;
@@ -23,7 +23,8 @@ export interface DiffOptions {
23
23
  }
24
24
  /**
25
25
  * Compute diff between local plans and remote plans (hub is source of truth).
26
- * Plans are matched by name. The CLI injects environment at apply time.
26
+ * Local plans should be resolved (variables replaced with actual values) before calling this.
27
+ * Plans are matched by name.
27
28
  *
28
29
  * Rules:
29
30
  * - CREATE: Plan exists locally but not on hub
@@ -31,7 +32,7 @@ export interface DiffOptions {
31
32
  * - DELETE: Plan exists on hub but not locally (only if includeDeletions is true)
32
33
  * - NOOP: Plan exists in both with same content
33
34
  */
34
- export declare function computeDiff(localPlans: PlanDSL[], remotePlans: PlanV1[], options: DiffOptions): DiffResult;
35
+ export declare function computeDiff(localPlans: ResolvedPlan[], remotePlans: PlanV1[], options: DiffOptions): DiffResult;
35
36
  /**
36
37
  * Format diff result as human-readable text with granular changes
37
38
  */
package/dist/core/diff.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { comparePlans } from "./plan-diff.js";
2
2
  /**
3
3
  * Compute diff between local plans and remote plans (hub is source of truth).
4
- * Plans are matched by name. The CLI injects environment at apply time.
4
+ * Local plans should be resolved (variables replaced with actual values) before calling this.
5
+ * Plans are matched by name.
5
6
  *
6
7
  * Rules:
7
8
  * - CREATE: Plan exists locally but not on hub
@@ -14,10 +14,23 @@ function createPlan(name, overrides) {
14
14
  ...overrides,
15
15
  };
16
16
  }
17
+ // Helper to create a resolved plan (without id)
18
+ function createResolvedPlan(name, overrides) {
19
+ return {
20
+ name,
21
+ project: "test-project",
22
+ environment: "test",
23
+ version: "1.0",
24
+ frequency: { every: 5, unit: "MINUTE" },
25
+ nodes: [],
26
+ edges: [],
27
+ ...overrides,
28
+ };
29
+ }
17
30
  describe("computeDiff", () => {
18
31
  describe("CREATE actions", () => {
19
32
  it("should create action when plan exists locally but not remotely", () => {
20
- const local = [createPlan("health-check")];
33
+ const local = [createResolvedPlan("health-check")];
21
34
  const remote = [];
22
35
  const result = computeDiff(local, remote, {
23
36
  includeDeletions: false,
@@ -34,7 +47,7 @@ describe("computeDiff", () => {
34
47
  describe("UPDATE actions", () => {
35
48
  it("should create update action when plan content differs", () => {
36
49
  const local = [
37
- createPlan("health-check", {
50
+ createResolvedPlan("health-check", {
38
51
  frequency: { every: 10, unit: "MINUTE" },
39
52
  }),
40
53
  ];
@@ -57,9 +70,10 @@ describe("computeDiff", () => {
57
70
  });
58
71
  describe("NOOP actions", () => {
59
72
  it("should create noop action when plan content matches", () => {
60
- const plan = createPlan("health-check");
61
- const local = [plan];
62
- const remote = [{ ...plan }];
73
+ const resolvedPlan = createResolvedPlan("health-check");
74
+ const remotePlan = createPlan("health-check");
75
+ const local = [resolvedPlan];
76
+ const remote = [remotePlan];
63
77
  const result = computeDiff(local, remote, {
64
78
  includeDeletions: false,
65
79
  });
@@ -93,11 +107,11 @@ describe("computeDiff", () => {
93
107
  describe("Mixed scenarios", () => {
94
108
  it("should handle multiple plans with different actions", () => {
95
109
  const local = [
96
- createPlan("new-plan"),
97
- createPlan("updated-plan", {
110
+ createResolvedPlan("new-plan"),
111
+ createResolvedPlan("updated-plan", {
98
112
  frequency: { every: 10, unit: "MINUTE" },
99
113
  }),
100
- createPlan("unchanged-plan"),
114
+ createResolvedPlan("unchanged-plan"),
101
115
  ];
102
116
  const remote = [
103
117
  createPlan("updated-plan", {
@@ -118,7 +132,8 @@ describe("computeDiff", () => {
118
132
  });
119
133
  describe("Matching by name", () => {
120
134
  it("should match plans by name, not by ID", () => {
121
- const local = [createPlan("health-check", { id: "local-id-123" })];
135
+ // Local plans don't have IDs after resolution
136
+ const local = [createResolvedPlan("health-check")];
122
137
  const remote = [createPlan("health-check", { id: "remote-id-456" })];
123
138
  const result = computeDiff(local, remote, {
124
139
  includeDeletions: false,
@@ -1,6 +1,4 @@
1
- import type { PlanDSL } from "@griffin-app/griffin-ts/types";
2
1
  import { PlanV1 } from "@griffin-app/griffin-hub-sdk";
3
- import { NodeType } from "@griffin-app/griffin-ts/schema";
4
2
  /**
5
3
  * Represents a change to a single field
6
4
  */
@@ -15,7 +13,7 @@ export interface FieldChange {
15
13
  export interface NodeChange {
16
14
  type: "add" | "remove" | "modify";
17
15
  nodeId: string;
18
- nodeType: NodeType.ASSERTION | NodeType.ENDPOINT | NodeType.WAIT;
16
+ nodeType: "ASSERTION" | "HTTP_REQUEST" | "WAIT";
19
17
  summary: string;
20
18
  fieldChanges: FieldChange[];
21
19
  }
@@ -37,6 +35,7 @@ export interface PlanChanges {
37
35
  topLevel: FieldChange[];
38
36
  }
39
37
  /**
40
- * Compare two test plans and return granular changes
38
+ * Compare two test plans and return granular changes.
39
+ * Local plan should be resolved (variables replaced with actual values).
41
40
  */
42
- export declare function comparePlans(local: PlanDSL, remote: PlanV1): PlanChanges;
41
+ export declare function comparePlans(local: PlanV1, remote: PlanV1): PlanChanges;
@@ -1,7 +1,8 @@
1
1
  import objectHash from "object-hash";
2
2
  import { NodeType } from "@griffin-app/griffin-ts/schema";
3
3
  /**
4
- * Compare two test plans and return granular changes
4
+ * Compare two test plans and return granular changes.
5
+ * Local plan should be resolved (variables replaced with actual values).
5
6
  */
6
7
  export function comparePlans(local, remote) {
7
8
  const nodeChanges = compareNodes(local.nodes, remote.nodes);
@@ -77,14 +78,12 @@ function compareNodes(localNodes, remoteNodes) {
77
78
  */
78
79
  function getNodeSummary(node) {
79
80
  switch (node.type) {
80
- case "ENDPOINT":
81
+ case "HTTP_REQUEST":
81
82
  return `${node.method} ${formatValue(node.path)}`;
82
83
  case "WAIT":
83
84
  return `wait ${node.duration_ms}ms`;
84
85
  case "ASSERTION":
85
86
  return `${node.assertions.length} assertion(s)`;
86
- default:
87
- return node.type;
88
87
  }
89
88
  }
90
89
  /**
@@ -117,8 +116,8 @@ function compareNodeFields(local, remote) {
117
116
  return changes;
118
117
  }
119
118
  switch (local.type) {
120
- case NodeType.ENDPOINT:
121
- compareEndpointFields(local, remote, changes);
119
+ case NodeType.HTTP_REQUEST:
120
+ compareHttpRequestFields(local, remote, changes);
122
121
  break;
123
122
  case NodeType.WAIT:
124
123
  compareWaitFields(local, remote, changes);
@@ -130,9 +129,9 @@ function compareNodeFields(local, remote) {
130
129
  return changes;
131
130
  }
132
131
  /**
133
- * Compare fields specific to Endpoint nodes
132
+ * Compare fields specific to HttpRequest nodes
134
133
  */
135
- function compareEndpointFields(local, remote, changes) {
134
+ function compareHttpRequestFields(local, remote, changes) {
136
135
  const fields = [
137
136
  "method",
138
137
  "path",