@agentuity/cli 1.0.5 → 1.0.7

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.
@@ -0,0 +1,180 @@
1
+ import * as tui from '../../../tui';
2
+ import { maskSecret } from '../../../env-util';
3
+
4
+ export interface EnvDiffEntry {
5
+ key: string;
6
+ /** The value from the "source" side (the side being pushed/pulled from) */
7
+ sourceValue: string;
8
+ /** The value from the "target" side (the side being overwritten), undefined if new */
9
+ targetValue?: string;
10
+ /** Whether the source side stores this as a secret */
11
+ sourceIsSecret: boolean;
12
+ /** Whether the target side stores this as a secret (undefined if new, i.e. no target) */
13
+ targetIsSecret?: boolean;
14
+ }
15
+
16
+ export interface EnvDiff {
17
+ newEntries: EnvDiffEntry[];
18
+ changedEntries: EnvDiffEntry[];
19
+ unchangedEntries: EnvDiffEntry[];
20
+ }
21
+
22
+ /**
23
+ * Compute diff between source and target env/secrets.
24
+ * "Source" is where values come from; "target" is what will be overwritten.
25
+ *
26
+ * For push: source=local, target=remote
27
+ * For pull: source=cloud, target=local
28
+ */
29
+ export function computeEnvDiff(
30
+ sourceEnv: Record<string, string>,
31
+ sourceSecrets: Record<string, string>,
32
+ targetEnv: Record<string, string>,
33
+ targetSecrets: Record<string, string>
34
+ ): EnvDiff {
35
+ const newEntries: EnvDiffEntry[] = [];
36
+ const changedEntries: EnvDiffEntry[] = [];
37
+ const unchangedEntries: EnvDiffEntry[] = [];
38
+
39
+ // Check env vars
40
+ for (const key of Object.keys(sourceEnv)) {
41
+ const sourceValue = sourceEnv[key]!;
42
+ if (key in targetEnv) {
43
+ if (sourceValue === targetEnv[key]) {
44
+ unchangedEntries.push({
45
+ key,
46
+ sourceValue,
47
+ targetValue: targetEnv[key],
48
+ sourceIsSecret: false,
49
+ targetIsSecret: false,
50
+ });
51
+ } else {
52
+ changedEntries.push({
53
+ key,
54
+ sourceValue,
55
+ targetValue: targetEnv[key],
56
+ sourceIsSecret: false,
57
+ targetIsSecret: false,
58
+ });
59
+ }
60
+ } else if (key in targetSecrets) {
61
+ // Key exists but as a different type - treat as changed
62
+ changedEntries.push({
63
+ key,
64
+ sourceValue,
65
+ targetValue: targetSecrets[key],
66
+ sourceIsSecret: false,
67
+ targetIsSecret: true,
68
+ });
69
+ } else {
70
+ newEntries.push({ key, sourceValue, sourceIsSecret: false });
71
+ }
72
+ }
73
+
74
+ // Check secrets
75
+ for (const key of Object.keys(sourceSecrets)) {
76
+ const sourceValue = sourceSecrets[key]!;
77
+ if (key in targetSecrets) {
78
+ if (sourceValue === targetSecrets[key]) {
79
+ unchangedEntries.push({
80
+ key,
81
+ sourceValue,
82
+ targetValue: targetSecrets[key],
83
+ sourceIsSecret: true,
84
+ targetIsSecret: true,
85
+ });
86
+ } else {
87
+ changedEntries.push({
88
+ key,
89
+ sourceValue,
90
+ targetValue: targetSecrets[key],
91
+ sourceIsSecret: true,
92
+ targetIsSecret: true,
93
+ });
94
+ }
95
+ } else if (key in targetEnv) {
96
+ // Key exists but as a different type - treat as changed
97
+ changedEntries.push({
98
+ key,
99
+ sourceValue,
100
+ targetValue: targetEnv[key],
101
+ sourceIsSecret: true,
102
+ targetIsSecret: false,
103
+ });
104
+ } else {
105
+ newEntries.push({ key, sourceValue, sourceIsSecret: true });
106
+ }
107
+ }
108
+
109
+ return { newEntries, changedEntries, unchangedEntries };
110
+ }
111
+
112
+ function displayValue(value: string, isSecret: boolean): string {
113
+ return isSecret ? maskSecret(value) : value;
114
+ }
115
+
116
+ export interface DisplayEnvDiffOptions {
117
+ /** Label for the operation, shown in header. E.g. "push" or "pull" */
118
+ direction: 'push' | 'pull';
119
+ }
120
+
121
+ /**
122
+ * Display a diff view of env changes.
123
+ * In TTY mode, shows a rich color-coded diff.
124
+ * In non-TTY mode, shows simple text summary.
125
+ */
126
+ export function displayEnvDiff(diff: EnvDiff, options: DisplayEnvDiffOptions): void {
127
+ const { direction } = options;
128
+
129
+ // Non-TTY: simple summary
130
+ if (!tui.isTTYLike()) {
131
+ if (diff.newEntries.length > 0) {
132
+ tui.info(
133
+ `New variables: ${diff.newEntries.length} (${diff.newEntries.map((e) => e.key).join(', ')})`
134
+ );
135
+ }
136
+ if (diff.changedEntries.length > 0) {
137
+ const verb = direction === 'push' ? 'overwritten remotely' : 'overwritten locally';
138
+ tui.warning(
139
+ `Variables that will be ${verb}: ${diff.changedEntries.length} (${diff.changedEntries.map((e) => e.key).join(', ')})`
140
+ );
141
+ }
142
+ if (diff.unchangedEntries.length > 0) {
143
+ tui.info(`Unchanged variables: ${diff.unchangedEntries.length}`);
144
+ }
145
+ return;
146
+ }
147
+
148
+ // TTY: rich diff view
149
+ tui.newline();
150
+ tui.info('Environment variable changes:');
151
+ tui.newline();
152
+
153
+ // Sort all entries by key for consistent display
154
+ const allEntries = [
155
+ ...diff.newEntries.map((e) => ({ ...e, status: 'new' as const })),
156
+ ...diff.changedEntries.map((e) => ({ ...e, status: 'changed' as const })),
157
+ ...diff.unchangedEntries.map((e) => ({ ...e, status: 'unchanged' as const })),
158
+ ].sort((a, b) => a.key.localeCompare(b.key));
159
+
160
+ for (const entry of allEntries) {
161
+ const typeLabel = entry.sourceIsSecret ? 'secret' : 'env';
162
+ const val = displayValue(entry.sourceValue, entry.sourceIsSecret);
163
+
164
+ if (entry.status === 'new') {
165
+ const line = ` ${tui.colorSuccess('+')} ${tui.bold(entry.key)}=${val} ${tui.colorMuted(`(new, ${typeLabel})`)}`;
166
+ process.stderr.write(line + '\n');
167
+ } else if (entry.status === 'changed') {
168
+ const oldVal = displayValue(entry.targetValue!, entry.targetIsSecret ?? false);
169
+ // For push: show "remote_old → local_new" (replacing remote with local)
170
+ // For pull: show "local_old → cloud_new" (replacing local with cloud)
171
+ const line = ` ${tui.colorWarning('~')} ${tui.bold(entry.key)}=${oldVal} → ${val} ${tui.colorMuted(`(changed, ${typeLabel})`)}`;
172
+ process.stderr.write(line + '\n');
173
+ } else {
174
+ const line = ` ${tui.colorMuted(`= ${entry.key}=${val} (unchanged, ${typeLabel})`)}`;
175
+ process.stderr.write(line + '\n');
176
+ }
177
+ }
178
+
179
+ tui.newline();
180
+ }
@@ -2,13 +2,24 @@ import { z } from 'zod';
2
2
  import { createSubcommand } from '../../../types';
