primitive-admin 1.0.43 → 1.0.45
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 +43 -0
- package/assets/skill/skills/primitive-platform/SKILL.md +85 -26
- package/dist/bin/primitive.js +6 -0
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/analytics.js +16 -16
- package/dist/src/commands/analytics.js.map +1 -1
- package/dist/src/commands/apps.js +14 -14
- package/dist/src/commands/apps.js.map +1 -1
- package/dist/src/commands/auth.js +70 -20
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/blob-buckets.js +11 -11
- package/dist/src/commands/blob-buckets.js.map +1 -1
- 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 +5 -5
- package/dist/src/commands/collection-type-configs.js.map +1 -1
- package/dist/src/commands/collections.js +6 -6
- package/dist/src/commands/collections.js.map +1 -1
- package/dist/src/commands/comparisons.js +6 -6
- package/dist/src/commands/comparisons.js.map +1 -1
- package/dist/src/commands/cron-triggers.js +17 -17
- package/dist/src/commands/cron-triggers.js.map +1 -1
- package/dist/src/commands/database-types.js +13 -13
- package/dist/src/commands/database-types.js.map +1 -1
- package/dist/src/commands/databases.js +132 -8
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/email-templates.js +6 -6
- package/dist/src/commands/email-templates.js.map +1 -1
- package/dist/src/commands/env.js +6 -6
- package/dist/src/commands/env.js.map +1 -1
- package/dist/src/commands/group-type-configs.js +6 -6
- package/dist/src/commands/group-type-configs.js.map +1 -1
- package/dist/src/commands/groups.js +7 -7
- package/dist/src/commands/groups.js.map +1 -1
- package/dist/src/commands/init.js +175 -144
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/integrations.js +31 -21
- 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 +8 -8
- package/dist/src/commands/rule-sets.js.map +1 -1
- package/dist/src/commands/sync.js +803 -275
- 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 +44 -3
- package/dist/src/commands/users.js.map +1 -1
- package/dist/src/commands/webhooks.js +18 -18
- package/dist/src/commands/webhooks.js.map +1 -1
- package/dist/src/commands/workflows.js +273 -63
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.js +240 -72
- package/dist/src/lib/api-client.js.map +1 -1
- 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/refresh-admin-credentials.js +103 -0
- package/dist/src/lib/refresh-admin-credentials.js.map +1 -0
- package/dist/src/lib/template.js +80 -1
- 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/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 +1 -1
|
@@ -3,9 +3,13 @@ import { join, basename } from "path";
|
|
|
3
3
|
import { createHash } from "crypto";
|
|
4
4
|
import * as TOML from "@iarna/toml";
|
|
5
5
|
import { lookup as mimeLookup } from "mime-types";
|
|
6
|
-
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";
|
|
7
9
|
import { getServerUrl, resolveAppId } from "../lib/config.js";
|
|
8
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";
|
|
9
13
|
import { success, error, info, warn, keyValue, json, divider, } from "../lib/output.js";
|
|
10
14
|
import chalk from "chalk";
|
|
11
15
|
function ensureDir(dirPath) {
|
|
@@ -29,6 +33,31 @@ function saveSyncState(configDir, state) {
|
|
|
29
33
|
const syncFile = join(configDir, ".primitive-sync.json");
|
|
30
34
|
writeFileSync(syncFile, JSON.stringify(state, null, 2));
|
|
31
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
|
+
}
|
|
32
61
|
export function computeFileHash(filePath) {
|
|
33
62
|
const content = readFileSync(filePath);
|
|
34
63
|
return createHash("sha256").update(content).digest("hex");
|
|
@@ -38,6 +67,55 @@ export function shouldPushFile(filePath, storedHash) {
|
|
|
38
67
|
return true;
|
|
39
68
|
return computeFileHash(filePath) !== storedHash;
|
|
40
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
|
+
}
|
|
41
119
|
// TOML serialization helpers
|
|
42
120
|
function serializeAppSettings(settings) {
|
|
43
121
|
const data = {
|
|
@@ -241,32 +319,20 @@ function serializeWorkflow(workflow, draft, configs) {
|
|
|
241
319
|
return TOML.stringify(data);
|
|
242
320
|
}
|
|
243
321
|
// Database config serialization helpers
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
data.triggers = typeConfig.triggers;
|
|
259
|
-
}
|
|
260
|
-
if (operations.length > 0) {
|
|
261
|
-
data.operations = operations.map((op) => ({
|
|
262
|
-
name: op.name,
|
|
263
|
-
type: op.type,
|
|
264
|
-
modelName: op.modelName,
|
|
265
|
-
access: op.access,
|
|
266
|
-
definition: typeof op.definition === "object" ? JSON.stringify(op.definition) : op.definition,
|
|
267
|
-
...(op.params ? { params: typeof op.params === "object" ? JSON.stringify(op.params) : op.params } : {}),
|
|
268
|
-
}));
|
|
269
|
-
}
|
|
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);
|
|
270
336
|
return TOML.stringify(data);
|
|
271
337
|
}
|
|
272
338
|
function serializeEmailTemplate(template) {
|
|
@@ -345,17 +411,44 @@ export function parseDatabaseTypeToml(tomlData) {
|
|
|
345
411
|
else if (typeSection.metadataAccess) {
|
|
346
412
|
typeConfig.metadataAccess = typeSection.metadataAccess;
|
|
347
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
|
+
}
|
|
348
424
|
if (tomlData.triggers) {
|
|
349
425
|
typeConfig.triggers = tomlData.triggers;
|
|
350
426
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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));
|
|
359
452
|
return { typeConfig, operations };
|
|
360
453
|
}
|
|
361
454
|
function parseRuleSetToml(tomlData) {
|
|
@@ -397,9 +490,16 @@ export function parseCollectionTypeConfigToml(tomlData) {
|
|
|
397
490
|
return result;
|
|
398
491
|
}
|
|
399
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.
|
|
400
499
|
function parseTomlFile(filePath) {
|
|
401
500
|
const content = readFileSync(filePath, "utf-8");
|
|
402
|
-
|
|
501
|
+
const parsed = TOML.parse(content);
|
|
502
|
+
return expandWorkflowTomlData(parsed, filePath);
|
|
403
503
|
}
|
|
404
504
|
/**
|
|
405
505
|
* Paginate through a list endpoint, collecting all items.
|
|
@@ -1087,11 +1187,16 @@ Directory Structure:
|
|
|
1087
1187
|
const filename = `${workflow.workflowKey}.toml`;
|
|
1088
1188
|
const filePath = join(configDir, "workflows", filename);
|
|
1089
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.
|
|
1090
1195
|
workflowEntities[workflow.workflowKey] = {
|
|
1091
1196
|
id: workflow.workflowId,
|
|
1092
1197
|
modifiedAt: workflow.modifiedAt || new Date().toISOString(),
|
|
1093
1198
|
activeConfigId: workflow.activeConfigId,
|
|
1094
|
-
contentHash:
|
|
1199
|
+
contentHash: computeExpandedContentHash(parseTomlFile(filePath)),
|
|
1095
1200
|
};
|
|
1096
1201
|
info(` Wrote workflows/${filename}`);
|
|
1097
1202
|
}
|
|
@@ -1167,7 +1272,17 @@ Directory Structure:
|
|
|
1167
1272
|
for (const { typeConfig, operations } of databaseTypesWithOps) {
|
|
1168
1273
|
const filename = `${typeConfig.databaseType}.toml`;
|
|
1169
1274
|
const filePath = join(configDir, "database-types", filename);
|
|
1170
|
-
|
|
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
|
+
}));
|
|
1171
1286
|
const opsEntities = {};
|
|
1172
1287
|
for (const op of operations) {
|
|
1173
1288
|
opsEntities[op.name] = {
|
|
@@ -1179,6 +1294,8 @@ Directory Structure:
|
|
|
1179
1294
|
modifiedAt: typeConfig.modifiedAt || new Date().toISOString(),
|
|
1180
1295
|
operations: Object.keys(opsEntities).length > 0 ? opsEntities : undefined,
|
|
1181
1296
|
contentHash: computeFileHash(filePath),
|
|
1297
|
+
hasSchema: typeof typeConfig.schema === "string" &&
|
|
1298
|
+
typeConfig.schema.trim().length > 0,
|
|
1182
1299
|
};
|
|
1183
1300
|
info(` Wrote database-types/${filename}`);
|
|
1184
1301
|
}
|
|
@@ -1277,6 +1394,7 @@ Directory Structure:
|
|
|
1277
1394
|
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
1278
1395
|
.option("--dry-run", "Show what would be changed without applying")
|
|
1279
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)")
|
|
1280
1398
|
.action(async (appId, options) => {
|
|
1281
1399
|
const resolvedAppId = resolveAppId(appId, options);
|
|
1282
1400
|
const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
|
|
@@ -1310,6 +1428,17 @@ Directory Structure:
|
|
|
1310
1428
|
const changes = [];
|
|
1311
1429
|
let skippedCount = 0;
|
|
1312
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
|
+
};
|
|
1313
1442
|
// Track name→ID mappings for resolving cross-references during push
|
|
1314
1443
|
const ruleSetNameToId = new Map();
|
|
1315
1444
|
const promptKeyToId = new Map();
|
|
@@ -1408,7 +1537,7 @@ Directory Structure:
|
|
|
1408
1537
|
});
|
|
1409
1538
|
}
|
|
1410
1539
|
else {
|
|
1411
|
-
throw err;
|
|
1540
|
+
throw wrapEntityError(err, "update", "rule set", fileKey);
|
|
1412
1541
|
}
|
|
1413
1542
|
}
|
|
1414
1543
|
}
|
|
@@ -1419,12 +1548,18 @@ Directory Structure:
|
|
|
1419
1548
|
// Create new rule set
|
|
1420
1549
|
changes.push({ type: "rule-set", action: "create", key: fileKey });
|
|
1421
1550
|
if (!options.dryRun) {
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
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
|
+
}
|
|
1428
1563
|
info(` Created rule set: ${fileKey}`);
|
|
1429
1564
|
if (syncState && created?.ruleSetId) {
|
|
1430
1565
|
if (!syncState.entities.ruleSets) {
|
|
@@ -1494,7 +1629,7 @@ Directory Structure:
|
|
|
1494
1629
|
});
|
|
1495
1630
|
}
|
|
1496
1631
|
else {
|
|
1497
|
-
throw err;
|
|
1632
|
+
throw wrapEntityError(err, "update", "integration", key);
|
|
1498
1633
|
}
|
|
1499
1634
|
}
|
|
1500
1635
|
}
|
|
@@ -1502,18 +1637,23 @@ Directory Structure:
|
|
|
1502
1637
|
else {
|
|
1503
1638
|
changes.push({ type: "integration", action: "create", key });
|
|
1504
1639
|
if (!options.dryRun) {
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
if (
|
|
1510
|
-
syncState.entities.integrations
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
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);
|
|
1517
1657
|
}
|
|
1518
1658
|
}
|
|
1519
1659
|
}
|
|
@@ -1580,7 +1720,7 @@ Directory Structure:
|
|
|
1580
1720
|
});
|
|
1581
1721
|
}
|
|
1582
1722
|
else {
|
|
1583
|
-
throw err;
|
|
1723
|
+
throw wrapEntityError(err, "update", "webhook", key);
|
|
1584
1724
|
}
|
|
1585
1725
|
}
|
|
1586
1726
|
}
|
|
@@ -1588,17 +1728,22 @@ Directory Structure:
|
|
|
1588
1728
|
else {
|
|
1589
1729
|
changes.push({ type: "webhook", action: "create", key });
|
|
1590
1730
|
if (!options.dryRun) {
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
if (
|
|
1595
|
-
syncState.entities.webhooks
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
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);
|
|
1602
1747
|
}
|
|
1603
1748
|
}
|
|
1604
1749
|
}
|
|
@@ -1648,24 +1793,29 @@ Directory Structure:
|
|
|
1648
1793
|
}
|
|
1649
1794
|
}
|
|
1650
1795
|
catch (err) {
|
|
1651
|
-
throw err;
|
|
1796
|
+
throw wrapEntityError(err, "update", "cron trigger", key);
|
|
1652
1797
|
}
|
|
1653
1798
|
}
|
|
1654
1799
|
}
|
|
1655
1800
|
else {
|
|
1656
1801
|
changes.push({ type: "cron-trigger", action: "create", key });
|
|
1657
1802
|
if (!options.dryRun) {
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
if (
|
|
1662
|
-
syncState.entities.cronTriggers
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
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);
|
|
1669
1819
|
}
|
|
1670
1820
|
}
|
|
1671
1821
|
}
|
|
@@ -1743,7 +1893,7 @@ Directory Structure:
|
|
|
1743
1893
|
}
|
|
1744
1894
|
}
|
|
1745
1895
|
else {
|
|
1746
|
-
throw err;
|
|
1896
|
+
throw wrapEntityError(err, "create", "blob bucket", key);
|
|
1747
1897
|
}
|
|
1748
1898
|
}
|
|
1749
1899
|
}
|
|
@@ -1857,7 +2007,7 @@ Directory Structure:
|
|
|
1857
2007
|
});
|
|
1858
2008
|
}
|
|
1859
2009
|
else {
|
|
1860
|
-
throw err;
|
|
2010
|
+
throw wrapEntityError(err, "update", "prompt", key);
|
|
1861
2011
|
}
|
|
1862
2012
|
}
|
|
1863
2013
|
}
|
|
@@ -1867,65 +2017,70 @@ Directory Structure:
|
|
|
1867
2017
|
const firstConfig = configs[0] || {};
|
|
1868
2018
|
changes.push({ type: "prompt", action: "create", key });
|
|
1869
2019
|
if (!options.dryRun) {
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
if (
|
|
1887
|
-
syncState.entities.prompts
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
// Track prompt key→ID and config name→ID
|
|
1896
|
-
if (created?.promptId) {
|
|
1897
|
-
promptKeyToId.set(key, created.promptId);
|
|
1898
|
-
}
|
|
1899
|
-
if (created?.configs) {
|
|
1900
|
-
for (const config of created.configs) {
|
|
1901
|
-
promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
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
|
+
};
|
|
1902
2045
|
}
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
const
|
|
1909
|
-
|
|
1910
|
-
description: extraConfig.description,
|
|
1911
|
-
provider: extraConfig.provider || firstConfig.provider || "openrouter",
|
|
1912
|
-
model: extraConfig.model || firstConfig.model || "google/gemini-2.0-flash-001",
|
|
1913
|
-
systemPrompt: extraConfig.systemPrompt,
|
|
1914
|
-
userPromptTemplate: extraConfig.userPromptTemplate,
|
|
1915
|
-
temperature: extraConfig.temperature,
|
|
1916
|
-
maxTokens: extraConfig.maxTokens,
|
|
1917
|
-
outputFormat: extraConfig.outputFormat,
|
|
1918
|
-
});
|
|
1919
|
-
if (extraCreated?.configId) {
|
|
1920
|
-
const configName = extraConfig.name || `config-${i + 1}`;
|
|
1921
|
-
promptConfigNameToId.set(`${key}#${configName}`, extraCreated.configId);
|
|
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);
|
|
1922
2053
|
}
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
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
|
+
}
|
|
1926
2078
|
}
|
|
2079
|
+
info(` Created ${configs.length - 1} additional config(s) for prompt: ${key}`);
|
|
1927
2080
|
}
|
|
1928
|
-
|
|
2081
|
+
}
|
|
2082
|
+
catch (err) {
|
|
2083
|
+
throw wrapEntityError(err, "create", "prompt", key);
|
|
1929
2084
|
}
|
|
1930
2085
|
}
|
|
1931
2086
|
}
|
|
@@ -1938,13 +2093,27 @@ Directory Structure:
|
|
|
1938
2093
|
for (const file of files) {
|
|
1939
2094
|
const filePath = join(workflowsDir, file);
|
|
1940
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
|
+
}
|
|
1941
2107
|
const workflow = tomlData.workflow || {};
|
|
1942
2108
|
const key = workflow.key || basename(file, ".toml");
|
|
1943
2109
|
const steps = tomlData.steps || [];
|
|
1944
2110
|
const existingId = syncState?.entities?.workflows?.[key]?.id;
|
|
1945
2111
|
const existingActiveConfigId = syncState?.entities?.workflows?.[key]?.activeConfigId;
|
|
1946
|
-
// Skip if file hasn't changed since last sync
|
|
1947
|
-
|
|
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)) {
|
|
1948
2117
|
skippedCount++;
|
|
1949
2118
|
// Only fetch config name→ID mappings if test cases exist for this workflow
|
|
1950
2119
|
const workflowTestsDir = getTestsDir(configDir, "workflow", key);
|
|
@@ -1978,7 +2147,10 @@ Directory Structure:
|
|
|
1978
2147
|
inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
|
|
1979
2148
|
outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : null,
|
|
1980
2149
|
}, expectedModifiedAt);
|
|
1981
|
-
// 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";
|
|
1982
2154
|
if (existingActiveConfigId) {
|
|
1983
2155
|
await client.updateWorkflowConfig(resolvedAppId, existingId, existingActiveConfigId, {
|
|
1984
2156
|
steps,
|
|
@@ -1990,12 +2162,14 @@ Directory Structure:
|
|
|
1990
2162
|
steps,
|
|
1991
2163
|
inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
|
|
1992
2164
|
});
|
|
2165
|
+
updateSlotLabel = "draft (legacy)";
|
|
1993
2166
|
}
|
|
1994
|
-
info(` Updated workflow: ${key}`);
|
|
1995
|
-
// 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.
|
|
1996
2170
|
if (syncState?.entities?.workflows?.[key] && updated?.workflow?.modifiedAt) {
|
|
1997
2171
|
syncState.entities.workflows[key].modifiedAt = updated.workflow.modifiedAt;
|
|
1998
|
-
syncState.entities.workflows[key].contentHash =
|
|
2172
|
+
syncState.entities.workflows[key].contentHash = computeExpandedContentHash(tomlData);
|
|
1999
2173
|
}
|
|
2000
2174
|
// Fetch full workflow to get config name→ID mappings
|
|
2001
2175
|
// (updateWorkflow response doesn't include configs)
|
|
@@ -2016,7 +2190,7 @@ Directory Structure:
|
|
|
2016
2190
|
});
|
|
2017
2191
|
}
|
|
2018
2192
|
else {
|
|
2019
|
-
throw err;
|
|
2193
|
+
throw wrapEntityError(err, "update", "workflow", key);
|
|
2020
2194
|
}
|
|
2021
2195
|
}
|
|
2022
2196
|
}
|
|
@@ -2025,63 +2199,68 @@ Directory Structure:
|
|
|
2025
2199
|
// Create new workflow (automatically creates default config)
|
|
2026
2200
|
changes.push({ type: "workflow", action: "create", key });
|
|
2027
2201
|
if (!options.dryRun) {
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
if (
|
|
2044
|
-
syncState.entities.workflows
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
// Track config name→ID mappings
|
|
2054
|
-
if (created?.configs) {
|
|
2055
|
-
for (const config of created.configs) {
|
|
2056
|
-
workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
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
|
+
};
|
|
2057
2227
|
}
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
if (workflowId && tomlConfigs.length > 1) {
|
|
2063
|
-
for (let i = 1; i < tomlConfigs.length; i++) {
|
|
2064
|
-
const extraConfig = tomlConfigs[i];
|
|
2065
|
-
const extraCreated = await client.createWorkflowConfig(resolvedAppId, workflowId, {
|
|
2066
|
-
configName: extraConfig.name || `config-${i + 1}`,
|
|
2067
|
-
description: extraConfig.description,
|
|
2068
|
-
steps,
|
|
2069
|
-
});
|
|
2070
|
-
if (extraCreated?.configId) {
|
|
2071
|
-
const configName = extraConfig.name || `config-${i + 1}`;
|
|
2072
|
-
workflowConfigNameToId.set(`${key}#${configName}`, extraCreated.configId);
|
|
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);
|
|
2073
2232
|
}
|
|
2074
2233
|
}
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
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
|
+
}
|
|
2083
2259
|
}
|
|
2084
2260
|
}
|
|
2261
|
+
catch (err) {
|
|
2262
|
+
throw wrapEntityError(err, "create", "workflow", key);
|
|
2263
|
+
}
|
|
2085
2264
|
}
|
|
2086
2265
|
}
|
|
2087
2266
|
}
|
|
@@ -2092,11 +2271,31 @@ Directory Structure:
|
|
|
2092
2271
|
const files = readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml"));
|
|
2093
2272
|
for (const file of files) {
|
|
2094
2273
|
const filePath = join(dbTypesDir, file);
|
|
2274
|
+
const rawToml = readFileSync(filePath, "utf-8");
|
|
2095
2275
|
const tomlData = parseTomlFile(filePath);
|
|
2096
2276
|
const { typeConfig, operations } = parseDatabaseTypeToml(tomlData);
|
|
2097
2277
|
const dbType = typeConfig.databaseType || basename(file, ".toml");
|
|
2098
2278
|
// Resolve ruleSetName → ruleSetId if using key-based reference
|
|
2099
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
|
+
}
|
|
2100
2299
|
const existingEntry = syncState?.entities?.databaseTypes?.[dbType];
|
|
2101
2300
|
// Skip if file hasn't changed since last sync
|
|
2102
2301
|
if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
|
|
@@ -2120,13 +2319,41 @@ Directory Structure:
|
|
|
2120
2319
|
updateData.triggers = typeConfig.triggers || null;
|
|
2121
2320
|
if ("metadataAccess" in typeConfig)
|
|
2122
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
|
+
}
|
|
2123
2343
|
if (Object.keys(updateData).length > 0) {
|
|
2124
2344
|
changes.push({ type: "database-type", action: "update", key: dbType });
|
|
2125
|
-
const updated = await client.updateDatabaseTypeConfig(resolvedAppId, dbType, updateData, expectedModifiedAt
|
|
2345
|
+
const updated = await client.updateDatabaseTypeConfig(resolvedAppId, dbType, updateData, expectedModifiedAt, {
|
|
2346
|
+
dryRun: !!options.dryRun,
|
|
2347
|
+
acceptWarnings: !!options.acceptWarnings,
|
|
2348
|
+
});
|
|
2126
2349
|
info(` Updated database type: ${dbType}`);
|
|
2127
2350
|
if (syncState?.entities?.databaseTypes?.[dbType] && updated?.modifiedAt) {
|
|
2128
2351
|
syncState.entities.databaseTypes[dbType].modifiedAt = updated.modifiedAt;
|
|
2129
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;
|
|
2130
2357
|
}
|
|
2131
2358
|
}
|
|
2132
2359
|
}
|
|
@@ -2139,8 +2366,41 @@ Directory Structure:
|
|
|
2139
2366
|
localModifiedAt: expectedModifiedAt || "unknown",
|
|
2140
2367
|
});
|
|
2141
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
|
+
}
|
|
2142
2402
|
else {
|
|
2143
|
-
throw err;
|
|
2403
|
+
throw wrapEntityError(err, "update", "database type", dbType);
|
|
2144
2404
|
}
|
|
2145
2405
|
}
|
|
2146
2406
|
}
|
|
@@ -2148,7 +2408,10 @@ Directory Structure:
|
|
|
2148
2408
|
// In dry-run mode, still report the change iff we would actually PATCH.
|
|
2149
2409
|
const wouldUpdate = "ruleSetId" in typeConfig ||
|
|
2150
2410
|
"triggers" in typeConfig ||
|
|
2151
|
-
"metadataAccess" in typeConfig
|
|
2411
|
+
"metadataAccess" in typeConfig ||
|
|
2412
|
+
"defaultAccess" in typeConfig ||
|
|
2413
|
+
"autoPopulatedFields" in typeConfig ||
|
|
2414
|
+
"schema" in typeConfig;
|
|
2152
2415
|
if (wouldUpdate) {
|
|
2153
2416
|
changes.push({ type: "database-type", action: "update", key: dbType });
|
|
2154
2417
|
}
|
|
@@ -2167,17 +2430,35 @@ Directory Structure:
|
|
|
2167
2430
|
createData.triggers = typeConfig.triggers;
|
|
2168
2431
|
if (typeConfig.metadataAccess)
|
|
2169
2432
|
createData.metadataAccess = typeConfig.metadataAccess;
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
if (
|
|
2173
|
-
|
|
2174
|
-
|
|
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
|
+
};
|
|
2175
2458
|
}
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
contentHash: computeFileHash(filePath),
|
|
2180
|
-
};
|
|
2459
|
+
}
|
|
2460
|
+
catch (err) {
|
|
2461
|
+
throw wrapEntityError(err, "create", "database type", dbType);
|
|
2181
2462
|
}
|
|
2182
2463
|
}
|
|
2183
2464
|
}
|
|
@@ -2195,6 +2476,10 @@ Directory Structure:
|
|
|
2195
2476
|
: existingOp.modifiedAt;
|
|
2196
2477
|
try {
|
|
2197
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,
|
|
2198
2483
|
modelName: op.modelName,
|
|
2199
2484
|
access: op.access,
|
|
2200
2485
|
definition: op.definition,
|
|
@@ -2214,8 +2499,23 @@ Directory Structure:
|
|
|
2214
2499
|
localModifiedAt: expectedOpModifiedAt || "unknown",
|
|
2215
2500
|
});
|
|
2216
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
|
+
}
|
|
2217
2517
|
else {
|
|
2218
|
-
throw err;
|
|
2518
|
+
throw wrapEntityError(err, "update", "operation", `${dbType}/${op.name}`);
|
|
2219
2519
|
}
|
|
2220
2520
|
}
|
|
2221
2521
|
}
|
|
@@ -2224,22 +2524,44 @@ Directory Structure:
|
|
|
2224
2524
|
// Create new operation
|
|
2225
2525
|
changes.push({ type: "operation", action: "create", key: `${dbType}/${op.name}` });
|
|
2226
2526
|
if (!options.dryRun) {
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
if (
|
|
2238
|
-
syncState.entities.databaseTypes[dbType].operations
|
|
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}`);
|
|
2239
2564
|
}
|
|
2240
|
-
syncState.entities.databaseTypes[dbType].operations[op.name] = {
|
|
2241
|
-
modifiedAt: created?.modifiedAt || new Date().toISOString(),
|
|
2242
|
-
};
|
|
2243
2565
|
}
|
|
2244
2566
|
}
|
|
2245
2567
|
}
|
|
@@ -2249,10 +2571,15 @@ Directory Structure:
|
|
|
2249
2571
|
if (!tomlOpNames.has(existingOpName)) {
|
|
2250
2572
|
changes.push({ type: "operation", action: "delete", key: `${dbType}/${existingOpName}` });
|
|
2251
2573
|
if (!options.dryRun) {
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
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}`);
|
|
2256
2583
|
}
|
|
2257
2584
|
}
|
|
2258
2585
|
}
|
|
@@ -2304,7 +2631,7 @@ Directory Structure:
|
|
|
2304
2631
|
});
|
|
2305
2632
|
}
|
|
2306
2633
|
else {
|
|
2307
|
-
throw err;
|
|
2634
|
+
throw wrapEntityError(err, "update", "group type config", groupType);
|
|
2308
2635
|
}
|
|
2309
2636
|
}
|
|
2310
2637
|
}
|
|
@@ -2313,20 +2640,25 @@ Directory Structure:
|
|
|
2313
2640
|
// Create new group type config
|
|
2314
2641
|
changes.push({ type: "group-type-config", action: "create", key: groupType });
|
|
2315
2642
|
if (!options.dryRun) {
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
if (
|
|
2324
|
-
syncState.entities.groupTypeConfigs
|
|
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
|
+
};
|
|
2325
2658
|
}
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
};
|
|
2659
|
+
}
|
|
2660
|
+
catch (err) {
|
|
2661
|
+
throw wrapEntityError(err, "create", "group type config", groupType);
|
|
2330
2662
|
}
|
|
2331
2663
|
}
|
|
2332
2664
|
}
|
|
@@ -2376,7 +2708,7 @@ Directory Structure:
|
|
|
2376
2708
|
});
|
|
2377
2709
|
}
|
|
2378
2710
|
else {
|
|
2379
|
-
throw err;
|
|
2711
|
+
throw wrapEntityError(err, "update", "collection type config", collectionType);
|
|
2380
2712
|
}
|
|
2381
2713
|
}
|
|
2382
2714
|
}
|
|
@@ -2385,19 +2717,24 @@ Directory Structure:
|
|
|
2385
2717
|
// Create new collection type config
|
|
2386
2718
|
changes.push({ type: "collection-type-config", action: "create", key: collectionType });
|
|
2387
2719
|
if (!options.dryRun) {
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
if (
|
|
2395
|
-
syncState.entities.collectionTypeConfigs
|
|
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
|
+
};
|
|
2396
2734
|
}
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
};
|
|
2735
|
+
}
|
|
2736
|
+
catch (err) {
|
|
2737
|
+
throw wrapEntityError(err, "create", "collection type config", collectionType);
|
|
2401
2738
|
}
|
|
2402
2739
|
}
|
|
2403
2740
|
}
|
|
@@ -2478,33 +2815,118 @@ Directory Structure:
|
|
|
2478
2815
|
console.log(` ${color(change.action)} ${change.type}: ${change.key}`);
|
|
2479
2816
|
}
|
|
2480
2817
|
}
|
|
2481
|
-
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) {
|
|
2482
2825
|
// Handle conflicts
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
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();
|
|
2490
2911
|
}
|
|
2491
|
-
console.log();
|
|
2492
|
-
warn("These entities were modified on the server since your last pull.");
|
|
2493
|
-
info("Options:");
|
|
2494
|
-
console.log(` 1. Run ${chalk.cyan("primitive sync pull")} to get latest changes`);
|
|
2495
|
-
console.log(` 2. Run ${chalk.cyan("primitive sync push --force")} to overwrite server`);
|
|
2496
|
-
console.log();
|
|
2497
2912
|
// Update sync state for non-conflicting changes
|
|
2498
2913
|
if (syncState) {
|
|
2499
2914
|
syncState.lastSyncedAt = new Date().toISOString();
|
|
2500
2915
|
saveSyncState(configDir, syncState);
|
|
2501
2916
|
}
|
|
2502
|
-
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;
|
|
2503
2925
|
if (successCount > 0) {
|
|
2504
|
-
success(`Pushed ${successCount} change(s). ${
|
|
2926
|
+
success(`Pushed ${successCount} change(s). ${totalBlocked} blocked.`);
|
|
2505
2927
|
}
|
|
2506
2928
|
else {
|
|
2507
|
-
error(`Push failed: ${
|
|
2929
|
+
error(`Push failed: ${totalBlocked} blocked. No changes applied.`);
|
|
2508
2930
|
}
|
|
2509
2931
|
process.exit(1);
|
|
2510
2932
|
}
|
|
@@ -2532,6 +2954,12 @@ Directory Structure:
|
|
|
2532
2954
|
}
|
|
2533
2955
|
}
|
|
2534
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
|
+
}
|
|
2535
2963
|
process.exit(1);
|
|
2536
2964
|
}
|
|
2537
2965
|
});
|
|
@@ -2852,5 +3280,105 @@ Directory Structure:
|
|
|
2852
3280
|
process.exit(1);
|
|
2853
3281
|
}
|
|
2854
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
|
+
});
|
|
2855
3383
|
}
|
|
2856
3384
|
//# sourceMappingURL=sync.js.map
|