primitive-admin 1.0.52 → 1.0.54
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 +38 -10
- package/dist/bin/primitive.js +6 -0
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/auth.js +16 -6
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/blob-buckets.js +84 -7
- package/dist/src/commands/blob-buckets.js.map +1 -1
- package/dist/src/commands/catalog.js +3 -3
- package/dist/src/commands/catalog.js.map +1 -1
- package/dist/src/commands/collections.js +12 -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 +197 -74
- 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/groups.js +12 -6
- package/dist/src/commands/groups.js.map +1 -1
- package/dist/src/commands/guides.d.ts +161 -0
- package/dist/src/commands/guides.js +502 -84
- 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/integrations.js +2 -2
- package/dist/src/commands/integrations.js.map +1 -1
- package/dist/src/commands/metadata.d.ts +2 -0
- package/dist/src/commands/metadata.js +344 -0
- package/dist/src/commands/metadata.js.map +1 -0
- package/dist/src/commands/prompts.js +2 -2
- package/dist/src/commands/prompts.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 +212 -16
- package/dist/src/commands/sync.js +1688 -287
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/vars.d.ts +8 -0
- package/dist/src/commands/vars.js +110 -0
- package/dist/src/commands/vars.js.map +1 -0
- package/dist/src/commands/webhooks.js +17 -3
- package/dist/src/commands/webhooks.js.map +1 -1
- package/dist/src/commands/workflows.d.ts +30 -0
- package/dist/src/commands/workflows.js +651 -52
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +123 -19
- package/dist/src/lib/api-client.js +128 -4
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/batch.d.ts +26 -0
- package/dist/src/lib/batch.js +32 -0
- package/dist/src/lib/batch.js.map +1 -0
- 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/codegen-shared/generatedFiles.d.ts +61 -0
- package/dist/src/lib/codegen-shared/generatedFiles.js +127 -0
- package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -0
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +68 -0
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +168 -0
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -0
- package/dist/src/lib/config-toml.d.ts +10 -0
- package/dist/src/lib/config-toml.js +42 -0
- package/dist/src/lib/config-toml.js.map +1 -0
- package/dist/src/lib/config.d.ts +16 -0
- package/dist/src/lib/config.js +41 -0
- package/dist/src/lib/config.js.map +1 -1
- package/dist/src/lib/constants.d.ts +9 -0
- package/dist/src/lib/constants.js +9 -0
- package/dist/src/lib/constants.js.map +1 -1
- package/dist/src/lib/csv.d.ts +1 -2
- package/dist/src/lib/csv.js +3 -8
- package/dist/src/lib/csv.js.map +1 -1
- package/dist/src/lib/db-codegen/dbGenerator.d.ts +35 -7
- package/dist/src/lib/db-codegen/dbGenerator.js +384 -125
- package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
- package/dist/src/lib/db-codegen/dbNaming.d.ts +29 -0
- package/dist/src/lib/db-codegen/dbNaming.js +58 -0
- package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
- package/dist/src/lib/db-codegen/dbTemplates.d.ts +114 -5
- package/dist/src/lib/db-codegen/dbTemplates.js +163 -16
- package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
- package/dist/src/lib/db-codegen/dbTsTypes.d.ts +7 -3
- package/dist/src/lib/db-codegen/dbTsTypes.js +23 -1
- package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -1
- package/dist/src/lib/generated-allowlist.js +52 -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 +21 -2
- 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 +10 -19
- package/dist/src/lib/toml-database-config.js +28 -59
- package/dist/src/lib/toml-database-config.js.map +1 -1
- package/dist/src/lib/toml-metadata-config.d.ts +147 -0
- package/dist/src/lib/toml-metadata-config.js +463 -0
- package/dist/src/lib/toml-metadata-config.js.map +1 -0
- package/dist/src/lib/toml-native-form.d.ts +46 -0
- package/dist/src/lib/toml-native-form.js +78 -0
- package/dist/src/lib/toml-native-form.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 +86 -0
- package/dist/src/lib/workflow-apply.js +127 -0
- package/dist/src/lib/workflow-apply.js.map +1 -0
- package/dist/src/lib/workflow-codegen/generated-schema-descriptor.d.ts +129 -0
- package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js +269 -0
- package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js.map +1 -0
- package/dist/src/lib/workflow-codegen/generator.d.ts +74 -0
- package/dist/src/lib/workflow-codegen/generator.js +215 -0
- package/dist/src/lib/workflow-codegen/generator.js.map +1 -0
- package/dist/src/lib/workflow-codegen/naming.d.ts +33 -0
- package/dist/src/lib/workflow-codegen/naming.js +81 -0
- package/dist/src/lib/workflow-codegen/naming.js.map +1 -0
- package/dist/src/lib/workflow-codegen/schemaToTs.d.ts +64 -0
- package/dist/src/lib/workflow-codegen/schemaToTs.js +282 -0
- package/dist/src/lib/workflow-codegen/schemaToTs.js.map +1 -0
- package/dist/src/lib/workflow-fragments.js +3 -3
- package/dist/src/lib/workflow-fragments.js.map +1 -1
- package/dist/src/lib/workflow-payload.d.ts +68 -0
- package/dist/src/lib/workflow-payload.js +150 -0
- package/dist/src/lib/workflow-payload.js.map +1 -0
- package/dist/src/lib/workflow-toml-validator.d.ts +1 -1
- package/dist/src/lib/workflow-toml-validator.js +37 -1
- package/dist/src/lib/workflow-toml-validator.js.map +1 -1
- package/dist/src/types/index.d.ts +11 -0
- package/dist/src/validators.d.ts +4 -3
- package/dist/src/validators.js +6 -5
- package/dist/src/validators.js.map +1 -1
- package/package.json +5 -3
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync, rmdirSync, statSync } from "fs";
|
|
2
2
|
import { join, basename } from "path";
|
|
3
3
|
import { createHash } from "crypto";
|
|
4
|
-
import * as TOML from "@iarna/toml";
|
|
5
4
|
import { lookup as mimeLookup } from "mime-types";
|
|
6
5
|
import { ApiClient, ApiError, ConflictError, SchemaRequiredError, OperationRefError, SchemaBreaksOpsError, SchemaHasUncheckableOpsError, TomlParseError, OpsExistError, } from "../lib/api-client.js";
|
|
7
6
|
import { buildDatabaseTypeTomlData, detectExistingOperationForms, normalizeOperationFromToml, normalizeSubscriptionFromToml, SubscriptionAccessKeyConflictError, } from "../lib/toml-database-config.js";
|
|
7
|
+
import { canEmitNative } from "../lib/toml-native-form.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 { parseConfigToml, stringifyConfigToml } from "../lib/config-toml.js";
|
|
16
|
+
import { applyWorkflowBody } from "../lib/workflow-apply.js";
|
|
17
|
+
import { buildWorkflowPayloadFromToml } from "../lib/workflow-payload.js";
|
|
18
|
+
import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
|
|
19
|
+
import { success, error, printApiError, info, warn, keyValue, json, divider, } from "../lib/output.js";
|
|
15
20
|
import { confirmPrompt } from "../lib/confirm-prompt.js";
|
|
16
21
|
import chalk from "chalk";
|
|
17
22
|
function ensureDir(dirPath) {
|
|
@@ -132,30 +137,43 @@ export function shouldPushExpandedFile(parsed, storedHash) {
|
|
|
132
137
|
return computeExpandedContentHash(parsed) !== storedHash;
|
|
133
138
|
}
|
|
134
139
|
/**
|
|
135
|
-
* Model defaults for the
|
|
136
|
-
*
|
|
137
|
-
* `perUserMaxRunning`, `perUserMaxQueued`, `
|
|
138
|
-
* `
|
|
139
|
-
* (`WorkflowDefinition`):
|
|
140
|
-
* `
|
|
140
|
+
* Model defaults for the workflow-level fields that `serializeWorkflow` emits
|
|
141
|
+
* with a concrete server default the *local* file may omit (`sync.ts` workflow
|
|
142
|
+
* serializer): `perUserMaxRunning`, `perUserMaxQueued`, `perAppMaxRunning`,
|
|
143
|
+
* `perAppMaxQueued`, `queueTtlSeconds`, `dequeueOrder`, `requiresClientApply`,
|
|
144
|
+
* `syncCallable`. These mirror `models.yaml` (`WorkflowDefinition`):
|
|
145
|
+
* `perUserMaxRunning=4`, `perUserMaxQueued=100`, `perAppMaxRunning=25`,
|
|
146
|
+
* `perAppMaxQueued=10000`, `queueTtlSeconds=43200`, `dequeueOrder="fifo"`,
|
|
147
|
+
* `requiresClientApply=true`, `syncCallable=false`.
|
|
148
|
+
*
|
|
149
|
+
* `perAppMax*` / `queueTtlSeconds` were added here for #1177: once the pull
|
|
150
|
+
* serializer began emitting them (they carry a non-null model default, so a GET
|
|
151
|
+
* always returns them), an unchanged workflow using the server defaults hashed
|
|
152
|
+
* unequal to a local TOML that omits them — a false `modified` diff that made
|
|
153
|
+
* `sync pull` rewrite the file just to inject defaults. Normalizing both sides
|
|
154
|
+
* to the model default here closes that gap the same way the per-user limits do.
|
|
141
155
|
*
|
|
142
156
|
* This is deliberately NOT a generic models.yaml default importer — the model
|
|
143
|
-
* carries
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
157
|
+
* carries defaults the serializer never writes. Only the serializer-emitted
|
|
158
|
+
* surface with a concrete default matters for a content comparison, because the
|
|
159
|
+
* remote side is hashed via `serializeWorkflow`. Emit-when-set fields whose GET
|
|
160
|
+
* value is null/empty when unset (`accessRule`, `runAs`, `capabilities`) are
|
|
161
|
+
* omitted on both sides and so need no normalization here.
|
|
147
162
|
*/
|
|
148
163
|
const WORKFLOW_SERIALIZER_DEFAULTS = {
|
|
149
164
|
perUserMaxRunning: 4,
|
|
150
165
|
perUserMaxQueued: 100,
|
|
166
|
+
perAppMaxRunning: 25,
|
|
167
|
+
perAppMaxQueued: 10000,
|
|
168
|
+
queueTtlSeconds: 43200,
|
|
151
169
|
dequeueOrder: "fifo",
|
|
152
170
|
requiresClientApply: true,
|
|
153
171
|
syncCallable: false,
|
|
154
172
|
};
|
|
155
173
|
/**
|
|
156
|
-
* Normalize a parsed workflow TOML object so that any of the
|
|
157
|
-
* serializer-emitted fields the *local* file omits are filled in with
|
|
158
|
-
* model default. Used by `diff` so a hand-authored workflow that omits e.g.
|
|
174
|
+
* Normalize a parsed workflow TOML object so that any of the
|
|
175
|
+
* serializer-emitted default fields the *local* file omits are filled in with
|
|
176
|
+
* the model default. Used by `diff` so a hand-authored workflow that omits e.g.
|
|
159
177
|
* `perUserMaxRunning` compares equal to a server that defaulted it to 4
|
|
160
178
|
* (the #1175 false-positive guard) — while a server value that was
|
|
161
179
|
* *explicitly set* to a non-default (e.g. 8) still shows as Modified.
|
|
@@ -173,6 +191,25 @@ export function normalizeWorkflowTomlDefaults(parsed) {
|
|
|
173
191
|
workflow[field] = defaultValue;
|
|
174
192
|
}
|
|
175
193
|
}
|
|
194
|
+
// Schema-form canonicalization (issue #1446): a legacy file carries
|
|
195
|
+
// `inputSchema` / `outputSchema` as a JSON string, while the native form (and
|
|
196
|
+
// the remote serialization) carries a parsed object. Parse the string form to
|
|
197
|
+
// its object here so the two forms hash equal — `sync diff` stays
|
|
198
|
+
// form-independent and an unchanged schema never shows a false `modified`
|
|
199
|
+
// during the transition. This runs only on the diff-hash path
|
|
200
|
+
// (`hashWorkflowTomlForDiff`); the stored-state hash path is untouched, so
|
|
201
|
+
// there is no forced one-time re-push after upgrade.
|
|
202
|
+
for (const field of ["inputSchema", "outputSchema"]) {
|
|
203
|
+
if (typeof workflow[field] === "string") {
|
|
204
|
+
try {
|
|
205
|
+
workflow[field] = JSON.parse(workflow[field]);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// Not valid JSON — leave the raw string so a genuinely malformed value
|
|
209
|
+
// still compares by its literal content.
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
176
213
|
return { ...parsed, workflow };
|
|
177
214
|
}
|
|
178
215
|
/**
|
|
@@ -191,14 +228,97 @@ export function hashWorkflowTomlForDiff(parsed) {
|
|
|
191
228
|
* `getWorkflow` + active `getWorkflowConfig`), mirroring exactly what a fresh
|
|
192
229
|
* `sync pull` would write to disk. Serializes via `serializeWorkflow`, parses
|
|
193
230
|
* the resulting TOML (remote serialized TOML never carries `include`s, so a
|
|
194
|
-
* plain `
|
|
231
|
+
* plain `parseConfigToml` is sufficient — no fragment path needed), then hashes
|
|
195
232
|
* through `hashWorkflowTomlForDiff` so it lines up with the local-file hash.
|
|
196
233
|
*/
|
|
197
234
|
export function hashRemoteWorkflowForDiff(workflow, draft, configs) {
|
|
198
235
|
const serialized = serializeWorkflow(workflow, draft, configs || []);
|
|
199
|
-
|
|
236
|
+
// serializeWorkflow emits via smol-toml (issue #1450) and may contain a
|
|
237
|
+
// heterogeneous array, so re-parse through the TOML 1.0-compliant facade.
|
|
238
|
+
const parsed = parseConfigToml(serialized);
|
|
200
239
|
return hashWorkflowTomlForDiff(parsed);
|
|
201
240
|
}
|
|
241
|
+
// Boolean [auth] keys in app.toml that map 1:1 to app-settings fields.
|
|
242
|
+
const AUTH_BOOLEAN_KEYS = [
|
|
243
|
+
"googleOAuthEnabled",
|
|
244
|
+
"passkeyEnabled",
|
|
245
|
+
"magicLinkEnabled",
|
|
246
|
+
"appleSignInEnabled",
|
|
247
|
+
"otpEnabled",
|
|
248
|
+
];
|
|
249
|
+
// Every recognized [auth] key. Single source of truth shared by pull
|
|
250
|
+
// serialization, push parsing, and the unrecognized-key warning so the three
|
|
251
|
+
// never drift apart. `appleAudiences` is a string array and `passkeys` is the
|
|
252
|
+
// passkey RP config object; the rest are booleans.
|
|
253
|
+
const RECOGNIZED_AUTH_KEYS = new Set([
|
|
254
|
+
...AUTH_BOOLEAN_KEYS,
|
|
255
|
+
"appleAudiences",
|
|
256
|
+
"passkeys",
|
|
257
|
+
]);
|
|
258
|
+
/**
|
|
259
|
+
* Build the [auth] block of app.toml from server settings (pull direction).
|
|
260
|
+
*
|
|
261
|
+
* Apple/OTP fields round-trip alongside the legacy google/passkey/magic-link
|
|
262
|
+
* keys. Two asymmetries are handled deliberately:
|
|
263
|
+
* - `appleSignInEnabled` comes back as `boolean | null`; smol-toml throws on
|
|
264
|
+
* null, so an unset value (null) is omitted rather than written.
|
|
265
|
+
* - `appleAudiences` is omitted when empty. An empty array and an omitted key
|
|
266
|
+
* both mean "no audiences" (the server normalizes [] -> null), so omitting
|
|
267
|
+
* an empty list on pull is not lossy.
|
|
268
|
+
*/
|
|
269
|
+
export function serializeAuthBlock(settings) {
|
|
270
|
+
const auth = {
|
|
271
|
+
googleOAuthEnabled: settings.googleOAuthEnabled,
|
|
272
|
+
passkeyEnabled: settings.passkeyEnabled,
|
|
273
|
+
magicLinkEnabled: settings.magicLinkEnabled,
|
|
274
|
+
};
|
|
275
|
+
if (typeof settings.appleSignInEnabled === "boolean") {
|
|
276
|
+
auth.appleSignInEnabled = settings.appleSignInEnabled;
|
|
277
|
+
}
|
|
278
|
+
if (Array.isArray(settings.appleAudiences) &&
|
|
279
|
+
settings.appleAudiences.length > 0) {
|
|
280
|
+
auth.appleAudiences = settings.appleAudiences;
|
|
281
|
+
}
|
|
282
|
+
if (typeof settings.otpEnabled === "boolean") {
|
|
283
|
+
auth.otpEnabled = settings.otpEnabled;
|
|
284
|
+
}
|
|
285
|
+
if (settings.passkeyRpConfig) {
|
|
286
|
+
auth.passkeys = settings.passkeyRpConfig;
|
|
287
|
+
}
|
|
288
|
+
return auth;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Translate the [auth] block of app.toml into app-settings fields (push
|
|
292
|
+
* direction).
|
|
293
|
+
*
|
|
294
|
+
* Only keys actually present in the TOML are forwarded — an omitted key is left
|
|
295
|
+
* untouched so it never overwrites server state (e.g. flipping a previously-true
|
|
296
|
+
* flag to false). An explicit value, including `false`, is forwarded as-is.
|
|
297
|
+
* `appleAudiences = []` is forwarded too; the server normalizes [] -> null
|
|
298
|
+
* ("no audiences"). Keys outside RECOGNIZED_AUTH_KEYS produce a warning rather
|
|
299
|
+
* than a hard error, so a typo or a future key doesn't silently drop.
|
|
300
|
+
*/
|
|
301
|
+
export function parseAppAuthSettings(auth) {
|
|
302
|
+
const settings = {};
|
|
303
|
+
const warnings = [];
|
|
304
|
+
for (const key of AUTH_BOOLEAN_KEYS) {
|
|
305
|
+
if (key in auth)
|
|
306
|
+
settings[key] = auth[key];
|
|
307
|
+
}
|
|
308
|
+
if ("appleAudiences" in auth) {
|
|
309
|
+
settings.appleAudiences = auth.appleAudiences;
|
|
310
|
+
}
|
|
311
|
+
if ("passkeys" in auth) {
|
|
312
|
+
settings.passkeyRpConfig = auth.passkeys;
|
|
313
|
+
}
|
|
314
|
+
for (const key of Object.keys(auth)) {
|
|
315
|
+
if (!RECOGNIZED_AUTH_KEYS.has(key)) {
|
|
316
|
+
warnings.push(`Unrecognized [auth] key "${key}" in app.toml — ignored. ` +
|
|
317
|
+
`Recognized keys: ${[...RECOGNIZED_AUTH_KEYS].join(", ")}.`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return { settings, warnings };
|
|
321
|
+
}
|
|
202
322
|
// TOML serialization helpers
|
|
203
323
|
function serializeAppSettings(settings) {
|
|
204
324
|
const data = {
|
|
@@ -208,15 +328,8 @@ function serializeAppSettings(settings) {
|
|
|
208
328
|
waitlistEnabled: settings.waitlistEnabled,
|
|
209
329
|
baseUrl: settings.baseUrl,
|
|
210
330
|
},
|
|
211
|
-
auth:
|
|
212
|
-
googleOAuthEnabled: settings.googleOAuthEnabled,
|
|
213
|
-
passkeyEnabled: settings.passkeyEnabled,
|
|
214
|
-
magicLinkEnabled: settings.magicLinkEnabled,
|
|
215
|
-
},
|
|
331
|
+
auth: serializeAuthBlock(settings),
|
|
216
332
|
};
|
|
217
|
-
if (settings.passkeyRpConfig) {
|
|
218
|
-
data.auth.passkeys = settings.passkeyRpConfig;
|
|
219
|
-
}
|
|
220
333
|
if (settings.corsMode === "custom") {
|
|
221
334
|
data.cors = {
|
|
222
335
|
mode: settings.corsMode,
|
|
@@ -226,7 +339,313 @@ function serializeAppSettings(settings) {
|
|
|
226
339
|
maxAge: settings.corsMaxAge,
|
|
227
340
|
};
|
|
228
341
|
}
|
|
229
|
-
return
|
|
342
|
+
return stringifyConfigToml(data);
|
|
343
|
+
}
|
|
344
|
+
// --- Config vars (issue #1423) ---------------------------------------------
|
|
345
|
+
// Per-environment non-secret scalars, round-tripped as a single flat
|
|
346
|
+
// `vars.toml` at the sync-dir root (like app.toml). They bind as
|
|
347
|
+
// `{{ vars.KEY }}` in workflow/integration config and `vars.*` in CEL rules.
|
|
348
|
+
/** Key format shared with the vars service (`^[A-Z][A-Z0-9_]{0,63}$`). */
|
|
349
|
+
const VARS_KEY_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
350
|
+
/** SHA-256 of a var's value — the per-key content hash tracked in sync state. */
|
|
351
|
+
function hashVarValue(value) {
|
|
352
|
+
return createHash("sha256").update(value).digest("hex");
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Serialize the app config vars to a flat TOML table (keys sorted for a stable,
|
|
356
|
+
* diff-friendly file). Values are non-secret plaintext; a header comment says
|
|
357
|
+
* so loudly. An empty var set yields a comment-only file.
|
|
358
|
+
*/
|
|
359
|
+
function serializeVars(vars) {
|
|
360
|
+
const data = {};
|
|
361
|
+
for (const v of [...vars].sort((a, b) => a.key.localeCompare(b.key))) {
|
|
362
|
+
data[v.key] = String(v.value ?? "");
|
|
363
|
+
}
|
|
364
|
+
const header = "# Per-environment non-secret config vars (issue #1423).\n" +
|
|
365
|
+
"# Bind as {{ vars.KEY }} in workflow/integration config and vars.* in CEL\n" +
|
|
366
|
+
"# rules. Values are checked into the repo and NOT secret — never put a\n" +
|
|
367
|
+
"# credential here; use `primitive secrets` for that.\n\n";
|
|
368
|
+
return header + stringifyConfigToml(data);
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Decide whether `sync pull` should (over)write `vars.toml`, given the outcome
|
|
372
|
+
* of fetching the app's config vars.
|
|
373
|
+
*
|
|
374
|
+
* The distinction that matters (issue #1423 review): a SUCCESSFUL fetch that
|
|
375
|
+
* returns zero vars is safe to write — the comment-only file is the intended
|
|
376
|
+
* empty state. A FAILED fetch (transient 401/500/network) must NOT write,
|
|
377
|
+
* because `serializeVars([])` would clobber a good local `vars.toml` with a
|
|
378
|
+
* comment-only file while the pull otherwise reports success. On failure we
|
|
379
|
+
* keep the prior manifest entries so the untouched file and the sync state stay
|
|
380
|
+
* consistent.
|
|
381
|
+
*
|
|
382
|
+
* Pure and side-effect-free (the caller owns the actual `writeFileSync`) so the
|
|
383
|
+
* clobber guard is unit-testable without a live server.
|
|
384
|
+
*/
|
|
385
|
+
export function planVarsPull(outcome, priorVars) {
|
|
386
|
+
const varEntities = {};
|
|
387
|
+
if (!outcome.ok) {
|
|
388
|
+
// Preserve the prior manifest entries for the (untouched) local file.
|
|
389
|
+
if (priorVars)
|
|
390
|
+
Object.assign(varEntities, priorVars);
|
|
391
|
+
return { write: false, content: null, varEntities };
|
|
392
|
+
}
|
|
393
|
+
for (const v of outcome.vars) {
|
|
394
|
+
varEntities[v.key] = {
|
|
395
|
+
// The vars API returns `updatedAt` (see toVarResponse); record it as the
|
|
396
|
+
// baseline server timestamp so `sync push` can detect a concurrent remote
|
|
397
|
+
// edit. Fall back to `modifiedAt`/now only for older/partial responses.
|
|
398
|
+
modifiedAt: v.updatedAt || v.modifiedAt || new Date().toISOString(),
|
|
399
|
+
contentHash: hashVarValue(String(v.value ?? "")),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
return { write: true, content: serializeVars(outcome.vars), varEntities };
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Maximum var value size, in bytes — mirrors the server's `MAX_VALUE_BYTES`
|
|
406
|
+
* (app-secrets-service.ts). Kept in sync manually because the CLI can't import
|
|
407
|
+
* the Worker service module.
|
|
408
|
+
*/
|
|
409
|
+
const MAX_VAR_VALUE_BYTES = 2048;
|
|
410
|
+
/**
|
|
411
|
+
* Maximum number of vars per app — mirrors the server's `MAX_VARS_PER_APP`
|
|
412
|
+
* (app-config-vars-service.ts). Kept in sync manually because the CLI can't
|
|
413
|
+
* import the Worker service module.
|
|
414
|
+
*/
|
|
415
|
+
const MAX_VARS_PER_APP = 100;
|
|
416
|
+
/**
|
|
417
|
+
* Validate a parsed `vars.toml` table against the same key/value constraints
|
|
418
|
+
* the server enforces (key format, string type, non-empty, size cap) plus the
|
|
419
|
+
* aggregate per-app var-count cap. Returns a list of human-readable errors
|
|
420
|
+
* (empty when valid).
|
|
421
|
+
*
|
|
422
|
+
* Run in `sync push`'s up-front preflight pass (issue #1423 review) so an
|
|
423
|
+
* invalid entry — or a file that would exceed the server's `MAX_VARS_PER_APP`
|
|
424
|
+
* cap — aborts BEFORE any mutation is applied. Validating only individual
|
|
425
|
+
* entries let a 101-entry file create/update many vars before the server
|
|
426
|
+
* rejected a later create at the cap, leaving a partial push (issue #1423
|
|
427
|
+
* review pass 3). Pure and side-effect-free so it's unit-testable without a
|
|
428
|
+
* live server.
|
|
429
|
+
*/
|
|
430
|
+
export function validateVarsFile(parsedVars) {
|
|
431
|
+
const errors = [];
|
|
432
|
+
const keyCount = Object.keys(parsedVars).length;
|
|
433
|
+
if (keyCount > MAX_VARS_PER_APP) {
|
|
434
|
+
errors.push(`vars.toml declares ${keyCount} vars, exceeding the maximum of ${MAX_VARS_PER_APP} vars per app. Remove ${keyCount - MAX_VARS_PER_APP} entr${keyCount - MAX_VARS_PER_APP === 1 ? "y" : "ies"}.`);
|
|
435
|
+
}
|
|
436
|
+
for (const [key, rawValue] of Object.entries(parsedVars)) {
|
|
437
|
+
if (!VARS_KEY_RE.test(key)) {
|
|
438
|
+
errors.push(`Invalid var key "${key}" in vars.toml: must match ^[A-Z][A-Z0-9_]{0,63}$ (uppercase letters, digits, underscores; start with a letter; max 64 chars)`);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (typeof rawValue !== "string") {
|
|
442
|
+
errors.push(`Var "${key}" in vars.toml must be a string value (got ${typeof rawValue}) — quote the value.`);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
const byteLength = new TextEncoder().encode(rawValue).length;
|
|
446
|
+
if (byteLength === 0) {
|
|
447
|
+
errors.push(`Var "${key}" in vars.toml must not be empty.`);
|
|
448
|
+
}
|
|
449
|
+
else if (byteLength > MAX_VAR_VALUE_BYTES) {
|
|
450
|
+
errors.push(`Var "${key}" in vars.toml exceeds the maximum value size of ${MAX_VAR_VALUE_BYTES} bytes (got ${byteLength}).`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return errors;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Plan the config-var writes a `sync push` should make, detecting concurrent
|
|
457
|
+
* remote edits before overwriting them (issue #1423 review).
|
|
458
|
+
*
|
|
459
|
+
* This is the client-side half of a two-layer concurrency guard. The vars
|
|
460
|
+
* PUT/DELETE endpoints now honor an `expectedModifiedAt` precondition (issue
|
|
461
|
+
* #1423 review r-2 P1) and raise a `ConflictError` when the server row changed
|
|
462
|
+
* since the caller's snapshot — the atomic, server-side check every other
|
|
463
|
+
* synced entity relies on. This function is the fast fail-before-mutate layer:
|
|
464
|
+
* it compares the recorded baseline against the current remote snapshot and, for
|
|
465
|
+
* a var whose remote value has drifted from what we last synced, reports a
|
|
466
|
+
* conflict (fed into the same `conflicts[]` accumulator the other entities use)
|
|
467
|
+
* before any write is attempted. A var tracked at the baseline but absent from
|
|
468
|
+
* the remote snapshot (deleted remotely while edited locally) is remote drift
|
|
469
|
+
* too, and also reported as a conflict rather than silently recreated.
|
|
470
|
+
* Conversely, a var removed locally that is already absent remotely is NOT
|
|
471
|
+
* planned for deletion — its desired (absent) state is already met, so a DELETE
|
|
472
|
+
* would 404 and fail the push. The same drift check guards deletions: a var
|
|
473
|
+
* removed locally whose remote value no longer matches the baseline was edited
|
|
474
|
+
* remotely since the last sync, and deleting it would silently discard that
|
|
475
|
+
* edit — reported as a conflict instead. `force` skips the conflict guard,
|
|
476
|
+
* matching `sync push --force` for every other entity.
|
|
477
|
+
*
|
|
478
|
+
* Pure and side-effect-free (the caller owns the API calls and sync-state
|
|
479
|
+
* updates) so the guard is unit-testable without a live server. Assumes
|
|
480
|
+
* `parsedVars` has already passed `validateVarsFile` (values are strings). The
|
|
481
|
+
* caller normally fails the push closed when the remote snapshot can't be
|
|
482
|
+
* fetched, but under `--force` it falls back to an empty snapshot and sets
|
|
483
|
+
* `snapshotUnavailable` — which keeps removed-key deletions in the plan (they'd
|
|
484
|
+
* otherwise be mistaken for already-deleted and dropped) and leans on the apply
|
|
485
|
+
* loop's idempotent 404 handling (issue #1423 review r-2 P2).
|
|
486
|
+
*/
|
|
487
|
+
export function planVarsPush(parsedVars, baseline, remoteVars, options = {}) {
|
|
488
|
+
const remoteByKey = new Map(remoteVars.map((r) => [r.key, r]));
|
|
489
|
+
const upserts = [];
|
|
490
|
+
const conflicts = [];
|
|
491
|
+
const deletions = [];
|
|
492
|
+
let skippedCount = 0;
|
|
493
|
+
const localKeys = new Set();
|
|
494
|
+
for (const [key, value] of Object.entries(parsedVars)) {
|
|
495
|
+
localKeys.add(key);
|
|
496
|
+
const valueHash = hashVarValue(value);
|
|
497
|
+
const existing = baseline?.[key];
|
|
498
|
+
// Unchanged since last sync — skip (unless forced), mirroring the
|
|
499
|
+
// content-hash skip the other file-backed entities use.
|
|
500
|
+
if (!options.force && existing && existing.contentHash === valueHash) {
|
|
501
|
+
skippedCount++;
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
const action = existing ? "update" : "create";
|
|
505
|
+
// Concurrent-edit guard: if the remote value has drifted from the baseline
|
|
506
|
+
// we last synced (for an update) — or a var with this key was created
|
|
507
|
+
// remotely with a different value (for a create) — pushing would silently
|
|
508
|
+
// clobber that remote change. Report a conflict instead. `--force`
|
|
509
|
+
// overrides, exactly like the other entities.
|
|
510
|
+
if (!options.force) {
|
|
511
|
+
const remote = remoteByKey.get(key);
|
|
512
|
+
if (remote && typeof remote.value === "string") {
|
|
513
|
+
const remoteHash = hashVarValue(remote.value);
|
|
514
|
+
const remoteDrifted = existing?.contentHash
|
|
515
|
+
? remoteHash !== existing.contentHash
|
|
516
|
+
: remoteHash !== valueHash;
|
|
517
|
+
if (remoteDrifted) {
|
|
518
|
+
conflicts.push({
|
|
519
|
+
key,
|
|
520
|
+
serverModifiedAt: remote.updatedAt || "unknown",
|
|
521
|
+
localModifiedAt: existing?.modifiedAt || "unknown",
|
|
522
|
+
});
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
else if (existing) {
|
|
527
|
+
// Tracked at the baseline but absent from the remote snapshot: the var
|
|
528
|
+
// was deleted remotely since our last sync while we edited it locally
|
|
529
|
+
// (we only reach here past the unchanged-skip above, so the local value
|
|
530
|
+
// differs from the baseline). Upserting would recreate the
|
|
531
|
+
// remotely-deleted var — that missing remote value is remote drift too,
|
|
532
|
+
// so report a conflict instead (issue #1423 review pass 3). `--force`
|
|
533
|
+
// overrides.
|
|
534
|
+
conflicts.push({
|
|
535
|
+
key,
|
|
536
|
+
serverModifiedAt: "deleted",
|
|
537
|
+
localModifiedAt: existing.modifiedAt || "unknown",
|
|
538
|
+
});
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
upserts.push({ action, key, value, valueHash });
|
|
543
|
+
}
|
|
544
|
+
// Prune vars tracked in the baseline but removed from the local file
|
|
545
|
+
// (hard-delete; nested-entity precedent). Skip keys already absent from the
|
|
546
|
+
// remote snapshot: a var independently deleted remotely and also removed
|
|
547
|
+
// locally is already in its desired (absent) state, and a server DELETE would
|
|
548
|
+
// 404 and fail the whole push (issue #1423 review pass 3). `--force` does not
|
|
549
|
+
// resurrect these deletes — the desired state is met either way.
|
|
550
|
+
for (const [key, entry] of Object.entries(baseline ?? {})) {
|
|
551
|
+
if (localKeys.has(key))
|
|
552
|
+
continue;
|
|
553
|
+
const remote = remoteByKey.get(key);
|
|
554
|
+
if (!remote) {
|
|
555
|
+
// Normally an absent remote entry means the var is already gone, so its
|
|
556
|
+
// desired (deleted) state is met and a DELETE would 404 — skip it. But
|
|
557
|
+
// when the remote snapshot could NOT be fetched (the `--force` fallback
|
|
558
|
+
// after a failed `listAppConfigVars`, which passes an empty snapshot),
|
|
559
|
+
// "absent from the snapshot" means "unknown", not "gone". Suppressing the
|
|
560
|
+
// delete would silently keep a var the user removed from `vars.toml` while
|
|
561
|
+
// the push still reports success (issue #1423 review r-2 P2). Enqueue the
|
|
562
|
+
// removal and rely on the apply loop's idempotent 404 handling to absorb a
|
|
563
|
+
// key that really was already absent.
|
|
564
|
+
if (options.snapshotUnavailable)
|
|
565
|
+
deletions.push(key);
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
// Concurrent-edit guard, mirroring the upsert path (issue #1423 review
|
|
569
|
+
// pass 4): deleting a var whose remote value drifted from the last-synced
|
|
570
|
+
// baseline would silently discard that remote edit. When drift cannot be
|
|
571
|
+
// verified (no baseline hash, or no remote value in the snapshot), fail
|
|
572
|
+
// closed and report the conflict. `--force` deletes unconditionally.
|
|
573
|
+
if (!options.force) {
|
|
574
|
+
const remoteDrifted = typeof remote.value !== "string" ||
|
|
575
|
+
!entry.contentHash ||
|
|
576
|
+
hashVarValue(remote.value) !== entry.contentHash;
|
|
577
|
+
if (remoteDrifted) {
|
|
578
|
+
conflicts.push({
|
|
579
|
+
key,
|
|
580
|
+
serverModifiedAt: remote.updatedAt || "unknown",
|
|
581
|
+
localModifiedAt: entry.modifiedAt || "unknown",
|
|
582
|
+
});
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
deletions.push(key);
|
|
587
|
+
}
|
|
588
|
+
return { upserts, deletions, conflicts, skippedCount };
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Compute the number of config vars the server would hold after a push plan
|
|
592
|
+
* is applied (issue #1423 review pass 5). Validating only the local file's
|
|
593
|
+
* entry count misses remote-only vars: 95 local entries plus 10 vars that
|
|
594
|
+
* exist only on the server passes a local-count check, and the creates then
|
|
595
|
+
* exceed the server's cap mid-push, after earlier writes were already
|
|
596
|
+
* applied. Counted from the fetched remote snapshot: planned deletions remove
|
|
597
|
+
* keys that exist remotely (the plan already omits deletes for absent keys),
|
|
598
|
+
* and only upserts whose key is NOT already on the server add to the total.
|
|
599
|
+
* Conflicted keys are not mutated, so they contribute nothing beyond their
|
|
600
|
+
* current remote presence. Pure and side-effect-free so it's unit-testable
|
|
601
|
+
* without a live server.
|
|
602
|
+
*/
|
|
603
|
+
export function countVarsAfterPush(remoteVars, plan) {
|
|
604
|
+
const remoteKeys = new Set(remoteVars.map((r) => r.key));
|
|
605
|
+
let count = remoteKeys.size;
|
|
606
|
+
for (const key of plan.deletions) {
|
|
607
|
+
if (remoteKeys.has(key))
|
|
608
|
+
count--;
|
|
609
|
+
}
|
|
610
|
+
for (const upsert of plan.upserts) {
|
|
611
|
+
if (!remoteKeys.has(upsert.key))
|
|
612
|
+
count++;
|
|
613
|
+
}
|
|
614
|
+
return count;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Compute the `sync diff` rows for config vars (issue #1423 review). Before
|
|
618
|
+
* this, `sync diff` ignored vars entirely, so an add/remove/value-drift between
|
|
619
|
+
* the local `vars.toml` and the server read as no difference. Value-aware: a
|
|
620
|
+
* var present on both sides whose value differs is reported as `modified`
|
|
621
|
+
* (framed like the other content-aware entities — `sync pull` would rewrite the
|
|
622
|
+
* local value). Pure and side-effect-free so it's unit-testable without a live
|
|
623
|
+
* server.
|
|
624
|
+
*/
|
|
625
|
+
export function diffVars(localVars, remoteVars) {
|
|
626
|
+
const rows = [];
|
|
627
|
+
for (const [key, localValue] of localVars) {
|
|
628
|
+
if (!remoteVars.has(key)) {
|
|
629
|
+
rows.push({ type: "var", key, status: "local only" });
|
|
630
|
+
}
|
|
631
|
+
else if (remoteVars.get(key) !== localValue) {
|
|
632
|
+
rows.push({
|
|
633
|
+
type: "var",
|
|
634
|
+
key,
|
|
635
|
+
status: "modified",
|
|
636
|
+
hint: "run `sync pull` to rewrite vars.toml to match the server value",
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
rows.push({ type: "var", key, status: "exists" });
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
for (const key of remoteVars.keys()) {
|
|
644
|
+
if (!localVars.has(key)) {
|
|
645
|
+
rows.push({ type: "var", key, status: "remote only" });
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
return rows;
|
|
230
649
|
}
|
|
231
650
|
function serializeIntegration(integration) {
|
|
232
651
|
const data = {
|
|
@@ -239,9 +658,9 @@ function serializeIntegration(integration) {
|
|
|
239
658
|
},
|
|
240
659
|
requestConfig: integration.requestConfig || {},
|
|
241
660
|
};
|
|
242
|
-
return
|
|
661
|
+
return stringifyConfigToml(data);
|
|
243
662
|
}
|
|
244
|
-
function serializeWebhook(webhook) {
|
|
663
|
+
export function serializeWebhook(webhook) {
|
|
245
664
|
const data = {
|
|
246
665
|
webhook: {
|
|
247
666
|
key: webhook.webhookKey,
|
|
@@ -256,6 +675,17 @@ function serializeWebhook(webhook) {
|
|
|
256
675
|
secretGracePeriodMs: webhook.secretGracePeriodMs,
|
|
257
676
|
},
|
|
258
677
|
};
|
|
678
|
+
// Re-emit a `{{secrets.KEY}}` reference so a pull→push round-trip is lossless
|
|
679
|
+
// (#1235). The server only returns `signingSecret` when it is such a
|
|
680
|
+
// reference (a real secret is returned masked, never in plaintext); guard on
|
|
681
|
+
// the template shape here too so a masked or raw value can never be written
|
|
682
|
+
// to TOML.
|
|
683
|
+
if (typeof webhook.signingSecret === "string" &&
|
|
684
|
+
// Whitespace-tolerant (#1468) to match the server's shared resolver, so a
|
|
685
|
+
// hand-authored spaced reference `{{ secrets.KEY }}` round-trips pull→push.
|
|
686
|
+
/\{\{\s*secrets\.[A-Z][A-Z0-9_]{0,63}\s*\}\}/.test(webhook.signingSecret)) {
|
|
687
|
+
data.webhook.signingSecret = webhook.signingSecret;
|
|
688
|
+
}
|
|
259
689
|
// Parse JSON fields if they're strings
|
|
260
690
|
if (webhook.allowedIpCidrs) {
|
|
261
691
|
try {
|
|
@@ -284,7 +714,7 @@ function serializeWebhook(webhook) {
|
|
|
284
714
|
if (data.webhook[k] === undefined)
|
|
285
715
|
delete data.webhook[k];
|
|
286
716
|
});
|
|
287
|
-
return
|
|
717
|
+
return stringifyConfigToml(data);
|
|
288
718
|
}
|
|
289
719
|
function serializeCronTrigger(trigger) {
|
|
290
720
|
const data = {
|
|
@@ -320,7 +750,7 @@ function serializeCronTrigger(trigger) {
|
|
|
320
750
|
if (data.cronTrigger[k] === undefined)
|
|
321
751
|
delete data.cronTrigger[k];
|
|
322
752
|
});
|
|
323
|
-
return
|
|
753
|
+
return stringifyConfigToml(data);
|
|
324
754
|
}
|
|
325
755
|
function serializeBlobBucket(bucket) {
|
|
326
756
|
const data = {
|
|
@@ -346,7 +776,32 @@ function serializeBlobBucket(bucket) {
|
|
|
346
776
|
if (data.bucket[k] === undefined)
|
|
347
777
|
delete data.bucket[k];
|
|
348
778
|
});
|
|
349
|
-
return
|
|
779
|
+
return stringifyConfigToml(data);
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Build the PATCH payload for a blob-bucket update from local TOML.
|
|
783
|
+
*
|
|
784
|
+
* EXACT extraction of the inline builder shared by the existing-update and
|
|
785
|
+
* 409-adopt branches — do NOT "clean up" the truthiness checks. The server
|
|
786
|
+
* treats `preset` and `accessPolicy` as mutually exclusive, only clears
|
|
787
|
+
* `ruleSetId` when a preset/accessPolicy is also present, and nulls
|
|
788
|
+
* `description` only when the key is explicitly present with a falsy value.
|
|
789
|
+
* `bucketKey` and `ttlTier` are immutable and never sent. Both call sites
|
|
790
|
+
* must go through this helper so their field sets never drift.
|
|
791
|
+
*/
|
|
792
|
+
function buildBlobBucketUpdatePayload(bucket) {
|
|
793
|
+
const updatePayload = {};
|
|
794
|
+
if (bucket.preset)
|
|
795
|
+
updatePayload.preset = bucket.preset;
|
|
796
|
+
else if (bucket.accessPolicy)
|
|
797
|
+
updatePayload.accessPolicy = bucket.accessPolicy;
|
|
798
|
+
if (bucket.ruleSetId)
|
|
799
|
+
updatePayload.ruleSetId = bucket.ruleSetId;
|
|
800
|
+
if (bucket.name)
|
|
801
|
+
updatePayload.name = bucket.name;
|
|
802
|
+
if (bucket.description !== undefined)
|
|
803
|
+
updatePayload.description = bucket.description || null;
|
|
804
|
+
return updatePayload;
|
|
350
805
|
}
|
|
351
806
|
function serializePrompt(prompt) {
|
|
352
807
|
const data = {
|
|
@@ -369,9 +824,102 @@ function serializePrompt(prompt) {
|
|
|
369
824
|
userPromptTemplate: config.userPromptTemplate,
|
|
370
825
|
})),
|
|
371
826
|
};
|
|
372
|
-
return
|
|
827
|
+
return stringifyConfigToml(data);
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Emit a workflow schema field (`inputSchema` / `outputSchema`) for the TOML
|
|
831
|
+
* `[workflow]` table.
|
|
832
|
+
*
|
|
833
|
+
* Issue #1446: pull used to `JSON.stringify` both schema fields into a single
|
|
834
|
+
* escaped-JSON line — unreadable and undiffable. We now emit the parsed JSON
|
|
835
|
+
* Schema as a native `[workflow.inputSchema]` / `[workflow.outputSchema]`
|
|
836
|
+
* table whenever it round-trips faithfully (`canEmitNative === null`), falling
|
|
837
|
+
* back to the JSON string only for the values TOML silently drops (`null` /
|
|
838
|
+
* `undefined` anywhere in the schema). The empty-object schema `{}` is a real,
|
|
839
|
+
* distinct value ("accepts anything") and emits as an empty table.
|
|
840
|
+
*
|
|
841
|
+
* Returns `undefined` for an absent schema (the key is then omitted). The
|
|
842
|
+
* sponsor-approved decision is to always move to native aggressively — no
|
|
843
|
+
* form preservation — so this does not consult the existing file's form.
|
|
844
|
+
*/
|
|
845
|
+
function serializeWorkflowSchemaField(value, fieldName, workflowKey, log) {
|
|
846
|
+
if (value === undefined || value === null)
|
|
847
|
+
return undefined;
|
|
848
|
+
// Schemas arrive from the server GET as parsed objects; defensively accept a
|
|
849
|
+
// JSON string too (e.g. an inline-authored file re-serialized here).
|
|
850
|
+
let obj = value;
|
|
851
|
+
if (typeof value === "string") {
|
|
852
|
+
try {
|
|
853
|
+
obj = JSON.parse(value);
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
// Not valid JSON — keep the original string as the emitted value.
|
|
857
|
+
return value;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
if (obj && typeof obj === "object") {
|
|
861
|
+
const reason = canEmitNative(obj);
|
|
862
|
+
if (reason) {
|
|
863
|
+
log(`Workflow '${workflowKey}': cannot emit \`${fieldName}\` as native TOML (${reason}); using JSON string.`);
|
|
864
|
+
return JSON.stringify(obj);
|
|
865
|
+
}
|
|
866
|
+
return obj;
|
|
867
|
+
}
|
|
868
|
+
// A non-object schema (unexpected) — preserve it as a JSON string.
|
|
869
|
+
return JSON.stringify(obj);
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Rewrite a workflow TOML file's string-form `inputSchema` / `outputSchema` to
|
|
873
|
+
* native object form for `sync migrate-toml` (issue #1446).
|
|
874
|
+
*
|
|
875
|
+
* Purely local, no server fetch. Parses the raw source WITHOUT expanding
|
|
876
|
+
* fragment `include`s (so those directives survive the rewrite), swaps a
|
|
877
|
+
* JSON-string schema for its parsed object when it round-trips faithfully
|
|
878
|
+
* (`canEmitNative === null`), and re-serializes via `stringifyConfigToml`.
|
|
879
|
+
*
|
|
880
|
+
* Only rewrites when a field is actually converted: a file whose schemas are
|
|
881
|
+
* already native, absent, or un-representable (a `null` default) is returned
|
|
882
|
+
* with `changed: false` and its original bytes untouched — so migrate is
|
|
883
|
+
* idempotent and never reports a spurious migration or reformats a file it
|
|
884
|
+
* didn't need to touch.
|
|
885
|
+
*/
|
|
886
|
+
export function rewriteWorkflowSchemasToNative(rawToml, workflowKey = "", log = () => { }) {
|
|
887
|
+
const parsed = parseConfigToml(rawToml);
|
|
888
|
+
if (!parsed || typeof parsed !== "object" || !parsed.workflow) {
|
|
889
|
+
return { content: rawToml, changed: false };
|
|
890
|
+
}
|
|
891
|
+
const workflow = parsed.workflow;
|
|
892
|
+
let converted = false;
|
|
893
|
+
for (const field of ["inputSchema", "outputSchema"]) {
|
|
894
|
+
const value = workflow[field];
|
|
895
|
+
// Only a JSON-string schema is a migration candidate; an object is already
|
|
896
|
+
// native and an absent key needs nothing.
|
|
897
|
+
if (typeof value !== "string")
|
|
898
|
+
continue;
|
|
899
|
+
let obj;
|
|
900
|
+
try {
|
|
901
|
+
obj = JSON.parse(value);
|
|
902
|
+
}
|
|
903
|
+
catch {
|
|
904
|
+
// Not JSON — leave the literal string in place.
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
if (!obj || typeof obj !== "object")
|
|
908
|
+
continue;
|
|
909
|
+
const reason = canEmitNative(obj);
|
|
910
|
+
if (reason) {
|
|
911
|
+
log(`Workflow '${workflowKey}': cannot migrate \`${field}\` to native TOML (${reason}); leaving as JSON string.`);
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
workflow[field] = obj;
|
|
915
|
+
converted = true;
|
|
916
|
+
}
|
|
917
|
+
if (!converted)
|
|
918
|
+
return { content: rawToml, changed: false };
|
|
919
|
+
return { content: stringifyConfigToml(parsed), changed: true };
|
|
373
920
|
}
|
|
374
|
-
function serializeWorkflow(workflow, draft, configs) {
|
|
921
|
+
export function serializeWorkflow(workflow, draft, configs, logger) {
|
|
922
|
+
const log = logger ?? (() => { });
|
|
375
923
|
// Find the active config or use the first one
|
|
376
924
|
const activeConfigId = workflow.activeConfigId;
|
|
377
925
|
const activeConfig = configs?.find((c) => c.configId === activeConfigId) || configs?.[0];
|
|
@@ -391,8 +939,24 @@ function serializeWorkflow(workflow, draft, configs) {
|
|
|
391
939
|
// #1081 — workflow principal mode. Emit only when set so a fresh
|
|
392
940
|
// pull → push round-trips it without writing a noisy `runAs = ""`.
|
|
393
941
|
runAs: workflow.runAs || undefined,
|
|
942
|
+
// #1233 — opt-in sensitive capability grants. The GET response always
|
|
943
|
+
// returns an array (empty when unset); emit only when non-empty so a
|
|
944
|
+
// fresh pull → push round-trips a real value without writing a noisy
|
|
945
|
+
// `capabilities = []`. This is the load-bearing fix: before this, pull
|
|
946
|
+
// dropped capabilities entirely and a pull → push cycle silently cleared
|
|
947
|
+
// them on the server.
|
|
948
|
+
capabilities: Array.isArray(workflow.capabilities) && workflow.capabilities.length > 0
|
|
949
|
+
? workflow.capabilities
|
|
950
|
+
: undefined,
|
|
394
951
|
perUserMaxRunning: workflow.perUserMaxRunning,
|
|
395
952
|
perUserMaxQueued: workflow.perUserMaxQueued,
|
|
953
|
+
// #1177 — app-level queue limits + queue TTL. Emit only when set so a
|
|
954
|
+
// fresh pull → push round-trips them without writing noisy zero/empty
|
|
955
|
+
// keys. The shared payload builder forwards these on create AND update,
|
|
956
|
+
// so the pull serializer must surface them for the round-trip to close.
|
|
957
|
+
perAppMaxRunning: workflow.perAppMaxRunning ?? undefined,
|
|
958
|
+
perAppMaxQueued: workflow.perAppMaxQueued ?? undefined,
|
|
959
|
+
queueTtlSeconds: workflow.queueTtlSeconds ?? undefined,
|
|
396
960
|
dequeueOrder: workflow.dequeueOrder,
|
|
397
961
|
// Sync-callable flags (#807): always emit both so a fresh pull → push
|
|
398
962
|
// cycle round-trips them. The GET response normalizes these to booleans
|
|
@@ -400,9 +964,11 @@ function serializeWorkflow(workflow, draft, configs) {
|
|
|
400
964
|
// they're safe to coerce here.
|
|
401
965
|
requiresClientApply: workflow.requiresClientApply !== false,
|
|
402
966
|
syncCallable: workflow.syncCallable === true,
|
|
403
|
-
// Schemas are at workflow level
|
|
404
|
-
|
|
405
|
-
|
|
967
|
+
// Schemas are at workflow level. Emit native `[workflow.inputSchema]` /
|
|
968
|
+
// `[workflow.outputSchema]` tables when representable, JSON string only
|
|
969
|
+
// as a data-loss fallback (issue #1446).
|
|
970
|
+
inputSchema: serializeWorkflowSchemaField(workflow.inputSchema, "inputSchema", workflow.workflowKey, log),
|
|
971
|
+
outputSchema: serializeWorkflowSchemaField(workflow.outputSchema, "outputSchema", workflow.workflowKey, log),
|
|
406
972
|
},
|
|
407
973
|
// Use active config steps, fall back to draft
|
|
408
974
|
steps,
|
|
@@ -413,8 +979,12 @@ function serializeWorkflow(workflow, draft, configs) {
|
|
|
413
979
|
status: config.status,
|
|
414
980
|
isActive: config.configId === activeConfigId,
|
|
415
981
|
})) || [],
|
|
982
|
+
// #1304 (P-C): the declared-access manifest — top-level `[metadata]` /
|
|
983
|
+
// `secrets`, merged the same way the collection/database type configs do so
|
|
984
|
+
// a workflow's `md`/`secrets` declarations round-trip through pull → push.
|
|
985
|
+
...serializeDeclaredAccessManifest(workflow.metadataManifest),
|
|
416
986
|
};
|
|
417
|
-
return
|
|
987
|
+
return stringifyConfigToml(data);
|
|
418
988
|
}
|
|
419
989
|
// Database config serialization helpers
|
|
420
990
|
//
|
|
@@ -423,15 +993,17 @@ function serializeWorkflow(workflow, draft, configs) {
|
|
|
423
993
|
// rows over JSON-strings stuffed into a single field. The form is sticky
|
|
424
994
|
// per file — if the existing file uses the legacy JSON-string form, we
|
|
425
995
|
// preserve it so we don't generate surprise diffs. New files default to
|
|
426
|
-
// native.
|
|
427
|
-
//
|
|
996
|
+
// native. The round-trip serializes with `smol-toml` (TOML 1.0), so
|
|
997
|
+
// heterogeneous arrays emit natively (issue #1450); only shapes that TOML
|
|
998
|
+
// genuinely can't represent without data loss (null / undefined / an array
|
|
999
|
+
// containing null) fall back to a JSON string per field with a log message.
|
|
428
1000
|
//
|
|
429
1001
|
// Issue #666: when the type has a stored `schema` (raw TOML of `[models.*]`
|
|
430
1002
|
// blocks), `buildDatabaseTypeTomlData` parses + merges it into the same TOML
|
|
431
1003
|
// object so the result round-trips as `[models.<Name>.fields.<field>]`.
|
|
432
1004
|
function serializeDatabaseType(typeConfig, operations, ruleSetIdToName, options = {}) {
|
|
433
1005
|
const data = buildDatabaseTypeTomlData(typeConfig, operations, ruleSetIdToName, options);
|
|
434
|
-
return
|
|
1006
|
+
return stringifyConfigToml(data);
|
|
435
1007
|
}
|
|
436
1008
|
function serializeEmailTemplate(template) {
|
|
437
1009
|
// The API returns { emailType, hasOverride, override: { subject, htmlBody, textBody }, default: { ... } }
|
|
@@ -449,7 +1021,7 @@ function serializeEmailTemplate(template) {
|
|
|
449
1021
|
if (override.textBody) {
|
|
450
1022
|
data.template.textBody = override.textBody;
|
|
451
1023
|
}
|
|
452
|
-
return
|
|
1024
|
+
return stringifyConfigToml(data);
|
|
453
1025
|
}
|
|
454
1026
|
function serializeRuleSet(ruleSet) {
|
|
455
1027
|
const data = {
|
|
@@ -466,9 +1038,9 @@ function serializeRuleSet(ruleSet) {
|
|
|
466
1038
|
if (Object.keys(rules).length > 0) {
|
|
467
1039
|
data.rules = rules;
|
|
468
1040
|
}
|
|
469
|
-
return
|
|
1041
|
+
return stringifyConfigToml(data);
|
|
470
1042
|
}
|
|
471
|
-
function serializeGroupTypeConfig(config, ruleSetIdToName) {
|
|
1043
|
+
export function serializeGroupTypeConfig(config, ruleSetIdToName) {
|
|
472
1044
|
const ruleSetName = config.ruleSetId ? (ruleSetIdToName.get(config.ruleSetId) || "") : "";
|
|
473
1045
|
const data = {
|
|
474
1046
|
groupTypeConfig: {
|
|
@@ -476,8 +1048,12 @@ function serializeGroupTypeConfig(config, ruleSetIdToName) {
|
|
|
476
1048
|
ruleSetName: ruleSetName,
|
|
477
1049
|
autoAddCreator: config.autoAddCreator ?? true,
|
|
478
1050
|
},
|
|
1051
|
+
// Declared-access manifest (issue #1350, extending #1304 P-C): merge the
|
|
1052
|
+
// top-level `[metadata]` + `secrets` fragments so a pull→push→pull cycle
|
|
1053
|
+
// round-trips, exactly like the collection/database/workflow type configs.
|
|
1054
|
+
...serializeDeclaredAccessManifest(config.metadataManifest),
|
|
479
1055
|
};
|
|
480
|
-
return
|
|
1056
|
+
return stringifyConfigToml(data);
|
|
481
1057
|
}
|
|
482
1058
|
function serializeCollectionTypeConfig(config, ruleSetIdToName) {
|
|
483
1059
|
const ruleSetName = config.ruleSetId ? (ruleSetIdToName.get(config.ruleSetId) || "") : "";
|
|
@@ -486,8 +1062,11 @@ function serializeCollectionTypeConfig(config, ruleSetIdToName) {
|
|
|
486
1062
|
collectionType: config.collectionType,
|
|
487
1063
|
ruleSetName: ruleSetName,
|
|
488
1064
|
},
|
|
1065
|
+
// Declared-access manifest (issue #1304, P-C): merge the top-level
|
|
1066
|
+
// `[metadata]` + `secrets` fragments so a pull→push→pull cycle round-trips.
|
|
1067
|
+
...serializeDeclaredAccessManifest(config.metadataManifest),
|
|
489
1068
|
};
|
|
490
|
-
return
|
|
1069
|
+
return stringifyConfigToml(data);
|
|
491
1070
|
}
|
|
492
1071
|
export function parseDatabaseTypeToml(tomlData) {
|
|
493
1072
|
const typeSection = tomlData.type || {};
|
|
@@ -530,6 +1109,17 @@ export function parseDatabaseTypeToml(tomlData) {
|
|
|
530
1109
|
if (tomlData.triggers) {
|
|
531
1110
|
typeConfig.triggers = tomlData.triggers;
|
|
532
1111
|
}
|
|
1112
|
+
// Declared-access manifest (issue #1304, P-C): the top-level `[metadata]` +
|
|
1113
|
+
// `secrets` blocks. Validated locally before push; the server re-validates
|
|
1114
|
+
// (graph/schema) at persist time.
|
|
1115
|
+
const manifest = parseDeclaredAccessManifestToml(tomlData);
|
|
1116
|
+
if (manifest) {
|
|
1117
|
+
const manifestError = validateDeclaredAccessManifest(manifest);
|
|
1118
|
+
if (manifestError) {
|
|
1119
|
+
throw new Error(`Invalid metadata manifest in database type "${typeSection.databaseType || ""}": ${manifestError}`);
|
|
1120
|
+
}
|
|
1121
|
+
typeConfig.metadataManifest = manifest;
|
|
1122
|
+
}
|
|
533
1123
|
// Issue #666: extract the `[models.*]` subtree (if present) and re-serialize
|
|
534
1124
|
// it to a TOML string for the server's `schema` field.
|
|
535
1125
|
//
|
|
@@ -542,7 +1132,7 @@ export function parseDatabaseTypeToml(tomlData) {
|
|
|
542
1132
|
let schemaSerialized = null;
|
|
543
1133
|
if (tomlData.models && typeof tomlData.models === "object") {
|
|
544
1134
|
try {
|
|
545
|
-
schemaSerialized =
|
|
1135
|
+
schemaSerialized = stringifyConfigToml({ models: tomlData.models });
|
|
546
1136
|
}
|
|
547
1137
|
catch {
|
|
548
1138
|
// Fall through — server-side parser will fail with a clear
|
|
@@ -584,6 +1174,17 @@ export function parseGroupTypeConfigToml(tomlData) {
|
|
|
584
1174
|
else if (section.ruleSetId && section.ruleSetId !== "") {
|
|
585
1175
|
result.ruleSetId = section.ruleSetId;
|
|
586
1176
|
}
|
|
1177
|
+
// Declared-access manifest (issue #1350, extending #1304 P-C): the top-level
|
|
1178
|
+
// `[metadata]` + `secrets` blocks. Validated locally before push; the server
|
|
1179
|
+
// re-validates (graph/schema) at persist time.
|
|
1180
|
+
const manifest = parseDeclaredAccessManifestToml(tomlData);
|
|
1181
|
+
if (manifest) {
|
|
1182
|
+
const manifestError = validateDeclaredAccessManifest(manifest);
|
|
1183
|
+
if (manifestError) {
|
|
1184
|
+
throw new Error(`Invalid metadata manifest in group type config "${section.groupType || ""}": ${manifestError}`);
|
|
1185
|
+
}
|
|
1186
|
+
result.metadataManifest = manifest;
|
|
1187
|
+
}
|
|
587
1188
|
return result;
|
|
588
1189
|
}
|
|
589
1190
|
export function parseCollectionTypeConfigToml(tomlData) {
|
|
@@ -598,6 +1199,17 @@ export function parseCollectionTypeConfigToml(tomlData) {
|
|
|
598
1199
|
else if (section.ruleSetId && section.ruleSetId !== "") {
|
|
599
1200
|
result.ruleSetId = section.ruleSetId;
|
|
600
1201
|
}
|
|
1202
|
+
// Declared-access manifest (issue #1304, P-C): the top-level `[metadata]` +
|
|
1203
|
+
// `secrets` blocks. Validated locally before push; the server re-validates
|
|
1204
|
+
// (graph/schema) at persist time.
|
|
1205
|
+
const manifest = parseDeclaredAccessManifestToml(tomlData);
|
|
1206
|
+
if (manifest) {
|
|
1207
|
+
const manifestError = validateDeclaredAccessManifest(manifest);
|
|
1208
|
+
if (manifestError) {
|
|
1209
|
+
throw new Error(`Invalid metadata manifest in collection type config "${section.collectionType || ""}": ${manifestError}`);
|
|
1210
|
+
}
|
|
1211
|
+
result.metadataManifest = manifest;
|
|
1212
|
+
}
|
|
601
1213
|
return result;
|
|
602
1214
|
}
|
|
603
1215
|
// Parsing helpers
|
|
@@ -609,7 +1221,7 @@ export function parseCollectionTypeConfigToml(tomlData) {
|
|
|
609
1221
|
// the original object untouched.
|
|
610
1222
|
function parseTomlFile(filePath) {
|
|
611
1223
|
const content = readFileSync(filePath, "utf-8");
|
|
612
|
-
const parsed =
|
|
1224
|
+
const parsed = parseConfigToml(content);
|
|
613
1225
|
return expandWorkflowTomlData(parsed, filePath);
|
|
614
1226
|
}
|
|
615
1227
|
/**
|
|
@@ -629,17 +1241,6 @@ export async function fetchAll(listFn, pageSize = 100, maxPages = 100) {
|
|
|
629
1241
|
} while (cursor);
|
|
630
1242
|
return all;
|
|
631
1243
|
}
|
|
632
|
-
/**
|
|
633
|
-
* Safely parse a JSON field that may be a string or already an object.
|
|
634
|
-
* TOML can parse inline tables as objects, so we need to handle both.
|
|
635
|
-
*/
|
|
636
|
-
export function safeJsonParse(value) {
|
|
637
|
-
if (value === undefined || value === null)
|
|
638
|
-
return undefined;
|
|
639
|
-
if (typeof value === "string")
|
|
640
|
-
return JSON.parse(value);
|
|
641
|
-
return value; // Already an object (TOML parsed it as a table)
|
|
642
|
-
}
|
|
643
1244
|
/**
|
|
644
1245
|
* Resolve a key-based rule set name reference to an ID.
|
|
645
1246
|
* Throws if the name cannot be resolved and throwOnMissing is true.
|
|
@@ -681,7 +1282,14 @@ export function resolveSlugCollisions(slug, usedSlugs) {
|
|
|
681
1282
|
return resolved;
|
|
682
1283
|
}
|
|
683
1284
|
function getTestsDir(configDir, blockType, blockKey) {
|
|
684
|
-
|
|
1285
|
+
// Scripts (blockType "script") live under `transforms/` on disk (their
|
|
1286
|
+
// bodies are `transforms/<name>.rhai`), so their test cases sit alongside at
|
|
1287
|
+
// `transforms/<name>.tests/`. Prompts and workflows keep their own dirs.
|
|
1288
|
+
const typeDir = blockType === "prompt"
|
|
1289
|
+
? "prompts"
|
|
1290
|
+
: blockType === "script"
|
|
1291
|
+
? "transforms"
|
|
1292
|
+
: "workflows";
|
|
685
1293
|
return join(configDir, typeDir, `${blockKey}.tests`);
|
|
686
1294
|
}
|
|
687
1295
|
export function serializeTestCase(testCase, lookupMaps) {
|
|
@@ -705,7 +1313,7 @@ export function serializeTestCase(testCase, lookupMaps) {
|
|
|
705
1313
|
expectedJsonSubset: testCase.expectedJsonSubset || "{}",
|
|
706
1314
|
},
|
|
707
1315
|
};
|
|
708
|
-
return
|
|
1316
|
+
return stringifyConfigToml(data);
|
|
709
1317
|
}
|
|
710
1318
|
export function parseTestCaseToml(tomlData) {
|
|
711
1319
|
const test = tomlData.test || {};
|
|
@@ -771,17 +1379,33 @@ export function parseTestCaseToml(tomlData) {
|
|
|
771
1379
|
/**
|
|
772
1380
|
* Pull server-side `Script` rows into `transforms/*.rhai` and record
|
|
773
1381
|
* each in the returned sync-state map. Issue #892 slice 7 + codex
|
|
774
|
-
* follow-up on PR #893
|
|
775
|
-
* against
|
|
1382
|
+
* follow-up on PR #893; body materialization fixed in issue #1196.
|
|
1383
|
+
* Exported so the unit test can drive it against stubbed
|
|
1384
|
+
* `client.listScripts` / `client.getScript`.
|
|
776
1385
|
*
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
*
|
|
1386
|
+
* Two-call contract: `listScripts` returns the `Script` HEADER only
|
|
1387
|
+
* (no `body` — see `serializeScript` in `src/admin-api.ts`). The Rhai
|
|
1388
|
+
* source lives on a versioned `ScriptConfig`. So for each listed
|
|
1389
|
+
* script we fetch the full record with `getScript`, which returns the
|
|
1390
|
+
* header plus serialized `configs` (each carrying its `body`), and
|
|
1391
|
+
* write the ACTIVE config's body. Reading the body off the list shape
|
|
1392
|
+
* (the old behavior) always produced 0-byte files.
|
|
1393
|
+
*
|
|
1394
|
+
* Idempotency: every call writes the active-config body the server
|
|
1395
|
+
* returned, so re-running `sync pull` on an unchanged server overwrites
|
|
1396
|
+
* with the same bytes and produces the same `contentHash`. The result
|
|
1397
|
+
* map always reflects the current server state for the writes performed.
|
|
1398
|
+
*
|
|
1399
|
+
* `count` is the number of files actually written, not the number of
|
|
1400
|
+
* scripts listed: scripts with no active config and scripts whose
|
|
1401
|
+
* config fetch fails are skipped (we never clobber a `.rhai` with an
|
|
1402
|
+
* empty file).
|
|
781
1403
|
*
|
|
782
1404
|
* Older-server graceful path: if `listScripts` rejects (e.g. older
|
|
783
1405
|
* server without the route), the caller catches and treats it as
|
|
784
|
-
* "no scripts to pull", leaving the directory empty.
|
|
1406
|
+
* "no scripts to pull", leaving the directory empty. A per-script
|
|
1407
|
+
* `getScript` failure is caught and that one script is skipped without
|
|
1408
|
+
* aborting the rest of the pull.
|
|
785
1409
|
*/
|
|
786
1410
|
export async function pullScripts(client, appId, configDir, logger = () => { }) {
|
|
787
1411
|
const transformsDir = join(configDir, "transforms");
|
|
@@ -795,22 +1419,49 @@ export async function pullScripts(client, appId, configDir, logger = () => { })
|
|
|
795
1419
|
catch {
|
|
796
1420
|
return { scriptEntities, count: 0 };
|
|
797
1421
|
}
|
|
1422
|
+
let written = 0;
|
|
798
1423
|
for (const script of items) {
|
|
799
1424
|
const name = script?.name;
|
|
800
1425
|
if (!name)
|
|
801
1426
|
continue;
|
|
1427
|
+
// The body lives on the active `ScriptConfig`, not the header. Skip
|
|
1428
|
+
// scripts with no active config rather than writing an empty file.
|
|
1429
|
+
if (!script.activeConfigId) {
|
|
1430
|
+
logger(` Skipped transforms/${name}.rhai (no active config)`);
|
|
1431
|
+
continue;
|
|
1432
|
+
}
|
|
1433
|
+
// Fetch the full script (header + serialized configs with bodies).
|
|
1434
|
+
// Tolerate a per-script failure: skip this one, keep the rest.
|
|
1435
|
+
let full;
|
|
1436
|
+
try {
|
|
1437
|
+
full = await client.getScript(appId, script.scriptId);
|
|
1438
|
+
}
|
|
1439
|
+
catch {
|
|
1440
|
+
logger(` Skipped transforms/${name}.rhai (could not fetch config)`);
|
|
1441
|
+
continue;
|
|
1442
|
+
}
|
|
1443
|
+
const configs = Array.isArray(full?.configs) ? full.configs : [];
|
|
1444
|
+
const activeConfigId = full?.activeConfigId ?? script.activeConfigId;
|
|
1445
|
+
const activeConfig = configs.find((c) => c.configId === activeConfigId) ||
|
|
1446
|
+
configs.find((c) => c.status === "active");
|
|
1447
|
+
if (!activeConfig || typeof activeConfig.body !== "string") {
|
|
1448
|
+
logger(` Skipped transforms/${name}.rhai (no active config body)`);
|
|
1449
|
+
continue;
|
|
1450
|
+
}
|
|
802
1451
|
const filename = `${name}.rhai`;
|
|
803
1452
|
const filePath = join(transformsDir, filename);
|
|
804
|
-
|
|
805
|
-
writeFileSync(filePath, body);
|
|
1453
|
+
writeFileSync(filePath, activeConfig.body);
|
|
806
1454
|
scriptEntities[name] = {
|
|
807
1455
|
id: script.scriptId,
|
|
808
1456
|
modifiedAt: script.modifiedAt || new Date().toISOString(),
|
|
1457
|
+
// Recompute from the written file (not the server's contentHash) so
|
|
1458
|
+
// it matches what `sync push`'s `shouldPushFile` gate recomputes.
|
|
809
1459
|
contentHash: computeFileHash(filePath),
|
|
810
1460
|
};
|
|
1461
|
+
written += 1;
|
|
811
1462
|
logger(` Wrote transforms/${filename}`);
|
|
812
1463
|
}
|
|
813
|
-
return { scriptEntities, count:
|
|
1464
|
+
return { scriptEntities, count: written };
|
|
814
1465
|
}
|
|
815
1466
|
async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, testCaseEntities, lookupMaps) {
|
|
816
1467
|
let testCases;
|
|
@@ -920,8 +1571,10 @@ async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey
|
|
|
920
1571
|
const configMapKey = `${blockKey}#${testPayload._configName}`;
|
|
921
1572
|
const configMap = blockType === "prompt"
|
|
922
1573
|
? resolutionMaps.promptConfigNameToId
|
|
923
|
-
:
|
|
924
|
-
|
|
1574
|
+
: blockType === "script"
|
|
1575
|
+
? resolutionMaps.scriptConfigNameToId
|
|
1576
|
+
: resolutionMaps.workflowConfigNameToId;
|
|
1577
|
+
const resolvedId = configMap?.get(configMapKey);
|
|
925
1578
|
if (resolvedId) {
|
|
926
1579
|
testPayload.configId = resolvedId;
|
|
927
1580
|
}
|
|
@@ -1123,6 +1776,7 @@ Directory Structure:
|
|
|
1123
1776
|
rule-sets/*.toml # Access rule sets
|
|
1124
1777
|
group-type-configs/*.toml # Group type configs
|
|
1125
1778
|
collection-type-configs/*.toml # Collection type configs
|
|
1779
|
+
metadata-category-configs/*.toml # Resource-metadata category configs
|
|
1126
1780
|
email-templates/*.toml # Email template overrides
|
|
1127
1781
|
`);
|
|
1128
1782
|
// Init
|
|
@@ -1153,6 +1807,7 @@ Directory Structure:
|
|
|
1153
1807
|
ensureDir(join(configDir, "rule-sets"));
|
|
1154
1808
|
ensureDir(join(configDir, "group-type-configs"));
|
|
1155
1809
|
ensureDir(join(configDir, "collection-type-configs"));
|
|
1810
|
+
ensureDir(join(configDir, "metadata-category-configs"));
|
|
1156
1811
|
ensureDir(join(configDir, "email-templates"));
|
|
1157
1812
|
const state = {
|
|
1158
1813
|
appId: resolvedAppId,
|
|
@@ -1224,11 +1879,12 @@ Directory Structure:
|
|
|
1224
1879
|
return;
|
|
1225
1880
|
}
|
|
1226
1881
|
// Fetch database config resources
|
|
1227
|
-
const [databaseTypeConfigsResult, ruleSetsResult, groupTypeConfigsResult, collectionTypeConfigsResult,] = await Promise.all([
|
|
1882
|
+
const [databaseTypeConfigsResult, ruleSetsResult, groupTypeConfigsResult, collectionTypeConfigsResult, metadataCategoryConfigsResult,] = await Promise.all([
|
|
1228
1883
|
client.listDatabaseTypeConfigs(resolvedAppId).catch(() => []),
|
|
1229
1884
|
client.listRuleSets(resolvedAppId).catch(() => []),
|
|
1230
1885
|
client.listGroupTypeConfigs(resolvedAppId).catch(() => []),
|
|
1231
1886
|
client.listCollectionTypeConfigs(resolvedAppId).catch(() => []),
|
|
1887
|
+
client.listMetadataCategoryConfigs(resolvedAppId).catch(() => []),
|
|
1232
1888
|
]);
|
|
1233
1889
|
// Fetch operations + subscriptions for each database type. Issue #803:
|
|
1234
1890
|
// subscriptions are pulled symmetrically with operations so a
|
|
@@ -1287,6 +1943,7 @@ Directory Structure:
|
|
|
1287
1943
|
ensureDir(join(configDir, "rule-sets"));
|
|
1288
1944
|
ensureDir(join(configDir, "group-type-configs"));
|
|
1289
1945
|
ensureDir(join(configDir, "collection-type-configs"));
|
|
1946
|
+
ensureDir(join(configDir, "metadata-category-configs"));
|
|
1290
1947
|
ensureDir(join(configDir, "email-templates"));
|
|
1291
1948
|
// Write app settings
|
|
1292
1949
|
if (settings) {
|
|
@@ -1294,6 +1951,38 @@ Directory Structure:
|
|
|
1294
1951
|
writeFileSync(appTomlPath, serializeAppSettings(settings));
|
|
1295
1952
|
info(" Wrote app.toml");
|
|
1296
1953
|
}
|
|
1954
|
+
// Write config vars (issue #1423). A single flat vars.toml at the
|
|
1955
|
+
// sync-dir root (like app.toml), holding the per-environment non-secret
|
|
1956
|
+
// scalars. Written (comment-only when empty) so the managed file exists
|
|
1957
|
+
// and a later push knows the vars surface is under sync control.
|
|
1958
|
+
//
|
|
1959
|
+
// Distinguish "app genuinely has zero vars" (a successful empty
|
|
1960
|
+
// response — safe to write the comment-only file) from "fetch failed"
|
|
1961
|
+
// (a transient 401/500/network error). Previously a `.catch(() => [])`
|
|
1962
|
+
// conflated the two, so a transient error silently clobbered a good
|
|
1963
|
+
// local vars.toml with a comment-only file while reporting success. On a
|
|
1964
|
+
// fetch error we now leave the existing vars.toml untouched and preserve
|
|
1965
|
+
// the prior manifest entry so state stays consistent with the file.
|
|
1966
|
+
const varsTomlPathPull = join(configDir, "vars.toml");
|
|
1967
|
+
let varsOutcome;
|
|
1968
|
+
try {
|
|
1969
|
+
varsOutcome = { ok: true, vars: await client.listAppConfigVars(resolvedAppId) };
|
|
1970
|
+
}
|
|
1971
|
+
catch (varErr) {
|
|
1972
|
+
varsOutcome = { ok: false, error: varErr };
|
|
1973
|
+
}
|
|
1974
|
+
const varsPlan = planVarsPull(varsOutcome, varsOutcome.ok ? undefined : loadSyncState(configDir)?.entities?.vars);
|
|
1975
|
+
const varEntities = varsPlan.varEntities;
|
|
1976
|
+
if (varsPlan.write && varsPlan.content !== null) {
|
|
1977
|
+
writeFileSync(varsTomlPathPull, varsPlan.content);
|
|
1978
|
+
const count = varsOutcome.vars.length;
|
|
1979
|
+
info(` Wrote vars.toml (${count} var${count === 1 ? "" : "s"})`);
|
|
1980
|
+
}
|
|
1981
|
+
else {
|
|
1982
|
+
const err = varsOutcome.error;
|
|
1983
|
+
error(` Skipped vars.toml — failed to fetch config vars: ${err?.message ?? err}. ` +
|
|
1984
|
+
"Left the existing vars.toml untouched.");
|
|
1985
|
+
}
|
|
1297
1986
|
// Write integrations
|
|
1298
1987
|
const integrationEntities = {};
|
|
1299
1988
|
for (const integration of integrations) {
|
|
@@ -1382,7 +2071,7 @@ Directory Structure:
|
|
|
1382
2071
|
for (const { workflow, draft, configs } of workflows) {
|
|
1383
2072
|
const filename = `${workflow.workflowKey}.toml`;
|
|
1384
2073
|
const filePath = join(configDir, "workflows", filename);
|
|
1385
|
-
writeFileSync(filePath, serializeWorkflow(workflow, draft, configs || []));
|
|
2074
|
+
writeFileSync(filePath, serializeWorkflow(workflow, draft, configs || [], (msg) => info(` ${msg}`)));
|
|
1386
2075
|
// Hash the expanded (post-fragment-splice) form so subsequent pushes
|
|
1387
2076
|
// can detect fragment-only edits. See `computeExpandedContentHash`.
|
|
1388
2077
|
// Pulled workflows never carry `include`, so the expander is a
|
|
@@ -1425,6 +2114,23 @@ Directory Structure:
|
|
|
1425
2114
|
}
|
|
1426
2115
|
}
|
|
1427
2116
|
}
|
|
2117
|
+
// Include script config names so a pinned script test case
|
|
2118
|
+
// round-trips its `configName` reference (the same way prompt/workflow
|
|
2119
|
+
// configs above do). One extra `configs` fetch per pulled script.
|
|
2120
|
+
for (const [name, entity] of Object.entries(scriptEntities)) {
|
|
2121
|
+
try {
|
|
2122
|
+
const { items: scriptConfigs } = await client.listScriptConfigs(resolvedAppId, entity.id);
|
|
2123
|
+
for (const config of scriptConfigs) {
|
|
2124
|
+
configIdToName.set(config.configId, config.configName);
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
catch {
|
|
2128
|
+
// Non-fatal: a script test case pinned to a config just serializes
|
|
2129
|
+
// without a resolvable name (falls back to active on push). The
|
|
2130
|
+
// header/body still pulled fine — `name` retained for clarity.
|
|
2131
|
+
void name;
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
1428
2134
|
const testCaseLookupMaps = { configIdToName, promptIdToKey };
|
|
1429
2135
|
// Write email templates (use detailed emailTemplates, not summaries)
|
|
1430
2136
|
const emailTemplateEntities = {};
|
|
@@ -1467,6 +2173,15 @@ Directory Structure:
|
|
|
1467
2173
|
}
|
|
1468
2174
|
totalTestCases += count;
|
|
1469
2175
|
}
|
|
2176
|
+
// Pull test cases for scripts (transforms). Script blocks use
|
|
2177
|
+
// blockType "script"; their tests land under transforms/<name>.tests/.
|
|
2178
|
+
for (const [name, entity] of Object.entries(scriptEntities)) {
|
|
2179
|
+
const count = await pullTestCasesForBlock(client, resolvedAppId, "script", entity.id, name, configDir, testCaseEntities, testCaseLookupMaps);
|
|
2180
|
+
if (count > 0) {
|
|
2181
|
+
info(` Wrote ${count} test case(s) for script: ${name}`);
|
|
2182
|
+
}
|
|
2183
|
+
totalTestCases += count;
|
|
2184
|
+
}
|
|
1470
2185
|
// Build ruleSetId → name map for database types and group type configs
|
|
1471
2186
|
const ruleSets = Array.isArray(ruleSetsResult) ? ruleSetsResult : [];
|
|
1472
2187
|
const ruleSetIdToName = new Map();
|
|
@@ -1562,6 +2277,23 @@ Directory Structure:
|
|
|
1562
2277
|
};
|
|
1563
2278
|
info(` Wrote collection-type-configs/${filename}`);
|
|
1564
2279
|
}
|
|
2280
|
+
// Write metadata category configs (issue #1304, P-B). One file per
|
|
2281
|
+
// `{resourceType, category}`; the entity key is the `resourceType#category`
|
|
2282
|
+
// pair so push can match it back.
|
|
2283
|
+
const metadataCategoryConfigEntities = {};
|
|
2284
|
+
const metadataCategoryConfigs = Array.isArray(metadataCategoryConfigsResult)
|
|
2285
|
+
? metadataCategoryConfigsResult
|
|
2286
|
+
: [];
|
|
2287
|
+
for (const config of metadataCategoryConfigs) {
|
|
2288
|
+
const filename = `${config.resourceType}.${config.category}.toml`;
|
|
2289
|
+
const filePath = join(configDir, "metadata-category-configs", filename);
|
|
2290
|
+
writeFileSync(filePath, serializeMetadataCategoryConfig(config));
|
|
2291
|
+
metadataCategoryConfigEntities[`${config.resourceType}#${config.category}`] = {
|
|
2292
|
+
modifiedAt: config.modifiedAt || new Date().toISOString(),
|
|
2293
|
+
contentHash: computeFileHash(filePath),
|
|
2294
|
+
};
|
|
2295
|
+
info(` Wrote metadata-category-configs/${filename}`);
|
|
2296
|
+
}
|
|
1565
2297
|
// Save sync state
|
|
1566
2298
|
const state = {
|
|
1567
2299
|
appId: resolvedAppId,
|
|
@@ -1580,15 +2312,20 @@ Directory Structure:
|
|
|
1580
2312
|
testCases: Object.keys(testCaseEntities).length > 0 ? testCaseEntities : undefined,
|
|
1581
2313
|
databaseTypes: Object.keys(databaseTypeEntities).length > 0 ? databaseTypeEntities : undefined,
|
|
1582
2314
|
ruleSets: Object.keys(ruleSetEntities).length > 0 ? ruleSetEntities : undefined,
|
|
2315
|
+
vars: Object.keys(varEntities).length > 0 ? varEntities : undefined,
|
|
1583
2316
|
groupTypeConfigs: Object.keys(groupTypeConfigEntities).length > 0 ? groupTypeConfigEntities : undefined,
|
|
1584
2317
|
collectionTypeConfigs: Object.keys(collectionTypeConfigEntities).length > 0
|
|
1585
2318
|
? collectionTypeConfigEntities
|
|
1586
2319
|
: undefined,
|
|
2320
|
+
metadataCategoryConfigs: Object.keys(metadataCategoryConfigEntities).length > 0
|
|
2321
|
+
? metadataCategoryConfigEntities
|
|
2322
|
+
: undefined,
|
|
1587
2323
|
},
|
|
1588
2324
|
};
|
|
1589
2325
|
saveSyncState(configDir, state);
|
|
1590
2326
|
divider();
|
|
1591
2327
|
success(`Pulled configuration to ${configDir}`);
|
|
2328
|
+
keyValue("Config Vars", Object.keys(varEntities).length);
|
|
1592
2329
|
keyValue("Integrations", integrations.length);
|
|
1593
2330
|
keyValue("Webhooks", webhooks.length);
|
|
1594
2331
|
keyValue("Cron Triggers", cronTriggerItems.length);
|
|
@@ -1602,6 +2339,7 @@ Directory Structure:
|
|
|
1602
2339
|
keyValue("Rule Sets", ruleSets.length);
|
|
1603
2340
|
keyValue("Group Type Configs", groupTypeConfigs.length);
|
|
1604
2341
|
keyValue("Collection Type Configs", collectionTypeConfigs.length);
|
|
2342
|
+
keyValue("Metadata Category Configs", metadataCategoryConfigs.length);
|
|
1605
2343
|
}
|
|
1606
2344
|
catch (err) {
|
|
1607
2345
|
error(err.message);
|
|
@@ -1853,10 +2591,91 @@ Directory Structure:
|
|
|
1853
2591
|
}
|
|
1854
2592
|
}
|
|
1855
2593
|
}
|
|
2594
|
+
// Validate config vars up-front (issue #1423 review). Parse vars.toml
|
|
2595
|
+
// and check the same key/value constraints the server enforces here, in
|
|
2596
|
+
// the preflight pass, so an invalid entry aborts BEFORE any mutation
|
|
2597
|
+
// (app settings or a var upsert) is applied. Previously validation ran
|
|
2598
|
+
// mid-push, after app settings could already have been updated.
|
|
2599
|
+
const preflightVarsTomlPath = join(configDir, "vars.toml");
|
|
2600
|
+
let preflightParsedVars = null;
|
|
2601
|
+
if (existsSync(preflightVarsTomlPath)) {
|
|
2602
|
+
try {
|
|
2603
|
+
const parsed = parseTomlFile(preflightVarsTomlPath);
|
|
2604
|
+
for (const e of validateVarsFile(parsed)) {
|
|
2605
|
+
preflightValidationErrors.push(` ${e}`);
|
|
2606
|
+
}
|
|
2607
|
+
preflightParsedVars = parsed;
|
|
2608
|
+
}
|
|
2609
|
+
catch (err) {
|
|
2610
|
+
preflightValidationErrors.push(` vars.toml: ${err?.message || String(err)}`);
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
1856
2613
|
if (preflightValidationErrors.length > 0) {
|
|
1857
2614
|
throw new Error(`Aborting push: ${preflightValidationErrors.length} TOML validation error(s) — no changes were applied.\n` +
|
|
1858
2615
|
preflightValidationErrors.join("\n"));
|
|
1859
2616
|
}
|
|
2617
|
+
// Fetch the current remote config vars up-front — before ANY mutation
|
|
2618
|
+
// (issue #1423 review pass 3). `planVarsPush` needs this snapshot both
|
|
2619
|
+
// to detect concurrent remote edits and to decide which already-absent
|
|
2620
|
+
// deletes to skip. A failed fetch must FAIL CLOSED: proceeding with an
|
|
2621
|
+
// empty snapshot would silently disable the concurrency guard and let a
|
|
2622
|
+
// changed local value clobber a remote edit. Abort unless `--force`.
|
|
2623
|
+
// Fetching here (not mid-push) keeps the abort fail-before-mutate.
|
|
2624
|
+
let remoteVarsSnapshot = [];
|
|
2625
|
+
// Whether the snapshot above is authoritative. A failed fetch under
|
|
2626
|
+
// `--force` leaves it empty but UNKNOWN (not "the server has no vars") —
|
|
2627
|
+
// `planVarsPush` needs this so it doesn't mistake "absent from the empty
|
|
2628
|
+
// snapshot" for "already deleted remotely" and silently drop deletions
|
|
2629
|
+
// the user asked for (issue #1423 review r-2 P2).
|
|
2630
|
+
let varSnapshotUnavailable = false;
|
|
2631
|
+
if (existsSync(preflightVarsTomlPath)) {
|
|
2632
|
+
try {
|
|
2633
|
+
remoteVarsSnapshot = await client.listAppConfigVars(resolvedAppId);
|
|
2634
|
+
}
|
|
2635
|
+
catch (err) {
|
|
2636
|
+
if (!options.force) {
|
|
2637
|
+
throw new Error(`Aborting push: could not fetch remote config vars to check for ` +
|
|
2638
|
+
`concurrent edits (${err?.message || String(err)}) — no changes ` +
|
|
2639
|
+
`were applied. Re-run with --force to push without the ` +
|
|
2640
|
+
`concurrency guard.`);
|
|
2641
|
+
}
|
|
2642
|
+
// --force: proceed without a snapshot; the guard is intentionally
|
|
2643
|
+
// bypassed and every local var is upserted as authoritative. The
|
|
2644
|
+
// snapshot is unknown, so deletions of removed keys are still
|
|
2645
|
+
// enqueued (idempotent 404 handling absorbs already-absent keys).
|
|
2646
|
+
remoteVarsSnapshot = [];
|
|
2647
|
+
varSnapshotUnavailable = true;
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
// Plan the var writes up-front and validate the FINAL server var
|
|
2651
|
+
// count against the cap BEFORE any mutation (issue #1423 review pass
|
|
2652
|
+
// 5). `validateVarsFile` above caps the local file's entry count, but
|
|
2653
|
+
// remote-only vars (on the server, not in vars.toml) still count
|
|
2654
|
+
// toward the server's limit: 95 local entries plus 10 remote-only
|
|
2655
|
+
// vars passes the local check, then a later create is rejected at the
|
|
2656
|
+
// cap after earlier writes were already applied. Computed from the
|
|
2657
|
+
// remote snapshot plus planned creates minus planned deletions. The
|
|
2658
|
+
// check applies even with `--force`: the cap is a server hard limit,
|
|
2659
|
+
// not a conflict guard, so a push that would exceed it cannot succeed
|
|
2660
|
+
// and forcing it would only leave a partial push. (Under `--force`
|
|
2661
|
+
// with a failed snapshot fetch the remote count is unknown; the check
|
|
2662
|
+
// then degrades to the local file count, like the concurrency guard.)
|
|
2663
|
+
let varPlan = null;
|
|
2664
|
+
if (preflightParsedVars) {
|
|
2665
|
+
varPlan = planVarsPush(preflightParsedVars, syncState?.entities?.vars, remoteVarsSnapshot, { force: options.force, snapshotUnavailable: varSnapshotUnavailable });
|
|
2666
|
+
const finalVarCount = countVarsAfterPush(remoteVarsSnapshot, varPlan);
|
|
2667
|
+
if (finalVarCount > MAX_VARS_PER_APP) {
|
|
2668
|
+
const localKeys = new Set(Object.keys(preflightParsedVars));
|
|
2669
|
+
const plannedDeletes = new Set(varPlan.deletions);
|
|
2670
|
+
const remoteOnlyCount = remoteVarsSnapshot.filter((r) => !localKeys.has(r.key) && !plannedDeletes.has(r.key)).length;
|
|
2671
|
+
throw new Error(`Aborting push: it would leave ${finalVarCount} config vars on the server, ` +
|
|
2672
|
+
`exceeding the maximum of ${MAX_VARS_PER_APP} vars per app ` +
|
|
2673
|
+
`(server has ${remoteVarsSnapshot.length} vars, ${remoteOnlyCount} of ` +
|
|
2674
|
+
`which are not in vars.toml) — no changes were applied. Run ` +
|
|
2675
|
+
`\`sync pull\` to bring the remote-only vars into vars.toml, or ` +
|
|
2676
|
+
`delete unneeded vars, then push again.`);
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
1860
2679
|
// Process app settings
|
|
1861
2680
|
const appTomlPath = join(configDir, "app.toml");
|
|
1862
2681
|
if (existsSync(appTomlPath)) {
|
|
@@ -1877,11 +2696,10 @@ Directory Structure:
|
|
|
1877
2696
|
}
|
|
1878
2697
|
}
|
|
1879
2698
|
if (tomlData.auth) {
|
|
1880
|
-
settings
|
|
1881
|
-
settings
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
settings.passkeyRpConfig = tomlData.auth.passkeys;
|
|
2699
|
+
const { settings: authSettings, warnings: authWarnings } = parseAppAuthSettings(tomlData.auth);
|
|
2700
|
+
Object.assign(settings, authSettings);
|
|
2701
|
+
for (const w of authWarnings) {
|
|
2702
|
+
warn(` ${w}`);
|
|
1885
2703
|
}
|
|
1886
2704
|
}
|
|
1887
2705
|
if (tomlData.cors) {
|
|
@@ -1905,6 +2723,135 @@ Directory Structure:
|
|
|
1905
2723
|
}
|
|
1906
2724
|
}
|
|
1907
2725
|
}
|
|
2726
|
+
// Process config vars (issue #1423). Read the flat vars.toml, upsert
|
|
2727
|
+
// each key (PUT by key is idempotent — one call covers create+update),
|
|
2728
|
+
// and hard-delete keys removed from the file (nested-entity precedent:
|
|
2729
|
+
// database operations/subscriptions). Pruning runs ONLY when the file
|
|
2730
|
+
// is present, so an absent vars.toml never mass-deletes server vars.
|
|
2731
|
+
//
|
|
2732
|
+
// Key/value validation now runs up-front in the preflight pass above,
|
|
2733
|
+
// so by the time we get here the file is known-valid (values are
|
|
2734
|
+
// strings). The remote-vars snapshot was also fetched up-front (fail
|
|
2735
|
+
// closed) so `planVarsPush` can detect a concurrent remote edit and
|
|
2736
|
+
// report it as a conflict instead of silently overwriting it (review
|
|
2737
|
+
// r-2 P1), and the plan itself was computed up-front so the final
|
|
2738
|
+
// server var count could be validated against the cap before any
|
|
2739
|
+
// mutation (review pass 5).
|
|
2740
|
+
if (varPlan) {
|
|
2741
|
+
skippedCount += varPlan.skippedCount;
|
|
2742
|
+
// Feed var conflicts into the shared accumulator — same convention as
|
|
2743
|
+
// every other entity, so the end-of-run report renders them as
|
|
2744
|
+
// `CONFLICT var: KEY` and the push exits non-zero.
|
|
2745
|
+
for (const c of varPlan.conflicts) {
|
|
2746
|
+
conflicts.push({
|
|
2747
|
+
type: "var",
|
|
2748
|
+
key: c.key,
|
|
2749
|
+
serverModifiedAt: c.serverModifiedAt,
|
|
2750
|
+
localModifiedAt: c.localModifiedAt,
|
|
2751
|
+
});
|
|
2752
|
+
}
|
|
2753
|
+
// Delete removed keys BEFORE upserting new ones (issue #1423 review
|
|
2754
|
+
// pass 3). At the server's 100-var cap, a replacement (remove one key,
|
|
2755
|
+
// add another) is a valid final state, but upserting the new key first
|
|
2756
|
+
// would transiently exceed the cap and the create would be rejected.
|
|
2757
|
+
// Freeing the removed slots first lets the replacement synchronize.
|
|
2758
|
+
// Each removal prints an "Unsetting var X" line so the destructive
|
|
2759
|
+
// action is visible.
|
|
2760
|
+
for (const key of varPlan.deletions) {
|
|
2761
|
+
changes.push({ type: "var", action: "delete", key });
|
|
2762
|
+
if (!options.dryRun) {
|
|
2763
|
+
// Server-side concurrency guard (issue #1423 review r-2 P1): send
|
|
2764
|
+
// the `updatedAt` we last synced so the server rejects the delete
|
|
2765
|
+
// (409 CONFLICT) if the var was edited remotely since — closing the
|
|
2766
|
+
// race between the client snapshot and this DELETE that the
|
|
2767
|
+
// client-side `planVarsPush` guard alone can't. `--force` omits it
|
|
2768
|
+
// and deletes unconditionally.
|
|
2769
|
+
const expectedModifiedAt = options.force
|
|
2770
|
+
? undefined
|
|
2771
|
+
: syncState?.entities?.vars?.[key]?.modifiedAt;
|
|
2772
|
+
try {
|
|
2773
|
+
await client.deleteAppConfigVar(resolvedAppId, key, expectedModifiedAt);
|
|
2774
|
+
info(` Unsetting var ${key}`);
|
|
2775
|
+
if (syncState?.entities?.vars)
|
|
2776
|
+
delete syncState.entities.vars[key];
|
|
2777
|
+
}
|
|
2778
|
+
catch (err) {
|
|
2779
|
+
// A remote edit landed since our snapshot — report a conflict
|
|
2780
|
+
// instead of discarding it (feeds the shared accumulator →
|
|
2781
|
+
// `CONFLICT var: KEY`, non-zero exit).
|
|
2782
|
+
if (err instanceof ConflictError) {
|
|
2783
|
+
conflicts.push({
|
|
2784
|
+
type: "var",
|
|
2785
|
+
key,
|
|
2786
|
+
serverModifiedAt: err.serverModifiedAt,
|
|
2787
|
+
localModifiedAt: expectedModifiedAt || "unknown",
|
|
2788
|
+
});
|
|
2789
|
+
}
|
|
2790
|
+
else if (err instanceof ApiError && err.statusCode === 404) {
|
|
2791
|
+
// A 404 means the var is already absent — the desired
|
|
2792
|
+
// (deleted) state is met, so treat it as an idempotent success
|
|
2793
|
+
// rather than failing the whole push (issue #1423 review pass
|
|
2794
|
+
// 3). The plan omits deletes for keys absent from a fetched
|
|
2795
|
+
// snapshot; this also absorbs the forced no-snapshot fallback
|
|
2796
|
+
// (r-2 P2), where removed keys are enqueued unconditionally.
|
|
2797
|
+
info(` Var ${key} already absent (nothing to unset)`);
|
|
2798
|
+
if (syncState?.entities?.vars)
|
|
2799
|
+
delete syncState.entities.vars[key];
|
|
2800
|
+
}
|
|
2801
|
+
else {
|
|
2802
|
+
throw wrapEntityError(err, "delete", "var", key);
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
for (const item of varPlan.upserts) {
|
|
2808
|
+
changes.push({ type: "var", action: item.action, key: item.key });
|
|
2809
|
+
if (!options.dryRun) {
|
|
2810
|
+
// Server-side concurrency guard (issue #1423 review r-2 P1): for an
|
|
2811
|
+
// update, send the `updatedAt` we last synced so the server rejects
|
|
2812
|
+
// the write (409 CONFLICT) if the var changed remotely since — this
|
|
2813
|
+
// closes the snapshot→PUT race the client-side guard can't. A
|
|
2814
|
+
// create has no baseline, so instead it sends a create-only
|
|
2815
|
+
// precondition (`expectNotExists`, review r-3 P1a): the server 409s
|
|
2816
|
+
// if the key was created remotely since our snapshot rather than
|
|
2817
|
+
// silently overwriting that concurrent create. `--force` overrides
|
|
2818
|
+
// both.
|
|
2819
|
+
const isCreate = item.action === "create";
|
|
2820
|
+
const expectedModifiedAt = options.force || isCreate
|
|
2821
|
+
? undefined
|
|
2822
|
+
: syncState?.entities?.vars?.[item.key]?.modifiedAt;
|
|
2823
|
+
try {
|
|
2824
|
+
const result = await client.upsertAppConfigVar(resolvedAppId, item.key, { value: item.value }, expectedModifiedAt, { expectNotExists: !options.force && isCreate });
|
|
2825
|
+
if (syncState) {
|
|
2826
|
+
if (!syncState.entities.vars)
|
|
2827
|
+
syncState.entities.vars = {};
|
|
2828
|
+
syncState.entities.vars[item.key] = {
|
|
2829
|
+
// Record the server's returned timestamp (updatedAt) so the
|
|
2830
|
+
// next push's concurrent-edit guard has an accurate baseline.
|
|
2831
|
+
modifiedAt: result?.updatedAt || result?.modifiedAt || new Date().toISOString(),
|
|
2832
|
+
contentHash: item.valueHash,
|
|
2833
|
+
};
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
catch (err) {
|
|
2837
|
+
// A remote edit landed since our snapshot — report a conflict
|
|
2838
|
+
// instead of clobbering it (feeds the shared accumulator →
|
|
2839
|
+
// `CONFLICT var: KEY`, non-zero exit).
|
|
2840
|
+
if (err instanceof ConflictError) {
|
|
2841
|
+
conflicts.push({
|
|
2842
|
+
type: "var",
|
|
2843
|
+
key: item.key,
|
|
2844
|
+
serverModifiedAt: err.serverModifiedAt,
|
|
2845
|
+
localModifiedAt: expectedModifiedAt || "unknown",
|
|
2846
|
+
});
|
|
2847
|
+
}
|
|
2848
|
+
else {
|
|
2849
|
+
throw wrapEntityError(err, item.action, "var", item.key);
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
}
|
|
1908
2855
|
// Process rule sets first (other entities may reference them by name)
|
|
1909
2856
|
const ruleSetsDir = join(configDir, "rule-sets");
|
|
1910
2857
|
if (existsSync(ruleSetsDir)) {
|
|
@@ -2099,6 +3046,14 @@ Directory Structure:
|
|
|
2099
3046
|
deduplicationWindowMs: webhook.deduplicationWindowMs,
|
|
2100
3047
|
secretGracePeriodMs: webhook.secretGracePeriodMs,
|
|
2101
3048
|
};
|
|
3049
|
+
// Forward the declared signing secret so a `{{secrets.KEY}}`
|
|
3050
|
+
// reference reaches the server intact (#1235). Without this the
|
|
3051
|
+
// value declared in TOML is silently dropped and the server
|
|
3052
|
+
// rejects a verified webhook with "signingSecret is required".
|
|
3053
|
+
// Mirrors how integrations forward requestConfig with refs intact.
|
|
3054
|
+
if (webhook.signingSecret !== undefined) {
|
|
3055
|
+
payload.signingSecret = webhook.signingSecret;
|
|
3056
|
+
}
|
|
2102
3057
|
// Handle JSON fields
|
|
2103
3058
|
if (tomlData.allowedIps?.cidrs) {
|
|
2104
3059
|
payload.allowedIpCidrs = JSON.stringify(tomlData.allowedIps.cidrs);
|
|
@@ -2303,30 +3258,27 @@ Directory Structure:
|
|
|
2303
3258
|
if (existingId) {
|
|
2304
3259
|
// #1020 (D8): idempotently apply preset/ruleSet/name/description
|
|
2305
3260
|
// changes via PATCH. bucketKey/ttlTier are immutable and not sent.
|
|
2306
|
-
const updatePayload =
|
|
2307
|
-
if (bucket.preset)
|
|
2308
|
-
updatePayload.preset = bucket.preset;
|
|
2309
|
-
else if (bucket.accessPolicy)
|
|
2310
|
-
updatePayload.accessPolicy = bucket.accessPolicy;
|
|
2311
|
-
if (bucket.ruleSetId)
|
|
2312
|
-
updatePayload.ruleSetId = bucket.ruleSetId;
|
|
2313
|
-
if (bucket.name)
|
|
2314
|
-
updatePayload.name = bucket.name;
|
|
2315
|
-
if (bucket.description !== undefined)
|
|
2316
|
-
updatePayload.description = bucket.description || null;
|
|
3261
|
+
const updatePayload = buildBlobBucketUpdatePayload(bucket);
|
|
2317
3262
|
changes.push({ type: "blob-bucket", action: "update", key });
|
|
2318
3263
|
if (!options.dryRun) {
|
|
2319
3264
|
try {
|
|
2320
3265
|
await client.updateBlobBucket(resolvedAppId, existingId, updatePayload);
|
|
2321
3266
|
info(` Updated blob bucket: ${key}`);
|
|
3267
|
+
// #1459: stamp the content hash ONLY after a successful
|
|
3268
|
+
// update, and only when this is not a dry run. A failed
|
|
3269
|
+
// update (or a dry run) must leave the file pending rather
|
|
3270
|
+
// than mark it in-sync and suppress it on the next push.
|
|
3271
|
+
if (syncState?.entities?.blobBuckets?.[key]) {
|
|
3272
|
+
syncState.entities.blobBuckets[key].contentHash =
|
|
3273
|
+
computeFileHash(filePath);
|
|
3274
|
+
}
|
|
2322
3275
|
}
|
|
2323
3276
|
catch (err) {
|
|
3277
|
+
// Keep the warn-and-continue contract (sponsor decision):
|
|
3278
|
+
// do not abort the push, just don't stamp the hash.
|
|
2324
3279
|
warn(` Failed to update blob bucket ${key}: ${String(err?.message || err)}`);
|
|
2325
3280
|
}
|
|
2326
3281
|
}
|
|
2327
|
-
if (syncState?.entities?.blobBuckets?.[key]) {
|
|
2328
|
-
syncState.entities.blobBuckets[key].contentHash = computeFileHash(filePath);
|
|
2329
|
-
}
|
|
2330
3282
|
}
|
|
2331
3283
|
else {
|
|
2332
3284
|
const payload = {
|
|
@@ -2361,23 +3313,86 @@ Directory Structure:
|
|
|
2361
3313
|
catch (err) {
|
|
2362
3314
|
const msg = String(err?.message || err);
|
|
2363
3315
|
if (msg.includes("already exists") || err.statusCode === 409) {
|
|
2364
|
-
|
|
2365
|
-
//
|
|
3316
|
+
// #1459: adopt-by-key on 409. A blob bucket can already
|
|
3317
|
+
// exist on the server (an out-of-band create, or a prior
|
|
3318
|
+
// push that aborted before recording it in sync state). The
|
|
3319
|
+
// old handler stamped the LOCAL file's contentHash WITHOUT
|
|
3320
|
+
// applying the local config, so a divergent bucket was
|
|
3321
|
+
// silently marked in-sync and never reconciled. Mirror the
|
|
3322
|
+
// cron/workflow adopt paths: look up the bucket by key,
|
|
3323
|
+
// re-issue the local payload as an UPDATE, and stamp the
|
|
3324
|
+
// hash ONLY after the update succeeds.
|
|
3325
|
+
info(` Blob bucket already exists on server, adopting by key: ${key}`);
|
|
3326
|
+
let existing;
|
|
2366
3327
|
try {
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
3328
|
+
existing = await client.getBlobBucket(resolvedAppId, key);
|
|
3329
|
+
}
|
|
3330
|
+
catch (lookupErr) {
|
|
3331
|
+
// A lookup failure after a 409 means we cannot adopt —
|
|
3332
|
+
// surface it instead of swallowing it (the old silent
|
|
3333
|
+
// `catch {}` left no sync-state entry, looping
|
|
3334
|
+
// create→409 on every push).
|
|
3335
|
+
throw wrapEntityError(new Error(`blob bucket "${key}" already exists but could not be adopted (lookup failed: ${String(lookupErr?.message || lookupErr)})`), "create", "blob bucket", key);
|
|
3336
|
+
}
|
|
3337
|
+
if (!existing?.bucketId) {
|
|
3338
|
+
// 409 but no matching bucket found — surface the original
|
|
3339
|
+
// create error rather than silently proceeding.
|
|
3340
|
+
throw wrapEntityError(err, "create", "blob bucket", key);
|
|
3341
|
+
}
|
|
3342
|
+
// Verify the resolved bucket is actually the one we asked
|
|
3343
|
+
// for. `getBlobBucket` resolves the identifier by primary id
|
|
3344
|
+
// (appId, bucketId) BEFORE bucketKey, and a bucketKey may be
|
|
3345
|
+
// a ULID-shaped string (the key regex permits it). If the
|
|
3346
|
+
// local key collides with an unrelated bucket's bucketId,
|
|
3347
|
+
// the lookup returns that unrelated bucket — patching and
|
|
3348
|
+
// stamping it would overwrite the wrong resource. Require an
|
|
3349
|
+
// exact key match, matching the cron/workflow adopt paths'
|
|
3350
|
+
// exact-key equality (they list + filter by key for the same
|
|
3351
|
+
// reason). Surface the mismatch via wrapEntityError; do NOT
|
|
3352
|
+
// patch or stamp.
|
|
3353
|
+
if (existing.bucketKey !== key) {
|
|
3354
|
+
throw wrapEntityError(new Error(`blob bucket "${key}" already exists, but the server resolved a different bucket (bucketKey "${existing.bucketKey}", bucketId "${existing.bucketId}"); refusing to adopt. This happens when the local key collides with an unrelated bucket's id — rename the local bucket key so it does not match an existing bucket id.`), "create", "blob bucket", key);
|
|
3355
|
+
}
|
|
3356
|
+
// ttlTier is immutable and cannot be reconciled by an
|
|
3357
|
+
// UPDATE. A local TOML whose retention tier diverges from
|
|
3358
|
+
// the server must fail loudly (sponsor decision) rather
|
|
3359
|
+
// than be silently blessed while the cosmetic fields
|
|
3360
|
+
// converge.
|
|
3361
|
+
if (bucket.ttlTier &&
|
|
3362
|
+
existing.ttlTier &&
|
|
3363
|
+
bucket.ttlTier !== existing.ttlTier) {
|
|
3364
|
+
throw wrapEntityError(new Error(`blob bucket "${key}" ttlTier mismatch: local TOML has "${bucket.ttlTier}" but the server bucket has "${existing.ttlTier}". ttlTier is immutable; align the TOML with the server (or recreate the bucket) before pushing.`), "update", "blob bucket", key);
|
|
3365
|
+
}
|
|
3366
|
+
const updatePayload = buildBlobBucketUpdatePayload(bucket);
|
|
3367
|
+
let updated;
|
|
3368
|
+
if (Object.keys(updatePayload).length > 0) {
|
|
3369
|
+
// Surface an update failure via wrapEntityError (no
|
|
3370
|
+
// stamp) — matches the cron adopt path.
|
|
3371
|
+
try {
|
|
3372
|
+
updated = await client.updateBlobBucket(resolvedAppId, existing.bucketId, updatePayload);
|
|
3373
|
+
}
|
|
3374
|
+
catch (updateErr) {
|
|
3375
|
+
throw wrapEntityError(updateErr, "update", "blob bucket", key);
|
|
2377
3376
|
}
|
|
3377
|
+
info(` Adopted + updated blob bucket: ${key}`);
|
|
3378
|
+
}
|
|
3379
|
+
else {
|
|
3380
|
+
// No mutable fields in the local TOML — the server
|
|
3381
|
+
// rejects an empty update (400). Adopt the existing
|
|
3382
|
+
// bucket without a PATCH.
|
|
3383
|
+
info(` Adopted blob bucket (no updatable fields): ${key}`);
|
|
2378
3384
|
}
|
|
2379
|
-
|
|
2380
|
-
|
|
3385
|
+
if (syncState) {
|
|
3386
|
+
if (!syncState.entities.blobBuckets) {
|
|
3387
|
+
syncState.entities.blobBuckets = {};
|
|
3388
|
+
}
|
|
3389
|
+
syncState.entities.blobBuckets[key] = {
|
|
3390
|
+
id: existing.bucketId,
|
|
3391
|
+
modifiedAt: updated?.modifiedAt ||
|
|
3392
|
+
existing.modifiedAt ||
|
|
3393
|
+
new Date().toISOString(),
|
|
3394
|
+
contentHash: computeFileHash(filePath),
|
|
3395
|
+
};
|
|
2381
3396
|
}
|
|
2382
3397
|
}
|
|
2383
3398
|
else {
|
|
@@ -2731,6 +3746,19 @@ Directory Structure:
|
|
|
2731
3746
|
const workflow = tomlData.workflow || {};
|
|
2732
3747
|
const key = workflow.key || basename(file, ".toml");
|
|
2733
3748
|
const steps = tomlData.steps || [];
|
|
3749
|
+
// #1304 (P-C): the declared-access manifest lives at top-level
|
|
3750
|
+
// `[metadata]` / `secrets` (same shape the database/collection type
|
|
3751
|
+
// configs use). Parse + surface-validate it, then carry it on the
|
|
3752
|
+
// `workflow` payload object so it flows through both the create call
|
|
3753
|
+
// and the shared `applyWorkflowBody` update. A null manifest (no
|
|
3754
|
+
// `[metadata]`) clears it on the server so pull → push round-trips a
|
|
3755
|
+
// drop; the deep graph check runs server-side at persist.
|
|
3756
|
+
const workflowManifest = parseDeclaredAccessManifestToml(tomlData);
|
|
3757
|
+
const workflowManifestError = validateDeclaredAccessManifest(workflowManifest);
|
|
3758
|
+
if (workflowManifestError) {
|
|
3759
|
+
throw new Error(`${filePath}: ${workflowManifestError}`);
|
|
3760
|
+
}
|
|
3761
|
+
workflow.metadataManifest = workflowManifest;
|
|
2734
3762
|
const existingId = syncState?.entities?.workflows?.[key]?.id;
|
|
2735
3763
|
const existingActiveConfigId = syncState?.entities?.workflows?.[key]?.activeConfigId;
|
|
2736
3764
|
// Skip if file hasn't changed since last sync. Use the expanded
|
|
@@ -2751,6 +3779,45 @@ Directory Structure:
|
|
|
2751
3779
|
}
|
|
2752
3780
|
continue;
|
|
2753
3781
|
}
|
|
3782
|
+
// Shared update body for both the in-manifest update branch and the
|
|
3783
|
+
// adopt-by-key recovery branch (#1174). The body is large and
|
|
3784
|
+
// ordering-sensitive (metadata PATCH → config/draft steps →
|
|
3785
|
+
// deferred `syncCallable` PATCH); both call sites MUST run it
|
|
3786
|
+
// identically, so it lives here as a single closure rather than
|
|
3787
|
+
// being duplicated. `activeConfigId` is passed in explicitly: the
|
|
3788
|
+
// update branch reads it from sync state, while the adopt branch
|
|
3789
|
+
// seeds it from a fresh `getWorkflow` (sync state has none for an
|
|
3790
|
+
// out-of-manifest workflow).
|
|
3791
|
+
const applyWorkflowUpdate = async (workflowId, opts) => {
|
|
3792
|
+
const { expectedModifiedAt, activeConfigId } = opts;
|
|
3793
|
+
// The ordering-sensitive core (metadata PATCH → config/draft
|
|
3794
|
+
// steps → deferred `syncCallable` PATCH, #807) lives in the
|
|
3795
|
+
// shared `applyWorkflowBody` helper so `workflows update
|
|
3796
|
+
// --from-file` runs it identically (#1249, Fork 4). The
|
|
3797
|
+
// sync-specific bookkeeping (the progress log, sync-state
|
|
3798
|
+
// `modifiedAt`/hash, and config name→ID map) stays here.
|
|
3799
|
+
const { latestModifiedAt, updateSlotLabel, fullWorkflow } = await applyWorkflowBody(client, resolvedAppId, workflowId, workflow, steps, {
|
|
3800
|
+
expectedModifiedAt,
|
|
3801
|
+
activeConfigId,
|
|
3802
|
+
force: options.force,
|
|
3803
|
+
});
|
|
3804
|
+
info(` Updated workflow: ${key} (${updateSlotLabel})`);
|
|
3805
|
+
// Update sync state with new modifiedAt. Store the *expanded*
|
|
3806
|
+
// content hash so future fragment-only edits are detected. The
|
|
3807
|
+
// adopt branch seeds the entity record before calling this, so
|
|
3808
|
+
// the entry exists for both call sites.
|
|
3809
|
+
if (syncState?.entities?.workflows?.[key] && latestModifiedAt) {
|
|
3810
|
+
syncState.entities.workflows[key].modifiedAt = latestModifiedAt;
|
|
3811
|
+
syncState.entities.workflows[key].contentHash = computeExpandedContentHash(tomlData);
|
|
3812
|
+
}
|
|
3813
|
+
// `applyWorkflowBody` already fetched the full workflow; reuse it
|
|
3814
|
+
// for the config name→ID mappings (the PATCH response omits configs).
|
|
3815
|
+
if (fullWorkflow?.configs) {
|
|
3816
|
+
for (const config of fullWorkflow.configs) {
|
|
3817
|
+
workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
3818
|
+
}
|
|
3819
|
+
}
|
|
3820
|
+
};
|
|
2754
3821
|
if (existingId) {
|
|
2755
3822
|
// Update existing workflow
|
|
2756
3823
|
changes.push({ type: "workflow", action: "update", key });
|
|
@@ -2759,95 +3826,10 @@ Directory Structure:
|
|
|
2759
3826
|
? undefined
|
|
2760
3827
|
: syncState?.entities?.workflows?.[key]?.modifiedAt;
|
|
2761
3828
|
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
|
-
}
|
|
3829
|
+
await applyWorkflowUpdate(existingId, {
|
|
3830
|
+
expectedModifiedAt,
|
|
3831
|
+
activeConfigId: existingActiveConfigId,
|
|
3832
|
+
});
|
|
2851
3833
|
}
|
|
2852
3834
|
catch (err) {
|
|
2853
3835
|
if (err instanceof ConflictError) {
|
|
@@ -2869,25 +3851,22 @@ Directory Structure:
|
|
|
2869
3851
|
changes.push({ type: "workflow", action: "create", key });
|
|
2870
3852
|
if (!options.dryRun) {
|
|
2871
3853
|
try {
|
|
3854
|
+
// #1177: build the `[workflow]`-derived fields through the
|
|
3855
|
+
// shared builder so `sync push` create can't drift from
|
|
3856
|
+
// `create --from-file` / the update path (this branch used to
|
|
3857
|
+
// drop `status`, `capabilities`, `perAppMax*`, `queueTtlSeconds`).
|
|
3858
|
+
// Structural fields (`workflowKey`, `steps`, `metadataManifest`)
|
|
3859
|
+
// and the manifest-key `name` fallback are attached here; the
|
|
3860
|
+
// `name` override follows the spread so it wins over the
|
|
3861
|
+
// builder's bare `name`.
|
|
2872
3862
|
const created = await client.createWorkflow(resolvedAppId, {
|
|
2873
3863
|
workflowKey: key,
|
|
2874
|
-
name: workflow.name || key,
|
|
2875
|
-
description: workflow.description,
|
|
2876
3864
|
steps,
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
//
|
|
2881
|
-
|
|
2882
|
-
perUserMaxRunning: workflow.perUserMaxRunning,
|
|
2883
|
-
perUserMaxQueued: workflow.perUserMaxQueued,
|
|
2884
|
-
dequeueOrder: workflow.dequeueOrder,
|
|
2885
|
-
// Sync-callable flags (#807): pass through as-is. An absent
|
|
2886
|
-
// TOML key is `undefined` (dropped by JSON.stringify), so the
|
|
2887
|
-
// server applies its defaults (requiresClientApply=true,
|
|
2888
|
-
// syncCallable=false); an explicit value is honored.
|
|
2889
|
-
requiresClientApply: workflow.requiresClientApply,
|
|
2890
|
-
syncCallable: workflow.syncCallable,
|
|
3865
|
+
...buildWorkflowPayloadFromToml(workflow, { mode: "create" }),
|
|
3866
|
+
name: workflow.name || key,
|
|
3867
|
+
// #1304 (P-C): declared-access manifest (absent → undefined,
|
|
3868
|
+
// server default null).
|
|
3869
|
+
metadataManifest: workflow.metadataManifest ?? undefined,
|
|
2891
3870
|
});
|
|
2892
3871
|
info(` Created workflow: ${key}`);
|
|
2893
3872
|
// Add new entity to sync state (including activeConfigId)
|
|
@@ -2936,7 +3915,116 @@ Directory Structure:
|
|
|
2936
3915
|
}
|
|
2937
3916
|
}
|
|
2938
3917
|
catch (err) {
|
|
2939
|
-
|
|
3918
|
+
// Issue #1174: idempotent create — adopt-by-key on conflict.
|
|
3919
|
+
// A workflow can exist on the server but be absent from local
|
|
3920
|
+
// sync state — e.g. created out-of-band via the admin API
|
|
3921
|
+
// (#971), or orphaned by a prior push that aborted before
|
|
3922
|
+
// recording it. On retry this CREATE path hits the
|
|
3923
|
+
// `workflowKeyPerApp` unique constraint and the server
|
|
3924
|
+
// returns "workflowKey already exists" (HTTP 400; cron uses
|
|
3925
|
+
// 409). Rather than abort the whole push, look up the
|
|
3926
|
+
// existing workflow by key (the list endpoint is app-scoped,
|
|
3927
|
+
// so every item is owned by this app), verify the SAME key,
|
|
3928
|
+
// adopt its id into sync state, and re-issue as an UPDATE so
|
|
3929
|
+
// the push converges. Mirrors the cron-trigger recovery at
|
|
3930
|
+
// ~sync.ts:2627. Unexpected errors still surface via
|
|
3931
|
+
// `wrapEntityError`, matching the db-type precedent.
|
|
3932
|
+
const msg = String(err?.message || err);
|
|
3933
|
+
const isConflict = err?.statusCode === 409 || msg.includes("already exists");
|
|
3934
|
+
if (!isConflict) {
|
|
3935
|
+
throw wrapEntityError(err, "create", "workflow", key);
|
|
3936
|
+
}
|
|
3937
|
+
info(` Workflow already exists on server, adopting by key: ${key}`);
|
|
3938
|
+
let adoptedId;
|
|
3939
|
+
try {
|
|
3940
|
+
// Use fetchAll — listWorkflows is paginated, so a single
|
|
3941
|
+
// page could miss the by-key match on a large app.
|
|
3942
|
+
const items = await fetchAll((p) => client.listWorkflows(resolvedAppId, p));
|
|
3943
|
+
// Verify same key + app ownership: the list endpoint only
|
|
3944
|
+
// returns workflows for `resolvedAppId`, so a workflowKey
|
|
3945
|
+
// match is also an ownership match. Never overwrite an
|
|
3946
|
+
// unrelated resource — require exact key equality.
|
|
3947
|
+
const existing = (items || []).find((w) => w?.workflowKey === key);
|
|
3948
|
+
if (existing?.workflowId) {
|
|
3949
|
+
adoptedId = existing.workflowId;
|
|
3950
|
+
}
|
|
3951
|
+
}
|
|
3952
|
+
catch (lookupErr) {
|
|
3953
|
+
throw wrapEntityError(new Error(`workflow "${key}" already exists but could not be adopted (lookup failed: ${String(lookupErr?.message || lookupErr)})`), "create", "workflow", key);
|
|
3954
|
+
}
|
|
3955
|
+
if (!adoptedId) {
|
|
3956
|
+
// "already exists" but no matching key found on the server —
|
|
3957
|
+
// surface the original error rather than silently
|
|
3958
|
+
// overwriting an unrelated workflow.
|
|
3959
|
+
throw wrapEntityError(err, "create", "workflow", key);
|
|
3960
|
+
}
|
|
3961
|
+
// Out-of-manifest adopt: warn (info-level), matching the cron
|
|
3962
|
+
// adopt line, and seed the sync-state entity record so the
|
|
3963
|
+
// shared update body can write modifiedAt/contentHash into it.
|
|
3964
|
+
if (syncState) {
|
|
3965
|
+
if (!syncState.entities.workflows) {
|
|
3966
|
+
syncState.entities.workflows = {};
|
|
3967
|
+
}
|
|
3968
|
+
if (!syncState.entities.workflows[key]) {
|
|
3969
|
+
syncState.entities.workflows[key] = {
|
|
3970
|
+
id: adoptedId,
|
|
3971
|
+
};
|
|
3972
|
+
}
|
|
3973
|
+
else {
|
|
3974
|
+
syncState.entities.workflows[key].id = adoptedId;
|
|
3975
|
+
}
|
|
3976
|
+
}
|
|
3977
|
+
// Seed the real activeConfigId from the server before the
|
|
3978
|
+
// shared update body runs. Sync state has none for an
|
|
3979
|
+
// out-of-manifest workflow, so without this the shared body
|
|
3980
|
+
// would wrongly fall back to `updateWorkflowDraft`.
|
|
3981
|
+
let adoptedActiveConfigId;
|
|
3982
|
+
try {
|
|
3983
|
+
const fullWorkflow = await client.getWorkflow(resolvedAppId, adoptedId);
|
|
3984
|
+
adoptedActiveConfigId =
|
|
3985
|
+
fullWorkflow?.workflow?.activeConfigId;
|
|
3986
|
+
}
|
|
3987
|
+
catch (getErr) {
|
|
3988
|
+
throw wrapEntityError(new Error(`workflow "${key}" was adopted but could not be loaded (getWorkflow failed: ${String(getErr?.message || getErr)})`), "update", "workflow", key);
|
|
3989
|
+
}
|
|
3990
|
+
// Persist the fetched activeConfigId into the seeded
|
|
3991
|
+
// sync-state record so it round-trips identically to the
|
|
3992
|
+
// CREATE path (~sync.ts:3387). Without this the *next*
|
|
3993
|
+
// content-changing push reads `existingActiveConfigId ===
|
|
3994
|
+
// undefined` (sync.ts:3187) and wrongly falls back to the
|
|
3995
|
+
// legacy `updateWorkflowDraft` instead of
|
|
3996
|
+
// `updateWorkflowConfig` — a persisted-state divergence from
|
|
3997
|
+
// a normally-created workflow.
|
|
3998
|
+
if (syncState?.entities?.workflows?.[key] &&
|
|
3999
|
+
adoptedActiveConfigId) {
|
|
4000
|
+
syncState.entities.workflows[key].activeConfigId =
|
|
4001
|
+
adoptedActiveConfigId;
|
|
4002
|
+
}
|
|
4003
|
+
// Switch to UPDATE on the adopted workflow. First adopt has no
|
|
4004
|
+
// stored modifiedAt → omit expectedModifiedAt (force the
|
|
4005
|
+
// PATCH), matching the cron adopt + the --force path. An
|
|
4006
|
+
// unexpected ConflictError here is still adoptable resilience
|
|
4007
|
+
// scope, so collect it; any other error surfaces.
|
|
4008
|
+
try {
|
|
4009
|
+
await applyWorkflowUpdate(adoptedId, {
|
|
4010
|
+
expectedModifiedAt: undefined,
|
|
4011
|
+
activeConfigId: adoptedActiveConfigId,
|
|
4012
|
+
});
|
|
4013
|
+
info(` Adopted + updated workflow: ${key}`);
|
|
4014
|
+
}
|
|
4015
|
+
catch (updErr) {
|
|
4016
|
+
if (updErr instanceof ConflictError) {
|
|
4017
|
+
conflicts.push({
|
|
4018
|
+
type: "workflow",
|
|
4019
|
+
key,
|
|
4020
|
+
serverModifiedAt: updErr.serverModifiedAt,
|
|
4021
|
+
localModifiedAt: "unknown",
|
|
4022
|
+
});
|
|
4023
|
+
}
|
|
4024
|
+
else {
|
|
4025
|
+
throw wrapEntityError(updErr, "update", "workflow", key);
|
|
4026
|
+
}
|
|
4027
|
+
}
|
|
2940
4028
|
}
|
|
2941
4029
|
}
|
|
2942
4030
|
}
|
|
@@ -3044,6 +4132,14 @@ Directory Structure:
|
|
|
3044
4132
|
if ("timestamps" in typeConfig) {
|
|
3045
4133
|
u.timestamps = typeConfig.timestamps || null;
|
|
3046
4134
|
}
|
|
4135
|
+
// Issue #1304 (P-C): declared-access manifest. `parseDatabaseTypeToml`
|
|
4136
|
+
// only sets `metadataManifest` when the TOML declares a `[metadata]`
|
|
4137
|
+
// / `secrets` block, so this forwards a set/update; clearing a
|
|
4138
|
+
// manifest by removing the block is a follow-on (mirrors the
|
|
4139
|
+
// schema-deletion tracking).
|
|
4140
|
+
if ("metadataManifest" in typeConfig) {
|
|
4141
|
+
u.metadataManifest = typeConfig.metadataManifest || null;
|
|
4142
|
+
}
|
|
3047
4143
|
const localHasSchema = typeof typeConfig.schema === "string" &&
|
|
3048
4144
|
typeConfig.schema.trim().length > 0;
|
|
3049
4145
|
if (localHasSchema) {
|
|
@@ -3413,7 +4509,8 @@ Directory Structure:
|
|
|
3413
4509
|
"defaultAccess" in typeConfig ||
|
|
3414
4510
|
"autoPopulatedFields" in typeConfig ||
|
|
3415
4511
|
"timestamps" in typeConfig ||
|
|
3416
|
-
"schema" in typeConfig
|
|
4512
|
+
"schema" in typeConfig ||
|
|
4513
|
+
"metadataManifest" in typeConfig;
|
|
3417
4514
|
if (wouldUpdate) {
|
|
3418
4515
|
changes.push({ type: "database-type", action: "update", key: dbType });
|
|
3419
4516
|
}
|
|
@@ -3446,6 +4543,9 @@ Directory Structure:
|
|
|
3446
4543
|
// validating against the intended shape.
|
|
3447
4544
|
if (typeConfig.schema)
|
|
3448
4545
|
createData.schema = typeConfig.schema;
|
|
4546
|
+
// Issue #1304 (P-C): declared-access manifest.
|
|
4547
|
+
if (typeConfig.metadataManifest)
|
|
4548
|
+
createData.metadataManifest = typeConfig.metadataManifest;
|
|
3449
4549
|
try {
|
|
3450
4550
|
const created = await client.createDatabaseTypeConfig(resolvedAppId, createData);
|
|
3451
4551
|
info(` Created database type: ${dbType}`);
|
|
@@ -3902,6 +5002,10 @@ Directory Structure:
|
|
|
3902
5002
|
const updated = await client.updateGroupTypeConfig(resolvedAppId, groupType, {
|
|
3903
5003
|
ruleSetId: configData.ruleSetId,
|
|
3904
5004
|
autoAddCreator: configData.autoAddCreator,
|
|
5005
|
+
// Issue #1350: null clears the manifest server-side when
|
|
5006
|
+
// the TOML drops the `[metadata]` block (matches the
|
|
5007
|
+
// collection type config update semantics).
|
|
5008
|
+
metadataManifest: configData.metadataManifest ?? null,
|
|
3905
5009
|
}, expectedModifiedAt);
|
|
3906
5010
|
info(` Updated group type config: ${groupType}`);
|
|
3907
5011
|
if (syncState?.entities?.groupTypeConfigs?.[groupType] && updated?.modifiedAt) {
|
|
@@ -3933,6 +5037,9 @@ Directory Structure:
|
|
|
3933
5037
|
groupType,
|
|
3934
5038
|
ruleSetId: configData.ruleSetId || undefined,
|
|
3935
5039
|
autoAddCreator: configData.autoAddCreator,
|
|
5040
|
+
...(configData.metadataManifest
|
|
5041
|
+
? { metadataManifest: configData.metadataManifest }
|
|
5042
|
+
: {}),
|
|
3936
5043
|
});
|
|
3937
5044
|
info(` Created group type config: ${groupType}`);
|
|
3938
5045
|
if (syncState) {
|
|
@@ -3979,6 +5086,7 @@ Directory Structure:
|
|
|
3979
5086
|
try {
|
|
3980
5087
|
const updated = await client.updateCollectionTypeConfig(resolvedAppId, collectionType, {
|
|
3981
5088
|
ruleSetId: configData.ruleSetId ?? null,
|
|
5089
|
+
metadataManifest: configData.metadataManifest ?? null,
|
|
3982
5090
|
}, expectedModifiedAt);
|
|
3983
5091
|
info(` Updated collection type config: ${collectionType}`);
|
|
3984
5092
|
if (syncState?.entities?.collectionTypeConfigs?.[collectionType] && updated?.modifiedAt) {
|
|
@@ -4009,6 +5117,9 @@ Directory Structure:
|
|
|
4009
5117
|
const created = await client.createCollectionTypeConfig(resolvedAppId, {
|
|
4010
5118
|
collectionType,
|
|
4011
5119
|
ruleSetId: configData.ruleSetId || undefined,
|
|
5120
|
+
...(configData.metadataManifest
|
|
5121
|
+
? { metadataManifest: configData.metadataManifest }
|
|
5122
|
+
: {}),
|
|
4012
5123
|
});
|
|
4013
5124
|
info(` Created collection type config: ${collectionType}`);
|
|
4014
5125
|
if (syncState) {
|
|
@@ -4028,6 +5139,69 @@ Directory Structure:
|
|
|
4028
5139
|
}
|
|
4029
5140
|
}
|
|
4030
5141
|
}
|
|
5142
|
+
// Process metadata category configs (issue #1304, P-B). Idempotent
|
|
5143
|
+
// upsert via the path-addressed PUT route — no create/update split.
|
|
5144
|
+
const metadataCategoryConfigsDir = join(configDir, "metadata-category-configs");
|
|
5145
|
+
if (existsSync(metadataCategoryConfigsDir)) {
|
|
5146
|
+
const files = readdirSync(metadataCategoryConfigsDir).filter((f) => f.endsWith(".toml"));
|
|
5147
|
+
for (const file of files) {
|
|
5148
|
+
const filePath = join(metadataCategoryConfigsDir, file);
|
|
5149
|
+
const tomlData = parseTomlFile(filePath);
|
|
5150
|
+
const configData = parseMetadataCategoryToml(tomlData);
|
|
5151
|
+
// Fall back to the filename (`resourceType.category.toml`) if the
|
|
5152
|
+
// section omits the identity fields.
|
|
5153
|
+
if (!configData.resourceType || !configData.category) {
|
|
5154
|
+
const base = basename(file, ".toml");
|
|
5155
|
+
const dot = base.indexOf(".");
|
|
5156
|
+
if (dot > 0) {
|
|
5157
|
+
configData.resourceType = configData.resourceType || base.slice(0, dot);
|
|
5158
|
+
configData.category = configData.category || base.slice(dot + 1);
|
|
5159
|
+
}
|
|
5160
|
+
}
|
|
5161
|
+
const validationError = validateMetadataCategoryConfig(configData);
|
|
5162
|
+
if (validationError) {
|
|
5163
|
+
throw new Error(`Invalid metadata category config in ${file}: ${validationError}`);
|
|
5164
|
+
}
|
|
5165
|
+
const entityKey = `${configData.resourceType}#${configData.category}`;
|
|
5166
|
+
const existingEntry = syncState?.entities?.metadataCategoryConfigs?.[entityKey];
|
|
5167
|
+
// Skip if the file hasn't changed since last sync.
|
|
5168
|
+
if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
|
|
5169
|
+
skippedCount++;
|
|
5170
|
+
continue;
|
|
5171
|
+
}
|
|
5172
|
+
changes.push({
|
|
5173
|
+
type: "metadata-category-config",
|
|
5174
|
+
action: existingEntry ? "update" : "create",
|
|
5175
|
+
key: entityKey,
|
|
5176
|
+
});
|
|
5177
|
+
if (!options.dryRun) {
|
|
5178
|
+
try {
|
|
5179
|
+
const saved = await client.upsertMetadataCategoryConfig(resolvedAppId, configData.resourceType, configData.category, {
|
|
5180
|
+
schema: configData.schema,
|
|
5181
|
+
readRule: configData.readRule,
|
|
5182
|
+
writeRule: configData.writeRule,
|
|
5183
|
+
description: configData.description,
|
|
5184
|
+
// #1420 Phase 2: send the declared-access manifest so a
|
|
5185
|
+
// category's md.self/secrets access round-trips through sync.
|
|
5186
|
+
metadataManifest: configData.metadataManifest,
|
|
5187
|
+
});
|
|
5188
|
+
info(` Upserted metadata category config: ${entityKey}`);
|
|
5189
|
+
if (syncState) {
|
|
5190
|
+
if (!syncState.entities.metadataCategoryConfigs) {
|
|
5191
|
+
syncState.entities.metadataCategoryConfigs = {};
|
|
5192
|
+
}
|
|
5193
|
+
syncState.entities.metadataCategoryConfigs[entityKey] = {
|
|
5194
|
+
modifiedAt: saved?.modifiedAt || new Date().toISOString(),
|
|
5195
|
+
contentHash: computeFileHash(filePath),
|
|
5196
|
+
};
|
|
5197
|
+
}
|
|
5198
|
+
}
|
|
5199
|
+
catch (err) {
|
|
5200
|
+
throw wrapEntityError(err, existingEntry ? "update" : "create", "metadata category config", entityKey);
|
|
5201
|
+
}
|
|
5202
|
+
}
|
|
5203
|
+
}
|
|
5204
|
+
}
|
|
4031
5205
|
// Process email templates
|
|
4032
5206
|
const emailTemplatesDir = join(configDir, "email-templates");
|
|
4033
5207
|
if (existsSync(emailTemplatesDir)) {
|
|
@@ -4064,11 +5238,13 @@ Directory Structure:
|
|
|
4064
5238
|
}
|
|
4065
5239
|
}
|
|
4066
5240
|
}
|
|
4067
|
-
// Push test cases for prompts and
|
|
5241
|
+
// Push test cases for prompts, workflows, and scripts
|
|
5242
|
+
const scriptConfigNameToId = new Map(); // "scriptName#configName" → configId
|
|
4068
5243
|
const pushMaps = {
|
|
4069
5244
|
promptKeyToId,
|
|
4070
5245
|
promptConfigNameToId,
|
|
4071
5246
|
workflowConfigNameToId,
|
|
5247
|
+
scriptConfigNameToId,
|
|
4072
5248
|
};
|
|
4073
5249
|
const promptsDir2 = join(configDir, "prompts");
|
|
4074
5250
|
if (existsSync(promptsDir2)) {
|
|
@@ -4092,6 +5268,33 @@ Directory Structure:
|
|
|
4092
5268
|
}
|
|
4093
5269
|
}
|
|
4094
5270
|
}
|
|
5271
|
+
// Push test cases for scripts (transforms). Each script's tests live at
|
|
5272
|
+
// transforms/<name>.tests/; the scriptId was recorded in
|
|
5273
|
+
// entities.scripts[name].id when the script body was pushed above.
|
|
5274
|
+
const transformsDir2 = join(configDir, "transforms");
|
|
5275
|
+
if (existsSync(transformsDir2)) {
|
|
5276
|
+
for (const file of readdirSync(transformsDir2).filter((f) => f.endsWith(".rhai"))) {
|
|
5277
|
+
const scriptName = basename(file, ".rhai");
|
|
5278
|
+
const blockId = syncState?.entities?.scripts?.[scriptName]?.id;
|
|
5279
|
+
if (!blockId)
|
|
5280
|
+
continue;
|
|
5281
|
+
const scriptTestsDir = join(transformsDir2, `${scriptName}.tests`);
|
|
5282
|
+
if (!existsSync(scriptTestsDir))
|
|
5283
|
+
continue;
|
|
5284
|
+
// Populate scriptConfigNameToId for this script so a test case that
|
|
5285
|
+
// pins a config by name resolves to a live configId.
|
|
5286
|
+
try {
|
|
5287
|
+
const { items: scriptConfigs } = await client.listScriptConfigs(resolvedAppId, blockId);
|
|
5288
|
+
for (const config of scriptConfigs) {
|
|
5289
|
+
scriptConfigNameToId.set(`${scriptName}#${config.configName}`, config.configId);
|
|
5290
|
+
}
|
|
5291
|
+
}
|
|
5292
|
+
catch {
|
|
5293
|
+
// Non-fatal — unpinned (active-version) test cases still push.
|
|
5294
|
+
}
|
|
5295
|
+
skippedCount += await pushTestCasesForBlock(client, resolvedAppId, "script", blockId, scriptName, configDir, syncState, options.dryRun, changes, pushMaps, { force: options.force });
|
|
5296
|
+
}
|
|
5297
|
+
}
|
|
4095
5298
|
divider();
|
|
4096
5299
|
// Issue #813: a gate failure surfaced by the validate-first pass (which
|
|
4097
5300
|
// also runs in dry-run mode via the server's dry-run gate endpoints).
|
|
@@ -4180,13 +5383,9 @@ Directory Structure:
|
|
|
4180
5383
|
// Don't mask the original error
|
|
4181
5384
|
}
|
|
4182
5385
|
}
|
|
4183
|
-
|
|
4184
|
-
//
|
|
4185
|
-
|
|
4186
|
-
for (const detail of err.details) {
|
|
4187
|
-
console.error(` - ${typeof detail === "string" ? detail : JSON.stringify(detail)}`);
|
|
4188
|
-
}
|
|
4189
|
-
}
|
|
5386
|
+
// Print message + structured server-side validation details
|
|
5387
|
+
// (issue #684; now via the shared #1173 helper).
|
|
5388
|
+
printApiError(err);
|
|
4190
5389
|
process.exit(1);
|
|
4191
5390
|
}
|
|
4192
5391
|
});
|
|
@@ -4309,6 +5508,55 @@ Directory Structure:
|
|
|
4309
5508
|
localEmailTemplates.add(emailType);
|
|
4310
5509
|
}
|
|
4311
5510
|
}
|
|
5511
|
+
// Transforms (Rhai scripts) — issue #1196. Before this, `sync diff`
|
|
5512
|
+
// ignored scripts entirely, so a body that drifted between the local
|
|
5513
|
+
// `.rhai` and the server's active config silently read as Synced. We
|
|
5514
|
+
// list the server scripts (header only) and read the local file
|
|
5515
|
+
// contents so the comparison below can hash both sides.
|
|
5516
|
+
let scriptItemsDiff = [];
|
|
5517
|
+
try {
|
|
5518
|
+
const scriptsResult = await client.listScripts(resolvedAppId);
|
|
5519
|
+
scriptItemsDiff = scriptsResult.items || [];
|
|
5520
|
+
}
|
|
5521
|
+
catch {
|
|
5522
|
+
// Older server without the scripts route — treat as no scripts.
|
|
5523
|
+
}
|
|
5524
|
+
const remoteScripts = new Map(scriptItemsDiff
|
|
5525
|
+
.filter((s) => s?.name)
|
|
5526
|
+
.map((s) => [s.name, s]));
|
|
5527
|
+
const localScripts = new Map();
|
|
5528
|
+
const transformsDirPath = join(configDir, "transforms");
|
|
5529
|
+
if (existsSync(transformsDirPath)) {
|
|
5530
|
+
for (const file of readdirSync(transformsDirPath).filter((f) => f.endsWith(".rhai"))) {
|
|
5531
|
+
const name = basename(file, ".rhai");
|
|
5532
|
+
localScripts.set(name, readFileSync(join(transformsDirPath, file), "utf-8"));
|
|
5533
|
+
}
|
|
5534
|
+
}
|
|
5535
|
+
// Config vars — issue #1423. Before this, `sync diff` ignored vars
|
|
5536
|
+
// entirely, so an add/remove/value-drift between the local `vars.toml`
|
|
5537
|
+
// and the server silently read as no difference right before a push
|
|
5538
|
+
// would change or unset them. Fetch the remote vars (value included —
|
|
5539
|
+
// vars are non-secret) and read the local `vars.toml` so the comparison
|
|
5540
|
+
// below can report added/removed/modified vars.
|
|
5541
|
+
let remoteVarItemsDiff = [];
|
|
5542
|
+
try {
|
|
5543
|
+
remoteVarItemsDiff = await client.listAppConfigVars(resolvedAppId);
|
|
5544
|
+
}
|
|
5545
|
+
catch {
|
|
5546
|
+
// Older server without the vars route — treat as no remote vars.
|
|
5547
|
+
}
|
|
5548
|
+
const remoteVars = new Map(remoteVarItemsDiff
|
|
5549
|
+
.filter((v) => typeof v?.key === "string")
|
|
5550
|
+
.map((v) => [v.key, String(v.value ?? "")]));
|
|
5551
|
+
const localVars = new Map();
|
|
5552
|
+
const varsTomlPathDiff = join(configDir, "vars.toml");
|
|
5553
|
+
if (existsSync(varsTomlPathDiff)) {
|
|
5554
|
+
const parsed = parseTomlFile(varsTomlPathDiff);
|
|
5555
|
+
for (const [key, rawValue] of Object.entries(parsed)) {
|
|
5556
|
+
if (typeof rawValue === "string")
|
|
5557
|
+
localVars.set(key, rawValue);
|
|
5558
|
+
}
|
|
5559
|
+
}
|
|
4312
5560
|
// Compare
|
|
4313
5561
|
const differences = [];
|
|
4314
5562
|
// Integrations
|
|
@@ -4464,6 +5712,59 @@ Directory Structure:
|
|
|
4464
5712
|
differences.push({ type: "email-template", key, status: "remote only" });
|
|
4465
5713
|
}
|
|
4466
5714
|
}
|
|
5715
|
+
// Transforms (Rhai scripts) — issue #1196. Content-aware, mirroring
|
|
5716
|
+
// the workflow block: for scripts present on both sides we fetch the
|
|
5717
|
+
// server's active-config body (via `getScript`, the same call pull
|
|
5718
|
+
// uses) and compare it to the local `.rhai` contents. A difference is
|
|
5719
|
+
// reported as `modified`, framed as a preview of `sync pull`. A
|
|
5720
|
+
// per-script fetch failure degrades to key-only (Synced, not compared)
|
|
5721
|
+
// rather than aborting the whole diff.
|
|
5722
|
+
for (const [name, localBody] of localScripts) {
|
|
5723
|
+
const remote = remoteScripts.get(name);
|
|
5724
|
+
if (!remote) {
|
|
5725
|
+
differences.push({ type: "transform", key: name, status: "local only" });
|
|
5726
|
+
continue;
|
|
5727
|
+
}
|
|
5728
|
+
let status = "exists";
|
|
5729
|
+
let hint;
|
|
5730
|
+
try {
|
|
5731
|
+
if (!remote.activeConfigId) {
|
|
5732
|
+
// No active config server-side — nothing to compare against.
|
|
5733
|
+
status = "exists";
|
|
5734
|
+
hint = "content not compared (no active config)";
|
|
5735
|
+
}
|
|
5736
|
+
else {
|
|
5737
|
+
const full = await client.getScript(resolvedAppId, remote.scriptId);
|
|
5738
|
+
const configs = Array.isArray(full?.configs) ? full.configs : [];
|
|
5739
|
+
const activeConfigId = full?.activeConfigId ?? remote.activeConfigId;
|
|
5740
|
+
const activeConfig = configs.find((c) => c.configId === activeConfigId) ||
|
|
5741
|
+
configs.find((c) => c.status === "active");
|
|
5742
|
+
if (!activeConfig || typeof activeConfig.body !== "string") {
|
|
5743
|
+
status = "exists";
|
|
5744
|
+
hint = "content not compared (no active config body)";
|
|
5745
|
+
}
|
|
5746
|
+
else if (activeConfig.body !== localBody) {
|
|
5747
|
+
status = "modified";
|
|
5748
|
+
hint = "run `sync pull` to rewrite the local .rhai to match running state";
|
|
5749
|
+
}
|
|
5750
|
+
}
|
|
5751
|
+
}
|
|
5752
|
+
catch {
|
|
5753
|
+
// Per-script fetch failure must not abort the whole diff.
|
|
5754
|
+
status = "exists";
|
|
5755
|
+
hint = "content not compared (fetch failed)";
|
|
5756
|
+
}
|
|
5757
|
+
differences.push({ type: "transform", key: name, status, hint });
|
|
5758
|
+
}
|
|
5759
|
+
for (const name of remoteScripts.keys()) {
|
|
5760
|
+
if (!localScripts.has(name)) {
|
|
5761
|
+
differences.push({ type: "transform", key: name, status: "remote only" });
|
|
5762
|
+
}
|
|
5763
|
+
}
|
|
5764
|
+
// Config vars — issue #1423 (see `diffVars`).
|
|
5765
|
+
for (const row of diffVars(localVars, remoteVars)) {
|
|
5766
|
+
differences.push(row);
|
|
5767
|
+
}
|
|
4467
5768
|
// Compare test cases for synced prompts and workflows
|
|
4468
5769
|
const testCaseDiffs = [];
|
|
4469
5770
|
// Helper to compare test cases for a block
|
|
@@ -4584,11 +5885,15 @@ Directory Structure:
|
|
|
4584
5885
|
// Migrate-toml (issue #752): bulk-rewrite database-type TOML files from
|
|
4585
5886
|
// the legacy JSON-string form for `definition`/`params` to the native
|
|
4586
5887
|
// nested-table form. Idempotent: ops already in native form are left as
|
|
4587
|
-
// is;
|
|
4588
|
-
//
|
|
5888
|
+
// is; the round-trip serializes with smol-toml (TOML 1.0, issue #1450), so
|
|
5889
|
+
// heterogeneous arrays now converge to native form too — only shapes that
|
|
5890
|
+
// TOML can't represent without data loss (null / undefined / an array
|
|
5891
|
+
// containing null) fall back to JSON-string per field with a log message.
|
|
4589
5892
|
sync
|
|
4590
5893
|
.command("migrate-toml")
|
|
4591
|
-
.description("Rewrite database-type TOML files to native
|
|
5894
|
+
.description("Rewrite database-type and workflow TOML files to native form " +
|
|
5895
|
+
"([operations.definition] / [[operations.params]] for database types; " +
|
|
5896
|
+
"[workflow.inputSchema] / [workflow.outputSchema] for workflows)")
|
|
4592
5897
|
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
4593
5898
|
.option("--app <app-id>", "App ID")
|
|
4594
5899
|
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
@@ -4607,83 +5912,122 @@ Directory Structure:
|
|
|
4607
5912
|
process.exit(1);
|
|
4608
5913
|
}
|
|
4609
5914
|
const dbTypesDir = join(configDir, "database-types");
|
|
4610
|
-
|
|
4611
|
-
|
|
5915
|
+
const workflowsDir = join(configDir, "workflows");
|
|
5916
|
+
const dbFiles = existsSync(dbTypesDir)
|
|
5917
|
+
? readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml"))
|
|
5918
|
+
: [];
|
|
5919
|
+
const workflowFiles = existsSync(workflowsDir)
|
|
5920
|
+
? readdirSync(workflowsDir).filter((f) => f.endsWith(".toml"))
|
|
5921
|
+
: [];
|
|
5922
|
+
// Restructured early-return (issue #1446): don't bail just because
|
|
5923
|
+
// `database-types/` is absent — a workflow-only project still has
|
|
5924
|
+
// `inputSchema`/`outputSchema` to migrate.
|
|
5925
|
+
if (dbFiles.length === 0 && workflowFiles.length === 0) {
|
|
5926
|
+
info("No database-type or workflow TOML files found; nothing to migrate.");
|
|
4612
5927
|
return;
|
|
4613
5928
|
}
|
|
4614
|
-
const files = readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml"));
|
|
4615
|
-
if (files.length === 0) {
|
|
4616
|
-
info("No database-type TOML files found; nothing to migrate.");
|
|
4617
|
-
return;
|
|
4618
|
-
}
|
|
4619
|
-
// Hydrate ruleSetIdToName from local sync state so files that
|
|
4620
|
-
// reference a rule set via the legacy `ruleSetId = "01..."` form
|
|
4621
|
-
// round-trip with the correct `ruleSetName`. Without this, any user
|
|
4622
|
-
// who runs migrate-toml against a file with a rule-set assignment
|
|
4623
|
-
// would silently lose that reference (review feedback r3246633010).
|
|
4624
|
-
//
|
|
4625
|
-
// Files that use the modern key-based `ruleSetName = "..."` form
|
|
4626
|
-
// don't need the map at all — `parseDatabaseTypeToml` stores the
|
|
4627
|
-
// value in `typeConfig._ruleSetName` and the serializer now prefers
|
|
4628
|
-
// that. The map is only load-bearing for the legacy ID-based form.
|
|
4629
|
-
const ruleSetIdToName = new Map();
|
|
4630
|
-
const migrateSyncState = loadSyncState(configDir);
|
|
4631
|
-
if (migrateSyncState?.entities?.ruleSets) {
|
|
4632
|
-
for (const [fileKey, entry] of Object.entries(migrateSyncState.entities.ruleSets)) {
|
|
4633
|
-
if (entry && typeof entry === "object" && "id" in entry && entry.id) {
|
|
4634
|
-
// fileKey is the sanitized rule-set name (see sync pull at
|
|
4635
|
-
// sync.ts:1385). It's the same shape the server returned and
|
|
4636
|
-
// matches what a TOML file's ruleSetName field would carry.
|
|
4637
|
-
ruleSetIdToName.set(entry.id, fileKey);
|
|
4638
|
-
}
|
|
4639
|
-
}
|
|
4640
|
-
}
|
|
4641
5929
|
let migratedCount = 0;
|
|
4642
5930
|
let unchangedCount = 0;
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
//
|
|
4648
|
-
//
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
5931
|
+
// ---- database-types/ pass ----
|
|
5932
|
+
if (dbFiles.length > 0) {
|
|
5933
|
+
// Hydrate ruleSetIdToName from local sync state so files that
|
|
5934
|
+
// reference a rule set via the legacy `ruleSetId = "01..."` form
|
|
5935
|
+
// round-trip with the correct `ruleSetName`. Without this, any user
|
|
5936
|
+
// who runs migrate-toml against a file with a rule-set assignment
|
|
5937
|
+
// would silently lose that reference (review feedback r3246633010).
|
|
5938
|
+
//
|
|
5939
|
+
// Files that use the modern key-based `ruleSetName = "..."` form
|
|
5940
|
+
// don't need the map at all — `parseDatabaseTypeToml` stores the
|
|
5941
|
+
// value in `typeConfig._ruleSetName` and the serializer now prefers
|
|
5942
|
+
// that. The map is only load-bearing for the legacy ID-based form.
|
|
5943
|
+
const ruleSetIdToName = new Map();
|
|
5944
|
+
const migrateSyncState = loadSyncState(configDir);
|
|
5945
|
+
if (migrateSyncState?.entities?.ruleSets) {
|
|
5946
|
+
for (const [fileKey, entry] of Object.entries(migrateSyncState.entities.ruleSets)) {
|
|
5947
|
+
if (entry && typeof entry === "object" && "id" in entry && entry.id) {
|
|
5948
|
+
// fileKey is the sanitized rule-set name (see sync pull at
|
|
5949
|
+
// sync.ts:1385). It's the same shape the server returned and
|
|
5950
|
+
// matches what a TOML file's ruleSetName field would carry.
|
|
5951
|
+
ruleSetIdToName.set(entry.id, fileKey);
|
|
5952
|
+
}
|
|
5953
|
+
}
|
|
4655
5954
|
}
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
|
|
5955
|
+
for (const file of dbFiles) {
|
|
5956
|
+
const filePath = join(dbTypesDir, file);
|
|
5957
|
+
const rawBefore = readFileSync(filePath, "utf-8");
|
|
5958
|
+
const tomlData = parseTomlFile(filePath);
|
|
5959
|
+
// `accessRule`/`access` conflicts surface as a clear error here too,
|
|
5960
|
+
// rather than silently dropping the field during a migrate rewrite.
|
|
5961
|
+
let typeConfig;
|
|
5962
|
+
let operations;
|
|
5963
|
+
let subscriptions;
|
|
5964
|
+
try {
|
|
5965
|
+
({ typeConfig, operations, subscriptions } =
|
|
5966
|
+
parseDatabaseTypeToml(tomlData));
|
|
5967
|
+
}
|
|
5968
|
+
catch (err) {
|
|
5969
|
+
if (err instanceof SubscriptionAccessKeyConflictError) {
|
|
5970
|
+
error(` ${file}: ${err.message}`);
|
|
5971
|
+
error(`Aborting migrate-toml: subscription \`access\`/\`accessRule\` conflict in ${file}.`);
|
|
5972
|
+
process.exit(1);
|
|
5973
|
+
}
|
|
5974
|
+
throw err;
|
|
5975
|
+
}
|
|
5976
|
+
// Force-native: ignore existing hints so every TOMLable field
|
|
5977
|
+
// ends up in nested-table form. Subscriptions are preserved on the
|
|
5978
|
+
// rewrite (issue #803) so migrate-toml never drops them.
|
|
5979
|
+
const rewritten = serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
|
|
5980
|
+
defaultForm: "native",
|
|
5981
|
+
logger: (msg) => info(` ${msg}`),
|
|
5982
|
+
subscriptions,
|
|
5983
|
+
});
|
|
5984
|
+
if (rewritten === rawBefore) {
|
|
5985
|
+
unchangedCount++;
|
|
5986
|
+
continue;
|
|
4661
5987
|
}
|
|
4662
|
-
|
|
5988
|
+
if (options.dryRun) {
|
|
5989
|
+
info(`Would migrate database-types/${file}`);
|
|
5990
|
+
}
|
|
5991
|
+
else {
|
|
5992
|
+
writeFileSync(filePath, rewritten);
|
|
5993
|
+
info(`Migrated database-types/${file}`);
|
|
5994
|
+
// Update content hash in sync state if we have an entry for it.
|
|
5995
|
+
const syncState = loadSyncState(configDir);
|
|
5996
|
+
const dbType = typeConfig.databaseType || basename(file, ".toml");
|
|
5997
|
+
if (syncState?.entities?.databaseTypes?.[dbType]) {
|
|
5998
|
+
syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
|
|
5999
|
+
saveSyncState(configDir, syncState);
|
|
6000
|
+
}
|
|
6001
|
+
}
|
|
6002
|
+
migratedCount++;
|
|
4663
6003
|
}
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
4667
|
-
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
6004
|
+
}
|
|
6005
|
+
// ---- workflows/ pass (issue #1446) ----
|
|
6006
|
+
// Convert string-form `inputSchema`/`outputSchema` to native tables.
|
|
6007
|
+
// Purely local, mirroring the database-types pass. The stored workflow
|
|
6008
|
+
// hash uses `computeExpandedContentHash` (parse then hash), NOT
|
|
6009
|
+
// `computeFileHash`, matching the workflow convention on the pull/push
|
|
6010
|
+
// paths — otherwise a push after migrate would spuriously re-push.
|
|
6011
|
+
for (const file of workflowFiles) {
|
|
6012
|
+
const filePath = join(workflowsDir, file);
|
|
6013
|
+
const rawBefore = readFileSync(filePath, "utf-8");
|
|
6014
|
+
const workflowKey = basename(file, ".toml");
|
|
6015
|
+
const { content: rewritten, changed } = rewriteWorkflowSchemasToNative(rawBefore, workflowKey, (msg) => info(` ${msg}`));
|
|
6016
|
+
if (!changed) {
|
|
4673
6017
|
unchangedCount++;
|
|
4674
6018
|
continue;
|
|
4675
6019
|
}
|
|
4676
6020
|
if (options.dryRun) {
|
|
4677
|
-
info(`Would migrate
|
|
6021
|
+
info(`Would migrate workflows/${file}`);
|
|
4678
6022
|
}
|
|
4679
6023
|
else {
|
|
4680
6024
|
writeFileSync(filePath, rewritten);
|
|
4681
|
-
info(`Migrated
|
|
4682
|
-
// Update content hash
|
|
6025
|
+
info(`Migrated workflows/${file}`);
|
|
6026
|
+
// Update the stored content hash so a subsequent push sees a no-op.
|
|
4683
6027
|
const syncState = loadSyncState(configDir);
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
6028
|
+
if (syncState?.entities?.workflows?.[workflowKey]) {
|
|
6029
|
+
syncState.entities.workflows[workflowKey].contentHash =
|
|
6030
|
+
computeExpandedContentHash(parseTomlFile(filePath));
|
|
4687
6031
|
saveSyncState(configDir, syncState);
|
|
4688
6032
|
}
|
|
4689
6033
|
}
|
|
@@ -4699,6 +6043,63 @@ Directory Structure:
|
|
|
4699
6043
|
success(`Rewrote ${migratedCount} TOML file(s) to native form.`);
|
|
4700
6044
|
}
|
|
4701
6045
|
});
|
|
6046
|
+
// migrate-v2 — convert a legacy sync directory into the unified block
|
|
6047
|
+
// layout `blocks/<type>/<key>/block.toml` (issue #1183, Phase 3). Purely
|
|
6048
|
+
// local: it reads already-pulled legacy files and writes the v2 tree. The
|
|
6049
|
+
// legacy files are left in place (legacy stays the default until v2 is
|
|
6050
|
+
// proven), so re-running push/pull is unaffected.
|
|
6051
|
+
sync
|
|
6052
|
+
.command("migrate-v2")
|
|
6053
|
+
.description("Convert legacy per-type sync files (prompts/, integrations/, workflows/, transforms/) into the v2 blocks/<type>/<key>/block.toml layout")
|
|
6054
|
+
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
6055
|
+
.option("--app <app-id>", "App ID")
|
|
6056
|
+
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
6057
|
+
.option("--dry-run", "Show what would change without writing files")
|
|
6058
|
+
.option("--force", "Overwrite existing block.toml files instead of skipping them")
|
|
6059
|
+
.action(async (appId, options) => {
|
|
6060
|
+
// Resolve appId so we land in the per-env sync dir even when the user
|
|
6061
|
+
// didn't pass `--dir`. The app ID itself isn't used for any API calls —
|
|
6062
|
+
// migrate-v2 is a purely local rewrite (like migrate-toml).
|
|
6063
|
+
const resolvedAppId = resolveAppId(appId, options);
|
|
6064
|
+
const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
|
|
6065
|
+
if (isAutoResolvedSyncDir(options.dir)) {
|
|
6066
|
+
info(`Using per-environment sync directory: ${configDir}`);
|
|
6067
|
+
}
|
|
6068
|
+
if (!existsSync(configDir)) {
|
|
6069
|
+
error(`Config directory not found: ${configDir}`);
|
|
6070
|
+
process.exit(1);
|
|
6071
|
+
}
|
|
6072
|
+
const before = detectLayout(configDir);
|
|
6073
|
+
if (!before.legacy) {
|
|
6074
|
+
info("No legacy block files found (prompts/, integrations/, workflows/, transforms/); nothing to migrate.");
|
|
6075
|
+
return;
|
|
6076
|
+
}
|
|
6077
|
+
const result = migrateLegacyToV2(configDir, {
|
|
6078
|
+
dryRun: options.dryRun,
|
|
6079
|
+
force: options.force,
|
|
6080
|
+
});
|
|
6081
|
+
for (const b of result.blocks) {
|
|
6082
|
+
const suffix = b.testsCopied > 0 ? ` (+${b.testsCopied} test case(s))` : "";
|
|
6083
|
+
if (options.dryRun) {
|
|
6084
|
+
info(`Would write blocks/${b.type === "script" ? "scripts" : b.type + "s"}/${b.key}/block.toml${suffix}`);
|
|
6085
|
+
}
|
|
6086
|
+
else {
|
|
6087
|
+
info(`Wrote blocks/${b.type === "script" ? "scripts" : b.type + "s"}/${b.key}/block.toml${suffix}`);
|
|
6088
|
+
}
|
|
6089
|
+
}
|
|
6090
|
+
for (const s of result.skipped) {
|
|
6091
|
+
warn(`Skipped ${s.type}/${s.key}: ${s.reason}`);
|
|
6092
|
+
}
|
|
6093
|
+
divider();
|
|
6094
|
+
keyValue("Migrated", result.blocks.length);
|
|
6095
|
+
keyValue("Skipped", result.skipped.length);
|
|
6096
|
+
if (options.dryRun) {
|
|
6097
|
+
info("Dry-run only — no files were modified.");
|
|
6098
|
+
}
|
|
6099
|
+
else if (result.blocks.length > 0) {
|
|
6100
|
+
success(`Converted ${result.blocks.length} block(s) to the v2 layout. Legacy files were left in place.`);
|
|
6101
|
+
}
|
|
6102
|
+
});
|
|
4702
6103
|
// Revert — restore a pre-pull snapshot (issue #578, Phase 1).
|
|
4703
6104
|
sync
|
|
4704
6105
|
.command("revert")
|