3
3
  import * as tui from '../../../tui';
4
4
  import { projectGet, orgEnvGet } from '@agentuity/server';
5
- import { findExistingEnvFile, readEnvFile, writeEnvFile, mergeEnvVars } from '../../../env-util';
5
+ import {
6
+ findExistingEnvFile,
7
+ readEnvFile,
8
+ writeEnvFile,
9
+ mergeEnvVars,
10
+ splitEnvAndSecrets,
11
+ filterAgentuitySdkKeys,
12
+ } from '../../../env-util';
6
13
  import { getCommand } from '../../../command-prefix';
7
14
  import { resolveOrgId, isOrgScope } from './org-util';
15
+ import { computeEnvDiff, displayEnvDiff } from './env-diff';
8
16
 
9
17
  const EnvPullResponseSchema = z.object({
10
18
  success: z.boolean().describe('Whether pull succeeded'),
11
19
  pulled: z.number().describe('Number of items pulled'),
20
+ newCount: z.number().describe('Number of new variables added locally'),
21
+ changedCount: z.number().describe('Number of local variables overwritten'),
22
+ unchangedCount: z.number().describe('Number of unchanged variables'),
12
23
  path: z.string().describe('Local file path where variables were saved'),
13
24
  force: z.boolean().describe('Whether force mode was used'),
14
25
  scope: z.enum(['project', 'org']).describe('The scope from which variables were pulled'),
@@ -41,13 +52,15 @@ export const pullSubcommand = createSubcommand({
41
52
  async handler(ctx) {
42
53
  const { opts, apiClient, project, projectDir, config } = ctx;
43
54
  const useOrgScope = isOrgScope(opts?.org);
55
+ const forceMode = opts?.force ?? false;
44
56
 
45
57
  // Require project context for local file operations
46
58
  if (!projectDir) {
47
59
  tui.fatal('Project context required. Run from a project directory.');
48
60
  }
49
61
 
50
- let cloudEnv: Record<string, string>;
62
+ let cloudEnvVars: Record<string, string> = {};
63
+ let cloudSecretVars: Record<string, string> = {};
51
64
  let scope: 'project' | 'org';
52
65
  let cloudApiKey: string | undefined;
53
66
 
@@ -62,7 +75,8 @@ export const pullSubcommand = createSubcommand({
62
75
  }
63
76
  );
64
77
 
65
- cloudEnv = { ...orgData.env, ...orgData.secrets };
78
+ cloudEnvVars = orgData.env || {};
79
+ cloudSecretVars = orgData.secrets || {};
66
80
  scope = 'org';
67
81
  cloudApiKey = undefined; // Orgs don't have api_key
68
82
  } else {
@@ -77,7 +91,8 @@ export const pullSubcommand = createSubcommand({
77
91
  return projectGet(apiClient, { id: project.projectId, mask: false });
78
92
  });
79
93
 
80
- cloudEnv = { ...projectData.env, ...projectData.secrets };
94
+ cloudEnvVars = projectData.env || {};
95
+ cloudSecretVars = projectData.secrets || {};
81
96
  scope = 'project';
82
97
  cloudApiKey = projectData.api_key;
83
98
  }
@@ -89,9 +104,43 @@ export const pullSubcommand = createSubcommand({
89
104
  // Preserve local AGENTUITY_SDK_KEY
90
105
  const localSdkKey = localEnv.AGENTUITY_SDK_KEY;
91
106
 
107
+ // Split local env for diff comparison (excluding AGENTUITY_ reserved keys)
108
+ const localForDiff = { ...localEnv };
109
+ delete localForDiff.AGENTUITY_SDK_KEY;
110
+ const filteredLocal = filterAgentuitySdkKeys(localForDiff);
111
+ const { env: localEnvVars, secrets: localSecretVars } = splitEnvAndSecrets(filteredLocal);
112
+
113
+ // Compute diff: cloud (source) → local (target)
114
+ const diff = computeEnvDiff(cloudEnvVars, cloudSecretVars, localEnvVars, localSecretVars);
115
+
116
+ // Display diff
117
+ displayEnvDiff(diff, { direction: 'pull' });
118
+
119
+ // If force mode and there are changes, prompt for confirmation
120
+ if (forceMode && diff.changedEntries.length > 0) {
121
+ const confirmed = await tui.confirm(
122
+ `${diff.changedEntries.length} local variable${diff.changedEntries.length !== 1 ? 's' : ''} will be overwritten. Continue?`,
123
+ true
124
+ );
125
+ if (!confirmed) {
126
+ tui.info('Pull cancelled');
127
+ return {
128
+ success: false,
129
+ pulled: 0,
130
+ newCount: 0,
131
+ changedCount: 0,
132
+ unchangedCount: 0,
133
+ path: targetEnvPath,
134
+ force: forceMode,
135
+ scope,
136
+ };
137
+ }
138
+ }
139
+
92
140
  // Merge: cloud values override local if force=true, otherwise keep local
141
+ const cloudEnv = { ...cloudEnvVars, ...cloudSecretVars };
93
142
  let mergedEnv: Record<string, string>;
94
- if (opts?.force) {
143
+ if (forceMode) {
95
144
  // Cloud values take priority
96
145
  mergedEnv = mergeEnvVars(localEnv, cloudEnv);
97
146
  } else {
@@ -122,15 +171,26 @@ export const pullSubcommand = createSubcommand({
122
171
 
123
172
  const count = Object.keys(cloudEnv).length;
124
173
  const scopeLabel = useOrgScope ? 'organization' : 'project';
125
- tui.success(
126
- `Pulled ${count} environment variable${count !== 1 ? 's' : ''} from ${scopeLabel} to ${targetEnvPath}`
127
- );
174
+
175
+ // Update success message with diff counts
176
+ if (forceMode) {
177
+ tui.success(
178
+ `Pulled ${count} variable${count !== 1 ? 's' : ''} from ${scopeLabel} (${diff.newEntries.length} new, ${diff.changedEntries.length} updated, ${diff.unchangedEntries.length} unchanged)`
179
+ );
180
+ } else {
181
+ tui.success(
182
+ `Pulled ${count} variable${count !== 1 ? 's' : ''} from ${scopeLabel} (${diff.newEntries.length} new, ${diff.changedEntries.length} skipped, ${diff.unchangedEntries.length} unchanged)`
183
+ );
184
+ }
128
185
 
129
186
  return {
130
187
  success: true,
131
188
  pulled: count,
189
+ newCount: diff.newEntries.length,
190
+ changedCount: forceMode ? diff.changedEntries.length : 0,
191
+ unchangedCount: diff.unchangedEntries.length,
132
192
  path: targetEnvPath,
133
- force: opts?.force ?? false,
193
+ force: forceMode,
134
194
  scope,
135
195
  };
136
196
  },
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { createSubcommand } from '../../../types';
3
3
  import * as tui from '../../../tui';
4
- import { projectEnvUpdate, orgEnvUpdate } from '@agentuity/server';
4
+ import { projectEnvUpdate, orgEnvUpdate, projectGet, orgEnvGet } from '@agentuity/server';
5
5
  import {
6
6
  findExistingEnvFile,
7
7
  readEnvFile,
@@ -11,14 +11,19 @@ import {
11
11
  } from '../../../env-util';
12
12
  import { getCommand } from '../../../command-prefix';
13
13
  import { resolveOrgId, isOrgScope } from './org-util';
14
+ import { computeEnvDiff, displayEnvDiff } from './env-diff';
14
15
 
15
16
  const EnvPushResponseSchema = z.object({
16
17
  success: z.boolean().describe('Whether push succeeded'),
17
18
  pushed: z.number().describe('Number of items pushed'),
18
19
  envCount: z.number().describe('Number of env vars pushed'),
19
20
  secretCount: z.number().describe('Number of secrets pushed'),
21
+ newCount: z.number().describe('Number of new variables added'),
22
+ changedCount: z.number().describe('Number of existing variables overwritten'),
23
+ unchangedCount: z.number().describe('Number of unchanged variables'),
20
24
  source: z.string().describe('Source file path'),
21
25
  scope: z.enum(['project', 'org']).describe('The scope where variables were pushed'),
26
+ force: z.boolean().describe('Whether force mode was used'),
22
27
  });
23
28
 
24
29
  export const pushSubcommand = createSubcommand({
@@ -39,6 +44,7 @@ export const pushSubcommand = createSubcommand({
39
44
  .union([z.boolean(), z.string()])
40
45
  .optional()
41
46
  .describe('push to organization level (use --org for default org)'),
47
+ force: z.boolean().default(false).describe('overwrite remote values without confirmation'),
42
48
  }),
43
49
  response: EnvPushResponseSchema,
44
50
  },
@@ -59,6 +65,8 @@ export const pushSubcommand = createSubcommand({
59
65
  // Filter out reserved AGENTUITY_ prefixed keys
60
66
  const filteredEnv = filterAgentuitySdkKeys(localEnv);
61
67
 
68
+ const forceMode = opts?.force ?? false;
69
+
62
70
  if (Object.keys(filteredEnv).length === 0) {
63
71
  tui.warning('No variables to push');
64
72
  return {
@@ -66,8 +74,12 @@ export const pushSubcommand = createSubcommand({
66
74
  pushed: 0,
67
75
  envCount: 0,
68
76
  secretCount: 0,
77
+ newCount: 0,
78
+ changedCount: 0,
79
+ unchangedCount: 0,
69
80
  source: envFilePath,
70
81
  scope: useOrgScope ? ('org' as const) : ('project' as const),
82
+ force: forceMode,
71
83
  };
72
84
  }
73
85
 
@@ -93,6 +105,38 @@ export const pushSubcommand = createSubcommand({
93
105
  // Organization scope
94
106
  const orgId = await resolveOrgId(apiClient, config, opts!.org!);
95
107
 
108
+ // Fetch remote state to compute diff
109
+ const orgData = await tui.spinner('Fetching remote variables', () => {
110
+ return orgEnvGet(apiClient, { id: orgId, mask: false });
111
+ });
112
+
113
+ const diff = computeEnvDiff(env, secrets, orgData.env || {}, orgData.secrets || {});
114
+
115
+ displayEnvDiff(diff, { direction: 'push' });
116
+
117
+ // Prompt for confirmation if there are changes to existing variables
118
+ if (diff.changedEntries.length > 0 && !forceMode) {
119
+ const confirmed = await tui.confirm(
120
+ `${diff.changedEntries.length} existing variable${diff.changedEntries.length !== 1 ? 's' : ''} will be overwritten. Continue?`,
121
+ true
122
+ );
123
+ if (!confirmed) {
124
+ tui.info('Push cancelled');
125
+ return {
126
+ success: false,
127
+ pushed: 0,
128
+ envCount: 0,
129
+ secretCount: 0,
130
+ newCount: 0,
131
+ changedCount: 0,
132
+ unchangedCount: 0,
133
+ source: envFilePath,
134
+ scope: 'org' as const,
135
+ force: forceMode,
136
+ };
137
+ }
138
+ }
139
+
96
140
  await tui.spinner('Pushing variables to organization', () => {
97
141
  return orgEnvUpdate(apiClient, {
98
142
  id: orgId,
@@ -106,7 +150,7 @@ export const pushSubcommand = createSubcommand({
106
150
  const totalCount = envCount + secretCount;
107
151
 
108
152
  tui.success(
109
- `Pushed ${totalCount} variable${totalCount !== 1 ? 's' : ''} to organization (${envCount} env, ${secretCount} secret${secretCount !== 1 ? 's' : ''})`
153
+ `Pushed ${totalCount} variable${totalCount !== 1 ? 's' : ''} to organization (${diff.newEntries.length} new, ${diff.changedEntries.length} updated, ${diff.unchangedEntries.length} unchanged)`
110
154
  );
111
155
 
112
156
  return {
@@ -114,17 +158,58 @@ export const pushSubcommand = createSubcommand({
114
158
  pushed: totalCount,
115
159
  envCount,
116
160
  secretCount,
161
+ newCount: diff.newEntries.length,
162
+ changedCount: diff.changedEntries.length,
163
+ unchangedCount: diff.unchangedEntries.length,
117
164
  source: envFilePath,
118
165
  scope: 'org' as const,
166
+ force: forceMode,
119
167
  };
120
168
  } else {
121
- // Project scope (existing behavior)
169
+ // Project scope
122
170
  if (!project) {
123
171
  tui.fatal(
124
172
  'Project context required. Run from a project directory or use --org for organization scope.'
125
173
  );
126
174
  }
127
175
 
176
+ // Fetch remote state to compute diff
177
+ const projectData = await tui.spinner('Fetching remote variables', () => {
178
+ return projectGet(apiClient, { id: project.projectId, mask: false });
179
+ });
180
+
181
+ const diff = computeEnvDiff(
182
+ env,
183
+ secrets,
184
+ projectData.env || {},
185
+ projectData.secrets || {}
186
+ );
187
+
188
+ displayEnvDiff(diff, { direction: 'push' });
189
+
190
+ // Prompt for confirmation if there are changes to existing variables
191
+ if (diff.changedEntries.length > 0 && !forceMode) {
192
+ const confirmed = await tui.confirm(
193
+ `${diff.changedEntries.length} existing variable${diff.changedEntries.length !== 1 ? 's' : ''} will be overwritten. Continue?`,
194
+ true
195
+ );
196
+ if (!confirmed) {
197
+ tui.info('Push cancelled');
198
+ return {
199
+ success: false,
200
+ pushed: 0,
201
+ envCount: 0,
202
+ secretCount: 0,
203
+ newCount: 0,
204
+ changedCount: 0,
205
+ unchangedCount: 0,
206
+ source: envFilePath,
207
+ scope: 'project' as const,
208
+ force: forceMode,
209
+ };
210
+ }
211
+ }
212
+
128
213
  await tui.spinner('Pushing variables to cloud', () => {
129
214
  return projectEnvUpdate(apiClient, {
130
215
  id: project.projectId,
@@ -138,7 +223,7 @@ export const pushSubcommand = createSubcommand({
138
223
  const totalCount = envCount + secretCount;
139
224
 
140
225
  tui.success(
141
- `Pushed ${totalCount} variable${totalCount !== 1 ? 's' : ''} to cloud (${envCount} env, ${secretCount} secret${secretCount !== 1 ? 's' : ''})`
226
+ `Pushed ${totalCount} variable${totalCount !== 1 ? 's' : ''} to cloud (${diff.newEntries.length} new, ${diff.changedEntries.length} updated, ${diff.unchangedEntries.length} unchanged)`
142
227
  );
143
228
 
144
229
  return {
@@ -146,8 +231,12 @@ export const pushSubcommand = createSubcommand({
146
231
  pushed: totalCount,
147
232
  envCount,
148
233
  secretCount,
234
+ newCount: diff.newEntries.length,
235
+ changedCount: diff.changedEntries.length,
236
+ unchangedCount: diff.unchangedEntries.length,
149
237
  source: envFilePath,
150
238
  scope: 'project' as const,
239
+ force: forceMode,
151
240
  };
152
241
  }
153
242
  },