primitive-admin 1.0.25 → 1.0.26
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 +124 -4
- package/dist/bin/primitive.js +21 -4
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/auth.js +113 -3
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/collection-type-configs.js +178 -0
- package/dist/src/commands/collection-type-configs.js.map +1 -0
- package/dist/src/commands/collections.js +512 -0
- package/dist/src/commands/collections.js.map +1 -0
- package/dist/src/commands/documents.js +105 -2
- package/dist/src/commands/documents.js.map +1 -1
- package/dist/src/commands/init.js +126 -18
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/sync.js +961 -75
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/tokens.js +17 -3
- package/dist/src/commands/tokens.js.map +1 -1
- package/dist/src/commands/users.js +60 -0
- package/dist/src/commands/users.js.map +1 -1
- package/dist/src/commands/workflows.js +21 -3
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.js +351 -1
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/template.js +161 -6
- package/dist/src/lib/template.js.map +1 -1
- package/dist/src/lib/version-check.js +3 -3
- package/dist/src/lib/version-check.js.map +1 -1
- package/package.json +4 -3
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync, rmdirSync, statSync } from "fs";
|
|
2
2
|
import { join, basename } from "path";
|
|
3
|
+
import { createHash } from "crypto";
|
|
3
4
|
import * as TOML from "@iarna/toml";
|
|
4
5
|
import { lookup as mimeLookup } from "mime-types";
|
|
5
6
|
import { ApiClient, ConflictError } from "../lib/api-client.js";
|
|
@@ -27,6 +28,15 @@ function saveSyncState(configDir, state) {
|
|
|
27
28
|
const syncFile = join(configDir, ".primitive-sync.json");
|
|
28
29
|
writeFileSync(syncFile, JSON.stringify(state, null, 2));
|
|
29
30
|
}
|
|
31
|
+
export function computeFileHash(filePath) {
|
|
32
|
+
const content = readFileSync(filePath);
|
|
33
|
+
return createHash("sha256").update(content).digest("hex");
|
|
34
|
+
}
|
|
35
|
+
export function shouldPushFile(filePath, storedHash) {
|
|
36
|
+
if (!storedHash)
|
|
37
|
+
return true;
|
|
38
|
+
return computeFileHash(filePath) !== storedHash;
|
|
39
|
+
}
|
|
30
40
|
// TOML serialization helpers
|
|
31
41
|
function serializeAppSettings(settings) {
|
|
32
42
|
const data = {
|
|
@@ -83,7 +93,7 @@ function serializePrompt(prompt) {
|
|
|
83
93
|
description: config.description,
|
|
84
94
|
provider: config.provider,
|
|
85
95
|
model: config.model,
|
|
86
|
-
temperature: config.temperature
|
|
96
|
+
temperature: config.temperature != null ? Number(config.temperature) : undefined,
|
|
87
97
|
maxTokens: config.maxTokens,
|
|
88
98
|
outputFormat: config.outputFormat,
|
|
89
99
|
systemPrompt: config.systemPrompt,
|
|
@@ -96,6 +106,7 @@ function serializeWorkflow(workflow, draft, configs) {
|
|
|
96
106
|
// Find the active config or use the first one
|
|
97
107
|
const activeConfigId = workflow.activeConfigId;
|
|
98
108
|
const activeConfig = configs?.find((c) => c.configId === activeConfigId) || configs?.[0];
|
|
109
|
+
const activeConfigName = activeConfig?.configName || "";
|
|
99
110
|
// Determine which steps to use (prefer config steps if non-empty, else draft steps)
|
|
100
111
|
const configSteps = activeConfig?.steps;
|
|
101
112
|
const draftSteps = draft?.steps;
|
|
@@ -106,7 +117,7 @@ function serializeWorkflow(workflow, draft, configs) {
|
|
|
106
117
|
name: workflow.name,
|
|
107
118
|
description: workflow.description,
|
|
108
119
|
status: workflow.status,
|
|
109
|
-
|
|
120
|
+
activeConfigName: activeConfigName,
|
|
110
121
|
perUserMaxRunning: workflow.perUserMaxRunning,
|
|
111
122
|
perUserMaxQueued: workflow.perUserMaxQueued,
|
|
112
123
|
dequeueOrder: workflow.dequeueOrder,
|
|
@@ -118,7 +129,6 @@ function serializeWorkflow(workflow, draft, configs) {
|
|
|
118
129
|
steps,
|
|
119
130
|
// Include all configurations
|
|
120
131
|
configs: configs?.map((config) => ({
|
|
121
|
-
id: config.configId,
|
|
122
132
|
name: config.configName,
|
|
123
133
|
description: config.description,
|
|
124
134
|
status: config.status,
|
|
@@ -127,6 +137,33 @@ function serializeWorkflow(workflow, draft, configs) {
|
|
|
127
137
|
};
|
|
128
138
|
return TOML.stringify(data);
|
|
129
139
|
}
|
|
140
|
+
// Database config serialization helpers
|
|
141
|
+
function serializeDatabaseType(typeConfig, operations, ruleSetIdToName) {
|
|
142
|
+
const ruleSetName = typeConfig.ruleSetId ? (ruleSetIdToName.get(typeConfig.ruleSetId) || "") : "";
|
|
143
|
+
const data = {
|
|
144
|
+
type: {
|
|
145
|
+
databaseType: typeConfig.databaseType,
|
|
146
|
+
ruleSetName: ruleSetName,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
if (typeConfig.metadataAccess) {
|
|
150
|
+
data.type.metadataAccess = typeConfig.metadataAccess;
|
|
151
|
+
}
|
|
152
|
+
if (typeConfig.triggers) {
|
|
153
|
+
data.triggers = typeConfig.triggers;
|
|
154
|
+
}
|
|
155
|
+
if (operations.length > 0) {
|
|
156
|
+
data.operations = operations.map((op) => ({
|
|
157
|
+
name: op.name,
|
|
158
|
+
type: op.type,
|
|
159
|
+
modelName: op.modelName,
|
|
160
|
+
access: op.access,
|
|
161
|
+
definition: typeof op.definition === "object" ? JSON.stringify(op.definition) : op.definition,
|
|
162
|
+
...(op.params ? { params: typeof op.params === "object" ? JSON.stringify(op.params) : op.params } : {}),
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
return TOML.stringify(data);
|
|
166
|
+
}
|
|
130
167
|
function serializeEmailTemplate(template) {
|
|
131
168
|
// The API returns { emailType, hasOverride, override: { subject, htmlBody, textBody }, default: { ... } }
|
|
132
169
|
// We only serialize overrides (custom templates), using the override fields.
|
|
@@ -145,11 +182,138 @@ function serializeEmailTemplate(template) {
|
|
|
145
182
|
}
|
|
146
183
|
return TOML.stringify(data);
|
|
147
184
|
}
|
|
185
|
+
function serializeRuleSet(ruleSet) {
|
|
186
|
+
const data = {
|
|
187
|
+
ruleSet: {
|
|
188
|
+
name: ruleSet.name,
|
|
189
|
+
resourceType: ruleSet.resourceType,
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
if (ruleSet.description) {
|
|
193
|
+
data.ruleSet.description = ruleSet.description;
|
|
194
|
+
}
|
|
195
|
+
// Rules are structured as { category: { operation: "expression" } }
|
|
196
|
+
const rules = typeof ruleSet.rules === "string" ? JSON.parse(ruleSet.rules) : (ruleSet.rules || {});
|
|
197
|
+
if (Object.keys(rules).length > 0) {
|
|
198
|
+
data.rules = rules;
|
|
199
|
+
}
|
|
200
|
+
return TOML.stringify(data);
|
|
201
|
+
}
|
|
202
|
+
function serializeGroupTypeConfig(config, ruleSetIdToName) {
|
|
203
|
+
const ruleSetName = config.ruleSetId ? (ruleSetIdToName.get(config.ruleSetId) || "") : "";
|
|
204
|
+
const data = {
|
|
205
|
+
groupTypeConfig: {
|
|
206
|
+
groupType: config.groupType,
|
|
207
|
+
ruleSetName: ruleSetName,
|
|
208
|
+
autoAddCreator: config.autoAddCreator ?? true,
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
return TOML.stringify(data);
|
|
212
|
+
}
|
|
213
|
+
export function parseDatabaseTypeToml(tomlData) {
|
|
214
|
+
const typeSection = tomlData.type || {};
|
|
215
|
+
const typeConfig = {
|
|
216
|
+
databaseType: typeSection.databaseType,
|
|
217
|
+
};
|
|
218
|
+
// Support both new key-based reference and legacy ID-based reference
|
|
219
|
+
if (typeSection.ruleSetName && typeSection.ruleSetName !== "") {
|
|
220
|
+
typeConfig._ruleSetName = typeSection.ruleSetName;
|
|
221
|
+
}
|
|
222
|
+
else if (typeSection.ruleSetId && typeSection.ruleSetId !== "") {
|
|
223
|
+
typeConfig.ruleSetId = typeSection.ruleSetId;
|
|
224
|
+
}
|
|
225
|
+
if (typeSection.metadataAccess) {
|
|
226
|
+
typeConfig.metadataAccess = typeSection.metadataAccess;
|
|
227
|
+
}
|
|
228
|
+
if (tomlData.triggers) {
|
|
229
|
+
typeConfig.triggers = tomlData.triggers;
|
|
230
|
+
}
|
|
231
|
+
const operations = (tomlData.operations || []).map((op) => ({
|
|
232
|
+
name: op.name,
|
|
233
|
+
type: op.type,
|
|
234
|
+
modelName: op.modelName,
|
|
235
|
+
access: op.access,
|
|
236
|
+
definition: typeof op.definition === "string" ? JSON.parse(op.definition) : op.definition,
|
|
237
|
+
params: op.params ? (typeof op.params === "string" ? JSON.parse(op.params) : op.params) : null,
|
|
238
|
+
}));
|
|
239
|
+
return { typeConfig, operations };
|
|
240
|
+
}
|
|
241
|
+
function parseRuleSetToml(tomlData) {
|
|
242
|
+
const ruleSetSection = tomlData.ruleSet || {};
|
|
243
|
+
return {
|
|
244
|
+
name: ruleSetSection.name,
|
|
245
|
+
resourceType: ruleSetSection.resourceType,
|
|
246
|
+
description: ruleSetSection.description || null,
|
|
247
|
+
rules: tomlData.rules || {},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
export function parseGroupTypeConfigToml(tomlData) {
|
|
251
|
+
const section = tomlData.groupTypeConfig || {};
|
|
252
|
+
const result = {
|
|
253
|
+
groupType: section.groupType,
|
|
254
|
+
autoAddCreator: section.autoAddCreator ?? true,
|
|
255
|
+
};
|
|
256
|
+
// Support both new key-based reference and legacy ID-based reference
|
|
257
|
+
if (section.ruleSetName && section.ruleSetName !== "") {
|
|
258
|
+
result._ruleSetName = section.ruleSetName;
|
|
259
|
+
}
|
|
260
|
+
else if (section.ruleSetId && section.ruleSetId !== "") {
|
|
261
|
+
result.ruleSetId = section.ruleSetId;
|
|
262
|
+
}
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
148
265
|
// Parsing helpers
|
|
149
266
|
function parseTomlFile(filePath) {
|
|
150
267
|
const content = readFileSync(filePath, "utf-8");
|
|
151
268
|
return TOML.parse(content);
|
|
152
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* Paginate through a list endpoint, collecting all items.
|
|
272
|
+
*/
|
|
273
|
+
export async function fetchAll(listFn, pageSize = 100, maxPages = 100) {
|
|
274
|
+
const all = [];
|
|
275
|
+
let cursor;
|
|
276
|
+
let page = 0;
|
|
277
|
+
do {
|
|
278
|
+
if (++page > maxPages) {
|
|
279
|
+
throw new Error(`fetchAll: too many pages (>${maxPages}), aborting to prevent infinite loop`);
|
|
280
|
+
}
|
|
281
|
+
const result = await listFn({ limit: pageSize, cursor: cursor });
|
|
282
|
+
all.push(...result.items);
|
|
283
|
+
cursor = result.nextCursor || undefined;
|
|
284
|
+
} while (cursor);
|
|
285
|
+
return all;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Safely parse a JSON field that may be a string or already an object.
|
|
289
|
+
* TOML can parse inline tables as objects, so we need to handle both.
|
|
290
|
+
*/
|
|
291
|
+
export function safeJsonParse(value) {
|
|
292
|
+
if (value === undefined || value === null)
|
|
293
|
+
return undefined;
|
|
294
|
+
if (typeof value === "string")
|
|
295
|
+
return JSON.parse(value);
|
|
296
|
+
return value; // Already an object (TOML parsed it as a table)
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Resolve a key-based rule set name reference to an ID.
|
|
300
|
+
* Throws if the name cannot be resolved and throwOnMissing is true.
|
|
301
|
+
*/
|
|
302
|
+
export function resolveRuleSetReference(entityConfig, ruleSetNameToId, entityLabel, options = {}) {
|
|
303
|
+
if (!entityConfig._ruleSetName)
|
|
304
|
+
return;
|
|
305
|
+
const resolvedRuleSetId = ruleSetNameToId.get(entityConfig._ruleSetName);
|
|
306
|
+
if (resolvedRuleSetId) {
|
|
307
|
+
entityConfig.ruleSetId = resolvedRuleSetId;
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
const msg = `Rule set "${entityConfig._ruleSetName}" not found for ${entityLabel}. The entity would be created without a rule set.`;
|
|
311
|
+
if (options.throwOnMissing !== false) {
|
|
312
|
+
throw new Error(msg);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
delete entityConfig._ruleSetName;
|
|
316
|
+
}
|
|
153
317
|
// Test case helpers (exported for unit testing)
|
|
154
318
|
export function slugifyTestCaseName(name) {
|
|
155
319
|
return name
|
|
@@ -175,15 +339,22 @@ function getTestsDir(configDir, blockType, blockKey) {
|
|
|
175
339
|
const typeDir = blockType === "prompt" ? "prompts" : "workflows";
|
|
176
340
|
return join(configDir, typeDir, `${blockKey}.tests`);
|
|
177
341
|
}
|
|
178
|
-
export function serializeTestCase(testCase) {
|
|
342
|
+
export function serializeTestCase(testCase, lookupMaps) {
|
|
343
|
+
// Resolve ID references to key-based references
|
|
344
|
+
const configName = testCase.configId && lookupMaps?.configIdToName
|
|
345
|
+
? (lookupMaps.configIdToName.get(testCase.configId) || "") : "";
|
|
346
|
+
const evaluatorPromptKey = testCase.evaluatorPromptId && lookupMaps?.promptIdToKey
|
|
347
|
+
? (lookupMaps.promptIdToKey.get(testCase.evaluatorPromptId) || "") : "";
|
|
348
|
+
const evaluatorConfigName = testCase.evaluatorConfigId && lookupMaps?.configIdToName
|
|
349
|
+
? (lookupMaps.configIdToName.get(testCase.evaluatorConfigId) || "") : "";
|
|
179
350
|
const data = {
|
|
180
351
|
test: {
|
|
181
352
|
name: testCase.name || "",
|
|
182
353
|
description: testCase.description || "",
|
|
183
354
|
inputVariables: testCase.inputVariables || "{}",
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
355
|
+
configName: configName,
|
|
356
|
+
evaluatorPromptKey: evaluatorPromptKey,
|
|
357
|
+
evaluatorConfigName: evaluatorConfigName,
|
|
187
358
|
expectedOutputPattern: testCase.expectedOutputPattern || "",
|
|
188
359
|
expectedOutputContains: testCase.expectedOutputContains || "[]",
|
|
189
360
|
expectedJsonSubset: testCase.expectedJsonSubset || "{}",
|
|
@@ -210,6 +381,18 @@ export function parseTestCaseToml(tomlData) {
|
|
|
210
381
|
if (test.description && test.description !== "") {
|
|
211
382
|
payload.description = test.description;
|
|
212
383
|
}
|
|
384
|
+
// Support both new key-based references and legacy ID-based references
|
|
385
|
+
// Key-based references (portable across apps)
|
|
386
|
+
if (test.configName && test.configName !== "") {
|
|
387
|
+
payload._configName = test.configName;
|
|
388
|
+
}
|
|
389
|
+
if (test.evaluatorPromptKey && test.evaluatorPromptKey !== "") {
|
|
390
|
+
payload._evaluatorPromptKey = test.evaluatorPromptKey;
|
|
391
|
+
}
|
|
392
|
+
if (test.evaluatorConfigName && test.evaluatorConfigName !== "") {
|
|
393
|
+
payload._evaluatorConfigName = test.evaluatorConfigName;
|
|
394
|
+
}
|
|
395
|
+
// Legacy ID-based references (backward compat)
|
|
213
396
|
if (test.configId && test.configId !== "") {
|
|
214
397
|
payload.configId = test.configId;
|
|
215
398
|
}
|
|
@@ -240,7 +423,7 @@ export function parseTestCaseToml(tomlData) {
|
|
|
240
423
|
}
|
|
241
424
|
return payload;
|
|
242
425
|
}
|
|
243
|
-
async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, testCaseEntities) {
|
|
426
|
+
async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, testCaseEntities, lookupMaps) {
|
|
244
427
|
let testCases;
|
|
245
428
|
try {
|
|
246
429
|
const result = await client.listTestCases(appId, blockType, blockId);
|
|
@@ -261,7 +444,7 @@ async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey
|
|
|
261
444
|
pulledSlugs.add(slug);
|
|
262
445
|
// Write test case TOML
|
|
263
446
|
const tomlPath = join(testsDir, `${slug}.toml`);
|
|
264
|
-
writeFileSync(tomlPath, serializeTestCase(tc));
|
|
447
|
+
writeFileSync(tomlPath, serializeTestCase(tc, lookupMaps));
|
|
265
448
|
// Handle attachments
|
|
266
449
|
let attachmentFilenames = [];
|
|
267
450
|
try {
|
|
@@ -297,6 +480,7 @@ async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey
|
|
|
297
480
|
slug,
|
|
298
481
|
modifiedAt: tc.modifiedAt || tc.createdAt || new Date().toISOString(),
|
|
299
482
|
attachmentFilenames: attachmentFilenames.length > 0 ? attachmentFilenames : undefined,
|
|
483
|
+
contentHash: computeFileHash(tomlPath),
|
|
300
484
|
};
|
|
301
485
|
}
|
|
302
486
|
// Clean up local files for remotely-deleted test cases
|
|
@@ -326,21 +510,75 @@ async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey
|
|
|
326
510
|
}
|
|
327
511
|
return testCases.length;
|
|
328
512
|
}
|
|
329
|
-
async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, syncState, dryRun, changes) {
|
|
513
|
+
async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, syncState, dryRun, changes, resolutionMaps, options) {
|
|
514
|
+
let skipped = 0;
|
|
330
515
|
const testsDir = getTestsDir(configDir, blockType, blockKey);
|
|
331
516
|
if (!existsSync(testsDir))
|
|
332
|
-
return;
|
|
517
|
+
return 0;
|
|
333
518
|
const files = readdirSync(testsDir).filter((f) => f.endsWith(".toml"));
|
|
334
519
|
if (files.length === 0)
|
|
335
|
-
return;
|
|
520
|
+
return 0;
|
|
336
521
|
const TEN_MB = 10 * 1024 * 1024;
|
|
337
522
|
for (const file of files) {
|
|
338
523
|
const slug = basename(file, ".toml");
|
|
339
524
|
const filePath = join(testsDir, file);
|
|
340
525
|
const tomlData = parseTomlFile(filePath);
|
|
341
526
|
const testPayload = parseTestCaseToml(tomlData);
|
|
527
|
+
// Resolve key-based references to IDs
|
|
528
|
+
if (resolutionMaps) {
|
|
529
|
+
// Resolve configName → configId
|
|
530
|
+
if (testPayload._configName && !testPayload.configId) {
|
|
531
|
+
const configMapKey = `${blockKey}#${testPayload._configName}`;
|
|
532
|
+
const configMap = blockType === "prompt"
|
|
533
|
+
? resolutionMaps.promptConfigNameToId
|
|
534
|
+
: resolutionMaps.workflowConfigNameToId;
|
|
535
|
+
const resolvedId = configMap.get(configMapKey);
|
|
536
|
+
if (resolvedId) {
|
|
537
|
+
testPayload.configId = resolvedId;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
delete testPayload._configName;
|
|
541
|
+
// Resolve evaluatorPromptKey → evaluatorPromptId
|
|
542
|
+
if (testPayload._evaluatorPromptKey && !testPayload.evaluatorPromptId) {
|
|
543
|
+
const resolvedId = resolutionMaps.promptKeyToId.get(testPayload._evaluatorPromptKey);
|
|
544
|
+
if (resolvedId) {
|
|
545
|
+
testPayload.evaluatorPromptId = resolvedId;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
const evaluatorPromptKey = testPayload._evaluatorPromptKey;
|
|
549
|
+
delete testPayload._evaluatorPromptKey;
|
|
550
|
+
// Resolve evaluatorConfigName → evaluatorConfigId
|
|
551
|
+
if (testPayload._evaluatorConfigName && !testPayload.evaluatorConfigId && evaluatorPromptKey) {
|
|
552
|
+
const configMapKey = `${evaluatorPromptKey}#${testPayload._evaluatorConfigName}`;
|
|
553
|
+
const resolvedId = resolutionMaps.promptConfigNameToId.get(configMapKey);
|
|
554
|
+
if (resolvedId) {
|
|
555
|
+
testPayload.evaluatorConfigId = resolvedId;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
delete testPayload._evaluatorConfigName;
|
|
559
|
+
}
|
|
342
560
|
const stateKey = `${blockType}/${blockKey}/${slug}`;
|
|
343
561
|
const existingEntry = syncState?.entities?.testCases?.[stateKey];
|
|
562
|
+
// Skip if file hasn't changed since last sync (also check for attachment changes)
|
|
563
|
+
if (!options?.force && existingEntry?.id && !shouldPushFile(filePath, existingEntry.contentHash)) {
|
|
564
|
+
const attachDir = join(testsDir, slug);
|
|
565
|
+
const localAttachFiles = existsSync(attachDir)
|
|
566
|
+
? readdirSync(attachDir).filter((f) => { try {
|
|
567
|
+
return statSync(join(attachDir, f)).isFile();
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
return false;
|
|
571
|
+
} })
|
|
572
|
+
: [];
|
|
573
|
+
const prevAttachFiles = existingEntry.attachmentFilenames || [];
|
|
574
|
+
const attachmentsChanged = localAttachFiles.length !== prevAttachFiles.length ||
|
|
575
|
+
localAttachFiles.some((f) => !prevAttachFiles.includes(f)) ||
|
|
576
|
+
prevAttachFiles.some((f) => !localAttachFiles.includes(f));
|
|
577
|
+
if (!attachmentsChanged) {
|
|
578
|
+
skipped++;
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
344
582
|
if (existingEntry?.id) {
|
|
345
583
|
// Update existing test case
|
|
346
584
|
changes.push({ type: "test-case", action: "update", key: `${blockKey}/${slug}` });
|
|
@@ -348,6 +586,9 @@ async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey
|
|
|
348
586
|
try {
|
|
349
587
|
await client.updateTestCase(appId, blockType, blockId, existingEntry.id, testPayload);
|
|
350
588
|
info(` Updated test case: ${blockKey}/${slug}`);
|
|
589
|
+
if (syncState?.entities?.testCases?.[stateKey]) {
|
|
590
|
+
syncState.entities.testCases[stateKey].contentHash = computeFileHash(filePath);
|
|
591
|
+
}
|
|
351
592
|
}
|
|
352
593
|
catch (err) {
|
|
353
594
|
warn(` Failed to update test case ${blockKey}/${slug}: ${err.message}`);
|
|
@@ -378,6 +619,7 @@ async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey
|
|
|
378
619
|
blockKey,
|
|
379
620
|
slug,
|
|
380
621
|
modifiedAt: created.modifiedAt || created.createdAt || new Date().toISOString(),
|
|
622
|
+
contentHash: computeFileHash(filePath),
|
|
381
623
|
};
|
|
382
624
|
}
|
|
383
625
|
}
|
|
@@ -456,6 +698,7 @@ async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey
|
|
|
456
698
|
}
|
|
457
699
|
}
|
|
458
700
|
}
|
|
701
|
+
return skipped;
|
|
459
702
|
}
|
|
460
703
|
function formatFileSize(bytes) {
|
|
461
704
|
if (bytes < 1024)
|
|
@@ -477,14 +720,17 @@ Examples:
|
|
|
477
720
|
|
|
478
721
|
Directory Structure:
|
|
479
722
|
config/
|
|
480
|
-
.primitive-sync.json
|
|
481
|
-
app.toml
|
|
482
|
-
integrations/*.toml
|
|
483
|
-
prompts/*.toml
|
|
484
|
-
prompts/{key}.tests/*.toml
|
|
485
|
-
workflows/*.toml
|
|
486
|
-
workflows/{key}.tests/*.toml
|
|
487
|
-
|
|
723
|
+
.primitive-sync.json # Sync state (auto-managed)
|
|
724
|
+
app.toml # App settings
|
|
725
|
+
integrations/*.toml # Integration configs
|
|
726
|
+
prompts/*.toml # Prompt configs
|
|
727
|
+
prompts/{key}.tests/*.toml # Prompt test cases
|
|
728
|
+
workflows/*.toml # Workflow definitions
|
|
729
|
+
workflows/{key}.tests/*.toml # Workflow test cases
|
|
730
|
+
database-types/*.toml # Database type configs + operations
|
|
731
|
+
rule-sets/*.toml # Access rule sets
|
|
732
|
+
group-type-configs/*.toml # Group type configs
|
|
733
|
+
email-templates/*.toml # Email template overrides
|
|
488
734
|
`);
|
|
489
735
|
// Init
|
|
490
736
|
sync
|
|
@@ -500,6 +746,9 @@ Directory Structure:
|
|
|
500
746
|
ensureDir(join(configDir, "integrations"));
|
|
501
747
|
ensureDir(join(configDir, "prompts"));
|
|
502
748
|
ensureDir(join(configDir, "workflows"));
|
|
749
|
+
ensureDir(join(configDir, "database-types"));
|
|
750
|
+
ensureDir(join(configDir, "rule-sets"));
|
|
751
|
+
ensureDir(join(configDir, "group-type-configs"));
|
|
503
752
|
ensureDir(join(configDir, "email-templates"));
|
|
504
753
|
const state = {
|
|
505
754
|
appId: resolvedAppId,
|
|
@@ -526,17 +775,17 @@ Directory Structure:
|
|
|
526
775
|
info(`Pulling configuration for app ${resolvedAppId}...`);
|
|
527
776
|
try {
|
|
528
777
|
// Fetch all data
|
|
529
|
-
const [settings,
|
|
778
|
+
const [settings, integrationItems, promptItems, workflowItems, emailTemplatesResult] = await Promise.all([
|
|
530
779
|
client.getAppSettings(resolvedAppId).catch(() => null),
|
|
531
|
-
client.listIntegrations(resolvedAppId,
|
|
532
|
-
client.listPrompts(resolvedAppId,
|
|
533
|
-
client.listWorkflows(resolvedAppId,
|
|
780
|
+
fetchAll((p) => client.listIntegrations(resolvedAppId, p)),
|
|
781
|
+
fetchAll((p) => client.listPrompts(resolvedAppId, p)),
|
|
782
|
+
fetchAll((p) => client.listWorkflows(resolvedAppId, p)),
|
|
534
783
|
client.listEmailTemplates(resolvedAppId).catch(() => ({ templates: [] })),
|
|
535
784
|
]);
|
|
536
785
|
// Fetch details for each entity
|
|
537
|
-
const integrations = await Promise.all(
|
|
538
|
-
const prompts = await Promise.all(
|
|
539
|
-
const workflows = await Promise.all(
|
|
786
|
+
const integrations = await Promise.all(integrationItems.map((i) => client.getIntegration(resolvedAppId, i.integrationId)));
|
|
787
|
+
const prompts = await Promise.all(promptItems.map((p) => client.getPrompt(resolvedAppId, p.promptId)));
|
|
788
|
+
const workflows = await Promise.all(workflowItems.map(async (w) => {
|
|
540
789
|
const workflowData = await client.getWorkflow(resolvedAppId, w.workflowId);
|
|
541
790
|
// Fetch active config with steps if available
|
|
542
791
|
const activeConfigId = workflowData.workflow?.activeConfigId;
|
|
@@ -564,25 +813,42 @@ Directory Structure:
|
|
|
564
813
|
json({ settings, integrations, prompts, workflows, emailTemplates });
|
|
565
814
|
return;
|
|
566
815
|
}
|
|
816
|
+
// Fetch database config resources
|
|
817
|
+
const [databaseTypeConfigsResult, ruleSetsResult, groupTypeConfigsResult] = await Promise.all([
|
|
818
|
+
client.listDatabaseTypeConfigs(resolvedAppId).catch(() => []),
|
|
819
|
+
client.listRuleSets(resolvedAppId).catch(() => []),
|
|
820
|
+
client.listGroupTypeConfigs(resolvedAppId).catch(() => []),
|
|
821
|
+
]);
|
|
822
|
+
// Fetch operations for each database type
|
|
823
|
+
const databaseTypesWithOps = await Promise.all((Array.isArray(databaseTypeConfigsResult) ? databaseTypeConfigsResult : []).map(async (typeConfig) => {
|
|
824
|
+
const ops = await client.listDatabaseTypeOperations(resolvedAppId, typeConfig.databaseType).catch(() => []);
|
|
825
|
+
return { typeConfig, operations: Array.isArray(ops) ? ops : [] };
|
|
826
|
+
}));
|
|
567
827
|
// Ensure directories exist
|
|
568
828
|
ensureDir(configDir);
|
|
569
829
|
ensureDir(join(configDir, "integrations"));
|
|
570
830
|
ensureDir(join(configDir, "prompts"));
|
|
571
831
|
ensureDir(join(configDir, "workflows"));
|
|
832
|
+
ensureDir(join(configDir, "database-types"));
|
|
833
|
+
ensureDir(join(configDir, "rule-sets"));
|
|
834
|
+
ensureDir(join(configDir, "group-type-configs"));
|
|
572
835
|
ensureDir(join(configDir, "email-templates"));
|
|
573
836
|
// Write app settings
|
|
574
837
|
if (settings) {
|
|
575
|
-
|
|
838
|
+
const appTomlPath = join(configDir, "app.toml");
|
|
839
|
+
writeFileSync(appTomlPath, serializeAppSettings(settings));
|
|
576
840
|
info(" Wrote app.toml");
|
|
577
841
|
}
|
|
578
842
|
// Write integrations
|
|
579
843
|
const integrationEntities = {};
|
|
580
844
|
for (const integration of integrations) {
|
|
581
845
|
const filename = `${integration.integrationKey}.toml`;
|
|
582
|
-
|
|
846
|
+
const filePath = join(configDir, "integrations", filename);
|
|
847
|
+
writeFileSync(filePath, serializeIntegration(integration));
|
|
583
848
|
integrationEntities[integration.integrationKey] = {
|
|
584
849
|
id: integration.integrationId,
|
|
585
850
|
modifiedAt: integration.modifiedAt || new Date().toISOString(),
|
|
851
|
+
contentHash: computeFileHash(filePath),
|
|
586
852
|
};
|
|
587
853
|
info(` Wrote integrations/${filename}`);
|
|
588
854
|
}
|
|
@@ -590,10 +856,12 @@ Directory Structure:
|
|
|
590
856
|
const promptEntities = {};
|
|
591
857
|
for (const prompt of prompts) {
|
|
592
858
|
const filename = `${prompt.promptKey}.toml`;
|
|
593
|
-
|
|
859
|
+
const filePath = join(configDir, "prompts", filename);
|
|
860
|
+
writeFileSync(filePath, serializePrompt(prompt));
|
|
594
861
|
promptEntities[prompt.promptKey] = {
|
|
595
862
|
id: prompt.promptId,
|
|
596
863
|
modifiedAt: prompt.modifiedAt || new Date().toISOString(),
|
|
864
|
+
contentHash: computeFileHash(filePath),
|
|
597
865
|
};
|
|
598
866
|
info(` Wrote prompts/${filename}`);
|
|
599
867
|
}
|
|
@@ -601,15 +869,37 @@ Directory Structure:
|
|
|
601
869
|
const workflowEntities = {};
|
|
602
870
|
for (const { workflow, draft, configs } of workflows) {
|
|
603
871
|
const filename = `${workflow.workflowKey}.toml`;
|
|
604
|
-
|
|
872
|
+
const filePath = join(configDir, "workflows", filename);
|
|
873
|
+
writeFileSync(filePath, serializeWorkflow(workflow, draft, configs || []));
|
|
605
874
|
workflowEntities[workflow.workflowKey] = {
|
|
606
875
|
id: workflow.workflowId,
|
|
607
876
|
modifiedAt: workflow.modifiedAt || new Date().toISOString(),
|
|
608
877
|
activeConfigId: workflow.activeConfigId,
|
|
878
|
+
contentHash: computeFileHash(filePath),
|
|
609
879
|
};
|
|
610
880
|
info(` Wrote workflows/${filename}`);
|
|
611
881
|
}
|
|
612
|
-
//
|
|
882
|
+
// Build lookup maps for test case serialization
|
|
883
|
+
const configIdToName = new Map();
|
|
884
|
+
const promptIdToKey = new Map();
|
|
885
|
+
for (const prompt of prompts) {
|
|
886
|
+
promptIdToKey.set(prompt.promptId, prompt.promptKey);
|
|
887
|
+
if (prompt.configs) {
|
|
888
|
+
for (const config of prompt.configs) {
|
|
889
|
+
configIdToName.set(config.configId, config.configName);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
// Also include workflow config names
|
|
894
|
+
for (const { configs } of workflows) {
|
|
895
|
+
if (configs) {
|
|
896
|
+
for (const config of configs) {
|
|
897
|
+
configIdToName.set(config.configId, config.configName);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
const testCaseLookupMaps = { configIdToName, promptIdToKey };
|
|
902
|
+
// Write email templates (use detailed emailTemplates, not summaries)
|
|
613
903
|
const emailTemplateEntities = {};
|
|
614
904
|
for (const template of emailTemplates) {
|
|
615
905
|
const filename = `${template.emailType}.toml`;
|
|
@@ -637,31 +927,87 @@ Directory Structure:
|
|
|
637
927
|
const testCaseEntities = {};
|
|
638
928
|
let totalTestCases = 0;
|
|
639
929
|
for (const prompt of prompts) {
|
|
640
|
-
const count = await pullTestCasesForBlock(client, resolvedAppId, "prompt", prompt.promptId, prompt.promptKey, configDir, testCaseEntities);
|
|
930
|
+
const count = await pullTestCasesForBlock(client, resolvedAppId, "prompt", prompt.promptId, prompt.promptKey, configDir, testCaseEntities, testCaseLookupMaps);
|
|
641
931
|
if (count > 0) {
|
|
642
932
|
info(` Wrote ${count} test case(s) for prompt: ${prompt.promptKey}`);
|
|
643
933
|
}
|
|
644
934
|
totalTestCases += count;
|
|
645
935
|
}
|
|
646
936
|
for (const { workflow } of workflows) {
|
|
647
|
-
const count = await pullTestCasesForBlock(client, resolvedAppId, "workflow", workflow.workflowId, workflow.workflowKey, configDir, testCaseEntities);
|
|
937
|
+
const count = await pullTestCasesForBlock(client, resolvedAppId, "workflow", workflow.workflowId, workflow.workflowKey, configDir, testCaseEntities, testCaseLookupMaps);
|
|
648
938
|
if (count > 0) {
|
|
649
939
|
info(` Wrote ${count} test case(s) for workflow: ${workflow.workflowKey}`);
|
|
650
940
|
}
|
|
651
941
|
totalTestCases += count;
|
|
652
942
|
}
|
|
943
|
+
// Build ruleSetId → name map for database types and group type configs
|
|
944
|
+
const ruleSets = Array.isArray(ruleSetsResult) ? ruleSetsResult : [];
|
|
945
|
+
const ruleSetIdToName = new Map();
|
|
946
|
+
for (const ruleSet of ruleSets) {
|
|
947
|
+
ruleSetIdToName.set(ruleSet.ruleSetId, ruleSet.name);
|
|
948
|
+
}
|
|
949
|
+
// Write database types
|
|
950
|
+
const databaseTypeEntities = {};
|
|
951
|
+
for (const { typeConfig, operations } of databaseTypesWithOps) {
|
|
952
|
+
const filename = `${typeConfig.databaseType}.toml`;
|
|
953
|
+
const filePath = join(configDir, "database-types", filename);
|
|
954
|
+
writeFileSync(filePath, serializeDatabaseType(typeConfig, operations, ruleSetIdToName));
|
|
955
|
+
const opsEntities = {};
|
|
956
|
+
for (const op of operations) {
|
|
957
|
+
opsEntities[op.name] = {
|
|
958
|
+
modifiedAt: op.modifiedAt || new Date().toISOString(),
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
databaseTypeEntities[typeConfig.databaseType] = {
|
|
962
|
+
databaseType: typeConfig.databaseType,
|
|
963
|
+
modifiedAt: typeConfig.modifiedAt || new Date().toISOString(),
|
|
964
|
+
operations: Object.keys(opsEntities).length > 0 ? opsEntities : undefined,
|
|
965
|
+
contentHash: computeFileHash(filePath),
|
|
966
|
+
};
|
|
967
|
+
info(` Wrote database-types/${filename}`);
|
|
968
|
+
}
|
|
969
|
+
// Write rule sets
|
|
970
|
+
const ruleSetEntities = {};
|
|
971
|
+
for (const ruleSet of ruleSets) {
|
|
972
|
+
const fileKey = (ruleSet.name || ruleSet.ruleSetId).replace(/[/\\:*?"<>|]/g, "_");
|
|
973
|
+
const filename = `${fileKey}.toml`;
|
|
974
|
+
const filePath = join(configDir, "rule-sets", filename);
|
|
975
|
+
writeFileSync(filePath, serializeRuleSet(ruleSet));
|
|
976
|
+
ruleSetEntities[fileKey] = {
|
|
977
|
+
id: ruleSet.ruleSetId,
|
|
978
|
+
modifiedAt: ruleSet.modifiedAt || new Date().toISOString(),
|
|
979
|
+
contentHash: computeFileHash(filePath),
|
|
980
|
+
};
|
|
981
|
+
info(` Wrote rule-sets/${filename}`);
|
|
982
|
+
}
|
|
983
|
+
// Write group type configs
|
|
984
|
+
const groupTypeConfigEntities = {};
|
|
985
|
+
const groupTypeConfigs = Array.isArray(groupTypeConfigsResult) ? groupTypeConfigsResult : [];
|
|
986
|
+
for (const config of groupTypeConfigs) {
|
|
987
|
+
const filename = `${config.groupType}.toml`;
|
|
988
|
+
const filePath = join(configDir, "group-type-configs", filename);
|
|
989
|
+
writeFileSync(filePath, serializeGroupTypeConfig(config, ruleSetIdToName));
|
|
990
|
+
groupTypeConfigEntities[config.groupType] = {
|
|
991
|
+
modifiedAt: config.modifiedAt || new Date().toISOString(),
|
|
992
|
+
contentHash: computeFileHash(filePath),
|
|
993
|
+
};
|
|
994
|
+
info(` Wrote group-type-configs/${filename}`);
|
|
995
|
+
}
|
|
653
996
|
// Save sync state
|
|
654
997
|
const state = {
|
|
655
998
|
appId: resolvedAppId,
|
|
656
999
|
serverUrl: getServerUrl(),
|
|
657
1000
|
lastSyncedAt: new Date().toISOString(),
|
|
658
1001
|
entities: {
|
|
659
|
-
app: settings ? { modifiedAt: new Date().toISOString() } : undefined,
|
|
1002
|
+
app: settings ? { modifiedAt: new Date().toISOString(), contentHash: computeFileHash(join(configDir, "app.toml")) } : undefined,
|
|
660
1003
|
integrations: integrationEntities,
|
|
661
1004
|
prompts: promptEntities,
|
|
662
1005
|
workflows: workflowEntities,
|
|
663
1006
|
emailTemplates: Object.keys(emailTemplateEntities).length > 0 ? emailTemplateEntities : undefined,
|
|
664
1007
|
testCases: Object.keys(testCaseEntities).length > 0 ? testCaseEntities : undefined,
|
|
1008
|
+
databaseTypes: Object.keys(databaseTypeEntities).length > 0 ? databaseTypeEntities : undefined,
|
|
1009
|
+
ruleSets: Object.keys(ruleSetEntities).length > 0 ? ruleSetEntities : undefined,
|
|
1010
|
+
groupTypeConfigs: Object.keys(groupTypeConfigEntities).length > 0 ? groupTypeConfigEntities : undefined,
|
|
665
1011
|
},
|
|
666
1012
|
};
|
|
667
1013
|
saveSyncState(configDir, state);
|
|
@@ -672,6 +1018,9 @@ Directory Structure:
|
|
|
672
1018
|
keyValue("Workflows", workflows.length);
|
|
673
1019
|
keyValue("Email Templates", emailTemplates.length);
|
|
674
1020
|
keyValue("Test Cases", totalTestCases);
|
|
1021
|
+
keyValue("Database Types", databaseTypesWithOps.length);
|
|
1022
|
+
keyValue("Rule Sets", ruleSets.length);
|
|
1023
|
+
keyValue("Group Type Configs", groupTypeConfigs.length);
|
|
675
1024
|
}
|
|
676
1025
|
catch (err) {
|
|
677
1026
|
error(err.message);
|
|
@@ -695,38 +1044,159 @@ Directory Structure:
|
|
|
695
1044
|
error(`Config directory not found: ${configDir}`);
|
|
696
1045
|
process.exit(1);
|
|
697
1046
|
}
|
|
698
|
-
|
|
1047
|
+
let syncState = loadSyncState(configDir);
|
|
1048
|
+
// Detect cross-app push: if pushing to a different app, clear entity IDs
|
|
1049
|
+
// so everything goes through the create path
|
|
1050
|
+
const isCrossAppPush = syncState && syncState.appId !== resolvedAppId;
|
|
1051
|
+
if (isCrossAppPush) {
|
|
1052
|
+
info(`Cross-app push detected: ${syncState.appId} → ${resolvedAppId}`);
|
|
1053
|
+
info("All entities will be created fresh in the target app.");
|
|
1054
|
+
syncState = {
|
|
1055
|
+
appId: resolvedAppId,
|
|
1056
|
+
serverUrl: syncState.serverUrl,
|
|
1057
|
+
lastSyncedAt: new Date().toISOString(),
|
|
1058
|
+
entities: {},
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
699
1061
|
info(`Pushing configuration for app ${resolvedAppId}...`);
|
|
700
1062
|
try {
|
|
701
1063
|
const changes = [];
|
|
1064
|
+
let skippedCount = 0;
|
|
702
1065
|
const conflicts = [];
|
|
1066
|
+
// Track name→ID mappings for resolving cross-references during push
|
|
1067
|
+
const ruleSetNameToId = new Map();
|
|
1068
|
+
const promptKeyToId = new Map();
|
|
1069
|
+
const promptConfigNameToId = new Map(); // key: "promptKey#configName"
|
|
1070
|
+
const workflowConfigNameToId = new Map(); // key: "workflowKey#configName"
|
|
703
1071
|
// Process app settings
|
|
704
1072
|
const appTomlPath = join(configDir, "app.toml");
|
|
705
1073
|
if (existsSync(appTomlPath)) {
|
|
706
|
-
const
|
|
707
|
-
|
|
708
|
-
if (
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
if (tomlData.auth) {
|
|
712
|
-
settings.googleOAuthEnabled = tomlData.auth.googleOAuthEnabled;
|
|
713
|
-
settings.passkeyEnabled = tomlData.auth.passkeyEnabled;
|
|
714
|
-
settings.magicLinkEnabled = tomlData.auth.magicLinkEnabled;
|
|
715
|
-
if (tomlData.auth.passkeys) {
|
|
716
|
-
settings.passkeyRpConfig = tomlData.auth.passkeys;
|
|
717
|
-
}
|
|
1074
|
+
const appChanged = options.force || !syncState?.entities?.app?.contentHash ||
|
|
1075
|
+
shouldPushFile(appTomlPath, syncState.entities.app.contentHash);
|
|
1076
|
+
if (!appChanged) {
|
|
1077
|
+
skippedCount++;
|
|
1078
|
+
info(" Skipped app settings (unchanged)");
|
|
718
1079
|
}
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
settings
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
1080
|
+
else {
|
|
1081
|
+
const tomlData = parseTomlFile(appTomlPath);
|
|
1082
|
+
const settings = {};
|
|
1083
|
+
if (tomlData.app) {
|
|
1084
|
+
Object.assign(settings, tomlData.app);
|
|
1085
|
+
// Don't overwrite the target app's name during cross-app push
|
|
1086
|
+
if (isCrossAppPush) {
|
|
1087
|
+
delete settings.name;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
if (tomlData.auth) {
|
|
1091
|
+
settings.googleOAuthEnabled = tomlData.auth.googleOAuthEnabled;
|
|
1092
|
+
settings.passkeyEnabled = tomlData.auth.passkeyEnabled;
|
|
1093
|
+
settings.magicLinkEnabled = tomlData.auth.magicLinkEnabled;
|
|
1094
|
+
if (tomlData.auth.passkeys) {
|
|
1095
|
+
settings.passkeyRpConfig = tomlData.auth.passkeys;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
if (tomlData.cors) {
|
|
1099
|
+
settings.corsMode = tomlData.cors.mode;
|
|
1100
|
+
settings.corsAllowedOrigins = tomlData.cors.allowedOrigins;
|
|
1101
|
+
settings.corsAllowCredentials = tomlData.cors.allowCredentials;
|
|
1102
|
+
settings.corsAllowedMethods = tomlData.cors.allowedMethods;
|
|
1103
|
+
settings.corsMaxAge = tomlData.cors.maxAge;
|
|
1104
|
+
}
|
|
1105
|
+
changes.push({ type: "app", action: "update", key: "settings" });
|
|
1106
|
+
if (!options.dryRun) {
|
|
1107
|
+
await client.updateAppSettings(resolvedAppId, settings);
|
|
1108
|
+
info(" Updated app settings");
|
|
1109
|
+
// Store content hash after successful push
|
|
1110
|
+
if (syncState) {
|
|
1111
|
+
if (!syncState.entities.app) {
|
|
1112
|
+
syncState.entities.app = { modifiedAt: new Date().toISOString() };
|
|
1113
|
+
}
|
|
1114
|
+
syncState.entities.app.contentHash = computeFileHash(appTomlPath);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
725
1117
|
}
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
1118
|
+
}
|
|
1119
|
+
// Process rule sets first (other entities may reference them by name)
|
|
1120
|
+
const ruleSetsDir = join(configDir, "rule-sets");
|
|
1121
|
+
if (existsSync(ruleSetsDir)) {
|
|
1122
|
+
const files = readdirSync(ruleSetsDir).filter((f) => f.endsWith(".toml"));
|
|
1123
|
+
for (const file of files) {
|
|
1124
|
+
const filePath = join(ruleSetsDir, file);
|
|
1125
|
+
const tomlData = parseTomlFile(filePath);
|
|
1126
|
+
const ruleSetData = parseRuleSetToml(tomlData);
|
|
1127
|
+
const fileKey = basename(file, ".toml");
|
|
1128
|
+
const existingId = syncState?.entities?.ruleSets?.[fileKey]?.id;
|
|
1129
|
+
// Skip if file hasn't changed since last sync
|
|
1130
|
+
if (!options.force && existingId && !shouldPushFile(filePath, syncState?.entities?.ruleSets?.[fileKey]?.contentHash)) {
|
|
1131
|
+
skippedCount++;
|
|
1132
|
+
ruleSetNameToId.set(ruleSetData.name, existingId);
|
|
1133
|
+
continue;
|
|
1134
|
+
}
|
|
1135
|
+
if (existingId) {
|
|
1136
|
+
// Update existing rule set
|
|
1137
|
+
changes.push({ type: "rule-set", action: "update", key: fileKey });
|
|
1138
|
+
if (!options.dryRun) {
|
|
1139
|
+
const expectedModifiedAt = options.force
|
|
1140
|
+
? undefined
|
|
1141
|
+
: syncState?.entities?.ruleSets?.[fileKey]?.modifiedAt;
|
|
1142
|
+
try {
|
|
1143
|
+
const updated = await client.updateRuleSet(resolvedAppId, existingId, {
|
|
1144
|
+
name: ruleSetData.name,
|
|
1145
|
+
description: ruleSetData.description,
|
|
1146
|
+
rules: ruleSetData.rules,
|
|
1147
|
+
}, expectedModifiedAt);
|
|
1148
|
+
info(` Updated rule set: ${fileKey}`);
|
|
1149
|
+
if (syncState?.entities?.ruleSets?.[fileKey] && updated?.modifiedAt) {
|
|
1150
|
+
syncState.entities.ruleSets[fileKey].modifiedAt = updated.modifiedAt;
|
|
1151
|
+
syncState.entities.ruleSets[fileKey].contentHash = computeFileHash(filePath);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
catch (err) {
|
|
1155
|
+
if (err instanceof ConflictError) {
|
|
1156
|
+
conflicts.push({
|
|
1157
|
+
type: "rule-set",
|
|
1158
|
+
key: fileKey,
|
|
1159
|
+
serverModifiedAt: err.serverModifiedAt,
|
|
1160
|
+
localModifiedAt: expectedModifiedAt || "unknown",
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
else {
|
|
1164
|
+
throw err;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
// Track name→ID for cross-reference resolution
|
|
1169
|
+
ruleSetNameToId.set(ruleSetData.name, existingId);
|
|
1170
|
+
}
|
|
1171
|
+
else {
|
|
1172
|
+
// Create new rule set
|
|
1173
|
+
changes.push({ type: "rule-set", action: "create", key: fileKey });
|
|
1174
|
+
if (!options.dryRun) {
|
|
1175
|
+
const created = await client.createRuleSet(resolvedAppId, {
|
|
1176
|
+
name: ruleSetData.name,
|
|
1177
|
+
resourceType: ruleSetData.resourceType,
|
|
1178
|
+
rules: ruleSetData.rules,
|
|
1179
|
+
description: ruleSetData.description,
|
|
1180
|
+
});
|
|
1181
|
+
info(` Created rule set: ${fileKey}`);
|
|
1182
|
+
if (syncState && created?.ruleSetId) {
|
|
1183
|
+
if (!syncState.entities.ruleSets) {
|
|
1184
|
+
syncState.entities.ruleSets = {};
|
|
1185
|
+
}
|
|
1186
|
+
syncState.entities.ruleSets[fileKey] = {
|
|
1187
|
+
id: created.ruleSetId,
|
|
1188
|
+
modifiedAt: created.modifiedAt || new Date().toISOString(),
|
|
1189
|
+
contentHash: computeFileHash(filePath),
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
// Track name→ID for cross-reference resolution
|
|
1193
|
+
ruleSetNameToId.set(ruleSetData.name, created.ruleSetId);
|
|
1194
|
+
}
|
|
1195
|
+
else {
|
|
1196
|
+
// In dry-run mode, use a placeholder so dependent entities can resolve
|
|
1197
|
+
ruleSetNameToId.set(ruleSetData.name, `dry-run-${ruleSetData.name}`);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
730
1200
|
}
|
|
731
1201
|
}
|
|
732
1202
|
// Process integrations
|
|
@@ -739,6 +1209,11 @@ Directory Structure:
|
|
|
739
1209
|
const integration = tomlData.integration || {};
|
|
740
1210
|
const key = integration.key || basename(file, ".toml");
|
|
741
1211
|
const existingId = syncState?.entities?.integrations?.[key]?.id;
|
|
1212
|
+
// Skip if file hasn't changed since last sync
|
|
1213
|
+
if (!options.force && existingId && !shouldPushFile(filePath, syncState?.entities?.integrations?.[key]?.contentHash)) {
|
|
1214
|
+
skippedCount++;
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
742
1217
|
const payload = {
|
|
743
1218
|
integrationKey: key,
|
|
744
1219
|
displayName: integration.displayName || key,
|
|
@@ -759,6 +1234,7 @@ Directory Structure:
|
|
|
759
1234
|
// Update sync state with new modifiedAt
|
|
760
1235
|
if (syncState?.entities?.integrations?.[key] && updated?.modifiedAt) {
|
|
761
1236
|
syncState.entities.integrations[key].modifiedAt = updated.modifiedAt;
|
|
1237
|
+
syncState.entities.integrations[key].contentHash = computeFileHash(filePath);
|
|
762
1238
|
}
|
|
763
1239
|
}
|
|
764
1240
|
catch (err) {
|
|
@@ -789,6 +1265,7 @@ Directory Structure:
|
|
|
789
1265
|
syncState.entities.integrations[key] = {
|
|
790
1266
|
id: created.integrationId,
|
|
791
1267
|
modifiedAt: created.modifiedAt,
|
|
1268
|
+
contentHash: computeFileHash(filePath),
|
|
792
1269
|
};
|
|
793
1270
|
}
|
|
794
1271
|
}
|
|
@@ -806,9 +1283,26 @@ Directory Structure:
|
|
|
806
1283
|
const key = prompt.key || basename(file, ".toml");
|
|
807
1284
|
const configs = tomlData.configs || [];
|
|
808
1285
|
const existingId = syncState?.entities?.prompts?.[key]?.id;
|
|
1286
|
+
// Skip if file hasn't changed since last sync
|
|
1287
|
+
if (!options.force && existingId && !shouldPushFile(filePath, syncState?.entities?.prompts?.[key]?.contentHash)) {
|
|
1288
|
+
skippedCount++;
|
|
1289
|
+
promptKeyToId.set(key, existingId);
|
|
1290
|
+
// Only fetch config name→ID mappings if test cases exist for this prompt
|
|
1291
|
+
const promptTestsDir = getTestsDir(configDir, "prompt", key);
|
|
1292
|
+
if (!options.dryRun && existsSync(promptTestsDir)) {
|
|
1293
|
+
const fullPrompt = await client.getPrompt(resolvedAppId, existingId);
|
|
1294
|
+
if (fullPrompt?.configs) {
|
|
1295
|
+
for (const config of fullPrompt.configs) {
|
|
1296
|
+
promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
809
1302
|
if (existingId) {
|
|
810
1303
|
// Update existing prompt
|
|
811
1304
|
changes.push({ type: "prompt", action: "update", key });
|
|
1305
|
+
promptKeyToId.set(key, existingId);
|
|
812
1306
|
if (!options.dryRun) {
|
|
813
1307
|
const expectedModifiedAt = options.force
|
|
814
1308
|
? undefined
|
|
@@ -824,6 +1318,55 @@ Directory Structure:
|
|
|
824
1318
|
// Update sync state with new modifiedAt
|
|
825
1319
|
if (syncState?.entities?.prompts?.[key] && updated?.modifiedAt) {
|
|
826
1320
|
syncState.entities.prompts[key].modifiedAt = updated.modifiedAt;
|
|
1321
|
+
syncState.entities.prompts[key].contentHash = computeFileHash(filePath);
|
|
1322
|
+
}
|
|
1323
|
+
// Fetch full prompt to get config name→ID mappings
|
|
1324
|
+
// (updatePrompt response doesn't include configs)
|
|
1325
|
+
const fullPrompt = await client.getPrompt(resolvedAppId, existingId);
|
|
1326
|
+
if (fullPrompt?.configs) {
|
|
1327
|
+
for (const config of fullPrompt.configs) {
|
|
1328
|
+
promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
// Update existing prompt configs with TOML values
|
|
1332
|
+
if (fullPrompt?.configs && configs.length > 0) {
|
|
1333
|
+
for (const tomlConfig of configs) {
|
|
1334
|
+
const configName = tomlConfig.name;
|
|
1335
|
+
if (!configName)
|
|
1336
|
+
continue;
|
|
1337
|
+
const serverConfig = fullPrompt.configs.find((c) => c.configName === configName);
|
|
1338
|
+
if (serverConfig) {
|
|
1339
|
+
// Update existing config
|
|
1340
|
+
await client.updatePromptConfig(resolvedAppId, existingId, serverConfig.configId, {
|
|
1341
|
+
description: tomlConfig.description,
|
|
1342
|
+
provider: tomlConfig.provider,
|
|
1343
|
+
model: tomlConfig.model,
|
|
1344
|
+
systemPrompt: tomlConfig.systemPrompt,
|
|
1345
|
+
userPromptTemplate: tomlConfig.userPromptTemplate,
|
|
1346
|
+
temperature: tomlConfig.temperature,
|
|
1347
|
+
maxTokens: tomlConfig.maxTokens,
|
|
1348
|
+
outputFormat: tomlConfig.outputFormat,
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
else {
|
|
1352
|
+
// Create new config that doesn't exist on server yet
|
|
1353
|
+
const newConfig = await client.createPromptConfig(resolvedAppId, existingId, {
|
|
1354
|
+
configName,
|
|
1355
|
+
description: tomlConfig.description,
|
|
1356
|
+
provider: tomlConfig.provider || "openrouter",
|
|
1357
|
+
model: tomlConfig.model || "google/gemini-2.0-flash-001",
|
|
1358
|
+
systemPrompt: tomlConfig.systemPrompt,
|
|
1359
|
+
userPromptTemplate: tomlConfig.userPromptTemplate,
|
|
1360
|
+
temperature: tomlConfig.temperature,
|
|
1361
|
+
maxTokens: tomlConfig.maxTokens,
|
|
1362
|
+
outputFormat: tomlConfig.outputFormat,
|
|
1363
|
+
});
|
|
1364
|
+
if (newConfig?.configId) {
|
|
1365
|
+
promptConfigNameToId.set(`${key}#${configName}`, newConfig.configId);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
info(` Synced ${configs.length} config(s) for prompt: ${key}`);
|
|
827
1370
|
}
|
|
828
1371
|
}
|
|
829
1372
|
catch (err) {
|
|
@@ -868,8 +1411,44 @@ Directory Structure:
|
|
|
868
1411
|
syncState.entities.prompts[key] = {
|
|
869
1412
|
id: created.promptId,
|
|
870
1413
|
modifiedAt: created.modifiedAt,
|
|
1414
|
+
contentHash: computeFileHash(filePath),
|
|
871
1415
|
};
|
|
872
1416
|
}
|
|
1417
|
+
// Track prompt key→ID and config name→ID
|
|
1418
|
+
if (created?.promptId) {
|
|
1419
|
+
promptKeyToId.set(key, created.promptId);
|
|
1420
|
+
}
|
|
1421
|
+
if (created?.configs) {
|
|
1422
|
+
for (const config of created.configs) {
|
|
1423
|
+
promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
// Create additional configs (configs[1..n]) that weren't included in the initial create
|
|
1427
|
+
if (created?.promptId && configs.length > 1) {
|
|
1428
|
+
for (let i = 1; i < configs.length; i++) {
|
|
1429
|
+
const extraConfig = configs[i];
|
|
1430
|
+
const extraCreated = await client.createPromptConfig(resolvedAppId, created.promptId, {
|
|
1431
|
+
configName: extraConfig.name || `config-${i + 1}`,
|
|
1432
|
+
description: extraConfig.description,
|
|
1433
|
+
provider: extraConfig.provider || firstConfig.provider || "openrouter",
|
|
1434
|
+
model: extraConfig.model || firstConfig.model || "google/gemini-2.0-flash-001",
|
|
1435
|
+
systemPrompt: extraConfig.systemPrompt,
|
|
1436
|
+
userPromptTemplate: extraConfig.userPromptTemplate,
|
|
1437
|
+
temperature: extraConfig.temperature,
|
|
1438
|
+
maxTokens: extraConfig.maxTokens,
|
|
1439
|
+
outputFormat: extraConfig.outputFormat,
|
|
1440
|
+
});
|
|
1441
|
+
if (extraCreated?.configId) {
|
|
1442
|
+
const configName = extraConfig.name || `config-${i + 1}`;
|
|
1443
|
+
promptConfigNameToId.set(`${key}#${configName}`, extraCreated.configId);
|
|
1444
|
+
}
|
|
1445
|
+
// Activate this config if it was the active one
|
|
1446
|
+
if (extraConfig.isActive && extraCreated?.configId) {
|
|
1447
|
+
await client.activatePromptConfig(resolvedAppId, created.promptId, extraCreated.configId);
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
info(` Created ${configs.length - 1} additional config(s) for prompt: ${key}`);
|
|
1451
|
+
}
|
|
873
1452
|
}
|
|
874
1453
|
}
|
|
875
1454
|
}
|
|
@@ -886,6 +1465,21 @@ Directory Structure:
|
|
|
886
1465
|
const steps = tomlData.steps || [];
|
|
887
1466
|
const existingId = syncState?.entities?.workflows?.[key]?.id;
|
|
888
1467
|
const existingActiveConfigId = syncState?.entities?.workflows?.[key]?.activeConfigId;
|
|
1468
|
+
// Skip if file hasn't changed since last sync
|
|
1469
|
+
if (!options.force && existingId && !shouldPushFile(filePath, syncState?.entities?.workflows?.[key]?.contentHash)) {
|
|
1470
|
+
skippedCount++;
|
|
1471
|
+
// Only fetch config name→ID mappings if test cases exist for this workflow
|
|
1472
|
+
const workflowTestsDir = getTestsDir(configDir, "workflow", key);
|
|
1473
|
+
if (!options.dryRun && existsSync(workflowTestsDir)) {
|
|
1474
|
+
const fullWorkflow = await client.getWorkflow(resolvedAppId, existingId);
|
|
1475
|
+
if (fullWorkflow?.configs) {
|
|
1476
|
+
for (const config of fullWorkflow.configs) {
|
|
1477
|
+
workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
continue;
|
|
1482
|
+
}
|
|
889
1483
|
if (existingId) {
|
|
890
1484
|
// Update existing workflow
|
|
891
1485
|
changes.push({ type: "workflow", action: "update", key });
|
|
@@ -902,8 +1496,8 @@ Directory Structure:
|
|
|
902
1496
|
perUserMaxRunning: workflow.perUserMaxRunning,
|
|
903
1497
|
perUserMaxQueued: workflow.perUserMaxQueued,
|
|
904
1498
|
dequeueOrder: workflow.dequeueOrder,
|
|
905
|
-
inputSchema: workflow.inputSchema ?
|
|
906
|
-
outputSchema: workflow.outputSchema ?
|
|
1499
|
+
inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
|
|
1500
|
+
outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : null,
|
|
907
1501
|
}, expectedModifiedAt);
|
|
908
1502
|
// Update active configuration steps (or draft for legacy)
|
|
909
1503
|
if (existingActiveConfigId) {
|
|
@@ -915,13 +1509,22 @@ Directory Structure:
|
|
|
915
1509
|
// Fallback to draft update for legacy workflows
|
|
916
1510
|
await client.updateWorkflowDraft(resolvedAppId, existingId, {
|
|
917
1511
|
steps,
|
|
918
|
-
inputSchema: workflow.inputSchema ?
|
|
1512
|
+
inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
|
|
919
1513
|
});
|
|
920
1514
|
}
|
|
921
1515
|
info(` Updated workflow: ${key}`);
|
|
922
1516
|
// Update sync state with new modifiedAt
|
|
923
1517
|
if (syncState?.entities?.workflows?.[key] && updated?.workflow?.modifiedAt) {
|
|
924
1518
|
syncState.entities.workflows[key].modifiedAt = updated.workflow.modifiedAt;
|
|
1519
|
+
syncState.entities.workflows[key].contentHash = computeFileHash(filePath);
|
|
1520
|
+
}
|
|
1521
|
+
// Fetch full workflow to get config name→ID mappings
|
|
1522
|
+
// (updateWorkflow response doesn't include configs)
|
|
1523
|
+
const fullWorkflow = await client.getWorkflow(resolvedAppId, existingId);
|
|
1524
|
+
if (fullWorkflow?.configs) {
|
|
1525
|
+
for (const config of fullWorkflow.configs) {
|
|
1526
|
+
workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
1527
|
+
}
|
|
925
1528
|
}
|
|
926
1529
|
}
|
|
927
1530
|
catch (err) {
|
|
@@ -948,8 +1551,8 @@ Directory Structure:
|
|
|
948
1551
|
name: workflow.name || key,
|
|
949
1552
|
description: workflow.description,
|
|
950
1553
|
steps,
|
|
951
|
-
inputSchema: workflow.inputSchema ?
|
|
952
|
-
outputSchema: workflow.outputSchema ?
|
|
1554
|
+
inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : undefined,
|
|
1555
|
+
outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : undefined,
|
|
953
1556
|
perUserMaxRunning: workflow.perUserMaxRunning,
|
|
954
1557
|
perUserMaxQueued: workflow.perUserMaxQueued,
|
|
955
1558
|
dequeueOrder: workflow.dequeueOrder,
|
|
@@ -964,6 +1567,271 @@ Directory Structure:
|
|
|
964
1567
|
id: created.workflow.workflowId,
|
|
965
1568
|
modifiedAt: created.workflow.modifiedAt,
|
|
966
1569
|
activeConfigId: created.workflow.activeConfigId,
|
|
1570
|
+
contentHash: computeFileHash(filePath),
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
// Track config name→ID mappings
|
|
1574
|
+
if (created?.configs) {
|
|
1575
|
+
for (const config of created.configs) {
|
|
1576
|
+
workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
const workflowId = created?.workflow?.workflowId;
|
|
1580
|
+
const tomlConfigs = tomlData.configs || [];
|
|
1581
|
+
// Create additional workflow configs (configs[1..n]) beyond the default
|
|
1582
|
+
if (workflowId && tomlConfigs.length > 1) {
|
|
1583
|
+
for (let i = 1; i < tomlConfigs.length; i++) {
|
|
1584
|
+
const extraConfig = tomlConfigs[i];
|
|
1585
|
+
const extraCreated = await client.createWorkflowConfig(resolvedAppId, workflowId, {
|
|
1586
|
+
configName: extraConfig.name || `config-${i + 1}`,
|
|
1587
|
+
description: extraConfig.description,
|
|
1588
|
+
steps,
|
|
1589
|
+
});
|
|
1590
|
+
if (extraCreated?.configId) {
|
|
1591
|
+
const configName = extraConfig.name || `config-${i + 1}`;
|
|
1592
|
+
workflowConfigNameToId.set(`${key}#${configName}`, extraCreated.configId);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
info(` Created ${tomlConfigs.length - 1} additional config(s) for workflow: ${key}`);
|
|
1596
|
+
}
|
|
1597
|
+
// Activate the correct config based on activeConfigName from TOML
|
|
1598
|
+
if (workflowId && workflow.activeConfigName) {
|
|
1599
|
+
const activeConfigId = workflowConfigNameToId.get(`${key}#${workflow.activeConfigName}`);
|
|
1600
|
+
if (activeConfigId && activeConfigId !== created?.workflow?.activeConfigId) {
|
|
1601
|
+
await client.activateWorkflowConfig(resolvedAppId, workflowId, activeConfigId);
|
|
1602
|
+
info(` Activated config "${workflow.activeConfigName}" for workflow: ${key}`);
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
// Process database types
|
|
1610
|
+
const dbTypesDir = join(configDir, "database-types");
|
|
1611
|
+
if (existsSync(dbTypesDir)) {
|
|
1612
|
+
const files = readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml"));
|
|
1613
|
+
for (const file of files) {
|
|
1614
|
+
const filePath = join(dbTypesDir, file);
|
|
1615
|
+
const tomlData = parseTomlFile(filePath);
|
|
1616
|
+
const { typeConfig, operations } = parseDatabaseTypeToml(tomlData);
|
|
1617
|
+
const dbType = typeConfig.databaseType || basename(file, ".toml");
|
|
1618
|
+
// Resolve ruleSetName → ruleSetId if using key-based reference
|
|
1619
|
+
resolveRuleSetReference(typeConfig, ruleSetNameToId, `database type ${dbType}`);
|
|
1620
|
+
const existingEntry = syncState?.entities?.databaseTypes?.[dbType];
|
|
1621
|
+
// Skip if file hasn't changed since last sync
|
|
1622
|
+
if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
|
|
1623
|
+
skippedCount++;
|
|
1624
|
+
continue;
|
|
1625
|
+
}
|
|
1626
|
+
if (existingEntry) {
|
|
1627
|
+
// Update existing type config
|
|
1628
|
+
changes.push({ type: "database-type", action: "update", key: dbType });
|
|
1629
|
+
if (!options.dryRun) {
|
|
1630
|
+
const expectedModifiedAt = options.force
|
|
1631
|
+
? undefined
|
|
1632
|
+
: existingEntry.modifiedAt;
|
|
1633
|
+
try {
|
|
1634
|
+
const updateData = {};
|
|
1635
|
+
if ("ruleSetId" in typeConfig)
|
|
1636
|
+
updateData.ruleSetId = typeConfig.ruleSetId || null;
|
|
1637
|
+
if ("triggers" in typeConfig)
|
|
1638
|
+
updateData.triggers = typeConfig.triggers || null;
|
|
1639
|
+
if ("metadataAccess" in typeConfig)
|
|
1640
|
+
updateData.metadataAccess = typeConfig.metadataAccess || null;
|
|
1641
|
+
const updated = await client.updateDatabaseTypeConfig(resolvedAppId, dbType, updateData, expectedModifiedAt);
|
|
1642
|
+
info(` Updated database type: ${dbType}`);
|
|
1643
|
+
if (syncState?.entities?.databaseTypes?.[dbType] && updated?.modifiedAt) {
|
|
1644
|
+
syncState.entities.databaseTypes[dbType].modifiedAt = updated.modifiedAt;
|
|
1645
|
+
syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
catch (err) {
|
|
1649
|
+
if (err instanceof ConflictError) {
|
|
1650
|
+
conflicts.push({
|
|
1651
|
+
type: "database-type",
|
|
1652
|
+
key: dbType,
|
|
1653
|
+
serverModifiedAt: err.serverModifiedAt,
|
|
1654
|
+
localModifiedAt: expectedModifiedAt || "unknown",
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
else {
|
|
1658
|
+
throw err;
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
else {
|
|
1664
|
+
// Create new type config
|
|
1665
|
+
changes.push({ type: "database-type", action: "create", key: dbType });
|
|
1666
|
+
if (!options.dryRun) {
|
|
1667
|
+
const createData = {
|
|
1668
|
+
databaseType: dbType,
|
|
1669
|
+
};
|
|
1670
|
+
if (typeConfig.ruleSetId)
|
|
1671
|
+
createData.ruleSetId = typeConfig.ruleSetId;
|
|
1672
|
+
if (typeConfig.triggers)
|
|
1673
|
+
createData.triggers = typeConfig.triggers;
|
|
1674
|
+
if (typeConfig.metadataAccess)
|
|
1675
|
+
createData.metadataAccess = typeConfig.metadataAccess;
|
|
1676
|
+
const created = await client.createDatabaseTypeConfig(resolvedAppId, createData);
|
|
1677
|
+
info(` Created database type: ${dbType}`);
|
|
1678
|
+
if (syncState) {
|
|
1679
|
+
if (!syncState.entities.databaseTypes) {
|
|
1680
|
+
syncState.entities.databaseTypes = {};
|
|
1681
|
+
}
|
|
1682
|
+
syncState.entities.databaseTypes[dbType] = {
|
|
1683
|
+
databaseType: dbType,
|
|
1684
|
+
modifiedAt: created?.modifiedAt || new Date().toISOString(),
|
|
1685
|
+
contentHash: computeFileHash(filePath),
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
// Process operations for this type
|
|
1691
|
+
const existingOps = existingEntry?.operations || {};
|
|
1692
|
+
const tomlOpNames = new Set(operations.map((op) => op.name));
|
|
1693
|
+
for (const op of operations) {
|
|
1694
|
+
const existingOp = existingOps[op.name];
|
|
1695
|
+
if (existingOp) {
|
|
1696
|
+
// Update existing operation
|
|
1697
|
+
changes.push({ type: "operation", action: "update", key: `${dbType}/${op.name}` });
|
|
1698
|
+
if (!options.dryRun) {
|
|
1699
|
+
const expectedOpModifiedAt = options.force
|
|
1700
|
+
? undefined
|
|
1701
|
+
: existingOp.modifiedAt;
|
|
1702
|
+
try {
|
|
1703
|
+
const updated = await client.updateDatabaseTypeOperation(resolvedAppId, dbType, op.name, {
|
|
1704
|
+
modelName: op.modelName,
|
|
1705
|
+
access: op.access,
|
|
1706
|
+
definition: op.definition,
|
|
1707
|
+
params: op.params,
|
|
1708
|
+
}, expectedOpModifiedAt);
|
|
1709
|
+
info(` Updated operation: ${dbType}/${op.name}`);
|
|
1710
|
+
if (syncState?.entities?.databaseTypes?.[dbType]?.operations?.[op.name] && updated?.modifiedAt) {
|
|
1711
|
+
syncState.entities.databaseTypes[dbType].operations[op.name].modifiedAt = updated.modifiedAt;
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
catch (err) {
|
|
1715
|
+
if (err instanceof ConflictError) {
|
|
1716
|
+
conflicts.push({
|
|
1717
|
+
type: "operation",
|
|
1718
|
+
key: `${dbType}/${op.name}`,
|
|
1719
|
+
serverModifiedAt: err.serverModifiedAt,
|
|
1720
|
+
localModifiedAt: expectedOpModifiedAt || "unknown",
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1723
|
+
else {
|
|
1724
|
+
throw err;
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
else {
|
|
1730
|
+
// Create new operation
|
|
1731
|
+
changes.push({ type: "operation", action: "create", key: `${dbType}/${op.name}` });
|
|
1732
|
+
if (!options.dryRun) {
|
|
1733
|
+
const created = await client.createDatabaseTypeOperation(resolvedAppId, dbType, {
|
|
1734
|
+
name: op.name,
|
|
1735
|
+
type: op.type,
|
|
1736
|
+
modelName: op.modelName,
|
|
1737
|
+
access: op.access,
|
|
1738
|
+
definition: op.definition,
|
|
1739
|
+
params: op.params,
|
|
1740
|
+
});
|
|
1741
|
+
info(` Created operation: ${dbType}/${op.name}`);
|
|
1742
|
+
if (syncState?.entities?.databaseTypes?.[dbType]) {
|
|
1743
|
+
if (!syncState.entities.databaseTypes[dbType].operations) {
|
|
1744
|
+
syncState.entities.databaseTypes[dbType].operations = {};
|
|
1745
|
+
}
|
|
1746
|
+
syncState.entities.databaseTypes[dbType].operations[op.name] = {
|
|
1747
|
+
modifiedAt: created?.modifiedAt || new Date().toISOString(),
|
|
1748
|
+
};
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
// Delete operations that were removed from TOML
|
|
1754
|
+
for (const existingOpName of Object.keys(existingOps)) {
|
|
1755
|
+
if (!tomlOpNames.has(existingOpName)) {
|
|
1756
|
+
changes.push({ type: "operation", action: "delete", key: `${dbType}/${existingOpName}` });
|
|
1757
|
+
if (!options.dryRun) {
|
|
1758
|
+
await client.deleteDatabaseTypeOperation(resolvedAppId, dbType, existingOpName);
|
|
1759
|
+
info(` Deleted operation: ${dbType}/${existingOpName}`);
|
|
1760
|
+
if (syncState?.entities?.databaseTypes?.[dbType]?.operations) {
|
|
1761
|
+
delete syncState.entities.databaseTypes[dbType].operations[existingOpName];
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
// Process group type configs
|
|
1769
|
+
const groupTypeConfigsDir = join(configDir, "group-type-configs");
|
|
1770
|
+
if (existsSync(groupTypeConfigsDir)) {
|
|
1771
|
+
const files = readdirSync(groupTypeConfigsDir).filter((f) => f.endsWith(".toml"));
|
|
1772
|
+
for (const file of files) {
|
|
1773
|
+
const filePath = join(groupTypeConfigsDir, file);
|
|
1774
|
+
const tomlData = parseTomlFile(filePath);
|
|
1775
|
+
const configData = parseGroupTypeConfigToml(tomlData);
|
|
1776
|
+
const groupType = configData.groupType || basename(file, ".toml");
|
|
1777
|
+
// Resolve ruleSetName → ruleSetId if using key-based reference
|
|
1778
|
+
resolveRuleSetReference(configData, ruleSetNameToId, `group type ${groupType}`);
|
|
1779
|
+
const existingEntry = syncState?.entities?.groupTypeConfigs?.[groupType];
|
|
1780
|
+
// Skip if file hasn't changed since last sync
|
|
1781
|
+
if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
|
|
1782
|
+
skippedCount++;
|
|
1783
|
+
continue;
|
|
1784
|
+
}
|
|
1785
|
+
if (existingEntry) {
|
|
1786
|
+
// Update existing group type config
|
|
1787
|
+
changes.push({ type: "group-type-config", action: "update", key: groupType });
|
|
1788
|
+
if (!options.dryRun) {
|
|
1789
|
+
const expectedModifiedAt = options.force
|
|
1790
|
+
? undefined
|
|
1791
|
+
: existingEntry.modifiedAt;
|
|
1792
|
+
try {
|
|
1793
|
+
const updated = await client.updateGroupTypeConfig(resolvedAppId, groupType, {
|
|
1794
|
+
ruleSetId: configData.ruleSetId,
|
|
1795
|
+
autoAddCreator: configData.autoAddCreator,
|
|
1796
|
+
}, expectedModifiedAt);
|
|
1797
|
+
info(` Updated group type config: ${groupType}`);
|
|
1798
|
+
if (syncState?.entities?.groupTypeConfigs?.[groupType] && updated?.modifiedAt) {
|
|
1799
|
+
syncState.entities.groupTypeConfigs[groupType].modifiedAt = updated.modifiedAt;
|
|
1800
|
+
syncState.entities.groupTypeConfigs[groupType].contentHash = computeFileHash(filePath);
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
catch (err) {
|
|
1804
|
+
if (err instanceof ConflictError) {
|
|
1805
|
+
conflicts.push({
|
|
1806
|
+
type: "group-type-config",
|
|
1807
|
+
key: groupType,
|
|
1808
|
+
serverModifiedAt: err.serverModifiedAt,
|
|
1809
|
+
localModifiedAt: expectedModifiedAt || "unknown",
|
|
1810
|
+
});
|
|
1811
|
+
}
|
|
1812
|
+
else {
|
|
1813
|
+
throw err;
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
else {
|
|
1819
|
+
// Create new group type config
|
|
1820
|
+
changes.push({ type: "group-type-config", action: "create", key: groupType });
|
|
1821
|
+
if (!options.dryRun) {
|
|
1822
|
+
const created = await client.createGroupTypeConfig(resolvedAppId, {
|
|
1823
|
+
groupType,
|
|
1824
|
+
ruleSetId: configData.ruleSetId || undefined,
|
|
1825
|
+
autoAddCreator: configData.autoAddCreator,
|
|
1826
|
+
});
|
|
1827
|
+
info(` Created group type config: ${groupType}`);
|
|
1828
|
+
if (syncState) {
|
|
1829
|
+
if (!syncState.entities.groupTypeConfigs) {
|
|
1830
|
+
syncState.entities.groupTypeConfigs = {};
|
|
1831
|
+
}
|
|
1832
|
+
syncState.entities.groupTypeConfigs[groupType] = {
|
|
1833
|
+
modifiedAt: created?.modifiedAt || new Date().toISOString(),
|
|
1834
|
+
contentHash: computeFileHash(filePath),
|
|
967
1835
|
};
|
|
968
1836
|
}
|
|
969
1837
|
}
|
|
@@ -1007,6 +1875,11 @@ Directory Structure:
|
|
|
1007
1875
|
}
|
|
1008
1876
|
}
|
|
1009
1877
|
// Push test cases for prompts and workflows
|
|
1878
|
+
const pushMaps = {
|
|
1879
|
+
promptKeyToId,
|
|
1880
|
+
promptConfigNameToId,
|
|
1881
|
+
workflowConfigNameToId,
|
|
1882
|
+
};
|
|
1010
1883
|
const promptsDir2 = join(configDir, "prompts");
|
|
1011
1884
|
if (existsSync(promptsDir2)) {
|
|
1012
1885
|
for (const file of readdirSync(promptsDir2).filter((f) => f.endsWith(".toml"))) {
|
|
@@ -1014,7 +1887,7 @@ Directory Structure:
|
|
|
1014
1887
|
const promptKey = tomlData.prompt?.key || basename(file, ".toml");
|
|
1015
1888
|
const blockId = syncState?.entities?.prompts?.[promptKey]?.id;
|
|
1016
1889
|
if (blockId) {
|
|
1017
|
-
await pushTestCasesForBlock(client, resolvedAppId, "prompt", blockId, promptKey, configDir, syncState, options.dryRun, changes);
|
|
1890
|
+
skippedCount += await pushTestCasesForBlock(client, resolvedAppId, "prompt", blockId, promptKey, configDir, syncState, options.dryRun, changes, pushMaps, { force: options.force });
|
|
1018
1891
|
}
|
|
1019
1892
|
}
|
|
1020
1893
|
}
|
|
@@ -1025,7 +1898,7 @@ Directory Structure:
|
|
|
1025
1898
|
const workflowKey = tomlData.workflow?.key || basename(file, ".toml");
|
|
1026
1899
|
const blockId = syncState?.entities?.workflows?.[workflowKey]?.id;
|
|
1027
1900
|
if (blockId) {
|
|
1028
|
-
await pushTestCasesForBlock(client, resolvedAppId, "workflow", blockId, workflowKey, configDir, syncState, options.dryRun, changes);
|
|
1901
|
+
skippedCount += await pushTestCasesForBlock(client, resolvedAppId, "workflow", blockId, workflowKey, configDir, syncState, options.dryRun, changes, pushMaps, { force: options.force });
|
|
1029
1902
|
}
|
|
1030
1903
|
}
|
|
1031
1904
|
}
|
|
@@ -1076,10 +1949,23 @@ Directory Structure:
|
|
|
1076
1949
|
syncState.lastSyncedAt = new Date().toISOString();
|
|
1077
1950
|
saveSyncState(configDir, syncState);
|
|
1078
1951
|
}
|
|
1079
|
-
|
|
1952
|
+
const pushMsg = skippedCount > 0
|
|
1953
|
+
? `Pushed ${changes.length} change(s), skipped ${skippedCount} unchanged file(s).`
|
|
1954
|
+
: `Pushed ${changes.length} changes.`;
|
|
1955
|
+
success(pushMsg);
|
|
1080
1956
|
}
|
|
1081
1957
|
}
|
|
1082
1958
|
catch (err) {
|
|
1959
|
+
// Save sync state even on failure so partial progress is preserved
|
|
1960
|
+
if (syncState) {
|
|
1961
|
+
try {
|
|
1962
|
+
syncState.lastSyncedAt = new Date().toISOString();
|
|
1963
|
+
saveSyncState(configDir, syncState);
|
|
1964
|
+
}
|
|
1965
|
+
catch {
|
|
1966
|
+
// Don't mask the original error
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1083
1969
|
error(err.message);
|
|
1084
1970
|
process.exit(1);
|
|
1085
1971
|
}
|
|
@@ -1103,15 +1989,15 @@ Directory Structure:
|
|
|
1103
1989
|
info(`Comparing local configuration with app ${resolvedAppId}...`);
|
|
1104
1990
|
try {
|
|
1105
1991
|
// Fetch remote state
|
|
1106
|
-
const [
|
|
1107
|
-
client.listIntegrations(resolvedAppId,
|
|
1108
|
-
client.listPrompts(resolvedAppId,
|
|
1109
|
-
client.listWorkflows(resolvedAppId,
|
|
1992
|
+
const [integrationItems, promptItems, workflowItems, emailTemplatesResult] = await Promise.all([
|
|
1993
|
+
fetchAll((p) => client.listIntegrations(resolvedAppId, p)),
|
|
1994
|
+
fetchAll((p) => client.listPrompts(resolvedAppId, p)),
|
|
1995
|
+
fetchAll((p) => client.listWorkflows(resolvedAppId, p)),
|
|
1110
1996
|
client.listEmailTemplates(resolvedAppId).catch(() => ({ templates: [] })),
|
|
1111
1997
|
]);
|
|
1112
|
-
const remoteIntegrations = new Set(
|
|
1113
|
-
const remotePrompts = new Set(
|
|
1114
|
-
const remoteWorkflows = new Set(
|
|
1998
|
+
const remoteIntegrations = new Set(integrationItems.map((i) => i.integrationKey));
|
|
1999
|
+
const remotePrompts = new Set(promptItems.map((p) => p.promptKey));
|
|
2000
|
+
const remoteWorkflows = new Set(workflowItems.map((w) => w.workflowKey));
|
|
1115
2001
|
const remoteEmailTemplates = new Set((emailTemplatesResult.templates || [])
|
|
1116
2002
|
.filter((t) => t.hasOverride)
|
|
1117
2003
|
.map((t) => t.emailType));
|
|
@@ -1247,12 +2133,12 @@ Directory Structure:
|
|
|
1247
2133
|
}
|
|
1248
2134
|
};
|
|
1249
2135
|
// Compare test cases for synced blocks
|
|
1250
|
-
for (const p of
|
|
2136
|
+
for (const p of promptItems) {
|
|
1251
2137
|
if (localPrompts.has(p.promptKey)) {
|
|
1252
2138
|
await compareTestCases("prompt", p.promptId, p.promptKey);
|
|
1253
2139
|
}
|
|
1254
2140
|
}
|
|
1255
|
-
for (const w of
|
|
2141
|
+
for (const w of workflowItems) {
|
|
1256
2142
|
if (localWorkflows.has(w.workflowKey)) {
|
|
1257
2143
|
await compareTestCases("workflow", w.workflowId, w.workflowKey);
|
|
1258
2144
|
}
|