primitive-admin 1.1.0-alpha.37 → 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/database-types.js +11 -1
- package/dist/src/commands/database-types.js.map +1 -1
- package/dist/src/commands/databases.js +6 -0
- package/dist/src/commands/databases.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/init.js +77 -45
- package/dist/src/commands/init.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 +52 -7
- package/dist/src/commands/sync.js +672 -128
- 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 +235 -4
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +59 -16
- package/dist/src/lib/api-client.js +54 -2
- 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 +18 -0
- package/dist/src/lib/generated-allowlist.js.map +1 -1
- package/dist/src/lib/init-config.d.ts +13 -0
- package/dist/src/lib/init-config.js +19 -0
- package/dist/src/lib/init-config.js.map +1 -1
- package/dist/src/lib/output.d.ts +42 -5
- package/dist/src/lib/output.js +70 -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/template.d.ts +9 -5
- package/dist/src/lib/template.js +58 -45
- package/dist/src/lib/template.js.map +1 -1
- 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,13 +5,16 @@ 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";
|
|
14
|
-
import {
|
|
15
|
+
import { applyWorkflowBody } from "../lib/workflow-apply.js";
|
|
16
|
+
import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
|
|
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";
|
|
17
20
|
function ensureDir(dirPath) {
|
|
@@ -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) {
|
|
@@ -771,17 +902,33 @@ export function parseTestCaseToml(tomlData) {
|
|
|
771
902
|
/**
|
|
772
903
|
* Pull server-side `Script` rows into `transforms/*.rhai` and record
|
|
773
904
|
* each in the returned sync-state map. Issue #892 slice 7 + codex
|
|
774
|
-
* follow-up on PR #893
|
|
775
|
-
* against
|
|
905
|
+
* follow-up on PR #893; body materialization fixed in issue #1196.
|
|
906
|
+
* Exported so the unit test can drive it against stubbed
|
|
907
|
+
* `client.listScripts` / `client.getScript`.
|
|
908
|
+
*
|
|
909
|
+
* Two-call contract: `listScripts` returns the `Script` HEADER only
|
|
910
|
+
* (no `body` — see `serializeScript` in `src/admin-api.ts`). The Rhai
|
|
911
|
+
* source lives on a versioned `ScriptConfig`. So for each listed
|
|
912
|
+
* script we fetch the full record with `getScript`, which returns the
|
|
913
|
+
* header plus serialized `configs` (each carrying its `body`), and
|
|
914
|
+
* write the ACTIVE config's body. Reading the body off the list shape
|
|
915
|
+
* (the old behavior) always produced 0-byte files.
|
|
776
916
|
*
|
|
777
|
-
* Idempotency: every call writes the body the server
|
|
778
|
-
* re-running `sync pull` on an unchanged server overwrites
|
|
779
|
-
* same bytes and produces the same `contentHash`. The result
|
|
780
|
-
* always reflects the current server state for the writes performed.
|
|
917
|
+
* Idempotency: every call writes the active-config body the server
|
|
918
|
+
* returned, so re-running `sync pull` on an unchanged server overwrites
|
|
919
|
+
* with the same bytes and produces the same `contentHash`. The result
|
|
920
|
+
* map always reflects the current server state for the writes performed.
|
|
921
|
+
*
|
|
922
|
+
* `count` is the number of files actually written, not the number of
|
|
923
|
+
* scripts listed: scripts with no active config and scripts whose
|
|
924
|
+
* config fetch fails are skipped (we never clobber a `.rhai` with an
|
|
925
|
+
* empty file).
|
|
781
926
|
*
|
|
782
927
|
* Older-server graceful path: if `listScripts` rejects (e.g. older
|
|
783
928
|
* server without the route), the caller catches and treats it as
|
|
784
|
-
* "no scripts to pull", leaving the directory empty.
|
|
929
|
+
* "no scripts to pull", leaving the directory empty. A per-script
|
|
930
|
+
* `getScript` failure is caught and that one script is skipped without
|
|
931
|
+
* aborting the rest of the pull.
|
|
785
932
|
*/
|
|
786
933
|
export async function pullScripts(client, appId, configDir, logger = () => { }) {
|
|
787
934
|
const transformsDir = join(configDir, "transforms");
|
|
@@ -795,22 +942,49 @@ export async function pullScripts(client, appId, configDir, logger = () => { })
|
|
|
795
942
|
catch {
|
|
796
943
|
return { scriptEntities, count: 0 };
|
|
797
944
|
}
|
|
945
|
+
let written = 0;
|
|
798
946
|
for (const script of items) {
|
|
799
947
|
const name = script?.name;
|
|
800
948
|
if (!name)
|
|
801
949
|
continue;
|
|
950
|
+
// The body lives on the active `ScriptConfig`, not the header. Skip
|
|
951
|
+
// scripts with no active config rather than writing an empty file.
|
|
952
|
+
if (!script.activeConfigId) {
|
|
953
|
+
logger(` Skipped transforms/${name}.rhai (no active config)`);
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
// Fetch the full script (header + serialized configs with bodies).
|
|
957
|
+
// Tolerate a per-script failure: skip this one, keep the rest.
|
|
958
|
+
let full;
|
|
959
|
+
try {
|
|
960
|
+
full = await client.getScript(appId, script.scriptId);
|
|
961
|
+
}
|
|
962
|
+
catch {
|
|
963
|
+
logger(` Skipped transforms/${name}.rhai (could not fetch config)`);
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
const configs = Array.isArray(full?.configs) ? full.configs : [];
|
|
967
|
+
const activeConfigId = full?.activeConfigId ?? script.activeConfigId;
|
|
968
|
+
const activeConfig = configs.find((c) => c.configId === activeConfigId) ||
|
|
969
|
+
configs.find((c) => c.status === "active");
|
|
970
|
+
if (!activeConfig || typeof activeConfig.body !== "string") {
|
|
971
|
+
logger(` Skipped transforms/${name}.rhai (no active config body)`);
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
802
974
|
const filename = `${name}.rhai`;
|
|
803
975
|
const filePath = join(transformsDir, filename);
|
|
804
|
-
|
|
805
|
-
writeFileSync(filePath, body);
|
|
976
|
+
writeFileSync(filePath, activeConfig.body);
|
|
806
977
|
scriptEntities[name] = {
|
|
807
978
|
id: script.scriptId,
|
|
808
979
|
modifiedAt: script.modifiedAt || new Date().toISOString(),
|
|
980
|
+
// Recompute from the written file (not the server's contentHash) so
|
|
981
|
+
// it matches what `sync push`'s `shouldPushFile` gate recomputes.
|
|
809
982
|
contentHash: computeFileHash(filePath),
|
|
810
983
|
};
|
|
984
|
+
written += 1;
|
|
811
985
|
logger(` Wrote transforms/${filename}`);
|
|
812
986
|
}
|
|
813
|
-
return { scriptEntities, count:
|
|
987
|
+
return { scriptEntities, count: written };
|
|
814
988
|
}
|
|
815
989
|
async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, testCaseEntities, lookupMaps) {
|
|
816
990
|
let testCases;
|
|
@@ -920,8 +1094,10 @@ async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey
|
|
|
920
1094
|
const configMapKey = `${blockKey}#${testPayload._configName}`;
|
|
921
1095
|
const configMap = blockType === "prompt"
|
|
922
1096
|
? resolutionMaps.promptConfigNameToId
|
|
923
|
-
:
|
|
924
|
-
|
|
1097
|
+
: blockType === "script"
|
|
1098
|
+
? resolutionMaps.scriptConfigNameToId
|
|
1099
|
+
: resolutionMaps.workflowConfigNameToId;
|
|
1100
|
+
const resolvedId = configMap?.get(configMapKey);
|
|
925
1101
|
if (resolvedId) {
|
|
926
1102
|
testPayload.configId = resolvedId;
|
|
927
1103
|
}
|
|
@@ -1123,6 +1299,7 @@ Directory Structure:
|
|
|
1123
1299
|
rule-sets/*.toml # Access rule sets
|
|
1124
1300
|
group-type-configs/*.toml # Group type configs
|
|
1125
1301
|
collection-type-configs/*.toml # Collection type configs
|
|
1302
|
+
metadata-category-configs/*.toml # Resource-metadata category configs
|
|
1126
1303
|
email-templates/*.toml # Email template overrides
|
|
1127
1304
|
`);
|
|
1128
1305
|
// Init
|
|
@@ -1153,6 +1330,7 @@ Directory Structure:
|
|
|
1153
1330
|
ensureDir(join(configDir, "rule-sets"));
|
|
1154
1331
|
ensureDir(join(configDir, "group-type-configs"));
|
|
1155
1332
|
ensureDir(join(configDir, "collection-type-configs"));
|
|
1333
|
+
ensureDir(join(configDir, "metadata-category-configs"));
|
|
1156
1334
|
ensureDir(join(configDir, "email-templates"));
|
|
1157
1335
|
const state = {
|
|
1158
1336
|
appId: resolvedAppId,
|
|
@@ -1224,11 +1402,12 @@ Directory Structure:
|
|
|
1224
1402
|
return;
|
|
1225
1403
|
}
|
|
1226
1404
|
// Fetch database config resources
|
|
1227
|
-
const [databaseTypeConfigsResult, ruleSetsResult, groupTypeConfigsResult, collectionTypeConfigsResult,] = await Promise.all([
|
|
1405
|
+
const [databaseTypeConfigsResult, ruleSetsResult, groupTypeConfigsResult, collectionTypeConfigsResult, metadataCategoryConfigsResult,] = await Promise.all([
|
|
1228
1406
|
client.listDatabaseTypeConfigs(resolvedAppId).catch(() => []),
|
|
1229
1407
|
client.listRuleSets(resolvedAppId).catch(() => []),
|
|
1230
1408
|
client.listGroupTypeConfigs(resolvedAppId).catch(() => []),
|
|
1231
1409
|
client.listCollectionTypeConfigs(resolvedAppId).catch(() => []),
|
|
1410
|
+
client.listMetadataCategoryConfigs(resolvedAppId).catch(() => []),
|
|
1232
1411
|
]);
|
|
1233
1412
|
// Fetch operations + subscriptions for each database type. Issue #803:
|
|
1234
1413
|
// subscriptions are pulled symmetrically with operations so a
|
|
@@ -1287,6 +1466,7 @@ Directory Structure:
|
|
|
1287
1466
|
ensureDir(join(configDir, "rule-sets"));
|
|
1288
1467
|
ensureDir(join(configDir, "group-type-configs"));
|
|
1289
1468
|
ensureDir(join(configDir, "collection-type-configs"));
|
|
1469
|
+
ensureDir(join(configDir, "metadata-category-configs"));
|
|
1290
1470
|
ensureDir(join(configDir, "email-templates"));
|
|
1291
1471
|
// Write app settings
|
|
1292
1472
|
if (settings) {
|
|
@@ -1425,6 +1605,23 @@ Directory Structure:
|
|
|
1425
1605
|
}
|
|
1426
1606
|
}
|
|
1427
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
|
+
}
|
|
1428
1625
|
const testCaseLookupMaps = { configIdToName, promptIdToKey };
|
|
1429
1626
|
// Write email templates (use detailed emailTemplates, not summaries)
|
|
1430
1627
|
const emailTemplateEntities = {};
|
|
@@ -1467,6 +1664,15 @@ Directory Structure:
|
|
|
1467
1664
|
}
|
|
1468
1665
|
totalTestCases += count;
|
|
1469
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
|
+
}
|
|
1470
1676
|
// Build ruleSetId → name map for database types and group type configs
|
|
1471
1677
|
const ruleSets = Array.isArray(ruleSetsResult) ? ruleSetsResult : [];
|
|
1472
1678
|
const ruleSetIdToName = new Map();
|
|
@@ -1562,6 +1768,23 @@ Directory Structure:
|
|
|
1562
1768
|
};
|
|
1563
1769
|
info(` Wrote collection-type-configs/${filename}`);
|
|
1564
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
|
+
}
|
|
1565
1788
|
// Save sync state
|
|
1566
1789
|
const state = {
|
|
1567
1790
|
appId: resolvedAppId,
|
|
@@ -1584,6 +1807,9 @@ Directory Structure:
|
|
|
1584
1807
|
collectionTypeConfigs: Object.keys(collectionTypeConfigEntities).length > 0
|
|
1585
1808
|
? collectionTypeConfigEntities
|
|
1586
1809
|
: undefined,
|
|
1810
|
+
metadataCategoryConfigs: Object.keys(metadataCategoryConfigEntities).length > 0
|
|
1811
|
+
? metadataCategoryConfigEntities
|
|
1812
|
+
: undefined,
|
|
1587
1813
|
},
|
|
1588
1814
|
};
|
|
1589
1815
|
saveSyncState(configDir, state);
|
|
@@ -1602,6 +1828,7 @@ Directory Structure:
|
|
|
1602
1828
|
keyValue("Rule Sets", ruleSets.length);
|
|
1603
1829
|
keyValue("Group Type Configs", groupTypeConfigs.length);
|
|
1604
1830
|
keyValue("Collection Type Configs", collectionTypeConfigs.length);
|
|
1831
|
+
keyValue("Metadata Category Configs", metadataCategoryConfigs.length);
|
|
1605
1832
|
}
|
|
1606
1833
|
catch (err) {
|
|
1607
1834
|
error(err.message);
|
|
@@ -1877,11 +2104,10 @@ Directory Structure:
|
|
|
1877
2104
|
}
|
|
1878
2105
|
}
|
|
1879
2106
|
if (tomlData.auth) {
|
|
1880
|
-
settings
|
|
1881
|
-
settings
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
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}`);
|
|
1885
2111
|
}
|
|
1886
2112
|
}
|
|
1887
2113
|
if (tomlData.cors) {
|
|
@@ -2099,6 +2325,14 @@ Directory Structure:
|
|
|
2099
2325
|
deduplicationWindowMs: webhook.deduplicationWindowMs,
|
|
2100
2326
|
secretGracePeriodMs: webhook.secretGracePeriodMs,
|
|
2101
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
|
+
}
|
|
2102
2336
|
// Handle JSON fields
|
|
2103
2337
|
if (tomlData.allowedIps?.cidrs) {
|
|
2104
2338
|
payload.allowedIpCidrs = JSON.stringify(tomlData.allowedIps.cidrs);
|
|
@@ -2731,6 +2965,19 @@ Directory Structure:
|
|
|
2731
2965
|
const workflow = tomlData.workflow || {};
|
|
2732
2966
|
const key = workflow.key || basename(file, ".toml");
|
|
2733
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;
|
|
2734
2981
|
const existingId = syncState?.entities?.workflows?.[key]?.id;
|
|
2735
2982
|
const existingActiveConfigId = syncState?.entities?.workflows?.[key]?.activeConfigId;
|
|
2736
2983
|
// Skip if file hasn't changed since last sync. Use the expanded
|
|
@@ -2751,6 +2998,45 @@ Directory Structure:
|
|
|
2751
2998
|
}
|
|
2752
2999
|
continue;
|
|
2753
3000
|
}
|
|
3001
|
+
// Shared update body for both the in-manifest update branch and the
|
|
3002
|
+
// adopt-by-key recovery branch (#1174). The body is large and
|
|
3003
|
+
// ordering-sensitive (metadata PATCH → config/draft steps →
|
|
3004
|
+
// deferred `syncCallable` PATCH); both call sites MUST run it
|
|
3005
|
+
// identically, so it lives here as a single closure rather than
|
|
3006
|
+
// being duplicated. `activeConfigId` is passed in explicitly: the
|
|
3007
|
+
// update branch reads it from sync state, while the adopt branch
|
|
3008
|
+
// seeds it from a fresh `getWorkflow` (sync state has none for an
|
|
3009
|
+
// out-of-manifest workflow).
|
|
3010
|
+
const applyWorkflowUpdate = async (workflowId, opts) => {
|
|
3011
|
+
const { expectedModifiedAt, activeConfigId } = opts;
|
|
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
|
+
});
|
|
3023
|
+
info(` Updated workflow: ${key} (${updateSlotLabel})`);
|
|
3024
|
+
// Update sync state with new modifiedAt. Store the *expanded*
|
|
3025
|
+
// content hash so future fragment-only edits are detected. The
|
|
3026
|
+
// adopt branch seeds the entity record before calling this, so
|
|
3027
|
+
// the entry exists for both call sites.
|
|
3028
|
+
if (syncState?.entities?.workflows?.[key] && latestModifiedAt) {
|
|
3029
|
+
syncState.entities.workflows[key].modifiedAt = latestModifiedAt;
|
|
3030
|
+
syncState.entities.workflows[key].contentHash = computeExpandedContentHash(tomlData);
|
|
3031
|
+
}
|
|
3032
|
+
// `applyWorkflowBody` already fetched the full workflow; reuse it
|
|
3033
|
+
// for the config name→ID mappings (the PATCH response omits configs).
|
|
3034
|
+
if (fullWorkflow?.configs) {
|
|
3035
|
+
for (const config of fullWorkflow.configs) {
|
|
3036
|
+
workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
3039
|
+
};
|
|
2754
3040
|
if (existingId) {
|
|
2755
3041
|
// Update existing workflow
|
|
2756
3042
|
changes.push({ type: "workflow", action: "update", key });
|
|
@@ -2759,95 +3045,10 @@ Directory Structure:
|
|
|
2759
3045
|
? undefined
|
|
2760
3046
|
: syncState?.entities?.workflows?.[key]?.modifiedAt;
|
|
2761
3047
|
try {
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
// re-validates `syncCallable: true` against the workflow's
|
|
2767
|
-
// CURRENTLY-active server steps (`loadCurrentActiveSteps`),
|
|
2768
|
-
// not the steps being pushed in the same sync. So when a
|
|
2769
|
-
// single TOML edit both enables `syncCallable` and replaces
|
|
2770
|
-
// sync-incompatible active steps with compatible ones, a
|
|
2771
|
-
// combined metadata-first PATCH would be rejected against the
|
|
2772
|
-
// stale steps. Defer `syncCallable` to a second PATCH issued
|
|
2773
|
-
// AFTER the config/step update lands, so it validates against
|
|
2774
|
-
// the new (compatible) active steps. `requiresClientApply`
|
|
2775
|
-
// has no step-dependent validation, so it stays in the first
|
|
2776
|
-
// PATCH unchanged.
|
|
2777
|
-
const updated = await client.updateWorkflow(resolvedAppId, existingId, {
|
|
2778
|
-
name: workflow.name,
|
|
2779
|
-
description: workflow.description,
|
|
2780
|
-
status: workflow.status,
|
|
2781
|
-
accessRule: workflow.accessRule !== undefined ? (workflow.accessRule || null) : undefined,
|
|
2782
|
-
// #1081 — workflow principal mode. Absent key → undefined
|
|
2783
|
-
// (server leaves it untouched); explicit value/empty is
|
|
2784
|
-
// forwarded so it round-trips.
|
|
2785
|
-
runAs: workflow.runAs !== undefined ? (workflow.runAs || null) : undefined,
|
|
2786
|
-
perUserMaxRunning: workflow.perUserMaxRunning,
|
|
2787
|
-
perUserMaxQueued: workflow.perUserMaxQueued,
|
|
2788
|
-
dequeueOrder: workflow.dequeueOrder,
|
|
2789
|
-
// Sync-callable flags (#807): pass through as-is. An absent
|
|
2790
|
-
// TOML key is `undefined` (dropped by JSON.stringify, so the
|
|
2791
|
-
// server's hasOwnProperty guard leaves the value untouched);
|
|
2792
|
-
// an explicit `false` is preserved as a meaningful value.
|
|
2793
|
-
requiresClientApply: workflow.requiresClientApply,
|
|
2794
|
-
inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
|
|
2795
|
-
outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : null,
|
|
2796
|
-
}, expectedModifiedAt);
|
|
2797
|
-
// Track the latest workflow `modifiedAt` for the sync-state
|
|
2798
|
-
// write below. The first PATCH bumps it; the `syncCallable`
|
|
2799
|
-
// PATCH (if any) bumps it again. The config/step update does
|
|
2800
|
-
// NOT touch the workflow definition's `modifiedAt`.
|
|
2801
|
-
let latestModifiedAt = updated?.workflow?.modifiedAt;
|
|
2802
|
-
// Update active configuration steps (or draft for legacy).
|
|
2803
|
-
// Issue #687: name the slot we touched so the dev-loop
|
|
2804
|
-
// user can confirm before previewing.
|
|
2805
|
-
let updateSlotLabel = "active config";
|
|
2806
|
-
if (existingActiveConfigId) {
|
|
2807
|
-
await client.updateWorkflowConfig(resolvedAppId, existingId, existingActiveConfigId, {
|
|
2808
|
-
steps,
|
|
2809
|
-
});
|
|
2810
|
-
}
|
|
2811
|
-
else {
|
|
2812
|
-
// Fallback to draft update for legacy workflows
|
|
2813
|
-
await client.updateWorkflowDraft(resolvedAppId, existingId, {
|
|
2814
|
-
steps,
|
|
2815
|
-
inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
|
|
2816
|
-
});
|
|
2817
|
-
updateSlotLabel = "draft (legacy)";
|
|
2818
|
-
}
|
|
2819
|
-
// Second PATCH: apply `syncCallable` now that the new steps
|
|
2820
|
-
// are active (#807). Only sent when the TOML actually carries
|
|
2821
|
-
// the key — an absent key stays `undefined` and we skip the
|
|
2822
|
-
// call entirely, preserving the no-clobber discipline. Chain
|
|
2823
|
-
// `expectedModifiedAt` off the first PATCH's returned value so
|
|
2824
|
-
// optimistic concurrency stays intact (the step update above
|
|
2825
|
-
// doesn't change the workflow definition's `modifiedAt`).
|
|
2826
|
-
if (workflow.syncCallable !== undefined) {
|
|
2827
|
-
const syncCallableUpdated = await client.updateWorkflow(resolvedAppId, existingId, { syncCallable: workflow.syncCallable },
|
|
2828
|
-
// Mirror the first PATCH's concurrency posture: `--force`
|
|
2829
|
-
// skips the check (undefined), otherwise reuse the fresh
|
|
2830
|
-
// `modifiedAt` from the first PATCH.
|
|
2831
|
-
options.force ? undefined : latestModifiedAt);
|
|
2832
|
-
if (syncCallableUpdated?.workflow?.modifiedAt) {
|
|
2833
|
-
latestModifiedAt = syncCallableUpdated.workflow.modifiedAt;
|
|
2834
|
-
}
|
|
2835
|
-
}
|
|
2836
|
-
info(` Updated workflow: ${key} (${updateSlotLabel})`);
|
|
2837
|
-
// Update sync state with new modifiedAt. Store the *expanded*
|
|
2838
|
-
// content hash so future fragment-only edits are detected.
|
|
2839
|
-
if (syncState?.entities?.workflows?.[key] && latestModifiedAt) {
|
|
2840
|
-
syncState.entities.workflows[key].modifiedAt = latestModifiedAt;
|
|
2841
|
-
syncState.entities.workflows[key].contentHash = computeExpandedContentHash(tomlData);
|
|
2842
|
-
}
|
|
2843
|
-
// Fetch full workflow to get config name→ID mappings
|
|
2844
|
-
// (updateWorkflow response doesn't include configs)
|
|
2845
|
-
const fullWorkflow = await client.getWorkflow(resolvedAppId, existingId);
|
|
2846
|
-
if (fullWorkflow?.configs) {
|
|
2847
|
-
for (const config of fullWorkflow.configs) {
|
|
2848
|
-
workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
2849
|
-
}
|
|
2850
|
-
}
|
|
3048
|
+
await applyWorkflowUpdate(existingId, {
|
|
3049
|
+
expectedModifiedAt,
|
|
3050
|
+
activeConfigId: existingActiveConfigId,
|
|
3051
|
+
});
|
|
2851
3052
|
}
|
|
2852
3053
|
catch (err) {
|
|
2853
3054
|
if (err instanceof ConflictError) {
|
|
@@ -2888,6 +3089,9 @@ Directory Structure:
|
|
|
2888
3089
|
// syncCallable=false); an explicit value is honored.
|
|
2889
3090
|
requiresClientApply: workflow.requiresClientApply,
|
|
2890
3091
|
syncCallable: workflow.syncCallable,
|
|
3092
|
+
// #1304 (P-C): declared-access manifest (absent → undefined,
|
|
3093
|
+
// server default null).
|
|
3094
|
+
metadataManifest: workflow.metadataManifest ?? undefined,
|
|
2891
3095
|
});
|
|
2892
3096
|
info(` Created workflow: ${key}`);
|
|
2893
3097
|
// Add new entity to sync state (including activeConfigId)
|
|
@@ -2936,7 +3140,116 @@ Directory Structure:
|
|
|
2936
3140
|
}
|
|
2937
3141
|
}
|
|
2938
3142
|
catch (err) {
|
|
2939
|
-
|
|
3143
|
+
// Issue #1174: idempotent create — adopt-by-key on conflict.
|
|
3144
|
+
// A workflow can exist on the server but be absent from local
|
|
3145
|
+
// sync state — e.g. created out-of-band via the admin API
|
|
3146
|
+
// (#971), or orphaned by a prior push that aborted before
|
|
3147
|
+
// recording it. On retry this CREATE path hits the
|
|
3148
|
+
// `workflowKeyPerApp` unique constraint and the server
|
|
3149
|
+
// returns "workflowKey already exists" (HTTP 400; cron uses
|
|
3150
|
+
// 409). Rather than abort the whole push, look up the
|
|
3151
|
+
// existing workflow by key (the list endpoint is app-scoped,
|
|
3152
|
+
// so every item is owned by this app), verify the SAME key,
|
|
3153
|
+
// adopt its id into sync state, and re-issue as an UPDATE so
|
|
3154
|
+
// the push converges. Mirrors the cron-trigger recovery at
|
|
3155
|
+
// ~sync.ts:2627. Unexpected errors still surface via
|
|
3156
|
+
// `wrapEntityError`, matching the db-type precedent.
|
|
3157
|
+
const msg = String(err?.message || err);
|
|
3158
|
+
const isConflict = err?.statusCode === 409 || msg.includes("already exists");
|
|
3159
|
+
if (!isConflict) {
|
|
3160
|
+
throw wrapEntityError(err, "create", "workflow", key);
|
|
3161
|
+
}
|
|
3162
|
+
info(` Workflow already exists on server, adopting by key: ${key}`);
|
|
3163
|
+
let adoptedId;
|
|
3164
|
+
try {
|
|
3165
|
+
// Use fetchAll — listWorkflows is paginated, so a single
|
|
3166
|
+
// page could miss the by-key match on a large app.
|
|
3167
|
+
const items = await fetchAll((p) => client.listWorkflows(resolvedAppId, p));
|
|
3168
|
+
// Verify same key + app ownership: the list endpoint only
|
|
3169
|
+
// returns workflows for `resolvedAppId`, so a workflowKey
|
|
3170
|
+
// match is also an ownership match. Never overwrite an
|
|
3171
|
+
// unrelated resource — require exact key equality.
|
|
3172
|
+
const existing = (items || []).find((w) => w?.workflowKey === key);
|
|
3173
|
+
if (existing?.workflowId) {
|
|
3174
|
+
adoptedId = existing.workflowId;
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3177
|
+
catch (lookupErr) {
|
|
3178
|
+
throw wrapEntityError(new Error(`workflow "${key}" already exists but could not be adopted (lookup failed: ${String(lookupErr?.message || lookupErr)})`), "create", "workflow", key);
|
|
3179
|
+
}
|
|
3180
|
+
if (!adoptedId) {
|
|
3181
|
+
// "already exists" but no matching key found on the server —
|
|
3182
|
+
// surface the original error rather than silently
|
|
3183
|
+
// overwriting an unrelated workflow.
|
|
3184
|
+
throw wrapEntityError(err, "create", "workflow", key);
|
|
3185
|
+
}
|
|
3186
|
+
// Out-of-manifest adopt: warn (info-level), matching the cron
|
|
3187
|
+
// adopt line, and seed the sync-state entity record so the
|
|
3188
|
+
// shared update body can write modifiedAt/contentHash into it.
|
|
3189
|
+
if (syncState) {
|
|
3190
|
+
if (!syncState.entities.workflows) {
|
|
3191
|
+
syncState.entities.workflows = {};
|
|
3192
|
+
}
|
|
3193
|
+
if (!syncState.entities.workflows[key]) {
|
|
3194
|
+
syncState.entities.workflows[key] = {
|
|
3195
|
+
id: adoptedId,
|
|
3196
|
+
};
|
|
3197
|
+
}
|
|
3198
|
+
else {
|
|
3199
|
+
syncState.entities.workflows[key].id = adoptedId;
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
// Seed the real activeConfigId from the server before the
|
|
3203
|
+
// shared update body runs. Sync state has none for an
|
|
3204
|
+
// out-of-manifest workflow, so without this the shared body
|
|
3205
|
+
// would wrongly fall back to `updateWorkflowDraft`.
|
|
3206
|
+
let adoptedActiveConfigId;
|
|
3207
|
+
try {
|
|
3208
|
+
const fullWorkflow = await client.getWorkflow(resolvedAppId, adoptedId);
|
|
3209
|
+
adoptedActiveConfigId =
|
|
3210
|
+
fullWorkflow?.workflow?.activeConfigId;
|
|
3211
|
+
}
|
|
3212
|
+
catch (getErr) {
|
|
3213
|
+
throw wrapEntityError(new Error(`workflow "${key}" was adopted but could not be loaded (getWorkflow failed: ${String(getErr?.message || getErr)})`), "update", "workflow", key);
|
|
3214
|
+
}
|
|
3215
|
+
// Persist the fetched activeConfigId into the seeded
|
|
3216
|
+
// sync-state record so it round-trips identically to the
|
|
3217
|
+
// CREATE path (~sync.ts:3387). Without this the *next*
|
|
3218
|
+
// content-changing push reads `existingActiveConfigId ===
|
|
3219
|
+
// undefined` (sync.ts:3187) and wrongly falls back to the
|
|
3220
|
+
// legacy `updateWorkflowDraft` instead of
|
|
3221
|
+
// `updateWorkflowConfig` — a persisted-state divergence from
|
|
3222
|
+
// a normally-created workflow.
|
|
3223
|
+
if (syncState?.entities?.workflows?.[key] &&
|
|
3224
|
+
adoptedActiveConfigId) {
|
|
3225
|
+
syncState.entities.workflows[key].activeConfigId =
|
|
3226
|
+
adoptedActiveConfigId;
|
|
3227
|
+
}
|
|
3228
|
+
// Switch to UPDATE on the adopted workflow. First adopt has no
|
|
3229
|
+
// stored modifiedAt → omit expectedModifiedAt (force the
|
|
3230
|
+
// PATCH), matching the cron adopt + the --force path. An
|
|
3231
|
+
// unexpected ConflictError here is still adoptable resilience
|
|
3232
|
+
// scope, so collect it; any other error surfaces.
|
|
3233
|
+
try {
|
|
3234
|
+
await applyWorkflowUpdate(adoptedId, {
|
|
3235
|
+
expectedModifiedAt: undefined,
|
|
3236
|
+
activeConfigId: adoptedActiveConfigId,
|
|
3237
|
+
});
|
|
3238
|
+
info(` Adopted + updated workflow: ${key}`);
|
|
3239
|
+
}
|
|
3240
|
+
catch (updErr) {
|
|
3241
|
+
if (updErr instanceof ConflictError) {
|
|
3242
|
+
conflicts.push({
|
|
3243
|
+
type: "workflow",
|
|
3244
|
+
key,
|
|
3245
|
+
serverModifiedAt: updErr.serverModifiedAt,
|
|
3246
|
+
localModifiedAt: "unknown",
|
|
3247
|
+
});
|
|
3248
|
+
}
|
|
3249
|
+
else {
|
|
3250
|
+
throw wrapEntityError(updErr, "update", "workflow", key);
|
|
3251
|
+
}
|
|
3252
|
+
}
|
|
2940
3253
|
}
|
|
2941
3254
|
}
|
|
2942
3255
|
}
|
|
@@ -3044,6 +3357,14 @@ Directory Structure:
|
|
|
3044
3357
|
if ("timestamps" in typeConfig) {
|
|
3045
3358
|
u.timestamps = typeConfig.timestamps || null;
|
|
3046
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
|
+
}
|
|
3047
3368
|
const localHasSchema = typeof typeConfig.schema === "string" &&
|
|
3048
3369
|
typeConfig.schema.trim().length > 0;
|
|
3049
3370
|
if (localHasSchema) {
|
|
@@ -3413,7 +3734,8 @@ Directory Structure:
|
|
|
3413
3734
|
"defaultAccess" in typeConfig ||
|
|
3414
3735
|
"autoPopulatedFields" in typeConfig ||
|
|
3415
3736
|
"timestamps" in typeConfig ||
|
|
3416
|
-
"schema" in typeConfig
|
|
3737
|
+
"schema" in typeConfig ||
|
|
3738
|
+
"metadataManifest" in typeConfig;
|
|
3417
3739
|
if (wouldUpdate) {
|
|
3418
3740
|
changes.push({ type: "database-type", action: "update", key: dbType });
|
|
3419
3741
|
}
|
|
@@ -3446,6 +3768,9 @@ Directory Structure:
|
|
|
3446
3768
|
// validating against the intended shape.
|
|
3447
3769
|
if (typeConfig.schema)
|
|
3448
3770
|
createData.schema = typeConfig.schema;
|
|
3771
|
+
// Issue #1304 (P-C): declared-access manifest.
|
|
3772
|
+
if (typeConfig.metadataManifest)
|
|
3773
|
+
createData.metadataManifest = typeConfig.metadataManifest;
|
|
3449
3774
|
try {
|
|
3450
3775
|
const created = await client.createDatabaseTypeConfig(resolvedAppId, createData);
|
|
3451
3776
|
info(` Created database type: ${dbType}`);
|
|
@@ -3979,6 +4304,7 @@ Directory Structure:
|
|
|
3979
4304
|
try {
|
|
3980
4305
|
const updated = await client.updateCollectionTypeConfig(resolvedAppId, collectionType, {
|
|
3981
4306
|
ruleSetId: configData.ruleSetId ?? null,
|
|
4307
|
+
metadataManifest: configData.metadataManifest ?? null,
|
|
3982
4308
|
}, expectedModifiedAt);
|
|
3983
4309
|
info(` Updated collection type config: ${collectionType}`);
|
|
3984
4310
|
if (syncState?.entities?.collectionTypeConfigs?.[collectionType] && updated?.modifiedAt) {
|
|
@@ -4009,6 +4335,9 @@ Directory Structure:
|
|
|
4009
4335
|
const created = await client.createCollectionTypeConfig(resolvedAppId, {
|
|
4010
4336
|
collectionType,
|
|
4011
4337
|
ruleSetId: configData.ruleSetId || undefined,
|
|
4338
|
+
...(configData.metadataManifest
|
|
4339
|
+
? { metadataManifest: configData.metadataManifest }
|
|
4340
|
+
: {}),
|
|
4012
4341
|
});
|
|
4013
4342
|
info(` Created collection type config: ${collectionType}`);
|
|
4014
4343
|
if (syncState) {
|
|
@@ -4028,6 +4357,66 @@ Directory Structure:
|
|
|
4028
4357
|
}
|
|
4029
4358
|
}
|
|
4030
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
|
+
}
|
|
4031
4420
|
// Process email templates
|
|
4032
4421
|
const emailTemplatesDir = join(configDir, "email-templates");
|
|
4033
4422
|
if (existsSync(emailTemplatesDir)) {
|
|
@@ -4064,11 +4453,13 @@ Directory Structure:
|
|
|
4064
4453
|
}
|
|
4065
4454
|
}
|
|
4066
4455
|
}
|
|
4067
|
-
// Push test cases for prompts and
|
|
4456
|
+
// Push test cases for prompts, workflows, and scripts
|
|
4457
|
+
const scriptConfigNameToId = new Map(); // "scriptName#configName" → configId
|
|
4068
4458
|
const pushMaps = {
|
|
4069
4459
|
promptKeyToId,
|
|
4070
4460
|
promptConfigNameToId,
|
|
4071
4461
|
workflowConfigNameToId,
|
|
4462
|
+
scriptConfigNameToId,
|
|
4072
4463
|
};
|
|
4073
4464
|
const promptsDir2 = join(configDir, "prompts");
|
|
4074
4465
|
if (existsSync(promptsDir2)) {
|
|
@@ -4092,6 +4483,33 @@ Directory Structure:
|
|
|
4092
4483
|
}
|
|
4093
4484
|
}
|
|
4094
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
|
+
}
|
|
4095
4513
|
divider();
|
|
4096
4514
|
// Issue #813: a gate failure surfaced by the validate-first pass (which
|
|
4097
4515
|
// also runs in dry-run mode via the server's dry-run gate endpoints).
|
|
@@ -4180,13 +4598,9 @@ Directory Structure:
|
|
|
4180
4598
|
// Don't mask the original error
|
|
4181
4599
|
}
|
|
4182
4600
|
}
|
|
4183
|
-
|
|
4184
|
-
//
|
|
4185
|
-
|
|
4186
|
-
for (const detail of err.details) {
|
|
4187
|
-
console.error(` - ${typeof detail === "string" ? detail : JSON.stringify(detail)}`);
|
|
4188
|
-
}
|
|
4189
|
-
}
|
|
4601
|
+
// Print message + structured server-side validation details
|
|
4602
|
+
// (issue #684; now via the shared #1173 helper).
|
|
4603
|
+
printApiError(err);
|
|
4190
4604
|
process.exit(1);
|
|
4191
4605
|
}
|
|
4192
4606
|
});
|
|
@@ -4309,6 +4723,30 @@ Directory Structure:
|
|
|
4309
4723
|
localEmailTemplates.add(emailType);
|
|
4310
4724
|
}
|
|
4311
4725
|
}
|
|
4726
|
+
// Transforms (Rhai scripts) — issue #1196. Before this, `sync diff`
|
|
4727
|
+
// ignored scripts entirely, so a body that drifted between the local
|
|
4728
|
+
// `.rhai` and the server's active config silently read as Synced. We
|
|
4729
|
+
// list the server scripts (header only) and read the local file
|
|
4730
|
+
// contents so the comparison below can hash both sides.
|
|
4731
|
+
let scriptItemsDiff = [];
|
|
4732
|
+
try {
|
|
4733
|
+
const scriptsResult = await client.listScripts(resolvedAppId);
|
|
4734
|
+
scriptItemsDiff = scriptsResult.items || [];
|
|
4735
|
+
}
|
|
4736
|
+
catch {
|
|
4737
|
+
// Older server without the scripts route — treat as no scripts.
|
|
4738
|
+
}
|
|
4739
|
+
const remoteScripts = new Map(scriptItemsDiff
|
|
4740
|
+
.filter((s) => s?.name)
|
|
4741
|
+
.map((s) => [s.name, s]));
|
|
4742
|
+
const localScripts = new Map();
|
|
4743
|
+
const transformsDirPath = join(configDir, "transforms");
|
|
4744
|
+
if (existsSync(transformsDirPath)) {
|
|
4745
|
+
for (const file of readdirSync(transformsDirPath).filter((f) => f.endsWith(".rhai"))) {
|
|
4746
|
+
const name = basename(file, ".rhai");
|
|
4747
|
+
localScripts.set(name, readFileSync(join(transformsDirPath, file), "utf-8"));
|
|
4748
|
+
}
|
|
4749
|
+
}
|
|
4312
4750
|
// Compare
|
|
4313
4751
|
const differences = [];
|
|
4314
4752
|
// Integrations
|
|
@@ -4464,6 +4902,55 @@ Directory Structure:
|
|
|
4464
4902
|
differences.push({ type: "email-template", key, status: "remote only" });
|
|
4465
4903
|
}
|
|
4466
4904
|
}
|
|
4905
|
+
// Transforms (Rhai scripts) — issue #1196. Content-aware, mirroring
|
|
4906
|
+
// the workflow block: for scripts present on both sides we fetch the
|
|
4907
|
+
// server's active-config body (via `getScript`, the same call pull
|
|
4908
|
+
// uses) and compare it to the local `.rhai` contents. A difference is
|
|
4909
|
+
// reported as `modified`, framed as a preview of `sync pull`. A
|
|
4910
|
+
// per-script fetch failure degrades to key-only (Synced, not compared)
|
|
4911
|
+
// rather than aborting the whole diff.
|
|
4912
|
+
for (const [name, localBody] of localScripts) {
|
|
4913
|
+
const remote = remoteScripts.get(name);
|
|
4914
|
+
if (!remote) {
|
|
4915
|
+
differences.push({ type: "transform", key: name, status: "local only" });
|
|
4916
|
+
continue;
|
|
4917
|
+
}
|
|
4918
|
+
let status = "exists";
|
|
4919
|
+
let hint;
|
|
4920
|
+
try {
|
|
4921
|
+
if (!remote.activeConfigId) {
|
|
4922
|
+
// No active config server-side — nothing to compare against.
|
|
4923
|
+
status = "exists";
|
|
4924
|
+
hint = "content not compared (no active config)";
|
|
4925
|
+
}
|
|
4926
|
+
else {
|
|
4927
|
+
const full = await client.getScript(resolvedAppId, remote.scriptId);
|
|
4928
|
+
const configs = Array.isArray(full?.configs) ? full.configs : [];
|
|
4929
|
+
const activeConfigId = full?.activeConfigId ?? remote.activeConfigId;
|
|
4930
|
+
const activeConfig = configs.find((c) => c.configId === activeConfigId) ||
|
|
4931
|
+
configs.find((c) => c.status === "active");
|
|
4932
|
+
if (!activeConfig || typeof activeConfig.body !== "string") {
|
|
4933
|
+
status = "exists";
|
|
4934
|
+
hint = "content not compared (no active config body)";
|
|
4935
|
+
}
|
|
4936
|
+
else if (activeConfig.body !== localBody) {
|
|
4937
|
+
status = "modified";
|
|
4938
|
+
hint = "run `sync pull` to rewrite the local .rhai to match running state";
|
|
4939
|
+
}
|
|
4940
|
+
}
|
|
4941
|
+
}
|
|
4942
|
+
catch {
|
|
4943
|
+
// Per-script fetch failure must not abort the whole diff.
|
|
4944
|
+
status = "exists";
|
|
4945
|
+
hint = "content not compared (fetch failed)";
|
|
4946
|
+
}
|
|
4947
|
+
differences.push({ type: "transform", key: name, status, hint });
|
|
4948
|
+
}
|
|
4949
|
+
for (const name of remoteScripts.keys()) {
|
|
4950
|
+
if (!localScripts.has(name)) {
|
|
4951
|
+
differences.push({ type: "transform", key: name, status: "remote only" });
|
|
4952
|
+
}
|
|
4953
|
+
}
|
|
4467
4954
|
// Compare test cases for synced prompts and workflows
|
|
4468
4955
|
const testCaseDiffs = [];
|
|
4469
4956
|
// Helper to compare test cases for a block
|
|
@@ -4699,6 +5186,63 @@ Directory Structure:
|
|
|
4699
5186
|
success(`Rewrote ${migratedCount} TOML file(s) to native form.`);
|
|
4700
5187
|
}
|
|
4701
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
|
+
});
|
|
4702
5246
|
// Revert — restore a pre-pull snapshot (issue #578, Phase 1).
|
|
4703
5247
|
sync
|
|
4704
5248
|
.command("revert")
|