@inforge/migrations-tools-cli 1.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.
- package/README.md +171 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1158 -0
- package/package.json +54 -0
package/README.md
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Inforge Migrations CLI (`imigrate`)
|
|
2
|
+
|
|
3
|
+
Inforge's interactive CLI tool that enables side-effect-free Salesforce data operations by managing validation rules, flows, and triggers.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Deactivate automation** (validation rules, flows, triggers) for clean data operations
|
|
8
|
+
- **Smart restore** with automatic state detection
|
|
9
|
+
- **Atomic operations** with automatic rollback on failure
|
|
10
|
+
- **Local backups** organized by org/object/type
|
|
11
|
+
- **Managed package awareness** - skip managed components gracefully
|
|
12
|
+
- **Full audit logging** for compliance
|
|
13
|
+
- **Beautiful interactive UI** powered by @clack/prompts
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Install globally
|
|
19
|
+
npm install -g @inforge/migrations-tools-cli
|
|
20
|
+
|
|
21
|
+
# Or use npx (no installation required)
|
|
22
|
+
npx @inforge/migrations-tools-cli
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Prerequisites
|
|
26
|
+
|
|
27
|
+
- Node.js 18+
|
|
28
|
+
- Salesforce CLI (`sf` or `sfdx`) with at least one authenticated org
|
|
29
|
+
- Authenticated org: `sf org login web -a my-org`
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
Run the CLI:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
imigrate
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Or with npx:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npx @inforge/migrations-tools-cli
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Deactivate Automation
|
|
46
|
+
|
|
47
|
+
1. Select "Deactivate automation"
|
|
48
|
+
2. Choose org, object, and automation type
|
|
49
|
+
3. Preview what will be affected
|
|
50
|
+
4. Confirm and execute
|
|
51
|
+
5. Backup saved automatically
|
|
52
|
+
|
|
53
|
+
### Restore from Backup
|
|
54
|
+
|
|
55
|
+
1. Select "Restore from backup"
|
|
56
|
+
2. Choose org, object, and automation type
|
|
57
|
+
3. Select backup from list (with metadata)
|
|
58
|
+
4. Preview smart detection (only restore what's needed)
|
|
59
|
+
5. Confirm and execute
|
|
60
|
+
|
|
61
|
+
### Manage Backups
|
|
62
|
+
|
|
63
|
+
View all backups organized by org/object/type.
|
|
64
|
+
|
|
65
|
+
### View Logs
|
|
66
|
+
|
|
67
|
+
See recent operations with status, timestamps, and details.
|
|
68
|
+
|
|
69
|
+
## Architecture
|
|
70
|
+
|
|
71
|
+
### Backup Structure
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
.backups/
|
|
75
|
+
├── my-org/
|
|
76
|
+
│ ├── Account/
|
|
77
|
+
│ │ ├── validation-rules/
|
|
78
|
+
│ │ │ ├── 2026-02-15_14-30-00-123.json
|
|
79
|
+
│ │ │ └── 2026-02-15_16-45-30-456.json
|
|
80
|
+
│ │ ├── flows/
|
|
81
|
+
│ │ │ └── 2026-02-15_15-00-00-789.json
|
|
82
|
+
│ │ └── triggers/
|
|
83
|
+
│ │ └── 2026-02-15_15-30-00-012.json
|
|
84
|
+
│ └── Contact/
|
|
85
|
+
│ └── validation-rules/
|
|
86
|
+
│ └── 2026-02-15_14-35-00-345.json
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Operation Logs
|
|
90
|
+
|
|
91
|
+
All operations are logged to `.logs/operations.log` (JSON format, one per line).
|
|
92
|
+
|
|
93
|
+
## Safety Features
|
|
94
|
+
|
|
95
|
+
### Atomic Operations
|
|
96
|
+
|
|
97
|
+
All deactivation and restore operations are **all-or-nothing**:
|
|
98
|
+
- If any error occurs, changes are automatically rolled back
|
|
99
|
+
- You'll never be left in a partial state
|
|
100
|
+
|
|
101
|
+
### Smart Restore
|
|
102
|
+
|
|
103
|
+
Before restoring, the tool checks the current org state:
|
|
104
|
+
- Only restores items that actually need it
|
|
105
|
+
- Skips items that are already in the desired state
|
|
106
|
+
- Shows a preview of exactly what will change
|
|
107
|
+
|
|
108
|
+
### Managed Package Awareness
|
|
109
|
+
|
|
110
|
+
The tool identifies managed package components:
|
|
111
|
+
- Shows managed items in preview with counts
|
|
112
|
+
- Skips them during deactivation (can't be modified)
|
|
113
|
+
- Clearly explains why they're skipped
|
|
114
|
+
|
|
115
|
+
### Preview Before Action
|
|
116
|
+
|
|
117
|
+
Every operation shows a detailed preview:
|
|
118
|
+
- What will be affected
|
|
119
|
+
- What will be skipped (managed packages)
|
|
120
|
+
- Exact counts
|
|
121
|
+
|
|
122
|
+
You must confirm before any changes are made.
|
|
123
|
+
|
|
124
|
+
## Development
|
|
125
|
+
|
|
126
|
+
### Setup
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
git clone <repo>
|
|
130
|
+
cd migrations-cli
|
|
131
|
+
npm install
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Run in dev mode
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
npm run dev
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Build
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
npm run build
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Test
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
npm test
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Supported Automation Types
|
|
153
|
+
|
|
154
|
+
### V1 (Current)
|
|
155
|
+
- Validation Rules
|
|
156
|
+
- Flows
|
|
157
|
+
- Triggers
|
|
158
|
+
|
|
159
|
+
### Future
|
|
160
|
+
- Process Builder
|
|
161
|
+
- Workflow Rules
|
|
162
|
+
- Duplicate Rules
|
|
163
|
+
- Matching Rules
|
|
164
|
+
|
|
165
|
+
## Contributing
|
|
166
|
+
|
|
167
|
+
Contributions welcome! Please open an issue or PR.
|
|
168
|
+
|
|
169
|
+
## License
|
|
170
|
+
|
|
171
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,1158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/ui/prompts.ts
|
|
4
|
+
import * as clack from "@clack/prompts";
|
|
5
|
+
var Prompts = class {
|
|
6
|
+
intro(title) {
|
|
7
|
+
clack.intro(title);
|
|
8
|
+
}
|
|
9
|
+
outro(message) {
|
|
10
|
+
clack.outro(message);
|
|
11
|
+
}
|
|
12
|
+
async selectOrg(orgs) {
|
|
13
|
+
const options = orgs.map((org) => ({
|
|
14
|
+
value: org.alias,
|
|
15
|
+
label: org.alias,
|
|
16
|
+
hint: `${org.username} (${org.isSandbox ? "Sandbox" : "Production"})`
|
|
17
|
+
}));
|
|
18
|
+
const selected = await clack.select({
|
|
19
|
+
message: "Select an org:",
|
|
20
|
+
options
|
|
21
|
+
});
|
|
22
|
+
if (clack.isCancel(selected)) {
|
|
23
|
+
this.cancel();
|
|
24
|
+
}
|
|
25
|
+
return selected;
|
|
26
|
+
}
|
|
27
|
+
async selectObject(objects) {
|
|
28
|
+
const selected = await clack.select({
|
|
29
|
+
message: "Select an object:",
|
|
30
|
+
options: objects.map((obj) => ({ value: obj, label: obj }))
|
|
31
|
+
});
|
|
32
|
+
if (clack.isCancel(selected)) {
|
|
33
|
+
this.cancel();
|
|
34
|
+
}
|
|
35
|
+
return selected;
|
|
36
|
+
}
|
|
37
|
+
async selectAutomationType() {
|
|
38
|
+
const selected = await clack.select({
|
|
39
|
+
message: "Select automation type:",
|
|
40
|
+
options: [
|
|
41
|
+
{ value: "validation-rules", label: "Validation Rules" },
|
|
42
|
+
{ value: "flows", label: "Flows" },
|
|
43
|
+
{ value: "triggers", label: "Triggers" }
|
|
44
|
+
]
|
|
45
|
+
});
|
|
46
|
+
if (clack.isCancel(selected)) {
|
|
47
|
+
this.cancel();
|
|
48
|
+
}
|
|
49
|
+
return selected;
|
|
50
|
+
}
|
|
51
|
+
async selectMainAction() {
|
|
52
|
+
const selected = await clack.select({
|
|
53
|
+
message: "What would you like to do?",
|
|
54
|
+
options: [
|
|
55
|
+
{ value: "deactivate", label: "Deactivate automation" },
|
|
56
|
+
{ value: "restore", label: "Restore from backup" },
|
|
57
|
+
{ value: "manage", label: "Manage backups" },
|
|
58
|
+
{ value: "logs", label: "View operation logs" },
|
|
59
|
+
{ value: "exit", label: "Exit" }
|
|
60
|
+
]
|
|
61
|
+
});
|
|
62
|
+
if (clack.isCancel(selected)) {
|
|
63
|
+
this.cancel();
|
|
64
|
+
}
|
|
65
|
+
return selected;
|
|
66
|
+
}
|
|
67
|
+
async selectFromOptions(message, options) {
|
|
68
|
+
const selected = await clack.select({
|
|
69
|
+
message,
|
|
70
|
+
options
|
|
71
|
+
});
|
|
72
|
+
if (clack.isCancel(selected)) {
|
|
73
|
+
this.cancel();
|
|
74
|
+
}
|
|
75
|
+
return selected;
|
|
76
|
+
}
|
|
77
|
+
async confirm(message) {
|
|
78
|
+
const result = await clack.confirm({
|
|
79
|
+
message
|
|
80
|
+
});
|
|
81
|
+
if (clack.isCancel(result)) {
|
|
82
|
+
this.cancel();
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
spinner() {
|
|
87
|
+
return clack.spinner();
|
|
88
|
+
}
|
|
89
|
+
note(message, title) {
|
|
90
|
+
clack.note(message, title);
|
|
91
|
+
}
|
|
92
|
+
log(message) {
|
|
93
|
+
clack.log.message(message);
|
|
94
|
+
}
|
|
95
|
+
success(message) {
|
|
96
|
+
clack.log.success(message);
|
|
97
|
+
}
|
|
98
|
+
error(message) {
|
|
99
|
+
clack.log.error(message);
|
|
100
|
+
}
|
|
101
|
+
warning(message) {
|
|
102
|
+
clack.log.warning(message);
|
|
103
|
+
}
|
|
104
|
+
cancel(message = "Operation cancelled") {
|
|
105
|
+
clack.cancel(message);
|
|
106
|
+
process.exit(0);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// src/core/sf-client.ts
|
|
111
|
+
import { AuthInfo, Org } from "@salesforce/core";
|
|
112
|
+
var SfClient = class {
|
|
113
|
+
async listOrgs() {
|
|
114
|
+
const authorizations = await AuthInfo.listAllAuthorizations();
|
|
115
|
+
return authorizations.map((auth) => ({
|
|
116
|
+
alias: auth.aliases?.[0] || auth.username,
|
|
117
|
+
username: auth.username,
|
|
118
|
+
instanceUrl: auth.instanceUrl || "",
|
|
119
|
+
isDevHub: auth.isDevHub,
|
|
120
|
+
isSandbox: auth.isSandbox
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
async getConnection(orgAlias) {
|
|
124
|
+
const org = await Org.create({ aliasOrUsername: orgAlias });
|
|
125
|
+
return org.getConnection();
|
|
126
|
+
}
|
|
127
|
+
async queryObjects(connection) {
|
|
128
|
+
const query = `
|
|
129
|
+
SELECT QualifiedApiName
|
|
130
|
+
FROM EntityDefinition
|
|
131
|
+
WHERE IsCustomizable = true
|
|
132
|
+
ORDER BY QualifiedApiName
|
|
133
|
+
`;
|
|
134
|
+
const result = await connection.query(query);
|
|
135
|
+
return result.records.map((r) => r.QualifiedApiName);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// src/core/backup.ts
|
|
140
|
+
import * as fs from "fs/promises";
|
|
141
|
+
import * as path from "path";
|
|
142
|
+
var BackupManager = class {
|
|
143
|
+
backupDir;
|
|
144
|
+
constructor(backupDir = ".backups") {
|
|
145
|
+
this.backupDir = backupDir;
|
|
146
|
+
}
|
|
147
|
+
async save(org, object, type, items, managedItems) {
|
|
148
|
+
const timestamp = this.generateTimestamp();
|
|
149
|
+
const backupPath = this.getBackupPath(org.alias, object, type, timestamp);
|
|
150
|
+
const backup = {
|
|
151
|
+
version: "1.0",
|
|
152
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
153
|
+
org,
|
|
154
|
+
object,
|
|
155
|
+
type,
|
|
156
|
+
items,
|
|
157
|
+
managedItems,
|
|
158
|
+
restoredAt: null,
|
|
159
|
+
restoredBy: null
|
|
160
|
+
};
|
|
161
|
+
await this.ensureDir(path.dirname(backupPath));
|
|
162
|
+
await fs.writeFile(backupPath, JSON.stringify(backup, null, 2), "utf-8");
|
|
163
|
+
return backupPath;
|
|
164
|
+
}
|
|
165
|
+
async list(orgAlias, object, type) {
|
|
166
|
+
const dir = path.join(this.backupDir, orgAlias, object, type);
|
|
167
|
+
try {
|
|
168
|
+
const files = await fs.readdir(dir);
|
|
169
|
+
return files.filter((f) => f.endsWith(".json")).map((f) => path.join(dir, f)).sort().reverse();
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (error.code === "ENOENT") {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async load(backupPath) {
|
|
178
|
+
const content = await fs.readFile(backupPath, "utf-8");
|
|
179
|
+
return JSON.parse(content);
|
|
180
|
+
}
|
|
181
|
+
async markAsRestored(backupPath, operationId) {
|
|
182
|
+
const backup = await this.load(backupPath);
|
|
183
|
+
backup.restoredAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
184
|
+
backup.restoredBy = operationId;
|
|
185
|
+
await fs.writeFile(backupPath, JSON.stringify(backup, null, 2), "utf-8");
|
|
186
|
+
}
|
|
187
|
+
async delete(backupPath) {
|
|
188
|
+
await fs.unlink(backupPath);
|
|
189
|
+
}
|
|
190
|
+
getBackupPath(orgAlias, object, type, timestamp) {
|
|
191
|
+
return path.join(this.backupDir, orgAlias, object, type, `${timestamp}.json`);
|
|
192
|
+
}
|
|
193
|
+
generateTimestamp() {
|
|
194
|
+
const now = /* @__PURE__ */ new Date();
|
|
195
|
+
const date = now.toISOString().split("T")[0];
|
|
196
|
+
const time = now.toTimeString().split(" ")[0].replace(/:/g, "-");
|
|
197
|
+
const ms = now.getMilliseconds().toString().padStart(3, "0");
|
|
198
|
+
return `${date}_${time}-${ms}`;
|
|
199
|
+
}
|
|
200
|
+
async ensureDir(dir) {
|
|
201
|
+
await fs.mkdir(dir, { recursive: true });
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// src/core/logger.ts
|
|
206
|
+
import * as fs2 from "fs/promises";
|
|
207
|
+
import * as path2 from "path";
|
|
208
|
+
var Logger = class {
|
|
209
|
+
logDir;
|
|
210
|
+
logFile;
|
|
211
|
+
constructor(logDir = ".logs") {
|
|
212
|
+
this.logDir = logDir;
|
|
213
|
+
this.logFile = path2.join(logDir, "operations.log");
|
|
214
|
+
}
|
|
215
|
+
async log(operation) {
|
|
216
|
+
await this.ensureLogDir();
|
|
217
|
+
const logLine = JSON.stringify(operation) + "\n";
|
|
218
|
+
await fs2.appendFile(this.logFile, logLine, "utf-8");
|
|
219
|
+
}
|
|
220
|
+
async getLogs() {
|
|
221
|
+
try {
|
|
222
|
+
const content = await fs2.readFile(this.logFile, "utf-8");
|
|
223
|
+
const lines = content.trim().split("\n").filter((line) => line.length > 0);
|
|
224
|
+
return lines.map((line) => JSON.parse(line));
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (error.code === "ENOENT") {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
generateOperationId() {
|
|
233
|
+
const timestamp = Date.now().toString(36);
|
|
234
|
+
const random = Math.random().toString(36).substring(2, 9);
|
|
235
|
+
return `op_${timestamp}${random}`;
|
|
236
|
+
}
|
|
237
|
+
async ensureLogDir() {
|
|
238
|
+
try {
|
|
239
|
+
await fs2.mkdir(this.logDir, { recursive: true });
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (error.code !== "EEXIST") {
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// src/core/metadata/validation-rules.ts
|
|
249
|
+
var ValidationRulesHandler = class {
|
|
250
|
+
async fetch(connection, objectName) {
|
|
251
|
+
const metadata = await connection.metadata.read("CustomObject", [objectName]);
|
|
252
|
+
if (!metadata || metadata.length === 0 || !metadata[0].validationRules) {
|
|
253
|
+
return [];
|
|
254
|
+
}
|
|
255
|
+
return metadata[0].validationRules.map((rule) => ({
|
|
256
|
+
fullName: `${objectName}.${rule.fullName}`,
|
|
257
|
+
active: rule.active,
|
|
258
|
+
metadata: rule
|
|
259
|
+
}));
|
|
260
|
+
}
|
|
261
|
+
async fetchSeparated(connection, objectName) {
|
|
262
|
+
const allRules = await this.fetch(connection, objectName);
|
|
263
|
+
const custom = [];
|
|
264
|
+
const managed = [];
|
|
265
|
+
for (const rule of allRules) {
|
|
266
|
+
const ruleMetadata = rule.metadata;
|
|
267
|
+
if (ruleMetadata.namespacePrefix) {
|
|
268
|
+
managed.push({
|
|
269
|
+
fullName: rule.fullName,
|
|
270
|
+
namespace: ruleMetadata.namespacePrefix,
|
|
271
|
+
reason: "Cannot deactivate managed package component"
|
|
272
|
+
});
|
|
273
|
+
} else {
|
|
274
|
+
custom.push(rule);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return { custom, managed };
|
|
278
|
+
}
|
|
279
|
+
async deactivate(connection, objectName, ruleNames) {
|
|
280
|
+
await this.updateRules(connection, objectName, ruleNames, false);
|
|
281
|
+
}
|
|
282
|
+
async activate(connection, objectName, ruleNames) {
|
|
283
|
+
await this.updateRules(connection, objectName, ruleNames, true);
|
|
284
|
+
}
|
|
285
|
+
async rollback(connection, objectName, originalRules) {
|
|
286
|
+
const ruleNamesToActivate = originalRules.filter((r) => r.active).map((r) => r.fullName.split(".")[1]);
|
|
287
|
+
const ruleNamesToDeactivate = originalRules.filter((r) => !r.active).map((r) => r.fullName.split(".")[1]);
|
|
288
|
+
if (ruleNamesToActivate.length > 0) {
|
|
289
|
+
await this.activate(connection, objectName, ruleNamesToActivate);
|
|
290
|
+
}
|
|
291
|
+
if (ruleNamesToDeactivate.length > 0) {
|
|
292
|
+
await this.deactivate(connection, objectName, ruleNamesToDeactivate);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
async updateRules(connection, objectName, ruleNames, active) {
|
|
296
|
+
const metadata = await connection.metadata.read("CustomObject", [objectName]);
|
|
297
|
+
if (!metadata || metadata.length === 0 || !metadata[0].validationRules) {
|
|
298
|
+
throw new Error(`No validation rules found for ${objectName}`);
|
|
299
|
+
}
|
|
300
|
+
const objectMetadata = metadata[0];
|
|
301
|
+
const rules = objectMetadata.validationRules;
|
|
302
|
+
objectMetadata.validationRules = rules.map((rule) => {
|
|
303
|
+
if (ruleNames.includes(rule.fullName)) {
|
|
304
|
+
return { ...rule, active };
|
|
305
|
+
}
|
|
306
|
+
return rule;
|
|
307
|
+
});
|
|
308
|
+
const result = await connection.metadata.update("CustomObject", objectMetadata);
|
|
309
|
+
if (!result || Array.isArray(result) && !result[0]?.success) {
|
|
310
|
+
throw new Error(`Failed to update validation rules for ${objectName}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// src/core/metadata/flows.ts
|
|
316
|
+
var FlowsHandler = class {
|
|
317
|
+
async fetch(connection, objectName) {
|
|
318
|
+
const query = `
|
|
319
|
+
SELECT Id, DeveloperName, ActiveVersion.VersionNumber, LatestVersion.VersionNumber,
|
|
320
|
+
ProcessType, NamespacePrefix
|
|
321
|
+
FROM FlowDefinition
|
|
322
|
+
WHERE ActiveVersion.VersionNumber != null
|
|
323
|
+
OR LatestVersion.VersionNumber != null
|
|
324
|
+
`;
|
|
325
|
+
const result = await connection.tooling.query(query);
|
|
326
|
+
const objectFlows = result.records.filter(
|
|
327
|
+
(flow) => flow.DeveloperName.includes(objectName)
|
|
328
|
+
);
|
|
329
|
+
return objectFlows.map((flow) => ({
|
|
330
|
+
fullName: flow.DeveloperName,
|
|
331
|
+
active: flow.ActiveVersion !== null && flow.ActiveVersion.VersionNumber > 0,
|
|
332
|
+
metadata: flow
|
|
333
|
+
}));
|
|
334
|
+
}
|
|
335
|
+
async fetchSeparated(connection, objectName) {
|
|
336
|
+
const allFlows = await this.fetch(connection, objectName);
|
|
337
|
+
const custom = [];
|
|
338
|
+
const managed = [];
|
|
339
|
+
for (const flow of allFlows) {
|
|
340
|
+
const flowMetadata = flow.metadata;
|
|
341
|
+
if (flowMetadata.NamespacePrefix) {
|
|
342
|
+
managed.push({
|
|
343
|
+
fullName: flow.fullName,
|
|
344
|
+
namespace: flowMetadata.NamespacePrefix,
|
|
345
|
+
reason: "Cannot deactivate managed package component"
|
|
346
|
+
});
|
|
347
|
+
} else {
|
|
348
|
+
custom.push(flow);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return { custom, managed };
|
|
352
|
+
}
|
|
353
|
+
async deactivate(connection, flowIds) {
|
|
354
|
+
const updates = flowIds.map((id) => ({
|
|
355
|
+
Id: id,
|
|
356
|
+
Metadata: {
|
|
357
|
+
activeVersionNumber: 0
|
|
358
|
+
}
|
|
359
|
+
}));
|
|
360
|
+
const results = await connection.tooling.update("FlowDefinition", updates);
|
|
361
|
+
if (Array.isArray(results)) {
|
|
362
|
+
const failures = results.filter((r) => !r.success);
|
|
363
|
+
if (failures.length > 0) {
|
|
364
|
+
throw new Error(`Failed to deactivate ${failures.length} flow(s)`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
async activate(connection, flowsWithVersions) {
|
|
369
|
+
const updates = flowsWithVersions.map((flow) => ({
|
|
370
|
+
Id: flow.id,
|
|
371
|
+
Metadata: {
|
|
372
|
+
activeVersionNumber: flow.version
|
|
373
|
+
}
|
|
374
|
+
}));
|
|
375
|
+
const results = await connection.tooling.update("FlowDefinition", updates);
|
|
376
|
+
if (Array.isArray(results)) {
|
|
377
|
+
const failures = results.filter((r) => !r.success);
|
|
378
|
+
if (failures.length > 0) {
|
|
379
|
+
throw new Error(`Failed to activate ${failures.length} flow(s)`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// src/core/metadata/triggers.ts
|
|
386
|
+
var TriggersHandler = class {
|
|
387
|
+
async fetch(connection, objectName) {
|
|
388
|
+
const query = `
|
|
389
|
+
SELECT Id, Name, TableEnumOrId, Status, NamespacePrefix
|
|
390
|
+
FROM ApexTrigger
|
|
391
|
+
WHERE TableEnumOrId = '${objectName}'
|
|
392
|
+
`;
|
|
393
|
+
const result = await connection.tooling.query(query);
|
|
394
|
+
return result.records.map((trigger) => ({
|
|
395
|
+
fullName: trigger.Name,
|
|
396
|
+
active: trigger.Status === "Active",
|
|
397
|
+
metadata: trigger
|
|
398
|
+
}));
|
|
399
|
+
}
|
|
400
|
+
async fetchSeparated(connection, objectName) {
|
|
401
|
+
const allTriggers = await this.fetch(connection, objectName);
|
|
402
|
+
const custom = [];
|
|
403
|
+
const managed = [];
|
|
404
|
+
for (const trigger of allTriggers) {
|
|
405
|
+
const triggerMetadata = trigger.metadata;
|
|
406
|
+
if (triggerMetadata.NamespacePrefix) {
|
|
407
|
+
managed.push({
|
|
408
|
+
fullName: trigger.fullName,
|
|
409
|
+
namespace: triggerMetadata.NamespacePrefix,
|
|
410
|
+
reason: "Cannot deactivate managed package component"
|
|
411
|
+
});
|
|
412
|
+
} else {
|
|
413
|
+
custom.push(trigger);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return { custom, managed };
|
|
417
|
+
}
|
|
418
|
+
async deactivate(connection, triggerNames) {
|
|
419
|
+
await this.updateStatus(connection, triggerNames, "Inactive");
|
|
420
|
+
}
|
|
421
|
+
async activate(connection, triggerNames) {
|
|
422
|
+
await this.updateStatus(connection, triggerNames, "Active");
|
|
423
|
+
}
|
|
424
|
+
async updateStatus(connection, triggerNames, status) {
|
|
425
|
+
const metadata = await connection.metadata.read("ApexTrigger", triggerNames);
|
|
426
|
+
const updates = metadata.map((trigger) => ({
|
|
427
|
+
...trigger,
|
|
428
|
+
status
|
|
429
|
+
}));
|
|
430
|
+
const results = await connection.metadata.update("ApexTrigger", updates);
|
|
431
|
+
if (Array.isArray(results)) {
|
|
432
|
+
const failures = results.filter((r) => !r.success);
|
|
433
|
+
if (failures.length > 0) {
|
|
434
|
+
throw new Error(`Failed to update ${failures.length} trigger(s)`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
// src/commands/deactivate.ts
|
|
441
|
+
var DeactivateCommand = class {
|
|
442
|
+
sfClient;
|
|
443
|
+
backupManager;
|
|
444
|
+
logger;
|
|
445
|
+
prompts;
|
|
446
|
+
constructor() {
|
|
447
|
+
this.sfClient = new SfClient();
|
|
448
|
+
this.backupManager = new BackupManager();
|
|
449
|
+
this.logger = new Logger();
|
|
450
|
+
this.prompts = new Prompts();
|
|
451
|
+
}
|
|
452
|
+
async execute() {
|
|
453
|
+
const orgs = await this.sfClient.listOrgs();
|
|
454
|
+
if (orgs.length === 0) {
|
|
455
|
+
this.prompts.error("No authenticated orgs found. Please authenticate with SF CLI first.");
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const selectedOrg = await this.prompts.selectOrg(orgs);
|
|
459
|
+
const org = orgs.find((o) => o.alias === selectedOrg);
|
|
460
|
+
const connection = await this.sfClient.getConnection(selectedOrg);
|
|
461
|
+
const objects = await this.sfClient.queryObjects(connection);
|
|
462
|
+
const selectedObject = await this.prompts.selectObject(objects);
|
|
463
|
+
const automationType = await this.prompts.selectAutomationType();
|
|
464
|
+
if (automationType === "validation-rules") {
|
|
465
|
+
await this.deactivateValidationRules(org, selectedObject, connection);
|
|
466
|
+
} else if (automationType === "flows") {
|
|
467
|
+
await this.deactivateFlows(org, selectedObject, connection);
|
|
468
|
+
} else if (automationType === "triggers") {
|
|
469
|
+
await this.deactivateTriggers(org, selectedObject, connection);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
async deactivateValidationRules(org, objectName, connection) {
|
|
473
|
+
const handler = new ValidationRulesHandler();
|
|
474
|
+
const spinner2 = this.prompts.spinner();
|
|
475
|
+
spinner2.start("Fetching validation rules...");
|
|
476
|
+
const { custom, managed } = await handler.fetchSeparated(connection, objectName);
|
|
477
|
+
const originalRules = [...custom];
|
|
478
|
+
spinner2.stop("Validation rules fetched");
|
|
479
|
+
if (custom.length === 0 && managed.length === 0) {
|
|
480
|
+
this.prompts.warning("No validation rules found for this object.");
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const previewMessage = this.buildPreviewMessage(custom, managed, "validation rules");
|
|
484
|
+
this.prompts.note(previewMessage, `Preview: ${objectName} Validation Rules`);
|
|
485
|
+
const confirmed = await this.prompts.confirm("Proceed with deactivation?");
|
|
486
|
+
if (!confirmed) {
|
|
487
|
+
this.prompts.cancel("Deactivation cancelled");
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const operationId = this.logger.generateOperationId();
|
|
491
|
+
const deactivateSpinner = this.prompts.spinner();
|
|
492
|
+
deactivateSpinner.start("Deactivating validation rules...");
|
|
493
|
+
try {
|
|
494
|
+
const ruleNames = custom.map((r) => r.fullName.split(".")[1]);
|
|
495
|
+
await handler.deactivate(connection, objectName, ruleNames);
|
|
496
|
+
const backupPath = await this.backupManager.save(
|
|
497
|
+
org,
|
|
498
|
+
objectName,
|
|
499
|
+
"validation-rules",
|
|
500
|
+
custom,
|
|
501
|
+
managed
|
|
502
|
+
);
|
|
503
|
+
await this.logger.log({
|
|
504
|
+
id: operationId,
|
|
505
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
506
|
+
operation: "deactivate",
|
|
507
|
+
org: org.alias,
|
|
508
|
+
object: objectName,
|
|
509
|
+
type: "validation-rules",
|
|
510
|
+
status: "success",
|
|
511
|
+
itemsAffected: custom.length,
|
|
512
|
+
itemsSkipped: managed.length,
|
|
513
|
+
backupPath,
|
|
514
|
+
error: null
|
|
515
|
+
});
|
|
516
|
+
deactivateSpinner.stop("Validation rules deactivated");
|
|
517
|
+
this.prompts.success(
|
|
518
|
+
`Successfully deactivated ${custom.length} validation rule(s). Backup saved to:
|
|
519
|
+
${backupPath}`
|
|
520
|
+
);
|
|
521
|
+
} catch (error) {
|
|
522
|
+
deactivateSpinner.stop("Deactivation failed - rolling back");
|
|
523
|
+
try {
|
|
524
|
+
const rollbackSpinner = this.prompts.spinner();
|
|
525
|
+
rollbackSpinner.start("Rolling back changes...");
|
|
526
|
+
await handler.rollback(connection, objectName, originalRules);
|
|
527
|
+
rollbackSpinner.stop("Rollback complete");
|
|
528
|
+
await this.logger.log({
|
|
529
|
+
id: operationId,
|
|
530
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
531
|
+
operation: "deactivate",
|
|
532
|
+
org: org.alias,
|
|
533
|
+
object: objectName,
|
|
534
|
+
type: "validation-rules",
|
|
535
|
+
status: "rollback",
|
|
536
|
+
itemsAffected: 0,
|
|
537
|
+
itemsSkipped: 0,
|
|
538
|
+
backupPath: null,
|
|
539
|
+
error: error.message
|
|
540
|
+
});
|
|
541
|
+
this.prompts.warning("Changes rolled back. No changes were made.");
|
|
542
|
+
} catch (rollbackError) {
|
|
543
|
+
this.prompts.error(`Rollback failed: ${rollbackError.message}`);
|
|
544
|
+
this.prompts.error("Manual intervention may be required.");
|
|
545
|
+
}
|
|
546
|
+
await this.logger.log({
|
|
547
|
+
id: operationId,
|
|
548
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
549
|
+
operation: "deactivate",
|
|
550
|
+
org: org.alias,
|
|
551
|
+
object: objectName,
|
|
552
|
+
type: "validation-rules",
|
|
553
|
+
status: "failure",
|
|
554
|
+
itemsAffected: 0,
|
|
555
|
+
itemsSkipped: 0,
|
|
556
|
+
backupPath: null,
|
|
557
|
+
error: error.message
|
|
558
|
+
});
|
|
559
|
+
this.prompts.error(`Failed to deactivate validation rules: ${error.message}`);
|
|
560
|
+
throw error;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
async deactivateFlows(org, objectName, connection) {
|
|
564
|
+
const handler = new FlowsHandler();
|
|
565
|
+
const spinner2 = this.prompts.spinner();
|
|
566
|
+
spinner2.start("Fetching flows...");
|
|
567
|
+
const { custom, managed } = await handler.fetchSeparated(connection, objectName);
|
|
568
|
+
spinner2.stop("Flows fetched");
|
|
569
|
+
if (custom.length === 0 && managed.length === 0) {
|
|
570
|
+
this.prompts.warning("No flows found for this object.");
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const previewMessage = this.buildPreviewMessage(custom, managed, "flows");
|
|
574
|
+
this.prompts.note(previewMessage, `Preview: ${objectName} Flows`);
|
|
575
|
+
const confirmed = await this.prompts.confirm("Proceed with deactivation?");
|
|
576
|
+
if (!confirmed) {
|
|
577
|
+
this.prompts.cancel("Deactivation cancelled");
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const operationId = this.logger.generateOperationId();
|
|
581
|
+
const deactivateSpinner = this.prompts.spinner();
|
|
582
|
+
deactivateSpinner.start("Deactivating flows...");
|
|
583
|
+
try {
|
|
584
|
+
const flowIds = custom.map((f) => f.metadata.Id);
|
|
585
|
+
await handler.deactivate(connection, flowIds);
|
|
586
|
+
const backupPath = await this.backupManager.save(
|
|
587
|
+
org,
|
|
588
|
+
objectName,
|
|
589
|
+
"flows",
|
|
590
|
+
custom,
|
|
591
|
+
managed
|
|
592
|
+
);
|
|
593
|
+
await this.logger.log({
|
|
594
|
+
id: operationId,
|
|
595
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
596
|
+
operation: "deactivate",
|
|
597
|
+
org: org.alias,
|
|
598
|
+
object: objectName,
|
|
599
|
+
type: "flows",
|
|
600
|
+
status: "success",
|
|
601
|
+
itemsAffected: custom.length,
|
|
602
|
+
itemsSkipped: managed.length,
|
|
603
|
+
backupPath,
|
|
604
|
+
error: null
|
|
605
|
+
});
|
|
606
|
+
deactivateSpinner.stop("Flows deactivated");
|
|
607
|
+
this.prompts.success(
|
|
608
|
+
`Successfully deactivated ${custom.length} flow(s). Backup saved to:
|
|
609
|
+
${backupPath}`
|
|
610
|
+
);
|
|
611
|
+
} catch (error) {
|
|
612
|
+
deactivateSpinner.stop("Deactivation failed");
|
|
613
|
+
await this.logger.log({
|
|
614
|
+
id: operationId,
|
|
615
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
616
|
+
operation: "deactivate",
|
|
617
|
+
org: org.alias,
|
|
618
|
+
object: objectName,
|
|
619
|
+
type: "flows",
|
|
620
|
+
status: "failure",
|
|
621
|
+
itemsAffected: 0,
|
|
622
|
+
itemsSkipped: 0,
|
|
623
|
+
backupPath: null,
|
|
624
|
+
error: error.message
|
|
625
|
+
});
|
|
626
|
+
this.prompts.error(`Failed to deactivate flows: ${error.message}`);
|
|
627
|
+
throw error;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
async deactivateTriggers(org, objectName, connection) {
|
|
631
|
+
const handler = new TriggersHandler();
|
|
632
|
+
const spinner2 = this.prompts.spinner();
|
|
633
|
+
spinner2.start("Fetching triggers...");
|
|
634
|
+
const { custom, managed } = await handler.fetchSeparated(connection, objectName);
|
|
635
|
+
spinner2.stop("Triggers fetched");
|
|
636
|
+
if (custom.length === 0 && managed.length === 0) {
|
|
637
|
+
this.prompts.warning("No triggers found for this object.");
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
const previewMessage = this.buildPreviewMessage(custom, managed, "triggers");
|
|
641
|
+
this.prompts.note(previewMessage, `Preview: ${objectName} Triggers`);
|
|
642
|
+
const confirmed = await this.prompts.confirm("Proceed with deactivation?");
|
|
643
|
+
if (!confirmed) {
|
|
644
|
+
this.prompts.cancel("Deactivation cancelled");
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
const operationId = this.logger.generateOperationId();
|
|
648
|
+
const deactivateSpinner = this.prompts.spinner();
|
|
649
|
+
deactivateSpinner.start("Deactivating triggers...");
|
|
650
|
+
try {
|
|
651
|
+
const triggerNames = custom.map((t) => t.fullName);
|
|
652
|
+
await handler.deactivate(connection, triggerNames);
|
|
653
|
+
const backupPath = await this.backupManager.save(
|
|
654
|
+
org,
|
|
655
|
+
objectName,
|
|
656
|
+
"triggers",
|
|
657
|
+
custom,
|
|
658
|
+
managed
|
|
659
|
+
);
|
|
660
|
+
await this.logger.log({
|
|
661
|
+
id: operationId,
|
|
662
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
663
|
+
operation: "deactivate",
|
|
664
|
+
org: org.alias,
|
|
665
|
+
object: objectName,
|
|
666
|
+
type: "triggers",
|
|
667
|
+
status: "success",
|
|
668
|
+
itemsAffected: custom.length,
|
|
669
|
+
itemsSkipped: managed.length,
|
|
670
|
+
backupPath,
|
|
671
|
+
error: null
|
|
672
|
+
});
|
|
673
|
+
deactivateSpinner.stop("Triggers deactivated");
|
|
674
|
+
this.prompts.success(
|
|
675
|
+
`Successfully deactivated ${custom.length} trigger(s). Backup saved to:
|
|
676
|
+
${backupPath}`
|
|
677
|
+
);
|
|
678
|
+
} catch (error) {
|
|
679
|
+
deactivateSpinner.stop("Deactivation failed");
|
|
680
|
+
await this.logger.log({
|
|
681
|
+
id: operationId,
|
|
682
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
683
|
+
operation: "deactivate",
|
|
684
|
+
org: org.alias,
|
|
685
|
+
object: objectName,
|
|
686
|
+
type: "triggers",
|
|
687
|
+
status: "failure",
|
|
688
|
+
itemsAffected: 0,
|
|
689
|
+
itemsSkipped: 0,
|
|
690
|
+
backupPath: null,
|
|
691
|
+
error: error.message
|
|
692
|
+
});
|
|
693
|
+
this.prompts.error(`Failed to deactivate triggers: ${error.message}`);
|
|
694
|
+
throw error;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
buildPreviewMessage(custom, managed, itemType) {
|
|
698
|
+
let message = "";
|
|
699
|
+
if (custom.length > 0) {
|
|
700
|
+
message += `${custom.length} custom ${itemType} will be deactivated
|
|
701
|
+
|
|
702
|
+
`;
|
|
703
|
+
message += "Custom items (will deactivate):\n";
|
|
704
|
+
custom.forEach((item) => {
|
|
705
|
+
message += ` - ${item.fullName}
|
|
706
|
+
`;
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
if (managed.length > 0) {
|
|
710
|
+
message += `
|
|
711
|
+
${managed.length} managed package ${itemType} cannot be deactivated
|
|
712
|
+
|
|
713
|
+
`;
|
|
714
|
+
message += "Managed items (will skip):\n";
|
|
715
|
+
managed.forEach((item) => {
|
|
716
|
+
message += ` - ${item.fullName} (${item.namespace})
|
|
717
|
+
`;
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
return message;
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
// src/commands/restore.ts
|
|
725
|
+
import * as path3 from "path";
|
|
726
|
+
var RestoreCommand = class {
|
|
727
|
+
sfClient;
|
|
728
|
+
backupManager;
|
|
729
|
+
logger;
|
|
730
|
+
prompts;
|
|
731
|
+
constructor() {
|
|
732
|
+
this.sfClient = new SfClient();
|
|
733
|
+
this.backupManager = new BackupManager();
|
|
734
|
+
this.logger = new Logger();
|
|
735
|
+
this.prompts = new Prompts();
|
|
736
|
+
}
|
|
737
|
+
async execute() {
|
|
738
|
+
const orgs = await this.sfClient.listOrgs();
|
|
739
|
+
if (orgs.length === 0) {
|
|
740
|
+
this.prompts.error("No authenticated orgs found.");
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const selectedOrg = await this.prompts.selectOrg(orgs);
|
|
744
|
+
const org = orgs.find((o) => o.alias === selectedOrg);
|
|
745
|
+
const connection = await this.sfClient.getConnection(selectedOrg);
|
|
746
|
+
const objects = await this.sfClient.queryObjects(connection);
|
|
747
|
+
const selectedObject = await this.prompts.selectObject(objects);
|
|
748
|
+
const automationType = await this.prompts.selectAutomationType();
|
|
749
|
+
const backups = await this.backupManager.list(selectedOrg, selectedObject, automationType);
|
|
750
|
+
if (backups.length === 0) {
|
|
751
|
+
this.prompts.warning("No backups found for this org/object/type.");
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
const backupOptions = await Promise.all(
|
|
755
|
+
backups.map(async (backupPath) => {
|
|
756
|
+
const backup2 = await this.backupManager.load(backupPath);
|
|
757
|
+
const filename = path3.basename(backupPath, ".json");
|
|
758
|
+
const status = backup2.restoredAt ? `restored on ${backup2.restoredAt.split("T")[0]}` : "not restored";
|
|
759
|
+
return {
|
|
760
|
+
value: backupPath,
|
|
761
|
+
label: filename,
|
|
762
|
+
hint: `${backup2.items.length} items, ${status}`
|
|
763
|
+
};
|
|
764
|
+
})
|
|
765
|
+
);
|
|
766
|
+
const selectedBackupPath = await this.prompts.selectFromOptions(
|
|
767
|
+
"Select a backup to restore:",
|
|
768
|
+
backupOptions
|
|
769
|
+
);
|
|
770
|
+
const backup = await this.backupManager.load(selectedBackupPath);
|
|
771
|
+
if (automationType === "validation-rules") {
|
|
772
|
+
await this.restoreValidationRules(org, selectedObject, connection, backup, selectedBackupPath);
|
|
773
|
+
} else if (automationType === "flows") {
|
|
774
|
+
await this.restoreFlows(org, selectedObject, connection, backup, selectedBackupPath);
|
|
775
|
+
} else if (automationType === "triggers") {
|
|
776
|
+
await this.restoreTriggers(org, selectedObject, connection, backup, selectedBackupPath);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
async restoreValidationRules(org, objectName, connection, backup, backupPath) {
|
|
780
|
+
const handler = new ValidationRulesHandler();
|
|
781
|
+
const spinner2 = this.prompts.spinner();
|
|
782
|
+
spinner2.start("Analyzing current state...");
|
|
783
|
+
const currentRules = await handler.fetch(connection, objectName);
|
|
784
|
+
const currentRulesMap = new Map(
|
|
785
|
+
currentRules.map((r) => [r.fullName, r])
|
|
786
|
+
);
|
|
787
|
+
const preview = this.analyzeRestoreNeeds(backup.items, currentRulesMap);
|
|
788
|
+
spinner2.stop("Analysis complete");
|
|
789
|
+
const previewMessage = this.buildRestorePreviewMessage(preview);
|
|
790
|
+
this.prompts.note(previewMessage, "Restore Preview");
|
|
791
|
+
if (preview.needsRestore.length === 0) {
|
|
792
|
+
this.prompts.success("All items are already active. Nothing to restore.");
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
const confirmed = await this.prompts.confirm("Proceed with restore?");
|
|
796
|
+
if (!confirmed) {
|
|
797
|
+
this.prompts.cancel("Restore cancelled");
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
const operationId = this.logger.generateOperationId();
|
|
801
|
+
const restoreSpinner = this.prompts.spinner();
|
|
802
|
+
restoreSpinner.start("Restoring validation rules...");
|
|
803
|
+
try {
|
|
804
|
+
const ruleNames = preview.needsRestore.map((r) => r.fullName.split(".")[1]);
|
|
805
|
+
await handler.activate(connection, objectName, ruleNames);
|
|
806
|
+
await this.backupManager.markAsRestored(backupPath, operationId);
|
|
807
|
+
await this.logger.log({
|
|
808
|
+
id: operationId,
|
|
809
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
810
|
+
operation: "restore",
|
|
811
|
+
org: org.alias,
|
|
812
|
+
object: objectName,
|
|
813
|
+
type: "validation-rules",
|
|
814
|
+
status: "success",
|
|
815
|
+
itemsAffected: preview.needsRestore.length,
|
|
816
|
+
itemsSkipped: preview.alreadyActive.length,
|
|
817
|
+
backupPath,
|
|
818
|
+
error: null
|
|
819
|
+
});
|
|
820
|
+
restoreSpinner.stop("Validation rules restored");
|
|
821
|
+
this.prompts.success(
|
|
822
|
+
`Successfully restored ${preview.needsRestore.length} validation rule(s).`
|
|
823
|
+
);
|
|
824
|
+
} catch (error) {
|
|
825
|
+
restoreSpinner.stop("Restore failed");
|
|
826
|
+
await this.logger.log({
|
|
827
|
+
id: operationId,
|
|
828
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
829
|
+
operation: "restore",
|
|
830
|
+
org: org.alias,
|
|
831
|
+
object: objectName,
|
|
832
|
+
type: "validation-rules",
|
|
833
|
+
status: "failure",
|
|
834
|
+
itemsAffected: 0,
|
|
835
|
+
itemsSkipped: 0,
|
|
836
|
+
backupPath,
|
|
837
|
+
error: error.message
|
|
838
|
+
});
|
|
839
|
+
this.prompts.error(`Failed to restore validation rules: ${error.message}`);
|
|
840
|
+
throw error;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
async restoreFlows(org, objectName, connection, backup, backupPath) {
|
|
844
|
+
const handler = new FlowsHandler();
|
|
845
|
+
const spinner2 = this.prompts.spinner();
|
|
846
|
+
spinner2.start("Analyzing current state...");
|
|
847
|
+
const currentFlows = await handler.fetch(connection, objectName);
|
|
848
|
+
const currentFlowsMap = new Map(currentFlows.map((f) => [f.fullName, f]));
|
|
849
|
+
const preview = this.analyzeRestoreNeeds(backup.items, currentFlowsMap);
|
|
850
|
+
spinner2.stop("Analysis complete");
|
|
851
|
+
const previewMessage = this.buildRestorePreviewMessage(preview);
|
|
852
|
+
this.prompts.note(previewMessage, "Restore Preview");
|
|
853
|
+
if (preview.needsRestore.length === 0) {
|
|
854
|
+
this.prompts.success("All flows are already active. Nothing to restore.");
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
const confirmed = await this.prompts.confirm("Proceed with restore?");
|
|
858
|
+
if (!confirmed) {
|
|
859
|
+
this.prompts.cancel("Restore cancelled");
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
const operationId = this.logger.generateOperationId();
|
|
863
|
+
const restoreSpinner = this.prompts.spinner();
|
|
864
|
+
restoreSpinner.start("Restoring flows...");
|
|
865
|
+
try {
|
|
866
|
+
const flowsWithVersions = preview.needsRestore.map((f) => ({
|
|
867
|
+
id: f.metadata.Id,
|
|
868
|
+
version: f.metadata.LatestVersion.VersionNumber
|
|
869
|
+
}));
|
|
870
|
+
await handler.activate(connection, flowsWithVersions);
|
|
871
|
+
await this.backupManager.markAsRestored(backupPath, operationId);
|
|
872
|
+
await this.logger.log({
|
|
873
|
+
id: operationId,
|
|
874
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
875
|
+
operation: "restore",
|
|
876
|
+
org: org.alias,
|
|
877
|
+
object: objectName,
|
|
878
|
+
type: "flows",
|
|
879
|
+
status: "success",
|
|
880
|
+
itemsAffected: preview.needsRestore.length,
|
|
881
|
+
itemsSkipped: preview.alreadyActive.length,
|
|
882
|
+
backupPath,
|
|
883
|
+
error: null
|
|
884
|
+
});
|
|
885
|
+
restoreSpinner.stop("Flows restored");
|
|
886
|
+
this.prompts.success(`Successfully restored ${preview.needsRestore.length} flow(s).`);
|
|
887
|
+
} catch (error) {
|
|
888
|
+
restoreSpinner.stop("Restore failed");
|
|
889
|
+
await this.logger.log({
|
|
890
|
+
id: operationId,
|
|
891
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
892
|
+
operation: "restore",
|
|
893
|
+
org: org.alias,
|
|
894
|
+
object: objectName,
|
|
895
|
+
type: "flows",
|
|
896
|
+
status: "failure",
|
|
897
|
+
itemsAffected: 0,
|
|
898
|
+
itemsSkipped: 0,
|
|
899
|
+
backupPath,
|
|
900
|
+
error: error.message
|
|
901
|
+
});
|
|
902
|
+
this.prompts.error(`Failed to restore flows: ${error.message}`);
|
|
903
|
+
throw error;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
async restoreTriggers(org, objectName, connection, backup, backupPath) {
|
|
907
|
+
const handler = new TriggersHandler();
|
|
908
|
+
const spinner2 = this.prompts.spinner();
|
|
909
|
+
spinner2.start("Analyzing current state...");
|
|
910
|
+
const currentTriggers = await handler.fetch(connection, objectName);
|
|
911
|
+
const currentTriggersMap = new Map(currentTriggers.map((t) => [t.fullName, t]));
|
|
912
|
+
const preview = this.analyzeRestoreNeeds(backup.items, currentTriggersMap);
|
|
913
|
+
spinner2.stop("Analysis complete");
|
|
914
|
+
const previewMessage = this.buildRestorePreviewMessage(preview);
|
|
915
|
+
this.prompts.note(previewMessage, "Restore Preview");
|
|
916
|
+
if (preview.needsRestore.length === 0) {
|
|
917
|
+
this.prompts.success("All triggers are already active. Nothing to restore.");
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
const confirmed = await this.prompts.confirm("Proceed with restore?");
|
|
921
|
+
if (!confirmed) {
|
|
922
|
+
this.prompts.cancel("Restore cancelled");
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
const operationId = this.logger.generateOperationId();
|
|
926
|
+
const restoreSpinner = this.prompts.spinner();
|
|
927
|
+
restoreSpinner.start("Restoring triggers...");
|
|
928
|
+
try {
|
|
929
|
+
const triggerNames = preview.needsRestore.map((t) => t.fullName);
|
|
930
|
+
await handler.activate(connection, triggerNames);
|
|
931
|
+
await this.backupManager.markAsRestored(backupPath, operationId);
|
|
932
|
+
await this.logger.log({
|
|
933
|
+
id: operationId,
|
|
934
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
935
|
+
operation: "restore",
|
|
936
|
+
org: org.alias,
|
|
937
|
+
object: objectName,
|
|
938
|
+
type: "triggers",
|
|
939
|
+
status: "success",
|
|
940
|
+
itemsAffected: preview.needsRestore.length,
|
|
941
|
+
itemsSkipped: preview.alreadyActive.length,
|
|
942
|
+
backupPath,
|
|
943
|
+
error: null
|
|
944
|
+
});
|
|
945
|
+
restoreSpinner.stop("Triggers restored");
|
|
946
|
+
this.prompts.success(`Successfully restored ${preview.needsRestore.length} trigger(s).`);
|
|
947
|
+
} catch (error) {
|
|
948
|
+
restoreSpinner.stop("Restore failed");
|
|
949
|
+
await this.logger.log({
|
|
950
|
+
id: operationId,
|
|
951
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
952
|
+
operation: "restore",
|
|
953
|
+
org: org.alias,
|
|
954
|
+
object: objectName,
|
|
955
|
+
type: "triggers",
|
|
956
|
+
status: "failure",
|
|
957
|
+
itemsAffected: 0,
|
|
958
|
+
itemsSkipped: 0,
|
|
959
|
+
backupPath,
|
|
960
|
+
error: error.message
|
|
961
|
+
});
|
|
962
|
+
this.prompts.error(`Failed to restore triggers: ${error.message}`);
|
|
963
|
+
throw error;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
analyzeRestoreNeeds(backupItems, currentRulesMap) {
|
|
967
|
+
const needsRestore = [];
|
|
968
|
+
const alreadyActive = [];
|
|
969
|
+
for (const item of backupItems) {
|
|
970
|
+
const currentRule = currentRulesMap.get(item.fullName);
|
|
971
|
+
if (!currentRule || !currentRule.active) {
|
|
972
|
+
needsRestore.push(item);
|
|
973
|
+
} else {
|
|
974
|
+
alreadyActive.push(item);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
return {
|
|
978
|
+
totalItems: backupItems.length,
|
|
979
|
+
needsRestore,
|
|
980
|
+
alreadyActive
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
buildRestorePreviewMessage(preview) {
|
|
984
|
+
let message = "Analysis:\n";
|
|
985
|
+
message += ` - ${preview.totalItems} rules in backup
|
|
986
|
+
`;
|
|
987
|
+
message += ` - ${preview.needsRestore.length} currently inactive (need restoration)
|
|
988
|
+
`;
|
|
989
|
+
message += ` - ${preview.alreadyActive.length} already active (will skip)
|
|
990
|
+
|
|
991
|
+
`;
|
|
992
|
+
if (preview.needsRestore.length > 0) {
|
|
993
|
+
message += "Will restore:\n";
|
|
994
|
+
preview.needsRestore.forEach((item) => {
|
|
995
|
+
message += ` - ${item.fullName}
|
|
996
|
+
`;
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
if (preview.alreadyActive.length > 0) {
|
|
1000
|
+
message += "\nAlready active (skipping):\n";
|
|
1001
|
+
preview.alreadyActive.forEach((item) => {
|
|
1002
|
+
message += ` - ${item.fullName}
|
|
1003
|
+
`;
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
return message;
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
// src/commands/manage.ts
|
|
1011
|
+
import * as fs3 from "fs/promises";
|
|
1012
|
+
import * as path4 from "path";
|
|
1013
|
+
var ManageCommand = class {
|
|
1014
|
+
backupManager;
|
|
1015
|
+
prompts;
|
|
1016
|
+
constructor() {
|
|
1017
|
+
this.backupManager = new BackupManager();
|
|
1018
|
+
this.prompts = new Prompts();
|
|
1019
|
+
}
|
|
1020
|
+
async execute() {
|
|
1021
|
+
const action = await this.prompts.selectFromOptions(
|
|
1022
|
+
"What would you like to do?",
|
|
1023
|
+
[
|
|
1024
|
+
{ value: "view", label: "View all backups" },
|
|
1025
|
+
{ value: "cleanup", label: "Clean up old backups" },
|
|
1026
|
+
{ value: "back", label: "Back to main menu" }
|
|
1027
|
+
]
|
|
1028
|
+
);
|
|
1029
|
+
switch (action) {
|
|
1030
|
+
case "view":
|
|
1031
|
+
await this.viewBackups();
|
|
1032
|
+
break;
|
|
1033
|
+
case "cleanup":
|
|
1034
|
+
await this.cleanupBackups();
|
|
1035
|
+
break;
|
|
1036
|
+
case "back":
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
async viewBackups() {
|
|
1041
|
+
const backupDir = ".backups";
|
|
1042
|
+
try {
|
|
1043
|
+
const orgs = await fs3.readdir(backupDir);
|
|
1044
|
+
if (orgs.length === 0) {
|
|
1045
|
+
this.prompts.warning("No backups found.");
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
let message = "Backups:\n\n";
|
|
1049
|
+
for (const org of orgs) {
|
|
1050
|
+
message += `${org}
|
|
1051
|
+
`;
|
|
1052
|
+
const orgPath = path4.join(backupDir, org);
|
|
1053
|
+
const objects = await fs3.readdir(orgPath);
|
|
1054
|
+
for (const object of objects) {
|
|
1055
|
+
const objectPath = path4.join(orgPath, object);
|
|
1056
|
+
const types = await fs3.readdir(objectPath);
|
|
1057
|
+
for (const type of types) {
|
|
1058
|
+
const typePath = path4.join(objectPath, type);
|
|
1059
|
+
const files = await fs3.readdir(typePath);
|
|
1060
|
+
message += ` ${object} > ${type} (${files.length} backup(s))
|
|
1061
|
+
`;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
message += "\n";
|
|
1065
|
+
}
|
|
1066
|
+
this.prompts.note(message, "All Backups");
|
|
1067
|
+
} catch (error) {
|
|
1068
|
+
if (error.code === "ENOENT") {
|
|
1069
|
+
this.prompts.warning("No backups directory found.");
|
|
1070
|
+
} else {
|
|
1071
|
+
throw error;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
async cleanupBackups() {
|
|
1076
|
+
this.prompts.warning("Interactive cleanup coming soon!");
|
|
1077
|
+
this.prompts.note(
|
|
1078
|
+
"For now, you can manually delete backup files from the .backups/ directory.",
|
|
1079
|
+
"Manual Cleanup"
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
};
|
|
1083
|
+
|
|
1084
|
+
// src/commands/logs.ts
|
|
1085
|
+
var LogsCommand = class {
|
|
1086
|
+
logger;
|
|
1087
|
+
prompts;
|
|
1088
|
+
constructor() {
|
|
1089
|
+
this.logger = new Logger();
|
|
1090
|
+
this.prompts = new Prompts();
|
|
1091
|
+
}
|
|
1092
|
+
async execute() {
|
|
1093
|
+
const logs = await this.logger.getLogs();
|
|
1094
|
+
if (logs.length === 0) {
|
|
1095
|
+
this.prompts.warning("No operation logs found.");
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
const recentLogs = logs.slice(-20).reverse();
|
|
1099
|
+
let message = "Recent Operations:\n\n";
|
|
1100
|
+
for (const log2 of recentLogs) {
|
|
1101
|
+
const statusEmoji = log2.status === "success" ? "\u2713" : "\u2717";
|
|
1102
|
+
const timestamp = new Date(log2.timestamp).toLocaleString();
|
|
1103
|
+
message += `${statusEmoji} ${log2.operation.toUpperCase()} - ${log2.org}/${log2.object}/${log2.type}
|
|
1104
|
+
`;
|
|
1105
|
+
message += ` ${timestamp}
|
|
1106
|
+
`;
|
|
1107
|
+
message += ` Items affected: ${log2.itemsAffected}, skipped: ${log2.itemsSkipped}
|
|
1108
|
+
`;
|
|
1109
|
+
if (log2.error) {
|
|
1110
|
+
message += ` Error: ${log2.error}
|
|
1111
|
+
`;
|
|
1112
|
+
}
|
|
1113
|
+
message += "\n";
|
|
1114
|
+
}
|
|
1115
|
+
this.prompts.note(message, `Operation Logs (showing ${recentLogs.length} of ${logs.length})`);
|
|
1116
|
+
}
|
|
1117
|
+
};
|
|
1118
|
+
|
|
1119
|
+
// src/index.ts
|
|
1120
|
+
async function main() {
|
|
1121
|
+
const prompts = new Prompts();
|
|
1122
|
+
prompts.intro("Salesforce Migration Tools CLI");
|
|
1123
|
+
try {
|
|
1124
|
+
while (true) {
|
|
1125
|
+
const action = await prompts.selectMainAction();
|
|
1126
|
+
switch (action) {
|
|
1127
|
+
case "deactivate":
|
|
1128
|
+
const deactivateCmd = new DeactivateCommand();
|
|
1129
|
+
await deactivateCmd.execute();
|
|
1130
|
+
break;
|
|
1131
|
+
case "restore":
|
|
1132
|
+
const restoreCmd = new RestoreCommand();
|
|
1133
|
+
await restoreCmd.execute();
|
|
1134
|
+
break;
|
|
1135
|
+
case "manage":
|
|
1136
|
+
const manageCmd = new ManageCommand();
|
|
1137
|
+
await manageCmd.execute();
|
|
1138
|
+
break;
|
|
1139
|
+
case "logs":
|
|
1140
|
+
const logsCmd = new LogsCommand();
|
|
1141
|
+
await logsCmd.execute();
|
|
1142
|
+
break;
|
|
1143
|
+
case "exit":
|
|
1144
|
+
prompts.outro("Goodbye!");
|
|
1145
|
+
process.exit(0);
|
|
1146
|
+
}
|
|
1147
|
+
const continuePrompt = await prompts.confirm("Perform another operation?");
|
|
1148
|
+
if (!continuePrompt) {
|
|
1149
|
+
prompts.outro("Goodbye!");
|
|
1150
|
+
process.exit(0);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
} catch (error) {
|
|
1154
|
+
prompts.error(`An error occurred: ${error.message}`);
|
|
1155
|
+
process.exit(1);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@inforge/migrations-tools-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Inforge's interactive CLI for side-effect-free Salesforce data operations by managing automation",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"imigrate": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"dev": "tsx src/index.ts",
|
|
15
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
16
|
+
"start": "node dist/index.js",
|
|
17
|
+
"typecheck": "tsc --noEmit",
|
|
18
|
+
"test": "vitest",
|
|
19
|
+
"test:ui": "vitest --ui"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"inforge",
|
|
23
|
+
"salesforce",
|
|
24
|
+
"cli",
|
|
25
|
+
"migration",
|
|
26
|
+
"automation",
|
|
27
|
+
"validation-rules",
|
|
28
|
+
"flows",
|
|
29
|
+
"triggers",
|
|
30
|
+
"data-loading"
|
|
31
|
+
],
|
|
32
|
+
"author": "Inforge",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18.0.0"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@clack/prompts": "^1.0.1",
|
|
44
|
+
"@salesforce/core": "^8.26.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^25.2.3",
|
|
48
|
+
"@vitest/ui": "^3.2.4",
|
|
49
|
+
"tsup": "^8.5.1",
|
|
50
|
+
"tsx": "^4.21.0",
|
|
51
|
+
"typescript": "^5.9.3",
|
|
52
|
+
"vitest": "^3.2.4"
|
|
53
|
+
}
|
|
54
|
+
}
|