primitive-admin 1.1.0-alpha.3 → 1.1.0-alpha.31
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 +168 -21
- package/assets/skill/skills/primitive-platform/SKILL.md +226 -0
- package/dist/bin/primitive.js +198 -15
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/admins.js +107 -0
- package/dist/src/commands/admins.js.map +1 -1
- package/dist/src/commands/analytics.js +464 -55
- package/dist/src/commands/analytics.js.map +1 -1
- package/dist/src/commands/apps.js +27 -13
- package/dist/src/commands/apps.js.map +1 -1
- package/dist/src/commands/auth.js +165 -5
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/blob-buckets.js +354 -0
- package/dist/src/commands/blob-buckets.js.map +1 -0
- package/dist/src/commands/catalog.js +17 -17
- package/dist/src/commands/catalog.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 +526 -0
- package/dist/src/commands/collections.js.map +1 -0
- package/dist/src/commands/comparisons.js +6 -6
- package/dist/src/commands/comparisons.js.map +1 -1
- package/dist/src/commands/cron-triggers.js +364 -0
- package/dist/src/commands/cron-triggers.js.map +1 -0
- package/dist/src/commands/database-types.js +462 -0
- package/dist/src/commands/database-types.js.map +1 -0
- package/dist/src/commands/databases.js +1067 -58
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/documents.js +510 -2
- package/dist/src/commands/documents.js.map +1 -1
- package/dist/src/commands/email-templates.js +281 -0
- package/dist/src/commands/email-templates.js.map +1 -0
- package/dist/src/commands/env.js +260 -0
- package/dist/src/commands/env.js.map +1 -0
- package/dist/src/commands/group-type-configs.js +189 -0
- package/dist/src/commands/group-type-configs.js.map +1 -0
- package/dist/src/commands/groups.js +28 -25
- package/dist/src/commands/groups.js.map +1 -1
- package/dist/src/commands/init.js +719 -188
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/integrations.js +504 -33
- package/dist/src/commands/integrations.js.map +1 -1
- package/dist/src/commands/prompts.js +17 -16
- package/dist/src/commands/prompts.js.map +1 -1
- package/dist/src/commands/rule-sets.js +366 -0
- package/dist/src/commands/rule-sets.js.map +1 -0
- package/dist/src/commands/secrets.js +108 -0
- package/dist/src/commands/secrets.js.map +1 -0
- package/dist/src/commands/skill.js +29 -0
- package/dist/src/commands/skill.js.map +1 -0
- package/dist/src/commands/sync.js +2363 -182
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/tokens.js +9 -9
- package/dist/src/commands/tokens.js.map +1 -1
- package/dist/src/commands/users.js +426 -4
- package/dist/src/commands/users.js.map +1 -1
- package/dist/src/commands/webhooks.js +386 -0
- package/dist/src/commands/webhooks.js.map +1 -0
- package/dist/src/commands/workflows.js +614 -64
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.js +942 -83
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/config.js +51 -53
- package/dist/src/lib/config.js.map +1 -1
- package/dist/src/lib/constants.js +3 -0
- package/dist/src/lib/constants.js.map +1 -0
- package/dist/src/lib/credentials-store.js +307 -0
- package/dist/src/lib/credentials-store.js.map +1 -0
- package/dist/src/lib/env-resolver.js +121 -0
- package/dist/src/lib/env-resolver.js.map +1 -0
- package/dist/src/lib/init-config.js +87 -0
- package/dist/src/lib/init-config.js.map +1 -0
- package/dist/src/lib/migration-nag.js +163 -0
- package/dist/src/lib/migration-nag.js.map +1 -0
- package/dist/src/lib/output.js +58 -6
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/paginate.js +42 -0
- package/dist/src/lib/paginate.js.map +1 -0
- package/dist/src/lib/project-config.js +209 -0
- package/dist/src/lib/project-config.js.map +1 -0
- package/dist/src/lib/refresh-admin-credentials.js +103 -0
- package/dist/src/lib/refresh-admin-credentials.js.map +1 -0
- package/dist/src/lib/skill-installer.js +135 -0
- package/dist/src/lib/skill-installer.js.map +1 -0
- package/dist/src/lib/sync-paths.js +102 -0
- package/dist/src/lib/sync-paths.js.map +1 -0
- package/dist/src/lib/template.js +279 -18
- package/dist/src/lib/template.js.map +1 -1
- package/dist/src/lib/toml-database-config.js +384 -0
- package/dist/src/lib/toml-database-config.js.map +1 -0
- package/dist/src/lib/toml-params-validator.js +183 -0
- package/dist/src/lib/toml-params-validator.js.map +1 -0
- package/dist/src/lib/version-check.js +172 -0
- package/dist/src/lib/version-check.js.map +1 -0
- package/dist/src/lib/workflow-fragments.js +121 -0
- package/dist/src/lib/workflow-fragments.js.map +1 -0
- package/dist/src/lib/workflow-toml-validator.js +328 -0
- package/dist/src/lib/workflow-toml-validator.js.map +1 -0
- package/package.json +7 -4
|
@@ -1,9 +1,15 @@
|
|
|
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
|
-
import { ApiClient, ConflictError } from "../lib/api-client.js";
|
|
6
|
+
import { ApiClient, ApiError, ConflictError, SchemaRequiredError, OperationRefError, SchemaBreaksOpsError, SchemaHasUncheckableOpsError, TomlParseError, OpsExistError, } from "../lib/api-client.js";
|
|
7
|
+
import { buildDatabaseTypeTomlData, detectExistingOperationForms, normalizeOperationFromToml, } from "../lib/toml-database-config.js";
|
|
8
|
+
import { validateOperations, formatIssue, } from "../lib/toml-params-validator.js";
|
|
6
9
|
import { getServerUrl, resolveAppId } from "../lib/config.js";
|
|
10
|
+
import { resolveSyncDir, isAutoResolvedSyncDir, checkLegacySyncMigration, } from "../lib/sync-paths.js";
|
|
11
|
+
import { validateWorkflowToml, formatWorkflowTomlErrors, } from "../lib/workflow-toml-validator.js";
|
|
12
|
+
import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
|
|
7
13
|
import { success, error, info, warn, keyValue, json, divider, } from "../lib/output.js";
|
|
8
14
|
import chalk from "chalk";
|
|
9
15
|
function ensureDir(dirPath) {
|
|
@@ -27,6 +33,89 @@ function saveSyncState(configDir, state) {
|
|
|
27
33
|
const syncFile = join(configDir, ".primitive-sync.json");
|
|
28
34
|
writeFileSync(syncFile, JSON.stringify(state, null, 2));
|
|
29
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Wrap a server-side error so the printed message identifies which entity
|
|
38
|
+
* was in flight. Used by every entity create/update/delete call site in the
|
|
39
|
+
* push loops (issue #684).
|
|
40
|
+
*
|
|
41
|
+
* Format: `Failed to ${action} ${kind} ${key}: ${serverMessage}`
|
|
42
|
+
*
|
|
43
|
+
* - `ConflictError` is rethrown unchanged so the existing
|
|
44
|
+
* `ConflictError → conflicts[]` accumulator in each loop still fires.
|
|
45
|
+
* - Other errors are rewrapped as a new `ApiError` carrying the wrapped
|
|
46
|
+
* message + the original `details[]` and `statusCode`.
|
|
47
|
+
*/
|
|
48
|
+
export function wrapEntityError(err, action, kind, key) {
|
|
49
|
+
if (err instanceof ConflictError) {
|
|
50
|
+
return err;
|
|
51
|
+
}
|
|
52
|
+
const original = err instanceof Error ? err.message : String(err?.message ?? err);
|
|
53
|
+
const wrappedMessage = `Failed to ${action} ${kind} ${key}: ${original}`;
|
|
54
|
+
if (err instanceof ApiError) {
|
|
55
|
+
return new ApiError(wrappedMessage, err.statusCode, err.code, err.details);
|
|
56
|
+
}
|
|
57
|
+
// Non-ApiError (e.g. network failure, JSON parse error) — preserve cause.
|
|
58
|
+
const wrapped = new Error(wrappedMessage);
|
|
59
|
+
return wrapped;
|
|
60
|
+
}
|
|
61
|
+
export function computeFileHash(filePath) {
|
|
62
|
+
const content = readFileSync(filePath);
|
|
63
|
+
return createHash("sha256").update(content).digest("hex");
|
|
64
|
+
}
|
|
65
|
+
export function shouldPushFile(filePath, storedHash) {
|
|
66
|
+
if (!storedHash)
|
|
67
|
+
return true;
|
|
68
|
+
return computeFileHash(filePath) !== storedHash;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Canonical-JSON serializer for hash computation.
|
|
72
|
+
*
|
|
73
|
+
* Sorts object keys at every level so two semantically-equivalent JSON
|
|
74
|
+
* blobs produce the same string. Arrays preserve order (step order
|
|
75
|
+
* matters in workflows). Used by `computeExpandedContentHash`.
|
|
76
|
+
*/
|
|
77
|
+
function canonicalJsonStringify(value) {
|
|
78
|
+
if (value === null || typeof value !== "object") {
|
|
79
|
+
return JSON.stringify(value);
|
|
80
|
+
}
|
|
81
|
+
if (Array.isArray(value)) {
|
|
82
|
+
return "[" + value.map((v) => canonicalJsonStringify(v)).join(",") + "]";
|
|
83
|
+
}
|
|
84
|
+
const keys = Object.keys(value).sort();
|
|
85
|
+
return ("{" +
|
|
86
|
+
keys
|
|
87
|
+
.map((k) => JSON.stringify(k) + ":" + canonicalJsonStringify(value[k]))
|
|
88
|
+
.join(",") +
|
|
89
|
+
"}");
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Hash the *expanded* (post-fragment-splice) content of a workflow TOML.
|
|
93
|
+
*
|
|
94
|
+
* The raw-file hash (`computeFileHash`) misses fragment-only edits: if a
|
|
95
|
+
* workflow uses `include = ["x"]` and only `workflow-fragments/x.toml`
|
|
96
|
+
* changes, the workflow file is byte-identical and shouldPushFile would
|
|
97
|
+
* skip the push, leaving the server with stale expanded steps.
|
|
98
|
+
*
|
|
99
|
+
* This helper hashes the parsed-and-expanded data instead, so fragment
|
|
100
|
+
* edits change the hash and force a re-push. Pass the already-parsed
|
|
101
|
+
* `tomlData` (from `parseTomlFile`) — that function runs the expander,
|
|
102
|
+
* so `tomlData` is the canonical expanded shape the server will see.
|
|
103
|
+
*/
|
|
104
|
+
export function computeExpandedContentHash(parsed) {
|
|
105
|
+
const canonical = canonicalJsonStringify(parsed);
|
|
106
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Variant of `shouldPushFile` for workflow files: compares the stored hash
|
|
110
|
+
* against the hash of the *expanded* content rather than the raw file
|
|
111
|
+
* bytes. Required so that edits to included fragments invalidate the
|
|
112
|
+
* push-skip cache for any workflow that references them.
|
|
113
|
+
*/
|
|
114
|
+
export function shouldPushExpandedFile(parsed, storedHash) {
|
|
115
|
+
if (!storedHash)
|
|
116
|
+
return true;
|
|
117
|
+
return computeExpandedContentHash(parsed) !== storedHash;
|
|
118
|
+
}
|
|
30
119
|
// TOML serialization helpers
|
|
31
120
|
function serializeAppSettings(settings) {
|
|
32
121
|
const data = {
|
|
@@ -69,6 +158,107 @@ function serializeIntegration(integration) {
|
|
|
69
158
|
};
|
|
70
159
|
return TOML.stringify(data);
|
|
71
160
|
}
|
|
161
|
+
function serializeWebhook(webhook) {
|
|
162
|
+
const data = {
|
|
163
|
+
webhook: {
|
|
164
|
+
key: webhook.webhookKey,
|
|
165
|
+
displayName: webhook.displayName,
|
|
166
|
+
description: webhook.description || undefined,
|
|
167
|
+
status: webhook.status,
|
|
168
|
+
workflowKey: webhook.workflowKey,
|
|
169
|
+
verificationScheme: webhook.verificationScheme,
|
|
170
|
+
toleranceSeconds: webhook.toleranceSeconds,
|
|
171
|
+
deduplicationEnabled: webhook.deduplicationEnabled,
|
|
172
|
+
deduplicationWindowMs: webhook.deduplicationWindowMs,
|
|
173
|
+
secretGracePeriodMs: webhook.secretGracePeriodMs,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
// Parse JSON fields if they're strings
|
|
177
|
+
if (webhook.allowedIpCidrs) {
|
|
178
|
+
try {
|
|
179
|
+
data.allowedIps = { cidrs: JSON.parse(webhook.allowedIpCidrs) };
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
data.allowedIps = { cidrs: webhook.allowedIpCidrs };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (webhook.inputMapping) {
|
|
186
|
+
try {
|
|
187
|
+
data.inputMapping = JSON.parse(webhook.inputMapping);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
data.inputMapping = { raw: webhook.inputMapping };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (webhook.metadata) {
|
|
194
|
+
try {
|
|
195
|
+
data.metadata = JSON.parse(webhook.metadata);
|
|
196
|
+
}
|
|
197
|
+
catch { }
|
|
198
|
+
}
|
|
199
|
+
// Remove undefined values
|
|
200
|
+
Object.keys(data.webhook).forEach(k => {
|
|
201
|
+
if (data.webhook[k] === undefined)
|
|
202
|
+
delete data.webhook[k];
|
|
203
|
+
});
|
|
204
|
+
return TOML.stringify(data);
|
|
205
|
+
}
|
|
206
|
+
function serializeCronTrigger(trigger) {
|
|
207
|
+
const data = {
|
|
208
|
+
cronTrigger: {
|
|
209
|
+
key: trigger.triggerKey,
|
|
210
|
+
displayName: trigger.displayName,
|
|
211
|
+
description: trigger.description || undefined,
|
|
212
|
+
cron: trigger.cron,
|
|
213
|
+
timezone: trigger.timezone || "UTC",
|
|
214
|
+
workflowKey: trigger.workflowKey,
|
|
215
|
+
overlapPolicy: trigger.overlapPolicy || "skip",
|
|
216
|
+
state: trigger.state,
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
if (trigger.rootInput) {
|
|
220
|
+
try {
|
|
221
|
+
data.rootInput = JSON.parse(trigger.rootInput);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
data.rootInput = { raw: trigger.rootInput };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (trigger.inputMapping) {
|
|
228
|
+
try {
|
|
229
|
+
data.inputMapping = JSON.parse(trigger.inputMapping);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
data.inputMapping = { raw: trigger.inputMapping };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Remove undefined values
|
|
236
|
+
Object.keys(data.cronTrigger).forEach(k => {
|
|
237
|
+
if (data.cronTrigger[k] === undefined)
|
|
238
|
+
delete data.cronTrigger[k];
|
|
239
|
+
});
|
|
240
|
+
return TOML.stringify(data);
|
|
241
|
+
}
|
|
242
|
+
function serializeBlobBucket(bucket) {
|
|
243
|
+
const data = {
|
|
244
|
+
bucket: {
|
|
245
|
+
key: bucket.bucketKey,
|
|
246
|
+
name: bucket.name,
|
|
247
|
+
description: bucket.description || undefined,
|
|
248
|
+
ttlTier: bucket.ttlTier,
|
|
249
|
+
accessPolicy: bucket.accessPolicy,
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
if (bucket.ruleSetId) {
|
|
253
|
+
data.bucket.ruleSetId = bucket.ruleSetId;
|
|
254
|
+
}
|
|
255
|
+
// Remove undefined values
|
|
256
|
+
Object.keys(data.bucket).forEach(k => {
|
|
257
|
+
if (data.bucket[k] === undefined)
|
|
258
|
+
delete data.bucket[k];
|
|
259
|
+
});
|
|
260
|
+
return TOML.stringify(data);
|
|
261
|
+
}
|
|
72
262
|
function serializePrompt(prompt) {
|
|
73
263
|
const data = {
|
|
74
264
|
prompt: {
|
|
@@ -83,7 +273,7 @@ function serializePrompt(prompt) {
|
|
|
83
273
|
description: config.description,
|
|
84
274
|
provider: config.provider,
|
|
85
275
|
model: config.model,
|
|
86
|
-
temperature: config.temperature
|
|
276
|
+
temperature: config.temperature != null ? Number(config.temperature) : undefined,
|
|
87
277
|
maxTokens: config.maxTokens,
|
|
88
278
|
outputFormat: config.outputFormat,
|
|
89
279
|
systemPrompt: config.systemPrompt,
|
|
@@ -96,6 +286,7 @@ function serializeWorkflow(workflow, draft, configs) {
|
|
|
96
286
|
// Find the active config or use the first one
|
|
97
287
|
const activeConfigId = workflow.activeConfigId;
|
|
98
288
|
const activeConfig = configs?.find((c) => c.configId === activeConfigId) || configs?.[0];
|
|
289
|
+
const activeConfigName = activeConfig?.configName || "";
|
|
99
290
|
// Determine which steps to use (prefer config steps if non-empty, else draft steps)
|
|
100
291
|
const configSteps = activeConfig?.steps;
|
|
101
292
|
const draftSteps = draft?.steps;
|
|
@@ -106,7 +297,8 @@ function serializeWorkflow(workflow, draft, configs) {
|
|
|
106
297
|
name: workflow.name,
|
|
107
298
|
description: workflow.description,
|
|
108
299
|
status: workflow.status,
|
|
109
|
-
|
|
300
|
+
activeConfigName: activeConfigName,
|
|
301
|
+
accessRule: workflow.accessRule || undefined,
|
|
110
302
|
perUserMaxRunning: workflow.perUserMaxRunning,
|
|
111
303
|
perUserMaxQueued: workflow.perUserMaxQueued,
|
|
112
304
|
dequeueOrder: workflow.dequeueOrder,
|
|
@@ -118,7 +310,6 @@ function serializeWorkflow(workflow, draft, configs) {
|
|
|
118
310
|
steps,
|
|
119
311
|
// Include all configurations
|
|
120
312
|
configs: configs?.map((config) => ({
|
|
121
|
-
id: config.configId,
|
|
122
313
|
name: config.configName,
|
|
123
314
|
description: config.description,
|
|
124
315
|
status: config.status,
|
|
@@ -127,10 +318,235 @@ function serializeWorkflow(workflow, draft, configs) {
|
|
|
127
318
|
};
|
|
128
319
|
return TOML.stringify(data);
|
|
129
320
|
}
|
|
321
|
+
// Database config serialization helpers
|
|
322
|
+
//
|
|
323
|
+
// Native-TOML form (issue #752): when emitting a database-type config, we
|
|
324
|
+
// prefer nested `[operations.definition]` tables and `[[operations.params]]`
|
|
325
|
+
// rows over JSON-strings stuffed into a single field. The form is sticky
|
|
326
|
+
// per file — if the existing file uses the legacy JSON-string form, we
|
|
327
|
+
// preserve it so we don't generate surprise diffs. New files default to
|
|
328
|
+
// native. Un-TOMLable shapes (null, mixed-type arrays) fall back to JSON
|
|
329
|
+
// string per field with a log message.
|
|
330
|
+
//
|
|
331
|
+
// Issue #666: when the type has a stored `schema` (raw TOML of `[models.*]`
|
|
332
|
+
// blocks), `buildDatabaseTypeTomlData` parses + merges it into the same TOML
|
|
333
|
+
// object so the result round-trips as `[models.<Name>.fields.<field>]`.
|
|
334
|
+
function serializeDatabaseType(typeConfig, operations, ruleSetIdToName, options = {}) {
|
|
335
|
+
const data = buildDatabaseTypeTomlData(typeConfig, operations, ruleSetIdToName, options);
|
|
336
|
+
return TOML.stringify(data);
|
|
337
|
+
}
|
|
338
|
+
function serializeEmailTemplate(template) {
|
|
339
|
+
// The API returns { emailType, hasOverride, override: { subject, htmlBody, textBody }, default: { ... } }
|
|
340
|
+
// We only serialize overrides (custom templates), using the override fields.
|
|
341
|
+
const override = template.override || {};
|
|
342
|
+
const data = {
|
|
343
|
+
template: {
|
|
344
|
+
emailType: template.emailType,
|
|
345
|
+
subject: override.subject || "",
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
if (override.htmlBody) {
|
|
349
|
+
data.template.htmlBody = override.htmlBody;
|
|
350
|
+
}
|
|
351
|
+
if (override.textBody) {
|
|
352
|
+
data.template.textBody = override.textBody;
|
|
353
|
+
}
|
|
354
|
+
return TOML.stringify(data);
|
|
355
|
+
}
|
|
356
|
+
function serializeRuleSet(ruleSet) {
|
|
357
|
+
const data = {
|
|
358
|
+
ruleSet: {
|
|
359
|
+
name: ruleSet.name,
|
|
360
|
+
resourceType: ruleSet.resourceType,
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
if (ruleSet.description) {
|
|
364
|
+
data.ruleSet.description = ruleSet.description;
|
|
365
|
+
}
|
|
366
|
+
// Rules are structured as { category: { operation: "expression" } }
|
|
367
|
+
const rules = typeof ruleSet.rules === "string" ? JSON.parse(ruleSet.rules) : (ruleSet.rules || {});
|
|
368
|
+
if (Object.keys(rules).length > 0) {
|
|
369
|
+
data.rules = rules;
|
|
370
|
+
}
|
|
371
|
+
return TOML.stringify(data);
|
|
372
|
+
}
|
|
373
|
+
function serializeGroupTypeConfig(config, ruleSetIdToName) {
|
|
374
|
+
const ruleSetName = config.ruleSetId ? (ruleSetIdToName.get(config.ruleSetId) || "") : "";
|
|
375
|
+
const data = {
|
|
376
|
+
groupTypeConfig: {
|
|
377
|
+
groupType: config.groupType,
|
|
378
|
+
ruleSetName: ruleSetName,
|
|
379
|
+
autoAddCreator: config.autoAddCreator ?? true,
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
return TOML.stringify(data);
|
|
383
|
+
}
|
|
384
|
+
function serializeCollectionTypeConfig(config, ruleSetIdToName) {
|
|
385
|
+
const ruleSetName = config.ruleSetId ? (ruleSetIdToName.get(config.ruleSetId) || "") : "";
|
|
386
|
+
const data = {
|
|
387
|
+
collectionTypeConfig: {
|
|
388
|
+
collectionType: config.collectionType,
|
|
389
|
+
ruleSetName: ruleSetName,
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
return TOML.stringify(data);
|
|
393
|
+
}
|
|
394
|
+
export function parseDatabaseTypeToml(tomlData) {
|
|
395
|
+
const typeSection = tomlData.type || {};
|
|
396
|
+
const typeConfig = {
|
|
397
|
+
databaseType: typeSection.databaseType,
|
|
398
|
+
};
|
|
399
|
+
// Support both new key-based reference and legacy ID-based reference
|
|
400
|
+
if (typeSection.ruleSetName && typeSection.ruleSetName !== "") {
|
|
401
|
+
typeConfig._ruleSetName = typeSection.ruleSetName;
|
|
402
|
+
}
|
|
403
|
+
else if (typeSection.ruleSetId && typeSection.ruleSetId !== "") {
|
|
404
|
+
typeConfig.ruleSetId = typeSection.ruleSetId;
|
|
405
|
+
}
|
|
406
|
+
// Accept both `celContextAccess` (new user-facing key) and `metadataAccess`
|
|
407
|
+
// (legacy). The newer key wins when both appear.
|
|
408
|
+
if (typeSection.celContextAccess) {
|
|
409
|
+
typeConfig.metadataAccess = typeSection.celContextAccess;
|
|
410
|
+
}
|
|
411
|
+
else if (typeSection.metadataAccess) {
|
|
412
|
+
typeConfig.metadataAccess = typeSection.metadataAccess;
|
|
413
|
+
}
|
|
414
|
+
if (typeSection.defaultAccess !== undefined) {
|
|
415
|
+
typeConfig.defaultAccess = typeSection.defaultAccess;
|
|
416
|
+
}
|
|
417
|
+
// autoPopulatedFields (issue #750) — pass through unchanged. Both the
|
|
418
|
+
// shorthand (`ownerId = "user.userId"`) and the verbose object form
|
|
419
|
+
// (`updatedBy = { value = "user.userId", on = ["create","update"] }`) are
|
|
420
|
+
// accepted; the server validates and normalizes at write time.
|
|
421
|
+
if (typeSection.autoPopulatedFields !== undefined) {
|
|
422
|
+
typeConfig.autoPopulatedFields = typeSection.autoPopulatedFields;
|
|
423
|
+
}
|
|
424
|
+
if (tomlData.triggers) {
|
|
425
|
+
typeConfig.triggers = tomlData.triggers;
|
|
426
|
+
}
|
|
427
|
+
// Issue #666: extract the `[models.*]` subtree (if present) and re-serialize
|
|
428
|
+
// it to a TOML string for the server's `schema` field.
|
|
429
|
+
//
|
|
430
|
+
// We always set `schema` — either to the serialized TOML string or to
|
|
431
|
+
// `null` — so the sync push diff can detect "models removed from local
|
|
432
|
+
// file" and forward `schema: null` to the server. Without this, removing
|
|
433
|
+
// `[models.*]` blocks would silently no-op and the next pull would
|
|
434
|
+
// resurrect the stale server schema, making schema deletion via `sync push`
|
|
435
|
+
// impossible (codex review on PR #766, [P2]).
|
|
436
|
+
let schemaSerialized = null;
|
|
437
|
+
if (tomlData.models && typeof tomlData.models === "object") {
|
|
438
|
+
try {
|
|
439
|
+
schemaSerialized = TOML.stringify({ models: tomlData.models });
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
// Fall through — server-side parser will fail with a clear
|
|
443
|
+
// TOML_PARSE_ERROR if we somehow produced an invalid round-trip.
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
typeConfig.schema = schemaSerialized;
|
|
447
|
+
// `normalizeOperationFromToml` accepts both forms:
|
|
448
|
+
// - legacy: `definition = '{...}'` (JSON string), `params = '{...}'`
|
|
449
|
+
// - native: `[operations.definition]` table, `[[operations.params]]` rows
|
|
450
|
+
// and returns the JS shape the server expects.
|
|
451
|
+
const operations = (tomlData.operations || []).map((op) => normalizeOperationFromToml(op));
|
|
452
|
+
return { typeConfig, operations };
|
|
453
|
+
}
|
|
454
|
+
function parseRuleSetToml(tomlData) {
|
|
455
|
+
const ruleSetSection = tomlData.ruleSet || {};
|
|
456
|
+
return {
|
|
457
|
+
name: ruleSetSection.name,
|
|
458
|
+
resourceType: ruleSetSection.resourceType,
|
|
459
|
+
description: ruleSetSection.description || null,
|
|
460
|
+
rules: tomlData.rules || {},
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
export function parseGroupTypeConfigToml(tomlData) {
|
|
464
|
+
const section = tomlData.groupTypeConfig || {};
|
|
465
|
+
const result = {
|
|
466
|
+
groupType: section.groupType,
|
|
467
|
+
autoAddCreator: section.autoAddCreator ?? true,
|
|
468
|
+
};
|
|
469
|
+
// Support both new key-based reference and legacy ID-based reference
|
|
470
|
+
if (section.ruleSetName && section.ruleSetName !== "") {
|
|
471
|
+
result._ruleSetName = section.ruleSetName;
|
|
472
|
+
}
|
|
473
|
+
else if (section.ruleSetId && section.ruleSetId !== "") {
|
|
474
|
+
result.ruleSetId = section.ruleSetId;
|
|
475
|
+
}
|
|
476
|
+
return result;
|
|
477
|
+
}
|
|
478
|
+
export function parseCollectionTypeConfigToml(tomlData) {
|
|
479
|
+
const section = tomlData.collectionTypeConfig || {};
|
|
480
|
+
const result = {
|
|
481
|
+
collectionType: section.collectionType,
|
|
482
|
+
};
|
|
483
|
+
// Support both new key-based reference and legacy ID-based reference
|
|
484
|
+
if (section.ruleSetName && section.ruleSetName !== "") {
|
|
485
|
+
result._ruleSetName = section.ruleSetName;
|
|
486
|
+
}
|
|
487
|
+
else if (section.ruleSetId && section.ruleSetId !== "") {
|
|
488
|
+
result.ruleSetId = section.ruleSetId;
|
|
489
|
+
}
|
|
490
|
+
return result;
|
|
491
|
+
}
|
|
130
492
|
// Parsing helpers
|
|
493
|
+
//
|
|
494
|
+
// All 20+ TOML parse sites in this file route through `parseTomlFile()`.
|
|
495
|
+
// Workflow fragment expansion is wired in here so every push path (workflows,
|
|
496
|
+
// prompts, tests, etc.) gets it for free. For non-workflow TOMLs the
|
|
497
|
+
// expander is a no-op: if the parsed result has no `include` key, it returns
|
|
498
|
+
// the original object untouched.
|
|
131
499
|
function parseTomlFile(filePath) {
|
|
132
500
|
const content = readFileSync(filePath, "utf-8");
|
|
133
|
-
|
|
501
|
+
const parsed = TOML.parse(content);
|
|
502
|
+
return expandWorkflowTomlData(parsed, filePath);
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Paginate through a list endpoint, collecting all items.
|
|
506
|
+
*/
|
|
507
|
+
export async function fetchAll(listFn, pageSize = 100, maxPages = 100) {
|
|
508
|
+
const all = [];
|
|
509
|
+
let cursor;
|
|
510
|
+
let page = 0;
|
|
511
|
+
do {
|
|
512
|
+
if (++page > maxPages) {
|
|
513
|
+
throw new Error(`fetchAll: too many pages (>${maxPages}), aborting to prevent infinite loop`);
|
|
514
|
+
}
|
|
515
|
+
const result = await listFn({ limit: pageSize, cursor: cursor });
|
|
516
|
+
all.push(...result.items);
|
|
517
|
+
cursor = result.nextCursor || undefined;
|
|
518
|
+
} while (cursor);
|
|
519
|
+
return all;
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Safely parse a JSON field that may be a string or already an object.
|
|
523
|
+
* TOML can parse inline tables as objects, so we need to handle both.
|
|
524
|
+
*/
|
|
525
|
+
export function safeJsonParse(value) {
|
|
526
|
+
if (value === undefined || value === null)
|
|
527
|
+
return undefined;
|
|
528
|
+
if (typeof value === "string")
|
|
529
|
+
return JSON.parse(value);
|
|
530
|
+
return value; // Already an object (TOML parsed it as a table)
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Resolve a key-based rule set name reference to an ID.
|
|
534
|
+
* Throws if the name cannot be resolved and throwOnMissing is true.
|
|
535
|
+
*/
|
|
536
|
+
export function resolveRuleSetReference(entityConfig, ruleSetNameToId, entityLabel, options = {}) {
|
|
537
|
+
if (!entityConfig._ruleSetName)
|
|
538
|
+
return;
|
|
539
|
+
const resolvedRuleSetId = ruleSetNameToId.get(entityConfig._ruleSetName);
|
|
540
|
+
if (resolvedRuleSetId) {
|
|
541
|
+
entityConfig.ruleSetId = resolvedRuleSetId;
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
const msg = `Rule set "${entityConfig._ruleSetName}" not found for ${entityLabel}. The entity would be created without a rule set.`;
|
|
545
|
+
if (options.throwOnMissing !== false) {
|
|
546
|
+
throw new Error(msg);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
delete entityConfig._ruleSetName;
|
|
134
550
|
}
|
|
135
551
|
// Test case helpers (exported for unit testing)
|
|
136
552
|
export function slugifyTestCaseName(name) {
|
|
@@ -157,15 +573,22 @@ function getTestsDir(configDir, blockType, blockKey) {
|
|
|
157
573
|
const typeDir = blockType === "prompt" ? "prompts" : "workflows";
|
|
158
574
|
return join(configDir, typeDir, `${blockKey}.tests`);
|
|
159
575
|
}
|
|
160
|
-
export function serializeTestCase(testCase) {
|
|
576
|
+
export function serializeTestCase(testCase, lookupMaps) {
|
|
577
|
+
// Resolve ID references to key-based references
|
|
578
|
+
const configName = testCase.configId && lookupMaps?.configIdToName
|
|
579
|
+
? (lookupMaps.configIdToName.get(testCase.configId) || "") : "";
|
|
580
|
+
const evaluatorPromptKey = testCase.evaluatorPromptId && lookupMaps?.promptIdToKey
|
|
581
|
+
? (lookupMaps.promptIdToKey.get(testCase.evaluatorPromptId) || "") : "";
|
|
582
|
+
const evaluatorConfigName = testCase.evaluatorConfigId && lookupMaps?.configIdToName
|
|
583
|
+
? (lookupMaps.configIdToName.get(testCase.evaluatorConfigId) || "") : "";
|
|
161
584
|
const data = {
|
|
162
585
|
test: {
|
|
163
586
|
name: testCase.name || "",
|
|
164
587
|
description: testCase.description || "",
|
|
165
588
|
inputVariables: testCase.inputVariables || "{}",
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
589
|
+
configName: configName,
|
|
590
|
+
evaluatorPromptKey: evaluatorPromptKey,
|
|
591
|
+
evaluatorConfigName: evaluatorConfigName,
|
|
169
592
|
expectedOutputPattern: testCase.expectedOutputPattern || "",
|
|
170
593
|
expectedOutputContains: testCase.expectedOutputContains || "[]",
|
|
171
594
|
expectedJsonSubset: testCase.expectedJsonSubset || "{}",
|
|
@@ -192,6 +615,18 @@ export function parseTestCaseToml(tomlData) {
|
|
|
192
615
|
if (test.description && test.description !== "") {
|
|
193
616
|
payload.description = test.description;
|
|
194
617
|
}
|
|
618
|
+
// Support both new key-based references and legacy ID-based references
|
|
619
|
+
// Key-based references (portable across apps)
|
|
620
|
+
if (test.configName && test.configName !== "") {
|
|
621
|
+
payload._configName = test.configName;
|
|
622
|
+
}
|
|
623
|
+
if (test.evaluatorPromptKey && test.evaluatorPromptKey !== "") {
|
|
624
|
+
payload._evaluatorPromptKey = test.evaluatorPromptKey;
|
|
625
|
+
}
|
|
626
|
+
if (test.evaluatorConfigName && test.evaluatorConfigName !== "") {
|
|
627
|
+
payload._evaluatorConfigName = test.evaluatorConfigName;
|
|
628
|
+
}
|
|
629
|
+
// Legacy ID-based references (backward compat)
|
|
195
630
|
if (test.configId && test.configId !== "") {
|
|
196
631
|
payload.configId = test.configId;
|
|
197
632
|
}
|
|
@@ -222,7 +657,7 @@ export function parseTestCaseToml(tomlData) {
|
|
|
222
657
|
}
|
|
223
658
|
return payload;
|
|
224
659
|
}
|
|
225
|
-
async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, testCaseEntities) {
|
|
660
|
+
async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, testCaseEntities, lookupMaps) {
|
|
226
661
|
let testCases;
|
|
227
662
|
try {
|
|
228
663
|
const result = await client.listTestCases(appId, blockType, blockId);
|
|
@@ -243,7 +678,7 @@ async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey
|
|
|
243
678
|
pulledSlugs.add(slug);
|
|
244
679
|
// Write test case TOML
|
|
245
680
|
const tomlPath = join(testsDir, `${slug}.toml`);
|
|
246
|
-
writeFileSync(tomlPath, serializeTestCase(tc));
|
|
681
|
+
writeFileSync(tomlPath, serializeTestCase(tc, lookupMaps));
|
|
247
682
|
// Handle attachments
|
|
248
683
|
let attachmentFilenames = [];
|
|
249
684
|
try {
|
|
@@ -279,6 +714,7 @@ async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey
|
|
|
279
714
|
slug,
|
|
280
715
|
modifiedAt: tc.modifiedAt || tc.createdAt || new Date().toISOString(),
|
|
281
716
|
attachmentFilenames: attachmentFilenames.length > 0 ? attachmentFilenames : undefined,
|
|
717
|
+
contentHash: computeFileHash(tomlPath),
|
|
282
718
|
};
|
|
283
719
|
}
|
|
284
720
|
// Clean up local files for remotely-deleted test cases
|
|
@@ -308,21 +744,75 @@ async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey
|
|
|
308
744
|
}
|
|
309
745
|
return testCases.length;
|
|
310
746
|
}
|
|
311
|
-
async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, syncState, dryRun, changes) {
|
|
747
|
+
async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, syncState, dryRun, changes, resolutionMaps, options) {
|
|
748
|
+
let skipped = 0;
|
|
312
749
|
const testsDir = getTestsDir(configDir, blockType, blockKey);
|
|
313
750
|
if (!existsSync(testsDir))
|
|
314
|
-
return;
|
|
751
|
+
return 0;
|
|
315
752
|
const files = readdirSync(testsDir).filter((f) => f.endsWith(".toml"));
|
|
316
753
|
if (files.length === 0)
|
|
317
|
-
return;
|
|
754
|
+
return 0;
|
|
318
755
|
const TEN_MB = 10 * 1024 * 1024;
|
|
319
756
|
for (const file of files) {
|
|
320
757
|
const slug = basename(file, ".toml");
|
|
321
758
|
const filePath = join(testsDir, file);
|
|
322
759
|
const tomlData = parseTomlFile(filePath);
|
|
323
760
|
const testPayload = parseTestCaseToml(tomlData);
|
|
761
|
+
// Resolve key-based references to IDs
|
|
762
|
+
if (resolutionMaps) {
|
|
763
|
+
// Resolve configName → configId
|
|
764
|
+
if (testPayload._configName && !testPayload.configId) {
|
|
765
|
+
const configMapKey = `${blockKey}#${testPayload._configName}`;
|
|
766
|
+
const configMap = blockType === "prompt"
|
|
767
|
+
? resolutionMaps.promptConfigNameToId
|
|
768
|
+
: resolutionMaps.workflowConfigNameToId;
|
|
769
|
+
const resolvedId = configMap.get(configMapKey);
|
|
770
|
+
if (resolvedId) {
|
|
771
|
+
testPayload.configId = resolvedId;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
delete testPayload._configName;
|
|
775
|
+
// Resolve evaluatorPromptKey → evaluatorPromptId
|
|
776
|
+
if (testPayload._evaluatorPromptKey && !testPayload.evaluatorPromptId) {
|
|
777
|
+
const resolvedId = resolutionMaps.promptKeyToId.get(testPayload._evaluatorPromptKey);
|
|
778
|
+
if (resolvedId) {
|
|
779
|
+
testPayload.evaluatorPromptId = resolvedId;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
const evaluatorPromptKey = testPayload._evaluatorPromptKey;
|
|
783
|
+
delete testPayload._evaluatorPromptKey;
|
|
784
|
+
// Resolve evaluatorConfigName → evaluatorConfigId
|
|
785
|
+
if (testPayload._evaluatorConfigName && !testPayload.evaluatorConfigId && evaluatorPromptKey) {
|
|
786
|
+
const configMapKey = `${evaluatorPromptKey}#${testPayload._evaluatorConfigName}`;
|
|
787
|
+
const resolvedId = resolutionMaps.promptConfigNameToId.get(configMapKey);
|
|
788
|
+
if (resolvedId) {
|
|
789
|
+
testPayload.evaluatorConfigId = resolvedId;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
delete testPayload._evaluatorConfigName;
|
|
793
|
+
}
|
|
324
794
|
const stateKey = `${blockType}/${blockKey}/${slug}`;
|
|
325
795
|
const existingEntry = syncState?.entities?.testCases?.[stateKey];
|
|
796
|
+
// Skip if file hasn't changed since last sync (also check for attachment changes)
|
|
797
|
+
if (!options?.force && existingEntry?.id && !shouldPushFile(filePath, existingEntry.contentHash)) {
|
|
798
|
+
const attachDir = join(testsDir, slug);
|
|
799
|
+
const localAttachFiles = existsSync(attachDir)
|
|
800
|
+
? readdirSync(attachDir).filter((f) => { try {
|
|
801
|
+
return statSync(join(attachDir, f)).isFile();
|
|
802
|
+
}
|
|
803
|
+
catch {
|
|
804
|
+
return false;
|
|
805
|
+
} })
|
|
806
|
+
: [];
|
|
807
|
+
const prevAttachFiles = existingEntry.attachmentFilenames || [];
|
|
808
|
+
const attachmentsChanged = localAttachFiles.length !== prevAttachFiles.length ||
|
|
809
|
+
localAttachFiles.some((f) => !prevAttachFiles.includes(f)) ||
|
|
810
|
+
prevAttachFiles.some((f) => !localAttachFiles.includes(f));
|
|
811
|
+
if (!attachmentsChanged) {
|
|
812
|
+
skipped++;
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
326
816
|
if (existingEntry?.id) {
|
|
327
817
|
// Update existing test case
|
|
328
818
|
changes.push({ type: "test-case", action: "update", key: `${blockKey}/${slug}` });
|
|
@@ -330,6 +820,9 @@ async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey
|
|
|
330
820
|
try {
|
|
331
821
|
await client.updateTestCase(appId, blockType, blockId, existingEntry.id, testPayload);
|
|
332
822
|
info(` Updated test case: ${blockKey}/${slug}`);
|
|
823
|
+
if (syncState?.entities?.testCases?.[stateKey]) {
|
|
824
|
+
syncState.entities.testCases[stateKey].contentHash = computeFileHash(filePath);
|
|
825
|
+
}
|
|
333
826
|
}
|
|
334
827
|
catch (err) {
|
|
335
828
|
warn(` Failed to update test case ${blockKey}/${slug}: ${err.message}`);
|
|
@@ -360,6 +853,7 @@ async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey
|
|
|
360
853
|
blockKey,
|
|
361
854
|
slug,
|
|
362
855
|
modifiedAt: created.modifiedAt || created.createdAt || new Date().toISOString(),
|
|
856
|
+
contentHash: computeFileHash(filePath),
|
|
363
857
|
};
|
|
364
858
|
}
|
|
365
859
|
}
|
|
@@ -438,6 +932,7 @@ async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey
|
|
|
438
932
|
}
|
|
439
933
|
}
|
|
440
934
|
}
|
|
935
|
+
return skipped;
|
|
441
936
|
}
|
|
442
937
|
function formatFileSize(bytes) {
|
|
443
938
|
if (bytes < 1024)
|
|
@@ -459,13 +954,21 @@ Examples:
|
|
|
459
954
|
|
|
460
955
|
Directory Structure:
|
|
461
956
|
config/
|
|
462
|
-
.primitive-sync.json
|
|
463
|
-
app.toml
|
|
464
|
-
integrations/*.toml
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
957
|
+
.primitive-sync.json # Sync state (auto-managed)
|
|
958
|
+
app.toml # App settings
|
|
959
|
+
integrations/*.toml # Integration configs
|
|
960
|
+
webhooks/*.toml # Webhook configs
|
|
961
|
+
cron-triggers/*.toml # Cron trigger configs
|
|
962
|
+
blob-buckets/*.toml # Blob bucket configs
|
|
963
|
+
prompts/*.toml # Prompt configs
|
|
964
|
+
prompts/{key}.tests/*.toml # Prompt test cases
|
|
965
|
+
workflows/*.toml # Workflow definitions
|
|
966
|
+
workflows/{key}.tests/*.toml # Workflow test cases
|
|
967
|
+
database-types/*.toml # Database type configs + operations
|
|
968
|
+
rule-sets/*.toml # Access rule sets
|
|
969
|
+
group-type-configs/*.toml # Group type configs
|
|
970
|
+
collection-type-configs/*.toml # Collection type configs
|
|
971
|
+
email-templates/*.toml # Email template overrides
|
|
469
972
|
`);
|
|
470
973
|
// Init
|
|
471
974
|
sync
|
|
@@ -473,14 +976,28 @@ Directory Structure:
|
|
|
473
976
|
.description("Initialize a sync directory structure")
|
|
474
977
|
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
475
978
|
.option("--app <app-id>", "App ID")
|
|
476
|
-
.option("--dir <path>", "Config directory
|
|
979
|
+
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
477
980
|
.action(async (appId, options) => {
|
|
478
981
|
const resolvedAppId = resolveAppId(appId, options);
|
|
479
|
-
const configDir = options.dir;
|
|
982
|
+
const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
|
|
983
|
+
if (isAutoResolvedSyncDir(options.dir)) {
|
|
984
|
+
info(`Using per-environment sync directory: ${configDir}`);
|
|
985
|
+
const legacyHint = checkLegacySyncMigration(configDir);
|
|
986
|
+
if (legacyHint)
|
|
987
|
+
warn(legacyHint);
|
|
988
|
+
}
|
|
480
989
|
ensureDir(configDir);
|
|
481
990
|
ensureDir(join(configDir, "integrations"));
|
|
991
|
+
ensureDir(join(configDir, "webhooks"));
|
|
992
|
+
ensureDir(join(configDir, "cron-triggers"));
|
|
993
|
+
ensureDir(join(configDir, "blob-buckets"));
|
|
482
994
|
ensureDir(join(configDir, "prompts"));
|
|
483
995
|
ensureDir(join(configDir, "workflows"));
|
|
996
|
+
ensureDir(join(configDir, "database-types"));
|
|
997
|
+
ensureDir(join(configDir, "rule-sets"));
|
|
998
|
+
ensureDir(join(configDir, "group-type-configs"));
|
|
999
|
+
ensureDir(join(configDir, "collection-type-configs"));
|
|
1000
|
+
ensureDir(join(configDir, "email-templates"));
|
|
484
1001
|
const state = {
|
|
485
1002
|
appId: resolvedAppId,
|
|
486
1003
|
serverUrl: getServerUrl(),
|
|
@@ -497,25 +1014,32 @@ Directory Structure:
|
|
|
497
1014
|
.description("Download configuration from server to TOML files")
|
|
498
1015
|
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
499
1016
|
.option("--app <app-id>", "App ID")
|
|
500
|
-
.option("--dir <path>", "Config directory
|
|
1017
|
+
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
501
1018
|
.option("--json", "Output as JSON instead of writing files")
|
|
502
1019
|
.action(async (appId, options) => {
|
|
503
1020
|
const resolvedAppId = resolveAppId(appId, options);
|
|
504
|
-
const configDir = options.dir;
|
|
1021
|
+
const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
|
|
1022
|
+
if (isAutoResolvedSyncDir(options.dir) && !options.json) {
|
|
1023
|
+
info(`Using per-environment sync directory: ${configDir}`);
|
|
1024
|
+
const legacyHint = checkLegacySyncMigration(configDir);
|
|
1025
|
+
if (legacyHint)
|
|
1026
|
+
warn(legacyHint);
|
|
1027
|
+
}
|
|
505
1028
|
const client = new ApiClient();
|
|
506
1029
|
info(`Pulling configuration for app ${resolvedAppId}...`);
|
|
507
1030
|
try {
|
|
508
1031
|
// Fetch all data
|
|
509
|
-
const [settings,
|
|
1032
|
+
const [settings, integrationItems, promptItems, workflowItems, emailTemplatesResult] = await Promise.all([
|
|
510
1033
|
client.getAppSettings(resolvedAppId).catch(() => null),
|
|
511
|
-
client.listIntegrations(resolvedAppId,
|
|
512
|
-
client.listPrompts(resolvedAppId,
|
|
513
|
-
client.listWorkflows(resolvedAppId,
|
|
1034
|
+
fetchAll((p) => client.listIntegrations(resolvedAppId, p)),
|
|
1035
|
+
fetchAll((p) => client.listPrompts(resolvedAppId, p)),
|
|
1036
|
+
fetchAll((p) => client.listWorkflows(resolvedAppId, p)),
|
|
1037
|
+
client.listEmailTemplates(resolvedAppId).catch(() => ({ templates: [] })),
|
|
514
1038
|
]);
|
|
515
1039
|
// Fetch details for each entity
|
|
516
|
-
const integrations = await Promise.all(
|
|
517
|
-
const prompts = await Promise.all(
|
|
518
|
-
const workflows = await Promise.all(
|
|
1040
|
+
const integrations = await Promise.all(integrationItems.map((i) => client.getIntegration(resolvedAppId, i.integrationId)));
|
|
1041
|
+
const prompts = await Promise.all(promptItems.map((p) => client.getPrompt(resolvedAppId, p.promptId)));
|
|
1042
|
+
const workflows = await Promise.all(workflowItems.map(async (w) => {
|
|
519
1043
|
const workflowData = await client.getWorkflow(resolvedAppId, w.workflowId);
|
|
520
1044
|
// Fetch active config with steps if available
|
|
521
1045
|
const activeConfigId = workflowData.workflow?.activeConfigId;
|
|
@@ -536,39 +1060,124 @@ Directory Structure:
|
|
|
536
1060
|
}
|
|
537
1061
|
return workflowData;
|
|
538
1062
|
}));
|
|
1063
|
+
// Fetch full details for email template overrides
|
|
1064
|
+
const emailTemplateOverrides = emailTemplatesResult.templates.filter((t) => t.hasOverride);
|
|
1065
|
+
const emailTemplates = await Promise.all(emailTemplateOverrides.map((t) => client.getEmailTemplate(resolvedAppId, t.emailType).catch(() => null))).then((results) => results.filter(Boolean));
|
|
539
1066
|
if (options.json) {
|
|
540
|
-
json({ settings, integrations, prompts, workflows });
|
|
1067
|
+
json({ settings, integrations, prompts, workflows, emailTemplates });
|
|
541
1068
|
return;
|
|
542
1069
|
}
|
|
1070
|
+
// Fetch database config resources
|
|
1071
|
+
const [databaseTypeConfigsResult, ruleSetsResult, groupTypeConfigsResult, collectionTypeConfigsResult,] = await Promise.all([
|
|
1072
|
+
client.listDatabaseTypeConfigs(resolvedAppId).catch(() => []),
|
|
1073
|
+
client.listRuleSets(resolvedAppId).catch(() => []),
|
|
1074
|
+
client.listGroupTypeConfigs(resolvedAppId).catch(() => []),
|
|
1075
|
+
client.listCollectionTypeConfigs(resolvedAppId).catch(() => []),
|
|
1076
|
+
]);
|
|
1077
|
+
// Fetch operations for each database type
|
|
1078
|
+
const databaseTypesWithOps = await Promise.all((Array.isArray(databaseTypeConfigsResult) ? databaseTypeConfigsResult : []).map(async (typeConfig) => {
|
|
1079
|
+
const ops = await client.listDatabaseTypeOperations(resolvedAppId, typeConfig.databaseType).catch(() => []);
|
|
1080
|
+
return { typeConfig, operations: Array.isArray(ops) ? ops : [] };
|
|
1081
|
+
}));
|
|
543
1082
|
// Ensure directories exist
|
|
544
1083
|
ensureDir(configDir);
|
|
545
1084
|
ensureDir(join(configDir, "integrations"));
|
|
1085
|
+
ensureDir(join(configDir, "webhooks"));
|
|
1086
|
+
ensureDir(join(configDir, "cron-triggers"));
|
|
1087
|
+
ensureDir(join(configDir, "blob-buckets"));
|
|
546
1088
|
ensureDir(join(configDir, "prompts"));
|
|
547
1089
|
ensureDir(join(configDir, "workflows"));
|
|
1090
|
+
ensureDir(join(configDir, "database-types"));
|
|
1091
|
+
ensureDir(join(configDir, "rule-sets"));
|
|
1092
|
+
ensureDir(join(configDir, "group-type-configs"));
|
|
1093
|
+
ensureDir(join(configDir, "collection-type-configs"));
|
|
1094
|
+
ensureDir(join(configDir, "email-templates"));
|
|
548
1095
|
// Write app settings
|
|
549
1096
|
if (settings) {
|
|
550
|
-
|
|
1097
|
+
const appTomlPath = join(configDir, "app.toml");
|
|
1098
|
+
writeFileSync(appTomlPath, serializeAppSettings(settings));
|
|
551
1099
|
info(" Wrote app.toml");
|
|
552
1100
|
}
|
|
553
1101
|
// Write integrations
|
|
554
1102
|
const integrationEntities = {};
|
|
555
1103
|
for (const integration of integrations) {
|
|
556
1104
|
const filename = `${integration.integrationKey}.toml`;
|
|
557
|
-
|
|
1105
|
+
const filePath = join(configDir, "integrations", filename);
|
|
1106
|
+
writeFileSync(filePath, serializeIntegration(integration));
|
|
558
1107
|
integrationEntities[integration.integrationKey] = {
|
|
559
1108
|
id: integration.integrationId,
|
|
560
1109
|
modifiedAt: integration.modifiedAt || new Date().toISOString(),
|
|
1110
|
+
contentHash: computeFileHash(filePath),
|
|
561
1111
|
};
|
|
562
1112
|
info(` Wrote integrations/${filename}`);
|
|
563
1113
|
}
|
|
1114
|
+
// Pull webhooks
|
|
1115
|
+
const webhookItems = await fetchAll((p) => client.listWebhooks(resolvedAppId, p));
|
|
1116
|
+
const webhooks = await Promise.all(webhookItems.map((w) => client.getWebhook(resolvedAppId, w.webhookId)));
|
|
1117
|
+
const webhooksDir = join(configDir, "webhooks");
|
|
1118
|
+
mkdirSync(webhooksDir, { recursive: true });
|
|
1119
|
+
const webhookEntities = {};
|
|
1120
|
+
for (const webhook of webhooks) {
|
|
1121
|
+
const filename = `${webhook.webhookKey}.toml`;
|
|
1122
|
+
const filePath = join(webhooksDir, filename);
|
|
1123
|
+
writeFileSync(filePath, serializeWebhook(webhook));
|
|
1124
|
+
webhookEntities[webhook.webhookKey] = {
|
|
1125
|
+
id: webhook.webhookId,
|
|
1126
|
+
modifiedAt: webhook.modifiedAt || new Date().toISOString(),
|
|
1127
|
+
contentHash: computeFileHash(filePath),
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
info(` Pulled ${webhooks.length} webhook(s)`);
|
|
1131
|
+
// Pull cron triggers
|
|
1132
|
+
let cronTriggerItems = [];
|
|
1133
|
+
try {
|
|
1134
|
+
const cronResult = await client.listCronTriggers(resolvedAppId);
|
|
1135
|
+
cronTriggerItems = cronResult.items || [];
|
|
1136
|
+
}
|
|
1137
|
+
catch {
|
|
1138
|
+
// Cron triggers may not be available on older servers
|
|
1139
|
+
}
|
|
1140
|
+
const cronTriggersDir = join(configDir, "cron-triggers");
|
|
1141
|
+
mkdirSync(cronTriggersDir, { recursive: true });
|
|
1142
|
+
const cronTriggerEntities = {};
|
|
1143
|
+
for (const trigger of cronTriggerItems) {
|
|
1144
|
+
const filename = `${trigger.triggerKey}.toml`;
|
|
1145
|
+
const filePath = join(cronTriggersDir, filename);
|
|
1146
|
+
writeFileSync(filePath, serializeCronTrigger(trigger));
|
|
1147
|
+
cronTriggerEntities[trigger.triggerKey] = {
|
|
1148
|
+
id: trigger.triggerId,
|
|
1149
|
+
modifiedAt: trigger.modifiedAt || new Date().toISOString(),
|
|
1150
|
+
contentHash: computeFileHash(filePath),
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
info(` Pulled ${cronTriggerItems.length} cron trigger(s)`);
|
|
1154
|
+
// Pull blob buckets
|
|
1155
|
+
const blobBucketsResult = await client.listBlobBuckets(resolvedAppId).catch(() => ({ items: [] }));
|
|
1156
|
+
const blobBucketItems = blobBucketsResult.items || [];
|
|
1157
|
+
const blobBucketsDir = join(configDir, "blob-buckets");
|
|
1158
|
+
mkdirSync(blobBucketsDir, { recursive: true });
|
|
1159
|
+
const blobBucketEntities = {};
|
|
1160
|
+
for (const bucket of blobBucketItems) {
|
|
1161
|
+
const filename = `${bucket.bucketKey}.toml`;
|
|
1162
|
+
const filePath = join(blobBucketsDir, filename);
|
|
1163
|
+
writeFileSync(filePath, serializeBlobBucket(bucket));
|
|
1164
|
+
blobBucketEntities[bucket.bucketKey] = {
|
|
1165
|
+
id: bucket.bucketId,
|
|
1166
|
+
modifiedAt: bucket.modifiedAt || new Date().toISOString(),
|
|
1167
|
+
contentHash: computeFileHash(filePath),
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
info(` Pulled ${blobBucketItems.length} blob bucket(s)`);
|
|
564
1171
|
// Write prompts
|
|
565
1172
|
const promptEntities = {};
|
|
566
1173
|
for (const prompt of prompts) {
|
|
567
1174
|
const filename = `${prompt.promptKey}.toml`;
|
|
568
|
-
|
|
1175
|
+
const filePath = join(configDir, "prompts", filename);
|
|
1176
|
+
writeFileSync(filePath, serializePrompt(prompt));
|
|
569
1177
|
promptEntities[prompt.promptKey] = {
|
|
570
1178
|
id: prompt.promptId,
|
|
571
1179
|
modifiedAt: prompt.modifiedAt || new Date().toISOString(),
|
|
1180
|
+
contentHash: computeFileHash(filePath),
|
|
572
1181
|
};
|
|
573
1182
|
info(` Wrote prompts/${filename}`);
|
|
574
1183
|
}
|
|
@@ -576,51 +1185,200 @@ Directory Structure:
|
|
|
576
1185
|
const workflowEntities = {};
|
|
577
1186
|
for (const { workflow, draft, configs } of workflows) {
|
|
578
1187
|
const filename = `${workflow.workflowKey}.toml`;
|
|
579
|
-
|
|
1188
|
+
const filePath = join(configDir, "workflows", filename);
|
|
1189
|
+
writeFileSync(filePath, serializeWorkflow(workflow, draft, configs || []));
|
|
1190
|
+
// Hash the expanded (post-fragment-splice) form so subsequent pushes
|
|
1191
|
+
// can detect fragment-only edits. See `computeExpandedContentHash`.
|
|
1192
|
+
// Pulled workflows never carry `include`, so the expander is a
|
|
1193
|
+
// no-op here — but we use the same canonical-JSON hash function so
|
|
1194
|
+
// the push-side comparison stays consistent.
|
|
580
1195
|
workflowEntities[workflow.workflowKey] = {
|
|
581
1196
|
id: workflow.workflowId,
|
|
582
1197
|
modifiedAt: workflow.modifiedAt || new Date().toISOString(),
|
|
583
1198
|
activeConfigId: workflow.activeConfigId,
|
|
1199
|
+
contentHash: computeExpandedContentHash(parseTomlFile(filePath)),
|
|
584
1200
|
};
|
|
585
1201
|
info(` Wrote workflows/${filename}`);
|
|
586
1202
|
}
|
|
1203
|
+
// Build lookup maps for test case serialization
|
|
1204
|
+
const configIdToName = new Map();
|
|
1205
|
+
const promptIdToKey = new Map();
|
|
1206
|
+
for (const prompt of prompts) {
|
|
1207
|
+
promptIdToKey.set(prompt.promptId, prompt.promptKey);
|
|
1208
|
+
if (prompt.configs) {
|
|
1209
|
+
for (const config of prompt.configs) {
|
|
1210
|
+
configIdToName.set(config.configId, config.configName);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
// Also include workflow config names
|
|
1215
|
+
for (const { configs } of workflows) {
|
|
1216
|
+
if (configs) {
|
|
1217
|
+
for (const config of configs) {
|
|
1218
|
+
configIdToName.set(config.configId, config.configName);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
const testCaseLookupMaps = { configIdToName, promptIdToKey };
|
|
1223
|
+
// Write email templates (use detailed emailTemplates, not summaries)
|
|
1224
|
+
const emailTemplateEntities = {};
|
|
1225
|
+
for (const template of emailTemplates) {
|
|
1226
|
+
const filename = `${template.emailType}.toml`;
|
|
1227
|
+
writeFileSync(join(configDir, "email-templates", filename), serializeEmailTemplate(template));
|
|
1228
|
+
emailTemplateEntities[template.emailType] = {
|
|
1229
|
+
id: template.emailType,
|
|
1230
|
+
modifiedAt: template.override?.modifiedAt || new Date().toISOString(),
|
|
1231
|
+
};
|
|
1232
|
+
info(` Wrote email-templates/${filename}`);
|
|
1233
|
+
}
|
|
1234
|
+
// Clean up local email template files for remotely-deleted overrides
|
|
1235
|
+
const emailTemplatesDir = join(configDir, "email-templates");
|
|
1236
|
+
if (existsSync(emailTemplatesDir)) {
|
|
1237
|
+
const pulledTypes = new Set(emailTemplates.map((t) => t.emailType));
|
|
1238
|
+
const localFiles = readdirSync(emailTemplatesDir).filter((f) => f.endsWith(".toml"));
|
|
1239
|
+
for (const file of localFiles) {
|
|
1240
|
+
const localType = basename(file, ".toml");
|
|
1241
|
+
if (!pulledTypes.has(localType)) {
|
|
1242
|
+
unlinkSync(join(emailTemplatesDir, file));
|
|
1243
|
+
info(` Removed email-templates/${file} (override deleted on server)`);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
587
1247
|
// Pull test cases for prompts and workflows
|
|
588
1248
|
const testCaseEntities = {};
|
|
589
1249
|
let totalTestCases = 0;
|
|
590
1250
|
for (const prompt of prompts) {
|
|
591
|
-
const count = await pullTestCasesForBlock(client, resolvedAppId, "prompt", prompt.promptId, prompt.promptKey, configDir, testCaseEntities);
|
|
1251
|
+
const count = await pullTestCasesForBlock(client, resolvedAppId, "prompt", prompt.promptId, prompt.promptKey, configDir, testCaseEntities, testCaseLookupMaps);
|
|
592
1252
|
if (count > 0) {
|
|
593
1253
|
info(` Wrote ${count} test case(s) for prompt: ${prompt.promptKey}`);
|
|
594
1254
|
}
|
|
595
1255
|
totalTestCases += count;
|
|
596
1256
|
}
|
|
597
1257
|
for (const { workflow } of workflows) {
|
|
598
|
-
const count = await pullTestCasesForBlock(client, resolvedAppId, "workflow", workflow.workflowId, workflow.workflowKey, configDir, testCaseEntities);
|
|
1258
|
+
const count = await pullTestCasesForBlock(client, resolvedAppId, "workflow", workflow.workflowId, workflow.workflowKey, configDir, testCaseEntities, testCaseLookupMaps);
|
|
599
1259
|
if (count > 0) {
|
|
600
1260
|
info(` Wrote ${count} test case(s) for workflow: ${workflow.workflowKey}`);
|
|
601
1261
|
}
|
|
602
1262
|
totalTestCases += count;
|
|
603
1263
|
}
|
|
1264
|
+
// Build ruleSetId → name map for database types and group type configs
|
|
1265
|
+
const ruleSets = Array.isArray(ruleSetsResult) ? ruleSetsResult : [];
|
|
1266
|
+
const ruleSetIdToName = new Map();
|
|
1267
|
+
for (const ruleSet of ruleSets) {
|
|
1268
|
+
ruleSetIdToName.set(ruleSet.ruleSetId, ruleSet.name);
|
|
1269
|
+
}
|
|
1270
|
+
// Write database types
|
|
1271
|
+
const databaseTypeEntities = {};
|
|
1272
|
+
for (const { typeConfig, operations } of databaseTypesWithOps) {
|
|
1273
|
+
const filename = `${typeConfig.databaseType}.toml`;
|
|
1274
|
+
const filePath = join(configDir, "database-types", filename);
|
|
1275
|
+
// Preserve the existing file's per-op form (issue #752): if the
|
|
1276
|
+
// file already exists, read it raw and detect which operations
|
|
1277
|
+
// currently use JSON-string vs native nested-table form.
|
|
1278
|
+
const hints = existsSync(filePath)
|
|
1279
|
+
? detectExistingOperationForms(readFileSync(filePath, "utf-8"))
|
|
1280
|
+
: undefined;
|
|
1281
|
+
writeFileSync(filePath, serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
|
|
1282
|
+
hints,
|
|
1283
|
+
defaultForm: "native",
|
|
1284
|
+
logger: (msg) => info(` ${msg}`),
|
|
1285
|
+
}));
|
|
1286
|
+
const opsEntities = {};
|
|
1287
|
+
for (const op of operations) {
|
|
1288
|
+
opsEntities[op.name] = {
|
|
1289
|
+
modifiedAt: op.modifiedAt || new Date().toISOString(),
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
databaseTypeEntities[typeConfig.databaseType] = {
|
|
1293
|
+
databaseType: typeConfig.databaseType,
|
|
1294
|
+
modifiedAt: typeConfig.modifiedAt || new Date().toISOString(),
|
|
1295
|
+
operations: Object.keys(opsEntities).length > 0 ? opsEntities : undefined,
|
|
1296
|
+
contentHash: computeFileHash(filePath),
|
|
1297
|
+
hasSchema: typeof typeConfig.schema === "string" &&
|
|
1298
|
+
typeConfig.schema.trim().length > 0,
|
|
1299
|
+
};
|
|
1300
|
+
info(` Wrote database-types/${filename}`);
|
|
1301
|
+
}
|
|
1302
|
+
// Write rule sets
|
|
1303
|
+
const ruleSetEntities = {};
|
|
1304
|
+
for (const ruleSet of ruleSets) {
|
|
1305
|
+
const fileKey = (ruleSet.name || ruleSet.ruleSetId).replace(/[/\\:*?"<>|]/g, "_");
|
|
1306
|
+
const filename = `${fileKey}.toml`;
|
|
1307
|
+
const filePath = join(configDir, "rule-sets", filename);
|
|
1308
|
+
writeFileSync(filePath, serializeRuleSet(ruleSet));
|
|
1309
|
+
ruleSetEntities[fileKey] = {
|
|
1310
|
+
id: ruleSet.ruleSetId,
|
|
1311
|
+
modifiedAt: ruleSet.modifiedAt || new Date().toISOString(),
|
|
1312
|
+
contentHash: computeFileHash(filePath),
|
|
1313
|
+
};
|
|
1314
|
+
info(` Wrote rule-sets/${filename}`);
|
|
1315
|
+
}
|
|
1316
|
+
// Write group type configs
|
|
1317
|
+
const groupTypeConfigEntities = {};
|
|
1318
|
+
const groupTypeConfigs = Array.isArray(groupTypeConfigsResult) ? groupTypeConfigsResult : [];
|
|
1319
|
+
for (const config of groupTypeConfigs) {
|
|
1320
|
+
const filename = `${config.groupType}.toml`;
|
|
1321
|
+
const filePath = join(configDir, "group-type-configs", filename);
|
|
1322
|
+
writeFileSync(filePath, serializeGroupTypeConfig(config, ruleSetIdToName));
|
|
1323
|
+
groupTypeConfigEntities[config.groupType] = {
|
|
1324
|
+
modifiedAt: config.modifiedAt || new Date().toISOString(),
|
|
1325
|
+
contentHash: computeFileHash(filePath),
|
|
1326
|
+
};
|
|
1327
|
+
info(` Wrote group-type-configs/${filename}`);
|
|
1328
|
+
}
|
|
1329
|
+
// Write collection type configs
|
|
1330
|
+
const collectionTypeConfigEntities = {};
|
|
1331
|
+
const collectionTypeConfigs = Array.isArray(collectionTypeConfigsResult)
|
|
1332
|
+
? collectionTypeConfigsResult
|
|
1333
|
+
: [];
|
|
1334
|
+
for (const config of collectionTypeConfigs) {
|
|
1335
|
+
const filename = `${config.collectionType}.toml`;
|
|
1336
|
+
const filePath = join(configDir, "collection-type-configs", filename);
|
|
1337
|
+
writeFileSync(filePath, serializeCollectionTypeConfig(config, ruleSetIdToName));
|
|
1338
|
+
collectionTypeConfigEntities[config.collectionType] = {
|
|
1339
|
+
modifiedAt: config.modifiedAt || new Date().toISOString(),
|
|
1340
|
+
contentHash: computeFileHash(filePath),
|
|
1341
|
+
};
|
|
1342
|
+
info(` Wrote collection-type-configs/${filename}`);
|
|
1343
|
+
}
|
|
604
1344
|
// Save sync state
|
|
605
1345
|
const state = {
|
|
606
1346
|
appId: resolvedAppId,
|
|
607
1347
|
serverUrl: getServerUrl(),
|
|
608
1348
|
lastSyncedAt: new Date().toISOString(),
|
|
609
1349
|
entities: {
|
|
610
|
-
app: settings ? { modifiedAt: new Date().toISOString() } : undefined,
|
|
1350
|
+
app: settings ? { modifiedAt: new Date().toISOString(), contentHash: computeFileHash(join(configDir, "app.toml")) } : undefined,
|
|
611
1351
|
integrations: integrationEntities,
|
|
1352
|
+
webhooks: webhookEntities,
|
|
1353
|
+
cronTriggers: Object.keys(cronTriggerEntities).length > 0 ? cronTriggerEntities : undefined,
|
|
1354
|
+
blobBuckets: Object.keys(blobBucketEntities).length > 0 ? blobBucketEntities : undefined,
|
|
612
1355
|
prompts: promptEntities,
|
|
613
1356
|
workflows: workflowEntities,
|
|
1357
|
+
emailTemplates: Object.keys(emailTemplateEntities).length > 0 ? emailTemplateEntities : undefined,
|
|
614
1358
|
testCases: Object.keys(testCaseEntities).length > 0 ? testCaseEntities : undefined,
|
|
1359
|
+
databaseTypes: Object.keys(databaseTypeEntities).length > 0 ? databaseTypeEntities : undefined,
|
|
1360
|
+
ruleSets: Object.keys(ruleSetEntities).length > 0 ? ruleSetEntities : undefined,
|
|
1361
|
+
groupTypeConfigs: Object.keys(groupTypeConfigEntities).length > 0 ? groupTypeConfigEntities : undefined,
|
|
1362
|
+
collectionTypeConfigs: Object.keys(collectionTypeConfigEntities).length > 0
|
|
1363
|
+
? collectionTypeConfigEntities
|
|
1364
|
+
: undefined,
|
|
615
1365
|
},
|
|
616
1366
|
};
|
|
617
1367
|
saveSyncState(configDir, state);
|
|
618
1368
|
divider();
|
|
619
1369
|
success(`Pulled configuration to ${configDir}`);
|
|
620
1370
|
keyValue("Integrations", integrations.length);
|
|
1371
|
+
keyValue("Webhooks", webhooks.length);
|
|
1372
|
+
keyValue("Cron Triggers", cronTriggerItems.length);
|
|
1373
|
+
keyValue("Blob Buckets", blobBucketItems.length);
|
|
621
1374
|
keyValue("Prompts", prompts.length);
|
|
622
1375
|
keyValue("Workflows", workflows.length);
|
|
1376
|
+
keyValue("Email Templates", emailTemplates.length);
|
|
623
1377
|
keyValue("Test Cases", totalTestCases);
|
|
1378
|
+
keyValue("Database Types", databaseTypesWithOps.length);
|
|
1379
|
+
keyValue("Rule Sets", ruleSets.length);
|
|
1380
|
+
keyValue("Group Type Configs", groupTypeConfigs.length);
|
|
1381
|
+
keyValue("Collection Type Configs", collectionTypeConfigs.length);
|
|
624
1382
|
}
|
|
625
1383
|
catch (err) {
|
|
626
1384
|
error(err.message);
|
|
@@ -633,49 +1391,194 @@ Directory Structure:
|
|
|
633
1391
|
.description("Upload local TOML configuration to server")
|
|
634
1392
|
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
635
1393
|
.option("--app <app-id>", "App ID")
|
|
636
|
-
.option("--dir <path>", "Config directory
|
|
1394
|
+
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
637
1395
|
.option("--dry-run", "Show what would be changed without applying")
|
|
638
1396
|
.option("--force", "Overwrite remote even if modified since last pull")
|
|
1397
|
+
.option("--accept-warnings", "Commit schema diffs that have operations with dynamic refs (issue #666 SCHEMA_HAS_UNCHECKABLE_OPS escape hatch)")
|
|
639
1398
|
.action(async (appId, options) => {
|
|
640
1399
|
const resolvedAppId = resolveAppId(appId, options);
|
|
641
|
-
const configDir = options.dir;
|
|
1400
|
+
const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
|
|
1401
|
+
if (isAutoResolvedSyncDir(options.dir)) {
|
|
1402
|
+
info(`Using per-environment sync directory: ${configDir}`);
|
|
1403
|
+
const legacyHint = checkLegacySyncMigration(configDir);
|
|
1404
|
+
if (legacyHint)
|
|
1405
|
+
warn(legacyHint);
|
|
1406
|
+
}
|
|
642
1407
|
const client = new ApiClient();
|
|
643
1408
|
if (!existsSync(configDir)) {
|
|
644
1409
|
error(`Config directory not found: ${configDir}`);
|
|
645
1410
|
process.exit(1);
|
|
646
1411
|
}
|
|
647
|
-
|
|
1412
|
+
let syncState = loadSyncState(configDir);
|
|
1413
|
+
// Detect cross-app push: if pushing to a different app, clear entity IDs
|
|
1414
|
+
// so everything goes through the create path
|
|
1415
|
+
const isCrossAppPush = syncState && syncState.appId !== resolvedAppId;
|
|
1416
|
+
if (isCrossAppPush) {
|
|
1417
|
+
info(`Cross-app push detected: ${syncState.appId} → ${resolvedAppId}`);
|
|
1418
|
+
info("All entities will be created fresh in the target app.");
|
|
1419
|
+
syncState = {
|
|
1420
|
+
appId: resolvedAppId,
|
|
1421
|
+
serverUrl: syncState.serverUrl,
|
|
1422
|
+
lastSyncedAt: new Date().toISOString(),
|
|
1423
|
+
entities: {},
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
648
1426
|
info(`Pushing configuration for app ${resolvedAppId}...`);
|
|
649
1427
|
try {
|
|
650
1428
|
const changes = [];
|
|
1429
|
+
let skippedCount = 0;
|
|
651
1430
|
const conflicts = [];
|
|
1431
|
+
// Issue #666: per-class buckets for schema-feature errors. Each entry
|
|
1432
|
+
// groups failures by the code so the end-of-run report can print
|
|
1433
|
+
// them once with the friendly hint.
|
|
1434
|
+
const schemaErrors = {
|
|
1435
|
+
schemaRequired: [],
|
|
1436
|
+
opRefs: [],
|
|
1437
|
+
schemaBreaks: [],
|
|
1438
|
+
uncheckableOps: [],
|
|
1439
|
+
tomlParse: [],
|
|
1440
|
+
opsExist: [],
|
|
1441
|
+
};
|
|
1442
|
+
// Track name→ID mappings for resolving cross-references during push
|
|
1443
|
+
const ruleSetNameToId = new Map();
|
|
1444
|
+
const promptKeyToId = new Map();
|
|
1445
|
+
const promptConfigNameToId = new Map(); // key: "promptKey#configName"
|
|
1446
|
+
const workflowConfigNameToId = new Map(); // key: "workflowKey#configName"
|
|
652
1447
|
// Process app settings
|
|
653
1448
|
const appTomlPath = join(configDir, "app.toml");
|
|
654
1449
|
if (existsSync(appTomlPath)) {
|
|
655
|
-
const
|
|
656
|
-
|
|
657
|
-
if (
|
|
658
|
-
|
|
1450
|
+
const appChanged = options.force || !syncState?.entities?.app?.contentHash ||
|
|
1451
|
+
shouldPushFile(appTomlPath, syncState.entities.app.contentHash);
|
|
1452
|
+
if (!appChanged) {
|
|
1453
|
+
skippedCount++;
|
|
1454
|
+
info(" Skipped app settings (unchanged)");
|
|
659
1455
|
}
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
settings
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
1456
|
+
else {
|
|
1457
|
+
const tomlData = parseTomlFile(appTomlPath);
|
|
1458
|
+
const settings = {};
|
|
1459
|
+
if (tomlData.app) {
|
|
1460
|
+
Object.assign(settings, tomlData.app);
|
|
1461
|
+
// Don't overwrite the target app's name during cross-app push
|
|
1462
|
+
if (isCrossAppPush) {
|
|
1463
|
+
delete settings.name;
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
if (tomlData.auth) {
|
|
1467
|
+
settings.googleOAuthEnabled = tomlData.auth.googleOAuthEnabled;
|
|
1468
|
+
settings.passkeyEnabled = tomlData.auth.passkeyEnabled;
|
|
1469
|
+
settings.magicLinkEnabled = tomlData.auth.magicLinkEnabled;
|
|
1470
|
+
if (tomlData.auth.passkeys) {
|
|
1471
|
+
settings.passkeyRpConfig = tomlData.auth.passkeys;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
if (tomlData.cors) {
|
|
1475
|
+
settings.corsMode = tomlData.cors.mode;
|
|
1476
|
+
settings.corsAllowedOrigins = tomlData.cors.allowedOrigins;
|
|
1477
|
+
settings.corsAllowCredentials = tomlData.cors.allowCredentials;
|
|
1478
|
+
settings.corsAllowedMethods = tomlData.cors.allowedMethods;
|
|
1479
|
+
settings.corsMaxAge = tomlData.cors.maxAge;
|
|
1480
|
+
}
|
|
1481
|
+
changes.push({ type: "app", action: "update", key: "settings" });
|
|
1482
|
+
if (!options.dryRun) {
|
|
1483
|
+
await client.updateAppSettings(resolvedAppId, settings);
|
|
1484
|
+
info(" Updated app settings");
|
|
1485
|
+
// Store content hash after successful push
|
|
1486
|
+
if (syncState) {
|
|
1487
|
+
if (!syncState.entities.app) {
|
|
1488
|
+
syncState.entities.app = { modifiedAt: new Date().toISOString() };
|
|
1489
|
+
}
|
|
1490
|
+
syncState.entities.app.contentHash = computeFileHash(appTomlPath);
|
|
1491
|
+
}
|
|
666
1492
|
}
|
|
667
1493
|
}
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
1494
|
+
}
|
|
1495
|
+
// Process rule sets first (other entities may reference them by name)
|
|
1496
|
+
const ruleSetsDir = join(configDir, "rule-sets");
|
|
1497
|
+
if (existsSync(ruleSetsDir)) {
|
|
1498
|
+
const files = readdirSync(ruleSetsDir).filter((f) => f.endsWith(".toml"));
|
|
1499
|
+
for (const file of files) {
|
|
1500
|
+
const filePath = join(ruleSetsDir, file);
|
|
1501
|
+
const tomlData = parseTomlFile(filePath);
|
|
1502
|
+
const ruleSetData = parseRuleSetToml(tomlData);
|
|
1503
|
+
const fileKey = basename(file, ".toml");
|
|
1504
|
+
const existingId = syncState?.entities?.ruleSets?.[fileKey]?.id;
|
|
1505
|
+
// Skip if file hasn't changed since last sync
|
|
1506
|
+
if (!options.force && existingId && !shouldPushFile(filePath, syncState?.entities?.ruleSets?.[fileKey]?.contentHash)) {
|
|
1507
|
+
skippedCount++;
|
|
1508
|
+
ruleSetNameToId.set(ruleSetData.name, existingId);
|
|
1509
|
+
continue;
|
|
1510
|
+
}
|
|
1511
|
+
if (existingId) {
|
|
1512
|
+
// Update existing rule set
|
|
1513
|
+
changes.push({ type: "rule-set", action: "update", key: fileKey });
|
|
1514
|
+
if (!options.dryRun) {
|
|
1515
|
+
const expectedModifiedAt = options.force
|
|
1516
|
+
? undefined
|
|
1517
|
+
: syncState?.entities?.ruleSets?.[fileKey]?.modifiedAt;
|
|
1518
|
+
try {
|
|
1519
|
+
const updated = await client.updateRuleSet(resolvedAppId, existingId, {
|
|
1520
|
+
name: ruleSetData.name,
|
|
1521
|
+
description: ruleSetData.description,
|
|
1522
|
+
rules: ruleSetData.rules,
|
|
1523
|
+
}, expectedModifiedAt);
|
|
1524
|
+
info(` Updated rule set: ${fileKey}`);
|
|
1525
|
+
if (syncState?.entities?.ruleSets?.[fileKey] && updated?.modifiedAt) {
|
|
1526
|
+
syncState.entities.ruleSets[fileKey].modifiedAt = updated.modifiedAt;
|
|
1527
|
+
syncState.entities.ruleSets[fileKey].contentHash = computeFileHash(filePath);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
catch (err) {
|
|
1531
|
+
if (err instanceof ConflictError) {
|
|
1532
|
+
conflicts.push({
|
|
1533
|
+
type: "rule-set",
|
|
1534
|
+
key: fileKey,
|
|
1535
|
+
serverModifiedAt: err.serverModifiedAt,
|
|
1536
|
+
localModifiedAt: expectedModifiedAt || "unknown",
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
else {
|
|
1540
|
+
throw wrapEntityError(err, "update", "rule set", fileKey);
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
// Track name→ID for cross-reference resolution
|
|
1545
|
+
ruleSetNameToId.set(ruleSetData.name, existingId);
|
|
1546
|
+
}
|
|
1547
|
+
else {
|
|
1548
|
+
// Create new rule set
|
|
1549
|
+
changes.push({ type: "rule-set", action: "create", key: fileKey });
|
|
1550
|
+
if (!options.dryRun) {
|
|
1551
|
+
let created;
|
|
1552
|
+
try {
|
|
1553
|
+
created = await client.createRuleSet(resolvedAppId, {
|
|
1554
|
+
name: ruleSetData.name,
|
|
1555
|
+
resourceType: ruleSetData.resourceType,
|
|
1556
|
+
rules: ruleSetData.rules,
|
|
1557
|
+
description: ruleSetData.description,
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
catch (err) {
|
|
1561
|
+
throw wrapEntityError(err, "create", "rule set", fileKey);
|
|
1562
|
+
}
|
|
1563
|
+
info(` Created rule set: ${fileKey}`);
|
|
1564
|
+
if (syncState && created?.ruleSetId) {
|
|
1565
|
+
if (!syncState.entities.ruleSets) {
|
|
1566
|
+
syncState.entities.ruleSets = {};
|
|
1567
|
+
}
|
|
1568
|
+
syncState.entities.ruleSets[fileKey] = {
|
|
1569
|
+
id: created.ruleSetId,
|
|
1570
|
+
modifiedAt: created.modifiedAt || new Date().toISOString(),
|
|
1571
|
+
contentHash: computeFileHash(filePath),
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
// Track name→ID for cross-reference resolution
|
|
1575
|
+
ruleSetNameToId.set(ruleSetData.name, created.ruleSetId);
|
|
1576
|
+
}
|
|
1577
|
+
else {
|
|
1578
|
+
// In dry-run mode, use a placeholder so dependent entities can resolve
|
|
1579
|
+
ruleSetNameToId.set(ruleSetData.name, `dry-run-${ruleSetData.name}`);
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
679
1582
|
}
|
|
680
1583
|
}
|
|
681
1584
|
// Process integrations
|
|
@@ -688,6 +1591,11 @@ Directory Structure:
|
|
|
688
1591
|
const integration = tomlData.integration || {};
|
|
689
1592
|
const key = integration.key || basename(file, ".toml");
|
|
690
1593
|
const existingId = syncState?.entities?.integrations?.[key]?.id;
|
|
1594
|
+
// Skip if file hasn't changed since last sync
|
|
1595
|
+
if (!options.force && existingId && !shouldPushFile(filePath, syncState?.entities?.integrations?.[key]?.contentHash)) {
|
|
1596
|
+
skippedCount++;
|
|
1597
|
+
continue;
|
|
1598
|
+
}
|
|
691
1599
|
const payload = {
|
|
692
1600
|
integrationKey: key,
|
|
693
1601
|
displayName: integration.displayName || key,
|
|
@@ -708,6 +1616,7 @@ Directory Structure:
|
|
|
708
1616
|
// Update sync state with new modifiedAt
|
|
709
1617
|
if (syncState?.entities?.integrations?.[key] && updated?.modifiedAt) {
|
|
710
1618
|
syncState.entities.integrations[key].modifiedAt = updated.modifiedAt;
|
|
1619
|
+
syncState.entities.integrations[key].contentHash = computeFileHash(filePath);
|
|
711
1620
|
}
|
|
712
1621
|
}
|
|
713
1622
|
catch (err) {
|
|
@@ -720,7 +1629,7 @@ Directory Structure:
|
|
|
720
1629
|
});
|
|
721
1630
|
}
|
|
722
1631
|
else {
|
|
723
|
-
throw err;
|
|
1632
|
+
throw wrapEntityError(err, "update", "integration", key);
|
|
724
1633
|
}
|
|
725
1634
|
}
|
|
726
1635
|
}
|
|
@@ -728,64 +1637,377 @@ Directory Structure:
|
|
|
728
1637
|
else {
|
|
729
1638
|
changes.push({ type: "integration", action: "create", key });
|
|
730
1639
|
if (!options.dryRun) {
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
if (
|
|
736
|
-
syncState.entities.integrations
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
1640
|
+
try {
|
|
1641
|
+
const created = await client.createIntegration(resolvedAppId, payload);
|
|
1642
|
+
info(` Created integration: ${key}`);
|
|
1643
|
+
// Add new entity to sync state
|
|
1644
|
+
if (syncState && created?.integrationId && created?.modifiedAt) {
|
|
1645
|
+
if (!syncState.entities.integrations) {
|
|
1646
|
+
syncState.entities.integrations = {};
|
|
1647
|
+
}
|
|
1648
|
+
syncState.entities.integrations[key] = {
|
|
1649
|
+
id: created.integrationId,
|
|
1650
|
+
modifiedAt: created.modifiedAt,
|
|
1651
|
+
contentHash: computeFileHash(filePath),
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
catch (err) {
|
|
1656
|
+
throw wrapEntityError(err, "create", "integration", key);
|
|
742
1657
|
}
|
|
743
1658
|
}
|
|
744
1659
|
}
|
|
745
1660
|
}
|
|
746
1661
|
}
|
|
747
|
-
// Process
|
|
748
|
-
const
|
|
749
|
-
if (existsSync(
|
|
750
|
-
const files = readdirSync(
|
|
1662
|
+
// Process webhooks
|
|
1663
|
+
const webhooksDir = join(configDir, "webhooks");
|
|
1664
|
+
if (existsSync(webhooksDir)) {
|
|
1665
|
+
const files = readdirSync(webhooksDir).filter((f) => f.endsWith(".toml"));
|
|
751
1666
|
for (const file of files) {
|
|
752
|
-
const filePath = join(
|
|
1667
|
+
const filePath = join(webhooksDir, file);
|
|
753
1668
|
const tomlData = parseTomlFile(filePath);
|
|
754
|
-
const
|
|
755
|
-
const key =
|
|
756
|
-
const
|
|
757
|
-
|
|
1669
|
+
const webhook = tomlData.webhook || {};
|
|
1670
|
+
const key = webhook.key || basename(file, ".toml");
|
|
1671
|
+
const existingId = syncState?.entities?.webhooks?.[key]?.id;
|
|
1672
|
+
if (!options.force && existingId &&
|
|
1673
|
+
!shouldPushFile(filePath, syncState?.entities?.webhooks?.[key]?.contentHash)) {
|
|
1674
|
+
skippedCount++;
|
|
1675
|
+
continue;
|
|
1676
|
+
}
|
|
1677
|
+
const payload = {
|
|
1678
|
+
webhookKey: key,
|
|
1679
|
+
displayName: webhook.displayName || key,
|
|
1680
|
+
description: webhook.description,
|
|
1681
|
+
workflowKey: webhook.workflowKey,
|
|
1682
|
+
verificationScheme: webhook.verificationScheme,
|
|
1683
|
+
status: webhook.status,
|
|
1684
|
+
toleranceSeconds: webhook.toleranceSeconds,
|
|
1685
|
+
deduplicationEnabled: webhook.deduplicationEnabled,
|
|
1686
|
+
deduplicationWindowMs: webhook.deduplicationWindowMs,
|
|
1687
|
+
secretGracePeriodMs: webhook.secretGracePeriodMs,
|
|
1688
|
+
};
|
|
1689
|
+
// Handle JSON fields
|
|
1690
|
+
if (tomlData.allowedIps?.cidrs) {
|
|
1691
|
+
payload.allowedIpCidrs = JSON.stringify(tomlData.allowedIps.cidrs);
|
|
1692
|
+
}
|
|
1693
|
+
if (tomlData.inputMapping) {
|
|
1694
|
+
payload.inputMapping = JSON.stringify(tomlData.inputMapping);
|
|
1695
|
+
}
|
|
1696
|
+
if (tomlData.metadata) {
|
|
1697
|
+
payload.metadata = JSON.stringify(tomlData.metadata);
|
|
1698
|
+
}
|
|
758
1699
|
if (existingId) {
|
|
759
|
-
|
|
760
|
-
changes.push({ type: "prompt", action: "update", key });
|
|
1700
|
+
changes.push({ type: "webhook", action: "update", key });
|
|
761
1701
|
if (!options.dryRun) {
|
|
762
1702
|
const expectedModifiedAt = options.force
|
|
763
1703
|
? undefined
|
|
764
|
-
: syncState?.entities?.
|
|
1704
|
+
: syncState?.entities?.webhooks?.[key]?.modifiedAt;
|
|
765
1705
|
try {
|
|
766
|
-
const updated = await client.
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
}, expectedModifiedAt);
|
|
772
|
-
info(` Updated prompt: ${key}`);
|
|
773
|
-
// Update sync state with new modifiedAt
|
|
774
|
-
if (syncState?.entities?.prompts?.[key] && updated?.modifiedAt) {
|
|
775
|
-
syncState.entities.prompts[key].modifiedAt = updated.modifiedAt;
|
|
1706
|
+
const updated = await client.updateWebhook(resolvedAppId, existingId, payload, expectedModifiedAt);
|
|
1707
|
+
info(` Updated webhook: ${key}`);
|
|
1708
|
+
if (syncState?.entities?.webhooks?.[key] && updated?.modifiedAt) {
|
|
1709
|
+
syncState.entities.webhooks[key].modifiedAt = updated.modifiedAt;
|
|
1710
|
+
syncState.entities.webhooks[key].contentHash = computeFileHash(filePath);
|
|
776
1711
|
}
|
|
777
1712
|
}
|
|
778
1713
|
catch (err) {
|
|
779
1714
|
if (err instanceof ConflictError) {
|
|
780
1715
|
conflicts.push({
|
|
781
|
-
type: "
|
|
1716
|
+
type: "webhook",
|
|
782
1717
|
key,
|
|
783
1718
|
serverModifiedAt: err.serverModifiedAt,
|
|
784
1719
|
localModifiedAt: expectedModifiedAt || "unknown",
|
|
785
1720
|
});
|
|
786
1721
|
}
|
|
787
1722
|
else {
|
|
788
|
-
throw err;
|
|
1723
|
+
throw wrapEntityError(err, "update", "webhook", key);
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
else {
|
|
1729
|
+
changes.push({ type: "webhook", action: "create", key });
|
|
1730
|
+
if (!options.dryRun) {
|
|
1731
|
+
try {
|
|
1732
|
+
const created = await client.createWebhook(resolvedAppId, payload);
|
|
1733
|
+
info(` Created webhook: ${key}`);
|
|
1734
|
+
if (syncState && created?.webhookId && created?.modifiedAt) {
|
|
1735
|
+
if (!syncState.entities.webhooks) {
|
|
1736
|
+
syncState.entities.webhooks = {};
|
|
1737
|
+
}
|
|
1738
|
+
syncState.entities.webhooks[key] = {
|
|
1739
|
+
id: created.webhookId,
|
|
1740
|
+
modifiedAt: created.modifiedAt,
|
|
1741
|
+
contentHash: computeFileHash(filePath),
|
|
1742
|
+
};
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
catch (err) {
|
|
1746
|
+
throw wrapEntityError(err, "create", "webhook", key);
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
// Process cron triggers
|
|
1753
|
+
const cronTriggersDir = join(configDir, "cron-triggers");
|
|
1754
|
+
if (existsSync(cronTriggersDir)) {
|
|
1755
|
+
const files = readdirSync(cronTriggersDir).filter((f) => f.endsWith(".toml"));
|
|
1756
|
+
for (const file of files) {
|
|
1757
|
+
const filePath = join(cronTriggersDir, file);
|
|
1758
|
+
const tomlData = parseTomlFile(filePath);
|
|
1759
|
+
const cronTrigger = tomlData.cronTrigger || {};
|
|
1760
|
+
const key = cronTrigger.key || basename(file, ".toml");
|
|
1761
|
+
const existingId = syncState?.entities?.cronTriggers?.[key]?.id;
|
|
1762
|
+
if (!options.force && existingId &&
|
|
1763
|
+
!shouldPushFile(filePath, syncState?.entities?.cronTriggers?.[key]?.contentHash)) {
|
|
1764
|
+
skippedCount++;
|
|
1765
|
+
continue;
|
|
1766
|
+
}
|
|
1767
|
+
const payload = {
|
|
1768
|
+
triggerKey: key,
|
|
1769
|
+
displayName: cronTrigger.displayName || key,
|
|
1770
|
+
description: cronTrigger.description,
|
|
1771
|
+
cron: cronTrigger.cron,
|
|
1772
|
+
timezone: cronTrigger.timezone,
|
|
1773
|
+
workflowKey: cronTrigger.workflowKey,
|
|
1774
|
+
overlapPolicy: cronTrigger.overlapPolicy,
|
|
1775
|
+
state: cronTrigger.state,
|
|
1776
|
+
};
|
|
1777
|
+
// Handle JSON fields
|
|
1778
|
+
if (tomlData.rootInput) {
|
|
1779
|
+
payload.rootInput = JSON.stringify(tomlData.rootInput);
|
|
1780
|
+
}
|
|
1781
|
+
if (tomlData.inputMapping) {
|
|
1782
|
+
payload.inputMapping = JSON.stringify(tomlData.inputMapping);
|
|
1783
|
+
}
|
|
1784
|
+
if (existingId) {
|
|
1785
|
+
changes.push({ type: "cron-trigger", action: "update", key });
|
|
1786
|
+
if (!options.dryRun) {
|
|
1787
|
+
try {
|
|
1788
|
+
const updated = await client.updateCronTrigger(resolvedAppId, existingId, payload);
|
|
1789
|
+
info(` Updated cron trigger: ${key}`);
|
|
1790
|
+
if (syncState?.entities?.cronTriggers?.[key] && updated?.modifiedAt) {
|
|
1791
|
+
syncState.entities.cronTriggers[key].modifiedAt = updated.modifiedAt;
|
|
1792
|
+
syncState.entities.cronTriggers[key].contentHash = computeFileHash(filePath);
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
catch (err) {
|
|
1796
|
+
throw wrapEntityError(err, "update", "cron trigger", key);
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
else {
|
|
1801
|
+
changes.push({ type: "cron-trigger", action: "create", key });
|
|
1802
|
+
if (!options.dryRun) {
|
|
1803
|
+
try {
|
|
1804
|
+
const created = await client.createCronTrigger(resolvedAppId, payload);
|
|
1805
|
+
info(` Created cron trigger: ${key}`);
|
|
1806
|
+
if (syncState && created?.triggerId && created?.modifiedAt) {
|
|
1807
|
+
if (!syncState.entities.cronTriggers) {
|
|
1808
|
+
syncState.entities.cronTriggers = {};
|
|
1809
|
+
}
|
|
1810
|
+
syncState.entities.cronTriggers[key] = {
|
|
1811
|
+
id: created.triggerId,
|
|
1812
|
+
modifiedAt: created.modifiedAt,
|
|
1813
|
+
contentHash: computeFileHash(filePath),
|
|
1814
|
+
};
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
catch (err) {
|
|
1818
|
+
throw wrapEntityError(err, "create", "cron trigger", key);
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
// Process blob buckets
|
|
1825
|
+
const blobBucketsPushDir = join(configDir, "blob-buckets");
|
|
1826
|
+
if (existsSync(blobBucketsPushDir)) {
|
|
1827
|
+
const files = readdirSync(blobBucketsPushDir).filter((f) => f.endsWith(".toml"));
|
|
1828
|
+
for (const file of files) {
|
|
1829
|
+
const filePath = join(blobBucketsPushDir, file);
|
|
1830
|
+
const tomlData = parseTomlFile(filePath);
|
|
1831
|
+
const bucket = tomlData.bucket || {};
|
|
1832
|
+
const key = bucket.key || basename(file, ".toml");
|
|
1833
|
+
const existingId = syncState?.entities?.blobBuckets?.[key]?.id;
|
|
1834
|
+
if (!options.force && existingId &&
|
|
1835
|
+
!shouldPushFile(filePath, syncState?.entities?.blobBuckets?.[key]?.contentHash)) {
|
|
1836
|
+
skippedCount++;
|
|
1837
|
+
continue;
|
|
1838
|
+
}
|
|
1839
|
+
if (existingId) {
|
|
1840
|
+
// Blob buckets don't have an update API - skip if already exists
|
|
1841
|
+
info(` Blob bucket already exists, skipping: ${key}`);
|
|
1842
|
+
if (syncState?.entities?.blobBuckets?.[key]) {
|
|
1843
|
+
syncState.entities.blobBuckets[key].contentHash = computeFileHash(filePath);
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
else {
|
|
1847
|
+
const payload = {
|
|
1848
|
+
bucketKey: key,
|
|
1849
|
+
name: bucket.name || key,
|
|
1850
|
+
ttlTier: bucket.ttlTier,
|
|
1851
|
+
accessPolicy: bucket.accessPolicy,
|
|
1852
|
+
};
|
|
1853
|
+
if (bucket.description)
|
|
1854
|
+
payload.description = bucket.description;
|
|
1855
|
+
if (bucket.ruleSetId)
|
|
1856
|
+
payload.ruleSetId = bucket.ruleSetId;
|
|
1857
|
+
changes.push({ type: "blob-bucket", action: "create", key });
|
|
1858
|
+
if (!options.dryRun) {
|
|
1859
|
+
try {
|
|
1860
|
+
const created = await client.createBlobBucket(resolvedAppId, payload);
|
|
1861
|
+
info(` Created blob bucket: ${key}`);
|
|
1862
|
+
if (syncState) {
|
|
1863
|
+
if (!syncState.entities.blobBuckets) {
|
|
1864
|
+
syncState.entities.blobBuckets = {};
|
|
1865
|
+
}
|
|
1866
|
+
syncState.entities.blobBuckets[key] = {
|
|
1867
|
+
id: created.bucketId,
|
|
1868
|
+
modifiedAt: created.modifiedAt || new Date().toISOString(),
|
|
1869
|
+
contentHash: computeFileHash(filePath),
|
|
1870
|
+
};
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
catch (err) {
|
|
1874
|
+
const msg = String(err?.message || err);
|
|
1875
|
+
if (msg.includes("already exists") || err.statusCode === 409) {
|
|
1876
|
+
info(` Blob bucket already exists on server: ${key}`);
|
|
1877
|
+
// Fetch the existing bucket to get its ID
|
|
1878
|
+
try {
|
|
1879
|
+
const existing = await client.getBlobBucket(resolvedAppId, key);
|
|
1880
|
+
if (syncState && existing?.bucketId) {
|
|
1881
|
+
if (!syncState.entities.blobBuckets) {
|
|
1882
|
+
syncState.entities.blobBuckets = {};
|
|
1883
|
+
}
|
|
1884
|
+
syncState.entities.blobBuckets[key] = {
|
|
1885
|
+
id: existing.bucketId,
|
|
1886
|
+
modifiedAt: existing.modifiedAt || new Date().toISOString(),
|
|
1887
|
+
contentHash: computeFileHash(filePath),
|
|
1888
|
+
};
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
catch {
|
|
1892
|
+
// Ignore fetch errors
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
else {
|
|
1896
|
+
throw wrapEntityError(err, "create", "blob bucket", key);
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
// Process prompts
|
|
1904
|
+
const promptsDir = join(configDir, "prompts");
|
|
1905
|
+
if (existsSync(promptsDir)) {
|
|
1906
|
+
const files = readdirSync(promptsDir).filter((f) => f.endsWith(".toml"));
|
|
1907
|
+
for (const file of files) {
|
|
1908
|
+
const filePath = join(promptsDir, file);
|
|
1909
|
+
const tomlData = parseTomlFile(filePath);
|
|
1910
|
+
const prompt = tomlData.prompt || {};
|
|
1911
|
+
const key = prompt.key || basename(file, ".toml");
|
|
1912
|
+
const configs = tomlData.configs || [];
|
|
1913
|
+
const existingId = syncState?.entities?.prompts?.[key]?.id;
|
|
1914
|
+
// Skip if file hasn't changed since last sync
|
|
1915
|
+
if (!options.force && existingId && !shouldPushFile(filePath, syncState?.entities?.prompts?.[key]?.contentHash)) {
|
|
1916
|
+
skippedCount++;
|
|
1917
|
+
promptKeyToId.set(key, existingId);
|
|
1918
|
+
// Only fetch config name→ID mappings if test cases exist for this prompt
|
|
1919
|
+
const promptTestsDir = getTestsDir(configDir, "prompt", key);
|
|
1920
|
+
if (!options.dryRun && existsSync(promptTestsDir)) {
|
|
1921
|
+
const fullPrompt = await client.getPrompt(resolvedAppId, existingId);
|
|
1922
|
+
if (fullPrompt?.configs) {
|
|
1923
|
+
for (const config of fullPrompt.configs) {
|
|
1924
|
+
promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
continue;
|
|
1929
|
+
}
|
|
1930
|
+
if (existingId) {
|
|
1931
|
+
// Update existing prompt
|
|
1932
|
+
changes.push({ type: "prompt", action: "update", key });
|
|
1933
|
+
promptKeyToId.set(key, existingId);
|
|
1934
|
+
if (!options.dryRun) {
|
|
1935
|
+
const expectedModifiedAt = options.force
|
|
1936
|
+
? undefined
|
|
1937
|
+
: syncState?.entities?.prompts?.[key]?.modifiedAt;
|
|
1938
|
+
try {
|
|
1939
|
+
const updated = await client.updatePrompt(resolvedAppId, existingId, {
|
|
1940
|
+
displayName: prompt.displayName,
|
|
1941
|
+
description: prompt.description,
|
|
1942
|
+
inputSchema: prompt.inputSchema,
|
|
1943
|
+
status: prompt.status,
|
|
1944
|
+
}, expectedModifiedAt);
|
|
1945
|
+
info(` Updated prompt: ${key}`);
|
|
1946
|
+
// Update sync state with new modifiedAt
|
|
1947
|
+
if (syncState?.entities?.prompts?.[key] && updated?.modifiedAt) {
|
|
1948
|
+
syncState.entities.prompts[key].modifiedAt = updated.modifiedAt;
|
|
1949
|
+
syncState.entities.prompts[key].contentHash = computeFileHash(filePath);
|
|
1950
|
+
}
|
|
1951
|
+
// Fetch full prompt to get config name→ID mappings
|
|
1952
|
+
// (updatePrompt response doesn't include configs)
|
|
1953
|
+
const fullPrompt = await client.getPrompt(resolvedAppId, existingId);
|
|
1954
|
+
if (fullPrompt?.configs) {
|
|
1955
|
+
for (const config of fullPrompt.configs) {
|
|
1956
|
+
promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
// Update existing prompt configs with TOML values
|
|
1960
|
+
if (fullPrompt?.configs && configs.length > 0) {
|
|
1961
|
+
for (const tomlConfig of configs) {
|
|
1962
|
+
const configName = tomlConfig.name;
|
|
1963
|
+
if (!configName)
|
|
1964
|
+
continue;
|
|
1965
|
+
const serverConfig = fullPrompt.configs.find((c) => c.configName === configName);
|
|
1966
|
+
if (serverConfig) {
|
|
1967
|
+
// Update existing config
|
|
1968
|
+
await client.updatePromptConfig(resolvedAppId, existingId, serverConfig.configId, {
|
|
1969
|
+
description: tomlConfig.description,
|
|
1970
|
+
provider: tomlConfig.provider,
|
|
1971
|
+
model: tomlConfig.model,
|
|
1972
|
+
systemPrompt: tomlConfig.systemPrompt,
|
|
1973
|
+
userPromptTemplate: tomlConfig.userPromptTemplate,
|
|
1974
|
+
temperature: tomlConfig.temperature,
|
|
1975
|
+
maxTokens: tomlConfig.maxTokens,
|
|
1976
|
+
outputFormat: tomlConfig.outputFormat,
|
|
1977
|
+
});
|
|
1978
|
+
}
|
|
1979
|
+
else {
|
|
1980
|
+
// Create new config that doesn't exist on server yet
|
|
1981
|
+
const newConfig = await client.createPromptConfig(resolvedAppId, existingId, {
|
|
1982
|
+
configName,
|
|
1983
|
+
description: tomlConfig.description,
|
|
1984
|
+
provider: tomlConfig.provider || "openrouter",
|
|
1985
|
+
model: tomlConfig.model || "google/gemini-2.0-flash-001",
|
|
1986
|
+
systemPrompt: tomlConfig.systemPrompt,
|
|
1987
|
+
userPromptTemplate: tomlConfig.userPromptTemplate,
|
|
1988
|
+
temperature: tomlConfig.temperature,
|
|
1989
|
+
maxTokens: tomlConfig.maxTokens,
|
|
1990
|
+
outputFormat: tomlConfig.outputFormat,
|
|
1991
|
+
});
|
|
1992
|
+
if (newConfig?.configId) {
|
|
1993
|
+
promptConfigNameToId.set(`${key}#${configName}`, newConfig.configId);
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
info(` Synced ${configs.length} config(s) for prompt: ${key}`);
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
catch (err) {
|
|
2001
|
+
if (err instanceof ConflictError) {
|
|
2002
|
+
conflicts.push({
|
|
2003
|
+
type: "prompt",
|
|
2004
|
+
key,
|
|
2005
|
+
serverModifiedAt: err.serverModifiedAt,
|
|
2006
|
+
localModifiedAt: expectedModifiedAt || "unknown",
|
|
2007
|
+
});
|
|
2008
|
+
}
|
|
2009
|
+
else {
|
|
2010
|
+
throw wrapEntityError(err, "update", "prompt", key);
|
|
789
2011
|
}
|
|
790
2012
|
}
|
|
791
2013
|
}
|
|
@@ -795,29 +2017,70 @@ Directory Structure:
|
|
|
795
2017
|
const firstConfig = configs[0] || {};
|
|
796
2018
|
changes.push({ type: "prompt", action: "create", key });
|
|
797
2019
|
if (!options.dryRun) {
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
if (
|
|
815
|
-
syncState.entities.prompts
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
2020
|
+
try {
|
|
2021
|
+
const created = await client.createPrompt(resolvedAppId, {
|
|
2022
|
+
promptKey: key,
|
|
2023
|
+
displayName: prompt.displayName || key,
|
|
2024
|
+
description: prompt.description,
|
|
2025
|
+
provider: firstConfig.provider || "openrouter",
|
|
2026
|
+
model: firstConfig.model || "google/gemini-2.0-flash-001",
|
|
2027
|
+
systemPrompt: firstConfig.systemPrompt,
|
|
2028
|
+
userPromptTemplate: firstConfig.userPromptTemplate || "{{ input }}",
|
|
2029
|
+
temperature: firstConfig.temperature,
|
|
2030
|
+
maxTokens: firstConfig.maxTokens,
|
|
2031
|
+
outputFormat: firstConfig.outputFormat,
|
|
2032
|
+
inputSchema: prompt.inputSchema,
|
|
2033
|
+
});
|
|
2034
|
+
info(` Created prompt: ${key}`);
|
|
2035
|
+
// Add new entity to sync state
|
|
2036
|
+
if (syncState && created?.promptId && created?.modifiedAt) {
|
|
2037
|
+
if (!syncState.entities.prompts) {
|
|
2038
|
+
syncState.entities.prompts = {};
|
|
2039
|
+
}
|
|
2040
|
+
syncState.entities.prompts[key] = {
|
|
2041
|
+
id: created.promptId,
|
|
2042
|
+
modifiedAt: created.modifiedAt,
|
|
2043
|
+
contentHash: computeFileHash(filePath),
|
|
2044
|
+
};
|
|
2045
|
+
}
|
|
2046
|
+
// Track prompt key→ID and config name→ID
|
|
2047
|
+
if (created?.promptId) {
|
|
2048
|
+
promptKeyToId.set(key, created.promptId);
|
|
2049
|
+
}
|
|
2050
|
+
if (created?.configs) {
|
|
2051
|
+
for (const config of created.configs) {
|
|
2052
|
+
promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
// Create additional configs (configs[1..n]) that weren't included in the initial create
|
|
2056
|
+
if (created?.promptId && configs.length > 1) {
|
|
2057
|
+
for (let i = 1; i < configs.length; i++) {
|
|
2058
|
+
const extraConfig = configs[i];
|
|
2059
|
+
const extraCreated = await client.createPromptConfig(resolvedAppId, created.promptId, {
|
|
2060
|
+
configName: extraConfig.name || `config-${i + 1}`,
|
|
2061
|
+
description: extraConfig.description,
|
|
2062
|
+
provider: extraConfig.provider || firstConfig.provider || "openrouter",
|
|
2063
|
+
model: extraConfig.model || firstConfig.model || "google/gemini-2.0-flash-001",
|
|
2064
|
+
systemPrompt: extraConfig.systemPrompt,
|
|
2065
|
+
userPromptTemplate: extraConfig.userPromptTemplate,
|
|
2066
|
+
temperature: extraConfig.temperature,
|
|
2067
|
+
maxTokens: extraConfig.maxTokens,
|
|
2068
|
+
outputFormat: extraConfig.outputFormat,
|
|
2069
|
+
});
|
|
2070
|
+
if (extraCreated?.configId) {
|
|
2071
|
+
const configName = extraConfig.name || `config-${i + 1}`;
|
|
2072
|
+
promptConfigNameToId.set(`${key}#${configName}`, extraCreated.configId);
|
|
2073
|
+
}
|
|
2074
|
+
// Activate this config if it was the active one
|
|
2075
|
+
if (extraConfig.isActive && extraCreated?.configId) {
|
|
2076
|
+
await client.activatePromptConfig(resolvedAppId, created.promptId, extraCreated.configId);
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
info(` Created ${configs.length - 1} additional config(s) for prompt: ${key}`);
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
catch (err) {
|
|
2083
|
+
throw wrapEntityError(err, "create", "prompt", key);
|
|
821
2084
|
}
|
|
822
2085
|
}
|
|
823
2086
|
}
|
|
@@ -830,11 +2093,40 @@ Directory Structure:
|
|
|
830
2093
|
for (const file of files) {
|
|
831
2094
|
const filePath = join(workflowsDir, file);
|
|
832
2095
|
const tomlData = parseTomlFile(filePath);
|
|
2096
|
+
// Issue #685: reject misnested headers (e.g.
|
|
2097
|
+
// [steps.<id>.request]) before pushing. The runtime silently
|
|
2098
|
+
// ignores fields outside the allowlist, so this is the only
|
|
2099
|
+
// place to catch the footgun. Validation happens BEFORE the
|
|
2100
|
+
// skip-if-unchanged check so a previously-pushed-but-broken
|
|
2101
|
+
// file gets a clear diagnostic on every push attempt.
|
|
2102
|
+
const tomlErrors = validateWorkflowToml(tomlData);
|
|
2103
|
+
if (tomlErrors.length > 0) {
|
|
2104
|
+
error(formatWorkflowTomlErrors(filePath, tomlErrors));
|
|
2105
|
+
process.exit(1);
|
|
2106
|
+
}
|
|
833
2107
|
const workflow = tomlData.workflow || {};
|
|
834
2108
|
const key = workflow.key || basename(file, ".toml");
|
|
835
2109
|
const steps = tomlData.steps || [];
|
|
836
2110
|
const existingId = syncState?.entities?.workflows?.[key]?.id;
|
|
837
2111
|
const existingActiveConfigId = syncState?.entities?.workflows?.[key]?.activeConfigId;
|
|
2112
|
+
// Skip if file hasn't changed since last sync. Use the expanded
|
|
2113
|
+
// content hash (post fragment splice) so that edits to included
|
|
2114
|
+
// `workflow-fragments/*.toml` files invalidate the push-skip cache
|
|
2115
|
+
// for any workflow that references them. See `computeExpandedContentHash`.
|
|
2116
|
+
if (!options.force && existingId && !shouldPushExpandedFile(tomlData, syncState?.entities?.workflows?.[key]?.contentHash)) {
|
|
2117
|
+
skippedCount++;
|
|
2118
|
+
// Only fetch config name→ID mappings if test cases exist for this workflow
|
|
2119
|
+
const workflowTestsDir = getTestsDir(configDir, "workflow", key);
|
|
2120
|
+
if (!options.dryRun && existsSync(workflowTestsDir)) {
|
|
2121
|
+
const fullWorkflow = await client.getWorkflow(resolvedAppId, existingId);
|
|
2122
|
+
if (fullWorkflow?.configs) {
|
|
2123
|
+
for (const config of fullWorkflow.configs) {
|
|
2124
|
+
workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
continue;
|
|
2129
|
+
}
|
|
838
2130
|
if (existingId) {
|
|
839
2131
|
// Update existing workflow
|
|
840
2132
|
changes.push({ type: "workflow", action: "update", key });
|
|
@@ -848,13 +2140,17 @@ Directory Structure:
|
|
|
848
2140
|
name: workflow.name,
|
|
849
2141
|
description: workflow.description,
|
|
850
2142
|
status: workflow.status,
|
|
2143
|
+
accessRule: workflow.accessRule !== undefined ? (workflow.accessRule || null) : undefined,
|
|
851
2144
|
perUserMaxRunning: workflow.perUserMaxRunning,
|
|
852
2145
|
perUserMaxQueued: workflow.perUserMaxQueued,
|
|
853
2146
|
dequeueOrder: workflow.dequeueOrder,
|
|
854
|
-
inputSchema: workflow.inputSchema ?
|
|
855
|
-
outputSchema: workflow.outputSchema ?
|
|
2147
|
+
inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
|
|
2148
|
+
outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : null,
|
|
856
2149
|
}, expectedModifiedAt);
|
|
857
|
-
// Update active configuration steps (or draft for legacy)
|
|
2150
|
+
// Update active configuration steps (or draft for legacy).
|
|
2151
|
+
// Issue #687: name the slot we touched so the dev-loop
|
|
2152
|
+
// user can confirm before previewing.
|
|
2153
|
+
let updateSlotLabel = "active config";
|
|
858
2154
|
if (existingActiveConfigId) {
|
|
859
2155
|
await client.updateWorkflowConfig(resolvedAppId, existingId, existingActiveConfigId, {
|
|
860
2156
|
steps,
|
|
@@ -864,13 +2160,24 @@ Directory Structure:
|
|
|
864
2160
|
// Fallback to draft update for legacy workflows
|
|
865
2161
|
await client.updateWorkflowDraft(resolvedAppId, existingId, {
|
|
866
2162
|
steps,
|
|
867
|
-
inputSchema: workflow.inputSchema ?
|
|
2163
|
+
inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
|
|
868
2164
|
});
|
|
2165
|
+
updateSlotLabel = "draft (legacy)";
|
|
869
2166
|
}
|
|
870
|
-
info(` Updated workflow: ${key}`);
|
|
871
|
-
// Update sync state with new modifiedAt
|
|
2167
|
+
info(` Updated workflow: ${key} (${updateSlotLabel})`);
|
|
2168
|
+
// Update sync state with new modifiedAt. Store the *expanded*
|
|
2169
|
+
// content hash so future fragment-only edits are detected.
|
|
872
2170
|
if (syncState?.entities?.workflows?.[key] && updated?.workflow?.modifiedAt) {
|
|
873
2171
|
syncState.entities.workflows[key].modifiedAt = updated.workflow.modifiedAt;
|
|
2172
|
+
syncState.entities.workflows[key].contentHash = computeExpandedContentHash(tomlData);
|
|
2173
|
+
}
|
|
2174
|
+
// Fetch full workflow to get config name→ID mappings
|
|
2175
|
+
// (updateWorkflow response doesn't include configs)
|
|
2176
|
+
const fullWorkflow = await client.getWorkflow(resolvedAppId, existingId);
|
|
2177
|
+
if (fullWorkflow?.configs) {
|
|
2178
|
+
for (const config of fullWorkflow.configs) {
|
|
2179
|
+
workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
2180
|
+
}
|
|
874
2181
|
}
|
|
875
2182
|
}
|
|
876
2183
|
catch (err) {
|
|
@@ -883,7 +2190,7 @@ Directory Structure:
|
|
|
883
2190
|
});
|
|
884
2191
|
}
|
|
885
2192
|
else {
|
|
886
|
-
throw err;
|
|
2193
|
+
throw wrapEntityError(err, "update", "workflow", key);
|
|
887
2194
|
}
|
|
888
2195
|
}
|
|
889
2196
|
}
|
|
@@ -892,34 +2199,589 @@ Directory Structure:
|
|
|
892
2199
|
// Create new workflow (automatically creates default config)
|
|
893
2200
|
changes.push({ type: "workflow", action: "create", key });
|
|
894
2201
|
if (!options.dryRun) {
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
2202
|
+
try {
|
|
2203
|
+
const created = await client.createWorkflow(resolvedAppId, {
|
|
2204
|
+
workflowKey: key,
|
|
2205
|
+
name: workflow.name || key,
|
|
2206
|
+
description: workflow.description,
|
|
2207
|
+
steps,
|
|
2208
|
+
inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : undefined,
|
|
2209
|
+
outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : undefined,
|
|
2210
|
+
accessRule: workflow.accessRule || undefined,
|
|
2211
|
+
perUserMaxRunning: workflow.perUserMaxRunning,
|
|
2212
|
+
perUserMaxQueued: workflow.perUserMaxQueued,
|
|
2213
|
+
dequeueOrder: workflow.dequeueOrder,
|
|
2214
|
+
});
|
|
2215
|
+
info(` Created workflow: ${key}`);
|
|
2216
|
+
// Add new entity to sync state (including activeConfigId)
|
|
2217
|
+
if (syncState && created?.workflow?.workflowId && created?.workflow?.modifiedAt) {
|
|
2218
|
+
if (!syncState.entities.workflows) {
|
|
2219
|
+
syncState.entities.workflows = {};
|
|
2220
|
+
}
|
|
2221
|
+
syncState.entities.workflows[key] = {
|
|
2222
|
+
id: created.workflow.workflowId,
|
|
2223
|
+
modifiedAt: created.workflow.modifiedAt,
|
|
2224
|
+
activeConfigId: created.workflow.activeConfigId,
|
|
2225
|
+
contentHash: computeExpandedContentHash(tomlData),
|
|
2226
|
+
};
|
|
2227
|
+
}
|
|
2228
|
+
// Track config name→ID mappings
|
|
2229
|
+
if (created?.configs) {
|
|
2230
|
+
for (const config of created.configs) {
|
|
2231
|
+
workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
const workflowId = created?.workflow?.workflowId;
|
|
2235
|
+
const tomlConfigs = tomlData.configs || [];
|
|
2236
|
+
// Create additional workflow configs (configs[1..n]) beyond the default
|
|
2237
|
+
if (workflowId && tomlConfigs.length > 1) {
|
|
2238
|
+
for (let i = 1; i < tomlConfigs.length; i++) {
|
|
2239
|
+
const extraConfig = tomlConfigs[i];
|
|
2240
|
+
const extraCreated = await client.createWorkflowConfig(resolvedAppId, workflowId, {
|
|
2241
|
+
configName: extraConfig.name || `config-${i + 1}`,
|
|
2242
|
+
description: extraConfig.description,
|
|
2243
|
+
steps,
|
|
2244
|
+
});
|
|
2245
|
+
if (extraCreated?.configId) {
|
|
2246
|
+
const configName = extraConfig.name || `config-${i + 1}`;
|
|
2247
|
+
workflowConfigNameToId.set(`${key}#${configName}`, extraCreated.configId);
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
info(` Created ${tomlConfigs.length - 1} additional config(s) for workflow: ${key}`);
|
|
2251
|
+
}
|
|
2252
|
+
// Activate the correct config based on activeConfigName from TOML
|
|
2253
|
+
if (workflowId && workflow.activeConfigName) {
|
|
2254
|
+
const activeConfigId = workflowConfigNameToId.get(`${key}#${workflow.activeConfigName}`);
|
|
2255
|
+
if (activeConfigId && activeConfigId !== created?.workflow?.activeConfigId) {
|
|
2256
|
+
await client.activateWorkflowConfig(resolvedAppId, workflowId, activeConfigId);
|
|
2257
|
+
info(` Activated config "${workflow.activeConfigName}" for workflow: ${key}`);
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
catch (err) {
|
|
2262
|
+
throw wrapEntityError(err, "create", "workflow", key);
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
// Process database types
|
|
2269
|
+
const dbTypesDir = join(configDir, "database-types");
|
|
2270
|
+
if (existsSync(dbTypesDir)) {
|
|
2271
|
+
const files = readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml"));
|
|
2272
|
+
for (const file of files) {
|
|
2273
|
+
const filePath = join(dbTypesDir, file);
|
|
2274
|
+
const rawToml = readFileSync(filePath, "utf-8");
|
|
2275
|
+
const tomlData = parseTomlFile(filePath);
|
|
2276
|
+
const { typeConfig, operations } = parseDatabaseTypeToml(tomlData);
|
|
2277
|
+
const dbType = typeConfig.databaseType || basename(file, ".toml");
|
|
2278
|
+
// Resolve ruleSetName → ruleSetId if using key-based reference
|
|
2279
|
+
resolveRuleSetReference(typeConfig, ruleSetNameToId, `database type ${dbType}`);
|
|
2280
|
+
// $params validator (issue #752): every `$params.X` reference
|
|
2281
|
+
// inside `definition` must correspond to a declared param in
|
|
2282
|
+
// `[[operations.params]]`. Catches typos like `$params.proectId`
|
|
2283
|
+
// at push time with the TOML file:line of the offending op block.
|
|
2284
|
+
const validation = validateOperations({
|
|
2285
|
+
filePath,
|
|
2286
|
+
rawToml,
|
|
2287
|
+
operations,
|
|
2288
|
+
});
|
|
2289
|
+
for (const w of validation.warnings) {
|
|
2290
|
+
warn(` ${formatIssue(w)}`);
|
|
2291
|
+
}
|
|
2292
|
+
if (validation.errors.length > 0) {
|
|
2293
|
+
for (const e of validation.errors) {
|
|
2294
|
+
error(` ${formatIssue(e)}`);
|
|
2295
|
+
}
|
|
2296
|
+
error(`Aborting push: ${validation.errors.length} unresolved $params reference(s) in ${file}.`);
|
|
2297
|
+
process.exit(1);
|
|
2298
|
+
}
|
|
2299
|
+
const existingEntry = syncState?.entities?.databaseTypes?.[dbType];
|
|
2300
|
+
// Skip if file hasn't changed since last sync
|
|
2301
|
+
if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
|
|
2302
|
+
skippedCount++;
|
|
2303
|
+
continue;
|
|
2304
|
+
}
|
|
2305
|
+
if (existingEntry) {
|
|
2306
|
+
// Update existing type config — only if there are type-level fields to update.
|
|
2307
|
+
// Operations are handled separately below, so skipping the PATCH here when
|
|
2308
|
+
// updateData is empty avoids sending an empty body (which the server rejects
|
|
2309
|
+
// with HTTP 400).
|
|
2310
|
+
if (!options.dryRun) {
|
|
2311
|
+
const expectedModifiedAt = options.force
|
|
2312
|
+
? undefined
|
|
2313
|
+
: existingEntry.modifiedAt;
|
|
2314
|
+
try {
|
|
2315
|
+
const updateData = {};
|
|
2316
|
+
if ("ruleSetId" in typeConfig)
|
|
2317
|
+
updateData.ruleSetId = typeConfig.ruleSetId || null;
|
|
2318
|
+
if ("triggers" in typeConfig)
|
|
2319
|
+
updateData.triggers = typeConfig.triggers || null;
|
|
2320
|
+
if ("metadataAccess" in typeConfig)
|
|
2321
|
+
updateData.metadataAccess = typeConfig.metadataAccess || null;
|
|
2322
|
+
if ("defaultAccess" in typeConfig)
|
|
2323
|
+
updateData.defaultAccess = typeConfig.defaultAccess || null;
|
|
2324
|
+
if ("autoPopulatedFields" in typeConfig) {
|
|
2325
|
+
updateData.autoPopulatedFields =
|
|
2326
|
+
typeConfig.autoPopulatedFields || null;
|
|
2327
|
+
}
|
|
2328
|
+
// Issue #666: forward `schema` when the local TOML declares
|
|
2329
|
+
// one (a set/update), OR when the server had one at last
|
|
2330
|
+
// sync and the local file no longer does (a deletion —
|
|
2331
|
+
// `schema: null` clears it server-side; codex review gap on
|
|
2332
|
+
// PR #766). When the type never had a schema and still
|
|
2333
|
+
// doesn't, omit it so an operations-only edit doesn't
|
|
2334
|
+
// register as an empty type-level update (issue #369).
|
|
2335
|
+
const localHasSchema = typeof typeConfig.schema === "string" &&
|
|
2336
|
+
typeConfig.schema.trim().length > 0;
|
|
2337
|
+
if (localHasSchema) {
|
|
2338
|
+
updateData.schema = typeConfig.schema;
|
|
2339
|
+
}
|
|
2340
|
+
else if (existingEntry.hasSchema) {
|
|
2341
|
+
updateData.schema = null;
|
|
2342
|
+
}
|
|
2343
|
+
if (Object.keys(updateData).length > 0) {
|
|
2344
|
+
changes.push({ type: "database-type", action: "update", key: dbType });
|
|
2345
|
+
const updated = await client.updateDatabaseTypeConfig(resolvedAppId, dbType, updateData, expectedModifiedAt, {
|
|
2346
|
+
dryRun: !!options.dryRun,
|
|
2347
|
+
acceptWarnings: !!options.acceptWarnings,
|
|
2348
|
+
});
|
|
2349
|
+
info(` Updated database type: ${dbType}`);
|
|
2350
|
+
if (syncState?.entities?.databaseTypes?.[dbType] && updated?.modifiedAt) {
|
|
2351
|
+
syncState.entities.databaseTypes[dbType].modifiedAt = updated.modifiedAt;
|
|
2352
|
+
syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
|
|
2353
|
+
syncState.entities.databaseTypes[dbType].hasSchema =
|
|
2354
|
+
"schema" in updateData
|
|
2355
|
+
? updateData.schema !== null
|
|
2356
|
+
: syncState.entities.databaseTypes[dbType].hasSchema;
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
catch (err) {
|
|
2361
|
+
if (err instanceof ConflictError) {
|
|
2362
|
+
conflicts.push({
|
|
2363
|
+
type: "database-type",
|
|
2364
|
+
key: dbType,
|
|
2365
|
+
serverModifiedAt: err.serverModifiedAt,
|
|
2366
|
+
localModifiedAt: expectedModifiedAt || "unknown",
|
|
2367
|
+
});
|
|
2368
|
+
}
|
|
2369
|
+
else if (err instanceof SchemaBreaksOpsError) {
|
|
2370
|
+
schemaErrors.schemaBreaks.push({
|
|
2371
|
+
type: "database-type",
|
|
2372
|
+
key: dbType,
|
|
2373
|
+
operations: err.operations,
|
|
2374
|
+
message: err.message,
|
|
2375
|
+
});
|
|
2376
|
+
}
|
|
2377
|
+
else if (err instanceof SchemaHasUncheckableOpsError) {
|
|
2378
|
+
schemaErrors.uncheckableOps.push({
|
|
2379
|
+
type: "database-type",
|
|
2380
|
+
key: dbType,
|
|
2381
|
+
operations: err.operations,
|
|
2382
|
+
message: err.message,
|
|
2383
|
+
});
|
|
2384
|
+
}
|
|
2385
|
+
else if (err instanceof TomlParseError) {
|
|
2386
|
+
schemaErrors.tomlParse.push({
|
|
2387
|
+
type: "database-type",
|
|
2388
|
+
key: dbType,
|
|
2389
|
+
line: err.line,
|
|
2390
|
+
column: err.column,
|
|
2391
|
+
message: err.message,
|
|
2392
|
+
});
|
|
2393
|
+
}
|
|
2394
|
+
else if (err instanceof OpsExistError) {
|
|
2395
|
+
schemaErrors.opsExist.push({
|
|
2396
|
+
type: "database-type",
|
|
2397
|
+
key: dbType,
|
|
2398
|
+
opCount: err.opCount,
|
|
2399
|
+
message: err.message,
|
|
2400
|
+
});
|
|
2401
|
+
}
|
|
2402
|
+
else {
|
|
2403
|
+
throw wrapEntityError(err, "update", "database type", dbType);
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
else {
|
|
2408
|
+
// In dry-run mode, still report the change iff we would actually PATCH.
|
|
2409
|
+
const wouldUpdate = "ruleSetId" in typeConfig ||
|
|
2410
|
+
"triggers" in typeConfig ||
|
|
2411
|
+
"metadataAccess" in typeConfig ||
|
|
2412
|
+
"defaultAccess" in typeConfig ||
|
|
2413
|
+
"autoPopulatedFields" in typeConfig ||
|
|
2414
|
+
"schema" in typeConfig;
|
|
2415
|
+
if (wouldUpdate) {
|
|
2416
|
+
changes.push({ type: "database-type", action: "update", key: dbType });
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
else {
|
|
2421
|
+
// Create new type config
|
|
2422
|
+
changes.push({ type: "database-type", action: "create", key: dbType });
|
|
2423
|
+
if (!options.dryRun) {
|
|
2424
|
+
const createData = {
|
|
2425
|
+
databaseType: dbType,
|
|
2426
|
+
};
|
|
2427
|
+
if (typeConfig.ruleSetId)
|
|
2428
|
+
createData.ruleSetId = typeConfig.ruleSetId;
|
|
2429
|
+
if (typeConfig.triggers)
|
|
2430
|
+
createData.triggers = typeConfig.triggers;
|
|
2431
|
+
if (typeConfig.metadataAccess)
|
|
2432
|
+
createData.metadataAccess = typeConfig.metadataAccess;
|
|
2433
|
+
if (typeConfig.defaultAccess)
|
|
2434
|
+
createData.defaultAccess = typeConfig.defaultAccess;
|
|
2435
|
+
if (typeConfig.autoPopulatedFields)
|
|
2436
|
+
createData.autoPopulatedFields = typeConfig.autoPopulatedFields;
|
|
2437
|
+
// Issue #666 addendum A2: forward `schema` on POST so a
|
|
2438
|
+
// single sync push can land both the type + its schema in
|
|
2439
|
+
// one round-trip (instead of POST → PATCH). The op-edit gate
|
|
2440
|
+
// itself no longer requires a schema on a fresh type, but
|
|
2441
|
+
// landing the schema up front keeps subsequent op-pushes
|
|
2442
|
+
// validating against the intended shape.
|
|
2443
|
+
if (typeConfig.schema)
|
|
2444
|
+
createData.schema = typeConfig.schema;
|
|
2445
|
+
try {
|
|
2446
|
+
const created = await client.createDatabaseTypeConfig(resolvedAppId, createData);
|
|
2447
|
+
info(` Created database type: ${dbType}`);
|
|
2448
|
+
if (syncState) {
|
|
2449
|
+
if (!syncState.entities.databaseTypes) {
|
|
2450
|
+
syncState.entities.databaseTypes = {};
|
|
2451
|
+
}
|
|
2452
|
+
syncState.entities.databaseTypes[dbType] = {
|
|
2453
|
+
databaseType: dbType,
|
|
2454
|
+
modifiedAt: created?.modifiedAt || new Date().toISOString(),
|
|
2455
|
+
contentHash: computeFileHash(filePath),
|
|
2456
|
+
hasSchema: !!createData.schema,
|
|
2457
|
+
};
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
catch (err) {
|
|
2461
|
+
throw wrapEntityError(err, "create", "database type", dbType);
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
// Process operations for this type
|
|
2466
|
+
const existingOps = existingEntry?.operations || {};
|
|
2467
|
+
const tomlOpNames = new Set(operations.map((op) => op.name));
|
|
2468
|
+
for (const op of operations) {
|
|
2469
|
+
const existingOp = existingOps[op.name];
|
|
2470
|
+
if (existingOp) {
|
|
2471
|
+
// Update existing operation
|
|
2472
|
+
changes.push({ type: "operation", action: "update", key: `${dbType}/${op.name}` });
|
|
2473
|
+
if (!options.dryRun) {
|
|
2474
|
+
const expectedOpModifiedAt = options.force
|
|
2475
|
+
? undefined
|
|
2476
|
+
: existingOp.modifiedAt;
|
|
2477
|
+
try {
|
|
2478
|
+
const updated = await client.updateDatabaseTypeOperation(resolvedAppId, dbType, op.name, {
|
|
2479
|
+
// Include `type` so the server can detect/apply
|
|
2480
|
+
// type-in-place changes (issue #692). When the type
|
|
2481
|
+
// matches the stored value, this is a no-op.
|
|
2482
|
+
type: op.type,
|
|
2483
|
+
modelName: op.modelName,
|
|
2484
|
+
access: op.access,
|
|
2485
|
+
definition: op.definition,
|
|
2486
|
+
params: op.params,
|
|
2487
|
+
}, expectedOpModifiedAt);
|
|
2488
|
+
info(` Updated operation: ${dbType}/${op.name}`);
|
|
2489
|
+
if (syncState?.entities?.databaseTypes?.[dbType]?.operations?.[op.name] && updated?.modifiedAt) {
|
|
2490
|
+
syncState.entities.databaseTypes[dbType].operations[op.name].modifiedAt = updated.modifiedAt;
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
catch (err) {
|
|
2494
|
+
if (err instanceof ConflictError) {
|
|
2495
|
+
conflicts.push({
|
|
2496
|
+
type: "operation",
|
|
2497
|
+
key: `${dbType}/${op.name}`,
|
|
2498
|
+
serverModifiedAt: err.serverModifiedAt,
|
|
2499
|
+
localModifiedAt: expectedOpModifiedAt || "unknown",
|
|
2500
|
+
});
|
|
2501
|
+
}
|
|
2502
|
+
else if (err instanceof SchemaRequiredError) {
|
|
2503
|
+
schemaErrors.schemaRequired.push({
|
|
2504
|
+
type: "operation",
|
|
2505
|
+
key: `${dbType}/${op.name}`,
|
|
2506
|
+
message: err.message,
|
|
2507
|
+
});
|
|
2508
|
+
}
|
|
2509
|
+
else if (err instanceof OperationRefError) {
|
|
2510
|
+
schemaErrors.opRefs.push({
|
|
2511
|
+
type: "operation",
|
|
2512
|
+
key: `${dbType}/${op.name}`,
|
|
2513
|
+
refs: err.refs,
|
|
2514
|
+
message: err.message,
|
|
2515
|
+
});
|
|
2516
|
+
}
|
|
2517
|
+
else {
|
|
2518
|
+
throw wrapEntityError(err, "update", "operation", `${dbType}/${op.name}`);
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
else {
|
|
2524
|
+
// Create new operation
|
|
2525
|
+
changes.push({ type: "operation", action: "create", key: `${dbType}/${op.name}` });
|
|
2526
|
+
if (!options.dryRun) {
|
|
2527
|
+
try {
|
|
2528
|
+
const created = await client.createDatabaseTypeOperation(resolvedAppId, dbType, {
|
|
2529
|
+
name: op.name,
|
|
2530
|
+
type: op.type,
|
|
2531
|
+
modelName: op.modelName,
|
|
2532
|
+
access: op.access,
|
|
2533
|
+
definition: op.definition,
|
|
2534
|
+
params: op.params,
|
|
2535
|
+
});
|
|
2536
|
+
info(` Created operation: ${dbType}/${op.name}`);
|
|
2537
|
+
if (syncState?.entities?.databaseTypes?.[dbType]) {
|
|
2538
|
+
if (!syncState.entities.databaseTypes[dbType].operations) {
|
|
2539
|
+
syncState.entities.databaseTypes[dbType].operations = {};
|
|
2540
|
+
}
|
|
2541
|
+
syncState.entities.databaseTypes[dbType].operations[op.name] = {
|
|
2542
|
+
modifiedAt: created?.modifiedAt || new Date().toISOString(),
|
|
2543
|
+
};
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
catch (err) {
|
|
2547
|
+
if (err instanceof SchemaRequiredError) {
|
|
2548
|
+
schemaErrors.schemaRequired.push({
|
|
2549
|
+
type: "operation",
|
|
2550
|
+
key: `${dbType}/${op.name}`,
|
|
2551
|
+
message: err.message,
|
|
2552
|
+
});
|
|
2553
|
+
}
|
|
2554
|
+
else if (err instanceof OperationRefError) {
|
|
2555
|
+
schemaErrors.opRefs.push({
|
|
2556
|
+
type: "operation",
|
|
2557
|
+
key: `${dbType}/${op.name}`,
|
|
2558
|
+
refs: err.refs,
|
|
2559
|
+
message: err.message,
|
|
2560
|
+
});
|
|
2561
|
+
}
|
|
2562
|
+
else {
|
|
2563
|
+
throw wrapEntityError(err, "create", "operation", `${dbType}/${op.name}`);
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
// Delete operations that were removed from TOML
|
|
2570
|
+
for (const existingOpName of Object.keys(existingOps)) {
|
|
2571
|
+
if (!tomlOpNames.has(existingOpName)) {
|
|
2572
|
+
changes.push({ type: "operation", action: "delete", key: `${dbType}/${existingOpName}` });
|
|
2573
|
+
if (!options.dryRun) {
|
|
2574
|
+
try {
|
|
2575
|
+
await client.deleteDatabaseTypeOperation(resolvedAppId, dbType, existingOpName);
|
|
2576
|
+
info(` Deleted operation: ${dbType}/${existingOpName}`);
|
|
2577
|
+
if (syncState?.entities?.databaseTypes?.[dbType]?.operations) {
|
|
2578
|
+
delete syncState.entities.databaseTypes[dbType].operations[existingOpName];
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
catch (err) {
|
|
2582
|
+
throw wrapEntityError(err, "delete", "operation", `${dbType}/${existingOpName}`);
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
// Process group type configs
|
|
2590
|
+
const groupTypeConfigsDir = join(configDir, "group-type-configs");
|
|
2591
|
+
if (existsSync(groupTypeConfigsDir)) {
|
|
2592
|
+
const files = readdirSync(groupTypeConfigsDir).filter((f) => f.endsWith(".toml"));
|
|
2593
|
+
for (const file of files) {
|
|
2594
|
+
const filePath = join(groupTypeConfigsDir, file);
|
|
2595
|
+
const tomlData = parseTomlFile(filePath);
|
|
2596
|
+
const configData = parseGroupTypeConfigToml(tomlData);
|
|
2597
|
+
const groupType = configData.groupType || basename(file, ".toml");
|
|
2598
|
+
// Resolve ruleSetName → ruleSetId if using key-based reference
|
|
2599
|
+
resolveRuleSetReference(configData, ruleSetNameToId, `group type ${groupType}`);
|
|
2600
|
+
const existingEntry = syncState?.entities?.groupTypeConfigs?.[groupType];
|
|
2601
|
+
// Skip if file hasn't changed since last sync
|
|
2602
|
+
if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
|
|
2603
|
+
skippedCount++;
|
|
2604
|
+
continue;
|
|
2605
|
+
}
|
|
2606
|
+
if (existingEntry) {
|
|
2607
|
+
// Update existing group type config
|
|
2608
|
+
changes.push({ type: "group-type-config", action: "update", key: groupType });
|
|
2609
|
+
if (!options.dryRun) {
|
|
2610
|
+
const expectedModifiedAt = options.force
|
|
2611
|
+
? undefined
|
|
2612
|
+
: existingEntry.modifiedAt;
|
|
2613
|
+
try {
|
|
2614
|
+
const updated = await client.updateGroupTypeConfig(resolvedAppId, groupType, {
|
|
2615
|
+
ruleSetId: configData.ruleSetId,
|
|
2616
|
+
autoAddCreator: configData.autoAddCreator,
|
|
2617
|
+
}, expectedModifiedAt);
|
|
2618
|
+
info(` Updated group type config: ${groupType}`);
|
|
2619
|
+
if (syncState?.entities?.groupTypeConfigs?.[groupType] && updated?.modifiedAt) {
|
|
2620
|
+
syncState.entities.groupTypeConfigs[groupType].modifiedAt = updated.modifiedAt;
|
|
2621
|
+
syncState.entities.groupTypeConfigs[groupType].contentHash = computeFileHash(filePath);
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
catch (err) {
|
|
2625
|
+
if (err instanceof ConflictError) {
|
|
2626
|
+
conflicts.push({
|
|
2627
|
+
type: "group-type-config",
|
|
2628
|
+
key: groupType,
|
|
2629
|
+
serverModifiedAt: err.serverModifiedAt,
|
|
2630
|
+
localModifiedAt: expectedModifiedAt || "unknown",
|
|
2631
|
+
});
|
|
2632
|
+
}
|
|
2633
|
+
else {
|
|
2634
|
+
throw wrapEntityError(err, "update", "group type config", groupType);
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
else {
|
|
2640
|
+
// Create new group type config
|
|
2641
|
+
changes.push({ type: "group-type-config", action: "create", key: groupType });
|
|
2642
|
+
if (!options.dryRun) {
|
|
2643
|
+
try {
|
|
2644
|
+
const created = await client.createGroupTypeConfig(resolvedAppId, {
|
|
2645
|
+
groupType,
|
|
2646
|
+
ruleSetId: configData.ruleSetId || undefined,
|
|
2647
|
+
autoAddCreator: configData.autoAddCreator,
|
|
2648
|
+
});
|
|
2649
|
+
info(` Created group type config: ${groupType}`);
|
|
2650
|
+
if (syncState) {
|
|
2651
|
+
if (!syncState.entities.groupTypeConfigs) {
|
|
2652
|
+
syncState.entities.groupTypeConfigs = {};
|
|
2653
|
+
}
|
|
2654
|
+
syncState.entities.groupTypeConfigs[groupType] = {
|
|
2655
|
+
modifiedAt: created?.modifiedAt || new Date().toISOString(),
|
|
2656
|
+
contentHash: computeFileHash(filePath),
|
|
2657
|
+
};
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
catch (err) {
|
|
2661
|
+
throw wrapEntityError(err, "create", "group type config", groupType);
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
// Process collection type configs
|
|
2668
|
+
const collectionTypeConfigsDir = join(configDir, "collection-type-configs");
|
|
2669
|
+
if (existsSync(collectionTypeConfigsDir)) {
|
|
2670
|
+
const files = readdirSync(collectionTypeConfigsDir).filter((f) => f.endsWith(".toml"));
|
|
2671
|
+
for (const file of files) {
|
|
2672
|
+
const filePath = join(collectionTypeConfigsDir, file);
|
|
2673
|
+
const tomlData = parseTomlFile(filePath);
|
|
2674
|
+
const configData = parseCollectionTypeConfigToml(tomlData);
|
|
2675
|
+
const collectionType = configData.collectionType || basename(file, ".toml");
|
|
2676
|
+
// Resolve ruleSetName → ruleSetId if using key-based reference
|
|
2677
|
+
resolveRuleSetReference(configData, ruleSetNameToId, `collection type ${collectionType}`);
|
|
2678
|
+
const existingEntry = syncState?.entities?.collectionTypeConfigs?.[collectionType];
|
|
2679
|
+
// Skip if file hasn't changed since last sync
|
|
2680
|
+
if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
|
|
2681
|
+
skippedCount++;
|
|
2682
|
+
continue;
|
|
2683
|
+
}
|
|
2684
|
+
if (existingEntry) {
|
|
2685
|
+
// Update existing collection type config
|
|
2686
|
+
changes.push({ type: "collection-type-config", action: "update", key: collectionType });
|
|
2687
|
+
if (!options.dryRun) {
|
|
2688
|
+
const expectedModifiedAt = options.force
|
|
2689
|
+
? undefined
|
|
2690
|
+
: existingEntry.modifiedAt;
|
|
2691
|
+
try {
|
|
2692
|
+
const updated = await client.updateCollectionTypeConfig(resolvedAppId, collectionType, {
|
|
2693
|
+
ruleSetId: configData.ruleSetId ?? null,
|
|
2694
|
+
}, expectedModifiedAt);
|
|
2695
|
+
info(` Updated collection type config: ${collectionType}`);
|
|
2696
|
+
if (syncState?.entities?.collectionTypeConfigs?.[collectionType] && updated?.modifiedAt) {
|
|
2697
|
+
syncState.entities.collectionTypeConfigs[collectionType].modifiedAt = updated.modifiedAt;
|
|
2698
|
+
syncState.entities.collectionTypeConfigs[collectionType].contentHash = computeFileHash(filePath);
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
catch (err) {
|
|
2702
|
+
if (err instanceof ConflictError) {
|
|
2703
|
+
conflicts.push({
|
|
2704
|
+
type: "collection-type-config",
|
|
2705
|
+
key: collectionType,
|
|
2706
|
+
serverModifiedAt: err.serverModifiedAt,
|
|
2707
|
+
localModifiedAt: expectedModifiedAt || "unknown",
|
|
2708
|
+
});
|
|
2709
|
+
}
|
|
2710
|
+
else {
|
|
2711
|
+
throw wrapEntityError(err, "update", "collection type config", collectionType);
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
else {
|
|
2717
|
+
// Create new collection type config
|
|
2718
|
+
changes.push({ type: "collection-type-config", action: "create", key: collectionType });
|
|
2719
|
+
if (!options.dryRun) {
|
|
2720
|
+
try {
|
|
2721
|
+
const created = await client.createCollectionTypeConfig(resolvedAppId, {
|
|
2722
|
+
collectionType,
|
|
2723
|
+
ruleSetId: configData.ruleSetId || undefined,
|
|
2724
|
+
});
|
|
2725
|
+
info(` Created collection type config: ${collectionType}`);
|
|
2726
|
+
if (syncState) {
|
|
2727
|
+
if (!syncState.entities.collectionTypeConfigs) {
|
|
2728
|
+
syncState.entities.collectionTypeConfigs = {};
|
|
2729
|
+
}
|
|
2730
|
+
syncState.entities.collectionTypeConfigs[collectionType] = {
|
|
2731
|
+
modifiedAt: created?.modifiedAt || new Date().toISOString(),
|
|
2732
|
+
contentHash: computeFileHash(filePath),
|
|
2733
|
+
};
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
catch (err) {
|
|
2737
|
+
throw wrapEntityError(err, "create", "collection type config", collectionType);
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
// Process email templates
|
|
2744
|
+
const emailTemplatesDir = join(configDir, "email-templates");
|
|
2745
|
+
if (existsSync(emailTemplatesDir)) {
|
|
2746
|
+
const files = readdirSync(emailTemplatesDir).filter((f) => f.endsWith(".toml"));
|
|
2747
|
+
for (const file of files) {
|
|
2748
|
+
const filePath = join(emailTemplatesDir, file);
|
|
2749
|
+
const tomlData = parseTomlFile(filePath);
|
|
2750
|
+
const template = tomlData.template || {};
|
|
2751
|
+
const emailType = template.emailType || basename(file, ".toml");
|
|
2752
|
+
const payload = {
|
|
2753
|
+
subject: template.subject || "",
|
|
2754
|
+
htmlBody: template.htmlBody || "",
|
|
2755
|
+
textBody: template.textBody || "",
|
|
2756
|
+
};
|
|
2757
|
+
changes.push({ type: "email-template", action: "update", key: emailType });
|
|
2758
|
+
if (!options.dryRun) {
|
|
2759
|
+
try {
|
|
2760
|
+
const result = await client.upsertEmailTemplate(resolvedAppId, emailType, payload);
|
|
2761
|
+
info(` Updated email template: ${emailType}`);
|
|
2762
|
+
// Update sync state
|
|
2763
|
+
if (syncState) {
|
|
2764
|
+
if (!syncState.entities.emailTemplates) {
|
|
2765
|
+
syncState.entities.emailTemplates = {};
|
|
2766
|
+
}
|
|
2767
|
+
syncState.entities.emailTemplates[emailType] = {
|
|
2768
|
+
id: emailType,
|
|
2769
|
+
modifiedAt: result?.modifiedAt || new Date().toISOString(),
|
|
916
2770
|
};
|
|
917
2771
|
}
|
|
918
2772
|
}
|
|
2773
|
+
catch (err) {
|
|
2774
|
+
warn(` Failed to update email template ${emailType}: ${err.message}`);
|
|
2775
|
+
}
|
|
919
2776
|
}
|
|
920
2777
|
}
|
|
921
2778
|
}
|
|
922
2779
|
// Push test cases for prompts and workflows
|
|
2780
|
+
const pushMaps = {
|
|
2781
|
+
promptKeyToId,
|
|
2782
|
+
promptConfigNameToId,
|
|
2783
|
+
workflowConfigNameToId,
|
|
2784
|
+
};
|
|
923
2785
|
const promptsDir2 = join(configDir, "prompts");
|
|
924
2786
|
if (existsSync(promptsDir2)) {
|
|
925
2787
|
for (const file of readdirSync(promptsDir2).filter((f) => f.endsWith(".toml"))) {
|
|
@@ -927,7 +2789,7 @@ Directory Structure:
|
|
|
927
2789
|
const promptKey = tomlData.prompt?.key || basename(file, ".toml");
|
|
928
2790
|
const blockId = syncState?.entities?.prompts?.[promptKey]?.id;
|
|
929
2791
|
if (blockId) {
|
|
930
|
-
await pushTestCasesForBlock(client, resolvedAppId, "prompt", blockId, promptKey, configDir, syncState, options.dryRun, changes);
|
|
2792
|
+
skippedCount += await pushTestCasesForBlock(client, resolvedAppId, "prompt", blockId, promptKey, configDir, syncState, options.dryRun, changes, pushMaps, { force: options.force });
|
|
931
2793
|
}
|
|
932
2794
|
}
|
|
933
2795
|
}
|
|
@@ -938,7 +2800,7 @@ Directory Structure:
|
|
|
938
2800
|
const workflowKey = tomlData.workflow?.key || basename(file, ".toml");
|
|
939
2801
|
const blockId = syncState?.entities?.workflows?.[workflowKey]?.id;
|
|
940
2802
|
if (blockId) {
|
|
941
|
-
await pushTestCasesForBlock(client, resolvedAppId, "workflow", blockId, workflowKey, configDir, syncState, options.dryRun, changes);
|
|
2803
|
+
skippedCount += await pushTestCasesForBlock(client, resolvedAppId, "workflow", blockId, workflowKey, configDir, syncState, options.dryRun, changes, pushMaps, { force: options.force });
|
|
942
2804
|
}
|
|
943
2805
|
}
|
|
944
2806
|
}
|
|
@@ -953,33 +2815,118 @@ Directory Structure:
|
|
|
953
2815
|
console.log(` ${color(change.action)} ${change.type}: ${change.key}`);
|
|
954
2816
|
}
|
|
955
2817
|
}
|
|
956
|
-
else if (conflicts.length > 0
|
|
2818
|
+
else if (conflicts.length > 0 ||
|
|
2819
|
+
schemaErrors.schemaRequired.length > 0 ||
|
|
2820
|
+
schemaErrors.opRefs.length > 0 ||
|
|
2821
|
+
schemaErrors.schemaBreaks.length > 0 ||
|
|
2822
|
+
schemaErrors.uncheckableOps.length > 0 ||
|
|
2823
|
+
schemaErrors.tomlParse.length > 0 ||
|
|
2824
|
+
schemaErrors.opsExist.length > 0) {
|
|
957
2825
|
// Handle conflicts
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
2826
|
+
if (conflicts.length > 0) {
|
|
2827
|
+
console.log();
|
|
2828
|
+
warn(`${conflicts.length} conflict(s) detected:`);
|
|
2829
|
+
console.log();
|
|
2830
|
+
for (const conflict of conflicts) {
|
|
2831
|
+
console.log(` ${chalk.red("CONFLICT")} ${conflict.type}: ${chalk.bold(conflict.key)}`);
|
|
2832
|
+
console.log(` Local last sync: ${chalk.dim(conflict.localModifiedAt)}`);
|
|
2833
|
+
console.log(` Server modified: ${chalk.yellow(conflict.serverModifiedAt)}`);
|
|
2834
|
+
}
|
|
2835
|
+
console.log();
|
|
2836
|
+
warn("These entities were modified on the server since your last pull.");
|
|
2837
|
+
info("Options:");
|
|
2838
|
+
console.log(` 1. Run ${chalk.cyan("primitive sync pull")} to get latest changes`);
|
|
2839
|
+
console.log(` 2. Run ${chalk.cyan("primitive sync push --force")} to overwrite server`);
|
|
2840
|
+
console.log();
|
|
2841
|
+
}
|
|
2842
|
+
// Report schema-feature errors (issue #666).
|
|
2843
|
+
if (schemaErrors.schemaRequired.length > 0) {
|
|
2844
|
+
console.log();
|
|
2845
|
+
warn(`${schemaErrors.schemaRequired.length} operation push(es) blocked: type has no schema set.`);
|
|
2846
|
+
for (const e of schemaErrors.schemaRequired) {
|
|
2847
|
+
const type = e.key.split("/")[0];
|
|
2848
|
+
console.log(` ${chalk.red("SCHEMA_REQUIRED")} ${e.key}`);
|
|
2849
|
+
console.log(` Run ${chalk.cyan(`primitive databases schema generate ${type}`)} to scaffold one, then retry.`);
|
|
2850
|
+
}
|
|
2851
|
+
console.log();
|
|
2852
|
+
}
|
|
2853
|
+
if (schemaErrors.opRefs.length > 0) {
|
|
2854
|
+
console.log();
|
|
2855
|
+
warn(`${schemaErrors.opRefs.length} operation push(es) blocked: unresolved references.`);
|
|
2856
|
+
for (const e of schemaErrors.opRefs) {
|
|
2857
|
+
console.log(` ${chalk.red("OPERATION_REFERENCES_UNDEFINED")} ${e.key}`);
|
|
2858
|
+
for (const ref of e.refs) {
|
|
2859
|
+
console.log(` - ${chalk.yellow(ref.ref)} at ${chalk.dim(ref.location)}`);
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
console.log();
|
|
2863
|
+
}
|
|
2864
|
+
if (schemaErrors.schemaBreaks.length > 0) {
|
|
2865
|
+
console.log();
|
|
2866
|
+
warn(`${schemaErrors.schemaBreaks.length} schema push(es) blocked: existing operations would break.`);
|
|
2867
|
+
for (const e of schemaErrors.schemaBreaks) {
|
|
2868
|
+
console.log(` ${chalk.red("SCHEMA_BREAKS_OPERATIONS")} ${e.key}`);
|
|
2869
|
+
for (const op of e.operations) {
|
|
2870
|
+
console.log(` ${chalk.bold(op.operation)}`);
|
|
2871
|
+
for (const ref of op.refs) {
|
|
2872
|
+
console.log(` - ${chalk.yellow(ref.ref)} at ${chalk.dim(ref.location)}`);
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
console.log();
|
|
2877
|
+
}
|
|
2878
|
+
if (schemaErrors.uncheckableOps.length > 0) {
|
|
2879
|
+
console.log();
|
|
2880
|
+
warn(`${schemaErrors.uncheckableOps.length} schema push(es) blocked: operations with dynamic refs.`);
|
|
2881
|
+
for (const e of schemaErrors.uncheckableOps) {
|
|
2882
|
+
console.log(` ${chalk.red("SCHEMA_HAS_UNCHECKABLE_OPS")} ${e.key}`);
|
|
2883
|
+
for (const op of e.operations) {
|
|
2884
|
+
console.log(` ${chalk.bold(op.operation)}`);
|
|
2885
|
+
for (const loc of op.locations) {
|
|
2886
|
+
console.log(` - ${chalk.dim(loc)}`);
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
info(`Re-run with ${chalk.cyan("--accept-warnings")} to commit anyway.`);
|
|
2891
|
+
console.log();
|
|
2892
|
+
}
|
|
2893
|
+
if (schemaErrors.tomlParse.length > 0) {
|
|
2894
|
+
console.log();
|
|
2895
|
+
warn(`${schemaErrors.tomlParse.length} TOML parse error(s):`);
|
|
2896
|
+
for (const e of schemaErrors.tomlParse) {
|
|
2897
|
+
const loc = e.line ? ` (line ${e.line}${e.column ? `, col ${e.column}` : ""})` : "";
|
|
2898
|
+
console.log(` ${chalk.red("TOML_PARSE_ERROR")} ${e.key}${loc}`);
|
|
2899
|
+
console.log(` ${e.message}`);
|
|
2900
|
+
}
|
|
2901
|
+
console.log();
|
|
2902
|
+
}
|
|
2903
|
+
if (schemaErrors.opsExist.length > 0) {
|
|
2904
|
+
console.log();
|
|
2905
|
+
warn(`${schemaErrors.opsExist.length} schema delete(s) blocked: operations still registered.`);
|
|
2906
|
+
for (const e of schemaErrors.opsExist) {
|
|
2907
|
+
console.log(` ${chalk.red("OPS_EXIST")} ${e.key} — ${e.opCount} op(s)`);
|
|
2908
|
+
console.log(` Delete or migrate the affected operations before clearing the schema.`);
|
|
2909
|
+
}
|
|
2910
|
+
console.log();
|
|
965
2911
|
}
|
|
966
|
-
console.log();
|
|
967
|
-
warn("These entities were modified on the server since your last pull.");
|
|
968
|
-
info("Options:");
|
|
969
|
-
console.log(` 1. Run ${chalk.cyan("primitive sync pull")} to get latest changes`);
|
|
970
|
-
console.log(` 2. Run ${chalk.cyan("primitive sync push --force")} to overwrite server`);
|
|
971
|
-
console.log();
|
|
972
2912
|
// Update sync state for non-conflicting changes
|
|
973
2913
|
if (syncState) {
|
|
974
2914
|
syncState.lastSyncedAt = new Date().toISOString();
|
|
975
2915
|
saveSyncState(configDir, syncState);
|
|
976
2916
|
}
|
|
977
|
-
const
|
|
2917
|
+
const totalBlocked = conflicts.length +
|
|
2918
|
+
schemaErrors.schemaRequired.length +
|
|
2919
|
+
schemaErrors.opRefs.length +
|
|
2920
|
+
schemaErrors.schemaBreaks.length +
|
|
2921
|
+
schemaErrors.uncheckableOps.length +
|
|
2922
|
+
schemaErrors.tomlParse.length +
|
|
2923
|
+
schemaErrors.opsExist.length;
|
|
2924
|
+
const successCount = changes.length - totalBlocked;
|
|
978
2925
|
if (successCount > 0) {
|
|
979
|
-
success(`Pushed ${successCount} change(s). ${
|
|
2926
|
+
success(`Pushed ${successCount} change(s). ${totalBlocked} blocked.`);
|
|
980
2927
|
}
|
|
981
2928
|
else {
|
|
982
|
-
error(`Push failed: ${
|
|
2929
|
+
error(`Push failed: ${totalBlocked} blocked. No changes applied.`);
|
|
983
2930
|
}
|
|
984
2931
|
process.exit(1);
|
|
985
2932
|
}
|
|
@@ -989,11 +2936,30 @@ Directory Structure:
|
|
|
989
2936
|
syncState.lastSyncedAt = new Date().toISOString();
|
|
990
2937
|
saveSyncState(configDir, syncState);
|
|
991
2938
|
}
|
|
992
|
-
|
|
2939
|
+
const pushMsg = skippedCount > 0
|
|
2940
|
+
? `Pushed ${changes.length} change(s), skipped ${skippedCount} unchanged file(s).`
|
|
2941
|
+
: `Pushed ${changes.length} changes.`;
|
|
2942
|
+
success(pushMsg);
|
|
993
2943
|
}
|
|
994
2944
|
}
|
|
995
2945
|
catch (err) {
|
|
2946
|
+
// Save sync state even on failure so partial progress is preserved
|
|
2947
|
+
if (syncState) {
|
|
2948
|
+
try {
|
|
2949
|
+
syncState.lastSyncedAt = new Date().toISOString();
|
|
2950
|
+
saveSyncState(configDir, syncState);
|
|
2951
|
+
}
|
|
2952
|
+
catch {
|
|
2953
|
+
// Don't mask the original error
|
|
2954
|
+
}
|
|
2955
|
+
}
|
|
996
2956
|
error(err.message);
|
|
2957
|
+
// Print structured server-side validation details (issue #684).
|
|
2958
|
+
if (err instanceof ApiError && Array.isArray(err.details) && err.details.length > 0) {
|
|
2959
|
+
for (const detail of err.details) {
|
|
2960
|
+
console.error(` - ${typeof detail === "string" ? detail : JSON.stringify(detail)}`);
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
997
2963
|
process.exit(1);
|
|
998
2964
|
}
|
|
999
2965
|
});
|
|
@@ -1003,10 +2969,16 @@ Directory Structure:
|
|
|
1003
2969
|
.description("Show differences between local and remote configuration")
|
|
1004
2970
|
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
1005
2971
|
.option("--app <app-id>", "App ID")
|
|
1006
|
-
.option("--dir <path>", "Config directory
|
|
2972
|
+
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
1007
2973
|
.action(async (appId, options) => {
|
|
1008
2974
|
const resolvedAppId = resolveAppId(appId, options);
|
|
1009
|
-
const configDir = options.dir;
|
|
2975
|
+
const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
|
|
2976
|
+
if (isAutoResolvedSyncDir(options.dir)) {
|
|
2977
|
+
info(`Using per-environment sync directory: ${configDir}`);
|
|
2978
|
+
const legacyHint = checkLegacySyncMigration(configDir);
|
|
2979
|
+
if (legacyHint)
|
|
2980
|
+
warn(legacyHint);
|
|
2981
|
+
}
|
|
1010
2982
|
const client = new ApiClient();
|
|
1011
2983
|
if (!existsSync(configDir)) {
|
|
1012
2984
|
error(`Config directory not found: ${configDir}`);
|
|
@@ -1016,18 +2988,39 @@ Directory Structure:
|
|
|
1016
2988
|
info(`Comparing local configuration with app ${resolvedAppId}...`);
|
|
1017
2989
|
try {
|
|
1018
2990
|
// Fetch remote state
|
|
1019
|
-
const [
|
|
1020
|
-
client.listIntegrations(resolvedAppId,
|
|
1021
|
-
client.listPrompts(resolvedAppId,
|
|
1022
|
-
client.listWorkflows(resolvedAppId,
|
|
2991
|
+
const [integrationItems, promptItems, workflowItems, emailTemplatesResult] = await Promise.all([
|
|
2992
|
+
fetchAll((p) => client.listIntegrations(resolvedAppId, p)),
|
|
2993
|
+
fetchAll((p) => client.listPrompts(resolvedAppId, p)),
|
|
2994
|
+
fetchAll((p) => client.listWorkflows(resolvedAppId, p)),
|
|
2995
|
+
client.listEmailTemplates(resolvedAppId).catch(() => ({ templates: [] })),
|
|
1023
2996
|
]);
|
|
1024
|
-
const
|
|
1025
|
-
|
|
1026
|
-
|
|
2997
|
+
const webhookItems = await fetchAll((p) => client.listWebhooks(resolvedAppId, p));
|
|
2998
|
+
let cronTriggerItemsDiff = [];
|
|
2999
|
+
try {
|
|
3000
|
+
const cronResult = await client.listCronTriggers(resolvedAppId);
|
|
3001
|
+
cronTriggerItemsDiff = cronResult.items || [];
|
|
3002
|
+
}
|
|
3003
|
+
catch {
|
|
3004
|
+
// Cron triggers may not be available on older servers
|
|
3005
|
+
}
|
|
3006
|
+
const blobBucketsDiffResult = await client.listBlobBuckets(resolvedAppId).catch(() => ({ items: [] }));
|
|
3007
|
+
const remoteIntegrations = new Set(integrationItems.map((i) => i.integrationKey));
|
|
3008
|
+
const remoteWebhooks = new Set(webhookItems.map((w) => w.webhookKey));
|
|
3009
|
+
const remoteCronTriggers = new Set(cronTriggerItemsDiff.map((t) => t.triggerKey));
|
|
3010
|
+
const remoteBlobBuckets = new Set((blobBucketsDiffResult.items || []).map((b) => b.bucketKey));
|
|
3011
|
+
const remotePrompts = new Set(promptItems.map((p) => p.promptKey));
|
|
3012
|
+
const remoteWorkflows = new Set(workflowItems.map((w) => w.workflowKey));
|
|
3013
|
+
const remoteEmailTemplates = new Set((emailTemplatesResult.templates || [])
|
|
3014
|
+
.filter((t) => t.hasOverride)
|
|
3015
|
+
.map((t) => t.emailType));
|
|
1027
3016
|
// Get local files
|
|
1028
3017
|
const localIntegrations = new Set();
|
|
3018
|
+
const localWebhooks = new Set();
|
|
3019
|
+
const localCronTriggers = new Set();
|
|
3020
|
+
const localBlobBuckets = new Set();
|
|
1029
3021
|
const localPrompts = new Set();
|
|
1030
3022
|
const localWorkflows = new Set();
|
|
3023
|
+
const localEmailTemplates = new Set();
|
|
1031
3024
|
const integrationsDir = join(configDir, "integrations");
|
|
1032
3025
|
if (existsSync(integrationsDir)) {
|
|
1033
3026
|
for (const file of readdirSync(integrationsDir).filter((f) => f.endsWith(".toml"))) {
|
|
@@ -1036,6 +3029,30 @@ Directory Structure:
|
|
|
1036
3029
|
localIntegrations.add(key);
|
|
1037
3030
|
}
|
|
1038
3031
|
}
|
|
3032
|
+
const webhooksDirPath = join(configDir, "webhooks");
|
|
3033
|
+
if (existsSync(webhooksDirPath)) {
|
|
3034
|
+
for (const file of readdirSync(webhooksDirPath).filter((f) => f.endsWith(".toml"))) {
|
|
3035
|
+
const tomlData = parseTomlFile(join(webhooksDirPath, file));
|
|
3036
|
+
const key = tomlData.webhook?.key || basename(file, ".toml");
|
|
3037
|
+
localWebhooks.add(key);
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
const cronTriggersDirPath = join(configDir, "cron-triggers");
|
|
3041
|
+
if (existsSync(cronTriggersDirPath)) {
|
|
3042
|
+
for (const file of readdirSync(cronTriggersDirPath).filter((f) => f.endsWith(".toml"))) {
|
|
3043
|
+
const tomlData = parseTomlFile(join(cronTriggersDirPath, file));
|
|
3044
|
+
const key = tomlData.cronTrigger?.key || basename(file, ".toml");
|
|
3045
|
+
localCronTriggers.add(key);
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
const blobBucketsDiffDir = join(configDir, "blob-buckets");
|
|
3049
|
+
if (existsSync(blobBucketsDiffDir)) {
|
|
3050
|
+
for (const file of readdirSync(blobBucketsDiffDir).filter((f) => f.endsWith(".toml"))) {
|
|
3051
|
+
const tomlData = parseTomlFile(join(blobBucketsDiffDir, file));
|
|
3052
|
+
const key = tomlData.bucket?.key || basename(file, ".toml");
|
|
3053
|
+
localBlobBuckets.add(key);
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
1039
3056
|
const promptsDir = join(configDir, "prompts");
|
|
1040
3057
|
if (existsSync(promptsDir)) {
|
|
1041
3058
|
for (const file of readdirSync(promptsDir).filter((f) => f.endsWith(".toml"))) {
|
|
@@ -1052,6 +3069,14 @@ Directory Structure:
|
|
|
1052
3069
|
localWorkflows.add(key);
|
|
1053
3070
|
}
|
|
1054
3071
|
}
|
|
3072
|
+
const emailTemplatesDirPath = join(configDir, "email-templates");
|
|
3073
|
+
if (existsSync(emailTemplatesDirPath)) {
|
|
3074
|
+
for (const file of readdirSync(emailTemplatesDirPath).filter((f) => f.endsWith(".toml"))) {
|
|
3075
|
+
const tomlData = parseTomlFile(join(emailTemplatesDirPath, file));
|
|
3076
|
+
const emailType = tomlData.template?.emailType || basename(file, ".toml");
|
|
3077
|
+
localEmailTemplates.add(emailType);
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
1055
3080
|
// Compare
|
|
1056
3081
|
const differences = [];
|
|
1057
3082
|
// Integrations
|
|
@@ -1068,6 +3093,48 @@ Directory Structure:
|
|
|
1068
3093
|
differences.push({ type: "integration", key, status: "remote only" });
|
|
1069
3094
|
}
|
|
1070
3095
|
}
|
|
3096
|
+
// Webhooks
|
|
3097
|
+
for (const key of localWebhooks) {
|
|
3098
|
+
if (!remoteWebhooks.has(key)) {
|
|
3099
|
+
differences.push({ type: "webhook", key, status: "local only" });
|
|
3100
|
+
}
|
|
3101
|
+
else {
|
|
3102
|
+
differences.push({ type: "webhook", key, status: "exists" });
|
|
3103
|
+
}
|
|
3104
|
+
}
|
|
3105
|
+
for (const key of remoteWebhooks) {
|
|
3106
|
+
if (!localWebhooks.has(key)) {
|
|
3107
|
+
differences.push({ type: "webhook", key, status: "remote only" });
|
|
3108
|
+
}
|
|
3109
|
+
}
|
|
3110
|
+
// Cron Triggers
|
|
3111
|
+
for (const key of localCronTriggers) {
|
|
3112
|
+
if (!remoteCronTriggers.has(key)) {
|
|
3113
|
+
differences.push({ type: "cron-trigger", key, status: "local only" });
|
|
3114
|
+
}
|
|
3115
|
+
else {
|
|
3116
|
+
differences.push({ type: "cron-trigger", key, status: "exists" });
|
|
3117
|
+
}
|
|
3118
|
+
}
|
|
3119
|
+
for (const key of remoteCronTriggers) {
|
|
3120
|
+
if (!localCronTriggers.has(key)) {
|
|
3121
|
+
differences.push({ type: "cron-trigger", key, status: "remote only" });
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
// Blob Buckets
|
|
3125
|
+
for (const key of localBlobBuckets) {
|
|
3126
|
+
if (!remoteBlobBuckets.has(key)) {
|
|
3127
|
+
differences.push({ type: "blob-bucket", key, status: "local only" });
|
|
3128
|
+
}
|
|
3129
|
+
else {
|
|
3130
|
+
differences.push({ type: "blob-bucket", key, status: "exists" });
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
for (const key of remoteBlobBuckets) {
|
|
3134
|
+
if (!localBlobBuckets.has(key)) {
|
|
3135
|
+
differences.push({ type: "blob-bucket", key, status: "remote only" });
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
1071
3138
|
// Prompts
|
|
1072
3139
|
for (const key of localPrompts) {
|
|
1073
3140
|
if (!remotePrompts.has(key)) {
|
|
@@ -1096,6 +3163,20 @@ Directory Structure:
|
|
|
1096
3163
|
differences.push({ type: "workflow", key, status: "remote only" });
|
|
1097
3164
|
}
|
|
1098
3165
|
}
|
|
3166
|
+
// Email Templates
|
|
3167
|
+
for (const key of localEmailTemplates) {
|
|
3168
|
+
if (!remoteEmailTemplates.has(key)) {
|
|
3169
|
+
differences.push({ type: "email-template", key, status: "local only" });
|
|
3170
|
+
}
|
|
3171
|
+
else {
|
|
3172
|
+
differences.push({ type: "email-template", key, status: "exists" });
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
for (const key of remoteEmailTemplates) {
|
|
3176
|
+
if (!localEmailTemplates.has(key)) {
|
|
3177
|
+
differences.push({ type: "email-template", key, status: "remote only" });
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
1099
3180
|
// Compare test cases for synced prompts and workflows
|
|
1100
3181
|
const testCaseDiffs = [];
|
|
1101
3182
|
// Helper to compare test cases for a block
|
|
@@ -1133,12 +3214,12 @@ Directory Structure:
|
|
|
1133
3214
|
}
|
|
1134
3215
|
};
|
|
1135
3216
|
// Compare test cases for synced blocks
|
|
1136
|
-
for (const p of
|
|
3217
|
+
for (const p of promptItems) {
|
|
1137
3218
|
if (localPrompts.has(p.promptKey)) {
|
|
1138
3219
|
await compareTestCases("prompt", p.promptId, p.promptKey);
|
|
1139
3220
|
}
|
|
1140
3221
|
}
|
|
1141
|
-
for (const w of
|
|
3222
|
+
for (const w of workflowItems) {
|
|
1142
3223
|
if (localWorkflows.has(w.workflowKey)) {
|
|
1143
3224
|
await compareTestCases("workflow", w.workflowId, w.workflowKey);
|
|
1144
3225
|
}
|
|
@@ -1199,5 +3280,105 @@ Directory Structure:
|
|
|
1199
3280
|
process.exit(1);
|
|
1200
3281
|
}
|
|
1201
3282
|
});
|
|
3283
|
+
// Migrate-toml (issue #752): bulk-rewrite database-type TOML files from
|
|
3284
|
+
// the legacy JSON-string form for `definition`/`params` to the native
|
|
3285
|
+
// nested-table form. Idempotent: ops already in native form are left as
|
|
3286
|
+
// is; ops that are un-TOMLable (mixed-type arrays, null values) fall
|
|
3287
|
+
// back to JSON-string per field with a log message.
|
|
3288
|
+
sync
|
|
3289
|
+
.command("migrate-toml")
|
|
3290
|
+
.description("Rewrite database-type TOML files to native [operations.definition] / [[operations.params]] form")
|
|
3291
|
+
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
3292
|
+
.option("--app <app-id>", "App ID")
|
|
3293
|
+
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
3294
|
+
.option("--dry-run", "Show what would change without writing files")
|
|
3295
|
+
.action(async (appId, options) => {
|
|
3296
|
+
// Resolve appId so we land in the per-env sync dir even when the user
|
|
3297
|
+
// didn't pass `--dir`. The app ID itself isn't used for any API calls
|
|
3298
|
+
// — `migrate-toml` is a purely local rewrite.
|
|
3299
|
+
const resolvedAppId = resolveAppId(appId, options);
|
|
3300
|
+
const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
|
|
3301
|
+
if (isAutoResolvedSyncDir(options.dir)) {
|
|
3302
|
+
info(`Using per-environment sync directory: ${configDir}`);
|
|
3303
|
+
}
|
|
3304
|
+
if (!existsSync(configDir)) {
|
|
3305
|
+
error(`Config directory not found: ${configDir}`);
|
|
3306
|
+
process.exit(1);
|
|
3307
|
+
}
|
|
3308
|
+
const dbTypesDir = join(configDir, "database-types");
|
|
3309
|
+
if (!existsSync(dbTypesDir)) {
|
|
3310
|
+
info("No database-types/ directory found; nothing to migrate.");
|
|
3311
|
+
return;
|
|
3312
|
+
}
|
|
3313
|
+
const files = readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml"));
|
|
3314
|
+
if (files.length === 0) {
|
|
3315
|
+
info("No database-type TOML files found; nothing to migrate.");
|
|
3316
|
+
return;
|
|
3317
|
+
}
|
|
3318
|
+
// Hydrate ruleSetIdToName from local sync state so files that
|
|
3319
|
+
// reference a rule set via the legacy `ruleSetId = "01..."` form
|
|
3320
|
+
// round-trip with the correct `ruleSetName`. Without this, any user
|
|
3321
|
+
// who runs migrate-toml against a file with a rule-set assignment
|
|
3322
|
+
// would silently lose that reference (review feedback r3246633010).
|
|
3323
|
+
//
|
|
3324
|
+
// Files that use the modern key-based `ruleSetName = "..."` form
|
|
3325
|
+
// don't need the map at all — `parseDatabaseTypeToml` stores the
|
|
3326
|
+
// value in `typeConfig._ruleSetName` and the serializer now prefers
|
|
3327
|
+
// that. The map is only load-bearing for the legacy ID-based form.
|
|
3328
|
+
const ruleSetIdToName = new Map();
|
|
3329
|
+
const migrateSyncState = loadSyncState(configDir);
|
|
3330
|
+
if (migrateSyncState?.entities?.ruleSets) {
|
|
3331
|
+
for (const [fileKey, entry] of Object.entries(migrateSyncState.entities.ruleSets)) {
|
|
3332
|
+
if (entry && typeof entry === "object" && "id" in entry && entry.id) {
|
|
3333
|
+
// fileKey is the sanitized rule-set name (see sync pull at
|
|
3334
|
+
// sync.ts:1385). It's the same shape the server returned and
|
|
3335
|
+
// matches what a TOML file's ruleSetName field would carry.
|
|
3336
|
+
ruleSetIdToName.set(entry.id, fileKey);
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
3339
|
+
}
|
|
3340
|
+
let migratedCount = 0;
|
|
3341
|
+
let unchangedCount = 0;
|
|
3342
|
+
for (const file of files) {
|
|
3343
|
+
const filePath = join(dbTypesDir, file);
|
|
3344
|
+
const rawBefore = readFileSync(filePath, "utf-8");
|
|
3345
|
+
const tomlData = parseTomlFile(filePath);
|
|
3346
|
+
const { typeConfig, operations } = parseDatabaseTypeToml(tomlData);
|
|
3347
|
+
// Force-native: ignore existing hints so every TOMLable field
|
|
3348
|
+
// ends up in nested-table form.
|
|
3349
|
+
const rewritten = serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
|
|
3350
|
+
defaultForm: "native",
|
|
3351
|
+
logger: (msg) => info(` ${msg}`),
|
|
3352
|
+
});
|
|
3353
|
+
if (rewritten === rawBefore) {
|
|
3354
|
+
unchangedCount++;
|
|
3355
|
+
continue;
|
|
3356
|
+
}
|
|
3357
|
+
if (options.dryRun) {
|
|
3358
|
+
info(`Would migrate database-types/${file}`);
|
|
3359
|
+
}
|
|
3360
|
+
else {
|
|
3361
|
+
writeFileSync(filePath, rewritten);
|
|
3362
|
+
info(`Migrated database-types/${file}`);
|
|
3363
|
+
// Update content hash in sync state if we have an entry for it.
|
|
3364
|
+
const syncState = loadSyncState(configDir);
|
|
3365
|
+
const dbType = typeConfig.databaseType || basename(file, ".toml");
|
|
3366
|
+
if (syncState?.entities?.databaseTypes?.[dbType]) {
|
|
3367
|
+
syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
|
|
3368
|
+
saveSyncState(configDir, syncState);
|
|
3369
|
+
}
|
|
3370
|
+
}
|
|
3371
|
+
migratedCount++;
|
|
3372
|
+
}
|
|
3373
|
+
divider();
|
|
3374
|
+
keyValue("Migrated", migratedCount);
|
|
3375
|
+
keyValue("Already native (unchanged)", unchangedCount);
|
|
3376
|
+
if (options.dryRun) {
|
|
3377
|
+
info("Dry-run only — no files were modified.");
|
|
3378
|
+
}
|
|
3379
|
+
else if (migratedCount > 0) {
|
|
3380
|
+
success(`Rewrote ${migratedCount} TOML file(s) to native form.`);
|
|
3381
|
+
}
|
|
3382
|
+
});
|
|
1202
3383
|
}
|
|
1203
3384
|
//# sourceMappingURL=sync.js.map
|