primitive-admin 1.1.0-alpha.38 → 1.1.0-alpha.39
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 +16 -0
- package/dist/bin/primitive.js +2 -0
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/blob-buckets.js +64 -0
- package/dist/src/commands/blob-buckets.js.map +1 -1
- package/dist/src/commands/collections.js +1 -9
- package/dist/src/commands/collections.js.map +1 -1
- package/dist/src/commands/group-type-configs.js +1 -9
- package/dist/src/commands/group-type-configs.js.map +1 -1
- package/dist/src/commands/guides.d.ts +75 -0
- package/dist/src/commands/guides.js +284 -47
- package/dist/src/commands/guides.js.map +1 -1
- package/dist/src/commands/rule-sets.d.ts +1 -0
- package/dist/src/commands/rule-sets.js +25 -3
- package/dist/src/commands/rule-sets.js.map +1 -1
- package/dist/src/commands/scripts.d.ts +2 -0
- package/dist/src/commands/scripts.js +667 -0
- package/dist/src/commands/scripts.js.map +1 -0
- package/dist/src/commands/sync.d.ts +29 -0
- package/dist/src/commands/sync.js +402 -97
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/webhooks.js +7 -2
- package/dist/src/commands/webhooks.js.map +1 -1
- package/dist/src/commands/workflows.js +219 -1
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +56 -15
- package/dist/src/lib/api-client.js +51 -0
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/block-layout.d.ts +160 -0
- package/dist/src/lib/block-layout.js +451 -0
- package/dist/src/lib/block-layout.js.map +1 -0
- package/dist/src/lib/config.js +21 -0
- package/dist/src/lib/config.js.map +1 -1
- package/dist/src/lib/generated-allowlist.js +9 -0
- package/dist/src/lib/generated-allowlist.js.map +1 -1
- package/dist/src/lib/output.d.ts +25 -5
- package/dist/src/lib/output.js +32 -4
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/query-operators.d.ts +43 -0
- package/dist/src/lib/query-operators.js +80 -0
- package/dist/src/lib/query-operators.js.map +1 -0
- package/dist/src/lib/toml-database-config.d.ts +20 -0
- package/dist/src/lib/toml-database-config.js +37 -2
- package/dist/src/lib/toml-database-config.js.map +1 -1
- package/dist/src/lib/toml-metadata-config.d.ts +108 -0
- package/dist/src/lib/toml-metadata-config.js +371 -0
- package/dist/src/lib/toml-metadata-config.js.map +1 -0
- package/dist/src/lib/toml-params-validator.d.ts +34 -0
- package/dist/src/lib/toml-params-validator.js +118 -3
- package/dist/src/lib/toml-params-validator.js.map +1 -1
- package/dist/src/lib/workflow-apply.d.ts +66 -0
- package/dist/src/lib/workflow-apply.js +117 -0
- package/dist/src/lib/workflow-apply.js.map +1 -0
- package/dist/src/lib/workflow-toml-validator.js +36 -0
- package/dist/src/lib/workflow-toml-validator.js.map +1 -1
- package/dist/src/types/index.d.ts +7 -0
- package/package.json +1 -1
|
@@ -5,12 +5,15 @@ import * as TOML from "@iarna/toml";
|
|
|
5
5
|
import { lookup as mimeLookup } from "mime-types";
|
|
6
6
|
import { ApiClient, ApiError, ConflictError, SchemaRequiredError, OperationRefError, SchemaBreaksOpsError, SchemaHasUncheckableOpsError, TomlParseError, OpsExistError, } from "../lib/api-client.js";
|
|
7
7
|
import { buildDatabaseTypeTomlData, detectExistingOperationForms, normalizeOperationFromToml, normalizeSubscriptionFromToml, SubscriptionAccessKeyConflictError, } from "../lib/toml-database-config.js";
|
|
8
|
+
import { parseMetadataCategoryToml, validateMetadataCategoryConfig, serializeMetadataCategoryConfig, parseDeclaredAccessManifestToml, validateDeclaredAccessManifest, serializeDeclaredAccessManifest, } from "../lib/toml-metadata-config.js";
|
|
8
9
|
import { validateOperations, formatIssue, } from "../lib/toml-params-validator.js";
|
|
9
10
|
import { getServerUrl, resolveAppId } from "../lib/config.js";
|
|
10
11
|
import { resolveSyncDir, resolveSnapshotsRoot, isAutoResolvedSyncDir, checkLegacySyncMigration, } from "../lib/sync-paths.js";
|
|
11
12
|
import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneSnapshots, } from "../lib/snapshots.js";
|
|
12
13
|
import { validateWorkflowToml, formatWorkflowTomlErrors, } from "../lib/workflow-toml-validator.js";
|
|
13
14
|
import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
|
|
15
|
+
import { applyWorkflowBody } from "../lib/workflow-apply.js";
|
|
16
|
+
import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
|
|
14
17
|
import { success, error, printApiError, info, warn, keyValue, json, divider, } from "../lib/output.js";
|
|
15
18
|
import { confirmPrompt } from "../lib/confirm-prompt.js";
|
|
16
19
|
import chalk from "chalk";
|
|
@@ -199,6 +202,87 @@ export function hashRemoteWorkflowForDiff(workflow, draft, configs) {
|
|
|
199
202
|
const parsed = TOML.parse(serialized);
|
|
200
203
|
return hashWorkflowTomlForDiff(parsed);
|
|
201
204
|
}
|
|
205
|
+
// Boolean [auth] keys in app.toml that map 1:1 to app-settings fields.
|
|
206
|
+
const AUTH_BOOLEAN_KEYS = [
|
|
207
|
+
"googleOAuthEnabled",
|
|
208
|
+
"passkeyEnabled",
|
|
209
|
+
"magicLinkEnabled",
|
|
210
|
+
"appleSignInEnabled",
|
|
211
|
+
"otpEnabled",
|
|
212
|
+
];
|
|
213
|
+
// Every recognized [auth] key. Single source of truth shared by pull
|
|
214
|
+
// serialization, push parsing, and the unrecognized-key warning so the three
|
|
215
|
+
// never drift apart. `appleAudiences` is a string array and `passkeys` is the
|
|
216
|
+
// passkey RP config object; the rest are booleans.
|
|
217
|
+
const RECOGNIZED_AUTH_KEYS = new Set([
|
|
218
|
+
...AUTH_BOOLEAN_KEYS,
|
|
219
|
+
"appleAudiences",
|
|
220
|
+
"passkeys",
|
|
221
|
+
]);
|
|
222
|
+
/**
|
|
223
|
+
* Build the [auth] block of app.toml from server settings (pull direction).
|
|
224
|
+
*
|
|
225
|
+
* Apple/OTP fields round-trip alongside the legacy google/passkey/magic-link
|
|
226
|
+
* keys. Two asymmetries are handled deliberately:
|
|
227
|
+
* - `appleSignInEnabled` comes back as `boolean | null`; @iarna/toml throws on
|
|
228
|
+
* null, so an unset value (null) is omitted rather than written.
|
|
229
|
+
* - `appleAudiences` is omitted when empty. An empty array and an omitted key
|
|
230
|
+
* both mean "no audiences" (the server normalizes [] -> null), so omitting
|
|
231
|
+
* an empty list on pull is not lossy.
|
|
232
|
+
*/
|
|
233
|
+
export function serializeAuthBlock(settings) {
|
|
234
|
+
const auth = {
|
|
235
|
+
googleOAuthEnabled: settings.googleOAuthEnabled,
|
|
236
|
+
passkeyEnabled: settings.passkeyEnabled,
|
|
237
|
+
magicLinkEnabled: settings.magicLinkEnabled,
|
|
238
|
+
};
|
|
239
|
+
if (typeof settings.appleSignInEnabled === "boolean") {
|
|
240
|
+
auth.appleSignInEnabled = settings.appleSignInEnabled;
|
|
241
|
+
}
|
|
242
|
+
if (Array.isArray(settings.appleAudiences) &&
|
|
243
|
+
settings.appleAudiences.length > 0) {
|
|
244
|
+
auth.appleAudiences = settings.appleAudiences;
|
|
245
|
+
}
|
|
246
|
+
if (typeof settings.otpEnabled === "boolean") {
|
|
247
|
+
auth.otpEnabled = settings.otpEnabled;
|
|
248
|
+
}
|
|
249
|
+
if (settings.passkeyRpConfig) {
|
|
250
|
+
auth.passkeys = settings.passkeyRpConfig;
|
|
251
|
+
}
|
|
252
|
+
return auth;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Translate the [auth] block of app.toml into app-settings fields (push
|
|
256
|
+
* direction).
|
|
257
|
+
*
|
|
258
|
+
* Only keys actually present in the TOML are forwarded — an omitted key is left
|
|
259
|
+
* untouched so it never overwrites server state (e.g. flipping a previously-true
|
|
260
|
+
* flag to false). An explicit value, including `false`, is forwarded as-is.
|
|
261
|
+
* `appleAudiences = []` is forwarded too; the server normalizes [] -> null
|
|
262
|
+
* ("no audiences"). Keys outside RECOGNIZED_AUTH_KEYS produce a warning rather
|
|
263
|
+
* than a hard error, so a typo or a future key doesn't silently drop.
|
|
264
|
+
*/
|
|
265
|
+
export function parseAppAuthSettings(auth) {
|
|
266
|
+
const settings = {};
|
|
267
|
+
const warnings = [];
|
|
268
|
+
for (const key of AUTH_BOOLEAN_KEYS) {
|
|
269
|
+
if (key in auth)
|
|
270
|
+
settings[key] = auth[key];
|
|
271
|
+
}
|
|
272
|
+
if ("appleAudiences" in auth) {
|
|
273
|
+
settings.appleAudiences = auth.appleAudiences;
|
|
274
|
+
}
|
|
275
|
+
if ("passkeys" in auth) {
|
|
276
|
+
settings.passkeyRpConfig = auth.passkeys;
|
|
277
|
+
}
|
|
278
|
+
for (const key of Object.keys(auth)) {
|
|
279
|
+
if (!RECOGNIZED_AUTH_KEYS.has(key)) {
|
|
280
|
+
warnings.push(`Unrecognized [auth] key "${key}" in app.toml — ignored. ` +
|
|
281
|
+
`Recognized keys: ${[...RECOGNIZED_AUTH_KEYS].join(", ")}.`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return { settings, warnings };
|
|
285
|
+
}
|
|
202
286
|
// TOML serialization helpers
|
|
203
287
|
function serializeAppSettings(settings) {
|
|
204
288
|
const data = {
|
|
@@ -208,15 +292,8 @@ function serializeAppSettings(settings) {
|
|
|
208
292
|
waitlistEnabled: settings.waitlistEnabled,
|
|
209
293
|
baseUrl: settings.baseUrl,
|
|
210
294
|
},
|
|
211
|
-
auth:
|
|
212
|
-
googleOAuthEnabled: settings.googleOAuthEnabled,
|
|
213
|
-
passkeyEnabled: settings.passkeyEnabled,
|
|
214
|
-
magicLinkEnabled: settings.magicLinkEnabled,
|
|
215
|
-
},
|
|
295
|
+
auth: serializeAuthBlock(settings),
|
|
216
296
|
};
|
|
217
|
-
if (settings.passkeyRpConfig) {
|
|
218
|
-
data.auth.passkeys = settings.passkeyRpConfig;
|
|
219
|
-
}
|
|
220
297
|
if (settings.corsMode === "custom") {
|
|
221
298
|
data.cors = {
|
|
222
299
|
mode: settings.corsMode,
|
|
@@ -256,6 +333,15 @@ function serializeWebhook(webhook) {
|
|
|
256
333
|
secretGracePeriodMs: webhook.secretGracePeriodMs,
|
|
257
334
|
},
|
|
258
335
|
};
|
|
336
|
+
// Re-emit a `{{secrets.KEY}}` reference so a pull→push round-trip is lossless
|
|
337
|
+
// (#1235). The server only returns `signingSecret` when it is such a
|
|
338
|
+
// reference (a real secret is returned masked, never in plaintext); guard on
|
|
339
|
+
// the template shape here too so a masked or raw value can never be written
|
|
340
|
+
// to TOML.
|
|
341
|
+
if (typeof webhook.signingSecret === "string" &&
|
|
342
|
+
/\{\{secrets\.[A-Z][A-Z0-9_]{0,63}\}\}/.test(webhook.signingSecret)) {
|
|
343
|
+
data.webhook.signingSecret = webhook.signingSecret;
|
|
344
|
+
}
|
|
259
345
|
// Parse JSON fields if they're strings
|
|
260
346
|
if (webhook.allowedIpCidrs) {
|
|
261
347
|
try {
|
|
@@ -371,7 +457,7 @@ function serializePrompt(prompt) {
|
|
|
371
457
|
};
|
|
372
458
|
return TOML.stringify(data);
|
|
373
459
|
}
|
|
374
|
-
function serializeWorkflow(workflow, draft, configs) {
|
|
460
|
+
export function serializeWorkflow(workflow, draft, configs) {
|
|
375
461
|
// Find the active config or use the first one
|
|
376
462
|
const activeConfigId = workflow.activeConfigId;
|
|
377
463
|
const activeConfig = configs?.find((c) => c.configId === activeConfigId) || configs?.[0];
|
|
@@ -391,6 +477,15 @@ function serializeWorkflow(workflow, draft, configs) {
|
|
|
391
477
|
// #1081 — workflow principal mode. Emit only when set so a fresh
|
|
392
478
|
// pull → push round-trips it without writing a noisy `runAs = ""`.
|
|
393
479
|
runAs: workflow.runAs || undefined,
|
|
480
|
+
// #1233 — opt-in sensitive capability grants. The GET response always
|
|
481
|
+
// returns an array (empty when unset); emit only when non-empty so a
|
|
482
|
+
// fresh pull → push round-trips a real value without writing a noisy
|
|
483
|
+
// `capabilities = []`. This is the load-bearing fix: before this, pull
|
|
484
|
+
// dropped capabilities entirely and a pull → push cycle silently cleared
|
|
485
|
+
// them on the server.
|
|
486
|
+
capabilities: Array.isArray(workflow.capabilities) && workflow.capabilities.length > 0
|
|
487
|
+
? workflow.capabilities
|
|
488
|
+
: undefined,
|
|
394
489
|
perUserMaxRunning: workflow.perUserMaxRunning,
|
|
395
490
|
perUserMaxQueued: workflow.perUserMaxQueued,
|
|
396
491
|
dequeueOrder: workflow.dequeueOrder,
|
|
@@ -413,6 +508,10 @@ function serializeWorkflow(workflow, draft, configs) {
|
|
|
413
508
|
status: config.status,
|
|
414
509
|
isActive: config.configId === activeConfigId,
|
|
415
510
|
})) || [],
|
|
511
|
+
// #1304 (P-C): the declared-access manifest — top-level `[metadata]` /
|
|
512
|
+
// `secrets`, merged the same way the collection/database type configs do so
|
|
513
|
+
// a workflow's `md`/`secrets` declarations round-trip through pull → push.
|
|
514
|
+
...serializeDeclaredAccessManifest(workflow.metadataManifest),
|
|
416
515
|
};
|
|
417
516
|
return TOML.stringify(data);
|
|
418
517
|
}
|
|
@@ -486,6 +585,9 @@ function serializeCollectionTypeConfig(config, ruleSetIdToName) {
|
|
|
486
585
|
collectionType: config.collectionType,
|
|
487
586
|
ruleSetName: ruleSetName,
|
|
488
587
|
},
|
|
588
|
+
// Declared-access manifest (issue #1304, P-C): merge the top-level
|
|
589
|
+
// `[metadata]` + `secrets` fragments so a pull→push→pull cycle round-trips.
|
|
590
|
+
...serializeDeclaredAccessManifest(config.metadataManifest),
|
|
489
591
|
};
|
|
490
592
|
return TOML.stringify(data);
|
|
491
593
|
}
|
|
@@ -530,6 +632,17 @@ export function parseDatabaseTypeToml(tomlData) {
|
|
|
530
632
|
if (tomlData.triggers) {
|
|
531
633
|
typeConfig.triggers = tomlData.triggers;
|
|
532
634
|
}
|
|
635
|
+
// Declared-access manifest (issue #1304, P-C): the top-level `[metadata]` +
|
|
636
|
+
// `secrets` blocks. Validated locally before push; the server re-validates
|
|
637
|
+
// (graph/schema) at persist time.
|
|
638
|
+
const manifest = parseDeclaredAccessManifestToml(tomlData);
|
|
639
|
+
if (manifest) {
|
|
640
|
+
const manifestError = validateDeclaredAccessManifest(manifest);
|
|
641
|
+
if (manifestError) {
|
|
642
|
+
throw new Error(`Invalid metadata manifest in database type "${typeSection.databaseType || ""}": ${manifestError}`);
|
|
643
|
+
}
|
|
644
|
+
typeConfig.metadataManifest = manifest;
|
|
645
|
+
}
|
|
533
646
|
// Issue #666: extract the `[models.*]` subtree (if present) and re-serialize
|
|
534
647
|
// it to a TOML string for the server's `schema` field.
|
|
535
648
|
//
|
|
@@ -598,6 +711,17 @@ export function parseCollectionTypeConfigToml(tomlData) {
|
|
|
598
711
|
else if (section.ruleSetId && section.ruleSetId !== "") {
|
|
599
712
|
result.ruleSetId = section.ruleSetId;
|
|
600
713
|
}
|
|
714
|
+
// Declared-access manifest (issue #1304, P-C): the top-level `[metadata]` +
|
|
715
|
+
// `secrets` blocks. Validated locally before push; the server re-validates
|
|
716
|
+
// (graph/schema) at persist time.
|
|
717
|
+
const manifest = parseDeclaredAccessManifestToml(tomlData);
|
|
718
|
+
if (manifest) {
|
|
719
|
+
const manifestError = validateDeclaredAccessManifest(manifest);
|
|
720
|
+
if (manifestError) {
|
|
721
|
+
throw new Error(`Invalid metadata manifest in collection type config "${section.collectionType || ""}": ${manifestError}`);
|
|
722
|
+
}
|
|
723
|
+
result.metadataManifest = manifest;
|
|
724
|
+
}
|
|
601
725
|
return result;
|
|
602
726
|
}
|
|
603
727
|
// Parsing helpers
|
|
@@ -681,7 +805,14 @@ export function resolveSlugCollisions(slug, usedSlugs) {
|
|
|
681
805
|
return resolved;
|
|
682
806
|
}
|
|
683
807
|
function getTestsDir(configDir, blockType, blockKey) {
|
|
684
|
-
|
|
808
|
+
// Scripts (blockType "script") live under `transforms/` on disk (their
|
|
809
|
+
// bodies are `transforms/<name>.rhai`), so their test cases sit alongside at
|
|
810
|
+
// `transforms/<name>.tests/`. Prompts and workflows keep their own dirs.
|
|
811
|
+
const typeDir = blockType === "prompt"
|
|
812
|
+
? "prompts"
|
|
813
|
+
: blockType === "script"
|
|
814
|
+
? "transforms"
|
|
815
|
+
: "workflows";
|
|
685
816
|
return join(configDir, typeDir, `${blockKey}.tests`);
|
|
686
817
|
}
|
|
687
818
|
export function serializeTestCase(testCase, lookupMaps) {
|
|
@@ -963,8 +1094,10 @@ async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey
|
|
|
963
1094
|
const configMapKey = `${blockKey}#${testPayload._configName}`;
|
|
964
1095
|
const configMap = blockType === "prompt"
|
|
965
1096
|
? resolutionMaps.promptConfigNameToId
|
|
966
|
-
:
|
|
967
|
-
|
|
1097
|
+
: blockType === "script"
|
|
1098
|
+
? resolutionMaps.scriptConfigNameToId
|
|
1099
|
+
: resolutionMaps.workflowConfigNameToId;
|
|
1100
|
+
const resolvedId = configMap?.get(configMapKey);
|
|
968
1101
|
if (resolvedId) {
|
|
969
1102
|
testPayload.configId = resolvedId;
|
|
970
1103
|
}
|
|
@@ -1166,6 +1299,7 @@ Directory Structure:
|
|
|
1166
1299
|
rule-sets/*.toml # Access rule sets
|
|
1167
1300
|
group-type-configs/*.toml # Group type configs
|
|
1168
1301
|
collection-type-configs/*.toml # Collection type configs
|
|
1302
|
+
metadata-category-configs/*.toml # Resource-metadata category configs
|
|
1169
1303
|
email-templates/*.toml # Email template overrides
|
|
1170
1304
|
`);
|
|
1171
1305
|
// Init
|
|
@@ -1196,6 +1330,7 @@ Directory Structure:
|
|
|
1196
1330
|
ensureDir(join(configDir, "rule-sets"));
|
|
1197
1331
|
ensureDir(join(configDir, "group-type-configs"));
|
|
1198
1332
|
ensureDir(join(configDir, "collection-type-configs"));
|
|
1333
|
+
ensureDir(join(configDir, "metadata-category-configs"));
|
|
1199
1334
|
ensureDir(join(configDir, "email-templates"));
|
|
1200
1335
|
const state = {
|
|
1201
1336
|
appId: resolvedAppId,
|
|
@@ -1267,11 +1402,12 @@ Directory Structure:
|
|
|
1267
1402
|
return;
|
|
1268
1403
|
}
|
|
1269
1404
|
// Fetch database config resources
|
|
1270
|
-
const [databaseTypeConfigsResult, ruleSetsResult, groupTypeConfigsResult, collectionTypeConfigsResult,] = await Promise.all([
|
|
1405
|
+
const [databaseTypeConfigsResult, ruleSetsResult, groupTypeConfigsResult, collectionTypeConfigsResult, metadataCategoryConfigsResult,] = await Promise.all([
|
|
1271
1406
|
client.listDatabaseTypeConfigs(resolvedAppId).catch(() => []),
|
|
1272
1407
|
client.listRuleSets(resolvedAppId).catch(() => []),
|
|
1273
1408
|
client.listGroupTypeConfigs(resolvedAppId).catch(() => []),
|
|
1274
1409
|
client.listCollectionTypeConfigs(resolvedAppId).catch(() => []),
|
|
1410
|
+
client.listMetadataCategoryConfigs(resolvedAppId).catch(() => []),
|
|
1275
1411
|
]);
|
|
1276
1412
|
// Fetch operations + subscriptions for each database type. Issue #803:
|
|
1277
1413
|
// subscriptions are pulled symmetrically with operations so a
|
|
@@ -1330,6 +1466,7 @@ Directory Structure:
|
|
|
1330
1466
|
ensureDir(join(configDir, "rule-sets"));
|
|
1331
1467
|
ensureDir(join(configDir, "group-type-configs"));
|
|
1332
1468
|
ensureDir(join(configDir, "collection-type-configs"));
|
|
1469
|
+
ensureDir(join(configDir, "metadata-category-configs"));
|
|
1333
1470
|
ensureDir(join(configDir, "email-templates"));
|
|
1334
1471
|
// Write app settings
|
|
1335
1472
|
if (settings) {
|
|
@@ -1468,6 +1605,23 @@ Directory Structure:
|
|
|
1468
1605
|
}
|
|
1469
1606
|
}
|
|
1470
1607
|
}
|
|
1608
|
+
// Include script config names so a pinned script test case
|
|
1609
|
+
// round-trips its `configName` reference (the same way prompt/workflow
|
|
1610
|
+
// configs above do). One extra `configs` fetch per pulled script.
|
|
1611
|
+
for (const [name, entity] of Object.entries(scriptEntities)) {
|
|
1612
|
+
try {
|
|
1613
|
+
const { items: scriptConfigs } = await client.listScriptConfigs(resolvedAppId, entity.id);
|
|
1614
|
+
for (const config of scriptConfigs) {
|
|
1615
|
+
configIdToName.set(config.configId, config.configName);
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
catch {
|
|
1619
|
+
// Non-fatal: a script test case pinned to a config just serializes
|
|
1620
|
+
// without a resolvable name (falls back to active on push). The
|
|
1621
|
+
// header/body still pulled fine — `name` retained for clarity.
|
|
1622
|
+
void name;
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1471
1625
|
const testCaseLookupMaps = { configIdToName, promptIdToKey };
|
|
1472
1626
|
// Write email templates (use detailed emailTemplates, not summaries)
|
|
1473
1627
|
const emailTemplateEntities = {};
|
|
@@ -1510,6 +1664,15 @@ Directory Structure:
|
|
|
1510
1664
|
}
|
|
1511
1665
|
totalTestCases += count;
|
|
1512
1666
|
}
|
|
1667
|
+
// Pull test cases for scripts (transforms). Script blocks use
|
|
1668
|
+
// blockType "script"; their tests land under transforms/<name>.tests/.
|
|
1669
|
+
for (const [name, entity] of Object.entries(scriptEntities)) {
|
|
1670
|
+
const count = await pullTestCasesForBlock(client, resolvedAppId, "script", entity.id, name, configDir, testCaseEntities, testCaseLookupMaps);
|
|
1671
|
+
if (count > 0) {
|
|
1672
|
+
info(` Wrote ${count} test case(s) for script: ${name}`);
|
|
1673
|
+
}
|
|
1674
|
+
totalTestCases += count;
|
|
1675
|
+
}
|
|
1513
1676
|
// Build ruleSetId → name map for database types and group type configs
|
|
1514
1677
|
const ruleSets = Array.isArray(ruleSetsResult) ? ruleSetsResult : [];
|
|
1515
1678
|
const ruleSetIdToName = new Map();
|
|
@@ -1605,6 +1768,23 @@ Directory Structure:
|
|
|
1605
1768
|
};
|
|
1606
1769
|
info(` Wrote collection-type-configs/${filename}`);
|
|
1607
1770
|
}
|
|
1771
|
+
// Write metadata category configs (issue #1304, P-B). One file per
|
|
1772
|
+
// `{resourceType, category}`; the entity key is the `resourceType#category`
|
|
1773
|
+
// pair so push can match it back.
|
|
1774
|
+
const metadataCategoryConfigEntities = {};
|
|
1775
|
+
const metadataCategoryConfigs = Array.isArray(metadataCategoryConfigsResult)
|
|
1776
|
+
? metadataCategoryConfigsResult
|
|
1777
|
+
: [];
|
|
1778
|
+
for (const config of metadataCategoryConfigs) {
|
|
1779
|
+
const filename = `${config.resourceType}.${config.category}.toml`;
|
|
1780
|
+
const filePath = join(configDir, "metadata-category-configs", filename);
|
|
1781
|
+
writeFileSync(filePath, serializeMetadataCategoryConfig(config));
|
|
1782
|
+
metadataCategoryConfigEntities[`${config.resourceType}#${config.category}`] = {
|
|
1783
|
+
modifiedAt: config.modifiedAt || new Date().toISOString(),
|
|
1784
|
+
contentHash: computeFileHash(filePath),
|
|
1785
|
+
};
|
|
1786
|
+
info(` Wrote metadata-category-configs/${filename}`);
|
|
1787
|
+
}
|
|
1608
1788
|
// Save sync state
|
|
1609
1789
|
const state = {
|
|
1610
1790
|
appId: resolvedAppId,
|
|
@@ -1627,6 +1807,9 @@ Directory Structure:
|
|
|
1627
1807
|
collectionTypeConfigs: Object.keys(collectionTypeConfigEntities).length > 0
|
|
1628
1808
|
? collectionTypeConfigEntities
|
|
1629
1809
|
: undefined,
|
|
1810
|
+
metadataCategoryConfigs: Object.keys(metadataCategoryConfigEntities).length > 0
|
|
1811
|
+
? metadataCategoryConfigEntities
|
|
1812
|
+
: undefined,
|
|
1630
1813
|
},
|
|
1631
1814
|
};
|
|
1632
1815
|
saveSyncState(configDir, state);
|
|
@@ -1645,6 +1828,7 @@ Directory Structure:
|
|
|
1645
1828
|
keyValue("Rule Sets", ruleSets.length);
|
|
1646
1829
|
keyValue("Group Type Configs", groupTypeConfigs.length);
|
|
1647
1830
|
keyValue("Collection Type Configs", collectionTypeConfigs.length);
|
|
1831
|
+
keyValue("Metadata Category Configs", metadataCategoryConfigs.length);
|
|
1648
1832
|
}
|
|
1649
1833
|
catch (err) {
|
|
1650
1834
|
error(err.message);
|
|
@@ -1920,11 +2104,10 @@ Directory Structure:
|
|
|
1920
2104
|
}
|
|
1921
2105
|
}
|
|
1922
2106
|
if (tomlData.auth) {
|
|
1923
|
-
settings
|
|
1924
|
-
settings
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
settings.passkeyRpConfig = tomlData.auth.passkeys;
|
|
2107
|
+
const { settings: authSettings, warnings: authWarnings } = parseAppAuthSettings(tomlData.auth);
|
|
2108
|
+
Object.assign(settings, authSettings);
|
|
2109
|
+
for (const w of authWarnings) {
|
|
2110
|
+
warn(` ${w}`);
|
|
1928
2111
|
}
|
|
1929
2112
|
}
|
|
1930
2113
|
if (tomlData.cors) {
|
|
@@ -2142,6 +2325,14 @@ Directory Structure:
|
|
|
2142
2325
|
deduplicationWindowMs: webhook.deduplicationWindowMs,
|
|
2143
2326
|
secretGracePeriodMs: webhook.secretGracePeriodMs,
|
|
2144
2327
|
};
|
|
2328
|
+
// Forward the declared signing secret so a `{{secrets.KEY}}`
|
|
2329
|
+
// reference reaches the server intact (#1235). Without this the
|
|
2330
|
+
// value declared in TOML is silently dropped and the server
|
|
2331
|
+
// rejects a verified webhook with "signingSecret is required".
|
|
2332
|
+
// Mirrors how integrations forward requestConfig with refs intact.
|
|
2333
|
+
if (webhook.signingSecret !== undefined) {
|
|
2334
|
+
payload.signingSecret = webhook.signingSecret;
|
|
2335
|
+
}
|
|
2145
2336
|
// Handle JSON fields
|
|
2146
2337
|
if (tomlData.allowedIps?.cidrs) {
|
|
2147
2338
|
payload.allowedIpCidrs = JSON.stringify(tomlData.allowedIps.cidrs);
|
|
@@ -2774,6 +2965,19 @@ Directory Structure:
|
|
|
2774
2965
|
const workflow = tomlData.workflow || {};
|
|
2775
2966
|
const key = workflow.key || basename(file, ".toml");
|
|
2776
2967
|
const steps = tomlData.steps || [];
|
|
2968
|
+
// #1304 (P-C): the declared-access manifest lives at top-level
|
|
2969
|
+
// `[metadata]` / `secrets` (same shape the database/collection type
|
|
2970
|
+
// configs use). Parse + surface-validate it, then carry it on the
|
|
2971
|
+
// `workflow` payload object so it flows through both the create call
|
|
2972
|
+
// and the shared `applyWorkflowBody` update. A null manifest (no
|
|
2973
|
+
// `[metadata]`) clears it on the server so pull → push round-trips a
|
|
2974
|
+
// drop; the deep graph check runs server-side at persist.
|
|
2975
|
+
const workflowManifest = parseDeclaredAccessManifestToml(tomlData);
|
|
2976
|
+
const workflowManifestError = validateDeclaredAccessManifest(workflowManifest);
|
|
2977
|
+
if (workflowManifestError) {
|
|
2978
|
+
throw new Error(`${filePath}: ${workflowManifestError}`);
|
|
2979
|
+
}
|
|
2980
|
+
workflow.metadataManifest = workflowManifest;
|
|
2777
2981
|
const existingId = syncState?.entities?.workflows?.[key]?.id;
|
|
2778
2982
|
const existingActiveConfigId = syncState?.entities?.workflows?.[key]?.activeConfigId;
|
|
2779
2983
|
// Skip if file hasn't changed since last sync. Use the expanded
|
|
@@ -2805,80 +3009,17 @@ Directory Structure:
|
|
|
2805
3009
|
// out-of-manifest workflow).
|
|
2806
3010
|
const applyWorkflowUpdate = async (workflowId, opts) => {
|
|
2807
3011
|
const { expectedModifiedAt, activeConfigId } = opts;
|
|
2808
|
-
//
|
|
2809
|
-
//
|
|
2810
|
-
//
|
|
2811
|
-
//
|
|
2812
|
-
//
|
|
2813
|
-
//
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
// AFTER the config/step update lands, so it validates against
|
|
2820
|
-
// the new (compatible) active steps. `requiresClientApply`
|
|
2821
|
-
// has no step-dependent validation, so it stays in the first
|
|
2822
|
-
// PATCH unchanged.
|
|
2823
|
-
const updated = await client.updateWorkflow(resolvedAppId, workflowId, {
|
|
2824
|
-
name: workflow.name,
|
|
2825
|
-
description: workflow.description,
|
|
2826
|
-
status: workflow.status,
|
|
2827
|
-
accessRule: workflow.accessRule !== undefined ? (workflow.accessRule || null) : undefined,
|
|
2828
|
-
// #1081 — workflow principal mode. Absent key → undefined
|
|
2829
|
-
// (server leaves it untouched); explicit value/empty is
|
|
2830
|
-
// forwarded so it round-trips.
|
|
2831
|
-
runAs: workflow.runAs !== undefined ? (workflow.runAs || null) : undefined,
|
|
2832
|
-
perUserMaxRunning: workflow.perUserMaxRunning,
|
|
2833
|
-
perUserMaxQueued: workflow.perUserMaxQueued,
|
|
2834
|
-
dequeueOrder: workflow.dequeueOrder,
|
|
2835
|
-
// Sync-callable flags (#807): pass through as-is. An absent
|
|
2836
|
-
// TOML key is `undefined` (dropped by JSON.stringify, so the
|
|
2837
|
-
// server's hasOwnProperty guard leaves the value untouched);
|
|
2838
|
-
// an explicit `false` is preserved as a meaningful value.
|
|
2839
|
-
requiresClientApply: workflow.requiresClientApply,
|
|
2840
|
-
inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
|
|
2841
|
-
outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : null,
|
|
2842
|
-
}, expectedModifiedAt);
|
|
2843
|
-
// Track the latest workflow `modifiedAt` for the sync-state
|
|
2844
|
-
// write below. The first PATCH bumps it; the `syncCallable`
|
|
2845
|
-
// PATCH (if any) bumps it again. The config/step update does
|
|
2846
|
-
// NOT touch the workflow definition's `modifiedAt`.
|
|
2847
|
-
let latestModifiedAt = updated?.workflow?.modifiedAt;
|
|
2848
|
-
// Update active configuration steps (or draft for legacy).
|
|
2849
|
-
// Issue #687: name the slot we touched so the dev-loop
|
|
2850
|
-
// user can confirm before previewing.
|
|
2851
|
-
let updateSlotLabel = "active config";
|
|
2852
|
-
if (activeConfigId) {
|
|
2853
|
-
await client.updateWorkflowConfig(resolvedAppId, workflowId, activeConfigId, {
|
|
2854
|
-
steps,
|
|
2855
|
-
});
|
|
2856
|
-
}
|
|
2857
|
-
else {
|
|
2858
|
-
// Fallback to draft update for legacy workflows
|
|
2859
|
-
await client.updateWorkflowDraft(resolvedAppId, workflowId, {
|
|
2860
|
-
steps,
|
|
2861
|
-
inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
|
|
2862
|
-
});
|
|
2863
|
-
updateSlotLabel = "draft (legacy)";
|
|
2864
|
-
}
|
|
2865
|
-
// Second PATCH: apply `syncCallable` now that the new steps
|
|
2866
|
-
// are active (#807). Only sent when the TOML actually carries
|
|
2867
|
-
// the key — an absent key stays `undefined` and we skip the
|
|
2868
|
-
// call entirely, preserving the no-clobber discipline. Chain
|
|
2869
|
-
// `expectedModifiedAt` off the first PATCH's returned value so
|
|
2870
|
-
// optimistic concurrency stays intact (the step update above
|
|
2871
|
-
// doesn't change the workflow definition's `modifiedAt`).
|
|
2872
|
-
if (workflow.syncCallable !== undefined) {
|
|
2873
|
-
const syncCallableUpdated = await client.updateWorkflow(resolvedAppId, workflowId, { syncCallable: workflow.syncCallable },
|
|
2874
|
-
// Mirror the first PATCH's concurrency posture: `--force`
|
|
2875
|
-
// skips the check (undefined), otherwise reuse the fresh
|
|
2876
|
-
// `modifiedAt` from the first PATCH.
|
|
2877
|
-
options.force ? undefined : latestModifiedAt);
|
|
2878
|
-
if (syncCallableUpdated?.workflow?.modifiedAt) {
|
|
2879
|
-
latestModifiedAt = syncCallableUpdated.workflow.modifiedAt;
|
|
2880
|
-
}
|
|
2881
|
-
}
|
|
3012
|
+
// The ordering-sensitive core (metadata PATCH → config/draft
|
|
3013
|
+
// steps → deferred `syncCallable` PATCH, #807) lives in the
|
|
3014
|
+
// shared `applyWorkflowBody` helper so `workflows update
|
|
3015
|
+
// --from-file` runs it identically (#1249, Fork 4). The
|
|
3016
|
+
// sync-specific bookkeeping (the progress log, sync-state
|
|
3017
|
+
// `modifiedAt`/hash, and config name→ID map) stays here.
|
|
3018
|
+
const { latestModifiedAt, updateSlotLabel, fullWorkflow } = await applyWorkflowBody(client, resolvedAppId, workflowId, workflow, steps, {
|
|
3019
|
+
expectedModifiedAt,
|
|
3020
|
+
activeConfigId,
|
|
3021
|
+
force: options.force,
|
|
3022
|
+
});
|
|
2882
3023
|
info(` Updated workflow: ${key} (${updateSlotLabel})`);
|
|
2883
3024
|
// Update sync state with new modifiedAt. Store the *expanded*
|
|
2884
3025
|
// content hash so future fragment-only edits are detected. The
|
|
@@ -2888,9 +3029,8 @@ Directory Structure:
|
|
|
2888
3029
|
syncState.entities.workflows[key].modifiedAt = latestModifiedAt;
|
|
2889
3030
|
syncState.entities.workflows[key].contentHash = computeExpandedContentHash(tomlData);
|
|
2890
3031
|
}
|
|
2891
|
-
//
|
|
2892
|
-
// (
|
|
2893
|
-
const fullWorkflow = await client.getWorkflow(resolvedAppId, workflowId);
|
|
3032
|
+
// `applyWorkflowBody` already fetched the full workflow; reuse it
|
|
3033
|
+
// for the config name→ID mappings (the PATCH response omits configs).
|
|
2894
3034
|
if (fullWorkflow?.configs) {
|
|
2895
3035
|
for (const config of fullWorkflow.configs) {
|
|
2896
3036
|
workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
@@ -2949,6 +3089,9 @@ Directory Structure:
|
|
|
2949
3089
|
// syncCallable=false); an explicit value is honored.
|
|
2950
3090
|
requiresClientApply: workflow.requiresClientApply,
|
|
2951
3091
|
syncCallable: workflow.syncCallable,
|
|
3092
|
+
// #1304 (P-C): declared-access manifest (absent → undefined,
|
|
3093
|
+
// server default null).
|
|
3094
|
+
metadataManifest: workflow.metadataManifest ?? undefined,
|
|
2952
3095
|
});
|
|
2953
3096
|
info(` Created workflow: ${key}`);
|
|
2954
3097
|
// Add new entity to sync state (including activeConfigId)
|
|
@@ -3214,6 +3357,14 @@ Directory Structure:
|
|
|
3214
3357
|
if ("timestamps" in typeConfig) {
|
|
3215
3358
|
u.timestamps = typeConfig.timestamps || null;
|
|
3216
3359
|
}
|
|
3360
|
+
// Issue #1304 (P-C): declared-access manifest. `parseDatabaseTypeToml`
|
|
3361
|
+
// only sets `metadataManifest` when the TOML declares a `[metadata]`
|
|
3362
|
+
// / `secrets` block, so this forwards a set/update; clearing a
|
|
3363
|
+
// manifest by removing the block is a follow-on (mirrors the
|
|
3364
|
+
// schema-deletion tracking).
|
|
3365
|
+
if ("metadataManifest" in typeConfig) {
|
|
3366
|
+
u.metadataManifest = typeConfig.metadataManifest || null;
|
|
3367
|
+
}
|
|
3217
3368
|
const localHasSchema = typeof typeConfig.schema === "string" &&
|
|
3218
3369
|
typeConfig.schema.trim().length > 0;
|
|
3219
3370
|
if (localHasSchema) {
|
|
@@ -3583,7 +3734,8 @@ Directory Structure:
|
|
|
3583
3734
|
"defaultAccess" in typeConfig ||
|
|
3584
3735
|
"autoPopulatedFields" in typeConfig ||
|
|
3585
3736
|
"timestamps" in typeConfig ||
|
|
3586
|
-
"schema" in typeConfig
|
|
3737
|
+
"schema" in typeConfig ||
|
|
3738
|
+
"metadataManifest" in typeConfig;
|
|
3587
3739
|
if (wouldUpdate) {
|
|
3588
3740
|
changes.push({ type: "database-type", action: "update", key: dbType });
|
|
3589
3741
|
}
|
|
@@ -3616,6 +3768,9 @@ Directory Structure:
|
|
|
3616
3768
|
// validating against the intended shape.
|
|
3617
3769
|
if (typeConfig.schema)
|
|
3618
3770
|
createData.schema = typeConfig.schema;
|
|
3771
|
+
// Issue #1304 (P-C): declared-access manifest.
|
|
3772
|
+
if (typeConfig.metadataManifest)
|
|
3773
|
+
createData.metadataManifest = typeConfig.metadataManifest;
|
|
3619
3774
|
try {
|
|
3620
3775
|
const created = await client.createDatabaseTypeConfig(resolvedAppId, createData);
|
|
3621
3776
|
info(` Created database type: ${dbType}`);
|
|
@@ -4149,6 +4304,7 @@ Directory Structure:
|
|
|
4149
4304
|
try {
|
|
4150
4305
|
const updated = await client.updateCollectionTypeConfig(resolvedAppId, collectionType, {
|
|
4151
4306
|
ruleSetId: configData.ruleSetId ?? null,
|
|
4307
|
+
metadataManifest: configData.metadataManifest ?? null,
|
|
4152
4308
|
}, expectedModifiedAt);
|
|
4153
4309
|
info(` Updated collection type config: ${collectionType}`);
|
|
4154
4310
|
if (syncState?.entities?.collectionTypeConfigs?.[collectionType] && updated?.modifiedAt) {
|
|
@@ -4179,6 +4335,9 @@ Directory Structure:
|
|
|
4179
4335
|
const created = await client.createCollectionTypeConfig(resolvedAppId, {
|
|
4180
4336
|
collectionType,
|
|
4181
4337
|
ruleSetId: configData.ruleSetId || undefined,
|
|
4338
|
+
...(configData.metadataManifest
|
|
4339
|
+
? { metadataManifest: configData.metadataManifest }
|
|
4340
|
+
: {}),
|
|
4182
4341
|
});
|
|
4183
4342
|
info(` Created collection type config: ${collectionType}`);
|
|
4184
4343
|
if (syncState) {
|
|
@@ -4198,6 +4357,66 @@ Directory Structure:
|
|
|
4198
4357
|
}
|
|
4199
4358
|
}
|
|
4200
4359
|
}
|
|
4360
|
+
// Process metadata category configs (issue #1304, P-B). Idempotent
|
|
4361
|
+
// upsert via the path-addressed PUT route — no create/update split.
|
|
4362
|
+
const metadataCategoryConfigsDir = join(configDir, "metadata-category-configs");
|
|
4363
|
+
if (existsSync(metadataCategoryConfigsDir)) {
|
|
4364
|
+
const files = readdirSync(metadataCategoryConfigsDir).filter((f) => f.endsWith(".toml"));
|
|
4365
|
+
for (const file of files) {
|
|
4366
|
+
const filePath = join(metadataCategoryConfigsDir, file);
|
|
4367
|
+
const tomlData = parseTomlFile(filePath);
|
|
4368
|
+
const configData = parseMetadataCategoryToml(tomlData);
|
|
4369
|
+
// Fall back to the filename (`resourceType.category.toml`) if the
|
|
4370
|
+
// section omits the identity fields.
|
|
4371
|
+
if (!configData.resourceType || !configData.category) {
|
|
4372
|
+
const base = basename(file, ".toml");
|
|
4373
|
+
const dot = base.indexOf(".");
|
|
4374
|
+
if (dot > 0) {
|
|
4375
|
+
configData.resourceType = configData.resourceType || base.slice(0, dot);
|
|
4376
|
+
configData.category = configData.category || base.slice(dot + 1);
|
|
4377
|
+
}
|
|
4378
|
+
}
|
|
4379
|
+
const validationError = validateMetadataCategoryConfig(configData);
|
|
4380
|
+
if (validationError) {
|
|
4381
|
+
throw new Error(`Invalid metadata category config in ${file}: ${validationError}`);
|
|
4382
|
+
}
|
|
4383
|
+
const entityKey = `${configData.resourceType}#${configData.category}`;
|
|
4384
|
+
const existingEntry = syncState?.entities?.metadataCategoryConfigs?.[entityKey];
|
|
4385
|
+
// Skip if the file hasn't changed since last sync.
|
|
4386
|
+
if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
|
|
4387
|
+
skippedCount++;
|
|
4388
|
+
continue;
|
|
4389
|
+
}
|
|
4390
|
+
changes.push({
|
|
4391
|
+
type: "metadata-category-config",
|
|
4392
|
+
action: existingEntry ? "update" : "create",
|
|
4393
|
+
key: entityKey,
|
|
4394
|
+
});
|
|
4395
|
+
if (!options.dryRun) {
|
|
4396
|
+
try {
|
|
4397
|
+
const saved = await client.upsertMetadataCategoryConfig(resolvedAppId, configData.resourceType, configData.category, {
|
|
4398
|
+
schema: configData.schema,
|
|
4399
|
+
readRule: configData.readRule,
|
|
4400
|
+
writeRule: configData.writeRule,
|
|
4401
|
+
description: configData.description,
|
|
4402
|
+
});
|
|
4403
|
+
info(` Upserted metadata category config: ${entityKey}`);
|
|
4404
|
+
if (syncState) {
|
|
4405
|
+
if (!syncState.entities.metadataCategoryConfigs) {
|
|
4406
|
+
syncState.entities.metadataCategoryConfigs = {};
|
|
4407
|
+
}
|
|
4408
|
+
syncState.entities.metadataCategoryConfigs[entityKey] = {
|
|
4409
|
+
modifiedAt: saved?.modifiedAt || new Date().toISOString(),
|
|
4410
|
+
contentHash: computeFileHash(filePath),
|
|
4411
|
+
};
|
|
4412
|
+
}
|
|
4413
|
+
}
|
|
4414
|
+
catch (err) {
|
|
4415
|
+
throw wrapEntityError(err, existingEntry ? "update" : "create", "metadata category config", entityKey);
|
|
4416
|
+
}
|
|
4417
|
+
}
|
|
4418
|
+
}
|
|
4419
|
+
}
|
|
4201
4420
|
// Process email templates
|
|
4202
4421
|
const emailTemplatesDir = join(configDir, "email-templates");
|
|
4203
4422
|
if (existsSync(emailTemplatesDir)) {
|
|
@@ -4234,11 +4453,13 @@ Directory Structure:
|
|
|
4234
4453
|
}
|
|
4235
4454
|
}
|
|
4236
4455
|
}
|
|
4237
|
-
// Push test cases for prompts and
|
|
4456
|
+
// Push test cases for prompts, workflows, and scripts
|
|
4457
|
+
const scriptConfigNameToId = new Map(); // "scriptName#configName" → configId
|
|
4238
4458
|
const pushMaps = {
|
|
4239
4459
|
promptKeyToId,
|
|
4240
4460
|
promptConfigNameToId,
|
|
4241
4461
|
workflowConfigNameToId,
|
|
4462
|
+
scriptConfigNameToId,
|
|
4242
4463
|
};
|
|
4243
4464
|
const promptsDir2 = join(configDir, "prompts");
|
|
4244
4465
|
if (existsSync(promptsDir2)) {
|
|
@@ -4262,6 +4483,33 @@ Directory Structure:
|
|
|
4262
4483
|
}
|
|
4263
4484
|
}
|
|
4264
4485
|
}
|
|
4486
|
+
// Push test cases for scripts (transforms). Each script's tests live at
|
|
4487
|
+
// transforms/<name>.tests/; the scriptId was recorded in
|
|
4488
|
+
// entities.scripts[name].id when the script body was pushed above.
|
|
4489
|
+
const transformsDir2 = join(configDir, "transforms");
|
|
4490
|
+
if (existsSync(transformsDir2)) {
|
|
4491
|
+
for (const file of readdirSync(transformsDir2).filter((f) => f.endsWith(".rhai"))) {
|
|
4492
|
+
const scriptName = basename(file, ".rhai");
|
|
4493
|
+
const blockId = syncState?.entities?.scripts?.[scriptName]?.id;
|
|
4494
|
+
if (!blockId)
|
|
4495
|
+
continue;
|
|
4496
|
+
const scriptTestsDir = join(transformsDir2, `${scriptName}.tests`);
|
|
4497
|
+
if (!existsSync(scriptTestsDir))
|
|
4498
|
+
continue;
|
|
4499
|
+
// Populate scriptConfigNameToId for this script so a test case that
|
|
4500
|
+
// pins a config by name resolves to a live configId.
|
|
4501
|
+
try {
|
|
4502
|
+
const { items: scriptConfigs } = await client.listScriptConfigs(resolvedAppId, blockId);
|
|
4503
|
+
for (const config of scriptConfigs) {
|
|
4504
|
+
scriptConfigNameToId.set(`${scriptName}#${config.configName}`, config.configId);
|
|
4505
|
+
}
|
|
4506
|
+
}
|
|
4507
|
+
catch {
|
|
4508
|
+
// Non-fatal — unpinned (active-version) test cases still push.
|
|
4509
|
+
}
|
|
4510
|
+
skippedCount += await pushTestCasesForBlock(client, resolvedAppId, "script", blockId, scriptName, configDir, syncState, options.dryRun, changes, pushMaps, { force: options.force });
|
|
4511
|
+
}
|
|
4512
|
+
}
|
|
4265
4513
|
divider();
|
|
4266
4514
|
// Issue #813: a gate failure surfaced by the validate-first pass (which
|
|
4267
4515
|
// also runs in dry-run mode via the server's dry-run gate endpoints).
|
|
@@ -4938,6 +5186,63 @@ Directory Structure:
|
|
|
4938
5186
|
success(`Rewrote ${migratedCount} TOML file(s) to native form.`);
|
|
4939
5187
|
}
|
|
4940
5188
|
});
|
|
5189
|
+
// migrate-v2 — convert a legacy sync directory into the unified block
|
|
5190
|
+
// layout `blocks/<type>/<key>/block.toml` (issue #1183, Phase 3). Purely
|
|
5191
|
+
// local: it reads already-pulled legacy files and writes the v2 tree. The
|
|
5192
|
+
// legacy files are left in place (legacy stays the default until v2 is
|
|
5193
|
+
// proven), so re-running push/pull is unaffected.
|
|
5194
|
+
sync
|
|
5195
|
+
.command("migrate-v2")
|
|
5196
|
+
.description("Convert legacy per-type sync files (prompts/, integrations/, workflows/, transforms/) into the v2 blocks/<type>/<key>/block.toml layout")
|
|
5197
|
+
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
5198
|
+
.option("--app <app-id>", "App ID")
|
|
5199
|
+
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
5200
|
+
.option("--dry-run", "Show what would change without writing files")
|
|
5201
|
+
.option("--force", "Overwrite existing block.toml files instead of skipping them")
|
|
5202
|
+
.action(async (appId, options) => {
|
|
5203
|
+
// Resolve appId so we land in the per-env sync dir even when the user
|
|
5204
|
+
// didn't pass `--dir`. The app ID itself isn't used for any API calls —
|
|
5205
|
+
// migrate-v2 is a purely local rewrite (like migrate-toml).
|
|
5206
|
+
const resolvedAppId = resolveAppId(appId, options);
|
|
5207
|
+
const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
|
|
5208
|
+
if (isAutoResolvedSyncDir(options.dir)) {
|
|
5209
|
+
info(`Using per-environment sync directory: ${configDir}`);
|
|
5210
|
+
}
|
|
5211
|
+
if (!existsSync(configDir)) {
|
|
5212
|
+
error(`Config directory not found: ${configDir}`);
|
|
5213
|
+
process.exit(1);
|
|
5214
|
+
}
|
|
5215
|
+
const before = detectLayout(configDir);
|
|
5216
|
+
if (!before.legacy) {
|
|
5217
|
+
info("No legacy block files found (prompts/, integrations/, workflows/, transforms/); nothing to migrate.");
|
|
5218
|
+
return;
|
|
5219
|
+
}
|
|
5220
|
+
const result = migrateLegacyToV2(configDir, {
|
|
5221
|
+
dryRun: options.dryRun,
|
|
5222
|
+
force: options.force,
|
|
5223
|
+
});
|
|
5224
|
+
for (const b of result.blocks) {
|
|
5225
|
+
const suffix = b.testsCopied > 0 ? ` (+${b.testsCopied} test case(s))` : "";
|
|
5226
|
+
if (options.dryRun) {
|
|
5227
|
+
info(`Would write blocks/${b.type === "script" ? "scripts" : b.type + "s"}/${b.key}/block.toml${suffix}`);
|
|
5228
|
+
}
|
|
5229
|
+
else {
|
|
5230
|
+
info(`Wrote blocks/${b.type === "script" ? "scripts" : b.type + "s"}/${b.key}/block.toml${suffix}`);
|
|
5231
|
+
}
|
|
5232
|
+
}
|
|
5233
|
+
for (const s of result.skipped) {
|
|
5234
|
+
warn(`Skipped ${s.type}/${s.key}: ${s.reason}`);
|
|
5235
|
+
}
|
|
5236
|
+
divider();
|
|
5237
|
+
keyValue("Migrated", result.blocks.length);
|
|
5238
|
+
keyValue("Skipped", result.skipped.length);
|
|
5239
|
+
if (options.dryRun) {
|
|
5240
|
+
info("Dry-run only — no files were modified.");
|
|
5241
|
+
}
|
|
5242
|
+
else if (result.blocks.length > 0) {
|
|
5243
|
+
success(`Converted ${result.blocks.length} block(s) to the v2 layout. Legacy files were left in place.`);
|
|
5244
|
+
}
|
|
5245
|
+
});
|
|
4941
5246
|
// Revert — restore a pre-pull snapshot (issue #578, Phase 1).
|
|
4942
5247
|
sync
|
|
4943
5248
|
.command("revert")
|