primitive-admin 1.0.53 → 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/databases.js +191 -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/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 +189 -9
- package/dist/src/commands/sync.js +1418 -256
- 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 +645 -59
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +120 -18
- package/dist/src/lib/api-client.js +125 -2
- 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 +43 -0
- package/dist/src/lib/generated-allowlist.js.map +1 -1
- package/dist/src/lib/init-config.js +2 -2
- package/dist/src/lib/init-config.js.map +1 -1
- package/dist/src/lib/output.d.ts +25 -5
- package/dist/src/lib/output.js +32 -4
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/query-operators.d.ts +43 -0
- package/dist/src/lib/query-operators.js +80 -0
- package/dist/src/lib/query-operators.js.map +1 -0
- package/dist/src/lib/toml-database-config.d.ts +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,16 +1,21 @@
|
|
|
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";
|
|
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";
|
|
14
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";
|
|
@@ -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);
|
|
373
870
|
}
|
|
374
|
-
|
|
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 };
|
|
920
|
+
}
|
|
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 || {};
|
|
@@ -963,8 +1571,10 @@ async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey
|
|
|
963
1571
|
const configMapKey = `${blockKey}#${testPayload._configName}`;
|
|
964
1572
|
const configMap = blockType === "prompt"
|
|
965
1573
|
? resolutionMaps.promptConfigNameToId
|
|
966
|
-
:
|
|
967
|
-
|
|
1574
|
+
: blockType === "script"
|
|
1575
|
+
? resolutionMaps.scriptConfigNameToId
|
|
1576
|
+
: resolutionMaps.workflowConfigNameToId;
|
|
1577
|
+
const resolvedId = configMap?.get(configMapKey);
|
|
968
1578
|
if (resolvedId) {
|
|
969
1579
|
testPayload.configId = resolvedId;
|
|
970
1580
|
}
|
|
@@ -1166,6 +1776,7 @@ Directory Structure:
|
|
|
1166
1776
|
rule-sets/*.toml # Access rule sets
|
|
1167
1777
|
group-type-configs/*.toml # Group type configs
|
|
1168
1778
|
collection-type-configs/*.toml # Collection type configs
|
|
1779
|
+
metadata-category-configs/*.toml # Resource-metadata category configs
|
|
1169
1780
|
email-templates/*.toml # Email template overrides
|
|
1170
1781
|
`);
|
|
1171
1782
|
// Init
|
|
@@ -1196,6 +1807,7 @@ Directory Structure:
|
|
|
1196
1807
|
ensureDir(join(configDir, "rule-sets"));
|
|
1197
1808
|
ensureDir(join(configDir, "group-type-configs"));
|
|
1198
1809
|
ensureDir(join(configDir, "collection-type-configs"));
|
|
1810
|
+
ensureDir(join(configDir, "metadata-category-configs"));
|
|
1199
1811
|
ensureDir(join(configDir, "email-templates"));
|
|
1200
1812
|
const state = {
|
|
1201
1813
|
appId: resolvedAppId,
|
|
@@ -1267,11 +1879,12 @@ Directory Structure:
|
|
|
1267
1879
|
return;
|
|
1268
1880
|
}
|
|
1269
1881
|
// Fetch database config resources
|
|
1270
|
-
const [databaseTypeConfigsResult, ruleSetsResult, groupTypeConfigsResult, collectionTypeConfigsResult,] = await Promise.all([
|
|
1882
|
+
const [databaseTypeConfigsResult, ruleSetsResult, groupTypeConfigsResult, collectionTypeConfigsResult, metadataCategoryConfigsResult,] = await Promise.all([
|
|
1271
1883
|
client.listDatabaseTypeConfigs(resolvedAppId).catch(() => []),
|
|
1272
1884
|
client.listRuleSets(resolvedAppId).catch(() => []),
|
|
1273
1885
|
client.listGroupTypeConfigs(resolvedAppId).catch(() => []),
|
|
1274
1886
|
client.listCollectionTypeConfigs(resolvedAppId).catch(() => []),
|
|
1887
|
+
client.listMetadataCategoryConfigs(resolvedAppId).catch(() => []),
|
|
1275
1888
|
]);
|
|
1276
1889
|
// Fetch operations + subscriptions for each database type. Issue #803:
|
|
1277
1890
|
// subscriptions are pulled symmetrically with operations so a
|
|
@@ -1330,6 +1943,7 @@ Directory Structure:
|
|
|
1330
1943
|
ensureDir(join(configDir, "rule-sets"));
|
|
1331
1944
|
ensureDir(join(configDir, "group-type-configs"));
|
|
1332
1945
|
ensureDir(join(configDir, "collection-type-configs"));
|
|
1946
|
+
ensureDir(join(configDir, "metadata-category-configs"));
|
|
1333
1947
|
ensureDir(join(configDir, "email-templates"));
|
|
1334
1948
|
// Write app settings
|
|
1335
1949
|
if (settings) {
|
|
@@ -1337,6 +1951,38 @@ Directory Structure:
|
|
|
1337
1951
|
writeFileSync(appTomlPath, serializeAppSettings(settings));
|
|
1338
1952
|
info(" Wrote app.toml");
|
|
1339
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
|
+
}
|
|
1340
1986
|
// Write integrations
|
|
1341
1987
|
const integrationEntities = {};
|
|
1342
1988
|
for (const integration of integrations) {
|
|
@@ -1425,7 +2071,7 @@ Directory Structure:
|
|
|
1425
2071
|
for (const { workflow, draft, configs } of workflows) {
|
|
1426
2072
|
const filename = `${workflow.workflowKey}.toml`;
|
|
1427
2073
|
const filePath = join(configDir, "workflows", filename);
|
|
1428
|
-
writeFileSync(filePath, serializeWorkflow(workflow, draft, configs || []));
|
|
2074
|
+
writeFileSync(filePath, serializeWorkflow(workflow, draft, configs || [], (msg) => info(` ${msg}`)));
|
|
1429
2075
|
// Hash the expanded (post-fragment-splice) form so subsequent pushes
|
|
1430
2076
|
// can detect fragment-only edits. See `computeExpandedContentHash`.
|
|
1431
2077
|
// Pulled workflows never carry `include`, so the expander is a
|
|
@@ -1468,6 +2114,23 @@ Directory Structure:
|
|
|
1468
2114
|
}
|
|
1469
2115
|
}
|
|
1470
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
|
+
}
|
|
1471
2134
|
const testCaseLookupMaps = { configIdToName, promptIdToKey };
|
|
1472
2135
|
// Write email templates (use detailed emailTemplates, not summaries)
|
|
1473
2136
|
const emailTemplateEntities = {};
|
|
@@ -1510,6 +2173,15 @@ Directory Structure:
|
|
|
1510
2173
|
}
|
|
1511
2174
|
totalTestCases += count;
|
|
1512
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
|
+
}
|
|
1513
2185
|
// Build ruleSetId → name map for database types and group type configs
|
|
1514
2186
|
const ruleSets = Array.isArray(ruleSetsResult) ? ruleSetsResult : [];
|
|
1515
2187
|
const ruleSetIdToName = new Map();
|
|
@@ -1605,6 +2277,23 @@ Directory Structure:
|
|
|
1605
2277
|
};
|
|
1606
2278
|
info(` Wrote collection-type-configs/${filename}`);
|
|
1607
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
|
+
}
|
|
1608
2297
|
// Save sync state
|
|
1609
2298
|
const state = {
|
|
1610
2299
|
appId: resolvedAppId,
|
|
@@ -1623,15 +2312,20 @@ Directory Structure:
|
|
|
1623
2312
|
testCases: Object.keys(testCaseEntities).length > 0 ? testCaseEntities : undefined,
|
|
1624
2313
|
databaseTypes: Object.keys(databaseTypeEntities).length > 0 ? databaseTypeEntities : undefined,
|
|
1625
2314
|
ruleSets: Object.keys(ruleSetEntities).length > 0 ? ruleSetEntities : undefined,
|
|
2315
|
+
vars: Object.keys(varEntities).length > 0 ? varEntities : undefined,
|
|
1626
2316
|
groupTypeConfigs: Object.keys(groupTypeConfigEntities).length > 0 ? groupTypeConfigEntities : undefined,
|
|
1627
2317
|
collectionTypeConfigs: Object.keys(collectionTypeConfigEntities).length > 0
|
|
1628
2318
|
? collectionTypeConfigEntities
|
|
1629
2319
|
: undefined,
|
|
2320
|
+
metadataCategoryConfigs: Object.keys(metadataCategoryConfigEntities).length > 0
|
|
2321
|
+
? metadataCategoryConfigEntities
|
|
2322
|
+
: undefined,
|
|
1630
2323
|
},
|
|
1631
2324
|
};
|
|
1632
2325
|
saveSyncState(configDir, state);
|
|
1633
2326
|
divider();
|
|
1634
2327
|
success(`Pulled configuration to ${configDir}`);
|
|
2328
|
+
keyValue("Config Vars", Object.keys(varEntities).length);
|
|
1635
2329
|
keyValue("Integrations", integrations.length);
|
|
1636
2330
|
keyValue("Webhooks", webhooks.length);
|
|
1637
2331
|
keyValue("Cron Triggers", cronTriggerItems.length);
|
|
@@ -1645,6 +2339,7 @@ Directory Structure:
|
|
|
1645
2339
|
keyValue("Rule Sets", ruleSets.length);
|
|
1646
2340
|
keyValue("Group Type Configs", groupTypeConfigs.length);
|
|
1647
2341
|
keyValue("Collection Type Configs", collectionTypeConfigs.length);
|
|
2342
|
+
keyValue("Metadata Category Configs", metadataCategoryConfigs.length);
|
|
1648
2343
|
}
|
|
1649
2344
|
catch (err) {
|
|
1650
2345
|
error(err.message);
|
|
@@ -1896,10 +2591,91 @@ Directory Structure:
|
|
|
1896
2591
|
}
|
|
1897
2592
|
}
|
|
1898
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
|
+
}
|
|
1899
2613
|
if (preflightValidationErrors.length > 0) {
|
|
1900
2614
|
throw new Error(`Aborting push: ${preflightValidationErrors.length} TOML validation error(s) — no changes were applied.\n` +
|
|
1901
2615
|
preflightValidationErrors.join("\n"));
|
|
1902
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
|
+
}
|
|
1903
2679
|
// Process app settings
|
|
1904
2680
|
const appTomlPath = join(configDir, "app.toml");
|
|
1905
2681
|
if (existsSync(appTomlPath)) {
|
|
@@ -1920,11 +2696,10 @@ Directory Structure:
|
|
|
1920
2696
|
}
|
|
1921
2697
|
}
|
|
1922
2698
|
if (tomlData.auth) {
|
|
1923
|
-
settings
|
|
1924
|
-
settings
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
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}`);
|
|
1928
2703
|
}
|
|
1929
2704
|
}
|
|
1930
2705
|
if (tomlData.cors) {
|
|
@@ -1948,6 +2723,135 @@ Directory Structure:
|
|
|
1948
2723
|
}
|
|
1949
2724
|
}
|
|
1950
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
|
+
}
|
|
1951
2855
|
// Process rule sets first (other entities may reference them by name)
|
|
1952
2856
|
const ruleSetsDir = join(configDir, "rule-sets");
|
|
1953
2857
|
if (existsSync(ruleSetsDir)) {
|
|
@@ -2142,6 +3046,14 @@ Directory Structure:
|
|
|
2142
3046
|
deduplicationWindowMs: webhook.deduplicationWindowMs,
|
|
2143
3047
|
secretGracePeriodMs: webhook.secretGracePeriodMs,
|
|
2144
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
|
+
}
|
|
2145
3057
|
// Handle JSON fields
|
|
2146
3058
|
if (tomlData.allowedIps?.cidrs) {
|
|
2147
3059
|
payload.allowedIpCidrs = JSON.stringify(tomlData.allowedIps.cidrs);
|
|
@@ -2346,30 +3258,27 @@ Directory Structure:
|
|
|
2346
3258
|
if (existingId) {
|
|
2347
3259
|
// #1020 (D8): idempotently apply preset/ruleSet/name/description
|
|
2348
3260
|
// changes via PATCH. bucketKey/ttlTier are immutable and not sent.
|
|
2349
|
-
const updatePayload =
|
|
2350
|
-
if (bucket.preset)
|
|
2351
|
-
updatePayload.preset = bucket.preset;
|
|
2352
|
-
else if (bucket.accessPolicy)
|
|
2353
|
-
updatePayload.accessPolicy = bucket.accessPolicy;
|
|
2354
|
-
if (bucket.ruleSetId)
|
|
2355
|
-
updatePayload.ruleSetId = bucket.ruleSetId;
|
|
2356
|
-
if (bucket.name)
|
|
2357
|
-
updatePayload.name = bucket.name;
|
|
2358
|
-
if (bucket.description !== undefined)
|
|
2359
|
-
updatePayload.description = bucket.description || null;
|
|
3261
|
+
const updatePayload = buildBlobBucketUpdatePayload(bucket);
|
|
2360
3262
|
changes.push({ type: "blob-bucket", action: "update", key });
|
|
2361
3263
|
if (!options.dryRun) {
|
|
2362
3264
|
try {
|
|
2363
3265
|
await client.updateBlobBucket(resolvedAppId, existingId, updatePayload);
|
|
2364
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
|
+
}
|
|
2365
3275
|
}
|
|
2366
3276
|
catch (err) {
|
|
3277
|
+
// Keep the warn-and-continue contract (sponsor decision):
|
|
3278
|
+
// do not abort the push, just don't stamp the hash.
|
|
2367
3279
|
warn(` Failed to update blob bucket ${key}: ${String(err?.message || err)}`);
|
|
2368
3280
|
}
|
|
2369
3281
|
}
|
|
2370
|
-
if (syncState?.entities?.blobBuckets?.[key]) {
|
|
2371
|
-
syncState.entities.blobBuckets[key].contentHash = computeFileHash(filePath);
|
|
2372
|
-
}
|
|
2373
3282
|
}
|
|
2374
3283
|
else {
|
|
2375
3284
|
const payload = {
|
|
@@ -2404,23 +3313,86 @@ Directory Structure:
|
|
|
2404
3313
|
catch (err) {
|
|
2405
3314
|
const msg = String(err?.message || err);
|
|
2406
3315
|
if (msg.includes("already exists") || err.statusCode === 409) {
|
|
2407
|
-
|
|
2408
|
-
//
|
|
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;
|
|
2409
3327
|
try {
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
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);
|
|
2420
3373
|
}
|
|
3374
|
+
catch (updateErr) {
|
|
3375
|
+
throw wrapEntityError(updateErr, "update", "blob bucket", key);
|
|
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}`);
|
|
2421
3384
|
}
|
|
2422
|
-
|
|
2423
|
-
|
|
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
|
+
};
|
|
2424
3396
|
}
|
|
2425
3397
|
}
|
|
2426
3398
|
else {
|
|
@@ -2774,6 +3746,19 @@ Directory Structure:
|
|
|
2774
3746
|
const workflow = tomlData.workflow || {};
|
|
2775
3747
|
const key = workflow.key || basename(file, ".toml");
|
|
2776
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;
|
|
2777
3762
|
const existingId = syncState?.entities?.workflows?.[key]?.id;
|
|
2778
3763
|
const existingActiveConfigId = syncState?.entities?.workflows?.[key]?.activeConfigId;
|
|
2779
3764
|
// Skip if file hasn't changed since last sync. Use the expanded
|
|
@@ -2805,80 +3790,17 @@ Directory Structure:
|
|
|
2805
3790
|
// out-of-manifest workflow).
|
|
2806
3791
|
const applyWorkflowUpdate = async (workflowId, opts) => {
|
|
2807
3792
|
const { expectedModifiedAt, activeConfigId } = opts;
|
|
2808
|
-
//
|
|
2809
|
-
//
|
|
2810
|
-
//
|
|
2811
|
-
//
|
|
2812
|
-
//
|
|
2813
|
-
//
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
// AFTER the config/step update lands, so it validates against
|
|
2820
|
-
// the new (compatible) active steps. `requiresClientApply`
|
|
2821
|
-
// has no step-dependent validation, so it stays in the first
|
|
2822
|
-
// PATCH unchanged.
|
|
2823
|
-
const updated = await client.updateWorkflow(resolvedAppId, workflowId, {
|
|
2824
|
-
name: workflow.name,
|
|
2825
|
-
description: workflow.description,
|
|
2826
|
-
status: workflow.status,
|
|
2827
|
-
accessRule: workflow.accessRule !== undefined ? (workflow.accessRule || null) : undefined,
|
|
2828
|
-
// #1081 — workflow principal mode. Absent key → undefined
|
|
2829
|
-
// (server leaves it untouched); explicit value/empty is
|
|
2830
|
-
// forwarded so it round-trips.
|
|
2831
|
-
runAs: workflow.runAs !== undefined ? (workflow.runAs || null) : undefined,
|
|
2832
|
-
perUserMaxRunning: workflow.perUserMaxRunning,
|
|
2833
|
-
perUserMaxQueued: workflow.perUserMaxQueued,
|
|
2834
|
-
dequeueOrder: workflow.dequeueOrder,
|
|
2835
|
-
// Sync-callable flags (#807): pass through as-is. An absent
|
|
2836
|
-
// TOML key is `undefined` (dropped by JSON.stringify, so the
|
|
2837
|
-
// server's hasOwnProperty guard leaves the value untouched);
|
|
2838
|
-
// an explicit `false` is preserved as a meaningful value.
|
|
2839
|
-
requiresClientApply: workflow.requiresClientApply,
|
|
2840
|
-
inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
|
|
2841
|
-
outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : null,
|
|
2842
|
-
}, expectedModifiedAt);
|
|
2843
|
-
// Track the latest workflow `modifiedAt` for the sync-state
|
|
2844
|
-
// write below. The first PATCH bumps it; the `syncCallable`
|
|
2845
|
-
// PATCH (if any) bumps it again. The config/step update does
|
|
2846
|
-
// NOT touch the workflow definition's `modifiedAt`.
|
|
2847
|
-
let latestModifiedAt = updated?.workflow?.modifiedAt;
|
|
2848
|
-
// Update active configuration steps (or draft for legacy).
|
|
2849
|
-
// Issue #687: name the slot we touched so the dev-loop
|
|
2850
|
-
// user can confirm before previewing.
|
|
2851
|
-
let updateSlotLabel = "active config";
|
|
2852
|
-
if (activeConfigId) {
|
|
2853
|
-
await client.updateWorkflowConfig(resolvedAppId, workflowId, activeConfigId, {
|
|
2854
|
-
steps,
|
|
2855
|
-
});
|
|
2856
|
-
}
|
|
2857
|
-
else {
|
|
2858
|
-
// Fallback to draft update for legacy workflows
|
|
2859
|
-
await client.updateWorkflowDraft(resolvedAppId, workflowId, {
|
|
2860
|
-
steps,
|
|
2861
|
-
inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
|
|
2862
|
-
});
|
|
2863
|
-
updateSlotLabel = "draft (legacy)";
|
|
2864
|
-
}
|
|
2865
|
-
// Second PATCH: apply `syncCallable` now that the new steps
|
|
2866
|
-
// are active (#807). Only sent when the TOML actually carries
|
|
2867
|
-
// the key — an absent key stays `undefined` and we skip the
|
|
2868
|
-
// call entirely, preserving the no-clobber discipline. Chain
|
|
2869
|
-
// `expectedModifiedAt` off the first PATCH's returned value so
|
|
2870
|
-
// optimistic concurrency stays intact (the step update above
|
|
2871
|
-
// doesn't change the workflow definition's `modifiedAt`).
|
|
2872
|
-
if (workflow.syncCallable !== undefined) {
|
|
2873
|
-
const syncCallableUpdated = await client.updateWorkflow(resolvedAppId, workflowId, { syncCallable: workflow.syncCallable },
|
|
2874
|
-
// Mirror the first PATCH's concurrency posture: `--force`
|
|
2875
|
-
// skips the check (undefined), otherwise reuse the fresh
|
|
2876
|
-
// `modifiedAt` from the first PATCH.
|
|
2877
|
-
options.force ? undefined : latestModifiedAt);
|
|
2878
|
-
if (syncCallableUpdated?.workflow?.modifiedAt) {
|
|
2879
|
-
latestModifiedAt = syncCallableUpdated.workflow.modifiedAt;
|
|
2880
|
-
}
|
|
2881
|
-
}
|
|
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
|
+
});
|
|
2882
3804
|
info(` Updated workflow: ${key} (${updateSlotLabel})`);
|
|
2883
3805
|
// Update sync state with new modifiedAt. Store the *expanded*
|
|
2884
3806
|
// content hash so future fragment-only edits are detected. The
|
|
@@ -2888,9 +3810,8 @@ Directory Structure:
|
|
|
2888
3810
|
syncState.entities.workflows[key].modifiedAt = latestModifiedAt;
|
|
2889
3811
|
syncState.entities.workflows[key].contentHash = computeExpandedContentHash(tomlData);
|
|
2890
3812
|
}
|
|
2891
|
-
//
|
|
2892
|
-
// (
|
|
2893
|
-
const fullWorkflow = await client.getWorkflow(resolvedAppId, workflowId);
|
|
3813
|
+
// `applyWorkflowBody` already fetched the full workflow; reuse it
|
|
3814
|
+
// for the config name→ID mappings (the PATCH response omits configs).
|
|
2894
3815
|
if (fullWorkflow?.configs) {
|
|
2895
3816
|
for (const config of fullWorkflow.configs) {
|
|
2896
3817
|
workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
@@ -2930,25 +3851,22 @@ Directory Structure:
|
|
|
2930
3851
|
changes.push({ type: "workflow", action: "create", key });
|
|
2931
3852
|
if (!options.dryRun) {
|
|
2932
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`.
|
|
2933
3862
|
const created = await client.createWorkflow(resolvedAppId, {
|
|
2934
3863
|
workflowKey: key,
|
|
2935
|
-
name: workflow.name || key,
|
|
2936
|
-
description: workflow.description,
|
|
2937
3864
|
steps,
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
//
|
|
2942
|
-
|
|
2943
|
-
perUserMaxRunning: workflow.perUserMaxRunning,
|
|
2944
|
-
perUserMaxQueued: workflow.perUserMaxQueued,
|
|
2945
|
-
dequeueOrder: workflow.dequeueOrder,
|
|
2946
|
-
// Sync-callable flags (#807): pass through as-is. An absent
|
|
2947
|
-
// TOML key is `undefined` (dropped by JSON.stringify), so the
|
|
2948
|
-
// server applies its defaults (requiresClientApply=true,
|
|
2949
|
-
// syncCallable=false); an explicit value is honored.
|
|
2950
|
-
requiresClientApply: workflow.requiresClientApply,
|
|
2951
|
-
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,
|
|
2952
3870
|
});
|
|
2953
3871
|
info(` Created workflow: ${key}`);
|
|
2954
3872
|
// Add new entity to sync state (including activeConfigId)
|
|
@@ -3214,6 +4132,14 @@ Directory Structure:
|
|
|
3214
4132
|
if ("timestamps" in typeConfig) {
|
|
3215
4133
|
u.timestamps = typeConfig.timestamps || null;
|
|
3216
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
|
+
}
|
|
3217
4143
|
const localHasSchema = typeof typeConfig.schema === "string" &&
|
|
3218
4144
|
typeConfig.schema.trim().length > 0;
|
|
3219
4145
|
if (localHasSchema) {
|
|
@@ -3583,7 +4509,8 @@ Directory Structure:
|
|
|
3583
4509
|
"defaultAccess" in typeConfig ||
|
|
3584
4510
|
"autoPopulatedFields" in typeConfig ||
|
|
3585
4511
|
"timestamps" in typeConfig ||
|
|
3586
|
-
"schema" in typeConfig
|
|
4512
|
+
"schema" in typeConfig ||
|
|
4513
|
+
"metadataManifest" in typeConfig;
|
|
3587
4514
|
if (wouldUpdate) {
|
|
3588
4515
|
changes.push({ type: "database-type", action: "update", key: dbType });
|
|
3589
4516
|
}
|
|
@@ -3616,6 +4543,9 @@ Directory Structure:
|
|
|
3616
4543
|
// validating against the intended shape.
|
|
3617
4544
|
if (typeConfig.schema)
|
|
3618
4545
|
createData.schema = typeConfig.schema;
|
|
4546
|
+
// Issue #1304 (P-C): declared-access manifest.
|
|
4547
|
+
if (typeConfig.metadataManifest)
|
|
4548
|
+
createData.metadataManifest = typeConfig.metadataManifest;
|
|
3619
4549
|
try {
|
|
3620
4550
|
const created = await client.createDatabaseTypeConfig(resolvedAppId, createData);
|
|
3621
4551
|
info(` Created database type: ${dbType}`);
|
|
@@ -4072,6 +5002,10 @@ Directory Structure:
|
|
|
4072
5002
|
const updated = await client.updateGroupTypeConfig(resolvedAppId, groupType, {
|
|
4073
5003
|
ruleSetId: configData.ruleSetId,
|
|
4074
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,
|
|
4075
5009
|
}, expectedModifiedAt);
|
|
4076
5010
|
info(` Updated group type config: ${groupType}`);
|
|
4077
5011
|
if (syncState?.entities?.groupTypeConfigs?.[groupType] && updated?.modifiedAt) {
|
|
@@ -4103,6 +5037,9 @@ Directory Structure:
|
|
|
4103
5037
|
groupType,
|
|
4104
5038
|
ruleSetId: configData.ruleSetId || undefined,
|
|
4105
5039
|
autoAddCreator: configData.autoAddCreator,
|
|
5040
|
+
...(configData.metadataManifest
|
|
5041
|
+
? { metadataManifest: configData.metadataManifest }
|
|
5042
|
+
: {}),
|
|
4106
5043
|
});
|
|
4107
5044
|
info(` Created group type config: ${groupType}`);
|
|
4108
5045
|
if (syncState) {
|
|
@@ -4149,6 +5086,7 @@ Directory Structure:
|
|
|
4149
5086
|
try {
|
|
4150
5087
|
const updated = await client.updateCollectionTypeConfig(resolvedAppId, collectionType, {
|
|
4151
5088
|
ruleSetId: configData.ruleSetId ?? null,
|
|
5089
|
+
metadataManifest: configData.metadataManifest ?? null,
|
|
4152
5090
|
}, expectedModifiedAt);
|
|
4153
5091
|
info(` Updated collection type config: ${collectionType}`);
|
|
4154
5092
|
if (syncState?.entities?.collectionTypeConfigs?.[collectionType] && updated?.modifiedAt) {
|
|
@@ -4179,6 +5117,9 @@ Directory Structure:
|
|
|
4179
5117
|
const created = await client.createCollectionTypeConfig(resolvedAppId, {
|
|
4180
5118
|
collectionType,
|
|
4181
5119
|
ruleSetId: configData.ruleSetId || undefined,
|
|
5120
|
+
...(configData.metadataManifest
|
|
5121
|
+
? { metadataManifest: configData.metadataManifest }
|
|
5122
|
+
: {}),
|
|
4182
5123
|
});
|
|
4183
5124
|
info(` Created collection type config: ${collectionType}`);
|
|
4184
5125
|
if (syncState) {
|
|
@@ -4198,6 +5139,69 @@ Directory Structure:
|
|
|
4198
5139
|
}
|
|
4199
5140
|
}
|
|
4200
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
|
+
}
|
|
4201
5205
|
// Process email templates
|
|
4202
5206
|
const emailTemplatesDir = join(configDir, "email-templates");
|
|
4203
5207
|
if (existsSync(emailTemplatesDir)) {
|
|
@@ -4234,11 +5238,13 @@ Directory Structure:
|
|
|
4234
5238
|
}
|
|
4235
5239
|
}
|
|
4236
5240
|
}
|
|
4237
|
-
// Push test cases for prompts and
|
|
5241
|
+
// Push test cases for prompts, workflows, and scripts
|
|
5242
|
+
const scriptConfigNameToId = new Map(); // "scriptName#configName" → configId
|
|
4238
5243
|
const pushMaps = {
|
|
4239
5244
|
promptKeyToId,
|
|
4240
5245
|
promptConfigNameToId,
|
|
4241
5246
|
workflowConfigNameToId,
|
|
5247
|
+
scriptConfigNameToId,
|
|
4242
5248
|
};
|
|
4243
5249
|
const promptsDir2 = join(configDir, "prompts");
|
|
4244
5250
|
if (existsSync(promptsDir2)) {
|
|
@@ -4262,6 +5268,33 @@ Directory Structure:
|
|
|
4262
5268
|
}
|
|
4263
5269
|
}
|
|
4264
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
|
+
}
|
|
4265
5298
|
divider();
|
|
4266
5299
|
// Issue #813: a gate failure surfaced by the validate-first pass (which
|
|
4267
5300
|
// also runs in dry-run mode via the server's dry-run gate endpoints).
|
|
@@ -4499,6 +5532,31 @@ Directory Structure:
|
|
|
4499
5532
|
localScripts.set(name, readFileSync(join(transformsDirPath, file), "utf-8"));
|
|
4500
5533
|
}
|
|
4501
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
|
+
}
|
|
4502
5560
|
// Compare
|
|
4503
5561
|
const differences = [];
|
|
4504
5562
|
// Integrations
|
|
@@ -4703,6 +5761,10 @@ Directory Structure:
|
|
|
4703
5761
|
differences.push({ type: "transform", key: name, status: "remote only" });
|
|
4704
5762
|
}
|
|
4705
5763
|
}
|
|
5764
|
+
// Config vars — issue #1423 (see `diffVars`).
|
|
5765
|
+
for (const row of diffVars(localVars, remoteVars)) {
|
|
5766
|
+
differences.push(row);
|
|
5767
|
+
}
|
|
4706
5768
|
// Compare test cases for synced prompts and workflows
|
|
4707
5769
|
const testCaseDiffs = [];
|
|
4708
5770
|
// Helper to compare test cases for a block
|
|
@@ -4823,11 +5885,15 @@ Directory Structure:
|
|
|
4823
5885
|
// Migrate-toml (issue #752): bulk-rewrite database-type TOML files from
|
|
4824
5886
|
// the legacy JSON-string form for `definition`/`params` to the native
|
|
4825
5887
|
// nested-table form. Idempotent: ops already in native form are left as
|
|
4826
|
-
// is;
|
|
4827
|
-
//
|
|
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.
|
|
4828
5892
|
sync
|
|
4829
5893
|
.command("migrate-toml")
|
|
4830
|
-
.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)")
|
|
4831
5897
|
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
4832
5898
|
.option("--app <app-id>", "App ID")
|
|
4833
5899
|
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
@@ -4846,83 +5912,122 @@ Directory Structure:
|
|
|
4846
5912
|
process.exit(1);
|
|
4847
5913
|
}
|
|
4848
5914
|
const dbTypesDir = join(configDir, "database-types");
|
|
4849
|
-
|
|
4850
|
-
|
|
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.");
|
|
4851
5927
|
return;
|
|
4852
5928
|
}
|
|
4853
|
-
const files = readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml"));
|
|
4854
|
-
if (files.length === 0) {
|
|
4855
|
-
info("No database-type TOML files found; nothing to migrate.");
|
|
4856
|
-
return;
|
|
4857
|
-
}
|
|
4858
|
-
// Hydrate ruleSetIdToName from local sync state so files that
|
|
4859
|
-
// reference a rule set via the legacy `ruleSetId = "01..."` form
|
|
4860
|
-
// round-trip with the correct `ruleSetName`. Without this, any user
|
|
4861
|
-
// who runs migrate-toml against a file with a rule-set assignment
|
|
4862
|
-
// would silently lose that reference (review feedback r3246633010).
|
|
4863
|
-
//
|
|
4864
|
-
// Files that use the modern key-based `ruleSetName = "..."` form
|
|
4865
|
-
// don't need the map at all — `parseDatabaseTypeToml` stores the
|
|
4866
|
-
// value in `typeConfig._ruleSetName` and the serializer now prefers
|
|
4867
|
-
// that. The map is only load-bearing for the legacy ID-based form.
|
|
4868
|
-
const ruleSetIdToName = new Map();
|
|
4869
|
-
const migrateSyncState = loadSyncState(configDir);
|
|
4870
|
-
if (migrateSyncState?.entities?.ruleSets) {
|
|
4871
|
-
for (const [fileKey, entry] of Object.entries(migrateSyncState.entities.ruleSets)) {
|
|
4872
|
-
if (entry && typeof entry === "object" && "id" in entry && entry.id) {
|
|
4873
|
-
// fileKey is the sanitized rule-set name (see sync pull at
|
|
4874
|
-
// sync.ts:1385). It's the same shape the server returned and
|
|
4875
|
-
// matches what a TOML file's ruleSetName field would carry.
|
|
4876
|
-
ruleSetIdToName.set(entry.id, fileKey);
|
|
4877
|
-
}
|
|
4878
|
-
}
|
|
4879
|
-
}
|
|
4880
5929
|
let migratedCount = 0;
|
|
4881
5930
|
let unchangedCount = 0;
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
|
|
4886
|
-
//
|
|
4887
|
-
//
|
|
4888
|
-
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
|
|
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
|
+
}
|
|
4894
5954
|
}
|
|
4895
|
-
|
|
4896
|
-
|
|
4897
|
-
|
|
4898
|
-
|
|
4899
|
-
|
|
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));
|
|
4900
5967
|
}
|
|
4901
|
-
|
|
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;
|
|
5987
|
+
}
|
|
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++;
|
|
4902
6003
|
}
|
|
4903
|
-
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
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) {
|
|
4912
6017
|
unchangedCount++;
|
|
4913
6018
|
continue;
|
|
4914
6019
|
}
|
|
4915
6020
|
if (options.dryRun) {
|
|
4916
|
-
info(`Would migrate
|
|
6021
|
+
info(`Would migrate workflows/${file}`);
|
|
4917
6022
|
}
|
|
4918
6023
|
else {
|
|
4919
6024
|
writeFileSync(filePath, rewritten);
|
|
4920
|
-
info(`Migrated
|
|
4921
|
-
// Update content hash
|
|
6025
|
+
info(`Migrated workflows/${file}`);
|
|
6026
|
+
// Update the stored content hash so a subsequent push sees a no-op.
|
|
4922
6027
|
const syncState = loadSyncState(configDir);
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
6028
|
+
if (syncState?.entities?.workflows?.[workflowKey]) {
|
|
6029
|
+
syncState.entities.workflows[workflowKey].contentHash =
|
|
6030
|
+
computeExpandedContentHash(parseTomlFile(filePath));
|
|
4926
6031
|
saveSyncState(configDir, syncState);
|
|
4927
6032
|
}
|
|
4928
6033
|
}
|
|
@@ -4938,6 +6043,63 @@ Directory Structure:
|
|
|
4938
6043
|
success(`Rewrote ${migratedCount} TOML file(s) to native form.`);
|
|
4939
6044
|
}
|
|
4940
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
|
+
});
|
|
4941
6103
|
// Revert — restore a pre-pull snapshot (issue #578, Phase 1).
|
|
4942
6104
|
sync
|
|
4943
6105
|
.command("revert")
|